diff --git a/.idea/dictionaries/ericf.xml b/.idea/dictionaries/ericf.xml index f3901a4d..3f9859b9 100644 --- a/.idea/dictionaries/ericf.xml +++ b/.idea/dictionaries/ericf.xml @@ -479,6 +479,7 @@ dxml dynload eachother + eaddrnotavail easteregghunt edcc editcontroller @@ -1132,6 +1133,7 @@ nline nlines nntplib + nodeactor nodepos nodpi nofiles diff --git a/assets/.asset_manifest_1.json b/assets/.asset_manifest_1.json index 462ef824..cd9617eb 100644 --- a/assets/.asset_manifest_1.json +++ b/assets/.asset_manifest_1.json @@ -35,6 +35,7 @@ "ba_data/python/ba/__pycache__/_modutils.cpython-37.opt-1.pyc", "ba_data/python/ba/__pycache__/_music.cpython-37.opt-1.pyc", "ba_data/python/ba/__pycache__/_netutils.cpython-37.opt-1.pyc", + "ba_data/python/ba/__pycache__/_nodeactor.cpython-37.opt-1.pyc", "ba_data/python/ba/__pycache__/_playlist.cpython-37.opt-1.pyc", "ba_data/python/ba/__pycache__/_powerup.cpython-37.opt-1.pyc", "ba_data/python/ba/__pycache__/_profile.cpython-37.opt-1.pyc", @@ -84,6 +85,7 @@ "ba_data/python/ba/_modutils.py", "ba_data/python/ba/_music.py", "ba_data/python/ba/_netutils.py", + "ba_data/python/ba/_nodeactor.py", "ba_data/python/ba/_playlist.py", "ba_data/python/ba/_powerup.py", "ba_data/python/ba/_profile.py", diff --git a/assets/Makefile b/assets/Makefile index 5b10fd59..bdd0bd6a 100644 --- a/assets/Makefile +++ b/assets/Makefile @@ -207,6 +207,7 @@ SCRIPT_TARGETS_PY_1 = \ build/ba_data/python/ba/_account.py \ build/ba_data/python/ba/_music.py \ build/ba_data/python/ba/_lang.py \ + build/ba_data/python/ba/_nodeactor.py \ build/ba_data/python/ba/_teamgame.py \ build/ba_data/python/ba/ui/__init__.py \ build/ba_data/python/bastd/mainmenu.py \ @@ -444,6 +445,7 @@ SCRIPT_TARGETS_PYC_1 = \ build/ba_data/python/ba/__pycache__/_account.cpython-37.opt-1.pyc \ build/ba_data/python/ba/__pycache__/_music.cpython-37.opt-1.pyc \ build/ba_data/python/ba/__pycache__/_lang.cpython-37.opt-1.pyc \ + build/ba_data/python/ba/__pycache__/_nodeactor.cpython-37.opt-1.pyc \ build/ba_data/python/ba/__pycache__/_teamgame.cpython-37.opt-1.pyc \ build/ba_data/python/ba/ui/__pycache__/__init__.cpython-37.opt-1.pyc \ build/ba_data/python/bastd/__pycache__/mainmenu.cpython-37.opt-1.pyc \ @@ -953,6 +955,11 @@ build/ba_data/python/ba/__pycache__/_lang.cpython-37.opt-1.pyc: \ @echo Compiling script: $^ @rm -rf $@ && $(TOOLS_DIR)/snippets compile_python_files $^ && chmod 444 $@ +build/ba_data/python/ba/__pycache__/_nodeactor.cpython-37.opt-1.pyc: \ + build/ba_data/python/ba/_nodeactor.py + @echo Compiling script: $^ + @rm -rf $@ && $(TOOLS_DIR)/snippets compile_python_files $^ && chmod 444 $@ + build/ba_data/python/ba/__pycache__/_teamgame.cpython-37.opt-1.pyc: \ build/ba_data/python/ba/_teamgame.py @echo Compiling script: $^ diff --git a/assets/src/ba_data/python/_ba.py b/assets/src/ba_data/python/_ba.py index 3f3038d5..b9078f4f 100644 --- a/assets/src/ba_data/python/_ba.py +++ b/assets/src/ba_data/python/_ba.py @@ -34,7 +34,7 @@ NOTE: This file was autogenerated by gendummymodule; do not edit by hand. """ # (hash we can use to see if this file is out of date) -# SOURCES_HASH=279775284016994471710131432123768789736 +# SOURCES_HASH=92329394206923431348342003830010439152 # I'm sorry Pylint. I know this file saddens you. Be strong. # pylint: disable=useless-suppression @@ -161,8 +161,8 @@ class Context: 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) + # 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') @@ -667,7 +667,7 @@ class Node: target attribute to any value or connecting another node attribute to it. - # example: create a locator and attach a light 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') @@ -1044,7 +1044,7 @@ class Timer: the 'timeformat' arg defaults to SECONDS but can also be MILLISECONDS if you want to pass time as milliseconds. - # example: use a Timer object to print repeatedly for a few seconds: + # Example: use a Timer object to print repeatedly for a few seconds: def say_it(): ba.screenmessage('BADGER!') def stop_saying_it(): @@ -1745,6 +1745,11 @@ def do_once() -> bool: 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!') """ return bool() diff --git a/assets/src/ba_data/python/ba/__init__.py b/assets/src/ba_data/python/ba/__init__.py index 9a6fb03f..47742952 100644 --- a/assets/src/ba_data/python/ba/__init__.py +++ b/assets/src/ba_data/python/ba/__init__.py @@ -40,6 +40,7 @@ from _ba import (CollideModel, Context, ContextCall, Data, InputDevice, charstr, textwidget, time, timer, open_url, widget) from ba._activity import Activity from ba._actor import Actor +from ba._nodeactor import NodeActor from ba._app import App from ba._coopgame import CoopGameActivity from ba._coopsession import CoopSession diff --git a/assets/src/ba_data/python/ba/_activity.py b/assets/src/ba_data/python/ba/_activity.py index d1432b83..1515a01e 100644 --- a/assets/src/ba_data/python/ba/_activity.py +++ b/assets/src/ba_data/python/ba/_activity.py @@ -558,12 +558,12 @@ class Activity(DependencyComponent): def create_player_node(self, player: ba.Player) -> ba.Node: """Create the 'player' node associated with the provided ba.Player.""" - from ba import _actor + from ba._nodeactor import NodeActor with _ba.Context(self): node = _ba.newnode('player', attrs={'playerID': player.get_id()}) # FIXME: Should add a dedicated slot for this on ba.Player # instead of cluttering up their gamedata dict. - player.gamedata['_playernode'] = _actor.Actor(node) + player.gamedata['_playernode'] = NodeActor(node) return node def begin(self, session: ba.Session) -> None: diff --git a/assets/src/ba_data/python/ba/_actor.py b/assets/src/ba_data/python/ba/_actor.py index f784fde5..b0efa0ce 100644 --- a/assets/src/ba_data/python/ba/_actor.py +++ b/assets/src/ba_data/python/ba/_actor.py @@ -37,24 +37,25 @@ T = TypeVar('T', bound='Actor') class Actor: """High level logical entities in a game/activity. - category: Gameplay Classes + Category: Gameplay Classes Actors act as controllers, combining some number of ba.Nodes, - ba.Textures, ba.Sounds, etc. into one cohesive unit. + ba.Textures, ba.Sounds, etc. into a high-level cohesive unit. - Some example actors include ba.Bomb, ba.Flag, and ba.Spaz. + Some example actors include Bomb, Flag, and Spaz classes in bastd. One key feature of Actors is that they generally 'die' (killing off or transitioning out their nodes) when the last Python reference to them disappears, so you can use logic such as: - # create a flag Actor in our game activity - self.flag = ba.Flag(position=(0, 10, 0)) + # 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.. + # 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. + # 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, @@ -69,30 +70,25 @@ class Actor: 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. + message type for Actors is the ba.DieMessage. - # another way to kill the flag from the example above: - # we can safely call this on any type with a 'handlemessage' method - # (though its not guaranteed to always have a meaningful effect) - # in this case the Actor instance will still be around, but its exists() - # and is_alive() methods will both return False + # Another way to kill the flag from the example above: + # We can safely call this on any type with a 'handlemessage' method + # (though its not guaranteed to always have a meaningful effect). + # In this case the Actor instance will still be around, but its exists() + # and is_alive() methods will both return False. self.flag.handlemessage(ba.DieMessage()) """ - def __init__(self, node: ba.Node = None): - """Instantiates an Actor in the current ba.Activity. + def __init__(self) -> None: + """Instantiates an Actor in the current ba.Activity.""" - If 'node' is provided, it is stored as the 'node' attribute - and the default ba.Actor.handlemessage() and ba.Actor.exists() - implementations will apply to it. This allows the creation of - simple node-wrapping Actors without having to create a new subclass. - """ + # FIXME: Actor should not be require to have a 'node' attr. self.node: Optional[ba.Node] = None + activity = _ba.getactivity() self._activity = weakref.ref(activity) activity.add_actor_weak_ref(self) - if node is not None: - self.node = node def __del__(self) -> None: try: @@ -112,14 +108,9 @@ class Actor: The default implementation will handle ba.DieMessages by calling self.node.delete() if self contains a 'node' attribute. """ - from ba import _messages - from ba import _error - if isinstance(msg, _messages.DieMessage): - node = getattr(self, 'node', None) - if node is not None: - node.delete() - return None - return _error.UNHANDLED + from ba._error import UNHANDLED + del msg # Unused. + return UNHANDLED def _handlemessage_sanity_check(self) -> None: if self.is_expired(): @@ -178,18 +169,13 @@ class Actor: 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 returns 'node.exists()' - if the Actor has a 'node' attr; otherwise True. + 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. """ - # As a default, if we have a 'node' attr, return whether it exists. - node: ba.Node = getattr(self, 'node', None) - if node is not None: - return node.exists() return True def __bool__(self) -> bool: diff --git a/assets/src/ba_data/python/ba/_app.py b/assets/src/ba_data/python/ba/_app.py index a8c87f2a..29c6fedb 100644 --- a/assets/src/ba_data/python/ba/_app.py +++ b/assets/src/ba_data/python/ba/_app.py @@ -638,7 +638,8 @@ class App: activity = _ba.get_foreground_host_activity() if (activity is not None and activity.allow_pausing and not _ba.have_connected_clients()): - from ba import _gameutils, _actor, _lang + from ba import _gameutils, _lang + from ba._nodeactor import NodeActor # FIXME: Shouldn't be touching scene stuff here; # should just pass the request on to the host-session. with _ba.Context(activity): @@ -648,7 +649,7 @@ class App: globs.paused = True # FIXME: This should not be an attr on Actor. - activity.paused_text = _actor.Actor( + activity.paused_text = NodeActor( _ba.newnode( 'text', attrs={ diff --git a/assets/src/ba_data/python/ba/_coopgame.py b/assets/src/ba_data/python/ba/_coopgame.py index 969ba719..6372c376 100644 --- a/assets/src/ba_data/python/ba/_coopgame.py +++ b/assets/src/ba_data/python/ba/_coopgame.py @@ -80,8 +80,8 @@ class CoopGameActivity(GameActivity): def _show_standard_scores_to_beat_ui(self, scores: List[Dict[str, Any]]) -> None: - from ba import _gameutils - from ba import _actor + from ba._gameutils import timestring, animate + from ba._nodeactor import NodeActor from ba._enums import TimeFormat display_type = self.get_score_type() if scores is not None: @@ -92,16 +92,14 @@ class CoopGameActivity(GameActivity): # Now make a display for the most recent challenge. for score in scores: if score['type'] == 'score_challenge': - tval = ( - score['player'] + ': ' + - (_gameutils.timestring( - int(score['value']) * 10, - timeformat=TimeFormat.MILLISECONDS).evaluate() - if display_type == 'time' else str(score['value']))) + tval = (score['player'] + ': ' + timestring( + int(score['value']) * 10, + timeformat=TimeFormat.MILLISECONDS).evaluate() + if display_type == 'time' else str(score['value'])) hattach = 'center' if display_type == 'time' else 'left' halign = 'center' if display_type == 'time' else 'left' pos = (20, -70) if display_type == 'time' else (20, -130) - txt = _actor.Actor( + txt = NodeActor( _ba.newnode('text', attrs={ 'v_attach': 'top', @@ -115,11 +113,7 @@ class CoopGameActivity(GameActivity): 'text': tval })).autoretain() assert txt.node is not None - _gameutils.animate(txt.node, 'scale', { - 1.0: 0.0, - 1.1: 0.7, - 1.2: 0.6 - }) + animate(txt.node, 'scale', {1.0: 0.0, 1.1: 0.7, 1.2: 0.6}) break # FIXME: this is now redundant with activityutils.get_score_info(); @@ -279,8 +273,8 @@ class CoopGameActivity(GameActivity): should_beep = True break if should_beep and self._life_warning_beep is None: - from ba import _actor - self._life_warning_beep = _actor.Actor( + from ba._nodeactor import NodeActor + self._life_warning_beep = NodeActor( _ba.newnode('sound', attrs={ 'sound': self._warn_beeps_sound, diff --git a/assets/src/ba_data/python/ba/_gameactivity.py b/assets/src/ba_data/python/ba/_gameactivity.py index 62102bda..8f817f63 100644 --- a/assets/src/ba_data/python/ba/_gameactivity.py +++ b/assets/src/ba_data/python/ba/_gameactivity.py @@ -703,7 +703,7 @@ class GameActivity(Activity): # pylint: disable=too-many-locals from ba._freeforallsession import FreeForAllSession from ba._gameutils import animate - from ba._actor import Actor + from ba._nodeactor import NodeActor sb_name = self.get_instance_scoreboard_display_string() # the description can be either a string or a sequence with args @@ -731,7 +731,7 @@ class GameActivity(Activity): yval -= 16 sbpos = ((15, yval) if isinstance(self.session, FreeForAllSession) else (15, yval)) - self._game_scoreboard_name_text = Actor( + self._game_scoreboard_name_text = NodeActor( _ba.newnode("text", attrs={ 'text': sb_name, @@ -756,7 +756,7 @@ class GameActivity(Activity): descpos = (((17, -44 + 10) if isinstance(self.session, FreeForAllSession) else (17, -44 + 10))) - self._game_scoreboard_description_text = Actor( + self._game_scoreboard_description_text = NodeActor( _ba.newnode( "text", attrs={ @@ -1163,13 +1163,13 @@ class GameActivity(Activity): """ from ba._gameutils import sharedobj from ba._general import WeakCall - from ba._actor import Actor + from ba._nodeactor import NodeActor if duration <= 0.0: return self._standard_time_limit_time = int(duration) self._standard_time_limit_timer = _ba.Timer( 1.0, WeakCall(self._standard_time_limit_tick), repeat=True) - self._standard_time_limit_text = Actor( + self._standard_time_limit_text = NodeActor( _ba.newnode('text', attrs={ 'v_attach': 'top', @@ -1180,7 +1180,7 @@ class GameActivity(Activity): 'flatness': 1.0, 'scale': 0.9 })) - self._standard_time_limit_text_input = Actor( + self._standard_time_limit_text_input = NodeActor( _ba.newnode('timedisplay', attrs={ 'time2': duration * 1000, @@ -1237,7 +1237,7 @@ class GameActivity(Activity): If the time-limit expires, end_game() will be called. """ from ba._general import WeakCall - from ba._actor import Actor + from ba._nodeactor import NodeActor from ba._enums import TimeType if duration <= 0.0: return @@ -1251,7 +1251,7 @@ class GameActivity(Activity): WeakCall(self._tournament_time_limit_tick), repeat=True, timetype=TimeType.BASE) - self._tournament_time_limit_title_text = Actor( + self._tournament_time_limit_title_text = NodeActor( _ba.newnode('text', attrs={ 'v_attach': 'bottom', @@ -1266,7 +1266,7 @@ class GameActivity(Activity): 'scale': 0.5, 'text': Lstr(resource='tournamentText') })) - self._tournament_time_limit_text = Actor( + self._tournament_time_limit_text = NodeActor( _ba.newnode('text', attrs={ 'v_attach': 'bottom', @@ -1280,7 +1280,7 @@ class GameActivity(Activity): 'flatness': 1.0, 'scale': 0.9 })) - self._tournament_time_limit_text_input = Actor( + self._tournament_time_limit_text_input = NodeActor( _ba.newnode('timedisplay', attrs={ 'timemin': 0, diff --git a/assets/src/ba_data/python/ba/_gameutils.py b/assets/src/ba_data/python/ba/_gameutils.py index 609c0b81..059eef51 100644 --- a/assets/src/ba_data/python/ba/_gameutils.py +++ b/assets/src/ba_data/python/ba/_gameutils.py @@ -441,7 +441,7 @@ def cameraflash(duration: float = 999.0) -> None: """ # pylint: disable=too-many-locals import random - from ba._actor import Actor + from ba._nodeactor import NodeActor x_spread = 10 y_spread = 5 positions = [[-x_spread, -y_spread], [0, -y_spread], [0, y_spread], @@ -455,7 +455,7 @@ def cameraflash(duration: float = 999.0) -> None: # noinspection PyTypeHints activity.camera_flash_data = [] # type: ignore for i in range(6): - light = Actor( + light = NodeActor( _ba.newnode("light", attrs={ 'position': (positions[i][0], 0, positions[i][1]), diff --git a/assets/src/ba_data/python/ba/_general.py b/assets/src/ba_data/python/ba/_general.py index 9849b456..92519239 100644 --- a/assets/src/ba_data/python/ba/_general.py +++ b/assets/src/ba_data/python/ba/_general.py @@ -159,7 +159,7 @@ class _WeakCall: Instantiate a WeakCall; pass a callable as the first arg, followed by any number of arguments or keywords. - # example: wrap a method call with some positional and + # Example: wrap a method call with some positional and # keyword args: myweakcall = ba.WeakCall(myobj.dostuff, argval1, namedarg=argval2) @@ -211,7 +211,7 @@ class _Call: 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. + # Example: wrap a method call with 1 positional and 1 keyword arg: mycall = ba.Call(myobj.dostuff, argval1, namedarg=argval2) # Now we have a single callable to run that whole mess. diff --git a/assets/src/ba_data/python/ba/_lobby.py b/assets/src/ba_data/python/ba/_lobby.py index bd500251..85ff0f1d 100644 --- a/assets/src/ba_data/python/ba/_lobby.py +++ b/assets/src/ba_data/python/ba/_lobby.py @@ -42,8 +42,8 @@ class JoinInfo: # pylint: disable=too-many-locals from ba import _input from ba._lang import Lstr - from ba import _actor - from ba import _general + from ba._nodeactor import NodeActor + from ba._general import WeakCall from ba._enums import SpecialChar can_switch_teams = (len(lobby.teams) > 1) self._state = 0 @@ -79,7 +79,7 @@ class JoinInfo: join_str = Lstr(resource='pressAnyButtonToJoinText') flatness = 1.0 if _ba.app.vr_mode else 0.0 - self._text = _actor.Actor( + self._text = NodeActor( _ba.newnode('text', attrs={ 'position': (0, -40), @@ -109,9 +109,7 @@ class JoinInfo: ' ' + _ba.charstr(SpecialChar.RIGHT_ARROW))]) ] if can_switch_teams else []) + [msg1] + [msg3] + [join_str]) - self._timer = _ba.Timer(4.0, - _general.WeakCall(self._update), - repeat=True) + self._timer = _ba.Timer(4.0, WeakCall(self._update), repeat=True) def _update(self) -> None: assert self._text.node @@ -393,7 +391,7 @@ class Chooser: def reload_profiles(self) -> None: """Reload all player profiles.""" - from ba import _general + from ba._general import json_prep app = _ba.app # Re-construct our profile index and other stuff since the profile @@ -422,7 +420,7 @@ class Chooser: # (non-unicode/non-json) version. # Make sure they conform to our standards # (unicode strings, no tuples, etc) - self.profiles = _general.json_prep(self.profiles) + self.profiles = json_prep(self.profiles) # Filter out any characters we're unaware of. for profile in list(self.profiles.items()): @@ -551,7 +549,7 @@ class Chooser: def _set_ready(self, ready: bool) -> None: # pylint: disable=cyclic-import from bastd.ui.profile import browser as pbrowser - from ba import _general + from ba._general import Call profilename = self._profilenames[self._profileindex] # Handle '_edit' as a special case. @@ -566,26 +564,23 @@ class Chooser: if not ready: self._player.assign_input_call( - 'leftPress', - _general.Call(self.handlemessage, ChangeMessage('team', -1))) + 'leftPress', Call(self.handlemessage, + ChangeMessage('team', -1))) self._player.assign_input_call( - 'rightPress', - _general.Call(self.handlemessage, ChangeMessage('team', 1))) + 'rightPress', Call(self.handlemessage, + ChangeMessage('team', 1))) self._player.assign_input_call( 'bombPress', - _general.Call(self.handlemessage, - ChangeMessage('character', 1))) + Call(self.handlemessage, ChangeMessage('character', 1))) self._player.assign_input_call( 'upPress', - _general.Call(self.handlemessage, - ChangeMessage('profileindex', -1))) + Call(self.handlemessage, ChangeMessage('profileindex', -1))) self._player.assign_input_call( 'downPress', - _general.Call(self.handlemessage, - ChangeMessage('profileindex', 1))) + Call(self.handlemessage, ChangeMessage('profileindex', 1))) self._player.assign_input_call( ('jumpPress', 'pickUpPress', 'punchPress'), - _general.Call(self.handlemessage, ChangeMessage('ready', 1))) + Call(self.handlemessage, ChangeMessage('ready', 1))) self._ready = False self._update_text() self._player.set_name('untitled', real=False) @@ -595,7 +590,7 @@ class Chooser: 'jumpPress', 'bombPress', 'pickUpPress'), self._do_nothing) self._player.assign_input_call( ('jumpPress', 'bombPress', 'pickUpPress', 'punchPress'), - _general.Call(self.handlemessage, ChangeMessage('ready', 0))) + Call(self.handlemessage, ChangeMessage('ready', 0))) # Store the last profile picked by this input for reuse. input_device = self._player.get_input_device() diff --git a/assets/src/ba_data/python/ba/_nodeactor.py b/assets/src/ba_data/python/ba/_nodeactor.py new file mode 100644 index 00000000..6a1305be --- /dev/null +++ b/assets/src/ba_data/python/ba/_nodeactor.py @@ -0,0 +1,53 @@ +# Copyright (c) 2011-2020 Eric Froemling +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ----------------------------------------------------------------------------- +"""Defines NodeActor class.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ba._messages import DieMessage +from ba._actor import Actor + +if TYPE_CHECKING: + import ba + from typing import Any + + +class NodeActor(Actor): + """A simple ba.Actor type that wraps a single ba.Node. + + Category: Gameplay Classes + + This Actor will delete its Node when told to die, and it's + exists() call will return whether the Node still exists or not. + """ + + def __init__(self, node: ba.Node): + super().__init__() + self.node = node + + def handlemessage(self, msg: Any) -> Any: + if isinstance(msg, DieMessage): + if self.node: + self.node.delete() + return None + return super().handlemessage(msg) diff --git a/assets/src/ba_data/python/ba/_teambasesession.py b/assets/src/ba_data/python/ba/_teambasesession.py index 11b3f91b..063c24ac 100644 --- a/assets/src/ba_data/python/ba/_teambasesession.py +++ b/assets/src/ba_data/python/ba/_teambasesession.py @@ -65,7 +65,7 @@ class TeamBaseSession(Session): team_names = None team_colors = None - print('FIXME: TEAM BASE SESSION WOULD CALC DEPS.') + # print('FIXME: TEAM BASE SESSION WOULD CALC DEPS.') depsets: Sequence[ba.DependencySet] = [] super().__init__(depsets, team_names=team_names, diff --git a/assets/src/ba_data/python/bastd/activity/coopscorescreen.py b/assets/src/ba_data/python/bastd/activity/coopscorescreen.py index 01ca4dce..b0ef4ab4 100644 --- a/assets/src/ba_data/python/bastd/activity/coopscorescreen.py +++ b/assets/src/ba_data/python/bastd/activity/coopscorescreen.py @@ -867,7 +867,7 @@ class CoopScoreScreen(ba.Activity): ba.timer(5.0, ba.WeakCall(self._show_tips)) def _play_drumroll(self) -> None: - ba.Actor( + ba.NodeActor( ba.newnode('sound', attrs={ 'sound': self.drum_roll_sound, @@ -1314,7 +1314,7 @@ class CoopScoreScreen(ba.Activity): star_tex = ba.gettexture('star') star_x = 135 + offs_x for _i in range(stars): - img = ba.Actor( + img = ba.NodeActor( ba.newnode('image', attrs={ 'texture': star_tex, @@ -1329,7 +1329,7 @@ class CoopScoreScreen(ba.Activity): ba.animate(img.node, 'opacity', {0.15: 0, 0.4: 1}) star_x += 60 for _i in range(3 - stars): - img = ba.Actor( + img = ba.NodeActor( ba.newnode('image', attrs={ 'texture': star_tex, @@ -1355,7 +1355,7 @@ class CoopScoreScreen(ba.Activity): transition_delay=1.0).autoretain() stx = xval + 20 for _i2 in range(count): - img2 = ba.Actor( + img2 = ba.NodeActor( ba.newnode('image', attrs={ 'texture': star_tex, diff --git a/assets/src/ba_data/python/bastd/actor/respawnicon.py b/assets/src/ba_data/python/bastd/actor/respawnicon.py index 9c11c998..5458003b 100644 --- a/assets/src/ba_data/python/bastd/actor/respawnicon.py +++ b/assets/src/ba_data/python/bastd/actor/respawnicon.py @@ -89,7 +89,7 @@ class RespawnIcon: texture = icon['texture'] h_offs = -10 ipos = (-40 - h_offs if on_right else 40 + h_offs, -180 + offs) - self._image: Optional[ba.Actor] = ba.Actor( + self._image: Optional[ba.NodeActor] = ba.NodeActor( ba.newnode('image', attrs={ 'texture': texture, @@ -108,7 +108,7 @@ class RespawnIcon: ba.animate(self._image.node, 'opacity', {0.0: 0, 0.2: 0.7}) npos = (-40 - h_offs if on_right else 40 + h_offs, -205 + 49 + offs) - self._name: Optional[ba.Actor] = ba.Actor( + self._name: Optional[ba.NodeActor] = ba.NodeActor( ba.newnode('text', attrs={ 'v_attach': 'top', @@ -128,7 +128,7 @@ class RespawnIcon: ba.animate(self._name.node, 'scale', {0: 0, 0.1: 0.5}) tpos = (-60 - h_offs if on_right else 60 + h_offs, -192 + offs) - self._text: Optional[ba.Actor] = ba.Actor( + self._text: Optional[ba.NodeActor] = ba.NodeActor( ba.newnode('text', attrs={ 'position': tpos, diff --git a/assets/src/ba_data/python/bastd/actor/scoreboard.py b/assets/src/ba_data/python/bastd/actor/scoreboard.py index 2c9d08fa..0a4a6aba 100644 --- a/assets/src/ba_data/python/bastd/actor/scoreboard.py +++ b/assets/src/ba_data/python/bastd/actor/scoreboard.py @@ -72,7 +72,7 @@ class _Entry: self._backing_color = [0.05 + c * 0.1 for c in safe_team_color] opacity = (0.8 if vrmode else 0.8) if self._do_cover else 0.5 - self._backing = ba.Actor( + self._backing = ba.NodeActor( ba.newnode('image', attrs={ 'scale': (self._width, self._height), @@ -84,7 +84,7 @@ class _Entry: })) self._barcolor = safe_team_color - self._bar = ba.Actor( + self._bar = ba.NodeActor( ba.newnode('image', attrs={ 'opacity': 0.7, @@ -112,7 +112,7 @@ class _Entry: self._bar_position.connectattr('output', self._bar.node, 'position') self._cover_color = safe_team_color if self._do_cover: - self._cover = ba.Actor( + self._cover = ba.NodeActor( ba.newnode('image', attrs={ 'scale': @@ -128,7 +128,7 @@ class _Entry: clr = safe_team_color maxwidth = 130.0 * (1.0 - scoreboard.score_split) flatness = ((1.0 if vrmode else 0.5) if self._do_cover else 1.0) - self._score_text = ba.Actor( + self._score_text = ba.NodeActor( ba.newnode('text', attrs={ 'h_attach': 'left', @@ -169,7 +169,7 @@ class _Entry: team_name_label = ba.Lstr(value=team_name_label) flatness = ((1.0 if vrmode else 0.5) if self._do_cover else 1.0) - self._name_text = ba.Actor( + self._name_text = ba.NodeActor( ba.newnode('text', attrs={ 'h_attach': 'left', diff --git a/assets/src/ba_data/python/bastd/actor/spaz.py b/assets/src/ba_data/python/bastd/actor/spaz.py index a3c53076..565bfa1b 100644 --- a/assets/src/ba_data/python/bastd/actor/spaz.py +++ b/assets/src/ba_data/python/bastd/actor/spaz.py @@ -58,10 +58,10 @@ class BombDiedMessage: def get_factory() -> SpazFactory: """Return the shared ba.SpazFactory object, creating it if necessary.""" # pylint: disable=cyclic-import + from bastd.actor.spazfactory import SpazFactory activity = ba.getactivity() factory = getattr(activity, 'shared_spaz_factory', None) if factory is None: - from bastd.actor.spazfactory import SpazFactory # noinspection PyTypeHints factory = activity.shared_spaz_factory = SpazFactory() # type: ignore assert isinstance(factory, SpazFactory) @@ -693,11 +693,11 @@ class Spaz(ba.Actor): # pylint: disable=too-many-branches if __debug__ is True: self._handlemessage_sanity_check() - assert self.node if isinstance(msg, ba.PickedUpMessage): - self.node.handlemessage("hurt_sound") - self.node.handlemessage("picked_up") + if self.node: + self.node.handlemessage("hurt_sound") + self.node.handlemessage("picked_up") # this counts as a hit self._num_times_hit += 1 @@ -713,7 +713,7 @@ class Spaz(ba.Actor): ba.timer(0.001, ba.WeakCall(self._hit_self, msg.intensity)) elif isinstance(msg, ba.PowerupMessage): - if self._dead: + if self._dead or not self.node: return True if self.pick_up_powerup_callback is not None: self.pick_up_powerup_callback(self) @@ -1069,14 +1069,15 @@ class Spaz(ba.Actor): newdamage = max(damage - 200, self.hitpoints - 10) damage = newdamage self.node.handlemessage("flash") - # if we're holding something, drop it + + # If we're holding something, drop it. if damage > 0.0 and self.node.hold_node: # self.node.hold_node = ba.Node(None) self.node.hold_node = None self.hitpoints -= damage self.node.hurt = 1.0 - float( self.hitpoints) / self.hitpoints_max - # if we're cursed, *any* damage blows us up + # If we're cursed, *any* damage blows us up. if self._cursed and damage > 0: ba.timer( 0.05, ba.WeakCall(self.curse_explode, @@ -1103,7 +1104,8 @@ class Spaz(ba.Actor): self._dead = True self.hitpoints = 0 if msg.immediate: - self.node.delete() + if self.node: + self.node.delete() elif self.node: self.node.hurt = 1.0 if self.play_big_death_sound and not wasdead: @@ -1117,11 +1119,15 @@ class Spaz(ba.Actor): elif isinstance(msg, ba.StandMessage): self._last_stand_pos = (msg.position[0], msg.position[1], msg.position[2]) - self.node.handlemessage("stand", msg.position[0], msg.position[1], - msg.position[2], msg.angle) + if self.node: + self.node.handlemessage("stand", msg.position[0], + msg.position[1], msg.position[2], + msg.angle) elif isinstance(msg, CurseExplodeMessage): self.curse_explode() elif isinstance(msg, PunchHitMessage): + if not self.node: + return None node = ba.get_collision_info("opposing_node") # only allow one hit per node per punch @@ -1178,6 +1184,9 @@ class Spaz(ba.Actor): ppos[2], punchdir[0], punchdir[1], punchdir[2], mag) elif isinstance(msg, PickupMessage): + if not self.node: + return None + opposing_node, opposing_body = ba.get_collision_info( 'opposing_node', 'opposing_body') diff --git a/assets/src/ba_data/python/bastd/game/capturetheflag.py b/assets/src/ba_data/python/bastd/game/capturetheflag.py index f6e84911..2d98ee98 100644 --- a/assets/src/ba_data/python/bastd/game/capturetheflag.py +++ b/assets/src/ba_data/python/bastd/game/capturetheflag.py @@ -354,7 +354,7 @@ class CaptureTheFlagGame(ba.TeamGameActivity): team.gamedata['touch_return_timer_ticking'] = None return # No need to return when its at home. if team.gamedata['touch_return_timer_ticking'] is None: - team.gamedata['touch_return_timer_ticking'] = ba.Actor( + team.gamedata['touch_return_timer_ticking'] = ba.NodeActor( ba.newnode('sound', attrs={ 'sound': self._ticking_sound, diff --git a/assets/src/ba_data/python/bastd/game/chosenone.py b/assets/src/ba_data/python/bastd/game/chosenone.py index 3ccc4756..119ab31c 100644 --- a/assets/src/ba_data/python/bastd/game/chosenone.py +++ b/assets/src/ba_data/python/bastd/game/chosenone.py @@ -293,7 +293,7 @@ class ChosenOneGame(ba.TeamGameActivity): 0.3 + c * 0.7 for c in ba.normalized_color(player.team.color) ] - light = player.gamedata['chosen_light'] = ba.Actor( + light = player.gamedata['chosen_light'] = ba.NodeActor( ba.newnode('light', attrs={ "intensity": 0.6, diff --git a/assets/src/ba_data/python/bastd/game/elimination.py b/assets/src/ba_data/python/bastd/game/elimination.py index b07cd224..6e53f8f7 100644 --- a/assets/src/ba_data/python/bastd/game/elimination.py +++ b/assets/src/ba_data/python/bastd/game/elimination.py @@ -449,7 +449,7 @@ class EliminationGame(ba.TeamGameActivity): self.setup_standard_time_limit(self.settings['Time Limit']) self.setup_standard_powerup_drops() if self._solo_mode: - self._vs_text = ba.Actor( + self._vs_text = ba.NodeActor( ba.newnode("text", attrs={ 'position': (0, 105), diff --git a/assets/src/ba_data/python/bastd/game/football.py b/assets/src/ba_data/python/bastd/game/football.py index de12acc7..b600eeb9 100644 --- a/assets/src/ba_data/python/bastd/game/football.py +++ b/assets/src/ba_data/python/bastd/game/football.py @@ -157,7 +157,7 @@ class FootballTeamGame(ba.TeamGameActivity): self._spawn_flag() defs = self.map.defs self._score_regions.append( - ba.Actor( + ba.NodeActor( ba.newnode('region', attrs={ 'position': defs.boxes['goal1'][0:3], @@ -166,7 +166,7 @@ class FootballTeamGame(ba.TeamGameActivity): 'materials': (self.score_region_material, ) }))) self._score_regions.append( - ba.Actor( + ba.NodeActor( ba.newnode('region', attrs={ 'position': defs.boxes['goal2'][0:3], @@ -279,7 +279,7 @@ class FootballTeamGame(ba.TeamGameActivity): elif isinstance(msg, stdflag.FlagDeathMessage): if not self.has_ended(): self._flag_respawn_timer = ba.Timer(3.0, self._spawn_flag) - self._flag_respawn_light = ba.Actor( + self._flag_respawn_light = ba.NodeActor( ba.newnode('light', attrs={ 'position': self._flag_spawn_pos, @@ -406,7 +406,7 @@ class FootballCoopGame(ba.CoopGameActivity): # Set up the two score regions. defs = self.map.defs self.score_regions.append( - ba.Actor( + ba.NodeActor( ba.newnode('region', attrs={ 'position': defs.boxes['goal1'][0:3], @@ -415,7 +415,7 @@ class FootballCoopGame(ba.CoopGameActivity): 'materials': [self._score_region_material] }))) self.score_regions.append( - ba.Actor( + ba.NodeActor( ba.newnode('region', attrs={ 'position': defs.boxes['goal2'][0:3], @@ -516,7 +516,7 @@ class FootballCoopGame(ba.CoopGameActivity): starttime_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS) assert isinstance(starttime_ms, int) self._starttime_ms = starttime_ms - self._time_text = ba.Actor( + self._time_text = ba.NodeActor( ba.newnode('text', attrs={ 'v_attach': 'top', @@ -529,7 +529,7 @@ class FootballCoopGame(ba.CoopGameActivity): 'scale': 1.3, 'text': '' })) - self._time_text_input = ba.Actor( + self._time_text_input = ba.NodeActor( ba.newnode('timedisplay', attrs={'showsubseconds': True})) ba.sharedobj('globals').connectattr('time', self._time_text_input.node, 'time2') @@ -844,7 +844,7 @@ class FootballCoopGame(ba.CoopGameActivity): elif isinstance(msg, stdflag.FlagDeathMessage): assert isinstance(msg.flag, FootballFlag) msg.flag.respawn_timer = ba.Timer(3.0, self._spawn_flag) - self._flag_respawn_light = ba.Actor( + self._flag_respawn_light = ba.NodeActor( ba.newnode('light', attrs={ 'position': self._flag_spawn_pos, diff --git a/assets/src/ba_data/python/bastd/game/hockey.py b/assets/src/ba_data/python/bastd/game/hockey.py index 9c13c9de..f8e394da 100644 --- a/assets/src/ba_data/python/bastd/game/hockey.py +++ b/assets/src/ba_data/python/bastd/game/hockey.py @@ -222,7 +222,7 @@ class HockeyGame(ba.TeamGameActivity): defs = self.map.defs self._score_regions = [] self._score_regions.append( - ba.Actor( + ba.NodeActor( ba.newnode("region", attrs={ 'position': defs.boxes["goal1"][0:3], @@ -231,7 +231,7 @@ class HockeyGame(ba.TeamGameActivity): 'materials': [self._score_region_material] }))) self._score_regions.append( - ba.Actor( + ba.NodeActor( ba.newnode("region", attrs={ 'position': defs.boxes["goal2"][0:3], diff --git a/assets/src/ba_data/python/bastd/game/onslaught.py b/assets/src/ba_data/python/bastd/game/onslaught.py index aaa0f4c3..fed8a4d2 100644 --- a/assets/src/ba_data/python/bastd/game/onslaught.py +++ b/assets/src/ba_data/python/bastd/game/onslaught.py @@ -170,7 +170,7 @@ class OnslaughtGame(ba.CoopGameActivity): 'sound': ba.getsound('ding') }] - self._spawn_info_text = ba.Actor( + self._spawn_info_text = ba.NodeActor( ba.newnode("text", attrs={ 'position': (15, -130), @@ -1049,7 +1049,7 @@ class OnslaughtGame(ba.CoopGameActivity): ('${A}', ba.Lstr(resource='timeBonusText')), ('${B}', str(self._time_bonus)), ]) - self._time_bonus_text = ba.Actor( + self._time_bonus_text = ba.NodeActor( ba.newnode('text', attrs={ 'v_attach': 'top', @@ -1075,7 +1075,7 @@ class OnslaughtGame(ba.CoopGameActivity): ('' if self._preset in ['endless', 'endless_tournament'] else ('/' + str(len(self._waves))))) ]) - self._wave_text = ba.Actor( + self._wave_text = ba.NodeActor( ba.newnode('text', attrs={ 'v_attach': 'top', diff --git a/assets/src/ba_data/python/bastd/game/race.py b/assets/src/ba_data/python/bastd/game/race.py index da228f08..3ea58f71 100644 --- a/assets/src/ba_data/python/bastd/game/race.py +++ b/assets/src/ba_data/python/bastd/game/race.py @@ -406,7 +406,7 @@ class RaceGame(ba.TeamGameActivity): self._team_finish_pts = 100 # Throw a timer up on-screen. - self._time_text = ba.Actor( + self._time_text = ba.NodeActor( ba.newnode('text', attrs={ 'v_attach': 'top', diff --git a/assets/src/ba_data/python/bastd/game/runaround.py b/assets/src/ba_data/python/bastd/game/runaround.py index f63a3108..d0dbf2f3 100644 --- a/assets/src/ba_data/python/bastd/game/runaround.py +++ b/assets/src/ba_data/python/bastd/game/runaround.py @@ -134,7 +134,7 @@ class RunaroundGame(ba.CoopGameActivity): super().on_transition_in() self._scoreboard = Scoreboard(label=ba.Lstr(resource='scoreText'), score_split=0.5) - self._score_region = ba.Actor( + self._score_region = ba.NodeActor( ba.newnode( 'region', attrs={ @@ -365,7 +365,7 @@ class RunaroundGame(ba.CoopGameActivity): l_offs = (-80 if interface_type == 'small' else -40 if interface_type == 'medium' else 0) - self._lives_bg = ba.Actor( + self._lives_bg = ba.NodeActor( ba.newnode('image', attrs={ 'texture': self._heart_tex, @@ -379,7 +379,7 @@ class RunaroundGame(ba.CoopGameActivity): # FIXME; should not set things based on vr mode. # (won't look right to non-vr connected clients, etc) vrmode = ba.app.vr_mode - self._lives_text = ba.Actor( + self._lives_text = ba.NodeActor( ba.newnode( 'text', attrs={ @@ -943,7 +943,7 @@ class RunaroundGame(ba.CoopGameActivity): subs=[('${A}', ba.Lstr(resource='timeBonusText')), ('${B}', str(int(self._time_bonus * self._time_bonus_mult))) ]) - self._time_bonus_text = ba.Actor( + self._time_bonus_text = ba.NodeActor( ba.newnode('text', attrs={ 'v_attach': 'top', @@ -973,7 +973,7 @@ class RunaroundGame(ba.CoopGameActivity): ('' if self._preset in ['endless', 'endless_tournament'] else ('/' + str(len(self._waves))))) ]) - self._wave_text = ba.Actor( + self._wave_text = ba.NodeActor( ba.newnode('text', attrs={ 'v_attach': 'top', diff --git a/assets/src/ba_data/python/bastd/mainmenu.py b/assets/src/ba_data/python/bastd/mainmenu.py index 47cc8033..f6df76bb 100644 --- a/assets/src/ba_data/python/bastd/mainmenu.py +++ b/assets/src/ba_data/python/bastd/mainmenu.py @@ -67,7 +67,7 @@ class MainMenuActivity(ba.Activity): # FIXME: Need a node attr for vr-specific-scale. scale = (0.9 if (app.interface_type == 'small' or vr_mode) else 0.7) - self.my_name = ba.Actor( + self.my_name = ba.NodeActor( ba.newnode('text', attrs={ 'v_attach': 'bottom', @@ -86,7 +86,7 @@ class MainMenuActivity(ba.Activity): # empty-ish screen. tval = ba.Lstr(resource='hostIsNavigatingMenusText', subs=[('${HOST}', _ba.get_account_display_string())]) - self._host_is_navigating_text = ba.Actor( + self._host_is_navigating_text = ba.NodeActor( ba.newnode('text', attrs={ 'text': tval, @@ -130,7 +130,7 @@ class MainMenuActivity(ba.Activity): text = ba.Lstr(value='${V}', subs=[('${V}', app.version)]) scale = 0.9 if (interface_type == 'small' or vr_mode) else 0.7 color = (1, 1, 1, 1) if vr_mode else (0.5, 0.6, 0.5, 0.7) - self.version = ba.Actor( + self.version = ba.NodeActor( ba.newnode( 'text', attrs={ @@ -153,7 +153,7 @@ class MainMenuActivity(ba.Activity): self.beta_info = self.beta_info_2 = None if app.test_build and not app.kiosk_mode: pos = (230, 125) if app.kiosk_mode else (230, 35) - self.beta_info = ba.Actor( + self.beta_info = ba.NodeActor( ba.newnode('text', attrs={ 'v_attach': 'center', @@ -191,7 +191,7 @@ class MainMenuActivity(ba.Activity): gnode.vignette_outer = (0.45, 0.55, 0.54) gnode.vignette_inner = (0.99, 0.98, 0.98) - self.bottom = ba.Actor( + self.bottom = ba.NodeActor( ba.newnode('terrain', attrs={ 'model': bottom_model, @@ -200,7 +200,7 @@ class MainMenuActivity(ba.Activity): 'reflection_scale': [0.45], 'color_texture': color_texture })) - self.vr_bottom_fill = ba.Actor( + self.vr_bottom_fill = ba.NodeActor( ba.newnode('terrain', attrs={ 'model': vr_bottom_fill_model, @@ -208,7 +208,7 @@ class MainMenuActivity(ba.Activity): 'vr_only': True, 'color_texture': color_texture })) - self.vr_top_fill = ba.Actor( + self.vr_top_fill = ba.NodeActor( ba.newnode('terrain', attrs={ 'model': vr_top_fill_model, @@ -216,7 +216,7 @@ class MainMenuActivity(ba.Activity): 'lighting': False, 'color_texture': bgtex })) - self.terrain = ba.Actor( + self.terrain = ba.NodeActor( ba.newnode('terrain', attrs={ 'model': model, @@ -224,7 +224,7 @@ class MainMenuActivity(ba.Activity): 'reflection': 'soft', 'reflection_scale': [0.3] })) - self.trees = ba.Actor( + self.trees = ba.NodeActor( ba.newnode('terrain', attrs={ 'model': trees_model, @@ -233,7 +233,7 @@ class MainMenuActivity(ba.Activity): 'reflection_scale': [0.1], 'color_texture': trees_texture })) - self.bgterrain = ba.Actor( + self.bgterrain = ba.NodeActor( ba.newnode('terrain', attrs={ 'model': bgmodel, @@ -380,7 +380,7 @@ class MainMenuActivity(ba.Activity): color2 = ((1, 1, 1, 1) if ba.app.vr_mode else (0.7, 0.65, 0.75, 1.0)) shadow = (1.0 if ba.app.vr_mode else 0.4) - self._text = ba.Actor( + self._text = ba.NodeActor( ba.newnode('text', attrs={ 'v_attach': 'top', @@ -652,7 +652,7 @@ class MainMenuActivity(ba.Activity): vr_depth_offset: float = 0.0, shadow: bool = False) -> None: if shadow: - word_obj = ba.Actor( + word_obj = ba.NodeActor( ba.newnode('text', attrs={ 'position': (x, y), @@ -669,7 +669,7 @@ class MainMenuActivity(ba.Activity): })) self._word_actors.append(word_obj) else: - word_obj = ba.Actor( + word_obj = ba.NodeActor( ba.newnode('text', attrs={ 'position': (x, y), @@ -776,7 +776,7 @@ class MainMenuActivity(ba.Activity): mopaque = (None if custom_texture is not None else ba.getmodel('logo')) mtrans = (None if custom_texture is not None else ba.getmodel('logoTransparent')) - logo = ba.Actor( + logo = ba.NodeActor( ba.newnode('image', attrs={ 'texture': ltex, diff --git a/assets/src/ba_data/python/bastd/ui/gather.py b/assets/src/ba_data/python/bastd/ui/gather.py index 03dd96fd..fc63e4f4 100644 --- a/assets/src/ba_data/python/bastd/ui/gather.py +++ b/assets/src/ba_data/python/bastd/ui/gather.py @@ -180,7 +180,7 @@ class GatherWindow(ba.Window): self._scroll_width = self._width - scroll_buffer_h self._scroll_height = self._height - 180.0 - # not actually using a scroll widget anymore; just an image + # Not actually using a scroll widget anymore; just an image. scroll_left = (self._width - self._scroll_width) * 0.5 scroll_bottom = self._height - self._scroll_height - 79 - 48 buffer_h = 10 @@ -214,8 +214,8 @@ class GatherWindow(ba.Window): or _ba.get_account_type() != 'Google Play'): account.show_sign_in_prompt('Google Play') else: - # if there's google play people connected to us, inform the user - # that they will get disconnected.. otherwise just go ahead.. + # If there's google play people connected to us, inform the user + # that they will get disconnected. Otherwise just go ahead. google_player_count = (_ba.get_google_play_party_client_count()) if google_player_count > 0: confirm.ConfirmWindow( @@ -246,25 +246,25 @@ class GatherWindow(ba.Window): return self._current_tab = tab - # we wanna preserve our current tab between runs + # We wanna preserve our current tab between runs. cfg = ba.app.config cfg['Gather Tab'] = tab cfg.commit() - # update tab colors based on which is selected + # Update tab colors based on which is selected. tabs.update_tab_button_colors(self._tab_buttons, tab) - # (re)create scroll widget + # (Re)create scroll widget. if self._tab_container: self._tab_container.delete() scroll_left = (self._width - self._scroll_width) * 0.5 scroll_bottom = self._height - self._scroll_height - 79 - 48 - # a place where tabs can store data to get cleared when switching to - # a different tab + # A place where tabs can store data to get cleared when switching to + # a different tab. self._tab_data = {} - # so we can still select root level widgets with direction buttons + # So we can still select root level widgets with direction buttons. def _simple_message(tab2: str, message: ba.Lstr, string_height: float, @@ -331,7 +331,7 @@ class GatherWindow(ba.Window): ('${BUTTON}', ba.charstr(ba.SpecialChar.TOP_BUTTON))]) - # let's not talk about sharing in vr-mode; its tricky to fit more + # Let's not talk about sharing in vr-mode; its tricky to fit more # than one head in a VR-headset ;-) if not ba.app.vr_mode: msg = ba.Lstr( @@ -472,8 +472,8 @@ class GatherWindow(ba.Window): up_widget=self._tab_buttons[tab]) ba.widget(edit=self._internet_join_text, right_widget=txt) - # attempt to fetch our local address so we have it for - # error messages + # Attempt to fetch our local address so we have it for + # error messages. self._internet_local_address = None class AddrFetchThread(threading.Thread): @@ -501,9 +501,9 @@ class GatherWindow(ba.Window): ba.pushcall(ba.Call(self._call, val), from_other_thread=True) except Exception: + # FIXME: Should filter out expected errors and + # report others here. ba.print_exception() - # FIXME: Should screen out expected errors and - # report others here. AddrFetchThread(ba.WeakCall( self._internet_fetch_local_addr_cb)).start() @@ -518,8 +518,8 @@ class GatherWindow(ba.Window): timetype=ba.TimeType.REAL) } - # also update it immediately so we don't have to wait for the - # initial query.. + # Also update it immediately so we don't have to wait for the + # initial query. self._update_internet_tab() elif tab == 'local_network': @@ -545,8 +545,8 @@ class GatherWindow(ba.Window): ba.WeakCall(self.update), timetype=ba.TimeType.REAL, repeat=True) - # go ahead and run a few *almost* immediately so we don't - # have to wait a second + # Go ahead and run a few *almost* immediately so we don't + # have to wait a second. self.update() ba.timer(0.25, ba.WeakCall(self.update), @@ -566,7 +566,8 @@ class GatherWindow(ba.Window): t_scale = 1.6 for child in self._columnwidget.get_children(): child.delete() - # grab this now this since adding widgets will change it + + # Grab this now this since adding widgets will change it. last_selected_host = self._last_selected_host hosts = _ba.host_scan_cycle() for i, host in enumerate(hosts): @@ -843,7 +844,7 @@ class GatherWindow(ba.Window): color=(1, 0, 0)) ba.playsound(ba.getsound('error')) else: - # store for later + # Store for later. cfg2 = ba.app.config cfg2['Last Manual Party Connect Address'] = addr2 cfg2.commit() @@ -866,7 +867,6 @@ class GatherWindow(ba.Window): tscl = 0.85 tspc = 25 - # v -= 35 def _safe_set_text(txt3: ba.Widget, val: Union[str, ba.Lstr], success: bool = True) -> None: @@ -875,11 +875,10 @@ class GatherWindow(ba.Window): text=val, color=(0, 1, 0) if success else (1, 1, 0)) - # this currently doesn't work from china since we go through a - # reverse proxy there - # EDIT - it should work now; our proxy server forwards along - # original IPs - # app = ba.app + # This currently doesn't work from china since we go through a + # reverse proxy there. + # UPDATE: it should work now; our proxy server forwards along + # original IPs. do_internet_check = True def do_it(v2: float, cnt2: Optional[ba.Widget]) -> None: @@ -940,6 +939,7 @@ class GatherWindow(ba.Window): from_other_thread=True) except Exception as exc: err_str = str(exc) + # FIXME: Should look at exception types here, # not strings. if 'Network is unreachable' in err_str: @@ -1024,14 +1024,15 @@ class GatherWindow(ba.Window): text='') self._doing_access_check = False - self._access_check_count = 0 # cap our refreshes eventually.. + self._access_check_count = 0 # Cap our refreshes eventually. self._tab_data['access_check_timer'] = ba.Timer( 10.0, ba.WeakCall(self._access_check_update, t_addr, t_accessible, t_accessible_extra), repeat=True, timetype=ba.TimeType.REAL) - # kick initial off + + # Kick initial off. self._access_check_update(t_addr, t_accessible, t_accessible_extra) if check_button: @@ -1061,7 +1062,7 @@ class GatherWindow(ba.Window): if playsound: ba.playsound(ba.getsound('click01')) - # if we're switching in from elsewhere, reset our selection + # If we're switching in from elsewhere, reset our selection. # (prevents selecting something way down the list if we switched away # and came back) if self._internet_tab != value: @@ -1077,7 +1078,7 @@ class GatherWindow(ba.Window): edit=self._internet_host_text, color=active_color if value == 'host' else inactive_color) - # clear anything in existence.. + # Clear anything in existence. for widget in [ self._internet_host_scrollwidget, self._internet_host_name_text, @@ -1095,7 +1096,6 @@ class GatherWindow(ba.Window): self._internet_join_status_text, self._internet_host_dedicated_server_info_text ]: - # widget = getattr(self, attr, None) if widget is not None: widget.delete() @@ -1107,9 +1107,10 @@ class GatherWindow(ba.Window): v -= 25 is_public_enabled = _ba.get_public_party_enabled() if value == 'join': - # reset this so we do an immediate refresh query + # Reset this so we do an immediate refresh query. self._internet_join_last_refresh_time = -99999.0 - # reset our list of public parties + + # Reset our list of public parties. self._public_parties = {} self._last_public_party_list_rebuild_time = 0 self._first_public_party_list_rebuild_time = None @@ -1125,8 +1126,7 @@ class GatherWindow(ba.Window): shadow=0.0, h_align='center', v_align='center') - # noinspection PyUnreachableCode - if False: # pylint: disable=using-constant-test + if bool(False): self._internet_join_party_language_label = ba.textwidget( text=ba.Lstr( resource='settingsWindowAdvanced.languageText'), @@ -1192,8 +1192,6 @@ class GatherWindow(ba.Window): color=(0.6, 0.6, 0.6), position=(c_width * 0.5, c_height * 0.5)) - # t_scale = 1.6 - if value == 'host': v -= 30 party_name_text = ba.Lstr( @@ -1313,15 +1311,15 @@ class GatherWindow(ba.Window): color=ba.app.infotextcolor, position=(c_width * 0.5, v)) - # if public sharing is already on, - # launch a status-check immediately + # If public sharing is already on, + # launch a status-check immediately. if _ba.get_public_party_enabled(): self._do_internet_status_check() - # now add a lock icon overlay for if we don't have pro + # Now add a lock icon overlay for if we don't have pro. icon = self._internet_lock_icon if icon and self._internet_lock_icon: - self._internet_lock_icon.delete() # kill any existing + self._internet_lock_icon.delete() # Kill any existing. self._internet_lock_icon = ba.imagewidget( parent=self._tab_container, position=(c_width * 0.5 - 60, c_height * 0.5 - 50), @@ -1354,11 +1352,11 @@ class GatherWindow(ba.Window): def _on_public_party_query_result( self, result: Optional[Dict[str, Any]]) -> None: with ba.Context('ui'): - # any time we get any result at all, kill our loading status + # Any time we get any result at all, kill our loading status. status_text = self._internet_join_status_text if status_text: - # don't show results if not signed in (probably didn't get any - # anyway) + # Don't show results if not signed in + # (probably didn't get any anyway). if _ba.get_account_state() != 'signed_in': ba.textwidget(edit=status_text, text=ba.Lstr(resource='notSignedInText')) @@ -1378,11 +1376,11 @@ class GatherWindow(ba.Window): partyval['claimed'] = False for party_in in parties_in: - # party is indexed by (ADDR)_(PORT) + # Party is indexed by (ADDR)_(PORT) party_key = party_in['a'] + '_' + str(party_in['p']) party = self._public_parties.get(party_key) if party is None: - # if this party is new to us, init it.. + # If this party is new to us, init it. index = self._next_public_party_entry_index self._next_public_party_entry_index = index + 1 party = self._public_parties[party_key] = { @@ -1395,7 +1393,8 @@ class GatherWindow(ba.Window): 'index': index, } - # now, new or not, update its values + + # Now, new or not, update its values. party['queue'] = party_in.get('q') party['port'] = party_in.get('p') party['name'] = party_in['n'] @@ -1407,7 +1406,7 @@ class GatherWindow(ba.Window): party['ping_interval'] = 0.001 * party_in['pi'] party['stats_addr'] = party_in['sa'] - # prune unclaimed party entries + # Prune unclaimed party entries. self._public_parties = { key: val for key, val in list(self._public_parties.items()) @@ -1423,8 +1422,9 @@ class GatherWindow(ba.Window): cur_time = ba.time(ba.TimeType.REAL) if self._first_public_party_list_rebuild_time is None: self._first_public_party_list_rebuild_time = cur_time - # update faster for the first few seconds; - # then ease off to keep the list from jumping around + + # Update faster for the first few seconds; + # then ease off to keep the list from jumping around. since_first = cur_time - self._first_public_party_list_rebuild_time wait_time = (1.0 if since_first < 2.0 else 2.5 if since_first < 10.0 else 5.0) @@ -1433,23 +1433,23 @@ class GatherWindow(ba.Window): return self._last_public_party_list_rebuild_time = cur_time - # first off, check for the existence of our column widget; - # if we don't have this, we're done + # First off, check for the existence of our column widget; + # if we don't have this, we're done. columnwidget = self._internet_host_columnwidget if not columnwidget: return with ba.Context('ui'): - # now kill and recreate all widgets + # Now kill and recreate all widgets. for widget in columnwidget.get_children(): widget.delete() - # sort - show queue-enabled ones first and sort by lowest ping + # Sort - show queue-enabled ones first and sort by lowest ping. ordered_parties = sorted( list(self._public_parties.values()), key=lambda p: ( - p['queue'] is None, # show non-queued last + p['queue'] is None, # Show non-queued last. p['ping'] if p['ping'] is not None else 999999, p['index'], p)) @@ -1457,22 +1457,19 @@ class GatherWindow(ba.Window): first = True sub_scroll_width = 830 - # rval = random.randrange(4, 10) - # print 'doing', rval - # ordered_parties = ordered_parties[:rval] lineheight = 42 sub_scroll_height = lineheight * len(ordered_parties) + 50 ba.containerwidget(edit=columnwidget, size=(sub_scroll_width, sub_scroll_height)) - # ew; this rebuilding generates deferred selection callbacks - # so we need to generated deferred ignore notices for ourself + # Ew; this rebuilding generates deferred selection callbacks + # so we need to generated deferred ignore notices for ourself. def refresh_on() -> None: self._refreshing_public_party_list = True ba.pushcall(refresh_on) - # janky - allow escaping if there's nothing in us + # Janky - allow escaping if there's nothing in us. ba.containerwidget(edit=self._internet_host_scrollwidget, claims_up_down=(len(ordered_parties) > 0)) @@ -1504,8 +1501,7 @@ class GatherWindow(ba.Window): if existing_selection == (party['address'], 'name'): ba.containerwidget(edit=columnwidget, selected_child=party['name_widget']) - # noinspection PyUnreachableCode - if False: # pylint: disable=using-constant-test + if bool(False): party['language_widget'] = ba.textwidget( text=ba.Lstr(translate=('languages', party['language'])), @@ -1621,20 +1617,20 @@ class GatherWindow(ba.Window): def _update_internet_tab(self) -> None: # pylint: disable=too-many-statements - # special case - if a party-queue window is up, don't do any of this - # (keeps things smoother) + # Special case: if a party-queue window is up, don't do any of this + # (keeps things smoother). if ba.app.have_party_queue_window: return - # if we've got a party-name text widget, keep its value plugged - # into our public host name... + # If we've got a party-name text widget, keep its value plugged + # into our public host name. text = self._internet_host_name_text if text: name = cast(str, ba.textwidget(query=self._internet_host_name_text)) _ba.set_public_party_name(name) - # show/hide the lock icon depending on if we've got pro + # Show/hide the lock icon depending on if we've got pro. icon = self._internet_lock_icon if icon: if self._is_internet_locked(): @@ -1657,17 +1653,17 @@ class GatherWindow(ba.Window): callback=ba.WeakCall(self._on_public_party_query_result)) _ba.run_transactions() - # go through our existing public party entries firing off pings - # for any that have timed out + # Go through our existing public party entries firing off pings + # for any that have timed out. for party in list(self._public_parties.values()): if (party['next_ping_time'] <= now and ba.app.ping_thread_count < 15): - # make sure to fully catch up and not to multi-ping if - # we're way behind somehow.. + # Make sure to fully catch up and not to multi-ping if + # we're way behind somehow. while party['next_ping_time'] <= now: - # crank the interval up for high-latency parties to - # save us some work + # Crank the interval up for high-latency parties to + # save us some work. mult = 1 if party['ping'] is not None: mult = (10 if party['ping'] > 300 else @@ -1682,8 +1678,6 @@ class GatherWindow(ba.Window): call: Callable[[str, int, Optional[int]], Optional[int]]): super().__init__() - # need utf8 here to avoid an error on our minimum - # bundled python self._address = address self._port = port self._call = call @@ -1701,11 +1695,9 @@ class GatherWindow(ba.Window): accessible = False starttime = time.time() - # send a simple ping and wait for a response; - # if we get it, they're accessible... - # send a few pings and wait a second for - # a response + # Send a few pings and wait a second for + # a response. sock.settimeout(1) for _i in range(3): sock.send(b'\x0b') @@ -1728,12 +1720,25 @@ class GatherWindow(ba.Window): from_other_thread=True) except OSError as exc: import errno - # ignore harmless errors - if exc.errno != errno.EHOSTUNREACH: - ba.print_exception('error on ping', + + # Ignore harmless errors. + if exc.errno == errno.EHOSTUNREACH: + pass + elif exc.errno == errno.EADDRNOTAVAIL: + if self._port == 0: + # This has happened. Ignore. + pass + elif ba.do_once(): + print( + f'Got EADDRNOTAVAIL on gather ping' + f' for addr {self._address}' + f' port {self._port}.') + else: + ba.print_exception('Error on gather ping.', once=True) except Exception: - ba.print_exception('error on ping', once=True) + ba.print_exception('Error on gather ping', + once=True) ba.app.ping_thread_count -= 1 PingThread(party['address'], party['port'], @@ -1741,8 +1746,8 @@ class GatherWindow(ba.Window): def _ping_callback(self, address: str, port: Optional[int], result: Optional[int]) -> None: - # Look for a widget corresponding to this target; if we find one, - # update our list. + # Look for a widget corresponding to this target. + # If we find one, update our list. party = self._public_parties.get(address + '_' + str(port)) if party is not None: # We now smooth ping a bit to reduce jumping around in the list @@ -1755,9 +1760,11 @@ class GatherWindow(ba.Window): (1.0 - smoothing) * result) else: party['ping'] = result - if 'ping_widget' not in party: - pass # This can happen if we switch away and then back to the + + # This can happen if we switch away and then back to the # client tab while pings are in flight. + if 'ping_widget' not in party: + pass elif party['ping_widget']: self._rebuild_public_party_list() @@ -1946,7 +1953,7 @@ class GatherWindow(ba.Window): ba.app.window_states[self.__class__.__name__] = { 'sel_name': sel_name, 'tab': self._current_tab, - 'internetTab': self._internet_tab + 'internet_tab': self._internet_tab } except Exception: ba.print_exception('error saving state for', self.__class__) @@ -1955,7 +1962,7 @@ class GatherWindow(ba.Window): try: winstate = ba.app.window_states.get(self.__class__.__name__, {}) sel_name = winstate.get('sel_name', None) - self._internet_tab = winstate.get('internetTab', 'join') + self._internet_tab = winstate.get('internet_tab', 'join') current_tab = ba.app.config.get('Gather Tab', None) if current_tab is None or current_tab not in self._tab_buttons: current_tab = 'about' diff --git a/assets/src/ba_data/python/efro/executils.py b/assets/src/ba_data/python/efro/executils.py index 7b587a97..e421f2ff 100644 --- a/assets/src/ba_data/python/efro/executils.py +++ b/assets/src/ba_data/python/efro/executils.py @@ -75,7 +75,7 @@ class CallbackSet(Generic[CT]): # passed. # To use this, simply assign your call type to this Call for type checking: -# example: +# Example: # class _MyCallWrapper: # # if TYPE_CHECKING: diff --git a/docs/ba_module.md b/docs/ba_module.md index 2bfec979..9d798d85 100644 --- a/docs/ba_module.md +++ b/docs/ba_module.md @@ -17,6 +17,7 @@
  • ba.Actor
  • ba.Chooser
  • ba.InputDevice
  • @@ -537,21 +538,22 @@ is a convenient way to access this same functionality.

    Category: Gameplay Classes

    Actors act as controllers, combining some number of ba.Nodes, - ba.Textures, ba.Sounds, etc. into one cohesive unit.

    + ba.Textures, ba.Sounds, etc. into a high-level cohesive unit.

    -

    Some example actors include ba.Bomb, ba.Flag, and ba.Spaz.

    +

    Some example actors include Bomb, Flag, and Spaz classes in bastd.

    One key feature of Actors is that they generally 'die' (killing off or transitioning out their nodes) when the last Python reference to them disappears, so you can use logic such as:

    -
        # create a flag Actor in our game activity
    -    self.flag = ba.Flag(position=(0, 10, 0))
    +
        # 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..
    +
        # 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.
    +    # 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, @@ -566,13 +568,13 @@ is a convenient way to access this same functionality.

    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.

    + message type for Actors is the ba.DieMessage.

    -
        # another way to kill the flag from the example above:
    -    # we can safely call this on any type with a 'handlemessage' method
    -    # (though its not guaranteed to always have a meaningful effect)
    -    # in this case the Actor instance will still be around, but its exists()
    -    # and is_alive() methods will both return False
    +
        # Another way to kill the flag from the example above:
    +    # We can safely call this on any type with a 'handlemessage' method
    +    # (though its not guaranteed to always have a meaningful effect).
    +    # In this case the Actor instance will still be around, but its exists()
    +    # and is_alive() methods will both return False.
         self.flag.handlemessage(ba.DieMessage())
     
    @@ -590,15 +592,10 @@ is a convenient way to access this same functionality.

    <constructor>, autoretain(), exists(), getactivity(), handlemessage(), is_alive(), is_expired(), on_expire()

    <constructor>

    -

    ba.Actor(node: ba.Node = None)

    +

    ba.Actor()

    Instantiates an Actor in the current ba.Activity.

    -

    If 'node' is provided, it is stored as the 'node' attribute -and the default ba.Actor.handlemessage() and ba.Actor.exists() -implementations will apply to it. This allows the creation of -simple node-wrapping Actors without having to create a new subclass.

    -

    autoretain()

    autoretain(self: T) -> T

    @@ -627,8 +624,7 @@ their corpse is visible; this is about presence, not being 'alive' 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 returns 'node.exists()' -if the Actor has a 'node' attr; otherwise True.

    +

    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 @@ -1106,7 +1102,7 @@ when done.

    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.
    +
    # Example: wrap a method call with 1 positional and 1 keyword arg:
     mycall = ba.Call(myobj.dostuff, argval1, namedarg=argval2)
    # Now we have a single callable to run that whole mess.
    @@ -1345,8 +1341,8 @@ Usage:

    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)
    +
    # 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')
    @@ -3250,7 +3246,7 @@ 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
    +
    # 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')
    @@ -3311,6 +3307,48 @@ 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.

    +
    +
    +
    +

    ba.NodeActor

    +

    inherits from: ba.Actor

    +

    A simple ba.Actor type that wraps a single ba.Node.

    + +

    Category: Gameplay Classes

    + +

    This Actor will delete its Node when told to die, and it's + exists() call will return whether the Node still exists or not. +

    + +

    Attributes:

    +
    +

    activity

    +

    ba.Activity

    +

    The Activity this Actor was created in.

    + +

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

    + +
    +
    +

    Methods Inherited:

    +
    autoretain(), exists(), getactivity(), is_alive(), is_expired(), on_expire()
    +

    Methods Defined or Overridden:

    +
    <constructor>, handlemessage()
    +
    +

    <constructor>

    +

    ba.NodeActor(node: ba.Node)

    + +

    Instantiates an Actor in the current ba.Activity.

    + +
    +

    handlemessage()

    +

    handlemessage(self, msg: Any) -> Any

    + +

    General message handling; can be passed any message object.

    + +

    The default implementation will handle ba.DieMessages by +calling self.node.delete() if self contains a 'node' attribute.

    +

    @@ -4652,7 +4690,7 @@ Real time timers are currently only available in the UI context.

    the 'timeformat' arg defaults to SECONDS but can also be MILLISECONDS if you want to pass time as milliseconds.

    -
    # example: use a Timer object to print repeatedly for a few seconds:
    +
    # Example: use a Timer object to print repeatedly for a few seconds:
     def say_it():
         ba.screenmessage('BADGER!')
     def stop_saying_it():
    @@ -4817,7 +4855,7 @@ self.t = ba.Timer(0.3, say_it, repeat=True)
     

    Instantiate a WeakCall; pass a callable as the first arg, followed by any number of arguments or keywords.

    -
    # example: wrap a method call with some positional and
    +
    # Example: wrap a method call with some positional and
     # keyword args:
     myweakcall = ba.WeakCall(myobj.dostuff, argval1, namedarg=argval2)
    @@ -5149,6 +5187,11 @@ 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!')
    +

    ba.emitfx()

    emitfx(position: Sequence[float],