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
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
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
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
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.
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.
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.
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.
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.
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'.
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()
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.
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.
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.
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.
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.
The settings dict passed in when the activity was made. This attribute is deprecated and should be avoided when possible; activities should pull all values they need from the 'settings' arg passed to the Activity __init__ call.
The list of ba.Team-s in the Activity. This gets populated just before on_begin() is called and is updated automatically as players join or leave the game. (at least in free-for-all mode where every player gets their own team; in teams mode there are always 2 teams regardless of the player count).
The list of ba.Player-s in the Activity. This gets populated just before on_begin() is called and is updated automatically as players join or leave the game.
Whether to print every time a player dies. This can be pertinent in games such as Death-Match but can be annoying in games where it doesn't matter.
Joining activities are for waiting for initial player joins. They are treated slightly differently than regular activities, mainly in that all players are passed to the activity at once instead of as each joins.
Whether idle players can potentially be kicked (should not happen in menus/etc).
In vr mode, this determines whether overlay nodes (text, images, etc) are created at a fixed position in space or one that moves based on the current map. Generally this should be on for games and off for transitions/score-screens/etc. that persist between maps.
Set this to True to inherit slow motion setting from previous activity (useful for transitions to avoid hitches).
Set this to True to keep playing the music from the previous activity (without even restarting it).
Set this to true to inherit VR camera offsets from the previous activity (useful for preventing sporadic camera movement during transitions).
Set this to true to inherit (non-fixed) VR overlay positioning from the previous activity (useful for prevent sporadic overlay jostling during transitions).
Set this to true to inherit screen tint/vignette colors from the previous activity (useful to prevent sudden color changes during transitions).
Whether players should be allowed to join in the middle of this activity. Note that Sessions may not allow mid-activity-joins even if the activity says its ok.
If the activity fades or transitions in, it should set the length of time here so that previous activities will be kept alive for that long (avoiding 'holes' in the screen) This value is given in real-time seconds.
Is it ok to show an ad after this activity ends before showing the next activity?
The 'globals' ba.Node for the activity. This contains various global controls and values.
The stats instance accessible while the activity is running.
If access is attempted before or after, raises a ba.NotFoundError.
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.
Entities needing to store simple data with an activity can put it here. This dict will be deleted when the activity expires, so contained objects generally do not need to worry about handling expired activities.
Whether the activity is expired.
An activity is set as expired when shutting down. At this point no new nodes, timers, etc should be made, run, etc, and the activity should be considered a 'zombie'.
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.
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)
The ba.Session this ba.Activity belongs go.
Raises a ba.SessionNotFoundError if the Session no longer exists.
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)
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.
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)
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Inherited Members
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
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())
70 def __init__(self) -> None: 71 """Instantiates an Actor in the current ba.Activity.""" 72 73 if __debug__: 74 self._root_actor_init_called = True 75 activity = _ba.getactivity() 76 self._activity = weakref.ref(activity) 77 activity.add_actor_weak_ref(self)
Instantiates an Actor in the current ba.Activity.
89 def handlemessage(self, msg: Any) -> Any: 90 """General message handling; can be passed any message object.""" 91 assert not self.expired 92 93 # By default, actors going out-of-bounds simply kill themselves. 94 if isinstance(msg, OutOfBoundsMessage): 95 return self.handlemessage(DieMessage(how=DeathType.OUT_OF_BOUNDS)) 96 97 return UNHANDLED
General message handling; can be passed any message object.
99 def autoretain(self: 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()
116 def on_expire(self) -> None: 117 """Called for remaining `ba.Actor`s when their ba.Activity shuts down. 118 119 Actors can use this opportunity to clear callbacks or other 120 references which have the potential of keeping the ba.Activity 121 alive inadvertently (Activities can not exit cleanly while 122 any Python references to them remain.) 123 124 Once an actor is expired (see ba.Actor.is_expired()) it should no 125 longer perform any game-affecting operations (creating, modifying, 126 or deleting nodes, media, timers, etc.) Attempts to do so will 127 likely result in errors. 128 """
Called for remaining ba.Actor
s when their ba.Activity shuts down.
Actors can use this opportunity to clear callbacks or other references which have the potential of keeping the ba.Activity alive inadvertently (Activities can not exit cleanly while any Python references to them remain.)
Once an actor is expired (see ba.Actor.is_expired()) it should no longer perform any game-affecting operations (creating, modifying, or deleting nodes, media, timers, etc.) Attempts to do so will likely result in errors.
139 def exists(self) -> bool: 140 """Returns whether the Actor is still present in a meaningful way. 141 142 Note that a dying character should still return True here as long as 143 their corpse is visible; this is about presence, not being 'alive' 144 (see ba.Actor.is_alive() for that). 145 146 If this returns False, it is assumed the Actor can be completely 147 deleted without affecting the game; this call is often used 148 when pruning lists of Actors, such as with ba.Actor.autoretain() 149 150 The default implementation of this method always return True. 151 152 Note that the boolean operator for the Actor class calls this method, 153 so a simple "if myactor" test will conveniently do the right thing 154 even if myactor is set to None. 155 """ 156 return True
Returns whether the Actor is still present in a meaningful way.
Note that a dying character should still return True here as long as their corpse is visible; this is about presence, not being 'alive' (see ba.Actor.is_alive() for that).
If this returns False, it is assumed the Actor can be completely deleted without affecting the game; this call is often used when pruning lists of Actors, such as with ba.Actor.autoretain()
The default implementation of this method always return True.
Note that the boolean operator for the Actor class calls this method, so a simple "if myactor" test will conveniently do the right thing even if myactor is set to None.
162 def is_alive(self) -> bool: 163 """Returns whether the Actor is 'alive'. 164 165 What this means is up to the Actor. 166 It is not a requirement for Actors to be able to die; 167 just that they report whether they consider themselves 168 to be alive or not. In cases where dead/alive is 169 irrelevant, True should be returned. 170 """ 171 return True
Returns whether the Actor is 'alive'.
What this means is up to the Actor. It is not a requirement for Actors to be able to die; just that they report whether they consider themselves to be alive or not. In cases where dead/alive is irrelevant, True should be returned.
The Activity this Actor was created in.
Raises a ba.ActivityNotFoundError if the Activity no longer exists.
194 def getactivity(self, doraise: bool = True) -> ba.Activity | None: 195 """Return the ba.Activity this Actor is associated with. 196 197 If the Activity no longer exists, raises a ba.ActivityNotFoundError 198 or returns None depending on whether 'doraise' is True. 199 """ 200 activity = self._activity() 201 if activity is None and doraise: 202 raise ActivityNotFoundError() 203 return activity
Return the ba.Activity this Actor is associated with.
If the Activity no longer exists, raises a ba.ActivityNotFoundError or returns None depending on whether 'doraise' is True.
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
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.
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.
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.
The Logic Thread's Asyncio Event Loop.
This allow async tasks to be run in the logic thread. Note that, at this time, the asyncio loop is encapsulated and explicitly stepped by the engine's logic thread loop and thus things like asyncio.get_running_loop() will not return this loop from most places in the logic thread; only from within a task explicitly created in this loop.
Integer build number.
This value increases by at least 1 with each release of the game. It is independent of the human readable ba.App.version string.
Human-readable version string; something like '1.3.24'.
This should not be interpreted as a number; it may contain string elements such as 'alpha', 'beta', 'test', etc. If a numeric version is needed, use 'ba.App.build_number'.
Whether the 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.
Whether the game was compiled in test mode.
Test mode enables extra checks and features that are useful for release testing but which do not slow the game down significantly.
String for subplatform.
Can be empty. For the 'android' platform, subplatform may be 'google', 'amazon', etc.
The game's api version.
Only Python modules and packages associated with the current API version number will be detected by the game (see the ba_meta tag). This value will change whenever backward-incompatible changes are introduced to game APIs. When that happens, scripts should be updated accordingly and set to target the new API version number.
Bounds of the 'safe' screen area in ui space.
This tuple contains: (x-min, x-max, y-min, y-max)
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.
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.
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.
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.
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.
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.
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.
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.
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'))
Handle a deep link URL.
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.
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.
Inherited Members
- enum.Enum
- name
- value
15class AppConfig(dict): 16 """A special dict that holds the game's persistent configuration values. 17 18 Category: **App Classes** 19 20 It also provides methods for fetching values with app-defined fallback 21 defaults, applying contained values to the game, and committing the 22 config to storage. 23 24 Call ba.appconfig() to get the single shared instance of this class. 25 26 AppConfig data is stored as json on disk on so make sure to only place 27 json-friendly values in it (dict, list, str, float, int, bool). 28 Be aware that tuples will be quietly converted to lists when stored. 29 """ 30 31 def resolve(self, key: str) -> Any: 32 """Given a string key, return a config value (type varies). 33 34 This will substitute application defaults for values not present in 35 the config dict, filter some invalid values, etc. Note that these 36 values do not represent the state of the app; simply the state of its 37 config. Use ba.App to access actual live state. 38 39 Raises an Exception for unrecognized key names. To get the list of keys 40 supported by this method, use ba.AppConfig.builtin_keys(). Note that it 41 is perfectly legal to store other data in the config; it just needs to 42 be accessed through standard dict methods and missing values handled 43 manually. 44 """ 45 return _ba.resolve_appconfig_value(key) 46 47 def default_value(self, key: str) -> Any: 48 """Given a string key, return its predefined default value. 49 50 This is the value that will be returned by ba.AppConfig.resolve() if 51 the key is not present in the config dict or of an incompatible type. 52 53 Raises an Exception for unrecognized key names. To get the list of keys 54 supported by this method, use ba.AppConfig.builtin_keys(). Note that it 55 is perfectly legal to store other data in the config; it just needs to 56 be accessed through standard dict methods and missing values handled 57 manually. 58 """ 59 return _ba.get_appconfig_default_value(key) 60 61 def builtin_keys(self) -> list[str]: 62 """Return the list of valid key names recognized by ba.AppConfig. 63 64 This set of keys can be used with resolve(), default_value(), etc. 65 It does not vary across platforms and may include keys that are 66 obsolete or not relevant on the current running version. (for instance, 67 VR related keys on non-VR platforms). This is to minimize the amount 68 of platform checking necessary) 69 70 Note that it is perfectly legal to store arbitrary named data in the 71 config, but in that case it is up to the user to test for the existence 72 of the key in the config dict, fall back to consistent defaults, etc. 73 """ 74 return _ba.get_appconfig_builtin_keys() 75 76 def apply(self) -> None: 77 """Apply config values to the running app.""" 78 _ba.apply_config() 79 80 def commit(self) -> None: 81 """Commits the config to local storage. 82 83 Note that this call is asynchronous so the actual write to disk may not 84 occur immediately. 85 """ 86 commit_app_config() 87 88 def apply_and_commit(self) -> None: 89 """Run apply() followed by commit(); for convenience. 90 91 (This way the commit() will not occur if apply() hits invalid data) 92 """ 93 self.apply() 94 self.commit()
A special dict that holds the game's persistent configuration values.
Category: App Classes
It also provides methods for fetching values with app-defined fallback defaults, applying contained values to the game, and committing the config to storage.
Call ba.appconfig() to get the single shared instance of this class.
AppConfig data is stored as json on disk on so make sure to only place json-friendly values in it (dict, list, str, float, int, bool). Be aware that tuples will be quietly converted to lists when stored.
31 def resolve(self, key: str) -> Any: 32 """Given a string key, return a config value (type varies). 33 34 This will substitute application defaults for values not present in 35 the config dict, filter some invalid values, etc. Note that these 36 values do not represent the state of the app; simply the state of its 37 config. Use ba.App to access actual live state. 38 39 Raises an Exception for unrecognized key names. To get the list of keys 40 supported by this method, use ba.AppConfig.builtin_keys(). Note that it 41 is perfectly legal to store other data in the config; it just needs to 42 be accessed through standard dict methods and missing values handled 43 manually. 44 """ 45 return _ba.resolve_appconfig_value(key)
Given a string key, return a config value (type varies).
This will substitute application defaults for values not present in the config dict, filter some invalid values, etc. Note that these values do not represent the state of the app; simply the state of its config. Use ba.App to access actual live state.
Raises an Exception for unrecognized key names. To get the list of keys supported by this method, use ba.AppConfig.builtin_keys(). Note that it is perfectly legal to store other data in the config; it just needs to be accessed through standard dict methods and missing values handled manually.
47 def default_value(self, key: str) -> Any: 48 """Given a string key, return its predefined default value. 49 50 This is the value that will be returned by ba.AppConfig.resolve() if 51 the key is not present in the config dict or of an incompatible type. 52 53 Raises an Exception for unrecognized key names. To get the list of keys 54 supported by this method, use ba.AppConfig.builtin_keys(). Note that it 55 is perfectly legal to store other data in the config; it just needs to 56 be accessed through standard dict methods and missing values handled 57 manually. 58 """ 59 return _ba.get_appconfig_default_value(key)
Given a string key, return its predefined default value.
This is the value that will be returned by ba.AppConfig.resolve() if the key is not present in the config dict or of an incompatible type.
Raises an Exception for unrecognized key names. To get the list of keys supported by this method, use ba.AppConfig.builtin_keys(). Note that it is perfectly legal to store other data in the config; it just needs to be accessed through standard dict methods and missing values handled manually.
61 def builtin_keys(self) -> list[str]: 62 """Return the list of valid key names recognized by ba.AppConfig. 63 64 This set of keys can be used with resolve(), default_value(), etc. 65 It does not vary across platforms and may include keys that are 66 obsolete or not relevant on the current running version. (for instance, 67 VR related keys on non-VR platforms). This is to minimize the amount 68 of platform checking necessary) 69 70 Note that it is perfectly legal to store arbitrary named data in the 71 config, but in that case it is up to the user to test for the existence 72 of the key in the config dict, fall back to consistent defaults, etc. 73 """ 74 return _ba.get_appconfig_builtin_keys()
Return the list of valid key names recognized by ba.AppConfig.
This set of keys can be used with resolve(), default_value(), etc. It does not vary across platforms and may include keys that are obsolete or not relevant on the current running version. (for instance, VR related keys on non-VR platforms). This is to minimize the amount of platform checking necessary)
Note that it is perfectly legal to store arbitrary named data in the config, but in that case it is up to the user to test for the existence of the key in the config dict, fall back to consistent defaults, etc.
80 def commit(self) -> None: 81 """Commits the config to local storage. 82 83 Note that this call is asynchronous so the actual write to disk may not 84 occur immediately. 85 """ 86 commit_app_config()
Commits the config to local storage.
Note that this call is asynchronous so the actual write to disk may not occur immediately.
88 def apply_and_commit(self) -> None: 89 """Run apply() followed by commit(); for convenience. 90 91 (This way the commit() will not occur if apply() hits invalid data) 92 """ 93 self.apply() 94 self.commit()
Run apply() followed by commit(); for convenience.
(This way the commit() will not occur if apply() hits invalid data)
Inherited Members
- builtins.dict
- get
- setdefault
- pop
- popitem
- keys
- items
- values
- update
- fromkeys
- clear
- copy
14class AppDelegate: 15 """Defines handlers for high level app functionality. 16 17 Category: App Classes 18 """ 19 20 def create_default_game_settings_ui( 21 self, 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
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.
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
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.
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.
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()
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()
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()
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()
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()
Inherited Members
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
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.
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()
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.
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.
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
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.
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.
78 def reset(self) -> None: 79 """Reset state for the Campaign.""" 80 _ba.app.config.setdefault('Campaigns', {})[self._name] = {}
Reset state for the Campaign.
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).
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
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.
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.
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
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
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)
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
17class Collision: 18 """A class providing info about occurring collisions. 19 20 Category: **Gameplay Classes** 21 """ 22 23 @property 24 def position(self) -> ba.Vec3: 25 """The position of the current collision.""" 26 return _ba.Vec3(_ba.get_collision_info('position')) 27 28 @property 29 def sourcenode(self) -> ba.Node: 30 """The node containing the material triggering the current callback. 31 32 Throws a ba.NodeNotFoundError if the node does not exist, though 33 the node should always exist (at least at the start of the collision 34 callback). 35 """ 36 node = _ba.get_collision_info('sourcenode') 37 assert isinstance(node, (_ba.Node, type(None))) 38 if not node: 39 raise NodeNotFoundError() 40 return node 41 42 @property 43 def opposingnode(self) -> ba.Node: 44 """The node the current callback material node is hitting. 45 46 Throws a ba.NodeNotFoundError if the node does not exist. 47 This can be expected in some cases such as in 'disconnect' 48 callbacks triggered by deleting a currently-colliding node. 49 """ 50 node = _ba.get_collision_info('opposingnode') 51 assert isinstance(node, (_ba.Node, type(None))) 52 if not node: 53 raise NodeNotFoundError() 54 return node 55 56 @property 57 def opposingbody(self) -> int: 58 """The body index on the opposing node in the current collision.""" 59 body = _ba.get_collision_info('opposingbody') 60 assert isinstance(body, int) 61 return body
A class providing info about occurring collisions.
Category: Gameplay Classes
The node containing the material triggering the current callback.
Throws a ba.NodeNotFoundError if the node does not exist, though the node should always exist (at least at the start of the collision callback).
The node the current callback material node is hitting.
Throws a ba.NodeNotFoundError if the node does not exist. This can be expected in some cases such as in 'disconnect' callbacks triggered by deleting a currently-colliding node.
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.
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.
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')
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))
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
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.
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.
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.
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.
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
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.
The ba.Session this ba.Activity belongs go.
Raises a ba.SessionNotFoundError if the Session no longer exists.
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.
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.
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.)
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.
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.
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).
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.
Inherited Members
- GameActivity
- tips
- name
- description
- available_settings
- scoreconfig
- allow_pausing
- allow_kick_idle_players
- show_kill_points
- default_music
- create_settings_ui
- getscoreconfig
- getname
- get_display_string
- get_team_display_string
- get_description
- get_description_display_string
- get_available_settings
- get_supported_maps
- get_settings_display_string
- map
- get_instance_display_string
- get_instance_scoreboard_display_string
- get_instance_description
- get_instance_description_short
- on_transition_in
- on_continue
- is_waiting_for_continue
- continue_or_end_game
- on_player_join
- handlemessage
- end
- end_game
- respawn_player
- spawn_player_if_exists
- spawn_player
- setup_standard_powerup_drops
- setup_standard_time_limit
- show_zoom_message
- Activity
- settings_raw
- teams
- players
- announce_player_deaths
- is_joining_activity
- use_fixed_vr_overlay
- slow_motion
- inherits_slow_motion
- inherits_music
- inherits_vr_camera_offset
- inherits_vr_overlay_center
- inherits_tint
- allow_mid_activity_joins
- transition_time
- can_show_ad_on_death
- globalsnode
- stats
- on_expire
- customdata
- expired
- playertype
- teamtype
- retain_actor
- add_actor_weak_ref
- on_player_leave
- on_team_join
- on_team_leave
- on_transition_out
- has_transitioned_in
- has_begun
- has_ended
- is_transitioning_out
- transition_out
- create_player
- create_team
20class CoopSession(Session): 21 """A ba.Session which runs cooperative-mode games. 22 23 Category: **Gameplay Classes** 24 25 These generally consist of 1-4 players against 26 the computer and include functionality such as 27 high score lists. 28 """ 29 30 use_teams = True 31 use_team_colors = False 32 allow_mid_activity_joins = False 33 34 # Note: even though these are instance vars, we annotate them at the 35 # class level so that docs generation can access their types. 36 37 campaign: ba.Campaign | None 38 """The ba.Campaign instance this Session represents, or None if 39 there is no associated Campaign.""" 40 41 def __init__(self) -> None: 42 """Instantiate a co-op mode session.""" 43 # pylint: disable=cyclic-import 44 from ba._campaign import getcampaign 45 from bastd.activity.coopjoin import CoopJoinActivity 46 47 _ba.increment_analytics_count('Co-op session start') 48 app = _ba.app 49 50 # If they passed in explicit min/max, honor that. 51 # Otherwise defer to user overrides or defaults. 52 if 'min_players' in app.coop_session_args: 53 min_players = app.coop_session_args['min_players'] 54 else: 55 min_players = 1 56 if 'max_players' in app.coop_session_args: 57 max_players = app.coop_session_args['max_players'] 58 else: 59 max_players = app.config.get('Coop Game Max Players', 4) 60 61 # print('FIXME: COOP SESSION WOULD CALC DEPS.') 62 depsets: Sequence[ba.DependencySet] = [] 63 64 super().__init__(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.
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.
Whether this session groups players into an explicit set of teams. If this is off, a unique team is generated for each player that joins.
Whether players on a team should all adopt the colors of that team instead of their own profile colors. This only applies if use_teams is enabled.
The ba.Campaign instance this Session represents, or None if there is no associated Campaign.
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.
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.
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.
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.
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.
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()
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.
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
Inherited Members
- enum.Enum
- name
- value
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
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().
35 def __init__(self, cls: type[T], config: Any = None): 36 """Instantiate a Dependency given a ba.DependencyComponent type. 37 38 Optionally, an arbitrary object can be passed as 'config' to 39 influence dependency calculation for the target class. 40 """ 41 self.cls: type[T] = cls 42 self.config = config 43 self._hash: int | None = None
Instantiate a Dependency given a ba.DependencyComponent type.
Optionally, an arbitrary object can be passed as 'config' to influence dependency calculation for the target class.
45 def get_hash(self) -> int: 46 """Return the dependency's hash, calculating it if necessary.""" 47 from efro.util import make_hash 48 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.
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
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.
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.
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)
17class DependencyError(Exception): 18 """Exception raised when one or more ba.Dependency items are missing. 19 20 Category: **Exception Classes** 21 22 (this will generally be missing assets). 23 """ 24 25 def __init__(self, deps: list[ba.Dependency]): 26 super().__init__() 27 self._deps = deps 28 29 @property 30 def deps(self) -> list[ba.Dependency]: 31 """The list of missing dependencies causing this error.""" 32 return self._deps
Exception raised when one or more ba.Dependency items are missing.
Category: Exception Classes
(this will generally be missing assets).
Inherited Members
- builtins.BaseException
- with_traceback
- args
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.
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).
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.
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.
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."""
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!')
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
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
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
30 def __init__(self) -> None: 31 _ba.increment_analytics_count('Teams session start') 32 super().__init__()
Set up playlists and launches a ba.Activity to accept joiners.
Whether this session groups players into an explicit set of teams. If this is off, a unique team is generated for each player that joins.
Whether players on a team should all adopt the colors of that team instead of their own profile colors. This only applies if use_teams is enabled.
Inherited Members
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.
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.
Inherited Members
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.
Inherited Members
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
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)
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
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
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
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
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.
Whether this session groups players into an explicit set of teams. If this is off, a unique team is generated for each player that joins.
Whether players on a team should all adopt the colors of that team instead of their own profile colors. This only applies if use_teams is enabled.
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.
Inherited Members
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.
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
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.
Whether idle players can potentially be kicked (should not happen in menus/etc).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
The map being used for this game.
Raises a ba.NotFoundError if the map does not currently exist.
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.
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.
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.
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.
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.
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.
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)
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
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.
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)
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.
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.
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.
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).
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().
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().
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.
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.
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.
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.
Inherited Members
- Activity
- settings_raw
- teams
- players
- announce_player_deaths
- is_joining_activity
- use_fixed_vr_overlay
- slow_motion
- inherits_slow_motion
- inherits_music
- inherits_vr_camera_offset
- inherits_vr_overlay_center
- inherits_tint
- allow_mid_activity_joins
- transition_time
- can_show_ad_on_death
- globalsnode
- stats
- on_expire
- customdata
- expired
- playertype
- teamtype
- retain_actor
- add_actor_weak_ref
- session
- on_player_leave
- on_team_join
- on_team_leave
- on_transition_out
- has_transitioned_in
- has_begun
- has_ended
- is_transitioning_out
- transition_out
- create_player
- create_team
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.
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
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.
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)
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.
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.
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.)
The winning ba.SessionTeam if there is exactly one, or else None.
29@dataclass 30class GameTip: 31 """Defines a tip presentable to the user at the start of a game. 32 33 Category: **Gameplay Classes** 34 """ 35 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
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().
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.
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.
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.
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
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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
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
The numeric client-id this device is associated with. This is only meaningful for remote client inputs; for all local devices this will be -1.
A string that can be used to persistently identify the device, even among other devices of the same type. Used for saving prefs, etc.
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.
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.
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)
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
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
Inherited Members
- enum.Enum
- name
- value
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
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
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.)
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).
14class Keyboard: 15 """Chars definitions for on-screen keyboard. 16 17 Category: **App Classes** 18 19 Keyboards are discoverable by the meta-tag system 20 and the user can select which one they want to use. 21 On-screen keyboard uses chars from active ba.Keyboard. 22 """ 23 24 name: str 25 """Displays when user selecting this keyboard.""" 26 27 chars: list[tuple[str, ...]] 28 """Used for row/column lengths.""" 29 30 pages: dict[str, tuple[str, ...]] 31 """Extra chars like emojis.""" 32 33 nums: tuple[str, ...] 34 """The 'num' page."""
Chars definitions for on-screen keyboard.
Category: App Classes
Keyboards are discoverable by the meta-tag system and the user can select which one they want to use. On-screen keyboard uses chars from active ba.Keyboard.
18class LanguageSubsystem: 19 """Wraps up language related app functionality. 20 21 Category: **App Classes** 22 23 To use this class, access the single instance of it at 'ba.app.lang'. 24 """ 25 26 def __init__(self) -> None: 27 self.language_target: AttrDict | None = None 28 self.language_merged: AttrDict | None = None 29 self.default_language = self._get_default_language() 30 31 def _can_display_language(self, language: str) -> bool: 32 """Tell whether we can display a particular language. 33 34 On some platforms we don't have unicode rendering yet 35 which limits the languages we can draw. 36 """ 37 38 # We don't yet support full unicode display on windows or linux :-(. 39 if (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'.
Raw country/language code detected by the game (such as 'en_US').
Generally for language-specific code you should look at ba.App.language, which is the language the game is using (which may differ from locale if the user sets a language, etc.)
The name of the language the game is running in.
This can be selected explicitly by the user or may be set automatically based on ba.App.locale or other factors.
A list of all available languages.
Note that languages that may be present in game assets but which are not displayable on the running version of the game are not included here.
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.
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.
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.
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.
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
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
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.
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.
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.
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.
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.
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.
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.
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.
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
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
A bool for whether this lobby is using team colors.
If False, inidividual player colors are used instead.
874 def get_choosers(self) -> list[Chooser]: 875 """Return the lobby's current choosers.""" 876 return self.choosers
Return the lobby's current choosers.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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).
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.
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.
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.
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.
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
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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)
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.
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.
Inherited Members
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.
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 themodify_part_collision
action)
('eval_not_colliding')
Is 'collide' false at this point in material evaluation? (see the
modify_part_collision
action)
('we_are_younger_than', age)
Is our part younger than
age
(in milliseconds)?
('we_are_older_than', age)
Is our part older than
age
(in milliseconds)?
('they_are_younger_than', age)
Is the part we're hitting younger than
age
(in milliseconds)?
('they_are_older_than', age)
Is the part we're hitting older than
age
(in milliseconds)?
('they_are_same_node_as_us')
Does the part we're hitting belong to the same ba.Node as us?
('they_are_different_node_than_us')
Does the part we're hitting belong to a different ba.Node than us?
Actions
In a similar manner, actions are specified as tuples. Multiple actions can be specified by providing a tuple of tuples.
Available Actions
('call', when, callable)
Calls the provided callable;
when
can be either'at_connect'
or'at_disconnect'
.'at_connect'
means to fire when the two parts first come in contact;'at_disconnect'
means to fire once they cease being in contact.
('message', who, when, message_obj)
Sends a message object;
who
can be either'our_node'
or'their_node'
,when
can be'at_connect'
or'at_disconnect'
, andmessage_obj
is the message object to send. This has the same effect as calling the node's ba.Node.handlemessage() method.
('modify_part_collision', attr, value)
Changes some characteristic of the physical collision that will occur between our part and their part. This change will remain in effect as long as the two parts remain overlapping. This means if you have a part with a material that turns
'collide'
off against parts younger than 100ms, and it touches another part that is 50ms old, it will continue to not collide with that part until they separate, even if the 100ms threshold is passed. Options for attr/value are:'physical'
(boolean value; whether a physical response will occur at all),'friction'
(float value; how friction-y the physical response will be),'collide'
(boolean value; whether any collision will occur at all, including non-physical stuff like callbacks),'use_node_collide'
(boolean value; whether to honor modify_node_collision overrides for this collision),'stiffness'
(float value, how springy the physical response is),'damping'
(float value, how damped the physical response is),'bounce'
(float value; how bouncy the physical response is).
('modify_node_collision', attr, value)
Similar to
modify_part_collision
, but operates at a node-level. collision attributes set here will remain in effect as long as anything from our part's node and their part's node overlap. A key use of this functionality is to prevent new nodes from colliding with each other if they appear overlapped; ifmodify_part_collision
is used, only the individual parts that were overlapping would avoid contact, but other parts could still contact leaving the two nodes 'tangled up'. Usingmodify_node_collision
ensures that the nodes must completely separate before they can start colliding. Currently the only attr available here is'collide'
(a boolean value).
('sound', sound, volume)
Plays a ba.Sound when a collision occurs, at a given volume, regardless of the collision speed/etc.
('impact_sound', sound, targetImpulse, volume)
Plays a sound when a collision occurs, based on the speed of impact. Provide a ba.Sound, a target-impulse, and a volume.
('skid_sound', sound, targetImpulse, volume)
Plays a sound during a collision when parts are 'scraping' against each other. Provide a ba.Sound, a target-impulse, and a volume.
('roll_sound', sound, targetImpulse, volume)
Plays a sound during a collision when parts are 'rolling' against each other. Provide a ba.Sound, a target-impulse, and a volume.
Examples
Example 1: create a material that lets us ignore collisions against any nodes we touch in the first 100 ms of our existence; handy for preventing us from exploding outward if we spawn on top of another object:
>>> m = ba.Material()
... m.add_actions(
... conditions=(('we_are_younger_than', 100),
... 'or', ('they_are_younger_than', 100)),
... actions=('modify_node_collision', 'collide', False))
Example 2: send a ba.DieMessage to anything we touch, but cause no physical response. This should cause any ba.Actor to drop dead:
>>> m = ba.Material()
... m.add_actions(
... actions=(('modify_part_collision', 'physical', False),
... ('message', 'their_node', 'at_connect',
... ba.DieMessage())))
Example 3: play some sounds when we're contacting the ground:
>>> m = ba.Material()
... m.add_actions(
... conditions=('they_have_material',
... shared.footing_material),
... actions=(('impact_sound', ba.getsound('metalHit'), 2, 5),
... ('skid_sound', ba.getsound('metalSkid'), 2, 5)))
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'.
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
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.
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.
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.
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.
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.
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.
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.
125 def get_series_length(self) -> int: 126 """Return teams series length.""" 127 return self._series_length
Return teams series length.
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.
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.
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.
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
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.
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.
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.
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.
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).
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.
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.
435 def shutdown(self) -> None: 436 """Shutdown music playback completely.""" 437 self.on_app_shutdown()
Shutdown music playback completely.
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..
448 def on_set_volume(self, volume: float) -> None: 449 """Called when the volume should be changed."""
Called when the volume should be changed.
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.
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
Inherited Members
- enum.Enum
- name
- value
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'.
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
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().
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Inherited Members
- enum.Enum
- name
- value
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.
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
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).
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.
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')
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.
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.
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.
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
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)
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.
18class NodeActor(Actor): 19 """A simple ba.Actor type that wraps a single ba.Node. 20 21 Category: **Gameplay Classes** 22 23 This Actor will delete its Node when told to die, and it's 24 exists() call will return whether the Node still exists or not. 25 """ 26 27 def __init__(self, node: ba.Node): 28 super().__init__() 29 self.node = node 30 31 def handlemessage(self, msg: Any) -> Any: 32 if isinstance(msg, DieMessage): 33 if self.node: 34 self.node.delete() 35 return None 36 return super().handlemessage(msg) 37 38 def exists(self) -> bool: 39 return bool(self.node)
A simple ba.Actor type that wraps a single ba.Node.
Category: Gameplay Classes
This Actor will delete its Node when told to die, and it's exists() call will return whether the Node still exists or not.
31 def handlemessage(self, msg: Any) -> Any: 32 if isinstance(msg, DieMessage): 33 if self.node: 34 self.node.delete() 35 return None 36 return super().handlemessage(msg)
General message handling; can be passed any message object.
Returns whether the Actor is still present in a meaningful way.
Note that a dying character should still return True here as long as their corpse is visible; this is about presence, not being 'alive' (see ba.Actor.is_alive() for that).
If this returns False, it is assumed the Actor can be completely deleted without affecting the game; this call is often used when pruning lists of Actors, such as with ba.Actor.autoretain()
The default implementation of this method always return True.
Note that the boolean operator for the Actor class calls this method, so a simple "if myactor" test will conveniently do the right thing even if myactor is set to None.
Inherited Members
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
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
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
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.
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
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
Inherited Members
- enum.Enum
- name
- value
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
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
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.
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.
Arbitrary values associated with the player. Though it is encouraged that most player values be properly defined on the ba.Player subclass, it may be useful for player-agnostic objects to store values here. This dict is cleared when the player leaves or expires so objects stored here will be disposed of at the expected time, unlike the Player instance itself which may continue to be referenced after it is no longer part of the game.
Return the ba.SessionPlayer corresponding to this Player.
Throws a ba.SessionPlayerNotFoundError if it does not exist.
A ba.Node of type 'player' associated with this Player.
This node can be used to get a generic player position/etc.
The position of the player, as defined by its current ba.Actor.
If the player currently has no actor, raises a ba.ActorNotFoundError.
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.
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.
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.
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)
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.
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
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.
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.
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.
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
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
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)
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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
.
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.
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.
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.
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.
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.
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.
36@dataclass 37class PowerupAcceptMessage: 38 """A message informing a ba.Powerup that it was accepted. 39 40 Category: **Message Classes** 41 42 This is generally sent in response to a ba.PowerupMessage 43 to inform the box (or whoever granted it) that it can go away. 44 """
A message informing a ba.Powerup that it was accepted.
Category: Message Classes
This is generally sent in response to a ba.PowerupMessage to inform the box (or whoever granted it) that it can go away.
16@dataclass 17class PowerupMessage: 18 """A message telling an object to accept a powerup. 19 20 Category: **Message Classes** 21 22 This message is normally received by touching a ba.PowerupBox. 23 """ 24 25 poweruptype: str 26 """The type of powerup to be granted (a string). 27 See ba.Powerup.poweruptype for available type values.""" 28 29 sourcenode: ba.Node | None = None 30 """The node the powerup game from, or None otherwise. 31 If a powerup is accepted, a ba.PowerupAcceptMessage should be sent 32 back to the sourcenode to inform it of the fact. This will generally 33 cause the powerup box to make a sound and disappear or whatnot."""
A message telling an object to accept a powerup.
Category: Message Classes
This message is normally received by touching a ba.PowerupBox.
The type of powerup to be granted (a string). See ba.Powerup.poweruptype for available type values.
The node the powerup game from, or None otherwise. If a powerup is accepted, a ba.PowerupAcceptMessage should be sent back to the sourcenode to inform it of the fact. This will generally cause the powerup box to make a sound and disappear or whatnot.
169def print_error(err_str: str, once: bool = False) -> None: 170 """Print info about an error along with pertinent context state. 171 172 Category: **General Utility Functions** 173 174 Prints all positional arguments provided along with various info about the 175 current context. 176 Pass the keyword 'once' as True if you want the call to only happen 177 one time from an exact calling location. 178 """ 179 import traceback 180 try: 181 # If we're only printing once and already have, bail. 182 if once: 183 if not _ba.do_once(): 184 return 185 186 print('ERROR:', err_str) 187 _ba.print_context() 188 189 # Basically the output of traceback.print_stack() 190 stackstr = ''.join(traceback.format_stack()) 191 print(stackstr, end='') 192 except Exception: 193 print('ERROR: exception in ba.print_error():') 194 traceback.print_exc()
Print info about an error along with pertinent context state.
Category: General Utility Functions
Prints all positional arguments provided along with various info about the current context. Pass the keyword 'once' as True if you want the call to only happen one time from an exact calling location.
129def print_exception(*args: Any, **keywds: Any) -> None: 130 """Print info about an exception along with pertinent context state. 131 132 Category: **General Utility Functions** 133 134 Prints all arguments provided along with various info about the 135 current context and the outstanding exception. 136 Pass the keyword 'once' as True if you want the call to only happen 137 one time from an exact calling location. 138 """ 139 import traceback 140 if keywds: 141 allowed_keywds = ['once'] 142 if any(keywd not in allowed_keywds for keywd in keywds): 143 raise TypeError('invalid keyword(s)') 144 try: 145 # If we're only printing once and already have, bail. 146 if keywds.get('once', False): 147 if not _ba.do_once(): 148 return 149 150 err_str = ' '.join([str(a) for a in args]) 151 print('ERROR:', err_str) 152 _ba.print_context() 153 print('PRINTED-FROM:') 154 155 # Basically the output of traceback.print_stack() 156 stackstr = ''.join(traceback.format_stack()) 157 print(stackstr, end='') 158 print('EXCEPTION:') 159 160 # Basically the output of traceback.print_exc() 161 excstr = traceback.format_exc() 162 print('\n'.join(' ' + l for l in excstr.splitlines())) 163 except Exception: 164 # I suppose using print_exception here would be a bad idea. 165 print('ERROR: exception in ba.print_exception():') 166 traceback.print_exc()
Print info about an exception along with pertinent context state.
Category: General Utility Functions
Prints all arguments provided along with various info about the current context and the outstanding exception. Pass the keyword 'once' as True if you want the call to only happen one time from an exact calling location.
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
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.
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.
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.
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.
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.
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
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
Inherited Members
- enum.Enum
- name
- value
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.
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.
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
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)
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.
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.
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.
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.
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).
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.
Whether this session groups players into an explicit set of teams. If this is off, a unique team is generated for each player that joins.
Whether players on a team should all adopt the colors of that team instead of their own profile colors. This only applies if use_teams is enabled.
The minimum number of players who must be present for the Session to proceed past the initial joining screen
All ba.SessionPlayers in the Session. Most things should use the list of ba.Player-s in ba.Activity; not this. Some players, such as those who have not yet selected a character, will only be found on this list.
A shared dictionary for objects to use as storage on this session. Ensure that keys here are unique to avoid collisions.
All the ba.SessionTeams in the Session. Most things should use the list of ba.Team-s in ba.Activity; not this.
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.
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.
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.
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.
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.
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.
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.
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.
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)
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.
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.
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
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
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.Node
s, ba.SessionPlayer objects are 'weak'
references under-the-hood; a player can leave the game at
any point. For this reason, you should make judicious use of the
ba.SessionPlayer.exists() method (or boolean operator) to ensure
that a SessionPlayer is still present if retaining references to one
for any length of time.
The unique numeric ID of the Player.
Note that you can also use the boolean operator for this same functionality, so a statement such as "if player" will do the right thing both for Player objects and values of None.
This bool value will be True once the Player has completed any lobby character/team selection.
The ba.SessionTeam this Player is on. If the SessionPlayer is still in its lobby selecting a team/etc. then a ba.SessionTeamNotFoundError will be raised.
The base color for this Player. In team games this will match the ba.SessionTeam's color.
A secondary color for this player. This is used for minor highlights and accents to allow a player to stand apart from his teammates who may all share the same team (primary) color.
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.
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.
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.
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).
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.
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.
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.
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
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.
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.
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.
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
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.)
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.
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
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
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
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
Inherited Members
- enum.Enum
- name
- value
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
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.
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
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
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.
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.
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.
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.
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.
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.
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.
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.
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] = {}
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.
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.
Arbitrary values associated with the team. Though it is encouraged that most player values be properly defined on the ba.Team subclass, it may be useful for player-agnostic objects to store values here. This dict is cleared when the team leaves or expires so objects stored here will be disposed of at the expected time, unlike the Team instance itself which may continue to be referenced after it is no longer part of the game.
Return the ba.SessionTeam corresponding to this Team.
Throws a ba.SessionTeamNotFoundError if there is none.
27class TeamGameActivity(GameActivity[PlayerType, TeamType]): 28 """Base class for teams and free-for-all mode games. 29 30 Category: **Gameplay Classes** 31 32 (Free-for-all is essentially just a special case where every 33 ba.Player has their own ba.Team) 34 """ 35 36 @classmethod 37 def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool: 38 """ 39 Class method override; 40 returns True for ba.DualTeamSessions and ba.FreeForAllSessions; 41 False otherwise. 42 """ 43 return (issubclass(sessiontype, DualTeamSession) 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)
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.
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.
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.
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.
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)
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).
Inherited Members
- GameActivity
- tips
- name
- description
- available_settings
- scoreconfig
- allow_pausing
- allow_kick_idle_players
- show_kill_points
- default_music
- create_settings_ui
- getscoreconfig
- getname
- get_display_string
- get_team_display_string
- get_description
- get_description_display_string
- get_available_settings
- get_supported_maps
- get_settings_display_string
- map
- get_instance_display_string
- get_instance_scoreboard_display_string
- get_instance_description
- get_instance_description_short
- on_continue
- is_waiting_for_continue
- continue_or_end_game
- on_player_join
- handlemessage
- end_game
- respawn_player
- spawn_player_if_exists
- spawn_player
- setup_standard_powerup_drops
- setup_standard_time_limit
- show_zoom_message
- Activity
- settings_raw
- teams
- players
- announce_player_deaths
- is_joining_activity
- use_fixed_vr_overlay
- slow_motion
- inherits_slow_motion
- inherits_music
- inherits_vr_camera_offset
- inherits_vr_overlay_center
- inherits_tint
- allow_mid_activity_joins
- transition_time
- can_show_ad_on_death
- globalsnode
- stats
- on_expire
- customdata
- expired
- playertype
- teamtype
- retain_actor
- add_actor_weak_ref
- session
- on_player_leave
- on_team_join
- on_team_leave
- on_transition_out
- has_transitioned_in
- has_begun
- has_ended
- is_transitioning_out
- transition_out
- create_player
- create_team
66class TeamNotFoundError(NotFoundError): 67 """Exception raised when an expected ba.Team does not exist. 68 69 Category: **Exception Classes** 70 """
Exception raised when an expected ba.Team does not exist.
Category: Exception Classes
Inherited Members
- builtins.Exception
- Exception
- builtins.BaseException
- with_traceback
- args
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.
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
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().
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
Inherited Members
- enum.Enum
- name
- value
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)
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
, orREAL
. It defaults toSIM
.
timeformat (ba.TimeFormat)
Defaults to seconds but can also be milliseconds.
- SIM time maps to local simulation time in ba.Activity or ba.Session Contexts. This means that it may progress slower in slow-motion play modes, stop when the game is paused, etc. This time type is not available in UI contexts.
- BASE time is also linked to gameplay in ba.Activity or ba.Session Contexts, but it progresses at a constant rate regardless of slow-motion states or pausing. It can, however, slow down or stop in certain cases such as network outages or game slowdowns due to cpu load. Like 'sim' time, this is unavailable in UI contexts.
- REAL time always maps to actual clock time with a bit of filtering added, regardless of Context. (The filtering prevents it from going backwards or jumping forward by large amounts due to the app being backgrounded, system time changing, etc.) Real time timers are currently only available in the UI context.
Examples
Print some stuff through time:
>>> ba.screenmessage('hello from now!')
>>> ba.timer(1.0, ba.Call(ba.screenmessage, 'hello from the future!'))
>>> ba.timer(2.0, ba.Call(ba.screenmessage,
... 'hello from the future 2!'))
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.
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)
Inherited Members
- enum.Enum
- name
- value
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.
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
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] = []
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.
Inherited Members
- enum.Enum
- name
- value
19class UISubsystem: 20 """Consolidated UI functionality for the app. 21 22 Category: **App Classes** 23 24 To use this class, access the single instance of it at 'ba.app.ui'. 25 """ 26 27 def __init__(self) -> None: 28 env = _ba.env() 29 30 self.controller: ba.UIController | None = None 31 32 self._main_menu_window: ba.Widget | None = None 33 self._main_menu_location: str | None = None 34 35 self._uiscale: ba.UIScale 36 37 interfacetype = env['ui_scale'] 38 if interfacetype == 'large': 39 self._uiscale = UIScale.LARGE 40 elif interfacetype == 'medium': 41 self._uiscale = UIScale.MEDIUM 42 elif interfacetype == 'small': 43 self._uiscale = UIScale.SMALL 44 else: 45 raise RuntimeError(f'Invalid UIScale value: {interfacetype}') 46 47 self.window_states: dict[type, Any] = {} # FIXME: Kill this. 48 self.main_menu_selection: str | None = None # FIXME: Kill this. 49 self.have_party_queue_window = False 50 self.quit_window: Any = None 51 self.dismiss_wii_remotes_window_call: (Callable[[], Any] | None) = None 52 self.cleanupchecks: list[UICleanupCheck] = [] 53 self.upkeeptimer: ba.Timer | None = None 54 self.use_toolbars = env.get('toolbar_test', True) 55 self.party_window: Any = None # FIXME: Don't use Any. 56 self.title_color = (0.72, 0.7, 0.75) 57 self.heading_color = (0.72, 0.7, 0.75) 58 self.infotextcolor = (0.7, 0.9, 0.7) 59 60 # Switch our overall game selection UI flow between Play and 61 # Private-party playlist selection modes; should do this in 62 # a more elegant way once we revamp high level UI stuff a bit. 63 self.selecting_private_party_playlist: bool = False 64 65 @property 66 def uiscale(self) -> ba.UIScale: 67 """Current ui scale for the app.""" 68 return self._uiscale 69 70 def on_app_launch(self) -> None: 71 """Should be run on app launch.""" 72 from ba.ui import UIController, ui_upkeep 73 from ba._generated.enums import TimeType 74 75 # IMPORTANT: If tweaking UI stuff, make sure it behaves for small, 76 # medium, and large UI modes. (doesn't run off screen, etc). 77 # The overrides below can be used to test with different sizes. 78 # Generally small is used on phones, medium is used on tablets/tvs, 79 # and large is on desktop computers or perhaps large tablets. When 80 # possible, run in windowed mode and resize the window to assure 81 # this holds true at all aspect ratios. 82 83 # UPDATE: A better way to test this is now by setting the environment 84 # variable BA_UI_SCALE to "small", "medium", or "large". 85 # This will affect system UIs not covered by the values below such 86 # as screen-messages. The below values remain functional, however, 87 # for cases such as Android where environment variables can't be set 88 # easily. 89 90 if bool(False): # force-test ui scale 91 self._uiscale = UIScale.SMALL 92 with _ba.Context('ui'): 93 _ba.pushcall(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'.
27 def __init__(self) -> None: 28 env = _ba.env() 29 30 self.controller: ba.UIController | None = None 31 32 self._main_menu_window: ba.Widget | None = None 33 self._main_menu_location: str | None = None 34 35 self._uiscale: ba.UIScale 36 37 interfacetype = env['ui_scale'] 38 if interfacetype == 'large': 39 self._uiscale = UIScale.LARGE 40 elif interfacetype == 'medium': 41 self._uiscale = UIScale.MEDIUM 42 elif interfacetype == 'small': 43 self._uiscale = UIScale.SMALL 44 else: 45 raise RuntimeError(f'Invalid UIScale value: {interfacetype}') 46 47 self.window_states: dict[type, Any] = {} # FIXME: Kill this. 48 self.main_menu_selection: str | None = None # FIXME: Kill this. 49 self.have_party_queue_window = False 50 self.quit_window: Any = None 51 self.dismiss_wii_remotes_window_call: (Callable[[], Any] | None) = None 52 self.cleanupchecks: list[UICleanupCheck] = [] 53 self.upkeeptimer: ba.Timer | None = None 54 self.use_toolbars = env.get('toolbar_test', True) 55 self.party_window: Any = None # FIXME: Don't use Any. 56 self.title_color = (0.72, 0.7, 0.75) 57 self.heading_color = (0.72, 0.7, 0.75) 58 self.infotextcolor = (0.7, 0.9, 0.7) 59 60 # Switch our overall game selection UI flow between Play and 61 # Private-party playlist selection modes; should do this in 62 # a more elegant way once we revamp high level UI stuff a bit. 63 self.selecting_private_party_playlist: bool = False
70 def on_app_launch(self) -> None: 71 """Should be run on app launch.""" 72 from ba.ui import UIController, ui_upkeep 73 from ba._generated.enums import TimeType 74 75 # IMPORTANT: If tweaking UI stuff, make sure it behaves for small, 76 # medium, and large UI modes. (doesn't run off screen, etc). 77 # The overrides below can be used to test with different sizes. 78 # Generally small is used on phones, medium is used on tablets/tvs, 79 # and large is on desktop computers or perhaps large tablets. When 80 # possible, run in windowed mode and resize the window to assure 81 # this holds true at all aspect ratios. 82 83 # UPDATE: A better way to test this is now by setting the environment 84 # variable BA_UI_SCALE to "small", "medium", or "large". 85 # This will affect system UIs not covered by the values below such 86 # as screen-messages. The below values remain functional, however, 87 # for cases such as Android where environment variables can't be set 88 # easily. 89 90 if bool(False): # force-test ui scale 91 self._uiscale = UIScale.SMALL 92 with _ba.Context('ui'): 93 _ba.pushcall(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.
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)
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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