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, Context, ContextCall, Data, InputDevice, Material, Model,
 13    Node, SessionPlayer, Sound, Texture, Timer, Vec3, Widget, buttonwidget,
 14    camerashake, checkboxwidget, columnwidget, containerwidget, do_once,
 15    emitfx, getactivity, getcollidemodel, getmodel, getnodes, getsession,
 16    getsound, gettexture, hscrollwidget, imagewidget, log, newactivity,
 17    newnode, playsound, printnodes, printobjects, pushcall, quit, rowwidget,
 18    safecolor, screenmessage, scrollwidget, set_analytics_screen, charstr,
 19    textwidget, time, timer, open_url, widget, clipboard_is_supported,
 20    clipboard_has_text, clipboard_get_text, clipboard_set_text, getdata)
 21from ba._activity import Activity
 22from ba._plugin import PotentialPlugin, Plugin, PluginSubsystem
 23from ba._actor import Actor
 24from ba._player import PlayerInfo, Player, EmptyPlayer, StandLocation
 25from ba._nodeactor import NodeActor
 26from ba._app import App
 27from ba._cloud import CloudSubsystem
 28from ba._coopgame import CoopGameActivity
 29from ba._coopsession import CoopSession
 30from ba._dependency import (Dependency, DependencyComponent, DependencySet,
 31                            AssetPackage)
 32from ba._generated.enums import (TimeType, Permission, TimeFormat, SpecialChar,
 33                                 InputType, UIScale)
 34from ba._error import (
 35    print_exception, print_error, ContextError, NotFoundError,
 36    PlayerNotFoundError, SessionPlayerNotFoundError, NodeNotFoundError,
 37    ActorNotFoundError, InputDeviceNotFoundError, WidgetNotFoundError,
 38    ActivityNotFoundError, TeamNotFoundError, SessionTeamNotFoundError,
 39    SessionNotFoundError, DelegateNotFoundError, DependencyError)
 40from ba._freeforallsession import FreeForAllSession
 41from ba._gameactivity import GameActivity
 42from ba._gameresults import GameResults
 43from ba._settings import (Setting, IntSetting, FloatSetting, ChoiceSetting,
 44                          BoolSetting, IntChoiceSetting, FloatChoiceSetting)
 45from ba._language import Lstr, LanguageSubsystem
 46from ba._map import Map, getmaps
 47from ba._session import Session
 48from ba._ui import UISubsystem
 49from ba._servermode import ServerController
 50from ba._score import ScoreType, ScoreConfig
 51from ba._stats import PlayerScoredMessage, PlayerRecord, Stats
 52from ba._team import SessionTeam, Team, EmptyTeam
 53from ba._teamgame import TeamGameActivity
 54from ba._dualteamsession import DualTeamSession
 55from ba._achievement import Achievement, AchievementSubsystem
 56from ba._appconfig import AppConfig
 57from ba._appdelegate import AppDelegate
 58from ba._apputils import is_browser_likely_available, garbage_collect
 59from ba._campaign import Campaign
 60from ba._gameutils import (GameTip, animate, animate_array, show_damage_count,
 61                           timestring, cameraflash)
 62from ba._general import (WeakCall, Call, existing, Existable,
 63                         verify_object_death, storagename, getclass)
 64from ba._keyboard import Keyboard
 65from ba._level import Level
 66from ba._lobby import Lobby, Chooser
 67from ba._math import normalized_color, is_point_in_box, vec3validate
 68from ba._meta import MetadataSubsystem
 69from ba._messages import (UNHANDLED, OutOfBoundsMessage, DeathType, DieMessage,
 70                          PlayerDiedMessage, StandMessage, PickUpMessage,
 71                          DropMessage, PickedUpMessage, DroppedMessage,
 72                          ShouldShatterMessage, ImpactDamageMessage,
 73                          FreezeMessage, ThawMessage, HitMessage,
 74                          CelebrateMessage)
 75from ba._music import (setmusic, MusicPlayer, MusicType, MusicPlayMode,
 76                       MusicSubsystem)
 77from ba._powerup import PowerupMessage, PowerupAcceptMessage
 78from ba._multiteamsession import MultiTeamSession
 79from ba.ui import Window, UIController, uicleanupcheck
 80from ba._collision import Collision, getcollision
 81
 82app: App
 83
 84__all__ = [
 85    'Achievement', 'AchievementSubsystem', 'Activity', 'ActivityNotFoundError',
 86    'Actor', 'ActorNotFoundError', 'animate', 'animate_array', 'app', 'App',
 87    'AppConfig', 'AppDelegate', 'AssetPackage', 'BoolSetting', 'buttonwidget',
 88    'Call', 'cameraflash', 'camerashake', 'Campaign', 'CelebrateMessage',
 89    'charstr', 'checkboxwidget', 'ChoiceSetting', 'Chooser',
 90    'clipboard_get_text', 'clipboard_has_text', 'clipboard_is_supported',
 91    'clipboard_set_text', 'CollideModel', 'Collision', 'columnwidget',
 92    'containerwidget', 'Context', 'ContextCall', 'ContextError',
 93    'CloudSubsystem', 'CoopGameActivity', 'CoopSession', 'Data', 'DeathType',
 94    'DelegateNotFoundError', 'Dependency', 'DependencyComponent',
 95    'DependencyError', 'DependencySet', 'DieMessage', 'do_once', 'DropMessage',
 96    'DroppedMessage', 'DualTeamSession', 'emitfx', 'EmptyPlayer', 'EmptyTeam',
 97    'Existable', 'existing', 'FloatChoiceSetting', 'FloatSetting',
 98    'FreeForAllSession', 'FreezeMessage', 'GameActivity', 'GameResults',
 99    'GameTip', 'garbage_collect', 'getactivity', 'getclass', 'getcollidemodel',
100    'getcollision', 'getdata', 'getmaps', 'getmodel', 'getnodes', 'getsession',
101    'getsound', 'gettexture', 'HitMessage', 'hscrollwidget', 'imagewidget',
102    'ImpactDamageMessage', 'InputDevice', 'InputDeviceNotFoundError',
103    'InputType', 'IntChoiceSetting', 'IntSetting',
104    'is_browser_likely_available', 'is_point_in_box', 'Keyboard',
105    'LanguageSubsystem', 'Level', 'Lobby', 'log', 'Lstr', 'Map', 'Material',
106    'MetadataSubsystem', 'Model', 'MultiTeamSession', 'MusicPlayer',
107    'MusicPlayMode', 'MusicSubsystem', 'MusicType', 'newactivity', 'newnode',
108    'Node', 'NodeActor', 'NodeNotFoundError', 'normalized_color',
109    'NotFoundError', 'open_url', 'OutOfBoundsMessage', 'Permission',
110    'PickedUpMessage', 'PickUpMessage', 'Player', 'PlayerDiedMessage',
111    'PlayerInfo', 'PlayerNotFoundError', 'PlayerRecord', 'PlayerScoredMessage',
112    'playsound', 'Plugin', 'PluginSubsystem', 'PotentialPlugin',
113    'PowerupAcceptMessage', 'PowerupMessage', 'print_error', 'print_exception',
114    'printnodes', 'printobjects', 'pushcall', 'quit', 'rowwidget', 'safecolor',
115    'ScoreConfig', 'ScoreType', 'screenmessage', 'scrollwidget',
116    'ServerController', 'Session', 'SessionNotFoundError', 'SessionPlayer',
117    'SessionPlayerNotFoundError', 'SessionTeam', 'SessionTeamNotFoundError',
118    'set_analytics_screen', 'setmusic', 'Setting', 'ShouldShatterMessage',
119    'show_damage_count', 'Sound', 'SpecialChar', 'StandLocation',
120    'StandMessage', 'Stats', 'storagename', 'Team', 'TeamGameActivity',
121    'TeamNotFoundError', 'Texture', 'textwidget', 'ThawMessage', 'time',
122    'TimeFormat', 'Timer', 'timer', 'timestring', 'TimeType', 'uicleanupcheck',
123    'UIController', 'UIScale', 'UISubsystem', 'UNHANDLED', 'Vec3',
124    'vec3validate', 'verify_object_death', 'WeakCall', 'Widget', 'widget',
125    'WidgetNotFoundError', 'Window'
126]
127
128
129# Have these things present themselves cleanly as 'ba.Foo'
130# instead of 'ba._submodule.Foo'
131def _simplify_module_names() -> None:
132    import os
133
134    # Though pdoc gets confused when we override __module__,
135    # so let's make an exception for it.
136    if os.environ.get('BA_DOCS_GENERATION', '0') != '1':
137        from efro.util import set_canonical_module
138        globs = globals()
139        set_canonical_module(
140            module_globals=globs,
141            names=[n for n in globs.keys() if not n.startswith('_')])
142
143
144_simplify_module_names()
145del _simplify_module_names
class Achievement:
 437class Achievement:
 438    """Represents attributes and state for an individual achievement.
 439
 440    Category: **App Classes**
 441    """
 442
 443    def __init__(self,
 444                 name: str,
 445                 icon_name: str,
 446                 icon_color: Sequence[float],
 447                 level_name: str,
 448                 award: int,
 449                 hard_mode_only: bool = False):
 450        self._name = name
 451        self._icon_name = icon_name
 452        self._icon_color: Sequence[float] = list(icon_color) + [1]
 453        self._level_name = level_name
 454        self._completion_banner_slot: int | None = None
 455        self._award = award
 456        self._hard_mode_only = hard_mode_only
 457
 458    @property
 459    def name(self) -> str:
 460        """The name of this achievement."""
 461        return self._name
 462
 463    @property
 464    def level_name(self) -> str:
 465        """The name of the level this achievement applies to."""
 466        return self._level_name
 467
 468    def get_icon_texture(self, complete: bool) -> ba.Texture:
 469        """Return the icon texture to display for this achievement"""
 470        return _ba.gettexture(
 471            self._icon_name if complete else 'achievementEmpty')
 472
 473    def get_icon_color(self, complete: bool) -> Sequence[float]:
 474        """Return the color tint for this Achievement's icon."""
 475        if complete:
 476            return self._icon_color
 477        return 1.0, 1.0, 1.0, 0.6
 478
 479    @property
 480    def hard_mode_only(self) -> bool:
 481        """Whether this Achievement is only unlockable in hard-mode."""
 482        return self._hard_mode_only
 483
 484    @property
 485    def complete(self) -> bool:
 486        """Whether this Achievement is currently complete."""
 487        val: bool = self._getconfig()['Complete']
 488        assert isinstance(val, bool)
 489        return val
 490
 491    def announce_completion(self, sound: bool = True) -> None:
 492        """Kick off an announcement for this achievement's completion."""
 493        from ba._generated.enums import TimeType
 494        app = _ba.app
 495
 496        # Even though there are technically achievements when we're not
 497        # signed in, lets not show them (otherwise we tend to get
 498        # confusing 'controller connected' achievements popping up while
 499        # waiting to log in which can be confusing).
 500        if _ba.get_v1_account_state() != 'signed_in':
 501            return
 502
 503        # If we're being freshly complete, display/report it and whatnot.
 504        if (self, sound) not in app.ach.achievements_to_display:
 505            app.ach.achievements_to_display.append((self, sound))
 506
 507        # If there's no achievement display timer going, kick one off
 508        # (if one's already running it will pick this up before it dies).
 509
 510        # Need to check last time too; its possible our timer wasn't able to
 511        # clear itself if an activity died and took it down with it.
 512        if ((app.ach.achievement_display_timer is None
 513             or _ba.time(TimeType.REAL) - app.ach.last_achievement_display_time
 514             > 2.0) and _ba.getactivity(doraise=False) is not None):
 515            app.ach.achievement_display_timer = _ba.Timer(
 516                1.0,
 517                _display_next_achievement,
 518                repeat=True,
 519                timetype=TimeType.BASE)
 520
 521            # Show the first immediately.
 522            _display_next_achievement()
 523
 524    def set_complete(self, complete: bool = True) -> None:
 525        """Set an achievement's completed state.
 526
 527        note this only sets local state; use a transaction to
 528        actually award achievements.
 529        """
 530        config = self._getconfig()
 531        if complete != config['Complete']:
 532            config['Complete'] = complete
 533
 534    @property
 535    def display_name(self) -> ba.Lstr:
 536        """Return a ba.Lstr for this Achievement's name."""
 537        from ba._language import Lstr
 538        name: ba.Lstr | str
 539        try:
 540            if self._level_name != '':
 541                from ba._campaign import getcampaign
 542                campaignname, campaign_level = self._level_name.split(':')
 543                name = getcampaign(campaignname).getlevel(
 544                    campaign_level).displayname
 545            else:
 546                name = ''
 547        except Exception:
 548            name = ''
 549            print_exception()
 550        return Lstr(resource='achievements.' + self._name + '.name',
 551                    subs=[('${LEVEL}', name)])
 552
 553    @property
 554    def description(self) -> ba.Lstr:
 555        """Get a ba.Lstr for the Achievement's brief description."""
 556        from ba._language import Lstr
 557        if 'description' in _ba.app.lang.get_resource('achievements')[
 558                self._name]:
 559            return Lstr(resource='achievements.' + self._name + '.description')
 560        return Lstr(resource='achievements.' + self._name + '.descriptionFull')
 561
 562    @property
 563    def description_complete(self) -> ba.Lstr:
 564        """Get a ba.Lstr for the Achievement's description when completed."""
 565        from ba._language import Lstr
 566        if 'descriptionComplete' in _ba.app.lang.get_resource('achievements')[
 567                self._name]:
 568            return Lstr(resource='achievements.' + self._name +
 569                        '.descriptionComplete')
 570        return Lstr(resource='achievements.' + self._name +
 571                    '.descriptionFullComplete')
 572
 573    @property
 574    def description_full(self) -> ba.Lstr:
 575        """Get a ba.Lstr for the Achievement's full description."""
 576        from ba._language import Lstr
 577
 578        return Lstr(
 579            resource='achievements.' + self._name + '.descriptionFull',
 580            subs=[('${LEVEL}',
 581                   Lstr(translate=('coopLevelNames',
 582                                   ACH_LEVEL_NAMES.get(self._name, '?'))))])
 583
 584    @property
 585    def description_full_complete(self) -> ba.Lstr:
 586        """Get a ba.Lstr for the Achievement's full desc. when completed."""
 587        from ba._language import Lstr
 588        return Lstr(
 589            resource='achievements.' + self._name + '.descriptionFullComplete',
 590            subs=[('${LEVEL}',
 591                   Lstr(translate=('coopLevelNames',
 592                                   ACH_LEVEL_NAMES.get(self._name, '?'))))])
 593
 594    def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int:
 595        """Get the ticket award value for this achievement."""
 596        val: int = (_ba.get_v1_account_misc_read_val('achAward.' + self._name,
 597                                                     self._award) *
 598                    _get_ach_mult(include_pro_bonus))
 599        assert isinstance(val, int)
 600        return val
 601
 602    @property
 603    def power_ranking_value(self) -> int:
 604        """Get the power-ranking award value for this achievement."""
 605        val: int = _ba.get_v1_account_misc_read_val(
 606            'achLeaguePoints.' + self._name, self._award)
 607        assert isinstance(val, int)
 608        return val
 609
 610    def create_display(self,
 611                       x: float,
 612                       y: float,
 613                       delay: float,
 614                       outdelay: float | None = None,
 615                       color: Sequence[float] | None = None,
 616                       style: str = 'post_game') -> list[ba.Actor]:
 617        """Create a display for the Achievement.
 618
 619        Shows the Achievement icon, name, and description.
 620        """
 621        # pylint: disable=cyclic-import
 622        from ba._language import Lstr
 623        from ba._generated.enums import SpecialChar
 624        from ba._coopsession import CoopSession
 625        from bastd.actor.image import Image
 626        from bastd.actor.text import Text
 627
 628        # Yeah this needs cleaning up.
 629        if style == 'post_game':
 630            in_game_colors = False
 631            in_main_menu = False
 632            h_attach = Text.HAttach.CENTER
 633            v_attach = Text.VAttach.CENTER
 634            attach = Image.Attach.CENTER
 635        elif style == 'in_game':
 636            in_game_colors = True
 637            in_main_menu = False
 638            h_attach = Text.HAttach.LEFT
 639            v_attach = Text.VAttach.TOP
 640            attach = Image.Attach.TOP_LEFT
 641        elif style == 'news':
 642            in_game_colors = True
 643            in_main_menu = True
 644            h_attach = Text.HAttach.CENTER
 645            v_attach = Text.VAttach.TOP
 646            attach = Image.Attach.TOP_CENTER
 647        else:
 648            raise ValueError('invalid style "' + style + '"')
 649
 650        # Attempt to determine what campaign we're in
 651        # (so we know whether to show "hard mode only").
 652        if in_main_menu:
 653            hmo = False
 654        else:
 655            try:
 656                session = _ba.getsession()
 657                if isinstance(session, CoopSession):
 658                    campaign = session.campaign
 659                    assert campaign is not None
 660                    hmo = (self._hard_mode_only and campaign.name == 'Easy')
 661                else:
 662                    hmo = False
 663            except Exception:
 664                print_exception('Error determining campaign.')
 665                hmo = False
 666
 667        objs: list[ba.Actor]
 668
 669        if in_game_colors:
 670            objs = []
 671            out_delay_fin = (delay +
 672                             outdelay) if outdelay is not None else None
 673            if color is not None:
 674                cl1 = (2.0 * color[0], 2.0 * color[1], 2.0 * color[2],
 675                       color[3])
 676                cl2 = color
 677            else:
 678                cl1 = (1.5, 1.5, 2, 1.0)
 679                cl2 = (0.8, 0.8, 1.0, 1.0)
 680
 681            if hmo:
 682                cl1 = (cl1[0], cl1[1], cl1[2], cl1[3] * 0.6)
 683                cl2 = (cl2[0], cl2[1], cl2[2], cl2[3] * 0.2)
 684
 685            objs.append(
 686                Image(self.get_icon_texture(False),
 687                      host_only=True,
 688                      color=cl1,
 689                      position=(x - 25, y + 5),
 690                      attach=attach,
 691                      transition=Image.Transition.FADE_IN,
 692                      transition_delay=delay,
 693                      vr_depth=4,
 694                      transition_out_delay=out_delay_fin,
 695                      scale=(40, 40)).autoretain())
 696            txt = self.display_name
 697            txt_s = 0.85
 698            txt_max_w = 300
 699            objs.append(
 700                Text(txt,
 701                     host_only=True,
 702                     maxwidth=txt_max_w,
 703                     position=(x, y + 2),
 704                     transition=Text.Transition.FADE_IN,
 705                     scale=txt_s,
 706                     flatness=0.6,
 707                     shadow=0.5,
 708                     h_attach=h_attach,
 709                     v_attach=v_attach,
 710                     color=cl2,
 711                     transition_delay=delay + 0.05,
 712                     transition_out_delay=out_delay_fin).autoretain())
 713            txt2_s = 0.62
 714            txt2_max_w = 400
 715            objs.append(
 716                Text(self.description_full
 717                     if in_main_menu else self.description,
 718                     host_only=True,
 719                     maxwidth=txt2_max_w,
 720                     position=(x, y - 14),
 721                     transition=Text.Transition.FADE_IN,
 722                     vr_depth=-5,
 723                     h_attach=h_attach,
 724                     v_attach=v_attach,
 725                     scale=txt2_s,
 726                     flatness=1.0,
 727                     shadow=0.5,
 728                     color=cl2,
 729                     transition_delay=delay + 0.1,
 730                     transition_out_delay=out_delay_fin).autoretain())
 731
 732            if hmo:
 733                txtactor = Text(
 734                    Lstr(resource='difficultyHardOnlyText'),
 735                    host_only=True,
 736                    maxwidth=txt2_max_w * 0.7,
 737                    position=(x + 60, y + 5),
 738                    transition=Text.Transition.FADE_IN,
 739                    vr_depth=-5,
 740                    h_attach=h_attach,
 741                    v_attach=v_attach,
 742                    h_align=Text.HAlign.CENTER,
 743                    v_align=Text.VAlign.CENTER,
 744                    scale=txt_s * 0.8,
 745                    flatness=1.0,
 746                    shadow=0.5,
 747                    color=(1, 1, 0.6, 1),
 748                    transition_delay=delay + 0.1,
 749                    transition_out_delay=out_delay_fin).autoretain()
 750                txtactor.node.rotate = 10
 751                objs.append(txtactor)
 752
 753            # Ticket-award.
 754            award_x = -100
 755            objs.append(
 756                Text(_ba.charstr(SpecialChar.TICKET),
 757                     host_only=True,
 758                     position=(x + award_x + 33, y + 7),
 759                     transition=Text.Transition.FADE_IN,
 760                     scale=1.5,
 761                     h_attach=h_attach,
 762                     v_attach=v_attach,
 763                     h_align=Text.HAlign.CENTER,
 764                     v_align=Text.VAlign.CENTER,
 765                     color=(1, 1, 1, 0.2 if hmo else 0.4),
 766                     transition_delay=delay + 0.05,
 767                     transition_out_delay=out_delay_fin).autoretain())
 768            objs.append(
 769                Text('+' + str(self.get_award_ticket_value()),
 770                     host_only=True,
 771                     position=(x + award_x + 28, y + 16),
 772                     transition=Text.Transition.FADE_IN,
 773                     scale=0.7,
 774                     flatness=1,
 775                     h_attach=h_attach,
 776                     v_attach=v_attach,
 777                     h_align=Text.HAlign.CENTER,
 778                     v_align=Text.VAlign.CENTER,
 779                     color=cl2,
 780                     transition_delay=delay + 0.05,
 781                     transition_out_delay=out_delay_fin).autoretain())
 782
 783        else:
 784            complete = self.complete
 785            objs = []
 786            c_icon = self.get_icon_color(complete)
 787            if hmo and not complete:
 788                c_icon = (c_icon[0], c_icon[1], c_icon[2], c_icon[3] * 0.3)
 789            objs.append(
 790                Image(self.get_icon_texture(complete),
 791                      host_only=True,
 792                      color=c_icon,
 793                      position=(x - 25, y + 5),
 794                      attach=attach,
 795                      vr_depth=4,
 796                      transition=Image.Transition.IN_RIGHT,
 797                      transition_delay=delay,
 798                      transition_out_delay=None,
 799                      scale=(40, 40)).autoretain())
 800            if complete:
 801                objs.append(
 802                    Image(_ba.gettexture('achievementOutline'),
 803                          host_only=True,
 804                          model_transparent=_ba.getmodel('achievementOutline'),
 805                          color=(2, 1.4, 0.4, 1),
 806                          vr_depth=8,
 807                          position=(x - 25, y + 5),
 808                          attach=attach,
 809                          transition=Image.Transition.IN_RIGHT,
 810                          transition_delay=delay,
 811                          transition_out_delay=None,
 812                          scale=(40, 40)).autoretain())
 813            else:
 814                if not complete:
 815                    award_x = -100
 816                    objs.append(
 817                        Text(_ba.charstr(SpecialChar.TICKET),
 818                             host_only=True,
 819                             position=(x + award_x + 33, y + 7),
 820                             transition=Text.Transition.IN_RIGHT,
 821                             scale=1.5,
 822                             h_attach=h_attach,
 823                             v_attach=v_attach,
 824                             h_align=Text.HAlign.CENTER,
 825                             v_align=Text.VAlign.CENTER,
 826                             color=(1, 1, 1, 0.4) if complete else
 827                             (1, 1, 1, (0.1 if hmo else 0.2)),
 828                             transition_delay=delay + 0.05,
 829                             transition_out_delay=None).autoretain())
 830                    objs.append(
 831                        Text('+' + str(self.get_award_ticket_value()),
 832                             host_only=True,
 833                             position=(x + award_x + 28, y + 16),
 834                             transition=Text.Transition.IN_RIGHT,
 835                             scale=0.7,
 836                             flatness=1,
 837                             h_attach=h_attach,
 838                             v_attach=v_attach,
 839                             h_align=Text.HAlign.CENTER,
 840                             v_align=Text.VAlign.CENTER,
 841                             color=((0.8, 0.93, 0.8, 1.0) if complete else
 842                                    (0.6, 0.6, 0.6, (0.2 if hmo else 0.4))),
 843                             transition_delay=delay + 0.05,
 844                             transition_out_delay=None).autoretain())
 845
 846                    # Show 'hard-mode-only' only over incomplete achievements
 847                    # when that's the case.
 848                    if hmo:
 849                        txtactor = Text(
 850                            Lstr(resource='difficultyHardOnlyText'),
 851                            host_only=True,
 852                            maxwidth=300 * 0.7,
 853                            position=(x + 60, y + 5),
 854                            transition=Text.Transition.FADE_IN,
 855                            vr_depth=-5,
 856                            h_attach=h_attach,
 857                            v_attach=v_attach,
 858                            h_align=Text.HAlign.CENTER,
 859                            v_align=Text.VAlign.CENTER,
 860                            scale=0.85 * 0.8,
 861                            flatness=1.0,
 862                            shadow=0.5,
 863                            color=(1, 1, 0.6, 1),
 864                            transition_delay=delay + 0.05,
 865                            transition_out_delay=None).autoretain()
 866                        assert txtactor.node
 867                        txtactor.node.rotate = 10
 868                        objs.append(txtactor)
 869
 870            objs.append(
 871                Text(self.display_name,
 872                     host_only=True,
 873                     maxwidth=300,
 874                     position=(x, y + 2),
 875                     transition=Text.Transition.IN_RIGHT,
 876                     scale=0.85,
 877                     flatness=0.6,
 878                     h_attach=h_attach,
 879                     v_attach=v_attach,
 880                     color=((0.8, 0.93, 0.8, 1.0) if complete else
 881                            (0.6, 0.6, 0.6, (0.2 if hmo else 0.4))),
 882                     transition_delay=delay + 0.05,
 883                     transition_out_delay=None).autoretain())
 884            objs.append(
 885                Text(self.description_complete
 886                     if complete else self.description,
 887                     host_only=True,
 888                     maxwidth=400,
 889                     position=(x, y - 14),
 890                     transition=Text.Transition.IN_RIGHT,
 891                     vr_depth=-5,
 892                     h_attach=h_attach,
 893                     v_attach=v_attach,
 894                     scale=0.62,
 895                     flatness=1.0,
 896                     color=((0.6, 0.6, 0.6, 1.0) if complete else
 897                            (0.6, 0.6, 0.6, (0.2 if hmo else 0.4))),
 898                     transition_delay=delay + 0.1,
 899                     transition_out_delay=None).autoretain())
 900        return objs
 901
 902    def _getconfig(self) -> dict[str, Any]:
 903        """
 904        Return the sub-dict in settings where this achievement's
 905        state is stored, creating it if need be.
 906        """
 907        val: dict[str, Any] = (_ba.app.config.setdefault(
 908            'Achievements', {}).setdefault(self._name, {'Complete': False}))
 909        assert isinstance(val, dict)
 910        return val
 911
 912    def _remove_banner_slot(self) -> None:
 913        assert self._completion_banner_slot is not None
 914        _ba.app.ach.achievement_completion_banner_slots.remove(
 915            self._completion_banner_slot)
 916        self._completion_banner_slot = None
 917
 918    def show_completion_banner(self, sound: bool = True) -> None:
 919        """Create the banner/sound for an acquired achievement announcement."""
 920        from ba import _gameutils
 921        from bastd.actor.text import Text
 922        from bastd.actor.image import Image
 923        from ba._general import WeakCall
 924        from ba._language import Lstr
 925        from ba._messages import DieMessage
 926        from ba._generated.enums import TimeType, SpecialChar
 927        app = _ba.app
 928        app.ach.last_achievement_display_time = _ba.time(TimeType.REAL)
 929
 930        # Just piggy-back onto any current activity
 931        # (should we use the session instead?..)
 932        activity = _ba.getactivity(doraise=False)
 933
 934        # If this gets called while this achievement is occupying a slot
 935        # already, ignore it. (probably should never happen in real
 936        # life but whatevs).
 937        if self._completion_banner_slot is not None:
 938            return
 939
 940        if activity is None:
 941            print('show_completion_banner() called with no current activity!')
 942            return
 943
 944        if sound:
 945            _ba.playsound(_ba.getsound('achievement'), host_only=True)
 946        else:
 947            _ba.timer(
 948                0.5,
 949                lambda: _ba.playsound(_ba.getsound('ding'), host_only=True))
 950
 951        in_time = 0.300
 952        out_time = 3.5
 953
 954        base_vr_depth = 200
 955
 956        # Find the first free slot.
 957        i = 0
 958        while True:
 959            if i not in app.ach.achievement_completion_banner_slots:
 960                app.ach.achievement_completion_banner_slots.add(i)
 961                self._completion_banner_slot = i
 962
 963                # Remove us from that slot when we close.
 964                # Use a real-timer in the UI context so the removal runs even
 965                # if our activity/session dies.
 966                with _ba.Context('ui'):
 967                    _ba.timer(in_time + out_time,
 968                              self._remove_banner_slot,
 969                              timetype=TimeType.REAL)
 970                break
 971            i += 1
 972        assert self._completion_banner_slot is not None
 973        y_offs = 110 * self._completion_banner_slot
 974        objs: list[ba.Actor] = []
 975        obj = Image(_ba.gettexture('shadow'),
 976                    position=(-30, 30 + y_offs),
 977                    front=True,
 978                    attach=Image.Attach.BOTTOM_CENTER,
 979                    transition=Image.Transition.IN_BOTTOM,
 980                    vr_depth=base_vr_depth - 100,
 981                    transition_delay=in_time,
 982                    transition_out_delay=out_time,
 983                    color=(0.0, 0.1, 0, 1),
 984                    scale=(1000, 300)).autoretain()
 985        objs.append(obj)
 986        assert obj.node
 987        obj.node.host_only = True
 988        obj = Image(_ba.gettexture('light'),
 989                    position=(-180, 60 + y_offs),
 990                    front=True,
 991                    attach=Image.Attach.BOTTOM_CENTER,
 992                    vr_depth=base_vr_depth,
 993                    transition=Image.Transition.IN_BOTTOM,
 994                    transition_delay=in_time,
 995                    transition_out_delay=out_time,
 996                    color=(1.8, 1.8, 1.0, 0.0),
 997                    scale=(40, 300)).autoretain()
 998        objs.append(obj)
 999        assert obj.node
1000        obj.node.host_only = True
1001        obj.node.premultiplied = True
1002        combine = _ba.newnode('combine', owner=obj.node, attrs={'size': 2})
1003        _gameutils.animate(
1004            combine, 'input0', {
1005                in_time: 0,
1006                in_time + 0.4: 30,
1007                in_time + 0.5: 40,
1008                in_time + 0.6: 30,
1009                in_time + 2.0: 0
1010            })
1011        _gameutils.animate(
1012            combine, 'input1', {
1013                in_time: 0,
1014                in_time + 0.4: 200,
1015                in_time + 0.5: 500,
1016                in_time + 0.6: 200,
1017                in_time + 2.0: 0
1018            })
1019        combine.connectattr('output', obj.node, 'scale')
1020        _gameutils.animate(obj.node,
1021                           'rotate', {
1022                               0: 0.0,
1023                               0.35: 360.0
1024                           },
1025                           loop=True)
1026        obj = Image(self.get_icon_texture(True),
1027                    position=(-180, 60 + y_offs),
1028                    attach=Image.Attach.BOTTOM_CENTER,
1029                    front=True,
1030                    vr_depth=base_vr_depth - 10,
1031                    transition=Image.Transition.IN_BOTTOM,
1032                    transition_delay=in_time,
1033                    transition_out_delay=out_time,
1034                    scale=(100, 100)).autoretain()
1035        objs.append(obj)
1036        assert obj.node
1037        obj.node.host_only = True
1038
1039        # Flash.
1040        color = self.get_icon_color(True)
1041        combine = _ba.newnode('combine', owner=obj.node, attrs={'size': 3})
1042        keys = {
1043            in_time: 1.0 * color[0],
1044            in_time + 0.4: 1.5 * color[0],
1045            in_time + 0.5: 6.0 * color[0],
1046            in_time + 0.6: 1.5 * color[0],
1047            in_time + 2.0: 1.0 * color[0]
1048        }
1049        _gameutils.animate(combine, 'input0', keys)
1050        keys = {
1051            in_time: 1.0 * color[1],
1052            in_time + 0.4: 1.5 * color[1],
1053            in_time + 0.5: 6.0 * color[1],
1054            in_time + 0.6: 1.5 * color[1],
1055            in_time + 2.0: 1.0 * color[1]
1056        }
1057        _gameutils.animate(combine, 'input1', keys)
1058        keys = {
1059            in_time: 1.0 * color[2],
1060            in_time + 0.4: 1.5 * color[2],
1061            in_time + 0.5: 6.0 * color[2],
1062            in_time + 0.6: 1.5 * color[2],
1063            in_time + 2.0: 1.0 * color[2]
1064        }
1065        _gameutils.animate(combine, 'input2', keys)
1066        combine.connectattr('output', obj.node, 'color')
1067
1068        obj = Image(_ba.gettexture('achievementOutline'),
1069                    model_transparent=_ba.getmodel('achievementOutline'),
1070                    position=(-180, 60 + y_offs),
1071                    front=True,
1072                    attach=Image.Attach.BOTTOM_CENTER,
1073                    vr_depth=base_vr_depth,
1074                    transition=Image.Transition.IN_BOTTOM,
1075                    transition_delay=in_time,
1076                    transition_out_delay=out_time,
1077                    scale=(100, 100)).autoretain()
1078        assert obj.node
1079        obj.node.host_only = True
1080
1081        # Flash.
1082        color = (2, 1.4, 0.4, 1)
1083        combine = _ba.newnode('combine', owner=obj.node, attrs={'size': 3})
1084        keys = {
1085            in_time: 1.0 * color[0],
1086            in_time + 0.4: 1.5 * color[0],
1087            in_time + 0.5: 6.0 * color[0],
1088            in_time + 0.6: 1.5 * color[0],
1089            in_time + 2.0: 1.0 * color[0]
1090        }
1091        _gameutils.animate(combine, 'input0', keys)
1092        keys = {
1093            in_time: 1.0 * color[1],
1094            in_time + 0.4: 1.5 * color[1],
1095            in_time + 0.5: 6.0 * color[1],
1096            in_time + 0.6: 1.5 * color[1],
1097            in_time + 2.0: 1.0 * color[1]
1098        }
1099        _gameutils.animate(combine, 'input1', keys)
1100        keys = {
1101            in_time: 1.0 * color[2],
1102            in_time + 0.4: 1.5 * color[2],
1103            in_time + 0.5: 6.0 * color[2],
1104            in_time + 0.6: 1.5 * color[2],
1105            in_time + 2.0: 1.0 * color[2]
1106        }
1107        _gameutils.animate(combine, 'input2', keys)
1108        combine.connectattr('output', obj.node, 'color')
1109        objs.append(obj)
1110
1111        objt = Text(Lstr(value='${A}:',
1112                         subs=[('${A}', Lstr(resource='achievementText'))]),
1113                    position=(-120, 91 + y_offs),
1114                    front=True,
1115                    v_attach=Text.VAttach.BOTTOM,
1116                    vr_depth=base_vr_depth - 10,
1117                    transition=Text.Transition.IN_BOTTOM,
1118                    flatness=0.5,
1119                    transition_delay=in_time,
1120                    transition_out_delay=out_time,
1121                    color=(1, 1, 1, 0.8),
1122                    scale=0.65).autoretain()
1123        objs.append(objt)
1124        assert objt.node
1125        objt.node.host_only = True
1126
1127        objt = Text(self.display_name,
1128                    position=(-120, 50 + y_offs),
1129                    front=True,
1130                    v_attach=Text.VAttach.BOTTOM,
1131                    transition=Text.Transition.IN_BOTTOM,
1132                    vr_depth=base_vr_depth,
1133                    flatness=0.5,
1134                    transition_delay=in_time,
1135                    transition_out_delay=out_time,
1136                    flash=True,
1137                    color=(1, 0.8, 0, 1.0),
1138                    scale=1.5).autoretain()
1139        objs.append(objt)
1140        assert objt.node
1141        objt.node.host_only = True
1142
1143        objt = Text(_ba.charstr(SpecialChar.TICKET),
1144                    position=(-120 - 170 + 5, 75 + y_offs - 20),
1145                    front=True,
1146                    v_attach=Text.VAttach.BOTTOM,
1147                    h_align=Text.HAlign.CENTER,
1148                    v_align=Text.VAlign.CENTER,
1149                    transition=Text.Transition.IN_BOTTOM,
1150                    vr_depth=base_vr_depth,
1151                    transition_delay=in_time,
1152                    transition_out_delay=out_time,
1153                    flash=True,
1154                    color=(0.5, 0.5, 0.5, 1),
1155                    scale=3.0).autoretain()
1156        objs.append(objt)
1157        assert objt.node
1158        objt.node.host_only = True
1159
1160        objt = Text('+' + str(self.get_award_ticket_value()),
1161                    position=(-120 - 180 + 5, 80 + y_offs - 20),
1162                    v_attach=Text.VAttach.BOTTOM,
1163                    front=True,
1164                    h_align=Text.HAlign.CENTER,
1165                    v_align=Text.VAlign.CENTER,
1166                    transition=Text.Transition.IN_BOTTOM,
1167                    vr_depth=base_vr_depth,
1168                    flatness=0.5,
1169                    shadow=1.0,
1170                    transition_delay=in_time,
1171                    transition_out_delay=out_time,
1172                    flash=True,
1173                    color=(0, 1, 0, 1),
1174                    scale=1.5).autoretain()
1175        objs.append(objt)
1176        assert objt.node
1177        objt.node.host_only = True
1178
1179        # Add the 'x 2' if we've got pro.
1180        if app.accounts_v1.have_pro():
1181            objt = Text('x 2',
1182                        position=(-120 - 180 + 45, 80 + y_offs - 50),
1183                        v_attach=Text.VAttach.BOTTOM,
1184                        front=True,
1185                        h_align=Text.HAlign.CENTER,
1186                        v_align=Text.VAlign.CENTER,
1187                        transition=Text.Transition.IN_BOTTOM,
1188                        vr_depth=base_vr_depth,
1189                        flatness=0.5,
1190                        shadow=1.0,
1191                        transition_delay=in_time,
1192                        transition_out_delay=out_time,
1193                        flash=True,
1194                        color=(0.4, 0, 1, 1),
1195                        scale=0.9).autoretain()
1196            objs.append(objt)
1197            assert objt.node
1198            objt.node.host_only = True
1199
1200        objt = Text(self.description_complete,
1201                    position=(-120, 30 + y_offs),
1202                    front=True,
1203                    v_attach=Text.VAttach.BOTTOM,
1204                    transition=Text.Transition.IN_BOTTOM,
1205                    vr_depth=base_vr_depth - 10,
1206                    flatness=0.5,
1207                    transition_delay=in_time,
1208                    transition_out_delay=out_time,
1209                    color=(1.0, 0.7, 0.5, 1.0),
1210                    scale=0.8).autoretain()
1211        objs.append(objt)
1212        assert objt.node
1213        objt.node.host_only = True
1214
1215        for actor in objs:
1216            _ba.timer(out_time + 1.000,
1217                      WeakCall(actor.handlemessage, DieMessage()))

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)
443    def __init__(self,
444                 name: str,
445                 icon_name: str,
446                 icon_color: Sequence[float],
447                 level_name: str,
448                 award: int,
449                 hard_mode_only: bool = False):
450        self._name = name
451        self._icon_name = icon_name
452        self._icon_color: Sequence[float] = list(icon_color) + [1]
453        self._level_name = level_name
454        self._completion_banner_slot: int | None = None
455        self._award = award
456        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:
468    def get_icon_texture(self, complete: bool) -> ba.Texture:
469        """Return the icon texture to display for this achievement"""
470        return _ba.gettexture(
471            self._icon_name if complete else 'achievementEmpty')

Return the icon texture to display for this achievement

def get_icon_color(self, complete: bool) -> Sequence[float]:
473    def get_icon_color(self, complete: bool) -> Sequence[float]:
474        """Return the color tint for this Achievement's icon."""
475        if complete:
476            return self._icon_color
477        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:
491    def announce_completion(self, sound: bool = True) -> None:
492        """Kick off an announcement for this achievement's completion."""
493        from ba._generated.enums import TimeType
494        app = _ba.app
495
496        # Even though there are technically achievements when we're not
497        # signed in, lets not show them (otherwise we tend to get
498        # confusing 'controller connected' achievements popping up while
499        # waiting to log in which can be confusing).
500        if _ba.get_v1_account_state() != 'signed_in':
501            return
502
503        # If we're being freshly complete, display/report it and whatnot.
504        if (self, sound) not in app.ach.achievements_to_display:
505            app.ach.achievements_to_display.append((self, sound))
506
507        # If there's no achievement display timer going, kick one off
508        # (if one's already running it will pick this up before it dies).
509
510        # Need to check last time too; its possible our timer wasn't able to
511        # clear itself if an activity died and took it down with it.
512        if ((app.ach.achievement_display_timer is None
513             or _ba.time(TimeType.REAL) - app.ach.last_achievement_display_time
514             > 2.0) and _ba.getactivity(doraise=False) is not None):
515            app.ach.achievement_display_timer = _ba.Timer(
516                1.0,
517                _display_next_achievement,
518                repeat=True,
519                timetype=TimeType.BASE)
520
521            # Show the first immediately.
522            _display_next_achievement()

Kick off an announcement for this achievement's completion.

def set_complete(self, complete: bool = True) -> None:
524    def set_complete(self, complete: bool = True) -> None:
525        """Set an achievement's completed state.
526
527        note this only sets local state; use a transaction to
528        actually award achievements.
529        """
530        config = self._getconfig()
531        if complete != config['Complete']:
532            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:
594    def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int:
595        """Get the ticket award value for this achievement."""
596        val: int = (_ba.get_v1_account_misc_read_val('achAward.' + self._name,
597                                                     self._award) *
598                    _get_ach_mult(include_pro_bonus))
599        assert isinstance(val, int)
600        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]:
610    def create_display(self,
611                       x: float,
612                       y: float,
613                       delay: float,
614                       outdelay: float | None = None,
615                       color: Sequence[float] | None = None,
616                       style: str = 'post_game') -> list[ba.Actor]:
617        """Create a display for the Achievement.
618
619        Shows the Achievement icon, name, and description.
620        """
621        # pylint: disable=cyclic-import
622        from ba._language import Lstr
623        from ba._generated.enums import SpecialChar
624        from ba._coopsession import CoopSession
625        from bastd.actor.image import Image
626        from bastd.actor.text import Text
627
628        # Yeah this needs cleaning up.
629        if style == 'post_game':
630            in_game_colors = False
631            in_main_menu = False
632            h_attach = Text.HAttach.CENTER
633            v_attach = Text.VAttach.CENTER
634            attach = Image.Attach.CENTER
635        elif style == 'in_game':
636            in_game_colors = True
637            in_main_menu = False
638            h_attach = Text.HAttach.LEFT
639            v_attach = Text.VAttach.TOP
640            attach = Image.Attach.TOP_LEFT
641        elif style == 'news':
642            in_game_colors = True
643            in_main_menu = True
644            h_attach = Text.HAttach.CENTER
645            v_attach = Text.VAttach.TOP
646            attach = Image.Attach.TOP_CENTER
647        else:
648            raise ValueError('invalid style "' + style + '"')
649
650        # Attempt to determine what campaign we're in
651        # (so we know whether to show "hard mode only").
652        if in_main_menu:
653            hmo = False
654        else:
655            try:
656                session = _ba.getsession()
657                if isinstance(session, CoopSession):
658                    campaign = session.campaign
659                    assert campaign is not None
660                    hmo = (self._hard_mode_only and campaign.name == 'Easy')
661                else:
662                    hmo = False
663            except Exception:
664                print_exception('Error determining campaign.')
665                hmo = False
666
667        objs: list[ba.Actor]
668
669        if in_game_colors:
670            objs = []
671            out_delay_fin = (delay +
672                             outdelay) if outdelay is not None else None
673            if color is not None:
674                cl1 = (2.0 * color[0], 2.0 * color[1], 2.0 * color[2],
675                       color[3])
676                cl2 = color
677            else:
678                cl1 = (1.5, 1.5, 2, 1.0)
679                cl2 = (0.8, 0.8, 1.0, 1.0)
680
681            if hmo:
682                cl1 = (cl1[0], cl1[1], cl1[2], cl1[3] * 0.6)
683                cl2 = (cl2[0], cl2[1], cl2[2], cl2[3] * 0.2)
684
685            objs.append(
686                Image(self.get_icon_texture(False),
687                      host_only=True,
688                      color=cl1,
689                      position=(x - 25, y + 5),
690                      attach=attach,
691                      transition=Image.Transition.FADE_IN,
692                      transition_delay=delay,
693                      vr_depth=4,
694                      transition_out_delay=out_delay_fin,
695                      scale=(40, 40)).autoretain())
696            txt = self.display_name
697            txt_s = 0.85
698            txt_max_w = 300
699            objs.append(
700                Text(txt,
701                     host_only=True,
702                     maxwidth=txt_max_w,
703                     position=(x, y + 2),
704                     transition=Text.Transition.FADE_IN,
705                     scale=txt_s,
706                     flatness=0.6,
707                     shadow=0.5,
708                     h_attach=h_attach,
709                     v_attach=v_attach,
710                     color=cl2,
711                     transition_delay=delay + 0.05,
712                     transition_out_delay=out_delay_fin).autoretain())
713            txt2_s = 0.62
714            txt2_max_w = 400
715            objs.append(
716                Text(self.description_full
717                     if in_main_menu else self.description,
718                     host_only=True,
719                     maxwidth=txt2_max_w,
720                     position=(x, y - 14),
721                     transition=Text.Transition.FADE_IN,
722                     vr_depth=-5,
723                     h_attach=h_attach,
724                     v_attach=v_attach,
725                     scale=txt2_s,
726                     flatness=1.0,
727                     shadow=0.5,
728                     color=cl2,
729                     transition_delay=delay + 0.1,
730                     transition_out_delay=out_delay_fin).autoretain())
731
732            if hmo:
733                txtactor = Text(
734                    Lstr(resource='difficultyHardOnlyText'),
735                    host_only=True,
736                    maxwidth=txt2_max_w * 0.7,
737                    position=(x + 60, y + 5),
738                    transition=Text.Transition.FADE_IN,
739                    vr_depth=-5,
740                    h_attach=h_attach,
741                    v_attach=v_attach,
742                    h_align=Text.HAlign.CENTER,
743                    v_align=Text.VAlign.CENTER,
744                    scale=txt_s * 0.8,
745                    flatness=1.0,
746                    shadow=0.5,
747                    color=(1, 1, 0.6, 1),
748                    transition_delay=delay + 0.1,
749                    transition_out_delay=out_delay_fin).autoretain()
750                txtactor.node.rotate = 10
751                objs.append(txtactor)
752
753            # Ticket-award.
754            award_x = -100
755            objs.append(
756                Text(_ba.charstr(SpecialChar.TICKET),
757                     host_only=True,
758                     position=(x + award_x + 33, y + 7),
759                     transition=Text.Transition.FADE_IN,
760                     scale=1.5,
761                     h_attach=h_attach,
762                     v_attach=v_attach,
763                     h_align=Text.HAlign.CENTER,
764                     v_align=Text.VAlign.CENTER,
765                     color=(1, 1, 1, 0.2 if hmo else 0.4),
766                     transition_delay=delay + 0.05,
767                     transition_out_delay=out_delay_fin).autoretain())
768            objs.append(
769                Text('+' + str(self.get_award_ticket_value()),
770                     host_only=True,
771                     position=(x + award_x + 28, y + 16),
772                     transition=Text.Transition.FADE_IN,
773                     scale=0.7,
774                     flatness=1,
775                     h_attach=h_attach,
776                     v_attach=v_attach,
777                     h_align=Text.HAlign.CENTER,
778                     v_align=Text.VAlign.CENTER,
779                     color=cl2,
780                     transition_delay=delay + 0.05,
781                     transition_out_delay=out_delay_fin).autoretain())
782
783        else:
784            complete = self.complete
785            objs = []
786            c_icon = self.get_icon_color(complete)
787            if hmo and not complete:
788                c_icon = (c_icon[0], c_icon[1], c_icon[2], c_icon[3] * 0.3)
789            objs.append(
790                Image(self.get_icon_texture(complete),
791                      host_only=True,
792                      color=c_icon,
793                      position=(x - 25, y + 5),
794                      attach=attach,
795                      vr_depth=4,
796                      transition=Image.Transition.IN_RIGHT,
797                      transition_delay=delay,
798                      transition_out_delay=None,
799                      scale=(40, 40)).autoretain())
800            if complete:
801                objs.append(
802                    Image(_ba.gettexture('achievementOutline'),
803                          host_only=True,
804                          model_transparent=_ba.getmodel('achievementOutline'),
805                          color=(2, 1.4, 0.4, 1),
806                          vr_depth=8,
807                          position=(x - 25, y + 5),
808                          attach=attach,
809                          transition=Image.Transition.IN_RIGHT,
810                          transition_delay=delay,
811                          transition_out_delay=None,
812                          scale=(40, 40)).autoretain())
813            else:
814                if not complete:
815                    award_x = -100
816                    objs.append(
817                        Text(_ba.charstr(SpecialChar.TICKET),
818                             host_only=True,
819                             position=(x + award_x + 33, y + 7),
820                             transition=Text.Transition.IN_RIGHT,
821                             scale=1.5,
822                             h_attach=h_attach,
823                             v_attach=v_attach,
824                             h_align=Text.HAlign.CENTER,
825                             v_align=Text.VAlign.CENTER,
826                             color=(1, 1, 1, 0.4) if complete else
827                             (1, 1, 1, (0.1 if hmo else 0.2)),
828                             transition_delay=delay + 0.05,
829                             transition_out_delay=None).autoretain())
830                    objs.append(
831                        Text('+' + str(self.get_award_ticket_value()),
832                             host_only=True,
833                             position=(x + award_x + 28, y + 16),
834                             transition=Text.Transition.IN_RIGHT,
835                             scale=0.7,
836                             flatness=1,
837                             h_attach=h_attach,
838                             v_attach=v_attach,
839                             h_align=Text.HAlign.CENTER,
840                             v_align=Text.VAlign.CENTER,
841                             color=((0.8, 0.93, 0.8, 1.0) if complete else
842                                    (0.6, 0.6, 0.6, (0.2 if hmo else 0.4))),
843                             transition_delay=delay + 0.05,
844                             transition_out_delay=None).autoretain())
845
846                    # Show 'hard-mode-only' only over incomplete achievements
847                    # when that's the case.
848                    if hmo:
849                        txtactor = Text(
850                            Lstr(resource='difficultyHardOnlyText'),
851                            host_only=True,
852                            maxwidth=300 * 0.7,
853                            position=(x + 60, y + 5),
854                            transition=Text.Transition.FADE_IN,
855                            vr_depth=-5,
856                            h_attach=h_attach,
857                            v_attach=v_attach,
858                            h_align=Text.HAlign.CENTER,
859                            v_align=Text.VAlign.CENTER,
860                            scale=0.85 * 0.8,
861                            flatness=1.0,
862                            shadow=0.5,
863                            color=(1, 1, 0.6, 1),
864                            transition_delay=delay + 0.05,
865                            transition_out_delay=None).autoretain()
866                        assert txtactor.node
867                        txtactor.node.rotate = 10
868                        objs.append(txtactor)
869
870            objs.append(
871                Text(self.display_name,
872                     host_only=True,
873                     maxwidth=300,
874                     position=(x, y + 2),
875                     transition=Text.Transition.IN_RIGHT,
876                     scale=0.85,
877                     flatness=0.6,
878                     h_attach=h_attach,
879                     v_attach=v_attach,
880                     color=((0.8, 0.93, 0.8, 1.0) if complete else
881                            (0.6, 0.6, 0.6, (0.2 if hmo else 0.4))),
882                     transition_delay=delay + 0.05,
883                     transition_out_delay=None).autoretain())
884            objs.append(
885                Text(self.description_complete
886                     if complete else self.description,
887                     host_only=True,
888                     maxwidth=400,
889                     position=(x, y - 14),
890                     transition=Text.Transition.IN_RIGHT,
891                     vr_depth=-5,
892                     h_attach=h_attach,
893                     v_attach=v_attach,
894                     scale=0.62,
895                     flatness=1.0,
896                     color=((0.6, 0.6, 0.6, 1.0) if complete else
897                            (0.6, 0.6, 0.6, (0.2 if hmo else 0.4))),
898                     transition_delay=delay + 0.1,
899                     transition_out_delay=None).autoretain())
900        return objs

Create a display for the Achievement.

Shows the Achievement icon, name, and description.

def show_completion_banner(self, sound: bool = True) -> None:
 918    def show_completion_banner(self, sound: bool = True) -> None:
 919        """Create the banner/sound for an acquired achievement announcement."""
 920        from ba import _gameutils
 921        from bastd.actor.text import Text
 922        from bastd.actor.image import Image
 923        from ba._general import WeakCall
 924        from ba._language import Lstr
 925        from ba._messages import DieMessage
 926        from ba._generated.enums import TimeType, SpecialChar
 927        app = _ba.app
 928        app.ach.last_achievement_display_time = _ba.time(TimeType.REAL)
 929
 930        # Just piggy-back onto any current activity
 931        # (should we use the session instead?..)
 932        activity = _ba.getactivity(doraise=False)
 933
 934        # If this gets called while this achievement is occupying a slot
 935        # already, ignore it. (probably should never happen in real
 936        # life but whatevs).
 937        if self._completion_banner_slot is not None:
 938            return
 939
 940        if activity is None:
 941            print('show_completion_banner() called with no current activity!')
 942            return
 943
 944        if sound:
 945            _ba.playsound(_ba.getsound('achievement'), host_only=True)
 946        else:
 947            _ba.timer(
 948                0.5,
 949                lambda: _ba.playsound(_ba.getsound('ding'), host_only=True))
 950
 951        in_time = 0.300
 952        out_time = 3.5
 953
 954        base_vr_depth = 200
 955
 956        # Find the first free slot.
 957        i = 0
 958        while True:
 959            if i not in app.ach.achievement_completion_banner_slots:
 960                app.ach.achievement_completion_banner_slots.add(i)
 961                self._completion_banner_slot = i
 962
 963                # Remove us from that slot when we close.
 964                # Use a real-timer in the UI context so the removal runs even
 965                # if our activity/session dies.
 966                with _ba.Context('ui'):
 967                    _ba.timer(in_time + out_time,
 968                              self._remove_banner_slot,
 969                              timetype=TimeType.REAL)
 970                break
 971            i += 1
 972        assert self._completion_banner_slot is not None
 973        y_offs = 110 * self._completion_banner_slot
 974        objs: list[ba.Actor] = []
 975        obj = Image(_ba.gettexture('shadow'),
 976                    position=(-30, 30 + y_offs),
 977                    front=True,
 978                    attach=Image.Attach.BOTTOM_CENTER,
 979                    transition=Image.Transition.IN_BOTTOM,
 980                    vr_depth=base_vr_depth - 100,
 981                    transition_delay=in_time,
 982                    transition_out_delay=out_time,
 983                    color=(0.0, 0.1, 0, 1),
 984                    scale=(1000, 300)).autoretain()
 985        objs.append(obj)
 986        assert obj.node
 987        obj.node.host_only = True
 988        obj = Image(_ba.gettexture('light'),
 989                    position=(-180, 60 + y_offs),
 990                    front=True,
 991                    attach=Image.Attach.BOTTOM_CENTER,
 992                    vr_depth=base_vr_depth,
 993                    transition=Image.Transition.IN_BOTTOM,
 994                    transition_delay=in_time,
 995                    transition_out_delay=out_time,
 996                    color=(1.8, 1.8, 1.0, 0.0),
 997                    scale=(40, 300)).autoretain()
 998        objs.append(obj)
 999        assert obj.node
1000        obj.node.host_only = True
1001        obj.node.premultiplied = True
1002        combine = _ba.newnode('combine', owner=obj.node, attrs={'size': 2})
1003        _gameutils.animate(
1004            combine, 'input0', {
1005                in_time: 0,
1006                in_time + 0.4: 30,
1007                in_time + 0.5: 40,
1008                in_time + 0.6: 30,
1009                in_time + 2.0: 0
1010            })
1011        _gameutils.animate(
1012            combine, 'input1', {
1013                in_time: 0,
1014                in_time + 0.4: 200,
1015                in_time + 0.5: 500,
1016                in_time + 0.6: 200,
1017                in_time + 2.0: 0
1018            })
1019        combine.connectattr('output', obj.node, 'scale')
1020        _gameutils.animate(obj.node,
1021                           'rotate', {
1022                               0: 0.0,
1023                               0.35: 360.0
1024                           },
1025                           loop=True)
1026        obj = Image(self.get_icon_texture(True),
1027                    position=(-180, 60 + y_offs),
1028                    attach=Image.Attach.BOTTOM_CENTER,
1029                    front=True,
1030                    vr_depth=base_vr_depth - 10,
1031                    transition=Image.Transition.IN_BOTTOM,
1032                    transition_delay=in_time,
1033                    transition_out_delay=out_time,
1034                    scale=(100, 100)).autoretain()
1035        objs.append(obj)
1036        assert obj.node
1037        obj.node.host_only = True
1038
1039        # Flash.
1040        color = self.get_icon_color(True)
1041        combine = _ba.newnode('combine', owner=obj.node, attrs={'size': 3})
1042        keys = {
1043            in_time: 1.0 * color[0],
1044            in_time + 0.4: 1.5 * color[0],
1045            in_time + 0.5: 6.0 * color[0],
1046            in_time + 0.6: 1.5 * color[0],
1047            in_time + 2.0: 1.0 * color[0]
1048        }
1049        _gameutils.animate(combine, 'input0', keys)
1050        keys = {
1051            in_time: 1.0 * color[1],
1052            in_time + 0.4: 1.5 * color[1],
1053            in_time + 0.5: 6.0 * color[1],
1054            in_time + 0.6: 1.5 * color[1],
1055            in_time + 2.0: 1.0 * color[1]
1056        }
1057        _gameutils.animate(combine, 'input1', keys)
1058        keys = {
1059            in_time: 1.0 * color[2],
1060            in_time + 0.4: 1.5 * color[2],
1061            in_time + 0.5: 6.0 * color[2],
1062            in_time + 0.6: 1.5 * color[2],
1063            in_time + 2.0: 1.0 * color[2]
1064        }
1065        _gameutils.animate(combine, 'input2', keys)
1066        combine.connectattr('output', obj.node, 'color')
1067
1068        obj = Image(_ba.gettexture('achievementOutline'),
1069                    model_transparent=_ba.getmodel('achievementOutline'),
1070                    position=(-180, 60 + y_offs),
1071                    front=True,
1072                    attach=Image.Attach.BOTTOM_CENTER,
1073                    vr_depth=base_vr_depth,
1074                    transition=Image.Transition.IN_BOTTOM,
1075                    transition_delay=in_time,
1076                    transition_out_delay=out_time,
1077                    scale=(100, 100)).autoretain()
1078        assert obj.node
1079        obj.node.host_only = True
1080
1081        # Flash.
1082        color = (2, 1.4, 0.4, 1)
1083        combine = _ba.newnode('combine', owner=obj.node, attrs={'size': 3})
1084        keys = {
1085            in_time: 1.0 * color[0],
1086            in_time + 0.4: 1.5 * color[0],
1087            in_time + 0.5: 6.0 * color[0],
1088            in_time + 0.6: 1.5 * color[0],
1089            in_time + 2.0: 1.0 * color[0]
1090        }
1091        _gameutils.animate(combine, 'input0', keys)
1092        keys = {
1093            in_time: 1.0 * color[1],
1094            in_time + 0.4: 1.5 * color[1],
1095            in_time + 0.5: 6.0 * color[1],
1096            in_time + 0.6: 1.5 * color[1],
1097            in_time + 2.0: 1.0 * color[1]
1098        }
1099        _gameutils.animate(combine, 'input1', keys)
1100        keys = {
1101            in_time: 1.0 * color[2],
1102            in_time + 0.4: 1.5 * color[2],
1103            in_time + 0.5: 6.0 * color[2],
1104            in_time + 0.6: 1.5 * color[2],
1105            in_time + 2.0: 1.0 * color[2]
1106        }
1107        _gameutils.animate(combine, 'input2', keys)
1108        combine.connectattr('output', obj.node, 'color')
1109        objs.append(obj)
1110
1111        objt = Text(Lstr(value='${A}:',
1112                         subs=[('${A}', Lstr(resource='achievementText'))]),
1113                    position=(-120, 91 + y_offs),
1114                    front=True,
1115                    v_attach=Text.VAttach.BOTTOM,
1116                    vr_depth=base_vr_depth - 10,
1117                    transition=Text.Transition.IN_BOTTOM,
1118                    flatness=0.5,
1119                    transition_delay=in_time,
1120                    transition_out_delay=out_time,
1121                    color=(1, 1, 1, 0.8),
1122                    scale=0.65).autoretain()
1123        objs.append(objt)
1124        assert objt.node
1125        objt.node.host_only = True
1126
1127        objt = Text(self.display_name,
1128                    position=(-120, 50 + y_offs),
1129                    front=True,
1130                    v_attach=Text.VAttach.BOTTOM,
1131                    transition=Text.Transition.IN_BOTTOM,
1132                    vr_depth=base_vr_depth,
1133                    flatness=0.5,
1134                    transition_delay=in_time,
1135                    transition_out_delay=out_time,
1136                    flash=True,
1137                    color=(1, 0.8, 0, 1.0),
1138                    scale=1.5).autoretain()
1139        objs.append(objt)
1140        assert objt.node
1141        objt.node.host_only = True
1142
1143        objt = Text(_ba.charstr(SpecialChar.TICKET),
1144                    position=(-120 - 170 + 5, 75 + y_offs - 20),
1145                    front=True,
1146                    v_attach=Text.VAttach.BOTTOM,
1147                    h_align=Text.HAlign.CENTER,
1148                    v_align=Text.VAlign.CENTER,
1149                    transition=Text.Transition.IN_BOTTOM,
1150                    vr_depth=base_vr_depth,
1151                    transition_delay=in_time,
1152                    transition_out_delay=out_time,
1153                    flash=True,
1154                    color=(0.5, 0.5, 0.5, 1),
1155                    scale=3.0).autoretain()
1156        objs.append(objt)
1157        assert objt.node
1158        objt.node.host_only = True
1159
1160        objt = Text('+' + str(self.get_award_ticket_value()),
1161                    position=(-120 - 180 + 5, 80 + y_offs - 20),
1162                    v_attach=Text.VAttach.BOTTOM,
1163                    front=True,
1164                    h_align=Text.HAlign.CENTER,
1165                    v_align=Text.VAlign.CENTER,
1166                    transition=Text.Transition.IN_BOTTOM,
1167                    vr_depth=base_vr_depth,
1168                    flatness=0.5,
1169                    shadow=1.0,
1170                    transition_delay=in_time,
1171                    transition_out_delay=out_time,
1172                    flash=True,
1173                    color=(0, 1, 0, 1),
1174                    scale=1.5).autoretain()
1175        objs.append(objt)
1176        assert objt.node
1177        objt.node.host_only = True
1178
1179        # Add the 'x 2' if we've got pro.
1180        if app.accounts_v1.have_pro():
1181            objt = Text('x 2',
1182                        position=(-120 - 180 + 45, 80 + y_offs - 50),
1183                        v_attach=Text.VAttach.BOTTOM,
1184                        front=True,
1185                        h_align=Text.HAlign.CENTER,
1186                        v_align=Text.VAlign.CENTER,
1187                        transition=Text.Transition.IN_BOTTOM,
1188                        vr_depth=base_vr_depth,
1189                        flatness=0.5,
1190                        shadow=1.0,
1191                        transition_delay=in_time,
1192                        transition_out_delay=out_time,
1193                        flash=True,
1194                        color=(0.4, 0, 1, 1),
1195                        scale=0.9).autoretain()
1196            objs.append(objt)
1197            assert objt.node
1198            objt.node.host_only = True
1199
1200        objt = Text(self.description_complete,
1201                    position=(-120, 30 + y_offs),
1202                    front=True,
1203                    v_attach=Text.VAttach.BOTTOM,
1204                    transition=Text.Transition.IN_BOTTOM,
1205                    vr_depth=base_vr_depth - 10,
1206                    flatness=0.5,
1207                    transition_delay=in_time,
1208                    transition_out_delay=out_time,
1209                    color=(1.0, 0.7, 0.5, 1.0),
1210                    scale=0.8).autoretain()
1211        objs.append(objt)
1212        assert objt.node
1213        objt.node.host_only = True
1214
1215        for actor in objs:
1216            _ba.timer(out_time + 1.000,
1217                      WeakCall(actor.handlemessage, DieMessage()))

Create the banner/sound for an acquired achievement announcement.

class AchievementSubsystem:
 66class AchievementSubsystem:
 67    """Subsystem for achievement handling.
 68
 69    Category: **App Classes**
 70
 71    Access the single shared instance of this class at 'ba.app.ach'.
 72    """
 73
 74    def __init__(self) -> None:
 75        self.achievements: list[Achievement] = []
 76        self.achievements_to_display: (list[tuple[ba.Achievement, bool]]) = []
 77        self.achievement_display_timer: _ba.Timer | None = None
 78        self.last_achievement_display_time: float = 0.0
 79        self.achievement_completion_banner_slots: set[int] = set()
 80        self._init_achievements()
 81
 82    def _init_achievements(self) -> None:
 83        """Fill in available achievements."""
 84
 85        achs = self.achievements
 86
 87        # 5
 88        achs.append(
 89            Achievement('In Control', 'achievementInControl', (1, 1, 1), '',
 90                        5))
 91        # 15
 92        achs.append(
 93            Achievement('Sharing is Caring', 'achievementSharingIsCaring',
 94                        (1, 1, 1), '', 15))
 95        # 10
 96        achs.append(
 97            Achievement('Dual Wielding', 'achievementDualWielding', (1, 1, 1),
 98                        '', 10))
 99
100        # 10
101        achs.append(
102            Achievement('Free Loader', 'achievementFreeLoader', (1, 1, 1), '',
103                        10))
104        # 20
105        achs.append(
106            Achievement('Team Player', 'achievementTeamPlayer', (1, 1, 1), '',
107                        20))
108
109        # 5
110        achs.append(
111            Achievement('Onslaught Training Victory', 'achievementOnslaught',
112                        (1, 1, 1), 'Default:Onslaught Training', 5))
113        # 5
114        achs.append(
115            Achievement('Off You Go Then', 'achievementOffYouGo',
116                        (1, 1.1, 1.3), 'Default:Onslaught Training', 5))
117        # 10
118        achs.append(
119            Achievement('Boxer',
120                        'achievementBoxer', (1, 0.6, 0.6),
121                        'Default:Onslaught Training',
122                        10,
123                        hard_mode_only=True))
124
125        # 10
126        achs.append(
127            Achievement('Rookie Onslaught Victory', 'achievementOnslaught',
128                        (0.5, 1.4, 0.6), 'Default:Rookie Onslaught', 10))
129        # 10
130        achs.append(
131            Achievement('Mine Games', 'achievementMine', (1, 1, 1.4),
132                        'Default:Rookie Onslaught', 10))
133        # 15
134        achs.append(
135            Achievement('Flawless Victory',
136                        'achievementFlawlessVictory', (1, 1, 1),
137                        'Default:Rookie Onslaught',
138                        15,
139                        hard_mode_only=True))
140
141        # 10
142        achs.append(
143            Achievement('Rookie Football Victory',
144                        'achievementFootballVictory', (1.0, 1, 0.6),
145                        'Default:Rookie Football', 10))
146        # 10
147        achs.append(
148            Achievement('Super Punch', 'achievementSuperPunch', (1, 1, 1.8),
149                        'Default:Rookie Football', 10))
150        # 15
151        achs.append(
152            Achievement('Rookie Football Shutout',
153                        'achievementFootballShutout', (1, 1, 1),
154                        'Default:Rookie Football',
155                        15,
156                        hard_mode_only=True))
157
158        # 15
159        achs.append(
160            Achievement('Pro Onslaught Victory', 'achievementOnslaught',
161                        (0.3, 1, 2.0), 'Default:Pro Onslaught', 15))
162        # 15
163        achs.append(
164            Achievement('Boom Goes the Dynamite', 'achievementTNT',
165                        (1.4, 1.2, 0.8), 'Default:Pro Onslaught', 15))
166        # 20
167        achs.append(
168            Achievement('Pro Boxer',
169                        'achievementBoxer', (2, 2, 0),
170                        'Default:Pro Onslaught',
171                        20,
172                        hard_mode_only=True))
173
174        # 15
175        achs.append(
176            Achievement('Pro Football Victory', 'achievementFootballVictory',
177                        (1.3, 1.3, 2.0), 'Default:Pro Football', 15))
178        # 15
179        achs.append(
180            Achievement('Super Mega Punch', 'achievementSuperPunch',
181                        (2, 1, 0.6), 'Default:Pro Football', 15))
182        # 20
183        achs.append(
184            Achievement('Pro Football Shutout',
185                        'achievementFootballShutout', (0.7, 0.7, 2.0),
186                        'Default:Pro Football',
187                        20,
188                        hard_mode_only=True))
189
190        # 15
191        achs.append(
192            Achievement('Pro Runaround Victory', 'achievementRunaround',
193                        (1, 1, 1), 'Default:Pro Runaround', 15))
194        # 20
195        achs.append(
196            Achievement('Precision Bombing',
197                        'achievementCrossHair', (1, 1, 1.3),
198                        'Default:Pro Runaround',
199                        20,
200                        hard_mode_only=True))
201        # 25
202        achs.append(
203            Achievement('The Wall',
204                        'achievementWall', (1, 0.7, 0.7),
205                        'Default:Pro Runaround',
206                        25,
207                        hard_mode_only=True))
208
209        # 30
210        achs.append(
211            Achievement('Uber Onslaught Victory', 'achievementOnslaught',
212                        (2, 2, 1), 'Default:Uber Onslaught', 30))
213        # 30
214        achs.append(
215            Achievement('Gold Miner',
216                        'achievementMine', (2, 1.6, 0.2),
217                        'Default:Uber Onslaught',
218                        30,
219                        hard_mode_only=True))
220        # 30
221        achs.append(
222            Achievement('TNT Terror',
223                        'achievementTNT', (2, 1.8, 0.3),
224                        'Default:Uber Onslaught',
225                        30,
226                        hard_mode_only=True))
227
228        # 30
229        achs.append(
230            Achievement('Uber Football Victory', 'achievementFootballVictory',
231                        (1.8, 1.4, 0.3), 'Default:Uber Football', 30))
232        # 30
233        achs.append(
234            Achievement('Got the Moves',
235                        'achievementGotTheMoves', (2, 1, 0),
236                        'Default:Uber Football',
237                        30,
238                        hard_mode_only=True))
239        # 40
240        achs.append(
241            Achievement('Uber Football Shutout',
242                        'achievementFootballShutout', (2, 2, 0),
243                        'Default:Uber Football',
244                        40,
245                        hard_mode_only=True))
246
247        # 30
248        achs.append(
249            Achievement('Uber Runaround Victory', 'achievementRunaround',
250                        (1.5, 1.2, 0.2), 'Default:Uber Runaround', 30))
251        # 40
252        achs.append(
253            Achievement('The Great Wall',
254                        'achievementWall', (2, 1.7, 0.4),
255                        'Default:Uber Runaround',
256                        40,
257                        hard_mode_only=True))
258        # 40
259        achs.append(
260            Achievement('Stayin\' Alive',
261                        'achievementStayinAlive', (2, 2, 1),
262                        'Default:Uber Runaround',
263                        40,
264                        hard_mode_only=True))
265
266        # 20
267        achs.append(
268            Achievement('Last Stand Master',
269                        'achievementMedalSmall', (2, 1.5, 0.3),
270                        'Default:The Last Stand',
271                        20,
272                        hard_mode_only=True))
273        # 40
274        achs.append(
275            Achievement('Last Stand Wizard',
276                        'achievementMedalMedium', (2, 1.5, 0.3),
277                        'Default:The Last Stand',
278                        40,
279                        hard_mode_only=True))
280        # 60
281        achs.append(
282            Achievement('Last Stand God',
283                        'achievementMedalLarge', (2, 1.5, 0.3),
284                        'Default:The Last Stand',
285                        60,
286                        hard_mode_only=True))
287
288        # 5
289        achs.append(
290            Achievement('Onslaught Master', 'achievementMedalSmall',
291                        (0.7, 1, 0.7), 'Challenges:Infinite Onslaught', 5))
292        # 15
293        achs.append(
294            Achievement('Onslaught Wizard', 'achievementMedalMedium',
295                        (0.7, 1.0, 0.7), 'Challenges:Infinite Onslaught', 15))
296        # 30
297        achs.append(
298            Achievement('Onslaught God', 'achievementMedalLarge',
299                        (0.7, 1.0, 0.7), 'Challenges:Infinite Onslaught', 30))
300
301        # 5
302        achs.append(
303            Achievement('Runaround Master', 'achievementMedalSmall',
304                        (1.0, 1.0, 1.2), 'Challenges:Infinite Runaround', 5))
305        # 15
306        achs.append(
307            Achievement('Runaround Wizard', 'achievementMedalMedium',
308                        (1.0, 1.0, 1.2), 'Challenges:Infinite Runaround', 15))
309        # 30
310        achs.append(
311            Achievement('Runaround God', 'achievementMedalLarge',
312                        (1.0, 1.0, 1.2), 'Challenges:Infinite Runaround', 30))
313
314    def award_local_achievement(self, achname: str) -> None:
315        """For non-game-based achievements such as controller-connection."""
316        try:
317            ach = self.get_achievement(achname)
318            if not ach.complete:
319
320                # Report new achievements to the game-service.
321                _ba.report_achievement(achname)
322
323                # And to our account.
324                _ba.add_transaction({'type': 'ACHIEVEMENT', 'name': achname})
325
326                # Now attempt to show a banner.
327                self.display_achievement_banner(achname)
328
329        except Exception:
330            print_exception()
331
332    def display_achievement_banner(self, achname: str) -> None:
333        """Display a completion banner for an achievement.
334
335        (internal)
336
337        Used for server-driven achievements.
338        """
339        try:
340            # FIXME: Need to get these using the UI context or some other
341            #  purely local context somehow instead of trying to inject these
342            #  into whatever activity happens to be active
343            #  (since that won't work while in client mode).
344            activity = _ba.get_foreground_host_activity()
345            if activity is not None:
346                with _ba.Context(activity):
347                    self.get_achievement(achname).announce_completion()
348        except Exception:
349            print_exception('error showing server ach')
350
351    def set_completed_achievements(self, achs: Sequence[str]) -> None:
352        """Set the current state of completed achievements.
353
354        (internal)
355
356        All achievements not included here will be set incomplete.
357        """
358
359        # Note: This gets called whenever game-center/game-circle/etc tells
360        # us which achievements we currently have.  We always defer to them,
361        # even if that means we have to un-set an achievement we think we have.
362
363        cfg = _ba.app.config
364        cfg['Achievements'] = {}
365        for a_name in achs:
366            self.get_achievement(a_name).set_complete(True)
367        cfg.commit()
368
369    def get_achievement(self, name: str) -> Achievement:
370        """Return an Achievement by name."""
371        achs = [a for a in self.achievements if a.name == name]
372        assert len(achs) < 2
373        if not achs:
374            raise ValueError("Invalid achievement name: '" + name + "'")
375        return achs[0]
376
377    def achievements_for_coop_level(self,
378                                    level_name: str) -> list[Achievement]:
379        """Given a level name, return achievements available for it."""
380
381        # For the Easy campaign we return achievements for the Default
382        # campaign too. (want the user to see what achievements are part of the
383        # level even if they can't unlock them all on easy mode).
384        return [
385            a for a in self.achievements
386            if a.level_name in (level_name,
387                                level_name.replace('Easy', 'Default'))
388        ]
389
390    def _test(self) -> None:
391        """For testing achievement animations."""
392        from ba._generated.enums import TimeType
393
394        def testcall1() -> None:
395            self.achievements[0].announce_completion()
396            self.achievements[1].announce_completion()
397            self.achievements[2].announce_completion()
398
399        def testcall2() -> None:
400            self.achievements[3].announce_completion()
401            self.achievements[4].announce_completion()
402            self.achievements[5].announce_completion()
403
404        _ba.timer(3.0, testcall1, timetype=TimeType.BASE)
405        _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()
74    def __init__(self) -> None:
75        self.achievements: list[Achievement] = []
76        self.achievements_to_display: (list[tuple[ba.Achievement, bool]]) = []
77        self.achievement_display_timer: _ba.Timer | None = None
78        self.last_achievement_display_time: float = 0.0
79        self.achievement_completion_banner_slots: set[int] = set()
80        self._init_achievements()
def award_local_achievement(self, achname: str) -> None:
314    def award_local_achievement(self, achname: str) -> None:
315        """For non-game-based achievements such as controller-connection."""
316        try:
317            ach = self.get_achievement(achname)
318            if not ach.complete:
319
320                # Report new achievements to the game-service.
321                _ba.report_achievement(achname)
322
323                # And to our account.
324                _ba.add_transaction({'type': 'ACHIEVEMENT', 'name': achname})
325
326                # Now attempt to show a banner.
327                self.display_achievement_banner(achname)
328
329        except Exception:
330            print_exception()

For non-game-based achievements such as controller-connection.

def get_achievement(self, name: str) -> ba.Achievement:
369    def get_achievement(self, name: str) -> Achievement:
370        """Return an Achievement by name."""
371        achs = [a for a in self.achievements if a.name == name]
372        assert len(achs) < 2
373        if not achs:
374            raise ValueError("Invalid achievement name: '" + name + "'")
375        return achs[0]

Return an Achievement by name.

def achievements_for_coop_level(self, level_name: str) -> list[ba.Achievement]:
377    def achievements_for_coop_level(self,
378                                    level_name: str) -> list[Achievement]:
379        """Given a level name, return achievements available for it."""
380
381        # For the Easy campaign we return achievements for the Default
382        # campaign too. (want the user to see what achievements are part of the
383        # level even if they can't unlock them all on easy mode).
384        return [
385            a for a in self.achievements
386            if a.level_name in (level_name,
387                                level_name.replace('Easy', 'Default'))
388        ]

Given a level name, return achievements available for it.

class Activity(ba.DependencyComponent, typing.Generic[~PlayerType, ~TeamType]):
 29class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
 30    """Units of execution wrangled by a ba.Session.
 31
 32    Category: Gameplay Classes
 33
 34    Examples of Activities include games, score-screens, cutscenes, etc.
 35    A ba.Session has one 'current' Activity at any time, though their existence
 36    can overlap during transitions.
 37    """
 38
 39    # pylint: disable=too-many-public-methods
 40
 41    settings_raw: dict[str, Any]
 42    """The settings dict passed in when the activity was made.
 43       This attribute is deprecated and should be avoided when possible;
 44       activities should pull all values they need from the 'settings' arg
 45       passed to the Activity __init__ call."""
 46
 47    teams: list[TeamType]
 48    """The list of ba.Team-s in the Activity. This gets populated just
 49       before on_begin() is called and is updated automatically as players
 50       join or leave the game. (at least in free-for-all mode where every
 51       player gets their own team; in teams mode there are always 2 teams
 52       regardless of the player count)."""
 53
 54    players: list[PlayerType]
 55    """The list of ba.Player-s in the Activity. This gets populated just
 56       before on_begin() is called and is updated automatically as players
 57       join or leave the game."""
 58
 59    announce_player_deaths = False
 60    """Whether to print every time a player dies. This can be pertinent
 61       in games such as Death-Match but can be annoying in games where it
 62       doesn't matter."""
 63
 64    is_joining_activity = False
 65    """Joining activities are for waiting for initial player joins.
 66       They are treated slightly differently than regular activities,
 67       mainly in that all players are passed to the activity at once
 68       instead of as each joins."""
 69
 70    allow_pausing = False
 71    """Whether game-time should still progress when in menus/etc."""
 72
 73    allow_kick_idle_players = True
 74    """Whether idle players can potentially be kicked (should not happen in
 75       menus/etc)."""
 76
 77    use_fixed_vr_overlay = False
 78    """In vr mode, this determines whether overlay nodes (text, images, etc)
 79       are created at a fixed position in space or one that moves based on
 80       the current map. Generally this should be on for games and off for
 81       transitions/score-screens/etc. that persist between maps."""
 82
 83    slow_motion = False
 84    """If True, runs in slow motion and turns down sound pitch."""
 85
 86    inherits_slow_motion = False
 87    """Set this to True to inherit slow motion setting from previous
 88       activity (useful for transitions to avoid hitches)."""
 89
 90    inherits_music = False
 91    """Set this to True to keep playing the music from the previous activity
 92       (without even restarting it)."""
 93
 94    inherits_vr_camera_offset = False
 95    """Set this to true to inherit VR camera offsets from the previous
 96       activity (useful for preventing sporadic camera movement
 97       during transitions)."""
 98
 99    inherits_vr_overlay_center = False
100    """Set this to true to inherit (non-fixed) VR overlay positioning from
101       the previous activity (useful for prevent sporadic overlay jostling
102       during transitions)."""
103
104    inherits_tint = False
105    """Set this to true to inherit screen tint/vignette colors from the
106       previous activity (useful to prevent sudden color changes during
107       transitions)."""
108
109    allow_mid_activity_joins: bool = True
110    """Whether players should be allowed to join in the middle of this
111       activity. Note that Sessions may not allow mid-activity-joins even
112       if the activity says its ok."""
113
114    transition_time = 0.0
115    """If the activity fades or transitions in, it should set the length of
116       time here so that previous activities will be kept alive for that
117       long (avoiding 'holes' in the screen)
118       This value is given in real-time seconds."""
119
120    can_show_ad_on_death = False
121    """Is it ok to show an ad after this activity ends before showing
122       the next activity?"""
123
124    def __init__(self, settings: dict):
125        """Creates an Activity in the current ba.Session.
126
127        The activity will not be actually run until ba.Session.setactivity
128        is called. 'settings' should be a dict of key/value pairs specific
129        to the activity.
130
131        Activities should preload as much of their media/etc as possible in
132        their constructor, but none of it should actually be used until they
133        are transitioned in.
134        """
135        super().__init__()
136
137        # Create our internal engine data.
138        self._activity_data = _ba.register_activity(self)
139
140        assert isinstance(settings, dict)
141        assert _ba.getactivity() is self
142
143        self._globalsnode: ba.Node | None = None
144
145        # Player/Team types should have been specified as type args;
146        # grab those.
147        self._playertype: type[PlayerType]
148        self._teamtype: type[TeamType]
149        self._setup_player_and_team_types()
150
151        # FIXME: Relocate or remove the need for this stuff.
152        self.paused_text: ba.Actor | None = None
153
154        self._session = weakref.ref(_ba.getsession())
155
156        # Preloaded data for actors, maps, etc; indexed by type.
157        self.preloads: dict[type, Any] = {}
158
159        # Hopefully can eventually kill this; activities should
160        # validate/store whatever settings they need at init time
161        # (in a more type-safe way).
162        self.settings_raw = settings
163
164        self._has_transitioned_in = False
165        self._has_begun = False
166        self._has_ended = False
167        self._activity_death_check_timer: ba.Timer | None = None
168        self._expired = False
169        self._delay_delete_players: list[PlayerType] = []
170        self._delay_delete_teams: list[TeamType] = []
171        self._players_that_left: list[weakref.ref[PlayerType]] = []
172        self._teams_that_left: list[weakref.ref[TeamType]] = []
173        self._transitioning_out = False
174
175        # A handy place to put most actors; this list is pruned of dead
176        # actors regularly and these actors are insta-killed as the activity
177        # is dying.
178        self._actor_refs: list[ba.Actor] = []
179        self._actor_weak_refs: list[weakref.ref[ba.Actor]] = []
180        self._last_prune_dead_actors_time = _ba.time()
181        self._prune_dead_actors_timer: ba.Timer | None = None
182
183        self.teams = []
184        self.players = []
185
186        self.lobby = None
187        self._stats: ba.Stats | None = None
188        self._customdata: dict | None = {}
189
190    def __del__(self) -> None:
191
192        # If the activity has been run then we should have already cleaned
193        # it up, but we still need to run expire calls for un-run activities.
194        if not self._expired:
195            with _ba.Context('empty'):
196                self._expire()
197
198        # Inform our owner that we officially kicked the bucket.
199        if self._transitioning_out:
200            session = self._session()
201            if session is not None:
202                _ba.pushcall(
203                    Call(session.transitioning_out_activity_was_freed,
204                         self.can_show_ad_on_death))
205
206    @property
207    def globalsnode(self) -> ba.Node:
208        """The 'globals' ba.Node for the activity. This contains various
209        global controls and values.
210        """
211        node = self._globalsnode
212        if not node:
213            raise NodeNotFoundError()
214        return node
215
216    @property
217    def stats(self) -> ba.Stats:
218        """The stats instance accessible while the activity is running.
219
220        If access is attempted before or after, raises a ba.NotFoundError.
221        """
222        if self._stats is None:
223            from ba._error import NotFoundError
224            raise NotFoundError()
225        return self._stats
226
227    def on_expire(self) -> None:
228        """Called when your activity is being expired.
229
230        If your activity has created anything explicitly that may be retaining
231        a strong reference to the activity and preventing it from dying, you
232        should clear that out here. From this point on your activity's sole
233        purpose in life is to hit zero references and die so the next activity
234        can begin.
235        """
236
237    @property
238    def customdata(self) -> dict:
239        """Entities needing to store simple data with an activity can put it
240        here. This dict will be deleted when the activity expires, so contained
241        objects generally do not need to worry about handling expired
242        activities.
243        """
244        assert not self._expired
245        assert isinstance(self._customdata, dict)
246        return self._customdata
247
248    @property
249    def expired(self) -> bool:
250        """Whether the activity is expired.
251
252        An activity is set as expired when shutting down.
253        At this point no new nodes, timers, etc should be made,
254        run, etc, and the activity should be considered a 'zombie'.
255        """
256        return self._expired
257
258    @property
259    def playertype(self) -> type[PlayerType]:
260        """The type of ba.Player this Activity is using."""
261        return self._playertype
262
263    @property
264    def teamtype(self) -> type[TeamType]:
265        """The type of ba.Team this Activity is using."""
266        return self._teamtype
267
268    def set_has_ended(self, val: bool) -> None:
269        """(internal)"""
270        self._has_ended = val
271
272    def expire(self) -> None:
273        """Begin the process of tearing down the activity.
274
275        (internal)
276        """
277        from ba._generated.enums import TimeType
278
279        # Create a real-timer that watches a weak-ref of this activity
280        # and reports any lingering references keeping it alive.
281        # We store the timer on the activity so as soon as the activity dies
282        # it gets cleaned up.
283        with _ba.Context('ui'):
284            ref = weakref.ref(self)
285            self._activity_death_check_timer = _ba.Timer(
286                5.0,
287                Call(self._check_activity_death, ref, [0]),
288                repeat=True,
289                timetype=TimeType.REAL)
290
291        # Run _expire in an empty context; nothing should be happening in
292        # there except deleting things which requires no context.
293        # (plus, _expire() runs in the destructor for un-run activities
294        # and we can't properly provide context in that situation anyway; might
295        # as well be consistent).
296        if not self._expired:
297            with _ba.Context('empty'):
298                self._expire()
299        else:
300            raise RuntimeError(f'destroy() called when'
301                               f' already expired for {self}')
302
303    def retain_actor(self, actor: ba.Actor) -> None:
304        """Add a strong-reference to a ba.Actor to this Activity.
305
306        The reference will be lazily released once ba.Actor.exists()
307        returns False for the Actor. The ba.Actor.autoretain() method
308        is a convenient way to access this same functionality.
309        """
310        if __debug__:
311            from ba._actor import Actor
312            assert isinstance(actor, Actor)
313        self._actor_refs.append(actor)
314
315    def add_actor_weak_ref(self, actor: ba.Actor) -> None:
316        """Add a weak-reference to a ba.Actor to the ba.Activity.
317
318        (called by the ba.Actor base class)
319        """
320        if __debug__:
321            from ba._actor import Actor
322            assert isinstance(actor, Actor)
323        self._actor_weak_refs.append(weakref.ref(actor))
324
325    @property
326    def session(self) -> ba.Session:
327        """The ba.Session this ba.Activity belongs go.
328
329        Raises a ba.SessionNotFoundError if the Session no longer exists.
330        """
331        session = self._session()
332        if session is None:
333            from ba._error import SessionNotFoundError
334            raise SessionNotFoundError()
335        return session
336
337    def on_player_join(self, player: PlayerType) -> None:
338        """Called when a new ba.Player has joined the Activity.
339
340        (including the initial set of Players)
341        """
342
343    def on_player_leave(self, player: PlayerType) -> None:
344        """Called when a ba.Player is leaving the Activity."""
345
346    def on_team_join(self, team: TeamType) -> None:
347        """Called when a new ba.Team joins the Activity.
348
349        (including the initial set of Teams)
350        """
351
352    def on_team_leave(self, team: TeamType) -> None:
353        """Called when a ba.Team leaves the Activity."""
354
355    def on_transition_in(self) -> None:
356        """Called when the Activity is first becoming visible.
357
358        Upon this call, the Activity should fade in backgrounds,
359        start playing music, etc. It does not yet have access to players
360        or teams, however. They remain owned by the previous Activity
361        up until ba.Activity.on_begin() is called.
362        """
363
364    def on_transition_out(self) -> None:
365        """Called when your activity begins transitioning out.
366
367        Note that this may happen at any time even if ba.Activity.end() has
368        not been called.
369        """
370
371    def on_begin(self) -> None:
372        """Called once the previous ba.Activity has finished transitioning out.
373
374        At this point the activity's initial players and teams are filled in
375        and it should begin its actual game logic.
376        """
377
378    def handlemessage(self, msg: Any) -> Any:
379        """General message handling; can be passed any message object."""
380        del msg  # Unused arg.
381        return UNHANDLED
382
383    def has_transitioned_in(self) -> bool:
384        """Return whether ba.Activity.on_transition_in()
385         has been called."""
386        return self._has_transitioned_in
387
388    def has_begun(self) -> bool:
389        """Return whether ba.Activity.on_begin() has been called."""
390        return self._has_begun
391
392    def has_ended(self) -> bool:
393        """Return whether the activity has commenced ending."""
394        return self._has_ended
395
396    def is_transitioning_out(self) -> bool:
397        """Return whether ba.Activity.on_transition_out() has been called."""
398        return self._transitioning_out
399
400    def transition_in(self, prev_globals: ba.Node | None) -> None:
401        """Called by Session to kick off transition-in.
402
403        (internal)
404        """
405        assert not self._has_transitioned_in
406        self._has_transitioned_in = True
407
408        # Set up the globals node based on our settings.
409        with _ba.Context(self):
410            glb = self._globalsnode = _ba.newnode('globals')
411
412            # Now that it's going to be front and center,
413            # set some global values based on what the activity wants.
414            glb.use_fixed_vr_overlay = self.use_fixed_vr_overlay
415            glb.allow_kick_idle_players = self.allow_kick_idle_players
416            if self.inherits_slow_motion and prev_globals is not None:
417                glb.slow_motion = prev_globals.slow_motion
418            else:
419                glb.slow_motion = self.slow_motion
420            if self.inherits_music and prev_globals is not None:
421                glb.music_continuous = True  # Prevent restarting same music.
422                glb.music = prev_globals.music
423                glb.music_count += 1
424            if self.inherits_vr_camera_offset and prev_globals is not None:
425                glb.vr_camera_offset = prev_globals.vr_camera_offset
426            if self.inherits_vr_overlay_center and prev_globals is not None:
427                glb.vr_overlay_center = prev_globals.vr_overlay_center
428                glb.vr_overlay_center_enabled = (
429                    prev_globals.vr_overlay_center_enabled)
430
431            # If they want to inherit tint from the previous self.
432            if self.inherits_tint and prev_globals is not None:
433                glb.tint = prev_globals.tint
434                glb.vignette_outer = prev_globals.vignette_outer
435                glb.vignette_inner = prev_globals.vignette_inner
436
437            # Start pruning our various things periodically.
438            self._prune_dead_actors()
439            self._prune_dead_actors_timer = _ba.Timer(5.17,
440                                                      self._prune_dead_actors,
441                                                      repeat=True)
442
443            _ba.timer(13.3, self._prune_delay_deletes, repeat=True)
444
445            # Also start our low-level scene running.
446            self._activity_data.start()
447
448            try:
449                self.on_transition_in()
450            except Exception:
451                print_exception(f'Error in on_transition_in for {self}.')
452
453        # Tell the C++ layer that this activity is the main one, so it uses
454        # settings from our globals, directs various events to us, etc.
455        self._activity_data.make_foreground()
456
457    def transition_out(self) -> None:
458        """Called by the Session to start us transitioning out."""
459        assert not self._transitioning_out
460        self._transitioning_out = True
461        with _ba.Context(self):
462            try:
463                self.on_transition_out()
464            except Exception:
465                print_exception(f'Error in on_transition_out for {self}.')
466
467    def begin(self, session: ba.Session) -> None:
468        """Begin the activity.
469
470        (internal)
471        """
472
473        assert not self._has_begun
474
475        # Inherit stats from the session.
476        self._stats = session.stats
477
478        # Add session's teams in.
479        for team in session.sessionteams:
480            self.add_team(team)
481
482        # Add session's players in.
483        for player in session.sessionplayers:
484            self.add_player(player)
485
486        self._has_begun = True
487
488        # Let the activity do its thing.
489        with _ba.Context(self):
490            # Note: do we want to catch errors here?
491            # Currently I believe we wind up canceling the
492            # activity launch; just wanna be sure that is intentional.
493            self.on_begin()
494
495    def end(self,
496            results: Any = None,
497            delay: float = 0.0,
498            force: bool = False) -> None:
499        """Commences Activity shutdown and delivers results to the ba.Session.
500
501        'delay' is the time delay before the Activity actually ends
502        (in seconds). Further calls to end() will be ignored up until
503        this time, unless 'force' is True, in which case the new results
504        will replace the old.
505        """
506
507        # Ask the session to end us.
508        self.session.end_activity(self, results, delay, force)
509
510    def create_player(self, sessionplayer: ba.SessionPlayer) -> PlayerType:
511        """Create the Player instance for this Activity.
512
513        Subclasses can override this if the activity's player class
514        requires a custom constructor; otherwise it will be called with
515        no args. Note that the player object should not be used at this
516        point as it is not yet fully wired up; wait for
517        ba.Activity.on_player_join() for that.
518        """
519        del sessionplayer  # Unused.
520        player = self._playertype()
521        return player
522
523    def create_team(self, sessionteam: ba.SessionTeam) -> TeamType:
524        """Create the Team instance for this Activity.
525
526        Subclasses can override this if the activity's team class
527        requires a custom constructor; otherwise it will be called with
528        no args. Note that the team object should not be used at this
529        point as it is not yet fully wired up; wait for on_team_join()
530        for that.
531        """
532        del sessionteam  # Unused.
533        team = self._teamtype()
534        return team
535
536    def add_player(self, sessionplayer: ba.SessionPlayer) -> None:
537        """(internal)"""
538        assert sessionplayer.sessionteam is not None
539        sessionplayer.resetinput()
540        sessionteam = sessionplayer.sessionteam
541        assert sessionplayer in sessionteam.players
542        team = sessionteam.activityteam
543        assert team is not None
544        sessionplayer.setactivity(self)
545        with _ba.Context(self):
546            sessionplayer.activityplayer = player = self.create_player(
547                sessionplayer)
548            player.postinit(sessionplayer)
549
550            assert player not in team.players
551            team.players.append(player)
552            assert player in team.players
553
554            assert player not in self.players
555            self.players.append(player)
556            assert player in self.players
557
558            try:
559                self.on_player_join(player)
560            except Exception:
561                print_exception(f'Error in on_player_join for {self}.')
562
563    def remove_player(self, sessionplayer: ba.SessionPlayer) -> None:
564        """Remove a player from the Activity while it is running.
565
566        (internal)
567        """
568        assert not self.expired
569
570        player: Any = sessionplayer.activityplayer
571        assert isinstance(player, self._playertype)
572        team: Any = sessionplayer.sessionteam.activityteam
573        assert isinstance(team, self._teamtype)
574
575        assert player in team.players
576        team.players.remove(player)
577        assert player not in team.players
578
579        assert player in self.players
580        self.players.remove(player)
581        assert player not in self.players
582
583        # This should allow our ba.Player instance to die.
584        # Complain if that doesn't happen.
585        # verify_object_death(player)
586
587        with _ba.Context(self):
588            try:
589                self.on_player_leave(player)
590            except Exception:
591                print_exception(f'Error in on_player_leave for {self}.')
592            try:
593                player.leave()
594            except Exception:
595                print_exception(f'Error on leave for {player} in {self}.')
596
597            self._reset_session_player_for_no_activity(sessionplayer)
598
599        # Add the player to a list to keep it around for a while. This is
600        # to discourage logic from firing on player object death, which
601        # may not happen until activity end if something is holding refs
602        # to it.
603        self._delay_delete_players.append(player)
604        self._players_that_left.append(weakref.ref(player))
605
606    def add_team(self, sessionteam: ba.SessionTeam) -> None:
607        """Add a team to the Activity
608
609        (internal)
610        """
611        assert not self.expired
612
613        with _ba.Context(self):
614            sessionteam.activityteam = team = self.create_team(sessionteam)
615            team.postinit(sessionteam)
616            self.teams.append(team)
617            try:
618                self.on_team_join(team)
619            except Exception:
620                print_exception(f'Error in on_team_join for {self}.')
621
622    def remove_team(self, sessionteam: ba.SessionTeam) -> None:
623        """Remove a team from a Running Activity
624
625        (internal)
626        """
627        assert not self.expired
628        assert sessionteam.activityteam is not None
629
630        team: Any = sessionteam.activityteam
631        assert isinstance(team, self._teamtype)
632
633        assert team in self.teams
634        self.teams.remove(team)
635        assert team not in self.teams
636
637        with _ba.Context(self):
638            # Make a decent attempt to persevere if user code breaks.
639            try:
640                self.on_team_leave(team)
641            except Exception:
642                print_exception(f'Error in on_team_leave for {self}.')
643            try:
644                team.leave()
645            except Exception:
646                print_exception(f'Error on leave for {team} in {self}.')
647
648            sessionteam.activityteam = None
649
650        # Add the team to a list to keep it around for a while. This is
651        # to discourage logic from firing on team object death, which
652        # may not happen until activity end if something is holding refs
653        # to it.
654        self._delay_delete_teams.append(team)
655        self._teams_that_left.append(weakref.ref(team))
656
657    def _reset_session_player_for_no_activity(
658            self, sessionplayer: ba.SessionPlayer) -> None:
659
660        # Let's be extra-defensive here: killing a node/input-call/etc
661        # could trigger user-code resulting in errors, but we would still
662        # like to complete the reset if possible.
663        try:
664            sessionplayer.setnode(None)
665        except Exception:
666            print_exception(
667                f'Error resetting SessionPlayer node on {sessionplayer}'
668                f' for {self}.')
669        try:
670            sessionplayer.resetinput()
671        except Exception:
672            print_exception(
673                f'Error resetting SessionPlayer input on {sessionplayer}'
674                f' for {self}.')
675
676        # These should never fail I think...
677        sessionplayer.setactivity(None)
678        sessionplayer.activityplayer = None
679
680    # noinspection PyUnresolvedReferences
681    def _setup_player_and_team_types(self) -> None:
682        """Pull player and team types from our typing.Generic params."""
683
684        # TODO: There are proper calls for pulling these in Python 3.8;
685        # should update this code when we adopt that.
686        # NOTE: If we get Any as PlayerType or TeamType (generally due
687        # to no generic params being passed) we automatically use the
688        # base class types, but also warn the user since this will mean
689        # less type safety for that class. (its better to pass the base
690        # player/team types explicitly vs. having them be Any)
691        if not TYPE_CHECKING:
692            self._playertype = type(self).__orig_bases__[-1].__args__[0]
693            if not isinstance(self._playertype, type):
694                self._playertype = Player
695                print(f'ERROR: {type(self)} was not passed a Player'
696                      f' type argument; please explicitly pass ba.Player'
697                      f' if you do not want to override it.')
698            self._teamtype = type(self).__orig_bases__[-1].__args__[1]
699            if not isinstance(self._teamtype, type):
700                self._teamtype = Team
701                print(f'ERROR: {type(self)} was not passed a Team'
702                      f' type argument; please explicitly pass ba.Team'
703                      f' if you do not want to override it.')
704        assert issubclass(self._playertype, Player)
705        assert issubclass(self._teamtype, Team)
706
707    @classmethod
708    def _check_activity_death(cls, activity_ref: weakref.ref[Activity],
709                              counter: list[int]) -> None:
710        """Sanity check to make sure an Activity was destroyed properly.
711
712        Receives a weakref to a ba.Activity which should have torn itself
713        down due to no longer being referenced anywhere. Will complain
714        and/or print debugging info if the Activity still exists.
715        """
716        try:
717            import gc
718            import types
719            activity = activity_ref()
720            print('ERROR: Activity is not dying when expected:', activity,
721                  '(warning ' + str(counter[0] + 1) + ')')
722            print('This means something is still strong-referencing it.')
723            counter[0] += 1
724
725            # FIXME: Running the code below shows us references but winds up
726            #  keeping the object alive; need to figure out why.
727            #  For now we just print refs if the count gets to 3, and then we
728            #  kill the app at 4 so it doesn't matter anyway.
729            if counter[0] == 3:
730                print('Activity references for', activity, ':')
731                refs = list(gc.get_referrers(activity))
732                i = 1
733                for ref in refs:
734                    if isinstance(ref, types.FrameType):
735                        continue
736                    print('  reference', i, ':', ref)
737                    i += 1
738            if counter[0] == 4:
739                print('Killing app due to stuck activity... :-(')
740                _ba.quit()
741
742        except Exception:
743            print_exception('Error on _check_activity_death/')
744
745    def _expire(self) -> None:
746        """Put the activity in a state where it can be garbage-collected.
747
748        This involves clearing anything that might be holding a reference
749        to it, etc.
750        """
751        assert not self._expired
752        self._expired = True
753
754        try:
755            self.on_expire()
756        except Exception:
757            print_exception(f'Error in Activity on_expire() for {self}.')
758
759        try:
760            self._customdata = None
761        except Exception:
762            print_exception(f'Error clearing customdata for {self}.')
763
764        # Don't want to be holding any delay-delete refs at this point.
765        self._prune_delay_deletes()
766
767        self._expire_actors()
768        self._expire_players()
769        self._expire_teams()
770
771        # This will kill all low level stuff: Timers, Nodes, etc., which
772        # should clear up any remaining refs to our Activity and allow us
773        # to die peacefully.
774        try:
775            self._activity_data.expire()
776        except Exception:
777            print_exception(f'Error expiring _activity_data for {self}.')
778
779    def _expire_actors(self) -> None:
780        # Expire all Actors.
781        for actor_ref in self._actor_weak_refs:
782            actor = actor_ref()
783            if actor is not None:
784                verify_object_death(actor)
785                try:
786                    actor.on_expire()
787                except Exception:
788                    print_exception(f'Error in Actor.on_expire()'
789                                    f' for {actor_ref()}.')
790
791    def _expire_players(self) -> None:
792
793        # Issue warnings for any players that left the game but don't
794        # get freed soon.
795        for ex_player in (p() for p in self._players_that_left):
796            if ex_player is not None:
797                verify_object_death(ex_player)
798
799        for player in self.players:
800            # This should allow our ba.Player instance to be freed.
801            # Complain if that doesn't happen.
802            verify_object_death(player)
803
804            try:
805                player.expire()
806            except Exception:
807                print_exception(f'Error expiring {player}')
808
809            # Reset the SessionPlayer to a not-in-an-activity state.
810            try:
811                sessionplayer = player.sessionplayer
812                self._reset_session_player_for_no_activity(sessionplayer)
813            except SessionPlayerNotFoundError:
814                # Conceivably, someone could have held on to a Player object
815                # until now whos underlying SessionPlayer left long ago...
816                pass
817            except Exception:
818                print_exception(f'Error expiring {player}.')
819
820    def _expire_teams(self) -> None:
821
822        # Issue warnings for any teams that left the game but don't
823        # get freed soon.
824        for ex_team in (p() for p in self._teams_that_left):
825            if ex_team is not None:
826                verify_object_death(ex_team)
827
828        for team in self.teams:
829            # This should allow our ba.Team instance to die.
830            # Complain if that doesn't happen.
831            verify_object_death(team)
832
833            try:
834                team.expire()
835            except Exception:
836                print_exception(f'Error expiring {team}')
837
838            try:
839                sessionteam = team.sessionteam
840                sessionteam.activityteam = None
841            except SessionTeamNotFoundError:
842                # It is expected that Team objects may last longer than
843                # the SessionTeam they came from (game objects may hold
844                # team references past the point at which the underlying
845                # player/team has left the game)
846                pass
847            except Exception:
848                print_exception(f'Error expiring Team {team}.')
849
850    def _prune_delay_deletes(self) -> None:
851        self._delay_delete_players.clear()
852        self._delay_delete_teams.clear()
853
854        # Clear out any dead weak-refs.
855        self._teams_that_left = [
856            t for t in self._teams_that_left if t() is not None
857        ]
858        self._players_that_left = [
859            p for p in self._players_that_left if p() is not None
860        ]
861
862    def _prune_dead_actors(self) -> None:
863        self._last_prune_dead_actors_time = _ba.time()
864
865        # Prune our strong refs when the Actor's exists() call gives False
866        self._actor_refs = [a for a in self._actor_refs if a.exists()]
867
868        # Prune our weak refs once the Actor object has been freed.
869        self._actor_weak_refs = [
870            a for a in self._actor_weak_refs if a() is not None
871        ]

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)
124    def __init__(self, settings: dict):
125        """Creates an Activity in the current ba.Session.
126
127        The activity will not be actually run until ba.Session.setactivity
128        is called. 'settings' should be a dict of key/value pairs specific
129        to the activity.
130
131        Activities should preload as much of their media/etc as possible in
132        their constructor, but none of it should actually be used until they
133        are transitioned in.
134        """
135        super().__init__()
136
137        # Create our internal engine data.
138        self._activity_data = _ba.register_activity(self)
139
140        assert isinstance(settings, dict)
141        assert _ba.getactivity() is self
142
143        self._globalsnode: ba.Node | None = None
144
145        # Player/Team types should have been specified as type args;
146        # grab those.
147        self._playertype: type[PlayerType]
148        self._teamtype: type[TeamType]
149        self._setup_player_and_team_types()
150
151        # FIXME: Relocate or remove the need for this stuff.
152        self.paused_text: ba.Actor | None = None
153
154        self._session = weakref.ref(_ba.getsession())
155
156        # Preloaded data for actors, maps, etc; indexed by type.
157        self.preloads: dict[type, Any] = {}
158
159        # Hopefully can eventually kill this; activities should
160        # validate/store whatever settings they need at init time
161        # (in a more type-safe way).
162        self.settings_raw = settings
163
164        self._has_transitioned_in = False
165        self._has_begun = False
166        self._has_ended = False
167        self._activity_death_check_timer: ba.Timer | None = None
168        self._expired = False
169        self._delay_delete_players: list[PlayerType] = []
170        self._delay_delete_teams: list[TeamType] = []
171        self._players_that_left: list[weakref.ref[PlayerType]] = []
172        self._teams_that_left: list[weakref.ref[TeamType]] = []
173        self._transitioning_out = False
174
175        # A handy place to put most actors; this list is pruned of dead
176        # actors regularly and these actors are insta-killed as the activity
177        # is dying.
178        self._actor_refs: list[ba.Actor] = []
179        self._actor_weak_refs: list[weakref.ref[ba.Actor]] = []
180        self._last_prune_dead_actors_time = _ba.time()
181        self._prune_dead_actors_timer: ba.Timer | None = None
182
183        self.teams = []
184        self.players = []
185
186        self.lobby = None
187        self._stats: ba.Stats | None = None
188        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:
227    def on_expire(self) -> None:
228        """Called when your activity is being expired.
229
230        If your activity has created anything explicitly that may be retaining
231        a strong reference to the activity and preventing it from dying, you
232        should clear that out here. From this point on your activity's sole
233        purpose in life is to hit zero references and die so the next activity
234        can begin.
235        """

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:
303    def retain_actor(self, actor: ba.Actor) -> None:
304        """Add a strong-reference to a ba.Actor to this Activity.
305
306        The reference will be lazily released once ba.Actor.exists()
307        returns False for the Actor. The ba.Actor.autoretain() method
308        is a convenient way to access this same functionality.
309        """
310        if __debug__:
311            from ba._actor import Actor
312            assert isinstance(actor, Actor)
313        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:
315    def add_actor_weak_ref(self, actor: ba.Actor) -> None:
316        """Add a weak-reference to a ba.Actor to the ba.Activity.
317
318        (called by the ba.Actor base class)
319        """
320        if __debug__:
321            from ba._actor import Actor
322            assert isinstance(actor, Actor)
323        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:
337    def on_player_join(self, player: PlayerType) -> None:
338        """Called when a new ba.Player has joined the Activity.
339
340        (including the initial set of Players)
341        """

Called when a new ba.Player has joined the Activity.

(including the initial set of Players)

def on_player_leave(self, player: ~PlayerType) -> None:
343    def on_player_leave(self, player: PlayerType) -> None:
344        """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:
346    def on_team_join(self, team: TeamType) -> None:
347        """Called when a new ba.Team joins the Activity.
348
349        (including the initial set of Teams)
350        """

Called when a new ba.Team joins the Activity.

(including the initial set of Teams)

def on_team_leave(self, team: ~TeamType) -> None:
352    def on_team_leave(self, team: TeamType) -> None:
353        """Called when a ba.Team leaves the Activity."""

Called when a ba.Team leaves the Activity.

def on_transition_in(self) -> None:
355    def on_transition_in(self) -> None:
356        """Called when the Activity is first becoming visible.
357
358        Upon this call, the Activity should fade in backgrounds,
359        start playing music, etc. It does not yet have access to players
360        or teams, however. They remain owned by the previous Activity
361        up until ba.Activity.on_begin() is called.
362        """

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:
364    def on_transition_out(self) -> None:
365        """Called when your activity begins transitioning out.
366
367        Note that this may happen at any time even if ba.Activity.end() has
368        not been called.
369        """

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:
371    def on_begin(self) -> None:
372        """Called once the previous ba.Activity has finished transitioning out.
373
374        At this point the activity's initial players and teams are filled in
375        and it should begin its actual game logic.
376        """

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:
378    def handlemessage(self, msg: Any) -> Any:
379        """General message handling; can be passed any message object."""
380        del msg  # Unused arg.
381        return UNHANDLED

General message handling; can be passed any message object.

def has_transitioned_in(self) -> bool:
383    def has_transitioned_in(self) -> bool:
384        """Return whether ba.Activity.on_transition_in()
385         has been called."""
386        return self._has_transitioned_in

Return whether ba.Activity.on_transition_in() has been called.

def has_begun(self) -> bool:
388    def has_begun(self) -> bool:
389        """Return whether ba.Activity.on_begin() has been called."""
390        return self._has_begun

Return whether ba.Activity.on_begin() has been called.

def has_ended(self) -> bool:
392    def has_ended(self) -> bool:
393        """Return whether the activity has commenced ending."""
394        return self._has_ended

Return whether the activity has commenced ending.

def is_transitioning_out(self) -> bool:
396    def is_transitioning_out(self) -> bool:
397        """Return whether ba.Activity.on_transition_out() has been called."""
398        return self._transitioning_out

Return whether ba.Activity.on_transition_out() has been called.

def transition_out(self) -> None:
457    def transition_out(self) -> None:
458        """Called by the Session to start us transitioning out."""
459        assert not self._transitioning_out
460        self._transitioning_out = True
461        with _ba.Context(self):
462            try:
463                self.on_transition_out()
464            except Exception:
465                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:
495    def end(self,
496            results: Any = None,
497            delay: float = 0.0,
498            force: bool = False) -> None:
499        """Commences Activity shutdown and delivers results to the ba.Session.
500
501        'delay' is the time delay before the Activity actually ends
502        (in seconds). Further calls to end() will be ignored up until
503        this time, unless 'force' is True, in which case the new results
504        will replace the old.
505        """
506
507        # Ask the session to end us.
508        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:
510    def create_player(self, sessionplayer: ba.SessionPlayer) -> PlayerType:
511        """Create the Player instance for this Activity.
512
513        Subclasses can override this if the activity's player class
514        requires a custom constructor; otherwise it will be called with
515        no args. Note that the player object should not be used at this
516        point as it is not yet fully wired up; wait for
517        ba.Activity.on_player_join() for that.
518        """
519        del sessionplayer  # Unused.
520        player = self._playertype()
521        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:
523    def create_team(self, sessionteam: ba.SessionTeam) -> TeamType:
524        """Create the Team instance for this Activity.
525
526        Subclasses can override this if the activity's team class
527        requires a custom constructor; otherwise it will be called with
528        no args. Note that the team object should not be used at this
529        point as it is not yet fully wired up; wait for on_team_join()
530        for that.
531        """
532        del sessionteam  # Unused.
533        team = self._teamtype()
534        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):
101class ActivityNotFoundError(NotFoundError):
102    """Exception raised when an expected ba.Activity does not exist.
103
104    Category: **Exception Classes**
105    """

Exception raised when an expected ba.Activity does not exist.

Category: Exception Classes

Inherited Members
builtins.Exception
Exception
builtins.BaseException
with_traceback
args
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: TA) -> TA:
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: ~TA) -> ~TA:
 99    def autoretain(self: TA) -> TA:
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):
94class ActorNotFoundError(NotFoundError):
95    """Exception raised when an expected ba.Actor does not exist.
96
97    Category: **Exception Classes**
98    """

Exception raised when an expected ba.Actor does not exist.

Category: Exception Classes

Inherited Members
builtins.Exception
Exception
builtins.BaseException
with_traceback
args
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:
 47def animate(node: ba.Node,
 48            attr: str,
 49            keys: dict[float, float],
 50            loop: bool = False,
 51            offset: float = 0,
 52            timetype: ba.TimeType = TimeType.SIM,
 53            timeformat: ba.TimeFormat = TimeFormat.SECONDS,
 54            suppress_format_warning: bool = False) -> ba.Node:
 55    """Animate values on a target ba.Node.
 56
 57    Category: **Gameplay Functions**
 58
 59    Creates an 'animcurve' node with the provided values and time as an input,
 60    connect it to the provided attribute, and set it to die with the target.
 61    Key values are provided as time:value dictionary pairs.  Time values are
 62    relative to the current time. By default, times are specified in seconds,
 63    but timeformat can also be set to MILLISECONDS to recreate the old behavior
 64    (prior to ba 1.5) of taking milliseconds. Returns the animcurve node.
 65    """
 66    if timetype is TimeType.SIM:
 67        driver = 'time'
 68    else:
 69        raise Exception('FIXME; only SIM timetype is supported currently.')
 70    items = list(keys.items())
 71    items.sort()
 72
 73    # Temp sanity check while we transition from milliseconds to seconds
 74    # based time values.
 75    if __debug__:
 76        if not suppress_format_warning:
 77            for item in items:
 78                _ba.time_format_check(timeformat, item[0])
 79
 80    curve = _ba.newnode('animcurve',
 81                        owner=node,
 82                        name='Driving ' + str(node) + ' \'' + attr + '\'')
 83
 84    if timeformat is TimeFormat.SECONDS:
 85        mult = 1000
 86    elif timeformat is TimeFormat.MILLISECONDS:
 87        mult = 1
 88    else:
 89        raise ValueError(f'invalid timeformat value: {timeformat}')
 90
 91    curve.times = [int(mult * time) for time, val in items]
 92    curve.offset = _ba.time(timeformat=TimeFormat.MILLISECONDS) + int(
 93        mult * offset)
 94    curve.values = [val for time, val in items]
 95    curve.loop = loop
 96
 97    # If we're not looping, set a timer to kill this curve
 98    # after its done its job.
 99    # FIXME: Even if we are looping we should have a way to die once we
100    #  get disconnected.
101    if not loop:
102        # noinspection PyUnresolvedReferences
103        _ba.timer(int(mult * items[-1][0]) + 1000,
104                  curve.delete,
105                  timeformat=TimeFormat.MILLISECONDS)
106
107    # Do the connects last so all our attrs are in place when we push initial
108    # values through.
109
110    # We operate in either activities or sessions..
111    try:
112        globalsnode = _ba.getactivity().globalsnode
113    except ActivityNotFoundError:
114        globalsnode = _ba.getsession().sessionglobalsnode
115
116    globalsnode.connectattr(driver, curve, 'in')
117    curve.connectattr('out', node, attr)
118    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:
121def animate_array(node: ba.Node,
122                  attr: str,
123                  size: int,
124                  keys: dict[float, Sequence[float]],
125                  loop: bool = False,
126                  offset: float = 0,
127                  timetype: ba.TimeType = TimeType.SIM,
128                  timeformat: ba.TimeFormat = TimeFormat.SECONDS,
129                  suppress_format_warning: bool = False) -> None:
130    """Animate an array of values on a target ba.Node.
131
132    Category: **Gameplay Functions**
133
134    Like ba.animate, but operates on array attributes.
135    """
136    # pylint: disable=too-many-locals
137    combine = _ba.newnode('combine', owner=node, attrs={'size': size})
138    if timetype is TimeType.SIM:
139        driver = 'time'
140    else:
141        raise Exception('FIXME: Only SIM timetype is supported currently.')
142    items = list(keys.items())
143    items.sort()
144
145    # Temp sanity check while we transition from milliseconds to seconds
146    # based time values.
147    if __debug__:
148        if not suppress_format_warning:
149            for item in items:
150                # (PyCharm seems to think item is a float, not a tuple)
151                _ba.time_format_check(timeformat, item[0])
152
153    if timeformat is TimeFormat.SECONDS:
154        mult = 1000
155    elif timeformat is TimeFormat.MILLISECONDS:
156        mult = 1
157    else:
158        raise ValueError('invalid timeformat value: "' + str(timeformat) + '"')
159
160    # We operate in either activities or sessions..
161    try:
162        globalsnode = _ba.getactivity().globalsnode
163    except ActivityNotFoundError:
164        globalsnode = _ba.getsession().sessionglobalsnode
165
166    for i in range(size):
167        curve = _ba.newnode('animcurve',
168                            owner=node,
169                            name=('Driving ' + str(node) + ' \'' + attr +
170                                  '\' member ' + str(i)))
171        globalsnode.connectattr(driver, curve, 'in')
172        curve.times = [int(mult * time) for time, val in items]
173        curve.values = [val[i] for time, val in items]
174        curve.offset = _ba.time(timeformat=TimeFormat.MILLISECONDS) + int(
175            mult * offset)
176        curve.loop = loop
177        curve.connectattr('out', combine, 'input' + str(i))
178
179        # If we're not looping, set a timer to kill this
180        # curve after its done its job.
181        if not loop:
182            # (PyCharm seems to think item is a float, not a tuple)
183            # noinspection PyUnresolvedReferences
184            _ba.timer(int(mult * items[-1][0]) + 1000,
185                      curve.delete,
186                      timeformat=TimeFormat.MILLISECONDS)
187    combine.connectattr('output', node, attr)
188
189    # If we're not looping, set a timer to kill the combine once
190    # the job is done.
191    # FIXME: Even if we are looping we should have a way to die
192    #  once we get disconnected.
193    if not loop:
194        # (PyCharm seems to think item is a float, not a tuple)
195        # noinspection PyUnresolvedReferences
196        _ba.timer(int(mult * items[-1][0]) + 1000,
197                  combine.delete,
198                  timeformat=TimeFormat.MILLISECONDS)

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:
 36class App:
 37    """A class for high level app functionality and state.
 38
 39    Category: **App Classes**
 40
 41    Use ba.app to access the single shared instance of this class.
 42
 43    Note that properties not documented here should be considered internal
 44    and subject to change without warning.
 45    """
 46
 47    # pylint: disable=too-many-public-methods
 48
 49    # Implementations for these will be filled in by internal libs.
 50    accounts_v2: AccountV2Subsystem
 51    cloud: CloudSubsystem
 52
 53    class State(Enum):
 54        """High level state the app can be in."""
 55
 56        # Python-level systems being inited but should not interact.
 57        LAUNCHING = 0
 58
 59        # Initial account logins, workspace & asset downloads, etc.
 60        LOADING = 1
 61
 62        # Normal running state.
 63        RUNNING = 2
 64
 65        # App is backgrounded or otherwise suspended.
 66        PAUSED = 3
 67
 68        # App is shutting down.
 69        SHUTTING_DOWN = 4
 70
 71    @property
 72    def aioloop(self) -> asyncio.AbstractEventLoop:
 73        """The Logic Thread's Asyncio Event Loop.
 74
 75        This allow async tasks to be run in the logic thread.
 76        Note that, at this time, the asyncio loop is encapsulated
 77        and explicitly stepped by the engine's logic thread loop and
 78        thus things like asyncio.get_running_loop() will not return this
 79        loop from most places in the logic thread; only from within a
 80        task explicitly created in this loop.
 81        """
 82        assert self._aioloop is not None
 83        return self._aioloop
 84
 85    @property
 86    def build_number(self) -> int:
 87        """Integer build number.
 88
 89        This value increases by at least 1 with each release of the game.
 90        It is independent of the human readable ba.App.version string.
 91        """
 92        assert isinstance(self._env['build_number'], int)
 93        return self._env['build_number']
 94
 95    @property
 96    def config_file_path(self) -> str:
 97        """Where the game's config file is stored on disk."""
 98        assert isinstance(self._env['config_file_path'], str)
 99        return self._env['config_file_path']
100
101    @property
102    def user_agent_string(self) -> str:
103        """String containing various bits of info about OS/device/etc."""
104        assert isinstance(self._env['user_agent_string'], str)
105        return self._env['user_agent_string']
106
107    @property
108    def version(self) -> str:
109        """Human-readable version string; something like '1.3.24'.
110
111        This should not be interpreted as a number; it may contain
112        string elements such as 'alpha', 'beta', 'test', etc.
113        If a numeric version is needed, use 'ba.App.build_number'.
114        """
115        assert isinstance(self._env['version'], str)
116        return self._env['version']
117
118    @property
119    def debug_build(self) -> bool:
120        """Whether the game was compiled in debug mode.
121
122        Debug builds generally run substantially slower than non-debug
123        builds due to compiler optimizations being disabled and extra
124        checks being run.
125        """
126        assert isinstance(self._env['debug_build'], bool)
127        return self._env['debug_build']
128
129    @property
130    def test_build(self) -> bool:
131        """Whether the game was compiled in test mode.
132
133        Test mode enables extra checks and features that are useful for
134        release testing but which do not slow the game down significantly.
135        """
136        assert isinstance(self._env['test_build'], bool)
137        return self._env['test_build']
138
139    @property
140    def python_directory_user(self) -> str:
141        """Path where the app looks for custom user scripts."""
142        assert isinstance(self._env['python_directory_user'], str)
143        return self._env['python_directory_user']
144
145    @property
146    def python_directory_app(self) -> str:
147        """Path where the app looks for its bundled scripts."""
148        assert isinstance(self._env['python_directory_app'], str)
149        return self._env['python_directory_app']
150
151    @property
152    def python_directory_app_site(self) -> str:
153        """Path containing pip packages bundled with the app."""
154        assert isinstance(self._env['python_directory_app_site'], str)
155        return self._env['python_directory_app_site']
156
157    @property
158    def config(self) -> ba.AppConfig:
159        """The ba.AppConfig instance representing the app's config state."""
160        assert self._config is not None
161        return self._config
162
163    @property
164    def platform(self) -> str:
165        """Name of the current platform.
166
167        Examples are: 'mac', 'windows', android'.
168        """
169        assert isinstance(self._env['platform'], str)
170        return self._env['platform']
171
172    @property
173    def subplatform(self) -> str:
174        """String for subplatform.
175
176        Can be empty. For the 'android' platform, subplatform may
177        be 'google', 'amazon', etc.
178        """
179        assert isinstance(self._env['subplatform'], str)
180        return self._env['subplatform']
181
182    @property
183    def api_version(self) -> int:
184        """The game's api version.
185
186        Only Python modules and packages associated with the current API
187        version number will be detected by the game (see the ba_meta tag).
188        This value will change whenever backward-incompatible changes are
189        introduced to game APIs. When that happens, scripts should be updated
190        accordingly and set to target the new API version number.
191        """
192        from ba._meta import CURRENT_API_VERSION
193        return CURRENT_API_VERSION
194
195    @property
196    def on_tv(self) -> bool:
197        """Whether the game is currently running on a TV."""
198        assert isinstance(self._env['on_tv'], bool)
199        return self._env['on_tv']
200
201    @property
202    def vr_mode(self) -> bool:
203        """Whether the game is currently running in VR."""
204        assert isinstance(self._env['vr_mode'], bool)
205        return self._env['vr_mode']
206
207    @property
208    def ui_bounds(self) -> tuple[float, float, float, float]:
209        """Bounds of the 'safe' screen area in ui space.
210
211        This tuple contains: (x-min, x-max, y-min, y-max)
212        """
213        return _ba.uibounds()
214
215    def __init__(self) -> None:
216        """(internal)
217
218        Do not instantiate this class; use ba.app to access
219        the single shared instance.
220        """
221        # pylint: disable=too-many-statements
222
223        self.state = self.State.LAUNCHING
224
225        self._launch_completed = False
226        self._initial_login_completed = False
227        self._meta_scan_completed = False
228        self._called_on_app_running = False
229        self._app_paused = False
230
231        # Config.
232        self.config_file_healthy = False
233
234        # This is incremented any time the app is backgrounded/foregrounded;
235        # can be a simple way to determine if network data should be
236        # refreshed/etc.
237        self.fg_state = 0
238
239        self._aioloop: asyncio.AbstractEventLoop | None = None
240
241        self._env = _ba.env()
242        self.protocol_version: int = self._env['protocol_version']
243        assert isinstance(self.protocol_version, int)
244        self.toolbar_test: bool = self._env['toolbar_test']
245        assert isinstance(self.toolbar_test, bool)
246        self.demo_mode: bool = self._env['demo_mode']
247        assert isinstance(self.demo_mode, bool)
248        self.arcade_mode: bool = self._env['arcade_mode']
249        assert isinstance(self.arcade_mode, bool)
250        self.headless_mode: bool = self._env['headless_mode']
251        assert isinstance(self.headless_mode, bool)
252        self.iircade_mode: bool = self._env['iircade_mode']
253        assert isinstance(self.iircade_mode, bool)
254        self.allow_ticket_purchases: bool = not self.iircade_mode
255
256        # Default executor which can be used for misc background processing.
257        # It should also be passed to any asyncio loops we create so that
258        # everything shares the same single set of threads.
259        self.threadpool = ThreadPoolExecutor(thread_name_prefix='baworker')
260
261        # Misc.
262        self.tips: list[str] = []
263        self.stress_test_reset_timer: ba.Timer | None = None
264        self.did_weak_call_warning = False
265
266        self.log_have_new = False
267        self.log_upload_timer_started = False
268        self._config: ba.AppConfig | None = None
269        self.printed_live_object_warning = False
270
271        # We include this extra hash with shared input-mapping names so
272        # that we don't share mappings between differently-configured
273        # systems. For instance, different android devices may give different
274        # key values for the same controller type so we keep their mappings
275        # distinct.
276        self.input_map_hash: str | None = None
277
278        # Co-op Campaigns.
279        self.campaigns: dict[str, ba.Campaign] = {}
280        self.custom_coop_practice_games: list[str] = []
281
282        # Server Mode.
283        self.server: ba.ServerController | None = None
284
285        self.meta = MetadataSubsystem()
286        self.accounts_v1 = AccountV1Subsystem()
287        self.plugins = PluginSubsystem()
288        self.music = MusicSubsystem()
289        self.lang = LanguageSubsystem()
290        self.ach = AchievementSubsystem()
291        self.ui = UISubsystem()
292        self.ads = AdsSubsystem()
293        self.net = NetworkSubsystem()
294        self.workspaces = WorkspaceSubsystem()
295
296        # Lobby.
297        self.lobby_random_profile_index: int = 1
298        self.lobby_random_char_index_offset = random.randrange(1000)
299        self.lobby_account_profile_device_id: int | None = None
300
301        # Main Menu.
302        self.main_menu_did_initial_transition = False
303        self.main_menu_last_news_fetch_time: float | None = None
304
305        # Spaz.
306        self.spaz_appearances: dict[str, spazappearance.Appearance] = {}
307        self.last_spaz_turbo_warn_time: float = -99999.0
308
309        # Maps.
310        self.maps: dict[str, type[ba.Map]] = {}
311
312        # Gameplay.
313        self.teams_series_length = 7
314        self.ffa_series_length = 24
315        self.coop_session_args: dict = {}
316
317        self.value_test_defaults: dict = {}
318        self.first_main_menu = True  # FIXME: Move to mainmenu class.
319        self.did_menu_intro = False  # FIXME: Move to mainmenu class.
320        self.main_menu_window_refresh_check_count = 0  # FIXME: Mv to mainmenu.
321        self.main_menu_resume_callbacks: list = []  # Can probably go away.
322        self.special_offer: dict | None = None
323        self.ping_thread_count = 0
324        self.invite_confirm_windows: list[Any] = []  # FIXME: Don't use Any.
325        self.store_layout: dict[str, list[dict[str, Any]]] | None = None
326        self.store_items: dict[str, dict] | None = None
327        self.pro_sale_start_time: int | None = None
328        self.pro_sale_start_val: int | None = None
329
330        self.delegate: ba.AppDelegate | None = None
331        self._asyncio_timer: ba.Timer | None = None
332
333    def on_app_launch(self) -> None:
334        """Runs after the app finishes low level bootstrapping.
335
336        (internal)"""
337        # pylint: disable=cyclic-import
338        # pylint: disable=too-many-locals
339        from ba import _asyncio
340        from ba import _apputils
341        from ba import _appconfig
342        from ba import _map
343        from ba import _campaign
344        from bastd import appdelegate
345        from bastd import maps as stdmaps
346        from bastd.actor import spazappearance
347        from ba._generated.enums import TimeType
348
349        assert _ba.in_game_thread()
350
351        self._aioloop = _asyncio.setup_asyncio()
352
353        cfg = self.config
354
355        self.delegate = appdelegate.AppDelegate()
356
357        self.ui.on_app_launch()
358
359        spazappearance.register_appearances()
360        _campaign.init_campaigns()
361
362        # FIXME: This should not be hard-coded.
363        for maptype in [
364                stdmaps.HockeyStadium, stdmaps.FootballStadium,
365                stdmaps.Bridgit, stdmaps.BigG, stdmaps.Roundabout,
366                stdmaps.MonkeyFace, stdmaps.ZigZag, stdmaps.ThePad,
367                stdmaps.DoomShroom, stdmaps.LakeFrigid, stdmaps.TipTop,
368                stdmaps.CragCastle, stdmaps.TowerD, stdmaps.HappyThoughts,
369                stdmaps.StepRightUp, stdmaps.Courtyard, stdmaps.Rampage
370        ]:
371            _map.register_map(maptype)
372
373        # Non-test, non-debug builds should generally be blessed; warn if not.
374        # (so I don't accidentally release a build that can't play tourneys)
375        if (not self.debug_build and not self.test_build
376                and not _ba.is_blessed()):
377            _ba.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0))
378
379        # If there's a leftover log file, attempt to upload it to the
380        # master-server and/or get rid of it.
381        _apputils.handle_leftover_log_file()
382
383        # Only do this stuff if our config file is healthy so we don't
384        # overwrite a broken one or whatnot and wipe out data.
385        if not self.config_file_healthy:
386            if self.platform in ('mac', 'linux', 'windows'):
387                from bastd.ui import configerror
388                configerror.ConfigErrorWindow()
389                return
390
391            # For now on other systems we just overwrite the bum config.
392            # At this point settings are already set; lets just commit them
393            # to disk.
394            _appconfig.commit_app_config(force=True)
395
396        self.music.on_app_launch()
397
398        launch_count = cfg.get('launchCount', 0)
399        launch_count += 1
400
401        # So we know how many times we've run the game at various
402        # version milestones.
403        for key in ('lc14173', 'lc14292'):
404            cfg.setdefault(key, launch_count)
405
406        cfg['launchCount'] = launch_count
407        cfg.commit()
408
409        # Run a test in a few seconds to see if we should pop up an existing
410        # pending special offer.
411        def check_special_offer() -> None:
412            from bastd.ui.specialoffer import show_offer
413            config = self.config
414            if ('pendingSpecialOffer' in config and _ba.get_public_login_id()
415                    == config['pendingSpecialOffer']['a']):
416                self.special_offer = config['pendingSpecialOffer']['o']
417                show_offer()
418
419        if not self.headless_mode:
420            _ba.timer(3.0, check_special_offer, timetype=TimeType.REAL)
421
422        # Get meta-system scanning built-in stuff in the bg.
423        self.meta.start_scan(scan_complete_cb=self.on_meta_scan_complete)
424
425        self.accounts_v2.on_app_launch()
426        self.accounts_v1.on_app_launch()
427
428        # See note below in on_app_pause.
429        if self.state != self.State.LAUNCHING:
430            logging.error('on_app_launch found state %s; expected LAUNCHING.',
431                          self.state)
432
433        self._launch_completed = True
434        self._update_state()
435
436    def on_app_running(self) -> None:
437        """Called when initially entering the running state."""
438
439        self.plugins.on_app_running()
440
441        # from ba._dependency import test_depset
442        # test_depset()
443
444    def on_meta_scan_complete(self) -> None:
445        """Called by meta-scan when it is done doing its thing."""
446        assert _ba.in_game_thread()
447        self.plugins.on_meta_scan_complete()
448
449        assert not self._meta_scan_completed
450        self._meta_scan_completed = True
451        self._update_state()
452
453    def _update_state(self) -> None:
454        assert _ba.in_game_thread()
455
456        if self._app_paused:
457            self.state = self.State.PAUSED
458        else:
459            if self._initial_login_completed and self._meta_scan_completed:
460                self.state = self.State.RUNNING
461                if not self._called_on_app_running:
462                    self._called_on_app_running = True
463                    self.on_app_running()
464            elif self._launch_completed:
465                self.state = self.State.LOADING
466            else:
467                self.state = self.State.LAUNCHING
468
469    def on_app_pause(self) -> None:
470        """Called when the app goes to a suspended state."""
471
472        self._app_paused = True
473        self._update_state()
474        self.plugins.on_app_pause()
475
476    def on_app_resume(self) -> None:
477        """Run when the app resumes from a suspended state."""
478
479        self._app_paused = False
480        self._update_state()
481        self.fg_state += 1
482        self.accounts_v1.on_app_resume()
483        self.music.on_app_resume()
484        self.plugins.on_app_resume()
485
486    def on_app_shutdown(self) -> None:
487        """(internal)"""
488        self.state = self.State.SHUTTING_DOWN
489        self.music.on_app_shutdown()
490        self.plugins.on_app_shutdown()
491
492    def read_config(self) -> None:
493        """(internal)"""
494        from ba._appconfig import read_config
495        self._config, self.config_file_healthy = read_config()
496
497    def pause(self) -> None:
498        """Pause the game due to a user request or menu popping up.
499
500        If there's a foreground host-activity that says it's pausable, tell it
501        to pause ..we now no longer pause if there are connected clients.
502        """
503        activity: ba.Activity | None = _ba.get_foreground_host_activity()
504        if (activity is not None and activity.allow_pausing
505                and not _ba.have_connected_clients()):
506            from ba._language import Lstr
507            from ba._nodeactor import NodeActor
508
509            # FIXME: Shouldn't be touching scene stuff here;
510            #  should just pass the request on to the host-session.
511            with _ba.Context(activity):
512                globs = activity.globalsnode
513                if not globs.paused:
514                    _ba.playsound(_ba.getsound('refWhistle'))
515                    globs.paused = True
516
517                # FIXME: This should not be an attr on Actor.
518                activity.paused_text = NodeActor(
519                    _ba.newnode('text',
520                                attrs={
521                                    'text': Lstr(resource='pausedByHostText'),
522                                    'client_only': True,
523                                    'flatness': 1.0,
524                                    'h_align': 'center'
525                                }))
526
527    def resume(self) -> None:
528        """Resume the game due to a user request or menu closing.
529
530        If there's a foreground host-activity that's currently paused, tell it
531        to resume.
532        """
533
534        # FIXME: Shouldn't be touching scene stuff here;
535        #  should just pass the request on to the host-session.
536        activity = _ba.get_foreground_host_activity()
537        if activity is not None:
538            with _ba.Context(activity):
539                globs = activity.globalsnode
540                if globs.paused:
541                    _ba.playsound(_ba.getsound('refWhistle'))
542                    globs.paused = False
543
544                    # FIXME: This should not be an actor attr.
545                    activity.paused_text = None
546
547    def add_coop_practice_level(self, level: Level) -> None:
548        """Adds an individual level to the 'practice' section in Co-op."""
549
550        # Assign this level to our catch-all campaign.
551        self.campaigns['Challenges'].addlevel(level)
552
553        # Make note to add it to our challenges UI.
554        self.custom_coop_practice_games.append(f'Challenges:{level.name}')
555
556    def return_to_main_menu_session_gracefully(self,
557                                               reset_ui: bool = True) -> None:
558        """Attempt to cleanly get back to the main menu."""
559        # pylint: disable=cyclic-import
560        from ba import _benchmark
561        from ba._general import Call
562        from bastd.mainmenu import MainMenuSession
563        if reset_ui:
564            _ba.app.ui.clear_main_menu_window()
565
566        if isinstance(_ba.get_foreground_host_session(), MainMenuSession):
567            # It may be possible we're on the main menu but the screen is faded
568            # so fade back in.
569            _ba.fade_screen(True)
570            return
571
572        _benchmark.stop_stress_test()  # Stop stress-test if in progress.
573
574        # If we're in a host-session, tell them to end.
575        # This lets them tear themselves down gracefully.
576        host_session: ba.Session | None = _ba.get_foreground_host_session()
577        if host_session is not None:
578
579            # Kick off a little transaction so we'll hopefully have all the
580            # latest account state when we get back to the menu.
581            _ba.add_transaction({
582                'type': 'END_SESSION',
583                'sType': str(type(host_session))
584            })
585            _ba.run_transactions()
586
587            host_session.end()
588
589        # Otherwise just force the issue.
590        else:
591            _ba.pushcall(Call(_ba.new_host_session, MainMenuSession))
592
593    def add_main_menu_close_callback(self, call: Callable[[], Any]) -> None:
594        """(internal)"""
595
596        # If there's no main menu up, just call immediately.
597        if not self.ui.has_main_menu_window():
598            with _ba.Context('ui'):
599                call()
600        else:
601            self.main_menu_resume_callbacks.append(call)
602
603    def launch_coop_game(self,
604                         game: str,
605                         force: bool = False,
606                         args: dict | None = None) -> bool:
607        """High level way to launch a local co-op session."""
608        # pylint: disable=cyclic-import
609        from ba._campaign import getcampaign
610        from bastd.ui.coop.level import CoopLevelLockedWindow
611        if args is None:
612            args = {}
613        if game == '':
614            raise ValueError('empty game name')
615        campaignname, levelname = game.split(':')
616        campaign = getcampaign(campaignname)
617
618        # If this campaign is sequential, make sure we've completed the
619        # one before this.
620        if campaign.sequential and not force:
621            for level in campaign.levels:
622                if level.name == levelname:
623                    break
624                if not level.complete:
625                    CoopLevelLockedWindow(
626                        campaign.getlevel(levelname).displayname,
627                        campaign.getlevel(level.name).displayname)
628                    return False
629
630        # Ok, we're good to go.
631        self.coop_session_args = {
632            'campaign': campaignname,
633            'level': levelname,
634        }
635        for arg_name, arg_val in list(args.items()):
636            self.coop_session_args[arg_name] = arg_val
637
638        def _fade_end() -> None:
639            from ba import _coopsession
640            try:
641                _ba.new_host_session(_coopsession.CoopSession)
642            except Exception:
643                from ba import _error
644                _error.print_exception()
645                from bastd.mainmenu import MainMenuSession
646                _ba.new_host_session(MainMenuSession)
647
648        _ba.fade_screen(False, endcall=_fade_end)
649        return True
650
651    def handle_deep_link(self, url: str) -> None:
652        """Handle a deep link URL."""
653        from ba._language import Lstr
654        appname = _ba.appname()
655        if url.startswith(f'{appname}://code/'):
656            code = url.replace(f'{appname}://code/', '')
657            self.accounts_v1.add_pending_promo_code(code)
658        else:
659            _ba.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0))
660            _ba.playsound(_ba.getsound('error'))
661
662    def on_initial_login_completed(self) -> None:
663        """Callback to be run after initial login process (or lack thereof).
664
665        This period includes things such as syncing account workspaces
666        or other data so it may take a substantial amount of time.
667        This should also run after a short amount of time if no login
668        has occurred.
669        """
670        # Tell meta it can start scanning extra stuff that just showed up
671        # (account workspaces).
672        self.meta.start_extra_scan()
673
674        self._initial_login_completed = True
675        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.

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 game was compiled in debug mode.

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

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_running(self) -> None:
436    def on_app_running(self) -> None:
437        """Called when initially entering the running state."""
438
439        self.plugins.on_app_running()
440
441        # from ba._dependency import test_depset
442        # test_depset()

Called when initially entering the running state.

def on_meta_scan_complete(self) -> None:
444    def on_meta_scan_complete(self) -> None:
445        """Called by meta-scan when it is done doing its thing."""
446        assert _ba.in_game_thread()
447        self.plugins.on_meta_scan_complete()
448
449        assert not self._meta_scan_completed
450        self._meta_scan_completed = True
451        self._update_state()

Called by meta-scan when it is done doing its thing.

def on_app_pause(self) -> None:
469    def on_app_pause(self) -> None:
470        """Called when the app goes to a suspended state."""
471
472        self._app_paused = True
473        self._update_state()
474        self.plugins.on_app_pause()

Called when the app goes to a suspended state.

def on_app_resume(self) -> None:
476    def on_app_resume(self) -> None:
477        """Run when the app resumes from a suspended state."""
478
479        self._app_paused = False
480        self._update_state()
481        self.fg_state += 1
482        self.accounts_v1.on_app_resume()
483        self.music.on_app_resume()
484        self.plugins.on_app_resume()

Run when the app resumes from a suspended state.

def pause(self) -> None:
497    def pause(self) -> None:
498        """Pause the game due to a user request or menu popping up.
499
500        If there's a foreground host-activity that says it's pausable, tell it
501        to pause ..we now no longer pause if there are connected clients.
502        """
503        activity: ba.Activity | None = _ba.get_foreground_host_activity()
504        if (activity is not None and activity.allow_pausing
505                and not _ba.have_connected_clients()):
506            from ba._language import Lstr
507            from ba._nodeactor import NodeActor
508
509            # FIXME: Shouldn't be touching scene stuff here;
510            #  should just pass the request on to the host-session.
511            with _ba.Context(activity):
512                globs = activity.globalsnode
513                if not globs.paused:
514                    _ba.playsound(_ba.getsound('refWhistle'))
515                    globs.paused = True
516
517                # FIXME: This should not be an attr on Actor.
518                activity.paused_text = NodeActor(
519                    _ba.newnode('text',
520                                attrs={
521                                    'text': Lstr(resource='pausedByHostText'),
522                                    'client_only': True,
523                                    'flatness': 1.0,
524                                    'h_align': 'center'
525                                }))

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:
527    def resume(self) -> None:
528        """Resume the game due to a user request or menu closing.
529
530        If there's a foreground host-activity that's currently paused, tell it
531        to resume.
532        """
533
534        # FIXME: Shouldn't be touching scene stuff here;
535        #  should just pass the request on to the host-session.
536        activity = _ba.get_foreground_host_activity()
537        if activity is not None:
538            with _ba.Context(activity):
539                globs = activity.globalsnode
540                if globs.paused:
541                    _ba.playsound(_ba.getsound('refWhistle'))
542                    globs.paused = False
543
544                    # FIXME: This should not be an actor attr.
545                    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:
547    def add_coop_practice_level(self, level: Level) -> None:
548        """Adds an individual level to the 'practice' section in Co-op."""
549
550        # Assign this level to our catch-all campaign.
551        self.campaigns['Challenges'].addlevel(level)
552
553        # Make note to add it to our challenges UI.
554        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:
556    def return_to_main_menu_session_gracefully(self,
557                                               reset_ui: bool = True) -> None:
558        """Attempt to cleanly get back to the main menu."""
559        # pylint: disable=cyclic-import
560        from ba import _benchmark
561        from ba._general import Call
562        from bastd.mainmenu import MainMenuSession
563        if reset_ui:
564            _ba.app.ui.clear_main_menu_window()
565
566        if isinstance(_ba.get_foreground_host_session(), MainMenuSession):
567            # It may be possible we're on the main menu but the screen is faded
568            # so fade back in.
569            _ba.fade_screen(True)
570            return
571
572        _benchmark.stop_stress_test()  # Stop stress-test if in progress.
573
574        # If we're in a host-session, tell them to end.
575        # This lets them tear themselves down gracefully.
576        host_session: ba.Session | None = _ba.get_foreground_host_session()
577        if host_session is not None:
578
579            # Kick off a little transaction so we'll hopefully have all the
580            # latest account state when we get back to the menu.
581            _ba.add_transaction({
582                'type': 'END_SESSION',
583                'sType': str(type(host_session))
584            })
585            _ba.run_transactions()
586
587            host_session.end()
588
589        # Otherwise just force the issue.
590        else:
591            _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:
603    def launch_coop_game(self,
604                         game: str,
605                         force: bool = False,
606                         args: dict | None = None) -> bool:
607        """High level way to launch a local co-op session."""
608        # pylint: disable=cyclic-import
609        from ba._campaign import getcampaign
610        from bastd.ui.coop.level import CoopLevelLockedWindow
611        if args is None:
612            args = {}
613        if game == '':
614            raise ValueError('empty game name')
615        campaignname, levelname = game.split(':')
616        campaign = getcampaign(campaignname)
617
618        # If this campaign is sequential, make sure we've completed the
619        # one before this.
620        if campaign.sequential and not force:
621            for level in campaign.levels:
622                if level.name == levelname:
623                    break
624                if not level.complete:
625                    CoopLevelLockedWindow(
626                        campaign.getlevel(levelname).displayname,
627                        campaign.getlevel(level.name).displayname)
628                    return False
629
630        # Ok, we're good to go.
631        self.coop_session_args = {
632            'campaign': campaignname,
633            'level': levelname,
634        }
635        for arg_name, arg_val in list(args.items()):
636            self.coop_session_args[arg_name] = arg_val
637
638        def _fade_end() -> None:
639            from ba import _coopsession
640            try:
641                _ba.new_host_session(_coopsession.CoopSession)
642            except Exception:
643                from ba import _error
644                _error.print_exception()
645                from bastd.mainmenu import MainMenuSession
646                _ba.new_host_session(MainMenuSession)
647
648        _ba.fade_screen(False, endcall=_fade_end)
649        return True

High level way to launch a local co-op session.

def on_initial_login_completed(self) -> None:
662    def on_initial_login_completed(self) -> None:
663        """Callback to be run after initial login process (or lack thereof).
664
665        This period includes things such as syncing account workspaces
666        or other data so it may take a substantial amount of time.
667        This should also run after a short amount of time if no login
668        has occurred.
669        """
670        # Tell meta it can start scanning extra stuff that just showed up
671        # (account workspaces).
672        self.meta.start_extra_scan()
673
674        self._initial_login_completed = True
675        self._update_state()

Callback to be run after initial login process (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):
53    class State(Enum):
54        """High level state the app can be in."""
55
56        # Python-level systems being inited but should not interact.
57        LAUNCHING = 0
58
59        # Initial account logins, workspace & asset downloads, etc.
60        LOADING = 1
61
62        # Normal running state.
63        RUNNING = 2
64
65        # App is backgrounded or otherwise suspended.
66        PAUSED = 3
67
68        # App is shutting down.
69        SHUTTING_DOWN = 4

High level state the app can be in.

LAUNCHING = <State.LAUNCHING: 0>
LOADING = <State.LOADING: 1>
RUNNING = <State.RUNNING: 2>
PAUSED = <State.PAUSED: 3>
SHUTTING_DOWN = <State.SHUTTING_DOWN: 4>
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, gameclass: type[ba.GameActivity],
22            sessiontype: type[ba.Session], settings: dict | None,
23            completion_call: Callable[[dict | None], None]) -> None:
24        """Launch a UI to configure the given game config.
25
26        It should manipulate the contents of config and call completion_call
27        when done.
28        """
29        del gameclass, sessiontype, settings, completion_call  # Unused.
30        from ba import _error
31        _error.print_error(
32            "create_default_game_settings_ui needs to be overridden")

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, gameclass: type[ba.GameActivity],
22            sessiontype: type[ba.Session], settings: dict | None,
23            completion_call: Callable[[dict | None], None]) -> None:
24        """Launch a UI to configure the given game config.
25
26        It should manipulate the contents of config and call completion_call
27        when done.
28        """
29        del gameclass, sessiontype, settings, completion_call  # Unused.
30        from ba import _error
31        _error.print_error(
32            "create_default_game_settings_ui needs to be overridden")

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):
292class AssetPackage(DependencyComponent):
293    """ba.DependencyComponent representing a bundled package of game assets.
294
295    Category: **Asset Classes**
296    """
297
298    def __init__(self) -> None:
299        super().__init__()
300
301        # This is used internally by the get_package_xxx calls.
302        self.context = _ba.Context('current')
303
304        entry = self._dep_entry()
305        assert entry is not None
306        assert isinstance(entry.config, str)
307        self.package_id = entry.config
308        print(f'LOADING ASSET PACKAGE {self.package_id}')
309
310    @classmethod
311    def dep_is_present(cls, config: Any = None) -> bool:
312        assert isinstance(config, str)
313
314        # Temp: hard-coding for a single asset-package at the moment.
315        if config == 'stdassets@1':
316            return True
317        return False
318
319    def gettexture(self, name: str) -> ba.Texture:
320        """Load a named ba.Texture from the AssetPackage.
321
322        Behavior is similar to ba.gettexture()
323        """
324        return _ba.get_package_texture(self, name)
325
326    def getmodel(self, name: str) -> ba.Model:
327        """Load a named ba.Model from the AssetPackage.
328
329        Behavior is similar to ba.getmodel()
330        """
331        return _ba.get_package_model(self, name)
332
333    def getcollidemodel(self, name: str) -> ba.CollideModel:
334        """Load a named ba.CollideModel from the AssetPackage.
335
336        Behavior is similar to ba.getcollideModel()
337        """
338        return _ba.get_package_collide_model(self, name)
339
340    def getsound(self, name: str) -> ba.Sound:
341        """Load a named ba.Sound from the AssetPackage.
342
343        Behavior is similar to ba.getsound()
344        """
345        return _ba.get_package_sound(self, name)
346
347    def getdata(self, name: str) -> ba.Data:
348        """Load a named ba.Data from the AssetPackage.
349
350        Behavior is similar to ba.getdata()
351        """
352        return _ba.get_package_data(self, name)

ba.DependencyComponent representing a bundled package of game assets.

Category: Asset Classes

AssetPackage()
298    def __init__(self) -> None:
299        super().__init__()
300
301        # This is used internally by the get_package_xxx calls.
302        self.context = _ba.Context('current')
303
304        entry = self._dep_entry()
305        assert entry is not None
306        assert isinstance(entry.config, str)
307        self.package_id = entry.config
308        print(f'LOADING ASSET PACKAGE {self.package_id}')

Instantiate a DependencyComponent.

@classmethod
def dep_is_present(cls, config: Any = None) -> bool:
310    @classmethod
311    def dep_is_present(cls, config: Any = None) -> bool:
312        assert isinstance(config, str)
313
314        # Temp: hard-coding for a single asset-package at the moment.
315        if config == 'stdassets@1':
316            return True
317        return False

Return whether this component/config is present on this device.

def gettexture(self, name: str) -> ba.Texture:
319    def gettexture(self, name: str) -> ba.Texture:
320        """Load a named ba.Texture from the AssetPackage.
321
322        Behavior is similar to ba.gettexture()
323        """
324        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:
326    def getmodel(self, name: str) -> ba.Model:
327        """Load a named ba.Model from the AssetPackage.
328
329        Behavior is similar to ba.getmodel()
330        """
331        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:
333    def getcollidemodel(self, name: str) -> ba.CollideModel:
334        """Load a named ba.CollideModel from the AssetPackage.
335
336        Behavior is similar to ba.getcollideModel()
337        """
338        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:
340    def getsound(self, name: str) -> ba.Sound:
341        """Load a named ba.Sound from the AssetPackage.
342
343        Behavior is similar to ba.getsound()
344        """
345        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:
347    def getdata(self, name: str) -> ba.Data:
348        """Load a named ba.Data from the AssetPackage.
349
350        Behavior is similar to ba.getdata()
351        """
352        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    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:
1171def buttonwidget(edit: ba.Widget | None = None,
1172                 parent: ba.Widget | None = None,
1173                 size: Sequence[float] | None = None,
1174                 position: Sequence[float] | None = None,
1175                 on_activate_call: Callable | None = None,
1176                 label: str | ba.Lstr | None = None,
1177                 color: Sequence[float] | None = None,
1178                 down_widget: ba.Widget | None = None,
1179                 up_widget: ba.Widget | None = None,
1180                 left_widget: ba.Widget | None = None,
1181                 right_widget: ba.Widget | None = None,
1182                 texture: ba.Texture | None = None,
1183                 text_scale: float | None = None,
1184                 textcolor: Sequence[float] | None = None,
1185                 enable_sound: bool | None = None,
1186                 model_transparent: ba.Model | None = None,
1187                 model_opaque: ba.Model | None = None,
1188                 repeat: bool | None = None,
1189                 scale: float | None = None,
1190                 transition_delay: float | None = None,
1191                 on_select_call: Callable | None = None,
1192                 button_type: str | None = None,
1193                 extra_touch_border_scale: float | None = None,
1194                 selectable: bool | None = None,
1195                 show_buffer_top: float | None = None,
1196                 icon: ba.Texture | None = None,
1197                 iconscale: float | None = None,
1198                 icon_tint: float | None = None,
1199                 icon_color: Sequence[float] | None = None,
1200                 autoselect: bool | None = None,
1201                 mask_texture: ba.Texture | None = None,
1202                 tint_texture: ba.Texture | None = None,
1203                 tint_color: Sequence[float] | None = None,
1204                 tint2_color: Sequence[float] | None = None,
1205                 text_flatness: float | None = None,
1206                 text_res_scale: float | None = None,
1207                 enabled: bool | None = None) -> ba.Widget:
1208    """Create or edit a button widget.
1209
1210    Category: **User Interface Functions**
1211
1212    Pass a valid existing ba.Widget as 'edit' to modify it; otherwise
1213    a new one is created and returned. Arguments that are not set to None
1214    are applied to the Widget.
1215    """
1216    import ba  # pylint: disable=cyclic-import
1217    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.

class Call:
215class _Call:
216    """Wraps a callable and arguments into a single callable object.
217
218    Category: **General Utility Classes**
219
220    The callable is strong-referenced so it won't die until this
221    object does.
222
223    Note that a bound method (ex: ``myobj.dosomething``) contains a reference
224    to ``self`` (``myobj`` in that case), so you will be keeping that object
225    alive too. Use ba.WeakCall if you want to pass a method to callback
226    without keeping its object alive.
227    """
228
229    def __init__(self, *args: Any, **keywds: Any):
230        """Instantiate a Call.
231
232        Pass a callable as the first arg, followed by any number of
233        arguments or keywords.
234
235        ##### Example
236        Wrap a method call with 1 positional and 1 keyword arg:
237        >>> mycall = ba.Call(myobj.dostuff, argval, namedarg=argval2)
238        ... # Now we have a single callable to run that whole mess.
239        ... # ..the same as calling myobj.dostuff(argval, namedarg=argval2)
240        ... mycall()
241        """
242        self._call = args[0]
243        self._args = args[1:]
244        self._keywds = keywds
245
246    def __call__(self, *args_extra: Any) -> Any:
247        return self._call(*self._args + args_extra, **self._keywds)
248
249    def __str__(self) -> str:
250        return ('<ba.Call object; _call=' + str(self._call) + ' _args=' +
251                str(self._args) + ' _keywds=' + str(self._keywds) + '>')

Wraps a callable and arguments into a single callable object.

Category: General Utility Classes

The callable is strong-referenced so it won't die until this object does.

Note that a bound method (ex: myobj.dosomething) contains a reference to self (myobj in that case), so you will be keeping that object alive too. Use ba.WeakCall if you want to pass a method to callback without keeping its object alive.

Call(*args: Any, **keywds: Any)
229    def __init__(self, *args: Any, **keywds: Any):
230        """Instantiate a Call.
231
232        Pass a callable as the first arg, followed by any number of
233        arguments or keywords.
234
235        ##### Example
236        Wrap a method call with 1 positional and 1 keyword arg:
237        >>> mycall = ba.Call(myobj.dostuff, argval, namedarg=argval2)
238        ... # Now we have a single callable to run that whole mess.
239        ... # ..the same as calling myobj.dostuff(argval, namedarg=argval2)
240        ... mycall()
241        """
242        self._call = args[0]
243        self._args = args[1:]
244        self._keywds = keywds

Instantiate a Call.

Pass a callable as the first arg, followed by any number of arguments or keywords.

Example

Wrap a method call with 1 positional and 1 keyword arg:

>>> mycall = ba.Call(myobj.dostuff, argval, namedarg=argval2)
... # Now we have a single callable to run that whole mess.
... # ..the same as calling myobj.dostuff(argval, namedarg=argval2)
... mycall()
def cameraflash(duration: float = 999.0) -> None:
325def cameraflash(duration: float = 999.0) -> None:
326    """Create a strobing camera flash effect.
327
328    Category: **Gameplay Functions**
329
330    (as seen when a team wins a game)
331    Duration is in seconds.
332    """
333    # pylint: disable=too-many-locals
334    import random
335    from ba._nodeactor import NodeActor
336    x_spread = 10
337    y_spread = 5
338    positions = [[-x_spread, -y_spread], [0, -y_spread], [0, y_spread],
339                 [x_spread, -y_spread], [x_spread, y_spread],
340                 [-x_spread, y_spread]]
341    times = [0, 2700, 1000, 1800, 500, 1400]
342
343    # Store this on the current activity so we only have one at a time.
344    # FIXME: Need a type safe way to do this.
345    activity = _ba.getactivity()
346    activity.camera_flash_data = []  # type: ignore
347    for i in range(6):
348        light = NodeActor(
349            _ba.newnode('light',
350                        attrs={
351                            'position': (positions[i][0], 0, positions[i][1]),
352                            'radius': 1.0,
353                            'lights_volumes': False,
354                            'height_attenuated': False,
355                            'color': (0.2, 0.2, 0.8)
356                        }))
357        sval = 1.87
358        iscale = 1.3
359        tcombine = _ba.newnode('combine',
360                               owner=light.node,
361                               attrs={
362                                   'size': 3,
363                                   'input0': positions[i][0],
364                                   'input1': 0,
365                                   'input2': positions[i][1]
366                               })
367        assert light.node
368        tcombine.connectattr('output', light.node, 'position')
369        xval = positions[i][0]
370        yval = positions[i][1]
371        spd = 0.5 + random.random()
372        spd2 = 0.5 + random.random()
373        animate(tcombine,
374                'input0', {
375                    0.0: xval + 0,
376                    0.069 * spd: xval + 10.0,
377                    0.143 * spd: xval - 10.0,
378                    0.201 * spd: xval + 0
379                },
380                loop=True)
381        animate(tcombine,
382                'input2', {
383                    0.0: yval + 0,
384                    0.15 * spd2: yval + 10.0,
385                    0.287 * spd2: yval - 10.0,
386                    0.398 * spd2: yval + 0
387                },
388                loop=True)
389        animate(light.node,
390                'intensity', {
391                    0.0: 0,
392                    0.02 * sval: 0,
393                    0.05 * sval: 0.8 * iscale,
394                    0.08 * sval: 0,
395                    0.1 * sval: 0
396                },
397                loop=True,
398                offset=times[i])
399        _ba.timer((times[i] + random.randint(1, int(duration)) * 40 * sval),
400                  light.node.delete,
401                  timeformat=TimeFormat.MILLISECONDS)
402        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:
1220def camerashake(intensity: float = 1.0) -> None:
1221    """Shake the camera.
1222
1223    Category: **Gameplay Functions**
1224
1225    Note that some cameras and/or platforms (such as VR) may not display
1226    camera-shake, so do not rely on this always being visible to the
1227    player as a gameplay cue.
1228    """
1229    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__(self,
33                 name: str,
34                 sequential: bool = True,
35                 levels: list[ba.Level] | None = None):
36        self._name = name
37        self._sequential = sequential
38        self._levels: list[ba.Level] = []
39        if levels is not None:
40            for level in levels:
41                self.addlevel(level)
42
43    @property
44    def name(self) -> str:
45        """The name of the Campaign."""
46        return self._name
47
48    @property
49    def sequential(self) -> bool:
50        """Whether this Campaign's levels must be played in sequence."""
51        return self._sequential
52
53    def addlevel(self, level: ba.Level, index: int | None = None) -> None:
54        """Adds a ba.Level to the Campaign."""
55        if level.campaign is not None:
56            raise RuntimeError('Level already belongs to a campaign.')
57        level.set_campaign(self, len(self._levels))
58        if index is None:
59            self._levels.append(level)
60        else:
61            self._levels.insert(index, level)
62
63    @property
64    def levels(self) -> list[ba.Level]:
65        """The list of ba.Level-s in the Campaign."""
66        return self._levels
67
68    def getlevel(self, name: str) -> ba.Level:
69        """Return a contained ba.Level by name."""
70        from ba import _error
71        for level in self._levels:
72            if level.name == name:
73                return level
74        raise _error.NotFoundError("Level '" + name +
75                                   "' not found in campaign '" + self.name +
76                                   "'")
77
78    def reset(self) -> None:
79        """Reset state for the Campaign."""
80        _ba.app.config.setdefault('Campaigns', {})[self._name] = {}
81
82    # FIXME should these give/take ba.Level instances instead of level names?..
83    def set_selected_level(self, levelname: str) -> None:
84        """Set the Level currently selected in the UI (by name)."""
85        self.configdict['Selection'] = levelname
86        _ba.app.config.commit()
87
88    def get_selected_level(self) -> str:
89        """Return the name of the Level currently selected in the UI."""
90        return self.configdict.get('Selection', self._levels[0].name)
91
92    @property
93    def configdict(self) -> dict[str, Any]:
94        """Return the live config dict for this campaign."""
95        val: dict[str, Any] = (_ba.app.config.setdefault('Campaigns',
96                                                         {}).setdefault(
97                                                             self._name, {}))
98        assert isinstance(val, dict)
99        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__(self,
33                 name: str,
34                 sequential: bool = True,
35                 levels: list[ba.Level] | None = None):
36        self._name = name
37        self._sequential = sequential
38        self._levels: list[ba.Level] = []
39        if levels is not None:
40            for level in levels:
41                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:
53    def addlevel(self, level: ba.Level, index: int | None = None) -> None:
54        """Adds a ba.Level to the Campaign."""
55        if level.campaign is not None:
56            raise RuntimeError('Level already belongs to a campaign.')
57        level.set_campaign(self, len(self._levels))
58        if index is None:
59            self._levels.append(level)
60        else:
61            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:
68    def getlevel(self, name: str) -> ba.Level:
69        """Return a contained ba.Level by name."""
70        from ba import _error
71        for level in self._levels:
72            if level.name == name:
73                return level
74        raise _error.NotFoundError("Level '" + name +
75                                   "' not found in campaign '" + self.name +
76                                   "'")

Return a contained ba.Level by name.

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

Reset state for the Campaign.

def set_selected_level(self, levelname: str) -> None:
83    def set_selected_level(self, levelname: str) -> None:
84        """Set the Level currently selected in the UI (by name)."""
85        self.configdict['Selection'] = levelname
86        _ba.app.config.commit()

Set the Level currently selected in the UI (by name).

def get_selected_level(self) -> str:
88    def get_selected_level(self) -> str:
89        """Return the name of the Level currently selected in the UI."""
90        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:
219@dataclass
220class CelebrateMessage:
221    """Tells an object to celebrate.
222
223    Category: **Message Classes**
224    """
225
226    duration: float = 10.0
227    """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:
1260def charstr(char_id: ba.SpecialChar) -> str:
1261    """Get a unicode string representing a special character.
1262
1263    Category: **General Utility Functions**
1264
1265    Note that these utilize the private-use block of unicode characters
1266    (U+E000-U+F8FF) and are specific to the game; exporting or rendering
1267    them elsewhere will be meaningless.
1268
1269    See ba.SpecialChar for the list of available characters.
1270    """
1271    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:
1281def checkboxwidget(edit: ba.Widget | None = None,
1282                   parent: ba.Widget | None = None,
1283                   size: Sequence[float] | None = None,
1284                   position: Sequence[float] | None = None,
1285                   text: str | ba.Lstr | None = None,
1286                   value: bool | None = None,
1287                   on_value_change_call: Callable[[bool], None] | None = None,
1288                   on_select_call: Callable[[], None] | None = None,
1289                   text_scale: float | None = None,
1290                   textcolor: Sequence[float] | None = None,
1291                   scale: float | None = None,
1292                   is_radio_button: bool | None = None,
1293                   maxwidth: float | None = None,
1294                   autoselect: bool | None = None,
1295                   color: Sequence[float] | None = None) -> ba.Widget:
1296    """Create or edit a check-box widget.
1297
1298    Category: **User Interface Functions**
1299
1300    Pass a valid existing ba.Widget as 'edit' to modify it; otherwise
1301    a new one is created and returned. Arguments that are not set to None
1302    are applied to the Widget.
1303    """
1304    import ba  # pylint: disable=cyclic-import
1305    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):
59@dataclass
60class ChoiceSetting(Setting):
61    """A setting with multiple choices.
62
63    Category: Settings Classes
64    """
65    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:
127class Chooser:
128    """A character/team selector for a ba.Player.
129
130    Category: Gameplay Classes
131    """
132
133    def __del__(self) -> None:
134
135        # Just kill off our base node; the rest should go down with it.
136        if self._text_node:
137            self._text_node.delete()
138
139    def __init__(self, vpos: float, sessionplayer: _ba.SessionPlayer,
140                 lobby: 'Lobby') -> None:
141        self._deek_sound = _ba.getsound('deek')
142        self._click_sound = _ba.getsound('click01')
143        self._punchsound = _ba.getsound('punch01')
144        self._swish_sound = _ba.getsound('punchSwish')
145        self._errorsound = _ba.getsound('error')
146        self._mask_texture = _ba.gettexture('characterIconMask')
147        self._vpos = vpos
148        self._lobby = weakref.ref(lobby)
149        self._sessionplayer = sessionplayer
150        self._inited = False
151        self._dead = False
152        self._text_node: ba.Node | None = None
153        self._profilename = ''
154        self._profilenames: list[str] = []
155        self._ready: bool = False
156        self._character_names: list[str] = []
157        self._last_change: Sequence[float | int] = (0, 0)
158        self._profiles: dict[str, dict[str, Any]] = {}
159
160        app = _ba.app
161
162        # Load available player profiles either from the local config or
163        # from the remote device.
164        self.reload_profiles()
165
166        # Note: this is just our local index out of available teams; *not*
167        # the team-id!
168        self._selected_team_index: int = self.lobby.next_add_team
169
170        # Store a persistent random character index and colors; we'll use this
171        # for the '_random' profile. Let's use their input_device id to seed
172        # it. This will give a persistent character for them between games
173        # and will distribute characters nicely if everyone is random.
174        self._random_color, self._random_highlight = (
175            get_player_profile_colors(None))
176
177        # To calc our random character we pick a random one out of our
178        # unlocked list and then locate that character's index in the full
179        # list.
180        char_index_offset = app.lobby_random_char_index_offset
181        self._random_character_index = (
182            (sessionplayer.inputdevice.id + char_index_offset) %
183            len(self._character_names))
184
185        # Attempt to set an initial profile based on what was used previously
186        # for this input-device, etc.
187        self._profileindex = self._select_initial_profile()
188        self._profilename = self._profilenames[self._profileindex]
189
190        self._text_node = _ba.newnode('text',
191                                      delegate=self,
192                                      attrs={
193                                          'position': (-100, self._vpos),
194                                          'maxwidth': 160,
195                                          'shadow': 0.5,
196                                          'vr_depth': -20,
197                                          'h_align': 'left',
198                                          'v_align': 'center',
199                                          'v_attach': 'top'
200                                      })
201        animate(self._text_node, 'scale', {0: 0, 0.1: 1.0})
202        self.icon = _ba.newnode('image',
203                                owner=self._text_node,
204                                attrs={
205                                    'position': (-130, self._vpos + 20),
206                                    'mask_texture': self._mask_texture,
207                                    'vr_depth': -10,
208                                    'attach': 'topCenter'
209                                })
210
211        animate_array(self.icon, 'scale', 2, {0: (0, 0), 0.1: (45, 45)})
212
213        # Set our initial name to '<choosing player>' in case anyone asks.
214        self._sessionplayer.setname(
215            Lstr(resource='choosingPlayerText').evaluate(), real=False)
216
217        # Init these to our rando but they should get switched to the
218        # selected profile (if any) right after.
219        self._character_index = self._random_character_index
220        self._color = self._random_color
221        self._highlight = self._random_highlight
222
223        self.update_from_profile()
224        self.update_position()
225        self._inited = True
226
227        self._set_ready(False)
228
229    def _select_initial_profile(self) -> int:
230        app = _ba.app
231        profilenames = self._profilenames
232        inputdevice = self._sessionplayer.inputdevice
233
234        # If we've got a set profile name for this device, work backwards
235        # from that to get our index.
236        dprofilename = (app.config.get('Default Player Profiles',
237                                       {}).get(inputdevice.name + ' ' +
238                                               inputdevice.unique_identifier))
239        if dprofilename is not None and dprofilename in profilenames:
240            # If we got '__account__' and its local and we haven't marked
241            # anyone as the 'account profile' device yet, mark this guy as
242            # it. (prevents the next joiner from getting the account
243            # profile too).
244            if (dprofilename == '__account__'
245                    and not inputdevice.is_remote_client
246                    and app.lobby_account_profile_device_id is None):
247                app.lobby_account_profile_device_id = inputdevice.id
248            return profilenames.index(dprofilename)
249
250        # We want to mark the first local input-device in the game
251        # as the 'account profile' device.
252        if (not inputdevice.is_remote_client
253                and not inputdevice.is_controller_app):
254            if (app.lobby_account_profile_device_id is None
255                    and '__account__' in profilenames):
256                app.lobby_account_profile_device_id = inputdevice.id
257
258        # If this is the designated account-profile-device, try to default
259        # to the account profile.
260        if (inputdevice.id == app.lobby_account_profile_device_id
261                and '__account__' in profilenames):
262            return profilenames.index('__account__')
263
264        # If this is the controller app, it defaults to using a random
265        # profile (since we can pull the random name from the app).
266        if inputdevice.is_controller_app and '_random' in profilenames:
267            return profilenames.index('_random')
268
269        # If its a client connection, for now just force
270        # the account profile if possible.. (need to provide a
271        # way for clients to specify/remember their default
272        # profile on remote servers that do not already know them).
273        if inputdevice.is_remote_client and '__account__' in profilenames:
274            return profilenames.index('__account__')
275
276        # Cycle through our non-random profiles once; after
277        # that, everyone gets random.
278        while (app.lobby_random_profile_index < len(profilenames)
279               and profilenames[app.lobby_random_profile_index]
280               in ('_random', '__account__', '_edit')):
281            app.lobby_random_profile_index += 1
282        if app.lobby_random_profile_index < len(profilenames):
283            profileindex = app.lobby_random_profile_index
284            app.lobby_random_profile_index += 1
285            return profileindex
286        assert '_random' in profilenames
287        return profilenames.index('_random')
288
289    @property
290    def sessionplayer(self) -> ba.SessionPlayer:
291        """The ba.SessionPlayer associated with this chooser."""
292        return self._sessionplayer
293
294    @property
295    def ready(self) -> bool:
296        """Whether this chooser is checked in as ready."""
297        return self._ready
298
299    def set_vpos(self, vpos: float) -> None:
300        """(internal)"""
301        self._vpos = vpos
302
303    def set_dead(self, val: bool) -> None:
304        """(internal)"""
305        self._dead = val
306
307    @property
308    def sessionteam(self) -> ba.SessionTeam:
309        """Return this chooser's currently selected ba.SessionTeam."""
310        return self.lobby.sessionteams[self._selected_team_index]
311
312    @property
313    def lobby(self) -> ba.Lobby:
314        """The chooser's ba.Lobby."""
315        lobby = self._lobby()
316        if lobby is None:
317            raise NotFoundError('Lobby does not exist.')
318        return lobby
319
320    def get_lobby(self) -> ba.Lobby | None:
321        """Return this chooser's lobby if it still exists; otherwise None."""
322        return self._lobby()
323
324    def update_from_profile(self) -> None:
325        """Set character/colors based on the current profile."""
326        self._profilename = self._profilenames[self._profileindex]
327        if self._profilename == '_edit':
328            pass
329        elif self._profilename == '_random':
330            self._character_index = self._random_character_index
331            self._color = self._random_color
332            self._highlight = self._random_highlight
333        else:
334            character = self._profiles[self._profilename]['character']
335
336            # At the moment we're not properly pulling the list
337            # of available characters from clients, so profiles might use a
338            # character not in their list. For now, just go ahead and add
339            # a character name to their list as long as we're aware of it.
340            # This just means they won't always be able to override their
341            # character to others they own, but profile characters
342            # should work (and we validate profiles on the master server
343            # so no exploit opportunities)
344            if (character not in self._character_names
345                    and character in _ba.app.spaz_appearances):
346                self._character_names.append(character)
347            self._character_index = self._character_names.index(character)
348            self._color, self._highlight = (get_player_profile_colors(
349                self._profilename, profiles=self._profiles))
350        self._update_icon()
351        self._update_text()
352
353    def reload_profiles(self) -> None:
354        """Reload all player profiles."""
355        from ba._general import json_prep
356        app = _ba.app
357
358        # Re-construct our profile index and other stuff since the profile
359        # list might have changed.
360        input_device = self._sessionplayer.inputdevice
361        is_remote = input_device.is_remote_client
362        is_test_input = input_device.name.startswith('TestInput')
363
364        # Pull this player's list of unlocked characters.
365        if is_remote:
366            # TODO: Pull this from the remote player.
367            # (but make sure to filter it to the ones we've got).
368            self._character_names = ['Spaz']
369        else:
370            self._character_names = self.lobby.character_names_local_unlocked
371
372        # If we're a local player, pull our local profiles from the config.
373        # Otherwise ask the remote-input-device for its profile list.
374        if is_remote:
375            self._profiles = input_device.get_player_profiles()
376        else:
377            self._profiles = app.config.get('Player Profiles', {})
378
379        # These may have come over the wire from an older
380        # (non-unicode/non-json) version.
381        # Make sure they conform to our standards
382        # (unicode strings, no tuples, etc)
383        self._profiles = json_prep(self._profiles)
384
385        # Filter out any characters we're unaware of.
386        for profile in list(self._profiles.items()):
387            if profile[1].get('character', '') not in app.spaz_appearances:
388                profile[1]['character'] = 'Spaz'
389
390        # Add in a random one so we're ok even if there's no user profiles.
391        self._profiles['_random'] = {}
392
393        # In kiosk mode we disable account profiles to force random.
394        if app.demo_mode or app.arcade_mode:
395            if '__account__' in self._profiles:
396                del self._profiles['__account__']
397
398        # For local devices, add it an 'edit' option which will pop up
399        # the profile window.
400        if not is_remote and not is_test_input and not (app.demo_mode
401                                                        or app.arcade_mode):
402            self._profiles['_edit'] = {}
403
404        # Build a sorted name list we can iterate through.
405        self._profilenames = list(self._profiles.keys())
406        self._profilenames.sort(key=lambda x: x.lower())
407
408        if self._profilename in self._profilenames:
409            self._profileindex = self._profilenames.index(self._profilename)
410        else:
411            self._profileindex = 0
412            # noinspection PyUnresolvedReferences
413            self._profilename = self._profilenames[self._profileindex]
414
415    def update_position(self) -> None:
416        """Update this chooser's position."""
417
418        assert self._text_node
419        spacing = 350
420        sessionteams = self.lobby.sessionteams
421        offs = (spacing * -0.5 * len(sessionteams) +
422                spacing * self._selected_team_index + 250)
423        if len(sessionteams) > 1:
424            offs -= 35
425        animate_array(self._text_node, 'position', 2, {
426            0: self._text_node.position,
427            0.1: (-100 + offs, self._vpos + 23)
428        })
429        animate_array(self.icon, 'position', 2, {
430            0: self.icon.position,
431            0.1: (-130 + offs, self._vpos + 22)
432        })
433
434    def get_character_name(self) -> str:
435        """Return the selected character name."""
436        return self._character_names[self._character_index]
437
438    def _do_nothing(self) -> None:
439        """Does nothing! (hacky way to disable callbacks)"""
440
441    def _getname(self, full: bool = False) -> str:
442        name_raw = name = self._profilenames[self._profileindex]
443        clamp = False
444        if name == '_random':
445            try:
446                name = (
447                    self._sessionplayer.inputdevice.get_default_player_name())
448            except Exception:
449                print_exception('Error getting _random chooser name.')
450                name = 'Invalid'
451            clamp = not full
452        elif name == '__account__':
453            try:
454                name = self._sessionplayer.inputdevice.get_v1_account_name(
455                    full)
456            except Exception:
457                print_exception('Error getting account name for chooser.')
458                name = 'Invalid'
459            clamp = not full
460        elif name == '_edit':
461            # Explicitly flattening this to a str; it's only relevant on
462            # the host so that's ok.
463            name = (Lstr(
464                resource='createEditPlayerText',
465                fallback_resource='editProfileWindow.titleNewText').evaluate())
466        else:
467            # If we have a regular profile marked as global with an icon,
468            # use it (for full only).
469            if full:
470                try:
471                    if self._profiles[name_raw].get('global', False):
472                        icon = (self._profiles[name_raw]['icon']
473                                if 'icon' in self._profiles[name_raw] else
474                                _ba.charstr(SpecialChar.LOGO))
475                        name = icon + name
476                except Exception:
477                    print_exception('Error applying global icon.')
478            else:
479                # We now clamp non-full versions of names so there's at
480                # least some hope of reading them in-game.
481                clamp = True
482
483        if clamp:
484            if len(name) > 10:
485                name = name[:10] + '...'
486        return name
487
488    def _set_ready(self, ready: bool) -> None:
489        # pylint: disable=cyclic-import
490        from bastd.ui.profile import browser as pbrowser
491        from ba._general import Call
492        profilename = self._profilenames[self._profileindex]
493
494        # Handle '_edit' as a special case.
495        if profilename == '_edit' and ready:
496            with _ba.Context('ui'):
497                pbrowser.ProfileBrowserWindow(in_main_menu=False)
498
499                # Give their input-device UI ownership too
500                # (prevent someone else from snatching it in crowded games)
501                _ba.set_ui_input_device(self._sessionplayer.inputdevice)
502            return
503
504        if not ready:
505            self._sessionplayer.assigninput(
506                InputType.LEFT_PRESS,
507                Call(self.handlemessage, ChangeMessage('team', -1)))
508            self._sessionplayer.assigninput(
509                InputType.RIGHT_PRESS,
510                Call(self.handlemessage, ChangeMessage('team', 1)))
511            self._sessionplayer.assigninput(
512                InputType.BOMB_PRESS,
513                Call(self.handlemessage, ChangeMessage('character', 1)))
514            self._sessionplayer.assigninput(
515                InputType.UP_PRESS,
516                Call(self.handlemessage, ChangeMessage('profileindex', -1)))
517            self._sessionplayer.assigninput(
518                InputType.DOWN_PRESS,
519                Call(self.handlemessage, ChangeMessage('profileindex', 1)))
520            self._sessionplayer.assigninput(
521                (InputType.JUMP_PRESS, InputType.PICK_UP_PRESS,
522                 InputType.PUNCH_PRESS),
523                Call(self.handlemessage, ChangeMessage('ready', 1)))
524            self._ready = False
525            self._update_text()
526            self._sessionplayer.setname('untitled', real=False)
527        else:
528            self._sessionplayer.assigninput(
529                (InputType.LEFT_PRESS, InputType.RIGHT_PRESS,
530                 InputType.UP_PRESS, InputType.DOWN_PRESS,
531                 InputType.JUMP_PRESS, InputType.BOMB_PRESS,
532                 InputType.PICK_UP_PRESS), self._do_nothing)
533            self._sessionplayer.assigninput(
534                (InputType.JUMP_PRESS, InputType.BOMB_PRESS,
535                 InputType.PICK_UP_PRESS, InputType.PUNCH_PRESS),
536                Call(self.handlemessage, ChangeMessage('ready', 0)))
537
538            # Store the last profile picked by this input for reuse.
539            input_device = self._sessionplayer.inputdevice
540            name = input_device.name
541            unique_id = input_device.unique_identifier
542            device_profiles = _ba.app.config.setdefault(
543                'Default Player Profiles', {})
544
545            # Make an exception if we have no custom profiles and are set
546            # to random; in that case we'll want to start picking up custom
547            # profiles if/when one is made so keep our setting cleared.
548            special = ('_random', '_edit', '__account__')
549            have_custom_profiles = any(p not in special
550                                       for p in self._profiles)
551
552            profilekey = name + ' ' + unique_id
553            if profilename == '_random' and not have_custom_profiles:
554                if profilekey in device_profiles:
555                    del device_profiles[profilekey]
556            else:
557                device_profiles[profilekey] = profilename
558            _ba.app.config.commit()
559
560            # Set this player's short and full name.
561            self._sessionplayer.setname(self._getname(),
562                                        self._getname(full=True),
563                                        real=True)
564            self._ready = True
565            self._update_text()
566
567            # Inform the session that this player is ready.
568            _ba.getsession().handlemessage(PlayerReadyMessage(self))
569
570    def _handle_ready_msg(self, ready: bool) -> None:
571        force_team_switch = False
572
573        # Team auto-balance kicks us to another team if we try to
574        # join the team with the most players.
575        if not self._ready:
576            if _ba.app.config.get('Auto Balance Teams', False):
577                lobby = self.lobby
578                sessionteams = lobby.sessionteams
579                if len(sessionteams) > 1:
580
581                    # First, calc how many players are on each team
582                    # ..we need to count both active players and
583                    # choosers that have been marked as ready.
584                    team_player_counts = {}
585                    for sessionteam in sessionteams:
586                        team_player_counts[sessionteam.id] = len(
587                            sessionteam.players)
588                    for chooser in lobby.choosers:
589                        if chooser.ready:
590                            team_player_counts[chooser.sessionteam.id] += 1
591                    largest_team_size = max(team_player_counts.values())
592                    smallest_team_size = (min(team_player_counts.values()))
593
594                    # Force switch if we're on the biggest sessionteam
595                    # and there's a smaller one available.
596                    if (largest_team_size != smallest_team_size
597                            and team_player_counts[self.sessionteam.id] >=
598                            largest_team_size):
599                        force_team_switch = True
600
601        # Either force switch teams, or actually for realsies do the set-ready.
602        if force_team_switch:
603            _ba.playsound(self._errorsound)
604            self.handlemessage(ChangeMessage('team', 1))
605        else:
606            _ba.playsound(self._punchsound)
607            self._set_ready(ready)
608
609    # TODO: should handle this at the engine layer so this is unnecessary.
610    def _handle_repeat_message_attack(self) -> None:
611        now = _ba.time()
612        count = self._last_change[1]
613        if now - self._last_change[0] < QUICK_CHANGE_INTERVAL:
614            count += 1
615            if count > MAX_QUICK_CHANGE_COUNT:
616                _ba.disconnect_client(
617                    self._sessionplayer.inputdevice.client_id)
618        elif now - self._last_change[0] > QUICK_CHANGE_RESET_INTERVAL:
619            count = 0
620        self._last_change = (now, count)
621
622    def handlemessage(self, msg: Any) -> Any:
623        """Standard generic message handler."""
624
625        if isinstance(msg, ChangeMessage):
626            self._handle_repeat_message_attack()
627
628            # If we've been removed from the lobby, ignore this stuff.
629            if self._dead:
630                print_error('chooser got ChangeMessage after dying')
631                return
632
633            if not self._text_node:
634                print_error('got ChangeMessage after nodes died')
635                return
636
637            if msg.what == 'team':
638                sessionteams = self.lobby.sessionteams
639                if len(sessionteams) > 1:
640                    _ba.playsound(self._swish_sound)
641                self._selected_team_index = (
642                    (self._selected_team_index + msg.value) %
643                    len(sessionteams))
644                self._update_text()
645                self.update_position()
646                self._update_icon()
647
648            elif msg.what == 'profileindex':
649                if len(self._profilenames) == 1:
650
651                    # This should be pretty hard to hit now with
652                    # automatic local accounts.
653                    _ba.playsound(_ba.getsound('error'))
654                else:
655
656                    # Pick the next player profile and assign our name
657                    # and character based on that.
658                    _ba.playsound(self._deek_sound)
659                    self._profileindex = ((self._profileindex + msg.value) %
660                                          len(self._profilenames))
661                    self.update_from_profile()
662
663            elif msg.what == 'character':
664                _ba.playsound(self._click_sound)
665                # update our index in our local list of characters
666                self._character_index = ((self._character_index + msg.value) %
667                                         len(self._character_names))
668                self._update_text()
669                self._update_icon()
670
671            elif msg.what == 'ready':
672                self._handle_ready_msg(bool(msg.value))
673
674    def _update_text(self) -> None:
675        assert self._text_node is not None
676        if self._ready:
677
678            # Once we're ready, we've saved the name, so lets ask the system
679            # for it so we get appended numbers and stuff.
680            text = Lstr(value=self._sessionplayer.getname(full=True))
681            text = Lstr(value='${A} (${B})',
682                        subs=[('${A}', text),
683                              ('${B}', Lstr(resource='readyText'))])
684        else:
685            text = Lstr(value=self._getname(full=True))
686
687        can_switch_teams = len(self.lobby.sessionteams) > 1
688
689        # Flash as we're coming in.
690        fin_color = _ba.safecolor(self.get_color()) + (1, )
691        if not self._inited:
692            animate_array(self._text_node, 'color', 4, {
693                0.15: fin_color,
694                0.25: (2, 2, 2, 1),
695                0.35: fin_color
696            })
697        else:
698
699            # Blend if we're in teams mode; switch instantly otherwise.
700            if can_switch_teams:
701                animate_array(self._text_node, 'color', 4, {
702                    0: self._text_node.color,
703                    0.1: fin_color
704                })
705            else:
706                self._text_node.color = fin_color
707
708        self._text_node.text = text
709
710    def get_color(self) -> Sequence[float]:
711        """Return the currently selected color."""
712        val: Sequence[float]
713        if self.lobby.use_team_colors:
714            val = self.lobby.sessionteams[self._selected_team_index].color
715        else:
716            val = self._color
717        if len(val) != 3:
718            print('get_color: ignoring invalid color of len', len(val))
719            val = (0, 1, 0)
720        return val
721
722    def get_highlight(self) -> Sequence[float]:
723        """Return the currently selected highlight."""
724        if self._profilenames[self._profileindex] == '_edit':
725            return 0, 1, 0
726
727        # If we're using team colors we wanna make sure our highlight color
728        # isn't too close to any other team's color.
729        highlight = list(self._highlight)
730        if self.lobby.use_team_colors:
731            for i, sessionteam in enumerate(self.lobby.sessionteams):
732                if i != self._selected_team_index:
733
734                    # Find the dominant component of this sessionteam's color
735                    # and adjust ours so that the component is
736                    # not super-dominant.
737                    max_val = 0.0
738                    max_index = 0
739                    for j in range(3):
740                        if sessionteam.color[j] > max_val:
741                            max_val = sessionteam.color[j]
742                            max_index = j
743                    that_color_for_us = highlight[max_index]
744                    our_second_biggest = max(highlight[(max_index + 1) % 3],
745                                             highlight[(max_index + 2) % 3])
746                    diff = (that_color_for_us - our_second_biggest)
747                    if diff > 0:
748                        highlight[max_index] -= diff * 0.6
749                        highlight[(max_index + 1) % 3] += diff * 0.3
750                        highlight[(max_index + 2) % 3] += diff * 0.2
751        return highlight
752
753    def getplayer(self) -> ba.SessionPlayer:
754        """Return the player associated with this chooser."""
755        return self._sessionplayer
756
757    def _update_icon(self) -> None:
758        if self._profilenames[self._profileindex] == '_edit':
759            tex = _ba.gettexture('black')
760            tint_tex = _ba.gettexture('black')
761            self.icon.color = (1, 1, 1)
762            self.icon.texture = tex
763            self.icon.tint_texture = tint_tex
764            self.icon.tint_color = (0, 1, 0)
765            return
766
767        try:
768            tex_name = (_ba.app.spaz_appearances[self._character_names[
769                self._character_index]].icon_texture)
770            tint_tex_name = (_ba.app.spaz_appearances[self._character_names[
771                self._character_index]].icon_mask_texture)
772        except Exception:
773            print_exception('Error updating char icon list')
774            tex_name = 'neoSpazIcon'
775            tint_tex_name = 'neoSpazIconColorMask'
776
777        tex = _ba.gettexture(tex_name)
778        tint_tex = _ba.gettexture(tint_tex_name)
779
780        self.icon.color = (1, 1, 1)
781        self.icon.texture = tex
782        self.icon.tint_texture = tint_tex
783        clr = self.get_color()
784        clr2 = self.get_highlight()
785
786        can_switch_teams = len(self.lobby.sessionteams) > 1
787
788        # If we're initing, flash.
789        if not self._inited:
790            animate_array(self.icon, 'color', 3, {
791                0.15: (1, 1, 1),
792                0.25: (2, 2, 2),
793                0.35: (1, 1, 1)
794            })
795
796        # Blend in teams mode; switch instantly in ffa-mode.
797        if can_switch_teams:
798            animate_array(self.icon, 'tint_color', 3, {
799                0: self.icon.tint_color,
800                0.1: clr
801            })
802        else:
803            self.icon.tint_color = clr
804        self.icon.tint2_color = clr2
805
806        # Store the icon info the the player.
807        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)
139    def __init__(self, vpos: float, sessionplayer: _ba.SessionPlayer,
140                 lobby: 'Lobby') -> None:
141        self._deek_sound = _ba.getsound('deek')
142        self._click_sound = _ba.getsound('click01')
143        self._punchsound = _ba.getsound('punch01')
144        self._swish_sound = _ba.getsound('punchSwish')
145        self._errorsound = _ba.getsound('error')
146        self._mask_texture = _ba.gettexture('characterIconMask')
147        self._vpos = vpos
148        self._lobby = weakref.ref(lobby)
149        self._sessionplayer = sessionplayer
150        self._inited = False
151        self._dead = False
152        self._text_node: ba.Node | None = None
153        self._profilename = ''
154        self._profilenames: list[str] = []
155        self._ready: bool = False
156        self._character_names: list[str] = []
157        self._last_change: Sequence[float | int] = (0, 0)
158        self._profiles: dict[str, dict[str, Any]] = {}
159
160        app = _ba.app
161
162        # Load available player profiles either from the local config or
163        # from the remote device.
164        self.reload_profiles()
165
166        # Note: this is just our local index out of available teams; *not*
167        # the team-id!
168        self._selected_team_index: int = self.lobby.next_add_team
169
170        # Store a persistent random character index and colors; we'll use this
171        # for the '_random' profile. Let's use their input_device id to seed
172        # it. This will give a persistent character for them between games
173        # and will distribute characters nicely if everyone is random.
174        self._random_color, self._random_highlight = (
175            get_player_profile_colors(None))
176
177        # To calc our random character we pick a random one out of our
178        # unlocked list and then locate that character's index in the full
179        # list.
180        char_index_offset = app.lobby_random_char_index_offset
181        self._random_character_index = (
182            (sessionplayer.inputdevice.id + char_index_offset) %
183            len(self._character_names))
184
185        # Attempt to set an initial profile based on what was used previously
186        # for this input-device, etc.
187        self._profileindex = self._select_initial_profile()
188        self._profilename = self._profilenames[self._profileindex]
189
190        self._text_node = _ba.newnode('text',
191                                      delegate=self,
192                                      attrs={
193                                          'position': (-100, self._vpos),
194                                          'maxwidth': 160,
195                                          'shadow': 0.5,
196                                          'vr_depth': -20,
197                                          'h_align': 'left',
198                                          'v_align': 'center',
199                                          'v_attach': 'top'
200                                      })
201        animate(self._text_node, 'scale', {0: 0, 0.1: 1.0})
202        self.icon = _ba.newnode('image',
203                                owner=self._text_node,
204                                attrs={
205                                    'position': (-130, self._vpos + 20),
206                                    'mask_texture': self._mask_texture,
207                                    'vr_depth': -10,
208                                    'attach': 'topCenter'
209                                })
210
211        animate_array(self.icon, 'scale', 2, {0: (0, 0), 0.1: (45, 45)})
212
213        # Set our initial name to '<choosing player>' in case anyone asks.
214        self._sessionplayer.setname(
215            Lstr(resource='choosingPlayerText').evaluate(), real=False)
216
217        # Init these to our rando but they should get switched to the
218        # selected profile (if any) right after.
219        self._character_index = self._random_character_index
220        self._color = self._random_color
221        self._highlight = self._random_highlight
222
223        self.update_from_profile()
224        self.update_position()
225        self._inited = True
226
227        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:
320    def get_lobby(self) -> ba.Lobby | None:
321        """Return this chooser's lobby if it still exists; otherwise None."""
322        return self._lobby()

Return this chooser's lobby if it still exists; otherwise None.

def update_from_profile(self) -> None:
324    def update_from_profile(self) -> None:
325        """Set character/colors based on the current profile."""
326        self._profilename = self._profilenames[self._profileindex]
327        if self._profilename == '_edit':
328            pass
329        elif self._profilename == '_random':
330            self._character_index = self._random_character_index
331            self._color = self._random_color
332            self._highlight = self._random_highlight
333        else:
334            character = self._profiles[self._profilename]['character']
335
336            # At the moment we're not properly pulling the list
337            # of available characters from clients, so profiles might use a
338            # character not in their list. For now, just go ahead and add
339            # a character name to their list as long as we're aware of it.
340            # This just means they won't always be able to override their
341            # character to others they own, but profile characters
342            # should work (and we validate profiles on the master server
343            # so no exploit opportunities)
344            if (character not in self._character_names
345                    and character in _ba.app.spaz_appearances):
346                self._character_names.append(character)
347            self._character_index = self._character_names.index(character)
348            self._color, self._highlight = (get_player_profile_colors(
349                self._profilename, profiles=self._profiles))
350        self._update_icon()
351        self._update_text()

Set character/colors based on the current profile.

def reload_profiles(self) -> None:
353    def reload_profiles(self) -> None:
354        """Reload all player profiles."""
355        from ba._general import json_prep
356        app = _ba.app
357
358        # Re-construct our profile index and other stuff since the profile
359        # list might have changed.
360        input_device = self._sessionplayer.inputdevice
361        is_remote = input_device.is_remote_client
362        is_test_input = input_device.name.startswith('TestInput')
363
364        # Pull this player's list of unlocked characters.
365        if is_remote:
366            # TODO: Pull this from the remote player.
367            # (but make sure to filter it to the ones we've got).
368            self._character_names = ['Spaz']
369        else:
370            self._character_names = self.lobby.character_names_local_unlocked
371
372        # If we're a local player, pull our local profiles from the config.
373        # Otherwise ask the remote-input-device for its profile list.
374        if is_remote:
375            self._profiles = input_device.get_player_profiles()
376        else:
377            self._profiles = app.config.get('Player Profiles', {})
378
379        # These may have come over the wire from an older
380        # (non-unicode/non-json) version.
381        # Make sure they conform to our standards
382        # (unicode strings, no tuples, etc)
383        self._profiles = json_prep(self._profiles)
384
385        # Filter out any characters we're unaware of.
386        for profile in list(self._profiles.items()):
387            if profile[1].get('character', '') not in app.spaz_appearances:
388                profile[1]['character'] = 'Spaz'
389
390        # Add in a random one so we're ok even if there's no user profiles.
391        self._profiles['_random'] = {}
392
393        # In kiosk mode we disable account profiles to force random.
394        if app.demo_mode or app.arcade_mode:
395            if '__account__' in self._profiles:
396                del self._profiles['__account__']
397
398        # For local devices, add it an 'edit' option which will pop up
399        # the profile window.
400        if not is_remote and not is_test_input and not (app.demo_mode
401                                                        or app.arcade_mode):
402            self._profiles['_edit'] = {}
403
404        # Build a sorted name list we can iterate through.
405        self._profilenames = list(self._profiles.keys())
406        self._profilenames.sort(key=lambda x: x.lower())
407
408        if self._profilename in self._profilenames:
409            self._profileindex = self._profilenames.index(self._profilename)
410        else:
411            self._profileindex = 0
412            # noinspection PyUnresolvedReferences
413            self._profilename = self._profilenames[self._profileindex]

Reload all player profiles.

def update_position(self) -> None:
415    def update_position(self) -> None:
416        """Update this chooser's position."""
417
418        assert self._text_node
419        spacing = 350
420        sessionteams = self.lobby.sessionteams
421        offs = (spacing * -0.5 * len(sessionteams) +
422                spacing * self._selected_team_index + 250)
423        if len(sessionteams) > 1:
424            offs -= 35
425        animate_array(self._text_node, 'position', 2, {
426            0: self._text_node.position,
427            0.1: (-100 + offs, self._vpos + 23)
428        })
429        animate_array(self.icon, 'position', 2, {
430            0: self.icon.position,
431            0.1: (-130 + offs, self._vpos + 22)
432        })

Update this chooser's position.

def get_character_name(self) -> str:
434    def get_character_name(self) -> str:
435        """Return the selected character name."""
436        return self._character_names[self._character_index]

Return the selected character name.

def handlemessage(self, msg: Any) -> Any:
622    def handlemessage(self, msg: Any) -> Any:
623        """Standard generic message handler."""
624
625        if isinstance(msg, ChangeMessage):
626            self._handle_repeat_message_attack()
627
628            # If we've been removed from the lobby, ignore this stuff.
629            if self._dead:
630                print_error('chooser got ChangeMessage after dying')
631                return
632
633            if not self._text_node:
634                print_error('got ChangeMessage after nodes died')
635                return
636
637            if msg.what == 'team':
638                sessionteams = self.lobby.sessionteams
639                if len(sessionteams) > 1:
640                    _ba.playsound(self._swish_sound)
641                self._selected_team_index = (
642                    (self._selected_team_index + msg.value) %
643                    len(sessionteams))
644                self._update_text()
645                self.update_position()
646                self._update_icon()
647
648            elif msg.what == 'profileindex':
649                if len(self._profilenames) == 1:
650
651                    # This should be pretty hard to hit now with
652                    # automatic local accounts.
653                    _ba.playsound(_ba.getsound('error'))
654                else:
655
656                    # Pick the next player profile and assign our name
657                    # and character based on that.
658                    _ba.playsound(self._deek_sound)
659                    self._profileindex = ((self._profileindex + msg.value) %
660                                          len(self._profilenames))
661                    self.update_from_profile()
662
663            elif msg.what == 'character':
664                _ba.playsound(self._click_sound)
665                # update our index in our local list of characters
666                self._character_index = ((self._character_index + msg.value) %
667                                         len(self._character_names))
668                self._update_text()
669                self._update_icon()
670
671            elif msg.what == 'ready':
672                self._handle_ready_msg(bool(msg.value))

Standard generic message handler.

def get_color(self) -> Sequence[float]:
710    def get_color(self) -> Sequence[float]:
711        """Return the currently selected color."""
712        val: Sequence[float]
713        if self.lobby.use_team_colors:
714            val = self.lobby.sessionteams[self._selected_team_index].color
715        else:
716            val = self._color
717        if len(val) != 3:
718            print('get_color: ignoring invalid color of len', len(val))
719            val = (0, 1, 0)
720        return val

Return the currently selected color.

def get_highlight(self) -> Sequence[float]:
722    def get_highlight(self) -> Sequence[float]:
723        """Return the currently selected highlight."""
724        if self._profilenames[self._profileindex] == '_edit':
725            return 0, 1, 0
726
727        # If we're using team colors we wanna make sure our highlight color
728        # isn't too close to any other team's color.
729        highlight = list(self._highlight)
730        if self.lobby.use_team_colors:
731            for i, sessionteam in enumerate(self.lobby.sessionteams):
732                if i != self._selected_team_index:
733
734                    # Find the dominant component of this sessionteam's color
735                    # and adjust ours so that the component is
736                    # not super-dominant.
737                    max_val = 0.0
738                    max_index = 0
739                    for j in range(3):
740                        if sessionteam.color[j] > max_val:
741                            max_val = sessionteam.color[j]
742                            max_index = j
743                    that_color_for_us = highlight[max_index]
744                    our_second_biggest = max(highlight[(max_index + 1) % 3],
745                                             highlight[(max_index + 2) % 3])
746                    diff = (that_color_for_us - our_second_biggest)
747                    if diff > 0:
748                        highlight[max_index] -= diff * 0.6
749                        highlight[(max_index + 1) % 3] += diff * 0.3
750                        highlight[(max_index + 2) % 3] += diff * 0.2
751        return highlight

Return the currently selected highlight.

def getplayer(self) -> ba.SessionPlayer:
753    def getplayer(self) -> ba.SessionPlayer:
754        """Return the player associated with this chooser."""
755        return self._sessionplayer

Return the player associated with this chooser.

def clipboard_get_text() -> str:
1313def clipboard_get_text() -> str:
1314    """Return text currently on the system clipboard.
1315
1316    Category: **General Utility Functions**
1317
1318    Ensure that ba.clipboard_has_text() returns True before calling
1319     this function.
1320    """
1321    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:
1324def clipboard_has_text() -> bool:
1325    """Return whether there is currently text on the clipboard.
1326
1327    Category: **General Utility Functions**
1328
1329    This will return False if no system clipboard is available; no need
1330     to call ba.clipboard_is_supported() separately.
1331    """
1332    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:
1335def clipboard_is_supported() -> bool:
1336    """Return whether this platform supports clipboard operations at all.
1337
1338    Category: **General Utility Functions**
1339
1340    If this returns False, UIs should not show 'copy to clipboard'
1341    buttons, etc.
1342    """
1343    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:
1346def clipboard_set_text(value: str) -> None:
1347    """Copy a string to the system clipboard.
1348
1349    Category: **General Utility Functions**
1350
1351    Ensure that ba.clipboard_is_supported() returns True before adding
1352     buttons/etc. that make use of this functionality.
1353    """
1354    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:
77class CollideModel:
78    """A reference to a collide-model.
79
80    Category: **Asset Classes**
81
82    Use ba.getcollidemodel() to instantiate one.
83    """
84    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:
1357def columnwidget(edit: ba.Widget | None = None,
1358                 parent: ba.Widget | None = None,
1359                 size: Sequence[float] | None = None,
1360                 position: Sequence[float] | None = None,
1361                 background: bool | None = None,
1362                 selected_child: ba.Widget | None = None,
1363                 visible_child: ba.Widget | None = None,
1364                 single_depth: bool | None = None,
1365                 print_list_exit_instructions: bool | None = None,
1366                 left_border: float | None = None,
1367                 top_border: float | None = None,
1368                 bottom_border: float | None = None,
1369                 selection_loops_to_parent: bool | None = None,
1370                 border: float | None = None,
1371                 margin: float | None = None,
1372                 claims_left_right: bool | None = None,
1373                 claims_tab: bool | None = None) -> ba.Widget:
1374    """Create or edit a column widget.
1375
1376    Category: **User Interface Functions**
1377
1378    Pass a valid existing ba.Widget as 'edit' to modify it; otherwise
1379    a new one is created and returned. Arguments that are not set to None
1380    are applied to the Widget.
1381    """
1382    import ba  # pylint: disable=cyclic-import
1383    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:
1408def containerwidget(edit: ba.Widget | None = None,
1409                    parent: ba.Widget | None = None,
1410                    size: Sequence[float] | None = None,
1411                    position: Sequence[float] | None = None,
1412                    background: bool | None = None,
1413                    selected_child: ba.Widget | None = None,
1414                    transition: str | None = None,
1415                    cancel_button: ba.Widget | None = None,
1416                    start_button: ba.Widget | None = None,
1417                    root_selectable: bool | None = None,
1418                    on_activate_call: Callable[[], None] | None = None,
1419                    claims_left_right: bool | None = None,
1420                    claims_tab: bool | None = None,
1421                    selection_loops: bool | None = None,
1422                    selection_loops_to_parent: bool | None = None,
1423                    scale: float | None = None,
1424                    on_outside_click_call: Callable[[], None] | None = None,
1425                    single_depth: bool | None = None,
1426                    visible_child: ba.Widget | None = None,
1427                    stack_offset: Sequence[float] | None = None,
1428                    color: Sequence[float] | None = None,
1429                    on_cancel_call: Callable[[], None] | None = None,
1430                    print_list_exit_instructions: bool | None = None,
1431                    click_activate: bool | None = None,
1432                    always_highlight: bool | None = None,
1433                    selectable: bool | None = None,
1434                    scale_origin_stack_offset: Sequence[float] | None = None,
1435                    toolbar_visibility: str | None = None,
1436                    on_select_call: Callable[[], None] | None = None,
1437                    claim_outside_clicks: bool | None = None,
1438                    claims_up_down: bool | None = None) -> ba.Widget:
1439    """Create or edit a container widget.
1440
1441    Category: **User Interface Functions**
1442
1443    Pass a valid existing ba.Widget as 'edit' to modify it; otherwise
1444    a new one is created and returned. Arguments that are not set to None
1445    are applied to the Widget.
1446    """
1447    import ba  # pylint: disable=cyclic-import
1448    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:
 87class Context:
 88    """A game context state.
 89
 90    Category: **General Utility Classes**
 91
 92    Many operations such as ba.newnode() or ba.gettexture() operate
 93    implicitly on the current context. Each ba.Activity has its own
 94    Context and objects within that activity (nodes, media, etc) can only
 95    interact with other objects from that context.
 96
 97    In general, as a modder, you should not need to worry about contexts,
 98    since timers and other callbacks will take care of saving and
 99    restoring the context automatically, but there may be rare cases where
100    you need to deal with them, such as when loading media in for use in
101    the UI (there is a special `'ui'` context for all
102    user-interface-related functionality).
103
104    When instantiating a ba.Context instance, a single `'source'` argument
105    is passed, which can be one of the following strings/objects:
106
107    ###### `'empty'`
108    > Gives an empty context; it can be handy to run code here to ensure
109    it does no loading of media, creation of nodes, etc.
110
111    ###### `'current'`
112    > Sets the context object to the current context.
113
114    ###### `'ui'`
115    > Sets to the UI context. UI functions as well as loading of media to
116    be used in said functions must happen in the UI context.
117
118    ###### A ba.Activity instance
119    > Gives the context for the provided ba.Activity.
120      Most all code run during a game happens in an Activity's Context.
121
122    ###### A ba.Session instance
123    > Gives the context for the provided ba.Session.
124    Generally a user should not need to run anything here.
125
126
127    ##### Usage
128    Contexts are generally used with the python 'with' statement, which
129    sets the context as current on entry and resets it to the previous
130    value on exit.
131
132    ##### Example
133    Load a few textures into the UI context
134    (for use in widgets, etc):
135    >>> with ba.Context('ui'):
136    ...     tex1 = ba.gettexture('foo_tex_1')
137    ...     tex2 = ba.gettexture('foo_tex_2')
138    """
139
140    def __init__(self, source: Any):
141        pass
142
143    def __enter__(self) -> None:
144        """Support for "with" statement."""
145        pass
146
147    def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> Any:
148        """Support for "with" statement."""
149        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)
140    def __init__(self, source: Any):
141        pass
class ContextCall:
152class ContextCall:
153    """A context-preserving callable.
154
155    Category: **General Utility Classes**
156
157    A ContextCall wraps a callable object along with a reference
158    to the current context (see ba.Context); it handles restoring the
159    context when run and automatically clears itself if the context
160    it belongs to shuts down.
161
162    Generally you should not need to use this directly; all standard
163    Ballistica callbacks involved with timers, materials, UI functions,
164    etc. handle this under-the-hood you don't have to worry about it.
165    The only time it may be necessary is if you are implementing your
166    own callbacks, such as a worker thread that does some action and then
167    runs some game code when done. By wrapping said callback in one of
168    these, you can ensure that you will not inadvertently be keeping the
169    current activity alive or running code in a torn-down (expired)
170    context.
171
172    You can also use ba.WeakCall for similar functionality, but
173    ContextCall has the added bonus that it will not run during context
174    shutdown, whereas ba.WeakCall simply looks at whether the target
175    object still exists.
176
177    ##### Examples
178    **Example A:** code like this can inadvertently prevent our activity
179    (self) from ending until the operation completes, since the bound
180    method we're passing (self.dosomething) contains a strong-reference
181    to self).
182    >>> start_some_long_action(callback_when_done=self.dosomething)
183
184    **Example B:** in this case our activity (self) can still die
185    properly; the callback will clear itself when the activity starts
186    shutting down, becoming a harmless no-op and releasing the reference
187    to our activity.
188
189    >>> start_long_action(
190    ...     callback_when_done=ba.ContextCall(self.mycallback))
191    """
192
193    def __init__(self, call: Callable):
194        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)
193    def __init__(self, call: Callable):
194        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
args
class CloudSubsystem:
 23class CloudSubsystem:
 24    """Manages communication with cloud components."""
 25
 26    def is_connected(self) -> bool:
 27        """Return whether a connection to the cloud is present.
 28
 29        This is a good indicator (though not for certain) that sending
 30        messages will succeed.
 31        """
 32        return False  # Needs to be overridden
 33
 34    @overload
 35    def send_message_cb(
 36        self,
 37        msg: bacommon.cloud.LoginProxyRequestMessage,
 38        on_response: Callable[
 39            [bacommon.cloud.LoginProxyRequestResponse | Exception], None],
 40    ) -> None:
 41        ...
 42
 43    @overload
 44    def send_message_cb(
 45        self,
 46        msg: bacommon.cloud.LoginProxyStateQueryMessage,
 47        on_response: Callable[
 48            [bacommon.cloud.LoginProxyStateQueryResponse | Exception], None],
 49    ) -> None:
 50        ...
 51
 52    @overload
 53    def send_message_cb(
 54        self,
 55        msg: bacommon.cloud.LoginProxyCompleteMessage,
 56        on_response: Callable[[None | Exception], None],
 57    ) -> None:
 58        ...
 59
 60    @overload
 61    def send_message_cb(
 62        self,
 63        msg: bacommon.cloud.PingMessage,
 64        on_response: Callable[[bacommon.cloud.PingResponse | Exception], None],
 65    ) -> None:
 66        ...
 67
 68    def send_message_cb(
 69        self,
 70        msg: Message,
 71        on_response: Callable[[Any], None],
 72    ) -> None:
 73        """Asynchronously send a message to the cloud from the logic thread.
 74
 75        The provided on_response call will be run in the logic thread
 76        and passed either the response or the error that occurred.
 77        """
 78        from ba._general import Call
 79        del msg  # Unused.
 80
 81        _ba.pushcall(
 82            Call(on_response,
 83                 RuntimeError('Cloud functionality is not available.')))
 84
 85    @overload
 86    def send_message(
 87        self, msg: bacommon.cloud.WorkspaceFetchMessage
 88    ) -> bacommon.cloud.WorkspaceFetchResponse:
 89        ...
 90
 91    @overload
 92    def send_message(
 93            self,
 94            msg: bacommon.cloud.TestMessage) -> bacommon.cloud.TestResponse:
 95        ...
 96
 97    def send_message(self, msg: Message) -> Response | None:
 98        """Synchronously send a message to the cloud.
 99
100        Must be called from a background thread.
101        """
102        raise RuntimeError('Cloud functionality is not available.')

Manages communication with cloud components.

CloudSubsystem()
def is_connected(self) -> bool:
26    def is_connected(self) -> bool:
27        """Return whether a connection to the cloud is present.
28
29        This is a good indicator (though not for certain) that sending
30        messages will succeed.
31        """
32        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 send_message_cb( self, msg: efro.message.Message, on_response: Callable[[Any], NoneType]) -> None:
68    def send_message_cb(
69        self,
70        msg: Message,
71        on_response: Callable[[Any], None],
72    ) -> None:
73        """Asynchronously send a message to the cloud from the logic thread.
74
75        The provided on_response call will be run in the logic thread
76        and passed either the response or the error that occurred.
77        """
78        from ba._general import Call
79        del msg  # Unused.
80
81        _ba.pushcall(
82            Call(on_response,
83                 RuntimeError('Cloud functionality is not available.')))

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:
 97    def send_message(self, msg: Message) -> Response | None:
 98        """Synchronously send a message to the cloud.
 99
100        Must be called from a background thread.
101        """
102        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]):
 24class CoopGameActivity(GameActivity[PlayerType, TeamType]):
 25    """Base class for cooperative-mode games.
 26
 27    Category: **Gameplay Classes**
 28    """
 29
 30    # We can assume our session is a CoopSession.
 31    session: ba.CoopSession
 32
 33    @classmethod
 34    def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool:
 35        from ba._coopsession import CoopSession
 36        return issubclass(sessiontype, CoopSession)
 37
 38    def __init__(self, settings: dict):
 39        super().__init__(settings)
 40
 41        # Cache these for efficiency.
 42        self._achievements_awarded: set[str] = set()
 43
 44        self._life_warning_beep: ba.Actor | None = None
 45        self._life_warning_beep_timer: ba.Timer | None = None
 46        self._warn_beeps_sound = _ba.getsound('warnBeeps')
 47
 48    def on_begin(self) -> None:
 49        super().on_begin()
 50
 51        # Show achievements remaining.
 52        if not (_ba.app.demo_mode or _ba.app.arcade_mode):
 53            _ba.timer(3.8, WeakCall(self._show_remaining_achievements))
 54
 55        # Preload achievement images in case we get some.
 56        _ba.timer(2.0, WeakCall(self._preload_achievements))
 57
 58        # Let's ask the server for a 'time-to-beat' value.
 59        levelname = self._get_coop_level_name()
 60        campaign = self.session.campaign
 61        assert campaign is not None
 62        config_str = (str(len(self.players)) + 'p' + campaign.getlevel(
 63            self.settings_raw['name']).get_score_version_string().replace(
 64                ' ', '_'))
 65        _ba.get_scores_to_beat(levelname, config_str,
 66                               WeakCall(self._on_got_scores_to_beat))
 67
 68    def _on_got_scores_to_beat(self, scores: list[dict[str, Any]]) -> None:
 69        pass
 70
 71    def _show_standard_scores_to_beat_ui(self,
 72                                         scores: list[dict[str, Any]]) -> None:
 73        from efro.util import asserttype
 74        from ba._gameutils import timestring, animate
 75        from ba._nodeactor import NodeActor
 76        from ba._generated.enums import TimeFormat
 77        display_type = self.get_score_type()
 78        if scores is not None:
 79
 80            # Sort by originating date so that the most recent is first.
 81            scores.sort(reverse=True, key=lambda s: asserttype(s['time'], int))
 82
 83            # Now make a display for the most recent challenge.
 84            for score in scores:
 85                if score['type'] == 'score_challenge':
 86                    tval = (score['player'] + ':  ' + timestring(
 87                        int(score['value']) * 10,
 88                        timeformat=TimeFormat.MILLISECONDS).evaluate()
 89                            if display_type == 'time' else str(score['value']))
 90                    hattach = 'center' if display_type == 'time' else 'left'
 91                    halign = 'center' if display_type == 'time' else 'left'
 92                    pos = (20, -70) if display_type == 'time' else (20, -130)
 93                    txt = NodeActor(
 94                        _ba.newnode('text',
 95                                    attrs={
 96                                        'v_attach': 'top',
 97                                        'h_attach': hattach,
 98                                        'h_align': halign,
 99                                        'color': (0.7, 0.4, 1, 1),
100                                        'shadow': 0.5,
101                                        'flatness': 1.0,
102                                        'position': pos,
103                                        'scale': 0.6,
104                                        'text': tval
105                                    })).autoretain()
106                    assert txt.node is not None
107                    animate(txt.node, 'scale', {1.0: 0.0, 1.1: 0.7, 1.2: 0.6})
108                    break
109
110    # FIXME: this is now redundant with activityutils.getscoreconfig();
111    #  need to kill this.
112    def get_score_type(self) -> str:
113        """
114        Return the score unit this co-op game uses ('point', 'seconds', etc.)
115        """
116        return 'points'
117
118    def _get_coop_level_name(self) -> str:
119        assert self.session.campaign is not None
120        return self.session.campaign.name + ':' + str(
121            self.settings_raw['name'])
122
123    def celebrate(self, duration: float) -> None:
124        """Tells all existing player-controlled characters to celebrate.
125
126        Can be useful in co-op games when the good guys score or complete
127        a wave.
128        duration is given in seconds.
129        """
130        from ba._messages import CelebrateMessage
131        for player in self.players:
132            if player.actor:
133                player.actor.handlemessage(CelebrateMessage(duration))
134
135    def _preload_achievements(self) -> None:
136        achievements = _ba.app.ach.achievements_for_coop_level(
137            self._get_coop_level_name())
138        for ach in achievements:
139            ach.get_icon_texture(True)
140
141    def _show_remaining_achievements(self) -> None:
142        # pylint: disable=cyclic-import
143        from ba._language import Lstr
144        from bastd.actor.text import Text
145        ts_h_offs = 30
146        v_offs = -200
147        achievements = [
148            a for a in _ba.app.ach.achievements_for_coop_level(
149                self._get_coop_level_name()) if not a.complete
150        ]
151        vrmode = _ba.app.vr_mode
152        if achievements:
153            Text(Lstr(resource='achievementsRemainingText'),
154                 host_only=True,
155                 position=(ts_h_offs - 10 + 40, v_offs - 10),
156                 transition=Text.Transition.FADE_IN,
157                 scale=1.1,
158                 h_attach=Text.HAttach.LEFT,
159                 v_attach=Text.VAttach.TOP,
160                 color=(1, 1, 1.2, 1) if vrmode else (0.8, 0.8, 1.0, 1.0),
161                 flatness=1.0 if vrmode else 0.6,
162                 shadow=1.0 if vrmode else 0.5,
163                 transition_delay=0.0,
164                 transition_out_delay=1.3
165                 if self.slow_motion else 4.0).autoretain()
166            hval = 70
167            vval = -50
168            tdelay = 0.0
169            for ach in achievements:
170                tdelay += 0.05
171                ach.create_display(hval + 40,
172                                   vval + v_offs,
173                                   0 + tdelay,
174                                   outdelay=1.3 if self.slow_motion else 4.0,
175                                   style='in_game')
176                vval -= 55
177
178    def spawn_player_spaz(self,
179                          player: PlayerType,
180                          position: Sequence[float] = (0.0, 0.0, 0.0),
181                          angle: float | None = None) -> PlayerSpaz:
182        """Spawn and wire up a standard player spaz."""
183        spaz = super().spawn_player_spaz(player, position, angle)
184
185        # Deaths are noteworthy in co-op games.
186        spaz.play_big_death_sound = True
187        return spaz
188
189    def _award_achievement(self,
190                           achievement_name: str,
191                           sound: bool = True) -> None:
192        """Award an achievement.
193
194        Returns True if a banner will be shown;
195        False otherwise
196        """
197
198        if achievement_name in self._achievements_awarded:
199            return
200
201        ach = _ba.app.ach.get_achievement(achievement_name)
202
203        # If we're in the easy campaign and this achievement is hard-mode-only,
204        # ignore it.
205        try:
206            campaign = self.session.campaign
207            assert campaign is not None
208            if ach.hard_mode_only and campaign.name == 'Easy':
209                return
210        except Exception:
211            from ba._error import print_exception
212            print_exception()
213
214        # If we haven't awarded this one, check to see if we've got it.
215        # If not, set it through the game service *and* add a transaction
216        # for it.
217        if not ach.complete:
218            self._achievements_awarded.add(achievement_name)
219
220            # Report new achievements to the game-service.
221            _ba.report_achievement(achievement_name)
222
223            # ...and to our account.
224            _ba.add_transaction({
225                'type': 'ACHIEVEMENT',
226                'name': achievement_name
227            })
228
229            # Now bring up a celebration banner.
230            ach.announce_completion(sound=sound)
231
232    def fade_to_red(self) -> None:
233        """Fade the screen to red; (such as when the good guys have lost)."""
234        from ba import _gameutils
235        c_existing = self.globalsnode.tint
236        cnode = _ba.newnode('combine',
237                            attrs={
238                                'input0': c_existing[0],
239                                'input1': c_existing[1],
240                                'input2': c_existing[2],
241                                'size': 3
242                            })
243        _gameutils.animate(cnode, 'input1', {0: c_existing[1], 2.0: 0})
244        _gameutils.animate(cnode, 'input2', {0: c_existing[2], 2.0: 0})
245        cnode.connectattr('output', self.globalsnode, 'tint')
246
247    def setup_low_life_warning_sound(self) -> None:
248        """Set up a beeping noise to play when any players are near death."""
249        self._life_warning_beep = None
250        self._life_warning_beep_timer = _ba.Timer(
251            1.0, WeakCall(self._update_life_warning), repeat=True)
252
253    def _update_life_warning(self) -> None:
254        # Beep continuously if anyone is close to death.
255        should_beep = False
256        for player in self.players:
257            if player.is_alive():
258                # FIXME: Should abstract this instead of
259                #  reading hitpoints directly.
260                if getattr(player.actor, 'hitpoints', 999) < 200:
261                    should_beep = True
262                    break
263        if should_beep and self._life_warning_beep is None:
264            from ba._nodeactor import NodeActor
265            self._life_warning_beep = NodeActor(
266                _ba.newnode('sound',
267                            attrs={
268                                'sound': self._warn_beeps_sound,
269                                'positional': False,
270                                'loop': True
271                            }))
272        if self._life_warning_beep is not None and not should_beep:
273            self._life_warning_beep = None

Base class for cooperative-mode games.

Category: Gameplay Classes

CoopGameActivity(settings: dict)
38    def __init__(self, settings: dict):
39        super().__init__(settings)
40
41        # Cache these for efficiency.
42        self._achievements_awarded: set[str] = set()
43
44        self._life_warning_beep: ba.Actor | None = None
45        self._life_warning_beep_timer: ba.Timer | None = None
46        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:
33    @classmethod
34    def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool:
35        from ba._coopsession import CoopSession
36        return issubclass(sessiontype, CoopSession)

Return whether this game supports the provided Session type.

def on_begin(self) -> None:
48    def on_begin(self) -> None:
49        super().on_begin()
50
51        # Show achievements remaining.
52        if not (_ba.app.demo_mode or _ba.app.arcade_mode):
53            _ba.timer(3.8, WeakCall(self._show_remaining_achievements))
54
55        # Preload achievement images in case we get some.
56        _ba.timer(2.0, WeakCall(self._preload_achievements))
57
58        # Let's ask the server for a 'time-to-beat' value.
59        levelname = self._get_coop_level_name()
60        campaign = self.session.campaign
61        assert campaign is not None
62        config_str = (str(len(self.players)) + 'p' + campaign.getlevel(
63            self.settings_raw['name']).get_score_version_string().replace(
64                ' ', '_'))
65        _ba.get_scores_to_beat(levelname, config_str,
66                               WeakCall(self._on_got_scores_to_beat))

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:
112    def get_score_type(self) -> str:
113        """
114        Return the score unit this co-op game uses ('point', 'seconds', etc.)
115        """
116        return 'points'

Return the score unit this co-op game uses ('point', 'seconds', etc.)

def celebrate(self, duration: float) -> None:
123    def celebrate(self, duration: float) -> None:
124        """Tells all existing player-controlled characters to celebrate.
125
126        Can be useful in co-op games when the good guys score or complete
127        a wave.
128        duration is given in seconds.
129        """
130        from ba._messages import CelebrateMessage
131        for player in self.players:
132            if player.actor:
133                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:
178    def spawn_player_spaz(self,
179                          player: PlayerType,
180                          position: Sequence[float] = (0.0, 0.0, 0.0),
181                          angle: float | None = None) -> PlayerSpaz:
182        """Spawn and wire up a standard player spaz."""
183        spaz = super().spawn_player_spaz(player, position, angle)
184
185        # Deaths are noteworthy in co-op games.
186        spaz.play_big_death_sound = True
187        return spaz

Spawn and wire up a standard player spaz.

def fade_to_red(self) -> None:
232    def fade_to_red(self) -> None:
233        """Fade the screen to red; (such as when the good guys have lost)."""
234        from ba import _gameutils
235        c_existing = self.globalsnode.tint
236        cnode = _ba.newnode('combine',
237                            attrs={
238                                'input0': c_existing[0],
239                                'input1': c_existing[1],
240                                'input2': c_existing[2],
241                                'size': 3
242                            })
243        _gameutils.animate(cnode, 'input1', {0: c_existing[1], 2.0: 0})
244        _gameutils.animate(cnode, 'input2', {0: c_existing[2], 2.0: 0})
245        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:
247    def setup_low_life_warning_sound(self) -> None:
248        """Set up a beeping noise to play when any players are near death."""
249        self._life_warning_beep = None
250        self._life_warning_beep_timer = _ba.Timer(
251            1.0, WeakCall(self._update_life_warning), repeat=True)

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__(depsets,
 65                         team_names=TEAM_NAMES,
 66                         team_colors=TEAM_COLORS,
 67                         min_players=min_players,
 68                         max_players=max_players)
 69
 70        # Tournament-ID if we correspond to a co-op tournament (otherwise None)
 71        self.tournament_id: str | None = (
 72            app.coop_session_args.get('tournament_id'))
 73
 74        self.campaign = getcampaign(app.coop_session_args['campaign'])
 75        self.campaign_level_name: str = app.coop_session_args['level']
 76
 77        self._ran_tutorial_activity = False
 78        self._tutorial_activity: ba.Activity | None = None
 79        self._custom_menu_ui: list[dict[str, Any]] = []
 80
 81        # Start our joining screen.
 82        self.setactivity(_ba.newactivity(CoopJoinActivity))
 83
 84        self._next_game_instance: ba.GameActivity | None = None
 85        self._next_game_level_name: str | None = None
 86        self._update_on_deck_game_instances()
 87
 88    def get_current_game_instance(self) -> ba.GameActivity:
 89        """Get the game instance currently being played."""
 90        return self._current_game_instance
 91
 92    def should_allow_mid_activity_joins(self, activity: ba.Activity) -> bool:
 93        # pylint: disable=cyclic-import
 94        from ba._gameactivity import GameActivity
 95
 96        # Disallow any joins in the middle of the game.
 97        if isinstance(activity, GameActivity):
 98            return False
 99
100        return True
101
102    def _update_on_deck_game_instances(self) -> None:
103        # pylint: disable=cyclic-import
104        from ba._gameactivity import GameActivity
105
106        # Instantiate levels we may be running soon to let them load in the bg.
107
108        # Build an instance for the current level.
109        assert self.campaign is not None
110        level = self.campaign.getlevel(self.campaign_level_name)
111        gametype = level.gametype
112        settings = level.get_settings()
113
114        # Make sure all settings the game expects are present.
115        neededsettings = gametype.get_available_settings(type(self))
116        for setting in neededsettings:
117            if setting.name not in settings:
118                settings[setting.name] = setting.default
119
120        newactivity = _ba.newactivity(gametype, settings)
121        assert isinstance(newactivity, GameActivity)
122        self._current_game_instance: GameActivity = newactivity
123
124        # Find the next level and build an instance for it too.
125        levels = self.campaign.levels
126        level = self.campaign.getlevel(self.campaign_level_name)
127
128        nextlevel: ba.Level | None
129        if level.index < len(levels) - 1:
130            nextlevel = levels[level.index + 1]
131        else:
132            nextlevel = None
133        if nextlevel:
134            gametype = nextlevel.gametype
135            settings = nextlevel.get_settings()
136
137            # Make sure all settings the game expects are present.
138            neededsettings = gametype.get_available_settings(type(self))
139            for setting in neededsettings:
140                if setting.name not in settings:
141                    settings[setting.name] = setting.default
142
143            # We wanna be in the activity's context while taking it down.
144            newactivity = _ba.newactivity(gametype, settings)
145            assert isinstance(newactivity, GameActivity)
146            self._next_game_instance = newactivity
147            self._next_game_level_name = nextlevel.name
148        else:
149            self._next_game_instance = None
150            self._next_game_level_name = None
151
152        # Special case:
153        # If our current level is 'onslaught training', instantiate
154        # our tutorial so its ready to go. (if we haven't run it yet).
155        if (self.campaign_level_name == 'Onslaught Training'
156                and self._tutorial_activity is None
157                and not self._ran_tutorial_activity):
158            from bastd.tutorial import TutorialActivity
159            self._tutorial_activity = _ba.newactivity(TutorialActivity)
160
161    def get_custom_menu_entries(self) -> list[dict[str, Any]]:
162        return self._custom_menu_ui
163
164    def on_player_leave(self, sessionplayer: ba.SessionPlayer) -> None:
165        from ba._general import WeakCall
166        super().on_player_leave(sessionplayer)
167
168        _ba.timer(2.0, WeakCall(self._handle_empty_activity))
169
170    def _handle_empty_activity(self) -> None:
171        """Handle cases where all players have left the current activity."""
172
173        from ba._gameactivity import GameActivity
174        activity = self.getactivity()
175        if activity is None:
176            return  # Hmm what should we do in this case?
177
178        # If there are still players in the current activity, we're good.
179        if activity.players:
180            return
181
182        # If there are *not* players in the current activity but there
183        # *are* in the session:
184        if not activity.players and self.sessionplayers:
185
186            # If we're in a game, we should restart to pull in players
187            # currently waiting in the session.
188            if isinstance(activity, GameActivity):
189
190                # Never restart tourney games however; just end the session
191                # if all players are gone.
192                if self.tournament_id is not None:
193                    self.end()
194                else:
195                    self.restart()
196
197        # Hmm; no players anywhere. Let's end the entire session if we're
198        # running a GUI (or just the current game if we're running headless).
199        else:
200            if not _ba.app.headless_mode:
201                self.end()
202            else:
203                if isinstance(activity, GameActivity):
204                    with _ba.Context(activity):
205                        activity.end_game()
206
207    def _on_tournament_restart_menu_press(
208            self, resume_callback: Callable[[], Any]) -> None:
209        # pylint: disable=cyclic-import
210        from bastd.ui.tournamententry import TournamentEntryWindow
211        from ba._gameactivity import GameActivity
212        activity = self.getactivity()
213        if activity is not None and not activity.expired:
214            assert self.tournament_id is not None
215            assert isinstance(activity, GameActivity)
216            TournamentEntryWindow(tournament_id=self.tournament_id,
217                                  tournament_activity=activity,
218                                  on_close_call=resume_callback)
219
220    def restart(self) -> None:
221        """Restart the current game activity."""
222
223        # Tell the current activity to end with a 'restart' outcome.
224        # We use 'force' so that we apply even if end has already been called
225        # (but is in its delay period).
226
227        # Make an exception if there's no players left. Otherwise this
228        # can override the default session end that occurs in that case.
229        if not self.sessionplayers:
230            return
231
232        # This method may get called from the UI context so make sure we
233        # explicitly run in the activity's context.
234        activity = self.getactivity()
235        if activity is not None and not activity.expired:
236            activity.can_show_ad_on_death = True
237            with _ba.Context(activity):
238                activity.end(results={'outcome': 'restart'}, force=True)
239
240    # noinspection PyUnresolvedReferences
241    def on_activity_end(self, activity: ba.Activity, results: Any) -> None:
242        """Method override for co-op sessions.
243
244        Jumps between co-op games and score screens.
245        """
246        # pylint: disable=too-many-branches
247        # pylint: disable=too-many-locals
248        # pylint: disable=too-many-statements
249        # pylint: disable=cyclic-import
250        from ba._activitytypes import JoinActivity, TransitionActivity
251        from ba._language import Lstr
252        from ba._general import WeakCall
253        from ba._coopgame import CoopGameActivity
254        from ba._gameresults import GameResults
255        from ba._score import ScoreType
256        from ba._player import PlayerInfo
257        from bastd.tutorial import TutorialActivity
258        from bastd.activity.coopscore import CoopScoreScreen
259
260        app = _ba.app
261
262        # If we're running a TeamGameActivity we'll have a GameResults
263        # as results. Otherwise its an old CoopGameActivity so its giving
264        # us a dict of random stuff.
265        if isinstance(results, GameResults):
266            outcome = 'defeat'  # This can't be 'beaten'.
267        else:
268            outcome = '' if results is None else results.get('outcome', '')
269
270        # If we're running with a gui and at any point we have no
271        # in-game players, quit out of the session (this can happen if
272        # someone leaves in the tutorial for instance).
273        if not _ba.app.headless_mode:
274            active_players = [p for p in self.sessionplayers if p.in_game]
275            if not active_players:
276                self.end()
277                return
278
279        # If we're in a between-round activity or a restart-activity,
280        # hop into a round.
281        if (isinstance(activity,
282                       (JoinActivity, CoopScoreScreen, TransitionActivity))):
283
284            if outcome == 'next_level':
285                if self._next_game_instance is None:
286                    raise RuntimeError()
287                assert self._next_game_level_name is not None
288                self.campaign_level_name = self._next_game_level_name
289                next_game = self._next_game_instance
290            else:
291                next_game = self._current_game_instance
292
293            # Special case: if we're coming from a joining-activity
294            # and will be going into onslaught-training, show the
295            # tutorial first.
296            if (isinstance(activity, JoinActivity)
297                    and self.campaign_level_name == 'Onslaught Training'
298                    and not (app.demo_mode or app.arcade_mode)):
299                if self._tutorial_activity is None:
300                    raise RuntimeError('Tutorial not preloaded properly.')
301                self.setactivity(self._tutorial_activity)
302                self._tutorial_activity = None
303                self._ran_tutorial_activity = True
304                self._custom_menu_ui = []
305
306            # Normal case; launch the next round.
307            else:
308
309                # Reset stats for the new activity.
310                self.stats.reset()
311                for player in self.sessionplayers:
312
313                    # Skip players that are still choosing a team.
314                    if player.in_game:
315                        self.stats.register_sessionplayer(player)
316                self.stats.setactivity(next_game)
317
318                # Now flip the current activity..
319                self.setactivity(next_game)
320
321                if not (app.demo_mode or app.arcade_mode):
322                    if self.tournament_id is not None:
323                        self._custom_menu_ui = [{
324                            'label':
325                                Lstr(resource='restartText'),
326                            'resume_on_call':
327                                False,
328                            'call':
329                                WeakCall(self._on_tournament_restart_menu_press
330                                         )
331                        }]
332                    else:
333                        self._custom_menu_ui = [{
334                            'label': Lstr(resource='restartText'),
335                            'call': WeakCall(self.restart)
336                        }]
337
338        # If we were in a tutorial, just pop a transition to get to the
339        # actual round.
340        elif isinstance(activity, TutorialActivity):
341            self.setactivity(_ba.newactivity(TransitionActivity))
342        else:
343
344            playerinfos: list[ba.PlayerInfo]
345
346            # Generic team games.
347            if isinstance(results, GameResults):
348                playerinfos = results.playerinfos
349                score = results.get_sessionteam_score(results.sessionteams[0])
350                fail_message = None
351                score_order = ('decreasing'
352                               if results.lower_is_better else 'increasing')
353                if results.scoretype in (ScoreType.SECONDS,
354                                         ScoreType.MILLISECONDS):
355                    scoretype = 'time'
356
357                    # ScoreScreen wants hundredths of a second.
358                    if score is not None:
359                        if results.scoretype is ScoreType.SECONDS:
360                            score *= 100
361                        elif results.scoretype is ScoreType.MILLISECONDS:
362                            score //= 10
363                        else:
364                            raise RuntimeError('FIXME')
365                else:
366                    if results.scoretype is not ScoreType.POINTS:
367                        print(f'Unknown ScoreType:'
368                              f' "{results.scoretype}"')
369                    scoretype = 'points'
370
371            # Old coop-game-specific results; should migrate away from these.
372            else:
373                playerinfos = results.get('playerinfos')
374                score = results['score'] if 'score' in results else None
375                fail_message = (results['fail_message']
376                                if 'fail_message' in results else None)
377                score_order = (results['score_order']
378                               if 'score_order' in results else 'increasing')
379                activity_score_type = (activity.get_score_type() if isinstance(
380                    activity, CoopGameActivity) else None)
381                assert activity_score_type is not None
382                scoretype = activity_score_type
383
384            # Validate types.
385            if playerinfos is not None:
386                assert isinstance(playerinfos, list)
387                assert (isinstance(i, PlayerInfo) for i in playerinfos)
388
389            # Looks like we were in a round - check the outcome and
390            # go from there.
391            if outcome == 'restart':
392
393                # This will pop up back in the same round.
394                self.setactivity(_ba.newactivity(TransitionActivity))
395            else:
396                self.setactivity(
397                    _ba.newactivity(
398                        CoopScoreScreen, {
399                            'playerinfos': playerinfos,
400                            'score': score,
401                            'fail_message': fail_message,
402                            'score_order': score_order,
403                            'score_type': scoretype,
404                            'outcome': outcome,
405                            'campaign': self.campaign,
406                            'level': self.campaign_level_name
407                        }))
408
409        # No matter what, get the next 2 levels ready to go.
410        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__(depsets,
65                         team_names=TEAM_NAMES,
66                         team_colors=TEAM_COLORS,
67                         min_players=min_players,
68                         max_players=max_players)
69
70        # Tournament-ID if we correspond to a co-op tournament (otherwise None)
71        self.tournament_id: str | None = (
72            app.coop_session_args.get('tournament_id'))
73
74        self.campaign = getcampaign(app.coop_session_args['campaign'])
75        self.campaign_level_name: str = app.coop_session_args['level']
76
77        self._ran_tutorial_activity = False
78        self._tutorial_activity: ba.Activity | None = None
79        self._custom_menu_ui: list[dict[str, Any]] = []
80
81        # Start our joining screen.
82        self.setactivity(_ba.newactivity(CoopJoinActivity))
83
84        self._next_game_instance: ba.GameActivity | None = None
85        self._next_game_level_name: str | None = None
86        self._update_on_deck_game_instances()

Instantiate a co-op mode session.

use_teams: bool = 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: bool = 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.

allow_mid_activity_joins = False
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:
88    def get_current_game_instance(self) -> ba.GameActivity:
89        """Get the game instance currently being played."""
90        return self._current_game_instance

Get the game instance currently being played.

def should_allow_mid_activity_joins(self, activity: ba.Activity) -> bool:
 92    def should_allow_mid_activity_joins(self, activity: ba.Activity) -> bool:
 93        # pylint: disable=cyclic-import
 94        from ba._gameactivity import GameActivity
 95
 96        # Disallow any joins in the middle of the game.
 97        if isinstance(activity, GameActivity):
 98            return False
 99
100        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]]:
161    def get_custom_menu_entries(self) -> list[dict[str, Any]]:
162        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:
164    def on_player_leave(self, sessionplayer: ba.SessionPlayer) -> None:
165        from ba._general import WeakCall
166        super().on_player_leave(sessionplayer)
167
168        _ba.timer(2.0, WeakCall(self._handle_empty_activity))

Called when a previously-accepted ba.SessionPlayer leaves.

def restart(self) -> None:
220    def restart(self) -> None:
221        """Restart the current game activity."""
222
223        # Tell the current activity to end with a 'restart' outcome.
224        # We use 'force' so that we apply even if end has already been called
225        # (but is in its delay period).
226
227        # Make an exception if there's no players left. Otherwise this
228        # can override the default session end that occurs in that case.
229        if not self.sessionplayers:
230            return
231
232        # This method may get called from the UI context so make sure we
233        # explicitly run in the activity's context.
234        activity = self.getactivity()
235        if activity is not None and not activity.expired:
236            activity.can_show_ad_on_death = True
237            with _ba.Context(activity):
238                activity.end(results={'outcome': 'restart'}, force=True)

Restart the current game activity.

def on_activity_end(self, activity: ba.Activity, results: Any) -> None:
241    def on_activity_end(self, activity: ba.Activity, results: Any) -> None:
242        """Method override for co-op sessions.
243
244        Jumps between co-op games and score screens.
245        """
246        # pylint: disable=too-many-branches
247        # pylint: disable=too-many-locals
248        # pylint: disable=too-many-statements
249        # pylint: disable=cyclic-import
250        from ba._activitytypes import JoinActivity, TransitionActivity
251        from ba._language import Lstr
252        from ba._general import WeakCall
253        from ba._coopgame import CoopGameActivity
254        from ba._gameresults import GameResults
255        from ba._score import ScoreType
256        from ba._player import PlayerInfo
257        from bastd.tutorial import TutorialActivity
258        from bastd.activity.coopscore import CoopScoreScreen
259
260        app = _ba.app
261
262        # If we're running a TeamGameActivity we'll have a GameResults
263        # as results. Otherwise its an old CoopGameActivity so its giving
264        # us a dict of random stuff.
265        if isinstance(results, GameResults):
266            outcome = 'defeat'  # This can't be 'beaten'.
267        else:
268            outcome = '' if results is None else results.get('outcome', '')
269
270        # If we're running with a gui and at any point we have no
271        # in-game players, quit out of the session (this can happen if
272        # someone leaves in the tutorial for instance).
273        if not _ba.app.headless_mode:
274            active_players = [p for p in self.sessionplayers if p.in_game]
275            if not active_players:
276                self.end()
277                return
278
279        # If we're in a between-round activity or a restart-activity,
280        # hop into a round.
281        if (isinstance(activity,
282                       (JoinActivity, CoopScoreScreen, TransitionActivity))):
283
284            if outcome == 'next_level':
285                if self._next_game_instance is None:
286                    raise RuntimeError()
287                assert self._next_game_level_name is not None
288                self.campaign_level_name = self._next_game_level_name
289                next_game = self._next_game_instance
290            else:
291                next_game = self._current_game_instance
292
293            # Special case: if we're coming from a joining-activity
294            # and will be going into onslaught-training, show the
295            # tutorial first.
296            if (isinstance(activity, JoinActivity)
297                    and self.campaign_level_name == 'Onslaught Training'
298                    and not (app.demo_mode or app.arcade_mode)):
299                if self._tutorial_activity is None:
300                    raise RuntimeError('Tutorial not preloaded properly.')
301                self.setactivity(self._tutorial_activity)
302                self._tutorial_activity = None
303                self._ran_tutorial_activity = True
304                self._custom_menu_ui = []
305
306            # Normal case; launch the next round.
307            else:
308
309                # Reset stats for the new activity.
310                self.stats.reset()
311                for player in self.sessionplayers:
312
313                    # Skip players that are still choosing a team.
314                    if player.in_game:
315                        self.stats.register_sessionplayer(player)
316                self.stats.setactivity(next_game)
317
318                # Now flip the current activity..
319                self.setactivity(next_game)
320
321                if not (app.demo_mode or app.arcade_mode):
322                    if self.tournament_id is not None:
323                        self._custom_menu_ui = [{
324                            'label':
325                                Lstr(resource='restartText'),
326                            'resume_on_call':
327                                False,
328                            'call':
329                                WeakCall(self._on_tournament_restart_menu_press
330                                         )
331                        }]
332                    else:
333                        self._custom_menu_ui = [{
334                            'label': Lstr(resource='restartText'),
335                            'call': WeakCall(self.restart)
336                        }]
337
338        # If we were in a tutorial, just pop a transition to get to the
339        # actual round.
340        elif isinstance(activity, TutorialActivity):
341            self.setactivity(_ba.newactivity(TransitionActivity))
342        else:
343
344            playerinfos: list[ba.PlayerInfo]
345
346            # Generic team games.
347            if isinstance(results, GameResults):
348                playerinfos = results.playerinfos
349                score = results.get_sessionteam_score(results.sessionteams[0])
350                fail_message = None
351                score_order = ('decreasing'
352                               if results.lower_is_better else 'increasing')
353                if results.scoretype in (ScoreType.SECONDS,
354                                         ScoreType.MILLISECONDS):
355                    scoretype = 'time'
356
357                    # ScoreScreen wants hundredths of a second.
358                    if score is not None:
359                        if results.scoretype is ScoreType.SECONDS:
360                            score *= 100
361                        elif results.scoretype is ScoreType.MILLISECONDS:
362                            score //= 10
363                        else:
364                            raise RuntimeError('FIXME')
365                else:
366                    if results.scoretype is not ScoreType.POINTS:
367                        print(f'Unknown ScoreType:'
368                              f' "{results.scoretype}"')
369                    scoretype = 'points'
370
371            # Old coop-game-specific results; should migrate away from these.
372            else:
373                playerinfos = results.get('playerinfos')
374                score = results['score'] if 'score' in results else None
375                fail_message = (results['fail_message']
376                                if 'fail_message' in results else None)
377                score_order = (results['score_order']
378                               if 'score_order' in results else 'increasing')
379                activity_score_type = (activity.get_score_type() if isinstance(
380                    activity, CoopGameActivity) else None)
381                assert activity_score_type is not None
382                scoretype = activity_score_type
383
384            # Validate types.
385            if playerinfos is not None:
386                assert isinstance(playerinfos, list)
387                assert (isinstance(i, PlayerInfo) for i in playerinfos)
388
389            # Looks like we were in a round - check the outcome and
390            # go from there.
391            if outcome == 'restart':
392
393                # This will pop up back in the same round.
394                self.setactivity(_ba.newactivity(TransitionActivity))
395            else:
396                self.setactivity(
397                    _ba.newactivity(
398                        CoopScoreScreen, {
399                            'playerinfos': playerinfos,
400                            'score': score,
401                            'fail_message': fail_message,
402                            'score_order': score_order,
403                            'score_type': scoretype,
404                            'outcome': outcome,
405                            'campaign': self.campaign,
406                            'level': self.campaign_level_name
407                        }))
408
409        # No matter what, get the next 2 levels ready to go.
410        self._update_on_deck_game_instances()

Method override for co-op sessions.

Jumps between co-op games and score screens.

class Data:
197class Data:
198    """A reference to a data object.
199
200    Category: **Asset Classes**
201
202    Use ba.getdata() to instantiate one.
203    """
204
205    def getvalue(self) -> Any:
206        """Return the data object's value.
207
208        This can consist of anything representable by json (dicts, lists,
209        numbers, bools, None, etc).
210        Note that this call will block if the data has not yet been loaded,
211        so it can be beneficial to plan a short bit of time between when
212        the data object is requested and when it's value is accessed.
213        """
214        return _uninferrable()

A reference to a data object.

Category: Asset Classes

Use ba.getdata() to instantiate one.

Data()
def getvalue(self) -> Any:
205    def getvalue(self) -> Any:
206        """Return the data object's value.
207
208        This can consist of anything representable by json (dicts, lists,
209        numbers, bools, None, etc).
210        Note that this call will block if the data has not yet been loaded,
211        so it can be beneficial to plan a short bit of time between when
212        the data object is requested and when it's value is accessed.
213        """
214        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    GENERIC = 'generic'
43    OUT_OF_BOUNDS = 'out_of_bounds'
44    IMPACT = 'impact'
45    FALL = 'fall'
46    REACHED_GOAL = 'reached_goal'
47    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):
73class DelegateNotFoundError(NotFoundError):
74    """Exception raised when an expected delegate object does not exist.
75
76    Category: **Exception Classes**
77    """

Exception raised when an expected delegate object does not exist.

Category: Exception Classes

Inherited Members
builtins.Exception
Exception
builtins.BaseException
with_traceback
args
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        if self._hash is None:
49            self._hash = make_hash((self.cls, self.config))
50        return self._hash
51
52    def __get__(self, obj: Any, cls: Any = None) -> T:
53        if not isinstance(obj, DependencyComponent):
54            if obj is None:
55                raise TypeError(
56                    'Dependency must be accessed through an instance.')
57            raise TypeError(
58                f'Dependency cannot be added to class of type {type(obj)}'
59                ' (class must inherit from ba.DependencyComponent).')
60
61        # We expect to be instantiated from an already living
62        # DependencyComponent with valid dep-data in place..
63        assert cls is not None
64
65        # Get the DependencyEntry this instance is associated with and from
66        # there get back to the DependencySet
67        entry = getattr(obj, '_dep_entry')
68        if entry is None:
69            raise RuntimeError('Invalid dependency access.')
70        entry = entry()
71        assert isinstance(entry, DependencyEntry)
72        depset = entry.depset()
73        assert isinstance(depset, DependencySet)
74
75        if not depset.resolved:
76            raise RuntimeError(
77                "Can't access data on an unresolved DependencySet.")
78
79        # Look up the data in the set based on the hash for this Dependency.
80        assert self._hash in depset.entries
81        entry = depset.entries[self._hash]
82        assert isinstance(entry, DependencyEntry)
83        retval = entry.get_component()
84        assert isinstance(retval, self.cls)
85        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        if self._hash is None:
49            self._hash = make_hash((self.cls, self.config))
50        return self._hash

Return the dependency's hash, calculating it if necessary.

class DependencyComponent:
 88class DependencyComponent:
 89    """Base class for all classes that can act as or use dependencies.
 90
 91    Category: **Dependency Classes**
 92    """
 93
 94    _dep_entry: weakref.ref[DependencyEntry]
 95
 96    def __init__(self) -> None:
 97        """Instantiate a DependencyComponent."""
 98
 99        # For now lets issue a warning if these are instantiated without
100        # a dep-entry; we'll make this an error once we're no longer
101        # seeing warnings.
102        # entry = getattr(self, '_dep_entry', None)
103        # if entry is None:
104        #     print(f'FIXME: INSTANTIATING DEP CLASS {type(self)} DIRECTLY.')
105
106    @classmethod
107    def dep_is_present(cls, config: Any = None) -> bool:
108        """Return whether this component/config is present on this device."""
109        del config  # Unused here.
110        return True
111
112    @classmethod
113    def get_dynamic_deps(cls, config: Any = None) -> list[Dependency]:
114        """Return any dynamically-calculated deps for this component/config.
115
116        Deps declared statically as part of the class do not need to be
117        included here; this is only for additional deps that may vary based
118        on the dep config value. (for instance a map required by a game type)
119        """
120        del config  # Unused here.
121        return []

Base class for all classes that can act as or use dependencies.

Category: Dependency Classes

DependencyComponent()
 96    def __init__(self) -> None:
 97        """Instantiate a DependencyComponent."""
 98
 99        # For now lets issue a warning if these are instantiated without
100        # a dep-entry; we'll make this an error once we're no longer
101        # seeing warnings.
102        # entry = getattr(self, '_dep_entry', None)
103        # if entry is None:
104        #     print(f'FIXME: INSTANTIATING DEP CLASS {type(self)} DIRECTLY.')

Instantiate a DependencyComponent.

@classmethod
def dep_is_present(cls, config: Any = None) -> bool:
106    @classmethod
107    def dep_is_present(cls, config: Any = None) -> bool:
108        """Return whether this component/config is present on this device."""
109        del config  # Unused here.
110        return True

Return whether this component/config is present on this device.

@classmethod
def get_dynamic_deps(cls, config: Any = None) -> list[ba.Dependency]:
112    @classmethod
113    def get_dynamic_deps(cls, config: Any = None) -> list[Dependency]:
114        """Return any dynamically-calculated deps for this component/config.
115
116        Deps declared statically as part of the class do not need to be
117        included here; this is only for additional deps that may vary based
118        on the dep config value. (for instance a map required by a game type)
119        """
120        del config  # Unused here.
121        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
args
class DependencySet(typing.Generic[~T]):
166class DependencySet(Generic[T]):
167    """Set of resolved dependencies and their associated data.
168
169    Category: **Dependency Classes**
170
171    To use DependencyComponents, a set must be created, resolved, and then
172    loaded. The DependencyComponents are only valid while the set remains
173    in existence.
174    """
175
176    def __init__(self, root_dependency: Dependency[T]):
177        # print('DepSet()')
178        self._root_dependency = root_dependency
179        self._resolved = False
180        self._loaded = False
181
182        # Dependency data indexed by hash.
183        self.entries: dict[int, DependencyEntry] = {}
184
185    # def __del__(self) -> None:
186    #     print("~DepSet()")
187
188    def resolve(self) -> None:
189        """Resolve the complete set of required dependencies for this set.
190
191        Raises a ba.DependencyError if dependencies are missing (or other
192        Exception types on other errors).
193        """
194
195        if self._resolved:
196            raise Exception('DependencySet has already been resolved.')
197
198        # print('RESOLVING DEP SET')
199
200        # First, recursively expand out all dependencies.
201        self._resolve(self._root_dependency, 0)
202
203        # Now, if any dependencies are not present, raise an Exception
204        # telling exactly which ones (so hopefully they'll be able to be
205        # downloaded/etc.
206        missing = [
207            Dependency(entry.cls, entry.config)
208            for entry in self.entries.values()
209            if not entry.cls.dep_is_present(entry.config)
210        ]
211        if missing:
212            from ba._error import DependencyError
213            raise DependencyError(missing)
214
215        self._resolved = True
216        # print('RESOLVE SUCCESS!')
217
218    @property
219    def resolved(self) -> bool:
220        """Whether this set has been successfully resolved."""
221        return self._resolved
222
223    def get_asset_package_ids(self) -> set[str]:
224        """Return the set of asset-package-ids required by this dep-set.
225
226        Must be called on a resolved dep-set.
227        """
228        ids: set[str] = set()
229        if not self._resolved:
230            raise Exception('Must be called on a resolved dep-set.')
231        for entry in self.entries.values():
232            if issubclass(entry.cls, AssetPackage):
233                assert isinstance(entry.config, str)
234                ids.add(entry.config)
235        return ids
236
237    def load(self) -> None:
238        """Instantiate all DependencyComponents in the set.
239
240        Returns a wrapper which can be used to instantiate the root dep.
241        """
242        # NOTE: stuff below here should probably go in a separate 'instantiate'
243        # method or something.
244        if not self._resolved:
245            raise RuntimeError("Can't load an unresolved DependencySet")
246
247        for entry in self.entries.values():
248            # Do a get on everything which will init all payloads
249            # in the proper order recursively.
250            entry.get_component()
251
252        self._loaded = True
253
254    @property
255    def root(self) -> T:
256        """The instantiated root DependencyComponent instance for the set."""
257        if not self._loaded:
258            raise RuntimeError('DependencySet is not loaded.')
259
260        rootdata = self.entries[self._root_dependency.get_hash()].component
261        assert isinstance(rootdata, self._root_dependency.cls)
262        return rootdata
263
264    def _resolve(self, dep: Dependency[T], recursion: int) -> None:
265
266        # Watch for wacky infinite dep loops.
267        if recursion > 10:
268            raise RecursionError('Max recursion reached')
269
270        hashval = dep.get_hash()
271
272        if hashval in self.entries:
273            # Found an already resolved one; we're done here.
274            return
275
276        # Add our entry before we recurse so we don't repeat add it if
277        # there's a dependency loop.
278        self.entries[hashval] = DependencyEntry(self, dep)
279
280        # Grab all Dependency instances we find in the class.
281        subdeps = [
282            cls for cls in dep.cls.__dict__.values()
283            if isinstance(cls, Dependency)
284        ]
285
286        # ..and add in any dynamic ones it provides.
287        subdeps += dep.cls.get_dynamic_deps(dep.config)
288        for subdep in subdeps:
289            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])
176    def __init__(self, root_dependency: Dependency[T]):
177        # print('DepSet()')
178        self._root_dependency = root_dependency
179        self._resolved = False
180        self._loaded = False
181
182        # Dependency data indexed by hash.
183        self.entries: dict[int, DependencyEntry] = {}
def resolve(self) -> None:
188    def resolve(self) -> None:
189        """Resolve the complete set of required dependencies for this set.
190
191        Raises a ba.DependencyError if dependencies are missing (or other
192        Exception types on other errors).
193        """
194
195        if self._resolved:
196            raise Exception('DependencySet has already been resolved.')
197
198        # print('RESOLVING DEP SET')
199
200        # First, recursively expand out all dependencies.
201        self._resolve(self._root_dependency, 0)
202
203        # Now, if any dependencies are not present, raise an Exception
204        # telling exactly which ones (so hopefully they'll be able to be
205        # downloaded/etc.
206        missing = [
207            Dependency(entry.cls, entry.config)
208            for entry in self.entries.values()
209            if not entry.cls.dep_is_present(entry.config)
210        ]
211        if missing:
212            from ba._error import DependencyError
213            raise DependencyError(missing)
214
215        self._resolved = True
216        # 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]:
223    def get_asset_package_ids(self) -> set[str]:
224        """Return the set of asset-package-ids required by this dep-set.
225
226        Must be called on a resolved dep-set.
227        """
228        ids: set[str] = set()
229        if not self._resolved:
230            raise Exception('Must be called on a resolved dep-set.')
231        for entry in self.entries.values():
232            if issubclass(entry.cls, AssetPackage):
233                assert isinstance(entry.config, str)
234                ids.add(entry.config)
235        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:
237    def load(self) -> None:
238        """Instantiate all DependencyComponents in the set.
239
240        Returns a wrapper which can be used to instantiate the root dep.
241        """
242        # NOTE: stuff below here should probably go in a separate 'instantiate'
243        # method or something.
244        if not self._resolved:
245            raise RuntimeError("Can't load an unresolved DependencySet")
246
247        for entry in self.entries.values():
248            # Do a get on everything which will init all payloads
249            # in the proper order recursively.
250            entry.get_component()
251
252        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:
50@dataclass
51class DieMessage:
52    """A message telling an object to die.
53
54    Category: **Message Classes**
55
56    Most ba.Actor-s respond to this.
57    """
58
59    immediate: bool = False
60    """If this is set to True, the actor should disappear immediately.
61       This is for 'removing' stuff from the game more so than 'killing'
62       it. If False, the actor should die a 'normal' death and can take
63       its time with lingering corpses, sound effects, etc."""
64
65    how: DeathType = DeathType.GENERIC
66    """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:
1477def do_once() -> bool:
1478    """Return whether this is the first time running a line of code.
1479
1480    Category: **General Utility Functions**
1481
1482    This is used by 'print_once()' type calls to keep from overflowing
1483    logs. The call functions by registering the filename and line where
1484    The call is made from.  Returns True if this location has not been
1485    registered already, and False if it has.
1486
1487    ##### Example
1488    This print will only fire for the first loop iteration:
1489    >>> for i in range(10):
1490    ... if ba.do_once():
1491    ...     print('Hello once from loop!')
1492    """
1493    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:
152@dataclass
153class DropMessage:
154    """Tells an object that it has dropped what it was holding.
155
156    Category: **Message Classes**
157    """

Tells an object that it has dropped what it was holding.

Category: Message Classes

DropMessage()
@dataclass
class DroppedMessage:
171@dataclass
172class DroppedMessage:
173    """Tells an object that it has been dropped.
174
175    Category: **Message Classes**
176    """
177
178    node: ba.Node
179    """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 (
38            TeamVictoryScoreScreenActivity)
39        from bastd.activity.multiteamvictory import (
40            TeamSeriesVictoryScoreScreenActivity)
41        winnergroups = results.winnergroups
42
43        # If everyone has the same score, call it a draw.
44        if len(winnergroups) < 2:
45            self.setactivity(_ba.newactivity(DrawScoreScreenActivity))
46        else:
47            winner = winnergroups[0].teams[0]
48            winner.customdata['score'] += 1
49
50            # If a team has won, show final victory screen.
51            if winner.customdata['score'] >= (self._series_length - 1) / 2 + 1:
52                self.setactivity(
53                    _ba.newactivity(TeamSeriesVictoryScoreScreenActivity,
54                                    {'winner': winner}))
55            else:
56                self.setactivity(
57                    _ba.newactivity(TeamVictoryScoreScreenActivity,
58                                    {'winner': winner}))

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: bool = 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: 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.

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:
1501def emitfx(position: Sequence[float],
1502           velocity: Sequence[float] | None = None,
1503           count: int = 10,
1504           scale: float = 1.0,
1505           spread: float = 1.0,
1506           chunk_type: str = 'rock',
1507           emit_type: str = 'chunks',
1508           tendril_type: str = 'smoke') -> None:
1509    """Emit particles, smoke, etc. into the fx sim layer.
1510
1511    Category: **Gameplay Functions**
1512
1513    The fx sim layer is a secondary dynamics simulation that runs in
1514    the background and just looks pretty; it does not affect gameplay.
1515    Note that the actual amount emitted may vary depending on graphics
1516    settings, exiting element counts, or other factors.
1517    """
1518    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')]):
274class EmptyPlayer(Player['ba.EmptyTeam']):
275    """An empty player for use by Activities that don't need to define one.
276
277    Category: Gameplay Classes
278
279    ba.Player and ba.Team are 'Generic' types, and so passing those top level
280    classes as type arguments when defining a ba.Activity reduces type safety.
281    For example, activity.teams[0].player will have type 'Any' in that case.
282    For that reason, it is better to pass EmptyPlayer and EmptyTeam when
283    defining a ba.Activity that does not need custom types of its own.
284
285    Note that EmptyPlayer defines its team type as EmptyTeam and vice versa,
286    so if you want to define your own class for one of them you should do so
287    for both.
288    """

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')]):
193class EmptyTeam(Team['ba.EmptyPlayer']):
194    """An empty player for use by Activities that don't need to define one.
195
196    Category: **Gameplay Classes**
197
198    ba.Player and ba.Team are 'Generic' types, and so passing those top level
199    classes as type arguments when defining a ba.Activity reduces type safety.
200    For example, activity.teams[0].player will have type 'Any' in that case.
201    For that reason, it is better to pass EmptyPlayer and EmptyTeam when
202    defining a ba.Activity that does not need custom types of its own.
203
204    Note that EmptyPlayer defines its team type as EmptyTeam and vice versa,
205    so if you want to define your own class for one of them you should do so
206    for both.
207    """

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):
25class Existable(Protocol):
26    """A Protocol for objects supporting an exists() method.
27
28    Category: **Protocols**
29    """
30
31    def exists(self) -> bool:
32        """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:
31    def exists(self) -> bool:
32        """Whether this object exists."""

Whether this object exists.

def existing(obj: Optional[~ExistableType]) -> Optional[~ExistableType]:
41def existing(obj: ExistableType | None) -> ExistableType | None:
42    """Convert invalid references to None for any ba.Existable object.
43
44    Category: **Gameplay Functions**
45
46    To best support type checking, it is important that invalid references
47    not be passed around and instead get converted to values of None.
48    That way the type checker can properly flag attempts to pass possibly-dead
49    objects (FooType | None) into functions expecting only live ones
50    (FooType), etc. This call can be used on any 'existable' object
51    (one with an exists() method) and will convert it to a None value
52    if it does not exist.
53
54    For more info, see notes on 'existables' here:
55    https://ballistica.net/wiki/Coding-Style-Guide
56    """
57    assert obj is None or hasattr(obj, 'exists'), f'No "exists" on {obj}'
58    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):
78@dataclass
79class FloatChoiceSetting(ChoiceSetting):
80    """A float setting with multiple choices.
81
82    Category: Settings Classes
83    """
84    default: float
85    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):
47@dataclass
48class FloatSetting(Setting):
49    """A floating point game setting.
50
51    Category: Settings Classes
52    """
53    default: float
54    min_value: float = 0.0
55    max_value: float = 9999.0
56    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)
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    use_teams = False
23    use_team_colors = False
24    _playlist_selection_var = 'Free-for-All Playlist Selection'
25    _playlist_randomize_var = 'Free-for-All Playlist Randomize'
26    _playlists_var = 'Free-for-All Playlists'
27
28    def get_ffa_point_awards(self) -> dict[int, int]:
29        """Return the number of points awarded for different rankings.
30
31        This is based on the current number of players.
32        """
33        point_awards: dict[int, int]
34        if len(self.sessionplayers) == 1:
35            point_awards = {}
36        elif len(self.sessionplayers) == 2:
37            point_awards = {0: 6}
38        elif len(self.sessionplayers) == 3:
39            point_awards = {0: 6, 1: 3}
40        elif len(self.sessionplayers) == 4:
41            point_awards = {0: 8, 1: 4, 2: 2}
42        elif len(self.sessionplayers) == 5:
43            point_awards = {0: 8, 1: 4, 2: 2}
44        elif len(self.sessionplayers) == 6:
45            point_awards = {0: 8, 1: 4, 2: 2}
46        else:
47            point_awards = {0: 8, 1: 4, 2: 2, 3: 1}
48        return point_awards
49
50    def __init__(self) -> None:
51        _ba.increment_analytics_count('Free-for-all session start')
52        super().__init__()
53
54    def _switch_to_score_screen(self, results: ba.GameResults) -> None:
55        # pylint: disable=cyclic-import
56        from efro.util import asserttype
57        from bastd.activity.drawscore import DrawScoreScreenActivity
58        from bastd.activity.multiteamvictory import (
59            TeamSeriesVictoryScoreScreenActivity)
60        from bastd.activity.freeforallvictory import (
61            FreeForAllVictoryScoreScreenActivity)
62        winners = results.winnergroups
63
64        # If there's multiple players and everyone has the same score,
65        # call it a draw.
66        if len(self.sessionplayers) > 1 and len(winners) < 2:
67            self.setactivity(
68                _ba.newactivity(DrawScoreScreenActivity, {'results': results}))
69        else:
70            # Award different point amounts based on number of players.
71            point_awards = self.get_ffa_point_awards()
72
73            for i, winner in enumerate(winners):
74                for team in winner.teams:
75                    points = (point_awards[i] if i in point_awards else 0)
76                    team.customdata['previous_score'] = (
77                        team.customdata['score'])
78                    team.customdata['score'] += points
79
80            series_winners = [
81                team for team in self.sessionteams
82                if team.customdata['score'] >= self._ffa_series_length
83            ]
84            series_winners.sort(
85                reverse=True,
86                key=lambda t: asserttype(t.customdata['score'], int))
87            if (len(series_winners) == 1
88                    or (len(series_winners) > 1
89                        and series_winners[0].customdata['score'] !=
90                        series_winners[1].customdata['score'])):
91                self.setactivity(
92                    _ba.newactivity(TeamSeriesVictoryScoreScreenActivity,
93                                    {'winner': series_winners[0]}))
94            else:
95                self.setactivity(
96                    _ba.newactivity(FreeForAllVictoryScoreScreenActivity,
97                                    {'results': results}))

ba.Session type for free-for-all mode games.

Category: Gameplay Classes

FreeForAllSession()
50    def __init__(self) -> None:
51        _ba.increment_analytics_count('Free-for-all session start')
52        super().__init__()

Set up playlists and launches a ba.Activity to accept joiners.

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 = 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]:
28    def get_ffa_point_awards(self) -> dict[int, int]:
29        """Return the number of points awarded for different rankings.
30
31        This is based on the current number of players.
32        """
33        point_awards: dict[int, int]
34        if len(self.sessionplayers) == 1:
35            point_awards = {}
36        elif len(self.sessionplayers) == 2:
37            point_awards = {0: 6}
38        elif len(self.sessionplayers) == 3:
39            point_awards = {0: 6, 1: 3}
40        elif len(self.sessionplayers) == 4:
41            point_awards = {0: 8, 1: 4, 2: 2}
42        elif len(self.sessionplayers) == 5:
43            point_awards = {0: 8, 1: 4, 2: 2}
44        elif len(self.sessionplayers) == 6:
45            point_awards = {0: 8, 1: 4, 2: 2}
46        else:
47            point_awards = {0: 8, 1: 4, 2: 2, 3: 1}
48        return point_awards

Return the number of points awarded for different rankings.

This is based on the current number of players.

@dataclass
class FreezeMessage:
201@dataclass
202class FreezeMessage:
203    """Tells an object to become frozen.
204
205    Category: **Message Classes**
206
207    As seen in the effects of an ice ba.Bomb.
208    """

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]):
  35class GameActivity(Activity[PlayerType, TeamType]):
  36    """Common base class for all game ba.Activities.
  37
  38    Category: **Gameplay Classes**
  39    """
  40    # pylint: disable=too-many-public-methods
  41
  42    # Tips to be presented to the user at the start of the game.
  43    tips: list[str | ba.GameTip] = []
  44
  45    # Default getname() will return this if not None.
  46    name: str | None = None
  47
  48    # Default get_description() will return this if not None.
  49    description: str | None = None
  50
  51    # Default get_available_settings() will return this if not None.
  52    available_settings: list[ba.Setting] | None = None
  53
  54    # Default getscoreconfig() will return this if not None.
  55    scoreconfig: ba.ScoreConfig | None = None
  56
  57    # Override some defaults.
  58    allow_pausing = True
  59    allow_kick_idle_players = True
  60
  61    # Whether to show points for kills.
  62    show_kill_points = True
  63
  64    # If not None, the music type that should play in on_transition_in()
  65    # (unless overridden by the map).
  66    default_music: ba.MusicType | None = None
  67
  68    @classmethod
  69    def create_settings_ui(
  70        cls,
  71        sessiontype: type[ba.Session],
  72        settings: dict | None,
  73        completion_call: Callable[[dict | None], None],
  74    ) -> None:
  75        """Launch an in-game UI to configure settings for a game type.
  76
  77        'sessiontype' should be the ba.Session class the game will be used in.
  78
  79        'settings' should be an existing settings dict (implies 'edit'
  80          ui mode) or None (implies 'add' ui mode).
  81
  82        'completion_call' will be called with a filled-out settings dict on
  83          success or None on cancel.
  84
  85        Generally subclasses don't need to override this; if they override
  86        ba.GameActivity.get_available_settings() and
  87        ba.GameActivity.get_supported_maps() they can just rely on
  88        the default implementation here which calls those methods.
  89        """
  90        delegate = _ba.app.delegate
  91        assert delegate is not None
  92        delegate.create_default_game_settings_ui(cls, sessiontype, settings,
  93                                                 completion_call)
  94
  95    @classmethod
  96    def getscoreconfig(cls) -> ba.ScoreConfig:
  97        """Return info about game scoring setup; can be overridden by games."""
  98        return (cls.scoreconfig
  99                if cls.scoreconfig is not None else ScoreConfig())
 100
 101    @classmethod
 102    def getname(cls) -> str:
 103        """Return a str name for this game type.
 104
 105        This default implementation simply returns the 'name' class attr.
 106        """
 107        return cls.name if cls.name is not None else 'Untitled Game'
 108
 109    @classmethod
 110    def get_display_string(cls, settings: dict | None = None) -> ba.Lstr:
 111        """Return a descriptive name for this game/settings combo.
 112
 113        Subclasses should override getname(); not this.
 114        """
 115        name = Lstr(translate=('gameNames', cls.getname()))
 116
 117        # A few substitutions for 'Epic', 'Solo' etc. modes.
 118        # FIXME: Should provide a way for game types to define filters of
 119        #  their own and should not rely on hard-coded settings names.
 120        if settings is not None:
 121            if 'Solo Mode' in settings and settings['Solo Mode']:
 122                name = Lstr(resource='soloNameFilterText',
 123                            subs=[('${NAME}', name)])
 124            if 'Epic Mode' in settings and settings['Epic Mode']:
 125                name = Lstr(resource='epicNameFilterText',
 126                            subs=[('${NAME}', name)])
 127
 128        return name
 129
 130    @classmethod
 131    def get_team_display_string(cls, name: str) -> ba.Lstr:
 132        """Given a team name, returns a localized version of it."""
 133        return Lstr(translate=('teamNames', name))
 134
 135    @classmethod
 136    def get_description(cls, sessiontype: type[ba.Session]) -> str:
 137        """Get a str description of this game type.
 138
 139        The default implementation simply returns the 'description' class var.
 140        Classes which want to change their description depending on the session
 141        can override this method.
 142        """
 143        del sessiontype  # Unused arg.
 144        return cls.description if cls.description is not None else ''
 145
 146    @classmethod
 147    def get_description_display_string(
 148            cls, sessiontype: type[ba.Session]) -> ba.Lstr:
 149        """Return a translated version of get_description().
 150
 151        Sub-classes should override get_description(); not this.
 152        """
 153        description = cls.get_description(sessiontype)
 154        return Lstr(translate=('gameDescriptions', description))
 155
 156    @classmethod
 157    def get_available_settings(
 158            cls, sessiontype: type[ba.Session]) -> list[ba.Setting]:
 159        """Return a list of settings relevant to this game type when
 160        running under the provided session type.
 161        """
 162        del sessiontype  # Unused arg.
 163        return [] if cls.available_settings is None else cls.available_settings
 164
 165    @classmethod
 166    def get_supported_maps(cls, sessiontype: type[ba.Session]) -> list[str]:
 167        """
 168        Called by the default ba.GameActivity.create_settings_ui()
 169        implementation; should return a list of map names valid
 170        for this game-type for the given ba.Session type.
 171        """
 172        del sessiontype  # Unused arg.
 173        return _map.getmaps('melee')
 174
 175    @classmethod
 176    def get_settings_display_string(cls, config: dict[str, Any]) -> ba.Lstr:
 177        """Given a game config dict, return a short description for it.
 178
 179        This is used when viewing game-lists or showing what game
 180        is up next in a series.
 181        """
 182        name = cls.get_display_string(config['settings'])
 183
 184        # In newer configs, map is in settings; it used to be in the
 185        # config root.
 186        if 'map' in config['settings']:
 187            sval = Lstr(value='${NAME} @ ${MAP}',
 188                        subs=[('${NAME}', name),
 189                              ('${MAP}',
 190                               _map.get_map_display_string(
 191                                   _map.get_filtered_map_name(
 192                                       config['settings']['map'])))])
 193        elif 'map' in config:
 194            sval = Lstr(value='${NAME} @ ${MAP}',
 195                        subs=[('${NAME}', name),
 196                              ('${MAP}',
 197                               _map.get_map_display_string(
 198                                   _map.get_filtered_map_name(config['map'])))
 199                              ])
 200        else:
 201            print('invalid game config - expected map entry under settings')
 202            sval = Lstr(value='???')
 203        return sval
 204
 205    @classmethod
 206    def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool:
 207        """Return whether this game supports the provided Session type."""
 208        from ba._multiteamsession import MultiTeamSession
 209
 210        # By default, games support any versus mode
 211        return issubclass(sessiontype, MultiTeamSession)
 212
 213    def __init__(self, settings: dict):
 214        """Instantiate the Activity."""
 215        super().__init__(settings)
 216
 217        # Holds some flattened info about the player set at the point
 218        # when on_begin() is called.
 219        self.initialplayerinfos: list[ba.PlayerInfo] | None = None
 220
 221        # Go ahead and get our map loading.
 222        self._map_type = _map.get_map_class(self._calc_map_name(settings))
 223
 224        self._spawn_sound = _ba.getsound('spawn')
 225        self._map_type.preload()
 226        self._map: ba.Map | None = None
 227        self._powerup_drop_timer: ba.Timer | None = None
 228        self._tnt_spawners: dict[int, TNTSpawner] | None = None
 229        self._tnt_drop_timer: ba.Timer | None = None
 230        self._game_scoreboard_name_text: ba.Actor | None = None
 231        self._game_scoreboard_description_text: ba.Actor | None = None
 232        self._standard_time_limit_time: int | None = None
 233        self._standard_time_limit_timer: ba.Timer | None = None
 234        self._standard_time_limit_text: ba.NodeActor | None = None
 235        self._standard_time_limit_text_input: ba.NodeActor | None = None
 236        self._tournament_time_limit: int | None = None
 237        self._tournament_time_limit_timer: ba.Timer | None = None
 238        self._tournament_time_limit_title_text: ba.NodeActor | None = None
 239        self._tournament_time_limit_text: ba.NodeActor | None = None
 240        self._tournament_time_limit_text_input: ba.NodeActor | None = None
 241        self._zoom_message_times: dict[int, float] = {}
 242        self._is_waiting_for_continue = False
 243
 244        self._continue_cost = _ba.get_v1_account_misc_read_val(
 245            'continueStartCost', 25)
 246        self._continue_cost_mult = _ba.get_v1_account_misc_read_val(
 247            'continuesMult', 2)
 248        self._continue_cost_offset = _ba.get_v1_account_misc_read_val(
 249            'continuesOffset', 0)
 250
 251    @property
 252    def map(self) -> ba.Map:
 253        """The map being used for this game.
 254
 255        Raises a ba.NotFoundError if the map does not currently exist.
 256        """
 257        if self._map is None:
 258            raise NotFoundError
 259        return self._map
 260
 261    def get_instance_display_string(self) -> ba.Lstr:
 262        """Return a name for this particular game instance."""
 263        return self.get_display_string(self.settings_raw)
 264
 265    # noinspection PyUnresolvedReferences
 266    def get_instance_scoreboard_display_string(self) -> ba.Lstr:
 267        """Return a name for this particular game instance.
 268
 269        This name is used above the game scoreboard in the corner
 270        of the screen, so it should be as concise as possible.
 271        """
 272        # If we're in a co-op session, use the level name.
 273        # FIXME: Should clean this up.
 274        try:
 275            from ba._coopsession import CoopSession
 276            if isinstance(self.session, CoopSession):
 277                campaign = self.session.campaign
 278                assert campaign is not None
 279                return campaign.getlevel(
 280                    self.session.campaign_level_name).displayname
 281        except Exception:
 282            print_error('error getting campaign level name')
 283        return self.get_instance_display_string()
 284
 285    def get_instance_description(self) -> str | Sequence:
 286        """Return a description for this game instance, in English.
 287
 288        This is shown in the center of the screen below the game name at the
 289        start of a game. It should start with a capital letter and end with a
 290        period, and can be a bit more verbose than the version returned by
 291        get_instance_description_short().
 292
 293        Note that translation is applied by looking up the specific returned
 294        value as a key, so the number of returned variations should be limited;
 295        ideally just one or two. To include arbitrary values in the
 296        description, you can return a sequence of values in the following
 297        form instead of just a string:
 298
 299        # This will give us something like 'Score 3 goals.' in English
 300        # and can properly translate to 'Anota 3 goles.' in Spanish.
 301        # If we just returned the string 'Score 3 Goals' here, there would
 302        # have to be a translation entry for each specific number. ew.
 303        return ['Score ${ARG1} goals.', self.settings_raw['Score to Win']]
 304
 305        This way the first string can be consistently translated, with any arg
 306        values then substituted into the result. ${ARG1} will be replaced with
 307        the first value, ${ARG2} with the second, etc.
 308        """
 309        return self.get_description(type(self.session))
 310
 311    def get_instance_description_short(self) -> str | Sequence:
 312        """Return a short description for this game instance in English.
 313
 314        This description is used above the game scoreboard in the
 315        corner of the screen, so it should be as concise as possible.
 316        It should be lowercase and should not contain periods or other
 317        punctuation.
 318
 319        Note that translation is applied by looking up the specific returned
 320        value as a key, so the number of returned variations should be limited;
 321        ideally just one or two. To include arbitrary values in the
 322        description, you can return a sequence of values in the following form
 323        instead of just a string:
 324
 325        # This will give us something like 'score 3 goals' in English
 326        # and can properly translate to 'anota 3 goles' in Spanish.
 327        # If we just returned the string 'score 3 goals' here, there would
 328        # have to be a translation entry for each specific number. ew.
 329        return ['score ${ARG1} goals', self.settings_raw['Score to Win']]
 330
 331        This way the first string can be consistently translated, with any arg
 332        values then substituted into the result. ${ARG1} will be replaced
 333        with the first value, ${ARG2} with the second, etc.
 334
 335        """
 336        return ''
 337
 338    def on_transition_in(self) -> None:
 339        super().on_transition_in()
 340
 341        # Make our map.
 342        self._map = self._map_type()
 343
 344        # Give our map a chance to override the music.
 345        # (for happy-thoughts and other such themed maps)
 346        map_music = self._map_type.get_music_type()
 347        music = map_music if map_music is not None else self.default_music
 348
 349        if music is not None:
 350            from ba import _music
 351            _music.setmusic(music)
 352
 353    def on_continue(self) -> None:
 354        """
 355        This is called if a game supports and offers a continue and the player
 356        accepts.  In this case the player should be given an extra life or
 357        whatever is relevant to keep the game going.
 358        """
 359
 360    def _continue_choice(self, do_continue: bool) -> None:
 361        self._is_waiting_for_continue = False
 362        if self.has_ended():
 363            return
 364        with _ba.Context(self):
 365            if do_continue:
 366                _ba.playsound(_ba.getsound('shieldUp'))
 367                _ba.playsound(_ba.getsound('cashRegister'))
 368                _ba.add_transaction({
 369                    'type': 'CONTINUE',
 370                    'cost': self._continue_cost
 371                })
 372                _ba.run_transactions()
 373                self._continue_cost = (
 374                    self._continue_cost * self._continue_cost_mult +
 375                    self._continue_cost_offset)
 376                self.on_continue()
 377            else:
 378                self.end_game()
 379
 380    def is_waiting_for_continue(self) -> bool:
 381        """Returns whether or not this activity is currently waiting for the
 382        player to continue (or timeout)"""
 383        return self._is_waiting_for_continue
 384
 385    def continue_or_end_game(self) -> None:
 386        """If continues are allowed, prompts the player to purchase a continue
 387        and calls either end_game or continue_game depending on the result"""
 388        # pylint: disable=too-many-nested-blocks
 389        # pylint: disable=cyclic-import
 390        from bastd.ui.continues import ContinuesWindow
 391        from ba._coopsession import CoopSession
 392        from ba._generated.enums import TimeType
 393
 394        try:
 395            if _ba.get_v1_account_misc_read_val('enableContinues', False):
 396                session = self.session
 397
 398                # We only support continuing in non-tournament games.
 399                tournament_id = session.tournament_id
 400                if tournament_id is None:
 401
 402                    # We currently only support continuing in sequential
 403                    # co-op campaigns.
 404                    if isinstance(session, CoopSession):
 405                        assert session.campaign is not None
 406                        if session.campaign.sequential:
 407                            gnode = self.globalsnode
 408
 409                            # Only attempt this if we're not currently paused
 410                            # and there appears to be no UI.
 411                            if (not gnode.paused
 412                                    and not _ba.app.ui.has_main_menu_window()):
 413                                self._is_waiting_for_continue = True
 414                                with _ba.Context('ui'):
 415                                    _ba.timer(
 416                                        0.5,
 417                                        lambda: ContinuesWindow(
 418                                            self,
 419                                            self._continue_cost,
 420                                            continue_call=WeakCall(
 421                                                self._continue_choice, True),
 422                                            cancel_call=WeakCall(
 423                                                self._continue_choice, False)),
 424                                        timetype=TimeType.REAL)
 425                                return
 426
 427        except Exception:
 428            print_exception('Error handling continues.')
 429
 430        self.end_game()
 431
 432    def on_begin(self) -> None:
 433        from ba._analytics import game_begin_analytics
 434        super().on_begin()
 435
 436        game_begin_analytics()
 437
 438        # We don't do this in on_transition_in because it may depend on
 439        # players/teams which aren't available until now.
 440        _ba.timer(0.001, self._show_scoreboard_info)
 441        _ba.timer(1.0, self._show_info)
 442        _ba.timer(2.5, self._show_tip)
 443
 444        # Store some basic info about players present at start time.
 445        self.initialplayerinfos = [
 446            PlayerInfo(name=p.getname(full=True), character=p.character)
 447            for p in self.players
 448        ]
 449
 450        # Sort this by name so high score lists/etc will be consistent
 451        # regardless of player join order.
 452        self.initialplayerinfos.sort(key=lambda x: x.name)
 453
 454        # If this is a tournament, query info about it such as how much
 455        # time is left.
 456        tournament_id = self.session.tournament_id
 457        if tournament_id is not None:
 458            _ba.tournament_query(
 459                args={
 460                    'tournamentIDs': [tournament_id],
 461                    'source': 'in-game time remaining query'
 462                },
 463                callback=WeakCall(self._on_tournament_query_response),
 464            )
 465
 466    def _on_tournament_query_response(self,
 467                                      data: dict[str, Any] | None) -> None:
 468        if data is not None:
 469            data_t = data['t']  # This used to be the whole payload.
 470
 471            # Keep our cached tourney info up to date
 472            _ba.app.accounts_v1.cache_tournament_info(data_t)
 473            self._setup_tournament_time_limit(
 474                max(5, data_t[0]['timeRemaining']))
 475
 476    def on_player_join(self, player: PlayerType) -> None:
 477        super().on_player_join(player)
 478
 479        # By default, just spawn a dude.
 480        self.spawn_player(player)
 481
 482    def handlemessage(self, msg: Any) -> Any:
 483        if isinstance(msg, PlayerDiedMessage):
 484            # pylint: disable=cyclic-import
 485            from bastd.actor.spaz import Spaz
 486
 487            player = msg.getplayer(self.playertype)
 488            killer = msg.getkillerplayer(self.playertype)
 489
 490            # Inform our stats of the demise.
 491            self.stats.player_was_killed(player,
 492                                         killed=msg.killed,
 493                                         killer=killer)
 494
 495            # Award the killer points if he's on a different team.
 496            # FIXME: This should not be linked to Spaz actors.
 497            # (should move get_death_points to Actor or make it a message)
 498            if killer and killer.team is not player.team:
 499                assert isinstance(killer.actor, Spaz)
 500                pts, importance = killer.actor.get_death_points(msg.how)
 501                if not self.has_ended():
 502                    self.stats.player_scored(killer,
 503                                             pts,
 504                                             kill=True,
 505                                             victim_player=player,
 506                                             importance=importance,
 507                                             showpoints=self.show_kill_points)
 508        else:
 509            return super().handlemessage(msg)
 510        return None
 511
 512    def _show_scoreboard_info(self) -> None:
 513        """Create the game info display.
 514
 515        This is the thing in the top left corner showing the name
 516        and short description of the game.
 517        """
 518        # pylint: disable=too-many-locals
 519        from ba._freeforallsession import FreeForAllSession
 520        from ba._gameutils import animate
 521        from ba._nodeactor import NodeActor
 522        sb_name = self.get_instance_scoreboard_display_string()
 523
 524        # The description can be either a string or a sequence with args
 525        # to swap in post-translation.
 526        sb_desc_in = self.get_instance_description_short()
 527        sb_desc_l: Sequence
 528        if isinstance(sb_desc_in, str):
 529            sb_desc_l = [sb_desc_in]  # handle simple string case
 530        else:
 531            sb_desc_l = sb_desc_in
 532        if not isinstance(sb_desc_l[0], str):
 533            raise TypeError('Invalid format for instance description.')
 534
 535        is_empty = (sb_desc_l[0] == '')
 536        subs = []
 537        for i in range(len(sb_desc_l) - 1):
 538            subs.append(('${ARG' + str(i + 1) + '}', str(sb_desc_l[i + 1])))
 539        translation = Lstr(translate=('gameDescriptions', sb_desc_l[0]),
 540                           subs=subs)
 541        sb_desc = translation
 542        vrmode = _ba.app.vr_mode
 543        yval = -34 if is_empty else -20
 544        yval -= 16
 545        sbpos = ((15, yval) if isinstance(self.session, FreeForAllSession) else
 546                 (15, yval))
 547        self._game_scoreboard_name_text = NodeActor(
 548            _ba.newnode('text',
 549                        attrs={
 550                            'text': sb_name,
 551                            'maxwidth': 300,
 552                            'position': sbpos,
 553                            'h_attach': 'left',
 554                            'vr_depth': 10,
 555                            'v_attach': 'top',
 556                            'v_align': 'bottom',
 557                            'color': (1.0, 1.0, 1.0, 1.0),
 558                            'shadow': 1.0 if vrmode else 0.6,
 559                            'flatness': 1.0 if vrmode else 0.5,
 560                            'scale': 1.1
 561                        }))
 562
 563        assert self._game_scoreboard_name_text.node
 564        animate(self._game_scoreboard_name_text.node, 'opacity', {
 565            0: 0.0,
 566            1.0: 1.0
 567        })
 568
 569        descpos = (((17, -44 +
 570                     10) if isinstance(self.session, FreeForAllSession) else
 571                    (17, -44 + 10)))
 572        self._game_scoreboard_description_text = NodeActor(
 573            _ba.newnode(
 574                'text',
 575                attrs={
 576                    'text': sb_desc,
 577                    'maxwidth': 480,
 578                    'position': descpos,
 579                    'scale': 0.7,
 580                    'h_attach': 'left',
 581                    'v_attach': 'top',
 582                    'v_align': 'top',
 583                    'shadow': 1.0 if vrmode else 0.7,
 584                    'flatness': 1.0 if vrmode else 0.8,
 585                    'color': (1, 1, 1, 1) if vrmode else (0.9, 0.9, 0.9, 1.0)
 586                }))
 587
 588        assert self._game_scoreboard_description_text.node
 589        animate(self._game_scoreboard_description_text.node, 'opacity', {
 590            0: 0.0,
 591            1.0: 1.0
 592        })
 593
 594    def _show_info(self) -> None:
 595        """Show the game description."""
 596        from ba._gameutils import animate
 597        from bastd.actor.zoomtext import ZoomText
 598        name = self.get_instance_display_string()
 599        ZoomText(name,
 600                 maxwidth=800,
 601                 lifespan=2.5,
 602                 jitter=2.0,
 603                 position=(0, 180),
 604                 flash=False,
 605                 color=(0.93 * 1.25, 0.9 * 1.25, 1.0 * 1.25),
 606                 trailcolor=(0.15, 0.05, 1.0, 0.0)).autoretain()
 607        _ba.timer(0.2, Call(_ba.playsound, _ba.getsound('gong')))
 608
 609        # The description can be either a string or a sequence with args
 610        # to swap in post-translation.
 611        desc_in = self.get_instance_description()
 612        desc_l: Sequence
 613        if isinstance(desc_in, str):
 614            desc_l = [desc_in]  # handle simple string case
 615        else:
 616            desc_l = desc_in
 617        if not isinstance(desc_l[0], str):
 618            raise TypeError('Invalid format for instance description')
 619        subs = []
 620        for i in range(len(desc_l) - 1):
 621            subs.append(('${ARG' + str(i + 1) + '}', str(desc_l[i + 1])))
 622        translation = Lstr(translate=('gameDescriptions', desc_l[0]),
 623                           subs=subs)
 624
 625        # Do some standard filters (epic mode, etc).
 626        if self.settings_raw.get('Epic Mode', False):
 627            translation = Lstr(resource='epicDescriptionFilterText',
 628                               subs=[('${DESCRIPTION}', translation)])
 629        vrmode = _ba.app.vr_mode
 630        dnode = _ba.newnode('text',
 631                            attrs={
 632                                'v_attach': 'center',
 633                                'h_attach': 'center',
 634                                'h_align': 'center',
 635                                'color': (1, 1, 1, 1),
 636                                'shadow': 1.0 if vrmode else 0.5,
 637                                'flatness': 1.0 if vrmode else 0.5,
 638                                'vr_depth': -30,
 639                                'position': (0, 80),
 640                                'scale': 1.2,
 641                                'maxwidth': 700,
 642                                'text': translation
 643                            })
 644        cnode = _ba.newnode('combine',
 645                            owner=dnode,
 646                            attrs={
 647                                'input0': 1.0,
 648                                'input1': 1.0,
 649                                'input2': 1.0,
 650                                'size': 4
 651                            })
 652        cnode.connectattr('output', dnode, 'color')
 653        keys = {0.5: 0, 1.0: 1.0, 2.5: 1.0, 4.0: 0.0}
 654        animate(cnode, 'input3', keys)
 655        _ba.timer(4.0, dnode.delete)
 656
 657    def _show_tip(self) -> None:
 658        # pylint: disable=too-many-locals
 659        from ba._gameutils import animate, GameTip
 660        from ba._generated.enums import SpecialChar
 661
 662        # If there's any tips left on the list, display one.
 663        if self.tips:
 664            tip = self.tips.pop(random.randrange(len(self.tips)))
 665            tip_title = Lstr(value='${A}:',
 666                             subs=[('${A}', Lstr(resource='tipText'))])
 667            icon: ba.Texture | None = None
 668            sound: ba.Sound | None = None
 669            if isinstance(tip, GameTip):
 670                icon = tip.icon
 671                sound = tip.sound
 672                tip = tip.text
 673                assert isinstance(tip, str)
 674
 675            # Do a few substitutions.
 676            tip_lstr = Lstr(translate=('tips', tip),
 677                            subs=[('${PICKUP}',
 678                                   _ba.charstr(SpecialChar.TOP_BUTTON))])
 679            base_position = (75, 50)
 680            tip_scale = 0.8
 681            tip_title_scale = 1.2
 682            vrmode = _ba.app.vr_mode
 683
 684            t_offs = -350.0
 685            tnode = _ba.newnode('text',
 686                                attrs={
 687                                    'text': tip_lstr,
 688                                    'scale': tip_scale,
 689                                    'maxwidth': 900,
 690                                    'position': (base_position[0] + t_offs,
 691                                                 base_position[1]),
 692                                    'h_align': 'left',
 693                                    'vr_depth': 300,
 694                                    'shadow': 1.0 if vrmode else 0.5,
 695                                    'flatness': 1.0 if vrmode else 0.5,
 696                                    'v_align': 'center',
 697                                    'v_attach': 'bottom'
 698                                })
 699            t2pos = (base_position[0] + t_offs - (20 if icon is None else 82),
 700                     base_position[1] + 2)
 701            t2node = _ba.newnode('text',
 702                                 owner=tnode,
 703                                 attrs={
 704                                     'text': tip_title,
 705                                     'scale': tip_title_scale,
 706                                     'position': t2pos,
 707                                     'h_align': 'right',
 708                                     'vr_depth': 300,
 709                                     'shadow': 1.0 if vrmode else 0.5,
 710                                     'flatness': 1.0 if vrmode else 0.5,
 711                                     'maxwidth': 140,
 712                                     'v_align': 'center',
 713                                     'v_attach': 'bottom'
 714                                 })
 715            if icon is not None:
 716                ipos = (base_position[0] + t_offs - 40, base_position[1] + 1)
 717                img = _ba.newnode('image',
 718                                  attrs={
 719                                      'texture': icon,
 720                                      'position': ipos,
 721                                      'scale': (50, 50),
 722                                      'opacity': 1.0,
 723                                      'vr_depth': 315,
 724                                      'color': (1, 1, 1),
 725                                      'absolute_scale': True,
 726                                      'attach': 'bottomCenter'
 727                                  })
 728                animate(img, 'opacity', {0: 0, 1.0: 1, 4.0: 1, 5.0: 0})
 729                _ba.timer(5.0, img.delete)
 730            if sound is not None:
 731                _ba.playsound(sound)
 732
 733            combine = _ba.newnode('combine',
 734                                  owner=tnode,
 735                                  attrs={
 736                                      'input0': 1.0,
 737                                      'input1': 0.8,
 738                                      'input2': 1.0,
 739                                      'size': 4
 740                                  })
 741            combine.connectattr('output', tnode, 'color')
 742            combine.connectattr('output', t2node, 'color')
 743            animate(combine, 'input3', {0: 0, 1.0: 1, 4.0: 1, 5.0: 0})
 744            _ba.timer(5.0, tnode.delete)
 745
 746    def end(self,
 747            results: Any = None,
 748            delay: float = 0.0,
 749            force: bool = False) -> None:
 750        from ba._gameresults import GameResults
 751
 752        # If results is a standard team-game-results, associate it with us
 753        # so it can grab our score prefs.
 754        if isinstance(results, GameResults):
 755            results.set_game(self)
 756
 757        # If we had a standard time-limit that had not expired, stop it so
 758        # it doesnt tick annoyingly.
 759        if (self._standard_time_limit_time is not None
 760                and self._standard_time_limit_time > 0):
 761            self._standard_time_limit_timer = None
 762            self._standard_time_limit_text = None
 763
 764        # Ditto with tournament time limits.
 765        if (self._tournament_time_limit is not None
 766                and self._tournament_time_limit > 0):
 767            self._tournament_time_limit_timer = None
 768            self._tournament_time_limit_text = None
 769            self._tournament_time_limit_title_text = None
 770
 771        super().end(results, delay, force)
 772
 773    def end_game(self) -> None:
 774        """Tell the game to wrap up and call ba.Activity.end() immediately.
 775
 776        This method should be overridden by subclasses. A game should always
 777        be prepared to end and deliver results, even if there is no 'winner'
 778        yet; this way things like the standard time-limit
 779        (ba.GameActivity.setup_standard_time_limit()) will work with the game.
 780        """
 781        print('WARNING: default end_game() implementation called;'
 782              ' your game should override this.')
 783
 784    def respawn_player(self,
 785                       player: PlayerType,
 786                       respawn_time: float | None = None) -> None:
 787        """
 788        Given a ba.Player, sets up a standard respawn timer,
 789        along with the standard counter display, etc.
 790        At the end of the respawn period spawn_player() will
 791        be called if the Player still exists.
 792        An explicit 'respawn_time' can optionally be provided
 793        (in seconds).
 794        """
 795        # pylint: disable=cyclic-import
 796
 797        assert player
 798        if respawn_time is None:
 799            teamsize = len(player.team.players)
 800            if teamsize == 1:
 801                respawn_time = 3.0
 802            elif teamsize == 2:
 803                respawn_time = 5.0
 804            elif teamsize == 3:
 805                respawn_time = 6.0
 806            else:
 807                respawn_time = 7.0
 808
 809        # If this standard setting is present, factor it in.
 810        if 'Respawn Times' in self.settings_raw:
 811            respawn_time *= self.settings_raw['Respawn Times']
 812
 813        # We want whole seconds.
 814        assert respawn_time is not None
 815        respawn_time = round(max(1.0, respawn_time), 0)
 816
 817        if player.actor and not self.has_ended():
 818            from bastd.actor.respawnicon import RespawnIcon
 819            player.customdata['respawn_timer'] = _ba.Timer(
 820                respawn_time, WeakCall(self.spawn_player_if_exists, player))
 821            player.customdata['respawn_icon'] = RespawnIcon(
 822                player, respawn_time)
 823
 824    def spawn_player_if_exists(self, player: PlayerType) -> None:
 825        """
 826        A utility method which calls self.spawn_player() *only* if the
 827        ba.Player provided still exists; handy for use in timers and whatnot.
 828
 829        There is no need to override this; just override spawn_player().
 830        """
 831        if player:
 832            self.spawn_player(player)
 833
 834    def spawn_player(self, player: PlayerType) -> ba.Actor:
 835        """Spawn *something* for the provided ba.Player.
 836
 837        The default implementation simply calls spawn_player_spaz().
 838        """
 839        assert player  # Dead references should never be passed as args.
 840
 841        return self.spawn_player_spaz(player)
 842
 843    def spawn_player_spaz(self,
 844                          player: PlayerType,
 845                          position: Sequence[float] = (0, 0, 0),
 846                          angle: float | None = None) -> PlayerSpaz:
 847        """Create and wire up a ba.PlayerSpaz for the provided ba.Player."""
 848        # pylint: disable=too-many-locals
 849        # pylint: disable=cyclic-import
 850        from ba import _math
 851        from ba._gameutils import animate
 852        from ba._coopsession import CoopSession
 853        from bastd.actor.playerspaz import PlayerSpaz
 854        name = player.getname()
 855        color = player.color
 856        highlight = player.highlight
 857
 858        playerspaztype = getattr(player, 'playerspaztype', PlayerSpaz)
 859        if not issubclass(playerspaztype, PlayerSpaz):
 860            playerspaztype = PlayerSpaz
 861
 862        light_color = _math.normalized_color(color)
 863        display_color = _ba.safecolor(color, target_intensity=0.75)
 864        spaz = playerspaztype(color=color,
 865                              highlight=highlight,
 866                              character=player.character,
 867                              player=player)
 868
 869        player.actor = spaz
 870        assert spaz.node
 871
 872        # If this is co-op and we're on Courtyard or Runaround, add the
 873        # material that allows us to collide with the player-walls.
 874        # FIXME: Need to generalize this.
 875        if isinstance(self.session, CoopSession) and self.map.getname() in [
 876                'Courtyard', 'Tower D'
 877        ]:
 878            mat = self.map.preloaddata['collide_with_wall_material']
 879            assert isinstance(spaz.node.materials, tuple)
 880            assert isinstance(spaz.node.roller_materials, tuple)
 881            spaz.node.materials += (mat, )
 882            spaz.node.roller_materials += (mat, )
 883
 884        spaz.node.name = name
 885        spaz.node.name_color = display_color
 886        spaz.connect_controls_to_player()
 887
 888        # Move to the stand position and add a flash of light.
 889        spaz.handlemessage(
 890            StandMessage(
 891                position,
 892                angle if angle is not None else random.uniform(0, 360)))
 893        _ba.playsound(self._spawn_sound, 1, position=spaz.node.position)
 894        light = _ba.newnode('light', attrs={'color': light_color})
 895        spaz.node.connectattr('position', light, 'position')
 896        animate(light, 'intensity', {0: 0, 0.25: 1, 0.5: 0})
 897        _ba.timer(0.5, light.delete)
 898        return spaz
 899
 900    def setup_standard_powerup_drops(self, enable_tnt: bool = True) -> None:
 901        """Create standard powerup drops for the current map."""
 902        # pylint: disable=cyclic-import
 903        from bastd.actor.powerupbox import DEFAULT_POWERUP_INTERVAL
 904        self._powerup_drop_timer = _ba.Timer(DEFAULT_POWERUP_INTERVAL,
 905                                             WeakCall(
 906                                                 self._standard_drop_powerups),
 907                                             repeat=True)
 908        self._standard_drop_powerups()
 909        if enable_tnt:
 910            self._tnt_spawners = {}
 911            self._setup_standard_tnt_drops()
 912
 913    def _standard_drop_powerup(self, index: int, expire: bool = True) -> None:
 914        # pylint: disable=cyclic-import
 915        from bastd.actor.powerupbox import PowerupBox, PowerupBoxFactory
 916        PowerupBox(
 917            position=self.map.powerup_spawn_points[index],
 918            poweruptype=PowerupBoxFactory.get().get_random_powerup_type(),
 919            expire=expire).autoretain()
 920
 921    def _standard_drop_powerups(self) -> None:
 922        """Standard powerup drop."""
 923
 924        # Drop one powerup per point.
 925        points = self.map.powerup_spawn_points
 926        for i in range(len(points)):
 927            _ba.timer(i * 0.4, WeakCall(self._standard_drop_powerup, i))
 928
 929    def _setup_standard_tnt_drops(self) -> None:
 930        """Standard tnt drop."""
 931        # pylint: disable=cyclic-import
 932        from bastd.actor.bomb import TNTSpawner
 933        for i, point in enumerate(self.map.tnt_points):
 934            assert self._tnt_spawners is not None
 935            if self._tnt_spawners.get(i) is None:
 936                self._tnt_spawners[i] = TNTSpawner(point)
 937
 938    def setup_standard_time_limit(self, duration: float) -> None:
 939        """
 940        Create a standard game time-limit given the provided
 941        duration in seconds.
 942        This will be displayed at the top of the screen.
 943        If the time-limit expires, end_game() will be called.
 944        """
 945        from ba._nodeactor import NodeActor
 946        if duration <= 0.0:
 947            return
 948        self._standard_time_limit_time = int(duration)
 949        self._standard_time_limit_timer = _ba.Timer(
 950            1.0, WeakCall(self._standard_time_limit_tick), repeat=True)
 951        self._standard_time_limit_text = NodeActor(
 952            _ba.newnode('text',
 953                        attrs={
 954                            'v_attach': 'top',
 955                            'h_attach': 'center',
 956                            'h_align': 'left',
 957                            'color': (1.0, 1.0, 1.0, 0.5),
 958                            'position': (-25, -30),
 959                            'flatness': 1.0,
 960                            'scale': 0.9
 961                        }))
 962        self._standard_time_limit_text_input = NodeActor(
 963            _ba.newnode('timedisplay',
 964                        attrs={
 965                            'time2': duration * 1000,
 966                            'timemin': 0
 967                        }))
 968        self.globalsnode.connectattr('time',
 969                                     self._standard_time_limit_text_input.node,
 970                                     'time1')
 971        assert self._standard_time_limit_text_input.node
 972        assert self._standard_time_limit_text.node
 973        self._standard_time_limit_text_input.node.connectattr(
 974            'output', self._standard_time_limit_text.node, 'text')
 975
 976    def _standard_time_limit_tick(self) -> None:
 977        from ba._gameutils import animate
 978        assert self._standard_time_limit_time is not None
 979        self._standard_time_limit_time -= 1
 980        if self._standard_time_limit_time <= 10:
 981            if self._standard_time_limit_time == 10:
 982                assert self._standard_time_limit_text is not None
 983                assert self._standard_time_limit_text.node
 984                self._standard_time_limit_text.node.scale = 1.3
 985                self._standard_time_limit_text.node.position = (-30, -45)
 986                cnode = _ba.newnode('combine',
 987                                    owner=self._standard_time_limit_text.node,
 988                                    attrs={'size': 4})
 989                cnode.connectattr('output',
 990                                  self._standard_time_limit_text.node, 'color')
 991                animate(cnode, 'input0', {0: 1, 0.15: 1}, loop=True)
 992                animate(cnode, 'input1', {0: 1, 0.15: 0.5}, loop=True)
 993                animate(cnode, 'input2', {0: 0.1, 0.15: 0.0}, loop=True)
 994                cnode.input3 = 1.0
 995            _ba.playsound(_ba.getsound('tick'))
 996        if self._standard_time_limit_time <= 0:
 997            self._standard_time_limit_timer = None
 998            self.end_game()
 999            node = _ba.newnode('text',
1000                               attrs={
1001                                   'v_attach': 'top',
1002                                   'h_attach': 'center',
1003                                   'h_align': 'center',
1004                                   'color': (1, 0.7, 0, 1),
1005                                   'position': (0, -90),
1006                                   'scale': 1.2,
1007                                   'text': Lstr(resource='timeExpiredText')
1008                               })
1009            _ba.playsound(_ba.getsound('refWhistle'))
1010            animate(node, 'scale', {0.0: 0.0, 0.1: 1.4, 0.15: 1.2})
1011
1012    def _setup_tournament_time_limit(self, duration: float) -> None:
1013        """
1014        Create a tournament game time-limit given the provided
1015        duration in seconds.
1016        This will be displayed at the top of the screen.
1017        If the time-limit expires, end_game() will be called.
1018        """
1019        from ba._nodeactor import NodeActor
1020        from ba._generated.enums import TimeType
1021        if duration <= 0.0:
1022            return
1023        self._tournament_time_limit = int(duration)
1024
1025        # We want this timer to match the server's time as close as possible,
1026        # so lets go with base-time. Theoretically we should do real-time but
1027        # then we have to mess with contexts and whatnot since its currently
1028        # not available in activity contexts. :-/
1029        self._tournament_time_limit_timer = _ba.Timer(
1030            1.0,
1031            WeakCall(self._tournament_time_limit_tick),
1032            repeat=True,
1033            timetype=TimeType.BASE)
1034        self._tournament_time_limit_title_text = NodeActor(
1035            _ba.newnode('text',
1036                        attrs={
1037                            'v_attach': 'bottom',
1038                            'h_attach': 'left',
1039                            'h_align': 'center',
1040                            'v_align': 'center',
1041                            'vr_depth': 300,
1042                            'maxwidth': 100,
1043                            'color': (1.0, 1.0, 1.0, 0.5),
1044                            'position': (60, 50),
1045                            'flatness': 1.0,
1046                            'scale': 0.5,
1047                            'text': Lstr(resource='tournamentText')
1048                        }))
1049        self._tournament_time_limit_text = NodeActor(
1050            _ba.newnode('text',
1051                        attrs={
1052                            'v_attach': 'bottom',
1053                            'h_attach': 'left',
1054                            'h_align': 'center',
1055                            'v_align': 'center',
1056                            'vr_depth': 300,
1057                            'maxwidth': 100,
1058                            'color': (1.0, 1.0, 1.0, 0.5),
1059                            'position': (60, 30),
1060                            'flatness': 1.0,
1061                            'scale': 0.9
1062                        }))
1063        self._tournament_time_limit_text_input = NodeActor(
1064            _ba.newnode('timedisplay',
1065                        attrs={
1066                            'timemin': 0,
1067                            'time2': self._tournament_time_limit * 1000
1068                        }))
1069        assert self._tournament_time_limit_text.node
1070        assert self._tournament_time_limit_text_input.node
1071        self._tournament_time_limit_text_input.node.connectattr(
1072            'output', self._tournament_time_limit_text.node, 'text')
1073
1074    def _tournament_time_limit_tick(self) -> None:
1075        from ba._gameutils import animate
1076        assert self._tournament_time_limit is not None
1077        self._tournament_time_limit -= 1
1078        if self._tournament_time_limit <= 10:
1079            if self._tournament_time_limit == 10:
1080                assert self._tournament_time_limit_title_text is not None
1081                assert self._tournament_time_limit_title_text.node
1082                assert self._tournament_time_limit_text is not None
1083                assert self._tournament_time_limit_text.node
1084                self._tournament_time_limit_title_text.node.scale = 1.0
1085                self._tournament_time_limit_text.node.scale = 1.3
1086                self._tournament_time_limit_title_text.node.position = (80, 85)
1087                self._tournament_time_limit_text.node.position = (80, 60)
1088                cnode = _ba.newnode(
1089                    'combine',
1090                    owner=self._tournament_time_limit_text.node,
1091                    attrs={'size': 4})
1092                cnode.connectattr('output',
1093                                  self._tournament_time_limit_title_text.node,
1094                                  'color')
1095                cnode.connectattr('output',
1096                                  self._tournament_time_limit_text.node,
1097                                  'color')
1098                animate(cnode, 'input0', {0: 1, 0.15: 1}, loop=True)
1099                animate(cnode, 'input1', {0: 1, 0.15: 0.5}, loop=True)
1100                animate(cnode, 'input2', {0: 0.1, 0.15: 0.0}, loop=True)
1101                cnode.input3 = 1.0
1102            _ba.playsound(_ba.getsound('tick'))
1103        if self._tournament_time_limit <= 0:
1104            self._tournament_time_limit_timer = None
1105            self.end_game()
1106            tval = Lstr(resource='tournamentTimeExpiredText',
1107                        fallback_resource='timeExpiredText')
1108            node = _ba.newnode('text',
1109                               attrs={
1110                                   'v_attach': 'top',
1111                                   'h_attach': 'center',
1112                                   'h_align': 'center',
1113                                   'color': (1, 0.7, 0, 1),
1114                                   'position': (0, -200),
1115                                   'scale': 1.6,
1116                                   'text': tval
1117                               })
1118            _ba.playsound(_ba.getsound('refWhistle'))
1119            animate(node, 'scale', {0: 0.0, 0.1: 1.4, 0.15: 1.2})
1120
1121        # Normally we just connect this to time, but since this is a bit of a
1122        # funky setup we just update it manually once per second.
1123        assert self._tournament_time_limit_text_input is not None
1124        assert self._tournament_time_limit_text_input.node
1125        self._tournament_time_limit_text_input.node.time2 = (
1126            self._tournament_time_limit * 1000)
1127
1128    def show_zoom_message(self,
1129                          message: ba.Lstr,
1130                          color: Sequence[float] = (0.9, 0.4, 0.0),
1131                          scale: float = 0.8,
1132                          duration: float = 2.0,
1133                          trail: bool = False) -> None:
1134        """Zooming text used to announce game names and winners."""
1135        # pylint: disable=cyclic-import
1136        from bastd.actor.zoomtext import ZoomText
1137
1138        # Reserve a spot on the screen (in case we get multiple of these so
1139        # they don't overlap).
1140        i = 0
1141        cur_time = _ba.time()
1142        while True:
1143            if (i not in self._zoom_message_times
1144                    or self._zoom_message_times[i] < cur_time):
1145                self._zoom_message_times[i] = cur_time + duration
1146                break
1147            i += 1
1148        ZoomText(message,
1149                 lifespan=duration,
1150                 jitter=2.0,
1151                 position=(0, 200 - i * 100),
1152                 scale=scale,
1153                 maxwidth=800,
1154                 trail=trail,
1155                 color=color).autoretain()
1156
1157    def _calc_map_name(self, settings: dict) -> str:
1158        map_name: str
1159        if 'map' in settings:
1160            map_name = settings['map']
1161        else:
1162            # If settings doesn't specify a map, pick a random one from the
1163            # list of supported ones.
1164            unowned_maps = _store.get_unowned_maps()
1165            valid_maps: list[str] = [
1166                m for m in self.get_supported_maps(type(self.session))
1167                if m not in unowned_maps
1168            ]
1169            if not valid_maps:
1170                _ba.screenmessage(Lstr(resource='noValidMapsErrorText'))
1171                raise Exception('No valid maps')
1172            map_name = valid_maps[random.randrange(len(valid_maps))]
1173        return map_name

Common base class for all game ba.Activities.

Category: Gameplay Classes

GameActivity(settings: dict)
213    def __init__(self, settings: dict):
214        """Instantiate the Activity."""
215        super().__init__(settings)
216
217        # Holds some flattened info about the player set at the point
218        # when on_begin() is called.
219        self.initialplayerinfos: list[ba.PlayerInfo] | None = None
220
221        # Go ahead and get our map loading.
222        self._map_type = _map.get_map_class(self._calc_map_name(settings))
223
224        self._spawn_sound = _ba.getsound('spawn')
225        self._map_type.preload()
226        self._map: ba.Map | None = None
227        self._powerup_drop_timer: ba.Timer | None = None
228        self._tnt_spawners: dict[int, TNTSpawner] | None = None
229        self._tnt_drop_timer: ba.Timer | None = None
230        self._game_scoreboard_name_text: ba.Actor | None = None
231        self._game_scoreboard_description_text: ba.Actor | None = None
232        self._standard_time_limit_time: int | None = None
233        self._standard_time_limit_timer: ba.Timer | None = None
234        self._standard_time_limit_text: ba.NodeActor | None = None
235        self._standard_time_limit_text_input: ba.NodeActor | None = None
236        self._tournament_time_limit: int | None = None
237        self._tournament_time_limit_timer: ba.Timer | None = None
238        self._tournament_time_limit_title_text: ba.NodeActor | None = None
239        self._tournament_time_limit_text: ba.NodeActor | None = None
240        self._tournament_time_limit_text_input: ba.NodeActor | None = None
241        self._zoom_message_times: dict[int, float] = {}
242        self._is_waiting_for_continue = False
243
244        self._continue_cost = _ba.get_v1_account_misc_read_val(
245            'continueStartCost', 25)
246        self._continue_cost_mult = _ba.get_v1_account_misc_read_val(
247            'continuesMult', 2)
248        self._continue_cost_offset = _ba.get_v1_account_misc_read_val(
249            'continuesOffset', 0)

Instantiate the Activity.

tips: list[str | ba.GameTip] = []
name: str | None = None
description: str | None = None
available_settings: list[ba.Setting] | None = None
scoreconfig: ba.ScoreConfig | None = None
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).

show_kill_points = True
default_music: ba.MusicType | None = None
@classmethod
def create_settings_ui( cls, sessiontype: type[ba.Session], settings: dict | None, completion_call: Callable[[dict | None], NoneType]) -> None:
68    @classmethod
69    def create_settings_ui(
70        cls,
71        sessiontype: type[ba.Session],
72        settings: dict | None,
73        completion_call: Callable[[dict | None], None],
74    ) -> None:
75        """Launch an in-game UI to configure settings for a game type.
76
77        'sessiontype' should be the ba.Session class the game will be used in.
78
79        'settings' should be an existing settings dict (implies 'edit'
80          ui mode) or None (implies 'add' ui mode).
81
82        'completion_call' will be called with a filled-out settings dict on
83          success or None on cancel.
84
85        Generally subclasses don't need to override this; if they override
86        ba.GameActivity.get_available_settings() and
87        ba.GameActivity.get_supported_maps() they can just rely on
88        the default implementation here which calls those methods.
89        """
90        delegate = _ba.app.delegate
91        assert delegate is not None
92        delegate.create_default_game_settings_ui(cls, sessiontype, settings,
93                                                 completion_call)

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:
95    @classmethod
96    def getscoreconfig(cls) -> ba.ScoreConfig:
97        """Return info about game scoring setup; can be overridden by games."""
98        return (cls.scoreconfig
99                if cls.scoreconfig is not None else ScoreConfig())

Return info about game scoring setup; can be overridden by games.

@classmethod
def getname(cls) -> str:
101    @classmethod
102    def getname(cls) -> str:
103        """Return a str name for this game type.
104
105        This default implementation simply returns the 'name' class attr.
106        """
107        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:
109    @classmethod
110    def get_display_string(cls, settings: dict | None = None) -> ba.Lstr:
111        """Return a descriptive name for this game/settings combo.
112
113        Subclasses should override getname(); not this.
114        """
115        name = Lstr(translate=('gameNames', cls.getname()))
116
117        # A few substitutions for 'Epic', 'Solo' etc. modes.
118        # FIXME: Should provide a way for game types to define filters of
119        #  their own and should not rely on hard-coded settings names.
120        if settings is not None:
121            if 'Solo Mode' in settings and settings['Solo Mode']:
122                name = Lstr(resource='soloNameFilterText',
123                            subs=[('${NAME}', name)])
124            if 'Epic Mode' in settings and settings['Epic Mode']:
125                name = Lstr(resource='epicNameFilterText',
126                            subs=[('${NAME}', name)])
127
128        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:
130    @classmethod
131    def get_team_display_string(cls, name: str) -> ba.Lstr:
132        """Given a team name, returns a localized version of it."""
133        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:
135    @classmethod
136    def get_description(cls, sessiontype: type[ba.Session]) -> str:
137        """Get a str description of this game type.
138
139        The default implementation simply returns the 'description' class var.
140        Classes which want to change their description depending on the session
141        can override this method.
142        """
143        del sessiontype  # Unused arg.
144        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:
146    @classmethod
147    def get_description_display_string(
148            cls, sessiontype: type[ba.Session]) -> ba.Lstr:
149        """Return a translated version of get_description().
150
151        Sub-classes should override get_description(); not this.
152        """
153        description = cls.get_description(sessiontype)
154        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]:
156    @classmethod
157    def get_available_settings(
158            cls, sessiontype: type[ba.Session]) -> list[ba.Setting]:
159        """Return a list of settings relevant to this game type when
160        running under the provided session type.
161        """
162        del sessiontype  # Unused arg.
163        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]:
165    @classmethod
166    def get_supported_maps(cls, sessiontype: type[ba.Session]) -> list[str]:
167        """
168        Called by the default ba.GameActivity.create_settings_ui()
169        implementation; should return a list of map names valid
170        for this game-type for the given ba.Session type.
171        """
172        del sessiontype  # Unused arg.
173        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:
175    @classmethod
176    def get_settings_display_string(cls, config: dict[str, Any]) -> ba.Lstr:
177        """Given a game config dict, return a short description for it.
178
179        This is used when viewing game-lists or showing what game
180        is up next in a series.
181        """
182        name = cls.get_display_string(config['settings'])
183
184        # In newer configs, map is in settings; it used to be in the
185        # config root.
186        if 'map' in config['settings']:
187            sval = Lstr(value='${NAME} @ ${MAP}',
188                        subs=[('${NAME}', name),
189                              ('${MAP}',
190                               _map.get_map_display_string(
191                                   _map.get_filtered_map_name(
192                                       config['settings']['map'])))])
193        elif 'map' in config:
194            sval = Lstr(value='${NAME} @ ${MAP}',
195                        subs=[('${NAME}', name),
196                              ('${MAP}',
197                               _map.get_map_display_string(
198                                   _map.get_filtered_map_name(config['map'])))
199                              ])
200        else:
201            print('invalid game config - expected map entry under settings')
202            sval = Lstr(value='???')
203        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:
205    @classmethod
206    def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool:
207        """Return whether this game supports the provided Session type."""
208        from ba._multiteamsession import MultiTeamSession
209
210        # By default, games support any versus mode
211        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.NotFoundError if the map does not currently exist.

def get_instance_display_string(self) -> ba.Lstr:
261    def get_instance_display_string(self) -> ba.Lstr:
262        """Return a name for this particular game instance."""
263        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:
266    def get_instance_scoreboard_display_string(self) -> ba.Lstr:
267        """Return a name for this particular game instance.
268
269        This name is used above the game scoreboard in the corner
270        of the screen, so it should be as concise as possible.
271        """
272        # If we're in a co-op session, use the level name.
273        # FIXME: Should clean this up.
274        try:
275            from ba._coopsession import CoopSession
276            if isinstance(self.session, CoopSession):
277                campaign = self.session.campaign
278                assert campaign is not None
279                return campaign.getlevel(
280                    self.session.campaign_level_name).displayname
281        except Exception:
282            print_error('error getting campaign level name')
283        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]:
285    def get_instance_description(self) -> str | Sequence:
286        """Return a description for this game instance, in English.
287
288        This is shown in the center of the screen below the game name at the
289        start of a game. It should start with a capital letter and end with a
290        period, and can be a bit more verbose than the version returned by
291        get_instance_description_short().
292
293        Note that translation is applied by looking up the specific returned
294        value as a key, so the number of returned variations should be limited;
295        ideally just one or two. To include arbitrary values in the
296        description, you can return a sequence of values in the following
297        form instead of just a string:
298
299        # This will give us something like 'Score 3 goals.' in English
300        # and can properly translate to 'Anota 3 goles.' in Spanish.
301        # If we just returned the string 'Score 3 Goals' here, there would
302        # have to be a translation entry for each specific number. ew.
303        return ['Score ${ARG1} goals.', self.settings_raw['Score to Win']]
304
305        This way the first string can be consistently translated, with any arg
306        values then substituted into the result. ${ARG1} will be replaced with
307        the first value, ${ARG2} with the second, etc.
308        """
309        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]:
311    def get_instance_description_short(self) -> str | Sequence:
312        """Return a short description for this game instance in English.
313
314        This description is used above the game scoreboard in the
315        corner of the screen, so it should be as concise as possible.
316        It should be lowercase and should not contain periods or other
317        punctuation.
318
319        Note that translation is applied by looking up the specific returned
320        value as a key, so the number of returned variations should be limited;
321        ideally just one or two. To include arbitrary values in the
322        description, you can return a sequence of values in the following form
323        instead of just a string:
324
325        # This will give us something like 'score 3 goals' in English
326        # and can properly translate to 'anota 3 goles' in Spanish.
327        # If we just returned the string 'score 3 goals' here, there would
328        # have to be a translation entry for each specific number. ew.
329        return ['score ${ARG1} goals', self.settings_raw['Score to Win']]
330
331        This way the first string can be consistently translated, with any arg
332        values then substituted into the result. ${ARG1} will be replaced
333        with the first value, ${ARG2} with the second, etc.
334
335        """
336        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:
338    def on_transition_in(self) -> None:
339        super().on_transition_in()
340
341        # Make our map.
342        self._map = self._map_type()
343
344        # Give our map a chance to override the music.
345        # (for happy-thoughts and other such themed maps)
346        map_music = self._map_type.get_music_type()
347        music = map_music if map_music is not None else self.default_music
348
349        if music is not None:
350            from ba import _music
351            _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:
353    def on_continue(self) -> None:
354        """
355        This is called if a game supports and offers a continue and the player
356        accepts.  In this case the player should be given an extra life or
357        whatever is relevant to keep the game going.
358        """

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:
380    def is_waiting_for_continue(self) -> bool:
381        """Returns whether or not this activity is currently waiting for the
382        player to continue (or timeout)"""
383        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:
385    def continue_or_end_game(self) -> None:
386        """If continues are allowed, prompts the player to purchase a continue
387        and calls either end_game or continue_game depending on the result"""
388        # pylint: disable=too-many-nested-blocks
389        # pylint: disable=cyclic-import
390        from bastd.ui.continues import ContinuesWindow
391        from ba._coopsession import CoopSession
392        from ba._generated.enums import TimeType
393
394        try:
395            if _ba.get_v1_account_misc_read_val('enableContinues', False):
396                session = self.session
397
398                # We only support continuing in non-tournament games.
399                tournament_id = session.tournament_id
400                if tournament_id is None:
401
402                    # We currently only support continuing in sequential
403                    # co-op campaigns.
404                    if isinstance(session, CoopSession):
405                        assert session.campaign is not None
406                        if session.campaign.sequential:
407                            gnode = self.globalsnode
408
409                            # Only attempt this if we're not currently paused
410                            # and there appears to be no UI.
411                            if (not gnode.paused
412                                    and not _ba.app.ui.has_main_menu_window()):
413                                self._is_waiting_for_continue = True
414                                with _ba.Context('ui'):
415                                    _ba.timer(
416                                        0.5,
417                                        lambda: ContinuesWindow(
418                                            self,
419                                            self._continue_cost,
420                                            continue_call=WeakCall(
421                                                self._continue_choice, True),
422                                            cancel_call=WeakCall(
423                                                self._continue_choice, False)),
424                                        timetype=TimeType.REAL)
425                                return
426
427        except Exception:
428            print_exception('Error handling continues.')
429
430        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:
432    def on_begin(self) -> None:
433        from ba._analytics import game_begin_analytics
434        super().on_begin()
435
436        game_begin_analytics()
437
438        # We don't do this in on_transition_in because it may depend on
439        # players/teams which aren't available until now.
440        _ba.timer(0.001, self._show_scoreboard_info)
441        _ba.timer(1.0, self._show_info)
442        _ba.timer(2.5, self._show_tip)
443
444        # Store some basic info about players present at start time.
445        self.initialplayerinfos = [
446            PlayerInfo(name=p.getname(full=True), character=p.character)
447            for p in self.players
448        ]
449
450        # Sort this by name so high score lists/etc will be consistent
451        # regardless of player join order.
452        self.initialplayerinfos.sort(key=lambda x: x.name)
453
454        # If this is a tournament, query info about it such as how much
455        # time is left.
456        tournament_id = self.session.tournament_id
457        if tournament_id is not None:
458            _ba.tournament_query(
459                args={
460                    'tournamentIDs': [tournament_id],
461                    'source': 'in-game time remaining query'
462                },
463                callback=WeakCall(self._on_tournament_query_response),
464            )

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:
476    def on_player_join(self, player: PlayerType) -> None:
477        super().on_player_join(player)
478
479        # By default, just spawn a dude.
480        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:
482    def handlemessage(self, msg: Any) -> Any:
483        if isinstance(msg, PlayerDiedMessage):
484            # pylint: disable=cyclic-import
485            from bastd.actor.spaz import Spaz
486
487            player = msg.getplayer(self.playertype)
488            killer = msg.getkillerplayer(self.playertype)
489
490            # Inform our stats of the demise.
491            self.stats.player_was_killed(player,
492                                         killed=msg.killed,
493                                         killer=killer)
494
495            # Award the killer points if he's on a different team.
496            # FIXME: This should not be linked to Spaz actors.
497            # (should move get_death_points to Actor or make it a message)
498            if killer and killer.team is not player.team:
499                assert isinstance(killer.actor, Spaz)
500                pts, importance = killer.actor.get_death_points(msg.how)
501                if not self.has_ended():
502                    self.stats.player_scored(killer,
503                                             pts,
504                                             kill=True,
505                                             victim_player=player,
506                                             importance=importance,
507                                             showpoints=self.show_kill_points)
508        else:
509            return super().handlemessage(msg)
510        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:
746    def end(self,
747            results: Any = None,
748            delay: float = 0.0,
749            force: bool = False) -> None:
750        from ba._gameresults import GameResults
751
752        # If results is a standard team-game-results, associate it with us
753        # so it can grab our score prefs.
754        if isinstance(results, GameResults):
755            results.set_game(self)
756
757        # If we had a standard time-limit that had not expired, stop it so
758        # it doesnt tick annoyingly.
759        if (self._standard_time_limit_time is not None
760                and self._standard_time_limit_time > 0):
761            self._standard_time_limit_timer = None
762            self._standard_time_limit_text = None
763
764        # Ditto with tournament time limits.
765        if (self._tournament_time_limit is not None
766                and self._tournament_time_limit > 0):
767            self._tournament_time_limit_timer = None
768            self._tournament_time_limit_text = None
769            self._tournament_time_limit_title_text = None
770
771        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:
773    def end_game(self) -> None:
774        """Tell the game to wrap up and call ba.Activity.end() immediately.
775
776        This method should be overridden by subclasses. A game should always
777        be prepared to end and deliver results, even if there is no 'winner'
778        yet; this way things like the standard time-limit
779        (ba.GameActivity.setup_standard_time_limit()) will work with the game.
780        """
781        print('WARNING: default end_game() implementation called;'
782              ' your game should override this.')

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:
784    def respawn_player(self,
785                       player: PlayerType,
786                       respawn_time: float | None = None) -> None:
787        """
788        Given a ba.Player, sets up a standard respawn timer,
789        along with the standard counter display, etc.
790        At the end of the respawn period spawn_player() will
791        be called if the Player still exists.
792        An explicit 'respawn_time' can optionally be provided
793        (in seconds).
794        """
795        # pylint: disable=cyclic-import
796
797        assert player
798        if respawn_time is None:
799            teamsize = len(player.team.players)
800            if teamsize == 1:
801                respawn_time = 3.0
802            elif teamsize == 2:
803                respawn_time = 5.0
804            elif teamsize == 3:
805                respawn_time = 6.0
806            else:
807                respawn_time = 7.0
808
809        # If this standard setting is present, factor it in.
810        if 'Respawn Times' in self.settings_raw:
811            respawn_time *= self.settings_raw['Respawn Times']
812
813        # We want whole seconds.
814        assert respawn_time is not None
815        respawn_time = round(max(1.0, respawn_time), 0)
816
817        if player.actor and not self.has_ended():
818            from bastd.actor.respawnicon import RespawnIcon
819            player.customdata['respawn_timer'] = _ba.Timer(
820                respawn_time, WeakCall(self.spawn_player_if_exists, player))
821            player.customdata['respawn_icon'] = RespawnIcon(
822                player, respawn_time)

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:
824    def spawn_player_if_exists(self, player: PlayerType) -> None:
825        """
826        A utility method which calls self.spawn_player() *only* if the
827        ba.Player provided still exists; handy for use in timers and whatnot.
828
829        There is no need to override this; just override spawn_player().
830        """
831        if player:
832            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:
834    def spawn_player(self, player: PlayerType) -> ba.Actor:
835        """Spawn *something* for the provided ba.Player.
836
837        The default implementation simply calls spawn_player_spaz().
838        """
839        assert player  # Dead references should never be passed as args.
840
841        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:
843    def spawn_player_spaz(self,
844                          player: PlayerType,
845                          position: Sequence[float] = (0, 0, 0),
846                          angle: float | None = None) -> PlayerSpaz:
847        """Create and wire up a ba.PlayerSpaz for the provided ba.Player."""
848        # pylint: disable=too-many-locals
849        # pylint: disable=cyclic-import
850        from ba import _math
851        from ba._gameutils import animate
852        from ba._coopsession import CoopSession
853        from bastd.actor.playerspaz import PlayerSpaz
854        name = player.getname()
855        color = player.color
856        highlight = player.highlight
857
858        playerspaztype = getattr(player, 'playerspaztype', PlayerSpaz)
859        if not issubclass(playerspaztype, PlayerSpaz):
860            playerspaztype = PlayerSpaz
861
862        light_color = _math.normalized_color(color)
863        display_color = _ba.safecolor(color, target_intensity=0.75)
864        spaz = playerspaztype(color=color,
865                              highlight=highlight,
866                              character=player.character,
867                              player=player)
868
869        player.actor = spaz
870        assert spaz.node
871
872        # If this is co-op and we're on Courtyard or Runaround, add the
873        # material that allows us to collide with the player-walls.
874        # FIXME: Need to generalize this.
875        if isinstance(self.session, CoopSession) and self.map.getname() in [
876                'Courtyard', 'Tower D'
877        ]:
878            mat = self.map.preloaddata['collide_with_wall_material']
879            assert isinstance(spaz.node.materials, tuple)
880            assert isinstance(spaz.node.roller_materials, tuple)
881            spaz.node.materials += (mat, )
882            spaz.node.roller_materials += (mat, )
883
884        spaz.node.name = name
885        spaz.node.name_color = display_color
886        spaz.connect_controls_to_player()
887
888        # Move to the stand position and add a flash of light.
889        spaz.handlemessage(
890            StandMessage(
891                position,
892                angle if angle is not None else random.uniform(0, 360)))
893        _ba.playsound(self._spawn_sound, 1, position=spaz.node.position)
894        light = _ba.newnode('light', attrs={'color': light_color})
895        spaz.node.connectattr('position', light, 'position')
896        animate(light, 'intensity', {0: 0, 0.25: 1, 0.5: 0})
897        _ba.timer(0.5, light.delete)
898        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:
900    def setup_standard_powerup_drops(self, enable_tnt: bool = True) -> None:
901        """Create standard powerup drops for the current map."""
902        # pylint: disable=cyclic-import
903        from bastd.actor.powerupbox import DEFAULT_POWERUP_INTERVAL
904        self._powerup_drop_timer = _ba.Timer(DEFAULT_POWERUP_INTERVAL,
905                                             WeakCall(
906                                                 self._standard_drop_powerups),
907                                             repeat=True)
908        self._standard_drop_powerups()
909        if enable_tnt:
910            self._tnt_spawners = {}
911            self._setup_standard_tnt_drops()

Create standard powerup drops for the current map.

def setup_standard_time_limit(self, duration: float) -> None:
938    def setup_standard_time_limit(self, duration: float) -> None:
939        """
940        Create a standard game time-limit given the provided
941        duration in seconds.
942        This will be displayed at the top of the screen.
943        If the time-limit expires, end_game() will be called.
944        """
945        from ba._nodeactor import NodeActor
946        if duration <= 0.0:
947            return
948        self._standard_time_limit_time = int(duration)
949        self._standard_time_limit_timer = _ba.Timer(
950            1.0, WeakCall(self._standard_time_limit_tick), repeat=True)
951        self._standard_time_limit_text = NodeActor(
952            _ba.newnode('text',
953                        attrs={
954                            'v_attach': 'top',
955                            'h_attach': 'center',
956                            'h_align': 'left',
957                            'color': (1.0, 1.0, 1.0, 0.5),
958                            'position': (-25, -30),
959                            'flatness': 1.0,
960                            'scale': 0.9
961                        }))
962        self._standard_time_limit_text_input = NodeActor(
963            _ba.newnode('timedisplay',
964                        attrs={
965                            'time2': duration * 1000,
966                            'timemin': 0
967                        }))
968        self.globalsnode.connectattr('time',
969                                     self._standard_time_limit_text_input.node,
970                                     'time1')
971        assert self._standard_time_limit_text_input.node
972        assert self._standard_time_limit_text.node
973        self._standard_time_limit_text_input.node.connectattr(
974            'output', self._standard_time_limit_text.node, 'text')

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:
1128    def show_zoom_message(self,
1129                          message: ba.Lstr,
1130                          color: Sequence[float] = (0.9, 0.4, 0.0),
1131                          scale: float = 0.8,
1132                          duration: float = 2.0,
1133                          trail: bool = False) -> None:
1134        """Zooming text used to announce game names and winners."""
1135        # pylint: disable=cyclic-import
1136        from bastd.actor.zoomtext import ZoomText
1137
1138        # Reserve a spot on the screen (in case we get multiple of these so
1139        # they don't overlap).
1140        i = 0
1141        cur_time = _ba.time()
1142        while True:
1143            if (i not in self._zoom_message_times
1144                    or self._zoom_message_times[i] < cur_time):
1145                self._zoom_message_times[i] = cur_time + duration
1146                break
1147            i += 1
1148        ZoomText(message,
1149                 lifespan=duration,
1150                 jitter=2.0,
1151                 position=(0, 200 - i * 100),
1152                 scale=scale,
1153                 maxwidth=800,
1154                 trail=trail,
1155                 color=color).autoretain()

Zooming text used to announce game names and winners.

class GameResults:
 27class GameResults:
 28    """
 29    Results for a completed game.
 30
 31    Category: **Gameplay Classes**
 32
 33    Upon completion, a game should fill one of these out and pass it to its
 34    ba.Activity.end call.
 35    """
 36
 37    def __init__(self) -> None:
 38        self._game_set = False
 39        self._scores: dict[int, tuple[weakref.ref[ba.SessionTeam],
 40                                      int | None]] = {}
 41        self._sessionteams: list[weakref.ref[ba.SessionTeam]] | None = None
 42        self._playerinfos: list[ba.PlayerInfo] | None = None
 43        self._lower_is_better: bool | None = None
 44        self._score_label: str | None = None
 45        self._none_is_winner: bool | None = None
 46        self._scoretype: ba.ScoreType | None = None
 47
 48    def set_game(self, game: ba.GameActivity) -> None:
 49        """Set the game instance these results are applying to."""
 50        if self._game_set:
 51            raise RuntimeError('Game set twice for GameResults.')
 52        self._game_set = True
 53        self._sessionteams = [
 54            weakref.ref(team.sessionteam) for team in game.teams
 55        ]
 56        scoreconfig = game.getscoreconfig()
 57        self._playerinfos = copy.deepcopy(game.initialplayerinfos)
 58        self._lower_is_better = scoreconfig.lower_is_better
 59        self._score_label = scoreconfig.label
 60        self._none_is_winner = scoreconfig.none_is_winner
 61        self._scoretype = scoreconfig.scoretype
 62
 63    def set_team_score(self, team: ba.Team, score: int | None) -> None:
 64        """Set the score for a given team.
 65
 66        This can be a number or None.
 67        (see the none_is_winner arg in the constructor)
 68        """
 69        assert isinstance(team, Team)
 70        sessionteam = team.sessionteam
 71        self._scores[sessionteam.id] = (weakref.ref(sessionteam), score)
 72
 73    def get_sessionteam_score(self, sessionteam: ba.SessionTeam) -> int | None:
 74        """Return the score for a given ba.SessionTeam."""
 75        assert isinstance(sessionteam, SessionTeam)
 76        for score in list(self._scores.values()):
 77            if score[0]() is sessionteam:
 78                return score[1]
 79
 80        # If we have no score value, assume None.
 81        return None
 82
 83    @property
 84    def sessionteams(self) -> list[ba.SessionTeam]:
 85        """Return all ba.SessionTeams in the results."""
 86        if not self._game_set:
 87            raise RuntimeError("Can't get teams until game is set.")
 88        teams = []
 89        assert self._sessionteams is not None
 90        for team_ref in self._sessionteams:
 91            team = team_ref()
 92            if team is not None:
 93                teams.append(team)
 94        return teams
 95
 96    def has_score_for_sessionteam(self, sessionteam: ba.SessionTeam) -> bool:
 97        """Return whether there is a score for a given session-team."""
 98        return any(s[0]() is sessionteam for s in self._scores.values())
 99
100    def get_sessionteam_score_str(self,
101                                  sessionteam: ba.SessionTeam) -> ba.Lstr:
102        """Return the score for the given session-team as an Lstr.
103
104        (properly formatted for the score type.)
105        """
106        from ba._gameutils import timestring
107        from ba._language import Lstr
108        from ba._generated.enums import TimeFormat
109        from ba._score import ScoreType
110        if not self._game_set:
111            raise RuntimeError("Can't get team-score-str until game is set.")
112        for score in list(self._scores.values()):
113            if score[0]() is sessionteam:
114                if score[1] is None:
115                    return Lstr(value='-')
116                if self._scoretype is ScoreType.SECONDS:
117                    return timestring(score[1] * 1000,
118                                      centi=False,
119                                      timeformat=TimeFormat.MILLISECONDS)
120                if self._scoretype is ScoreType.MILLISECONDS:
121                    return timestring(score[1],
122                                      centi=True,
123                                      timeformat=TimeFormat.MILLISECONDS)
124                return Lstr(value=str(score[1]))
125        return Lstr(value='-')
126
127    @property
128    def playerinfos(self) -> list[ba.PlayerInfo]:
129        """Get info about the players represented by the results."""
130        if not self._game_set:
131            raise RuntimeError("Can't get player-info until game is set.")
132        assert self._playerinfos is not None
133        return self._playerinfos
134
135    @property
136    def scoretype(self) -> ba.ScoreType:
137        """The type of score."""
138        if not self._game_set:
139            raise RuntimeError("Can't get score-type until game is set.")
140        assert self._scoretype is not None
141        return self._scoretype
142
143    @property
144    def score_label(self) -> str:
145        """The label associated with scores ('points', etc)."""
146        if not self._game_set:
147            raise RuntimeError("Can't get score-label until game is set.")
148        assert self._score_label is not None
149        return self._score_label
150
151    @property
152    def lower_is_better(self) -> bool:
153        """Whether lower scores are better."""
154        if not self._game_set:
155            raise RuntimeError("Can't get lower-is-better until game is set.")
156        assert self._lower_is_better is not None
157        return self._lower_is_better
158
159    @property
160    def winning_sessionteam(self) -> ba.SessionTeam | None:
161        """The winning ba.SessionTeam if there is exactly one, or else None."""
162        if not self._game_set:
163            raise RuntimeError("Can't get winners until game is set.")
164        winners = self.winnergroups
165        if winners and len(winners[0].teams) == 1:
166            return winners[0].teams[0]
167        return None
168
169    @property
170    def winnergroups(self) -> list[WinnerGroup]:
171        """Get an ordered list of winner groups."""
172        if not self._game_set:
173            raise RuntimeError("Can't get winners until game is set.")
174
175        # Group by best scoring teams.
176        winners: dict[int, list[ba.SessionTeam]] = {}
177        scores = [
178            score for score in self._scores.values()
179            if score[0]() is not None and score[1] is not None
180        ]
181        for score in scores:
182            assert score[1] is not None
183            sval = winners.setdefault(score[1], [])
184            team = score[0]()
185            assert team is not None
186            sval.append(team)
187        results: list[tuple[int | None,
188                            list[ba.SessionTeam]]] = list(winners.items())
189        results.sort(reverse=not self._lower_is_better,
190                     key=lambda x: asserttype(x[0], int))
191
192        # Also group the 'None' scores.
193        none_sessionteams: list[ba.SessionTeam] = []
194        for score in self._scores.values():
195            scoreteam = score[0]()
196            if scoreteam is not None and score[1] is None:
197                none_sessionteams.append(scoreteam)
198
199        # Add the Nones to the list (either as winners or losers
200        # depending on the rules).
201        if none_sessionteams:
202            nones: list[tuple[int | None, list[ba.SessionTeam]]] = [
203                (None, none_sessionteams)
204            ]
205            if self._none_is_winner:
206                results = nones + results
207            else:
208                results = results + nones
209
210        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()
37    def __init__(self) -> None:
38        self._game_set = False
39        self._scores: dict[int, tuple[weakref.ref[ba.SessionTeam],
40                                      int | None]] = {}
41        self._sessionteams: list[weakref.ref[ba.SessionTeam]] | None = None
42        self._playerinfos: list[ba.PlayerInfo] | None = None
43        self._lower_is_better: bool | None = None
44        self._score_label: str | None = None
45        self._none_is_winner: bool | None = None
46        self._scoretype: ba.ScoreType | None = None
def set_game(self, game: ba.GameActivity) -> None:
48    def set_game(self, game: ba.GameActivity) -> None:
49        """Set the game instance these results are applying to."""
50        if self._game_set:
51            raise RuntimeError('Game set twice for GameResults.')
52        self._game_set = True
53        self._sessionteams = [
54            weakref.ref(team.sessionteam) for team in game.teams
55        ]
56        scoreconfig = game.getscoreconfig()
57        self._playerinfos = copy.deepcopy(game.initialplayerinfos)
58        self._lower_is_better = scoreconfig.lower_is_better
59        self._score_label = scoreconfig.label
60        self._none_is_winner = scoreconfig.none_is_winner
61        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:
63    def set_team_score(self, team: ba.Team, score: int | None) -> None:
64        """Set the score for a given team.
65
66        This can be a number or None.
67        (see the none_is_winner arg in the constructor)
68        """
69        assert isinstance(team, Team)
70        sessionteam = team.sessionteam
71        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:
73    def get_sessionteam_score(self, sessionteam: ba.SessionTeam) -> int | None:
74        """Return the score for a given ba.SessionTeam."""
75        assert isinstance(sessionteam, SessionTeam)
76        for score in list(self._scores.values()):
77            if score[0]() is sessionteam:
78                return score[1]
79
80        # If we have no score value, assume None.
81        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:
96    def has_score_for_sessionteam(self, sessionteam: ba.SessionTeam) -> bool:
97        """Return whether there is a score for a given session-team."""
98        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:
100    def get_sessionteam_score_str(self,
101                                  sessionteam: ba.SessionTeam) -> ba.Lstr:
102        """Return the score for the given session-team as an Lstr.
103
104        (properly formatted for the score type.)
105        """
106        from ba._gameutils import timestring
107        from ba._language import Lstr
108        from ba._generated.enums import TimeFormat
109        from ba._score import ScoreType
110        if not self._game_set:
111            raise RuntimeError("Can't get team-score-str until game is set.")
112        for score in list(self._scores.values()):
113            if score[0]() is sessionteam:
114                if score[1] is None:
115                    return Lstr(value='-')
116                if self._scoretype is ScoreType.SECONDS:
117                    return timestring(score[1] * 1000,
118                                      centi=False,
119                                      timeformat=TimeFormat.MILLISECONDS)
120                if self._scoretype is ScoreType.MILLISECONDS:
121                    return timestring(score[1],
122                                      centi=True,
123                                      timeformat=TimeFormat.MILLISECONDS)
124                return Lstr(value=str(score[1]))
125        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    text: str
36    icon: ba.Texture | None = None
37    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)
icon: ba.Texture | None = None
sound: ba.Sound | None = None
def garbage_collect() -> None:
169def garbage_collect() -> None:
170    """Run an explicit pass of garbage collection.
171
172    category: General Utility Functions
173
174    May also print warnings/etc. if collection takes too long or if
175    uncollectible objects are found (so use this instead of simply
176    gc.collect().
177    """
178    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:
1951def getactivity(doraise: bool = True) -> ba.Activity | None:
1952    """Return the current ba.Activity instance.
1953
1954    Category: **Gameplay Functions**
1955
1956    Note that this is based on context; thus code run in a timer generated
1957    in Activity 'foo' will properly return 'foo' here, even if another
1958    Activity has since been created or is transitioning in.
1959    If there is no current Activity, raises a ba.ActivityNotFoundError.
1960    If doraise is False, None will be returned instead in that case.
1961    """
1962    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]:
61def getclass(name: str, subclassof: type[T]) -> type[T]:
62    """Given a full class name such as foo.bar.MyClass, return the class.
63
64    Category: **General Utility Functions**
65
66    The class will be checked to make sure it is a subclass of the provided
67    'subclassof' class, and a TypeError will be raised if not.
68    """
69    import importlib
70    splits = name.split('.')
71    modulename = '.'.join(splits[:-1])
72    classname = splits[-1]
73    module = importlib.import_module(modulename)
74    cls: type = getattr(module, classname)
75
76    if not issubclass(cls, subclassof):
77        raise TypeError(f'{name} is not a subclass of {subclassof}.')
78    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:
1965def getcollidemodel(name: str) -> ba.CollideModel:
1966    """Return a collide-model, loading it if necessary.
1967
1968    Category: **Asset Functions**
1969
1970    Collide-models are used in physics calculations for such things as
1971    terrain.
1972
1973    Note that this function returns immediately even if the media has yet
1974    to be loaded. To avoid hitches, instantiate your media objects in
1975    advance of when you will be using them, allowing time for them to load
1976    in the background if necessary.
1977    """
1978    import ba  # pylint: disable=cyclic-import
1979    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:
1982def getdata(name: str) -> ba.Data:
1983    """Return a data, loading it if necessary.
1984
1985    Category: **Asset Functions**
1986
1987    Note that this function returns immediately even if the media has yet
1988    to be loaded. To avoid hitches, instantiate your media objects in
1989    advance of when you will be using them, allowing time for them to load
1990    in the background if necessary.
1991    """
1992    import ba  # pylint: disable=cyclic-import
1993    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]:
 56def getmaps(playtype: str) -> list[str]:
 57    """Return a list of ba.Map types supporting a playtype str.
 58
 59    Category: **Asset Functions**
 60
 61    Maps supporting a given playtype must provide a particular set of
 62    features and lend themselves to a certain style of play.
 63
 64    Play Types:
 65
 66    'melee'
 67      General fighting map.
 68      Has one or more 'spawn' locations.
 69
 70    'team_flag'
 71      For games such as Capture The Flag where each team spawns by a flag.
 72      Has two or more 'spawn' locations, each with a corresponding 'flag'
 73      location (based on index).
 74
 75    'single_flag'
 76      For games such as King of the Hill or Keep Away where multiple teams
 77      are fighting over a single flag.
 78      Has two or more 'spawn' locations and 1 'flag_default' location.
 79
 80    'conquest'
 81      For games such as Conquest where flags are spread throughout the map
 82      - has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations.
 83
 84    'king_of_the_hill' - has 2+ 'spawn' locations, 1+ 'flag_default' locations,
 85                         and 1+ 'powerup_spawn' locations
 86
 87    'hockey'
 88      For hockey games.
 89      Has two 'goal' locations, corresponding 'spawn' locations, and one
 90      'flag_default' location (for where puck spawns)
 91
 92    'football'
 93      For football games.
 94      Has two 'goal' locations, corresponding 'spawn' locations, and one
 95      'flag_default' location (for where flag/ball/etc. spawns)
 96
 97    'race'
 98      For racing games where players much touch each region in order.
 99      Has two or more 'race_point' locations.
100    """
101    return sorted(key for key, val in _ba.app.maps.items()
102                  if playtype in val.get_play_types())

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:
2025def getmodel(name: str) -> ba.Model:
2026    """Return a model, loading it if necessary.
2027
2028    Category: **Asset Functions**
2029
2030    Note that this function returns immediately even if the media has yet
2031    to be loaded. To avoid hitches, instantiate your media objects in
2032    advance of when you will be using them, allowing time for them to load
2033    in the background if necessary.
2034    """
2035    import ba  # pylint: disable=cyclic-import
2036    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:
2039def getnodes() -> list:
2040    """Return all nodes in the current ba.Context.
2041
2042    Category: **Gameplay Functions**
2043    """
2044    return list()

Return all nodes in the current ba.Context.

Category: Gameplay Functions

def getsession(doraise: bool = True) -> ba.Session | None:
2058def getsession(doraise: bool = True) -> ba.Session | None:
2059    """Category: **Gameplay Functions**
2060
2061    Returns the current ba.Session instance.
2062    Note that this is based on context; thus code being run in the UI
2063    context will return the UI context here even if a game Session also
2064    exists, etc. If there is no current Session, an Exception is raised, or
2065    if doraise is False then None is returned instead.
2066    """
2067    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:
2070def getsound(name: str) -> ba.Sound:
2071    """Return a sound, loading it if necessary.
2072
2073    Category: **Asset Functions**
2074
2075    Note that this function returns immediately even if the media has yet
2076    to be loaded. To avoid hitches, instantiate your media objects in
2077    advance of when you will be using them, allowing time for them to load
2078    in the background if necessary.
2079    """
2080    import ba  # pylint: disable=cyclic-import
2081    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:
2084def gettexture(name: str) -> ba.Texture:
2085    """Return a texture, loading it if necessary.
2086
2087    Category: **Asset Functions**
2088
2089    Note that this function returns immediately even if the media has yet
2090    to be loaded. To avoid hitches, instantiate your media objects in
2091    advance of when you will be using them, allowing time for them to load
2092    in the background if necessary.
2093    """
2094    import ba  # pylint: disable=cyclic-import
2095    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:
230class HitMessage:
231    """Tells an object it has been hit in some way.
232
233    Category: **Message Classes**
234
235    This is used by punches, explosions, etc to convey
236    their effect to a target.
237    """
238
239    def __init__(self,
240                 srcnode: ba.Node | None = None,
241                 pos: Sequence[float] | None = None,
242                 velocity: Sequence[float] | None = None,
243                 magnitude: float = 1.0,
244                 velocity_magnitude: float = 0.0,
245                 radius: float = 1.0,
246                 source_player: ba.Player | None = None,
247                 kick_back: float = 1.0,
248                 flat_damage: float | None = None,
249                 hit_type: str = 'generic',
250                 force_direction: Sequence[float] | None = None,
251                 hit_subtype: str = 'default'):
252        """Instantiate a message with given values."""
253
254        self.srcnode = srcnode
255        self.pos = pos if pos is not None else _ba.Vec3()
256        self.velocity = velocity if velocity is not None else _ba.Vec3()
257        self.magnitude = magnitude
258        self.velocity_magnitude = velocity_magnitude
259        self.radius = radius
260
261        # We should not be getting passed an invalid ref.
262        assert source_player is None or source_player.exists()
263        self._source_player = source_player
264        self.kick_back = kick_back
265        self.flat_damage = flat_damage
266        self.hit_type = hit_type
267        self.hit_subtype = hit_subtype
268        self.force_direction = (force_direction
269                                if force_direction is not None else velocity)
270
271    def get_source_player(self,
272                          playertype: type[PlayerType]) -> PlayerType | None:
273        """Return the source-player if one exists and is the provided type."""
274        player: Any = self._source_player
275
276        # We should not be delivering invalid refs.
277        # (we could translate to None here but technically we are changing
278        # the message delivered which seems wrong)
279        assert player is None or player.exists()
280
281        # Return the player *only* if they're the type given.
282        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')
239    def __init__(self,
240                 srcnode: ba.Node | None = None,
241                 pos: Sequence[float] | None = None,
242                 velocity: Sequence[float] | None = None,
243                 magnitude: float = 1.0,
244                 velocity_magnitude: float = 0.0,
245                 radius: float = 1.0,
246                 source_player: ba.Player | None = None,
247                 kick_back: float = 1.0,
248                 flat_damage: float | None = None,
249                 hit_type: str = 'generic',
250                 force_direction: Sequence[float] | None = None,
251                 hit_subtype: str = 'default'):
252        """Instantiate a message with given values."""
253
254        self.srcnode = srcnode
255        self.pos = pos if pos is not None else _ba.Vec3()
256        self.velocity = velocity if velocity is not None else _ba.Vec3()
257        self.magnitude = magnitude
258        self.velocity_magnitude = velocity_magnitude
259        self.radius = radius
260
261        # We should not be getting passed an invalid ref.
262        assert source_player is None or source_player.exists()
263        self._source_player = source_player
264        self.kick_back = kick_back
265        self.flat_damage = flat_damage
266        self.hit_type = hit_type
267        self.hit_subtype = hit_subtype
268        self.force_direction = (force_direction
269                                if force_direction is not None else velocity)

Instantiate a message with given values.

def get_source_player(self, playertype: type[~PlayerType]) -> Optional[~PlayerType]:
271    def get_source_player(self,
272                          playertype: type[PlayerType]) -> PlayerType | None:
273        """Return the source-player if one exists and is the provided type."""
274        player: Any = self._source_player
275
276        # We should not be delivering invalid refs.
277        # (we could translate to None here but technically we are changing
278        # the message delivered which seems wrong)
279        assert player is None or player.exists()
280
281        # Return the player *only* if they're the type given.
282        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:
2157def hscrollwidget(edit: ba.Widget | None = None,
2158                  parent: ba.Widget | None = None,
2159                  size: Sequence[float] | None = None,
2160                  position: Sequence[float] | None = None,
2161                  background: bool | None = None,
2162                  selected_child: ba.Widget | None = None,
2163                  capture_arrows: bool | None = None,
2164                  on_select_call: Callable[[], None] | None = None,
2165                  center_small_content: bool | None = None,
2166                  color: Sequence[float] | None = None,
2167                  highlight: bool | None = None,
2168                  border_opacity: float | None = None,
2169                  simple_culling_h: float | None = None,
2170                  claims_left_right: bool | None = None,
2171                  claims_up_down: bool | None = None,
2172                  claims_tab: bool | None = None) -> ba.Widget:
2173    """Create or edit a horizontal scroll widget.
2174
2175    Category: **User Interface Functions**
2176
2177    Pass a valid existing ba.Widget as 'edit' to modify it; otherwise
2178    a new one is created and returned. Arguments that are not set to None
2179    are applied to the Widget.
2180    """
2181    import ba  # pylint: disable=cyclic-import
2182    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:
2185def imagewidget(edit: ba.Widget | None = None,
2186                parent: ba.Widget | None = None,
2187                size: Sequence[float] | None = None,
2188                position: Sequence[float] | None = None,
2189                color: Sequence[float] | None = None,
2190                texture: ba.Texture | None = None,
2191                opacity: float | None = None,
2192                model_transparent: ba.Model | None = None,
2193                model_opaque: ba.Model | None = None,
2194                has_alpha_channel: bool = True,
2195                tint_texture: ba.Texture | None = None,
2196                tint_color: Sequence[float] | None = None,
2197                transition_delay: float | None = None,
2198                draw_controller: ba.Widget | None = None,
2199                tint2_color: Sequence[float] | None = None,
2200                tilt_scale: float | None = None,
2201                mask_texture: ba.Texture | None = None,
2202                radial_amount: float | None = None) -> ba.Widget:
2203    """Create or edit an image widget.
2204
2205    Category: **User Interface Functions**
2206
2207    Pass a valid existing ba.Widget as 'edit' to modify it; otherwise
2208    a new one is created and returned. Arguments that are not set to None
2209    are applied to the Widget.
2210    """
2211    import ba  # pylint: disable=cyclic-import
2212    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:
190@dataclass
191class ImpactDamageMessage:
192    """Tells an object that it has been jarred violently.
193
194    Category: **Message Classes**
195    """
196
197    intensity: float
198    """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:
217class InputDevice:
218    """An input-device such as a gamepad, touchscreen, or keyboard.
219
220    Category: **Gameplay Classes**
221    """
222    allows_configuring: bool
223    """Whether the input-device can be configured."""
224
225    has_meaningful_button_names: bool
226    """Whether button names returned by this instance match labels
227       on the actual device. (Can be used to determine whether to show
228       them in controls-overlays, etc.)."""
229
230    player: ba.SessionPlayer | None
231    """The player associated with this input device."""
232
233    client_id: int
234    """The numeric client-id this device is associated with.
235       This is only meaningful for remote client inputs; for
236       all local devices this will be -1."""
237
238    name: str
239    """The name of the device."""
240
241    unique_identifier: str
242    """A string that can be used to persistently identify the device,
243       even among other devices of the same type. Used for saving
244       prefs, etc."""
245
246    id: int
247    """The unique numeric id of this device."""
248
249    instance_number: int
250    """The number of this device among devices of the same type."""
251
252    is_controller_app: bool
253    """Whether this input-device represents a locally-connected
254       controller-app."""
255
256    is_remote_client: bool
257    """Whether this input-device represents a remotely-connected
258       client."""
259
260    def exists(self) -> bool:
261        """Return whether the underlying device for this object is
262        still present.
263        """
264        return bool()
265
266    def get_axis_name(self, axis_id: int) -> str:
267        """Given an axis ID, return the name of the axis on this device.
268
269        Can return an empty string if the value is not meaningful to humans.
270        """
271        return str()
272
273    def get_button_name(self, button_id: int) -> ba.Lstr:
274        """Given a button ID, return a human-readable name for that key/button.
275
276        Can return an empty string if the value is not meaningful to humans.
277        """
278        import ba  # pylint: disable=cyclic-import
279        return ba.Lstr(value='')
280
281    def get_default_player_name(self) -> str:
282        """(internal)
283
284        Returns the default player name for this device. (used for the 'random'
285        profile)
286        """
287        return str()
288
289    def get_player_profiles(self) -> dict:
290        """(internal)"""
291        return dict()
292
293    def get_v1_account_name(self, full: bool) -> str:
294        """Returns the account name associated with this device.
295
296        (can be used to get account names for remote players)
297        """
298        return str()
299
300    def is_connected_to_remote_player(self) -> bool:
301        """(internal)"""
302        return bool()
303
304    def remove_remote_player_from_game(self) -> None:
305        """(internal)"""
306        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:
260    def exists(self) -> bool:
261        """Return whether the underlying device for this object is
262        still present.
263        """
264        return bool()

Return whether the underlying device for this object is still present.

def get_axis_name(self, axis_id: int) -> str:
266    def get_axis_name(self, axis_id: int) -> str:
267        """Given an axis ID, return the name of the axis on this device.
268
269        Can return an empty string if the value is not meaningful to humans.
270        """
271        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:
273    def get_button_name(self, button_id: int) -> ba.Lstr:
274        """Given a button ID, return a human-readable name for that key/button.
275
276        Can return an empty string if the value is not meaningful to humans.
277        """
278        import ba  # pylint: disable=cyclic-import
279        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:
293    def get_v1_account_name(self, full: bool) -> str:
294        """Returns the account name associated with this device.
295
296        (can be used to get account names for remote players)
297        """
298        return str()

Returns the account name associated with this device.

(can be used to get account names for remote players)

class InputDeviceNotFoundError(ba.NotFoundError):
115class InputDeviceNotFoundError(NotFoundError):
116    """Exception raised when an expected ba.InputDevice does not exist.
117
118    Category: **Exception Classes**
119    """

Exception raised when an expected ba.InputDevice does not exist.

Category: Exception Classes

Inherited Members
builtins.Exception
Exception
builtins.BaseException
with_traceback
args
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    UP_DOWN = 2
15    LEFT_RIGHT = 3
16    JUMP_PRESS = 4
17    JUMP_RELEASE = 5
18    PUNCH_PRESS = 6
19    PUNCH_RELEASE = 7
20    BOMB_PRESS = 8
21    BOMB_RELEASE = 9
22    PICK_UP_PRESS = 10
23    PICK_UP_RELEASE = 11
24    RUN = 12
25    FLY_PRESS = 13
26    FLY_RELEASE = 14
27    START_PRESS = 15
28    START_RELEASE = 16
29    HOLD_POSITION_PRESS = 17
30    HOLD_POSITION_RELEASE = 18
31    LEFT_PRESS = 19
32    LEFT_RELEASE = 20
33    RIGHT_PRESS = 21
34    RIGHT_RELEASE = 22
35    UP_PRESS = 23
36    UP_RELEASE = 24
37    DOWN_PRESS = 25
38    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):
68@dataclass
69class IntChoiceSetting(ChoiceSetting):
70    """An int setting with multiple choices.
71
72    Category: Settings Classes
73    """
74    default: int
75    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):
35@dataclass
36class IntSetting(Setting):
37    """An integer game setting.
38
39    Category: Settings Classes
40    """
41    default: int
42    min_value: int = 0
43    max_value: int = 9999
44    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)
min_value: int = 0
max_value: int = 9999
increment: int = 1
def is_browser_likely_available() -> bool:
18def is_browser_likely_available() -> bool:
19    """Return whether a browser likely exists on the current device.
20
21    category: General Utility Functions
22
23    If this returns False you may want to avoid calling ba.show_url()
24    with any lengthy addresses. (ba.show_url() will display an address
25    as a string in a window if unable to bring up a browser, but that
26    is only useful for simple URLs.)
27    """
28    app = _ba.app
29    platform = app.platform
30    touchscreen = _ba.getinputdevice('TouchScreen', '#1', doraise=False)
31
32    # If we're on a vr device or an android device with no touchscreen,
33    # assume no browser.
34    # FIXME: Might not be the case anymore; should make this definable
35    #  at the platform level.
36    if app.vr_mode or (platform == 'android' and touchscreen is None):
37        return False
38
39    # Anywhere else assume we've got one.
40    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:
37def is_point_in_box(pnt: Sequence[float], box: Sequence[float]) -> bool:
38    """Return whether a given point is within a given box.
39
40    category: General Utility Functions
41
42    For use with standard def boxes (position|rotate|scale).
43    """
44    return ((abs(pnt[0] - box[0]) <= box[6] * 0.5)
45            and (abs(pnt[1] - box[1]) <= box[7] * 0.5)
46            and (abs(pnt[2] - box[2]) <= box[8] * 0.5))

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 (language in {
 40                'Chinese', 'ChineseTraditional', 'Persian', 'Korean', 'Arabic',
 41                'Hindi', 'Vietnamese', 'Thai', 'Tamil'
 42        } and not _ba.can_display_full_unicode()):
 43            return False
 44        return True
 45
 46    @property
 47    def locale(self) -> str:
 48        """Raw country/language code detected by the game (such as 'en_US').
 49
 50        Generally for language-specific code you should look at
 51        ba.App.language, which is the language the game is using
 52        (which may differ from locale if the user sets a language, etc.)
 53        """
 54        env = _ba.env()
 55        assert isinstance(env['locale'], str)
 56        return env['locale']
 57
 58    def _get_default_language(self) -> str:
 59        languages = {
 60            'de': 'German',
 61            'es': 'Spanish',
 62            'sk': 'Slovak',
 63            'it': 'Italian',
 64            'nl': 'Dutch',
 65            'da': 'Danish',
 66            'pt': 'Portuguese',
 67            'fr': 'French',
 68            'el': 'Greek',
 69            'ru': 'Russian',
 70            'pl': 'Polish',
 71            'sv': 'Swedish',
 72            'eo': 'Esperanto',
 73            'cs': 'Czech',
 74            'hr': 'Croatian',
 75            'hu': 'Hungarian',
 76            'be': 'Belarussian',
 77            'ro': 'Romanian',
 78            'ko': 'Korean',
 79            'fa': 'Persian',
 80            'ar': 'Arabic',
 81            'zh': 'Chinese',
 82            'tr': 'Turkish',
 83            'th': 'Thai',
 84            'id': 'Indonesian',
 85            'sr': 'Serbian',
 86            'uk': 'Ukrainian',
 87            'vi': 'Vietnamese',
 88            'vec': 'Venetian',
 89            'hi': 'Hindi',
 90            'ta': 'Tamil',
 91            'fil': 'Filipino',
 92        }
 93
 94        # Special case for Chinese: map specific variations to traditional.
 95        # (otherwise will map to 'Chinese' which is simplified)
 96        if self.locale in ('zh_HANT', 'zh_TW'):
 97            language = 'ChineseTraditional'
 98        else:
 99            language = languages.get(self.locale[:2], 'English')
100        if not self._can_display_language(language):
101            language = 'English'
102        return language
103
104    @property
105    def language(self) -> str:
106        """The name of the language the game is running in.
107
108        This can be selected explicitly by the user or may be set
109        automatically based on ba.App.locale or other factors.
110        """
111        assert isinstance(_ba.app.config, dict)
112        return _ba.app.config.get('Lang', self.default_language)
113
114    @property
115    def available_languages(self) -> list[str]:
116        """A list of all available languages.
117
118        Note that languages that may be present in game assets but which
119        are not displayable on the running version of the game are not
120        included here.
121        """
122        langs = set()
123        try:
124            names = os.listdir('ba_data/data/languages')
125            names = [n.replace('.json', '').capitalize() for n in names]
126
127            # FIXME: our simple capitalization fails on multi-word names;
128            # should handle this in a better way...
129            for i, name in enumerate(names):
130                if name == 'Chinesetraditional':
131                    names[i] = 'ChineseTraditional'
132        except Exception:
133            from ba import _error
134            _error.print_exception()
135            names = []
136        for name in names:
137            if self._can_display_language(name):
138                langs.add(name)
139        return sorted(name for name in names
140                      if self._can_display_language(name))
141
142    def setlanguage(self,
143                    language: str | None,
144                    print_change: bool = True,
145                    store_to_config: bool = True) -> None:
146        """Set the active language used for the game.
147
148        Pass None to use OS default language.
149        """
150        # pylint: disable=too-many-locals
151        # pylint: disable=too-many-statements
152        # pylint: disable=too-many-branches
153        cfg = _ba.app.config
154        cur_language = cfg.get('Lang', None)
155
156        # Store this in the config if its changing.
157        if language != cur_language and store_to_config:
158            if language is None:
159                if 'Lang' in cfg:
160                    del cfg['Lang']  # Clear it out for default.
161            else:
162                cfg['Lang'] = language
163            cfg.commit()
164            switched = True
165        else:
166            switched = False
167
168        with open('ba_data/data/languages/english.json',
169                  encoding='utf-8') as infile:
170            lenglishvalues = json.loads(infile.read())
171
172        # None implies default.
173        if language is None:
174            language = self.default_language
175        try:
176            if language == 'English':
177                lmodvalues = None
178            else:
179                lmodfile = 'ba_data/data/languages/' + language.lower(
180                ) + '.json'
181                with open(lmodfile, encoding='utf-8') as infile:
182                    lmodvalues = json.loads(infile.read())
183        except Exception:
184            from ba import _error
185            _error.print_exception('Exception importing language:', language)
186            _ba.screenmessage("Error setting language to '" + language +
187                              "'; see log for details",
188                              color=(1, 0, 0))
189            switched = False
190            lmodvalues = None
191
192        # Create an attrdict of *just* our target language.
193        self.language_target = AttrDict()
194        langtarget = self.language_target
195        assert langtarget is not None
196        _add_to_attr_dict(
197            langtarget,
198            lmodvalues if lmodvalues is not None else lenglishvalues)
199
200        # Create an attrdict of our target language overlaid
201        # on our base (english).
202        languages = [lenglishvalues]
203        if lmodvalues is not None:
204            languages.append(lmodvalues)
205        lfull = AttrDict()
206        for lmod in languages:
207            _add_to_attr_dict(lfull, lmod)
208        self.language_merged = lfull
209
210        # Pass some keys/values in for low level code to use;
211        # start with everything in their 'internal' section.
212        internal_vals = [
213            v for v in list(lfull['internal'].items())
214            if isinstance(v[1], str)
215        ]
216
217        # Cherry-pick various other values to include.
218        # (should probably get rid of the 'internal' section
219        # and do everything this way)
220        for value in [
221                'replayNameDefaultText', 'replayWriteErrorText',
222                'replayVersionErrorText', 'replayReadErrorText'
223        ]:
224            internal_vals.append((value, lfull[value]))
225        internal_vals.append(
226            ('axisText', lfull['configGamepadWindow']['axisText']))
227        internal_vals.append(('buttonText', lfull['buttonText']))
228        lmerged = self.language_merged
229        assert lmerged is not None
230        random_names = [
231            n.strip() for n in lmerged['randomPlayerNamesText'].split(',')
232        ]
233        random_names = [n for n in random_names if n != '']
234        _ba.set_internal_language_keys(internal_vals, random_names)
235        if switched and print_change:
236            _ba.screenmessage(Lstr(resource='languageSetText',
237                                   subs=[('${LANGUAGE}',
238                                          Lstr(translate=('languages',
239                                                          language)))]),
240                              color=(0, 1, 0))
241
242    def get_resource(self,
243                     resource: str,
244                     fallback_resource: str | None = None,
245                     fallback_value: Any = None) -> Any:
246        """Return a translation resource by name.
247
248        DEPRECATED; use ba.Lstr functionality for these purposes.
249        """
250        try:
251            # If we have no language set, go ahead and set it.
252            if self.language_merged is None:
253                language = self.language
254                try:
255                    self.setlanguage(language,
256                                     print_change=False,
257                                     store_to_config=False)
258                except Exception:
259                    from ba import _error
260                    _error.print_exception('exception setting language to',
261                                           language)
262
263                    # Try english as a fallback.
264                    if language != 'English':
265                        print('Resorting to fallback language (English)')
266                        try:
267                            self.setlanguage('English',
268                                             print_change=False,
269                                             store_to_config=False)
270                        except Exception:
271                            _error.print_exception(
272                                'error setting language to english fallback')
273
274            # If they provided a fallback_resource value, try the
275            # target-language-only dict first and then fall back to trying the
276            # fallback_resource value in the merged dict.
277            if fallback_resource is not None:
278                try:
279                    values = self.language_target
280                    splits = resource.split('.')
281                    dicts = splits[:-1]
282                    key = splits[-1]
283                    for dct in dicts:
284                        assert values is not None
285                        values = values[dct]
286                    assert values is not None
287                    val = values[key]
288                    return val
289                except Exception:
290                    # FIXME: Shouldn't we try the fallback resource in the
291                    #  merged dict AFTER we try the main resource in the
292                    #  merged dict?
293                    try:
294                        values = self.language_merged
295                        splits = fallback_resource.split('.')
296                        dicts = splits[:-1]
297                        key = splits[-1]
298                        for dct in dicts:
299                            assert values is not None
300                            values = values[dct]
301                        assert values is not None
302                        val = values[key]
303                        return val
304
305                    except Exception:
306                        # If we got nothing for fallback_resource, default
307                        # to the normal code which checks or primary
308                        # value in the merge dict; there's a chance we can
309                        # get an english value for it (which we weren't
310                        # looking for the first time through).
311                        pass
312
313            values = self.language_merged
314            splits = resource.split('.')
315            dicts = splits[:-1]
316            key = splits[-1]
317            for dct in dicts:
318                assert values is not None
319                values = values[dct]
320            assert values is not None
321            val = values[key]
322            return val
323
324        except Exception:
325            # Ok, looks like we couldn't find our main or fallback resource
326            # anywhere. Now if we've been given a fallback value, return it;
327            # otherwise fail.
328            from ba import _error
329            if fallback_value is not None:
330                return fallback_value
331            raise _error.NotFoundError(
332                f"Resource not found: '{resource}'") from None
333
334    def translate(self,
335                  category: str,
336                  strval: str,
337                  raise_exceptions: bool = False,
338                  print_errors: bool = False) -> str:
339        """Translate a value (or return the value if no translation available)
340
341        DEPRECATED; use ba.Lstr functionality for these purposes.
342        """
343        try:
344            translated = self.get_resource('translations')[category][strval]
345        except Exception as exc:
346            if raise_exceptions:
347                raise
348            if print_errors:
349                print(('Translate error: category=\'' + category +
350                       '\' name=\'' + strval + '\' exc=' + str(exc) + ''))
351            translated = None
352        translated_out: str
353        if translated is None:
354            translated_out = strval
355        else:
356            translated_out = translated
357        assert isinstance(translated_out, str)
358        return translated_out
359
360    def is_custom_unicode_char(self, char: str) -> bool:
361        """Return whether a char is in the custom unicode range we use."""
362        assert isinstance(char, str)
363        if len(char) != 1:
364            raise ValueError('Invalid Input; must be length 1')
365        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:
142    def setlanguage(self,
143                    language: str | None,
144                    print_change: bool = True,
145                    store_to_config: bool = True) -> None:
146        """Set the active language used for the game.
147
148        Pass None to use OS default language.
149        """
150        # pylint: disable=too-many-locals
151        # pylint: disable=too-many-statements
152        # pylint: disable=too-many-branches
153        cfg = _ba.app.config
154        cur_language = cfg.get('Lang', None)
155
156        # Store this in the config if its changing.
157        if language != cur_language and store_to_config:
158            if language is None:
159                if 'Lang' in cfg:
160                    del cfg['Lang']  # Clear it out for default.
161            else:
162                cfg['Lang'] = language
163            cfg.commit()
164            switched = True
165        else:
166            switched = False
167
168        with open('ba_data/data/languages/english.json',
169                  encoding='utf-8') as infile:
170            lenglishvalues = json.loads(infile.read())
171
172        # None implies default.
173        if language is None:
174            language = self.default_language
175        try:
176            if language == 'English':
177                lmodvalues = None
178            else:
179                lmodfile = 'ba_data/data/languages/' + language.lower(
180                ) + '.json'
181                with open(lmodfile, encoding='utf-8') as infile:
182                    lmodvalues = json.loads(infile.read())
183        except Exception:
184            from ba import _error
185            _error.print_exception('Exception importing language:', language)
186            _ba.screenmessage("Error setting language to '" + language +
187                              "'; see log for details",
188                              color=(1, 0, 0))
189            switched = False
190            lmodvalues = None
191
192        # Create an attrdict of *just* our target language.
193        self.language_target = AttrDict()
194        langtarget = self.language_target
195        assert langtarget is not None
196        _add_to_attr_dict(
197            langtarget,
198            lmodvalues if lmodvalues is not None else lenglishvalues)
199
200        # Create an attrdict of our target language overlaid
201        # on our base (english).
202        languages = [lenglishvalues]
203        if lmodvalues is not None:
204            languages.append(lmodvalues)
205        lfull = AttrDict()
206        for lmod in languages:
207            _add_to_attr_dict(lfull, lmod)
208        self.language_merged = lfull
209
210        # Pass some keys/values in for low level code to use;
211        # start with everything in their 'internal' section.
212        internal_vals = [
213            v for v in list(lfull['internal'].items())
214            if isinstance(v[1], str)
215        ]
216
217        # Cherry-pick various other values to include.
218        # (should probably get rid of the 'internal' section
219        # and do everything this way)
220        for value in [
221                'replayNameDefaultText', 'replayWriteErrorText',
222                'replayVersionErrorText', 'replayReadErrorText'
223        ]:
224            internal_vals.append((value, lfull[value]))
225        internal_vals.append(
226            ('axisText', lfull['configGamepadWindow']['axisText']))
227        internal_vals.append(('buttonText', lfull['buttonText']))
228        lmerged = self.language_merged
229        assert lmerged is not None
230        random_names = [
231            n.strip() for n in lmerged['randomPlayerNamesText'].split(',')
232        ]
233        random_names = [n for n in random_names if n != '']
234        _ba.set_internal_language_keys(internal_vals, random_names)
235        if switched and print_change:
236            _ba.screenmessage(Lstr(resource='languageSetText',
237                                   subs=[('${LANGUAGE}',
238                                          Lstr(translate=('languages',
239                                                          language)))]),
240                              color=(0, 1, 0))

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:
242    def get_resource(self,
243                     resource: str,
244                     fallback_resource: str | None = None,
245                     fallback_value: Any = None) -> Any:
246        """Return a translation resource by name.
247
248        DEPRECATED; use ba.Lstr functionality for these purposes.
249        """
250        try:
251            # If we have no language set, go ahead and set it.
252            if self.language_merged is None:
253                language = self.language
254                try:
255                    self.setlanguage(language,
256                                     print_change=False,
257                                     store_to_config=False)
258                except Exception:
259                    from ba import _error
260                    _error.print_exception('exception setting language to',
261                                           language)
262
263                    # Try english as a fallback.
264                    if language != 'English':
265                        print('Resorting to fallback language (English)')
266                        try:
267                            self.setlanguage('English',
268                                             print_change=False,
269                                             store_to_config=False)
270                        except Exception:
271                            _error.print_exception(
272                                'error setting language to english fallback')
273
274            # If they provided a fallback_resource value, try the
275            # target-language-only dict first and then fall back to trying the
276            # fallback_resource value in the merged dict.
277            if fallback_resource is not None:
278                try:
279                    values = self.language_target
280                    splits = resource.split('.')
281                    dicts = splits[:-1]
282                    key = splits[-1]
283                    for dct in dicts:
284                        assert values is not None
285                        values = values[dct]
286                    assert values is not None
287                    val = values[key]
288                    return val
289                except Exception:
290                    # FIXME: Shouldn't we try the fallback resource in the
291                    #  merged dict AFTER we try the main resource in the
292                    #  merged dict?
293                    try:
294                        values = self.language_merged
295                        splits = fallback_resource.split('.')
296                        dicts = splits[:-1]
297                        key = splits[-1]
298                        for dct in dicts:
299                            assert values is not None
300                            values = values[dct]
301                        assert values is not None
302                        val = values[key]
303                        return val
304
305                    except Exception:
306                        # If we got nothing for fallback_resource, default
307                        # to the normal code which checks or primary
308                        # value in the merge dict; there's a chance we can
309                        # get an english value for it (which we weren't
310                        # looking for the first time through).
311                        pass
312
313            values = self.language_merged
314            splits = resource.split('.')
315            dicts = splits[:-1]
316            key = splits[-1]
317            for dct in dicts:
318                assert values is not None
319                values = values[dct]
320            assert values is not None
321            val = values[key]
322            return val
323
324        except Exception:
325            # Ok, looks like we couldn't find our main or fallback resource
326            # anywhere. Now if we've been given a fallback value, return it;
327            # otherwise fail.
328            from ba import _error
329            if fallback_value is not None:
330                return fallback_value
331            raise _error.NotFoundError(
332                f"Resource not found: '{resource}'") 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:
334    def translate(self,
335                  category: str,
336                  strval: str,
337                  raise_exceptions: bool = False,
338                  print_errors: bool = False) -> str:
339        """Translate a value (or return the value if no translation available)
340
341        DEPRECATED; use ba.Lstr functionality for these purposes.
342        """
343        try:
344            translated = self.get_resource('translations')[category][strval]
345        except Exception as exc:
346            if raise_exceptions:
347                raise
348            if print_errors:
349                print(('Translate error: category=\'' + category +
350                       '\' name=\'' + strval + '\' exc=' + str(exc) + ''))
351            translated = None
352        translated_out: str
353        if translated is None:
354            translated_out = strval
355        else:
356            translated_out = translated
357        assert isinstance(translated_out, str)
358        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:
360    def is_custom_unicode_char(self, char: str) -> bool:
361        """Return whether a char is in the custom unicode range we use."""
362        assert isinstance(char, str)
363        if len(char) != 1:
364            raise ValueError('Invalid Input; must be length 1')
365        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__(self,
 25                 name: str,
 26                 gametype: type[ba.GameActivity],
 27                 settings: dict,
 28                 preview_texture_name: str,
 29                 displayname: str | None = None):
 30        self._name = name
 31        self._gametype = gametype
 32        self._settings = settings
 33        self._preview_texture_name = preview_texture_name
 34        self._displayname = displayname
 35        self._campaign: weakref.ref[ba.Campaign] | None = None
 36        self._index: int | None = None
 37        self._score_version_string: str | None = None
 38
 39    def __repr__(self) -> str:
 40        cls = type(self)
 41        return f"<{cls.__module__}.{cls.__name__} '{self._name}'>"
 42
 43    @property
 44    def name(self) -> str:
 45        """The unique name for this Level."""
 46        return self._name
 47
 48    def get_settings(self) -> dict[str, Any]:
 49        """Returns the settings for this Level."""
 50        settings = copy.deepcopy(self._settings)
 51
 52        # So the game knows what the level is called.
 53        # Hmm; seems hacky; I think we should take this out.
 54        settings['name'] = self._name
 55        return settings
 56
 57    @property
 58    def preview_texture_name(self) -> str:
 59        """The preview texture name for this Level."""
 60        return self._preview_texture_name
 61
 62    def get_preview_texture(self) -> ba.Texture:
 63        """Load/return the preview Texture for this Level."""
 64        return _ba.gettexture(self._preview_texture_name)
 65
 66    @property
 67    def displayname(self) -> ba.Lstr:
 68        """The localized name for this Level."""
 69        from ba import _language
 70        return _language.Lstr(
 71            translate=('coopLevelNames', self._displayname
 72                       if self._displayname is not None else self._name),
 73            subs=[('${GAME}',
 74                   self._gametype.get_display_string(self._settings))])
 75
 76    @property
 77    def gametype(self) -> type[ba.GameActivity]:
 78        """The type of game used for this Level."""
 79        return self._gametype
 80
 81    @property
 82    def campaign(self) -> ba.Campaign | None:
 83        """The ba.Campaign this Level is associated with, or None."""
 84        return None if self._campaign is None else self._campaign()
 85
 86    @property
 87    def index(self) -> int:
 88        """The zero-based index of this Level in its ba.Campaign.
 89
 90        Access results in a RuntimeError if the Level is  not assigned to a
 91        Campaign.
 92        """
 93        if self._index is None:
 94            raise RuntimeError('Level is not part of a Campaign')
 95        return self._index
 96
 97    @property
 98    def complete(self) -> bool:
 99        """Whether this Level has been completed."""
100        config = self._get_config_dict()
101        return config.get('Complete', False)
102
103    def set_complete(self, val: bool) -> None:
104        """Set whether or not this level is complete."""
105        old_val = self.complete
106        assert isinstance(old_val, bool)
107        assert isinstance(val, bool)
108        if val != old_val:
109            config = self._get_config_dict()
110            config['Complete'] = val
111
112    def get_high_scores(self) -> dict:
113        """Return the current high scores for this Level."""
114        config = self._get_config_dict()
115        high_scores_key = 'High Scores' + self.get_score_version_string()
116        if high_scores_key not in config:
117            return {}
118        return copy.deepcopy(config[high_scores_key])
119
120    def set_high_scores(self, high_scores: dict) -> None:
121        """Set high scores for this level."""
122        config = self._get_config_dict()
123        high_scores_key = 'High Scores' + self.get_score_version_string()
124        config[high_scores_key] = high_scores
125
126    def get_score_version_string(self) -> str:
127        """Return the score version string for this Level.
128
129        If a Level's gameplay changes significantly, its version string
130        can be changed to separate its new high score lists/etc. from the old.
131        """
132        if self._score_version_string is None:
133            scorever = self._gametype.getscoreconfig().version
134            if scorever != '':
135                scorever = ' ' + scorever
136            self._score_version_string = scorever
137        assert self._score_version_string is not None
138        return self._score_version_string
139
140    @property
141    def rating(self) -> float:
142        """The current rating for this Level."""
143        return self._get_config_dict().get('Rating', 0.0)
144
145    def set_rating(self, rating: float) -> None:
146        """Set a rating for this Level, replacing the old ONLY IF higher."""
147        old_rating = self.rating
148        config = self._get_config_dict()
149        config['Rating'] = max(old_rating, rating)
150
151    def _get_config_dict(self) -> dict[str, Any]:
152        """Return/create the persistent state dict for this level.
153
154        The referenced dict exists under the game's config dict and
155        can be modified in place."""
156        campaign = self.campaign
157        if campaign is None:
158            raise RuntimeError('Level is not in a campaign.')
159        configdict = campaign.configdict
160        val: dict[str, Any] = configdict.setdefault(self._name, {
161            'Rating': 0.0,
162            'Complete': False
163        })
164        assert isinstance(val, dict)
165        return val
166
167    def set_campaign(self, campaign: ba.Campaign, index: int) -> None:
168        """For use by ba.Campaign when adding levels to itself.
169
170        (internal)"""
171        self._campaign = weakref.ref(campaign)
172        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__(self,
25                 name: str,
26                 gametype: type[ba.GameActivity],
27                 settings: dict,
28                 preview_texture_name: str,
29                 displayname: str | None = None):
30        self._name = name
31        self._gametype = gametype
32        self._settings = settings
33        self._preview_texture_name = preview_texture_name
34        self._displayname = displayname
35        self._campaign: weakref.ref[ba.Campaign] | None = None
36        self._index: int | None = None
37        self._score_version_string: str | None = None
name: str

The unique name for this Level.

def get_settings(self) -> dict[str, typing.Any]:
48    def get_settings(self) -> dict[str, Any]:
49        """Returns the settings for this Level."""
50        settings = copy.deepcopy(self._settings)
51
52        # So the game knows what the level is called.
53        # Hmm; seems hacky; I think we should take this out.
54        settings['name'] = self._name
55        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:
62    def get_preview_texture(self) -> ba.Texture:
63        """Load/return the preview Texture for this Level."""
64        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:
103    def set_complete(self, val: bool) -> None:
104        """Set whether or not this level is complete."""
105        old_val = self.complete
106        assert isinstance(old_val, bool)
107        assert isinstance(val, bool)
108        if val != old_val:
109            config = self._get_config_dict()
110            config['Complete'] = val

Set whether or not this level is complete.

def get_high_scores(self) -> dict:
112    def get_high_scores(self) -> dict:
113        """Return the current high scores for this Level."""
114        config = self._get_config_dict()
115        high_scores_key = 'High Scores' + self.get_score_version_string()
116        if high_scores_key not in config:
117            return {}
118        return copy.deepcopy(config[high_scores_key])

Return the current high scores for this Level.

def set_high_scores(self, high_scores: dict) -> None:
120    def set_high_scores(self, high_scores: dict) -> None:
121        """Set high scores for this level."""
122        config = self._get_config_dict()
123        high_scores_key = 'High Scores' + self.get_score_version_string()
124        config[high_scores_key] = high_scores

Set high scores for this level.

def get_score_version_string(self) -> str:
126    def get_score_version_string(self) -> str:
127        """Return the score version string for this Level.
128
129        If a Level's gameplay changes significantly, its version string
130        can be changed to separate its new high score lists/etc. from the old.
131        """
132        if self._score_version_string is None:
133            scorever = self._gametype.getscoreconfig().version
134            if scorever != '':
135                scorever = ' ' + scorever
136            self._score_version_string = scorever
137        assert self._score_version_string is not None
138        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:
145    def set_rating(self, rating: float) -> None:
146        """Set a rating for this Level, replacing the old ONLY IF higher."""
147        old_rating = self.rating
148        config = self._get_config_dict()
149        config['Rating'] = max(old_rating, rating)

Set a rating for this Level, replacing the old ONLY IF higher.

class Lobby:
810class Lobby:
811    """Container for ba.Choosers.
812
813    Category: Gameplay Classes
814    """
815
816    def __del__(self) -> None:
817
818        # Reset any players that still have a chooser in us.
819        # (should allow the choosers to die).
820        sessionplayers = [
821            c.sessionplayer for c in self.choosers if c.sessionplayer
822        ]
823        for sessionplayer in sessionplayers:
824            sessionplayer.resetinput()
825
826    def __init__(self) -> None:
827        from ba._team import SessionTeam
828        from ba._coopsession import CoopSession
829        session = _ba.getsession()
830        self._use_team_colors = session.use_team_colors
831        if session.use_teams:
832            self._sessionteams = [
833                weakref.ref(team) for team in session.sessionteams
834            ]
835        else:
836            self._dummy_teams = SessionTeam()
837            self._sessionteams = [weakref.ref(self._dummy_teams)]
838        v_offset = (-150 if isinstance(session, CoopSession) else -50)
839        self.choosers: list[Chooser] = []
840        self.base_v_offset = v_offset
841        self.update_positions()
842        self._next_add_team = 0
843        self.character_names_local_unlocked: list[str] = []
844        self._vpos = 0
845
846        # Grab available profiles.
847        self.reload_profiles()
848
849        self._join_info_text = None
850
851    @property
852    def next_add_team(self) -> int:
853        """(internal)"""
854        return self._next_add_team
855
856    @property
857    def use_team_colors(self) -> bool:
858        """A bool for whether this lobby is using team colors.
859
860        If False, inidividual player colors are used instead.
861        """
862        return self._use_team_colors
863
864    @property
865    def sessionteams(self) -> list[ba.SessionTeam]:
866        """ba.SessionTeams available in this lobby."""
867        allteams = []
868        for tref in self._sessionteams:
869            team = tref()
870            assert team is not None
871            allteams.append(team)
872        return allteams
873
874    def get_choosers(self) -> list[Chooser]:
875        """Return the lobby's current choosers."""
876        return self.choosers
877
878    def create_join_info(self) -> JoinInfo:
879        """Create a display of on-screen information for joiners.
880
881        (how to switch teams, players, etc.)
882        Intended for use in initial joining-screens.
883        """
884        return JoinInfo(self)
885
886    def reload_profiles(self) -> None:
887        """Reload available player profiles."""
888        # pylint: disable=cyclic-import
889        from bastd.actor.spazappearance import get_appearances
890
891        # We may have gained or lost character names if the user
892        # bought something; reload these too.
893        self.character_names_local_unlocked = get_appearances()
894        self.character_names_local_unlocked.sort(key=lambda x: x.lower())
895
896        # Do any overall prep we need to such as creating account profile.
897        _ba.app.accounts_v1.ensure_have_account_player_profile()
898        for chooser in self.choosers:
899            try:
900                chooser.reload_profiles()
901                chooser.update_from_profile()
902            except Exception:
903                print_exception('Error reloading profiles.')
904
905    def update_positions(self) -> None:
906        """Update positions for all choosers."""
907        self._vpos = -100 + self.base_v_offset
908        for chooser in self.choosers:
909            chooser.set_vpos(self._vpos)
910            chooser.update_position()
911            self._vpos -= 48
912
913    def check_all_ready(self) -> bool:
914        """Return whether all choosers are marked ready."""
915        return all(chooser.ready for chooser in self.choosers)
916
917    def add_chooser(self, sessionplayer: ba.SessionPlayer) -> None:
918        """Add a chooser to the lobby for the provided player."""
919        self.choosers.append(
920            Chooser(vpos=self._vpos, sessionplayer=sessionplayer, lobby=self))
921        self._next_add_team = (self._next_add_team + 1) % len(
922            self._sessionteams)
923        self._vpos -= 48
924
925    def remove_chooser(self, player: ba.SessionPlayer) -> None:
926        """Remove a single player's chooser; does not kick them.
927
928        This is used when a player enters the game and no longer
929        needs a chooser."""
930        found = False
931        chooser = None
932        for chooser in self.choosers:
933            if chooser.getplayer() is player:
934                found = True
935
936                # Mark it as dead since there could be more
937                # change-commands/etc coming in still for it;
938                # want to avoid duplicate player-adds/etc.
939                chooser.set_dead(True)
940                self.choosers.remove(chooser)
941                break
942        if not found:
943            print_error(f'remove_chooser did not find player {player}')
944        elif chooser in self.choosers:
945            print_error(f'chooser remains after removal for {player}')
946        self.update_positions()
947
948    def remove_all_choosers(self) -> None:
949        """Remove all choosers without kicking players.
950
951        This is called after all players check in and enter a game.
952        """
953        self.choosers = []
954        self.update_positions()
955
956    def remove_all_choosers_and_kick_players(self) -> None:
957        """Remove all player choosers and kick attached players."""
958
959        # Copy the list; it can change under us otherwise.
960        for chooser in list(self.choosers):
961            if chooser.sessionplayer:
962                chooser.sessionplayer.remove_from_game()
963        self.remove_all_choosers()

Container for ba.Choosers.

Category: Gameplay Classes

Lobby()
826    def __init__(self) -> None:
827        from ba._team import SessionTeam
828        from ba._coopsession import CoopSession
829        session = _ba.getsession()
830        self._use_team_colors = session.use_team_colors
831        if session.use_teams:
832            self._sessionteams = [
833                weakref.ref(team) for team in session.sessionteams
834            ]
835        else:
836            self._dummy_teams = SessionTeam()
837            self._sessionteams = [weakref.ref(self._dummy_teams)]
838        v_offset = (-150 if isinstance(session, CoopSession) else -50)
839        self.choosers: list[Chooser] = []
840        self.base_v_offset = v_offset
841        self.update_positions()
842        self._next_add_team = 0
843        self.character_names_local_unlocked: list[str] = []
844        self._vpos = 0
845
846        # Grab available profiles.
847        self.reload_profiles()
848
849        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]:
874    def get_choosers(self) -> list[Chooser]:
875        """Return the lobby's current choosers."""
876        return self.choosers

Return the lobby's current choosers.

def create_join_info(self) -> ba._lobby.JoinInfo:
878    def create_join_info(self) -> JoinInfo:
879        """Create a display of on-screen information for joiners.
880
881        (how to switch teams, players, etc.)
882        Intended for use in initial joining-screens.
883        """
884        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:
886    def reload_profiles(self) -> None:
887        """Reload available player profiles."""
888        # pylint: disable=cyclic-import
889        from bastd.actor.spazappearance import get_appearances
890
891        # We may have gained or lost character names if the user
892        # bought something; reload these too.
893        self.character_names_local_unlocked = get_appearances()
894        self.character_names_local_unlocked.sort(key=lambda x: x.lower())
895
896        # Do any overall prep we need to such as creating account profile.
897        _ba.app.accounts_v1.ensure_have_account_player_profile()
898        for chooser in self.choosers:
899            try:
900                chooser.reload_profiles()
901                chooser.update_from_profile()
902            except Exception:
903                print_exception('Error reloading profiles.')

Reload available player profiles.

def update_positions(self) -> None:
905    def update_positions(self) -> None:
906        """Update positions for all choosers."""
907        self._vpos = -100 + self.base_v_offset
908        for chooser in self.choosers:
909            chooser.set_vpos(self._vpos)
910            chooser.update_position()
911            self._vpos -= 48

Update positions for all choosers.

def check_all_ready(self) -> bool:
913    def check_all_ready(self) -> bool:
914        """Return whether all choosers are marked ready."""
915        return all(chooser.ready for chooser in self.choosers)

Return whether all choosers are marked ready.

def add_chooser(self, sessionplayer: ba.SessionPlayer) -> None:
917    def add_chooser(self, sessionplayer: ba.SessionPlayer) -> None:
918        """Add a chooser to the lobby for the provided player."""
919        self.choosers.append(
920            Chooser(vpos=self._vpos, sessionplayer=sessionplayer, lobby=self))
921        self._next_add_team = (self._next_add_team + 1) % len(
922            self._sessionteams)
923        self._vpos -= 48

Add a chooser to the lobby for the provided player.

def remove_chooser(self, player: ba.SessionPlayer) -> None:
925    def remove_chooser(self, player: ba.SessionPlayer) -> None:
926        """Remove a single player's chooser; does not kick them.
927
928        This is used when a player enters the game and no longer
929        needs a chooser."""
930        found = False
931        chooser = None
932        for chooser in self.choosers:
933            if chooser.getplayer() is player:
934                found = True
935
936                # Mark it as dead since there could be more
937                # change-commands/etc coming in still for it;
938                # want to avoid duplicate player-adds/etc.
939                chooser.set_dead(True)
940                self.choosers.remove(chooser)
941                break
942        if not found:
943            print_error(f'remove_chooser did not find player {player}')
944        elif chooser in self.choosers:
945            print_error(f'chooser remains after removal for {player}')
946        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:
948    def remove_all_choosers(self) -> None:
949        """Remove all choosers without kicking players.
950
951        This is called after all players check in and enter a game.
952        """
953        self.choosers = []
954        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:
956    def remove_all_choosers_and_kick_players(self) -> None:
957        """Remove all player choosers and kick attached players."""
958
959        # Copy the list; it can change under us otherwise.
960        for chooser in list(self.choosers):
961            if chooser.sessionplayer:
962                chooser.sessionplayer.remove_from_game()
963        self.remove_all_choosers()

Remove all player choosers and kick attached players.

def log(message: str, to_stdout: bool = True, to_server: bool = True) -> None:
2306def log(message: str, to_stdout: bool = True, to_server: bool = True) -> None:
2307    """Category: **General Utility Functions**
2308
2309    Log a message. This goes to the default logging mechanism depending
2310    on the platform (stdout on mac, android log on android, etc).
2311
2312    Log messages also go to the in-game console unless 'to_console'
2313    is False. They are also sent to the master-server for use in analyzing
2314    issues unless to_server is False.
2315
2316    Python's standard print() is wired to call this (with default values)
2317    so in most cases you can just use that.
2318    """
2319    return None

Category: General Utility Functions

Log a message. This goes to the default logging mechanism depending on the platform (stdout on mac, android log on android, etc).

Log messages also go to the in-game console unless 'to_console' is False. They are also sent to the master-server for use in analyzing issues unless to_server is False.

Python's standard print() is wired to call this (with default values) so in most cases you can just use that.

class Lstr:
368class Lstr:
369    """Used to define strings in a language-independent way.
370
371    Category: **General Utility Classes**
372
373    These should be used whenever possible in place of hard-coded strings
374    so that in-game or UI elements show up correctly on all clients in their
375    currently-active language.
376
377    To see available resource keys, look at any of the bs_language_*.py files
378    in the game or the translations pages at legacy.ballistica.net/translate.
379
380    ##### Examples
381    EXAMPLE 1: specify a string from a resource path
382    >>> mynode.text = ba.Lstr(resource='audioSettingsWindow.titleText')
383
384    EXAMPLE 2: specify a translated string via a category and english
385    value; if a translated value is available, it will be used; otherwise
386    the english value will be. To see available translation categories,
387    look under the 'translations' resource section.
388    >>> mynode.text = ba.Lstr(translate=('gameDescriptions',
389    ...                                  'Defeat all enemies'))
390
391    EXAMPLE 3: specify a raw value and some substitutions. Substitutions
392    can be used with resource and translate modes as well.
393    >>> mynode.text = ba.Lstr(value='${A} / ${B}',
394    ...               subs=[('${A}', str(score)), ('${B}', str(total))])
395
396    EXAMPLE 4: ba.Lstr's can be nested. This example would display the
397    resource at res_a but replace ${NAME} with the value of the
398    resource at res_b
399    >>> mytextnode.text = ba.Lstr(
400    ...     resource='res_a',
401    ...     subs=[('${NAME}', ba.Lstr(resource='res_b'))])
402    """
403
404    # pylint: disable=dangerous-default-value
405    # noinspection PyDefaultArgument
406    @overload
407    def __init__(self,
408                 *,
409                 resource: str,
410                 fallback_resource: str = '',
411                 fallback_value: str = '',
412                 subs: Sequence[tuple[str, str | Lstr]] = []) -> None:
413        """Create an Lstr from a string resource."""
414
415    # noinspection PyShadowingNames,PyDefaultArgument
416    @overload
417    def __init__(self,
418                 *,
419                 translate: tuple[str, str],
420                 subs: Sequence[tuple[str, str | Lstr]] = []) -> None:
421        """Create an Lstr by translating a string in a category."""
422
423    # noinspection PyDefaultArgument
424    @overload
425    def __init__(self,
426                 *,
427                 value: str,
428                 subs: Sequence[tuple[str, str | Lstr]] = []) -> None:
429        """Create an Lstr from a raw string value."""
430
431    # pylint: enable=redefined-outer-name, dangerous-default-value
432
433    def __init__(self, *args: Any, **keywds: Any) -> None:
434        """Instantiate a Lstr.
435
436        Pass a value for either 'resource', 'translate',
437        or 'value'. (see Lstr help for examples).
438        'subs' can be a sequence of 2-member sequences consisting of values
439        and replacements.
440        'fallback_resource' can be a resource key that will be used if the
441        main one is not present for
442        the current language in place of falling back to the english value
443        ('resource' mode only).
444        'fallback_value' can be a literal string that will be used if neither
445        the resource nor the fallback resource is found ('resource' mode only).
446        """
447        # pylint: disable=too-many-branches
448        if args:
449            raise TypeError('Lstr accepts only keyword arguments')
450
451        # Basically just store the exact args they passed.
452        # However if they passed any Lstr values for subs,
453        # replace them with that Lstr's dict.
454        self.args = keywds
455        our_type = type(self)
456
457        if isinstance(self.args.get('value'), our_type):
458            raise TypeError("'value' must be a regular string; not an Lstr")
459
460        if 'subs' in self.args:
461            subs_new = []
462            for key, value in keywds['subs']:
463                if isinstance(value, our_type):
464                    subs_new.append((key, value.args))
465                else:
466                    subs_new.append((key, value))
467            self.args['subs'] = subs_new
468
469        # As of protocol 31 we support compact key names
470        # ('t' instead of 'translate', etc). Convert as needed.
471        if 'translate' in keywds:
472            keywds['t'] = keywds['translate']
473            del keywds['translate']
474        if 'resource' in keywds:
475            keywds['r'] = keywds['resource']
476            del keywds['resource']
477        if 'value' in keywds:
478            keywds['v'] = keywds['value']
479            del keywds['value']
480        if 'fallback' in keywds:
481            from ba import _error
482            _error.print_error(
483                'deprecated "fallback" arg passed to Lstr(); use '
484                'either "fallback_resource" or "fallback_value"',
485                once=True)
486            keywds['f'] = keywds['fallback']
487            del keywds['fallback']
488        if 'fallback_resource' in keywds:
489            keywds['f'] = keywds['fallback_resource']
490            del keywds['fallback_resource']
491        if 'subs' in keywds:
492            keywds['s'] = keywds['subs']
493            del keywds['subs']
494        if 'fallback_value' in keywds:
495            keywds['fv'] = keywds['fallback_value']
496            del keywds['fallback_value']
497
498    def evaluate(self) -> str:
499        """Evaluate the Lstr and returns a flat string in the current language.
500
501        You should avoid doing this as much as possible and instead pass
502        and store Lstr values.
503        """
504        return _ba.evaluate_lstr(self._get_json())
505
506    def is_flat_value(self) -> bool:
507        """Return whether the Lstr is a 'flat' value.
508
509        This is defined as a simple string value incorporating no translations,
510        resources, or substitutions.  In this case it may be reasonable to
511        replace it with a raw string value, perform string manipulation on it,
512        etc.
513        """
514        return bool('v' in self.args and not self.args.get('s', []))
515
516    def _get_json(self) -> str:
517        try:
518            return json.dumps(self.args, separators=(',', ':'))
519        except Exception:
520            from ba import _error
521            _error.print_exception('_get_json failed for', self.args)
522            return 'JSON_ERR'
523
524    def __str__(self) -> str:
525        return '<ba.Lstr: ' + self._get_json() + '>'
526
527    def __repr__(self) -> str:
528        return '<ba.Lstr: ' + self._get_json() + '>'
529
530    @staticmethod
531    def from_json(json_string: str) -> ba.Lstr:
532        """Given a json string, returns a ba.Lstr. Does no data validation."""
533        lstr = Lstr(value='')
534        lstr.args = json.loads(json_string)
535        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)
433    def __init__(self, *args: Any, **keywds: Any) -> None:
434        """Instantiate a Lstr.
435
436        Pass a value for either 'resource', 'translate',
437        or 'value'. (see Lstr help for examples).
438        'subs' can be a sequence of 2-member sequences consisting of values
439        and replacements.
440        'fallback_resource' can be a resource key that will be used if the
441        main one is not present for
442        the current language in place of falling back to the english value
443        ('resource' mode only).
444        'fallback_value' can be a literal string that will be used if neither
445        the resource nor the fallback resource is found ('resource' mode only).
446        """
447        # pylint: disable=too-many-branches
448        if args:
449            raise TypeError('Lstr accepts only keyword arguments')
450
451        # Basically just store the exact args they passed.
452        # However if they passed any Lstr values for subs,
453        # replace them with that Lstr's dict.
454        self.args = keywds
455        our_type = type(self)
456
457        if isinstance(self.args.get('value'), our_type):
458            raise TypeError("'value' must be a regular string; not an Lstr")
459
460        if 'subs' in self.args:
461            subs_new = []
462            for key, value in keywds['subs']:
463                if isinstance(value, our_type):
464                    subs_new.append((key, value.args))
465                else:
466                    subs_new.append((key, value))
467            self.args['subs'] = subs_new
468
469        # As of protocol 31 we support compact key names
470        # ('t' instead of 'translate', etc). Convert as needed.
471        if 'translate' in keywds:
472            keywds['t'] = keywds['translate']
473            del keywds['translate']
474        if 'resource' in keywds:
475            keywds['r'] = keywds['resource']
476            del keywds['resource']
477        if 'value' in keywds:
478            keywds['v'] = keywds['value']
479            del keywds['value']
480        if 'fallback' in keywds:
481            from ba import _error
482            _error.print_error(
483                'deprecated "fallback" arg passed to Lstr(); use '
484                'either "fallback_resource" or "fallback_value"',
485                once=True)
486            keywds['f'] = keywds['fallback']
487            del keywds['fallback']
488        if 'fallback_resource' in keywds:
489            keywds['f'] = keywds['fallback_resource']
490            del keywds['fallback_resource']
491        if 'subs' in keywds:
492            keywds['s'] = keywds['subs']
493            del keywds['subs']
494        if 'fallback_value' in keywds:
495            keywds['fv'] = keywds['fallback_value']
496            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:
498    def evaluate(self) -> str:
499        """Evaluate the Lstr and returns a flat string in the current language.
500
501        You should avoid doing this as much as possible and instead pass
502        and store Lstr values.
503        """
504        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:
506    def is_flat_value(self) -> bool:
507        """Return whether the Lstr is a 'flat' value.
508
509        This is defined as a simple string value incorporating no translations,
510        resources, or substitutions.  In this case it may be reasonable to
511        replace it with a raw string value, perform string manipulation on it,
512        etc.
513        """
514        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:
530    @staticmethod
531    def from_json(json_string: str) -> ba.Lstr:
532        """Given a json string, returns a ba.Lstr. Does no data validation."""
533        lstr = Lstr(value='')
534        lstr.args = json.loads(json_string)
535        return lstr

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

class Map(ba.Actor):
118class Map(Actor):
119    """A game map.
120
121    Category: **Gameplay Classes**
122
123    Consists of a collection of terrain nodes, metadata, and other
124    functionality comprising a game map.
125    """
126    defs: Any = None
127    name = 'Map'
128    _playtypes: list[str] = []
129
130    @classmethod
131    def preload(cls) -> None:
132        """Preload map media.
133
134        This runs the class's on_preload() method as needed to prep it to run.
135        Preloading should generally be done in a ba.Activity's __init__ method.
136        Note that this is a classmethod since it is not operate on map
137        instances but rather on the class itself before instances are made
138        """
139        activity = _ba.getactivity()
140        if cls not in activity.preloads:
141            activity.preloads[cls] = cls.on_preload()
142
143    @classmethod
144    def get_play_types(cls) -> list[str]:
145        """Return valid play types for this map."""
146        return []
147
148    @classmethod
149    def get_preview_texture_name(cls) -> str | None:
150        """Return the name of the preview texture for this map."""
151        return None
152
153    @classmethod
154    def on_preload(cls) -> Any:
155        """Called when the map is being preloaded.
156
157        It should return any media/data it requires to operate
158        """
159        return None
160
161    @classmethod
162    def getname(cls) -> str:
163        """Return the unique name of this map, in English."""
164        return cls.name
165
166    @classmethod
167    def get_music_type(cls) -> ba.MusicType | None:
168        """Return a music-type string that should be played on this map.
169
170        If None is returned, default music will be used.
171        """
172        return None
173
174    def __init__(self,
175                 vr_overlay_offset: Sequence[float] | None = None) -> None:
176        """Instantiate a map."""
177        super().__init__()
178
179        # This is expected to always be a ba.Node object (whether valid or not)
180        # should be set to something meaningful by child classes.
181        self.node: _ba.Node | None = None
182
183        # Make our class' preload-data available to us
184        # (and instruct the user if we weren't preloaded properly).
185        try:
186            self.preloaddata = _ba.getactivity().preloads[type(self)]
187        except Exception as exc:
188            from ba import _error
189            raise _error.NotFoundError(
190                'Preload data not found for ' + str(type(self)) +
191                '; make sure to call the type\'s preload()'
192                ' staticmethod in the activity constructor') from exc
193
194        # Set various globals.
195        gnode = _ba.getactivity().globalsnode
196
197        # Set area-of-interest bounds.
198        aoi_bounds = self.get_def_bound_box('area_of_interest_bounds')
199        if aoi_bounds is None:
200            print('WARNING: no "aoi_bounds" found for map:', self.getname())
201            aoi_bounds = (-1, -1, -1, 1, 1, 1)
202        gnode.area_of_interest_bounds = aoi_bounds
203
204        # Set map bounds.
205        map_bounds = self.get_def_bound_box('map_bounds')
206        if map_bounds is None:
207            print('WARNING: no "map_bounds" found for map:', self.getname())
208            map_bounds = (-30, -10, -30, 30, 100, 30)
209        _ba.set_map_bounds(map_bounds)
210
211        # Set shadow ranges.
212        try:
213            gnode.shadow_range = [
214                self.defs.points[v][1] for v in [
215                    'shadow_lower_bottom', 'shadow_lower_top',
216                    'shadow_upper_bottom', 'shadow_upper_top'
217                ]
218            ]
219        except Exception:
220            pass
221
222        # In vr, set a fixed point in space for the overlay to show up at.
223        # By default we use the bounds center but allow the map to override it.
224        center = ((aoi_bounds[0] + aoi_bounds[3]) * 0.5,
225                  (aoi_bounds[1] + aoi_bounds[4]) * 0.5,
226                  (aoi_bounds[2] + aoi_bounds[5]) * 0.5)
227        if vr_overlay_offset is not None:
228            center = (center[0] + vr_overlay_offset[0],
229                      center[1] + vr_overlay_offset[1],
230                      center[2] + vr_overlay_offset[2])
231        gnode.vr_overlay_center = center
232        gnode.vr_overlay_center_enabled = True
233
234        self.spawn_points = (self.get_def_points('spawn')
235                             or [(0, 0, 0, 0, 0, 0)])
236        self.ffa_spawn_points = (self.get_def_points('ffa_spawn')
237                                 or [(0, 0, 0, 0, 0, 0)])
238        self.spawn_by_flag_points = (self.get_def_points('spawn_by_flag')
239                                     or [(0, 0, 0, 0, 0, 0)])
240        self.flag_points = self.get_def_points('flag') or [(0, 0, 0)]
241
242        # We just want points.
243        self.flag_points = [p[:3] for p in self.flag_points]
244        self.flag_points_default = (self.get_def_point('flag_default')
245                                    or (0, 1, 0))
246        self.powerup_spawn_points = self.get_def_points('powerup_spawn') or [
247            (0, 0, 0)
248        ]
249
250        # We just want points.
251        self.powerup_spawn_points = ([
252            p[:3] for p in self.powerup_spawn_points
253        ])
254        self.tnt_points = self.get_def_points('tnt') or []
255
256        # We just want points.
257        self.tnt_points = [p[:3] for p in self.tnt_points]
258
259        self.is_hockey = False
260        self.is_flying = False
261
262        # FIXME: this should be part of game; not map.
263        # Let's select random index for first spawn point,
264        # so that no one is offended by the constant spawn on the edge.
265        self._next_ffa_start_index = random.randrange(
266            len(self.ffa_spawn_points))
267
268    def is_point_near_edge(self,
269                           point: ba.Vec3,
270                           running: bool = False) -> bool:
271        """Return whether the provided point is near an edge of the map.
272
273        Simple bot logic uses this call to determine if they
274        are approaching a cliff or wall. If this returns True they will
275        generally not walk/run any farther away from the origin.
276        If 'running' is True, the buffer should be a bit larger.
277        """
278        del point, running  # Unused.
279        return False
280
281    def get_def_bound_box(
282            self, name: str
283    ) -> tuple[float, float, float, float, float, float] | None:
284        """Return a 6 member bounds tuple or None if it is not defined."""
285        try:
286            box = self.defs.boxes[name]
287            return (box[0] - box[6] / 2.0, box[1] - box[7] / 2.0,
288                    box[2] - box[8] / 2.0, box[0] + box[6] / 2.0,
289                    box[1] + box[7] / 2.0, box[2] + box[8] / 2.0)
290        except Exception:
291            return None
292
293    def get_def_point(self, name: str) -> Sequence[float] | None:
294        """Return a single defined point or a default value in its absence."""
295        val = self.defs.points.get(name)
296        return (None if val is None else
297                _math.vec3validate(val) if __debug__ else val)
298
299    def get_def_points(self, name: str) -> list[Sequence[float]]:
300        """Return a list of named points.
301
302        Return as many sequential ones are defined (flag1, flag2, flag3), etc.
303        If none are defined, returns an empty list.
304        """
305        point_list = []
306        if self.defs and name + '1' in self.defs.points:
307            i = 1
308            while name + str(i) in self.defs.points:
309                pts = self.defs.points[name + str(i)]
310                if len(pts) == 6:
311                    point_list.append(pts)
312                else:
313                    if len(pts) != 3:
314                        raise ValueError('invalid point')
315                    point_list.append(pts + (0, 0, 0))
316                i += 1
317        return point_list
318
319    def get_start_position(self, team_index: int) -> Sequence[float]:
320        """Return a random starting position for the given team index."""
321        pnt = self.spawn_points[team_index % len(self.spawn_points)]
322        x_range = (-0.5, 0.5) if pnt[3] == 0.0 else (-pnt[3], pnt[3])
323        z_range = (-0.5, 0.5) if pnt[5] == 0.0 else (-pnt[5], pnt[5])
324        pnt = (pnt[0] + random.uniform(*x_range), pnt[1],
325               pnt[2] + random.uniform(*z_range))
326        return pnt
327
328    def get_ffa_start_position(
329            self, players: Sequence[ba.Player]) -> Sequence[float]:
330        """Return a random starting position in one of the FFA spawn areas.
331
332        If a list of ba.Player-s is provided; the returned points will be
333        as far from these players as possible.
334        """
335
336        # Get positions for existing players.
337        player_pts = []
338        for player in players:
339            if player.is_alive():
340                player_pts.append(player.position)
341
342        def _getpt() -> Sequence[float]:
343            point = self.ffa_spawn_points[self._next_ffa_start_index]
344            self._next_ffa_start_index = ((self._next_ffa_start_index + 1) %
345                                          len(self.ffa_spawn_points))
346            x_range = (-0.5, 0.5) if point[3] == 0.0 else (-point[3], point[3])
347            z_range = (-0.5, 0.5) if point[5] == 0.0 else (-point[5], point[5])
348            point = (point[0] + random.uniform(*x_range), point[1],
349                     point[2] + random.uniform(*z_range))
350            return point
351
352        if not player_pts:
353            return _getpt()
354
355        # Let's calc several start points and then pick whichever is
356        # farthest from all existing players.
357        farthestpt_dist = -1.0
358        farthestpt = None
359        for _i in range(10):
360            testpt = _ba.Vec3(_getpt())
361            closest_player_dist = 9999.0
362            for ppt in player_pts:
363                dist = (ppt - testpt).length()
364                if dist < closest_player_dist:
365                    closest_player_dist = dist
366            if closest_player_dist > farthestpt_dist:
367                farthestpt_dist = closest_player_dist
368                farthestpt = testpt
369        assert farthestpt is not None
370        return tuple(farthestpt)
371
372    def get_flag_position(self,
373                          team_index: int | None = None) -> Sequence[float]:
374        """Return a flag position on the map for the given team index.
375
376        Pass None to get the default flag point.
377        (used for things such as king-of-the-hill)
378        """
379        if team_index is None:
380            return self.flag_points_default[:3]
381        return self.flag_points[team_index % len(self.flag_points)][:3]
382
383    def exists(self) -> bool:
384        return bool(self.node)
385
386    def handlemessage(self, msg: Any) -> Any:
387        from ba import _messages
388        if isinstance(msg, _messages.DieMessage):
389            if self.node:
390                self.node.delete()
391        else:
392            return super().handlemessage(msg)
393        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)
174    def __init__(self,
175                 vr_overlay_offset: Sequence[float] | None = None) -> None:
176        """Instantiate a map."""
177        super().__init__()
178
179        # This is expected to always be a ba.Node object (whether valid or not)
180        # should be set to something meaningful by child classes.
181        self.node: _ba.Node | None = None
182
183        # Make our class' preload-data available to us
184        # (and instruct the user if we weren't preloaded properly).
185        try:
186            self.preloaddata = _ba.getactivity().preloads[type(self)]
187        except Exception as exc:
188            from ba import _error
189            raise _error.NotFoundError(
190                'Preload data not found for ' + str(type(self)) +
191                '; make sure to call the type\'s preload()'
192                ' staticmethod in the activity constructor') from exc
193
194        # Set various globals.
195        gnode = _ba.getactivity().globalsnode
196
197        # Set area-of-interest bounds.
198        aoi_bounds = self.get_def_bound_box('area_of_interest_bounds')
199        if aoi_bounds is None:
200            print('WARNING: no "aoi_bounds" found for map:', self.getname())
201            aoi_bounds = (-1, -1, -1, 1, 1, 1)
202        gnode.area_of_interest_bounds = aoi_bounds
203
204        # Set map bounds.
205        map_bounds = self.get_def_bound_box('map_bounds')
206        if map_bounds is None:
207            print('WARNING: no "map_bounds" found for map:', self.getname())
208            map_bounds = (-30, -10, -30, 30, 100, 30)
209        _ba.set_map_bounds(map_bounds)
210
211        # Set shadow ranges.
212        try:
213            gnode.shadow_range = [
214                self.defs.points[v][1] for v in [
215                    'shadow_lower_bottom', 'shadow_lower_top',
216                    'shadow_upper_bottom', 'shadow_upper_top'
217                ]
218            ]
219        except Exception:
220            pass
221
222        # In vr, set a fixed point in space for the overlay to show up at.
223        # By default we use the bounds center but allow the map to override it.
224        center = ((aoi_bounds[0] + aoi_bounds[3]) * 0.5,
225                  (aoi_bounds[1] + aoi_bounds[4]) * 0.5,
226                  (aoi_bounds[2] + aoi_bounds[5]) * 0.5)
227        if vr_overlay_offset is not None:
228            center = (center[0] + vr_overlay_offset[0],
229                      center[1] + vr_overlay_offset[1],
230                      center[2] + vr_overlay_offset[2])
231        gnode.vr_overlay_center = center
232        gnode.vr_overlay_center_enabled = True
233
234        self.spawn_points = (self.get_def_points('spawn')
235                             or [(0, 0, 0, 0, 0, 0)])
236        self.ffa_spawn_points = (self.get_def_points('ffa_spawn')
237                                 or [(0, 0, 0, 0, 0, 0)])
238        self.spawn_by_flag_points = (self.get_def_points('spawn_by_flag')
239                                     or [(0, 0, 0, 0, 0, 0)])
240        self.flag_points = self.get_def_points('flag') or [(0, 0, 0)]
241
242        # We just want points.
243        self.flag_points = [p[:3] for p in self.flag_points]
244        self.flag_points_default = (self.get_def_point('flag_default')
245                                    or (0, 1, 0))
246        self.powerup_spawn_points = self.get_def_points('powerup_spawn') or [
247            (0, 0, 0)
248        ]
249
250        # We just want points.
251        self.powerup_spawn_points = ([
252            p[:3] for p in self.powerup_spawn_points
253        ])
254        self.tnt_points = self.get_def_points('tnt') or []
255
256        # We just want points.
257        self.tnt_points = [p[:3] for p in self.tnt_points]
258
259        self.is_hockey = False
260        self.is_flying = False
261
262        # FIXME: this should be part of game; not map.
263        # Let's select random index for first spawn point,
264        # so that no one is offended by the constant spawn on the edge.
265        self._next_ffa_start_index = random.randrange(
266            len(self.ffa_spawn_points))

Instantiate a map.

defs: Any = None
name = 'Map'
@classmethod
def preload(cls) -> None:
130    @classmethod
131    def preload(cls) -> None:
132        """Preload map media.
133
134        This runs the class's on_preload() method as needed to prep it to run.
135        Preloading should generally be done in a ba.Activity's __init__ method.
136        Note that this is a classmethod since it is not operate on map
137        instances but rather on the class itself before instances are made
138        """
139        activity = _ba.getactivity()
140        if cls not in activity.preloads:
141            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]:
143    @classmethod
144    def get_play_types(cls) -> list[str]:
145        """Return valid play types for this map."""
146        return []

Return valid play types for this map.

@classmethod
def get_preview_texture_name(cls) -> str | None:
148    @classmethod
149    def get_preview_texture_name(cls) -> str | None:
150        """Return the name of the preview texture for this map."""
151        return None

Return the name of the preview texture for this map.

@classmethod
def on_preload(cls) -> Any:
153    @classmethod
154    def on_preload(cls) -> Any:
155        """Called when the map is being preloaded.
156
157        It should return any media/data it requires to operate
158        """
159        return None

Called when the map is being preloaded.

It should return any media/data it requires to operate

@classmethod
def getname(cls) -> str:
161    @classmethod
162    def getname(cls) -> str:
163        """Return the unique name of this map, in English."""
164        return cls.name

Return the unique name of this map, in English.

@classmethod
def get_music_type(cls) -> ba.MusicType | None:
166    @classmethod
167    def get_music_type(cls) -> ba.MusicType | None:
168        """Return a music-type string that should be played on this map.
169
170        If None is returned, default music will be used.
171        """
172        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:
268    def is_point_near_edge(self,
269                           point: ba.Vec3,
270                           running: bool = False) -> bool:
271        """Return whether the provided point is near an edge of the map.
272
273        Simple bot logic uses this call to determine if they
274        are approaching a cliff or wall. If this returns True they will
275        generally not walk/run any farther away from the origin.
276        If 'running' is True, the buffer should be a bit larger.
277        """
278        del point, running  # Unused.
279        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:
281    def get_def_bound_box(
282            self, name: str
283    ) -> tuple[float, float, float, float, float, float] | None:
284        """Return a 6 member bounds tuple or None if it is not defined."""
285        try:
286            box = self.defs.boxes[name]
287            return (box[0] - box[6] / 2.0, box[1] - box[7] / 2.0,
288                    box[2] - box[8] / 2.0, box[0] + box[6] / 2.0,
289                    box[1] + box[7] / 2.0, box[2] + box[8] / 2.0)
290        except Exception:
291            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]]:
293    def get_def_point(self, name: str) -> Sequence[float] | None:
294        """Return a single defined point or a default value in its absence."""
295        val = self.defs.points.get(name)
296        return (None if val is None else
297                _math.vec3validate(val) if __debug__ else val)

Return a single defined point or a default value in its absence.

def get_def_points(self, name: str) -> list[typing.Sequence[float]]:
299    def get_def_points(self, name: str) -> list[Sequence[float]]:
300        """Return a list of named points.
301
302        Return as many sequential ones are defined (flag1, flag2, flag3), etc.
303        If none are defined, returns an empty list.
304        """
305        point_list = []
306        if self.defs and name + '1' in self.defs.points:
307            i = 1
308            while name + str(i) in self.defs.points:
309                pts = self.defs.points[name + str(i)]
310                if len(pts) == 6:
311                    point_list.append(pts)
312                else:
313                    if len(pts) != 3:
314                        raise ValueError('invalid point')
315                    point_list.append(pts + (0, 0, 0))
316                i += 1
317        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]:
319    def get_start_position(self, team_index: int) -> Sequence[float]:
320        """Return a random starting position for the given team index."""
321        pnt = self.spawn_points[team_index % len(self.spawn_points)]
322        x_range = (-0.5, 0.5) if pnt[3] == 0.0 else (-pnt[3], pnt[3])
323        z_range = (-0.5, 0.5) if pnt[5] == 0.0 else (-pnt[5], pnt[5])
324        pnt = (pnt[0] + random.uniform(*x_range), pnt[1],
325               pnt[2] + random.uniform(*z_range))
326        return pnt

Return a random starting position for the given team index.

def get_ffa_start_position(self, players: Sequence[ba.Player]) -> Sequence[float]:
328    def get_ffa_start_position(
329            self, players: Sequence[ba.Player]) -> Sequence[float]:
330        """Return a random starting position in one of the FFA spawn areas.
331
332        If a list of ba.Player-s is provided; the returned points will be
333        as far from these players as possible.
334        """
335
336        # Get positions for existing players.
337        player_pts = []
338        for player in players:
339            if player.is_alive():
340                player_pts.append(player.position)
341
342        def _getpt() -> Sequence[float]:
343            point = self.ffa_spawn_points[self._next_ffa_start_index]
344            self._next_ffa_start_index = ((self._next_ffa_start_index + 1) %
345                                          len(self.ffa_spawn_points))
346            x_range = (-0.5, 0.5) if point[3] == 0.0 else (-point[3], point[3])
347            z_range = (-0.5, 0.5) if point[5] == 0.0 else (-point[5], point[5])
348            point = (point[0] + random.uniform(*x_range), point[1],
349                     point[2] + random.uniform(*z_range))
350            return point
351
352        if not player_pts:
353            return _getpt()
354
355        # Let's calc several start points and then pick whichever is
356        # farthest from all existing players.
357        farthestpt_dist = -1.0
358        farthestpt = None
359        for _i in range(10):
360            testpt = _ba.Vec3(_getpt())
361            closest_player_dist = 9999.0
362            for ppt in player_pts:
363                dist = (ppt - testpt).length()
364                if dist < closest_player_dist:
365                    closest_player_dist = dist
366            if closest_player_dist > farthestpt_dist:
367                farthestpt_dist = closest_player_dist
368                farthestpt = testpt
369        assert farthestpt is not None
370        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]:
372    def get_flag_position(self,
373                          team_index: int | None = None) -> Sequence[float]:
374        """Return a flag position on the map for the given team index.
375
376        Pass None to get the default flag point.
377        (used for things such as king-of-the-hill)
378        """
379        if team_index is None:
380            return self.flag_points_default[:3]
381        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:
383    def exists(self) -> bool:
384        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:
386    def handlemessage(self, msg: Any) -> Any:
387        from ba import _messages
388        if isinstance(msg, _messages.DieMessage):
389            if self.node:
390                self.node.delete()
391        else:
392            return super().handlemessage(msg)
393        return None

General message handling; can be passed any message object.

class Material:
309class Material:
310    """An entity applied to game objects to modify collision behavior.
311
312    Category: **Gameplay Classes**
313
314    A material can affect physical characteristics, generate sounds,
315    or trigger callback functions when collisions occur.
316
317    Materials are applied to 'parts', which are groups of one or more
318    rigid bodies created as part of a ba.Node. Nodes can have any number
319    of parts, each with its own set of materials. Generally materials are
320    specified as array attributes on the Node. The `spaz` node, for
321    example, has various attributes such as `materials`,
322    `roller_materials`, and `punch_materials`, which correspond
323    to the various parts it creates.
324
325    Use ba.Material to instantiate a blank material, and then use its
326    ba.Material.add_actions() method to define what the material does.
327    """
328
329    def __init__(self, label: str | None = None):
330        pass
331
332    label: str
333    """A label for the material; only used for debugging."""
334
335    def add_actions(self,
336                    actions: tuple,
337                    conditions: tuple | None = None) -> None:
338        """Add one or more actions to the material, optionally with conditions.
339
340        ##### Conditions
341        Conditions are provided as tuples which can be combined
342        to form boolean logic. A single condition might look like
343        `('condition_name', cond_arg)`, or a more complex nested one
344        might look like `(('some_condition', cond_arg), 'or',
345        ('another_condition', cond2_arg))`.
346
347        `'and'`, `'or'`, and `'xor'` are available to chain
348        together 2 conditions, as seen above.
349
350        ##### Available Conditions
351        ###### `('they_have_material', material)`
352        > Does the part we're hitting have a given ba.Material?
353
354        ###### `('they_dont_have_material', material)`
355        > Does the part we're hitting not have a given ba.Material?
356
357        ###### `('eval_colliding')`
358        > Is `'collide'` true at this point
359        in material evaluation? (see the `modify_part_collision` action)
360
361        ###### `('eval_not_colliding')`
362        > Is 'collide' false at this point
363        in material evaluation? (see the `modify_part_collision` action)
364
365        ###### `('we_are_younger_than', age)`
366        > Is our part younger than `age` (in milliseconds)?
367
368        ###### `('we_are_older_than', age)`
369        > Is our part older than `age` (in milliseconds)?
370
371        ###### `('they_are_younger_than', age)`
372        > Is the part we're hitting younger than `age` (in milliseconds)?
373
374        ###### `('they_are_older_than', age)`
375        > Is the part we're hitting older than `age` (in milliseconds)?
376
377        ###### `('they_are_same_node_as_us')`
378        > Does the part we're hitting belong to the same ba.Node as us?
379
380        ###### `('they_are_different_node_than_us')`
381        > Does the part we're hitting belong to a different ba.Node than us?
382
383        ##### Actions
384        In a similar manner, actions are specified as tuples.
385        Multiple actions can be specified by providing a tuple
386        of tuples.
387
388        ##### Available Actions
389        ###### `('call', when, callable)`
390        > Calls the provided callable;
391        `when` can be either `'at_connect'` or `'at_disconnect'`.
392        `'at_connect'` means to fire
393        when the two parts first come in contact; `'at_disconnect'`
394        means to fire once they cease being in contact.
395
396        ###### `('message', who, when, message_obj)`
397        > Sends a message object;
398        `who` can be either `'our_node'` or `'their_node'`, `when` can be
399        `'at_connect'` or `'at_disconnect'`, and `message_obj` is the message
400        object to send.
401        This has the same effect as calling the node's
402        ba.Node.handlemessage() method.
403
404        ###### `('modify_part_collision', attr, value)`
405        > Changes some
406        characteristic of the physical collision that will occur between
407        our part and their part. This change will remain in effect as
408        long as the two parts remain overlapping. This means if you have a
409        part with a material that turns `'collide'` off against parts
410        younger than 100ms, and it touches another part that is 50ms old,
411        it will continue to not collide with that part until they separate,
412        even if the 100ms threshold is passed. Options for attr/value are:
413        `'physical'` (boolean value; whether a *physical* response will
414        occur at all), `'friction'` (float value; how friction-y the
415        physical response will be), `'collide'` (boolean value;
416        whether *any* collision will occur at all, including non-physical
417        stuff like callbacks), `'use_node_collide'`
418        (boolean value; whether to honor modify_node_collision
419        overrides for this collision), `'stiffness'` (float value,
420        how springy the physical response is), `'damping'` (float
421        value, how damped the physical response is), `'bounce'` (float
422        value; how bouncy the physical response is).
423
424        ###### `('modify_node_collision', attr, value)`
425        > Similar to
426        `modify_part_collision`, but operates at a node-level.
427        collision attributes set here will remain in effect as long as
428        *anything* from our part's node and their part's node overlap.
429        A key use of this functionality is to prevent new nodes from
430        colliding with each other if they appear overlapped;
431        if `modify_part_collision` is used, only the individual
432        parts that were overlapping would avoid contact, but other parts
433        could still contact leaving the two nodes 'tangled up'. Using
434        `modify_node_collision` ensures that the nodes must completely
435        separate before they can start colliding. Currently the only attr
436        available here is `'collide'` (a boolean value).
437
438        ###### `('sound', sound, volume)`
439        > Plays a ba.Sound when a collision
440        occurs, at a given volume, regardless of the collision speed/etc.
441
442        ###### `('impact_sound', sound, targetImpulse, volume)`
443        > Plays a sound
444        when a collision occurs, based on the speed of impact.
445        Provide a ba.Sound, a target-impulse, and a volume.
446
447        ###### `('skid_sound', sound, targetImpulse, volume)`
448        > Plays a sound
449        during a collision when parts are 'scraping' against each other.
450        Provide a ba.Sound, a target-impulse, and a volume.
451
452        ###### `('roll_sound', sound, targetImpulse, volume)`
453        > Plays a sound
454        during a collision when parts are 'rolling' against each other.
455        Provide a ba.Sound, a target-impulse, and a volume.
456
457        ##### Examples
458        **Example 1:** create a material that lets us ignore
459        collisions against any nodes we touch in the first
460        100 ms of our existence; handy for preventing us from
461        exploding outward if we spawn on top of another object:
462        >>> m = ba.Material()
463        ... m.add_actions(
464        ...     conditions=(('we_are_younger_than', 100),
465        ...                 'or', ('they_are_younger_than', 100)),
466        ...     actions=('modify_node_collision', 'collide', False))
467
468        **Example 2:** send a ba.DieMessage to anything we touch, but cause
469        no physical response. This should cause any ba.Actor to drop dead:
470        >>> m = ba.Material()
471        ... m.add_actions(
472        ...     actions=(('modify_part_collision', 'physical', False),
473        ...              ('message', 'their_node', 'at_connect',
474        ...                  ba.DieMessage())))
475
476        **Example 3:** play some sounds when we're contacting the ground:
477        >>> m = ba.Material()
478        ... m.add_actions(
479        ...     conditions=('they_have_material',
480        ...                 shared.footing_material),
481        ...     actions=(('impact_sound', ba.getsound('metalHit'), 2, 5),
482        ...              ('skid_sound', ba.getsound('metalSkid'), 2, 5)))
483        """
484        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)
329    def __init__(self, label: str | None = None):
330        pass
label: str

A label for the material; only used for debugging.

def add_actions(self, actions: tuple, conditions: tuple | None = None) -> None:
335    def add_actions(self,
336                    actions: tuple,
337                    conditions: tuple | None = None) -> None:
338        """Add one or more actions to the material, optionally with conditions.
339
340        ##### Conditions
341        Conditions are provided as tuples which can be combined
342        to form boolean logic. A single condition might look like
343        `('condition_name', cond_arg)`, or a more complex nested one
344        might look like `(('some_condition', cond_arg), 'or',
345        ('another_condition', cond2_arg))`.
346
347        `'and'`, `'or'`, and `'xor'` are available to chain
348        together 2 conditions, as seen above.
349
350        ##### Available Conditions
351        ###### `('they_have_material', material)`
352        > Does the part we're hitting have a given ba.Material?
353
354        ###### `('they_dont_have_material', material)`
355        > Does the part we're hitting not have a given ba.Material?
356
357        ###### `('eval_colliding')`
358        > Is `'collide'` true at this point
359        in material evaluation? (see the `modify_part_collision` action)
360
361        ###### `('eval_not_colliding')`
362        > Is 'collide' false at this point
363        in material evaluation? (see the `modify_part_collision` action)
364
365        ###### `('we_are_younger_than', age)`
366        > Is our part younger than `age` (in milliseconds)?
367
368        ###### `('we_are_older_than', age)`
369        > Is our part older than `age` (in milliseconds)?
370
371        ###### `('they_are_younger_than', age)`
372        > Is the part we're hitting younger than `age` (in milliseconds)?
373
374        ###### `('they_are_older_than', age)`
375        > Is the part we're hitting older than `age` (in milliseconds)?
376
377        ###### `('they_are_same_node_as_us')`
378        > Does the part we're hitting belong to the same ba.Node as us?
379
380        ###### `('they_are_different_node_than_us')`
381        > Does the part we're hitting belong to a different ba.Node than us?
382
383        ##### Actions
384        In a similar manner, actions are specified as tuples.
385        Multiple actions can be specified by providing a tuple
386        of tuples.
387
388        ##### Available Actions
389        ###### `('call', when, callable)`
390        > Calls the provided callable;
391        `when` can be either `'at_connect'` or `'at_disconnect'`.
392        `'at_connect'` means to fire
393        when the two parts first come in contact; `'at_disconnect'`
394        means to fire once they cease being in contact.
395
396        ###### `('message', who, when, message_obj)`
397        > Sends a message object;
398        `who` can be either `'our_node'` or `'their_node'`, `when` can be
399        `'at_connect'` or `'at_disconnect'`, and `message_obj` is the message
400        object to send.
401        This has the same effect as calling the node's
402        ba.Node.handlemessage() method.
403
404        ###### `('modify_part_collision', attr, value)`
405        > Changes some
406        characteristic of the physical collision that will occur between
407        our part and their part. This change will remain in effect as
408        long as the two parts remain overlapping. This means if you have a
409        part with a material that turns `'collide'` off against parts
410        younger than 100ms, and it touches another part that is 50ms old,
411        it will continue to not collide with that part until they separate,
412        even if the 100ms threshold is passed. Options for attr/value are:
413        `'physical'` (boolean value; whether a *physical* response will
414        occur at all), `'friction'` (float value; how friction-y the
415        physical response will be), `'collide'` (boolean value;
416        whether *any* collision will occur at all, including non-physical
417        stuff like callbacks), `'use_node_collide'`
418        (boolean value; whether to honor modify_node_collision
419        overrides for this collision), `'stiffness'` (float value,
420        how springy the physical response is), `'damping'` (float
421        value, how damped the physical response is), `'bounce'` (float
422        value; how bouncy the physical response is).
423
424        ###### `('modify_node_collision', attr, value)`
425        > Similar to
426        `modify_part_collision`, but operates at a node-level.
427        collision attributes set here will remain in effect as long as
428        *anything* from our part's node and their part's node overlap.
429        A key use of this functionality is to prevent new nodes from
430        colliding with each other if they appear overlapped;
431        if `modify_part_collision` is used, only the individual
432        parts that were overlapping would avoid contact, but other parts
433        could still contact leaving the two nodes 'tangled up'. Using
434        `modify_node_collision` ensures that the nodes must completely
435        separate before they can start colliding. Currently the only attr
436        available here is `'collide'` (a boolean value).
437
438        ###### `('sound', sound, volume)`
439        > Plays a ba.Sound when a collision
440        occurs, at a given volume, regardless of the collision speed/etc.
441
442        ###### `('impact_sound', sound, targetImpulse, volume)`
443        > Plays a sound
444        when a collision occurs, based on the speed of impact.
445        Provide a ba.Sound, a target-impulse, and a volume.
446
447        ###### `('skid_sound', sound, targetImpulse, volume)`
448        > Plays a sound
449        during a collision when parts are 'scraping' against each other.
450        Provide a ba.Sound, a target-impulse, and a volume.
451
452        ###### `('roll_sound', sound, targetImpulse, volume)`
453        > Plays a sound
454        during a collision when parts are 'rolling' against each other.
455        Provide a ba.Sound, a target-impulse, and a volume.
456
457        ##### Examples
458        **Example 1:** create a material that lets us ignore
459        collisions against any nodes we touch in the first
460        100 ms of our existence; handy for preventing us from
461        exploding outward if we spawn on top of another object:
462        >>> m = ba.Material()
463        ... m.add_actions(
464        ...     conditions=(('we_are_younger_than', 100),
465        ...                 'or', ('they_are_younger_than', 100)),
466        ...     actions=('modify_node_collision', 'collide', False))
467
468        **Example 2:** send a ba.DieMessage to anything we touch, but cause
469        no physical response. This should cause any ba.Actor to drop dead:
470        >>> m = ba.Material()
471        ... m.add_actions(
472        ...     actions=(('modify_part_collision', 'physical', False),
473        ...              ('message', 'their_node', 'at_connect',
474        ...                  ba.DieMessage())))
475
476        **Example 3:** play some sounds when we're contacting the ground:
477        >>> m = ba.Material()
478        ... m.add_actions(
479        ...     conditions=('they_have_material',
480        ...                 shared.footing_material),
481        ...     actions=(('impact_sound', ba.getsound('metalHit'), 2, 5),
482        ...              ('skid_sound', ba.getsound('metalSkid'), 2, 5)))
483        """
484        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:
 51class MetadataSubsystem:
 52    """Subsystem for working with script metadata in the app.
 53
 54    Category: **App Classes**
 55
 56    Access the single shared instance of this class at 'ba.app.meta'.
 57    """
 58
 59    def __init__(self) -> None:
 60
 61        self._scan: DirectoryScan | None = None
 62
 63        # Can be populated before starting the scan.
 64        self.extra_scan_dirs: list[str] = []
 65
 66        # Results populated once scan is complete.
 67        self.scanresults: ScanResults | None = None
 68
 69        self._scan_complete_cb: Callable[[], None] | None = None
 70
 71    def start_scan(self, scan_complete_cb: Callable[[], None]) -> None:
 72        """Begin the overall scan.
 73
 74        This will start scanning built in directories (which for vanilla
 75        installs should be the vast majority of the work). This should only
 76        be called once.
 77        """
 78        assert self._scan_complete_cb is None
 79        assert self._scan is None
 80
 81        self._scan_complete_cb = scan_complete_cb
 82        self._scan = DirectoryScan(
 83            [_ba.app.python_directory_app, _ba.app.python_directory_user])
 84
 85        Thread(target=self._do_scan_dirs, daemon=True).start()
 86
 87    def start_extra_scan(self) -> None:
 88        """Provide extra dirs to be scanned (namely Workspace dirs).
 89
 90        This is the bare minimum part of the scan that must be delayed until
 91        workspaces have been synced/etc. This must be called exactly once.
 92        """
 93        assert self._scan is not None
 94        self._scan.set_extras(self.extra_scan_dirs)
 95
 96    def wait_for_scan_results(self) -> ScanResults:
 97        """Return scan results, blocking if the scan is not yet complete."""
 98        if self.scanresults is None:
 99            logging.warning('ba.meta.wait_for_scan_results()'
100                            ' called before scan completed;'
101                            ' this can cause hitches.')
102
103            # Now wait a bit for the scan to complete.
104            # Eventually error though if it doesn't.
105            starttime = time.time()
106            while self.scanresults is None:
107                time.sleep(0.05)
108                if time.time() - starttime > 10.0:
109                    raise TimeoutError(
110                        'timeout waiting for meta scan to complete.')
111        return self.scanresults
112
113    def _handle_scan_results(self) -> None:
114        """Called in the logic thread with results of a completed scan."""
115        from ba._language import Lstr
116        assert _ba.in_game_thread()
117
118        results = self.scanresults
119        assert results is not None
120
121        # Spit out any warnings/errors that happened.
122        # Warnings generally only get printed locally for users' benefit
123        # (things like out-of-date scripts being ignored, etc.)
124        # Errors are more serious and will get included in the regular log.
125        if results.warnings or results.errors:
126            import textwrap
127            _ba.screenmessage(Lstr(resource='scanScriptsErrorText'),
128                              color=(1, 0, 0))
129            _ba.playsound(_ba.getsound('error'))
130            if results.warnings:
131                _ba.log(textwrap.indent('\n'.join(results.warnings),
132                                        'Warning (meta-scan): '),
133                        to_server=False)
134            if results.errors:
135                _ba.log(
136                    textwrap.indent('\n'.join(results.errors),
137                                    'Error (meta-scan): '))
138
139        # Let the game know we're done.
140        assert self._scan_complete_cb is not None
141        self._scan_complete_cb()
142
143    def _do_scan_dirs(self) -> None:
144        """Runs a scan (for use in background thread)."""
145        try:
146            assert self._scan is not None
147            self._scan.run()
148            results = self._scan.results
149            self._scan = None
150        except Exception as exc:
151            results = ScanResults(errors=[f'Scan exception: {exc}'])
152
153        # Place results and tell the logic thread they're ready.
154        self.scanresults = results
155        _ba.pushcall(self._handle_scan_results, from_other_thread=True)

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()
59    def __init__(self) -> None:
60
61        self._scan: DirectoryScan | None = None
62
63        # Can be populated before starting the scan.
64        self.extra_scan_dirs: list[str] = []
65
66        # Results populated once scan is complete.
67        self.scanresults: ScanResults | None = None
68
69        self._scan_complete_cb: Callable[[], None] | None = None
def start_scan(self, scan_complete_cb: Callable[[], NoneType]) -> None:
71    def start_scan(self, scan_complete_cb: Callable[[], None]) -> None:
72        """Begin the overall scan.
73
74        This will start scanning built in directories (which for vanilla
75        installs should be the vast majority of the work). This should only
76        be called once.
77        """
78        assert self._scan_complete_cb is None
79        assert self._scan is None
80
81        self._scan_complete_cb = scan_complete_cb
82        self._scan = DirectoryScan(
83            [_ba.app.python_directory_app, _ba.app.python_directory_user])
84
85        Thread(target=self._do_scan_dirs, 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:
87    def start_extra_scan(self) -> None:
88        """Provide extra dirs to be scanned (namely Workspace dirs).
89
90        This is the bare minimum part of the scan that must be delayed until
91        workspaces have been synced/etc. This must be called exactly once.
92        """
93        assert self._scan is not None
94        self._scan.set_extras(self.extra_scan_dirs)

Provide extra dirs to be scanned (namely Workspace dirs).

This is the bare minimum part of the scan that must be delayed until workspaces have been synced/etc. This must be called exactly once.

def wait_for_scan_results(self) -> ba._meta.ScanResults:
 96    def wait_for_scan_results(self) -> ScanResults:
 97        """Return scan results, blocking if the scan is not yet complete."""
 98        if self.scanresults is None:
 99            logging.warning('ba.meta.wait_for_scan_results()'
100                            ' called before scan completed;'
101                            ' this can cause hitches.')
102
103            # Now wait a bit for the scan to complete.
104            # Eventually error though if it doesn't.
105            starttime = time.time()
106            while self.scanresults is None:
107                time.sleep(0.05)
108                if time.time() - starttime > 10.0:
109                    raise TimeoutError(
110                        'timeout waiting for meta scan to complete.')
111        return self.scanresults

Return scan results, blocking if the scan is not yet complete.

class Model:
487class Model:
488    """A reference to a model.
489
490    Category: **Asset Classes**
491
492    Models are used for drawing.
493    Use ba.getmodel() to instantiate one.
494    """
495    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        app = _ba.app
 43        cfg = app.config
 44
 45        if self.use_teams:
 46            team_names = cfg.get('Custom Team Names', DEFAULT_TEAM_NAMES)
 47            team_colors = cfg.get('Custom Team Colors', DEFAULT_TEAM_COLORS)
 48        else:
 49            team_names = None
 50            team_colors = None
 51
 52        # print('FIXME: TEAM BASE SESSION WOULD CALC DEPS.')
 53        depsets: Sequence[ba.DependencySet] = []
 54
 55        super().__init__(depsets,
 56                         team_names=team_names,
 57                         team_colors=team_colors,
 58                         min_players=1,
 59                         max_players=self.get_max_players())
 60
 61        self._series_length = app.teams_series_length
 62        self._ffa_series_length = app.ffa_series_length
 63
 64        show_tutorial = cfg.get('Show Tutorial', True)
 65
 66        self._tutorial_activity_instance: ba.Activity | None
 67        if show_tutorial:
 68            from bastd.tutorial import TutorialActivity
 69
 70            # Get this loading.
 71            self._tutorial_activity_instance = _ba.newactivity(
 72                TutorialActivity)
 73        else:
 74            self._tutorial_activity_instance = None
 75
 76        self._playlist_name = cfg.get(self._playlist_selection_var,
 77                                      '__default__')
 78        self._playlist_randomize = cfg.get(self._playlist_randomize_var, False)
 79
 80        # Which game activity we're on.
 81        self._game_number = 0
 82
 83        playlists = cfg.get(self._playlists_var, {})
 84
 85        if (self._playlist_name != '__default__'
 86                and self._playlist_name in playlists):
 87
 88            # Make sure to copy this, as we muck with it in place once we've
 89            # got it and we don't want that to affect our config.
 90            playlist = copy.deepcopy(playlists[self._playlist_name])
 91        else:
 92            if self.use_teams:
 93                playlist = _playlist.get_default_teams_playlist()
 94            else:
 95                playlist = _playlist.get_default_free_for_all_playlist()
 96
 97        # Resolve types and whatnot to get our final playlist.
 98        playlist_resolved = _playlist.filter_playlist(playlist,
 99                                                      sessiontype=type(self),
100                                                      add_resolved_type=True)
101
102        if not playlist_resolved:
103            raise RuntimeError('Playlist contains no valid games.')
104
105        self._playlist = ShuffleList(playlist_resolved,
106                                     shuffle=self._playlist_randomize)
107
108        # Get a game on deck ready to go.
109        self._current_game_spec: dict[str, Any] | None = None
110        self._next_game_spec: dict[str, Any] = self._playlist.pull_next()
111        self._next_game: type[ba.GameActivity] = (
112            self._next_game_spec['resolved_type'])
113
114        # Go ahead and instantiate the next game we'll
115        # use so it has lots of time to load.
116        self._instantiate_next_game()
117
118        # Start in our custom join screen.
119        self.setactivity(_ba.newactivity(MultiTeamJoinActivity))
120
121    def get_ffa_series_length(self) -> int:
122        """Return free-for-all series length."""
123        return self._ffa_series_length
124
125    def get_series_length(self) -> int:
126        """Return teams series length."""
127        return self._series_length
128
129    def get_next_game_description(self) -> ba.Lstr:
130        """Returns a description of the next game on deck."""
131        # pylint: disable=cyclic-import
132        from ba._gameactivity import GameActivity
133        gametype: type[GameActivity] = self._next_game_spec['resolved_type']
134        assert issubclass(gametype, GameActivity)
135        return gametype.get_settings_display_string(self._next_game_spec)
136
137    def get_game_number(self) -> int:
138        """Returns which game in the series is currently being played."""
139        return self._game_number
140
141    def on_team_join(self, team: ba.SessionTeam) -> None:
142        team.customdata['previous_score'] = team.customdata['score'] = 0
143
144    def get_max_players(self) -> int:
145        """Return max number of ba.Player-s allowed to join the game at once"""
146        if self.use_teams:
147            return _ba.app.config.get('Team Game Max Players', 8)
148        return _ba.app.config.get('Free-for-All Max Players', 8)
149
150    def _instantiate_next_game(self) -> None:
151        self._next_game_instance = _ba.newactivity(
152            self._next_game_spec['resolved_type'],
153            self._next_game_spec['settings'])
154
155    def on_activity_end(self, activity: ba.Activity, results: Any) -> None:
156        # pylint: disable=cyclic-import
157        from bastd.tutorial import TutorialActivity
158        from bastd.activity.multiteamvictory import (
159            TeamSeriesVictoryScoreScreenActivity)
160        from ba._activitytypes import (TransitionActivity, JoinActivity,
161                                       ScoreScreenActivity)
162
163        # If we have a tutorial to show, that's the first thing we do no
164        # matter what.
165        if self._tutorial_activity_instance is not None:
166            self.setactivity(self._tutorial_activity_instance)
167            self._tutorial_activity_instance = None
168
169        # If we're leaving the tutorial activity, pop a transition activity
170        # to transition us into a round gracefully (otherwise we'd snap from
171        # one terrain to another instantly).
172        elif isinstance(activity, TutorialActivity):
173            self.setactivity(_ba.newactivity(TransitionActivity))
174
175        # If we're in a between-round activity or a restart-activity, hop
176        # into a round.
177        elif isinstance(
178                activity,
179            (JoinActivity, TransitionActivity, ScoreScreenActivity)):
180
181            # If we're coming from a series-end activity, reset scores.
182            if isinstance(activity, TeamSeriesVictoryScoreScreenActivity):
183                self.stats.reset()
184                self._game_number = 0
185                for team in self.sessionteams:
186                    team.customdata['score'] = 0
187
188            # Otherwise just set accum (per-game) scores.
189            else:
190                self.stats.reset_accum()
191
192            next_game = self._next_game_instance
193
194            self._current_game_spec = self._next_game_spec
195            self._next_game_spec = self._playlist.pull_next()
196            self._game_number += 1
197
198            # Instantiate the next now so they have plenty of time to load.
199            self._instantiate_next_game()
200
201            # (Re)register all players and wire stats to our next activity.
202            for player in self.sessionplayers:
203                # ..but only ones who have been placed on a team
204                # (ie: no longer sitting in the lobby).
205                try:
206                    has_team = (player.sessionteam is not None)
207                except NotFoundError:
208                    has_team = False
209                if has_team:
210                    self.stats.register_sessionplayer(player)
211            self.stats.setactivity(next_game)
212
213            # Now flip the current activity.
214            self.setactivity(next_game)
215
216        # If we're leaving a round, go to the score screen.
217        else:
218            self._switch_to_score_screen(results)
219
220    def _switch_to_score_screen(self, results: Any) -> None:
221        """Switch to a score screen after leaving a round."""
222        del results  # Unused arg.
223        print_error('this should be overridden')
224
225    def announce_game_results(self,
226                              activity: ba.GameActivity,
227                              results: ba.GameResults,
228                              delay: float,
229                              announce_winning_team: bool = True) -> None:
230        """Show basic game result at the end of a game.
231
232        (before transitioning to a score screen).
233        This will include a zoom-text of 'BLUE WINS'
234        or whatnot, along with a possible audio
235        announcement of the same.
236        """
237        # pylint: disable=cyclic-import
238        # pylint: disable=too-many-locals
239        from ba._math import normalized_color
240        from ba._general import Call
241        from ba._gameutils import cameraflash
242        from ba._language import Lstr
243        from ba._freeforallsession import FreeForAllSession
244        from ba._messages import CelebrateMessage
245        _ba.timer(delay, Call(_ba.playsound, _ba.getsound('boxingBell')))
246
247        if announce_winning_team:
248            winning_sessionteam = results.winning_sessionteam
249            if winning_sessionteam is not None:
250                # Have all players celebrate.
251                celebrate_msg = CelebrateMessage(duration=10.0)
252                assert winning_sessionteam.activityteam is not None
253                for player in winning_sessionteam.activityteam.players:
254                    if player.actor:
255                        player.actor.handlemessage(celebrate_msg)
256                cameraflash()
257
258                # Some languages say "FOO WINS" different for teams vs players.
259                if isinstance(self, FreeForAllSession):
260                    wins_resource = 'winsPlayerText'
261                else:
262                    wins_resource = 'winsTeamText'
263                wins_text = Lstr(resource=wins_resource,
264                                 subs=[('${NAME}', winning_sessionteam.name)])
265                activity.show_zoom_message(
266                    wins_text,
267                    scale=0.85,
268                    color=normalized_color(winning_sessionteam.color),
269                )

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        app = _ba.app
 43        cfg = app.config
 44
 45        if self.use_teams:
 46            team_names = cfg.get('Custom Team Names', DEFAULT_TEAM_NAMES)
 47            team_colors = cfg.get('Custom Team Colors', DEFAULT_TEAM_COLORS)
 48        else:
 49            team_names = None
 50            team_colors = None
 51
 52        # print('FIXME: TEAM BASE SESSION WOULD CALC DEPS.')
 53        depsets: Sequence[ba.DependencySet] = []
 54
 55        super().__init__(depsets,
 56                         team_names=team_names,
 57                         team_colors=team_colors,
 58                         min_players=1,
 59                         max_players=self.get_max_players())
 60
 61        self._series_length = app.teams_series_length
 62        self._ffa_series_length = app.ffa_series_length
 63
 64        show_tutorial = cfg.get('Show Tutorial', True)
 65
 66        self._tutorial_activity_instance: ba.Activity | None
 67        if show_tutorial:
 68            from bastd.tutorial import TutorialActivity
 69
 70            # Get this loading.
 71            self._tutorial_activity_instance = _ba.newactivity(
 72                TutorialActivity)
 73        else:
 74            self._tutorial_activity_instance = None
 75
 76        self._playlist_name = cfg.get(self._playlist_selection_var,
 77                                      '__default__')
 78        self._playlist_randomize = cfg.get(self._playlist_randomize_var, False)
 79
 80        # Which game activity we're on.
 81        self._game_number = 0
 82
 83        playlists = cfg.get(self._playlists_var, {})
 84
 85        if (self._playlist_name != '__default__'
 86                and self._playlist_name in playlists):
 87
 88            # Make sure to copy this, as we muck with it in place once we've
 89            # got it and we don't want that to affect our config.
 90            playlist = copy.deepcopy(playlists[self._playlist_name])
 91        else:
 92            if self.use_teams:
 93                playlist = _playlist.get_default_teams_playlist()
 94            else:
 95                playlist = _playlist.get_default_free_for_all_playlist()
 96
 97        # Resolve types and whatnot to get our final playlist.
 98        playlist_resolved = _playlist.filter_playlist(playlist,
 99                                                      sessiontype=type(self),
100                                                      add_resolved_type=True)
101
102        if not playlist_resolved:
103            raise RuntimeError('Playlist contains no valid games.')
104
105        self._playlist = ShuffleList(playlist_resolved,
106                                     shuffle=self._playlist_randomize)
107
108        # Get a game on deck ready to go.
109        self._current_game_spec: dict[str, Any] | None = None
110        self._next_game_spec: dict[str, Any] = self._playlist.pull_next()
111        self._next_game: type[ba.GameActivity] = (
112            self._next_game_spec['resolved_type'])
113
114        # Go ahead and instantiate the next game we'll
115        # use so it has lots of time to load.
116        self._instantiate_next_game()
117
118        # Start in our custom join screen.
119        self.setactivity(_ba.newactivity(MultiTeamJoinActivity))

Set up playlists and launches a ba.Activity to accept joiners.

def get_ffa_series_length(self) -> int:
121    def get_ffa_series_length(self) -> int:
122        """Return free-for-all series length."""
123        return self._ffa_series_length

Return free-for-all series length.

def get_series_length(self) -> int:
125    def get_series_length(self) -> int:
126        """Return teams series length."""
127        return self._series_length

Return teams series length.

def get_next_game_description(self) -> ba.Lstr:
129    def get_next_game_description(self) -> ba.Lstr:
130        """Returns a description of the next game on deck."""
131        # pylint: disable=cyclic-import
132        from ba._gameactivity import GameActivity
133        gametype: type[GameActivity] = self._next_game_spec['resolved_type']
134        assert issubclass(gametype, GameActivity)
135        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:
137    def get_game_number(self) -> int:
138        """Returns which game in the series is currently being played."""
139        return self._game_number

Returns which game in the series is currently being played.

def on_team_join(self, team: ba.SessionTeam) -> None:
141    def on_team_join(self, team: ba.SessionTeam) -> None:
142        team.customdata['previous_score'] = team.customdata['score'] = 0

Called when a new ba.Team joins the session.

def get_max_players(self) -> int:
144    def get_max_players(self) -> int:
145        """Return max number of ba.Player-s allowed to join the game at once"""
146        if self.use_teams:
147            return _ba.app.config.get('Team Game Max Players', 8)
148        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:
155    def on_activity_end(self, activity: ba.Activity, results: Any) -> None:
156        # pylint: disable=cyclic-import
157        from bastd.tutorial import TutorialActivity
158        from bastd.activity.multiteamvictory import (
159            TeamSeriesVictoryScoreScreenActivity)
160        from ba._activitytypes import (TransitionActivity, JoinActivity,
161                                       ScoreScreenActivity)
162
163        # If we have a tutorial to show, that's the first thing we do no
164        # matter what.
165        if self._tutorial_activity_instance is not None:
166            self.setactivity(self._tutorial_activity_instance)
167            self._tutorial_activity_instance = None
168
169        # If we're leaving the tutorial activity, pop a transition activity
170        # to transition us into a round gracefully (otherwise we'd snap from
171        # one terrain to another instantly).
172        elif isinstance(activity, TutorialActivity):
173            self.setactivity(_ba.newactivity(TransitionActivity))
174
175        # If we're in a between-round activity or a restart-activity, hop
176        # into a round.
177        elif isinstance(
178                activity,
179            (JoinActivity, TransitionActivity, ScoreScreenActivity)):
180
181            # If we're coming from a series-end activity, reset scores.
182            if isinstance(activity, TeamSeriesVictoryScoreScreenActivity):
183                self.stats.reset()
184                self._game_number = 0
185                for team in self.sessionteams:
186                    team.customdata['score'] = 0
187
188            # Otherwise just set accum (per-game) scores.
189            else:
190                self.stats.reset_accum()
191
192            next_game = self._next_game_instance
193
194            self._current_game_spec = self._next_game_spec
195            self._next_game_spec = self._playlist.pull_next()
196            self._game_number += 1
197
198            # Instantiate the next now so they have plenty of time to load.
199            self._instantiate_next_game()
200
201            # (Re)register all players and wire stats to our next activity.
202            for player in self.sessionplayers:
203                # ..but only ones who have been placed on a team
204                # (ie: no longer sitting in the lobby).
205                try:
206                    has_team = (player.sessionteam is not None)
207                except NotFoundError:
208                    has_team = False
209                if has_team:
210                    self.stats.register_sessionplayer(player)
211            self.stats.setactivity(next_game)
212
213            # Now flip the current activity.
214            self.setactivity(next_game)
215
216        # If we're leaving a round, go to the score screen.
217        else:
218            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:
225    def announce_game_results(self,
226                              activity: ba.GameActivity,
227                              results: ba.GameResults,
228                              delay: float,
229                              announce_winning_team: bool = True) -> None:
230        """Show basic game result at the end of a game.
231
232        (before transitioning to a score screen).
233        This will include a zoom-text of 'BLUE WINS'
234        or whatnot, along with a possible audio
235        announcement of the same.
236        """
237        # pylint: disable=cyclic-import
238        # pylint: disable=too-many-locals
239        from ba._math import normalized_color
240        from ba._general import Call
241        from ba._gameutils import cameraflash
242        from ba._language import Lstr
243        from ba._freeforallsession import FreeForAllSession
244        from ba._messages import CelebrateMessage
245        _ba.timer(delay, Call(_ba.playsound, _ba.getsound('boxingBell')))
246
247        if announce_winning_team:
248            winning_sessionteam = results.winning_sessionteam
249            if winning_sessionteam is not None:
250                # Have all players celebrate.
251                celebrate_msg = CelebrateMessage(duration=10.0)
252                assert winning_sessionteam.activityteam is not None
253                for player in winning_sessionteam.activityteam.players:
254                    if player.actor:
255                        player.actor.handlemessage(celebrate_msg)
256                cameraflash()
257
258                # Some languages say "FOO WINS" different for teams vs players.
259                if isinstance(self, FreeForAllSession):
260                    wins_resource = 'winsPlayerText'
261                else:
262                    wins_resource = 'winsTeamText'
263                wins_text = Lstr(resource=wins_resource,
264                                 subs=[('${NAME}', winning_sessionteam.name)])
265                activity.show_zoom_message(
266                    wins_text,
267                    scale=0.85,
268                    color=normalized_color(winning_sessionteam.color),
269                )

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:
386class MusicPlayer:
387    """Wrangles soundtrack music playback.
388
389    Category: **App Classes**
390
391    Music can be played either through the game itself
392    or via a platform-specific external player.
393    """
394
395    def __init__(self) -> None:
396        self._have_set_initial_volume = False
397        self._entry_to_play: Any = None
398        self._volume = 1.0
399        self._actually_playing = False
400
401    def select_entry(self, callback: Callable[[Any], None], current_entry: Any,
402                     selection_target_name: str) -> Any:
403        """Summons a UI to select a new soundtrack entry."""
404        return self.on_select_entry(callback, current_entry,
405                                    selection_target_name)
406
407    def set_volume(self, volume: float) -> None:
408        """Set player volume (value should be between 0 and 1)."""
409        self._volume = volume
410        self.on_set_volume(volume)
411        self._update_play_state()
412
413    def play(self, entry: Any) -> None:
414        """Play provided entry."""
415        if not self._have_set_initial_volume:
416            self._volume = _ba.app.config.resolve('Music Volume')
417            self.on_set_volume(self._volume)
418            self._have_set_initial_volume = True
419        self._entry_to_play = copy.deepcopy(entry)
420
421        # If we're currently *actually* playing something,
422        # switch to the new thing.
423        # Otherwise update state which will start us playing *only*
424        # if proper (volume > 0, etc).
425        if self._actually_playing:
426            self.on_play(self._entry_to_play)
427        else:
428            self._update_play_state()
429
430    def stop(self) -> None:
431        """Stop any playback that is occurring."""
432        self._entry_to_play = None
433        self._update_play_state()
434
435    def shutdown(self) -> None:
436        """Shutdown music playback completely."""
437        self.on_app_shutdown()
438
439    def on_select_entry(self, callback: Callable[[Any], None],
440                        current_entry: Any, selection_target_name: str) -> Any:
441        """Present a GUI to select an entry.
442
443        The callback should be called with a valid entry or None to
444        signify that the default soundtrack should be used.."""
445
446    # Subclasses should override the following:
447
448    def on_set_volume(self, volume: float) -> None:
449        """Called when the volume should be changed."""
450
451    def on_play(self, entry: Any) -> None:
452        """Called when a new song/playlist/etc should be played."""
453
454    def on_stop(self) -> None:
455        """Called when the music should stop."""
456
457    def on_app_shutdown(self) -> None:
458        """Called on final app shutdown."""
459
460    def _update_play_state(self) -> None:
461
462        # If we aren't playing, should be, and have positive volume, do so.
463        if not self._actually_playing:
464            if self._entry_to_play is not None and self._volume > 0.0:
465                self.on_play(self._entry_to_play)
466                self._actually_playing = True
467        else:
468            if self._actually_playing and (self._entry_to_play is None
469                                           or self._volume <= 0.0):
470                self.on_stop()
471                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()
395    def __init__(self) -> None:
396        self._have_set_initial_volume = False
397        self._entry_to_play: Any = None
398        self._volume = 1.0
399        self._actually_playing = False
def select_entry( self, callback: Callable[[Any], NoneType], current_entry: Any, selection_target_name: str) -> Any:
401    def select_entry(self, callback: Callable[[Any], None], current_entry: Any,
402                     selection_target_name: str) -> Any:
403        """Summons a UI to select a new soundtrack entry."""
404        return self.on_select_entry(callback, current_entry,
405                                    selection_target_name)

Summons a UI to select a new soundtrack entry.

def set_volume(self, volume: float) -> None:
407    def set_volume(self, volume: float) -> None:
408        """Set player volume (value should be between 0 and 1)."""
409        self._volume = volume
410        self.on_set_volume(volume)
411        self._update_play_state()

Set player volume (value should be between 0 and 1).

def play(self, entry: Any) -> None:
413    def play(self, entry: Any) -> None:
414        """Play provided entry."""
415        if not self._have_set_initial_volume:
416            self._volume = _ba.app.config.resolve('Music Volume')
417            self.on_set_volume(self._volume)
418            self._have_set_initial_volume = True
419        self._entry_to_play = copy.deepcopy(entry)
420
421        # If we're currently *actually* playing something,
422        # switch to the new thing.
423        # Otherwise update state which will start us playing *only*
424        # if proper (volume > 0, etc).
425        if self._actually_playing:
426            self.on_play(self._entry_to_play)
427        else:
428            self._update_play_state()

Play provided entry.

def stop(self) -> None:
430    def stop(self) -> None:
431        """Stop any playback that is occurring."""
432        self._entry_to_play = None
433        self._update_play_state()

Stop any playback that is occurring.

def shutdown(self) -> None:
435    def shutdown(self) -> None:
436        """Shutdown music playback completely."""
437        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:
439    def on_select_entry(self, callback: Callable[[Any], None],
440                        current_entry: Any, selection_target_name: str) -> Any:
441        """Present a GUI to select an entry.
442
443        The callback should be called with a valid entry or None to
444        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:
448    def on_set_volume(self, volume: float) -> None:
449        """Called when the volume should be changed."""

Called when the volume should be changed.

def on_play(self, entry: Any) -> None:
451    def on_play(self, entry: Any) -> None:
452        """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:
454    def on_stop(self) -> None:
455        """Called when the music should stop."""

Called when the music should stop.

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

Called on final app shutdown.

class MusicPlayMode(enum.Enum):
52class MusicPlayMode(Enum):
53    """Influences behavior when playing music.
54
55    Category: **Enums**
56    """
57    REGULAR = 'regular'
58    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:
121class MusicSubsystem:
122    """Subsystem for music playback in the app.
123
124    Category: **App Classes**
125
126    Access the single shared instance of this class at 'ba.app.music'.
127    """
128
129    def __init__(self) -> None:
130        # pylint: disable=cyclic-import
131        self._music_node: _ba.Node | None = None
132        self._music_mode: MusicPlayMode = MusicPlayMode.REGULAR
133        self._music_player: MusicPlayer | None = None
134        self._music_player_type: type[MusicPlayer] | None = None
135        self.music_types: dict[MusicPlayMode, MusicType | None] = {
136            MusicPlayMode.REGULAR: None,
137            MusicPlayMode.TEST: None
138        }
139
140        # Set up custom music players for platforms that support them.
141        # FIXME: should generalize this to support arbitrary players per
142        # platform (which can be discovered via ba_meta).
143        # Our standard asset playback should probably just be one of them
144        # instead of a special case.
145        if self.supports_soundtrack_entry_type('musicFile'):
146            from ba.osmusic import OSMusicPlayer
147            self._music_player_type = OSMusicPlayer
148        elif self.supports_soundtrack_entry_type('iTunesPlaylist'):
149            from ba.macmusicapp import MacMusicAppMusicPlayer
150            self._music_player_type = MacMusicAppMusicPlayer
151
152    def on_app_launch(self) -> None:
153        """Should be called by app on_app_launch()."""
154
155        # If we're using a non-default playlist, lets go ahead and get our
156        # music-player going since it may hitch (better while we're faded
157        # out than later).
158        try:
159            cfg = _ba.app.config
160            if ('Soundtrack' in cfg and cfg['Soundtrack']
161                    not in ['__default__', 'Default Soundtrack']):
162                self.get_music_player()
163        except Exception:
164            from ba import _error
165            _error.print_exception('error prepping music-player')
166
167    def on_app_shutdown(self) -> None:
168        """Should be called when the app is shutting down."""
169        if self._music_player is not None:
170            self._music_player.shutdown()
171
172    def have_music_player(self) -> bool:
173        """Returns whether a music player is present."""
174        return self._music_player_type is not None
175
176    def get_music_player(self) -> MusicPlayer:
177        """Returns the system music player, instantiating if necessary."""
178        if self._music_player is None:
179            if self._music_player_type is None:
180                raise TypeError('no music player type set')
181            self._music_player = self._music_player_type()
182        return self._music_player
183
184    def music_volume_changed(self, val: float) -> None:
185        """Should be called when changing the music volume."""
186        if self._music_player is not None:
187            self._music_player.set_volume(val)
188
189    def set_music_play_mode(self,
190                            mode: MusicPlayMode,
191                            force_restart: bool = False) -> None:
192        """Sets music play mode; used for soundtrack testing/etc."""
193        old_mode = self._music_mode
194        self._music_mode = mode
195        if old_mode != self._music_mode or force_restart:
196
197            # If we're switching into test mode we don't
198            # actually play anything until its requested.
199            # If we're switching *out* of test mode though
200            # we want to go back to whatever the normal song was.
201            if mode is MusicPlayMode.REGULAR:
202                mtype = self.music_types[MusicPlayMode.REGULAR]
203                self.do_play_music(None if mtype is None else mtype.value)
204
205    def supports_soundtrack_entry_type(self, entry_type: str) -> bool:
206        """Return whether provided soundtrack entry type is supported here."""
207        uas = _ba.env()['user_agent_string']
208        assert isinstance(uas, str)
209
210        # FIXME: Generalize this.
211        if entry_type == 'iTunesPlaylist':
212            return 'Mac' in uas
213        if entry_type in ('musicFile', 'musicFolder'):
214            return ('android' in uas
215                    and _ba.android_get_external_files_dir() is not None)
216        if entry_type == 'default':
217            return True
218        return False
219
220    def get_soundtrack_entry_type(self, entry: Any) -> str:
221        """Given a soundtrack entry, returns its type, taking into
222        account what is supported locally."""
223        try:
224            if entry is None:
225                entry_type = 'default'
226
227            # Simple string denotes iTunesPlaylist (legacy format).
228            elif isinstance(entry, str):
229                entry_type = 'iTunesPlaylist'
230
231            # For other entries we expect type and name strings in a dict.
232            elif (isinstance(entry, dict) and 'type' in entry
233                  and isinstance(entry['type'], str) and 'name' in entry
234                  and isinstance(entry['name'], str)):
235                entry_type = entry['type']
236            else:
237                raise TypeError('invalid soundtrack entry: ' + str(entry) +
238                                ' (type ' + str(type(entry)) + ')')
239            if self.supports_soundtrack_entry_type(entry_type):
240                return entry_type
241            raise ValueError('invalid soundtrack entry:' + str(entry))
242        except Exception:
243            from ba import _error
244            _error.print_exception()
245            return 'default'
246
247    def get_soundtrack_entry_name(self, entry: Any) -> str:
248        """Given a soundtrack entry, returns its name."""
249        try:
250            if entry is None:
251                raise TypeError('entry is None')
252
253            # Simple string denotes an iTunesPlaylist name (legacy entry).
254            if isinstance(entry, str):
255                return entry
256
257            # For other entries we expect type and name strings in a dict.
258            if (isinstance(entry, dict) and 'type' in entry
259                    and isinstance(entry['type'], str) and 'name' in entry
260                    and isinstance(entry['name'], str)):
261                return entry['name']
262            raise ValueError('invalid soundtrack entry:' + str(entry))
263        except Exception:
264            from ba import _error
265            _error.print_exception()
266            return 'default'
267
268    def on_app_resume(self) -> None:
269        """Should be run when the app resumes from a suspended state."""
270        if _ba.is_os_playing_music():
271            self.do_play_music(None)
272
273    def do_play_music(self,
274                      musictype: MusicType | str | None,
275                      continuous: bool = False,
276                      mode: MusicPlayMode = MusicPlayMode.REGULAR,
277                      testsoundtrack: dict[str, Any] | None = None) -> None:
278        """Plays the requested music type/mode.
279
280        For most cases, setmusic() is the proper call to use, which itself
281        calls this. Certain cases, however, such as soundtrack testing, may
282        require calling this directly.
283        """
284
285        # We can be passed a MusicType or the string value corresponding
286        # to one.
287        if musictype is not None:
288            try:
289                musictype = MusicType(musictype)
290            except ValueError:
291                print(f"Invalid music type: '{musictype}'")
292                musictype = None
293
294        with _ba.Context('ui'):
295
296            # If they don't want to restart music and we're already
297            # playing what's requested, we're done.
298            if continuous and self.music_types[mode] is musictype:
299                return
300            self.music_types[mode] = musictype
301
302            # If the OS tells us there's currently music playing,
303            # all our operations default to playing nothing.
304            if _ba.is_os_playing_music():
305                musictype = None
306
307            # If we're not in the mode this music is being set for,
308            # don't actually change what's playing.
309            if mode != self._music_mode:
310                return
311
312            # Some platforms have a special music-player for things like iTunes
313            # soundtracks, mp3s, etc. if this is the case, attempt to grab an
314            # entry for this music-type, and if we have one, have the
315            # music-player play it.  If not, we'll play game music ourself.
316            if musictype is not None and self._music_player_type is not None:
317                if testsoundtrack is not None:
318                    soundtrack = testsoundtrack
319                else:
320                    soundtrack = self._get_user_soundtrack()
321                entry = soundtrack.get(musictype.value)
322            else:
323                entry = None
324
325            # Go through music-player.
326            if entry is not None:
327                self._play_music_player_music(entry)
328
329            # Handle via internal music.
330            else:
331                self._play_internal_music(musictype)
332
333    def _get_user_soundtrack(self) -> dict[str, Any]:
334        """Return current user soundtrack or empty dict otherwise."""
335        cfg = _ba.app.config
336        soundtrack: dict[str, Any] = {}
337        soundtrackname = cfg.get('Soundtrack')
338        if soundtrackname is not None and soundtrackname != '__default__':
339            try:
340                soundtrack = cfg.get('Soundtracks', {})[soundtrackname]
341            except Exception as exc:
342                print(f'Error looking up user soundtrack: {exc}')
343                soundtrack = {}
344        return soundtrack
345
346    def _play_music_player_music(self, entry: Any) -> None:
347
348        # Stop any existing internal music.
349        if self._music_node is not None:
350            self._music_node.delete()
351            self._music_node = None
352
353        # Do the thing.
354        self.get_music_player().play(entry)
355
356    def _play_internal_music(self, musictype: MusicType | None) -> None:
357
358        # Stop any existing music-player playback.
359        if self._music_player is not None:
360            self._music_player.stop()
361
362        # Stop any existing internal music.
363        if self._music_node:
364            self._music_node.delete()
365            self._music_node = None
366
367        # Start up new internal music.
368        if musictype is not None:
369
370            entry = ASSET_SOUNDTRACK_ENTRIES.get(musictype)
371            if entry is None:
372                print(f"Unknown music: '{musictype}'")
373                entry = ASSET_SOUNDTRACK_ENTRIES[MusicType.FLAG_CATCHER]
374
375            self._music_node = _ba.newnode(
376                type='sound',
377                attrs={
378                    'sound': _ba.getsound(entry.assetname),
379                    'positional': False,
380                    'music': True,
381                    'volume': entry.volume * 5.0,
382                    'loop': entry.loop
383                })

Subsystem for music playback in the app.

Category: App Classes

Access the single shared instance of this class at 'ba.app.music'.

MusicSubsystem()
129    def __init__(self) -> None:
130        # pylint: disable=cyclic-import
131        self._music_node: _ba.Node | None = None
132        self._music_mode: MusicPlayMode = MusicPlayMode.REGULAR
133        self._music_player: MusicPlayer | None = None
134        self._music_player_type: type[MusicPlayer] | None = None
135        self.music_types: dict[MusicPlayMode, MusicType | None] = {
136            MusicPlayMode.REGULAR: None,
137            MusicPlayMode.TEST: None
138        }
139
140        # Set up custom music players for platforms that support them.
141        # FIXME: should generalize this to support arbitrary players per
142        # platform (which can be discovered via ba_meta).
143        # Our standard asset playback should probably just be one of them
144        # instead of a special case.
145        if self.supports_soundtrack_entry_type('musicFile'):
146            from ba.osmusic import OSMusicPlayer
147            self._music_player_type = OSMusicPlayer
148        elif self.supports_soundtrack_entry_type('iTunesPlaylist'):
149            from ba.macmusicapp import MacMusicAppMusicPlayer
150            self._music_player_type = MacMusicAppMusicPlayer
def on_app_launch(self) -> None:
152    def on_app_launch(self) -> None:
153        """Should be called by app on_app_launch()."""
154
155        # If we're using a non-default playlist, lets go ahead and get our
156        # music-player going since it may hitch (better while we're faded
157        # out than later).
158        try:
159            cfg = _ba.app.config
160            if ('Soundtrack' in cfg and cfg['Soundtrack']
161                    not in ['__default__', 'Default Soundtrack']):
162                self.get_music_player()
163        except Exception:
164            from ba import _error
165            _error.print_exception('error prepping music-player')

Should be called by app on_app_launch().

def on_app_shutdown(self) -> None:
167    def on_app_shutdown(self) -> None:
168        """Should be called when the app is shutting down."""
169        if self._music_player is not None:
170            self._music_player.shutdown()

Should be called when the app is shutting down.

def have_music_player(self) -> bool:
172    def have_music_player(self) -> bool:
173        """Returns whether a music player is present."""
174        return self._music_player_type is not None

Returns whether a music player is present.

def get_music_player(self) -> ba.MusicPlayer:
176    def get_music_player(self) -> MusicPlayer:
177        """Returns the system music player, instantiating if necessary."""
178        if self._music_player is None:
179            if self._music_player_type is None:
180                raise TypeError('no music player type set')
181            self._music_player = self._music_player_type()
182        return self._music_player

Returns the system music player, instantiating if necessary.

def music_volume_changed(self, val: float) -> None:
184    def music_volume_changed(self, val: float) -> None:
185        """Should be called when changing the music volume."""
186        if self._music_player is not None:
187            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:
189    def set_music_play_mode(self,
190                            mode: MusicPlayMode,
191                            force_restart: bool = False) -> None:
192        """Sets music play mode; used for soundtrack testing/etc."""
193        old_mode = self._music_mode
194        self._music_mode = mode
195        if old_mode != self._music_mode or force_restart:
196
197            # If we're switching into test mode we don't
198            # actually play anything until its requested.
199            # If we're switching *out* of test mode though
200            # we want to go back to whatever the normal song was.
201            if mode is MusicPlayMode.REGULAR:
202                mtype = self.music_types[MusicPlayMode.REGULAR]
203                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:
205    def supports_soundtrack_entry_type(self, entry_type: str) -> bool:
206        """Return whether provided soundtrack entry type is supported here."""
207        uas = _ba.env()['user_agent_string']
208        assert isinstance(uas, str)
209
210        # FIXME: Generalize this.
211        if entry_type == 'iTunesPlaylist':
212            return 'Mac' in uas
213        if entry_type in ('musicFile', 'musicFolder'):
214            return ('android' in uas
215                    and _ba.android_get_external_files_dir() is not None)
216        if entry_type == 'default':
217            return True
218        return False

Return whether provided soundtrack entry type is supported here.

def get_soundtrack_entry_type(self, entry: Any) -> str:
220    def get_soundtrack_entry_type(self, entry: Any) -> str:
221        """Given a soundtrack entry, returns its type, taking into
222        account what is supported locally."""
223        try:
224            if entry is None:
225                entry_type = 'default'
226
227            # Simple string denotes iTunesPlaylist (legacy format).
228            elif isinstance(entry, str):
229                entry_type = 'iTunesPlaylist'
230
231            # For other entries we expect type and name strings in a dict.
232            elif (isinstance(entry, dict) and 'type' in entry
233                  and isinstance(entry['type'], str) and 'name' in entry
234                  and isinstance(entry['name'], str)):
235                entry_type = entry['type']
236            else:
237                raise TypeError('invalid soundtrack entry: ' + str(entry) +
238                                ' (type ' + str(type(entry)) + ')')
239            if self.supports_soundtrack_entry_type(entry_type):
240                return entry_type
241            raise ValueError('invalid soundtrack entry:' + str(entry))
242        except Exception:
243            from ba import _error
244            _error.print_exception()
245            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:
247    def get_soundtrack_entry_name(self, entry: Any) -> str:
248        """Given a soundtrack entry, returns its name."""
249        try:
250            if entry is None:
251                raise TypeError('entry is None')
252
253            # Simple string denotes an iTunesPlaylist name (legacy entry).
254            if isinstance(entry, str):
255                return entry
256
257            # For other entries we expect type and name strings in a dict.
258            if (isinstance(entry, dict) and 'type' in entry
259                    and isinstance(entry['type'], str) and 'name' in entry
260                    and isinstance(entry['name'], str)):
261                return entry['name']
262            raise ValueError('invalid soundtrack entry:' + str(entry))
263        except Exception:
264            from ba import _error
265            _error.print_exception()
266            return 'default'

Given a soundtrack entry, returns its name.

def on_app_resume(self) -> None:
268    def on_app_resume(self) -> None:
269        """Should be run when the app resumes from a suspended state."""
270        if _ba.is_os_playing_music():
271            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:
273    def do_play_music(self,
274                      musictype: MusicType | str | None,
275                      continuous: bool = False,
276                      mode: MusicPlayMode = MusicPlayMode.REGULAR,
277                      testsoundtrack: dict[str, Any] | None = None) -> None:
278        """Plays the requested music type/mode.
279
280        For most cases, setmusic() is the proper call to use, which itself
281        calls this. Certain cases, however, such as soundtrack testing, may
282        require calling this directly.
283        """
284
285        # We can be passed a MusicType or the string value corresponding
286        # to one.
287        if musictype is not None:
288            try:
289                musictype = MusicType(musictype)
290            except ValueError:
291                print(f"Invalid music type: '{musictype}'")
292                musictype = None
293
294        with _ba.Context('ui'):
295
296            # If they don't want to restart music and we're already
297            # playing what's requested, we're done.
298            if continuous and self.music_types[mode] is musictype:
299                return
300            self.music_types[mode] = musictype
301
302            # If the OS tells us there's currently music playing,
303            # all our operations default to playing nothing.
304            if _ba.is_os_playing_music():
305                musictype = None
306
307            # If we're not in the mode this music is being set for,
308            # don't actually change what's playing.
309            if mode != self._music_mode:
310                return
311
312            # Some platforms have a special music-player for things like iTunes
313            # soundtracks, mp3s, etc. if this is the case, attempt to grab an
314            # entry for this music-type, and if we have one, have the
315            # music-player play it.  If not, we'll play game music ourself.
316            if musictype is not None and self._music_player_type is not None:
317                if testsoundtrack is not None:
318                    soundtrack = testsoundtrack
319                else:
320                    soundtrack = self._get_user_soundtrack()
321                entry = soundtrack.get(musictype.value)
322            else:
323                entry = None
324
325            # Go through music-player.
326            if entry is not None:
327                self._play_music_player_music(entry)
328
329            # Handle via internal music.
330            else:
331                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    MENU = 'Menu'
29    VICTORY = 'Victory'
30    CHAR_SELECT = 'CharSelect'
31    RUN_AWAY = 'RunAway'
32    ONSLAUGHT = 'Onslaught'
33    KEEP_AWAY = 'Keep Away'
34    RACE = 'Race'
35    EPIC_RACE = 'Epic Race'
36    SCORES = 'Scores'
37    GRAND_ROMP = 'GrandRomp'
38    TO_THE_DEATH = 'ToTheDeath'
39    CHOSEN_ONE = 'Chosen One'
40    FORWARD_MARCH = 'ForwardMarch'
41    FLAG_CATCHER = 'FlagCatcher'
42    SURVIVAL = 'Survival'
43    EPIC = 'Epic'
44    SPORTS = 'Sports'
45    HOCKEY = 'Hockey'
46    FOOTBALL = 'Football'
47    FLYING = 'Flying'
48    SCARY = 'Scary'
49    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:
2413def newactivity(activity_type: type[ba.Activity],
2414                settings: dict | None = None) -> ba.Activity:
2415    """Instantiates a ba.Activity given a type object.
2416
2417    Category: **General Utility Functions**
2418
2419    Activities require special setup and thus cannot be directly
2420    instantiated; you must go through this function.
2421    """
2422    import ba  # pylint: disable=cyclic-import
2423    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:
2426def newnode(type: str,
2427            owner: ba.Node | None = None,
2428            attrs: dict | None = None,
2429            name: str | None = None,
2430            delegate: Any = None) -> Node:
2431    """Add a node of the given type to the game.
2432
2433    Category: **Gameplay Functions**
2434
2435    If a dict is provided for 'attributes', the node's initial attributes
2436    will be set based on them.
2437
2438    'name', if provided, will be stored with the node purely for debugging
2439    purposes. If no name is provided, an automatic one will be generated
2440    such as 'terrain@foo.py:30'.
2441
2442    If 'delegate' is provided, Python messages sent to the node will go to
2443    that object's handlemessage() method. Note that the delegate is stored
2444    as a weak-ref, so the node itself will not keep the object alive.
2445
2446    if 'owner' is provided, the node will be automatically killed when that
2447    object dies. 'owner' can be another node or a ba.Actor
2448    """
2449    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:
498class Node:
499    """Reference to a Node; the low level building block of the game.
500
501    Category: **Gameplay Classes**
502
503    At its core, a game is nothing more than a scene of Nodes
504    with attributes getting interconnected or set over time.
505
506    A ba.Node instance should be thought of as a weak-reference
507    to a game node; *not* the node itself. This means a Node's
508    lifecycle is completely independent of how many Python references
509    to it exist. To explicitly add a new node to the game, use
510    ba.newnode(), and to explicitly delete one, use ba.Node.delete().
511    ba.Node.exists() can be used to determine if a Node still points to
512    a live node in the game.
513
514    You can use `ba.Node(None)` to instantiate an invalid
515    Node reference (sometimes used as attr values/etc).
516    """
517
518    # Note attributes:
519    # NOTE: I'm just adding *all* possible node attrs here
520    # now now since we have a single ba.Node type; in the
521    # future I hope to create proper individual classes
522    # corresponding to different node types with correct
523    # attributes per node-type.
524    color: Sequence[float] = (0.0, 0.0, 0.0)
525    size: Sequence[float] = (0.0, 0.0, 0.0)
526    position: Sequence[float] = (0.0, 0.0, 0.0)
527    position_center: Sequence[float] = (0.0, 0.0, 0.0)
528    position_forward: Sequence[float] = (0.0, 0.0, 0.0)
529    punch_position: Sequence[float] = (0.0, 0.0, 0.0)
530    punch_velocity: Sequence[float] = (0.0, 0.0, 0.0)
531    velocity: Sequence[float] = (0.0, 0.0, 0.0)
532    name_color: Sequence[float] = (0.0, 0.0, 0.0)
533    tint_color: Sequence[float] = (0.0, 0.0, 0.0)
534    tint2_color: Sequence[float] = (0.0, 0.0, 0.0)
535    text: ba.Lstr | str = ''
536    texture: ba.Texture | None = None
537    tint_texture: ba.Texture | None = None
538    times: Sequence[int] = (1, 2, 3, 4, 5)
539    values: Sequence[float] = (1.0, 2.0, 3.0, 4.0)
540    offset: float = 0.0
541    input0: float = 0.0
542    input1: float = 0.0
543    input2: float = 0.0
544    input3: float = 0.0
545    flashing: bool = False
546    scale: float | Sequence[float] = 0.0
547    opacity: float = 0.0
548    loop: bool = False
549    time1: int = 0
550    time2: int = 0
551    timemax: int = 0
552    client_only: bool = False
553    materials: Sequence[Material] = ()
554    roller_materials: Sequence[Material] = ()
555    name: str = ''
556    punch_materials: Sequence[ba.Material] = ()
557    pickup_materials: Sequence[ba.Material] = ()
558    extras_material: Sequence[ba.Material] = ()
559    rotate: float = 0.0
560    hold_node: ba.Node | None = None
561    hold_body: int = 0
562    host_only: bool = False
563    premultiplied: bool = False
564    source_player: ba.Player | None = None
565    model_opaque: ba.Model | None = None
566    model_transparent: ba.Model | None = None
567    damage_smoothed: float = 0.0
568    gravity_scale: float = 1.0
569    punch_power: float = 0.0
570    punch_momentum_linear: Sequence[float] = (0.0, 0.0, 0.0)
571    punch_momentum_angular: float = 0.0
572    rate: int = 0
573    vr_depth: float = 0.0
574    is_area_of_interest: bool = False
575    jump_pressed: bool = False
576    pickup_pressed: bool = False
577    punch_pressed: bool = False
578    bomb_pressed: bool = False
579    fly_pressed: bool = False
580    hold_position_pressed: bool = False
581    knockout: float = 0.0
582    invincible: bool = False
583    stick_to_owner: bool = False
584    damage: int = 0
585    run: float = 0.0
586    move_up_down: float = 0.0
587    move_left_right: float = 0.0
588    curse_death_time: int = 0
589    boxing_gloves: bool = False
590    hockey: bool = False
591    use_fixed_vr_overlay: bool = False
592    allow_kick_idle_players: bool = False
593    music_continuous: bool = False
594    music_count: int = 0
595    hurt: float = 0.0
596    always_show_health_bar: bool = False
597    mini_billboard_1_texture: ba.Texture | None = None
598    mini_billboard_1_start_time: int = 0
599    mini_billboard_1_end_time: int = 0
600    mini_billboard_2_texture: ba.Texture | None = None
601    mini_billboard_2_start_time: int = 0
602    mini_billboard_2_end_time: int = 0
603    mini_billboard_3_texture: ba.Texture | None = None
604    mini_billboard_3_start_time: int = 0
605    mini_billboard_3_end_time: int = 0
606    boxing_gloves_flashing: bool = False
607    dead: bool = False
608    floor_reflection: bool = False
609    debris_friction: float = 0.0
610    debris_kill_height: float = 0.0
611    vr_near_clip: float = 0.0
612    shadow_ortho: bool = False
613    happy_thoughts_mode: bool = False
614    shadow_offset: Sequence[float] = (0.0, 0.0)
615    paused: bool = False
616    time: int = 0
617    ambient_color: Sequence[float] = (1.0, 1.0, 1.0)
618    camera_mode: str = 'rotate'
619    frozen: bool = False
620    area_of_interest_bounds: Sequence[float] = (-1, -1, -1, 1, 1, 1)
621    shadow_range: Sequence[float] = (0, 0, 0, 0)
622    counter_text: str = ''
623    counter_texture: ba.Texture | None = None
624    shattered: int = 0
625    billboard_texture: ba.Texture | None = None
626    billboard_cross_out: bool = False
627    billboard_opacity: float = 0.0
628    slow_motion: bool = False
629    music: str = ''
630    vr_camera_offset: Sequence[float] = (0.0, 0.0, 0.0)
631    vr_overlay_center: Sequence[float] = (0.0, 0.0, 0.0)
632    vr_overlay_center_enabled: bool = False
633    vignette_outer: Sequence[float] = (0.0, 0.0)
634    vignette_inner: Sequence[float] = (0.0, 0.0)
635    tint: Sequence[float] = (1.0, 1.0, 1.0)
636
637    def add_death_action(self, action: Callable[[], None]) -> None:
638        """Add a callable object to be called upon this node's death.
639        Note that these actions are run just after the node dies, not before.
640        """
641        return None
642
643    def connectattr(self, srcattr: str, dstnode: Node, dstattr: str) -> None:
644        """Connect one of this node's attributes to an attribute on another
645        node. This will immediately set the target attribute's value to that
646        of the source attribute, and will continue to do so once per step
647        as long as the two nodes exist. The connection can be severed by
648        setting the target attribute to any value or connecting another
649        node attribute to it.
650
651        ##### Example
652        Create a locator and attach a light to it:
653        >>> light = ba.newnode('light')
654        ... loc = ba.newnode('locator', attrs={'position': (0, 10, 0)})
655        ... loc.connectattr('position', light, 'position')
656        """
657        return None
658
659    def delete(self, ignore_missing: bool = True) -> None:
660        """Delete the node. Ignores already-deleted nodes if `ignore_missing`
661        is True; otherwise a ba.NodeNotFoundError is thrown.
662        """
663        return None
664
665    def exists(self) -> bool:
666        """Returns whether the Node still exists.
667        Most functionality will fail on a nonexistent Node, so it's never a bad
668        idea to check this.
669
670        Note that you can also use the boolean operator for this same
671        functionality, so a statement such as "if mynode" will do
672        the right thing both for Node objects and values of None.
673        """
674        return bool()
675
676    # Show that ur return type varies based on "doraise" value:
677    @overload
678    def getdelegate(self,
679                    type: type[_T],
680                    doraise: Literal[False] = False) -> _T | None:
681        ...
682
683    @overload
684    def getdelegate(self, type: type[_T], doraise: Literal[True]) -> _T:
685        ...
686
687    def getdelegate(self, type: Any, doraise: bool = False) -> Any:
688        """Return the node's current delegate object if it matches
689        a certain type.
690
691        If the node has no delegate or it is not an instance of the passed
692        type, then None will be returned. If 'doraise' is True, then an
693        ba.DelegateNotFoundError will be raised instead.
694        """
695        return None
696
697    def getname(self) -> str:
698        """Return the name assigned to a Node; used mainly for debugging"""
699        return str()
700
701    def getnodetype(self) -> str:
702        """Return the type of Node referenced by this object as a string.
703        (Note this is different from the Python type which is always ba.Node)
704        """
705        return str()
706
707    def handlemessage(self, *args: Any) -> None:
708        """General message handling; can be passed any message object.
709
710        All standard message objects are forwarded along to the ba.Node's
711        delegate for handling (generally the ba.Actor that made the node).
712
713        ba.Node-s are unique, however, in that they can be passed a second
714        form of message; 'node-messages'.  These consist of a string type-name
715        as a first argument along with the args specific to that type name
716        as additional arguments.
717        Node-messages communicate directly with the low-level node layer
718        and are delivered simultaneously on all game clients,
719        acting as an alternative to setting node attributes.
720        """
721        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()
color: Sequence[float] = (0.0, 0.0, 0.0)
size: Sequence[float] = (0.0, 0.0, 0.0)
position: Sequence[float] = (0.0, 0.0, 0.0)
position_center: Sequence[float] = (0.0, 0.0, 0.0)
position_forward: Sequence[float] = (0.0, 0.0, 0.0)
punch_position: Sequence[float] = (0.0, 0.0, 0.0)
punch_velocity: Sequence[float] = (0.0, 0.0, 0.0)
velocity: Sequence[float] = (0.0, 0.0, 0.0)
name_color: Sequence[float] = (0.0, 0.0, 0.0)
tint_color: Sequence[float] = (0.0, 0.0, 0.0)
tint2_color: Sequence[float] = (0.0, 0.0, 0.0)
text: ba.Lstr | str = ''
texture: ba.Texture | None = None
tint_texture: ba.Texture | None = None
times: Sequence[int] = (1, 2, 3, 4, 5)
values: Sequence[float] = (1.0, 2.0, 3.0, 4.0)
offset: float = 0.0
input0: float = 0.0
input1: float = 0.0
input2: float = 0.0
input3: float = 0.0
flashing: bool = False
scale: Union[float, Sequence[float]] = 0.0
opacity: float = 0.0
loop: bool = False
time1: int = 0
time2: int = 0
timemax: int = 0
client_only: bool = False
materials: Sequence[ba.Material] = ()
roller_materials: Sequence[ba.Material] = ()
name: str = ''
punch_materials: Sequence[ba.Material] = ()
pickup_materials: Sequence[ba.Material] = ()
extras_material: Sequence[ba.Material] = ()
rotate: float = 0.0
hold_node: ba.Node | None = None
hold_body: int = 0
host_only: bool = False
premultiplied: bool = False
source_player: ba.Player | None = None
model_opaque: ba.Model | None = None
model_transparent: ba.Model | None = None
damage_smoothed: float = 0.0
gravity_scale: float = 1.0
punch_power: float = 0.0
punch_momentum_linear: Sequence[float] = (0.0, 0.0, 0.0)
punch_momentum_angular: float = 0.0
rate: int = 0
vr_depth: float = 0.0
is_area_of_interest: bool = False
jump_pressed: bool = False
pickup_pressed: bool = False
punch_pressed: bool = False
bomb_pressed: bool = False
fly_pressed: bool = False
hold_position_pressed: bool = False
knockout: float = 0.0
invincible: bool = False
stick_to_owner: bool = False
damage: int = 0
run: float = 0.0
move_up_down: float = 0.0
move_left_right: float = 0.0
curse_death_time: int = 0
boxing_gloves: bool = False
hockey: bool = False
use_fixed_vr_overlay: bool = False
allow_kick_idle_players: bool = False
music_continuous: bool = False
music_count: int = 0
hurt: float = 0.0
always_show_health_bar: bool = False
mini_billboard_1_texture: ba.Texture | None = None
mini_billboard_1_start_time: int = 0
mini_billboard_1_end_time: int = 0
mini_billboard_2_texture: ba.Texture | None = None
mini_billboard_2_start_time: int = 0
mini_billboard_2_end_time: int = 0
mini_billboard_3_texture: ba.Texture | None = None
mini_billboard_3_start_time: int = 0
mini_billboard_3_end_time: int = 0
boxing_gloves_flashing: bool = False
dead: bool = False
floor_reflection: bool = False
debris_friction: float = 0.0
debris_kill_height: float = 0.0
vr_near_clip: float = 0.0
shadow_ortho: bool = False
happy_thoughts_mode: bool = False
shadow_offset: Sequence[float] = (0.0, 0.0)
paused: bool = False
time: int = 0
ambient_color: Sequence[float] = (1.0, 1.0, 1.0)
camera_mode: str = 'rotate'
frozen: bool = False
area_of_interest_bounds: Sequence[float] = (-1, -1, -1, 1, 1, 1)
shadow_range: Sequence[float] = (0, 0, 0, 0)
counter_text: str = ''
counter_texture: ba.Texture | None = None
shattered: int = 0
billboard_texture: ba.Texture | None = None
billboard_cross_out: bool = False
billboard_opacity: float = 0.0
slow_motion: bool = False
music: str = ''
vr_camera_offset: Sequence[float] = (0.0, 0.0, 0.0)
vr_overlay_center: Sequence[float] = (0.0, 0.0, 0.0)
vr_overlay_center_enabled: bool = False
vignette_outer: Sequence[float] = (0.0, 0.0)
vignette_inner: Sequence[float] = (0.0, 0.0)
tint: Sequence[float] = (1.0, 1.0, 1.0)
def add_death_action(self, action: Callable[[], NoneType]) -> None:
637    def add_death_action(self, action: Callable[[], None]) -> None:
638        """Add a callable object to be called upon this node's death.
639        Note that these actions are run just after the node dies, not before.
640        """
641        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:
643    def connectattr(self, srcattr: str, dstnode: Node, dstattr: str) -> None:
644        """Connect one of this node's attributes to an attribute on another
645        node. This will immediately set the target attribute's value to that
646        of the source attribute, and will continue to do so once per step
647        as long as the two nodes exist. The connection can be severed by
648        setting the target attribute to any value or connecting another
649        node attribute to it.
650
651        ##### Example
652        Create a locator and attach a light to it:
653        >>> light = ba.newnode('light')
654        ... loc = ba.newnode('locator', attrs={'position': (0, 10, 0)})
655        ... loc.connectattr('position', light, 'position')
656        """
657        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:
659    def delete(self, ignore_missing: bool = True) -> None:
660        """Delete the node. Ignores already-deleted nodes if `ignore_missing`
661        is True; otherwise a ba.NodeNotFoundError is thrown.
662        """
663        return None

Delete the node. Ignores already-deleted nodes if ignore_missing is True; otherwise a ba.NodeNotFoundError is thrown.

def exists(self) -> bool:
665    def exists(self) -> bool:
666        """Returns whether the Node still exists.
667        Most functionality will fail on a nonexistent Node, so it's never a bad
668        idea to check this.
669
670        Note that you can also use the boolean operator for this same
671        functionality, so a statement such as "if mynode" will do
672        the right thing both for Node objects and values of None.
673        """
674        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:
687    def getdelegate(self, type: Any, doraise: bool = False) -> Any:
688        """Return the node's current delegate object if it matches
689        a certain type.
690
691        If the node has no delegate or it is not an instance of the passed
692        type, then None will be returned. If 'doraise' is True, then an
693        ba.DelegateNotFoundError will be raised instead.
694        """
695        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:
697    def getname(self) -> str:
698        """Return the name assigned to a Node; used mainly for debugging"""
699        return str()

Return the name assigned to a Node; used mainly for debugging

def getnodetype(self) -> str:
701    def getnodetype(self) -> str:
702        """Return the type of Node referenced by this object as a string.
703        (Note this is different from the Python type which is always ba.Node)
704        """
705        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:
707    def handlemessage(self, *args: Any) -> None:
708        """General message handling; can be passed any message object.
709
710        All standard message objects are forwarded along to the ba.Node's
711        delegate for handling (generally the ba.Actor that made the node).
712
713        ba.Node-s are unique, however, in that they can be passed a second
714        form of message; 'node-messages'.  These consist of a string type-name
715        as a first argument along with the args specific to that type name
716        as additional arguments.
717        Node-messages communicate directly with the low-level node layer
718        and are delivered simultaneously on all game clients,
719        acting as an alternative to setting node attributes.
720        """
721        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):
87class NodeNotFoundError(NotFoundError):
88    """Exception raised when an expected ba.Node does not exist.
89
90    Category: **Exception Classes**
91    """

Exception raised when an expected ba.Node does not exist.

Category: Exception Classes

Inherited Members
builtins.Exception
Exception
builtins.BaseException
with_traceback
args
def normalized_color(color: Sequence[float]) -> tuple[float, ...]:
49def normalized_color(color: Sequence[float]) -> tuple[float, ...]:
50    """Scale a color so its largest value is 1; useful for coloring lights.
51
52    category: General Utility Functions
53    """
54    color_biased = tuple(max(c, 0.01) for c in color)  # account for black
55    mult = 1.0 / max(color_biased)
56    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
args
def open_url(address: str) -> None:
2468def open_url(address: str) -> None:
2469    """Open a provided URL.
2470
2471    Category: **General Utility Functions**
2472
2473    Open the provided url in a web-browser, or display the URL
2474    string in a window if that isn't possible.
2475    """
2476    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.

@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):
 97class Permission(Enum):
 98    """Permissions that can be requested from the OS.
 99
100    Category: Enums
101    """
102    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:
160@dataclass
161class PickedUpMessage:
162    """Tells an object that it has been picked up by something.
163
164    Category: **Message Classes**
165    """
166
167    node: ba.Node
168    """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:
141@dataclass
142class PickUpMessage:
143    """Tells an object that it has picked something up.
144
145    Category: **Message Classes**
146    """
147
148    node: ba.Node
149    """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]):
 46class Player(Generic[TeamType]):
 47    """A player in a specific ba.Activity.
 48
 49    Category: Gameplay Classes
 50
 51    These correspond to ba.SessionPlayer objects, but are associated with a
 52    single ba.Activity instance. This allows activities to specify their
 53    own custom ba.Player types.
 54    """
 55
 56    # These are instance attrs but we define them at the type level so
 57    # their type annotations are introspectable (for docs generation).
 58    character: str
 59
 60    actor: ba.Actor | None
 61    """The ba.Actor associated with the player."""
 62
 63    color: Sequence[float]
 64    highlight: Sequence[float]
 65
 66    _team: TeamType
 67    _sessionplayer: ba.SessionPlayer
 68    _nodeactor: ba.NodeActor | None
 69    _expired: bool
 70    _postinited: bool
 71    _customdata: dict
 72
 73    # NOTE: avoiding having any __init__() here since it seems to not
 74    # get called by default if a dataclass inherits from us.
 75    # This also lets us keep trivial player classes cleaner by skipping
 76    # the super().__init__() line.
 77
 78    def postinit(self, sessionplayer: ba.SessionPlayer) -> None:
 79        """Wire up a newly created player.
 80
 81        (internal)
 82        """
 83        from ba._nodeactor import NodeActor
 84
 85        # Sanity check; if a dataclass is created that inherits from us,
 86        # it will define an equality operator by default which will break
 87        # internal game logic. So complain loudly if we find one.
 88        if type(self).__eq__ is not object.__eq__:
 89            raise RuntimeError(
 90                f'Player class {type(self)} defines an equality'
 91                f' operator (__eq__) which will break internal'
 92                f' logic. Please remove it.\n'
 93                f'For dataclasses you can do "dataclass(eq=False)"'
 94                f' in the class decorator.')
 95
 96        self.actor = None
 97        self.character = ''
 98        self._nodeactor: ba.NodeActor | None = None
 99        self._sessionplayer = sessionplayer
100        self.character = sessionplayer.character
101        self.color = sessionplayer.color
102        self.highlight = sessionplayer.highlight
103        self._team = cast(TeamType, sessionplayer.sessionteam.activityteam)
104        assert self._team is not None
105        self._customdata = {}
106        self._expired = False
107        self._postinited = True
108        node = _ba.newnode('player', attrs={'playerID': sessionplayer.id})
109        self._nodeactor = NodeActor(node)
110        sessionplayer.setnode(node)
111
112    def leave(self) -> None:
113        """Called when the Player leaves a running game.
114
115        (internal)
116        """
117        assert self._postinited
118        assert not self._expired
119        try:
120            # If they still have an actor, kill it.
121            if self.actor:
122                self.actor.handlemessage(DieMessage(how=DeathType.LEFT_GAME))
123            self.actor = None
124        except Exception:
125            print_exception(f'Error killing actor on leave for {self}')
126        self._nodeactor = None
127        del self._team
128        del self._customdata
129
130    def expire(self) -> None:
131        """Called when the Player is expiring (when its Activity does so).
132
133        (internal)
134        """
135        assert self._postinited
136        assert not self._expired
137        self._expired = True
138
139        try:
140            self.on_expire()
141        except Exception:
142            print_exception(f'Error in on_expire for {self}.')
143
144        self._nodeactor = None
145        self.actor = None
146        del self._team
147        del self._customdata
148
149    def on_expire(self) -> None:
150        """Can be overridden to handle player expiration.
151
152        The player expires when the Activity it is a part of expires.
153        Expired players should no longer run any game logic (which will
154        likely error). They should, however, remove any references to
155        players/teams/games/etc. which could prevent them from being freed.
156        """
157
158    @property
159    def team(self) -> TeamType:
160        """The ba.Team for this player."""
161        assert self._postinited
162        assert not self._expired
163        return self._team
164
165    @property
166    def customdata(self) -> dict:
167        """Arbitrary values associated with the player.
168        Though it is encouraged that most player values be properly defined
169        on the ba.Player subclass, it may be useful for player-agnostic
170        objects to store values here. This dict is cleared when the player
171        leaves or expires so objects stored here will be disposed of at
172        the expected time, unlike the Player instance itself which may
173        continue to be referenced after it is no longer part of the game.
174        """
175        assert self._postinited
176        assert not self._expired
177        return self._customdata
178
179    @property
180    def sessionplayer(self) -> ba.SessionPlayer:
181        """Return the ba.SessionPlayer corresponding to this Player.
182
183        Throws a ba.SessionPlayerNotFoundError if it does not exist.
184        """
185        assert self._postinited
186        if bool(self._sessionplayer):
187            return self._sessionplayer
188        raise SessionPlayerNotFoundError()
189
190    @property
191    def node(self) -> ba.Node:
192        """A ba.Node of type 'player' associated with this Player.
193
194        This node can be used to get a generic player position/etc.
195        """
196        assert self._postinited
197        assert not self._expired
198        assert self._nodeactor
199        return self._nodeactor.node
200
201    @property
202    def position(self) -> ba.Vec3:
203        """The position of the player, as defined by its current ba.Actor.
204
205        If the player currently has no actor, raises a ba.ActorNotFoundError.
206        """
207        assert self._postinited
208        assert not self._expired
209        if self.actor is None:
210            raise ActorNotFoundError
211        return _ba.Vec3(self.node.position)
212
213    def exists(self) -> bool:
214        """Whether the underlying player still exists.
215
216        This will return False if the underlying ba.SessionPlayer has
217        left the game or if the ba.Activity this player was associated
218        with has ended.
219        Most functionality will fail on a nonexistent player.
220        Note that you can also use the boolean operator for this same
221        functionality, so a statement such as "if player" will do
222        the right thing both for Player objects and values of None.
223        """
224        assert self._postinited
225        return self._sessionplayer.exists() and not self._expired
226
227    def getname(self, full: bool = False, icon: bool = True) -> str:
228        """
229        Returns the player's name. If icon is True, the long version of the
230        name may include an icon.
231        """
232        assert self._postinited
233        assert not self._expired
234        return self._sessionplayer.getname(full=full, icon=icon)
235
236    def is_alive(self) -> bool:
237        """
238        Returns True if the player has a ba.Actor assigned and its
239        is_alive() method return True. False is returned otherwise.
240        """
241        assert self._postinited
242        assert not self._expired
243        return self.actor is not None and self.actor.is_alive()
244
245    def get_icon(self) -> dict[str, Any]:
246        """
247        Returns the character's icon (images, colors, etc contained in a dict)
248        """
249        assert self._postinited
250        assert not self._expired
251        return self._sessionplayer.get_icon()
252
253    def assigninput(self, inputtype: ba.InputType | tuple[ba.InputType, ...],
254                    call: Callable) -> None:
255        """
256        Set the python callable to be run for one or more types of input.
257        """
258        assert self._postinited
259        assert not self._expired
260        return self._sessionplayer.assigninput(type=inputtype, call=call)
261
262    def resetinput(self) -> None:
263        """
264        Clears out the player's assigned input actions.
265        """
266        assert self._postinited
267        assert not self._expired
268        self._sessionplayer.resetinput()
269
270    def __bool__(self) -> bool:
271        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:
149    def on_expire(self) -> None:
150        """Can be overridden to handle player expiration.
151
152        The player expires when the Activity it is a part of expires.
153        Expired players should no longer run any game logic (which will
154        likely error). They should, however, remove any references to
155        players/teams/games/etc. which could prevent them from being freed.
156        """

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:
213    def exists(self) -> bool:
214        """Whether the underlying player still exists.
215
216        This will return False if the underlying ba.SessionPlayer has
217        left the game or if the ba.Activity this player was associated
218        with has ended.
219        Most functionality will fail on a nonexistent player.
220        Note that you can also use the boolean operator for this same
221        functionality, so a statement such as "if player" will do
222        the right thing both for Player objects and values of None.
223        """
224        assert self._postinited
225        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:
227    def getname(self, full: bool = False, icon: bool = True) -> str:
228        """
229        Returns the player's name. If icon is True, the long version of the
230        name may include an icon.
231        """
232        assert self._postinited
233        assert not self._expired
234        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:
236    def is_alive(self) -> bool:
237        """
238        Returns True if the player has a ba.Actor assigned and its
239        is_alive() method return True. False is returned otherwise.
240        """
241        assert self._postinited
242        assert not self._expired
243        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]:
245    def get_icon(self) -> dict[str, Any]:
246        """
247        Returns the character's icon (images, colors, etc contained in a dict)
248        """
249        assert self._postinited
250        assert not self._expired
251        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:
253    def assigninput(self, inputtype: ba.InputType | tuple[ba.InputType, ...],
254                    call: Callable) -> None:
255        """
256        Set the python callable to be run for one or more types of input.
257        """
258        assert self._postinited
259        assert not self._expired
260        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:
262    def resetinput(self) -> None:
263        """
264        Clears out the player's assigned input actions.
265        """
266        assert self._postinited
267        assert not self._expired
268        self._sessionplayer.resetinput()

Clears out the player's assigned input actions.

class PlayerDiedMessage:
 74class PlayerDiedMessage:
 75    """A message saying a ba.Player has died.
 76
 77    Category: **Message Classes**
 78    """
 79
 80    killed: bool
 81    """If True, the player was killed;
 82       If False, they left the game or the round ended."""
 83
 84    how: ba.DeathType
 85    """The particular type of death."""
 86
 87    def __init__(self, player: ba.Player, was_killed: bool,
 88                 killerplayer: ba.Player | None, how: ba.DeathType):
 89        """Instantiate a message with the given values."""
 90
 91        # Invalid refs should never be passed as args.
 92        assert player.exists()
 93        self._player = player
 94
 95        # Invalid refs should never be passed as args.
 96        assert killerplayer is None or killerplayer.exists()
 97        self._killerplayer = killerplayer
 98        self.killed = was_killed
 99        self.how = how
100
101    def getkillerplayer(self,
102                        playertype: type[PlayerType]) -> PlayerType | None:
103        """Return the ba.Player responsible for the killing, if any.
104
105        Pass the Player type being used by the current game.
106        """
107        assert isinstance(self._killerplayer, (playertype, type(None)))
108        return self._killerplayer
109
110    def getplayer(self, playertype: type[PlayerType]) -> PlayerType:
111        """Return the ba.Player that died.
112
113        The type of player for the current activity should be passed so that
114        the type-checker properly identifies the returned value as one.
115        """
116        player: Any = self._player
117        assert isinstance(player, playertype)
118
119        # We should never be delivering invalid refs.
120        # (could theoretically happen if someone holds on to us)
121        assert player.exists()
122        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)
87    def __init__(self, player: ba.Player, was_killed: bool,
88                 killerplayer: ba.Player | None, how: ba.DeathType):
89        """Instantiate a message with the given values."""
90
91        # Invalid refs should never be passed as args.
92        assert player.exists()
93        self._player = player
94
95        # Invalid refs should never be passed as args.
96        assert killerplayer is None or killerplayer.exists()
97        self._killerplayer = killerplayer
98        self.killed = was_killed
99        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]:
101    def getkillerplayer(self,
102                        playertype: type[PlayerType]) -> PlayerType | None:
103        """Return the ba.Player responsible for the killing, if any.
104
105        Pass the Player type being used by the current game.
106        """
107        assert isinstance(self._killerplayer, (playertype, type(None)))
108        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:
110    def getplayer(self, playertype: type[PlayerType]) -> PlayerType:
111        """Return the ba.Player that died.
112
113        The type of player for the current activity should be passed so that
114        the type-checker properly identifies the returned value as one.
115        """
116        player: Any = self._player
117        assert isinstance(player, playertype)
118
119        # We should never be delivering invalid refs.
120        # (could theoretically happen if someone holds on to us)
121        assert player.exists()
122        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:
26@dataclass
27class PlayerInfo:
28    """Holds basic info about a player.
29
30    Category: Gameplay Classes
31    """
32    name: str
33    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
args
class PlayerRecord:
 32class PlayerRecord:
 33    """Stats for an individual player in a ba.Stats object.
 34
 35    Category: **Gameplay Classes**
 36
 37    This does not necessarily correspond to a ba.Player that is
 38    still present (stats may be retained for players that leave
 39    mid-game)
 40    """
 41    character: str
 42
 43    def __init__(self, name: str, name_full: str,
 44                 sessionplayer: ba.SessionPlayer, stats: ba.Stats):
 45        self.name = name
 46        self.name_full = name_full
 47        self.score = 0
 48        self.accumscore = 0
 49        self.kill_count = 0
 50        self.accum_kill_count = 0
 51        self.killed_count = 0
 52        self.accum_killed_count = 0
 53        self._multi_kill_timer: ba.Timer | None = None
 54        self._multi_kill_count = 0
 55        self._stats = weakref.ref(stats)
 56        self._last_sessionplayer: ba.SessionPlayer | None = None
 57        self._sessionplayer: ba.SessionPlayer | None = None
 58        self._sessionteam: weakref.ref[ba.SessionTeam] | None = None
 59        self.streak = 0
 60        self.associate_with_sessionplayer(sessionplayer)
 61
 62    @property
 63    def team(self) -> ba.SessionTeam:
 64        """The ba.SessionTeam the last associated player was last on.
 65
 66        This can still return a valid result even if the player is gone.
 67        Raises a ba.SessionTeamNotFoundError if the team no longer exists.
 68        """
 69        assert self._sessionteam is not None
 70        team = self._sessionteam()
 71        if team is None:
 72            raise SessionTeamNotFoundError()
 73        return team
 74
 75    @property
 76    def player(self) -> ba.SessionPlayer:
 77        """Return the instance's associated ba.SessionPlayer.
 78
 79        Raises a ba.SessionPlayerNotFoundError if the player
 80        no longer exists.
 81        """
 82        if not self._sessionplayer:
 83            raise SessionPlayerNotFoundError()
 84        return self._sessionplayer
 85
 86    def getname(self, full: bool = False) -> str:
 87        """Return the player entry's name."""
 88        return self.name_full if full else self.name
 89
 90    def get_icon(self) -> dict[str, Any]:
 91        """Get the icon for this instance's player."""
 92        player = self._last_sessionplayer
 93        assert player is not None
 94        return player.get_icon()
 95
 96    def cancel_multi_kill_timer(self) -> None:
 97        """Cancel any multi-kill timer for this player entry."""
 98        self._multi_kill_timer = None
 99
100    def getactivity(self) -> ba.Activity | None:
101        """Return the ba.Activity this instance is currently associated with.
102
103        Returns None if the activity no longer exists."""
104        stats = self._stats()
105        if stats is not None:
106            return stats.getactivity()
107        return None
108
109    def associate_with_sessionplayer(self,
110                                     sessionplayer: ba.SessionPlayer) -> None:
111        """Associate this entry with a ba.SessionPlayer."""
112        self._sessionteam = weakref.ref(sessionplayer.sessionteam)
113        self.character = sessionplayer.character
114        self._last_sessionplayer = sessionplayer
115        self._sessionplayer = sessionplayer
116        self.streak = 0
117
118    def _end_multi_kill(self) -> None:
119        self._multi_kill_timer = None
120        self._multi_kill_count = 0
121
122    def get_last_sessionplayer(self) -> ba.SessionPlayer:
123        """Return the last ba.Player we were associated with."""
124        assert self._last_sessionplayer is not None
125        return self._last_sessionplayer
126
127    def submit_kill(self, showpoints: bool = True) -> None:
128        """Submit a kill for this player entry."""
129        # FIXME Clean this up.
130        # pylint: disable=too-many-statements
131        from ba._language import Lstr
132        from ba._general import Call
133        self._multi_kill_count += 1
134        stats = self._stats()
135        assert stats
136        if self._multi_kill_count == 1:
137            score = 0
138            name = None
139            delay = 0.0
140            color = (0.0, 0.0, 0.0, 1.0)
141            scale = 1.0
142            sound = None
143        elif self._multi_kill_count == 2:
144            score = 20
145            name = Lstr(resource='twoKillText')
146            color = (0.1, 1.0, 0.0, 1)
147            scale = 1.0
148            delay = 0.0
149            sound = stats.orchestrahitsound1
150        elif self._multi_kill_count == 3:
151            score = 40
152            name = Lstr(resource='threeKillText')
153            color = (1.0, 0.7, 0.0, 1)
154            scale = 1.1
155            delay = 0.3
156            sound = stats.orchestrahitsound2
157        elif self._multi_kill_count == 4:
158            score = 60
159            name = Lstr(resource='fourKillText')
160            color = (1.0, 1.0, 0.0, 1)
161            scale = 1.2
162            delay = 0.6
163            sound = stats.orchestrahitsound3
164        elif self._multi_kill_count == 5:
165            score = 80
166            name = Lstr(resource='fiveKillText')
167            color = (1.0, 0.5, 0.0, 1)
168            scale = 1.3
169            delay = 0.9
170            sound = stats.orchestrahitsound4
171        else:
172            score = 100
173            name = Lstr(resource='multiKillText',
174                        subs=[('${COUNT}', str(self._multi_kill_count))])
175            color = (1.0, 0.5, 0.0, 1)
176            scale = 1.3
177            delay = 1.0
178            sound = stats.orchestrahitsound4
179
180        def _apply(name2: Lstr, score2: int, showpoints2: bool,
181                   color2: tuple[float, float, float, float], scale2: float,
182                   sound2: ba.Sound | None) -> None:
183            from bastd.actor.popuptext import PopupText
184
185            # Only award this if they're still alive and we can get
186            # a current position for them.
187            our_pos: ba.Vec3 | None = None
188            if self._sessionplayer:
189                if self._sessionplayer.activityplayer is not None:
190                    try:
191                        our_pos = self._sessionplayer.activityplayer.position
192                    except NotFoundError:
193                        pass
194            if our_pos is None:
195                return
196
197            # Jitter position a bit since these often come in clusters.
198            our_pos = _ba.Vec3(our_pos[0] + (random.random() - 0.5) * 2.0,
199                               our_pos[1] + (random.random() - 0.5) * 2.0,
200                               our_pos[2] + (random.random() - 0.5) * 2.0)
201            activity = self.getactivity()
202            if activity is not None:
203                PopupText(Lstr(
204                    value=(('+' + str(score2) + ' ') if showpoints2 else '') +
205                    '${N}',
206                    subs=[('${N}', name2)]),
207                          color=color2,
208                          scale=scale2,
209                          position=our_pos).autoretain()
210            if sound2:
211                _ba.playsound(sound2)
212
213            self.score += score2
214            self.accumscore += score2
215
216            # Inform a running game of the score.
217            if score2 != 0 and activity is not None:
218                activity.handlemessage(PlayerScoredMessage(score=score2))
219
220        if name is not None:
221            _ba.timer(
222                0.3 + delay,
223                Call(_apply, name, score, showpoints, color, scale, sound))
224
225        # Keep the tally rollin'...
226        # set a timer for a bit in the future.
227        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)
43    def __init__(self, name: str, name_full: str,
44                 sessionplayer: ba.SessionPlayer, stats: ba.Stats):
45        self.name = name
46        self.name_full = name_full
47        self.score = 0
48        self.accumscore = 0
49        self.kill_count = 0
50        self.accum_kill_count = 0
51        self.killed_count = 0
52        self.accum_killed_count = 0
53        self._multi_kill_timer: ba.Timer | None = None
54        self._multi_kill_count = 0
55        self._stats = weakref.ref(stats)
56        self._last_sessionplayer: ba.SessionPlayer | None = None
57        self._sessionplayer: ba.SessionPlayer | None = None
58        self._sessionteam: weakref.ref[ba.SessionTeam] | None = None
59        self.streak = 0
60        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:
86    def getname(self, full: bool = False) -> str:
87        """Return the player entry's name."""
88        return self.name_full if full else self.name

Return the player entry's name.

def get_icon(self) -> dict[str, typing.Any]:
90    def get_icon(self) -> dict[str, Any]:
91        """Get the icon for this instance's player."""
92        player = self._last_sessionplayer
93        assert player is not None
94        return player.get_icon()

Get the icon for this instance's player.

def cancel_multi_kill_timer(self) -> None:
96    def cancel_multi_kill_timer(self) -> None:
97        """Cancel any multi-kill timer for this player entry."""
98        self._multi_kill_timer = None

Cancel any multi-kill timer for this player entry.

def getactivity(self) -> ba.Activity | None:
100    def getactivity(self) -> ba.Activity | None:
101        """Return the ba.Activity this instance is currently associated with.
102
103        Returns None if the activity no longer exists."""
104        stats = self._stats()
105        if stats is not None:
106            return stats.getactivity()
107        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:
109    def associate_with_sessionplayer(self,
110                                     sessionplayer: ba.SessionPlayer) -> None:
111        """Associate this entry with a ba.SessionPlayer."""
112        self._sessionteam = weakref.ref(sessionplayer.sessionteam)
113        self.character = sessionplayer.character
114        self._last_sessionplayer = sessionplayer
115        self._sessionplayer = sessionplayer
116        self.streak = 0

Associate this entry with a ba.SessionPlayer.

def get_last_sessionplayer(self) -> ba.SessionPlayer:
122    def get_last_sessionplayer(self) -> ba.SessionPlayer:
123        """Return the last ba.Player we were associated with."""
124        assert self._last_sessionplayer is not None
125        return self._last_sessionplayer

Return the last ba.Player we were associated with.

def submit_kill(self, showpoints: bool = True) -> None:
127    def submit_kill(self, showpoints: bool = True) -> None:
128        """Submit a kill for this player entry."""
129        # FIXME Clean this up.
130        # pylint: disable=too-many-statements
131        from ba._language import Lstr
132        from ba._general import Call
133        self._multi_kill_count += 1
134        stats = self._stats()
135        assert stats
136        if self._multi_kill_count == 1:
137            score = 0
138            name = None
139            delay = 0.0
140            color = (0.0, 0.0, 0.0, 1.0)
141            scale = 1.0
142            sound = None
143        elif self._multi_kill_count == 2:
144            score = 20
145            name = Lstr(resource='twoKillText')
146            color = (0.1, 1.0, 0.0, 1)
147            scale = 1.0
148            delay = 0.0
149            sound = stats.orchestrahitsound1
150        elif self._multi_kill_count == 3:
151            score = 40
152            name = Lstr(resource='threeKillText')
153            color = (1.0, 0.7, 0.0, 1)
154            scale = 1.1
155            delay = 0.3
156            sound = stats.orchestrahitsound2
157        elif self._multi_kill_count == 4:
158            score = 60
159            name = Lstr(resource='fourKillText')
160            color = (1.0, 1.0, 0.0, 1)
161            scale = 1.2
162            delay = 0.6
163            sound = stats.orchestrahitsound3
164        elif self._multi_kill_count == 5:
165            score = 80
166            name = Lstr(resource='fiveKillText')
167            color = (1.0, 0.5, 0.0, 1)
168            scale = 1.3
169            delay = 0.9
170            sound = stats.orchestrahitsound4
171        else:
172            score = 100
173            name = Lstr(resource='multiKillText',
174                        subs=[('${COUNT}', str(self._multi_kill_count))])
175            color = (1.0, 0.5, 0.0, 1)
176            scale = 1.3
177            delay = 1.0
178            sound = stats.orchestrahitsound4
179
180        def _apply(name2: Lstr, score2: int, showpoints2: bool,
181                   color2: tuple[float, float, float, float], scale2: float,
182                   sound2: ba.Sound | None) -> None:
183            from bastd.actor.popuptext import PopupText
184
185            # Only award this if they're still alive and we can get
186            # a current position for them.
187            our_pos: ba.Vec3 | None = None
188            if self._sessionplayer:
189                if self._sessionplayer.activityplayer is not None:
190                    try:
191                        our_pos = self._sessionplayer.activityplayer.position
192                    except NotFoundError:
193                        pass
194            if our_pos is None:
195                return
196
197            # Jitter position a bit since these often come in clusters.
198            our_pos = _ba.Vec3(our_pos[0] + (random.random() - 0.5) * 2.0,
199                               our_pos[1] + (random.random() - 0.5) * 2.0,
200                               our_pos[2] + (random.random() - 0.5) * 2.0)
201            activity = self.getactivity()
202            if activity is not None:
203                PopupText(Lstr(
204                    value=(('+' + str(score2) + ' ') if showpoints2 else '') +
205                    '${N}',
206                    subs=[('${N}', name2)]),
207                          color=color2,
208                          scale=scale2,
209                          position=our_pos).autoretain()
210            if sound2:
211                _ba.playsound(sound2)
212
213            self.score += score2
214            self.accumscore += score2
215
216            # Inform a running game of the score.
217            if score2 != 0 and activity is not None:
218                activity.handlemessage(PlayerScoredMessage(score=score2))
219
220        if name is not None:
221            _ba.timer(
222                0.3 + delay,
223                Call(_apply, name, score, showpoints, color, scale, sound))
224
225        # Keep the tally rollin'...
226        # set a timer for a bit in the future.
227        self._multi_kill_timer = _ba.Timer(1.0, self._end_multi_kill)

Submit a kill for this player entry.

@dataclass
class PlayerScoredMessage:
21@dataclass
22class PlayerScoredMessage:
23    """Informs something that a ba.Player scored.
24
25    Category: **Message Classes**
26    """
27
28    score: int
29    """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:
2479def playsound(sound: Sound,
2480              volume: float = 1.0,
2481              position: Sequence[float] | None = None,
2482              host_only: bool = False) -> None:
2483    """Play a ba.Sound a single time.
2484
2485    Category: **Gameplay Functions**
2486
2487    If position is not provided, the sound will be at a constant volume
2488    everywhere. Position should be a float tuple of size 3.
2489    """
2490    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:
184class Plugin:
185    """A plugin to alter app behavior in some way.
186
187    Category: **App Classes**
188
189    Plugins are discoverable by the meta-tag system
190    and the user can select which ones they want to activate.
191    Active plugins are then called at specific times as the
192    app is running in order to modify its behavior in some way.
193    """
194
195    def on_app_running(self) -> None:
196        """Called when the app reaches the running state."""
197
198    def on_app_pause(self) -> None:
199        """Called after pausing game activity."""
200
201    def on_app_resume(self) -> None:
202        """Called after the game continues."""
203
204    def on_app_shutdown(self) -> None:
205        """Called before closing the application."""

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:
195    def on_app_running(self) -> None:
196        """Called when the app reaches the running state."""

Called when the app reaches the running state.

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

Called after pausing game activity.

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

Called after the game continues.

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

Called before closing the application.

class PluginSubsystem:
 17class PluginSubsystem:
 18    """Subsystem for plugin handling in the app.
 19
 20    Category: **App Classes**
 21
 22    Access the single shared instance of this class at `ba.app.plugins`.
 23    """
 24
 25    def __init__(self) -> None:
 26        self.potential_plugins: list[ba.PotentialPlugin] = []
 27        self.active_plugins: dict[str, ba.Plugin] = {}
 28
 29    def on_meta_scan_complete(self) -> None:
 30        """Should be called when meta-scanning is complete."""
 31        from ba._language import Lstr
 32
 33        plugs = _ba.app.plugins
 34        config_changed = False
 35        found_new = False
 36        plugstates: dict[str, dict] = _ba.app.config.setdefault('Plugins', {})
 37        assert isinstance(plugstates, dict)
 38
 39        results = _ba.app.meta.scanresults
 40        assert results is not None
 41
 42        # Create a potential-plugin for each class we found in the scan.
 43        for class_path in results.exports_of_class(Plugin):
 44            plugs.potential_plugins.append(
 45                PotentialPlugin(display_name=Lstr(value=class_path),
 46                                class_path=class_path,
 47                                available=True))
 48            if class_path not in plugstates:
 49                # Go ahead and enable new plugins by default, but we'll
 50                # inform the user that they need to restart to pick them up.
 51                # they can also disable them in settings so they never load.
 52                plugstates[class_path] = {'enabled': True}
 53                config_changed = True
 54                found_new = True
 55
 56        plugs.potential_plugins.sort(key=lambda p: p.class_path)
 57
 58        # Note: these days we complete meta-scan and immediately activate
 59        # plugins, so we don't need the message about 'restart to activate'
 60        # anymore.
 61        if found_new and bool(False):
 62            _ba.screenmessage(Lstr(resource='pluginsDetectedText'),
 63                              color=(0, 1, 0))
 64            _ba.playsound(_ba.getsound('ding'))
 65
 66        if config_changed:
 67            _ba.app.config.commit()
 68
 69    def on_app_running(self) -> None:
 70        """Should be called when the app reaches the running state."""
 71        # Load up our plugins and go ahead and call their on_app_running calls.
 72        self.load_plugins()
 73        for plugin in self.active_plugins.values():
 74            try:
 75                plugin.on_app_running()
 76            except Exception:
 77                from ba import _error
 78                _error.print_exception('Error in plugin on_app_running()')
 79
 80    def on_app_pause(self) -> None:
 81        """Called when the app goes to a suspended state."""
 82        for plugin in self.active_plugins.values():
 83            try:
 84                plugin.on_app_pause()
 85            except Exception:
 86                from ba import _error
 87                _error.print_exception('Error in plugin on_app_pause()')
 88
 89    def on_app_resume(self) -> None:
 90        """Run when the app resumes from a suspended state."""
 91        for plugin in self.active_plugins.values():
 92            try:
 93                plugin.on_app_resume()
 94            except Exception:
 95                from ba import _error
 96                _error.print_exception('Error in plugin on_app_resume()')
 97
 98    def on_app_shutdown(self) -> None:
 99        """Called when the app is being closed."""
100        for plugin in self.active_plugins.values():
101            try:
102                plugin.on_app_shutdown()
103            except Exception:
104                from ba import _error
105                _error.print_exception('Error in plugin on_app_shutdown()')
106
107    def load_plugins(self) -> None:
108        """(internal)"""
109        from ba._general import getclass
110        from ba._language import Lstr
111
112        # Note: the plugins we load is purely based on what's enabled
113        # in the app config. Its not our job to look at meta stuff here.
114        plugstates: dict[str, dict] = _ba.app.config.get('Plugins', {})
115        assert isinstance(plugstates, dict)
116        plugkeys: list[str] = sorted(key for key, val in plugstates.items()
117                                     if val.get('enabled', False))
118        disappeared_plugs: set[str] = set()
119        for plugkey in plugkeys:
120            try:
121                cls = getclass(plugkey, Plugin)
122            except ModuleNotFoundError:
123                disappeared_plugs.add(plugkey)
124                continue
125            except Exception as exc:
126                _ba.playsound(_ba.getsound('error'))
127                _ba.screenmessage(Lstr(resource='pluginClassLoadErrorText',
128                                       subs=[('${PLUGIN}', plugkey),
129                                             ('${ERROR}', str(exc))]),
130                                  color=(1, 0, 0))
131                _ba.log(f"Error loading plugin class '{plugkey}': {exc}",
132                        to_server=False)
133                continue
134            try:
135                plugin = cls()
136                assert plugkey not in self.active_plugins
137                self.active_plugins[plugkey] = plugin
138            except Exception as exc:
139                from ba import _error
140                _ba.playsound(_ba.getsound('error'))
141                _ba.screenmessage(Lstr(resource='pluginInitErrorText',
142                                       subs=[('${PLUGIN}', plugkey),
143                                             ('${ERROR}', str(exc))]),
144                                  color=(1, 0, 0))
145                _error.print_exception(f"Error initing plugin: '{plugkey}'.")
146
147        # If plugins disappeared, let the user know gently and remove them
148        # from the config so we'll again let the user know if they later
149        # reappear. This makes it much smoother to switch between users
150        # or workspaces.
151        if disappeared_plugs:
152            _ba.playsound(_ba.getsound('shieldDown'))
153            _ba.screenmessage(
154                Lstr(resource='pluginsRemovedText',
155                     subs=[('${NUM}', str(len(disappeared_plugs)))]),
156                color=(1, 1, 0),
157            )
158            plugnames = ', '.join(disappeared_plugs)
159            _ba.log(
160                f'{len(disappeared_plugs)} plugin(s) no longer found:'
161                f' {plugnames}.',
162                to_server=False)
163            for goneplug in disappeared_plugs:
164                del _ba.app.config['Plugins'][goneplug]
165            _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()
25    def __init__(self) -> None:
26        self.potential_plugins: list[ba.PotentialPlugin] = []
27        self.active_plugins: dict[str, ba.Plugin] = {}
def on_meta_scan_complete(self) -> None:
29    def on_meta_scan_complete(self) -> None:
30        """Should be called when meta-scanning is complete."""
31        from ba._language import Lstr
32
33        plugs = _ba.app.plugins
34        config_changed = False
35        found_new = False
36        plugstates: dict[str, dict] = _ba.app.config.setdefault('Plugins', {})
37        assert isinstance(plugstates, dict)
38
39        results = _ba.app.meta.scanresults
40        assert results is not None
41
42        # Create a potential-plugin for each class we found in the scan.
43        for class_path in results.exports_of_class(Plugin):
44            plugs.potential_plugins.append(
45                PotentialPlugin(display_name=Lstr(value=class_path),
46                                class_path=class_path,
47                                available=True))
48            if class_path not in plugstates:
49                # Go ahead and enable new plugins by default, but we'll
50                # inform the user that they need to restart to pick them up.
51                # they can also disable them in settings so they never load.
52                plugstates[class_path] = {'enabled': True}
53                config_changed = True
54                found_new = True
55
56        plugs.potential_plugins.sort(key=lambda p: p.class_path)
57
58        # Note: these days we complete meta-scan and immediately activate
59        # plugins, so we don't need the message about 'restart to activate'
60        # anymore.
61        if found_new and bool(False):
62            _ba.screenmessage(Lstr(resource='pluginsDetectedText'),
63                              color=(0, 1, 0))
64            _ba.playsound(_ba.getsound('ding'))
65
66        if config_changed:
67            _ba.app.config.commit()

Should be called when meta-scanning is complete.

def on_app_running(self) -> None:
69    def on_app_running(self) -> None:
70        """Should be called when the app reaches the running state."""
71        # Load up our plugins and go ahead and call their on_app_running calls.
72        self.load_plugins()
73        for plugin in self.active_plugins.values():
74            try:
75                plugin.on_app_running()
76            except Exception:
77                from ba import _error
78                _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:
80    def on_app_pause(self) -> None:
81        """Called when the app goes to a suspended state."""
82        for plugin in self.active_plugins.values():
83            try:
84                plugin.on_app_pause()
85            except Exception:
86                from ba import _error
87                _error.print_exception('Error in plugin on_app_pause()')

Called when the app goes to a suspended state.

def on_app_resume(self) -> None:
89    def on_app_resume(self) -> None:
90        """Run when the app resumes from a suspended state."""
91        for plugin in self.active_plugins.values():
92            try:
93                plugin.on_app_resume()
94            except Exception:
95                from ba import _error
96                _error.print_exception('Error in plugin on_app_resume()')

Run when the app resumes from a suspended state.

def on_app_shutdown(self) -> None:
 98    def on_app_shutdown(self) -> None:
 99        """Called when the app is being closed."""
100        for plugin in self.active_plugins.values():
101            try:
102                plugin.on_app_shutdown()
103            except Exception:
104                from ba import _error
105                _error.print_exception('Error in plugin on_app_shutdown()')

Called when the app is being closed.

@dataclass
class PotentialPlugin:
168@dataclass
169class PotentialPlugin:
170    """Represents a ba.Plugin which can potentially be loaded.
171
172    Category: **App Classes**
173
174    These generally represent plugins which were detected by the
175    meta-tag scan. However they may also represent plugins which
176    were previously set to be loaded but which were unable to be
177    for some reason. In that case, 'available' will be set to False.
178    """
179    display_name: ba.Lstr
180    class_path: str
181    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:
2524def printnodes() -> None:
2525    """Print various info about existing nodes; useful for debugging.
2526
2527    Category: **Gameplay Functions**
2528    """
2529    return None

Print various info about existing nodes; useful for debugging.

Category: Gameplay Functions

def printobjects() -> None:
2532def printobjects() -> None:
2533    """Print debugging info about game objects.
2534
2535    Category: **General Utility Functions**
2536
2537    This call only functions in debug builds of the game.
2538    It prints various info about the current object count, etc.
2539    """
2540    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) -> None:
2548def pushcall(call: Callable,
2549             from_other_thread: bool = False,
2550             suppress_other_thread_warning: bool = False) -> None:
2551    """Pushes a call onto the event loop to be run during the next cycle.
2552
2553    Category: **General Utility Functions**
2554
2555    This can be handy for calls that are disallowed from within other
2556    callbacks, etc.
2557
2558    This call expects to be used in the game thread, and will automatically
2559    save and restore the ba.Context to behave seamlessly.
2560
2561    If you want to push a call from outside of the game thread,
2562    however, you can pass 'from_other_thread' as True. In this case
2563    the call will always run in the UI context on the game thread.
2564    """
2565    return None

Pushes a call onto the event loop to be run during the next cycle.

Category: General Utility Functions

This can be handy for calls that are disallowed from within other callbacks, etc.

This call expects to be used in the game thread, and will automatically save and restore the ba.Context to behave seamlessly.

If you want to push a call from outside of the game thread, however, you can pass 'from_other_thread' as True. In this case the call will always run in the UI context on the game thread.

def quit(soft: bool = False, back: bool = False) -> None:
2568def quit(soft: bool = False, back: bool = False) -> None:
2569    """Quit the game.
2570
2571    Category: **General Utility Functions**
2572
2573    On systems like android, 'soft' will end the activity but keep the
2574    app running.
2575    """
2576    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:
2649def rowwidget(edit: ba.Widget | None = None,
2650              parent: ba.Widget | None = None,
2651              size: Sequence[float] | None = None,
2652              position: Sequence[float] | None = None,
2653              background: bool | None = None,
2654              selected_child: ba.Widget | None = None,
2655              visible_child: ba.Widget | None = None,
2656              claims_left_right: bool | None = None,
2657              claims_tab: bool | None = None,
2658              selection_loops_to_parent: bool | None = None) -> ba.Widget:
2659    """Create or edit a row widget.
2660
2661    Category: **User Interface Functions**
2662
2663    Pass a valid existing ba.Widget as 'edit' to modify it; otherwise
2664    a new one is created and returned. Arguments that are not set to None
2665    are applied to the Widget.
2666    """
2667    import ba  # pylint: disable=cyclic-import
2668    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, ...]:
2676def safecolor(color: Sequence[float],
2677              target_intensity: float = 0.6) -> tuple[float, ...]:
2678    """Given a color tuple, return a color safe to display as text.
2679
2680    Category: **General Utility Functions**
2681
2682    Accepts tuples of length 3 or 4. This will slightly brighten very
2683    dark colors, etc.
2684    """
2685    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:
27@dataclass
28class ScoreConfig:
29    """Settings for how a game handles scores.
30
31    Category: **Gameplay Classes**
32    """
33
34    label: str = 'Score'
35    """A label show to the user for scores; 'Score', 'Time Survived', etc."""
36
37    scoretype: ba.ScoreType = ScoreType.POINTS
38    """How the score value should be displayed."""
39
40    lower_is_better: bool = False
41    """Whether lower scores are preferable. Higher scores are by default."""
42
43    none_is_winner: bool = False
44    """Whether a value of None is considered better than other scores.
45       By default it is not."""
46
47    version: str = ''
48    """To change high-score lists used by a game without renaming the game,
49       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    SECONDS = 's'
23    MILLISECONDS = 'ms'
24    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:
2688def screenmessage(message: str | ba.Lstr,
2689                  color: Sequence[float] | None = None,
2690                  top: bool = False,
2691                  image: dict[str, Any] | None = None,
2692                  log: bool = False,
2693                  clients: Sequence[int] | None = None,
2694                  transient: bool = False) -> None:
2695    """Print a message to the local client's screen, in a given color.
2696
2697    Category: **General Utility Functions**
2698
2699    If 'top' is True, the message will go to the top message area.
2700    For 'top' messages, 'image' can be a texture to display alongside the
2701    message.
2702    If 'log' is True, the message will also be printed to the output log
2703    'clients' can be a list of client-ids the message should be sent to,
2704    or None to specify that everyone should receive it.
2705    If 'transient' is True, the message will not be included in the
2706    game-stream and thus will not show up when viewing replays.
2707    Currently the 'clients' option only works for transient messages.
2708    """
2709    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' can be a texture to display alongside the message. If 'log' is True, the message will also be printed to the output 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:
2712def scrollwidget(edit: ba.Widget | None = None,
2713                 parent: ba.Widget | None = None,
2714                 size: Sequence[float] | None = None,
2715                 position: Sequence[float] | None = None,
2716                 background: bool | None = None,
2717                 selected_child: ba.Widget | None = None,
2718                 capture_arrows: bool = False,
2719                 on_select_call: Callable | None = None,
2720                 center_small_content: bool | None = None,
2721                 color: Sequence[float] | None = None,
2722                 highlight: bool | None = None,
2723                 border_opacity: float | None = None,
2724                 simple_culling_v: float | None = None,
2725                 selection_loops_to_parent: bool | None = None,
2726                 claims_left_right: bool | None = None,
2727                 claims_up_down: bool | None = None,
2728                 claims_tab: bool | None = None,
2729                 autoselect: bool | None = None) -> ba.Widget:
2730    """Create or edit a scroll widget.
2731
2732    Category: **User Interface Functions**
2733
2734    Pass a valid existing ba.Widget as 'edit' to modify it; otherwise
2735    a new one is created and returned. Arguments that are not set to None
2736    are applied to the Widget.
2737    """
2738    import ba  # pylint: disable=cyclic-import
2739    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:
 78class ServerController:
 79    """Overall controller for the app in server mode.
 80
 81    Category: **App Classes**
 82    """
 83
 84    def __init__(self, config: ServerConfig) -> None:
 85
 86        self._config = config
 87        self._playlist_name = '__default__'
 88        self._ran_access_check = False
 89        self._prep_timer: ba.Timer | None = None
 90        self._next_stuck_login_warn_time = time.time() + 10.0
 91        self._first_run = True
 92        self._shutdown_reason: ShutdownReason | None = None
 93        self._executing_shutdown = False
 94
 95        # Make note if they want us to import a playlist;
 96        # we'll need to do that first if so.
 97        self._playlist_fetch_running = self._config.playlist_code is not None
 98        self._playlist_fetch_sent_request = False
 99        self._playlist_fetch_got_response = False
100        self._playlist_fetch_code = -1
101
102        # Now sit around doing any pre-launch prep such as waiting for
103        # account sign-in or fetching playlists; this will kick off the
104        # session once done.
105        with _ba.Context('ui'):
106            self._prep_timer = _ba.Timer(0.25,
107                                         self._prepare_to_serve,
108                                         timetype=TimeType.REAL,
109                                         repeat=True)
110
111    def print_client_list(self) -> None:
112        """Print info about all connected clients."""
113        import json
114        roster = _ba.get_game_roster()
115        title1 = 'Client ID'
116        title2 = 'Account Name'
117        title3 = 'Players'
118        col1 = 10
119        col2 = 16
120        out = (f'{Clr.BLD}'
121               f'{title1:<{col1}} {title2:<{col2}} {title3}'
122               f'{Clr.RST}')
123        for client in roster:
124            if client['client_id'] == -1:
125                continue
126            spec = json.loads(client['spec_string'])
127            name = spec['n']
128            players = ', '.join(n['name'] for n in client['players'])
129            clientid = client['client_id']
130            out += f'\n{clientid:<{col1}} {name:<{col2}} {players}'
131        print(out)
132
133    def kick(self, client_id: int, ban_time: int | None) -> None:
134        """Kick the provided client id.
135
136        ban_time is provided in seconds.
137        If ban_time is None, ban duration will be determined automatically.
138        Pass 0 or a negative number for no ban time.
139        """
140
141        # FIXME: this case should be handled under the hood.
142        if ban_time is None:
143            ban_time = 300
144
145        _ba.disconnect_client(client_id=client_id, ban_time=ban_time)
146
147    def shutdown(self, reason: ShutdownReason, immediate: bool) -> None:
148        """Set the app to quit either now or at the next clean opportunity."""
149        self._shutdown_reason = reason
150        if immediate:
151            print(f'{Clr.SBLU}Immediate shutdown initiated.{Clr.RST}')
152            self._execute_shutdown()
153        else:
154            print(f'{Clr.SBLU}Shutdown initiated;'
155                  f' server process will exit at the next clean opportunity.'
156                  f'{Clr.RST}')
157
158    def handle_transition(self) -> bool:
159        """Handle transitioning to a new ba.Session or quitting the app.
160
161        Will be called once at the end of an activity that is marked as
162        a good 'end-point' (such as a final score screen).
163        Should return True if action will be handled by us; False if the
164        session should just continue on it's merry way.
165        """
166        if self._shutdown_reason is not None:
167            self._execute_shutdown()
168            return True
169        return False
170
171    def _execute_shutdown(self) -> None:
172        from ba._language import Lstr
173        if self._executing_shutdown:
174            return
175        self._executing_shutdown = True
176        timestrval = time.strftime('%c')
177        if self._shutdown_reason is ShutdownReason.RESTARTING:
178            _ba.screenmessage(Lstr(resource='internal.serverRestartingText'),
179                              color=(1, 0.5, 0.0))
180            print(f'{Clr.SBLU}Exiting for server-restart'
181                  f' at {timestrval}.{Clr.RST}')
182        else:
183            _ba.screenmessage(Lstr(resource='internal.serverShuttingDownText'),
184                              color=(1, 0.5, 0.0))
185            print(f'{Clr.SBLU}Exiting for server-shutdown'
186                  f' at {timestrval}.{Clr.RST}')
187        with _ba.Context('ui'):
188            _ba.timer(2.0, _ba.quit, timetype=TimeType.REAL)
189
190    def _run_access_check(self) -> None:
191        """Check with the master server to see if we're likely joinable."""
192        from ba._net import master_server_get
193        master_server_get(
194            'bsAccessCheck',
195            {
196                'port': _ba.get_game_port(),
197                'b': _ba.app.build_number
198            },
199            callback=self._access_check_response,
200        )
201
202    def _access_check_response(self, data: dict[str, Any] | None) -> None:
203        import os
204        if data is None:
205            print('error on UDP port access check (internet down?)')
206        else:
207            addr = data['address']
208            port = data['port']
209            show_addr = os.environ.get('BA_ACCESS_CHECK_VERBOSE', '0') == '1'
210            if show_addr:
211                addrstr = f' {addr}'
212                poststr = ''
213            else:
214                addrstr = ''
215                poststr = (
216                    '\nSet environment variable BA_ACCESS_CHECK_VERBOSE=1'
217                    ' for more info.')
218            if data['accessible']:
219                print(f'{Clr.SBLU}Master server access check of{addrstr}'
220                      f' udp port {port} succeeded.\n'
221                      f'Your server appears to be'
222                      f' joinable from the internet.{poststr}{Clr.RST}')
223            else:
224                print(f'{Clr.SRED}Master server access check of{addrstr}'
225                      f' udp port {port} failed.\n'
226                      f'Your server does not appear to be'
227                      f' joinable from the internet.{poststr}{Clr.RST}')
228
229    def _prepare_to_serve(self) -> None:
230        """Run in a timer to do prep before beginning to serve."""
231        signed_in = _ba.get_v1_account_state() == 'signed_in'
232        if not signed_in:
233
234            # Signing in to the local server account should not take long;
235            # complain if it does...
236            curtime = time.time()
237            if curtime > self._next_stuck_login_warn_time:
238                print('Still waiting for account sign-in...')
239                self._next_stuck_login_warn_time = curtime + 10.0
240            return
241
242        can_launch = False
243
244        # If we're fetching a playlist, we need to do that first.
245        if not self._playlist_fetch_running:
246            can_launch = True
247        else:
248            if not self._playlist_fetch_sent_request:
249                print(f'{Clr.SBLU}Requesting shared-playlist'
250                      f' {self._config.playlist_code}...{Clr.RST}')
251                _ba.add_transaction(
252                    {
253                        'type': 'IMPORT_PLAYLIST',
254                        'code': str(self._config.playlist_code),
255                        'overwrite': True
256                    },
257                    callback=self._on_playlist_fetch_response)
258                _ba.run_transactions()
259                self._playlist_fetch_sent_request = True
260
261            if self._playlist_fetch_got_response:
262                self._playlist_fetch_running = False
263                can_launch = True
264
265        if can_launch:
266            self._prep_timer = None
267            _ba.pushcall(self._launch_server_session)
268
269    def _on_playlist_fetch_response(
270        self,
271        result: dict[str, Any] | None,
272    ) -> None:
273        if result is None:
274            print('Error fetching playlist; aborting.')
275            sys.exit(-1)
276
277        # Once we get here, simply modify our config to use this playlist.
278        typename = (
279            'teams' if result['playlistType'] == 'Team Tournament' else
280            'ffa' if result['playlistType'] == 'Free-for-All' else '??')
281        plistname = result['playlistName']
282        print(f'{Clr.SBLU}Got playlist: "{plistname}" ({typename}).{Clr.RST}')
283        self._playlist_fetch_got_response = True
284        self._config.session_type = typename
285        self._playlist_name = (result['playlistName'])
286
287    def _get_session_type(self) -> type[ba.Session]:
288        # Convert string session type to the class.
289        # Hmm should we just keep this as a string?
290        if self._config.session_type == 'ffa':
291            return FreeForAllSession
292        if self._config.session_type == 'teams':
293            return DualTeamSession
294        if self._config.session_type == 'coop':
295            return CoopSession
296        raise RuntimeError(
297            f'Invalid session_type: "{self._config.session_type}"')
298
299    def _launch_server_session(self) -> None:
300        """Kick off a host-session based on the current server config."""
301        # pylint: disable=too-many-branches
302        app = _ba.app
303        appcfg = app.config
304        sessiontype = self._get_session_type()
305
306        if _ba.get_v1_account_state() != 'signed_in':
307            print('WARNING: launch_server_session() expects to run '
308                  'with a signed in server account')
309
310        # If we didn't fetch a playlist but there's an inline one in the
311        # server-config, pull it in to the game config and use it.
312        if (self._config.playlist_code is None
313                and self._config.playlist_inline is not None):
314            self._playlist_name = 'ServerModePlaylist'
315            if sessiontype is FreeForAllSession:
316                ptypename = 'Free-for-All'
317            elif sessiontype is DualTeamSession:
318                ptypename = 'Team Tournament'
319            elif sessiontype is CoopSession:
320                ptypename = 'Coop'
321            else:
322                raise RuntimeError(f'Unknown session type {sessiontype}')
323
324            # Need to add this in a transaction instead of just setting
325            # it directly or it will get overwritten by the master-server.
326            _ba.add_transaction({
327                'type': 'ADD_PLAYLIST',
328                'playlistType': ptypename,
329                'playlistName': self._playlist_name,
330                'playlist': self._config.playlist_inline
331            })
332            _ba.run_transactions()
333
334        if self._first_run:
335            curtimestr = time.strftime('%c')
336            _ba.log(
337                f'{Clr.BLD}{Clr.BLU}{_ba.appnameupper()} {app.version}'
338                f' ({app.build_number})'
339                f' entering server-mode {curtimestr}{Clr.RST}',
340                to_server=False)
341
342        if sessiontype is FreeForAllSession:
343            appcfg['Free-for-All Playlist Selection'] = self._playlist_name
344            appcfg['Free-for-All Playlist Randomize'] = (
345                self._config.playlist_shuffle)
346        elif sessiontype is DualTeamSession:
347            appcfg['Team Tournament Playlist Selection'] = self._playlist_name
348            appcfg['Team Tournament Playlist Randomize'] = (
349                self._config.playlist_shuffle)
350        elif sessiontype is CoopSession:
351            app.coop_session_args = {
352                'campaign': self._config.coop_campaign,
353                'level': self._config.coop_level,
354            }
355        else:
356            raise RuntimeError(f'Unknown session type {sessiontype}')
357
358        app.teams_series_length = self._config.teams_series_length
359        app.ffa_series_length = self._config.ffa_series_length
360
361        _ba.set_authenticate_clients(self._config.authenticate_clients)
362
363        _ba.set_enable_default_kick_voting(
364            self._config.enable_default_kick_voting)
365        _ba.set_admins(self._config.admins)
366
367        # Call set-enabled last (will push state to the cloud).
368        _ba.set_public_party_max_size(self._config.max_party_size)
369        _ba.set_public_party_name(self._config.party_name)
370        _ba.set_public_party_stats_url(self._config.stats_url)
371        _ba.set_public_party_enabled(self._config.party_is_public)
372
373        # And here.. we.. go.
374        if self._config.stress_test_players is not None:
375            # Special case: run a stress test.
376            from ba.internal import run_stress_test
377            run_stress_test(playlist_type='Random',
378                            playlist_name='__default__',
379                            player_count=self._config.stress_test_players,
380                            round_duration=30)
381        else:
382            _ba.new_host_session(sessiontype)
383
384        # Run an access check if we're trying to make a public party.
385        if not self._ran_access_check and self._config.party_is_public:
386            self._run_access_check()
387            self._ran_access_check = True

Overall controller for the app in server mode.

Category: App Classes

ServerController(config: bacommon.servermanager.ServerConfig)
 84    def __init__(self, config: ServerConfig) -> None:
 85
 86        self._config = config
 87        self._playlist_name = '__default__'
 88        self._ran_access_check = False
 89        self._prep_timer: ba.Timer | None = None
 90        self._next_stuck_login_warn_time = time.time() + 10.0
 91        self._first_run = True
 92        self._shutdown_reason: ShutdownReason | None = None
 93        self._executing_shutdown = False
 94
 95        # Make note if they want us to import a playlist;
 96        # we'll need to do that first if so.
 97        self._playlist_fetch_running = self._config.playlist_code is not None
 98        self._playlist_fetch_sent_request = False
 99        self._playlist_fetch_got_response = False
100        self._playlist_fetch_code = -1
101
102        # Now sit around doing any pre-launch prep such as waiting for
103        # account sign-in or fetching playlists; this will kick off the
104        # session once done.
105        with _ba.Context('ui'):
106            self._prep_timer = _ba.Timer(0.25,
107                                         self._prepare_to_serve,
108                                         timetype=TimeType.REAL,
109                                         repeat=True)
def print_client_list(self) -> None:
111    def print_client_list(self) -> None:
112        """Print info about all connected clients."""
113        import json
114        roster = _ba.get_game_roster()
115        title1 = 'Client ID'
116        title2 = 'Account Name'
117        title3 = 'Players'
118        col1 = 10
119        col2 = 16
120        out = (f'{Clr.BLD}'
121               f'{title1:<{col1}} {title2:<{col2}} {title3}'
122               f'{Clr.RST}')
123        for client in roster:
124            if client['client_id'] == -1:
125                continue
126            spec = json.loads(client['spec_string'])
127            name = spec['n']
128            players = ', '.join(n['name'] for n in client['players'])
129            clientid = client['client_id']
130            out += f'\n{clientid:<{col1}} {name:<{col2}} {players}'
131        print(out)

Print info about all connected clients.

def kick(self, client_id: int, ban_time: int | None) -> None:
133    def kick(self, client_id: int, ban_time: int | None) -> None:
134        """Kick the provided client id.
135
136        ban_time is provided in seconds.
137        If ban_time is None, ban duration will be determined automatically.
138        Pass 0 or a negative number for no ban time.
139        """
140
141        # FIXME: this case should be handled under the hood.
142        if ban_time is None:
143            ban_time = 300
144
145        _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:
147    def shutdown(self, reason: ShutdownReason, immediate: bool) -> None:
148        """Set the app to quit either now or at the next clean opportunity."""
149        self._shutdown_reason = reason
150        if immediate:
151            print(f'{Clr.SBLU}Immediate shutdown initiated.{Clr.RST}')
152            self._execute_shutdown()
153        else:
154            print(f'{Clr.SBLU}Shutdown initiated;'
155                  f' server process will exit at the next clean opportunity.'
156                  f'{Clr.RST}')

Set the app to quit either now or at the next clean opportunity.

def handle_transition(self) -> bool:
158    def handle_transition(self) -> bool:
159        """Handle transitioning to a new ba.Session or quitting the app.
160
161        Will be called once at the end of an activity that is marked as
162        a good 'end-point' (such as a final score screen).
163        Should return True if action will be handled by us; False if the
164        session should just continue on it's merry way.
165        """
166        if self._shutdown_reason is not None:
167            self._execute_shutdown()
168            return True
169        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__(self,
 75                 depsets: Sequence[ba.DependencySet],
 76                 team_names: Sequence[str] | None = None,
 77                 team_colors: Sequence[Sequence[float]] | None = None,
 78                 min_players: int = 1,
 79                 max_players: int = 8):
 80        """Instantiate a session.
 81
 82        depsets should be a sequence of successfully resolved ba.DependencySet
 83        instances; one for each ba.Activity the session may potentially run.
 84        """
 85        # pylint: disable=too-many-statements
 86        # pylint: disable=too-many-locals
 87        # pylint: disable=cyclic-import
 88        # pylint: disable=too-many-branches
 89        from ba._lobby import Lobby
 90        from ba._stats import Stats
 91        from ba._gameactivity import GameActivity
 92        from ba._activity import Activity
 93        from ba._team import SessionTeam
 94        from ba._error import DependencyError
 95        from ba._dependency import Dependency, AssetPackage
 96        from efro.util import empty_weakref
 97
 98        # First off, resolve all dependency-sets we were passed.
 99        # If things are missing, we'll try to gather them into a single
100        # missing-deps exception if possible to give the caller a clean
101        # path to download missing stuff and try again.
102        missing_asset_packages: set[str] = set()
103        for depset in depsets:
104            try:
105                depset.resolve()
106            except DependencyError as exc:
107                # Gather/report missing assets only; barf on anything else.
108                if all(issubclass(d.cls, AssetPackage) for d in exc.deps):
109                    for dep in exc.deps:
110                        assert isinstance(dep.config, str)
111                        missing_asset_packages.add(dep.config)
112                else:
113                    missing_info = [(d.cls, d.config) for d in exc.deps]
114                    raise RuntimeError(
115                        f'Missing non-asset dependencies: {missing_info}'
116                    ) from exc
117
118        # Throw a combined exception if we found anything missing.
119        if missing_asset_packages:
120            raise DependencyError([
121                Dependency(AssetPackage, set_id)
122                for set_id in missing_asset_packages
123            ])
124
125        # Ok; looks like our dependencies check out.
126        # Now give the engine a list of asset-set-ids to pass along to clients.
127        required_asset_packages: set[str] = set()
128        for depset in depsets:
129            required_asset_packages.update(depset.get_asset_package_ids())
130
131        # print('Would set host-session asset-reqs to:',
132        # required_asset_packages)
133
134        # Init our C++ layer data.
135        self._sessiondata = _ba.register_session(self)
136
137        # Should remove this if possible.
138        self.tournament_id: str | None = None
139
140        self.sessionteams = []
141        self.sessionplayers = []
142        self.min_players = min_players
143        self.max_players = max_players
144
145        self.customdata = {}
146        self._in_set_activity = False
147        self._next_team_id = 0
148        self._activity_retained: ba.Activity | None = None
149        self._launch_end_session_activity_time: float | None = None
150        self._activity_end_timer: ba.Timer | None = None
151        self._activity_weak = empty_weakref(Activity)
152        self._next_activity: ba.Activity | None = None
153        self._wants_to_end = False
154        self._ending = False
155        self._activity_should_end_immediately = False
156        self._activity_should_end_immediately_results: (ba.GameResults
157                                                        | None) = None
158        self._activity_should_end_immediately_delay = 0.0
159
160        # Create static teams if we're using them.
161        if self.use_teams:
162            if team_names is None:
163                raise RuntimeError(
164                    'use_teams is True but team_names not provided.')
165            if team_colors is None:
166                raise RuntimeError(
167                    'use_teams is True but team_colors not provided.')
168            if len(team_colors) != len(team_names):
169                raise RuntimeError(f'Got {len(team_names)} team_names'
170                                   f' and {len(team_colors)} team_colors;'
171                                   f' these numbers must match.')
172            for i, color in enumerate(team_colors):
173                team = SessionTeam(team_id=self._next_team_id,
174                                   name=GameActivity.get_team_display_string(
175                                       team_names[i]),
176                                   color=color)
177                self.sessionteams.append(team)
178                self._next_team_id += 1
179                try:
180                    with _ba.Context(self):
181                        self.on_team_join(team)
182                except Exception:
183                    print_exception(f'Error in on_team_join for {self}.')
184
185        self.lobby = Lobby()
186        self.stats = Stats()
187
188        # Instantiate our session globals node which will apply its settings.
189        self._sessionglobalsnode = _ba.newnode('sessionglobals')
190
191    @property
192    def sessionglobalsnode(self) -> ba.Node:
193        """The sessionglobals ba.Node for the session."""
194        node = self._sessionglobalsnode
195        if not node:
196            raise NodeNotFoundError()
197        return node
198
199    def should_allow_mid_activity_joins(self, activity: ba.Activity) -> bool:
200        """Ask ourself if we should allow joins during an Activity.
201
202        Note that for a join to be allowed, both the Session and Activity
203        have to be ok with it (via this function and the
204        Activity.allow_mid_activity_joins property.
205        """
206        del activity  # Unused.
207        return True
208
209    def on_player_request(self, player: ba.SessionPlayer) -> bool:
210        """Called when a new ba.Player wants to join the Session.
211
212        This should return True or False to accept/reject.
213        """
214
215        # Limit player counts *unless* we're in a stress test.
216        if _ba.app.stress_test_reset_timer is None:
217
218            if len(self.sessionplayers) >= self.max_players:
219                # Print a rejection message *only* to the client trying to
220                # join (prevents spamming everyone else in the game).
221                _ba.playsound(_ba.getsound('error'))
222                _ba.screenmessage(Lstr(resource='playerLimitReachedText',
223                                       subs=[('${COUNT}',
224                                              str(self.max_players))]),
225                                  color=(0.8, 0.0, 0.0),
226                                  clients=[player.inputdevice.client_id],
227                                  transient=True)
228                return False
229
230        _ba.playsound(_ba.getsound('dripity'))
231        return True
232
233    def on_player_leave(self, sessionplayer: ba.SessionPlayer) -> None:
234        """Called when a previously-accepted ba.SessionPlayer leaves."""
235
236        if sessionplayer not in self.sessionplayers:
237            print('ERROR: Session.on_player_leave called'
238                  ' for player not in our list.')
239            return
240
241        _ba.playsound(_ba.getsound('playerLeft'))
242
243        activity = self._activity_weak()
244
245        if not sessionplayer.in_game:
246
247            # Ok, the player is still in the lobby; simply remove them.
248            with _ba.Context(self):
249                try:
250                    self.lobby.remove_chooser(sessionplayer)
251                except Exception:
252                    print_exception('Error in Lobby.remove_chooser().')
253        else:
254            # Ok, they've already entered the game. Remove them from
255            # teams/activities/etc.
256            sessionteam = sessionplayer.sessionteam
257            assert sessionteam is not None
258
259            _ba.screenmessage(
260                Lstr(resource='playerLeftText',
261                     subs=[('${PLAYER}', sessionplayer.getname(full=True))]))
262
263            # Remove them from their SessionTeam.
264            if sessionplayer in sessionteam.players:
265                sessionteam.players.remove(sessionplayer)
266            else:
267                print('SessionPlayer not found in SessionTeam'
268                      ' in on_player_leave.')
269
270            # Grab their activity-specific player instance.
271            player = sessionplayer.activityplayer
272            assert isinstance(player, (Player, type(None)))
273
274            # Remove them from any current Activity.
275            if player is not None and activity is not None:
276                if player in activity.players:
277                    activity.remove_player(sessionplayer)
278                else:
279                    print('Player not found in Activity in on_player_leave.')
280
281            # If we're a non-team session, remove their team too.
282            if not self.use_teams:
283                self._remove_player_team(sessionteam, activity)
284
285        # Now remove them from the session list.
286        self.sessionplayers.remove(sessionplayer)
287
288    def _remove_player_team(self, sessionteam: ba.SessionTeam,
289                            activity: ba.Activity | None) -> None:
290        """Remove the player-specific team in non-teams mode."""
291
292        # They should have been the only one on their team.
293        assert not sessionteam.players
294
295        # Remove their Team from the Activity.
296        if activity is not None:
297            if sessionteam.activityteam in activity.teams:
298                activity.remove_team(sessionteam)
299            else:
300                print('Team not found in Activity in on_player_leave.')
301
302        # And then from the Session.
303        with _ba.Context(self):
304            if sessionteam in self.sessionteams:
305                try:
306                    self.sessionteams.remove(sessionteam)
307                    self.on_team_leave(sessionteam)
308                except Exception:
309                    print_exception(
310                        f'Error in on_team_leave for Session {self}.')
311            else:
312                print('Team no in Session teams in on_player_leave.')
313            try:
314                sessionteam.leave()
315            except Exception:
316                print_exception(f'Error clearing sessiondata'
317                                f' for team {sessionteam} in session {self}.')
318
319    def end(self) -> None:
320        """Initiates an end to the session and a return to the main menu.
321
322        Note that this happens asynchronously, allowing the
323        session and its activities to shut down gracefully.
324        """
325        self._wants_to_end = True
326        if self._next_activity is None:
327            self._launch_end_session_activity()
328
329    def _launch_end_session_activity(self) -> None:
330        """(internal)"""
331        from ba._activitytypes import EndSessionActivity
332        from ba._generated.enums import TimeType
333        with _ba.Context(self):
334            curtime = _ba.time(TimeType.REAL)
335            if self._ending:
336                # Ignore repeats unless its been a while.
337                assert self._launch_end_session_activity_time is not None
338                since_last = (curtime - self._launch_end_session_activity_time)
339                if since_last < 30.0:
340                    return
341                print_error(
342                    '_launch_end_session_activity called twice (since_last=' +
343                    str(since_last) + ')')
344            self._launch_end_session_activity_time = curtime
345            self.setactivity(_ba.newactivity(EndSessionActivity))
346            self._wants_to_end = False
347            self._ending = True  # Prevent further actions.
348
349    def on_team_join(self, team: ba.SessionTeam) -> None:
350        """Called when a new ba.Team joins the session."""
351
352    def on_team_leave(self, team: ba.SessionTeam) -> None:
353        """Called when a ba.Team is leaving the session."""
354
355    def end_activity(self, activity: ba.Activity, results: Any, delay: float,
356                     force: bool) -> None:
357        """Commence shutdown of a ba.Activity (if not already occurring).
358
359        'delay' is the time delay before the Activity actually ends
360        (in seconds). Further calls to end() will be ignored up until
361        this time, unless 'force' is True, in which case the new results
362        will replace the old.
363        """
364        from ba._general import Call
365        from ba._generated.enums import TimeType
366
367        # Only pay attention if this is coming from our current activity.
368        if activity is not self._activity_retained:
369            return
370
371        # If this activity hasn't begun yet, just set it up to end immediately
372        # once it does.
373        if not activity.has_begun():
374            # activity.set_immediate_end(results, delay, force)
375            if not self._activity_should_end_immediately or force:
376                self._activity_should_end_immediately = True
377                self._activity_should_end_immediately_results = results
378                self._activity_should_end_immediately_delay = delay
379
380        # The activity has already begun; get ready to end it.
381        else:
382            if (not activity.has_ended()) or force:
383                activity.set_has_ended(True)
384
385                # Set a timer to set in motion this activity's demise.
386                self._activity_end_timer = _ba.Timer(
387                    delay,
388                    Call(self._complete_end_activity, activity, results),
389                    timetype=TimeType.BASE)
390
391    def handlemessage(self, msg: Any) -> Any:
392        """General message handling; can be passed any message object."""
393        from ba._lobby import PlayerReadyMessage
394        from ba._messages import PlayerProfilesChangedMessage, UNHANDLED
395
396        if isinstance(msg, PlayerReadyMessage):
397            self._on_player_ready(msg.chooser)
398
399        elif isinstance(msg, PlayerProfilesChangedMessage):
400            # If we have a current activity with a lobby, ask it to reload
401            # profiles.
402            with _ba.Context(self):
403                self.lobby.reload_profiles()
404            return None
405
406        else:
407            return UNHANDLED
408        return None
409
410    class _SetActivityScopedLock:
411
412        def __init__(self, session: ba.Session) -> None:
413            self._session = session
414            if session._in_set_activity:
415                raise RuntimeError('Session.setactivity() called recursively.')
416            self._session._in_set_activity = True
417
418        def __del__(self) -> None:
419            self._session._in_set_activity = False
420
421    def setactivity(self, activity: ba.Activity) -> None:
422        """Assign a new current ba.Activity for the session.
423
424        Note that this will not change the current context to the new
425        Activity's. Code must be run in the new activity's methods
426        (on_transition_in, etc) to get it. (so you can't do
427        session.setactivity(foo) and then ba.newnode() to add a node to foo)
428        """
429        from ba._generated.enums import TimeType
430
431        # Make sure we don't get called recursively.
432        _rlock = self._SetActivityScopedLock(self)
433
434        if activity.session is not _ba.getsession():
435            raise RuntimeError("Provided Activity's Session is not current.")
436
437        # Quietly ignore this if the whole session is going down.
438        if self._ending:
439            return
440
441        if activity is self._activity_retained:
442            print_error('Activity set to already-current activity.')
443            return
444
445        if self._next_activity is not None:
446            raise RuntimeError('Activity switch already in progress (to ' +
447                               str(self._next_activity) + ')')
448
449        prev_activity = self._activity_retained
450        prev_globals = (prev_activity.globalsnode
451                        if prev_activity is not None else None)
452
453        # Let the activity do its thing.
454        activity.transition_in(prev_globals)
455
456        self._next_activity = activity
457
458        # If we have a current activity, tell it it's transitioning out;
459        # the next one will become current once this one dies.
460        if prev_activity is not None:
461            prev_activity.transition_out()
462
463            # Setting this to None should free up the old activity to die,
464            # which will call begin_next_activity.
465            # We can still access our old activity through
466            # self._activity_weak() to keep it up to date on player
467            # joins/departures/etc until it dies.
468            self._activity_retained = None
469
470        # There's no existing activity; lets just go ahead with the begin call.
471        else:
472            self.begin_next_activity()
473
474        # We want to call destroy() for the previous activity once it should
475        # tear itself down, clear out any self-refs, etc. After this call
476        # the activity should have no refs left to it and should die (which
477        # will trigger the next activity to run).
478        if prev_activity is not None:
479            with _ba.Context('ui'):
480                _ba.timer(max(0.0, activity.transition_time),
481                          prev_activity.expire,
482                          timetype=TimeType.REAL)
483        self._in_set_activity = False
484
485    def getactivity(self) -> ba.Activity | None:
486        """Return the current foreground activity for this session."""
487        return self._activity_weak()
488
489    def get_custom_menu_entries(self) -> list[dict[str, Any]]:
490        """Subclasses can override this to provide custom menu entries.
491
492        The returned value should be a list of dicts, each containing
493        a 'label' and 'call' entry, with 'label' being the text for
494        the entry and 'call' being the callable to trigger if the entry
495        is pressed.
496        """
497        return []
498
499    def _complete_end_activity(self, activity: ba.Activity,
500                               results: Any) -> None:
501        # Run the subclass callback in the session context.
502        try:
503            with _ba.Context(self):
504                self.on_activity_end(activity, results)
505        except Exception:
506            print_exception(f'Error in on_activity_end() for session {self}'
507                            f' activity {activity} with results {results}')
508
509    def _request_player(self, sessionplayer: ba.SessionPlayer) -> bool:
510        """Called by the native layer when a player wants to join."""
511
512        # If we're ending, allow no new players.
513        if self._ending:
514            return False
515
516        # Ask the ba.Session subclass to approve/deny this request.
517        try:
518            with _ba.Context(self):
519                result = self.on_player_request(sessionplayer)
520        except Exception:
521            print_exception(f'Error in on_player_request for {self}')
522            result = False
523
524        # If they said yes, add the player to the lobby.
525        if result:
526            self.sessionplayers.append(sessionplayer)
527            with _ba.Context(self):
528                try:
529                    self.lobby.add_chooser(sessionplayer)
530                except Exception:
531                    print_exception('Error in lobby.add_chooser().')
532
533        return result
534
535    def on_activity_end(self, activity: ba.Activity, results: Any) -> None:
536        """Called when the current ba.Activity has ended.
537
538        The ba.Session should look at the results and start
539        another ba.Activity.
540        """
541
542    def begin_next_activity(self) -> None:
543        """Called once the previous activity has been totally torn down.
544
545        This means we're ready to begin the next one
546        """
547        if self._next_activity is None:
548            # Should this ever happen?
549            print_error('begin_next_activity() called with no _next_activity')
550            return
551
552        # We store both a weak and a strong ref to the new activity;
553        # the strong is to keep it alive and the weak is so we can access
554        # it even after we've released the strong-ref to allow it to die.
555        self._activity_retained = self._next_activity
556        self._activity_weak = weakref.ref(self._next_activity)
557        self._next_activity = None
558        self._activity_should_end_immediately = False
559
560        # Kick out anyone loitering in the lobby.
561        self.lobby.remove_all_choosers_and_kick_players()
562
563        # Kick off the activity.
564        self._activity_retained.begin(self)
565
566        # If we want to completely end the session, we can now kick that off.
567        if self._wants_to_end:
568            self._launch_end_session_activity()
569        else:
570            # Otherwise, if the activity has already been told to end,
571            # do so now.
572            if self._activity_should_end_immediately:
573                self._activity_retained.end(
574                    self._activity_should_end_immediately_results,
575                    self._activity_should_end_immediately_delay)
576
577    def _on_player_ready(self, chooser: ba.Chooser) -> None:
578        """Called when a ba.Player has checked themself ready."""
579        lobby = chooser.lobby
580        activity = self._activity_weak()
581
582        # This happens sometimes. That seems like it shouldn't be happening;
583        # when would we have a session and a chooser with players but no
584        # active activity?
585        if activity is None:
586            print('_on_player_ready called with no activity.')
587            return
588
589        # In joining-activities, we wait till all choosers are ready
590        # and then create all players at once.
591        if activity.is_joining_activity:
592            if not lobby.check_all_ready():
593                return
594            choosers = lobby.get_choosers()
595            min_players = self.min_players
596            if len(choosers) >= min_players:
597                for lch in lobby.get_choosers():
598                    self._add_chosen_player(lch)
599                lobby.remove_all_choosers()
600
601                # Get our next activity going.
602                self._complete_end_activity(activity, {})
603            else:
604                _ba.screenmessage(
605                    Lstr(resource='notEnoughPlayersText',
606                         subs=[('${COUNT}', str(min_players))]),
607                    color=(1, 1, 0),
608                )
609                _ba.playsound(_ba.getsound('error'))
610
611        # Otherwise just add players on the fly.
612        else:
613            self._add_chosen_player(chooser)
614            lobby.remove_chooser(chooser.getplayer())
615
616    def transitioning_out_activity_was_freed(
617            self, can_show_ad_on_death: bool) -> None:
618        """(internal)"""
619        from ba._apputils import garbage_collect
620
621        # Since things should be generally still right now, it's a good time
622        # to run garbage collection to clear out any circular dependency
623        # loops. We keep this disabled normally to avoid non-deterministic
624        # hitches.
625        garbage_collect()
626
627        with _ba.Context(self):
628            if can_show_ad_on_death:
629                _ba.app.ads.call_after_ad(self.begin_next_activity)
630            else:
631                _ba.pushcall(self.begin_next_activity)
632
633    def _add_chosen_player(self, chooser: ba.Chooser) -> ba.SessionPlayer:
634        from ba._team import SessionTeam
635        sessionplayer = chooser.getplayer()
636        assert sessionplayer in self.sessionplayers, (
637            'SessionPlayer not found in session '
638            'player-list after chooser selection.')
639
640        activity = self._activity_weak()
641        assert activity is not None
642
643        # Reset the player's input here, as it is probably
644        # referencing the chooser which could inadvertently keep it alive.
645        sessionplayer.resetinput()
646
647        # We can pass it to the current activity if it has already begun
648        # (otherwise it'll get passed once begin is called).
649        pass_to_activity = (activity.has_begun()
650                            and not activity.is_joining_activity)
651
652        # However, if we're not allowing mid-game joins, don't actually pass;
653        # just announce the arrival and say they'll partake next round.
654        if pass_to_activity:
655            if not (activity.allow_mid_activity_joins
656                    and self.should_allow_mid_activity_joins(activity)):
657                pass_to_activity = False
658                with _ba.Context(self):
659                    _ba.screenmessage(
660                        Lstr(resource='playerDelayedJoinText',
661                             subs=[('${PLAYER}',
662                                    sessionplayer.getname(full=True))]),
663                        color=(0, 1, 0),
664                    )
665
666        # If we're a non-team session, each player gets their own team.
667        # (keeps mini-game coding simpler if we can always deal with teams).
668        if self.use_teams:
669            sessionteam = chooser.sessionteam
670        else:
671            our_team_id = self._next_team_id
672            self._next_team_id += 1
673            sessionteam = SessionTeam(
674                team_id=our_team_id,
675                color=chooser.get_color(),
676                name=chooser.getplayer().getname(full=True, icon=False),
677            )
678
679            # Add player's team to the Session.
680            self.sessionteams.append(sessionteam)
681
682            with _ba.Context(self):
683                try:
684                    self.on_team_join(sessionteam)
685                except Exception:
686                    print_exception(f'Error in on_team_join for {self}.')
687
688            # Add player's team to the Activity.
689            if pass_to_activity:
690                activity.add_team(sessionteam)
691
692        assert sessionplayer not in sessionteam.players
693        sessionteam.players.append(sessionplayer)
694        sessionplayer.setdata(team=sessionteam,
695                              character=chooser.get_character_name(),
696                              color=chooser.get_color(),
697                              highlight=chooser.get_highlight())
698
699        self.stats.register_sessionplayer(sessionplayer)
700        if pass_to_activity:
701            activity.add_player(sessionplayer)
702        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__(self,
 75                 depsets: Sequence[ba.DependencySet],
 76                 team_names: Sequence[str] | None = None,
 77                 team_colors: Sequence[Sequence[float]] | None = None,
 78                 min_players: int = 1,
 79                 max_players: int = 8):
 80        """Instantiate a session.
 81
 82        depsets should be a sequence of successfully resolved ba.DependencySet
 83        instances; one for each ba.Activity the session may potentially run.
 84        """
 85        # pylint: disable=too-many-statements
 86        # pylint: disable=too-many-locals
 87        # pylint: disable=cyclic-import
 88        # pylint: disable=too-many-branches
 89        from ba._lobby import Lobby
 90        from ba._stats import Stats
 91        from ba._gameactivity import GameActivity
 92        from ba._activity import Activity
 93        from ba._team import SessionTeam
 94        from ba._error import DependencyError
 95        from ba._dependency import Dependency, AssetPackage
 96        from efro.util import empty_weakref
 97
 98        # First off, resolve all dependency-sets we were passed.
 99        # If things are missing, we'll try to gather them into a single
100        # missing-deps exception if possible to give the caller a clean
101        # path to download missing stuff and try again.
102        missing_asset_packages: set[str] = set()
103        for depset in depsets:
104            try:
105                depset.resolve()
106            except DependencyError as exc:
107                # Gather/report missing assets only; barf on anything else.
108                if all(issubclass(d.cls, AssetPackage) for d in exc.deps):
109                    for dep in exc.deps:
110                        assert isinstance(dep.config, str)
111                        missing_asset_packages.add(dep.config)
112                else:
113                    missing_info = [(d.cls, d.config) for d in exc.deps]
114                    raise RuntimeError(
115                        f'Missing non-asset dependencies: {missing_info}'
116                    ) from exc
117
118        # Throw a combined exception if we found anything missing.
119        if missing_asset_packages:
120            raise DependencyError([
121                Dependency(AssetPackage, set_id)
122                for set_id in missing_asset_packages
123            ])
124
125        # Ok; looks like our dependencies check out.
126        # Now give the engine a list of asset-set-ids to pass along to clients.
127        required_asset_packages: set[str] = set()
128        for depset in depsets:
129            required_asset_packages.update(depset.get_asset_package_ids())
130
131        # print('Would set host-session asset-reqs to:',
132        # required_asset_packages)
133
134        # Init our C++ layer data.
135        self._sessiondata = _ba.register_session(self)
136
137        # Should remove this if possible.
138        self.tournament_id: str | None = None
139
140        self.sessionteams = []
141        self.sessionplayers = []
142        self.min_players = min_players
143        self.max_players = max_players
144
145        self.customdata = {}
146        self._in_set_activity = False
147        self._next_team_id = 0
148        self._activity_retained: ba.Activity | None = None
149        self._launch_end_session_activity_time: float | None = None
150        self._activity_end_timer: ba.Timer | None = None
151        self._activity_weak = empty_weakref(Activity)
152        self._next_activity: ba.Activity | None = None
153        self._wants_to_end = False
154        self._ending = False
155        self._activity_should_end_immediately = False
156        self._activity_should_end_immediately_results: (ba.GameResults
157                                                        | None) = None
158        self._activity_should_end_immediately_delay = 0.0
159
160        # Create static teams if we're using them.
161        if self.use_teams:
162            if team_names is None:
163                raise RuntimeError(
164                    'use_teams is True but team_names not provided.')
165            if team_colors is None:
166                raise RuntimeError(
167                    'use_teams is True but team_colors not provided.')
168            if len(team_colors) != len(team_names):
169                raise RuntimeError(f'Got {len(team_names)} team_names'
170                                   f' and {len(team_colors)} team_colors;'
171                                   f' these numbers must match.')
172            for i, color in enumerate(team_colors):
173                team = SessionTeam(team_id=self._next_team_id,
174                                   name=GameActivity.get_team_display_string(
175                                       team_names[i]),
176                                   color=color)
177                self.sessionteams.append(team)
178                self._next_team_id += 1
179                try:
180                    with _ba.Context(self):
181                        self.on_team_join(team)
182                except Exception:
183                    print_exception(f'Error in on_team_join for {self}.')
184
185        self.lobby = Lobby()
186        self.stats = Stats()
187
188        # Instantiate our session globals node which will apply its settings.
189        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:
199    def should_allow_mid_activity_joins(self, activity: ba.Activity) -> bool:
200        """Ask ourself if we should allow joins during an Activity.
201
202        Note that for a join to be allowed, both the Session and Activity
203        have to be ok with it (via this function and the
204        Activity.allow_mid_activity_joins property.
205        """
206        del activity  # Unused.
207        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:
209    def on_player_request(self, player: ba.SessionPlayer) -> bool:
210        """Called when a new ba.Player wants to join the Session.
211
212        This should return True or False to accept/reject.
213        """
214
215        # Limit player counts *unless* we're in a stress test.
216        if _ba.app.stress_test_reset_timer is None:
217
218            if len(self.sessionplayers) >= self.max_players:
219                # Print a rejection message *only* to the client trying to
220                # join (prevents spamming everyone else in the game).
221                _ba.playsound(_ba.getsound('error'))
222                _ba.screenmessage(Lstr(resource='playerLimitReachedText',
223                                       subs=[('${COUNT}',
224                                              str(self.max_players))]),
225                                  color=(0.8, 0.0, 0.0),
226                                  clients=[player.inputdevice.client_id],
227                                  transient=True)
228                return False
229
230        _ba.playsound(_ba.getsound('dripity'))
231        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:
233    def on_player_leave(self, sessionplayer: ba.SessionPlayer) -> None:
234        """Called when a previously-accepted ba.SessionPlayer leaves."""
235
236        if sessionplayer not in self.sessionplayers:
237            print('ERROR: Session.on_player_leave called'
238                  ' for player not in our list.')
239            return
240
241        _ba.playsound(_ba.getsound('playerLeft'))
242
243        activity = self._activity_weak()
244
245        if not sessionplayer.in_game:
246
247            # Ok, the player is still in the lobby; simply remove them.
248            with _ba.Context(self):
249                try:
250                    self.lobby.remove_chooser(sessionplayer)
251                except Exception:
252                    print_exception('Error in Lobby.remove_chooser().')
253        else:
254            # Ok, they've already entered the game. Remove them from
255            # teams/activities/etc.
256            sessionteam = sessionplayer.sessionteam
257            assert sessionteam is not None
258
259            _ba.screenmessage(
260                Lstr(resource='playerLeftText',
261                     subs=[('${PLAYER}', sessionplayer.getname(full=True))]))
262
263            # Remove them from their SessionTeam.
264            if sessionplayer in sessionteam.players:
265                sessionteam.players.remove(sessionplayer)
266            else:
267                print('SessionPlayer not found in SessionTeam'
268                      ' in on_player_leave.')
269
270            # Grab their activity-specific player instance.
271            player = sessionplayer.activityplayer
272            assert isinstance(player, (Player, type(None)))
273
274            # Remove them from any current Activity.
275            if player is not None and activity is not None:
276                if player in activity.players:
277                    activity.remove_player(sessionplayer)
278                else:
279                    print('Player not found in Activity in on_player_leave.')
280
281            # If we're a non-team session, remove their team too.
282            if not self.use_teams:
283                self._remove_player_team(sessionteam, activity)
284
285        # Now remove them from the session list.
286        self.sessionplayers.remove(sessionplayer)

Called when a previously-accepted ba.SessionPlayer leaves.

def end(self) -> None:
319    def end(self) -> None:
320        """Initiates an end to the session and a return to the main menu.
321
322        Note that this happens asynchronously, allowing the
323        session and its activities to shut down gracefully.
324        """
325        self._wants_to_end = True
326        if self._next_activity is None:
327            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:
349    def on_team_join(self, team: ba.SessionTeam) -> None:
350        """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:
352    def on_team_leave(self, team: ba.SessionTeam) -> None:
353        """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:
355    def end_activity(self, activity: ba.Activity, results: Any, delay: float,
356                     force: bool) -> None:
357        """Commence shutdown of a ba.Activity (if not already occurring).
358
359        'delay' is the time delay before the Activity actually ends
360        (in seconds). Further calls to end() will be ignored up until
361        this time, unless 'force' is True, in which case the new results
362        will replace the old.
363        """
364        from ba._general import Call
365        from ba._generated.enums import TimeType
366
367        # Only pay attention if this is coming from our current activity.
368        if activity is not self._activity_retained:
369            return
370
371        # If this activity hasn't begun yet, just set it up to end immediately
372        # once it does.
373        if not activity.has_begun():
374            # activity.set_immediate_end(results, delay, force)
375            if not self._activity_should_end_immediately or force:
376                self._activity_should_end_immediately = True
377                self._activity_should_end_immediately_results = results
378                self._activity_should_end_immediately_delay = delay
379
380        # The activity has already begun; get ready to end it.
381        else:
382            if (not activity.has_ended()) or force:
383                activity.set_has_ended(True)
384
385                # Set a timer to set in motion this activity's demise.
386                self._activity_end_timer = _ba.Timer(
387                    delay,
388                    Call(self._complete_end_activity, activity, results),
389                    timetype=TimeType.BASE)

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:
391    def handlemessage(self, msg: Any) -> Any:
392        """General message handling; can be passed any message object."""
393        from ba._lobby import PlayerReadyMessage
394        from ba._messages import PlayerProfilesChangedMessage, UNHANDLED
395
396        if isinstance(msg, PlayerReadyMessage):
397            self._on_player_ready(msg.chooser)
398
399        elif isinstance(msg, PlayerProfilesChangedMessage):
400            # If we have a current activity with a lobby, ask it to reload
401            # profiles.
402            with _ba.Context(self):
403                self.lobby.reload_profiles()
404            return None
405
406        else:
407            return UNHANDLED
408        return None

General message handling; can be passed any message object.

def setactivity(self, activity: ba.Activity) -> None:
421    def setactivity(self, activity: ba.Activity) -> None:
422        """Assign a new current ba.Activity for the session.
423
424        Note that this will not change the current context to the new
425        Activity's. Code must be run in the new activity's methods
426        (on_transition_in, etc) to get it. (so you can't do
427        session.setactivity(foo) and then ba.newnode() to add a node to foo)
428        """
429        from ba._generated.enums import TimeType
430
431        # Make sure we don't get called recursively.
432        _rlock = self._SetActivityScopedLock(self)
433
434        if activity.session is not _ba.getsession():
435            raise RuntimeError("Provided Activity's Session is not current.")
436
437        # Quietly ignore this if the whole session is going down.
438        if self._ending:
439            return
440
441        if activity is self._activity_retained:
442            print_error('Activity set to already-current activity.')
443            return
444
445        if self._next_activity is not None:
446            raise RuntimeError('Activity switch already in progress (to ' +
447                               str(self._next_activity) + ')')
448
449        prev_activity = self._activity_retained
450        prev_globals = (prev_activity.globalsnode
451                        if prev_activity is not None else None)
452
453        # Let the activity do its thing.
454        activity.transition_in(prev_globals)
455
456        self._next_activity = activity
457
458        # If we have a current activity, tell it it's transitioning out;
459        # the next one will become current once this one dies.
460        if prev_activity is not None:
461            prev_activity.transition_out()
462
463            # Setting this to None should free up the old activity to die,
464            # which will call begin_next_activity.
465            # We can still access our old activity through
466            # self._activity_weak() to keep it up to date on player
467            # joins/departures/etc until it dies.
468            self._activity_retained = None
469
470        # There's no existing activity; lets just go ahead with the begin call.
471        else:
472            self.begin_next_activity()
473
474        # We want to call destroy() for the previous activity once it should
475        # tear itself down, clear out any self-refs, etc. After this call
476        # the activity should have no refs left to it and should die (which
477        # will trigger the next activity to run).
478        if prev_activity is not None:
479            with _ba.Context('ui'):
480                _ba.timer(max(0.0, activity.transition_time),
481                          prev_activity.expire,
482                          timetype=TimeType.REAL)
483        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:
485    def getactivity(self) -> ba.Activity | None:
486        """Return the current foreground activity for this session."""
487        return self._activity_weak()

Return the current foreground activity for this session.

def get_custom_menu_entries(self) -> list[dict[str, typing.Any]]:
489    def get_custom_menu_entries(self) -> list[dict[str, Any]]:
490        """Subclasses can override this to provide custom menu entries.
491
492        The returned value should be a list of dicts, each containing
493        a 'label' and 'call' entry, with 'label' being the text for
494        the entry and 'call' being the callable to trigger if the entry
495        is pressed.
496        """
497        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:
535    def on_activity_end(self, activity: ba.Activity, results: Any) -> None:
536        """Called when the current ba.Activity has ended.
537
538        The ba.Session should look at the results and start
539        another ba.Activity.
540        """

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:
542    def begin_next_activity(self) -> None:
543        """Called once the previous activity has been totally torn down.
544
545        This means we're ready to begin the next one
546        """
547        if self._next_activity is None:
548            # Should this ever happen?
549            print_error('begin_next_activity() called with no _next_activity')
550            return
551
552        # We store both a weak and a strong ref to the new activity;
553        # the strong is to keep it alive and the weak is so we can access
554        # it even after we've released the strong-ref to allow it to die.
555        self._activity_retained = self._next_activity
556        self._activity_weak = weakref.ref(self._next_activity)
557        self._next_activity = None
558        self._activity_should_end_immediately = False
559
560        # Kick out anyone loitering in the lobby.
561        self.lobby.remove_all_choosers_and_kick_players()
562
563        # Kick off the activity.
564        self._activity_retained.begin(self)
565
566        # If we want to completely end the session, we can now kick that off.
567        if self._wants_to_end:
568            self._launch_end_session_activity()
569        else:
570            # Otherwise, if the activity has already been told to end,
571            # do so now.
572            if self._activity_should_end_immediately:
573                self._activity_retained.end(
574                    self._activity_should_end_immediately_results,
575                    self._activity_should_end_immediately_delay)

Called once the previous activity has been totally torn down.

This means we're ready to begin the next one

class SessionNotFoundError(ba.NotFoundError):
108class SessionNotFoundError(NotFoundError):
109    """Exception raised when an expected ba.Session does not exist.
110
111    Category: **Exception Classes**
112    """

Exception raised when an expected ba.Session does not exist.

Category: Exception Classes

Inherited Members
builtins.Exception
Exception
builtins.BaseException
with_traceback
args
class SessionPlayer:
734class SessionPlayer:
735    """A reference to a player in the ba.Session.
736
737    Category: **Gameplay Classes**
738
739    These are created and managed internally and
740    provided to your ba.Session/ba.Activity instances.
741    Be aware that, like `ba.Node`s, ba.SessionPlayer objects are 'weak'
742    references under-the-hood; a player can leave the game at
743     any point. For this reason, you should make judicious use of the
744    ba.SessionPlayer.exists() method (or boolean operator) to ensure
745    that a SessionPlayer is still present if retaining references to one
746    for any length of time.
747    """
748    id: int
749    """The unique numeric ID of the Player.
750
751       Note that you can also use the boolean operator for this same
752       functionality, so a statement such as "if player" will do
753       the right thing both for Player objects and values of None."""
754
755    in_game: bool
756    """This bool value will be True once the Player has completed
757       any lobby character/team selection."""
758
759    sessionteam: ba.SessionTeam
760    """The ba.SessionTeam this Player is on. If the SessionPlayer
761       is still in its lobby selecting a team/etc. then a
762       ba.SessionTeamNotFoundError will be raised."""
763
764    inputdevice: ba.InputDevice
765    """The input device associated with the player."""
766
767    color: Sequence[float]
768    """The base color for this Player.
769       In team games this will match the ba.SessionTeam's color."""
770
771    highlight: Sequence[float]
772    """A secondary color for this player.
773       This is used for minor highlights and accents
774       to allow a player to stand apart from his teammates
775       who may all share the same team (primary) color."""
776
777    character: str
778    """The character this player has selected in their profile."""
779
780    activityplayer: ba.Player | None
781    """The current game-specific instance for this player."""
782
783    def assigninput(self, type: ba.InputType | tuple[ba.InputType, ...],
784                    call: Callable) -> None:
785        """Set the python callable to be run for one or more types of input."""
786        return None
787
788    def exists(self) -> bool:
789        """Return whether the underlying player is still in the game."""
790        return bool()
791
792    def get_icon(self) -> dict[str, Any]:
793        """Returns the character's icon (images, colors, etc contained
794        in a dict.
795        """
796        return {'foo': 'bar'}
797
798    def get_icon_info(self) -> dict[str, Any]:
799        """(internal)"""
800        return {'foo': 'bar'}
801
802    def get_v1_account_id(self) -> str:
803        """Return the V1 Account ID this player is signed in under, if
804        there is one and it can be determined with relative certainty.
805        Returns None otherwise. Note that this may require an active
806        internet connection (especially for network-connected players)
807        and may return None for a short while after a player initially
808        joins (while verification occurs).
809        """
810        return str()
811
812    def getname(self, full: bool = False, icon: bool = True) -> str:
813        """Returns the player's name. If icon is True, the long version of the
814        name may include an icon.
815        """
816        return str()
817
818    def remove_from_game(self) -> None:
819        """Removes the player from the game."""
820        return None
821
822    def resetinput(self) -> None:
823        """Clears out the player's assigned input actions."""
824        return None
825
826    def set_icon_info(self, texture: str, tint_texture: str,
827                      tint_color: Sequence[float],
828                      tint2_color: Sequence[float]) -> None:
829        """(internal)"""
830        return None
831
832    def setactivity(self, activity: ba.Activity | None) -> None:
833        """(internal)"""
834        return None
835
836    def setdata(self, team: ba.SessionTeam, character: str,
837                color: Sequence[float], highlight: Sequence[float]) -> None:
838        """(internal)"""
839        return None
840
841    def setname(self,
842                name: str,
843                full_name: str | None = None,
844                real: bool = True) -> None:
845        """Set the player's name to the provided string.
846        A number will automatically be appended if the name is not unique from
847        other players.
848        """
849        return None
850
851    def setnode(self, node: Node | None) -> None:
852        """(internal)"""
853        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:
783    def assigninput(self, type: ba.InputType | tuple[ba.InputType, ...],
784                    call: Callable) -> None:
785        """Set the python callable to be run for one or more types of input."""
786        return None

Set the python callable to be run for one or more types of input.

def exists(self) -> bool:
788    def exists(self) -> bool:
789        """Return whether the underlying player is still in the game."""
790        return bool()

Return whether the underlying player is still in the game.

def get_icon(self) -> dict[str, typing.Any]:
792    def get_icon(self) -> dict[str, Any]:
793        """Returns the character's icon (images, colors, etc contained
794        in a dict.
795        """
796        return {'foo': 'bar'}

Returns the character's icon (images, colors, etc contained in a dict.

def get_v1_account_id(self) -> str:
802    def get_v1_account_id(self) -> str:
803        """Return the V1 Account ID this player is signed in under, if
804        there is one and it can be determined with relative certainty.
805        Returns None otherwise. Note that this may require an active
806        internet connection (especially for network-connected players)
807        and may return None for a short while after a player initially
808        joins (while verification occurs).
809        """
810        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:
812    def getname(self, full: bool = False, icon: bool = True) -> str:
813        """Returns the player's name. If icon is True, the long version of the
814        name may include an icon.
815        """
816        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:
818    def remove_from_game(self) -> None:
819        """Removes the player from the game."""
820        return None

Removes the player from the game.

def resetinput(self) -> None:
822    def resetinput(self) -> None:
823        """Clears out the player's assigned input actions."""
824        return None

Clears out the player's assigned input actions.

def setname(self, name: str, full_name: str | None = None, real: bool = True) -> None:
841    def setname(self,
842                name: str,
843                full_name: str | None = None,
844                real: bool = True) -> None:
845        """Set the player's name to the provided string.
846        A number will automatically be appended if the name is not unique from
847        other players.
848        """
849        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
args
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__(self,
49                 team_id: int = 0,
50                 name: ba.Lstr | str = '',
51                 color: Sequence[float] = (1.0, 1.0, 1.0)):
52        """Instantiate a ba.SessionTeam.
53
54        In most cases, all teams are provided to you by the ba.Session,
55        ba.Session, so calling this shouldn't be necessary.
56        """
57
58        self.id = team_id
59        self.name = name
60        self.color = tuple(color)
61        self.players = []
62        self.customdata = {}
63        self.activityteam: Team | None = None
64
65    def leave(self) -> None:
66        """(internal)"""
67        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__(self,
49                 team_id: int = 0,
50                 name: ba.Lstr | str = '',
51                 color: Sequence[float] = (1.0, 1.0, 1.0)):
52        """Instantiate a ba.SessionTeam.
53
54        In most cases, all teams are provided to you by the ba.Session,
55        ba.Session, so calling this shouldn't be necessary.
56        """
57
58        self.id = team_id
59        self.name = name
60        self.color = tuple(color)
61        self.players = []
62        self.customdata = {}
63        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):
80class SessionTeamNotFoundError(NotFoundError):
81    """Exception raised when an expected ba.SessionTeam does not exist.
82
83    Category: **Exception Classes**
84    """

Exception raised when an expected ba.SessionTeam does not exist.

Category: Exception Classes

Inherited Members
builtins.Exception
Exception
builtins.BaseException
with_traceback
args
def set_analytics_screen(screen: str) -> None:
2747def set_analytics_screen(screen: str) -> None:
2748    """Used for analytics to see where in the app players spend their time.
2749
2750    Category: **General Utility Functions**
2751
2752    Generally called when opening a new window or entering some UI.
2753    'screen' should be a string description of an app location
2754    ('Main Menu', etc.)
2755    """
2756    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:
474def setmusic(musictype: ba.MusicType | None, continuous: bool = False) -> None:
475    """Set the app to play (or stop playing) a certain type of music.
476
477    category: **Gameplay Functions**
478
479    This function will handle loading and playing sound assets as necessary,
480    and also supports custom user soundtracks on specific platforms so the
481    user can override particular game music with their own.
482
483    Pass None to stop music.
484
485    if 'continuous' is True and musictype is the same as what is already
486    playing, the playing track will not be restarted.
487    """
488
489    # All we do here now is set a few music attrs on the current globals
490    # node. The foreground globals' current playing music then gets fed to
491    # the do_play_music call in our music controller. This way we can
492    # seamlessly support custom soundtracks in replays/etc since we're being
493    # driven purely by node data.
494    gnode = _ba.getactivity().globalsnode
495    gnode.music_continuous = continuous
496    gnode.music = '' if musictype is None else musictype.value
497    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:
182@dataclass
183class ShouldShatterMessage:
184    """Tells an object that it should shatter.
185
186    Category: **Message Classes**
187    """

Tells an object that it should shatter.

Category: Message Classes

ShouldShatterMessage()
def show_damage_count( damage: str, position: Sequence[float], direction: Sequence[float]) -> None:
201def show_damage_count(damage: str, position: Sequence[float],
202                      direction: Sequence[float]) -> None:
203    """Pop up a damage count at a position in space.
204
205    Category: **Gameplay Functions**
206    """
207    lifespan = 1.0
208    app = _ba.app
209
210    # FIXME: Should never vary game elements based on local config.
211    #  (connected clients may have differing configs so they won't
212    #  get the intended results).
213    do_big = app.ui.uiscale is UIScale.SMALL or app.vr_mode
214    txtnode = _ba.newnode('text',
215                          attrs={
216                              'text': damage,
217                              'in_world': True,
218                              'h_align': 'center',
219                              'flatness': 1.0,
220                              'shadow': 1.0 if do_big else 0.7,
221                              'color': (1, 0.25, 0.25, 1),
222                              'scale': 0.015 if do_big else 0.01
223                          })
224    # Translate upward.
225    tcombine = _ba.newnode('combine', owner=txtnode, attrs={'size': 3})
226    tcombine.connectattr('output', txtnode, 'position')
227    v_vals = []
228    pval = 0.0
229    vval = 0.07
230    count = 6
231    for i in range(count):
232        v_vals.append((float(i) / count, pval))
233        pval += vval
234        vval *= 0.5
235    p_start = position[0]
236    p_dir = direction[0]
237    animate(tcombine, 'input0',
238            {i[0] * lifespan: p_start + p_dir * i[1]
239             for i in v_vals})
240    p_start = position[1]
241    p_dir = direction[1]
242    animate(tcombine, 'input1',
243            {i[0] * lifespan: p_start + p_dir * i[1]
244             for i in v_vals})
245    p_start = position[2]
246    p_dir = direction[2]
247    animate(tcombine, 'input2',
248            {i[0] * lifespan: p_start + p_dir * i[1]
249             for i in v_vals})
250    animate(txtnode, 'opacity', {0.7 * lifespan: 1.0, lifespan: 0.0})
251    _ba.timer(lifespan, txtnode.delete)

Pop up a damage count at a position in space.

Category: Gameplay Functions

class Sound:
856class Sound:
857    """A reference to a sound.
858
859    Category: **Asset Classes**
860
861    Use ba.getsound() to instantiate one.
862    """
863    pass

A reference to a sound.

Category: Asset Classes

Use ba.getsound() to instantiate one.

Sound()
class SpecialChar(enum.Enum):
105class SpecialChar(Enum):
106    """Special characters the game can print.
107
108    Category: Enums
109    """
110    DOWN_ARROW = 0
111    UP_ARROW = 1
112    LEFT_ARROW = 2
113    RIGHT_ARROW = 3
114    TOP_BUTTON = 4
115    LEFT_BUTTON = 5
116    RIGHT_BUTTON = 6
117    BOTTOM_BUTTON = 7
118    DELETE = 8
119    SHIFT = 9
120    BACK = 10
121    LOGO_FLAT = 11
122    REWIND_BUTTON = 12
123    PLAY_PAUSE_BUTTON = 13
124    FAST_FORWARD_BUTTON = 14
125    DPAD_CENTER_BUTTON = 15
126    OUYA_BUTTON_O = 16
127    OUYA_BUTTON_U = 17
128    OUYA_BUTTON_Y = 18
129    OUYA_BUTTON_A = 19
130    OUYA_LOGO = 20
131    LOGO = 21
132    TICKET = 22
133    GOOGLE_PLAY_GAMES_LOGO = 23
134    GAME_CENTER_LOGO = 24
135    DICE_BUTTON1 = 25
136    DICE_BUTTON2 = 26
137    DICE_BUTTON3 = 27
138    DICE_BUTTON4 = 28
139    GAME_CIRCLE_LOGO = 29
140    PARTY_ICON = 30
141    TEST_ACCOUNT = 31
142    TICKET_BACKING = 32
143    TROPHY1 = 33
144    TROPHY2 = 34
145    TROPHY3 = 35
146    TROPHY0A = 36
147    TROPHY0B = 37
148    TROPHY4 = 38
149    LOCAL_ACCOUNT = 39
150    ALIBABA_LOGO = 40
151    FLAG_UNITED_STATES = 41
152    FLAG_MEXICO = 42
153    FLAG_GERMANY = 43
154    FLAG_BRAZIL = 44
155    FLAG_RUSSIA = 45
156    FLAG_CHINA = 46
157    FLAG_UNITED_KINGDOM = 47
158    FLAG_CANADA = 48
159    FLAG_INDIA = 49
160    FLAG_JAPAN = 50
161    FLAG_FRANCE = 51
162    FLAG_INDONESIA = 52
163    FLAG_ITALY = 53
164    FLAG_SOUTH_KOREA = 54
165    FLAG_NETHERLANDS = 55
166    FEDORA = 56
167    HAL = 57
168    CROWN = 58
169    YIN_YANG = 59
170    EYE_BALL = 60
171    SKULL = 61
172    HEART = 62
173    DRAGON = 63
174    HELMET = 64
175    MUSHROOM = 65
176    NINJA_STAR = 66
177    VIKING_HELMET = 67
178    MOON = 68
179    SPIDER = 69
180    FIREBALL = 70
181    FLAG_UNITED_ARAB_EMIRATES = 71
182    FLAG_QATAR = 72
183    FLAG_EGYPT = 73
184    FLAG_KUWAIT = 74
185    FLAG_ALGERIA = 75
186    FLAG_SAUDI_ARABIA = 76
187    FLAG_MALAYSIA = 77
188    FLAG_CZECH_REPUBLIC = 78
189    FLAG_AUSTRALIA = 79
190    FLAG_SINGAPORE = 80
191    OCULUS_LOGO = 81
192    STEAM_LOGO = 82
193    NVIDIA_LOGO = 83
194    FLAG_IRAN = 84
195    FLAG_POLAND = 85
196    FLAG_ARGENTINA = 86
197    FLAG_PHILIPPINES = 87
198    FLAG_CHILE = 88
199    MIKIROG = 89
200    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:
36@dataclass
37class StandLocation:
38    """Describes a point in space and an angle to face.
39
40    Category: Gameplay Classes
41    """
42    position: ba.Vec3
43    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)
angle: float | None = None
@dataclass
class StandMessage:
125@dataclass
126class StandMessage:
127    """A message telling an object to move to a position in space.
128
129    Category: **Message Classes**
130
131    Used when teleporting players to home base, etc.
132    """
133
134    position: Sequence[float] = (0.0, 0.0, 0.0)
135    """Where to move to."""
136
137    angle: float = 0.0
138    """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:
230class Stats:
231    """Manages scores and statistics for a ba.Session.
232
233    Category: **Gameplay Classes**
234    """
235
236    def __init__(self) -> None:
237        self._activity: weakref.ref[ba.Activity] | None = None
238        self._player_records: dict[str, PlayerRecord] = {}
239        self.orchestrahitsound1: ba.Sound | None = None
240        self.orchestrahitsound2: ba.Sound | None = None
241        self.orchestrahitsound3: ba.Sound | None = None
242        self.orchestrahitsound4: ba.Sound | None = None
243
244    def setactivity(self, activity: ba.Activity | None) -> None:
245        """Set the current activity for this instance."""
246
247        self._activity = None if activity is None else weakref.ref(activity)
248
249        # Load our media into this activity's context.
250        if activity is not None:
251            if activity.expired:
252                print_error('unexpected finalized activity')
253            else:
254                with _ba.Context(activity):
255                    self._load_activity_media()
256
257    def getactivity(self) -> ba.Activity | None:
258        """Get the activity associated with this instance.
259
260        May return None.
261        """
262        if self._activity is None:
263            return None
264        return self._activity()
265
266    def _load_activity_media(self) -> None:
267        self.orchestrahitsound1 = _ba.getsound('orchestraHit')
268        self.orchestrahitsound2 = _ba.getsound('orchestraHit2')
269        self.orchestrahitsound3 = _ba.getsound('orchestraHit3')
270        self.orchestrahitsound4 = _ba.getsound('orchestraHit4')
271
272    def reset(self) -> None:
273        """Reset the stats instance completely."""
274
275        # Just to be safe, lets make sure no multi-kill timers are gonna go off
276        # for no-longer-on-the-list players.
277        for p_entry in list(self._player_records.values()):
278            p_entry.cancel_multi_kill_timer()
279        self._player_records = {}
280
281    def reset_accum(self) -> None:
282        """Reset per-sound sub-scores."""
283        for s_player in list(self._player_records.values()):
284            s_player.cancel_multi_kill_timer()
285            s_player.accumscore = 0
286            s_player.accum_kill_count = 0
287            s_player.accum_killed_count = 0
288            s_player.streak = 0
289
290    def register_sessionplayer(self, player: ba.SessionPlayer) -> None:
291        """Register a ba.SessionPlayer with this score-set."""
292        assert player.exists()  # Invalid refs should never be passed to funcs.
293        name = player.getname()
294        if name in self._player_records:
295            # If the player already exists, update his character and such as
296            # it may have changed.
297            self._player_records[name].associate_with_sessionplayer(player)
298        else:
299            name_full = player.getname(full=True)
300            self._player_records[name] = PlayerRecord(name, name_full, player,
301                                                      self)
302
303    def get_records(self) -> dict[str, ba.PlayerRecord]:
304        """Get PlayerRecord corresponding to still-existing players."""
305        records = {}
306
307        # Go through our player records and return ones whose player id still
308        # corresponds to a player with that name.
309        for record_id, record in self._player_records.items():
310            lastplayer = record.get_last_sessionplayer()
311            if lastplayer and lastplayer.getname() == record_id:
312                records[record_id] = record
313        return records
314
315    def player_scored(self,
316                      player: ba.Player,
317                      base_points: int = 1,
318                      target: Sequence[float] | None = None,
319                      kill: bool = False,
320                      victim_player: ba.Player | None = None,
321                      scale: float = 1.0,
322                      color: Sequence[float] | None = None,
323                      title: str | ba.Lstr | None = None,
324                      screenmessage: bool = True,
325                      display: bool = True,
326                      importance: int = 1,
327                      showpoints: bool = True,
328                      big_message: bool = False) -> int:
329        """Register a score for the player.
330
331        Return value is actual score with multipliers and such factored in.
332        """
333        # FIXME: Tidy this up.
334        # pylint: disable=cyclic-import
335        # pylint: disable=too-many-branches
336        # pylint: disable=too-many-locals
337        # pylint: disable=too-many-statements
338        from bastd.actor.popuptext import PopupText
339        from ba import _math
340        from ba._gameactivity import GameActivity
341        from ba._language import Lstr
342        del victim_player  # Currently unused.
343        name = player.getname()
344        s_player = self._player_records[name]
345
346        if kill:
347            s_player.submit_kill(showpoints=showpoints)
348
349        display_color: Sequence[float] = (1.0, 1.0, 1.0, 1.0)
350
351        if color is not None:
352            display_color = color
353        elif importance != 1:
354            display_color = (1.0, 1.0, 0.4, 1.0)
355        points = base_points
356
357        # If they want a big announcement, throw a zoom-text up there.
358        if display and big_message:
359            try:
360                assert self._activity is not None
361                activity = self._activity()
362                if isinstance(activity, GameActivity):
363                    name_full = player.getname(full=True, icon=False)
364                    activity.show_zoom_message(
365                        Lstr(resource='nameScoresText',
366                             subs=[('${NAME}', name_full)]),
367                        color=_math.normalized_color(player.team.color))
368            except Exception:
369                print_exception('error showing big_message')
370
371        # If we currently have a actor, pop up a score over it.
372        if display and showpoints:
373            our_pos = player.node.position if player.node else None
374            if our_pos is not None:
375                if target is None:
376                    target = our_pos
377
378                # If display-pos is *way* lower than us, raise it up
379                # (so we can still see scores from dudes that fell off cliffs).
380                display_pos = (target[0], max(target[1], our_pos[1] - 2.0),
381                               min(target[2], our_pos[2] + 2.0))
382                activity = self.getactivity()
383                if activity is not None:
384                    if title is not None:
385                        sval = Lstr(value='+${A} ${B}',
386                                    subs=[('${A}', str(points)),
387                                          ('${B}', title)])
388                    else:
389                        sval = Lstr(value='+${A}',
390                                    subs=[('${A}', str(points))])
391                    PopupText(sval,
392                              color=display_color,
393                              scale=1.2 * scale,
394                              position=display_pos).autoretain()
395
396        # Tally kills.
397        if kill:
398            s_player.accum_kill_count += 1
399            s_player.kill_count += 1
400
401        # Report non-kill scorings.
402        try:
403            if screenmessage and not kill:
404                _ba.screenmessage(Lstr(resource='nameScoresText',
405                                       subs=[('${NAME}', name)]),
406                                  top=True,
407                                  color=player.color,
408                                  image=player.get_icon())
409        except Exception:
410            print_exception('error announcing score')
411
412        s_player.score += points
413        s_player.accumscore += points
414
415        # Inform a running game of the score.
416        if points != 0:
417            activity = self._activity() if self._activity is not None else None
418            if activity is not None:
419                activity.handlemessage(PlayerScoredMessage(score=points))
420
421        return points
422
423    def player_was_killed(self,
424                          player: ba.Player,
425                          killed: bool = False,
426                          killer: ba.Player | None = None) -> None:
427        """Should be called when a player is killed."""
428        from ba._language import Lstr
429        name = player.getname()
430        prec = self._player_records[name]
431        prec.streak = 0
432        if killed:
433            prec.accum_killed_count += 1
434            prec.killed_count += 1
435        try:
436            if killed and _ba.getactivity().announce_player_deaths:
437                if killer is player:
438                    _ba.screenmessage(Lstr(resource='nameSuicideText',
439                                           subs=[('${NAME}', name)]),
440                                      top=True,
441                                      color=player.color,
442                                      image=player.get_icon())
443                elif killer is not None:
444                    if killer.team is player.team:
445                        _ba.screenmessage(Lstr(resource='nameBetrayedText',
446                                               subs=[('${NAME}',
447                                                      killer.getname()),
448                                                     ('${VICTIM}', name)]),
449                                          top=True,
450                                          color=killer.color,
451                                          image=killer.get_icon())
452                    else:
453                        _ba.screenmessage(Lstr(resource='nameKilledText',
454                                               subs=[('${NAME}',
455                                                      killer.getname()),
456                                                     ('${VICTIM}', name)]),
457                                          top=True,
458                                          color=killer.color,
459                                          image=killer.get_icon())
460                else:
461                    _ba.screenmessage(Lstr(resource='nameDiedText',
462                                           subs=[('${NAME}', name)]),
463                                      top=True,
464                                      color=player.color,
465                                      image=player.get_icon())
466        except Exception:
467            print_exception('error announcing kill')

Manages scores and statistics for a ba.Session.

Category: Gameplay Classes

Stats()
236    def __init__(self) -> None:
237        self._activity: weakref.ref[ba.Activity] | None = None
238        self._player_records: dict[str, PlayerRecord] = {}
239        self.orchestrahitsound1: ba.Sound | None = None
240        self.orchestrahitsound2: ba.Sound | None = None
241        self.orchestrahitsound3: ba.Sound | None = None
242        self.orchestrahitsound4: ba.Sound | None = None
def setactivity(self, activity: ba.Activity | None) -> None:
244    def setactivity(self, activity: ba.Activity | None) -> None:
245        """Set the current activity for this instance."""
246
247        self._activity = None if activity is None else weakref.ref(activity)
248
249        # Load our media into this activity's context.
250        if activity is not None:
251            if activity.expired:
252                print_error('unexpected finalized activity')
253            else:
254                with _ba.Context(activity):
255                    self._load_activity_media()

Set the current activity for this instance.

def getactivity(self) -> ba.Activity | None:
257    def getactivity(self) -> ba.Activity | None:
258        """Get the activity associated with this instance.
259
260        May return None.
261        """
262        if self._activity is None:
263            return None
264        return self._activity()

Get the activity associated with this instance.

May return None.

def reset(self) -> None:
272    def reset(self) -> None:
273        """Reset the stats instance completely."""
274
275        # Just to be safe, lets make sure no multi-kill timers are gonna go off
276        # for no-longer-on-the-list players.
277        for p_entry in list(self._player_records.values()):
278            p_entry.cancel_multi_kill_timer()
279        self._player_records = {}

Reset the stats instance completely.

def reset_accum(self) -> None:
281    def reset_accum(self) -> None:
282        """Reset per-sound sub-scores."""
283        for s_player in list(self._player_records.values()):
284            s_player.cancel_multi_kill_timer()
285            s_player.accumscore = 0
286            s_player.accum_kill_count = 0
287            s_player.accum_killed_count = 0
288            s_player.streak = 0

Reset per-sound sub-scores.

def register_sessionplayer(self, player: ba.SessionPlayer) -> None:
290    def register_sessionplayer(self, player: ba.SessionPlayer) -> None:
291        """Register a ba.SessionPlayer with this score-set."""
292        assert player.exists()  # Invalid refs should never be passed to funcs.
293        name = player.getname()
294        if name in self._player_records:
295            # If the player already exists, update his character and such as
296            # it may have changed.
297            self._player_records[name].associate_with_sessionplayer(player)
298        else:
299            name_full = player.getname(full=True)
300            self._player_records[name] = PlayerRecord(name, name_full, player,
301                                                      self)

Register a ba.SessionPlayer with this score-set.

def get_records(self) -> dict[str, ba.PlayerRecord]:
303    def get_records(self) -> dict[str, ba.PlayerRecord]:
304        """Get PlayerRecord corresponding to still-existing players."""
305        records = {}
306
307        # Go through our player records and return ones whose player id still
308        # corresponds to a player with that name.
309        for record_id, record in self._player_records.items():
310            lastplayer = record.get_last_sessionplayer()
311            if lastplayer and lastplayer.getname() == record_id:
312                records[record_id] = record
313        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:
315    def player_scored(self,
316                      player: ba.Player,
317                      base_points: int = 1,
318                      target: Sequence[float] | None = None,
319                      kill: bool = False,
320                      victim_player: ba.Player | None = None,
321                      scale: float = 1.0,
322                      color: Sequence[float] | None = None,
323                      title: str | ba.Lstr | None = None,
324                      screenmessage: bool = True,
325                      display: bool = True,
326                      importance: int = 1,
327                      showpoints: bool = True,
328                      big_message: bool = False) -> int:
329        """Register a score for the player.
330
331        Return value is actual score with multipliers and such factored in.
332        """
333        # FIXME: Tidy this up.
334        # pylint: disable=cyclic-import
335        # pylint: disable=too-many-branches
336        # pylint: disable=too-many-locals
337        # pylint: disable=too-many-statements
338        from bastd.actor.popuptext import PopupText
339        from ba import _math
340        from ba._gameactivity import GameActivity
341        from ba._language import Lstr
342        del victim_player  # Currently unused.
343        name = player.getname()
344        s_player = self._player_records[name]
345
346        if kill:
347            s_player.submit_kill(showpoints=showpoints)
348
349        display_color: Sequence[float] = (1.0, 1.0, 1.0, 1.0)
350
351        if color is not None:
352            display_color = color
353        elif importance != 1:
354            display_color = (1.0, 1.0, 0.4, 1.0)
355        points = base_points
356
357        # If they want a big announcement, throw a zoom-text up there.
358        if display and big_message:
359            try:
360                assert self._activity is not None
361                activity = self._activity()
362                if isinstance(activity, GameActivity):
363                    name_full = player.getname(full=True, icon=False)
364                    activity.show_zoom_message(
365                        Lstr(resource='nameScoresText',
366                             subs=[('${NAME}', name_full)]),
367                        color=_math.normalized_color(player.team.color))
368            except Exception:
369                print_exception('error showing big_message')
370
371        # If we currently have a actor, pop up a score over it.
372        if display and showpoints:
373            our_pos = player.node.position if player.node else None
374            if our_pos is not None:
375                if target is None:
376                    target = our_pos
377
378                # If display-pos is *way* lower than us, raise it up
379                # (so we can still see scores from dudes that fell off cliffs).
380                display_pos = (target[0], max(target[1], our_pos[1] - 2.0),
381                               min(target[2], our_pos[2] + 2.0))
382                activity = self.getactivity()
383                if activity is not None:
384                    if title is not None:
385                        sval = Lstr(value='+${A} ${B}',
386                                    subs=[('${A}', str(points)),
387                                          ('${B}', title)])
388                    else:
389                        sval = Lstr(value='+${A}',
390                                    subs=[('${A}', str(points))])
391                    PopupText(sval,
392                              color=display_color,
393                              scale=1.2 * scale,
394                              position=display_pos).autoretain()
395
396        # Tally kills.
397        if kill:
398            s_player.accum_kill_count += 1
399            s_player.kill_count += 1
400
401        # Report non-kill scorings.
402        try:
403            if screenmessage and not kill:
404                _ba.screenmessage(Lstr(resource='nameScoresText',
405                                       subs=[('${NAME}', name)]),
406                                  top=True,
407                                  color=player.color,
408                                  image=player.get_icon())
409        except Exception:
410            print_exception('error announcing score')
411
412        s_player.score += points
413        s_player.accumscore += points
414
415        # Inform a running game of the score.
416        if points != 0:
417            activity = self._activity() if self._activity is not None else None
418            if activity is not None:
419                activity.handlemessage(PlayerScoredMessage(score=points))
420
421        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:
423    def player_was_killed(self,
424                          player: ba.Player,
425                          killed: bool = False,
426                          killer: ba.Player | None = None) -> None:
427        """Should be called when a player is killed."""
428        from ba._language import Lstr
429        name = player.getname()
430        prec = self._player_records[name]
431        prec.streak = 0
432        if killed:
433            prec.accum_killed_count += 1
434            prec.killed_count += 1
435        try:
436            if killed and _ba.getactivity().announce_player_deaths:
437                if killer is player:
438                    _ba.screenmessage(Lstr(resource='nameSuicideText',
439                                           subs=[('${NAME}', name)]),
440                                      top=True,
441                                      color=player.color,
442                                      image=player.get_icon())
443                elif killer is not None:
444                    if killer.team is player.team:
445                        _ba.screenmessage(Lstr(resource='nameBetrayedText',
446                                               subs=[('${NAME}',
447                                                      killer.getname()),
448                                                     ('${VICTIM}', name)]),
449                                          top=True,
450                                          color=killer.color,
451                                          image=killer.get_icon())
452                    else:
453                        _ba.screenmessage(Lstr(resource='nameKilledText',
454                                               subs=[('${NAME}',
455                                                      killer.getname()),
456                                                     ('${VICTIM}', name)]),
457                                          top=True,
458                                          color=killer.color,
459                                          image=killer.get_icon())
460                else:
461                    _ba.screenmessage(Lstr(resource='nameDiedText',
462                                           subs=[('${NAME}', name)]),
463                                      top=True,
464                                      color=player.color,
465                                      image=player.get_icon())
466        except Exception:
467            print_exception('error announcing kill')

Should be called when a player is killed.

def storagename(suffix: str | None = None) -> str:
364def storagename(suffix: str | None = None) -> str:
365    """Generate a unique name for storing class data in shared places.
366
367    Category: **General Utility Functions**
368
369    This consists of a leading underscore, the module path at the
370    call site with dots replaced by underscores, the containing class's
371    qualified name, and the provided suffix. When storing data in public
372    places such as 'customdata' dicts, this minimizes the chance of
373    collisions with other similarly named classes.
374
375    Note that this will function even if called in the class definition.
376
377    ##### Examples
378    Generate a unique name for storage purposes:
379    >>> class MyThingie:
380    ...     # This will give something like
381    ...     # '_mymodule_submodule_mythingie_data'.
382    ...     _STORENAME = ba.storagename('data')
383    ...
384    ...     # Use that name to store some data in the Activity we were
385    ...     # passed.
386    ...     def __init__(self, activity):
387    ...         activity.customdata[self._STORENAME] = {}
388    """
389    frame = inspect.currentframe()
390    if frame is None:
391        raise RuntimeError('Cannot get current stack frame.')
392    fback = frame.f_back
393
394    # Note: We need to explicitly clear frame here to avoid a ref-loop
395    # that keeps all function-dicts in the stack alive until the next
396    # full GC cycle (the stack frame refers to this function's dict,
397    # which refers to the stack frame).
398    del frame
399
400    if fback is None:
401        raise RuntimeError('Cannot get parent stack frame.')
402    modulepath = fback.f_globals.get('__name__')
403    if modulepath is None:
404        raise RuntimeError('Cannot get parent stack module path.')
405    assert isinstance(modulepath, str)
406    qualname = fback.f_locals.get('__qualname__')
407    if qualname is not None:
408        assert isinstance(qualname, str)
409        fullpath = f'_{modulepath}_{qualname.lower()}'
410    else:
411        fullpath = f'_{modulepath}'
412    if suffix is not None:
413        fullpath = f'{fullpath}_{suffix}'
414    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]):
 75class Team(Generic[PlayerType]):
 76    """A team in a specific ba.Activity.
 77
 78    Category: **Gameplay Classes**
 79
 80    These correspond to ba.SessionTeam objects, but are created per activity
 81    so that the activity can use its own custom team subclass.
 82    """
 83
 84    # Defining these types at the class level instead of in __init__ so
 85    # that types are introspectable (these are still instance attrs).
 86    players: list[PlayerType]
 87    id: int
 88    name: ba.Lstr | str
 89    color: tuple[float, ...]  # FIXME: can't we make this fixed length?
 90    _sessionteam: weakref.ref[SessionTeam]
 91    _expired: bool
 92    _postinited: bool
 93    _customdata: dict
 94
 95    # NOTE: avoiding having any __init__() here since it seems to not
 96    # get called by default if a dataclass inherits from us.
 97
 98    def postinit(self, sessionteam: SessionTeam) -> None:
 99        """Wire up a newly created SessionTeam.
100
101        (internal)
102        """
103
104        # Sanity check; if a dataclass is created that inherits from us,
105        # it will define an equality operator by default which will break
106        # internal game logic. So complain loudly if we find one.
107        if type(self).__eq__ is not object.__eq__:
108            raise RuntimeError(
109                f'Team class {type(self)} defines an equality'
110                f' operator (__eq__) which will break internal'
111                f' logic. Please remove it.\n'
112                f'For dataclasses you can do "dataclass(eq=False)"'
113                f' in the class decorator.')
114
115        self.players = []
116        self._sessionteam = weakref.ref(sessionteam)
117        self.id = sessionteam.id
118        self.name = sessionteam.name
119        self.color = sessionteam.color
120        self._customdata = {}
121        self._expired = False
122        self._postinited = True
123
124    def manual_init(self, team_id: int, name: ba.Lstr | str,
125                    color: tuple[float, ...]) -> None:
126        """Manually init a team for uses such as bots."""
127        self.id = team_id
128        self.name = name
129        self.color = color
130        self._customdata = {}
131        self._expired = False
132        self._postinited = True
133
134    @property
135    def customdata(self) -> dict:
136        """Arbitrary values associated with the team.
137        Though it is encouraged that most player values be properly defined
138        on the ba.Team subclass, it may be useful for player-agnostic
139        objects to store values here. This dict is cleared when the team
140        leaves or expires so objects stored here will be disposed of at
141        the expected time, unlike the Team instance itself which may
142        continue to be referenced after it is no longer part of the game.
143        """
144        assert self._postinited
145        assert not self._expired
146        return self._customdata
147
148    def leave(self) -> None:
149        """Called when the Team leaves a running game.
150
151        (internal)
152        """
153        assert self._postinited
154        assert not self._expired
155        del self._customdata
156        del self.players
157
158    def expire(self) -> None:
159        """Called when the Team is expiring (due to the Activity expiring).
160
161        (internal)
162        """
163        assert self._postinited
164        assert not self._expired
165        self._expired = True
166
167        try:
168            self.on_expire()
169        except Exception:
170            print_exception(f'Error in on_expire for {self}.')
171
172        del self._customdata
173        del self.players
174
175    def on_expire(self) -> None:
176        """Can be overridden to handle team expiration."""
177
178    @property
179    def sessionteam(self) -> SessionTeam:
180        """Return the ba.SessionTeam corresponding to this Team.
181
182        Throws a ba.SessionTeamNotFoundError if there is none.
183        """
184        assert self._postinited
185        if self._sessionteam is not None:
186            sessionteam = self._sessionteam()
187            if sessionteam is not None:
188                return sessionteam
189        from ba import _error
190        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:
124    def manual_init(self, team_id: int, name: ba.Lstr | str,
125                    color: tuple[float, ...]) -> None:
126        """Manually init a team for uses such as bots."""
127        self.id = team_id
128        self.name = name
129        self.color = color
130        self._customdata = {}
131        self._expired = False
132        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:
175    def on_expire(self) -> None:
176        """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)
 44                or issubclass(sessiontype, FreeForAllSession))
 45
 46    def __init__(self, settings: dict):
 47        super().__init__(settings)
 48
 49        # By default we don't show kill-points in free-for-all sessions.
 50        # (there's usually some activity-specific score and we don't
 51        # wanna confuse things)
 52        if isinstance(self.session, FreeForAllSession):
 53            self.show_kill_points = False
 54
 55    def on_transition_in(self) -> None:
 56        # pylint: disable=cyclic-import
 57        from ba._coopsession import CoopSession
 58        from bastd.actor.controlsguide import ControlsGuide
 59        super().on_transition_in()
 60
 61        # On the first game, show the controls UI momentarily.
 62        # (unless we're being run in co-op mode, in which case we leave
 63        # it up to them)
 64        if not isinstance(self.session, CoopSession):
 65            attrname = '_have_shown_ctrl_help_overlay'
 66            if not getattr(self.session, attrname, False):
 67                delay = 4.0
 68                lifespan = 10.0
 69                if self.slow_motion:
 70                    lifespan *= 0.3
 71                ControlsGuide(delay=delay,
 72                              lifespan=lifespan,
 73                              scale=0.8,
 74                              position=(380, 200),
 75                              bright=True).autoretain()
 76                setattr(self.session, attrname, True)
 77
 78    def on_begin(self) -> None:
 79        super().on_begin()
 80        try:
 81            # Award a few achievements.
 82            if isinstance(self.session, FreeForAllSession):
 83                if len(self.players) >= 2:
 84                    _ba.app.ach.award_local_achievement('Free Loader')
 85            elif isinstance(self.session, DualTeamSession):
 86                if len(self.players) >= 4:
 87                    from ba import _achievement
 88                    _ba.app.ach.award_local_achievement('Team Player')
 89        except Exception:
 90            from ba import _error
 91            _error.print_exception()
 92
 93    def spawn_player_spaz(self,
 94                          player: PlayerType,
 95                          position: Sequence[float] | None = None,
 96                          angle: float | None = None) -> PlayerSpaz:
 97        """
 98        Method override; spawns and wires up a standard ba.PlayerSpaz for
 99        a ba.Player.
100
101        If position or angle is not supplied, a default will be chosen based
102        on the ba.Player and their ba.Team.
103        """
104        if position is None:
105            # In teams-mode get our team-start-location.
106            if isinstance(self.session, DualTeamSession):
107                position = (self.map.get_start_position(player.team.id))
108            else:
109                # Otherwise do free-for-all spawn locations.
110                position = self.map.get_ffa_start_position(self.players)
111
112        return super().spawn_player_spaz(player, position, angle)
113
114    # FIXME: need to unify these arguments with GameActivity.end()
115    def end(  # type: ignore
116            self,
117            results: Any = None,
118            announce_winning_team: bool = True,
119            announce_delay: float = 0.1,
120            force: bool = False) -> None:
121        """
122        End the game and announce the single winning team
123        unless 'announce_winning_team' is False.
124        (for results without a single most-important winner).
125        """
126        # pylint: disable=arguments-renamed
127        from ba._coopsession import CoopSession
128        from ba._multiteamsession import MultiTeamSession
129        from ba._general import Call
130
131        # Announce win (but only for the first finish() call)
132        # (also don't announce in co-op sessions; we leave that up to them).
133        session = self.session
134        if not isinstance(session, CoopSession):
135            do_announce = not self.has_ended()
136            super().end(results, delay=2.0 + announce_delay, force=force)
137
138            # Need to do this *after* end end call so that results is valid.
139            assert isinstance(results, GameResults)
140            if do_announce and isinstance(session, MultiTeamSession):
141                session.announce_game_results(
142                    self,
143                    results,
144                    delay=announce_delay,
145                    announce_winning_team=announce_winning_team)
146
147        # For co-op we just pass this up the chain with a delay added
148        # (in most cases). Team games expect a delay for the announce
149        # portion in teams/ffa mode so this keeps it consistent.
150        else:
151            # don't want delay on restarts..
152            if (isinstance(results, dict) and 'outcome' in results
153                    and results['outcome'] == 'restart'):
154                delay = 0.0
155            else:
156                delay = 2.0
157                _ba.timer(0.1, Call(_ba.playsound, _ba.getsound('boxingBell')))
158            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)
46    def __init__(self, settings: dict):
47        super().__init__(settings)
48
49        # By default we don't show kill-points in free-for-all sessions.
50        # (there's usually some activity-specific score and we don't
51        # wanna confuse things)
52        if isinstance(self.session, FreeForAllSession):
53            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)
44                or issubclass(sessiontype, FreeForAllSession))

Class method override; returns True for ba.DualTeamSessions and ba.FreeForAllSessions; False otherwise.

def on_transition_in(self) -> None:
55    def on_transition_in(self) -> None:
56        # pylint: disable=cyclic-import
57        from ba._coopsession import CoopSession
58        from bastd.actor.controlsguide import ControlsGuide
59        super().on_transition_in()
60
61        # On the first game, show the controls UI momentarily.
62        # (unless we're being run in co-op mode, in which case we leave
63        # it up to them)
64        if not isinstance(self.session, CoopSession):
65            attrname = '_have_shown_ctrl_help_overlay'
66            if not getattr(self.session, attrname, False):
67                delay = 4.0
68                lifespan = 10.0
69                if self.slow_motion:
70                    lifespan *= 0.3
71                ControlsGuide(delay=delay,
72                              lifespan=lifespan,
73                              scale=0.8,
74                              position=(380, 200),
75                              bright=True).autoretain()
76                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:
78    def on_begin(self) -> None:
79        super().on_begin()
80        try:
81            # Award a few achievements.
82            if isinstance(self.session, FreeForAllSession):
83                if len(self.players) >= 2:
84                    _ba.app.ach.award_local_achievement('Free Loader')
85            elif isinstance(self.session, DualTeamSession):
86                if len(self.players) >= 4:
87                    from ba import _achievement
88                    _ba.app.ach.award_local_achievement('Team Player')
89        except Exception:
90            from ba import _error
91            _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:
 93    def spawn_player_spaz(self,
 94                          player: PlayerType,
 95                          position: Sequence[float] | None = None,
 96                          angle: float | None = None) -> PlayerSpaz:
 97        """
 98        Method override; spawns and wires up a standard ba.PlayerSpaz for
 99        a ba.Player.
100
101        If position or angle is not supplied, a default will be chosen based
102        on the ba.Player and their ba.Team.
103        """
104        if position is None:
105            # In teams-mode get our team-start-location.
106            if isinstance(self.session, DualTeamSession):
107                position = (self.map.get_start_position(player.team.id))
108            else:
109                # Otherwise do free-for-all spawn locations.
110                position = self.map.get_ffa_start_position(self.players)
111
112        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:
115    def end(  # type: ignore
116            self,
117            results: Any = None,
118            announce_winning_team: bool = True,
119            announce_delay: float = 0.1,
120            force: bool = False) -> None:
121        """
122        End the game and announce the single winning team
123        unless 'announce_winning_team' is False.
124        (for results without a single most-important winner).
125        """
126        # pylint: disable=arguments-renamed
127        from ba._coopsession import CoopSession
128        from ba._multiteamsession import MultiTeamSession
129        from ba._general import Call
130
131        # Announce win (but only for the first finish() call)
132        # (also don't announce in co-op sessions; we leave that up to them).
133        session = self.session
134        if not isinstance(session, CoopSession):
135            do_announce = not self.has_ended()
136            super().end(results, delay=2.0 + announce_delay, force=force)
137
138            # Need to do this *after* end end call so that results is valid.
139            assert isinstance(results, GameResults)
140            if do_announce and isinstance(session, MultiTeamSession):
141                session.announce_game_results(
142                    self,
143                    results,
144                    delay=announce_delay,
145                    announce_winning_team=announce_winning_team)
146
147        # For co-op we just pass this up the chain with a delay added
148        # (in most cases). Team games expect a delay for the announce
149        # portion in teams/ffa mode so this keeps it consistent.
150        else:
151            # don't want delay on restarts..
152            if (isinstance(results, dict) and 'outcome' in results
153                    and results['outcome'] == 'restart'):
154                delay = 0.0
155            else:
156                delay = 2.0
157                _ba.timer(0.1, Call(_ba.playsound, _ba.getsound('boxingBell')))
158            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
args
class Texture:
866class Texture:
867    """A reference to a texture.
868
869    Category: **Asset Classes**
870
871    Use ba.gettexture() to instantiate one.
872    """
873    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:
2993def textwidget(edit: ba.Widget | None = None,
2994               parent: ba.Widget | None = None,
2995               size: Sequence[float] | None = None,
2996               position: Sequence[float] | None = None,
2997               text: str | ba.Lstr | None = None,
2998               v_align: str | None = None,
2999               h_align: str | None = None,
3000               editable: bool | None = None,
3001               padding: float | None = None,
3002               on_return_press_call: Callable[[], None] | None = None,
3003               on_activate_call: Callable[[], None] | None = None,
3004               selectable: bool | None = None,
3005               query: ba.Widget | None = None,
3006               max_chars: int | None = None,
3007               color: Sequence[float] | None = None,
3008               click_activate: bool | None = None,
3009               on_select_call: Callable[[], None] | None = None,
3010               always_highlight: bool | None = None,
3011               draw_controller: ba.Widget | None = None,
3012               scale: float | None = None,
3013               corner_scale: float | None = None,
3014               description: str | ba.Lstr | None = None,
3015               transition_delay: float | None = None,
3016               maxwidth: float | None = None,
3017               max_height: float | None = None,
3018               flatness: float | None = None,
3019               shadow: float | None = None,
3020               autoselect: bool | None = None,
3021               rotate: float | None = None,
3022               enabled: bool | None = None,
3023               force_internal_editing: bool | None = None,
3024               always_show_carat: bool | None = None,
3025               big: bool | None = None,
3026               extra_touch_border_scale: float | None = None,
3027               res_scale: float | None = None) -> Widget:
3028    """Create or edit a text widget.
3029
3030    Category: **User Interface Functions**
3031
3032    Pass a valid existing ba.Widget as 'edit' to modify it; otherwise
3033    a new one is created and returned. Arguments that are not set to None
3034    are applied to the Widget.
3035    """
3036    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:
211@dataclass
212class ThawMessage:
213    """Tells an object to stop being frozen.
214
215    Category: **Message Classes**
216    """

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:
3063def time(timetype: ba.TimeType = TimeType.SIM,
3064         timeformat: ba.TimeFormat = TimeFormat.SECONDS) -> Any:
3065    """Return the current time.
3066
3067    Category: **General Utility Functions**
3068
3069    The time returned depends on the current ba.Context and timetype.
3070
3071    timetype can be either SIM, BASE, or REAL. It defaults to
3072    SIM. Types are explained below:
3073
3074    - SIM time maps to local simulation time in ba.Activity or ba.Session
3075    Contexts. This means that it may progress slower in slow-motion play
3076    modes, stop when the game is paused, etc.  This time type is not
3077    available in UI contexts.
3078    - BASE time is also linked to gameplay in ba.Activity or ba.Session
3079    Contexts, but it progresses at a constant rate regardless of
3080     slow-motion states or pausing.  It can, however, slow down or stop
3081    in certain cases such as network outages or game slowdowns due to
3082    cpu load. Like 'sim' time, this is unavailable in UI contexts.
3083    - REAL time always maps to actual clock time with a bit of filtering
3084    added, regardless of Context. (The filtering prevents it from going
3085    backwards or jumping forward by large amounts due to the app being
3086    backgrounded, system time changing, etc.)
3087    Real time timers are currently only available in the UI context.
3088
3089    The 'timeformat' arg defaults to SECONDS which returns float seconds,
3090    but it can also be MILLISECONDS to return integer milliseconds.
3091
3092    Note: If you need pure unfiltered clock time, just use the standard
3093    Python functions such as time.time().
3094    """
3095    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):
88class TimeFormat(Enum):
89    """Specifies the format time values are provided in.
90
91    Category: Enums
92    """
93    SECONDS = 0
94    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:
876class Timer:
877    """Timers are used to run code at later points in time.
878
879    Category: **General Utility Classes**
880
881    This class encapsulates a timer in the current ba.Context.
882    The underlying timer will be destroyed when either this object is
883    no longer referenced or when its Context (Activity, etc.) dies. If you
884    do not want to worry about keeping a reference to your timer around,
885    you should use the ba.timer() function instead.
886
887    ###### time
888    > Length of time (in seconds by default) that the timer will wait
889    before firing. Note that the actual delay experienced may vary
890    depending on the timetype. (see below)
891
892    ###### call
893    > A callable Python object. Note that the timer will retain a
894    strong reference to the callable for as long as it exists, so you
895    may want to look into concepts such as ba.WeakCall if that is not
896    desired.
897
898    ###### repeat
899    > If True, the timer will fire repeatedly, with each successive
900    firing having the same delay as the first.
901
902    ###### timetype
903    > A ba.TimeType value determining which timeline the timer is
904    placed onto.
905
906    ###### timeformat
907    > A ba.TimeFormat value determining how the passed time is
908    interpreted.
909
910    ##### Example
911
912    Use a Timer object to print repeatedly for a few seconds:
913    >>> def say_it():
914    ...     ba.screenmessage('BADGER!')
915    ... def stop_saying_it():
916    ...     self.t = None
917    ... ba.screenmessage('MUSHROOM MUSHROOM!')
918    ... # Create our timer; it will run as long as we have the self.t ref.
919    ... self.t = ba.Timer(0.3, say_it, repeat=True)
920    ... # Now fire off a one-shot timer to kill it.
921    ... ba.timer(3.89, stop_saying_it)
922    """
923
924    def __init__(self,
925                 time: float,
926                 call: Callable[[], Any],
927                 repeat: bool = False,
928                 timetype: ba.TimeType = TimeType.SIM,
929                 timeformat: ba.TimeFormat = TimeFormat.SECONDS,
930                 suppress_format_warning: bool = False):
931        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)
924    def __init__(self,
925                 time: float,
926                 call: Callable[[], Any],
927                 repeat: bool = False,
928                 timetype: ba.TimeType = TimeType.SIM,
929                 timeformat: ba.TimeFormat = TimeFormat.SECONDS,
930                 suppress_format_warning: bool = False):
931        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:
3109def timer(time: float,
3110          call: Callable[[], Any],
3111          repeat: bool = False,
3112          timetype: ba.TimeType = TimeType.SIM,
3113          timeformat: ba.TimeFormat = TimeFormat.SECONDS,
3114          suppress_format_warning: bool = False) -> None:
3115    """Schedule a call to run at a later point in time.
3116
3117    Category: **General Utility Functions**
3118
3119    This function adds a timer to the current ba.Context.
3120    This timer cannot be canceled or modified once created. If you
3121     require the ability to do so, use the ba.Timer class instead.
3122
3123    ##### Arguments
3124    ###### time (float)
3125    > Length of time (in seconds by default) that the timer will wait
3126    before firing. Note that the actual delay experienced may vary
3127     depending on the timetype. (see below)
3128
3129    ###### call (Callable[[], Any])
3130    > A callable Python object. Note that the timer will retain a
3131    strong reference to the callable for as long as it exists, so you
3132    may want to look into concepts such as ba.WeakCall if that is not
3133    desired.
3134
3135    ###### repeat (bool)
3136    > If True, the timer will fire repeatedly, with each successive
3137    firing having the same delay as the first.
3138
3139    ###### timetype (ba.TimeType)
3140    > Can be either `SIM`, `BASE`, or `REAL`. It defaults to
3141    `SIM`.
3142
3143    ###### timeformat (ba.TimeFormat)
3144    > Defaults to seconds but can also be milliseconds.
3145
3146    - SIM time maps to local simulation time in ba.Activity or ba.Session
3147    Contexts. This means that it may progress slower in slow-motion play
3148    modes, stop when the game is paused, etc.  This time type is not
3149    available in UI contexts.
3150    - BASE time is also linked to gameplay in ba.Activity or ba.Session
3151    Contexts, but it progresses at a constant rate regardless of
3152     slow-motion states or pausing.  It can, however, slow down or stop
3153    in certain cases such as network outages or game slowdowns due to
3154    cpu load. Like 'sim' time, this is unavailable in UI contexts.
3155    - REAL time always maps to actual clock time with a bit of filtering
3156    added, regardless of Context. (The filtering prevents it from going
3157    backwards or jumping forward by large amounts due to the app being
3158    backgrounded, system time changing, etc.)
3159    Real time timers are currently only available in the UI context.
3160
3161    ##### Examples
3162    Print some stuff through time:
3163    >>> ba.screenmessage('hello from now!')
3164    >>> ba.timer(1.0, ba.Call(ba.screenmessage, 'hello from the future!'))
3165    >>> ba.timer(2.0, ba.Call(ba.screenmessage,
3166    ...                       'hello from the future 2!'))
3167    """
3168    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, centi: bool = True, timeformat: ba.TimeFormat = <TimeFormat.SECONDS: 0>, suppress_format_warning: bool = False) -> ba.Lstr:
254def timestring(timeval: float,
255               centi: bool = True,
256               timeformat: ba.TimeFormat = TimeFormat.SECONDS,
257               suppress_format_warning: bool = False) -> ba.Lstr:
258    """Generate a ba.Lstr for displaying a time value.
259
260    Category: **General Utility Functions**
261
262    Given a time value, returns a ba.Lstr with:
263    (hours if > 0 ) : minutes : seconds : (centiseconds if centi=True).
264
265    Time 'timeval' is specified in seconds by default, or 'timeformat' can
266    be set to ba.TimeFormat.MILLISECONDS to accept milliseconds instead.
267
268    WARNING: the underlying Lstr value is somewhat large so don't use this
269    to rapidly update Node text values for an onscreen timer or you may
270    consume significant network bandwidth.  For that purpose you should
271    use a 'timedisplay' Node and attribute connections.
272
273    """
274    from ba._language import Lstr
275
276    # Temp sanity check while we transition from milliseconds to seconds
277    # based time values.
278    if __debug__:
279        if not suppress_format_warning:
280            _ba.time_format_check(timeformat, timeval)
281
282    # We operate on milliseconds internally.
283    if timeformat is TimeFormat.SECONDS:
284        timeval = int(1000 * timeval)
285    elif timeformat is TimeFormat.MILLISECONDS:
286        pass
287    else:
288        raise ValueError(f'invalid timeformat: {timeformat}')
289    if not isinstance(timeval, int):
290        timeval = int(timeval)
291    bits = []
292    subs = []
293    hval = (timeval // 1000) // (60 * 60)
294    if hval != 0:
295        bits.append('${H}')
296        subs.append(('${H}',
297                     Lstr(resource='timeSuffixHoursText',
298                          subs=[('${COUNT}', str(hval))])))
299    mval = ((timeval // 1000) // 60) % 60
300    if mval != 0:
301        bits.append('${M}')
302        subs.append(('${M}',
303                     Lstr(resource='timeSuffixMinutesText',
304                          subs=[('${COUNT}', str(mval))])))
305
306    # We add seconds if its non-zero *or* we haven't added anything else.
307    if centi:
308        # pylint: disable=consider-using-f-string
309        sval = (timeval / 1000.0 % 60.0)
310        if sval >= 0.005 or not bits:
311            bits.append('${S}')
312            subs.append(('${S}',
313                         Lstr(resource='timeSuffixSecondsText',
314                              subs=[('${COUNT}', ('%.2f' % sval))])))
315    else:
316        sval = (timeval // 1000 % 60)
317        if sval != 0 or not bits:
318            bits.append('${S}')
319            subs.append(('${S}',
320                         Lstr(resource='timeSuffixSecondsText',
321                              subs=[('${COUNT}', str(sval))])))
322    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):
66class TimeType(Enum):
67    """Specifies the type of time for various operations to target/use.
68
69    Category: Enums
70
71    'sim' time is the local simulation time for an activity or session.
72       It can proceed at different rates depending on game speed, stops
73       for pauses, etc.
74
75    'base' is the baseline time for an activity or session.  It proceeds
76       consistently regardless of game speed or pausing, but may stop during
77       occurrences such as network outages.
78
79    'real' time is mostly based on clock time, with a few exceptions.  It may
80       not advance while the app is backgrounded for instance.  (the engine
81       attempts to prevent single large time jumps from occurring)
82    """
83    SIM = 0
84    BASE = 1
85    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:
164def uicleanupcheck(obj: Any, widget: ba.Widget) -> None:
165    """Add a check to ensure a widget-owning object gets cleaned up properly.
166
167    Category: User Interface Functions
168
169    This adds a check which will print an error message if the provided
170    object still exists ~5 seconds after the provided ba.Widget dies.
171
172    This is a good sanity check for any sort of object that wraps or
173    controls a ba.Widget. For instance, a 'Window' class instance has
174    no reason to still exist once its root container ba.Widget has fully
175    transitioned out and been destroyed. Circular references or careless
176    strong referencing can lead to such objects never getting destroyed,
177    however, and this helps detect such cases to avoid memory leaks.
178    """
179    if DEBUG_UI_CLEANUP_CHECKS:
180        print(f'adding uicleanup to {obj}')
181    if not isinstance(widget, _ba.Widget):
182        raise TypeError('widget arg is not a ba.Widget')
183
184    if bool(False):
185
186        def foobar() -> None:
187            """Just testing."""
188            if DEBUG_UI_CLEANUP_CHECKS:
189                print('uicleanupcheck widget dying...')
190
191        widget.add_delete_callback(foobar)
192
193    _ba.app.ui.cleanupchecks.append(
194        UICleanupCheck(obj=weakref.ref(obj),
195                       widget=widget,
196                       widget_death_time=None))

Add a check to ensure a widget-owning object gets cleaned up properly.

Category: User Interface Functions

This adds a check which will print an error message if the provided object still exists ~5 seconds after the provided ba.Widget dies.

This is a good sanity check for any sort of object that wraps or controls a ba.Widget. For instance, a 'Window' class instance has no reason to still exist once its root container ba.Widget has fully transitioned out and been destroyed. Circular references or careless strong referencing can lead to such objects never getting destroyed, however, and this helps detect such cases to avoid memory leaks.

class UIController:
120class UIController:
121    """Wrangles ba.UILocations.
122
123    Category: User Interface Classes
124    """
125
126    def __init__(self) -> None:
127
128        # FIXME: document why we have separate stacks for game and menu...
129        self._main_stack_game: list[UIEntry] = []
130        self._main_stack_menu: list[UIEntry] = []
131
132        # This points at either the game or menu stack.
133        self._main_stack: list[UIEntry] | None = None
134
135        # There's only one of these since we don't need to preserve its state
136        # between sessions.
137        self._dialog_stack: list[UIEntry] = []
138
139    def show_main_menu(self, in_game: bool = True) -> None:
140        """Show the main menu, clearing other UIs from location stacks."""
141        self._main_stack = []
142        self._dialog_stack = []
143        self._main_stack = (self._main_stack_game
144                            if in_game else self._main_stack_menu)
145        self._main_stack.append(UIEntry('mainmenu', self))
146        self._update_ui()
147
148    def _update_ui(self) -> None:
149        """Instantiate the topmost ui in our stacks."""
150
151        # First tell any existing UIs to get outta here.
152        for stack in (self._dialog_stack, self._main_stack):
153            assert stack is not None
154            for entry in stack:
155                entry.destroy()
156
157        # Now create the topmost one if there is one.
158        entrynew = (self._dialog_stack[-1] if self._dialog_stack else
159                    self._main_stack[-1] if self._main_stack else None)
160        if entrynew is not None:
161            entrynew.create()

Wrangles ba.UILocations.

Category: User Interface Classes

UIController()
126    def __init__(self) -> None:
127
128        # FIXME: document why we have separate stacks for game and menu...
129        self._main_stack_game: list[UIEntry] = []
130        self._main_stack_menu: list[UIEntry] = []
131
132        # This points at either the game or menu stack.
133        self._main_stack: list[UIEntry] | None = None
134
135        # There's only one of these since we don't need to preserve its state
136        # between sessions.
137        self._dialog_stack: list[UIEntry] = []
def show_main_menu(self, in_game: bool = True) -> None:
139    def show_main_menu(self, in_game: bool = True) -> None:
140        """Show the main menu, clearing other UIs from location stacks."""
141        self._main_stack = []
142        self._dialog_stack = []
143        self._main_stack = (self._main_stack_game
144                            if in_game else self._main_stack_menu)
145        self._main_stack.append(UIEntry('mainmenu', self))
146        self._update_ui()

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

class UIScale(enum.Enum):
41class UIScale(Enum):
42    """The overall scale the UI is being rendered for. Note that this is
43    independent of pixel resolution. For example, a phone and a desktop PC
44    might render the game at similar pixel resolutions but the size they
45    display content at will vary significantly.
46
47    Category: Enums
48
49    'large' is used for devices such as desktop PCs where fine details can
50       be clearly seen. UI elements are generally smaller on the screen
51       and more content can be seen at once.
52
53    'medium' is used for devices such as tablets, TVs, or VR headsets.
54       This mode strikes a balance between clean readability and amount of
55       content visible.
56
57    'small' is used primarily for phones or other small devices where
58       content needs to be presented as large and clear in order to remain
59       readable from an average distance.
60    """
61    LARGE = 0
62    MEDIUM = 1
63    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(lambda: _ba.screenmessage(
 94                    f'FORCING UISCALE {self._uiscale.name} FOR TESTING',
 95                    color=(1, 0, 1),
 96                    log=True))
 97
 98        self.controller = UIController()
 99
100        # Kick off our periodic UI upkeep.
101        # FIXME: Can probably kill this if we do immediate UI death checks.
102        self.upkeeptimer = _ba.Timer(2.6543,
103                                     ui_upkeep,
104                                     timetype=TimeType.REAL,
105                                     repeat=True)
106
107    def set_main_menu_window(self, window: ba.Widget) -> None:
108        """Set the current 'main' window, replacing any existing."""
109        existing = self._main_menu_window
110        from ba._generated.enums import TimeType
111        from inspect import currentframe, getframeinfo
112
113        # Let's grab the location where we were called from to report
114        # if we have to force-kill the existing window (which normally
115        # should not happen).
116        frameline = None
117        try:
118            frame = currentframe()
119            if frame is not None:
120                frame = frame.f_back
121            if frame is not None:
122                frameinfo = getframeinfo(frame)
123                frameline = f'{frameinfo.filename} {frameinfo.lineno}'
124        except Exception:
125            from ba._error import print_exception
126            print_exception('Error calcing line for set_main_menu_window')
127
128        # With our legacy main-menu system, the caller is responsible for
129        # clearing out the old main menu window when assigning the new.
130        # However there are corner cases where that doesn't happen and we get
131        # old windows stuck under the new main one. So let's guard against
132        # that. However, we can't simply delete the existing main window when
133        # a new one is assigned because the user may transition the old out
134        # *after* the assignment. Sigh. So, as a happy medium, let's check in
135        # on the old after a short bit of time and kill it if its still alive.
136        # That will be a bit ugly on screen but at least should un-break
137        # things.
138        def _delay_kill() -> None:
139            import time
140            if existing:
141                print(f'Killing old main_menu_window'
142                      f' when called at: {frameline} t={time.time():.3f}')
143                existing.delete()
144
145        _ba.timer(1.0, _delay_kill, timetype=TimeType.REAL)
146        self._main_menu_window = window
147
148    def clear_main_menu_window(self, transition: str | None = None) -> None:
149        """Clear any existing 'main' window with the provided transition."""
150        if self._main_menu_window:
151            if transition is not None:
152                _ba.containerwidget(edit=self._main_menu_window,
153                                    transition=transition)
154            else:
155                self._main_menu_window.delete()
156
157    def has_main_menu_window(self) -> bool:
158        """Return whether a main menu window is present."""
159        return bool(self._main_menu_window)
160
161    def set_main_menu_location(self, location: str) -> None:
162        """Set the location represented by the current main menu window."""
163        self._main_menu_location = location
164
165    def get_main_menu_location(self) -> str | None:
166        """Return the current named main menu location, if any."""
167        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(lambda: _ba.screenmessage(
 94                    f'FORCING UISCALE {self._uiscale.name} FOR TESTING',
 95                    color=(1, 0, 1),
 96                    log=True))
 97
 98        self.controller = UIController()
 99
100        # Kick off our periodic UI upkeep.
101        # FIXME: Can probably kill this if we do immediate UI death checks.
102        self.upkeeptimer = _ba.Timer(2.6543,
103                                     ui_upkeep,
104                                     timetype=TimeType.REAL,
105                                     repeat=True)

Should be run on app launch.

def set_main_menu_window(self, window: ba.Widget) -> None:
107    def set_main_menu_window(self, window: ba.Widget) -> None:
108        """Set the current 'main' window, replacing any existing."""
109        existing = self._main_menu_window
110        from ba._generated.enums import TimeType
111        from inspect import currentframe, getframeinfo
112
113        # Let's grab the location where we were called from to report
114        # if we have to force-kill the existing window (which normally
115        # should not happen).
116        frameline = None
117        try:
118            frame = currentframe()
119            if frame is not None:
120                frame = frame.f_back
121            if frame is not None:
122                frameinfo = getframeinfo(frame)
123                frameline = f'{frameinfo.filename} {frameinfo.lineno}'
124        except Exception:
125            from ba._error import print_exception
126            print_exception('Error calcing line for set_main_menu_window')
127
128        # With our legacy main-menu system, the caller is responsible for
129        # clearing out the old main menu window when assigning the new.
130        # However there are corner cases where that doesn't happen and we get
131        # old windows stuck under the new main one. So let's guard against
132        # that. However, we can't simply delete the existing main window when
133        # a new one is assigned because the user may transition the old out
134        # *after* the assignment. Sigh. So, as a happy medium, let's check in
135        # on the old after a short bit of time and kill it if its still alive.
136        # That will be a bit ugly on screen but at least should un-break
137        # things.
138        def _delay_kill() -> None:
139            import time
140            if existing:
141                print(f'Killing old main_menu_window'
142                      f' when called at: {frameline} t={time.time():.3f}')
143                existing.delete()
144
145        _ba.timer(1.0, _delay_kill, timetype=TimeType.REAL)
146        self._main_menu_window = window

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

def clear_main_menu_window(self, transition: str | None = None) -> None:
148    def clear_main_menu_window(self, transition: str | None = None) -> None:
149        """Clear any existing 'main' window with the provided transition."""
150        if self._main_menu_window:
151            if transition is not None:
152                _ba.containerwidget(edit=self._main_menu_window,
153                                    transition=transition)
154            else:
155                self._main_menu_window.delete()

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

def has_main_menu_window(self) -> bool:
157    def has_main_menu_window(self) -> bool:
158        """Return whether a main menu window is present."""
159        return bool(self._main_menu_window)

Return whether a main menu window is present.

def set_main_menu_location(self, location: str) -> None:
161    def set_main_menu_location(self, location: str) -> None:
162        """Set the location represented by the current main menu window."""
163        self._main_menu_location = location

Set the location represented by the current main menu window.

def get_main_menu_location(self) -> str | None:
165    def get_main_menu_location(self) -> str | None:
166        """Return the current named main menu location, if any."""
167        return self._main_menu_location

Return the current named main menu location, if any.

UNHANDLED = <ba._messages._UnhandledType object>
class Vec3(typing.Sequence[float]):
 934class Vec3(Sequence[float]):
 935    """A vector of 3 floats.
 936
 937    Category: **General Utility Classes**
 938
 939    These can be created the following ways (checked in this order):
 940    - with no args, all values are set to 0
 941    - with a single numeric arg, all values are set to that value
 942    - with a single three-member sequence arg, sequence values are copied
 943    - otherwise assumes individual x/y/z args (positional or keywords)
 944    """
 945    x: float
 946    """The vector's X component."""
 947
 948    y: float
 949    """The vector's Y component."""
 950
 951    z: float
 952    """The vector's Z component."""
 953
 954    # pylint: disable=function-redefined
 955
 956    @overload
 957    def __init__(self) -> None:
 958        pass
 959
 960    @overload
 961    def __init__(self, value: float):
 962        pass
 963
 964    @overload
 965    def __init__(self, values: Sequence[float]):
 966        pass
 967
 968    @overload
 969    def __init__(self, x: float, y: float, z: float):
 970        pass
 971
 972    def __init__(self, *args: Any, **kwds: Any):
 973        pass
 974
 975    def __add__(self, other: Vec3) -> Vec3:
 976        return self
 977
 978    def __sub__(self, other: Vec3) -> Vec3:
 979        return self
 980
 981    @overload
 982    def __mul__(self, other: float) -> Vec3:
 983        return self
 984
 985    @overload
 986    def __mul__(self, other: Sequence[float]) -> Vec3:
 987        return self
 988
 989    def __mul__(self, other: Any) -> Any:
 990        return self
 991
 992    @overload
 993    def __rmul__(self, other: float) -> Vec3:
 994        return self
 995
 996    @overload
 997    def __rmul__(self, other: Sequence[float]) -> Vec3:
 998        return self
 999
1000    def __rmul__(self, other: Any) -> Any:
1001        return self
1002
1003    # (for index access)
1004    def __getitem__(self, typeargs: Any) -> Any:
1005        return 0.0
1006
1007    def __len__(self) -> int:
1008        return 3
1009
1010    # (for iterator access)
1011    def __iter__(self) -> Any:
1012        return self
1013
1014    def __next__(self) -> float:
1015        return 0.0
1016
1017    def __neg__(self) -> Vec3:
1018        return self
1019
1020    def __setitem__(self, index: int, val: float) -> None:
1021        pass
1022
1023    def cross(self, other: Vec3) -> Vec3:
1024        """Returns the cross product of this vector and another."""
1025        return Vec3()
1026
1027    def dot(self, other: Vec3) -> float:
1028        """Returns the dot product of this vector and another."""
1029        return float()
1030
1031    def length(self) -> float:
1032        """Returns the length of the vector."""
1033        return float()
1034
1035    def normalized(self) -> Vec3:
1036        """Returns a normalized version of the vector."""
1037        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)
972    def __init__(self, *args: Any, **kwds: Any):
973        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:
1023    def cross(self, other: Vec3) -> Vec3:
1024        """Returns the cross product of this vector and another."""
1025        return Vec3()

Returns the cross product of this vector and another.

def dot(self, other: ba.Vec3) -> float:
1027    def dot(self, other: Vec3) -> float:
1028        """Returns the dot product of this vector and another."""
1029        return float()

Returns the dot product of this vector and another.

def length(self) -> float:
1031    def length(self) -> float:
1032        """Returns the length of the vector."""
1033        return float()

Returns the length of the vector.

def normalized(self) -> ba.Vec3:
1035    def normalized(self) -> Vec3:
1036        """Returns a normalized version of the vector."""
1037        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    if not isinstance(value, abc.Sequence):
29        raise TypeError(f"Expected a sequence; got {type(value)}")
30    if len(value) != 3:
31        raise TypeError(f"Expected a length-3 sequence (got {len(value)})")
32    if not all(isinstance(i, Number) for i in value):
33        raise TypeError(f"Non-numeric value passed for vec3: {value}")
34    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:
286def verify_object_death(obj: object) -> None:
287    """Warn if an object does not get freed within a short period.
288
289    Category: **General Utility Functions**
290
291    This can be handy to detect and prevent memory/resource leaks.
292    """
293    try:
294        ref = weakref.ref(obj)
295    except Exception:
296        print_exception('Unable to create weak-ref in verify_object_death')
297
298    # Use a slight range for our checks so they don't all land at once
299    # if we queue a lot of them.
300    delay = random.uniform(2.0, 5.5)
301    with _ba.Context('ui'):
302        _ba.timer(delay,
303                  lambda: _verify_object_death(ref),
304                  timetype=TimeType.REAL)

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.

class WeakCall:
142class _WeakCall:
143    """Wrap a callable and arguments into a single callable object.
144
145    Category: **General Utility Classes**
146
147    When passed a bound method as the callable, the instance portion
148    of it is weak-referenced, meaning the underlying instance is
149    free to die if all other references to it go away. Should this
150    occur, calling the WeakCall is simply a no-op.
151
152    Think of this as a handy way to tell an object to do something
153    at some point in the future if it happens to still exist.
154
155    ##### Examples
156    **EXAMPLE A:** this code will create a FooClass instance and call its
157    bar() method 5 seconds later; it will be kept alive even though
158    we overwrite its variable with None because the bound method
159    we pass as a timer callback (foo.bar) strong-references it
160    >>> foo = FooClass()
161    ... ba.timer(5.0, foo.bar)
162    ... foo = None
163
164    **EXAMPLE B:** This code will *not* keep our object alive; it will die
165    when we overwrite it with None and the timer will be a no-op when it
166    fires
167    >>> foo = FooClass()
168    ... ba.timer(5.0, ba.WeakCall(foo.bar))
169    ... foo = None
170
171    **EXAMPLE C:** Wrap a method call with some positional and keyword args:
172    >>> myweakcall = ba.WeakCall(self.dostuff, argval1,
173    ...                          namedarg=argval2)
174    ... # Now we have a single callable to run that whole mess.
175    ... # The same as calling myobj.dostuff(argval1, namedarg=argval2)
176    ... # (provided my_obj still exists; this will do nothing
177    ... # otherwise).
178    ... myweakcall()
179
180    Note: additional args and keywords you provide to the WeakCall()
181    constructor are stored as regular strong-references; you'll need
182    to wrap them in weakrefs manually if desired.
183    """
184
185    def __init__(self, *args: Any, **keywds: Any) -> None:
186        """Instantiate a WeakCall.
187
188        Pass a callable as the first arg, followed by any number of
189        arguments or keywords.
190        """
191        if hasattr(args[0], '__func__'):
192            self._call = WeakMethod(args[0])
193        else:
194            app = _ba.app
195            if not app.did_weak_call_warning:
196                print(('Warning: callable passed to ba.WeakCall() is not'
197                       ' weak-referencable (' + str(args[0]) +
198                       '); use ba.Call() instead to avoid this '
199                       'warning. Stack-trace:'))
200                import traceback
201                traceback.print_stack()
202                app.did_weak_call_warning = True
203            self._call = args[0]
204        self._args = args[1:]
205        self._keywds = keywds
206
207    def __call__(self, *args_extra: Any) -> Any:
208        return self._call(*self._args + args_extra, **self._keywds)
209
210    def __str__(self) -> str:
211        return ('<ba.WeakCall object; _call=' + str(self._call) + ' _args=' +
212                str(self._args) + ' _keywds=' + str(self._keywds) + '>')

Wrap a callable and arguments into a single callable object.

Category: General Utility Classes

When passed a bound method as the callable, the instance portion of it is weak-referenced, meaning the underlying instance is free to die if all other references to it go away. Should this occur, calling the WeakCall is simply a no-op.

Think of this as a handy way to tell an object to do something at some point in the future if it happens to still exist.

Examples

EXAMPLE A: this code will create a FooClass instance and call its bar() method 5 seconds later; it will be kept alive even though we overwrite its variable with None because the bound method we pass as a timer callback (foo.bar) strong-references it

>>> foo = FooClass()
... ba.timer(5.0, foo.bar)
... foo = None

EXAMPLE B: This code will not keep our object alive; it will die when we overwrite it with None and the timer will be a no-op when it fires

>>> foo = FooClass()
... ba.timer(5.0, ba.WeakCall(foo.bar))
... foo = None

EXAMPLE C: Wrap a method call with some positional and keyword args:

>>> myweakcall = ba.WeakCall(self.dostuff, argval1,
...                          namedarg=argval2)
... # Now we have a single callable to run that whole mess.
... # The same as calling myobj.dostuff(argval1, namedarg=argval2)
... # (provided my_obj still exists; this will do nothing
... # otherwise).
... myweakcall()

Note: additional args and keywords you provide to the WeakCall() constructor are stored as regular strong-references; you'll need to wrap them in weakrefs manually if desired.

WeakCall(*args: Any, **keywds: Any)
185    def __init__(self, *args: Any, **keywds: Any) -> None:
186        """Instantiate a WeakCall.
187
188        Pass a callable as the first arg, followed by any number of
189        arguments or keywords.
190        """
191        if hasattr(args[0], '__func__'):
192            self._call = WeakMethod(args[0])
193        else:
194            app = _ba.app
195            if not app.did_weak_call_warning:
196                print(('Warning: callable passed to ba.WeakCall() is not'
197                       ' weak-referencable (' + str(args[0]) +
198                       '); use ba.Call() instead to avoid this '
199                       'warning. Stack-trace:'))
200                import traceback
201                traceback.print_stack()
202                app.did_weak_call_warning = True
203            self._call = args[0]
204        self._args = args[1:]
205        self._keywds = keywds

Instantiate a WeakCall.

Pass a callable as the first arg, followed by any number of arguments or keywords.

class Widget:
1040class Widget:
1041    """Internal type for low level UI elements; buttons, windows, etc.
1042
1043    Category: **User Interface Classes**
1044
1045    This class represents a weak reference to a widget object
1046    in the internal C++ layer. Currently, functions such as
1047    ba.buttonwidget() must be used to instantiate or edit these.
1048    """
1049
1050    def activate(self) -> None:
1051        """Activates a widget; the same as if it had been clicked."""
1052        return None
1053
1054    def add_delete_callback(self, call: Callable) -> None:
1055        """Add a call to be run immediately after this widget is destroyed."""
1056        return None
1057
1058    def delete(self, ignore_missing: bool = True) -> None:
1059        """Delete the Widget. Ignores already-deleted Widgets if ignore_missing
1060        is True; otherwise an Exception is thrown.
1061        """
1062        return None
1063
1064    def exists(self) -> bool:
1065        """Returns whether the Widget still exists.
1066        Most functionality will fail on a nonexistent widget.
1067
1068        Note that you can also use the boolean operator for this same
1069        functionality, so a statement such as "if mywidget" will do
1070        the right thing both for Widget objects and values of None.
1071        """
1072        return bool()
1073
1074    def get_children(self) -> list[ba.Widget]:
1075        """Returns any child Widgets of this Widget."""
1076        return [Widget()]
1077
1078    def get_screen_space_center(self) -> tuple[float, float]:
1079        """Returns the coords of the ba.Widget center relative to the center
1080        of the screen. This can be useful for placing pop-up windows and other
1081        special cases.
1082        """
1083        return (0.0, 0.0)
1084
1085    def get_selected_child(self) -> ba.Widget | None:
1086        """Returns the selected child Widget or None if nothing is selected."""
1087        return Widget()
1088
1089    def get_widget_type(self) -> str:
1090        """Return the internal type of the Widget as a string. Note that this
1091        is different from the Python ba.Widget type, which is the same for
1092        all widgets.
1093        """
1094        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:
1050    def activate(self) -> None:
1051        """Activates a widget; the same as if it had been clicked."""
1052        return None

Activates a widget; the same as if it had been clicked.

def add_delete_callback(self, call: Callable) -> None:
1054    def add_delete_callback(self, call: Callable) -> None:
1055        """Add a call to be run immediately after this widget is destroyed."""
1056        return None

Add a call to be run immediately after this widget is destroyed.

def delete(self, ignore_missing: bool = True) -> None:
1058    def delete(self, ignore_missing: bool = True) -> None:
1059        """Delete the Widget. Ignores already-deleted Widgets if ignore_missing
1060        is True; otherwise an Exception is thrown.
1061        """
1062        return None

Delete the Widget. Ignores already-deleted Widgets if ignore_missing is True; otherwise an Exception is thrown.

def exists(self) -> bool:
1064    def exists(self) -> bool:
1065        """Returns whether the Widget still exists.
1066        Most functionality will fail on a nonexistent widget.
1067
1068        Note that you can also use the boolean operator for this same
1069        functionality, so a statement such as "if mywidget" will do
1070        the right thing both for Widget objects and values of None.
1071        """
1072        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]:
1074    def get_children(self) -> list[ba.Widget]:
1075        """Returns any child Widgets of this Widget."""
1076        return [Widget()]

Returns any child Widgets of this Widget.

def get_screen_space_center(self) -> tuple[float, float]:
1078    def get_screen_space_center(self) -> tuple[float, float]:
1079        """Returns the coords of the ba.Widget center relative to the center
1080        of the screen. This can be useful for placing pop-up windows and other
1081        special cases.
1082        """
1083        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:
1085    def get_selected_child(self) -> ba.Widget | None:
1086        """Returns the selected child Widget or None if nothing is selected."""
1087        return Widget()

Returns the selected child Widget or None if nothing is selected.

def get_widget_type(self) -> str:
1089    def get_widget_type(self) -> str:
1090        """Return the internal type of the Widget as a string. Note that this
1091        is different from the Python ba.Widget type, which is the same for
1092        all widgets.
1093        """
1094        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:
3203def widget(edit: ba.Widget | None = None,
3204           up_widget: ba.Widget | None = None,
3205           down_widget: ba.Widget | None = None,
3206           left_widget: ba.Widget | None = None,
3207           right_widget: ba.Widget | None = None,
3208           show_buffer_top: float | None = None,
3209           show_buffer_bottom: float | None = None,
3210           show_buffer_left: float | None = None,
3211           show_buffer_right: float | None = None,
3212           autoselect: bool | None = None) -> None:
3213    """Edit common attributes of any widget.
3214
3215    Category: **User Interface Functions**
3216
3217    Unlike other UI calls, this can only be used to edit, not to create.
3218    """
3219    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):
122class WidgetNotFoundError(NotFoundError):
123    """Exception raised when an expected ba.Widget does not exist.
124
125    Category: **Exception Classes**
126    """

Exception raised when an expected ba.Widget does not exist.

Category: Exception Classes

Inherited Members
builtins.Exception
Exception
builtins.BaseException
with_traceback
args
class Window:
27class Window:
28    """A basic window.
29
30    Category: User Interface Classes
31    """
32
33    def __init__(self, root_widget: ba.Widget, cleanupcheck: bool = True):
34        self._root_widget = root_widget
35
36        # Complain if we outlive our root widget.
37        if cleanupcheck:
38            uicleanupcheck(self, root_widget)
39
40    def get_root_widget(self) -> ba.Widget:
41        """Return the root widget."""
42        return self._root_widget

A basic window.

Category: User Interface Classes

Window(root_widget: ba.Widget, cleanupcheck: bool = True)
33    def __init__(self, root_widget: ba.Widget, cleanupcheck: bool = True):
34        self._root_widget = root_widget
35
36        # Complain if we outlive our root widget.
37        if cleanupcheck:
38            uicleanupcheck(self, root_widget)
def get_root_widget(self) -> ba.Widget:
40    def get_root_widget(self) -> ba.Widget:
41        """Return the root widget."""
42        return self._root_widget

Return the root widget.