bascenev1
Gameplay-centric api for classic BombSquad.
1# Released under the MIT License. See LICENSE for details. 2# 3"""Gameplay-centric api for classic BombSquad.""" 4 5# ba_meta require api 9 6 7# The stuff we expose here at the top level is our 'public' api for use 8# from other modules/packages. Code *within* this package should import 9# things from this package's submodules directly to reduce the chance of 10# dependency loops. The exception is TYPE_CHECKING blocks and 11# annotations since those aren't evaluated at runtime. 12 13import logging 14 15# Aside from our own stuff, we also bundle a number of things from ba or 16# other modules; the goal is to let most simple mods rely solely on this 17# module to keep things simple. 18 19from efro.util import set_canonical_module_names 20from babase import ( 21 add_clean_frame_callback, 22 app, 23 App, 24 AppIntent, 25 AppIntentDefault, 26 AppIntentExec, 27 AppMode, 28 apptime, 29 AppTime, 30 apptimer, 31 AppTimer, 32 Call, 33 ContextError, 34 ContextRef, 35 displaytime, 36 DisplayTime, 37 displaytimer, 38 DisplayTimer, 39 existing, 40 fade_screen, 41 get_remote_app_name, 42 increment_analytics_count, 43 InputType, 44 is_point_in_box, 45 lock_all_input, 46 Lstr, 47 NodeNotFoundError, 48 normalized_color, 49 NotFoundError, 50 PlayerNotFoundError, 51 Plugin, 52 pushcall, 53 safecolor, 54 screenmessage, 55 set_analytics_screen, 56 SessionNotFoundError, 57 storagename, 58 timestring, 59 UIScale, 60 unlock_all_input, 61 Vec3, 62 WeakCall, 63) 64 65from _bascenev1 import ( 66 ActivityData, 67 basetime, 68 basetimer, 69 BaseTimer, 70 camerashake, 71 capture_gamepad_input, 72 capture_keyboard_input, 73 chatmessage, 74 client_info_query_response, 75 CollisionMesh, 76 connect_to_party, 77 Data, 78 disconnect_client, 79 disconnect_from_host, 80 emitfx, 81 end_host_scanning, 82 get_chat_messages, 83 get_connection_to_host_info, 84 get_connection_to_host_info_2, 85 get_foreground_host_activity, 86 get_foreground_host_session, 87 get_game_port, 88 get_game_roster, 89 get_local_active_input_devices_count, 90 get_public_party_enabled, 91 get_public_party_max_size, 92 get_random_names, 93 get_replay_speed_exponent, 94 get_ui_input_device, 95 getactivity, 96 getcollisionmesh, 97 getdata, 98 getinputdevice, 99 getmesh, 100 getnodes, 101 getsession, 102 getsound, 103 gettexture, 104 have_connected_clients, 105 have_touchscreen_input, 106 host_scan_cycle, 107 InputDevice, 108 is_in_replay, 109 is_replay_paused, 110 ls_input_devices, 111 ls_objects, 112 Material, 113 Mesh, 114 new_host_session, 115 new_replay_session, 116 newactivity, 117 newnode, 118 Node, 119 pause_replay, 120 printnodes, 121 protocol_version, 122 release_gamepad_input, 123 release_keyboard_input, 124 reset_random_player_names, 125 resume_replay, 126 seek_replay, 127 broadcastmessage, 128 SessionData, 129 SessionPlayer, 130 set_admins, 131 set_authenticate_clients, 132 set_debug_speed_exponent, 133 set_enable_default_kick_voting, 134 set_internal_music, 135 set_map_bounds, 136 set_master_server_source, 137 set_public_party_enabled, 138 set_public_party_max_size, 139 set_public_party_name, 140 set_public_party_public_address_ipv4, 141 set_public_party_public_address_ipv6, 142 set_public_party_queue_enabled, 143 set_public_party_stats_url, 144 set_replay_speed_exponent, 145 set_touchscreen_editing, 146 Sound, 147 Texture, 148 time, 149 timer, 150 Timer, 151) 152from bascenev1._activity import Activity 153from bascenev1._activitytypes import JoinActivity, ScoreScreenActivity 154from bascenev1._actor import Actor 155from bascenev1._campaign import init_campaigns, Campaign 156from bascenev1._collision import Collision, getcollision 157from bascenev1._coopgame import CoopGameActivity 158from bascenev1._coopsession import CoopSession 159from bascenev1._debug import print_live_object_warnings 160from bascenev1._dependency import ( 161 Dependency, 162 DependencyComponent, 163 DependencySet, 164 AssetPackage, 165) 166from bascenev1._dualteamsession import DualTeamSession 167from bascenev1._freeforallsession import FreeForAllSession 168from bascenev1._gameactivity import GameActivity 169from bascenev1._gameresults import GameResults 170from bascenev1._gameutils import ( 171 animate, 172 animate_array, 173 BaseTime, 174 cameraflash, 175 GameTip, 176 get_trophy_string, 177 show_damage_count, 178 Time, 179) 180from bascenev1._level import Level 181from bascenev1._lobby import Lobby, Chooser 182from bascenev1._map import ( 183 get_filtered_map_name, 184 get_map_class, 185 get_map_display_string, 186 Map, 187 register_map, 188) 189from bascenev1._messages import ( 190 CelebrateMessage, 191 DeathType, 192 DieMessage, 193 DropMessage, 194 DroppedMessage, 195 FreezeMessage, 196 HitMessage, 197 ImpactDamageMessage, 198 OutOfBoundsMessage, 199 PickedUpMessage, 200 PickUpMessage, 201 PlayerDiedMessage, 202 PlayerProfilesChangedMessage, 203 ShouldShatterMessage, 204 StandMessage, 205 ThawMessage, 206 UNHANDLED, 207) 208from bascenev1._multiteamsession import ( 209 MultiTeamSession, 210 DEFAULT_TEAM_COLORS, 211 DEFAULT_TEAM_NAMES, 212) 213from bascenev1._music import MusicType, setmusic 214from bascenev1._net import HostInfo 215from bascenev1._nodeactor import NodeActor 216from bascenev1._powerup import get_default_powerup_distribution 217from bascenev1._profile import ( 218 get_player_colors, 219 get_player_profile_icon, 220 get_player_profile_colors, 221) 222from bascenev1._player import PlayerInfo, Player, EmptyPlayer, StandLocation 223from bascenev1._playlist import ( 224 get_default_free_for_all_playlist, 225 get_default_teams_playlist, 226 filter_playlist, 227) 228from bascenev1._powerup import PowerupMessage, PowerupAcceptMessage 229from bascenev1._score import ScoreType, ScoreConfig 230from bascenev1._settings import ( 231 BoolSetting, 232 ChoiceSetting, 233 FloatChoiceSetting, 234 FloatSetting, 235 IntChoiceSetting, 236 IntSetting, 237 Setting, 238) 239from bascenev1._session import ( 240 Session, 241 set_player_rejoin_cooldown, 242 set_max_players_override, 243) 244from bascenev1._stats import PlayerScoredMessage, PlayerRecord, Stats 245from bascenev1._team import SessionTeam, Team, EmptyTeam 246from bascenev1._teamgame import TeamGameActivity 247 248__all__ = [ 249 'Activity', 250 'ActivityData', 251 'Actor', 252 'animate', 253 'animate_array', 254 'add_clean_frame_callback', 255 'app', 256 'App', 257 'AppIntent', 258 'AppIntentDefault', 259 'AppIntentExec', 260 'AppMode', 261 'AppTime', 262 'apptime', 263 'apptimer', 264 'AppTimer', 265 'AssetPackage', 266 'basetime', 267 'BaseTime', 268 'basetimer', 269 'BaseTimer', 270 'BoolSetting', 271 'Call', 272 'cameraflash', 273 'camerashake', 274 'Campaign', 275 'capture_gamepad_input', 276 'capture_keyboard_input', 277 'CelebrateMessage', 278 'chatmessage', 279 'ChoiceSetting', 280 'Chooser', 281 'client_info_query_response', 282 'Collision', 283 'CollisionMesh', 284 'connect_to_party', 285 'ContextError', 286 'ContextRef', 287 'CoopGameActivity', 288 'CoopSession', 289 'Data', 290 'DeathType', 291 'DEFAULT_TEAM_COLORS', 292 'DEFAULT_TEAM_NAMES', 293 'Dependency', 294 'DependencyComponent', 295 'DependencySet', 296 'DieMessage', 297 'disconnect_client', 298 'disconnect_from_host', 299 'displaytime', 300 'DisplayTime', 301 'displaytimer', 302 'DisplayTimer', 303 'DropMessage', 304 'DroppedMessage', 305 'DualTeamSession', 306 'emitfx', 307 'EmptyPlayer', 308 'EmptyTeam', 309 'end_host_scanning', 310 'existing', 311 'fade_screen', 312 'filter_playlist', 313 'FloatChoiceSetting', 314 'FloatSetting', 315 'FreeForAllSession', 316 'FreezeMessage', 317 'GameActivity', 318 'GameResults', 319 'GameTip', 320 'get_chat_messages', 321 'get_connection_to_host_info', 322 'get_connection_to_host_info_2', 323 'get_default_free_for_all_playlist', 324 'get_default_teams_playlist', 325 'get_default_powerup_distribution', 326 'get_filtered_map_name', 327 'get_foreground_host_activity', 328 'get_foreground_host_session', 329 'get_game_port', 330 'get_game_roster', 331 'get_game_roster', 332 'get_local_active_input_devices_count', 333 'get_map_class', 334 'get_map_display_string', 335 'get_player_colors', 336 'get_player_profile_colors', 337 'get_player_profile_icon', 338 'get_public_party_enabled', 339 'get_public_party_max_size', 340 'get_random_names', 341 'get_remote_app_name', 342 'get_replay_speed_exponent', 343 'get_trophy_string', 344 'get_ui_input_device', 345 'getactivity', 346 'getcollision', 347 'getcollisionmesh', 348 'getdata', 349 'getinputdevice', 350 'getmesh', 351 'getnodes', 352 'getsession', 353 'getsound', 354 'gettexture', 355 'have_connected_clients', 356 'have_touchscreen_input', 357 'HitMessage', 358 'HostInfo', 359 'host_scan_cycle', 360 'ImpactDamageMessage', 361 'increment_analytics_count', 362 'init_campaigns', 363 'InputDevice', 364 'InputType', 365 'IntChoiceSetting', 366 'IntSetting', 367 'is_in_replay', 368 'is_point_in_box', 369 'is_replay_paused', 370 'JoinActivity', 371 'Level', 372 'Lobby', 373 'lock_all_input', 374 'ls_input_devices', 375 'ls_objects', 376 'Lstr', 377 'Map', 378 'Material', 379 'Mesh', 380 'MultiTeamSession', 381 'MusicType', 382 'new_host_session', 383 'new_replay_session', 384 'newactivity', 385 'newnode', 386 'Node', 387 'NodeActor', 388 'NodeNotFoundError', 389 'normalized_color', 390 'NotFoundError', 391 'OutOfBoundsMessage', 392 'pause_replay', 393 'PickedUpMessage', 394 'PickUpMessage', 395 'Player', 396 'PlayerDiedMessage', 397 'PlayerProfilesChangedMessage', 398 'PlayerInfo', 399 'PlayerNotFoundError', 400 'PlayerRecord', 401 'PlayerScoredMessage', 402 'Plugin', 403 'PowerupAcceptMessage', 404 'PowerupMessage', 405 'print_live_object_warnings', 406 'printnodes', 407 'protocol_version', 408 'pushcall', 409 'register_map', 410 'release_gamepad_input', 411 'release_keyboard_input', 412 'reset_random_player_names', 413 'resume_replay', 414 'seek_replay', 415 'safecolor', 416 'screenmessage', 417 'ScoreConfig', 418 'ScoreScreenActivity', 419 'ScoreType', 420 'SessionNotFoundError', 421 'broadcastmessage', 422 'Session', 423 'SessionData', 424 'SessionPlayer', 425 'SessionTeam', 426 'set_admins', 427 'set_analytics_screen', 428 'set_authenticate_clients', 429 'set_debug_speed_exponent', 430 'set_debug_speed_exponent', 431 'set_enable_default_kick_voting', 432 'set_internal_music', 433 'set_map_bounds', 434 'set_master_server_source', 435 'set_public_party_enabled', 436 'set_public_party_max_size', 437 'set_public_party_name', 438 'set_public_party_public_address_ipv4', 439 'set_public_party_public_address_ipv6', 440 'set_public_party_queue_enabled', 441 'set_public_party_stats_url', 442 'set_player_rejoin_cooldown', 443 'set_max_players_override', 444 'set_replay_speed_exponent', 445 'set_touchscreen_editing', 446 'setmusic', 447 'Setting', 448 'ShouldShatterMessage', 449 'show_damage_count', 450 'Sound', 451 'StandLocation', 452 'StandMessage', 453 'Stats', 454 'storagename', 455 'Team', 456 'TeamGameActivity', 457 'Texture', 458 'ThawMessage', 459 'time', 460 'Time', 461 'timer', 462 'Timer', 463 'timestring', 464 'UIScale', 465 'UNHANDLED', 466 'unlock_all_input', 467 'Vec3', 468 'WeakCall', 469] 470 471# We want stuff here to show up as bascenev1.Foo instead of 472# bascenev1._submodule.Foo. 473set_canonical_module_names(globals()) 474 475# Sanity check: we want to keep ballistica's dependencies and 476# bootstrapping order clearly defined; let's check a few particular 477# modules to make sure they never directly or indirectly import us 478# before their own execs complete. 479if __debug__: 480 for _mdl in 'babase', '_babase': 481 if not hasattr(__import__(_mdl), '_REACHED_END_OF_MODULE'): 482 logging.warning( 483 '%s was imported before %s finished importing;' 484 ' should not happen.', 485 __name__, 486 _mdl, 487 )
26class Activity(DependencyComponent, Generic[PlayerT, TeamT]): 27 """Units of execution wrangled by a bascenev1.Session. 28 29 Examples of Activities include games, score-screens, cutscenes, etc. 30 A bascenev1.Session has one 'current' Activity at any time, though 31 their existence can overlap during transitions. 32 """ 33 34 # pylint: disable=too-many-public-methods 35 36 settings_raw: dict[str, Any] 37 """The settings dict passed in when the activity was made. 38 This attribute is deprecated and should be avoided when possible; 39 activities should pull all values they need from the 'settings' arg 40 passed to the Activity __init__ call.""" 41 42 teams: list[TeamT] 43 """The list of bascenev1.Team-s in the Activity. This gets populated just 44 before on_begin() is called and is updated automatically as players 45 join or leave the game. (at least in free-for-all mode where every 46 player gets their own team; in teams mode there are always 2 teams 47 regardless of the player count).""" 48 49 players: list[PlayerT] 50 """The list of bascenev1.Player-s in the Activity. This gets populated 51 just before on_begin() is called and is updated automatically as 52 players join or leave the game.""" 53 54 announce_player_deaths = False 55 """Whether to print every time a player dies. This can be pertinent 56 in games such as Death-Match but can be annoying in games where it 57 doesn't matter.""" 58 59 is_joining_activity = False 60 """Joining activities are for waiting for initial player joins. 61 They are treated slightly differently than regular activities, 62 mainly in that all players are passed to the activity at once 63 instead of as each joins.""" 64 65 allow_pausing = False 66 """Whether game-time should still progress when in menus/etc.""" 67 68 allow_kick_idle_players = True 69 """Whether idle players can potentially be kicked (should not happen in 70 menus/etc).""" 71 72 use_fixed_vr_overlay = False 73 """In vr mode, this determines whether overlay nodes (text, images, etc) 74 are created at a fixed position in space or one that moves based on 75 the current map. Generally this should be on for games and off for 76 transitions/score-screens/etc. that persist between maps.""" 77 78 slow_motion = False 79 """If True, runs in slow motion and turns down sound pitch.""" 80 81 inherits_slow_motion = False 82 """Set this to True to inherit slow motion setting from previous 83 activity (useful for transitions to avoid hitches).""" 84 85 inherits_music = False 86 """Set this to True to keep playing the music from the previous activity 87 (without even restarting it).""" 88 89 inherits_vr_camera_offset = False 90 """Set this to true to inherit VR camera offsets from the previous 91 activity (useful for preventing sporadic camera movement 92 during transitions).""" 93 94 inherits_vr_overlay_center = False 95 """Set this to true to inherit (non-fixed) VR overlay positioning from 96 the previous activity (useful for prevent sporadic overlay jostling 97 during transitions).""" 98 99 inherits_tint = False 100 """Set this to true to inherit screen tint/vignette colors from the 101 previous activity (useful to prevent sudden color changes during 102 transitions).""" 103 104 allow_mid_activity_joins: bool = True 105 """Whether players should be allowed to join in the middle of this 106 activity. Note that Sessions may not allow mid-activity-joins even 107 if the activity says its ok.""" 108 109 transition_time = 0.0 110 """If the activity fades or transitions in, it should set the length of 111 time here so that previous activities will be kept alive for that 112 long (avoiding 'holes' in the screen) 113 This value is given in real-time seconds.""" 114 115 can_show_ad_on_death = False 116 """Is it ok to show an ad after this activity ends before showing 117 the next activity?""" 118 119 def __init__(self, settings: dict): 120 """Creates an Activity in the current bascenev1.Session. 121 122 The activity will not be actually run until 123 bascenev1.Session.setactivity is called. 'settings' should be a 124 dict of key/value pairs specific to the activity. 125 126 Activities should preload as much of their media/etc as possible in 127 their constructor, but none of it should actually be used until they 128 are transitioned in. 129 """ 130 super().__init__() 131 132 # Create our internal engine data. 133 self._activity_data = _bascenev1.register_activity(self) 134 135 assert isinstance(settings, dict) 136 assert _bascenev1.getactivity() is self 137 138 self._globalsnode: bascenev1.Node | None = None 139 140 # Player/Team types should have been specified as type args; 141 # grab those. 142 self._playertype: type[PlayerT] 143 self._teamtype: type[TeamT] 144 self._setup_player_and_team_types() 145 146 # FIXME: Relocate or remove the need for this stuff. 147 self.paused_text: bascenev1.Actor | None = None 148 149 self._session = weakref.ref(_bascenev1.getsession()) 150 151 # Preloaded data for actors, maps, etc; indexed by type. 152 self.preloads: dict[type, Any] = {} 153 154 # Hopefully can eventually kill this; activities should 155 # validate/store whatever settings they need at init time 156 # (in a more type-safe way). 157 self.settings_raw = settings 158 159 self._has_transitioned_in = False 160 self._has_begun = False 161 self._has_ended = False 162 self._activity_death_check_timer: bascenev1.AppTimer | None = None 163 self._expired = False 164 self._delay_delete_players: list[PlayerT] = [] 165 self._delay_delete_teams: list[TeamT] = [] 166 self._players_that_left: list[weakref.ref[PlayerT]] = [] 167 self._teams_that_left: list[weakref.ref[TeamT]] = [] 168 self._transitioning_out = False 169 170 # A handy place to put most actors; this list is pruned of dead 171 # actors regularly and these actors are insta-killed as the activity 172 # is dying. 173 self._actor_refs: list[bascenev1.Actor] = [] 174 self._actor_weak_refs: list[weakref.ref[bascenev1.Actor]] = [] 175 self._last_prune_dead_actors_time = babase.apptime() 176 self._prune_dead_actors_timer: bascenev1.Timer | None = None 177 178 self.teams = [] 179 self.players = [] 180 181 self.lobby = None 182 self._stats: bascenev1.Stats | None = None 183 self._customdata: dict | None = {} 184 185 def __del__(self) -> None: 186 # If the activity has been run then we should have already cleaned 187 # it up, but we still need to run expire calls for un-run activities. 188 if not self._expired: 189 with babase.ContextRef.empty(): 190 self._expire() 191 192 # Inform our owner that we officially kicked the bucket. 193 if self._transitioning_out: 194 session = self._session() 195 if session is not None: 196 babase.pushcall( 197 babase.Call( 198 session.transitioning_out_activity_was_freed, 199 self.can_show_ad_on_death, 200 ) 201 ) 202 203 @property 204 def context(self) -> bascenev1.ContextRef: 205 """A context-ref pointing at this activity.""" 206 return self._activity_data.context() 207 208 @property 209 def globalsnode(self) -> bascenev1.Node: 210 """The 'globals' bascenev1.Node for the activity. This contains various 211 global controls and values. 212 """ 213 node = self._globalsnode 214 if not node: 215 raise babase.NodeNotFoundError() 216 return node 217 218 @property 219 def stats(self) -> bascenev1.Stats: 220 """The stats instance accessible while the activity is running. 221 222 If access is attempted before or after, raises a 223 bascenev1.NotFoundError. 224 """ 225 if self._stats is None: 226 raise babase.NotFoundError() 227 return self._stats 228 229 def on_expire(self) -> None: 230 """Called when your activity is being expired. 231 232 If your activity has created anything explicitly that may be retaining 233 a strong reference to the activity and preventing it from dying, you 234 should clear that out here. From this point on your activity's sole 235 purpose in life is to hit zero references and die so the next activity 236 can begin. 237 """ 238 239 @property 240 def customdata(self) -> dict: 241 """Entities needing to store simple data with an activity can put it 242 here. This dict will be deleted when the activity expires, so contained 243 objects generally do not need to worry about handling expired 244 activities. 245 """ 246 assert not self._expired 247 assert isinstance(self._customdata, dict) 248 return self._customdata 249 250 @property 251 def expired(self) -> bool: 252 """Whether the activity is expired. 253 254 An activity is set as expired when shutting down. 255 At this point no new nodes, timers, etc should be made, 256 run, etc, and the activity should be considered a 'zombie'. 257 """ 258 return self._expired 259 260 @property 261 def playertype(self) -> type[PlayerT]: 262 """The type of bascenev1.Player this Activity is using.""" 263 return self._playertype 264 265 @property 266 def teamtype(self) -> type[TeamT]: 267 """The type of bascenev1.Team this Activity is using.""" 268 return self._teamtype 269 270 def set_has_ended(self, val: bool) -> None: 271 """(internal)""" 272 self._has_ended = val 273 274 def expire(self) -> None: 275 """Begin the process of tearing down the activity. 276 277 (internal) 278 """ 279 280 # Create an app-timer that watches a weak-ref of this activity 281 # and reports any lingering references keeping it alive. 282 # We store the timer on the activity so as soon as the activity dies 283 # it gets cleaned up. 284 with babase.ContextRef.empty(): 285 ref = weakref.ref(self) 286 self._activity_death_check_timer = babase.AppTimer( 287 5.0, 288 babase.Call(self._check_activity_death, ref, [0]), 289 repeat=True, 290 ) 291 292 # Run _expire in an empty context; nothing should be happening in 293 # there except deleting things which requires no context. 294 # (plus, _expire() runs in the destructor for un-run activities 295 # and we can't properly provide context in that situation anyway; might 296 # as well be consistent). 297 if not self._expired: 298 with babase.ContextRef.empty(): 299 self._expire() 300 else: 301 raise RuntimeError( 302 f'destroy() called when already expired for {self}.' 303 ) 304 305 def retain_actor(self, actor: bascenev1.Actor) -> None: 306 """Add a strong-reference to a bascenev1.Actor to this Activity. 307 308 The reference will be lazily released once bascenev1.Actor.exists() 309 returns False for the Actor. The bascenev1.Actor.autoretain() method 310 is a convenient way to access this same functionality. 311 """ 312 if __debug__: 313 from bascenev1._actor import Actor 314 315 assert isinstance(actor, Actor) 316 self._actor_refs.append(actor) 317 318 def add_actor_weak_ref(self, actor: bascenev1.Actor) -> None: 319 """Add a weak-reference to a bascenev1.Actor to the bascenev1.Activity. 320 321 (called by the bascenev1.Actor base class) 322 """ 323 if __debug__: 324 from bascenev1._actor import Actor 325 326 assert isinstance(actor, Actor) 327 self._actor_weak_refs.append(weakref.ref(actor)) 328 329 @property 330 def session(self) -> bascenev1.Session: 331 """The bascenev1.Session this bascenev1.Activity belongs to. 332 333 Raises a :class:`~bascenev1.SessionNotFoundError` if the Session 334 no longer exists. 335 """ 336 session = self._session() 337 if session is None: 338 raise babase.SessionNotFoundError() 339 return session 340 341 def on_player_join(self, player: PlayerT) -> None: 342 """Called when a new bascenev1.Player has joined the Activity. 343 344 (including the initial set of Players) 345 """ 346 347 def on_player_leave(self, player: PlayerT) -> None: 348 """Called when a bascenev1.Player is leaving the Activity.""" 349 350 def on_team_join(self, team: TeamT) -> None: 351 """Called when a new bascenev1.Team joins the Activity. 352 353 (including the initial set of Teams) 354 """ 355 356 def on_team_leave(self, team: TeamT) -> None: 357 """Called when a bascenev1.Team leaves the Activity.""" 358 359 def on_transition_in(self) -> None: 360 """Called when the Activity is first becoming visible. 361 362 Upon this call, the Activity should fade in backgrounds, 363 start playing music, etc. It does not yet have access to players 364 or teams, however. They remain owned by the previous Activity 365 up until bascenev1.Activity.on_begin() is called. 366 """ 367 368 def on_transition_out(self) -> None: 369 """Called when your activity begins transitioning out. 370 371 Note that this may happen at any time even if bascenev1.Activity.end() 372 has not been called. 373 """ 374 375 def on_begin(self) -> None: 376 """Called once the previous Activity has finished transitioning out. 377 378 At this point the activity's initial players and teams are filled in 379 and it should begin its actual game logic. 380 """ 381 382 def handlemessage(self, msg: Any) -> Any: 383 """General message handling; can be passed any message object.""" 384 del msg # Unused arg. 385 return UNHANDLED 386 387 def has_transitioned_in(self) -> bool: 388 """Return whether bascenev1.Activity.on_transition_in() has run.""" 389 return self._has_transitioned_in 390 391 def has_begun(self) -> bool: 392 """Return whether bascenev1.Activity.on_begin() has run.""" 393 return self._has_begun 394 395 def has_ended(self) -> bool: 396 """Return whether the activity has commenced ending.""" 397 return self._has_ended 398 399 def is_transitioning_out(self) -> bool: 400 """Return whether bascenev1.Activity.on_transition_out() has run.""" 401 return self._transitioning_out 402 403 def transition_in(self, prev_globals: bascenev1.Node | None) -> None: 404 """Called by Session to kick off transition-in. 405 406 (internal) 407 """ 408 assert not self._has_transitioned_in 409 self._has_transitioned_in = True 410 411 # Set up the globals node based on our settings. 412 with self.context: 413 glb = self._globalsnode = _bascenev1.newnode('globals') 414 415 # Now that it's going to be front and center, 416 # set some global values based on what the activity wants. 417 glb.use_fixed_vr_overlay = self.use_fixed_vr_overlay 418 glb.allow_kick_idle_players = self.allow_kick_idle_players 419 if self.inherits_slow_motion and prev_globals is not None: 420 glb.slow_motion = prev_globals.slow_motion 421 else: 422 glb.slow_motion = self.slow_motion 423 if self.inherits_music and prev_globals is not None: 424 glb.music_continuous = True # Prevent restarting same music. 425 glb.music = prev_globals.music 426 glb.music_count += 1 427 if self.inherits_vr_camera_offset and prev_globals is not None: 428 glb.vr_camera_offset = prev_globals.vr_camera_offset 429 if self.inherits_vr_overlay_center and prev_globals is not None: 430 glb.vr_overlay_center = prev_globals.vr_overlay_center 431 glb.vr_overlay_center_enabled = ( 432 prev_globals.vr_overlay_center_enabled 433 ) 434 435 # If they want to inherit tint from the previous self. 436 if self.inherits_tint and prev_globals is not None: 437 glb.tint = prev_globals.tint 438 glb.vignette_outer = prev_globals.vignette_outer 439 glb.vignette_inner = prev_globals.vignette_inner 440 441 # Start pruning our various things periodically. 442 self._prune_dead_actors() 443 self._prune_dead_actors_timer = _bascenev1.Timer( 444 5.17, self._prune_dead_actors, repeat=True 445 ) 446 447 _bascenev1.timer(13.3, self._prune_delay_deletes, repeat=True) 448 449 # Also start our low-level scene running. 450 self._activity_data.start() 451 452 try: 453 self.on_transition_in() 454 except Exception: 455 logging.exception('Error in on_transition_in for %s.', self) 456 457 # Tell the C++ layer that this activity is the main one, so it uses 458 # settings from our globals, directs various events to us, etc. 459 self._activity_data.make_foreground() 460 461 def transition_out(self) -> None: 462 """Called by the Session to start us transitioning out.""" 463 assert not self._transitioning_out 464 self._transitioning_out = True 465 with self.context: 466 try: 467 self.on_transition_out() 468 except Exception: 469 logging.exception('Error in on_transition_out for %s.', self) 470 471 def begin(self, session: bascenev1.Session) -> None: 472 """Begin the activity. 473 474 (internal) 475 """ 476 477 assert not self._has_begun 478 479 # Inherit stats from the session. 480 self._stats = session.stats 481 482 # Add session's teams in. 483 for team in session.sessionteams: 484 self.add_team(team) 485 486 # Add session's players in. 487 for player in session.sessionplayers: 488 self.add_player(player) 489 490 self._has_begun = True 491 492 # Let the activity do its thing. 493 with self.context: 494 # Note: do we want to catch errors here? 495 # Currently I believe we wind up canceling the 496 # activity launch; just wanna be sure that is intentional. 497 self.on_begin() 498 499 def end( 500 self, results: Any = None, delay: float = 0.0, force: bool = False 501 ) -> None: 502 """Commences Activity shutdown and delivers results to the Session. 503 504 'delay' is the time delay before the Activity actually ends 505 (in seconds). Further calls to end() will be ignored up until 506 this time, unless 'force' is True, in which case the new results 507 will replace the old. 508 """ 509 510 # Ask the session to end us. 511 self.session.end_activity(self, results, delay, force) 512 513 def create_player(self, sessionplayer: bascenev1.SessionPlayer) -> PlayerT: 514 """Create the Player instance for this Activity. 515 516 Subclasses can override this if the activity's player class 517 requires a custom constructor; otherwise it will be called with 518 no args. Note that the player object should not be used at this 519 point as it is not yet fully wired up; wait for 520 bascenev1.Activity.on_player_join() for that. 521 """ 522 del sessionplayer # Unused. 523 player = self._playertype() 524 return player 525 526 def create_team(self, sessionteam: bascenev1.SessionTeam) -> TeamT: 527 """Create the Team instance for this Activity. 528 529 Subclasses can override this if the activity's team class 530 requires a custom constructor; otherwise it will be called with 531 no args. Note that the team object should not be used at this 532 point as it is not yet fully wired up; wait for on_team_join() 533 for that. 534 """ 535 del sessionteam # Unused. 536 team = self._teamtype() 537 return team 538 539 def add_player(self, sessionplayer: bascenev1.SessionPlayer) -> None: 540 """(internal)""" 541 assert sessionplayer.sessionteam is not None 542 sessionplayer.resetinput() 543 sessionteam = sessionplayer.sessionteam 544 assert sessionplayer in sessionteam.players 545 team = sessionteam.activityteam 546 assert team is not None 547 sessionplayer.setactivity(self) 548 with self.context: 549 sessionplayer.activityplayer = player = self.create_player( 550 sessionplayer 551 ) 552 player.postinit(sessionplayer) 553 554 assert player not in team.players 555 team.players.append(player) 556 assert player in team.players 557 558 assert player not in self.players 559 self.players.append(player) 560 assert player in self.players 561 562 try: 563 self.on_player_join(player) 564 except Exception: 565 logging.exception('Error in on_player_join for %s.', self) 566 567 def remove_player(self, sessionplayer: bascenev1.SessionPlayer) -> None: 568 """Remove a player from the Activity while it is running. 569 570 (internal) 571 """ 572 assert not self.expired 573 574 player: Any = sessionplayer.activityplayer 575 assert isinstance(player, self._playertype) 576 team: Any = sessionplayer.sessionteam.activityteam 577 assert isinstance(team, self._teamtype) 578 579 assert player in team.players 580 team.players.remove(player) 581 assert player not in team.players 582 583 assert player in self.players 584 self.players.remove(player) 585 assert player not in self.players 586 587 # This should allow our bascenev1.Player instance to die. 588 # Complain if that doesn't happen. 589 # verify_object_death(player) 590 591 with self.context: 592 try: 593 self.on_player_leave(player) 594 except Exception: 595 logging.exception('Error in on_player_leave for %s.', self) 596 try: 597 player.leave() 598 except Exception: 599 logging.exception('Error on leave for %s in %s.', player, self) 600 601 self._reset_session_player_for_no_activity(sessionplayer) 602 603 # Add the player to a list to keep it around for a while. This is 604 # to discourage logic from firing on player object death, which 605 # may not happen until activity end if something is holding refs 606 # to it. 607 self._delay_delete_players.append(player) 608 self._players_that_left.append(weakref.ref(player)) 609 610 def add_team(self, sessionteam: bascenev1.SessionTeam) -> None: 611 """Add a team to the Activity 612 613 (internal) 614 """ 615 assert not self.expired 616 617 with self.context: 618 sessionteam.activityteam = team = self.create_team(sessionteam) 619 team.postinit(sessionteam) 620 self.teams.append(team) 621 try: 622 self.on_team_join(team) 623 except Exception: 624 logging.exception('Error in on_team_join for %s.', self) 625 626 def remove_team(self, sessionteam: bascenev1.SessionTeam) -> None: 627 """Remove a team from a Running Activity 628 629 (internal) 630 """ 631 assert not self.expired 632 assert sessionteam.activityteam is not None 633 634 team: Any = sessionteam.activityteam 635 assert isinstance(team, self._teamtype) 636 637 assert team in self.teams 638 self.teams.remove(team) 639 assert team not in self.teams 640 641 with self.context: 642 # Make a decent attempt to persevere if user code breaks. 643 try: 644 self.on_team_leave(team) 645 except Exception: 646 logging.exception('Error in on_team_leave for %s.', self) 647 try: 648 team.leave() 649 except Exception: 650 logging.exception('Error on leave for %s in %s.', team, self) 651 652 sessionteam.activityteam = None 653 654 # Add the team to a list to keep it around for a while. This is 655 # to discourage logic from firing on team object death, which 656 # may not happen until activity end if something is holding refs 657 # to it. 658 self._delay_delete_teams.append(team) 659 self._teams_that_left.append(weakref.ref(team)) 660 661 def _reset_session_player_for_no_activity( 662 self, sessionplayer: bascenev1.SessionPlayer 663 ) -> None: 664 # Let's be extra-defensive here: killing a node/input-call/etc 665 # could trigger user-code resulting in errors, but we would still 666 # like to complete the reset if possible. 667 try: 668 sessionplayer.setnode(None) 669 except Exception: 670 logging.exception( 671 'Error resetting SessionPlayer node on %s for %s.', 672 sessionplayer, 673 self, 674 ) 675 try: 676 sessionplayer.resetinput() 677 except Exception: 678 logging.exception( 679 'Error resetting SessionPlayer input on %s for %s.', 680 sessionplayer, 681 self, 682 ) 683 684 # These should never fail I think... 685 sessionplayer.setactivity(None) 686 sessionplayer.activityplayer = None 687 688 # noinspection PyUnresolvedReferences 689 def _setup_player_and_team_types(self) -> None: 690 """Pull player and team types from our typing.Generic params.""" 691 692 # TODO: There are proper calls for pulling these in Python 3.8; 693 # should update this code when we adopt that. 694 # NOTE: If we get Any as PlayerT or TeamT (generally due 695 # to no generic params being passed) we automatically use the 696 # base class types, but also warn the user since this will mean 697 # less type safety for that class. (its better to pass the base 698 # player/team types explicitly vs. having them be Any) 699 if not TYPE_CHECKING: 700 self._playertype = type(self).__orig_bases__[-1].__args__[0] 701 if not isinstance(self._playertype, type): 702 self._playertype = Player 703 print( 704 f'ERROR: {type(self)} was not passed a Player' 705 f' type argument; please explicitly pass bascenev1.Player' 706 f' if you do not want to override it.' 707 ) 708 self._teamtype = type(self).__orig_bases__[-1].__args__[1] 709 if not isinstance(self._teamtype, type): 710 self._teamtype = Team 711 print( 712 f'ERROR: {type(self)} was not passed a Team' 713 f' type argument; please explicitly pass bascenev1.Team' 714 f' if you do not want to override it.' 715 ) 716 assert issubclass(self._playertype, Player) 717 assert issubclass(self._teamtype, Team) 718 719 @classmethod 720 def _check_activity_death( 721 cls, activity_ref: weakref.ref[Activity], counter: list[int] 722 ) -> None: 723 """Sanity check to make sure an Activity was destroyed properly. 724 725 Receives a weakref to a bascenev1.Activity which should have torn 726 itself down due to no longer being referenced anywhere. Will complain 727 and/or print debugging info if the Activity still exists. 728 """ 729 try: 730 activity = activity_ref() 731 print( 732 'ERROR: Activity is not dying when expected:', 733 activity, 734 '(warning ' + str(counter[0] + 1) + ')', 735 ) 736 print( 737 'This means something is still strong-referencing it.\n' 738 'Check out methods such as efro.debug.printrefs() to' 739 ' help debug this sort of thing.' 740 ) 741 # Note: no longer calling gc.get_referrers() here because it's 742 # usage can bork stuff. (see notes at top of efro.debug) 743 counter[0] += 1 744 if counter[0] == 4: 745 print('Killing app due to stuck activity... :-(') 746 babase.quit() 747 748 except Exception: 749 logging.exception('Error on _check_activity_death.') 750 751 def _expire(self) -> None: 752 """Put the activity in a state where it can be garbage-collected. 753 754 This involves clearing anything that might be holding a reference 755 to it, etc. 756 """ 757 assert not self._expired 758 self._expired = True 759 760 try: 761 self.on_expire() 762 except Exception: 763 logging.exception('Error in Activity on_expire() for %s.', self) 764 765 try: 766 self._customdata = None 767 except Exception: 768 logging.exception('Error clearing customdata for %s.', self) 769 770 # Don't want to be holding any delay-delete refs at this point. 771 self._prune_delay_deletes() 772 773 self._expire_actors() 774 self._expire_players() 775 self._expire_teams() 776 777 # This will kill all low level stuff: Timers, Nodes, etc., which 778 # should clear up any remaining refs to our Activity and allow us 779 # to die peacefully. 780 try: 781 self._activity_data.expire() 782 except Exception: 783 logging.exception('Error expiring _activity_data for %s.', self) 784 785 def _expire_actors(self) -> None: 786 # Expire all Actors. 787 for actor_ref in self._actor_weak_refs: 788 actor = actor_ref() 789 if actor is not None: 790 babase.verify_object_death(actor) 791 try: 792 actor.on_expire() 793 except Exception: 794 logging.exception( 795 'Error in Actor.on_expire() for %s.', actor_ref() 796 ) 797 798 def _expire_players(self) -> None: 799 # Issue warnings for any players that left the game but don't 800 # get freed soon. 801 for ex_player in (p() for p in self._players_that_left): 802 if ex_player is not None: 803 babase.verify_object_death(ex_player) 804 805 for player in self.players: 806 # This should allow our bascenev1.Player instance to be freed. 807 # Complain if that doesn't happen. 808 babase.verify_object_death(player) 809 810 try: 811 player.expire() 812 except Exception: 813 logging.exception('Error expiring %s.', player) 814 815 # Reset the SessionPlayer to a not-in-an-activity state. 816 try: 817 sessionplayer = player.sessionplayer 818 self._reset_session_player_for_no_activity(sessionplayer) 819 except babase.SessionPlayerNotFoundError: 820 # Conceivably, someone could have held on to a Player object 821 # until now whos underlying SessionPlayer left long ago... 822 pass 823 except Exception: 824 logging.exception('Error expiring %s.', player) 825 826 def _expire_teams(self) -> None: 827 # Issue warnings for any teams that left the game but don't 828 # get freed soon. 829 for ex_team in (p() for p in self._teams_that_left): 830 if ex_team is not None: 831 babase.verify_object_death(ex_team) 832 833 for team in self.teams: 834 # This should allow our bascenev1.Team instance to die. 835 # Complain if that doesn't happen. 836 babase.verify_object_death(team) 837 838 try: 839 team.expire() 840 except Exception: 841 logging.exception('Error expiring %s.', team) 842 843 try: 844 sessionteam = team.sessionteam 845 sessionteam.activityteam = None 846 except babase.SessionTeamNotFoundError: 847 # It is expected that Team objects may last longer than 848 # the SessionTeam they came from (game objects may hold 849 # team references past the point at which the underlying 850 # player/team has left the game) 851 pass 852 except Exception: 853 logging.exception('Error expiring Team %s.', team) 854 855 def _prune_delay_deletes(self) -> None: 856 self._delay_delete_players.clear() 857 self._delay_delete_teams.clear() 858 859 # Clear out any dead weak-refs. 860 self._teams_that_left = [ 861 t for t in self._teams_that_left if t() is not None 862 ] 863 self._players_that_left = [ 864 p for p in self._players_that_left if p() is not None 865 ] 866 867 def _prune_dead_actors(self) -> None: 868 self._last_prune_dead_actors_time = babase.apptime() 869 870 # Prune our strong refs when the Actor's exists() call gives False 871 self._actor_refs = [a for a in self._actor_refs if a.exists()] 872 873 # Prune our weak refs once the Actor object has been freed. 874 self._actor_weak_refs = [ 875 a for a in self._actor_weak_refs if a() is not None 876 ]
Units of execution wrangled by a bascenev1.Session.
Examples of Activities include games, score-screens, cutscenes, etc. A bascenev1.Session has one 'current' Activity at any time, though their existence can overlap during transitions.
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 bascenev1.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 bascenev1.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?
203 @property 204 def context(self) -> bascenev1.ContextRef: 205 """A context-ref pointing at this activity.""" 206 return self._activity_data.context()
A context-ref pointing at this activity.
208 @property 209 def globalsnode(self) -> bascenev1.Node: 210 """The 'globals' bascenev1.Node for the activity. This contains various 211 global controls and values. 212 """ 213 node = self._globalsnode 214 if not node: 215 raise babase.NodeNotFoundError() 216 return node
The 'globals' bascenev1.Node for the activity. This contains various global controls and values.
218 @property 219 def stats(self) -> bascenev1.Stats: 220 """The stats instance accessible while the activity is running. 221 222 If access is attempted before or after, raises a 223 bascenev1.NotFoundError. 224 """ 225 if self._stats is None: 226 raise babase.NotFoundError() 227 return self._stats
The stats instance accessible while the activity is running.
If access is attempted before or after, raises a bascenev1.NotFoundError.
229 def on_expire(self) -> None: 230 """Called when your activity is being expired. 231 232 If your activity has created anything explicitly that may be retaining 233 a strong reference to the activity and preventing it from dying, you 234 should clear that out here. From this point on your activity's sole 235 purpose in life is to hit zero references and die so the next activity 236 can begin. 237 """
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.
239 @property 240 def customdata(self) -> dict: 241 """Entities needing to store simple data with an activity can put it 242 here. This dict will be deleted when the activity expires, so contained 243 objects generally do not need to worry about handling expired 244 activities. 245 """ 246 assert not self._expired 247 assert isinstance(self._customdata, dict) 248 return self._customdata
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.
250 @property 251 def expired(self) -> bool: 252 """Whether the activity is expired. 253 254 An activity is set as expired when shutting down. 255 At this point no new nodes, timers, etc should be made, 256 run, etc, and the activity should be considered a 'zombie'. 257 """ 258 return self._expired
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'.
260 @property 261 def playertype(self) -> type[PlayerT]: 262 """The type of bascenev1.Player this Activity is using.""" 263 return self._playertype
The type of bascenev1.Player this Activity is using.
265 @property 266 def teamtype(self) -> type[TeamT]: 267 """The type of bascenev1.Team this Activity is using.""" 268 return self._teamtype
The type of bascenev1.Team this Activity is using.
305 def retain_actor(self, actor: bascenev1.Actor) -> None: 306 """Add a strong-reference to a bascenev1.Actor to this Activity. 307 308 The reference will be lazily released once bascenev1.Actor.exists() 309 returns False for the Actor. The bascenev1.Actor.autoretain() method 310 is a convenient way to access this same functionality. 311 """ 312 if __debug__: 313 from bascenev1._actor import Actor 314 315 assert isinstance(actor, Actor) 316 self._actor_refs.append(actor)
Add a strong-reference to a bascenev1.Actor to this Activity.
The reference will be lazily released once bascenev1.Actor.exists() returns False for the Actor. The bascenev1.Actor.autoretain() method is a convenient way to access this same functionality.
318 def add_actor_weak_ref(self, actor: bascenev1.Actor) -> None: 319 """Add a weak-reference to a bascenev1.Actor to the bascenev1.Activity. 320 321 (called by the bascenev1.Actor base class) 322 """ 323 if __debug__: 324 from bascenev1._actor import Actor 325 326 assert isinstance(actor, Actor) 327 self._actor_weak_refs.append(weakref.ref(actor))
Add a weak-reference to a bascenev1.Actor to the bascenev1.Activity.
(called by the bascenev1.Actor base class)
329 @property 330 def session(self) -> bascenev1.Session: 331 """The bascenev1.Session this bascenev1.Activity belongs to. 332 333 Raises a :class:`~bascenev1.SessionNotFoundError` if the Session 334 no longer exists. 335 """ 336 session = self._session() 337 if session is None: 338 raise babase.SessionNotFoundError() 339 return session
The bascenev1.Session this bascenev1.Activity belongs to.
Raises a ~bascenev1.SessionNotFoundError
if the Session
no longer exists.
341 def on_player_join(self, player: PlayerT) -> None: 342 """Called when a new bascenev1.Player has joined the Activity. 343 344 (including the initial set of Players) 345 """
Called when a new bascenev1.Player has joined the Activity.
(including the initial set of Players)
347 def on_player_leave(self, player: PlayerT) -> None: 348 """Called when a bascenev1.Player is leaving the Activity."""
Called when a bascenev1.Player is leaving the Activity.
350 def on_team_join(self, team: TeamT) -> None: 351 """Called when a new bascenev1.Team joins the Activity. 352 353 (including the initial set of Teams) 354 """
Called when a new bascenev1.Team joins the Activity.
(including the initial set of Teams)
356 def on_team_leave(self, team: TeamT) -> None: 357 """Called when a bascenev1.Team leaves the Activity."""
Called when a bascenev1.Team leaves the Activity.
359 def on_transition_in(self) -> None: 360 """Called when the Activity is first becoming visible. 361 362 Upon this call, the Activity should fade in backgrounds, 363 start playing music, etc. It does not yet have access to players 364 or teams, however. They remain owned by the previous Activity 365 up until bascenev1.Activity.on_begin() is called. 366 """
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 bascenev1.Activity.on_begin() is called.
368 def on_transition_out(self) -> None: 369 """Called when your activity begins transitioning out. 370 371 Note that this may happen at any time even if bascenev1.Activity.end() 372 has not been called. 373 """
Called when your activity begins transitioning out.
Note that this may happen at any time even if bascenev1.Activity.end() has not been called.
375 def on_begin(self) -> None: 376 """Called once the previous Activity has finished transitioning out. 377 378 At this point the activity's initial players and teams are filled in 379 and it should begin its actual game logic. 380 """
Called once the previous 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.
382 def handlemessage(self, msg: Any) -> Any: 383 """General message handling; can be passed any message object.""" 384 del msg # Unused arg. 385 return UNHANDLED
General message handling; can be passed any message object.
387 def has_transitioned_in(self) -> bool: 388 """Return whether bascenev1.Activity.on_transition_in() has run.""" 389 return self._has_transitioned_in
Return whether bascenev1.Activity.on_transition_in() has run.
391 def has_begun(self) -> bool: 392 """Return whether bascenev1.Activity.on_begin() has run.""" 393 return self._has_begun
Return whether bascenev1.Activity.on_begin() has run.
395 def has_ended(self) -> bool: 396 """Return whether the activity has commenced ending.""" 397 return self._has_ended
Return whether the activity has commenced ending.
399 def is_transitioning_out(self) -> bool: 400 """Return whether bascenev1.Activity.on_transition_out() has run.""" 401 return self._transitioning_out
Return whether bascenev1.Activity.on_transition_out() has run.
461 def transition_out(self) -> None: 462 """Called by the Session to start us transitioning out.""" 463 assert not self._transitioning_out 464 self._transitioning_out = True 465 with self.context: 466 try: 467 self.on_transition_out() 468 except Exception: 469 logging.exception('Error in on_transition_out for %s.', self)
Called by the Session to start us transitioning out.
499 def end( 500 self, results: Any = None, delay: float = 0.0, force: bool = False 501 ) -> None: 502 """Commences Activity shutdown and delivers results to the Session. 503 504 'delay' is the time delay before the Activity actually ends 505 (in seconds). Further calls to end() will be ignored up until 506 this time, unless 'force' is True, in which case the new results 507 will replace the old. 508 """ 509 510 # Ask the session to end us. 511 self.session.end_activity(self, results, delay, force)
Commences Activity shutdown and delivers results to the 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.
513 def create_player(self, sessionplayer: bascenev1.SessionPlayer) -> PlayerT: 514 """Create the Player instance for this Activity. 515 516 Subclasses can override this if the activity's player class 517 requires a custom constructor; otherwise it will be called with 518 no args. Note that the player object should not be used at this 519 point as it is not yet fully wired up; wait for 520 bascenev1.Activity.on_player_join() for that. 521 """ 522 del sessionplayer # Unused. 523 player = self._playertype() 524 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 bascenev1.Activity.on_player_join() for that.
526 def create_team(self, sessionteam: bascenev1.SessionTeam) -> TeamT: 527 """Create the Team instance for this Activity. 528 529 Subclasses can override this if the activity's team class 530 requires a custom constructor; otherwise it will be called with 531 no args. Note that the team object should not be used at this 532 point as it is not yet fully wired up; wait for on_team_join() 533 for that. 534 """ 535 del sessionteam # Unused. 536 team = self._teamtype() 537 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.
30class Actor: 31 """High level logical entities in a bascenev1.Activity. 32 33 Actors act as controllers, combining some number of Nodes, Textures, 34 Sounds, etc. into a high-level cohesive unit. 35 36 Some example actors include the Bomb, Flag, and Spaz classes that 37 live in the bascenev1lib.actor.* modules. 38 39 One key feature of Actors is that they generally 'die' (killing off 40 or transitioning out their nodes) when the last Python reference to 41 them disappears, so you can use logic such as: 42 43 ##### Example 44 >>> # Create a flag Actor in our game activity: 45 ... from bascenev1lib.actor.flag import Flag 46 ... self.flag = Flag(position=(0, 10, 0)) 47 ... 48 ... # Later, destroy the flag. 49 ... # (provided nothing else is holding a reference to it) 50 ... # We could also just assign a new flag to this value. 51 ... # Either way, the old flag disappears. 52 ... self.flag = None 53 54 This is in contrast to the behavior of the more low level 55 bascenev1.Node, which is always explicitly created and destroyed 56 and doesn't care how many Python references to it exist. 57 58 Note, however, that you can use the bascenev1.Actor.autoretain() method 59 if you want an Actor to stick around until explicitly killed 60 regardless of references. 61 62 Another key feature of bascenev1.Actor is its 63 bascenev1.Actor.handlemessage() method, which takes a single arbitrary 64 object as an argument. This provides a safe way to communicate between 65 bascenev1.Actor, bascenev1.Activity, bascenev1.Session, and any other 66 class providing a handlemessage() method. The most universally handled 67 message type for Actors is the bascenev1.DieMessage. 68 69 Another way to kill the flag from the example above: 70 We can safely call this on any type with a 'handlemessage' method 71 (though its not guaranteed to always have a meaningful effect). 72 In this case the Actor instance will still be around, but its 73 bascenev1.Actor.exists() and bascenev1.Actor.is_alive() methods will 74 both return False. 75 >>> self.flag.handlemessage(bascenev1.DieMessage()) 76 """ 77 78 def __init__(self) -> None: 79 """Instantiates an Actor in the current bascenev1.Activity.""" 80 81 if __debug__: 82 self._root_actor_init_called = True 83 activity = _bascenev1.getactivity() 84 self._activity = weakref.ref(activity) 85 activity.add_actor_weak_ref(self) 86 87 def __del__(self) -> None: 88 try: 89 # Unexpired Actors send themselves a DieMessage when going down. 90 # That way we can treat DieMessage handling as the single 91 # point-of-action for death. 92 if not self.expired: 93 self.handlemessage(DieMessage()) 94 except Exception: 95 logging.exception( 96 'Error in bascenev1.Actor.__del__() for %s.', self 97 ) 98 99 def handlemessage(self, msg: Any) -> Any: 100 """General message handling; can be passed any message object.""" 101 assert not self.expired 102 103 # By default, actors going out-of-bounds simply kill themselves. 104 if isinstance(msg, OutOfBoundsMessage): 105 return self.handlemessage(DieMessage(how=DeathType.OUT_OF_BOUNDS)) 106 107 return UNHANDLED 108 109 def autoretain(self: ActorT) -> ActorT: 110 """Keep this Actor alive without needing to hold a reference to it. 111 112 This keeps the bascenev1.Actor in existence by storing a reference 113 to it with the bascenev1.Activity it was created in. The reference 114 is lazily released once bascenev1.Actor.exists() returns False for 115 it or when the Activity is set as expired. This can be a convenient 116 alternative to storing references explicitly just to keep a 117 bascenev1.Actor from dying. 118 For convenience, this method returns the bascenev1.Actor it is called 119 with, enabling chained statements such as: 120 myflag = bascenev1.Flag().autoretain() 121 """ 122 activity = self._activity() 123 if activity is None: 124 raise babase.ActivityNotFoundError() 125 activity.retain_actor(self) 126 return self 127 128 def on_expire(self) -> None: 129 """Called for remaining `bascenev1.Actor`s when their activity dies. 130 131 Actors can use this opportunity to clear callbacks or other 132 references which have the potential of keeping the bascenev1.Activity 133 alive inadvertently (Activities can not exit cleanly while 134 any Python references to them remain.) 135 136 Once an actor is expired (see bascenev1.Actor.is_expired()) it should 137 no longer perform any game-affecting operations (creating, modifying, 138 or deleting nodes, media, timers, etc.) Attempts to do so will 139 likely result in errors. 140 """ 141 142 @property 143 def expired(self) -> bool: 144 """Whether the Actor is expired. 145 146 (see bascenev1.Actor.on_expire()) 147 """ 148 activity = self.getactivity(doraise=False) 149 return True if activity is None else activity.expired 150 151 def exists(self) -> bool: 152 """Returns whether the Actor is still present in a meaningful way. 153 154 Note that a dying character should still return True here as long as 155 their corpse is visible; this is about presence, not being 'alive' 156 (see bascenev1.Actor.is_alive() for that). 157 158 If this returns False, it is assumed the Actor can be completely 159 deleted without affecting the game; this call is often used 160 when pruning lists of Actors, such as with bascenev1.Actor.autoretain() 161 162 The default implementation of this method always return True. 163 164 Note that the boolean operator for the Actor class calls this method, 165 so a simple "if myactor" test will conveniently do the right thing 166 even if myactor is set to None. 167 """ 168 return True 169 170 def __bool__(self) -> bool: 171 # Cleaner way to test existence; friendlier to None values. 172 return self.exists() 173 174 def is_alive(self) -> bool: 175 """Returns whether the Actor is 'alive'. 176 177 What this means is up to the Actor. 178 It is not a requirement for Actors to be able to die; 179 just that they report whether they consider themselves 180 to be alive or not. In cases where dead/alive is 181 irrelevant, True should be returned. 182 """ 183 return True 184 185 @property 186 def activity(self) -> bascenev1.Activity: 187 """The Activity this Actor was created in. 188 189 Raises a bascenev1.ActivityNotFoundError if the Activity no longer 190 exists. 191 """ 192 activity = self._activity() 193 if activity is None: 194 raise babase.ActivityNotFoundError() 195 return activity 196 197 # Overloads to convey our exact return type depending on 'doraise' value. 198 199 @overload 200 def getactivity( 201 self, doraise: Literal[True] = True 202 ) -> bascenev1.Activity: ... 203 204 @overload 205 def getactivity( 206 self, doraise: Literal[False] 207 ) -> bascenev1.Activity | None: ... 208 209 def getactivity(self, doraise: bool = True) -> bascenev1.Activity | None: 210 """Return the bascenev1.Activity this Actor is associated with. 211 212 If the Activity no longer exists, raises a 213 bascenev1.ActivityNotFoundError or returns None depending on whether 214 'doraise' is True. 215 """ 216 activity = self._activity() 217 if activity is None and doraise: 218 raise babase.ActivityNotFoundError() 219 return activity
High level logical entities in a bascenev1.Activity.
Actors act as controllers, combining some number of Nodes, Textures, Sounds, etc. into a high-level cohesive unit.
Some example actors include the Bomb, Flag, and Spaz classes that live in the bascenev1lib.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 bascenev1lib.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 bascenev1.Node, which is always explicitly created and destroyed and doesn't care how many Python references to it exist.
Note, however, that you can use the bascenev1.Actor.autoretain() method if you want an Actor to stick around until explicitly killed regardless of references.
Another key feature of bascenev1.Actor is its bascenev1.Actor.handlemessage() method, which takes a single arbitrary object as an argument. This provides a safe way to communicate between bascenev1.Actor, bascenev1.Activity, bascenev1.Session, and any other class providing a handlemessage() method. The most universally handled message type for Actors is the bascenev1.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 bascenev1.Actor.exists() and bascenev1.Actor.is_alive() methods will both return False.
>>> self.flag.handlemessage(bascenev1.DieMessage())
78 def __init__(self) -> None: 79 """Instantiates an Actor in the current bascenev1.Activity.""" 80 81 if __debug__: 82 self._root_actor_init_called = True 83 activity = _bascenev1.getactivity() 84 self._activity = weakref.ref(activity) 85 activity.add_actor_weak_ref(self)
Instantiates an Actor in the current bascenev1.Activity.
99 def handlemessage(self, msg: Any) -> Any: 100 """General message handling; can be passed any message object.""" 101 assert not self.expired 102 103 # By default, actors going out-of-bounds simply kill themselves. 104 if isinstance(msg, OutOfBoundsMessage): 105 return self.handlemessage(DieMessage(how=DeathType.OUT_OF_BOUNDS)) 106 107 return UNHANDLED
General message handling; can be passed any message object.
109 def autoretain(self: ActorT) -> ActorT: 110 """Keep this Actor alive without needing to hold a reference to it. 111 112 This keeps the bascenev1.Actor in existence by storing a reference 113 to it with the bascenev1.Activity it was created in. The reference 114 is lazily released once bascenev1.Actor.exists() returns False for 115 it or when the Activity is set as expired. This can be a convenient 116 alternative to storing references explicitly just to keep a 117 bascenev1.Actor from dying. 118 For convenience, this method returns the bascenev1.Actor it is called 119 with, enabling chained statements such as: 120 myflag = bascenev1.Flag().autoretain() 121 """ 122 activity = self._activity() 123 if activity is None: 124 raise babase.ActivityNotFoundError() 125 activity.retain_actor(self) 126 return self
Keep this Actor alive without needing to hold a reference to it.
This keeps the bascenev1.Actor in existence by storing a reference to it with the bascenev1.Activity it was created in. The reference is lazily released once bascenev1.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 bascenev1.Actor from dying. For convenience, this method returns the bascenev1.Actor it is called with, enabling chained statements such as: myflag = bascenev1.Flag().autoretain()
128 def on_expire(self) -> None: 129 """Called for remaining `bascenev1.Actor`s when their activity dies. 130 131 Actors can use this opportunity to clear callbacks or other 132 references which have the potential of keeping the bascenev1.Activity 133 alive inadvertently (Activities can not exit cleanly while 134 any Python references to them remain.) 135 136 Once an actor is expired (see bascenev1.Actor.is_expired()) it should 137 no longer perform any game-affecting operations (creating, modifying, 138 or deleting nodes, media, timers, etc.) Attempts to do so will 139 likely result in errors. 140 """
Called for remaining bascenev1.Actor
s when their activity dies.
Actors can use this opportunity to clear callbacks or other references which have the potential of keeping the bascenev1.Activity alive inadvertently (Activities can not exit cleanly while any Python references to them remain.)
Once an actor is expired (see bascenev1.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.
142 @property 143 def expired(self) -> bool: 144 """Whether the Actor is expired. 145 146 (see bascenev1.Actor.on_expire()) 147 """ 148 activity = self.getactivity(doraise=False) 149 return True if activity is None else activity.expired
Whether the Actor is expired.
151 def exists(self) -> bool: 152 """Returns whether the Actor is still present in a meaningful way. 153 154 Note that a dying character should still return True here as long as 155 their corpse is visible; this is about presence, not being 'alive' 156 (see bascenev1.Actor.is_alive() for that). 157 158 If this returns False, it is assumed the Actor can be completely 159 deleted without affecting the game; this call is often used 160 when pruning lists of Actors, such as with bascenev1.Actor.autoretain() 161 162 The default implementation of this method always return True. 163 164 Note that the boolean operator for the Actor class calls this method, 165 so a simple "if myactor" test will conveniently do the right thing 166 even if myactor is set to None. 167 """ 168 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 bascenev1.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 bascenev1.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.
174 def is_alive(self) -> bool: 175 """Returns whether the Actor is 'alive'. 176 177 What this means is up to the Actor. 178 It is not a requirement for Actors to be able to die; 179 just that they report whether they consider themselves 180 to be alive or not. In cases where dead/alive is 181 irrelevant, True should be returned. 182 """ 183 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.
185 @property 186 def activity(self) -> bascenev1.Activity: 187 """The Activity this Actor was created in. 188 189 Raises a bascenev1.ActivityNotFoundError if the Activity no longer 190 exists. 191 """ 192 activity = self._activity() 193 if activity is None: 194 raise babase.ActivityNotFoundError() 195 return activity
The Activity this Actor was created in.
Raises a bascenev1.ActivityNotFoundError if the Activity no longer exists.
209 def getactivity(self, doraise: bool = True) -> bascenev1.Activity | None: 210 """Return the bascenev1.Activity this Actor is associated with. 211 212 If the Activity no longer exists, raises a 213 bascenev1.ActivityNotFoundError or returns None depending on whether 214 'doraise' is True. 215 """ 216 activity = self._activity() 217 if activity is None and doraise: 218 raise babase.ActivityNotFoundError() 219 return activity
Return the bascenev1.Activity this Actor is associated with.
If the Activity no longer exists, raises a bascenev1.ActivityNotFoundError or returns None depending on whether 'doraise' is True.
49def animate( 50 node: bascenev1.Node, 51 attr: str, 52 keys: dict[float, float], 53 loop: bool = False, 54 offset: float = 0, 55) -> bascenev1.Node: 56 """Animate values on a target bascenev1.Node. 57 58 Creates an 'animcurve' node with the provided values and time as an input, 59 connect it to the provided attribute, and set it to die with the target. 60 Key values are provided as time:value dictionary pairs. Time values are 61 relative to the current time. By default, times are specified in seconds, 62 but timeformat can also be set to MILLISECONDS to recreate the old behavior 63 (prior to ba 1.5) of taking milliseconds. Returns the animcurve node. 64 """ 65 items = list(keys.items()) 66 items.sort() 67 68 curve = _bascenev1.newnode( 69 'animcurve', 70 owner=node, 71 name='Driving ' + str(node) + ' \'' + attr + '\'', 72 ) 73 74 # We take seconds but operate on milliseconds internally. 75 mult = 1000 76 77 curve.times = [int(mult * time) for time, val in items] 78 curve.offset = int(_bascenev1.time() * 1000.0) + int(mult * offset) 79 curve.values = [val for time, val in items] 80 curve.loop = loop 81 82 # If we're not looping, set a timer to kill this curve 83 # after its done its job. 84 # FIXME: Even if we are looping we should have a way to die once we 85 # get disconnected. 86 if not loop: 87 # noinspection PyUnresolvedReferences 88 _bascenev1.timer( 89 (int(mult * items[-1][0]) + 1000) / 1000.0, curve.delete 90 ) 91 92 # Do the connects last so all our attrs are in place when we push initial 93 # values through. 94 95 # We operate in either activities or sessions.. 96 try: 97 globalsnode = _bascenev1.getactivity().globalsnode 98 except babase.ActivityNotFoundError: 99 globalsnode = _bascenev1.getsession().sessionglobalsnode 100 101 globalsnode.connectattr('time', curve, 'in') 102 curve.connectattr('out', node, attr) 103 return curve
Animate values on a target bascenev1.Node.
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.
106def animate_array( 107 node: bascenev1.Node, 108 attr: str, 109 size: int, 110 keys: dict[float, Sequence[float]], 111 *, 112 loop: bool = False, 113 offset: float = 0, 114) -> None: 115 """Animate an array of values on a target bascenev1.Node. 116 117 Like bs.animate, but operates on array attributes. 118 """ 119 combine = _bascenev1.newnode('combine', owner=node, attrs={'size': size}) 120 items = list(keys.items()) 121 items.sort() 122 123 # We take seconds but operate on milliseconds internally. 124 mult = 1000 125 126 # We operate in either activities or sessions.. 127 try: 128 globalsnode = _bascenev1.getactivity().globalsnode 129 except babase.ActivityNotFoundError: 130 globalsnode = _bascenev1.getsession().sessionglobalsnode 131 132 for i in range(size): 133 curve = _bascenev1.newnode( 134 'animcurve', 135 owner=node, 136 name=( 137 'Driving ' + str(node) + ' \'' + attr + '\' member ' + str(i) 138 ), 139 ) 140 globalsnode.connectattr('time', curve, 'in') 141 curve.times = [int(mult * time) for time, val in items] 142 curve.values = [val[i] for time, val in items] 143 curve.offset = int(_bascenev1.time() * 1000.0) + int(mult * offset) 144 curve.loop = loop 145 curve.connectattr('out', combine, 'input' + str(i)) 146 147 # If we're not looping, set a timer to kill this 148 # curve after its done its job. 149 if not loop: 150 # (PyCharm seems to think item is a float, not a tuple) 151 # noinspection PyUnresolvedReferences 152 _bascenev1.timer( 153 (int(mult * items[-1][0]) + 1000) / 1000.0, 154 curve.delete, 155 ) 156 combine.connectattr('output', node, attr) 157 158 # If we're not looping, set a timer to kill the combine once 159 # the job is done. 160 # FIXME: Even if we are looping we should have a way to die 161 # once we get disconnected. 162 if not loop: 163 # (PyCharm seems to think item is a float, not a tuple) 164 # noinspection PyUnresolvedReferences 165 _bascenev1.timer( 166 (int(mult * items[-1][0]) + 1000) / 1000.0, combine.delete 167 )
Animate an array of values on a target bascenev1.Node.
Like bs.animate, but operates on array attributes.
52class App: 53 """High level Ballistica app functionality and state. 54 55 Access the single shared instance of this class via the "app" attr 56 available on various high level modules such as :mod:`bauiv1` and 57 :mod:`bascenev1`. 58 """ 59 60 # pylint: disable=too-many-public-methods 61 62 class State(Enum): 63 """High level state the app can be in.""" 64 65 #: The app has not yet begun starting and should not be used in 66 #: any way. 67 NOT_STARTED = 0 68 69 #: The native layer is spinning up its machinery (screens, 70 #: renderers, etc.). Nothing should happen in the Python layer 71 #: until this completes. 72 NATIVE_BOOTSTRAPPING = 1 73 74 #: Python app subsystems are being inited but should not yet 75 #: interact or do any work. 76 INITING = 2 77 78 #: Python app subsystems are inited and interacting, but the app 79 #: has not yet embarked on a high level course of action. It is 80 #: doing initial account logins, workspace & asset downloads, 81 #: etc. 82 LOADING = 3 83 84 #: All pieces are in place and the app is now doing its thing. 85 RUNNING = 4 86 87 #: Used on platforms such as mobile where the app basically needs 88 #: to shut down while backgrounded. In this state, all event 89 #: loops are suspended and all graphics and audio must cease 90 #: completely. Be aware that the suspended state can be entered 91 #: from any other state including NATIVE_BOOTSTRAPPING and 92 #: SHUTTING_DOWN. 93 SUSPENDED = 5 94 95 #: The app is shutting down. This process may involve sending 96 #: network messages or other things that can take up to a few 97 #: seconds, so ideally graphics and audio should remain 98 #: functional (with fades or spinners or whatever to show 99 #: something is happening). 100 SHUTTING_DOWN = 6 101 102 #: The app has completed shutdown. Any code running here should 103 #: be basically immediate. 104 SHUTDOWN_COMPLETE = 7 105 106 class DefaultAppModeSelector(AppModeSelector): 107 """Decides which AppMode to use to handle AppIntents. 108 109 This default version is generated by the project updater based 110 on the 'default_app_modes' value in the projectconfig. 111 112 It is also possible to modify app mode selection behavior by 113 setting app.mode_selector to an instance of a custom 114 AppModeSelector subclass. This is a good way to go if you are 115 modifying app behavior dynamically via a plugin instead of 116 statically in a spinoff project. 117 """ 118 119 @override 120 def app_mode_for_intent( 121 self, intent: AppIntent 122 ) -> type[AppMode] | None: 123 # pylint: disable=cyclic-import 124 125 # __DEFAULT_APP_MODE_SELECTION_BEGIN__ 126 # This section generated by batools.appmodule; do not edit. 127 128 # Ask our default app modes to handle it. 129 # (generated from 'default_app_modes' in projectconfig). 130 import baclassic 131 import babase 132 133 for appmode in [ 134 baclassic.ClassicAppMode, 135 babase.EmptyAppMode, 136 ]: 137 if appmode.can_handle_intent(intent): 138 return appmode 139 140 return None 141 142 # __DEFAULT_APP_MODE_SELECTION_END__ 143 144 # A few things defined as non-optional values but not actually 145 # available until the app starts. 146 plugins: PluginSubsystem 147 lang: LanguageSubsystem 148 health_monitor: AppHealthMonitor 149 150 # Define some other types here in the class-def so docs-generators 151 # are more likely to know about them. 152 config: AppConfig 153 env: babase.Env 154 state: State 155 threadpool: ThreadPoolExecutorPlus 156 meta: MetadataSubsystem 157 net: NetworkSubsystem 158 workspaces: WorkspaceSubsystem 159 components: AppComponentSubsystem 160 stringedit: StringEditSubsystem 161 devconsole: DevConsoleSubsystem 162 fg_state: int 163 164 #: How long we allow shutdown tasks to run before killing them. 165 #: Currently the entire app hard-exits if shutdown takes 15 seconds, 166 #: so we need to keep it under that. Staying above 10 should allow 167 #: 10 second network timeouts to happen though. 168 SHUTDOWN_TASK_TIMEOUT_SECONDS = 12 169 170 def __init__(self) -> None: 171 """(internal) 172 173 Do not instantiate this class. You can access the single shared 174 instance of it through various high level packages: 'babase.app', 175 'bascenev1.app', 'bauiv1.app', etc. 176 """ 177 178 # Hack for docs-generation: we can be imported with dummy modules 179 # instead of our actual binary ones, but we don't function. 180 if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1': 181 return 182 183 # Wrap our raw app config in our special wrapper and pass it to 184 # the native layer. 185 self.config = AppConfig(_babase.get_initial_app_config()) 186 _babase.set_app_config(self.config) 187 188 self.env = _babase.Env() 189 self.state = self.State.NOT_STARTED 190 191 # Default executor which can be used for misc background 192 # processing. It should also be passed to any additional asyncio 193 # loops we create so that everything shares the same single set 194 # of worker threads. 195 self.threadpool = ThreadPoolExecutorPlus( 196 thread_name_prefix='baworker', 197 initializer=self._thread_pool_thread_init, 198 ) 199 self.meta = MetadataSubsystem() 200 self.net = NetworkSubsystem() 201 self.workspaces = WorkspaceSubsystem() 202 self.components = AppComponentSubsystem() 203 self.stringedit = StringEditSubsystem() 204 self.devconsole = DevConsoleSubsystem() 205 206 # This is incremented any time the app is backgrounded or 207 # foregrounded; can be a simple way to determine if network data 208 # should be refreshed/etc. 209 self.fg_state = 0 210 211 self._subsystems: list[AppSubsystem] = [] 212 self._native_bootstrapping_completed = False 213 self._init_completed = False 214 self._meta_scan_completed = False 215 self._native_start_called = False 216 self._native_suspended = False 217 self._native_shutdown_called = False 218 self._native_shutdown_complete_called = False 219 self._initial_sign_in_completed = False 220 self._called_on_initing = False 221 self._called_on_loading = False 222 self._called_on_running = False 223 self._subsystem_registration_ended = False 224 self._pending_apply_app_config = False 225 self._asyncio_loop: asyncio.AbstractEventLoop | None = None 226 self._asyncio_tasks: set[asyncio.Task] = set() 227 self._asyncio_timer: babase.AppTimer | None = None 228 self._pending_intent: AppIntent | None = None 229 self._intent: AppIntent | None = None 230 self._mode_selector: babase.AppModeSelector | None = None 231 self._mode_instances: dict[type[AppMode], AppMode] = {} 232 self._mode: AppMode | None = None 233 self._shutdown_task: asyncio.Task[None] | None = None 234 self._shutdown_tasks: list[Coroutine[None, None, None]] = [ 235 self._wait_for_shutdown_suppressions(), 236 self._fade_and_shutdown_graphics(), 237 self._fade_and_shutdown_audio(), 238 ] 239 self._pool_thread_count = 0 240 241 # We hold a lock while lazy-loading our subsystem properties so 242 # we don't spin up any subsystem more than once, but the lock is 243 # recursive so that the subsystems can instantiate other 244 # subsystems. 245 self._subsystem_property_lock = RLock() 246 self._subsystem_property_data: dict[str, AppSubsystem | bool] = {} 247 248 def postinit(self) -> None: 249 """Called after we've been inited and assigned to babase.app. 250 251 Anything that accesses babase.app as part of its init process 252 must go here instead of __init__. 253 """ 254 255 # Hack for docs-generation: We can be imported with dummy 256 # modules instead of our actual binary ones, but we don't 257 # function. 258 if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1': 259 return 260 261 self.lang = LanguageSubsystem() 262 self.plugins = PluginSubsystem() 263 264 @property 265 def active(self) -> bool: 266 """Whether the app is currently front and center. 267 268 This will be False when the app is hidden, other activities 269 are covering it, etc. (depending on the platform). 270 """ 271 return _babase.app_is_active() 272 273 @property 274 def mode(self) -> AppMode | None: 275 """The app's current mode.""" 276 assert _babase.in_logic_thread() 277 return self._mode 278 279 @property 280 def asyncio_loop(self) -> asyncio.AbstractEventLoop: 281 """The logic thread's asyncio event loop. 282 283 This allow async tasks to be run in the logic thread. 284 285 Generally you should call App.create_async_task() to schedule 286 async code to run instead of using this directly. That will 287 handle retaining the task and logging errors automatically. 288 Only schedule tasks onto asyncio_loop yourself when you intend 289 to hold on to the returned task and await its results. Releasing 290 the task reference can lead to subtle bugs such as unreported 291 errors and garbage-collected tasks disappearing before their 292 work is done. 293 294 Note that, at this time, the asyncio loop is encapsulated 295 and explicitly stepped by the engine's logic thread loop and 296 thus things like asyncio.get_running_loop() will unintuitively 297 *not* return this loop from most places in the logic thread; 298 only from within a task explicitly created in this loop. 299 Hopefully this situation will be improved in the future with a 300 unified event loop. 301 """ 302 assert _babase.in_logic_thread() 303 assert self._asyncio_loop is not None 304 return self._asyncio_loop 305 306 def create_async_task( 307 self, coro: Coroutine[Any, Any, T], *, name: str | None = None 308 ) -> None: 309 """Create a fully managed async task. 310 311 This will automatically retain and release a reference to the task 312 and log any exceptions that occur in it. If you need to await a task 313 or otherwise need more control, schedule a task directly using 314 App.asyncio_loop. 315 """ 316 assert _babase.in_logic_thread() 317 318 # We hold a strong reference to the task until it is done. 319 # Otherwise it is possible for it to be garbage collected and 320 # disappear midway if the caller does not hold on to the 321 # returned task, which seems like a great way to introduce 322 # hard-to-track bugs. 323 task = self.asyncio_loop.create_task(coro, name=name) 324 self._asyncio_tasks.add(task) 325 task.add_done_callback(self._on_task_done) 326 327 def _on_task_done(self, task: asyncio.Task) -> None: 328 # Report any errors that occurred. 329 try: 330 exc = task.exception() 331 if exc is not None: 332 logging.error( 333 "Error in async task '%s'.", task.get_name(), exc_info=exc 334 ) 335 except Exception: 336 logging.exception('Error reporting async task error.') 337 338 self._asyncio_tasks.remove(task) 339 340 @property 341 def mode_selector(self) -> babase.AppModeSelector: 342 """Controls which app-modes are used for handling given intents. 343 344 Plugins can override this to change high level app behavior and 345 spinoff projects can change the default implementation for the 346 same effect. 347 """ 348 if self._mode_selector is None: 349 raise RuntimeError( 350 'mode_selector cannot be used until the app reaches' 351 ' the running state.' 352 ) 353 return self._mode_selector 354 355 @mode_selector.setter 356 def mode_selector(self, selector: babase.AppModeSelector) -> None: 357 self._mode_selector = selector 358 359 def _get_subsystem_property( 360 self, ssname: str, create_call: Callable[[], AppSubsystem | None] 361 ) -> AppSubsystem | None: 362 363 # Quick-out: if a subsystem is present, just return it; no 364 # locking necessary. 365 val = self._subsystem_property_data.get(ssname) 366 if val is not None: 367 if val is False: 368 # False means subsystem is confirmed as unavailable. 369 return None 370 if val is not True: 371 # A subsystem has been set. Return it. 372 return val 373 374 # Anything else (no val present or val True) requires locking. 375 with self._subsystem_property_lock: 376 val = self._subsystem_property_data.get(ssname) 377 if val is not None: 378 if val is False: 379 # False means confirmed as not present. 380 return None 381 if val is True: 382 # True means this property is already being loaded, 383 # and the fact that we're holding the lock means 384 # we're doing the loading, so this is a dependency 385 # loop. Not good. 386 raise RuntimeError( 387 f'Subsystem dependency loop detected for {ssname}' 388 ) 389 # Must be an instantiated subsystem. Noice. 390 return val 391 392 # Ok, there's nothing here for it. Instantiate and set it 393 # while we hold the lock. Set a placeholder value of True 394 # while we load so we can error if something we're loading 395 # tries to recursively load us. 396 self._subsystem_property_data[ssname] = True 397 398 # Do our one attempt to create the singleton. 399 val = create_call() 400 self._subsystem_property_data[ssname] = ( 401 False if val is None else val 402 ) 403 404 return val 405 406 # __FEATURESET_APP_SUBSYSTEM_PROPERTIES_BEGIN__ 407 # This section generated by batools.appmodule; do not edit. 408 409 @property 410 def classic(self) -> ClassicAppSubsystem | None: 411 """Our classic subsystem (if available).""" 412 return self._get_subsystem_property( 413 'classic', self._create_classic_subsystem 414 ) # type: ignore 415 416 @staticmethod 417 def _create_classic_subsystem() -> ClassicAppSubsystem | None: 418 # pylint: disable=cyclic-import 419 try: 420 from baclassic import ClassicAppSubsystem 421 422 return ClassicAppSubsystem() 423 except ImportError: 424 return None 425 except Exception: 426 logging.exception('Error importing baclassic.') 427 return None 428 429 @property 430 def plus(self) -> PlusAppSubsystem | None: 431 """Our plus subsystem (if available).""" 432 return self._get_subsystem_property( 433 'plus', self._create_plus_subsystem 434 ) # type: ignore 435 436 @staticmethod 437 def _create_plus_subsystem() -> PlusAppSubsystem | None: 438 # pylint: disable=cyclic-import 439 try: 440 from baplus import PlusAppSubsystem 441 442 return PlusAppSubsystem() 443 except ImportError: 444 return None 445 except Exception: 446 logging.exception('Error importing baplus.') 447 return None 448 449 @property 450 def ui_v1(self) -> UIV1AppSubsystem: 451 """Our ui_v1 subsystem (always available).""" 452 return self._get_subsystem_property( 453 'ui_v1', self._create_ui_v1_subsystem 454 ) # type: ignore 455 456 @staticmethod 457 def _create_ui_v1_subsystem() -> UIV1AppSubsystem: 458 # pylint: disable=cyclic-import 459 460 from bauiv1 import UIV1AppSubsystem 461 462 return UIV1AppSubsystem() 463 464 # __FEATURESET_APP_SUBSYSTEM_PROPERTIES_END__ 465 466 def register_subsystem(self, subsystem: AppSubsystem) -> None: 467 """Called by the AppSubsystem class. Do not use directly.""" 468 469 # We only allow registering new subsystems if we've not yet 470 # reached the 'running' state. This ensures that all subsystems 471 # receive a consistent set of callbacks starting with 472 # on_app_running(). 473 474 if self._subsystem_registration_ended: 475 raise RuntimeError( 476 'Subsystems can no longer be registered at this point.' 477 ) 478 self._subsystems.append(subsystem) 479 480 def add_shutdown_task(self, coro: Coroutine[None, None, None]) -> None: 481 """Add a task to be run on app shutdown. 482 483 Note that shutdown tasks will be canceled after 484 :py:const:`SHUTDOWN_TASK_TIMEOUT_SECONDS` if they are still 485 running. 486 """ 487 if ( 488 self.state is self.State.SHUTTING_DOWN 489 or self.state is self.State.SHUTDOWN_COMPLETE 490 ): 491 stname = self.state.name 492 raise RuntimeError( 493 f'Cannot add shutdown tasks with current state {stname}.' 494 ) 495 self._shutdown_tasks.append(coro) 496 497 def run(self) -> None: 498 """Run the app to completion. 499 500 Note that this only works on builds where Ballistica manages 501 its own event loop. 502 """ 503 _babase.run_app() 504 505 def set_intent(self, intent: AppIntent) -> None: 506 """Set the intent for the app. 507 508 Intent defines what the app is trying to do at a given time. 509 This call is asynchronous; the intent switch will happen in the 510 logic thread in the near future. If set_intent is called 511 repeatedly before the change takes place, the final intent to be 512 set will be used. 513 """ 514 515 # Mark this one as pending. We do this synchronously so that the 516 # last one marked actually takes effect if there is overlap 517 # (doing this in the bg thread could result in race conditions). 518 self._pending_intent = intent 519 520 # Do the actual work of calcing our app-mode/etc. in a bg thread 521 # since it may block for a moment to load modules/etc. 522 self.threadpool.submit_no_wait(self._set_intent, intent) 523 524 def push_apply_app_config(self) -> None: 525 """Internal. Use app.config.apply() to apply app config changes.""" 526 # To be safe, let's run this by itself in the event loop. 527 # This avoids potential trouble if this gets called mid-draw or 528 # something like that. 529 self._pending_apply_app_config = True 530 _babase.pushcall(self._apply_app_config, raw=True) 531 532 def on_native_start(self) -> None: 533 """Called by the native layer when the app is being started.""" 534 assert _babase.in_logic_thread() 535 assert not self._native_start_called 536 self._native_start_called = True 537 self._update_state() 538 539 def on_native_bootstrapping_complete(self) -> None: 540 """Called by the native layer once its ready to rock.""" 541 assert _babase.in_logic_thread() 542 assert not self._native_bootstrapping_completed 543 self._native_bootstrapping_completed = True 544 self._update_state() 545 546 def on_native_suspend(self) -> None: 547 """Called by the native layer when the app is suspended.""" 548 assert _babase.in_logic_thread() 549 assert not self._native_suspended # Should avoid redundant calls. 550 self._native_suspended = True 551 self._update_state() 552 553 def on_native_unsuspend(self) -> None: 554 """Called by the native layer when the app suspension ends.""" 555 assert _babase.in_logic_thread() 556 assert self._native_suspended # Should avoid redundant calls. 557 self._native_suspended = False 558 self._update_state() 559 560 def on_native_shutdown(self) -> None: 561 """Called by the native layer when the app starts shutting down.""" 562 assert _babase.in_logic_thread() 563 self._native_shutdown_called = True 564 self._update_state() 565 566 def on_native_shutdown_complete(self) -> None: 567 """Called by the native layer when the app is done shutting down.""" 568 assert _babase.in_logic_thread() 569 self._native_shutdown_complete_called = True 570 self._update_state() 571 572 def on_native_active_changed(self) -> None: 573 """Called by the native layer when the app active state changes.""" 574 assert _babase.in_logic_thread() 575 if self._mode is not None: 576 self._mode.on_app_active_changed() 577 578 def handle_deep_link(self, url: str) -> None: 579 """Handle a deep link URL.""" 580 from babase._language import Lstr 581 582 assert _babase.in_logic_thread() 583 584 appname = _babase.appname() 585 if url.startswith(f'{appname}://code/'): 586 code = url.replace(f'{appname}://code/', '') 587 if self.classic is not None: 588 self.classic.accounts.add_pending_promo_code(code) 589 else: 590 try: 591 _babase.screenmessage( 592 Lstr(resource='errorText'), color=(1, 0, 0) 593 ) 594 _babase.getsimplesound('error').play() 595 except ImportError: 596 pass 597 598 def on_initial_sign_in_complete(self) -> None: 599 """Called when initial sign-in (or lack thereof) completes. 600 601 This normally gets called by the plus subsystem. The 602 initial-sign-in process may include tasks such as syncing 603 account workspaces or other data so it may take a substantial 604 amount of time. 605 """ 606 assert _babase.in_logic_thread() 607 assert not self._initial_sign_in_completed 608 609 # Tell meta it can start scanning extra stuff that just showed 610 # up (namely account workspaces). 611 self.meta.start_extra_scan() 612 613 self._initial_sign_in_completed = True 614 self._update_state() 615 616 def set_ui_scale(self, scale: babase.UIScale) -> None: 617 """Change ui-scale on the fly. 618 619 Currently this is mainly for debugging and will not be called as 620 part of normal app operation. 621 """ 622 assert _babase.in_logic_thread() 623 624 # Apply to the native layer. 625 _babase.set_ui_scale(scale.name.lower()) 626 627 # Inform all subsystems that something screen-related has 628 # changed. We assume subsystems won't be added at this point so 629 # we can use the list directly. 630 assert self._subsystem_registration_ended 631 for subsystem in self._subsystems: 632 try: 633 subsystem.on_ui_scale_change() 634 except Exception: 635 logging.exception( 636 'Error in on_ui_scale_change() for subsystem %s.', subsystem 637 ) 638 639 def on_screen_size_change(self) -> None: 640 """Screen size has changed.""" 641 642 # Inform all app subsystems in the same order they were inited. 643 # Operate on a copy of the list here because this can be called 644 # while subsystems are still being added. 645 for subsystem in self._subsystems.copy(): 646 try: 647 subsystem.on_screen_size_change() 648 except Exception: 649 logging.exception( 650 'Error in on_screen_size_change() for subsystem %s.', 651 subsystem, 652 ) 653 654 def _set_intent(self, intent: AppIntent) -> None: 655 from babase._appmode import AppMode 656 657 # This should be happening in a bg thread. 658 assert not _babase.in_logic_thread() 659 try: 660 # Ask the selector what app-mode to use for this intent. 661 if self.mode_selector is None: 662 raise RuntimeError('No AppModeSelector set.') 663 664 modetype: type[AppMode] | None 665 666 # Special case - for testing we may force a specific 667 # app-mode to handle this intent instead of going through our 668 # usual selector. 669 forced_mode_type = getattr(intent, '_force_app_mode_handler', None) 670 if isinstance(forced_mode_type, type) and issubclass( 671 forced_mode_type, AppMode 672 ): 673 modetype = forced_mode_type 674 else: 675 modetype = self.mode_selector.app_mode_for_intent(intent) 676 677 # NOTE: Since intents are somewhat high level things, 678 # perhaps we should do some universal thing like a 679 # screenmessage saying 'The app cannot handle the request' 680 # on failure. 681 682 if modetype is None: 683 raise RuntimeError( 684 f'No app-mode found to handle app-intent' 685 f' type {type(intent)}.' 686 ) 687 688 # Make sure the app-mode the selector gave us *actually* 689 # supports the intent. 690 if not modetype.can_handle_intent(intent): 691 raise RuntimeError( 692 f'Intent {intent} cannot be handled by AppMode type' 693 f' {modetype} (selector {self.mode_selector}' 694 f' incorrectly thinks that it can be).' 695 ) 696 697 # Ok; seems legit. Now instantiate the mode if necessary and 698 # kick back to the logic thread to apply. 699 mode = self._mode_instances.get(modetype) 700 if mode is None: 701 self._mode_instances[modetype] = mode = modetype() 702 _babase.pushcall( 703 partial(self._apply_intent, intent, mode), 704 from_other_thread=True, 705 ) 706 except Exception: 707 logging.exception('Error setting app intent to %s.', intent) 708 _babase.pushcall( 709 partial(self._display_set_intent_error, intent), 710 from_other_thread=True, 711 ) 712 713 def _apply_intent(self, intent: AppIntent, mode: AppMode) -> None: 714 assert _babase.in_logic_thread() 715 716 # ONLY apply this intent if it is still the most recent one 717 # submitted. 718 if intent is not self._pending_intent: 719 return 720 721 # If the app-mode for this intent is different than the active 722 # one, switch modes. 723 if type(mode) is not type(self._mode): 724 if self._mode is None: 725 is_initial_mode = True 726 else: 727 is_initial_mode = False 728 try: 729 self._mode.on_deactivate() 730 except Exception: 731 logging.exception( 732 'Error deactivating app-mode %s.', self._mode 733 ) 734 735 # Reset all subsystems. We assume subsystems won't be added 736 # at this point so we can use the list directly. 737 assert self._subsystem_registration_ended 738 for subsystem in self._subsystems: 739 try: 740 subsystem.reset() 741 except Exception: 742 logging.exception( 743 'Error in reset() for subsystem %s.', subsystem 744 ) 745 746 self._mode = mode 747 try: 748 mode.on_activate() 749 except Exception: 750 # Hmm; what should we do in this case?... 751 logging.exception('Error activating app-mode %s.', mode) 752 753 # Let the world know when we first have an app-mode; certain 754 # app stuff such as input processing can proceed at that 755 # point. 756 if is_initial_mode: 757 _babase.on_initial_app_mode_set() 758 759 try: 760 mode.handle_intent(intent) 761 except Exception: 762 logging.exception( 763 'Error handling intent %s in app-mode %s.', intent, mode 764 ) 765 766 def _display_set_intent_error(self, intent: AppIntent) -> None: 767 """Show the *user* something went wrong setting an intent.""" 768 from babase._language import Lstr 769 770 del intent 771 _babase.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0)) 772 _babase.getsimplesound('error').play() 773 774 def _on_initing(self) -> None: 775 """Called when the app enters the initing state. 776 777 Here we can put together subsystems and other pieces for the 778 app, but most things should not be doing any work yet. 779 """ 780 # pylint: disable=cyclic-import 781 from babase import _asyncio 782 from babase import _appconfig 783 from babase._apputils import AppHealthMonitor 784 from babase import _env 785 786 assert _babase.in_logic_thread() 787 788 _env.on_app_state_initing() 789 790 self._asyncio_loop = _asyncio.setup_asyncio() 791 self.health_monitor = AppHealthMonitor() 792 793 # __FEATURESET_APP_SUBSYSTEM_CREATE_BEGIN__ 794 # This section generated by batools.appmodule; do not edit. 795 796 # Poke these attrs to create all our subsystems. 797 _ = self.plus 798 _ = self.classic 799 _ = self.ui_v1 800 801 # __FEATURESET_APP_SUBSYSTEM_CREATE_END__ 802 803 # We're a pretty short-lived state. This should flip us to 804 # 'loading'. 805 self._init_completed = True 806 self._update_state() 807 808 def _on_loading(self) -> None: 809 """Called when we enter the loading state. 810 811 At this point, all built-in pieces of the app should be in place 812 and can start talking to each other and doing work. Though at a 813 high level, the goal of the app at this point is only to sign in 814 to initial accounts, download workspaces, and otherwise prepare 815 itself to really 'run'. 816 """ 817 assert _babase.in_logic_thread() 818 819 # Get meta-system scanning built-in stuff in the bg. 820 self.meta.start_scan(scan_complete_cb=self._on_meta_scan_complete) 821 822 # Inform all app subsystems in the same order they were inited. 823 # Operate on a copy of the list here because subsystems can 824 # still be added at this point. 825 for subsystem in self._subsystems.copy(): 826 try: 827 subsystem.on_app_loading() 828 except Exception: 829 logging.exception( 830 'Error in on_app_loading() for subsystem %s.', subsystem 831 ) 832 833 # Normally plus tells us when initial sign-in is done. If plus 834 # is not present, however, we just do it ourself so we can 835 # proceed on to the running state. 836 if self.plus is None: 837 _babase.pushcall(self.on_initial_sign_in_complete) 838 839 def _on_meta_scan_complete(self) -> None: 840 """Called when meta-scan is done doing its thing.""" 841 assert _babase.in_logic_thread() 842 843 # Now that we know what's out there, build our final plugin set. 844 self.plugins.on_meta_scan_complete() 845 846 assert not self._meta_scan_completed 847 self._meta_scan_completed = True 848 self._update_state() 849 850 def _on_running(self) -> None: 851 """Called when we enter the running state. 852 853 At this point, all workspaces, initial accounts, etc. are in place 854 and we can actually get started doing whatever we're gonna do. 855 """ 856 assert _babase.in_logic_thread() 857 858 # Let our native layer know. 859 _babase.on_app_running() 860 861 # Set a default app-mode-selector if none has been set yet 862 # by a plugin or whatnot. 863 if self._mode_selector is None: 864 self._mode_selector = self.DefaultAppModeSelector() 865 866 # Inform all app subsystems in the same order they were 867 # registered. Operate on a copy here because subsystems can 868 # still be added at this point. 869 # 870 # NOTE: Do we need to allow registering still at this point? If 871 # something gets registered here, it won't have its 872 # on_app_running callback called. Hmm; I suppose that's the only 873 # way that plugins can register subsystems though. 874 for subsystem in self._subsystems.copy(): 875 try: 876 subsystem.on_app_running() 877 except Exception: 878 logging.exception( 879 'Error in on_app_running() for subsystem %s.', subsystem 880 ) 881 882 # Cut off new subsystem additions at this point. 883 self._subsystem_registration_ended = True 884 885 # If 'exec' code was provided to the app, always kick that off 886 # here as an intent. 887 exec_cmd = _babase.exec_arg() 888 if exec_cmd is not None: 889 self.set_intent(AppIntentExec(exec_cmd)) 890 elif self._pending_intent is None: 891 # Otherwise tell the app to do its default thing *only* if a 892 # plugin hasn't already told it to do something. 893 self.set_intent(AppIntentDefault()) 894 895 def _apply_app_config(self) -> None: 896 assert _babase.in_logic_thread() 897 898 lifecyclelog.info('apply-app-config') 899 900 # If multiple apply calls have been made, only actually apply 901 # once. 902 if not self._pending_apply_app_config: 903 return 904 905 _pending_apply_app_config = False 906 907 # Inform all app subsystems in the same order they were inited. 908 # Operate on a copy here because subsystems may still be able to 909 # be added at this point. 910 for subsystem in self._subsystems.copy(): 911 try: 912 subsystem.do_apply_app_config() 913 except Exception: 914 logging.exception( 915 'Error in do_apply_app_config() for subsystem %s.', 916 subsystem, 917 ) 918 919 # Let the native layer do its thing. 920 _babase.do_apply_app_config() 921 922 def _update_state(self) -> None: 923 # pylint: disable=too-many-branches 924 assert _babase.in_logic_thread() 925 926 # Shutdown-complete trumps absolutely all. 927 if self._native_shutdown_complete_called: 928 if self.state is not self.State.SHUTDOWN_COMPLETE: 929 self.state = self.State.SHUTDOWN_COMPLETE 930 lifecyclelog.info('app-state is now %s', self.state.name) 931 self._on_shutdown_complete() 932 933 # Shutdown trumps all. Though we can't start shutting down until 934 # init is completed since we need our asyncio stuff to exist for 935 # the shutdown process. 936 elif self._native_shutdown_called and self._init_completed: 937 # Entering shutdown state: 938 if self.state is not self.State.SHUTTING_DOWN: 939 self.state = self.State.SHUTTING_DOWN 940 applog.info('Shutting down...') 941 lifecyclelog.info('app-state is now %s', self.state.name) 942 self._on_shutting_down() 943 944 elif self._native_suspended: 945 # Entering suspended state: 946 if self.state is not self.State.SUSPENDED: 947 self.state = self.State.SUSPENDED 948 self._on_suspend() 949 else: 950 # Leaving suspended state: 951 if self.state is self.State.SUSPENDED: 952 self._on_unsuspend() 953 954 # Entering or returning to running state 955 if self._initial_sign_in_completed and self._meta_scan_completed: 956 if self.state != self.State.RUNNING: 957 self.state = self.State.RUNNING 958 lifecyclelog.info('app-state is now %s', self.state.name) 959 if not self._called_on_running: 960 self._called_on_running = True 961 self._on_running() 962 963 # Entering or returning to loading state: 964 elif self._init_completed: 965 if self.state is not self.State.LOADING: 966 self.state = self.State.LOADING 967 lifecyclelog.info('app-state is now %s', self.state.name) 968 if not self._called_on_loading: 969 self._called_on_loading = True 970 self._on_loading() 971 972 # Entering or returning to initing state: 973 elif self._native_bootstrapping_completed: 974 if self.state is not self.State.INITING: 975 self.state = self.State.INITING 976 lifecyclelog.info('app-state is now %s', self.state.name) 977 if not self._called_on_initing: 978 self._called_on_initing = True 979 self._on_initing() 980 981 # Entering or returning to native bootstrapping: 982 elif self._native_start_called: 983 if self.state is not self.State.NATIVE_BOOTSTRAPPING: 984 self.state = self.State.NATIVE_BOOTSTRAPPING 985 lifecyclelog.info('app-state is now %s', self.state.name) 986 else: 987 # Only logical possibility left is NOT_STARTED, in which 988 # case we should not be getting called. 989 logging.warning( 990 'App._update_state called while in %s state;' 991 ' should not happen.', 992 self.state.value, 993 stack_info=True, 994 ) 995 996 async def _shutdown(self) -> None: 997 import asyncio 998 999 _babase.lock_all_input() 1000 try: 1001 async with asyncio.TaskGroup() as task_group: 1002 for task_coro in self._shutdown_tasks: 1003 # Note: Mypy currently complains if we don't take 1004 # this return value, but we don't actually need to. 1005 # https://github.com/python/mypy/issues/15036 1006 _ = task_group.create_task( 1007 self._run_shutdown_task(task_coro) 1008 ) 1009 except* Exception: 1010 logging.exception('Unexpected error(s) in shutdown.') 1011 1012 # Note: ideally we should run this directly here, but currently 1013 # it does some legacy stuff which blocks, so running it here 1014 # gives us asyncio task-took-too-long warnings. If we can 1015 # convert those to nice graceful async tasks we should revert 1016 # this to a direct call. 1017 _babase.pushcall(_babase.complete_shutdown) 1018 1019 async def _run_shutdown_task( 1020 self, coro: Coroutine[None, None, None] 1021 ) -> None: 1022 """Run a shutdown task; report errors and abort if taking too long.""" 1023 import asyncio 1024 1025 task = asyncio.create_task(coro) 1026 try: 1027 await asyncio.wait_for(task, self.SHUTDOWN_TASK_TIMEOUT_SECONDS) 1028 except Exception: 1029 logging.exception('Error in shutdown task (%s).', coro) 1030 1031 def _on_suspend(self) -> None: 1032 """Called when the app goes to a suspended state.""" 1033 assert _babase.in_logic_thread() 1034 1035 # Suspend all app subsystems in the opposite order they were inited. 1036 for subsystem in reversed(self._subsystems): 1037 try: 1038 subsystem.on_app_suspend() 1039 except Exception: 1040 logging.exception( 1041 'Error in on_app_suspend() for subsystem %s.', subsystem 1042 ) 1043 1044 def _on_unsuspend(self) -> None: 1045 """Called when unsuspending.""" 1046 assert _babase.in_logic_thread() 1047 self.fg_state += 1 1048 1049 # Unsuspend all app subsystems in the same order they were inited. 1050 for subsystem in self._subsystems: 1051 try: 1052 subsystem.on_app_unsuspend() 1053 except Exception: 1054 logging.exception( 1055 'Error in on_app_unsuspend() for subsystem %s.', subsystem 1056 ) 1057 1058 def _on_shutting_down(self) -> None: 1059 """(internal)""" 1060 assert _babase.in_logic_thread() 1061 1062 # Inform app subsystems that we're shutting down in the opposite 1063 # order they were inited. 1064 for subsystem in reversed(self._subsystems): 1065 try: 1066 subsystem.on_app_shutdown() 1067 except Exception: 1068 logging.exception( 1069 'Error in on_app_shutdown() for subsystem %s.', subsystem 1070 ) 1071 1072 # Now kick off any async shutdown task(s). 1073 assert self._asyncio_loop is not None 1074 self._shutdown_task = self._asyncio_loop.create_task(self._shutdown()) 1075 1076 def _on_shutdown_complete(self) -> None: 1077 """(internal)""" 1078 assert _babase.in_logic_thread() 1079 1080 # Deactivate any active app-mode. This allows things like saving 1081 # state to happen naturally without needing to handle 1082 # app-shutdown as a special case. 1083 if self._mode is not None: 1084 try: 1085 self._mode.on_deactivate() 1086 except Exception: 1087 logging.exception( 1088 'Error deactivating app-mode %s at app shutdown.', 1089 self._mode, 1090 ) 1091 self._mode = None 1092 1093 # Inform app subsystems that we're done shutting down in the opposite 1094 # order they were inited. 1095 for subsystem in reversed(self._subsystems): 1096 try: 1097 subsystem.on_app_shutdown_complete() 1098 except Exception: 1099 logging.exception( 1100 'Error in on_app_shutdown_complete() for subsystem %s.', 1101 subsystem, 1102 ) 1103 1104 async def _wait_for_shutdown_suppressions(self) -> None: 1105 import asyncio 1106 1107 # Spin and wait for anything blocking shutdown to complete. 1108 starttime = _babase.apptime() 1109 lifecyclelog.info('shutdown-suppress-wait begin') 1110 while _babase.shutdown_suppress_count() > 0: 1111 await asyncio.sleep(0.001) 1112 lifecyclelog.info('shutdown-suppress-wait end') 1113 duration = _babase.apptime() - starttime 1114 if duration > 1.0: 1115 logging.warning( 1116 'Shutdown-suppressions lasted longer than ideal ' 1117 '(%.2f seconds).', 1118 duration, 1119 ) 1120 1121 async def _fade_and_shutdown_graphics(self) -> None: 1122 import asyncio 1123 1124 # Kick off a short fade and give it time to complete. 1125 lifecyclelog.info('fade-and-shutdown-graphics begin') 1126 _babase.fade_screen(False, time=0.15) 1127 await asyncio.sleep(0.15) 1128 1129 # Now tell the graphics system to go down and wait until 1130 # it has done so. 1131 _babase.graphics_shutdown_begin() 1132 while not _babase.graphics_shutdown_is_complete(): 1133 await asyncio.sleep(0.01) 1134 lifecyclelog.info('fade-and-shutdown-graphics end') 1135 1136 async def _fade_and_shutdown_audio(self) -> None: 1137 import asyncio 1138 1139 # Tell the audio system to go down and give it a bit of 1140 # time to do so gracefully. 1141 lifecyclelog.info('fade-and-shutdown-audio begin') 1142 _babase.audio_shutdown_begin() 1143 await asyncio.sleep(0.15) 1144 while not _babase.audio_shutdown_is_complete(): 1145 await asyncio.sleep(0.01) 1146 lifecyclelog.info('fade-and-shutdown-audio end') 1147 1148 def _thread_pool_thread_init(self) -> None: 1149 # Help keep things clear in profiling tools/etc. 1150 self._pool_thread_count += 1 1151 _babase.set_thread_name(f'ballistica worker-{self._pool_thread_count}')
High level Ballistica app functionality and state.
Access the single shared instance of this class via the "app" attr
available on various high level modules such as bauiv1
and
bascenev1
.
248 def postinit(self) -> None: 249 """Called after we've been inited and assigned to babase.app. 250 251 Anything that accesses babase.app as part of its init process 252 must go here instead of __init__. 253 """ 254 255 # Hack for docs-generation: We can be imported with dummy 256 # modules instead of our actual binary ones, but we don't 257 # function. 258 if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1': 259 return 260 261 self.lang = LanguageSubsystem() 262 self.plugins = PluginSubsystem()
Called after we've been inited and assigned to babase.app.
Anything that accesses babase.app as part of its init process must go here instead of __init__.
264 @property 265 def active(self) -> bool: 266 """Whether the app is currently front and center. 267 268 This will be False when the app is hidden, other activities 269 are covering it, etc. (depending on the platform). 270 """ 271 return _babase.app_is_active()
Whether the app is currently front and center.
This will be False when the app is hidden, other activities are covering it, etc. (depending on the platform).
273 @property 274 def mode(self) -> AppMode | None: 275 """The app's current mode.""" 276 assert _babase.in_logic_thread() 277 return self._mode
The app's current mode.
279 @property 280 def asyncio_loop(self) -> asyncio.AbstractEventLoop: 281 """The logic thread's asyncio event loop. 282 283 This allow async tasks to be run in the logic thread. 284 285 Generally you should call App.create_async_task() to schedule 286 async code to run instead of using this directly. That will 287 handle retaining the task and logging errors automatically. 288 Only schedule tasks onto asyncio_loop yourself when you intend 289 to hold on to the returned task and await its results. Releasing 290 the task reference can lead to subtle bugs such as unreported 291 errors and garbage-collected tasks disappearing before their 292 work is done. 293 294 Note that, at this time, the asyncio loop is encapsulated 295 and explicitly stepped by the engine's logic thread loop and 296 thus things like asyncio.get_running_loop() will unintuitively 297 *not* return this loop from most places in the logic thread; 298 only from within a task explicitly created in this loop. 299 Hopefully this situation will be improved in the future with a 300 unified event loop. 301 """ 302 assert _babase.in_logic_thread() 303 assert self._asyncio_loop is not None 304 return self._asyncio_loop
The logic thread's asyncio event loop.
This allow async tasks to be run in the logic thread.
Generally you should call App.create_async_task() to schedule async code to run instead of using this directly. That will handle retaining the task and logging errors automatically. Only schedule tasks onto asyncio_loop yourself when you intend to hold on to the returned task and await its results. Releasing the task reference can lead to subtle bugs such as unreported errors and garbage-collected tasks disappearing before their work is done.
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 unintuitively not return this loop from most places in the logic thread; only from within a task explicitly created in this loop. Hopefully this situation will be improved in the future with a unified event loop.
306 def create_async_task( 307 self, coro: Coroutine[Any, Any, T], *, name: str | None = None 308 ) -> None: 309 """Create a fully managed async task. 310 311 This will automatically retain and release a reference to the task 312 and log any exceptions that occur in it. If you need to await a task 313 or otherwise need more control, schedule a task directly using 314 App.asyncio_loop. 315 """ 316 assert _babase.in_logic_thread() 317 318 # We hold a strong reference to the task until it is done. 319 # Otherwise it is possible for it to be garbage collected and 320 # disappear midway if the caller does not hold on to the 321 # returned task, which seems like a great way to introduce 322 # hard-to-track bugs. 323 task = self.asyncio_loop.create_task(coro, name=name) 324 self._asyncio_tasks.add(task) 325 task.add_done_callback(self._on_task_done)
Create a fully managed async task.
This will automatically retain and release a reference to the task and log any exceptions that occur in it. If you need to await a task or otherwise need more control, schedule a task directly using App.asyncio_loop.
340 @property 341 def mode_selector(self) -> babase.AppModeSelector: 342 """Controls which app-modes are used for handling given intents. 343 344 Plugins can override this to change high level app behavior and 345 spinoff projects can change the default implementation for the 346 same effect. 347 """ 348 if self._mode_selector is None: 349 raise RuntimeError( 350 'mode_selector cannot be used until the app reaches' 351 ' the running state.' 352 ) 353 return self._mode_selector
Controls which app-modes are used for handling given intents.
Plugins can override this to change high level app behavior and spinoff projects can change the default implementation for the same effect.
409 @property 410 def classic(self) -> ClassicAppSubsystem | None: 411 """Our classic subsystem (if available).""" 412 return self._get_subsystem_property( 413 'classic', self._create_classic_subsystem 414 ) # type: ignore
Our classic subsystem (if available).
429 @property 430 def plus(self) -> PlusAppSubsystem | None: 431 """Our plus subsystem (if available).""" 432 return self._get_subsystem_property( 433 'plus', self._create_plus_subsystem 434 ) # type: ignore
Our plus subsystem (if available).
449 @property 450 def ui_v1(self) -> UIV1AppSubsystem: 451 """Our ui_v1 subsystem (always available).""" 452 return self._get_subsystem_property( 453 'ui_v1', self._create_ui_v1_subsystem 454 ) # type: ignore
Our ui_v1 subsystem (always available).
466 def register_subsystem(self, subsystem: AppSubsystem) -> None: 467 """Called by the AppSubsystem class. Do not use directly.""" 468 469 # We only allow registering new subsystems if we've not yet 470 # reached the 'running' state. This ensures that all subsystems 471 # receive a consistent set of callbacks starting with 472 # on_app_running(). 473 474 if self._subsystem_registration_ended: 475 raise RuntimeError( 476 'Subsystems can no longer be registered at this point.' 477 ) 478 self._subsystems.append(subsystem)
Called by the AppSubsystem class. Do not use directly.
480 def add_shutdown_task(self, coro: Coroutine[None, None, None]) -> None: 481 """Add a task to be run on app shutdown. 482 483 Note that shutdown tasks will be canceled after 484 :py:const:`SHUTDOWN_TASK_TIMEOUT_SECONDS` if they are still 485 running. 486 """ 487 if ( 488 self.state is self.State.SHUTTING_DOWN 489 or self.state is self.State.SHUTDOWN_COMPLETE 490 ): 491 stname = self.state.name 492 raise RuntimeError( 493 f'Cannot add shutdown tasks with current state {stname}.' 494 ) 495 self._shutdown_tasks.append(coro)
Add a task to be run on app shutdown.
Note that shutdown tasks will be canceled after
SHUTDOWN_TASK_TIMEOUT_SECONDS
if they are still
running.
497 def run(self) -> None: 498 """Run the app to completion. 499 500 Note that this only works on builds where Ballistica manages 501 its own event loop. 502 """ 503 _babase.run_app()
Run the app to completion.
Note that this only works on builds where Ballistica manages its own event loop.
505 def set_intent(self, intent: AppIntent) -> None: 506 """Set the intent for the app. 507 508 Intent defines what the app is trying to do at a given time. 509 This call is asynchronous; the intent switch will happen in the 510 logic thread in the near future. If set_intent is called 511 repeatedly before the change takes place, the final intent to be 512 set will be used. 513 """ 514 515 # Mark this one as pending. We do this synchronously so that the 516 # last one marked actually takes effect if there is overlap 517 # (doing this in the bg thread could result in race conditions). 518 self._pending_intent = intent 519 520 # Do the actual work of calcing our app-mode/etc. in a bg thread 521 # since it may block for a moment to load modules/etc. 522 self.threadpool.submit_no_wait(self._set_intent, intent)
Set the intent for the app.
Intent defines what the app is trying to do at a given time. This call is asynchronous; the intent switch will happen in the logic thread in the near future. If set_intent is called repeatedly before the change takes place, the final intent to be set will be used.
524 def push_apply_app_config(self) -> None: 525 """Internal. Use app.config.apply() to apply app config changes.""" 526 # To be safe, let's run this by itself in the event loop. 527 # This avoids potential trouble if this gets called mid-draw or 528 # something like that. 529 self._pending_apply_app_config = True 530 _babase.pushcall(self._apply_app_config, raw=True)
Internal. Use app.config.apply() to apply app config changes.
532 def on_native_start(self) -> None: 533 """Called by the native layer when the app is being started.""" 534 assert _babase.in_logic_thread() 535 assert not self._native_start_called 536 self._native_start_called = True 537 self._update_state()
Called by the native layer when the app is being started.
539 def on_native_bootstrapping_complete(self) -> None: 540 """Called by the native layer once its ready to rock.""" 541 assert _babase.in_logic_thread() 542 assert not self._native_bootstrapping_completed 543 self._native_bootstrapping_completed = True 544 self._update_state()
Called by the native layer once its ready to rock.
546 def on_native_suspend(self) -> None: 547 """Called by the native layer when the app is suspended.""" 548 assert _babase.in_logic_thread() 549 assert not self._native_suspended # Should avoid redundant calls. 550 self._native_suspended = True 551 self._update_state()
Called by the native layer when the app is suspended.
553 def on_native_unsuspend(self) -> None: 554 """Called by the native layer when the app suspension ends.""" 555 assert _babase.in_logic_thread() 556 assert self._native_suspended # Should avoid redundant calls. 557 self._native_suspended = False 558 self._update_state()
Called by the native layer when the app suspension ends.
560 def on_native_shutdown(self) -> None: 561 """Called by the native layer when the app starts shutting down.""" 562 assert _babase.in_logic_thread() 563 self._native_shutdown_called = True 564 self._update_state()
Called by the native layer when the app starts shutting down.
566 def on_native_shutdown_complete(self) -> None: 567 """Called by the native layer when the app is done shutting down.""" 568 assert _babase.in_logic_thread() 569 self._native_shutdown_complete_called = True 570 self._update_state()
Called by the native layer when the app is done shutting down.
572 def on_native_active_changed(self) -> None: 573 """Called by the native layer when the app active state changes.""" 574 assert _babase.in_logic_thread() 575 if self._mode is not None: 576 self._mode.on_app_active_changed()
Called by the native layer when the app active state changes.
578 def handle_deep_link(self, url: str) -> None: 579 """Handle a deep link URL.""" 580 from babase._language import Lstr 581 582 assert _babase.in_logic_thread() 583 584 appname = _babase.appname() 585 if url.startswith(f'{appname}://code/'): 586 code = url.replace(f'{appname}://code/', '') 587 if self.classic is not None: 588 self.classic.accounts.add_pending_promo_code(code) 589 else: 590 try: 591 _babase.screenmessage( 592 Lstr(resource='errorText'), color=(1, 0, 0) 593 ) 594 _babase.getsimplesound('error').play() 595 except ImportError: 596 pass
Handle a deep link URL.
598 def on_initial_sign_in_complete(self) -> None: 599 """Called when initial sign-in (or lack thereof) completes. 600 601 This normally gets called by the plus subsystem. The 602 initial-sign-in process may include tasks such as syncing 603 account workspaces or other data so it may take a substantial 604 amount of time. 605 """ 606 assert _babase.in_logic_thread() 607 assert not self._initial_sign_in_completed 608 609 # Tell meta it can start scanning extra stuff that just showed 610 # up (namely account workspaces). 611 self.meta.start_extra_scan() 612 613 self._initial_sign_in_completed = True 614 self._update_state()
Called when initial sign-in (or lack thereof) completes.
This normally gets called by the plus subsystem. The initial-sign-in process may include tasks such as syncing account workspaces or other data so it may take a substantial amount of time.
616 def set_ui_scale(self, scale: babase.UIScale) -> None: 617 """Change ui-scale on the fly. 618 619 Currently this is mainly for debugging and will not be called as 620 part of normal app operation. 621 """ 622 assert _babase.in_logic_thread() 623 624 # Apply to the native layer. 625 _babase.set_ui_scale(scale.name.lower()) 626 627 # Inform all subsystems that something screen-related has 628 # changed. We assume subsystems won't be added at this point so 629 # we can use the list directly. 630 assert self._subsystem_registration_ended 631 for subsystem in self._subsystems: 632 try: 633 subsystem.on_ui_scale_change() 634 except Exception: 635 logging.exception( 636 'Error in on_ui_scale_change() for subsystem %s.', subsystem 637 )
Change ui-scale on the fly.
Currently this is mainly for debugging and will not be called as part of normal app operation.
639 def on_screen_size_change(self) -> None: 640 """Screen size has changed.""" 641 642 # Inform all app subsystems in the same order they were inited. 643 # Operate on a copy of the list here because this can be called 644 # while subsystems are still being added. 645 for subsystem in self._subsystems.copy(): 646 try: 647 subsystem.on_screen_size_change() 648 except Exception: 649 logging.exception( 650 'Error in on_screen_size_change() for subsystem %s.', 651 subsystem, 652 )
Screen size has changed.
62 class State(Enum): 63 """High level state the app can be in.""" 64 65 #: The app has not yet begun starting and should not be used in 66 #: any way. 67 NOT_STARTED = 0 68 69 #: The native layer is spinning up its machinery (screens, 70 #: renderers, etc.). Nothing should happen in the Python layer 71 #: until this completes. 72 NATIVE_BOOTSTRAPPING = 1 73 74 #: Python app subsystems are being inited but should not yet 75 #: interact or do any work. 76 INITING = 2 77 78 #: Python app subsystems are inited and interacting, but the app 79 #: has not yet embarked on a high level course of action. It is 80 #: doing initial account logins, workspace & asset downloads, 81 #: etc. 82 LOADING = 3 83 84 #: All pieces are in place and the app is now doing its thing. 85 RUNNING = 4 86 87 #: Used on platforms such as mobile where the app basically needs 88 #: to shut down while backgrounded. In this state, all event 89 #: loops are suspended and all graphics and audio must cease 90 #: completely. Be aware that the suspended state can be entered 91 #: from any other state including NATIVE_BOOTSTRAPPING and 92 #: SHUTTING_DOWN. 93 SUSPENDED = 5 94 95 #: The app is shutting down. This process may involve sending 96 #: network messages or other things that can take up to a few 97 #: seconds, so ideally graphics and audio should remain 98 #: functional (with fades or spinners or whatever to show 99 #: something is happening). 100 SHUTTING_DOWN = 6 101 102 #: The app has completed shutdown. Any code running here should 103 #: be basically immediate. 104 SHUTDOWN_COMPLETE = 7
High level state the app can be in.
106 class DefaultAppModeSelector(AppModeSelector): 107 """Decides which AppMode to use to handle AppIntents. 108 109 This default version is generated by the project updater based 110 on the 'default_app_modes' value in the projectconfig. 111 112 It is also possible to modify app mode selection behavior by 113 setting app.mode_selector to an instance of a custom 114 AppModeSelector subclass. This is a good way to go if you are 115 modifying app behavior dynamically via a plugin instead of 116 statically in a spinoff project. 117 """ 118 119 @override 120 def app_mode_for_intent( 121 self, intent: AppIntent 122 ) -> type[AppMode] | None: 123 # pylint: disable=cyclic-import 124 125 # __DEFAULT_APP_MODE_SELECTION_BEGIN__ 126 # This section generated by batools.appmodule; do not edit. 127 128 # Ask our default app modes to handle it. 129 # (generated from 'default_app_modes' in projectconfig). 130 import baclassic 131 import babase 132 133 for appmode in [ 134 baclassic.ClassicAppMode, 135 babase.EmptyAppMode, 136 ]: 137 if appmode.can_handle_intent(intent): 138 return appmode 139 140 return None 141 142 # __DEFAULT_APP_MODE_SELECTION_END__
Decides which AppMode to use to handle AppIntents.
This default version is generated by the project updater based on the 'default_app_modes' value in the projectconfig.
It is also possible to modify app mode selection behavior by setting app.mode_selector to an instance of a custom AppModeSelector subclass. This is a good way to go if you are modifying app behavior dynamically via a plugin instead of statically in a spinoff project.
119 @override 120 def app_mode_for_intent( 121 self, intent: AppIntent 122 ) -> type[AppMode] | None: 123 # pylint: disable=cyclic-import 124 125 # __DEFAULT_APP_MODE_SELECTION_BEGIN__ 126 # This section generated by batools.appmodule; do not edit. 127 128 # Ask our default app modes to handle it. 129 # (generated from 'default_app_modes' in projectconfig). 130 import baclassic 131 import babase 132 133 for appmode in [ 134 baclassic.ClassicAppMode, 135 babase.EmptyAppMode, 136 ]: 137 if appmode.can_handle_intent(intent): 138 return appmode 139 140 return None 141 142 # __DEFAULT_APP_MODE_SELECTION_END__
Given an AppIntent, return the AppMode that should handle it.
If None is returned, the AppIntent will be ignored.
This may be called in a background thread, so avoid any calls limited to logic thread use/etc.
A high level directive given to the app.
Tells the app to simply run in its default mode.
21class AppIntentExec(AppIntent): 22 """Tells the app to exec some Python code.""" 23 24 def __init__(self, code: str): 25 self.code = code
Tells the app to exec some Python code.
14class AppMode: 15 """A high level mode for the app.""" 16 17 @classmethod 18 def get_app_experience(cls) -> AppExperience: 19 """Return the overall experience provided by this mode.""" 20 raise NotImplementedError('AppMode subclasses must override this.') 21 22 @classmethod 23 def can_handle_intent(cls, intent: AppIntent) -> bool: 24 """Return whether this mode can handle the provided intent. 25 26 For this to return True, the AppMode must claim to support the 27 provided intent (via its _can_handle_intent() method) AND the 28 AppExperience associated with the AppMode must be supported by 29 the current app and runtime environment. 30 """ 31 # TODO: check AppExperience against current environment. 32 return cls._can_handle_intent(intent) 33 34 @classmethod 35 def _can_handle_intent(cls, intent: AppIntent) -> bool: 36 """Return whether our mode can handle the provided intent. 37 38 AppModes should override this to communicate what they can 39 handle. Note that AppExperience does not have to be considered 40 here; that is handled automatically by the can_handle_intent() 41 call. 42 """ 43 raise NotImplementedError('AppMode subclasses must override this.') 44 45 def handle_intent(self, intent: AppIntent) -> None: 46 """Handle an intent.""" 47 raise NotImplementedError('AppMode subclasses must override this.') 48 49 def on_activate(self) -> None: 50 """Called when the mode is becoming the active one fro the app.""" 51 52 def on_deactivate(self) -> None: 53 """Called when the mode stops being the active one for the app. 54 55 On platforms where the app is explicitly exited (such as desktop 56 PC) this will also be called at app shutdown. 57 58 To best cover both mobile and desktop style platforms, actions 59 such as saving state should generally happen in response to both 60 on_deactivate() and on_app_active_changed() (when active is 61 False). 62 """ 63 64 def on_app_active_changed(self) -> None: 65 """Called when app active state changes while in this app-mode. 66 67 This corresponds to :attr:`babase.App.active`. App-active state 68 becomes false when the app is hidden, minimized, backgrounded, 69 etc. The app-mode may want to take action such as pausing a 70 running game or saving state when this occurs. 71 72 On platforms such as mobile where apps get suspended and later 73 silently terminated by the OS, this is likely to be the last 74 reliable place to save state/etc. 75 76 To best cover both mobile and desktop style platforms, actions 77 such as saving state should generally happen in response to both 78 on_deactivate() and on_app_active_changed() (when active is 79 False). 80 """ 81 82 def on_purchase_process_begin( 83 self, item_id: str, user_initiated: bool 84 ) -> None: 85 """Called when in-app-purchase processing is beginning. 86 87 This call happens after a purchase has been completed locally 88 but before its receipt/info is sent to the master-server to 89 apply to the account. 90 """ 91 # pylint: disable=cyclic-import 92 import babase 93 94 del item_id # Unused. 95 96 # Show nothing for stuff not directly kicked off by the user. 97 if not user_initiated: 98 return 99 100 babase.screenmessage( 101 babase.Lstr(resource='updatingAccountText'), 102 color=(0, 1, 0), 103 ) 104 # Ick; we can be called early in the bootstrapping process 105 # before we're allowed to load assets. Guard against that. 106 if babase.asset_loads_allowed(): 107 babase.getsimplesound('click01').play() 108 109 def on_purchase_process_end( 110 self, item_id: str, user_initiated: bool, applied: bool 111 ) -> None: 112 """Called when in-app-purchase processing completes. 113 114 Each call to on_purchase_process_begin will be followed up by a 115 call to this method. If the purchase was found to be valid and 116 was applied to the account, applied will be True. In the case of 117 redundant or invalid purchases or communication failures it will 118 be False. 119 """ 120 # pylint: disable=cyclic-import 121 import babase 122 123 # Ignore this; we want to announce newly applied stuff even if 124 # it was from a different launch or client or whatever. 125 del user_initiated 126 127 # If the purchase wasn't applied, do nothing. This likely means it 128 # was redundant or something else harmless. 129 if not applied: 130 return 131 132 # By default just announce the item id we got. Real app-modes 133 # probably want to do something more specific based on item-id. 134 babase.screenmessage( 135 babase.Lstr( 136 translate=('serverResponses', 'You got a ${ITEM}!'), 137 subs=[('${ITEM}', item_id)], 138 ), 139 color=(0, 1, 0), 140 ) 141 if babase.asset_loads_allowed(): 142 babase.getsimplesound('cashRegister').play()
A high level mode for the app.
17 @classmethod 18 def get_app_experience(cls) -> AppExperience: 19 """Return the overall experience provided by this mode.""" 20 raise NotImplementedError('AppMode subclasses must override this.')
Return the overall experience provided by this mode.
22 @classmethod 23 def can_handle_intent(cls, intent: AppIntent) -> bool: 24 """Return whether this mode can handle the provided intent. 25 26 For this to return True, the AppMode must claim to support the 27 provided intent (via its _can_handle_intent() method) AND the 28 AppExperience associated with the AppMode must be supported by 29 the current app and runtime environment. 30 """ 31 # TODO: check AppExperience against current environment. 32 return cls._can_handle_intent(intent)
Return whether this mode can handle the provided intent.
For this to return True, the AppMode must claim to support the provided intent (via its _can_handle_intent() method) AND the AppExperience associated with the AppMode must be supported by the current app and runtime environment.
45 def handle_intent(self, intent: AppIntent) -> None: 46 """Handle an intent.""" 47 raise NotImplementedError('AppMode subclasses must override this.')
Handle an intent.
49 def on_activate(self) -> None: 50 """Called when the mode is becoming the active one fro the app."""
Called when the mode is becoming the active one fro the app.
52 def on_deactivate(self) -> None: 53 """Called when the mode stops being the active one for the app. 54 55 On platforms where the app is explicitly exited (such as desktop 56 PC) this will also be called at app shutdown. 57 58 To best cover both mobile and desktop style platforms, actions 59 such as saving state should generally happen in response to both 60 on_deactivate() and on_app_active_changed() (when active is 61 False). 62 """
Called when the mode stops being the active one for the app.
On platforms where the app is explicitly exited (such as desktop PC) this will also be called at app shutdown.
To best cover both mobile and desktop style platforms, actions such as saving state should generally happen in response to both on_deactivate() and on_app_active_changed() (when active is False).
64 def on_app_active_changed(self) -> None: 65 """Called when app active state changes while in this app-mode. 66 67 This corresponds to :attr:`babase.App.active`. App-active state 68 becomes false when the app is hidden, minimized, backgrounded, 69 etc. The app-mode may want to take action such as pausing a 70 running game or saving state when this occurs. 71 72 On platforms such as mobile where apps get suspended and later 73 silently terminated by the OS, this is likely to be the last 74 reliable place to save state/etc. 75 76 To best cover both mobile and desktop style platforms, actions 77 such as saving state should generally happen in response to both 78 on_deactivate() and on_app_active_changed() (when active is 79 False). 80 """
Called when app active state changes while in this app-mode.
This corresponds to babase.App.active
. App-active state
becomes false when the app is hidden, minimized, backgrounded,
etc. The app-mode may want to take action such as pausing a
running game or saving state when this occurs.
On platforms such as mobile where apps get suspended and later silently terminated by the OS, this is likely to be the last reliable place to save state/etc.
To best cover both mobile and desktop style platforms, actions such as saving state should generally happen in response to both on_deactivate() and on_app_active_changed() (when active is False).
82 def on_purchase_process_begin( 83 self, item_id: str, user_initiated: bool 84 ) -> None: 85 """Called when in-app-purchase processing is beginning. 86 87 This call happens after a purchase has been completed locally 88 but before its receipt/info is sent to the master-server to 89 apply to the account. 90 """ 91 # pylint: disable=cyclic-import 92 import babase 93 94 del item_id # Unused. 95 96 # Show nothing for stuff not directly kicked off by the user. 97 if not user_initiated: 98 return 99 100 babase.screenmessage( 101 babase.Lstr(resource='updatingAccountText'), 102 color=(0, 1, 0), 103 ) 104 # Ick; we can be called early in the bootstrapping process 105 # before we're allowed to load assets. Guard against that. 106 if babase.asset_loads_allowed(): 107 babase.getsimplesound('click01').play()
Called when in-app-purchase processing is beginning.
This call happens after a purchase has been completed locally but before its receipt/info is sent to the master-server to apply to the account.
109 def on_purchase_process_end( 110 self, item_id: str, user_initiated: bool, applied: bool 111 ) -> None: 112 """Called when in-app-purchase processing completes. 113 114 Each call to on_purchase_process_begin will be followed up by a 115 call to this method. If the purchase was found to be valid and 116 was applied to the account, applied will be True. In the case of 117 redundant or invalid purchases or communication failures it will 118 be False. 119 """ 120 # pylint: disable=cyclic-import 121 import babase 122 123 # Ignore this; we want to announce newly applied stuff even if 124 # it was from a different launch or client or whatever. 125 del user_initiated 126 127 # If the purchase wasn't applied, do nothing. This likely means it 128 # was redundant or something else harmless. 129 if not applied: 130 return 131 132 # By default just announce the item id we got. Real app-modes 133 # probably want to do something more specific based on item-id. 134 babase.screenmessage( 135 babase.Lstr( 136 translate=('serverResponses', 'You got a ${ITEM}!'), 137 subs=[('${ITEM}', item_id)], 138 ), 139 color=(0, 1, 0), 140 ) 141 if babase.asset_loads_allowed(): 142 babase.getsimplesound('cashRegister').play()
Called when in-app-purchase processing completes.
Each call to on_purchase_process_begin will be followed up by a call to this method. If the purchase was found to be valid and was applied to the account, applied will be True. In the case of redundant or invalid purchases or communication failures it will be False.
540def apptime() -> babase.AppTime: 541 """Return the current app-time in seconds. 542 543 App-time is a monotonic time value; it starts at 0.0 when the app 544 launches and will never jump by large amounts or go backwards, even if 545 the system time changes. Its progression will pause when the app is in 546 a suspended state. 547 548 Note that the AppTime returned here is simply float; it just has a 549 unique type in the type-checker's eyes to help prevent it from being 550 accidentally used with time functionality expecting other time types. 551 """ 552 import babase # pylint: disable=cyclic-import 553 554 return babase.AppTime(0.0)
Return the current app-time in seconds.
App-time is a monotonic time value; it starts at 0.0 when the app launches and will never jump by large amounts or go backwards, even if the system time changes. Its progression will pause when the app is in a suspended state.
Note that the AppTime returned here is simply float; it just has a unique type in the type-checker's eyes to help prevent it from being accidentally used with time functionality expecting other time types.
557def apptimer(time: float, call: Callable[[], Any]) -> None: 558 """Schedule a callable object to run based on app-time. 559 560 This function creates a one-off timer which cannot be canceled or 561 modified once created. If you require the ability to do so, or need 562 a repeating timer, use the babase.AppTimer class instead. 563 564 Args: 565 time: Length of time in seconds that the timer will wait before 566 firing. 567 568 call: A callable Python object. Note that the timer will retain a 569 strong reference to the callable for as long as the timer 570 exists, so you may want to look into concepts such as 571 babase.WeakCall if that is not desired. 572 573 Example: Print some stuff through time: 574 >>> babase.screenmessage('hello from now!') 575 >>> babase.apptimer(1.0, babase.Call(babase.screenmessage, 576 ... 'hello from the future!')) 577 >>> babase.apptimer(2.0, babase.Call(babase.screenmessage, 578 ... 'hello from the future 2!')) 579 """ 580 return None
Schedule a callable object to run based on app-time.
This function creates a one-off timer which cannot be canceled or modified once created. If you require the ability to do so, or need a repeating timer, use the babase.AppTimer class instead.
Args: time: Length of time in seconds that the timer will wait before firing.
call: A callable Python object. Note that the timer will retain a
strong reference to the callable for as long as the timer
exists, so you may want to look into concepts such as
babase.WeakCall if that is not desired.
Example: Print some stuff through time:
>>> babase.screenmessage('hello from now!')
>>> babase.apptimer(1.0, babase.Call(babase.screenmessage,
... 'hello from the future!'))
>>> babase.apptimer(2.0, babase.Call(babase.screenmessage,
... 'hello from the future 2!'))
55class AppTimer: 56 """Timers are used to run code at later points in time. 57 58 This class encapsulates a timer based on app-time. 59 The underlying timer will be destroyed when this object is no longer 60 referenced. If you do not want to worry about keeping a reference to 61 your timer around, use the babase.apptimer() function instead to get a 62 one-off timer. 63 64 ##### Arguments 65 ###### time 66 > Length of time in seconds that the timer will wait before firing. 67 68 ###### call 69 > A callable Python object. Remember that the timer will retain a 70 strong reference to the callable for as long as it exists, so you 71 may want to look into concepts such as babase.WeakCall if that is not 72 desired. 73 74 ###### repeat 75 > If True, the timer will fire repeatedly, with each successive 76 firing having the same delay as the first. 77 78 ##### Example 79 80 Use a Timer object to print repeatedly for a few seconds: 81 ... def say_it(): 82 ... babase.screenmessage('BADGER!') 83 ... def stop_saying_it(): 84 ... global g_timer 85 ... g_timer = None 86 ... babase.screenmessage('MUSHROOM MUSHROOM!') 87 ... # Create our timer; it will run as long as we have the self.t ref. 88 ... g_timer = babase.AppTimer(0.3, say_it, repeat=True) 89 ... # Now fire off a one-shot timer to kill it. 90 ... babase.apptimer(3.89, stop_saying_it) 91 """ 92 93 def __init__( 94 self, time: float, call: Callable[[], Any], repeat: bool = False 95 ) -> None: 96 pass
Timers are used to run code at later points in time.
This class encapsulates a timer based on app-time. The underlying timer will be destroyed when this object is no longer referenced. If you do not want to worry about keeping a reference to your timer around, use the babase.apptimer() function instead to get a one-off timer.
Arguments
time
Length of time in seconds that the timer will wait before firing.
call
A callable Python object. Remember 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 babase.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.
Example
Use a Timer object to print repeatedly for a few seconds: ... def say_it(): ... babase.screenmessage('BADGER!') ... def stop_saying_it(): ... global g_timer ... g_timer = None ... babase.screenmessage('MUSHROOM MUSHROOM!') ... # Create our timer; it will run as long as we have the self.t ref. ... g_timer = babase.AppTimer(0.3, say_it, repeat=True) ... # Now fire off a one-shot timer to kill it. ... babase.apptimer(3.89, stop_saying_it)
292class AssetPackage(DependencyComponent): 293 """bascenev1.DependencyComponent representing a package of assets.""" 294 295 def __init__(self) -> None: 296 super().__init__() 297 298 # This is used internally by the get_package_xxx calls. 299 self.context = babase.ContextRef() 300 301 entry = self._dep_entry() 302 assert entry is not None 303 assert isinstance(entry.config, str) 304 self.package_id = entry.config 305 print(f'LOADING ASSET PACKAGE {self.package_id}') 306 307 @override 308 @classmethod 309 def dep_is_present(cls, config: Any = None) -> bool: 310 assert isinstance(config, str) 311 312 # Temp: hard-coding for a single asset-package at the moment. 313 if config == 'stdassets@1': 314 return True 315 return False 316 317 def gettexture(self, name: str) -> bascenev1.Texture: 318 """Load a named bascenev1.Texture from the AssetPackage. 319 320 Behavior is similar to bascenev1.gettexture() 321 """ 322 return _bascenev1.get_package_texture(self, name) 323 324 def getmesh(self, name: str) -> bascenev1.Mesh: 325 """Load a named bascenev1.Mesh from the AssetPackage. 326 327 Behavior is similar to bascenev1.getmesh() 328 """ 329 return _bascenev1.get_package_mesh(self, name) 330 331 def getcollisionmesh(self, name: str) -> bascenev1.CollisionMesh: 332 """Load a named bascenev1.CollisionMesh from the AssetPackage. 333 334 Behavior is similar to bascenev1.getcollisionmesh() 335 """ 336 return _bascenev1.get_package_collision_mesh(self, name) 337 338 def getsound(self, name: str) -> bascenev1.Sound: 339 """Load a named bascenev1.Sound from the AssetPackage. 340 341 Behavior is similar to bascenev1.getsound() 342 """ 343 return _bascenev1.get_package_sound(self, name) 344 345 def getdata(self, name: str) -> bascenev1.Data: 346 """Load a named bascenev1.Data from the AssetPackage. 347 348 Behavior is similar to bascenev1.getdata() 349 """ 350 return _bascenev1.get_package_data(self, name)
bascenev1.DependencyComponent representing a package of assets.
295 def __init__(self) -> None: 296 super().__init__() 297 298 # This is used internally by the get_package_xxx calls. 299 self.context = babase.ContextRef() 300 301 entry = self._dep_entry() 302 assert entry is not None 303 assert isinstance(entry.config, str) 304 self.package_id = entry.config 305 print(f'LOADING ASSET PACKAGE {self.package_id}')
Instantiate a DependencyComponent.
307 @override 308 @classmethod 309 def dep_is_present(cls, config: Any = None) -> bool: 310 assert isinstance(config, str) 311 312 # Temp: hard-coding for a single asset-package at the moment. 313 if config == 'stdassets@1': 314 return True 315 return False
Return whether this component/config is present on this device.
317 def gettexture(self, name: str) -> bascenev1.Texture: 318 """Load a named bascenev1.Texture from the AssetPackage. 319 320 Behavior is similar to bascenev1.gettexture() 321 """ 322 return _bascenev1.get_package_texture(self, name)
Load a named bascenev1.Texture from the AssetPackage.
Behavior is similar to bascenev1.gettexture()
324 def getmesh(self, name: str) -> bascenev1.Mesh: 325 """Load a named bascenev1.Mesh from the AssetPackage. 326 327 Behavior is similar to bascenev1.getmesh() 328 """ 329 return _bascenev1.get_package_mesh(self, name)
Load a named bascenev1.Mesh from the AssetPackage.
Behavior is similar to bascenev1.getmesh()
331 def getcollisionmesh(self, name: str) -> bascenev1.CollisionMesh: 332 """Load a named bascenev1.CollisionMesh from the AssetPackage. 333 334 Behavior is similar to bascenev1.getcollisionmesh() 335 """ 336 return _bascenev1.get_package_collision_mesh(self, name)
Load a named bascenev1.CollisionMesh from the AssetPackage.
Behavior is similar to bascenev1.getcollisionmesh()
338 def getsound(self, name: str) -> bascenev1.Sound: 339 """Load a named bascenev1.Sound from the AssetPackage. 340 341 Behavior is similar to bascenev1.getsound() 342 """ 343 return _bascenev1.get_package_sound(self, name)
Load a named bascenev1.Sound from the AssetPackage.
Behavior is similar to bascenev1.getsound()
345 def getdata(self, name: str) -> bascenev1.Data: 346 """Load a named bascenev1.Data from the AssetPackage. 347 348 Behavior is similar to bascenev1.getdata() 349 """ 350 return _bascenev1.get_package_data(self, name)
Load a named bascenev1.Data from the AssetPackage.
Behavior is similar to bascenev1.getdata()
941def basetime() -> bascenev1.BaseTime: 942 """Return the base-time in seconds for the current scene-v1 context. 943 944 Base-time is a time value that progresses at a constant rate for a scene, 945 even when the scene is sped up, slowed down, or paused. It may, however, 946 speed up or slow down due to replay speed adjustments or may slow down 947 if the cpu is overloaded. 948 Note that the value returned here is simply a float; it just has a 949 unique type in the type-checker's eyes to help prevent it from being 950 accidentally used with time functionality expecting other time types. 951 """ 952 import bascenev1 # pylint: disable=cyclic-import 953 954 return bascenev1.BaseTime(0.0)
Return the base-time in seconds for the current scene-v1 context.
Base-time is a time value that progresses at a constant rate for a scene, even when the scene is sped up, slowed down, or paused. It may, however, speed up or slow down due to replay speed adjustments or may slow down if the cpu is overloaded. Note that the value returned here is simply a float; it just has a unique type in the type-checker's eyes to help prevent it from being accidentally used with time functionality expecting other time types.
959def basetimer( 960 time: float, call: Callable[[], Any], repeat: bool = False 961) -> None: 962 """Schedule a call to run at a later point in scene base-time. 963 Base-time is a value that progresses at a constant rate for a scene, 964 even when the scene is sped up, slowed down, or paused. It may, 965 however, speed up or slow down due to replay speed adjustments or may 966 slow down if the cpu is overloaded. 967 968 This function adds a timer to the current scene context. 969 This timer cannot be canceled or modified once created. If you 970 require the ability to do so, use the bascenev1.BaseTimer class 971 instead. 972 973 ##### Arguments 974 ###### time (float) 975 > Length of time in seconds that the timer will wait before firing. 976 977 ###### call (Callable[[], Any]) 978 > A callable Python object. Remember that the timer will retain a 979 strong reference to the callable for the duration of the timer, so you 980 may want to look into concepts such as babase.WeakCall if that is not 981 desired. 982 983 ###### repeat (bool) 984 > If True, the timer will fire repeatedly, with each successive 985 firing having the same delay as the first. 986 987 ##### Examples 988 Print some stuff through time: 989 >>> import bascenev1 as bs 990 >>> bs.screenmessage('hello from now!') 991 >>> bs.basetimer(1.0, bs.Call(bs.screenmessage, 'hello from the future!')) 992 >>> bs.basetimer(2.0, bs.Call(bs.screenmessage, 993 ... 'hello from the future 2!')) 994 """ 995 return None
Schedule a call to run at a later point in scene base-time. Base-time is a value that progresses at a constant rate for a scene, even when the scene is sped up, slowed down, or paused. It may, however, speed up or slow down due to replay speed adjustments or may slow down if the cpu is overloaded.
This function adds a timer to the current scene context. This timer cannot be canceled or modified once created. If you require the ability to do so, use the bascenev1.BaseTimer class instead.
Arguments
time (float)
Length of time in seconds that the timer will wait before firing.
call (Callable[[], Any])
A callable Python object. Remember that the timer will retain a strong reference to the callable for the duration of the timer, so you may want to look into concepts such as babase.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.
Examples
Print some stuff through time:
>>> import bascenev1 as bs
>>> bs.screenmessage('hello from now!')
>>> bs.basetimer(1.0, bs.Call(bs.screenmessage, 'hello from the future!'))
>>> bs.basetimer(2.0, bs.Call(bs.screenmessage,
... 'hello from the future 2!'))
82class BaseTimer: 83 """Timers are used to run code at later points in time. 84 85 This class encapsulates a base-time timer in the current scene 86 context. 87 The underlying timer will be destroyed when either this object is 88 no longer referenced or when its Context (Activity, etc.) dies. If you 89 do not want to worry about keeping a reference to your timer around, 90 you should use the bascenev1.basetimer() function instead. 91 92 ###### time (float) 93 > Length of time in seconds that the timer will wait 94 before firing. 95 96 ###### call (Callable[[], Any]) 97 > A callable Python object. Remember that the timer will retain a 98 strong reference to the callable for as long as it exists, so you 99 may want to look into concepts such as babase.WeakCall if that is not 100 desired. 101 102 ###### repeat (bool) 103 > If True, the timer will fire repeatedly, with each successive 104 firing having the same delay as the first. 105 106 ##### Example 107 108 Use a BaseTimer object to print repeatedly for a few seconds: 109 >>> import bascenev1 as bs 110 ... def say_it(): 111 ... bs.screenmessage('BADGER!') 112 ... def stop_saying_it(): 113 ... global g_timer 114 ... g_timer = None 115 ... bs.screenmessage('MUSHROOM MUSHROOM!') 116 ... # Create our timer; it will run as long as we have the self.t ref. 117 ... g_timer = bs.BaseTimer(0.3, say_it, repeat=True) 118 ... # Now fire off a one-shot timer to kill it. 119 ... bs.basetimer(3.89, stop_saying_it) 120 """ 121 122 def __init__( 123 self, time: float, call: Callable[[], Any], repeat: bool = False 124 ) -> None: 125 pass
Timers are used to run code at later points in time.
This class encapsulates a base-time timer in the current scene 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 bascenev1.basetimer() function instead.
time (float)
Length of time in seconds that the timer will wait before firing.
call (Callable[[], Any])
A callable Python object. Remember 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 babase.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.
Example
Use a BaseTimer object to print repeatedly for a few seconds:
>>> import bascenev1 as bs
... def say_it():
... bs.screenmessage('BADGER!')
... def stop_saying_it():
... global g_timer
... g_timer = None
... bs.screenmessage('MUSHROOM MUSHROOM!')
... # Create our timer; it will run as long as we have the self.t ref.
... g_timer = bs.BaseTimer(0.3, say_it, repeat=True)
... # Now fire off a one-shot timer to kill it.
... bs.basetimer(3.89, stop_saying_it)
A boolean game setting.
230def cameraflash(duration: float = 999.0) -> None: 231 """Create a strobing camera flash effect. 232 233 (as seen when a team wins a game) 234 Duration is in seconds. 235 """ 236 # pylint: disable=too-many-locals 237 from bascenev1._nodeactor import NodeActor 238 239 x_spread = 10 240 y_spread = 5 241 positions = [ 242 [-x_spread, -y_spread], 243 [0, -y_spread], 244 [0, y_spread], 245 [x_spread, -y_spread], 246 [x_spread, y_spread], 247 [-x_spread, y_spread], 248 ] 249 times = [0, 2700, 1000, 1800, 500, 1400] 250 251 # Store this on the current activity so we only have one at a time. 252 # FIXME: Need a type safe way to do this. 253 activity = _bascenev1.getactivity() 254 activity.camera_flash_data = [] # type: ignore 255 for i in range(6): 256 light = NodeActor( 257 _bascenev1.newnode( 258 'light', 259 attrs={ 260 'position': (positions[i][0], 0, positions[i][1]), 261 'radius': 1.0, 262 'lights_volumes': False, 263 'height_attenuated': False, 264 'color': (0.2, 0.2, 0.8), 265 }, 266 ) 267 ) 268 sval = 1.87 269 iscale = 1.3 270 tcombine = _bascenev1.newnode( 271 'combine', 272 owner=light.node, 273 attrs={ 274 'size': 3, 275 'input0': positions[i][0], 276 'input1': 0, 277 'input2': positions[i][1], 278 }, 279 ) 280 assert light.node 281 tcombine.connectattr('output', light.node, 'position') 282 xval = positions[i][0] 283 yval = positions[i][1] 284 spd = 0.5 + random.random() 285 spd2 = 0.5 + random.random() 286 animate( 287 tcombine, 288 'input0', 289 { 290 0.0: xval + 0, 291 0.069 * spd: xval + 10.0, 292 0.143 * spd: xval - 10.0, 293 0.201 * spd: xval + 0, 294 }, 295 loop=True, 296 ) 297 animate( 298 tcombine, 299 'input2', 300 { 301 0.0: yval + 0, 302 0.15 * spd2: yval + 10.0, 303 0.287 * spd2: yval - 10.0, 304 0.398 * spd2: yval + 0, 305 }, 306 loop=True, 307 ) 308 animate( 309 light.node, 310 'intensity', 311 { 312 0.0: 0, 313 0.02 * sval: 0, 314 0.05 * sval: 0.8 * iscale, 315 0.08 * sval: 0, 316 0.1 * sval: 0, 317 }, 318 loop=True, 319 offset=times[i], 320 ) 321 _bascenev1.timer( 322 (times[i] + random.randint(1, int(duration)) * 40 * sval) / 1000.0, 323 light.node.delete, 324 ) 325 activity.camera_flash_data.append(light) # type: ignore
Create a strobing camera flash effect.
(as seen when a team wins a game) Duration is in seconds.
1023def camerashake(intensity: float = 1.0) -> None: 1024 """Shake the camera. 1025 1026 Note that some cameras and/or platforms (such as VR) may not display 1027 camera-shake, so do not rely on this always being visible to the 1028 player as a gameplay cue. 1029 """ 1030 return None
Shake the camera.
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.
22class Campaign: 23 """Represents a unique set or series of :class:`bascenev1.Level`s.""" 24 25 def __init__( 26 self, 27 name: str, 28 sequential: bool = True, 29 levels: list[bascenev1.Level] | None = None, 30 ): 31 self._name = name 32 self._sequential = sequential 33 self._levels: list[bascenev1.Level] = [] 34 if levels is not None: 35 for level in levels: 36 self.addlevel(level) 37 38 @property 39 def name(self) -> str: 40 """The name of the Campaign.""" 41 return self._name 42 43 @property 44 def sequential(self) -> bool: 45 """Whether this Campaign's levels must be played in sequence.""" 46 return self._sequential 47 48 def addlevel( 49 self, level: bascenev1.Level, index: int | None = None 50 ) -> None: 51 """Adds a baclassic.Level to the Campaign.""" 52 if level.campaign is not None: 53 raise RuntimeError('Level already belongs to a campaign.') 54 level.set_campaign(self, len(self._levels)) 55 if index is None: 56 self._levels.append(level) 57 else: 58 self._levels.insert(index, level) 59 60 @property 61 def levels(self) -> list[bascenev1.Level]: 62 """The list of baclassic.Level-s in the Campaign.""" 63 return self._levels 64 65 def getlevel(self, name: str) -> bascenev1.Level: 66 """Return a contained baclassic.Level by name.""" 67 68 for level in self._levels: 69 if level.name == name: 70 return level 71 raise babase.NotFoundError( 72 "Level '" + name + "' not found in campaign '" + self.name + "'" 73 ) 74 75 def reset(self) -> None: 76 """Reset state for the Campaign.""" 77 babase.app.config.setdefault('Campaigns', {})[self._name] = {} 78 79 # FIXME should these give/take baclassic.Level instances instead 80 # of level names?.. 81 def set_selected_level(self, levelname: str) -> None: 82 """Set the Level currently selected in the UI (by name).""" 83 self.configdict['Selection'] = levelname 84 babase.app.config.commit() 85 86 def get_selected_level(self) -> str: 87 """Return the name of the Level currently selected in the UI.""" 88 val = self.configdict.get('Selection', self._levels[0].name) 89 assert isinstance(val, str) 90 return val 91 92 @property 93 def configdict(self) -> dict[str, Any]: 94 """Return the live config dict for this campaign.""" 95 val: dict[str, Any] = babase.app.config.setdefault( 96 'Campaigns', {} 97 ).setdefault(self._name, {}) 98 assert isinstance(val, dict) 99 return val
Represents a unique set or series of bascenev1.Level
s.
43 @property 44 def sequential(self) -> bool: 45 """Whether this Campaign's levels must be played in sequence.""" 46 return self._sequential
Whether this Campaign's levels must be played in sequence.
48 def addlevel( 49 self, level: bascenev1.Level, index: int | None = None 50 ) -> None: 51 """Adds a baclassic.Level to the Campaign.""" 52 if level.campaign is not None: 53 raise RuntimeError('Level already belongs to a campaign.') 54 level.set_campaign(self, len(self._levels)) 55 if index is None: 56 self._levels.append(level) 57 else: 58 self._levels.insert(index, level)
Adds a baclassic.Level to the Campaign.
60 @property 61 def levels(self) -> list[bascenev1.Level]: 62 """The list of baclassic.Level-s in the Campaign.""" 63 return self._levels
The list of baclassic.Level-s in the Campaign.
65 def getlevel(self, name: str) -> bascenev1.Level: 66 """Return a contained baclassic.Level by name.""" 67 68 for level in self._levels: 69 if level.name == name: 70 return level 71 raise babase.NotFoundError( 72 "Level '" + name + "' not found in campaign '" + self.name + "'" 73 )
Return a contained baclassic.Level by name.
75 def reset(self) -> None: 76 """Reset state for the Campaign.""" 77 babase.app.config.setdefault('Campaigns', {})[self._name] = {}
Reset state for the Campaign.
81 def set_selected_level(self, levelname: str) -> None: 82 """Set the Level currently selected in the UI (by name).""" 83 self.configdict['Selection'] = levelname 84 babase.app.config.commit()
Set the Level currently selected in the UI (by name).
86 def get_selected_level(self) -> str: 87 """Return the name of the Level currently selected in the UI.""" 88 val = self.configdict.get('Selection', self._levels[0].name) 89 assert isinstance(val, str) 90 return val
Return the name of the Level currently selected in the UI.
92 @property 93 def configdict(self) -> dict[str, Any]: 94 """Return the live config dict for this campaign.""" 95 val: dict[str, Any] = babase.app.config.setdefault( 96 'Campaigns', {} 97 ).setdefault(self._name, {}) 98 assert isinstance(val, dict) 99 return val
Return the live config dict for this campaign.
190@dataclass 191class CelebrateMessage: 192 """Tells an object to celebrate.""" 193 194 duration: float = 10.0 195 """Amount of time to celebrate in seconds."""
Tells an object to celebrate.
50@dataclass 51class ChoiceSetting(Setting): 52 """A setting with multiple choices.""" 53 54 choices: list[tuple[str, Any]]
A setting with multiple choices.
181class Chooser: 182 """A character/team selector for a bascenev1.Player.""" 183 184 def __del__(self) -> None: 185 # Just kill off our base node; the rest should go down with it. 186 if self._text_node: 187 self._text_node.delete() 188 189 def __init__( 190 self, 191 vpos: float, 192 sessionplayer: bascenev1.SessionPlayer, 193 lobby: 'Lobby', 194 ) -> None: 195 self._deek_sound = _bascenev1.getsound('deek') 196 self._click_sound = _bascenev1.getsound('click01') 197 self._punchsound = _bascenev1.getsound('punch01') 198 self._swish_sound = _bascenev1.getsound('punchSwish') 199 self._errorsound = _bascenev1.getsound('error') 200 self._mask_texture = _bascenev1.gettexture('characterIconMask') 201 self._vpos = vpos 202 self._lobby = weakref.ref(lobby) 203 self._sessionplayer = sessionplayer 204 self._inited = False 205 self._dead = False 206 self._text_node: bascenev1.Node | None = None 207 self._profilename = '' 208 self._profilenames: list[str] = [] 209 self._ready: bool = False 210 self._character_names: list[str] = [] 211 self._last_change: Sequence[float | int] = (0, 0) 212 self._profiles: dict[str, dict[str, Any]] = {} 213 214 app = babase.app 215 assert app.classic is not None 216 217 # Load available player profiles either from the local config or 218 # from the remote device. 219 self.reload_profiles() 220 221 # Note: this is just our local index out of available teams; *not* 222 # the team-id! 223 self._selected_team_index: int = self.lobby.next_add_team 224 225 # Store a persistent random character index and colors; we'll use this 226 # for the '_random' profile. Let's use their input_device id to seed 227 # it. This will give a persistent character for them between games 228 # and will distribute characters nicely if everyone is random. 229 self._random_color, self._random_highlight = get_player_profile_colors( 230 None 231 ) 232 233 # To calc our random character we pick a random one out of our 234 # unlocked list and then locate that character's index in the full 235 # list. 236 char_index_offset: int = app.classic.lobby_random_char_index_offset 237 self._random_character_index = ( 238 sessionplayer.inputdevice.id + char_index_offset 239 ) % len(self._character_names) 240 241 # Attempt to set an initial profile based on what was used previously 242 # for this input-device, etc. 243 self._profileindex = self._select_initial_profile() 244 self._profilename = self._profilenames[self._profileindex] 245 246 self._text_node = _bascenev1.newnode( 247 'text', 248 delegate=self, 249 attrs={ 250 'position': (-100, self._vpos), 251 'maxwidth': 160, 252 'shadow': 0.5, 253 'vr_depth': -20, 254 'h_align': 'left', 255 'v_align': 'center', 256 'v_attach': 'top', 257 }, 258 ) 259 animate(self._text_node, 'scale', {0: 0, 0.1: 1.0}) 260 self.icon = _bascenev1.newnode( 261 'image', 262 owner=self._text_node, 263 attrs={ 264 'position': (-130, self._vpos + 20), 265 'mask_texture': self._mask_texture, 266 'vr_depth': -10, 267 'attach': 'topCenter', 268 }, 269 ) 270 271 animate_array(self.icon, 'scale', 2, {0: (0, 0), 0.1: (45, 45)}) 272 273 # Set our initial name to '<choosing player>' in case anyone asks. 274 self._sessionplayer.setname( 275 babase.Lstr(resource='choosingPlayerText').evaluate(), real=False 276 ) 277 278 # Init these to our rando but they should get switched to the 279 # selected profile (if any) right after. 280 self._character_index = self._random_character_index 281 self._color = self._random_color 282 self._highlight = self._random_highlight 283 284 self.update_from_profile() 285 self.update_position() 286 self._inited = True 287 288 self._set_ready(False) 289 290 def _select_initial_profile(self) -> int: 291 app = babase.app 292 assert app.classic is not None 293 profilenames = self._profilenames 294 inputdevice = self._sessionplayer.inputdevice 295 296 # If we've got a set profile name for this device, work backwards 297 # from that to get our index. 298 dprofilename = app.config.get('Default Player Profiles', {}).get( 299 inputdevice.name + ' ' + inputdevice.unique_identifier 300 ) 301 if dprofilename is not None and dprofilename in profilenames: 302 # If we got '__account__' and its local and we haven't marked 303 # anyone as the 'account profile' device yet, mark this guy as 304 # it. (prevents the next joiner from getting the account 305 # profile too). 306 if ( 307 dprofilename == '__account__' 308 and not inputdevice.is_remote_client 309 and app.classic.lobby_account_profile_device_id is None 310 ): 311 app.classic.lobby_account_profile_device_id = inputdevice.id 312 return profilenames.index(dprofilename) 313 314 # We want to mark the first local input-device in the game 315 # as the 'account profile' device. 316 if ( 317 not inputdevice.is_remote_client 318 and not inputdevice.is_controller_app 319 ): 320 if ( 321 app.classic.lobby_account_profile_device_id is None 322 and '__account__' in profilenames 323 ): 324 app.classic.lobby_account_profile_device_id = inputdevice.id 325 326 # If this is the designated account-profile-device, try to default 327 # to the account profile. 328 if ( 329 inputdevice.id == app.classic.lobby_account_profile_device_id 330 and '__account__' in profilenames 331 ): 332 return profilenames.index('__account__') 333 334 # If this is the controller app, it defaults to using a random 335 # profile (since we can pull the random name from the app). 336 if inputdevice.is_controller_app and '_random' in profilenames: 337 return profilenames.index('_random') 338 339 # If its a client connection, for now just force 340 # the account profile if possible.. (need to provide a 341 # way for clients to specify/remember their default 342 # profile on remote servers that do not already know them). 343 if inputdevice.is_remote_client and '__account__' in profilenames: 344 return profilenames.index('__account__') 345 346 # Cycle through our non-random profiles once; after 347 # that, everyone gets random. 348 while app.classic.lobby_random_profile_index < len( 349 profilenames 350 ) and profilenames[app.classic.lobby_random_profile_index] in ( 351 '_random', 352 '__account__', 353 '_edit', 354 ): 355 app.classic.lobby_random_profile_index += 1 356 if app.classic.lobby_random_profile_index < len(profilenames): 357 profileindex: int = app.classic.lobby_random_profile_index 358 app.classic.lobby_random_profile_index += 1 359 return profileindex 360 assert '_random' in profilenames 361 return profilenames.index('_random') 362 363 @property 364 def sessionplayer(self) -> bascenev1.SessionPlayer: 365 """The bascenev1.SessionPlayer associated with this chooser.""" 366 return self._sessionplayer 367 368 @property 369 def ready(self) -> bool: 370 """Whether this chooser is checked in as ready.""" 371 return self._ready 372 373 def set_vpos(self, vpos: float) -> None: 374 """(internal)""" 375 self._vpos = vpos 376 377 def set_dead(self, val: bool) -> None: 378 """(internal)""" 379 self._dead = val 380 381 @property 382 def sessionteam(self) -> bascenev1.SessionTeam: 383 """Return this chooser's currently selected bascenev1.SessionTeam.""" 384 return self.lobby.sessionteams[self._selected_team_index] 385 386 @property 387 def lobby(self) -> bascenev1.Lobby: 388 """The chooser's baclassic.Lobby.""" 389 lobby = self._lobby() 390 if lobby is None: 391 raise babase.NotFoundError('Lobby does not exist.') 392 return lobby 393 394 def get_lobby(self) -> bascenev1.Lobby | None: 395 """Return this chooser's lobby if it still exists; otherwise None.""" 396 return self._lobby() 397 398 def update_from_profile(self) -> None: 399 """Set character/colors based on the current profile.""" 400 assert babase.app.classic is not None 401 self._profilename = self._profilenames[self._profileindex] 402 if self._profilename == '_edit': 403 pass 404 elif self._profilename == '_random': 405 self._character_index = self._random_character_index 406 self._color = self._random_color 407 self._highlight = self._random_highlight 408 else: 409 character = self._profiles[self._profilename]['character'] 410 411 # At the moment we're not properly pulling the list 412 # of available characters from clients, so profiles might use a 413 # character not in their list. For now, just go ahead and add 414 # a character name to their list as long as we're aware of it. 415 # This just means they won't always be able to override their 416 # character to others they own, but profile characters 417 # should work (and we validate profiles on the master server 418 # so no exploit opportunities) 419 if ( 420 character not in self._character_names 421 and character in babase.app.classic.spaz_appearances 422 ): 423 self._character_names.append(character) 424 self._character_index = self._character_names.index(character) 425 self._color, self._highlight = get_player_profile_colors( 426 self._profilename, profiles=self._profiles 427 ) 428 self._update_icon() 429 self._update_text() 430 431 def reload_profiles(self) -> None: 432 """Reload all player profiles.""" 433 434 app = babase.app 435 env = app.env 436 assert app.classic is not None 437 438 # Re-construct our profile index and other stuff since the profile 439 # list might have changed. 440 input_device = self._sessionplayer.inputdevice 441 is_remote = input_device.is_remote_client 442 is_test_input = input_device.is_test_input 443 444 # Pull this player's list of unlocked characters. 445 if is_remote: 446 # TODO: Pull this from the remote player. 447 # (but make sure to filter it to the ones we've got). 448 self._character_names = ['Spaz'] 449 else: 450 self._character_names = self.lobby.character_names_local_unlocked 451 452 # If we're a local player, pull our local profiles from the config. 453 # Otherwise ask the remote-input-device for its profile list. 454 if is_remote: 455 self._profiles = input_device.get_player_profiles() 456 else: 457 self._profiles = app.config.get('Player Profiles', {}) 458 459 # These may have come over the wire from an older 460 # (non-unicode/non-json) version. 461 # Make sure they conform to our standards 462 # (unicode strings, no tuples, etc) 463 self._profiles = app.classic.json_prep(self._profiles) 464 465 # Filter out any characters we're unaware of. 466 for profile in list(self._profiles.items()): 467 if ( 468 profile[1].get('character', '') 469 not in app.classic.spaz_appearances 470 ): 471 profile[1]['character'] = 'Spaz' 472 473 # Add in a random one so we're ok even if there's no user profiles. 474 self._profiles['_random'] = {} 475 476 # In kiosk mode we disable account profiles to force random. 477 if env.demo or env.arcade: 478 if '__account__' in self._profiles: 479 del self._profiles['__account__'] 480 481 # For local devices, add it an 'edit' option which will pop up 482 # the profile window. 483 if not is_remote and not is_test_input and not (env.demo or env.arcade): 484 self._profiles['_edit'] = {} 485 486 # Build a sorted name list we can iterate through. 487 self._profilenames = list(self._profiles.keys()) 488 self._profilenames.sort(key=lambda x: x.lower()) 489 490 if self._profilename in self._profilenames: 491 self._profileindex = self._profilenames.index(self._profilename) 492 else: 493 self._profileindex = 0 494 # noinspection PyUnresolvedReferences 495 self._profilename = self._profilenames[self._profileindex] 496 497 def update_position(self) -> None: 498 """Update this chooser's position.""" 499 500 assert self._text_node 501 spacing = 350 502 sessionteams = self.lobby.sessionteams 503 offs = ( 504 spacing * -0.5 * len(sessionteams) 505 + spacing * self._selected_team_index 506 + 250 507 ) 508 if len(sessionteams) > 1: 509 offs -= 35 510 animate_array( 511 self._text_node, 512 'position', 513 2, 514 {0: self._text_node.position, 0.1: (-100 + offs, self._vpos + 23)}, 515 ) 516 animate_array( 517 self.icon, 518 'position', 519 2, 520 {0: self.icon.position, 0.1: (-130 + offs, self._vpos + 22)}, 521 ) 522 523 def get_character_name(self) -> str: 524 """Return the selected character name.""" 525 return self._character_names[self._character_index] 526 527 def _do_nothing(self) -> None: 528 """Does nothing! (hacky way to disable callbacks)""" 529 530 def _getname(self, full: bool = False) -> str: 531 name_raw = name = self._profilenames[self._profileindex] 532 clamp = False 533 if name == '_random': 534 try: 535 name = self._sessionplayer.inputdevice.get_default_player_name() 536 except Exception: 537 logging.exception('Error getting _random chooser name.') 538 name = 'Invalid' 539 clamp = not full 540 elif name == '__account__': 541 try: 542 name = self._sessionplayer.inputdevice.get_v1_account_name(full) 543 except Exception: 544 logging.exception('Error getting account name for chooser.') 545 name = 'Invalid' 546 clamp = not full 547 elif name == '_edit': 548 # Explicitly flattening this to a str; it's only relevant on 549 # the host so that's ok. 550 name = babase.Lstr( 551 resource='createEditPlayerText', 552 fallback_resource='editProfileWindow.titleNewText', 553 ).evaluate() 554 else: 555 # If we have a regular profile marked as global with an icon, 556 # use it (for full only). 557 if full: 558 try: 559 if self._profiles[name_raw].get('global', False): 560 icon = ( 561 self._profiles[name_raw]['icon'] 562 if 'icon' in self._profiles[name_raw] 563 else babase.charstr(babase.SpecialChar.LOGO) 564 ) 565 name = icon + name 566 except Exception: 567 logging.exception('Error applying global icon.') 568 else: 569 # We now clamp non-full versions of names so there's at 570 # least some hope of reading them in-game. 571 clamp = True 572 573 if clamp: 574 if len(name) > 10: 575 name = name[:10] + '...' 576 return name 577 578 def _set_ready(self, ready: bool) -> None: 579 # pylint: disable=cyclic-import 580 581 classic = babase.app.classic 582 assert classic is not None 583 584 profilename = self._profilenames[self._profileindex] 585 586 # Handle '_edit' as a special case. 587 if profilename == '_edit' and ready: 588 with babase.ContextRef.empty(): 589 590 classic.profile_browser_window() 591 592 # Give their input-device UI ownership too (prevent 593 # someone else from snatching it in crowded games). 594 babase.set_ui_input_device(self._sessionplayer.inputdevice.id) 595 return 596 597 if not ready: 598 self._sessionplayer.assigninput( 599 babase.InputType.LEFT_PRESS, 600 babase.Call(self.handlemessage, ChangeMessage('team', -1)), 601 ) 602 self._sessionplayer.assigninput( 603 babase.InputType.RIGHT_PRESS, 604 babase.Call(self.handlemessage, ChangeMessage('team', 1)), 605 ) 606 self._sessionplayer.assigninput( 607 babase.InputType.BOMB_PRESS, 608 babase.Call(self.handlemessage, ChangeMessage('character', 1)), 609 ) 610 self._sessionplayer.assigninput( 611 babase.InputType.UP_PRESS, 612 babase.Call( 613 self.handlemessage, ChangeMessage('profileindex', -1) 614 ), 615 ) 616 self._sessionplayer.assigninput( 617 babase.InputType.DOWN_PRESS, 618 babase.Call( 619 self.handlemessage, ChangeMessage('profileindex', 1) 620 ), 621 ) 622 self._sessionplayer.assigninput( 623 ( 624 babase.InputType.JUMP_PRESS, 625 babase.InputType.PICK_UP_PRESS, 626 babase.InputType.PUNCH_PRESS, 627 ), 628 babase.Call(self.handlemessage, ChangeMessage('ready', 1)), 629 ) 630 self._ready = False 631 self._update_text() 632 self._sessionplayer.setname('untitled', real=False) 633 else: 634 self._sessionplayer.assigninput( 635 ( 636 babase.InputType.LEFT_PRESS, 637 babase.InputType.RIGHT_PRESS, 638 babase.InputType.UP_PRESS, 639 babase.InputType.DOWN_PRESS, 640 babase.InputType.JUMP_PRESS, 641 babase.InputType.BOMB_PRESS, 642 babase.InputType.PICK_UP_PRESS, 643 ), 644 self._do_nothing, 645 ) 646 self._sessionplayer.assigninput( 647 ( 648 babase.InputType.JUMP_PRESS, 649 babase.InputType.BOMB_PRESS, 650 babase.InputType.PICK_UP_PRESS, 651 babase.InputType.PUNCH_PRESS, 652 ), 653 babase.Call(self.handlemessage, ChangeMessage('ready', 0)), 654 ) 655 656 # Store the last profile picked by this input for reuse. 657 input_device = self._sessionplayer.inputdevice 658 name = input_device.name 659 unique_id = input_device.unique_identifier 660 device_profiles = babase.app.config.setdefault( 661 'Default Player Profiles', {} 662 ) 663 664 # Make an exception if we have no custom profiles and are set 665 # to random; in that case we'll want to start picking up custom 666 # profiles if/when one is made so keep our setting cleared. 667 special = ('_random', '_edit', '__account__') 668 have_custom_profiles = any(p not in special for p in self._profiles) 669 670 profilekey = name + ' ' + unique_id 671 if profilename == '_random' and not have_custom_profiles: 672 if profilekey in device_profiles: 673 del device_profiles[profilekey] 674 else: 675 device_profiles[profilekey] = profilename 676 babase.app.config.commit() 677 678 # Set this player's short and full name. 679 self._sessionplayer.setname( 680 self._getname(), self._getname(full=True), real=True 681 ) 682 self._ready = True 683 self._update_text() 684 685 # Inform the session that this player is ready. 686 _bascenev1.getsession().handlemessage(PlayerReadyMessage(self)) 687 688 def _handle_ready_msg(self, ready: bool) -> None: 689 force_team_switch = False 690 691 # Team auto-balance kicks us to another team if we try to 692 # join the team with the most players. 693 if not self._ready: 694 if babase.app.config.get('Auto Balance Teams', False): 695 lobby = self.lobby 696 sessionteams = lobby.sessionteams 697 if len(sessionteams) > 1: 698 # First, calc how many players are on each team 699 # ..we need to count both active players and 700 # choosers that have been marked as ready. 701 team_player_counts = {} 702 for sessionteam in sessionteams: 703 team_player_counts[sessionteam.id] = len( 704 sessionteam.players 705 ) 706 for chooser in lobby.choosers: 707 if chooser.ready: 708 team_player_counts[chooser.sessionteam.id] += 1 709 largest_team_size = max(team_player_counts.values()) 710 smallest_team_size = min(team_player_counts.values()) 711 712 # Force switch if we're on the biggest sessionteam 713 # and there's a smaller one available. 714 if ( 715 largest_team_size != smallest_team_size 716 and team_player_counts[self.sessionteam.id] 717 >= largest_team_size 718 ): 719 force_team_switch = True 720 721 # Either force switch teams, or actually for realsies do the set-ready. 722 if force_team_switch: 723 self._errorsound.play() 724 self.handlemessage(ChangeMessage('team', 1)) 725 else: 726 self._punchsound.play() 727 self._set_ready(ready) 728 729 # TODO: should handle this at the engine layer so this is unnecessary. 730 def _handle_repeat_message_attack(self) -> None: 731 now = babase.apptime() 732 count = self._last_change[1] 733 if now - self._last_change[0] < QUICK_CHANGE_INTERVAL: 734 count += 1 735 if count > MAX_QUICK_CHANGE_COUNT: 736 _bascenev1.disconnect_client( 737 self._sessionplayer.inputdevice.client_id 738 ) 739 elif now - self._last_change[0] > QUICK_CHANGE_RESET_INTERVAL: 740 count = 0 741 self._last_change = (now, count) 742 743 def handlemessage(self, msg: Any) -> Any: 744 """Standard generic message handler.""" 745 746 if isinstance(msg, ChangeMessage): 747 self._handle_repeat_message_attack() 748 749 # If we've been removed from the lobby, ignore this stuff. 750 if self._dead: 751 logging.error('chooser got ChangeMessage after dying') 752 return 753 754 if not self._text_node: 755 logging.error('got ChangeMessage after nodes died') 756 return 757 758 if msg.what == 'team': 759 sessionteams = self.lobby.sessionteams 760 if len(sessionteams) > 1: 761 self._swish_sound.play() 762 self._selected_team_index = ( 763 self._selected_team_index + msg.value 764 ) % len(sessionteams) 765 self._update_text() 766 self.update_position() 767 self._update_icon() 768 769 elif msg.what == 'profileindex': 770 if len(self._profilenames) == 1: 771 # This should be pretty hard to hit now with 772 # automatic local accounts. 773 _bascenev1.getsound('error').play() 774 else: 775 # Pick the next player profile and assign our name 776 # and character based on that. 777 self._deek_sound.play() 778 self._profileindex = (self._profileindex + msg.value) % len( 779 self._profilenames 780 ) 781 self.update_from_profile() 782 783 elif msg.what == 'character': 784 self._click_sound.play() 785 # update our index in our local list of characters 786 self._character_index = ( 787 self._character_index + msg.value 788 ) % len(self._character_names) 789 self._update_text() 790 self._update_icon() 791 792 elif msg.what == 'ready': 793 self._handle_ready_msg(bool(msg.value)) 794 795 def _update_text(self) -> None: 796 assert self._text_node is not None 797 if self._ready: 798 # Once we're ready, we've saved the name, so lets ask the system 799 # for it so we get appended numbers and stuff. 800 text = babase.Lstr(value=self._sessionplayer.getname(full=True)) 801 text = babase.Lstr( 802 value='${A} (${B})', 803 subs=[ 804 ('${A}', text), 805 ('${B}', babase.Lstr(resource='readyText')), 806 ], 807 ) 808 else: 809 text = babase.Lstr(value=self._getname(full=True)) 810 811 can_switch_teams = len(self.lobby.sessionteams) > 1 812 813 # Flash as we're coming in. 814 fin_color = babase.safecolor(self.get_color()) + (1,) 815 if not self._inited: 816 animate_array( 817 self._text_node, 818 'color', 819 4, 820 {0.15: fin_color, 0.25: (2, 2, 2, 1), 0.35: fin_color}, 821 ) 822 else: 823 # Blend if we're in teams mode; switch instantly otherwise. 824 if can_switch_teams: 825 animate_array( 826 self._text_node, 827 'color', 828 4, 829 {0: self._text_node.color, 0.1: fin_color}, 830 ) 831 else: 832 self._text_node.color = fin_color 833 834 self._text_node.text = text 835 836 def get_color(self) -> Sequence[float]: 837 """Return the currently selected color.""" 838 val: Sequence[float] 839 if self.lobby.use_team_colors: 840 val = self.lobby.sessionteams[self._selected_team_index].color 841 else: 842 val = self._color 843 if len(val) != 3: 844 print('get_color: ignoring invalid color of len', len(val)) 845 val = (0, 1, 0) 846 return val 847 848 def get_highlight(self) -> Sequence[float]: 849 """Return the currently selected highlight.""" 850 if self._profilenames[self._profileindex] == '_edit': 851 return 0, 1, 0 852 853 # If we're using team colors we wanna make sure our highlight color 854 # isn't too close to any other team's color. 855 highlight = list(self._highlight) 856 if self.lobby.use_team_colors: 857 for i, sessionteam in enumerate(self.lobby.sessionteams): 858 if i != self._selected_team_index: 859 # Find the dominant component of this sessionteam's color 860 # and adjust ours so that the component is 861 # not super-dominant. 862 max_val = 0.0 863 max_index = 0 864 for j in range(3): 865 if sessionteam.color[j] > max_val: 866 max_val = sessionteam.color[j] 867 max_index = j 868 that_color_for_us = highlight[max_index] 869 our_second_biggest = max( 870 highlight[(max_index + 1) % 3], 871 highlight[(max_index + 2) % 3], 872 ) 873 diff = that_color_for_us - our_second_biggest 874 if diff > 0: 875 highlight[max_index] -= diff * 0.6 876 highlight[(max_index + 1) % 3] += diff * 0.3 877 highlight[(max_index + 2) % 3] += diff * 0.2 878 return highlight 879 880 def getplayer(self) -> bascenev1.SessionPlayer: 881 """Return the player associated with this chooser.""" 882 return self._sessionplayer 883 884 def _update_icon(self) -> None: 885 assert babase.app.classic is not None 886 if self._profilenames[self._profileindex] == '_edit': 887 tex = _bascenev1.gettexture('black') 888 tint_tex = _bascenev1.gettexture('black') 889 self.icon.color = (1, 1, 1) 890 self.icon.texture = tex 891 self.icon.tint_texture = tint_tex 892 self.icon.tint_color = (0, 1, 0) 893 return 894 895 try: 896 tex_name = babase.app.classic.spaz_appearances[ 897 self._character_names[self._character_index] 898 ].icon_texture 899 tint_tex_name = babase.app.classic.spaz_appearances[ 900 self._character_names[self._character_index] 901 ].icon_mask_texture 902 except Exception: 903 logging.exception('Error updating char icon list') 904 tex_name = 'neoSpazIcon' 905 tint_tex_name = 'neoSpazIconColorMask' 906 907 tex = _bascenev1.gettexture(tex_name) 908 tint_tex = _bascenev1.gettexture(tint_tex_name) 909 910 self.icon.color = (1, 1, 1) 911 self.icon.texture = tex 912 self.icon.tint_texture = tint_tex 913 clr = self.get_color() 914 clr2 = self.get_highlight() 915 916 can_switch_teams = len(self.lobby.sessionteams) > 1 917 918 # If we're initing, flash. 919 if not self._inited: 920 animate_array( 921 self.icon, 922 'color', 923 3, 924 {0.15: (1, 1, 1), 0.25: (2, 2, 2), 0.35: (1, 1, 1)}, 925 ) 926 927 # Blend in teams mode; switch instantly in ffa-mode. 928 if can_switch_teams: 929 animate_array( 930 self.icon, 'tint_color', 3, {0: self.icon.tint_color, 0.1: clr} 931 ) 932 else: 933 self.icon.tint_color = clr 934 self.icon.tint2_color = clr2 935 936 # Store the icon info the the player. 937 self._sessionplayer.set_icon_info(tex_name, tint_tex_name, clr, clr2)
A character/team selector for a bascenev1.Player.
189 def __init__( 190 self, 191 vpos: float, 192 sessionplayer: bascenev1.SessionPlayer, 193 lobby: 'Lobby', 194 ) -> None: 195 self._deek_sound = _bascenev1.getsound('deek') 196 self._click_sound = _bascenev1.getsound('click01') 197 self._punchsound = _bascenev1.getsound('punch01') 198 self._swish_sound = _bascenev1.getsound('punchSwish') 199 self._errorsound = _bascenev1.getsound('error') 200 self._mask_texture = _bascenev1.gettexture('characterIconMask') 201 self._vpos = vpos 202 self._lobby = weakref.ref(lobby) 203 self._sessionplayer = sessionplayer 204 self._inited = False 205 self._dead = False 206 self._text_node: bascenev1.Node | None = None 207 self._profilename = '' 208 self._profilenames: list[str] = [] 209 self._ready: bool = False 210 self._character_names: list[str] = [] 211 self._last_change: Sequence[float | int] = (0, 0) 212 self._profiles: dict[str, dict[str, Any]] = {} 213 214 app = babase.app 215 assert app.classic is not None 216 217 # Load available player profiles either from the local config or 218 # from the remote device. 219 self.reload_profiles() 220 221 # Note: this is just our local index out of available teams; *not* 222 # the team-id! 223 self._selected_team_index: int = self.lobby.next_add_team 224 225 # Store a persistent random character index and colors; we'll use this 226 # for the '_random' profile. Let's use their input_device id to seed 227 # it. This will give a persistent character for them between games 228 # and will distribute characters nicely if everyone is random. 229 self._random_color, self._random_highlight = get_player_profile_colors( 230 None 231 ) 232 233 # To calc our random character we pick a random one out of our 234 # unlocked list and then locate that character's index in the full 235 # list. 236 char_index_offset: int = app.classic.lobby_random_char_index_offset 237 self._random_character_index = ( 238 sessionplayer.inputdevice.id + char_index_offset 239 ) % len(self._character_names) 240 241 # Attempt to set an initial profile based on what was used previously 242 # for this input-device, etc. 243 self._profileindex = self._select_initial_profile() 244 self._profilename = self._profilenames[self._profileindex] 245 246 self._text_node = _bascenev1.newnode( 247 'text', 248 delegate=self, 249 attrs={ 250 'position': (-100, self._vpos), 251 'maxwidth': 160, 252 'shadow': 0.5, 253 'vr_depth': -20, 254 'h_align': 'left', 255 'v_align': 'center', 256 'v_attach': 'top', 257 }, 258 ) 259 animate(self._text_node, 'scale', {0: 0, 0.1: 1.0}) 260 self.icon = _bascenev1.newnode( 261 'image', 262 owner=self._text_node, 263 attrs={ 264 'position': (-130, self._vpos + 20), 265 'mask_texture': self._mask_texture, 266 'vr_depth': -10, 267 'attach': 'topCenter', 268 }, 269 ) 270 271 animate_array(self.icon, 'scale', 2, {0: (0, 0), 0.1: (45, 45)}) 272 273 # Set our initial name to '<choosing player>' in case anyone asks. 274 self._sessionplayer.setname( 275 babase.Lstr(resource='choosingPlayerText').evaluate(), real=False 276 ) 277 278 # Init these to our rando but they should get switched to the 279 # selected profile (if any) right after. 280 self._character_index = self._random_character_index 281 self._color = self._random_color 282 self._highlight = self._random_highlight 283 284 self.update_from_profile() 285 self.update_position() 286 self._inited = True 287 288 self._set_ready(False)
363 @property 364 def sessionplayer(self) -> bascenev1.SessionPlayer: 365 """The bascenev1.SessionPlayer associated with this chooser.""" 366 return self._sessionplayer
The bascenev1.SessionPlayer associated with this chooser.
368 @property 369 def ready(self) -> bool: 370 """Whether this chooser is checked in as ready.""" 371 return self._ready
Whether this chooser is checked in as ready.
381 @property 382 def sessionteam(self) -> bascenev1.SessionTeam: 383 """Return this chooser's currently selected bascenev1.SessionTeam.""" 384 return self.lobby.sessionteams[self._selected_team_index]
Return this chooser's currently selected bascenev1.SessionTeam.
386 @property 387 def lobby(self) -> bascenev1.Lobby: 388 """The chooser's baclassic.Lobby.""" 389 lobby = self._lobby() 390 if lobby is None: 391 raise babase.NotFoundError('Lobby does not exist.') 392 return lobby
The chooser's baclassic.Lobby.
394 def get_lobby(self) -> bascenev1.Lobby | None: 395 """Return this chooser's lobby if it still exists; otherwise None.""" 396 return self._lobby()
Return this chooser's lobby if it still exists; otherwise None.
398 def update_from_profile(self) -> None: 399 """Set character/colors based on the current profile.""" 400 assert babase.app.classic is not None 401 self._profilename = self._profilenames[self._profileindex] 402 if self._profilename == '_edit': 403 pass 404 elif self._profilename == '_random': 405 self._character_index = self._random_character_index 406 self._color = self._random_color 407 self._highlight = self._random_highlight 408 else: 409 character = self._profiles[self._profilename]['character'] 410 411 # At the moment we're not properly pulling the list 412 # of available characters from clients, so profiles might use a 413 # character not in their list. For now, just go ahead and add 414 # a character name to their list as long as we're aware of it. 415 # This just means they won't always be able to override their 416 # character to others they own, but profile characters 417 # should work (and we validate profiles on the master server 418 # so no exploit opportunities) 419 if ( 420 character not in self._character_names 421 and character in babase.app.classic.spaz_appearances 422 ): 423 self._character_names.append(character) 424 self._character_index = self._character_names.index(character) 425 self._color, self._highlight = get_player_profile_colors( 426 self._profilename, profiles=self._profiles 427 ) 428 self._update_icon() 429 self._update_text()
Set character/colors based on the current profile.
431 def reload_profiles(self) -> None: 432 """Reload all player profiles.""" 433 434 app = babase.app 435 env = app.env 436 assert app.classic is not None 437 438 # Re-construct our profile index and other stuff since the profile 439 # list might have changed. 440 input_device = self._sessionplayer.inputdevice 441 is_remote = input_device.is_remote_client 442 is_test_input = input_device.is_test_input 443 444 # Pull this player's list of unlocked characters. 445 if is_remote: 446 # TODO: Pull this from the remote player. 447 # (but make sure to filter it to the ones we've got). 448 self._character_names = ['Spaz'] 449 else: 450 self._character_names = self.lobby.character_names_local_unlocked 451 452 # If we're a local player, pull our local profiles from the config. 453 # Otherwise ask the remote-input-device for its profile list. 454 if is_remote: 455 self._profiles = input_device.get_player_profiles() 456 else: 457 self._profiles = app.config.get('Player Profiles', {}) 458 459 # These may have come over the wire from an older 460 # (non-unicode/non-json) version. 461 # Make sure they conform to our standards 462 # (unicode strings, no tuples, etc) 463 self._profiles = app.classic.json_prep(self._profiles) 464 465 # Filter out any characters we're unaware of. 466 for profile in list(self._profiles.items()): 467 if ( 468 profile[1].get('character', '') 469 not in app.classic.spaz_appearances 470 ): 471 profile[1]['character'] = 'Spaz' 472 473 # Add in a random one so we're ok even if there's no user profiles. 474 self._profiles['_random'] = {} 475 476 # In kiosk mode we disable account profiles to force random. 477 if env.demo or env.arcade: 478 if '__account__' in self._profiles: 479 del self._profiles['__account__'] 480 481 # For local devices, add it an 'edit' option which will pop up 482 # the profile window. 483 if not is_remote and not is_test_input and not (env.demo or env.arcade): 484 self._profiles['_edit'] = {} 485 486 # Build a sorted name list we can iterate through. 487 self._profilenames = list(self._profiles.keys()) 488 self._profilenames.sort(key=lambda x: x.lower()) 489 490 if self._profilename in self._profilenames: 491 self._profileindex = self._profilenames.index(self._profilename) 492 else: 493 self._profileindex = 0 494 # noinspection PyUnresolvedReferences 495 self._profilename = self._profilenames[self._profileindex]
Reload all player profiles.
497 def update_position(self) -> None: 498 """Update this chooser's position.""" 499 500 assert self._text_node 501 spacing = 350 502 sessionteams = self.lobby.sessionteams 503 offs = ( 504 spacing * -0.5 * len(sessionteams) 505 + spacing * self._selected_team_index 506 + 250 507 ) 508 if len(sessionteams) > 1: 509 offs -= 35 510 animate_array( 511 self._text_node, 512 'position', 513 2, 514 {0: self._text_node.position, 0.1: (-100 + offs, self._vpos + 23)}, 515 ) 516 animate_array( 517 self.icon, 518 'position', 519 2, 520 {0: self.icon.position, 0.1: (-130 + offs, self._vpos + 22)}, 521 )
Update this chooser's position.
523 def get_character_name(self) -> str: 524 """Return the selected character name.""" 525 return self._character_names[self._character_index]
Return the selected character name.
743 def handlemessage(self, msg: Any) -> Any: 744 """Standard generic message handler.""" 745 746 if isinstance(msg, ChangeMessage): 747 self._handle_repeat_message_attack() 748 749 # If we've been removed from the lobby, ignore this stuff. 750 if self._dead: 751 logging.error('chooser got ChangeMessage after dying') 752 return 753 754 if not self._text_node: 755 logging.error('got ChangeMessage after nodes died') 756 return 757 758 if msg.what == 'team': 759 sessionteams = self.lobby.sessionteams 760 if len(sessionteams) > 1: 761 self._swish_sound.play() 762 self._selected_team_index = ( 763 self._selected_team_index + msg.value 764 ) % len(sessionteams) 765 self._update_text() 766 self.update_position() 767 self._update_icon() 768 769 elif msg.what == 'profileindex': 770 if len(self._profilenames) == 1: 771 # This should be pretty hard to hit now with 772 # automatic local accounts. 773 _bascenev1.getsound('error').play() 774 else: 775 # Pick the next player profile and assign our name 776 # and character based on that. 777 self._deek_sound.play() 778 self._profileindex = (self._profileindex + msg.value) % len( 779 self._profilenames 780 ) 781 self.update_from_profile() 782 783 elif msg.what == 'character': 784 self._click_sound.play() 785 # update our index in our local list of characters 786 self._character_index = ( 787 self._character_index + msg.value 788 ) % len(self._character_names) 789 self._update_text() 790 self._update_icon() 791 792 elif msg.what == 'ready': 793 self._handle_ready_msg(bool(msg.value))
Standard generic message handler.
836 def get_color(self) -> Sequence[float]: 837 """Return the currently selected color.""" 838 val: Sequence[float] 839 if self.lobby.use_team_colors: 840 val = self.lobby.sessionteams[self._selected_team_index].color 841 else: 842 val = self._color 843 if len(val) != 3: 844 print('get_color: ignoring invalid color of len', len(val)) 845 val = (0, 1, 0) 846 return val
Return the currently selected color.
848 def get_highlight(self) -> Sequence[float]: 849 """Return the currently selected highlight.""" 850 if self._profilenames[self._profileindex] == '_edit': 851 return 0, 1, 0 852 853 # If we're using team colors we wanna make sure our highlight color 854 # isn't too close to any other team's color. 855 highlight = list(self._highlight) 856 if self.lobby.use_team_colors: 857 for i, sessionteam in enumerate(self.lobby.sessionteams): 858 if i != self._selected_team_index: 859 # Find the dominant component of this sessionteam's color 860 # and adjust ours so that the component is 861 # not super-dominant. 862 max_val = 0.0 863 max_index = 0 864 for j in range(3): 865 if sessionteam.color[j] > max_val: 866 max_val = sessionteam.color[j] 867 max_index = j 868 that_color_for_us = highlight[max_index] 869 our_second_biggest = max( 870 highlight[(max_index + 1) % 3], 871 highlight[(max_index + 2) % 3], 872 ) 873 diff = that_color_for_us - our_second_biggest 874 if diff > 0: 875 highlight[max_index] -= diff * 0.6 876 highlight[(max_index + 1) % 3] += diff * 0.3 877 highlight[(max_index + 2) % 3] += diff * 0.2 878 return highlight
Return the currently selected highlight.
17class Collision: 18 """A class providing info about occurring collisions.""" 19 20 @property 21 def position(self) -> bascenev1.Vec3: 22 """The position of the current collision.""" 23 return babase.Vec3(_bascenev1.get_collision_info('position')) 24 25 @property 26 def sourcenode(self) -> bascenev1.Node: 27 """The node containing the material triggering the current callback. 28 29 Throws a bascenev1.NodeNotFoundError if the node does not exist, 30 though the node should always exist (at least at the start of the 31 collision callback). 32 """ 33 node = _bascenev1.get_collision_info('sourcenode') 34 assert isinstance(node, (_bascenev1.Node, type(None))) 35 if not node: 36 raise babase.NodeNotFoundError() 37 return node 38 39 @property 40 def opposingnode(self) -> bascenev1.Node: 41 """The node the current callback material node is hitting. 42 43 Throws a bascenev1.NodeNotFoundError if the node does not exist. 44 This can be expected in some cases such as in 'disconnect' 45 callbacks triggered by deleting a currently-colliding node. 46 """ 47 node = _bascenev1.get_collision_info('opposingnode') 48 assert isinstance(node, (_bascenev1.Node, type(None))) 49 if not node: 50 raise babase.NodeNotFoundError() 51 return node 52 53 @property 54 def opposingbody(self) -> int: 55 """The body index on the opposing node in the current collision.""" 56 body = _bascenev1.get_collision_info('opposingbody') 57 assert isinstance(body, int) 58 return body
A class providing info about occurring collisions.
20 @property 21 def position(self) -> bascenev1.Vec3: 22 """The position of the current collision.""" 23 return babase.Vec3(_bascenev1.get_collision_info('position'))
The position of the current collision.
25 @property 26 def sourcenode(self) -> bascenev1.Node: 27 """The node containing the material triggering the current callback. 28 29 Throws a bascenev1.NodeNotFoundError if the node does not exist, 30 though the node should always exist (at least at the start of the 31 collision callback). 32 """ 33 node = _bascenev1.get_collision_info('sourcenode') 34 assert isinstance(node, (_bascenev1.Node, type(None))) 35 if not node: 36 raise babase.NodeNotFoundError() 37 return node
The node containing the material triggering the current callback.
Throws a bascenev1.NodeNotFoundError if the node does not exist, though the node should always exist (at least at the start of the collision callback).
39 @property 40 def opposingnode(self) -> bascenev1.Node: 41 """The node the current callback material node is hitting. 42 43 Throws a bascenev1.NodeNotFoundError if the node does not exist. 44 This can be expected in some cases such as in 'disconnect' 45 callbacks triggered by deleting a currently-colliding node. 46 """ 47 node = _bascenev1.get_collision_info('opposingnode') 48 assert isinstance(node, (_bascenev1.Node, type(None))) 49 if not node: 50 raise babase.NodeNotFoundError() 51 return node
The node the current callback material node is hitting.
Throws a bascenev1.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.
53 @property 54 def opposingbody(self) -> int: 55 """The body index on the opposing node in the current collision.""" 56 body = _bascenev1.get_collision_info('opposingbody') 57 assert isinstance(body, int) 58 return body
The body index on the opposing node in the current collision.
128class CollisionMesh: 129 """A reference to a collision-mesh. 130 131 Use bascenev1.getcollisionmesh() to instantiate one. 132 """ 133 134 pass
A reference to a collision-mesh.
Use bascenev1.getcollisionmesh() to instantiate one.
16class ContextError(Exception): 17 """Exception raised when a call is made in an invalid context. 18 19 Examples of this include calling UI functions within an Activity 20 context or calling scene manipulation functions outside of a game 21 context. 22 """
Exception raised when a call is made in an invalid context.
Examples of this include calling UI functions within an Activity context or calling scene manipulation functions outside of a game context.
146class ContextRef: 147 """Store or use a ballistica context. 148 149 Many operations such as bascenev1.newnode() or bascenev1.gettexture() 150 operate implicitly on a current 'context'. A context is some sort of 151 state that functionality can implicitly use. Context determines, for 152 example, which scene nodes or textures get added to without having to 153 specify it explicitly in the newnode()/gettexture() call. Contexts can 154 also affect object lifecycles; for example a babase.ContextCall will 155 become a no-op when the context it was created in is destroyed. 156 157 In general, if you are a modder, you should not need to worry about 158 contexts; mod code should mostly be getting run in the correct 159 context and timers and other callbacks will take care of saving 160 and restoring contexts automatically. There may be rare cases, 161 however, where you need to deal directly with contexts, and that is 162 where this class comes in. 163 164 Creating a babase.ContextRef() will capture a reference to the current 165 context. Other modules may provide ways to access their contexts; for 166 example a bascenev1.Activity instance has a 'context' attribute. You 167 can also use babase.ContextRef.empty() to create a reference to *no* 168 context. Some code such as UI calls may expect this and may complain 169 if you try to use them within a context. 170 171 ##### Usage 172 ContextRefs are generally used with the Python 'with' statement, which 173 sets the context they point to as current on entry and resets it to 174 the previous value on exit. 175 176 ##### Example 177 Explicitly create a few UI bits with no context set. 178 (UI stuff may complain if called within a context): 179 >>> with bui.ContextRef.empty(): 180 ... my_container = bui.containerwidget() 181 """ 182 183 def __init__( 184 self, 185 ) -> None: 186 pass 187 188 def __enter__(self) -> None: 189 """Support for "with" statement.""" 190 pass 191 192 def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> Any: 193 """Support for "with" statement.""" 194 pass 195 196 @classmethod 197 def empty(cls) -> ContextRef: 198 """Return a ContextRef pointing to no context. 199 200 This is useful when code should be run free of a context. 201 For example, UI code generally insists on being run this way. 202 Otherwise, callbacks set on the UI could inadvertently stop working 203 due to a game activity ending, which would be unintuitive behavior. 204 """ 205 return ContextRef() 206 207 def is_empty(self) -> bool: 208 """Whether the context was created as empty.""" 209 return bool() 210 211 def is_expired(self) -> bool: 212 """Whether the context has expired.""" 213 return bool()
Store or use a ballistica context.
Many operations such as bascenev1.newnode() or bascenev1.gettexture() operate implicitly on a current 'context'. A context is some sort of state that functionality can implicitly use. Context determines, for example, which scene nodes or textures get added to without having to specify it explicitly in the newnode()/gettexture() call. Contexts can also affect object lifecycles; for example a babase.ContextCall will become a no-op when the context it was created in is destroyed.
In general, if you are a modder, you should not need to worry about contexts; mod code should mostly be getting run in the correct context and timers and other callbacks will take care of saving and restoring contexts automatically. There may be rare cases, however, where you need to deal directly with contexts, and that is where this class comes in.
Creating a babase.ContextRef() will capture a reference to the current context. Other modules may provide ways to access their contexts; for example a bascenev1.Activity instance has a 'context' attribute. You can also use babase.ContextRef.empty() to create a reference to no context. Some code such as UI calls may expect this and may complain if you try to use them within a context.
Usage
ContextRefs are generally used with the Python 'with' statement, which sets the context they point to as current on entry and resets it to the previous value on exit.
Example
Explicitly create a few UI bits with no context set. (UI stuff may complain if called within a context):
>>> with bui.ContextRef.empty():
... my_container = bui.containerwidget()
196 @classmethod 197 def empty(cls) -> ContextRef: 198 """Return a ContextRef pointing to no context. 199 200 This is useful when code should be run free of a context. 201 For example, UI code generally insists on being run this way. 202 Otherwise, callbacks set on the UI could inadvertently stop working 203 due to a game activity ending, which would be unintuitive behavior. 204 """ 205 return ContextRef()
Return a ContextRef pointing to no context.
This is useful when code should be run free of a context. For example, UI code generally insists on being run this way. Otherwise, callbacks set on the UI could inadvertently stop working due to a game activity ending, which would be unintuitive behavior.
26class CoopGameActivity(GameActivity[PlayerT, TeamT]): 27 """Base class for cooperative-mode games.""" 28 29 # We can assume our session is a CoopSession. 30 session: bascenev1.CoopSession 31 32 @override 33 @classmethod 34 def supports_session_type( 35 cls, sessiontype: type[bascenev1.Session] 36 ) -> bool: 37 from bascenev1._coopsession import CoopSession 38 39 return issubclass(sessiontype, CoopSession) 40 41 def __init__(self, settings: dict): 42 super().__init__(settings) 43 44 # Cache these for efficiency. 45 self._achievements_awarded: set[str] = set() 46 47 self._life_warning_beep: bascenev1.Actor | None = None 48 self._life_warning_beep_timer: bascenev1.Timer | None = None 49 self._warn_beeps_sound = _bascenev1.getsound('warnBeeps') 50 51 @override 52 def on_begin(self) -> None: 53 super().on_begin() 54 55 # Show achievements remaining. 56 env = babase.app.env 57 if not (env.demo or env.arcade): 58 _bascenev1.timer( 59 3.8, babase.WeakCall(self._show_remaining_achievements) 60 ) 61 62 # Preload achievement images in case we get some. 63 _bascenev1.timer(2.0, babase.WeakCall(self._preload_achievements)) 64 65 # FIXME: this is now redundant with activityutils.getscoreconfig(); 66 # need to kill this. 67 def get_score_type(self) -> str: 68 """ 69 Return the score unit this co-op game uses ('point', 'seconds', etc.) 70 """ 71 return 'points' 72 73 def _get_coop_level_name(self) -> str: 74 assert self.session.campaign is not None 75 return self.session.campaign.name + ':' + str(self.settings_raw['name']) 76 77 def celebrate(self, duration: float) -> None: 78 """Tells all existing player-controlled characters to celebrate. 79 80 Can be useful in co-op games when the good guys score or complete 81 a wave. 82 duration is given in seconds. 83 """ 84 from bascenev1._messages import CelebrateMessage 85 86 for player in self.players: 87 if player.actor: 88 player.actor.handlemessage(CelebrateMessage(duration)) 89 90 def _preload_achievements(self) -> None: 91 assert babase.app.classic is not None 92 achievements = babase.app.classic.ach.achievements_for_coop_level( 93 self._get_coop_level_name() 94 ) 95 for ach in achievements: 96 ach.get_icon_texture(True) 97 98 def _show_remaining_achievements(self) -> None: 99 # pylint: disable=cyclic-import 100 from bascenev1lib.actor.text import Text 101 102 assert babase.app.classic is not None 103 ts_h_offs = 30 104 v_offs = -200 105 achievements = [ 106 a 107 for a in babase.app.classic.ach.achievements_for_coop_level( 108 self._get_coop_level_name() 109 ) 110 if not a.complete 111 ] 112 vrmode = babase.app.env.vr 113 if achievements: 114 Text( 115 babase.Lstr(resource='achievementsRemainingText'), 116 host_only=True, 117 position=(ts_h_offs - 10 + 40, v_offs - 10), 118 transition=Text.Transition.FADE_IN, 119 scale=1.1, 120 h_attach=Text.HAttach.LEFT, 121 v_attach=Text.VAttach.TOP, 122 color=(1, 1, 1.2, 1) if vrmode else (0.8, 0.8, 1.0, 1.0), 123 flatness=1.0 if vrmode else 0.6, 124 shadow=1.0 if vrmode else 0.5, 125 transition_delay=0.0, 126 transition_out_delay=1.3 if self.slow_motion else 4.0, 127 ).autoretain() 128 hval = 70 129 vval = -50 130 tdelay = 0.0 131 for ach in achievements: 132 tdelay += 0.05 133 ach.create_display( 134 hval + 40, 135 vval + v_offs, 136 0 + tdelay, 137 outdelay=1.3 if self.slow_motion else 4.0, 138 style='in_game', 139 ) 140 vval -= 55 141 142 @override 143 def spawn_player_spaz( 144 self, 145 player: PlayerT, 146 position: Sequence[float] = (0.0, 0.0, 0.0), 147 angle: float | None = None, 148 ) -> PlayerSpaz: 149 """Spawn and wire up a standard player spaz.""" 150 spaz = super().spawn_player_spaz(player, position, angle) 151 152 # Deaths are noteworthy in co-op games. 153 spaz.play_big_death_sound = True 154 return spaz 155 156 def _award_achievement( 157 self, achievement_name: str, sound: bool = True 158 ) -> None: 159 """Award an achievement. 160 161 Returns True if a banner will be shown; 162 False otherwise 163 """ 164 165 classic = babase.app.classic 166 plus = babase.app.plus 167 if classic is None or plus is None: 168 logging.warning( 169 '_award_achievement is a no-op without classic and plus.' 170 ) 171 return 172 173 if achievement_name in self._achievements_awarded: 174 return 175 176 ach = classic.ach.get_achievement(achievement_name) 177 178 # If we're in the easy campaign and this achievement is hard-mode-only, 179 # ignore it. 180 try: 181 campaign = self.session.campaign 182 assert campaign is not None 183 if ach.hard_mode_only and campaign.name == 'Easy': 184 return 185 except Exception: 186 logging.exception('Error in _award_achievement.') 187 188 # If we haven't awarded this one, check to see if we've got it. 189 # If not, set it through the game service *and* add a transaction 190 # for it. 191 if not ach.complete: 192 self._achievements_awarded.add(achievement_name) 193 194 # Report new achievements to the game-service. 195 plus.report_achievement(achievement_name) 196 197 # ...and to our account. 198 plus.add_v1_account_transaction( 199 {'type': 'ACHIEVEMENT', 'name': achievement_name} 200 ) 201 202 # Now bring up a celebration banner. 203 ach.announce_completion(sound=sound) 204 205 def fade_to_red(self) -> None: 206 """Fade the screen to red; (such as when the good guys have lost).""" 207 from bascenev1 import _gameutils 208 209 c_existing = self.globalsnode.tint 210 cnode = _bascenev1.newnode( 211 'combine', 212 attrs={ 213 'input0': c_existing[0], 214 'input1': c_existing[1], 215 'input2': c_existing[2], 216 'size': 3, 217 }, 218 ) 219 _gameutils.animate(cnode, 'input1', {0: c_existing[1], 2.0: 0}) 220 _gameutils.animate(cnode, 'input2', {0: c_existing[2], 2.0: 0}) 221 cnode.connectattr('output', self.globalsnode, 'tint') 222 223 def setup_low_life_warning_sound(self) -> None: 224 """Set up a beeping noise to play when any players are near death.""" 225 self._life_warning_beep = None 226 self._life_warning_beep_timer = _bascenev1.Timer( 227 1.0, babase.WeakCall(self._update_life_warning), repeat=True 228 ) 229 230 def _update_life_warning(self) -> None: 231 # Beep continuously if anyone is close to death. 232 should_beep = False 233 for player in self.players: 234 if player.is_alive(): 235 # FIXME: Should abstract this instead of 236 # reading hitpoints directly. 237 if getattr(player.actor, 'hitpoints', 999) < 200: 238 should_beep = True 239 break 240 if should_beep and self._life_warning_beep is None: 241 from bascenev1._nodeactor import NodeActor 242 243 self._life_warning_beep = NodeActor( 244 _bascenev1.newnode( 245 'sound', 246 attrs={ 247 'sound': self._warn_beeps_sound, 248 'positional': False, 249 'loop': True, 250 }, 251 ) 252 ) 253 if self._life_warning_beep is not None and not should_beep: 254 self._life_warning_beep = None
Base class for cooperative-mode games.
41 def __init__(self, settings: dict): 42 super().__init__(settings) 43 44 # Cache these for efficiency. 45 self._achievements_awarded: set[str] = set() 46 47 self._life_warning_beep: bascenev1.Actor | None = None 48 self._life_warning_beep_timer: bascenev1.Timer | None = None 49 self._warn_beeps_sound = _bascenev1.getsound('warnBeeps')
Instantiate the Activity.
329 @property 330 def session(self) -> bascenev1.Session: 331 """The bascenev1.Session this bascenev1.Activity belongs to. 332 333 Raises a :class:`~bascenev1.SessionNotFoundError` if the Session 334 no longer exists. 335 """ 336 session = self._session() 337 if session is None: 338 raise babase.SessionNotFoundError() 339 return session
The bascenev1.Session this bascenev1.Activity belongs to.
Raises a ~bascenev1.SessionNotFoundError
if the Session
no longer exists.
32 @override 33 @classmethod 34 def supports_session_type( 35 cls, sessiontype: type[bascenev1.Session] 36 ) -> bool: 37 from bascenev1._coopsession import CoopSession 38 39 return issubclass(sessiontype, CoopSession)
Return whether this game supports the provided Session type.
51 @override 52 def on_begin(self) -> None: 53 super().on_begin() 54 55 # Show achievements remaining. 56 env = babase.app.env 57 if not (env.demo or env.arcade): 58 _bascenev1.timer( 59 3.8, babase.WeakCall(self._show_remaining_achievements) 60 ) 61 62 # Preload achievement images in case we get some. 63 _bascenev1.timer(2.0, babase.WeakCall(self._preload_achievements))
Called once the previous 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.
67 def get_score_type(self) -> str: 68 """ 69 Return the score unit this co-op game uses ('point', 'seconds', etc.) 70 """ 71 return 'points'
Return the score unit this co-op game uses ('point', 'seconds', etc.)
77 def celebrate(self, duration: float) -> None: 78 """Tells all existing player-controlled characters to celebrate. 79 80 Can be useful in co-op games when the good guys score or complete 81 a wave. 82 duration is given in seconds. 83 """ 84 from bascenev1._messages import CelebrateMessage 85 86 for player in self.players: 87 if player.actor: 88 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.
142 @override 143 def spawn_player_spaz( 144 self, 145 player: PlayerT, 146 position: Sequence[float] = (0.0, 0.0, 0.0), 147 angle: float | None = None, 148 ) -> PlayerSpaz: 149 """Spawn and wire up a standard player spaz.""" 150 spaz = super().spawn_player_spaz(player, position, angle) 151 152 # Deaths are noteworthy in co-op games. 153 spaz.play_big_death_sound = True 154 return spaz
Spawn and wire up a standard player spaz.
205 def fade_to_red(self) -> None: 206 """Fade the screen to red; (such as when the good guys have lost).""" 207 from bascenev1 import _gameutils 208 209 c_existing = self.globalsnode.tint 210 cnode = _bascenev1.newnode( 211 'combine', 212 attrs={ 213 'input0': c_existing[0], 214 'input1': c_existing[1], 215 'input2': c_existing[2], 216 'size': 3, 217 }, 218 ) 219 _gameutils.animate(cnode, 'input1', {0: c_existing[1], 2.0: 0}) 220 _gameutils.animate(cnode, 'input2', {0: c_existing[2], 2.0: 0}) 221 cnode.connectattr('output', self.globalsnode, 'tint')
Fade the screen to red; (such as when the good guys have lost).
223 def setup_low_life_warning_sound(self) -> None: 224 """Set up a beeping noise to play when any players are near death.""" 225 self._life_warning_beep = None 226 self._life_warning_beep_timer = _bascenev1.Timer( 227 1.0, babase.WeakCall(self._update_life_warning), repeat=True 228 )
Set up a beeping noise to play when any players are near death.
23class CoopSession(Session): 24 """A bascenev1.Session which runs cooperative-mode games. 25 26 These generally consist of 1-4 players against 27 the computer and include functionality such as 28 high score lists. 29 """ 30 31 use_teams = True 32 use_team_colors = False 33 allow_mid_activity_joins = False 34 35 # Note: even though these are instance vars, we annotate them at the 36 # class level so that docs generation can access their types. 37 38 campaign: bascenev1.Campaign | None 39 """The baclassic.Campaign instance this Session represents, or None if 40 there is no associated Campaign.""" 41 42 def __init__(self) -> None: 43 """Instantiate a co-op mode session.""" 44 # pylint: disable=cyclic-import 45 from bascenev1lib.activity.coopjoin import CoopJoinActivity 46 47 babase.increment_analytics_count('Co-op session start') 48 app = babase.app 49 classic = app.classic 50 assert classic is not None 51 52 # If they passed in explicit min/max, honor that. 53 # Otherwise defer to user overrides or defaults. 54 if 'min_players' in classic.coop_session_args: 55 min_players = classic.coop_session_args['min_players'] 56 else: 57 min_players = 1 58 if 'max_players' in classic.coop_session_args: 59 max_players = classic.coop_session_args['max_players'] 60 else: 61 max_players = app.config.get('Coop Game Max Players', 4) 62 if 'submit_score' in classic.coop_session_args: 63 submit_score = classic.coop_session_args['submit_score'] 64 else: 65 submit_score = True 66 67 # print('FIXME: COOP SESSION WOULD CALC DEPS.') 68 depsets: Sequence[bascenev1.DependencySet] = [] 69 70 super().__init__( 71 depsets, 72 team_names=TEAM_NAMES, 73 team_colors=TEAM_COLORS, 74 min_players=min_players, 75 max_players=max_players, 76 submit_score=submit_score, 77 ) 78 79 # Tournament-ID if we correspond to a co-op tournament (otherwise None) 80 self.tournament_id: str | None = classic.coop_session_args.get( 81 'tournament_id' 82 ) 83 84 self.campaign = classic.getcampaign( 85 classic.coop_session_args['campaign'] 86 ) 87 self.campaign_level_name: str = classic.coop_session_args['level'] 88 89 self._ran_tutorial_activity = False 90 self._tutorial_activity: bascenev1.Activity | None = None 91 self._custom_menu_ui: list[dict[str, Any]] = [] 92 93 # Start our joining screen. 94 self.setactivity(_bascenev1.newactivity(CoopJoinActivity)) 95 96 self._next_game_instance: bascenev1.GameActivity | None = None 97 self._next_game_level_name: str | None = None 98 self._update_on_deck_game_instances() 99 100 def get_current_game_instance(self) -> bascenev1.GameActivity: 101 """Get the game instance currently being played.""" 102 return self._current_game_instance 103 104 @override 105 def should_allow_mid_activity_joins( 106 self, activity: bascenev1.Activity 107 ) -> bool: 108 # pylint: disable=cyclic-import 109 from bascenev1._gameactivity import GameActivity 110 111 # Disallow any joins in the middle of the game. 112 if isinstance(activity, GameActivity): 113 return False 114 115 return True 116 117 def _update_on_deck_game_instances(self) -> None: 118 # pylint: disable=cyclic-import 119 from bascenev1._gameactivity import GameActivity 120 121 classic = babase.app.classic 122 assert classic is not None 123 124 # Instantiate levels we may be running soon to let them load in the bg. 125 126 # Build an instance for the current level. 127 assert self.campaign is not None 128 level = self.campaign.getlevel(self.campaign_level_name) 129 gametype = level.gametype 130 settings = level.get_settings() 131 132 # Make sure all settings the game expects are present. 133 neededsettings = gametype.get_available_settings(type(self)) 134 for setting in neededsettings: 135 if setting.name not in settings: 136 settings[setting.name] = setting.default 137 138 newactivity = _bascenev1.newactivity(gametype, settings) 139 assert isinstance(newactivity, GameActivity) 140 self._current_game_instance: GameActivity = newactivity 141 142 # Find the next level and build an instance for it too. 143 levels = self.campaign.levels 144 level = self.campaign.getlevel(self.campaign_level_name) 145 146 nextlevel: bascenev1.Level | None 147 if level.index < len(levels) - 1: 148 nextlevel = levels[level.index + 1] 149 else: 150 nextlevel = None 151 if nextlevel: 152 gametype = nextlevel.gametype 153 settings = nextlevel.get_settings() 154 155 # Make sure all settings the game expects are present. 156 neededsettings = gametype.get_available_settings(type(self)) 157 for setting in neededsettings: 158 if setting.name not in settings: 159 settings[setting.name] = setting.default 160 161 # We wanna be in the activity's context while taking it down. 162 newactivity = _bascenev1.newactivity(gametype, settings) 163 assert isinstance(newactivity, GameActivity) 164 self._next_game_instance = newactivity 165 self._next_game_level_name = nextlevel.name 166 else: 167 self._next_game_instance = None 168 self._next_game_level_name = None 169 170 # Special case: 171 # If our current level is 'onslaught training', instantiate 172 # our tutorial so its ready to go. (if we haven't run it yet). 173 if ( 174 self.campaign_level_name == 'Onslaught Training' 175 and self._tutorial_activity is None 176 and not self._ran_tutorial_activity 177 ): 178 from bascenev1lib.tutorial import TutorialActivity 179 180 self._tutorial_activity = _bascenev1.newactivity(TutorialActivity) 181 182 @override 183 def get_custom_menu_entries(self) -> list[dict[str, Any]]: 184 return self._custom_menu_ui 185 186 @override 187 def on_player_leave(self, sessionplayer: bascenev1.SessionPlayer) -> None: 188 super().on_player_leave(sessionplayer) 189 190 _bascenev1.timer(2.0, babase.WeakCall(self._handle_empty_activity)) 191 192 def _handle_empty_activity(self) -> None: 193 """Handle cases where all players have left the current activity.""" 194 195 from bascenev1._gameactivity import GameActivity 196 197 activity = self.getactivity() 198 if activity is None: 199 return # Hmm what should we do in this case? 200 201 # If there are still players in the current activity, we're good. 202 if activity.players: 203 return 204 205 # If there are *not* players in the current activity but there 206 # *are* in the session: 207 if not activity.players and self.sessionplayers: 208 # If we're in a game, we should restart to pull in players 209 # currently waiting in the session. 210 if isinstance(activity, GameActivity): 211 # Never restart tourney games however; just end the session 212 # if all players are gone. 213 if self.tournament_id is not None: 214 self.end() 215 else: 216 self.restart() 217 218 # Hmm; no players anywhere. Let's end the entire session if we're 219 # running a GUI (or just the current game if we're running headless). 220 else: 221 if babase.app.env.gui: 222 self.end() 223 else: 224 if isinstance(activity, GameActivity): 225 with activity.context: 226 activity.end_game() 227 228 def _on_tournament_restart_menu_press( 229 self, resume_callback: Callable[[], Any] 230 ) -> None: 231 # pylint: disable=cyclic-import 232 from bascenev1._gameactivity import GameActivity 233 234 assert babase.app.classic is not None 235 activity = self.getactivity() 236 if activity is not None and not activity.expired: 237 assert self.tournament_id is not None 238 assert isinstance(activity, GameActivity) 239 babase.app.classic.tournament_entry_window( 240 tournament_id=self.tournament_id, 241 tournament_activity=activity, 242 on_close_call=resume_callback, 243 ) 244 245 def restart(self) -> None: 246 """Restart the current game activity.""" 247 248 # Tell the current activity to end with a 'restart' outcome. 249 # We use 'force' so that we apply even if end has already been called 250 # (but is in its delay period). 251 252 # Make an exception if there's no players left. Otherwise this 253 # can override the default session end that occurs in that case. 254 if not self.sessionplayers: 255 return 256 257 # This method may get called from the UI context so make sure we 258 # explicitly run in the activity's context. 259 activity = self.getactivity() 260 if activity is not None and not activity.expired: 261 activity.can_show_ad_on_death = True 262 with activity.context: 263 activity.end(results={'outcome': 'restart'}, force=True) 264 265 # noinspection PyUnresolvedReferences 266 @override 267 def on_activity_end( 268 self, activity: bascenev1.Activity, results: Any 269 ) -> None: 270 """Method override for co-op sessions. 271 272 Jumps between co-op games and score screens. 273 """ 274 # pylint: disable=too-many-branches 275 # pylint: disable=too-many-locals 276 # pylint: disable=too-many-statements 277 # pylint: disable=cyclic-import 278 from bascenev1lib.activity.coopscore import CoopScoreScreen 279 from bascenev1lib.tutorial import TutorialActivity 280 281 from bascenev1._gameresults import GameResults 282 from bascenev1._player import PlayerInfo 283 from bascenev1._activitytypes import JoinActivity, TransitionActivity 284 from bascenev1._coopgame import CoopGameActivity 285 from bascenev1._score import ScoreType 286 287 app = babase.app 288 env = app.env 289 classic = app.classic 290 assert classic is not None 291 292 # If we're running a TeamGameActivity we'll have a GameResults 293 # as results. Otherwise its an old CoopGameActivity so its giving 294 # us a dict of random stuff. 295 if isinstance(results, GameResults): 296 outcome = 'defeat' # This can't be 'beaten'. 297 else: 298 outcome = '' if results is None else results.get('outcome', '') 299 300 # If we're running with a gui and at any point we have no 301 # in-game players, quit out of the session (this can happen if 302 # someone leaves in the tutorial for instance). 303 if env.gui: 304 active_players = [p for p in self.sessionplayers if p.in_game] 305 if not active_players: 306 self.end() 307 return 308 309 # If we're in a between-round activity or a restart-activity, 310 # hop into a round. 311 if isinstance( 312 activity, (JoinActivity, CoopScoreScreen, TransitionActivity) 313 ): 314 if outcome == 'next_level': 315 if self._next_game_instance is None: 316 raise RuntimeError() 317 assert self._next_game_level_name is not None 318 self.campaign_level_name = self._next_game_level_name 319 next_game = self._next_game_instance 320 else: 321 next_game = self._current_game_instance 322 323 # Special case: if we're coming from a joining-activity 324 # and will be going into onslaught-training, show the 325 # tutorial first. 326 if ( 327 isinstance(activity, JoinActivity) 328 and self.campaign_level_name == 'Onslaught Training' 329 and not (env.demo or env.arcade) 330 ): 331 if self._tutorial_activity is None: 332 raise RuntimeError('Tutorial not preloaded properly.') 333 self.setactivity(self._tutorial_activity) 334 self._tutorial_activity = None 335 self._ran_tutorial_activity = True 336 self._custom_menu_ui = [] 337 338 # Normal case; launch the next round. 339 else: 340 # Reset stats for the new activity. 341 self.stats.reset() 342 for player in self.sessionplayers: 343 # Skip players that are still choosing a team. 344 if player.in_game: 345 self.stats.register_sessionplayer(player) 346 self.stats.setactivity(next_game) 347 348 # Now flip the current activity.. 349 self.setactivity(next_game) 350 351 if not (env.demo or env.arcade): 352 if ( 353 self.tournament_id is not None 354 and classic.coop_session_args['submit_score'] 355 ): 356 self._custom_menu_ui = [ 357 { 358 'label': babase.Lstr(resource='restartText'), 359 'resume_on_call': False, 360 'call': babase.WeakCall( 361 self._on_tournament_restart_menu_press 362 ), 363 } 364 ] 365 else: 366 self._custom_menu_ui = [ 367 { 368 'label': babase.Lstr(resource='restartText'), 369 'call': babase.WeakCall(self.restart), 370 } 371 ] 372 373 # If we were in a tutorial, just pop a transition to get to the 374 # actual round. 375 elif isinstance(activity, TutorialActivity): 376 self.setactivity(_bascenev1.newactivity(TransitionActivity)) 377 else: 378 playerinfos: list[bascenev1.PlayerInfo] 379 380 # Generic team games. 381 if isinstance(results, GameResults): 382 playerinfos = results.playerinfos 383 score = results.get_sessionteam_score(results.sessionteams[0]) 384 fail_message = None 385 score_order = ( 386 'decreasing' if results.lower_is_better else 'increasing' 387 ) 388 if results.scoretype in ( 389 ScoreType.SECONDS, 390 ScoreType.MILLISECONDS, 391 ): 392 scoretype = 'time' 393 394 # ScoreScreen wants hundredths of a second. 395 if score is not None: 396 if results.scoretype is ScoreType.SECONDS: 397 score *= 100 398 elif results.scoretype is ScoreType.MILLISECONDS: 399 score //= 10 400 else: 401 raise RuntimeError('FIXME') 402 else: 403 if results.scoretype is not ScoreType.POINTS: 404 print(f'Unknown ScoreType:' f' "{results.scoretype}"') 405 scoretype = 'points' 406 407 # Old coop-game-specific results; should migrate away from these. 408 else: 409 playerinfos = results.get('playerinfos') 410 score = results['score'] if 'score' in results else None 411 fail_message = ( 412 results['fail_message'] 413 if 'fail_message' in results 414 else None 415 ) 416 score_order = ( 417 results['score_order'] 418 if 'score_order' in results 419 else 'increasing' 420 ) 421 activity_score_type = ( 422 activity.get_score_type() 423 if isinstance(activity, CoopGameActivity) 424 else None 425 ) 426 assert activity_score_type is not None 427 scoretype = activity_score_type 428 429 # Validate types. 430 if playerinfos is not None: 431 assert isinstance(playerinfos, list) 432 assert all(isinstance(i, PlayerInfo) for i in playerinfos) 433 434 # Looks like we were in a round - check the outcome and 435 # go from there. 436 if outcome == 'restart': 437 # This will pop up back in the same round. 438 self.setactivity(_bascenev1.newactivity(TransitionActivity)) 439 else: 440 self.setactivity( 441 _bascenev1.newactivity( 442 CoopScoreScreen, 443 { 444 'playerinfos': playerinfos, 445 'score': score, 446 'fail_message': fail_message, 447 'score_order': score_order, 448 'score_type': scoretype, 449 'outcome': outcome, 450 'campaign': self.campaign, 451 'level': self.campaign_level_name, 452 }, 453 ) 454 ) 455 456 # No matter what, get the next 2 levels ready to go. 457 self._update_on_deck_game_instances()
A bascenev1.Session which runs cooperative-mode games.
These generally consist of 1-4 players against the computer and include functionality such as high score lists.
42 def __init__(self) -> None: 43 """Instantiate a co-op mode session.""" 44 # pylint: disable=cyclic-import 45 from bascenev1lib.activity.coopjoin import CoopJoinActivity 46 47 babase.increment_analytics_count('Co-op session start') 48 app = babase.app 49 classic = app.classic 50 assert classic is not None 51 52 # If they passed in explicit min/max, honor that. 53 # Otherwise defer to user overrides or defaults. 54 if 'min_players' in classic.coop_session_args: 55 min_players = classic.coop_session_args['min_players'] 56 else: 57 min_players = 1 58 if 'max_players' in classic.coop_session_args: 59 max_players = classic.coop_session_args['max_players'] 60 else: 61 max_players = app.config.get('Coop Game Max Players', 4) 62 if 'submit_score' in classic.coop_session_args: 63 submit_score = classic.coop_session_args['submit_score'] 64 else: 65 submit_score = True 66 67 # print('FIXME: COOP SESSION WOULD CALC DEPS.') 68 depsets: Sequence[bascenev1.DependencySet] = [] 69 70 super().__init__( 71 depsets, 72 team_names=TEAM_NAMES, 73 team_colors=TEAM_COLORS, 74 min_players=min_players, 75 max_players=max_players, 76 submit_score=submit_score, 77 ) 78 79 # Tournament-ID if we correspond to a co-op tournament (otherwise None) 80 self.tournament_id: str | None = classic.coop_session_args.get( 81 'tournament_id' 82 ) 83 84 self.campaign = classic.getcampaign( 85 classic.coop_session_args['campaign'] 86 ) 87 self.campaign_level_name: str = classic.coop_session_args['level'] 88 89 self._ran_tutorial_activity = False 90 self._tutorial_activity: bascenev1.Activity | None = None 91 self._custom_menu_ui: list[dict[str, Any]] = [] 92 93 # Start our joining screen. 94 self.setactivity(_bascenev1.newactivity(CoopJoinActivity)) 95 96 self._next_game_instance: bascenev1.GameActivity | None = None 97 self._next_game_level_name: str | None = None 98 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 baclassic.Campaign instance this Session represents, or None if there is no associated Campaign.
100 def get_current_game_instance(self) -> bascenev1.GameActivity: 101 """Get the game instance currently being played.""" 102 return self._current_game_instance
Get the game instance currently being played.
104 @override 105 def should_allow_mid_activity_joins( 106 self, activity: bascenev1.Activity 107 ) -> bool: 108 # pylint: disable=cyclic-import 109 from bascenev1._gameactivity import GameActivity 110 111 # Disallow any joins in the middle of the game. 112 if isinstance(activity, GameActivity): 113 return False 114 115 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.
186 @override 187 def on_player_leave(self, sessionplayer: bascenev1.SessionPlayer) -> None: 188 super().on_player_leave(sessionplayer) 189 190 _bascenev1.timer(2.0, babase.WeakCall(self._handle_empty_activity))
Called when a previously-accepted bascenev1.SessionPlayer leaves.
245 def restart(self) -> None: 246 """Restart the current game activity.""" 247 248 # Tell the current activity to end with a 'restart' outcome. 249 # We use 'force' so that we apply even if end has already been called 250 # (but is in its delay period). 251 252 # Make an exception if there's no players left. Otherwise this 253 # can override the default session end that occurs in that case. 254 if not self.sessionplayers: 255 return 256 257 # This method may get called from the UI context so make sure we 258 # explicitly run in the activity's context. 259 activity = self.getactivity() 260 if activity is not None and not activity.expired: 261 activity.can_show_ad_on_death = True 262 with activity.context: 263 activity.end(results={'outcome': 'restart'}, force=True)
Restart the current game activity.
266 @override 267 def on_activity_end( 268 self, activity: bascenev1.Activity, results: Any 269 ) -> None: 270 """Method override for co-op sessions. 271 272 Jumps between co-op games and score screens. 273 """ 274 # pylint: disable=too-many-branches 275 # pylint: disable=too-many-locals 276 # pylint: disable=too-many-statements 277 # pylint: disable=cyclic-import 278 from bascenev1lib.activity.coopscore import CoopScoreScreen 279 from bascenev1lib.tutorial import TutorialActivity 280 281 from bascenev1._gameresults import GameResults 282 from bascenev1._player import PlayerInfo 283 from bascenev1._activitytypes import JoinActivity, TransitionActivity 284 from bascenev1._coopgame import CoopGameActivity 285 from bascenev1._score import ScoreType 286 287 app = babase.app 288 env = app.env 289 classic = app.classic 290 assert classic is not None 291 292 # If we're running a TeamGameActivity we'll have a GameResults 293 # as results. Otherwise its an old CoopGameActivity so its giving 294 # us a dict of random stuff. 295 if isinstance(results, GameResults): 296 outcome = 'defeat' # This can't be 'beaten'. 297 else: 298 outcome = '' if results is None else results.get('outcome', '') 299 300 # If we're running with a gui and at any point we have no 301 # in-game players, quit out of the session (this can happen if 302 # someone leaves in the tutorial for instance). 303 if env.gui: 304 active_players = [p for p in self.sessionplayers if p.in_game] 305 if not active_players: 306 self.end() 307 return 308 309 # If we're in a between-round activity or a restart-activity, 310 # hop into a round. 311 if isinstance( 312 activity, (JoinActivity, CoopScoreScreen, TransitionActivity) 313 ): 314 if outcome == 'next_level': 315 if self._next_game_instance is None: 316 raise RuntimeError() 317 assert self._next_game_level_name is not None 318 self.campaign_level_name = self._next_game_level_name 319 next_game = self._next_game_instance 320 else: 321 next_game = self._current_game_instance 322 323 # Special case: if we're coming from a joining-activity 324 # and will be going into onslaught-training, show the 325 # tutorial first. 326 if ( 327 isinstance(activity, JoinActivity) 328 and self.campaign_level_name == 'Onslaught Training' 329 and not (env.demo or env.arcade) 330 ): 331 if self._tutorial_activity is None: 332 raise RuntimeError('Tutorial not preloaded properly.') 333 self.setactivity(self._tutorial_activity) 334 self._tutorial_activity = None 335 self._ran_tutorial_activity = True 336 self._custom_menu_ui = [] 337 338 # Normal case; launch the next round. 339 else: 340 # Reset stats for the new activity. 341 self.stats.reset() 342 for player in self.sessionplayers: 343 # Skip players that are still choosing a team. 344 if player.in_game: 345 self.stats.register_sessionplayer(player) 346 self.stats.setactivity(next_game) 347 348 # Now flip the current activity.. 349 self.setactivity(next_game) 350 351 if not (env.demo or env.arcade): 352 if ( 353 self.tournament_id is not None 354 and classic.coop_session_args['submit_score'] 355 ): 356 self._custom_menu_ui = [ 357 { 358 'label': babase.Lstr(resource='restartText'), 359 'resume_on_call': False, 360 'call': babase.WeakCall( 361 self._on_tournament_restart_menu_press 362 ), 363 } 364 ] 365 else: 366 self._custom_menu_ui = [ 367 { 368 'label': babase.Lstr(resource='restartText'), 369 'call': babase.WeakCall(self.restart), 370 } 371 ] 372 373 # If we were in a tutorial, just pop a transition to get to the 374 # actual round. 375 elif isinstance(activity, TutorialActivity): 376 self.setactivity(_bascenev1.newactivity(TransitionActivity)) 377 else: 378 playerinfos: list[bascenev1.PlayerInfo] 379 380 # Generic team games. 381 if isinstance(results, GameResults): 382 playerinfos = results.playerinfos 383 score = results.get_sessionteam_score(results.sessionteams[0]) 384 fail_message = None 385 score_order = ( 386 'decreasing' if results.lower_is_better else 'increasing' 387 ) 388 if results.scoretype in ( 389 ScoreType.SECONDS, 390 ScoreType.MILLISECONDS, 391 ): 392 scoretype = 'time' 393 394 # ScoreScreen wants hundredths of a second. 395 if score is not None: 396 if results.scoretype is ScoreType.SECONDS: 397 score *= 100 398 elif results.scoretype is ScoreType.MILLISECONDS: 399 score //= 10 400 else: 401 raise RuntimeError('FIXME') 402 else: 403 if results.scoretype is not ScoreType.POINTS: 404 print(f'Unknown ScoreType:' f' "{results.scoretype}"') 405 scoretype = 'points' 406 407 # Old coop-game-specific results; should migrate away from these. 408 else: 409 playerinfos = results.get('playerinfos') 410 score = results['score'] if 'score' in results else None 411 fail_message = ( 412 results['fail_message'] 413 if 'fail_message' in results 414 else None 415 ) 416 score_order = ( 417 results['score_order'] 418 if 'score_order' in results 419 else 'increasing' 420 ) 421 activity_score_type = ( 422 activity.get_score_type() 423 if isinstance(activity, CoopGameActivity) 424 else None 425 ) 426 assert activity_score_type is not None 427 scoretype = activity_score_type 428 429 # Validate types. 430 if playerinfos is not None: 431 assert isinstance(playerinfos, list) 432 assert all(isinstance(i, PlayerInfo) for i in playerinfos) 433 434 # Looks like we were in a round - check the outcome and 435 # go from there. 436 if outcome == 'restart': 437 # This will pop up back in the same round. 438 self.setactivity(_bascenev1.newactivity(TransitionActivity)) 439 else: 440 self.setactivity( 441 _bascenev1.newactivity( 442 CoopScoreScreen, 443 { 444 'playerinfos': playerinfos, 445 'score': score, 446 'fail_message': fail_message, 447 'score_order': score_order, 448 'score_type': scoretype, 449 'outcome': outcome, 450 'campaign': self.campaign, 451 'level': self.campaign_level_name, 452 }, 453 ) 454 ) 455 456 # No matter what, get the next 2 levels ready to go. 457 self._update_on_deck_game_instances()
Method override for co-op sessions.
Jumps between co-op games and score screens.
137class Data: 138 """A reference to a data object. 139 140 Use bascenev1.getdata() to instantiate one. 141 """ 142 143 def getvalue(self) -> Any: 144 """Return the data object's value. 145 146 This can consist of anything representable by json (dicts, lists, 147 numbers, bools, None, etc). 148 Note that this call will block if the data has not yet been loaded, 149 so it can be beneficial to plan a short bit of time between when 150 the data object is requested and when it's value is accessed. 151 """ 152 return _uninferrable()
A reference to a data object.
Use bascenev1.getdata() to instantiate one.
143 def getvalue(self) -> Any: 144 """Return the data object's value. 145 146 This can consist of anything representable by json (dicts, lists, 147 numbers, bools, None, etc). 148 Note that this call will block if the data has not yet been loaded, 149 so it can be beneficial to plan a short bit of time between when 150 the data object is requested and when it's value is accessed. 151 """ 152 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.
35class DeathType(Enum): 36 """A reason for a death.""" 37 38 GENERIC = 'generic' 39 OUT_OF_BOUNDS = 'out_of_bounds' 40 IMPACT = 'impact' 41 FALL = 'fall' 42 REACHED_GOAL = 'reached_goal' 43 LEFT_GAME = 'left_game'
A reason for a death.
23class Dependency(Generic[T]): 24 """A dependency on a DependencyComponent (with an optional config). 25 26 This class is used to request and access functionality provided 27 by other DependencyComponent classes from a DependencyComponent class. 28 The class functions as a descriptor, allowing dependencies to 29 be added at a class level much the same as properties or methods 30 and then used with class instances to access those dependencies. 31 For instance, if you do 'floofcls = bascenev1.Dependency(FloofClass)' 32 you would then be able to instantiate a FloofClass in your class's 33 methods via self.floofcls(). 34 """ 35 36 def __init__(self, cls: type[T], config: Any = None): 37 """Instantiate a Dependency given a bascenev1.DependencyComponent type. 38 39 Optionally, an arbitrary object can be passed as 'config' to 40 influence dependency calculation for the target class. 41 """ 42 self.cls: type[T] = cls 43 self.config = config 44 self._hash: int | None = None 45 46 def get_hash(self) -> int: 47 """Return the dependency's hash, calculating it if necessary.""" 48 from efro.util import make_hash 49 50 if self._hash is None: 51 self._hash = make_hash((self.cls, self.config)) 52 return self._hash 53 54 def __get__(self, obj: Any, cls: Any = None) -> T: 55 if not isinstance(obj, DependencyComponent): 56 if obj is None: 57 raise TypeError( 58 'Dependency must be accessed through an instance.' 59 ) 60 raise TypeError( 61 f'Dependency cannot be added to class of type {type(obj)}' 62 ' (class must inherit from bascenev1.DependencyComponent).' 63 ) 64 65 # We expect to be instantiated from an already living 66 # DependencyComponent with valid dep-data in place.. 67 assert cls is not None 68 69 # Get the DependencyEntry this instance is associated with and from 70 # there get back to the DependencySet 71 entry = getattr(obj, '_dep_entry') 72 if entry is None: 73 raise RuntimeError('Invalid dependency access.') 74 entry = entry() 75 assert isinstance(entry, DependencyEntry) 76 depset = entry.depset() 77 assert isinstance(depset, DependencySet) 78 79 if not depset.resolved: 80 raise RuntimeError( 81 "Can't access data on an unresolved DependencySet." 82 ) 83 84 # Look up the data in the set based on the hash for this Dependency. 85 assert self._hash in depset.entries 86 entry = depset.entries[self._hash] 87 assert isinstance(entry, DependencyEntry) 88 retval = entry.get_component() 89 assert isinstance(retval, self.cls) 90 return retval
A dependency on a DependencyComponent (with an optional config).
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 = bascenev1.Dependency(FloofClass)' you would then be able to instantiate a FloofClass in your class's methods via self.floofcls().
36 def __init__(self, cls: type[T], config: Any = None): 37 """Instantiate a Dependency given a bascenev1.DependencyComponent type. 38 39 Optionally, an arbitrary object can be passed as 'config' to 40 influence dependency calculation for the target class. 41 """ 42 self.cls: type[T] = cls 43 self.config = config 44 self._hash: int | None = None
Instantiate a Dependency given a bascenev1.DependencyComponent type.
Optionally, an arbitrary object can be passed as 'config' to influence dependency calculation for the target class.
46 def get_hash(self) -> int: 47 """Return the dependency's hash, calculating it if necessary.""" 48 from efro.util import make_hash 49 50 if self._hash is None: 51 self._hash = make_hash((self.cls, self.config)) 52 return self._hash
Return the dependency's hash, calculating it if necessary.
93class DependencyComponent: 94 """Base class for all classes that can act as or use dependencies.""" 95 96 _dep_entry: weakref.ref[DependencyEntry] 97 98 def __init__(self) -> None: 99 """Instantiate a DependencyComponent.""" 100 101 # For now lets issue a warning if these are instantiated without 102 # a dep-entry; we'll make this an error once we're no longer 103 # seeing warnings. 104 # entry = getattr(self, '_dep_entry', None) 105 # if entry is None: 106 # print(f'FIXME: INSTANTIATING DEP CLASS {type(self)} DIRECTLY.') 107 108 @classmethod 109 def dep_is_present(cls, config: Any = None) -> bool: 110 """Return whether this component/config is present on this device.""" 111 del config # Unused here. 112 return True 113 114 @classmethod 115 def get_dynamic_deps(cls, config: Any = None) -> list[Dependency]: 116 """Return any dynamically-calculated deps for this component/config. 117 118 Deps declared statically as part of the class do not need to be 119 included here; this is only for additional deps that may vary based 120 on the dep config value. (for instance a map required by a game type) 121 """ 122 del config # Unused here. 123 return []
Base class for all classes that can act as or use dependencies.
98 def __init__(self) -> None: 99 """Instantiate a DependencyComponent.""" 100 101 # For now lets issue a warning if these are instantiated without 102 # a dep-entry; we'll make this an error once we're no longer 103 # seeing warnings. 104 # entry = getattr(self, '_dep_entry', None) 105 # if entry is None: 106 # print(f'FIXME: INSTANTIATING DEP CLASS {type(self)} DIRECTLY.')
Instantiate a DependencyComponent.
108 @classmethod 109 def dep_is_present(cls, config: Any = None) -> bool: 110 """Return whether this component/config is present on this device.""" 111 del config # Unused here. 112 return True
Return whether this component/config is present on this device.
114 @classmethod 115 def get_dynamic_deps(cls, config: Any = None) -> list[Dependency]: 116 """Return any dynamically-calculated deps for this component/config. 117 118 Deps declared statically as part of the class do not need to be 119 included here; this is only for additional deps that may vary based 120 on the dep config value. (for instance a map required by a game type) 121 """ 122 del config # Unused here. 123 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)
169class DependencySet(Generic[T]): 170 """Set of resolved dependencies and their associated data. 171 172 To use DependencyComponents, a set must be created, resolved, and then 173 loaded. The DependencyComponents are only valid while the set remains 174 in existence. 175 """ 176 177 def __init__(self, root_dependency: Dependency[T]): 178 # print('DepSet()') 179 self._root_dependency = root_dependency 180 self._resolved = False 181 self._loaded = False 182 183 # Dependency data indexed by hash. 184 self.entries: dict[int, DependencyEntry] = {} 185 186 # def __del__(self) -> None: 187 # print("~DepSet()") 188 189 def resolve(self) -> None: 190 """Resolve the complete set of required dependencies for this set. 191 192 Raises a bascenev1.DependencyError if dependencies are missing (or 193 other Exception types on other errors). 194 """ 195 196 if self._resolved: 197 raise RuntimeError('DependencySet has already been resolved.') 198 199 # print('RESOLVING DEP SET') 200 201 # First, recursively expand out all dependencies. 202 self._resolve(self._root_dependency, 0) 203 204 # Now, if any dependencies are not present, raise an Exception 205 # telling exactly which ones (so hopefully they'll be able to be 206 # downloaded/etc. 207 missing = [ 208 Dependency(entry.cls, entry.config) 209 for entry in self.entries.values() 210 if not entry.cls.dep_is_present(entry.config) 211 ] 212 if missing: 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 RuntimeError('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 # Watch for wacky infinite dep loops. 266 if recursion > 10: 267 raise RecursionError('Max recursion reached') 268 269 hashval = dep.get_hash() 270 271 if hashval in self.entries: 272 # Found an already resolved one; we're done here. 273 return 274 275 # Add our entry before we recurse so we don't repeat add it if 276 # there's a dependency loop. 277 self.entries[hashval] = DependencyEntry(self, dep) 278 279 # Grab all Dependency instances we find in the class. 280 subdeps = [ 281 cls 282 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.
To use DependencyComponents, a set must be created, resolved, and then loaded. The DependencyComponents are only valid while the set remains in existence.
189 def resolve(self) -> None: 190 """Resolve the complete set of required dependencies for this set. 191 192 Raises a bascenev1.DependencyError if dependencies are missing (or 193 other Exception types on other errors). 194 """ 195 196 if self._resolved: 197 raise RuntimeError('DependencySet has already been resolved.') 198 199 # print('RESOLVING DEP SET') 200 201 # First, recursively expand out all dependencies. 202 self._resolve(self._root_dependency, 0) 203 204 # Now, if any dependencies are not present, raise an Exception 205 # telling exactly which ones (so hopefully they'll be able to be 206 # downloaded/etc. 207 missing = [ 208 Dependency(entry.cls, entry.config) 209 for entry in self.entries.values() 210 if not entry.cls.dep_is_present(entry.config) 211 ] 212 if missing: 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 bascenev1.DependencyError if dependencies are missing (or other Exception types on other errors).
218 @property 219 def resolved(self) -> bool: 220 """Whether this set has been successfully resolved.""" 221 return self._resolved
Whether this set has been successfully resolved.
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 RuntimeError('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.
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
The instantiated root DependencyComponent instance for the set.
46@dataclass 47class DieMessage: 48 """A message telling an object to die. 49 50 Most bascenev1.Actor-s respond to this. 51 """ 52 53 #: If this is set to True, the actor should disappear immediately. 54 #: This is for 'removing' stuff from the game more so than 'killing' 55 #: it. If False, the actor should die a 'normal' death and can take 56 #: its time with lingering corpses, sound effects, etc. 57 immediate: bool = False 58 59 #: The particular reason for death. 60 how: DeathType = DeathType.GENERIC
A message telling an object to die.
Most bascenev1.Actor-s respond to this.
729def displaytime() -> babase.DisplayTime: 730 """Return the current display-time in seconds. 731 732 Display-time is a time value intended to be used for animation and other 733 visual purposes. It will generally increment by a consistent amount each 734 frame. It will pass at an overall similar rate to AppTime, but trades 735 accuracy for smoothness. 736 737 Note that the value returned here is simply a float; it just has a 738 unique type in the type-checker's eyes to help prevent it from being 739 accidentally used with time functionality expecting other time types. 740 """ 741 import babase # pylint: disable=cyclic-import 742 743 return babase.DisplayTime(0.0)
Return the current display-time in seconds.
Display-time is a time value intended to be used for animation and other visual purposes. It will generally increment by a consistent amount each frame. It will pass at an overall similar rate to AppTime, but trades accuracy for smoothness.
Note that the value returned here is simply a float; it just has a unique type in the type-checker's eyes to help prevent it from being accidentally used with time functionality expecting other time types.
746def displaytimer(time: float, call: Callable[[], Any]) -> None: 747 """Schedule a callable object to run based on display-time. 748 749 This function creates a one-off timer which cannot be canceled or 750 modified once created. If you require the ability to do so, or need 751 a repeating timer, use the babase.DisplayTimer class instead. 752 753 Display-time is a time value intended to be used for animation and other 754 visual purposes. It will generally increment by a consistent amount each 755 frame. It will pass at an overall similar rate to AppTime, but trades 756 accuracy for smoothness. 757 758 ##### Arguments 759 ###### time (float) 760 > Length of time in seconds that the timer will wait before firing. 761 762 ###### call (Callable[[], Any]) 763 > A callable Python object. Note that the timer will retain a 764 strong reference to the callable for as long as the timer exists, so you 765 may want to look into concepts such as babase.WeakCall if that is not 766 desired. 767 768 ##### Examples 769 Print some stuff through time: 770 >>> babase.screenmessage('hello from now!') 771 >>> babase.displaytimer(1.0, babase.Call(babase.screenmessage, 772 ... 'hello from the future!')) 773 >>> babase.displaytimer(2.0, babase.Call(babase.screenmessage, 774 ... 'hello from the future 2!')) 775 """ 776 return None
Schedule a callable object to run based on display-time.
This function creates a one-off timer which cannot be canceled or modified once created. If you require the ability to do so, or need a repeating timer, use the babase.DisplayTimer class instead.
Display-time is a time value intended to be used for animation and other visual purposes. It will generally increment by a consistent amount each frame. It will pass at an overall similar rate to AppTime, but trades accuracy for smoothness.
Arguments
time (float)
Length of time in seconds that the timer will wait before firing.
call (Callable[[], Any])
A callable Python object. Note that the timer will retain a strong reference to the callable for as long as the timer exists, so you may want to look into concepts such as babase.WeakCall if that is not desired.
Examples
Print some stuff through time:
>>> babase.screenmessage('hello from now!')
>>> babase.displaytimer(1.0, babase.Call(babase.screenmessage,
... 'hello from the future!'))
>>> babase.displaytimer(2.0, babase.Call(babase.screenmessage,
... 'hello from the future 2!'))
216class DisplayTimer: 217 """Timers are used to run code at later points in time. 218 219 This class encapsulates a timer based on display-time. 220 The underlying timer will be destroyed when this object is no longer 221 referenced. If you do not want to worry about keeping a reference to 222 your timer around, use the babase.displaytimer() function instead to get a 223 one-off timer. 224 225 Display-time is a time value intended to be used for animation and 226 other visual purposes. It will generally increment by a consistent 227 amount each frame. It will pass at an overall similar rate to AppTime, 228 but trades accuracy for smoothness. 229 230 ##### Arguments 231 ###### time 232 > Length of time in seconds that the timer will wait before firing. 233 234 ###### call 235 > A callable Python object. Remember that the timer will retain a 236 strong reference to the callable for as long as it exists, so you 237 may want to look into concepts such as babase.WeakCall if that is not 238 desired. 239 240 ###### repeat 241 > If True, the timer will fire repeatedly, with each successive 242 firing having the same delay as the first. 243 244 ##### Example 245 246 Use a Timer object to print repeatedly for a few seconds: 247 ... def say_it(): 248 ... babase.screenmessage('BADGER!') 249 ... def stop_saying_it(): 250 ... global g_timer 251 ... g_timer = None 252 ... babase.screenmessage('MUSHROOM MUSHROOM!') 253 ... # Create our timer; it will run as long as we have the self.t ref. 254 ... g_timer = babase.DisplayTimer(0.3, say_it, repeat=True) 255 ... # Now fire off a one-shot timer to kill it. 256 ... babase.displaytimer(3.89, stop_saying_it) 257 """ 258 259 def __init__( 260 self, time: float, call: Callable[[], Any], repeat: bool = False 261 ) -> None: 262 pass
Timers are used to run code at later points in time.
This class encapsulates a timer based on display-time. The underlying timer will be destroyed when this object is no longer referenced. If you do not want to worry about keeping a reference to your timer around, use the babase.displaytimer() function instead to get a one-off timer.
Display-time is a time value intended to be used for animation and other visual purposes. It will generally increment by a consistent amount each frame. It will pass at an overall similar rate to AppTime, but trades accuracy for smoothness.
Arguments
time
Length of time in seconds that the timer will wait before firing.
call
A callable Python object. Remember 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 babase.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.
Example
Use a Timer object to print repeatedly for a few seconds: ... def say_it(): ... babase.screenmessage('BADGER!') ... def stop_saying_it(): ... global g_timer ... g_timer = None ... babase.screenmessage('MUSHROOM MUSHROOM!') ... # Create our timer; it will run as long as we have the self.t ref. ... g_timer = babase.DisplayTimer(0.3, say_it, repeat=True) ... # Now fire off a one-shot timer to kill it. ... babase.displaytimer(3.89, stop_saying_it)
140@dataclass 141class DropMessage: 142 """Tells an object that it has dropped what it was holding."""
Tells an object that it has dropped what it was holding.
153@dataclass 154class DroppedMessage: 155 """Tells an object that it has been dropped.""" 156 157 node: bascenev1.Node 158 """The bascenev1.Node doing the dropping."""
Tells an object that it has been dropped.
18class DualTeamSession(MultiTeamSession): 19 """bascenev1.Session type for teams mode games.""" 20 21 # Base class overrides: 22 use_teams = True 23 use_team_colors = True 24 25 _playlist_selection_var = 'Team Tournament Playlist Selection' 26 _playlist_randomize_var = 'Team Tournament Playlist Randomize' 27 _playlists_var = 'Team Tournament Playlists' 28 29 def __init__(self) -> None: 30 babase.increment_analytics_count('Teams session start') 31 super().__init__() 32 33 @override 34 def _switch_to_score_screen(self, results: bascenev1.GameResults) -> None: 35 # pylint: disable=cyclic-import 36 from bascenev1lib.activity.multiteamvictory import ( 37 TeamSeriesVictoryScoreScreenActivity, 38 ) 39 from bascenev1lib.activity.dualteamscore import ( 40 TeamVictoryScoreScreenActivity, 41 ) 42 from bascenev1lib.activity.drawscore import DrawScoreScreenActivity 43 44 winnergroups = results.winnergroups 45 46 # If everyone has the same score, call it a draw. 47 if len(winnergroups) < 2: 48 self.setactivity(_bascenev1.newactivity(DrawScoreScreenActivity)) 49 else: 50 winner = winnergroups[0].teams[0] 51 winner.customdata['score'] += 1 52 53 # If a team has won, show final victory screen. 54 if winner.customdata['score'] >= (self._series_length - 1) / 2 + 1: 55 self.setactivity( 56 _bascenev1.newactivity( 57 TeamSeriesVictoryScoreScreenActivity, 58 {'winner': winner}, 59 ) 60 ) 61 else: 62 self.setactivity( 63 _bascenev1.newactivity( 64 TeamVictoryScoreScreenActivity, {'winner': winner} 65 ) 66 )
bascenev1.Session type for teams mode games.
29 def __init__(self) -> None: 30 babase.increment_analytics_count('Teams session start') 31 super().__init__()
Set up playlists & launch a bascenev1.Activity to accept joiners.
1082def emitfx( 1083 position: Sequence[float], 1084 velocity: Sequence[float] | None = None, 1085 count: int = 10, 1086 scale: float = 1.0, 1087 spread: float = 1.0, 1088 chunk_type: str = 'rock', 1089 emit_type: str = 'chunks', 1090 tendril_type: str = 'smoke', 1091) -> None: 1092 """Emit particles, smoke, etc. into the fx sim layer. 1093 1094 The fx sim layer is a secondary dynamics simulation that runs in 1095 the background and just looks pretty; it does not affect gameplay. 1096 Note that the actual amount emitted may vary depending on graphics 1097 settings, exiting element counts, or other factors. 1098 """ 1099 return None
Emit particles, smoke, etc. into the fx sim layer.
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.
275class EmptyPlayer(Player['bascenev1.EmptyTeam']): 276 """An empty player for use by Activities that don't need to define one. 277 278 bascenev1.Player and bascenev1.Team are 'Generic' types, and so passing 279 those top level classes as type arguments when defining a 280 bascenev1.Activity reduces type safety. For example, 281 activity.teams[0].player will have type 'Any' in that case. For that 282 reason, it is better to pass EmptyPlayer and EmptyTeam when defining 283 a bascenev1.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.
bascenev1.Player and bascenev1.Team are 'Generic' types, and so passing those top level classes as type arguments when defining a bascenev1.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 bascenev1.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.
194class EmptyTeam(Team['bascenev1.EmptyPlayer']): 195 """An empty player for use by Activities that don't define one. 196 197 bascenev1.Player and bascenev1.Team are 'Generic' types, and so 198 passing those top level classes as type arguments when defining a 199 bascenev1.Activity reduces type safety. For example, 200 activity.teams[0].player will have type 'Any' in that case. For that 201 reason, it is better to pass EmptyPlayer and EmptyTeam when defining 202 a bascenev1.Activity that does not need custom types of its own. 203 204 Note that EmptyPlayer defines its team type as EmptyTeam and vice 205 versa, so if you want to define your own class for one of them you 206 should do so for both. 207 """
An empty player for use by Activities that don't define one.
bascenev1.Player and bascenev1.Team are 'Generic' types, and so passing those top level classes as type arguments when defining a bascenev1.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 bascenev1.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.
48def existing(obj: ExistableT | None) -> ExistableT | None: 49 """Convert invalid references to None for any babase.Existable object. 50 51 To best support type checking, it is important that invalid references 52 not be passed around and instead get converted to values of None. 53 That way the type checker can properly flag attempts to pass possibly-dead 54 objects (FooType | None) into functions expecting only live ones 55 (FooType), etc. This call can be used on any 'existable' object 56 (one with an exists() method) and will convert it to a None value 57 if it does not exist. 58 59 For more info, see notes on 'existables' here: 60 https://ballistica.net/wiki/Coding-Style-Guide 61 """ 62 assert obj is None or hasattr(obj, 'exists'), f'No "exists" attr on {obj}.' 63 return obj if obj is not None and obj.exists() else None
Convert invalid references to None for any babase.Existable object.
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
22def filter_playlist( 23 playlist: PlaylistType, 24 sessiontype: type[Session], 25 *, 26 add_resolved_type: bool = False, 27 remove_unowned: bool = True, 28 mark_unowned: bool = False, 29 name: str = '?', 30) -> PlaylistType: 31 """Return a filtered version of a playlist. 32 33 Strips out or replaces invalid or unowned game types, makes sure all 34 settings are present, and adds in a 'resolved_type' which is the actual 35 type. 36 """ 37 # pylint: disable=too-many-locals 38 # pylint: disable=too-many-branches 39 # pylint: disable=too-many-statements 40 from bascenev1._map import get_filtered_map_name 41 from bascenev1._gameactivity import GameActivity 42 43 assert babase.app.classic is not None 44 45 goodlist: list[dict] = [] 46 unowned_maps: Sequence[str] 47 available_maps: list[str] = list(babase.app.classic.maps.keys()) 48 if (remove_unowned or mark_unowned) and babase.app.classic is not None: 49 unowned_maps = babase.app.classic.store.get_unowned_maps() 50 unowned_game_types = babase.app.classic.store.get_unowned_game_types() 51 else: 52 unowned_maps = [] 53 unowned_game_types = set() 54 55 for entry in copy.deepcopy(playlist): 56 # 'map' used to be called 'level' here. 57 if 'level' in entry: 58 entry['map'] = entry['level'] 59 del entry['level'] 60 61 # We now stuff map into settings instead of it being its own thing. 62 if 'map' in entry: 63 entry['settings']['map'] = entry['map'] 64 del entry['map'] 65 66 # Update old map names to new ones. 67 entry['settings']['map'] = get_filtered_map_name( 68 entry['settings']['map'] 69 ) 70 if remove_unowned and entry['settings']['map'] in unowned_maps: 71 continue 72 73 # Ok, for each game in our list, try to import the module and grab 74 # the actual game class. add successful ones to our initial list 75 # to present to the user. 76 if not isinstance(entry['type'], str): 77 raise TypeError('invalid entry format') 78 try: 79 # Do some type filters for backwards compat. 80 if entry['type'] in ( 81 'Assault.AssaultGame', 82 'Happy_Thoughts.HappyThoughtsGame', 83 'bsAssault.AssaultGame', 84 'bs_assault.AssaultGame', 85 'bastd.game.assault.AssaultGame', 86 ): 87 entry['type'] = 'bascenev1lib.game.assault.AssaultGame' 88 if entry['type'] in ( 89 'King_of_the_Hill.KingOfTheHillGame', 90 'bsKingOfTheHill.KingOfTheHillGame', 91 'bs_king_of_the_hill.KingOfTheHillGame', 92 'bastd.game.kingofthehill.KingOfTheHillGame', 93 ): 94 entry['type'] = ( 95 'bascenev1lib.game.kingofthehill.KingOfTheHillGame' 96 ) 97 if entry['type'] in ( 98 'Capture_the_Flag.CTFGame', 99 'bsCaptureTheFlag.CTFGame', 100 'bs_capture_the_flag.CTFGame', 101 'bastd.game.capturetheflag.CaptureTheFlagGame', 102 ): 103 entry['type'] = ( 104 'bascenev1lib.game.capturetheflag.CaptureTheFlagGame' 105 ) 106 if entry['type'] in ( 107 'Death_Match.DeathMatchGame', 108 'bsDeathMatch.DeathMatchGame', 109 'bs_death_match.DeathMatchGame', 110 'bastd.game.deathmatch.DeathMatchGame', 111 ): 112 entry['type'] = 'bascenev1lib.game.deathmatch.DeathMatchGame' 113 if entry['type'] in ( 114 'ChosenOne.ChosenOneGame', 115 'bsChosenOne.ChosenOneGame', 116 'bs_chosen_one.ChosenOneGame', 117 'bastd.game.chosenone.ChosenOneGame', 118 ): 119 entry['type'] = 'bascenev1lib.game.chosenone.ChosenOneGame' 120 if entry['type'] in ( 121 'Conquest.Conquest', 122 'Conquest.ConquestGame', 123 'bsConquest.ConquestGame', 124 'bs_conquest.ConquestGame', 125 'bastd.game.conquest.ConquestGame', 126 ): 127 entry['type'] = 'bascenev1lib.game.conquest.ConquestGame' 128 if entry['type'] in ( 129 'Elimination.EliminationGame', 130 'bsElimination.EliminationGame', 131 'bs_elimination.EliminationGame', 132 'bastd.game.elimination.EliminationGame', 133 ): 134 entry['type'] = 'bascenev1lib.game.elimination.EliminationGame' 135 if entry['type'] in ( 136 'Football.FootballGame', 137 'bsFootball.FootballTeamGame', 138 'bs_football.FootballTeamGame', 139 'bastd.game.football.FootballTeamGame', 140 ): 141 entry['type'] = 'bascenev1lib.game.football.FootballTeamGame' 142 if entry['type'] in ( 143 'Hockey.HockeyGame', 144 'bsHockey.HockeyGame', 145 'bs_hockey.HockeyGame', 146 'bastd.game.hockey.HockeyGame', 147 ): 148 entry['type'] = 'bascenev1lib.game.hockey.HockeyGame' 149 if entry['type'] in ( 150 'Keep_Away.KeepAwayGame', 151 'bsKeepAway.KeepAwayGame', 152 'bs_keep_away.KeepAwayGame', 153 'bastd.game.keepaway.KeepAwayGame', 154 ): 155 entry['type'] = 'bascenev1lib.game.keepaway.KeepAwayGame' 156 if entry['type'] in ( 157 'Race.RaceGame', 158 'bsRace.RaceGame', 159 'bs_race.RaceGame', 160 'bastd.game.race.RaceGame', 161 ): 162 entry['type'] = 'bascenev1lib.game.race.RaceGame' 163 if entry['type'] in ( 164 'bsEasterEggHunt.EasterEggHuntGame', 165 'bs_easter_egg_hunt.EasterEggHuntGame', 166 'bastd.game.easteregghunt.EasterEggHuntGame', 167 ): 168 entry['type'] = ( 169 'bascenev1lib.game.easteregghunt.EasterEggHuntGame' 170 ) 171 if entry['type'] in ( 172 'bsMeteorShower.MeteorShowerGame', 173 'bs_meteor_shower.MeteorShowerGame', 174 'bastd.game.meteorshower.MeteorShowerGame', 175 ): 176 entry['type'] = ( 177 'bascenev1lib.game.meteorshower.MeteorShowerGame' 178 ) 179 if entry['type'] in ( 180 'bsTargetPractice.TargetPracticeGame', 181 'bs_target_practice.TargetPracticeGame', 182 'bastd.game.targetpractice.TargetPracticeGame', 183 ): 184 entry['type'] = ( 185 'bascenev1lib.game.targetpractice.TargetPracticeGame' 186 ) 187 188 gameclass = babase.getclass(entry['type'], GameActivity) 189 190 if entry['settings']['map'] not in available_maps: 191 raise babase.MapNotFoundError() 192 193 if remove_unowned and gameclass in unowned_game_types: 194 continue 195 if add_resolved_type: 196 entry['resolved_type'] = gameclass 197 if mark_unowned and entry['settings']['map'] in unowned_maps: 198 entry['is_unowned_map'] = True 199 if mark_unowned and gameclass in unowned_game_types: 200 entry['is_unowned_game'] = True 201 202 # Make sure all settings the game defines are present. 203 neededsettings = gameclass.get_available_settings(sessiontype) 204 for setting in neededsettings: 205 if setting.name not in entry['settings']: 206 entry['settings'][setting.name] = setting.default 207 208 goodlist.append(entry) 209 210 except babase.MapNotFoundError: 211 logging.warning( 212 'Map \'%s\' not found while scanning playlist \'%s\'.', 213 entry['settings']['map'], 214 name, 215 ) 216 except ImportError as exc: 217 logging.warning( 218 'Import failed while scanning playlist \'%s\': %s', name, exc 219 ) 220 except Exception: 221 logging.exception('Error in filter_playlist.') 222 223 return goodlist
Return a filtered version of a playlist.
Strips out or replaces invalid or unowned game types, makes sure all settings are present, and adds in a 'resolved_type' which is the actual type.
65@dataclass 66class FloatChoiceSetting(ChoiceSetting): 67 """A float setting with multiple choices.""" 68 69 default: float 70 choices: list[tuple[str, float]]
A float setting with multiple choices.
40@dataclass 41class FloatSetting(Setting): 42 """A floating point game setting.""" 43 44 default: float 45 min_value: float = 0.0 46 max_value: float = 9999.0 47 increment: float = 1.0
A floating point game setting.
19class FreeForAllSession(MultiTeamSession): 20 """bascenev1.Session type for free-for-all mode games.""" 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 babase.increment_analytics_count('Free-for-all session start') 52 super().__init__() 53 54 @override 55 def _switch_to_score_screen(self, results: bascenev1.GameResults) -> None: 56 # pylint: disable=cyclic-import 57 from efro.util import asserttype 58 from bascenev1lib.activity.multiteamvictory import ( 59 TeamSeriesVictoryScoreScreenActivity, 60 ) 61 from bascenev1lib.activity.freeforallvictory import ( 62 FreeForAllVictoryScoreScreenActivity, 63 ) 64 from bascenev1lib.activity.drawscore import DrawScoreScreenActivity 65 66 winners = results.winnergroups 67 68 # If there's multiple players and everyone has the same score, 69 # call it a draw. 70 if len(self.sessionplayers) > 1 and len(winners) < 2: 71 self.setactivity( 72 _bascenev1.newactivity( 73 DrawScoreScreenActivity, {'results': results} 74 ) 75 ) 76 else: 77 # Award different point amounts based on number of players. 78 point_awards = self.get_ffa_point_awards() 79 80 for i, winner in enumerate(winners): 81 for team in winner.teams: 82 points = point_awards[i] if i in point_awards else 0 83 team.customdata['previous_score'] = team.customdata['score'] 84 team.customdata['score'] += points 85 86 series_winners = [ 87 team 88 for team in self.sessionteams 89 if team.customdata['score'] >= self._ffa_series_length 90 ] 91 series_winners.sort( 92 reverse=True, 93 key=lambda t: asserttype(t.customdata['score'], int), 94 ) 95 if len(series_winners) == 1 or ( 96 len(series_winners) > 1 97 and series_winners[0].customdata['score'] 98 != series_winners[1].customdata['score'] 99 ): 100 self.setactivity( 101 _bascenev1.newactivity( 102 TeamSeriesVictoryScoreScreenActivity, 103 {'winner': series_winners[0]}, 104 ) 105 ) 106 else: 107 self.setactivity( 108 _bascenev1.newactivity( 109 FreeForAllVictoryScoreScreenActivity, 110 {'results': results}, 111 ) 112 )
bascenev1.Session type for free-for-all mode games.
50 def __init__(self) -> None: 51 babase.increment_analytics_count('Free-for-all session start') 52 super().__init__()
Set up playlists & launch a bascenev1.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.
174@dataclass 175class FreezeMessage: 176 """Tells an object to become frozen. 177 178 As seen in the effects of an ice bascenev1.Bomb. 179 """ 180 181 time: float = 5.0 182 """The amount of time the object will be frozen."""
Tells an object to become frozen.
As seen in the effects of an ice bascenev1.Bomb.
34class GameActivity(Activity[PlayerT, TeamT]): 35 """Common base class for all game bascenev1.Activities.""" 36 37 # pylint: disable=too-many-public-methods 38 39 # Tips to be presented to the user at the start of the game. 40 tips: list[str | bascenev1.GameTip] = [] 41 42 # Default getname() will return this if not None. 43 name: str | None = None 44 45 # Default get_description() will return this if not None. 46 description: str | None = None 47 48 # Default get_available_settings() will return this if not None. 49 available_settings: list[bascenev1.Setting] | None = None 50 51 # Default getscoreconfig() will return this if not None. 52 scoreconfig: bascenev1.ScoreConfig | None = None 53 54 # Override some defaults. 55 allow_pausing = True 56 allow_kick_idle_players = True 57 58 # Whether to show points for kills. 59 show_kill_points = True 60 61 # If not None, the music type that should play in on_transition_in() 62 # (unless overridden by the map). 63 default_music: bascenev1.MusicType | None = None 64 65 @classmethod 66 def getscoreconfig(cls) -> bascenev1.ScoreConfig: 67 """Return info about game scoring setup; can be overridden by games.""" 68 return cls.scoreconfig if cls.scoreconfig is not None else ScoreConfig() 69 70 @classmethod 71 def getname(cls) -> str: 72 """Return a str name for this game type. 73 74 This default implementation simply returns the 'name' class attr. 75 """ 76 return cls.name if cls.name is not None else 'Untitled Game' 77 78 @classmethod 79 def get_display_string(cls, settings: dict | None = None) -> babase.Lstr: 80 """Return a descriptive name for this game/settings combo. 81 82 Subclasses should override getname(); not this. 83 """ 84 name = babase.Lstr(translate=('gameNames', cls.getname())) 85 86 # A few substitutions for 'Epic', 'Solo' etc. modes. 87 # FIXME: Should provide a way for game types to define filters of 88 # their own and should not rely on hard-coded settings names. 89 if settings is not None: 90 if 'Solo Mode' in settings and settings['Solo Mode']: 91 name = babase.Lstr( 92 resource='soloNameFilterText', subs=[('${NAME}', name)] 93 ) 94 if 'Epic Mode' in settings and settings['Epic Mode']: 95 name = babase.Lstr( 96 resource='epicNameFilterText', subs=[('${NAME}', name)] 97 ) 98 99 return name 100 101 @classmethod 102 def get_team_display_string(cls, name: str) -> babase.Lstr: 103 """Given a team name, returns a localized version of it.""" 104 return babase.Lstr(translate=('teamNames', name)) 105 106 @classmethod 107 def get_description(cls, sessiontype: type[bascenev1.Session]) -> str: 108 """Get a str description of this game type. 109 110 The default implementation simply returns the 'description' class var. 111 Classes which want to change their description depending on the session 112 can override this method. 113 """ 114 del sessiontype # Unused arg. 115 return cls.description if cls.description is not None else '' 116 117 @classmethod 118 def get_description_display_string( 119 cls, sessiontype: type[bascenev1.Session] 120 ) -> babase.Lstr: 121 """Return a translated version of get_description(). 122 123 Sub-classes should override get_description(); not this. 124 """ 125 description = cls.get_description(sessiontype) 126 return babase.Lstr(translate=('gameDescriptions', description)) 127 128 @classmethod 129 def get_available_settings( 130 cls, sessiontype: type[bascenev1.Session] 131 ) -> list[bascenev1.Setting]: 132 """Return a list of settings relevant to this game type when 133 running under the provided session type. 134 """ 135 del sessiontype # Unused arg. 136 return [] if cls.available_settings is None else cls.available_settings 137 138 @classmethod 139 def get_supported_maps( 140 cls, sessiontype: type[bascenev1.Session] 141 ) -> list[str]: 142 """ 143 Called by the default bascenev1.GameActivity.create_settings_ui() 144 implementation; should return a list of map names valid 145 for this game-type for the given bascenev1.Session type. 146 """ 147 del sessiontype # Unused arg. 148 assert babase.app.classic is not None 149 return babase.app.classic.getmaps('melee') 150 151 @classmethod 152 def get_settings_display_string(cls, config: dict[str, Any]) -> babase.Lstr: 153 """Given a game config dict, return a short description for it. 154 155 This is used when viewing game-lists or showing what game 156 is up next in a series. 157 """ 158 name = cls.get_display_string(config['settings']) 159 160 # In newer configs, map is in settings; it used to be in the 161 # config root. 162 if 'map' in config['settings']: 163 sval = babase.Lstr( 164 value='${NAME} @ ${MAP}', 165 subs=[ 166 ('${NAME}', name), 167 ( 168 '${MAP}', 169 _map.get_map_display_string( 170 _map.get_filtered_map_name( 171 config['settings']['map'] 172 ) 173 ), 174 ), 175 ], 176 ) 177 elif 'map' in config: 178 sval = babase.Lstr( 179 value='${NAME} @ ${MAP}', 180 subs=[ 181 ('${NAME}', name), 182 ( 183 '${MAP}', 184 _map.get_map_display_string( 185 _map.get_filtered_map_name(config['map']) 186 ), 187 ), 188 ], 189 ) 190 else: 191 print('invalid game config - expected map entry under settings') 192 sval = babase.Lstr(value='???') 193 return sval 194 195 @classmethod 196 def supports_session_type( 197 cls, sessiontype: type[bascenev1.Session] 198 ) -> bool: 199 """Return whether this game supports the provided Session type.""" 200 from bascenev1._multiteamsession import MultiTeamSession 201 202 # By default, games support any versus mode 203 return issubclass(sessiontype, MultiTeamSession) 204 205 def __init__(self, settings: dict): 206 """Instantiate the Activity.""" 207 super().__init__(settings) 208 209 # Holds some flattened info about the player set at the point 210 # when on_begin() is called. 211 self.initialplayerinfos: list[bascenev1.PlayerInfo] | None = None 212 213 # Go ahead and get our map loading. 214 self._map_type = _map.get_map_class(self._calc_map_name(settings)) 215 216 self._spawn_sound = _bascenev1.getsound('spawn') 217 self._map_type.preload() 218 self._map: bascenev1.Map | None = None 219 self._powerup_drop_timer: bascenev1.Timer | None = None 220 self._tnt_spawners: dict[int, TNTSpawner] | None = None 221 self._tnt_drop_timer: bascenev1.Timer | None = None 222 self._game_scoreboard_name_text: bascenev1.Actor | None = None 223 self._game_scoreboard_description_text: bascenev1.Actor | None = None 224 self._standard_time_limit_time: int | None = None 225 self._standard_time_limit_timer: bascenev1.Timer | None = None 226 self._standard_time_limit_text: bascenev1.NodeActor | None = None 227 self._standard_time_limit_text_input: bascenev1.NodeActor | None = None 228 self._tournament_time_limit: int | None = None 229 self._tournament_time_limit_timer: bascenev1.BaseTimer | None = None 230 self._tournament_time_limit_title_text: bascenev1.NodeActor | None = ( 231 None 232 ) 233 self._tournament_time_limit_text: bascenev1.NodeActor | None = None 234 self._tournament_time_limit_text_input: bascenev1.NodeActor | None = ( 235 None 236 ) 237 self._zoom_message_times: dict[int, float] = {} 238 239 @property 240 def map(self) -> _map.Map: 241 """The map being used for this game. 242 243 Raises a bascenev1.MapNotFoundError if the map does not currently 244 exist. 245 """ 246 if self._map is None: 247 raise babase.MapNotFoundError 248 return self._map 249 250 def get_instance_display_string(self) -> babase.Lstr: 251 """Return a name for this particular game instance.""" 252 return self.get_display_string(self.settings_raw) 253 254 # noinspection PyUnresolvedReferences 255 def get_instance_scoreboard_display_string(self) -> babase.Lstr: 256 """Return a name for this particular game instance. 257 258 This name is used above the game scoreboard in the corner 259 of the screen, so it should be as concise as possible. 260 """ 261 # If we're in a co-op session, use the level name. 262 # FIXME: Should clean this up. 263 try: 264 from bascenev1._coopsession import CoopSession 265 266 if isinstance(self.session, CoopSession): 267 campaign = self.session.campaign 268 assert campaign is not None 269 return campaign.getlevel( 270 self.session.campaign_level_name 271 ).displayname 272 except Exception: 273 logging.exception('Error getting campaign level name.') 274 return self.get_instance_display_string() 275 276 def get_instance_description(self) -> str | Sequence: 277 """Return a description for this game instance, in English. 278 279 This is shown in the center of the screen below the game name at the 280 start of a game. It should start with a capital letter and end with a 281 period, and can be a bit more verbose than the version returned by 282 get_instance_description_short(). 283 284 Note that translation is applied by looking up the specific returned 285 value as a key, so the number of returned variations should be limited; 286 ideally just one or two. To include arbitrary values in the 287 description, you can return a sequence of values in the following 288 form instead of just a string: 289 290 # This will give us something like 'Score 3 goals.' in English 291 # and can properly translate to 'Anota 3 goles.' in Spanish. 292 # If we just returned the string 'Score 3 Goals' here, there would 293 # have to be a translation entry for each specific number. ew. 294 return ['Score ${ARG1} goals.', self.settings_raw['Score to Win']] 295 296 This way the first string can be consistently translated, with any arg 297 values then substituted into the result. ${ARG1} will be replaced with 298 the first value, ${ARG2} with the second, etc. 299 """ 300 return self.get_description(type(self.session)) 301 302 def get_instance_description_short(self) -> str | Sequence: 303 """Return a short description for this game instance in English. 304 305 This description is used above the game scoreboard in the 306 corner of the screen, so it should be as concise as possible. 307 It should be lowercase and should not contain periods or other 308 punctuation. 309 310 Note that translation is applied by looking up the specific returned 311 value as a key, so the number of returned variations should be limited; 312 ideally just one or two. To include arbitrary values in the 313 description, you can return a sequence of values in the following form 314 instead of just a string: 315 316 # This will give us something like 'score 3 goals' in English 317 # and can properly translate to 'anota 3 goles' in Spanish. 318 # If we just returned the string 'score 3 goals' here, there would 319 # have to be a translation entry for each specific number. ew. 320 return ['score ${ARG1} goals', self.settings_raw['Score to Win']] 321 322 This way the first string can be consistently translated, with any arg 323 values then substituted into the result. ${ARG1} will be replaced 324 with the first value, ${ARG2} with the second, etc. 325 326 """ 327 return '' 328 329 @override 330 def on_transition_in(self) -> None: 331 super().on_transition_in() 332 333 # Make our map. 334 self._map = self._map_type() 335 336 # Give our map a chance to override the music. 337 # (for happy-thoughts and other such themed maps) 338 map_music = self._map_type.get_music_type() 339 music = map_music if map_music is not None else self.default_music 340 341 if music is not None: 342 _music.setmusic(music) 343 344 @override 345 def on_begin(self) -> None: 346 super().on_begin() 347 348 if babase.app.classic is not None: 349 babase.app.classic.game_begin_analytics() 350 351 # We don't do this in on_transition_in because it may depend on 352 # players/teams which aren't available until now. 353 _bascenev1.timer(0.001, self._show_scoreboard_info) 354 _bascenev1.timer(1.0, self._show_info) 355 _bascenev1.timer(2.5, self._show_tip) 356 357 # Store some basic info about players present at start time. 358 self.initialplayerinfos = [ 359 PlayerInfo(name=p.getname(full=True), character=p.character) 360 for p in self.players 361 ] 362 363 # Sort this by name so high score lists/etc will be consistent 364 # regardless of player join order. 365 self.initialplayerinfos.sort(key=lambda x: x.name) 366 367 # If this is a tournament, query info about it such as how much 368 # time is left. 369 tournament_id = self.session.tournament_id 370 if tournament_id is not None: 371 assert babase.app.plus is not None 372 babase.app.plus.tournament_query( 373 args={ 374 'tournamentIDs': [tournament_id], 375 'source': 'in-game time remaining query', 376 }, 377 callback=babase.WeakCall(self._on_tournament_query_response), 378 ) 379 380 def _on_tournament_query_response( 381 self, data: dict[str, Any] | None 382 ) -> None: 383 if data is not None: 384 data_t = data['t'] # This used to be the whole payload. 385 386 # Keep our cached tourney info up to date 387 assert babase.app.classic is not None 388 babase.app.classic.accounts.cache_tournament_info(data_t) 389 self._setup_tournament_time_limit( 390 max(5, data_t[0]['timeRemaining']) 391 ) 392 393 @override 394 def on_player_join(self, player: PlayerT) -> None: 395 super().on_player_join(player) 396 397 # By default, just spawn a dude. 398 self.spawn_player(player) 399 400 @override 401 def handlemessage(self, msg: Any) -> Any: 402 if isinstance(msg, PlayerDiedMessage): 403 # pylint: disable=cyclic-import 404 from bascenev1lib.actor.spaz import Spaz 405 406 player = msg.getplayer(self.playertype) 407 killer = msg.getkillerplayer(self.playertype) 408 409 # Inform our stats of the demise. 410 self.stats.player_was_killed( 411 player, killed=msg.killed, killer=killer 412 ) 413 414 # Award the killer points if he's on a different team. 415 # FIXME: This should not be linked to Spaz actors. 416 # (should move get_death_points to Actor or make it a message) 417 if killer and killer.team is not player.team: 418 assert isinstance(killer.actor, Spaz) 419 pts, importance = killer.actor.get_death_points(msg.how) 420 if not self.has_ended(): 421 self.stats.player_scored( 422 killer, 423 pts, 424 kill=True, 425 victim_player=player, 426 importance=importance, 427 showpoints=self.show_kill_points, 428 ) 429 else: 430 return super().handlemessage(msg) 431 return None 432 433 def _show_scoreboard_info(self) -> None: 434 """Create the game info display. 435 436 This is the thing in the top left corner showing the name 437 and short description of the game. 438 """ 439 # pylint: disable=too-many-locals 440 from bascenev1._freeforallsession import FreeForAllSession 441 from bascenev1._gameutils import animate 442 from bascenev1._nodeactor import NodeActor 443 444 sb_name = self.get_instance_scoreboard_display_string() 445 446 # The description can be either a string or a sequence with args 447 # to swap in post-translation. 448 sb_desc_in = self.get_instance_description_short() 449 sb_desc_l: Sequence 450 if isinstance(sb_desc_in, str): 451 sb_desc_l = [sb_desc_in] # handle simple string case 452 else: 453 sb_desc_l = sb_desc_in 454 if not isinstance(sb_desc_l[0], str): 455 raise TypeError('Invalid format for instance description.') 456 457 is_empty = sb_desc_l[0] == '' 458 subs = [] 459 for i in range(len(sb_desc_l) - 1): 460 subs.append(('${ARG' + str(i + 1) + '}', str(sb_desc_l[i + 1]))) 461 translation = babase.Lstr( 462 translate=('gameDescriptions', sb_desc_l[0]), subs=subs 463 ) 464 sb_desc = translation 465 vrmode = babase.app.env.vr 466 yval = -34 if is_empty else -20 467 yval -= 16 468 sbpos = ( 469 (15, yval) 470 if isinstance(self.session, FreeForAllSession) 471 else (15, yval) 472 ) 473 self._game_scoreboard_name_text = NodeActor( 474 _bascenev1.newnode( 475 'text', 476 attrs={ 477 'text': sb_name, 478 'maxwidth': 300, 479 'position': sbpos, 480 'h_attach': 'left', 481 'vr_depth': 10, 482 'v_attach': 'top', 483 'v_align': 'bottom', 484 'color': (1.0, 1.0, 1.0, 1.0), 485 'shadow': 1.0 if vrmode else 0.6, 486 'flatness': 1.0 if vrmode else 0.5, 487 'scale': 1.1, 488 }, 489 ) 490 ) 491 492 assert self._game_scoreboard_name_text.node 493 animate( 494 self._game_scoreboard_name_text.node, 'opacity', {0: 0.0, 1.0: 1.0} 495 ) 496 497 descpos = ( 498 (17, -44 + 10) 499 if isinstance(self.session, FreeForAllSession) 500 else (17, -44 + 10) 501 ) 502 self._game_scoreboard_description_text = NodeActor( 503 _bascenev1.newnode( 504 'text', 505 attrs={ 506 'text': sb_desc, 507 'maxwidth': 480, 508 'position': descpos, 509 'scale': 0.7, 510 'h_attach': 'left', 511 'v_attach': 'top', 512 'v_align': 'top', 513 'shadow': 1.0 if vrmode else 0.7, 514 'flatness': 1.0 if vrmode else 0.8, 515 'color': (1, 1, 1, 1) if vrmode else (0.9, 0.9, 0.9, 1.0), 516 }, 517 ) 518 ) 519 520 assert self._game_scoreboard_description_text.node 521 animate( 522 self._game_scoreboard_description_text.node, 523 'opacity', 524 {0: 0.0, 1.0: 1.0}, 525 ) 526 527 def _show_info(self) -> None: 528 """Show the game description.""" 529 from bascenev1._gameutils import animate 530 from bascenev1lib.actor.zoomtext import ZoomText 531 532 name = self.get_instance_display_string() 533 ZoomText( 534 name, 535 maxwidth=800, 536 lifespan=2.5, 537 jitter=2.0, 538 position=(0, 180), 539 flash=False, 540 color=(0.93 * 1.25, 0.9 * 1.25, 1.0 * 1.25), 541 trailcolor=(0.15, 0.05, 1.0, 0.0), 542 ).autoretain() 543 _bascenev1.timer(0.2, _bascenev1.getsound('gong').play) 544 # _bascenev1.timer( 545 # 0.2, Call(_bascenev1.playsound, _bascenev1.getsound('gong')) 546 # ) 547 548 # The description can be either a string or a sequence with args 549 # to swap in post-translation. 550 desc_in = self.get_instance_description() 551 desc_l: Sequence 552 if isinstance(desc_in, str): 553 desc_l = [desc_in] # handle simple string case 554 else: 555 desc_l = desc_in 556 if not isinstance(desc_l[0], str): 557 raise TypeError('Invalid format for instance description') 558 subs = [] 559 for i in range(len(desc_l) - 1): 560 subs.append(('${ARG' + str(i + 1) + '}', str(desc_l[i + 1]))) 561 translation = babase.Lstr( 562 translate=('gameDescriptions', desc_l[0]), subs=subs 563 ) 564 565 # Do some standard filters (epic mode, etc). 566 if self.settings_raw.get('Epic Mode', False): 567 translation = babase.Lstr( 568 resource='epicDescriptionFilterText', 569 subs=[('${DESCRIPTION}', translation)], 570 ) 571 vrmode = babase.app.env.vr 572 dnode = _bascenev1.newnode( 573 'text', 574 attrs={ 575 'v_attach': 'center', 576 'h_attach': 'center', 577 'h_align': 'center', 578 'color': (1, 1, 1, 1), 579 'shadow': 1.0 if vrmode else 0.5, 580 'flatness': 1.0 if vrmode else 0.5, 581 'vr_depth': -30, 582 'position': (0, 80), 583 'scale': 1.2, 584 'maxwidth': 700, 585 'text': translation, 586 }, 587 ) 588 cnode = _bascenev1.newnode( 589 'combine', 590 owner=dnode, 591 attrs={'input0': 1.0, 'input1': 1.0, 'input2': 1.0, 'size': 4}, 592 ) 593 cnode.connectattr('output', dnode, 'color') 594 keys = {0.5: 0, 1.0: 1.0, 2.5: 1.0, 4.0: 0.0} 595 animate(cnode, 'input3', keys) 596 _bascenev1.timer(4.0, dnode.delete) 597 598 def _show_tip(self) -> None: 599 # pylint: disable=too-many-locals 600 from bascenev1._gameutils import animate, GameTip 601 602 # If there's any tips left on the list, display one. 603 if self.tips: 604 tip = self.tips.pop(random.randrange(len(self.tips))) 605 tip_title = babase.Lstr( 606 value='${A}:', subs=[('${A}', babase.Lstr(resource='tipText'))] 607 ) 608 icon: bascenev1.Texture | None = None 609 sound: bascenev1.Sound | None = None 610 if isinstance(tip, GameTip): 611 icon = tip.icon 612 sound = tip.sound 613 tip = tip.text 614 assert isinstance(tip, str) 615 616 # Do a few substitutions. 617 tip_lstr = babase.Lstr( 618 translate=('tips', tip), 619 subs=[ 620 ('${PICKUP}', babase.charstr(babase.SpecialChar.TOP_BUTTON)) 621 ], 622 ) 623 base_position = (75, 50) 624 tip_scale = 0.8 625 tip_title_scale = 1.2 626 vrmode = babase.app.env.vr 627 628 t_offs = -350.0 629 tnode = _bascenev1.newnode( 630 'text', 631 attrs={ 632 'text': tip_lstr, 633 'scale': tip_scale, 634 'maxwidth': 900, 635 'position': (base_position[0] + t_offs, base_position[1]), 636 'h_align': 'left', 637 'vr_depth': 300, 638 'shadow': 1.0 if vrmode else 0.5, 639 'flatness': 1.0 if vrmode else 0.5, 640 'v_align': 'center', 641 'v_attach': 'bottom', 642 }, 643 ) 644 t2pos = ( 645 base_position[0] + t_offs - (20 if icon is None else 82), 646 base_position[1] + 2, 647 ) 648 t2node = _bascenev1.newnode( 649 'text', 650 owner=tnode, 651 attrs={ 652 'text': tip_title, 653 'scale': tip_title_scale, 654 'position': t2pos, 655 'h_align': 'right', 656 'vr_depth': 300, 657 'shadow': 1.0 if vrmode else 0.5, 658 'flatness': 1.0 if vrmode else 0.5, 659 'maxwidth': 140, 660 'v_align': 'center', 661 'v_attach': 'bottom', 662 }, 663 ) 664 if icon is not None: 665 ipos = (base_position[0] + t_offs - 40, base_position[1] + 1) 666 img = _bascenev1.newnode( 667 'image', 668 attrs={ 669 'texture': icon, 670 'position': ipos, 671 'scale': (50, 50), 672 'opacity': 1.0, 673 'vr_depth': 315, 674 'color': (1, 1, 1), 675 'absolute_scale': True, 676 'attach': 'bottomCenter', 677 }, 678 ) 679 animate(img, 'opacity', {0: 0, 1.0: 1, 4.0: 1, 5.0: 0}) 680 _bascenev1.timer(5.0, img.delete) 681 if sound is not None: 682 sound.play() 683 684 combine = _bascenev1.newnode( 685 'combine', 686 owner=tnode, 687 attrs={'input0': 1.0, 'input1': 0.8, 'input2': 1.0, 'size': 4}, 688 ) 689 combine.connectattr('output', tnode, 'color') 690 combine.connectattr('output', t2node, 'color') 691 animate(combine, 'input3', {0: 0, 1.0: 1, 4.0: 1, 5.0: 0}) 692 _bascenev1.timer(5.0, tnode.delete) 693 694 @override 695 def end( 696 self, results: Any = None, delay: float = 0.0, force: bool = False 697 ) -> None: 698 from bascenev1._gameresults import GameResults 699 700 # If results is a standard team-game-results, associate it with us 701 # so it can grab our score prefs. 702 if isinstance(results, GameResults): 703 results.set_game(self) 704 705 # If we had a standard time-limit that had not expired, stop it so 706 # it doesnt tick annoyingly. 707 if ( 708 self._standard_time_limit_time is not None 709 and self._standard_time_limit_time > 0 710 ): 711 self._standard_time_limit_timer = None 712 self._standard_time_limit_text = None 713 714 # Ditto with tournament time limits. 715 if ( 716 self._tournament_time_limit is not None 717 and self._tournament_time_limit > 0 718 ): 719 self._tournament_time_limit_timer = None 720 self._tournament_time_limit_text = None 721 self._tournament_time_limit_title_text = None 722 723 super().end(results, delay, force) 724 725 def end_game(self) -> None: 726 """Tell the game to wrap up and call bascenev1.Activity.end(). 727 728 This method should be overridden by subclasses. A game should always 729 be prepared to end and deliver results, even if there is no 'winner' 730 yet; this way things like the standard time-limit 731 (bascenev1.GameActivity.setup_standard_time_limit()) will work with 732 the game. 733 """ 734 print( 735 'WARNING: default end_game() implementation called;' 736 ' your game should override this.' 737 ) 738 739 def respawn_player( 740 self, player: PlayerT, respawn_time: float | None = None 741 ) -> None: 742 """ 743 Given a bascenev1.Player, sets up a standard respawn timer, 744 along with the standard counter display, etc. 745 At the end of the respawn period spawn_player() will 746 be called if the Player still exists. 747 An explicit 'respawn_time' can optionally be provided 748 (in seconds). 749 """ 750 # pylint: disable=cyclic-import 751 752 assert player 753 if respawn_time is None: 754 teamsize = len(player.team.players) 755 if teamsize == 1: 756 respawn_time = 3.0 757 elif teamsize == 2: 758 respawn_time = 5.0 759 elif teamsize == 3: 760 respawn_time = 6.0 761 else: 762 respawn_time = 7.0 763 764 # If this standard setting is present, factor it in. 765 if 'Respawn Times' in self.settings_raw: 766 respawn_time *= self.settings_raw['Respawn Times'] 767 768 # We want whole seconds. 769 assert respawn_time is not None 770 respawn_time = round(max(1.0, respawn_time), 0) 771 772 if player.actor and not self.has_ended(): 773 from bascenev1lib.actor.respawnicon import RespawnIcon 774 775 player.customdata['respawn_timer'] = _bascenev1.Timer( 776 respawn_time, 777 babase.WeakCall(self.spawn_player_if_exists, player), 778 ) 779 player.customdata['respawn_icon'] = RespawnIcon( 780 player, respawn_time 781 ) 782 783 def spawn_player_if_exists(self, player: PlayerT) -> None: 784 """ 785 A utility method which calls self.spawn_player() *only* if the 786 bascenev1.Player provided still exists; handy for use in timers 787 and whatnot. 788 789 There is no need to override this; just override spawn_player(). 790 """ 791 if player: 792 self.spawn_player(player) 793 794 def spawn_player(self, player: PlayerT) -> bascenev1.Actor: 795 """Spawn *something* for the provided bascenev1.Player. 796 797 The default implementation simply calls spawn_player_spaz(). 798 """ 799 assert player # Dead references should never be passed as args. 800 801 return self.spawn_player_spaz(player) 802 803 def spawn_player_spaz( 804 self, 805 player: PlayerT, 806 position: Sequence[float] = (0, 0, 0), 807 angle: float | None = None, 808 ) -> PlayerSpaz: 809 """Create and wire up a bascenev1.PlayerSpaz for the provided Player.""" 810 # pylint: disable=too-many-locals 811 # pylint: disable=cyclic-import 812 from bascenev1._gameutils import animate 813 from bascenev1._coopsession import CoopSession 814 from bascenev1lib.actor.playerspaz import PlayerSpaz 815 816 name = player.getname() 817 color = player.color 818 highlight = player.highlight 819 820 playerspaztype = getattr(player, 'playerspaztype', PlayerSpaz) 821 if not issubclass(playerspaztype, PlayerSpaz): 822 playerspaztype = PlayerSpaz 823 824 light_color = babase.normalized_color(color) 825 display_color = babase.safecolor(color, target_intensity=0.75) 826 spaz = playerspaztype( 827 color=color, 828 highlight=highlight, 829 character=player.character, 830 player=player, 831 ) 832 833 player.actor = spaz 834 assert spaz.node 835 836 # If this is co-op and we're on Courtyard or Runaround, add the 837 # material that allows us to collide with the player-walls. 838 # FIXME: Need to generalize this. 839 if isinstance(self.session, CoopSession) and self.map.getname() in [ 840 'Courtyard', 841 'Tower D', 842 ]: 843 mat = self.map.preloaddata['collide_with_wall_material'] 844 assert isinstance(spaz.node.materials, tuple) 845 assert isinstance(spaz.node.roller_materials, tuple) 846 spaz.node.materials += (mat,) 847 spaz.node.roller_materials += (mat,) 848 849 spaz.node.name = name 850 spaz.node.name_color = display_color 851 spaz.connect_controls_to_player() 852 853 # Move to the stand position and add a flash of light. 854 spaz.handlemessage( 855 StandMessage( 856 position, angle if angle is not None else random.uniform(0, 360) 857 ) 858 ) 859 self._spawn_sound.play(1, position=spaz.node.position) 860 light = _bascenev1.newnode('light', attrs={'color': light_color}) 861 spaz.node.connectattr('position', light, 'position') 862 animate(light, 'intensity', {0: 0, 0.25: 1, 0.5: 0}) 863 _bascenev1.timer(0.5, light.delete) 864 return spaz 865 866 def setup_standard_powerup_drops(self, enable_tnt: bool = True) -> None: 867 """Create standard powerup drops for the current map.""" 868 # pylint: disable=cyclic-import 869 from bascenev1lib.actor.powerupbox import DEFAULT_POWERUP_INTERVAL 870 871 self._powerup_drop_timer = _bascenev1.Timer( 872 DEFAULT_POWERUP_INTERVAL, 873 babase.WeakCall(self._standard_drop_powerups), 874 repeat=True, 875 ) 876 self._standard_drop_powerups() 877 if enable_tnt: 878 self._tnt_spawners = {} 879 self._setup_standard_tnt_drops() 880 881 def _standard_drop_powerup(self, index: int, expire: bool = True) -> None: 882 # pylint: disable=cyclic-import 883 from bascenev1lib.actor.powerupbox import PowerupBox, PowerupBoxFactory 884 885 PowerupBox( 886 position=self.map.powerup_spawn_points[index], 887 poweruptype=PowerupBoxFactory.get().get_random_powerup_type(), 888 expire=expire, 889 ).autoretain() 890 891 def _standard_drop_powerups(self) -> None: 892 """Standard powerup drop.""" 893 894 # Drop one powerup per point. 895 points = self.map.powerup_spawn_points 896 for i in range(len(points)): 897 _bascenev1.timer( 898 i * 0.4, babase.WeakCall(self._standard_drop_powerup, i) 899 ) 900 901 def _setup_standard_tnt_drops(self) -> None: 902 """Standard tnt drop.""" 903 # pylint: disable=cyclic-import 904 from bascenev1lib.actor.bomb import TNTSpawner 905 906 for i, point in enumerate(self.map.tnt_points): 907 assert self._tnt_spawners is not None 908 if self._tnt_spawners.get(i) is None: 909 self._tnt_spawners[i] = TNTSpawner(point) 910 911 def setup_standard_time_limit(self, duration: float) -> None: 912 """ 913 Create a standard game time-limit given the provided 914 duration in seconds. 915 This will be displayed at the top of the screen. 916 If the time-limit expires, end_game() will be called. 917 """ 918 from bascenev1._nodeactor import NodeActor 919 920 if duration <= 0.0: 921 return 922 self._standard_time_limit_time = int(duration) 923 self._standard_time_limit_timer = _bascenev1.Timer( 924 1.0, babase.WeakCall(self._standard_time_limit_tick), repeat=True 925 ) 926 self._standard_time_limit_text = NodeActor( 927 _bascenev1.newnode( 928 'text', 929 attrs={ 930 'v_attach': 'top', 931 'h_attach': 'center', 932 'h_align': 'left', 933 'color': (1.0, 1.0, 1.0, 0.5), 934 'position': (-25, -30), 935 'flatness': 1.0, 936 'scale': 0.9, 937 }, 938 ) 939 ) 940 self._standard_time_limit_text_input = NodeActor( 941 _bascenev1.newnode( 942 'timedisplay', attrs={'time2': duration * 1000, 'timemin': 0} 943 ) 944 ) 945 self.globalsnode.connectattr( 946 'time', self._standard_time_limit_text_input.node, 'time1' 947 ) 948 assert self._standard_time_limit_text_input.node 949 assert self._standard_time_limit_text.node 950 self._standard_time_limit_text_input.node.connectattr( 951 'output', self._standard_time_limit_text.node, 'text' 952 ) 953 954 def _standard_time_limit_tick(self) -> None: 955 from bascenev1._gameutils import animate 956 957 assert self._standard_time_limit_time is not None 958 self._standard_time_limit_time -= 1 959 if self._standard_time_limit_time <= 10: 960 if self._standard_time_limit_time == 10: 961 assert self._standard_time_limit_text is not None 962 assert self._standard_time_limit_text.node 963 self._standard_time_limit_text.node.scale = 1.3 964 self._standard_time_limit_text.node.position = (-30, -45) 965 cnode = _bascenev1.newnode( 966 'combine', 967 owner=self._standard_time_limit_text.node, 968 attrs={'size': 4}, 969 ) 970 cnode.connectattr( 971 'output', self._standard_time_limit_text.node, 'color' 972 ) 973 animate(cnode, 'input0', {0: 1, 0.15: 1}, loop=True) 974 animate(cnode, 'input1', {0: 1, 0.15: 0.5}, loop=True) 975 animate(cnode, 'input2', {0: 0.1, 0.15: 0.0}, loop=True) 976 cnode.input3 = 1.0 977 _bascenev1.getsound('tick').play() 978 if self._standard_time_limit_time <= 0: 979 self._standard_time_limit_timer = None 980 self.end_game() 981 node = _bascenev1.newnode( 982 'text', 983 attrs={ 984 'v_attach': 'top', 985 'h_attach': 'center', 986 'h_align': 'center', 987 'color': (1, 0.7, 0, 1), 988 'position': (0, -90), 989 'scale': 1.2, 990 'text': babase.Lstr(resource='timeExpiredText'), 991 }, 992 ) 993 _bascenev1.getsound('refWhistle').play() 994 animate(node, 'scale', {0.0: 0.0, 0.1: 1.4, 0.15: 1.2}) 995 996 def _setup_tournament_time_limit(self, duration: float) -> None: 997 """ 998 Create a tournament game time-limit given the provided 999 duration in seconds. 1000 This will be displayed at the top of the screen. 1001 If the time-limit expires, end_game() will be called. 1002 """ 1003 from bascenev1._nodeactor import NodeActor 1004 1005 if duration <= 0.0: 1006 return 1007 self._tournament_time_limit = int(duration) 1008 1009 # We want this timer to match the server's time as close as possible, 1010 # so lets go with base-time. Theoretically we should do real-time but 1011 # then we have to mess with contexts and whatnot since its currently 1012 # not available in activity contexts. :-/ 1013 self._tournament_time_limit_timer = _bascenev1.BaseTimer( 1014 1.0, babase.WeakCall(self._tournament_time_limit_tick), repeat=True 1015 ) 1016 self._tournament_time_limit_title_text = NodeActor( 1017 _bascenev1.newnode( 1018 'text', 1019 attrs={ 1020 'v_attach': 'bottom', 1021 'h_attach': 'left', 1022 'h_align': 'center', 1023 'v_align': 'center', 1024 'vr_depth': 300, 1025 'maxwidth': 100, 1026 'color': (1.0, 1.0, 1.0, 0.5), 1027 'position': (60, 50), 1028 'flatness': 1.0, 1029 'scale': 0.5, 1030 'text': babase.Lstr(resource='tournamentText'), 1031 }, 1032 ) 1033 ) 1034 self._tournament_time_limit_text = NodeActor( 1035 _bascenev1.newnode( 1036 'text', 1037 attrs={ 1038 'v_attach': 'bottom', 1039 'h_attach': 'left', 1040 'h_align': 'center', 1041 'v_align': 'center', 1042 'vr_depth': 300, 1043 'maxwidth': 100, 1044 'color': (1.0, 1.0, 1.0, 0.5), 1045 'position': (60, 30), 1046 'flatness': 1.0, 1047 'scale': 0.9, 1048 }, 1049 ) 1050 ) 1051 self._tournament_time_limit_text_input = NodeActor( 1052 _bascenev1.newnode( 1053 'timedisplay', 1054 attrs={ 1055 'timemin': 0, 1056 'time2': self._tournament_time_limit * 1000, 1057 }, 1058 ) 1059 ) 1060 assert self._tournament_time_limit_text.node 1061 assert self._tournament_time_limit_text_input.node 1062 self._tournament_time_limit_text_input.node.connectattr( 1063 'output', self._tournament_time_limit_text.node, 'text' 1064 ) 1065 1066 def _tournament_time_limit_tick(self) -> None: 1067 from bascenev1._gameutils import animate 1068 1069 assert self._tournament_time_limit is not None 1070 self._tournament_time_limit -= 1 1071 if self._tournament_time_limit <= 10: 1072 if self._tournament_time_limit == 10: 1073 assert self._tournament_time_limit_title_text is not None 1074 assert self._tournament_time_limit_title_text.node 1075 assert self._tournament_time_limit_text is not None 1076 assert self._tournament_time_limit_text.node 1077 self._tournament_time_limit_title_text.node.scale = 1.0 1078 self._tournament_time_limit_text.node.scale = 1.3 1079 self._tournament_time_limit_title_text.node.position = (80, 85) 1080 self._tournament_time_limit_text.node.position = (80, 60) 1081 cnode = _bascenev1.newnode( 1082 'combine', 1083 owner=self._tournament_time_limit_text.node, 1084 attrs={'size': 4}, 1085 ) 1086 cnode.connectattr( 1087 'output', 1088 self._tournament_time_limit_title_text.node, 1089 'color', 1090 ) 1091 cnode.connectattr( 1092 'output', self._tournament_time_limit_text.node, 'color' 1093 ) 1094 animate(cnode, 'input0', {0: 1, 0.15: 1}, loop=True) 1095 animate(cnode, 'input1', {0: 1, 0.15: 0.5}, loop=True) 1096 animate(cnode, 'input2', {0: 0.1, 0.15: 0.0}, loop=True) 1097 cnode.input3 = 1.0 1098 _bascenev1.getsound('tick').play() 1099 if self._tournament_time_limit <= 0: 1100 self._tournament_time_limit_timer = None 1101 self.end_game() 1102 tval = babase.Lstr( 1103 resource='tournamentTimeExpiredText', 1104 fallback_resource='timeExpiredText', 1105 ) 1106 node = _bascenev1.newnode( 1107 'text', 1108 attrs={ 1109 'v_attach': 'top', 1110 'h_attach': 'center', 1111 'h_align': 'center', 1112 'color': (1, 0.7, 0, 1), 1113 'position': (0, -200), 1114 'scale': 1.6, 1115 'text': tval, 1116 }, 1117 ) 1118 _bascenev1.getsound('refWhistle').play() 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 1129 def show_zoom_message( 1130 self, 1131 message: babase.Lstr, 1132 *, 1133 color: Sequence[float] = (0.9, 0.4, 0.0), 1134 scale: float = 0.8, 1135 duration: float = 2.0, 1136 trail: bool = False, 1137 ) -> None: 1138 """Zooming text used to announce game names and winners.""" 1139 # pylint: disable=cyclic-import 1140 from bascenev1lib.actor.zoomtext import ZoomText 1141 1142 # Reserve a spot on the screen (in case we get multiple of these so 1143 # they don't overlap). 1144 i = 0 1145 cur_time = babase.apptime() 1146 while True: 1147 if ( 1148 i not in self._zoom_message_times 1149 or self._zoom_message_times[i] < cur_time 1150 ): 1151 self._zoom_message_times[i] = cur_time + duration 1152 break 1153 i += 1 1154 ZoomText( 1155 message, 1156 lifespan=duration, 1157 jitter=2.0, 1158 position=(0, 200 - i * 100), 1159 scale=scale, 1160 maxwidth=800, 1161 trail=trail, 1162 color=color, 1163 ).autoretain() 1164 1165 def _calc_map_name(self, settings: dict) -> str: 1166 map_name: str 1167 if 'map' in settings: 1168 map_name = settings['map'] 1169 else: 1170 # If settings doesn't specify a map, pick a random one from the 1171 # list of supported ones. 1172 unowned_maps: list[str] = ( 1173 babase.app.classic.store.get_unowned_maps() 1174 if babase.app.classic is not None 1175 else [] 1176 ) 1177 valid_maps: list[str] = [ 1178 m 1179 for m in self.get_supported_maps(type(self.session)) 1180 if m not in unowned_maps 1181 ] 1182 if not valid_maps: 1183 _bascenev1.broadcastmessage( 1184 babase.Lstr(resource='noValidMapsErrorText') 1185 ) 1186 raise RuntimeError('No valid maps') 1187 map_name = valid_maps[random.randrange(len(valid_maps))] 1188 return map_name
Common base class for all game bascenev1.Activities.
205 def __init__(self, settings: dict): 206 """Instantiate the Activity.""" 207 super().__init__(settings) 208 209 # Holds some flattened info about the player set at the point 210 # when on_begin() is called. 211 self.initialplayerinfos: list[bascenev1.PlayerInfo] | None = None 212 213 # Go ahead and get our map loading. 214 self._map_type = _map.get_map_class(self._calc_map_name(settings)) 215 216 self._spawn_sound = _bascenev1.getsound('spawn') 217 self._map_type.preload() 218 self._map: bascenev1.Map | None = None 219 self._powerup_drop_timer: bascenev1.Timer | None = None 220 self._tnt_spawners: dict[int, TNTSpawner] | None = None 221 self._tnt_drop_timer: bascenev1.Timer | None = None 222 self._game_scoreboard_name_text: bascenev1.Actor | None = None 223 self._game_scoreboard_description_text: bascenev1.Actor | None = None 224 self._standard_time_limit_time: int | None = None 225 self._standard_time_limit_timer: bascenev1.Timer | None = None 226 self._standard_time_limit_text: bascenev1.NodeActor | None = None 227 self._standard_time_limit_text_input: bascenev1.NodeActor | None = None 228 self._tournament_time_limit: int | None = None 229 self._tournament_time_limit_timer: bascenev1.BaseTimer | None = None 230 self._tournament_time_limit_title_text: bascenev1.NodeActor | None = ( 231 None 232 ) 233 self._tournament_time_limit_text: bascenev1.NodeActor | None = None 234 self._tournament_time_limit_text_input: bascenev1.NodeActor | None = ( 235 None 236 ) 237 self._zoom_message_times: dict[int, float] = {}
Instantiate the Activity.
Whether idle players can potentially be kicked (should not happen in menus/etc).
65 @classmethod 66 def getscoreconfig(cls) -> bascenev1.ScoreConfig: 67 """Return info about game scoring setup; can be overridden by games.""" 68 return cls.scoreconfig if cls.scoreconfig is not None else ScoreConfig()
Return info about game scoring setup; can be overridden by games.
70 @classmethod 71 def getname(cls) -> str: 72 """Return a str name for this game type. 73 74 This default implementation simply returns the 'name' class attr. 75 """ 76 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.
78 @classmethod 79 def get_display_string(cls, settings: dict | None = None) -> babase.Lstr: 80 """Return a descriptive name for this game/settings combo. 81 82 Subclasses should override getname(); not this. 83 """ 84 name = babase.Lstr(translate=('gameNames', cls.getname())) 85 86 # A few substitutions for 'Epic', 'Solo' etc. modes. 87 # FIXME: Should provide a way for game types to define filters of 88 # their own and should not rely on hard-coded settings names. 89 if settings is not None: 90 if 'Solo Mode' in settings and settings['Solo Mode']: 91 name = babase.Lstr( 92 resource='soloNameFilterText', subs=[('${NAME}', name)] 93 ) 94 if 'Epic Mode' in settings and settings['Epic Mode']: 95 name = babase.Lstr( 96 resource='epicNameFilterText', subs=[('${NAME}', name)] 97 ) 98 99 return name
Return a descriptive name for this game/settings combo.
Subclasses should override getname(); not this.
101 @classmethod 102 def get_team_display_string(cls, name: str) -> babase.Lstr: 103 """Given a team name, returns a localized version of it.""" 104 return babase.Lstr(translate=('teamNames', name))
Given a team name, returns a localized version of it.
106 @classmethod 107 def get_description(cls, sessiontype: type[bascenev1.Session]) -> str: 108 """Get a str description of this game type. 109 110 The default implementation simply returns the 'description' class var. 111 Classes which want to change their description depending on the session 112 can override this method. 113 """ 114 del sessiontype # Unused arg. 115 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.
117 @classmethod 118 def get_description_display_string( 119 cls, sessiontype: type[bascenev1.Session] 120 ) -> babase.Lstr: 121 """Return a translated version of get_description(). 122 123 Sub-classes should override get_description(); not this. 124 """ 125 description = cls.get_description(sessiontype) 126 return babase.Lstr(translate=('gameDescriptions', description))
Return a translated version of get_description().
Sub-classes should override get_description(); not this.
128 @classmethod 129 def get_available_settings( 130 cls, sessiontype: type[bascenev1.Session] 131 ) -> list[bascenev1.Setting]: 132 """Return a list of settings relevant to this game type when 133 running under the provided session type. 134 """ 135 del sessiontype # Unused arg. 136 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.
138 @classmethod 139 def get_supported_maps( 140 cls, sessiontype: type[bascenev1.Session] 141 ) -> list[str]: 142 """ 143 Called by the default bascenev1.GameActivity.create_settings_ui() 144 implementation; should return a list of map names valid 145 for this game-type for the given bascenev1.Session type. 146 """ 147 del sessiontype # Unused arg. 148 assert babase.app.classic is not None 149 return babase.app.classic.getmaps('melee')
Called by the default bascenev1.GameActivity.create_settings_ui() implementation; should return a list of map names valid for this game-type for the given bascenev1.Session type.
151 @classmethod 152 def get_settings_display_string(cls, config: dict[str, Any]) -> babase.Lstr: 153 """Given a game config dict, return a short description for it. 154 155 This is used when viewing game-lists or showing what game 156 is up next in a series. 157 """ 158 name = cls.get_display_string(config['settings']) 159 160 # In newer configs, map is in settings; it used to be in the 161 # config root. 162 if 'map' in config['settings']: 163 sval = babase.Lstr( 164 value='${NAME} @ ${MAP}', 165 subs=[ 166 ('${NAME}', name), 167 ( 168 '${MAP}', 169 _map.get_map_display_string( 170 _map.get_filtered_map_name( 171 config['settings']['map'] 172 ) 173 ), 174 ), 175 ], 176 ) 177 elif 'map' in config: 178 sval = babase.Lstr( 179 value='${NAME} @ ${MAP}', 180 subs=[ 181 ('${NAME}', name), 182 ( 183 '${MAP}', 184 _map.get_map_display_string( 185 _map.get_filtered_map_name(config['map']) 186 ), 187 ), 188 ], 189 ) 190 else: 191 print('invalid game config - expected map entry under settings') 192 sval = babase.Lstr(value='???') 193 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.
195 @classmethod 196 def supports_session_type( 197 cls, sessiontype: type[bascenev1.Session] 198 ) -> bool: 199 """Return whether this game supports the provided Session type.""" 200 from bascenev1._multiteamsession import MultiTeamSession 201 202 # By default, games support any versus mode 203 return issubclass(sessiontype, MultiTeamSession)
Return whether this game supports the provided Session type.
239 @property 240 def map(self) -> _map.Map: 241 """The map being used for this game. 242 243 Raises a bascenev1.MapNotFoundError if the map does not currently 244 exist. 245 """ 246 if self._map is None: 247 raise babase.MapNotFoundError 248 return self._map
The map being used for this game.
Raises a bascenev1.MapNotFoundError if the map does not currently exist.
250 def get_instance_display_string(self) -> babase.Lstr: 251 """Return a name for this particular game instance.""" 252 return self.get_display_string(self.settings_raw)
Return a name for this particular game instance.
255 def get_instance_scoreboard_display_string(self) -> babase.Lstr: 256 """Return a name for this particular game instance. 257 258 This name is used above the game scoreboard in the corner 259 of the screen, so it should be as concise as possible. 260 """ 261 # If we're in a co-op session, use the level name. 262 # FIXME: Should clean this up. 263 try: 264 from bascenev1._coopsession import CoopSession 265 266 if isinstance(self.session, CoopSession): 267 campaign = self.session.campaign 268 assert campaign is not None 269 return campaign.getlevel( 270 self.session.campaign_level_name 271 ).displayname 272 except Exception: 273 logging.exception('Error getting campaign level name.') 274 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.
276 def get_instance_description(self) -> str | Sequence: 277 """Return a description for this game instance, in English. 278 279 This is shown in the center of the screen below the game name at the 280 start of a game. It should start with a capital letter and end with a 281 period, and can be a bit more verbose than the version returned by 282 get_instance_description_short(). 283 284 Note that translation is applied by looking up the specific returned 285 value as a key, so the number of returned variations should be limited; 286 ideally just one or two. To include arbitrary values in the 287 description, you can return a sequence of values in the following 288 form instead of just a string: 289 290 # This will give us something like 'Score 3 goals.' in English 291 # and can properly translate to 'Anota 3 goles.' in Spanish. 292 # If we just returned the string 'Score 3 Goals' here, there would 293 # have to be a translation entry for each specific number. ew. 294 return ['Score ${ARG1} goals.', self.settings_raw['Score to Win']] 295 296 This way the first string can be consistently translated, with any arg 297 values then substituted into the result. ${ARG1} will be replaced with 298 the first value, ${ARG2} with the second, etc. 299 """ 300 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.
302 def get_instance_description_short(self) -> str | Sequence: 303 """Return a short description for this game instance in English. 304 305 This description is used above the game scoreboard in the 306 corner of the screen, so it should be as concise as possible. 307 It should be lowercase and should not contain periods or other 308 punctuation. 309 310 Note that translation is applied by looking up the specific returned 311 value as a key, so the number of returned variations should be limited; 312 ideally just one or two. To include arbitrary values in the 313 description, you can return a sequence of values in the following form 314 instead of just a string: 315 316 # This will give us something like 'score 3 goals' in English 317 # and can properly translate to 'anota 3 goles' in Spanish. 318 # If we just returned the string 'score 3 goals' here, there would 319 # have to be a translation entry for each specific number. ew. 320 return ['score ${ARG1} goals', self.settings_raw['Score to Win']] 321 322 This way the first string can be consistently translated, with any arg 323 values then substituted into the result. ${ARG1} will be replaced 324 with the first value, ${ARG2} with the second, etc. 325 326 """ 327 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.
329 @override 330 def on_transition_in(self) -> None: 331 super().on_transition_in() 332 333 # Make our map. 334 self._map = self._map_type() 335 336 # Give our map a chance to override the music. 337 # (for happy-thoughts and other such themed maps) 338 map_music = self._map_type.get_music_type() 339 music = map_music if map_music is not None else self.default_music 340 341 if music is not None: 342 _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 bascenev1.Activity.on_begin() is called.
344 @override 345 def on_begin(self) -> None: 346 super().on_begin() 347 348 if babase.app.classic is not None: 349 babase.app.classic.game_begin_analytics() 350 351 # We don't do this in on_transition_in because it may depend on 352 # players/teams which aren't available until now. 353 _bascenev1.timer(0.001, self._show_scoreboard_info) 354 _bascenev1.timer(1.0, self._show_info) 355 _bascenev1.timer(2.5, self._show_tip) 356 357 # Store some basic info about players present at start time. 358 self.initialplayerinfos = [ 359 PlayerInfo(name=p.getname(full=True), character=p.character) 360 for p in self.players 361 ] 362 363 # Sort this by name so high score lists/etc will be consistent 364 # regardless of player join order. 365 self.initialplayerinfos.sort(key=lambda x: x.name) 366 367 # If this is a tournament, query info about it such as how much 368 # time is left. 369 tournament_id = self.session.tournament_id 370 if tournament_id is not None: 371 assert babase.app.plus is not None 372 babase.app.plus.tournament_query( 373 args={ 374 'tournamentIDs': [tournament_id], 375 'source': 'in-game time remaining query', 376 }, 377 callback=babase.WeakCall(self._on_tournament_query_response), 378 )
Called once the previous 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.
393 @override 394 def on_player_join(self, player: PlayerT) -> None: 395 super().on_player_join(player) 396 397 # By default, just spawn a dude. 398 self.spawn_player(player)
Called when a new bascenev1.Player has joined the Activity.
(including the initial set of Players)
400 @override 401 def handlemessage(self, msg: Any) -> Any: 402 if isinstance(msg, PlayerDiedMessage): 403 # pylint: disable=cyclic-import 404 from bascenev1lib.actor.spaz import Spaz 405 406 player = msg.getplayer(self.playertype) 407 killer = msg.getkillerplayer(self.playertype) 408 409 # Inform our stats of the demise. 410 self.stats.player_was_killed( 411 player, killed=msg.killed, killer=killer 412 ) 413 414 # Award the killer points if he's on a different team. 415 # FIXME: This should not be linked to Spaz actors. 416 # (should move get_death_points to Actor or make it a message) 417 if killer and killer.team is not player.team: 418 assert isinstance(killer.actor, Spaz) 419 pts, importance = killer.actor.get_death_points(msg.how) 420 if not self.has_ended(): 421 self.stats.player_scored( 422 killer, 423 pts, 424 kill=True, 425 victim_player=player, 426 importance=importance, 427 showpoints=self.show_kill_points, 428 ) 429 else: 430 return super().handlemessage(msg) 431 return None
General message handling; can be passed any message object.
694 @override 695 def end( 696 self, results: Any = None, delay: float = 0.0, force: bool = False 697 ) -> None: 698 from bascenev1._gameresults import GameResults 699 700 # If results is a standard team-game-results, associate it with us 701 # so it can grab our score prefs. 702 if isinstance(results, GameResults): 703 results.set_game(self) 704 705 # If we had a standard time-limit that had not expired, stop it so 706 # it doesnt tick annoyingly. 707 if ( 708 self._standard_time_limit_time is not None 709 and self._standard_time_limit_time > 0 710 ): 711 self._standard_time_limit_timer = None 712 self._standard_time_limit_text = None 713 714 # Ditto with tournament time limits. 715 if ( 716 self._tournament_time_limit is not None 717 and self._tournament_time_limit > 0 718 ): 719 self._tournament_time_limit_timer = None 720 self._tournament_time_limit_text = None 721 self._tournament_time_limit_title_text = None 722 723 super().end(results, delay, force)
Commences Activity shutdown and delivers results to the 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.
725 def end_game(self) -> None: 726 """Tell the game to wrap up and call bascenev1.Activity.end(). 727 728 This method should be overridden by subclasses. A game should always 729 be prepared to end and deliver results, even if there is no 'winner' 730 yet; this way things like the standard time-limit 731 (bascenev1.GameActivity.setup_standard_time_limit()) will work with 732 the game. 733 """ 734 print( 735 'WARNING: default end_game() implementation called;' 736 ' your game should override this.' 737 )
Tell the game to wrap up and call bascenev1.Activity.end().
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 (bascenev1.GameActivity.setup_standard_time_limit()) will work with the game.
739 def respawn_player( 740 self, player: PlayerT, respawn_time: float | None = None 741 ) -> None: 742 """ 743 Given a bascenev1.Player, sets up a standard respawn timer, 744 along with the standard counter display, etc. 745 At the end of the respawn period spawn_player() will 746 be called if the Player still exists. 747 An explicit 'respawn_time' can optionally be provided 748 (in seconds). 749 """ 750 # pylint: disable=cyclic-import 751 752 assert player 753 if respawn_time is None: 754 teamsize = len(player.team.players) 755 if teamsize == 1: 756 respawn_time = 3.0 757 elif teamsize == 2: 758 respawn_time = 5.0 759 elif teamsize == 3: 760 respawn_time = 6.0 761 else: 762 respawn_time = 7.0 763 764 # If this standard setting is present, factor it in. 765 if 'Respawn Times' in self.settings_raw: 766 respawn_time *= self.settings_raw['Respawn Times'] 767 768 # We want whole seconds. 769 assert respawn_time is not None 770 respawn_time = round(max(1.0, respawn_time), 0) 771 772 if player.actor and not self.has_ended(): 773 from bascenev1lib.actor.respawnicon import RespawnIcon 774 775 player.customdata['respawn_timer'] = _bascenev1.Timer( 776 respawn_time, 777 babase.WeakCall(self.spawn_player_if_exists, player), 778 ) 779 player.customdata['respawn_icon'] = RespawnIcon( 780 player, respawn_time 781 )
Given a bascenev1.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).
783 def spawn_player_if_exists(self, player: PlayerT) -> None: 784 """ 785 A utility method which calls self.spawn_player() *only* if the 786 bascenev1.Player provided still exists; handy for use in timers 787 and whatnot. 788 789 There is no need to override this; just override spawn_player(). 790 """ 791 if player: 792 self.spawn_player(player)
A utility method which calls self.spawn_player() only if the bascenev1.Player provided still exists; handy for use in timers and whatnot.
There is no need to override this; just override spawn_player().
794 def spawn_player(self, player: PlayerT) -> bascenev1.Actor: 795 """Spawn *something* for the provided bascenev1.Player. 796 797 The default implementation simply calls spawn_player_spaz(). 798 """ 799 assert player # Dead references should never be passed as args. 800 801 return self.spawn_player_spaz(player)
Spawn something for the provided bascenev1.Player.
The default implementation simply calls spawn_player_spaz().
803 def spawn_player_spaz( 804 self, 805 player: PlayerT, 806 position: Sequence[float] = (0, 0, 0), 807 angle: float | None = None, 808 ) -> PlayerSpaz: 809 """Create and wire up a bascenev1.PlayerSpaz for the provided Player.""" 810 # pylint: disable=too-many-locals 811 # pylint: disable=cyclic-import 812 from bascenev1._gameutils import animate 813 from bascenev1._coopsession import CoopSession 814 from bascenev1lib.actor.playerspaz import PlayerSpaz 815 816 name = player.getname() 817 color = player.color 818 highlight = player.highlight 819 820 playerspaztype = getattr(player, 'playerspaztype', PlayerSpaz) 821 if not issubclass(playerspaztype, PlayerSpaz): 822 playerspaztype = PlayerSpaz 823 824 light_color = babase.normalized_color(color) 825 display_color = babase.safecolor(color, target_intensity=0.75) 826 spaz = playerspaztype( 827 color=color, 828 highlight=highlight, 829 character=player.character, 830 player=player, 831 ) 832 833 player.actor = spaz 834 assert spaz.node 835 836 # If this is co-op and we're on Courtyard or Runaround, add the 837 # material that allows us to collide with the player-walls. 838 # FIXME: Need to generalize this. 839 if isinstance(self.session, CoopSession) and self.map.getname() in [ 840 'Courtyard', 841 'Tower D', 842 ]: 843 mat = self.map.preloaddata['collide_with_wall_material'] 844 assert isinstance(spaz.node.materials, tuple) 845 assert isinstance(spaz.node.roller_materials, tuple) 846 spaz.node.materials += (mat,) 847 spaz.node.roller_materials += (mat,) 848 849 spaz.node.name = name 850 spaz.node.name_color = display_color 851 spaz.connect_controls_to_player() 852 853 # Move to the stand position and add a flash of light. 854 spaz.handlemessage( 855 StandMessage( 856 position, angle if angle is not None else random.uniform(0, 360) 857 ) 858 ) 859 self._spawn_sound.play(1, position=spaz.node.position) 860 light = _bascenev1.newnode('light', attrs={'color': light_color}) 861 spaz.node.connectattr('position', light, 'position') 862 animate(light, 'intensity', {0: 0, 0.25: 1, 0.5: 0}) 863 _bascenev1.timer(0.5, light.delete) 864 return spaz
Create and wire up a bascenev1.PlayerSpaz for the provided Player.
866 def setup_standard_powerup_drops(self, enable_tnt: bool = True) -> None: 867 """Create standard powerup drops for the current map.""" 868 # pylint: disable=cyclic-import 869 from bascenev1lib.actor.powerupbox import DEFAULT_POWERUP_INTERVAL 870 871 self._powerup_drop_timer = _bascenev1.Timer( 872 DEFAULT_POWERUP_INTERVAL, 873 babase.WeakCall(self._standard_drop_powerups), 874 repeat=True, 875 ) 876 self._standard_drop_powerups() 877 if enable_tnt: 878 self._tnt_spawners = {} 879 self._setup_standard_tnt_drops()
Create standard powerup drops for the current map.
911 def setup_standard_time_limit(self, duration: float) -> None: 912 """ 913 Create a standard game time-limit given the provided 914 duration in seconds. 915 This will be displayed at the top of the screen. 916 If the time-limit expires, end_game() will be called. 917 """ 918 from bascenev1._nodeactor import NodeActor 919 920 if duration <= 0.0: 921 return 922 self._standard_time_limit_time = int(duration) 923 self._standard_time_limit_timer = _bascenev1.Timer( 924 1.0, babase.WeakCall(self._standard_time_limit_tick), repeat=True 925 ) 926 self._standard_time_limit_text = NodeActor( 927 _bascenev1.newnode( 928 'text', 929 attrs={ 930 'v_attach': 'top', 931 'h_attach': 'center', 932 'h_align': 'left', 933 'color': (1.0, 1.0, 1.0, 0.5), 934 'position': (-25, -30), 935 'flatness': 1.0, 936 'scale': 0.9, 937 }, 938 ) 939 ) 940 self._standard_time_limit_text_input = NodeActor( 941 _bascenev1.newnode( 942 'timedisplay', attrs={'time2': duration * 1000, 'timemin': 0} 943 ) 944 ) 945 self.globalsnode.connectattr( 946 'time', self._standard_time_limit_text_input.node, 'time1' 947 ) 948 assert self._standard_time_limit_text_input.node 949 assert self._standard_time_limit_text.node 950 self._standard_time_limit_text_input.node.connectattr( 951 'output', self._standard_time_limit_text.node, 'text' 952 )
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.
1129 def show_zoom_message( 1130 self, 1131 message: babase.Lstr, 1132 *, 1133 color: Sequence[float] = (0.9, 0.4, 0.0), 1134 scale: float = 0.8, 1135 duration: float = 2.0, 1136 trail: bool = False, 1137 ) -> None: 1138 """Zooming text used to announce game names and winners.""" 1139 # pylint: disable=cyclic-import 1140 from bascenev1lib.actor.zoomtext import ZoomText 1141 1142 # Reserve a spot on the screen (in case we get multiple of these so 1143 # they don't overlap). 1144 i = 0 1145 cur_time = babase.apptime() 1146 while True: 1147 if ( 1148 i not in self._zoom_message_times 1149 or self._zoom_message_times[i] < cur_time 1150 ): 1151 self._zoom_message_times[i] = cur_time + duration 1152 break 1153 i += 1 1154 ZoomText( 1155 message, 1156 lifespan=duration, 1157 jitter=2.0, 1158 position=(0, 200 - i * 100), 1159 scale=scale, 1160 maxwidth=800, 1161 trail=trail, 1162 color=color, 1163 ).autoretain()
Zooming text used to announce game names and winners.
31class GameResults: 32 """ 33 Results for a completed game. 34 35 Upon completion, a game should fill one of these out and pass it to its 36 bascenev1.Activity.end call. 37 """ 38 39 def __init__(self) -> None: 40 self._game_set = False 41 self._scores: dict[ 42 int, tuple[weakref.ref[bascenev1.SessionTeam], int | None] 43 ] = {} 44 self._sessionteams: list[weakref.ref[bascenev1.SessionTeam]] | None = ( 45 None 46 ) 47 self._playerinfos: list[bascenev1.PlayerInfo] | None = None 48 self._lower_is_better: bool | None = None 49 self._score_label: str | None = None 50 self._none_is_winner: bool | None = None 51 self._scoretype: bascenev1.ScoreType | None = None 52 53 def set_game(self, game: bascenev1.GameActivity) -> None: 54 """Set the game instance these results are applying to.""" 55 if self._game_set: 56 raise RuntimeError('Game set twice for GameResults.') 57 self._game_set = True 58 self._sessionteams = [ 59 weakref.ref(team.sessionteam) for team in game.teams 60 ] 61 scoreconfig = game.getscoreconfig() 62 self._playerinfos = copy.deepcopy(game.initialplayerinfos) 63 self._lower_is_better = scoreconfig.lower_is_better 64 self._score_label = scoreconfig.label 65 self._none_is_winner = scoreconfig.none_is_winner 66 self._scoretype = scoreconfig.scoretype 67 68 def set_team_score(self, team: bascenev1.Team, score: int | None) -> None: 69 """Set the score for a given team. 70 71 This can be a number or None. 72 (see the none_is_winner arg in the constructor) 73 """ 74 assert isinstance(team, Team) 75 sessionteam = team.sessionteam 76 self._scores[sessionteam.id] = (weakref.ref(sessionteam), score) 77 78 def get_sessionteam_score( 79 self, sessionteam: bascenev1.SessionTeam 80 ) -> int | None: 81 """Return the score for a given bascenev1.SessionTeam.""" 82 assert isinstance(sessionteam, SessionTeam) 83 for score in list(self._scores.values()): 84 if score[0]() is sessionteam: 85 return score[1] 86 87 # If we have no score value, assume None. 88 return None 89 90 @property 91 def sessionteams(self) -> list[bascenev1.SessionTeam]: 92 """Return all bascenev1.SessionTeams in the results.""" 93 if not self._game_set: 94 raise RuntimeError("Can't get teams until game is set.") 95 teams = [] 96 assert self._sessionteams is not None 97 for team_ref in self._sessionteams: 98 team = team_ref() 99 if team is not None: 100 teams.append(team) 101 return teams 102 103 def has_score_for_sessionteam( 104 self, sessionteam: bascenev1.SessionTeam 105 ) -> bool: 106 """Return whether there is a score for a given session-team.""" 107 return any(s[0]() is sessionteam for s in self._scores.values()) 108 109 def get_sessionteam_score_str( 110 self, sessionteam: bascenev1.SessionTeam 111 ) -> babase.Lstr: 112 """Return the score for the given session-team as an Lstr. 113 114 (properly formatted for the score type.) 115 """ 116 from bascenev1._score import ScoreType 117 118 if not self._game_set: 119 raise RuntimeError("Can't get team-score-str until game is set.") 120 for score in list(self._scores.values()): 121 if score[0]() is sessionteam: 122 if score[1] is None: 123 return babase.Lstr(value='-') 124 if self._scoretype is ScoreType.SECONDS: 125 return babase.timestring(score[1], centi=False) 126 if self._scoretype is ScoreType.MILLISECONDS: 127 return babase.timestring(score[1] / 1000.0, centi=True) 128 return babase.Lstr(value=str(score[1])) 129 return babase.Lstr(value='-') 130 131 @property 132 def playerinfos(self) -> list[bascenev1.PlayerInfo]: 133 """Get info about the players represented by the results.""" 134 if not self._game_set: 135 raise RuntimeError("Can't get player-info until game is set.") 136 assert self._playerinfos is not None 137 return self._playerinfos 138 139 @property 140 def scoretype(self) -> bascenev1.ScoreType: 141 """The type of score.""" 142 if not self._game_set: 143 raise RuntimeError("Can't get score-type until game is set.") 144 assert self._scoretype is not None 145 return self._scoretype 146 147 @property 148 def score_label(self) -> str: 149 """The label associated with scores ('points', etc).""" 150 if not self._game_set: 151 raise RuntimeError("Can't get score-label until game is set.") 152 assert self._score_label is not None 153 return self._score_label 154 155 @property 156 def lower_is_better(self) -> bool: 157 """Whether lower scores are better.""" 158 if not self._game_set: 159 raise RuntimeError("Can't get lower-is-better until game is set.") 160 assert self._lower_is_better is not None 161 return self._lower_is_better 162 163 @property 164 def winning_sessionteam(self) -> bascenev1.SessionTeam | None: 165 """The winning SessionTeam if there is exactly one, or else None.""" 166 if not self._game_set: 167 raise RuntimeError("Can't get winners until game is set.") 168 winners = self.winnergroups 169 if winners and len(winners[0].teams) == 1: 170 return winners[0].teams[0] 171 return None 172 173 @property 174 def winnergroups(self) -> list[WinnerGroup]: 175 """Get an ordered list of winner groups.""" 176 if not self._game_set: 177 raise RuntimeError("Can't get winners until game is set.") 178 179 # Group by best scoring teams. 180 winners: dict[int, list[bascenev1.SessionTeam]] = {} 181 scores = [ 182 score 183 for score in self._scores.values() 184 if score[0]() is not None and score[1] is not None 185 ] 186 for score in scores: 187 assert score[1] is not None 188 sval = winners.setdefault(score[1], []) 189 team = score[0]() 190 assert team is not None 191 sval.append(team) 192 results: list[tuple[int | None, list[bascenev1.SessionTeam]]] = list( 193 winners.items() 194 ) 195 results.sort( 196 reverse=not self._lower_is_better, 197 key=lambda x: asserttype(x[0], int), 198 ) 199 200 # Also group the 'None' scores. 201 none_sessionteams: list[bascenev1.SessionTeam] = [] 202 for score in self._scores.values(): 203 scoreteam = score[0]() 204 if scoreteam is not None and score[1] is None: 205 none_sessionteams.append(scoreteam) 206 207 # Add the Nones to the list (either as winners or losers 208 # depending on the rules). 209 if none_sessionteams: 210 nones: list[tuple[int | None, list[bascenev1.SessionTeam]]] = [ 211 (None, none_sessionteams) 212 ] 213 if self._none_is_winner: 214 results = nones + results 215 else: 216 results = results + nones 217 218 return [WinnerGroup(score, team) for score, team in results]
Results for a completed game.
Upon completion, a game should fill one of these out and pass it to its bascenev1.Activity.end call.
53 def set_game(self, game: bascenev1.GameActivity) -> None: 54 """Set the game instance these results are applying to.""" 55 if self._game_set: 56 raise RuntimeError('Game set twice for GameResults.') 57 self._game_set = True 58 self._sessionteams = [ 59 weakref.ref(team.sessionteam) for team in game.teams 60 ] 61 scoreconfig = game.getscoreconfig() 62 self._playerinfos = copy.deepcopy(game.initialplayerinfos) 63 self._lower_is_better = scoreconfig.lower_is_better 64 self._score_label = scoreconfig.label 65 self._none_is_winner = scoreconfig.none_is_winner 66 self._scoretype = scoreconfig.scoretype
Set the game instance these results are applying to.
68 def set_team_score(self, team: bascenev1.Team, score: int | None) -> None: 69 """Set the score for a given team. 70 71 This can be a number or None. 72 (see the none_is_winner arg in the constructor) 73 """ 74 assert isinstance(team, Team) 75 sessionteam = team.sessionteam 76 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)
78 def get_sessionteam_score( 79 self, sessionteam: bascenev1.SessionTeam 80 ) -> int | None: 81 """Return the score for a given bascenev1.SessionTeam.""" 82 assert isinstance(sessionteam, SessionTeam) 83 for score in list(self._scores.values()): 84 if score[0]() is sessionteam: 85 return score[1] 86 87 # If we have no score value, assume None. 88 return None
Return the score for a given bascenev1.SessionTeam.
90 @property 91 def sessionteams(self) -> list[bascenev1.SessionTeam]: 92 """Return all bascenev1.SessionTeams in the results.""" 93 if not self._game_set: 94 raise RuntimeError("Can't get teams until game is set.") 95 teams = [] 96 assert self._sessionteams is not None 97 for team_ref in self._sessionteams: 98 team = team_ref() 99 if team is not None: 100 teams.append(team) 101 return teams
Return all bascenev1.SessionTeams in the results.
103 def has_score_for_sessionteam( 104 self, sessionteam: bascenev1.SessionTeam 105 ) -> bool: 106 """Return whether there is a score for a given session-team.""" 107 return any(s[0]() is sessionteam for s in self._scores.values())
Return whether there is a score for a given session-team.
109 def get_sessionteam_score_str( 110 self, sessionteam: bascenev1.SessionTeam 111 ) -> babase.Lstr: 112 """Return the score for the given session-team as an Lstr. 113 114 (properly formatted for the score type.) 115 """ 116 from bascenev1._score import ScoreType 117 118 if not self._game_set: 119 raise RuntimeError("Can't get team-score-str until game is set.") 120 for score in list(self._scores.values()): 121 if score[0]() is sessionteam: 122 if score[1] is None: 123 return babase.Lstr(value='-') 124 if self._scoretype is ScoreType.SECONDS: 125 return babase.timestring(score[1], centi=False) 126 if self._scoretype is ScoreType.MILLISECONDS: 127 return babase.timestring(score[1] / 1000.0, centi=True) 128 return babase.Lstr(value=str(score[1])) 129 return babase.Lstr(value='-')
Return the score for the given session-team as an Lstr.
(properly formatted for the score type.)
131 @property 132 def playerinfos(self) -> list[bascenev1.PlayerInfo]: 133 """Get info about the players represented by the results.""" 134 if not self._game_set: 135 raise RuntimeError("Can't get player-info until game is set.") 136 assert self._playerinfos is not None 137 return self._playerinfos
Get info about the players represented by the results.
139 @property 140 def scoretype(self) -> bascenev1.ScoreType: 141 """The type of score.""" 142 if not self._game_set: 143 raise RuntimeError("Can't get score-type until game is set.") 144 assert self._scoretype is not None 145 return self._scoretype
The type of score.
147 @property 148 def score_label(self) -> str: 149 """The label associated with scores ('points', etc).""" 150 if not self._game_set: 151 raise RuntimeError("Can't get score-label until game is set.") 152 assert self._score_label is not None 153 return self._score_label
The label associated with scores ('points', etc).
155 @property 156 def lower_is_better(self) -> bool: 157 """Whether lower scores are better.""" 158 if not self._game_set: 159 raise RuntimeError("Can't get lower-is-better until game is set.") 160 assert self._lower_is_better is not None 161 return self._lower_is_better
Whether lower scores are better.
163 @property 164 def winning_sessionteam(self) -> bascenev1.SessionTeam | None: 165 """The winning SessionTeam if there is exactly one, or else None.""" 166 if not self._game_set: 167 raise RuntimeError("Can't get winners until game is set.") 168 winners = self.winnergroups 169 if winners and len(winners[0].teams) == 1: 170 return winners[0].teams[0] 171 return None
The winning SessionTeam if there is exactly one, or else None.
173 @property 174 def winnergroups(self) -> list[WinnerGroup]: 175 """Get an ordered list of winner groups.""" 176 if not self._game_set: 177 raise RuntimeError("Can't get winners until game is set.") 178 179 # Group by best scoring teams. 180 winners: dict[int, list[bascenev1.SessionTeam]] = {} 181 scores = [ 182 score 183 for score in self._scores.values() 184 if score[0]() is not None and score[1] is not None 185 ] 186 for score in scores: 187 assert score[1] is not None 188 sval = winners.setdefault(score[1], []) 189 team = score[0]() 190 assert team is not None 191 sval.append(team) 192 results: list[tuple[int | None, list[bascenev1.SessionTeam]]] = list( 193 winners.items() 194 ) 195 results.sort( 196 reverse=not self._lower_is_better, 197 key=lambda x: asserttype(x[0], int), 198 ) 199 200 # Also group the 'None' scores. 201 none_sessionteams: list[bascenev1.SessionTeam] = [] 202 for score in self._scores.values(): 203 scoreteam = score[0]() 204 if scoreteam is not None and score[1] is None: 205 none_sessionteams.append(scoreteam) 206 207 # Add the Nones to the list (either as winners or losers 208 # depending on the rules). 209 if none_sessionteams: 210 nones: list[tuple[int | None, list[bascenev1.SessionTeam]]] = [ 211 (None, none_sessionteams) 212 ] 213 if self._none_is_winner: 214 results = nones + results 215 else: 216 results = results + nones 217 218 return [WinnerGroup(score, team) for score, team in results]
Get an ordered list of winner groups.
33@dataclass 34class GameTip: 35 """Defines a tip presentable to the user at the start of a game.""" 36 37 text: str 38 icon: bascenev1.Texture | None = None 39 sound: bascenev1.Sound | None = None
Defines a tip presentable to the user at the start of a game.
1148def get_connection_to_host_info_2() -> bascenev1.HostInfo | None: 1149 """Return info about the host we are currently connected to.""" 1150 import bascenev1 # pylint: disable=cyclic-import 1151 1152 return bascenev1.HostInfo('dummyname', -1, 'dummy_addr', -1)
Return info about the host we are currently connected to.
226def get_default_free_for_all_playlist() -> PlaylistType: 227 """Return a default playlist for free-for-all mode.""" 228 229 # NOTE: these are currently using old type/map names, 230 # but filtering translates them properly to the new ones. 231 # (is kinda a handy way to ensure filtering is working). 232 # Eventually should update these though. 233 return [ 234 { 235 'settings': { 236 'Epic Mode': False, 237 'Kills to Win Per Player': 10, 238 'Respawn Times': 1.0, 239 'Time Limit': 300, 240 'map': 'Doom Shroom', 241 }, 242 'type': 'bs_death_match.DeathMatchGame', 243 }, 244 { 245 'settings': { 246 'Chosen One Gets Gloves': True, 247 'Chosen One Gets Shield': False, 248 'Chosen One Time': 30, 249 'Epic Mode': 0, 250 'Respawn Times': 1.0, 251 'Time Limit': 300, 252 'map': 'Monkey Face', 253 }, 254 'type': 'bs_chosen_one.ChosenOneGame', 255 }, 256 { 257 'settings': { 258 'Hold Time': 30, 259 'Respawn Times': 1.0, 260 'Time Limit': 300, 261 'map': 'Zigzag', 262 }, 263 'type': 'bs_king_of_the_hill.KingOfTheHillGame', 264 }, 265 { 266 'settings': {'Epic Mode': False, 'map': 'Rampage'}, 267 'type': 'bs_meteor_shower.MeteorShowerGame', 268 }, 269 { 270 'settings': { 271 'Epic Mode': 1, 272 'Lives Per Player': 1, 273 'Respawn Times': 1.0, 274 'Time Limit': 120, 275 'map': 'Tip Top', 276 }, 277 'type': 'bs_elimination.EliminationGame', 278 }, 279 { 280 'settings': { 281 'Hold Time': 30, 282 'Respawn Times': 1.0, 283 'Time Limit': 300, 284 'map': 'The Pad', 285 }, 286 'type': 'bs_keep_away.KeepAwayGame', 287 }, 288 { 289 'settings': { 290 'Epic Mode': True, 291 'Kills to Win Per Player': 10, 292 'Respawn Times': 0.25, 293 'Time Limit': 120, 294 'map': 'Rampage', 295 }, 296 'type': 'bs_death_match.DeathMatchGame', 297 }, 298 { 299 'settings': { 300 'Bomb Spawning': 1000, 301 'Epic Mode': False, 302 'Laps': 3, 303 'Mine Spawn Interval': 4000, 304 'Mine Spawning': 4000, 305 'Time Limit': 300, 306 'map': 'Big G', 307 }, 308 'type': 'bs_race.RaceGame', 309 }, 310 { 311 'settings': { 312 'Hold Time': 30, 313 'Respawn Times': 1.0, 314 'Time Limit': 300, 315 'map': 'Happy Thoughts', 316 }, 317 'type': 'bs_king_of_the_hill.KingOfTheHillGame', 318 }, 319 { 320 'settings': { 321 'Enable Impact Bombs': 1, 322 'Enable Triple Bombs': False, 323 'Target Count': 2, 324 'map': 'Doom Shroom', 325 }, 326 'type': 'bs_target_practice.TargetPracticeGame', 327 }, 328 { 329 'settings': { 330 'Epic Mode': False, 331 'Lives Per Player': 5, 332 'Respawn Times': 1.0, 333 'Time Limit': 300, 334 'map': 'Step Right Up', 335 }, 336 'type': 'bs_elimination.EliminationGame', 337 }, 338 { 339 'settings': { 340 'Epic Mode': False, 341 'Kills to Win Per Player': 10, 342 'Respawn Times': 1.0, 343 'Time Limit': 300, 344 'map': 'Crag Castle', 345 }, 346 'type': 'bs_death_match.DeathMatchGame', 347 }, 348 { 349 'map': 'Lake Frigid', 350 'settings': { 351 'Bomb Spawning': 0, 352 'Epic Mode': False, 353 'Laps': 6, 354 'Mine Spawning': 2000, 355 'Time Limit': 300, 356 'map': 'Lake Frigid', 357 }, 358 'type': 'bs_race.RaceGame', 359 }, 360 ]
Return a default playlist for free-for-all mode.
363def get_default_teams_playlist() -> PlaylistType: 364 """Return a default playlist for teams mode.""" 365 366 # NOTE: these are currently using old type/map names, 367 # but filtering translates them properly to the new ones. 368 # (is kinda a handy way to ensure filtering is working). 369 # Eventually should update these though. 370 return [ 371 { 372 'settings': { 373 'Epic Mode': False, 374 'Flag Idle Return Time': 30, 375 'Flag Touch Return Time': 0, 376 'Respawn Times': 1.0, 377 'Score to Win': 3, 378 'Time Limit': 600, 379 'map': 'Bridgit', 380 }, 381 'type': 'bs_capture_the_flag.CTFGame', 382 }, 383 { 384 'settings': { 385 'Epic Mode': False, 386 'Respawn Times': 1.0, 387 'Score to Win': 3, 388 'Time Limit': 600, 389 'map': 'Step Right Up', 390 }, 391 'type': 'bs_assault.AssaultGame', 392 }, 393 { 394 'settings': { 395 'Balance Total Lives': False, 396 'Epic Mode': False, 397 'Lives Per Player': 3, 398 'Respawn Times': 1.0, 399 'Solo Mode': True, 400 'Time Limit': 600, 401 'map': 'Rampage', 402 }, 403 'type': 'bs_elimination.EliminationGame', 404 }, 405 { 406 'settings': { 407 'Epic Mode': False, 408 'Kills to Win Per Player': 5, 409 'Respawn Times': 1.0, 410 'Time Limit': 300, 411 'map': 'Roundabout', 412 }, 413 'type': 'bs_death_match.DeathMatchGame', 414 }, 415 { 416 'settings': { 417 'Respawn Times': 1.0, 418 'Score to Win': 1, 419 'Time Limit': 600, 420 'map': 'Hockey Stadium', 421 }, 422 'type': 'bs_hockey.HockeyGame', 423 }, 424 { 425 'settings': { 426 'Hold Time': 30, 427 'Respawn Times': 1.0, 428 'Time Limit': 300, 429 'map': 'Monkey Face', 430 }, 431 'type': 'bs_keep_away.KeepAwayGame', 432 }, 433 { 434 'settings': { 435 'Balance Total Lives': False, 436 'Epic Mode': True, 437 'Lives Per Player': 1, 438 'Respawn Times': 1.0, 439 'Solo Mode': False, 440 'Time Limit': 120, 441 'map': 'Tip Top', 442 }, 443 'type': 'bs_elimination.EliminationGame', 444 }, 445 { 446 'settings': { 447 'Epic Mode': False, 448 'Respawn Times': 1.0, 449 'Score to Win': 3, 450 'Time Limit': 300, 451 'map': 'Crag Castle', 452 }, 453 'type': 'bs_assault.AssaultGame', 454 }, 455 { 456 'settings': { 457 'Epic Mode': False, 458 'Kills to Win Per Player': 5, 459 'Respawn Times': 1.0, 460 'Time Limit': 300, 461 'map': 'Doom Shroom', 462 }, 463 'type': 'bs_death_match.DeathMatchGame', 464 }, 465 { 466 'settings': {'Epic Mode': False, 'map': 'Rampage'}, 467 'type': 'bs_meteor_shower.MeteorShowerGame', 468 }, 469 { 470 'settings': { 471 'Epic Mode': False, 472 'Flag Idle Return Time': 30, 473 'Flag Touch Return Time': 0, 474 'Respawn Times': 1.0, 475 'Score to Win': 2, 476 'Time Limit': 600, 477 'map': 'Roundabout', 478 }, 479 'type': 'bs_capture_the_flag.CTFGame', 480 }, 481 { 482 'settings': { 483 'Respawn Times': 1.0, 484 'Score to Win': 21, 485 'Time Limit': 600, 486 'map': 'Football Stadium', 487 }, 488 'type': 'bs_football.FootballTeamGame', 489 }, 490 { 491 'settings': { 492 'Epic Mode': True, 493 'Respawn Times': 0.25, 494 'Score to Win': 3, 495 'Time Limit': 120, 496 'map': 'Bridgit', 497 }, 498 'type': 'bs_assault.AssaultGame', 499 }, 500 { 501 'map': 'Doom Shroom', 502 'settings': { 503 'Enable Impact Bombs': 1, 504 'Enable Triple Bombs': False, 505 'Target Count': 2, 506 'map': 'Doom Shroom', 507 }, 508 'type': 'bs_target_practice.TargetPracticeGame', 509 }, 510 { 511 'settings': { 512 'Hold Time': 30, 513 'Respawn Times': 1.0, 514 'Time Limit': 300, 515 'map': 'Tip Top', 516 }, 517 'type': 'bs_king_of_the_hill.KingOfTheHillGame', 518 }, 519 { 520 'settings': { 521 'Epic Mode': False, 522 'Respawn Times': 1.0, 523 'Score to Win': 2, 524 'Time Limit': 300, 525 'map': 'Zigzag', 526 }, 527 'type': 'bs_assault.AssaultGame', 528 }, 529 { 530 'settings': { 531 'Epic Mode': False, 532 'Flag Idle Return Time': 30, 533 'Flag Touch Return Time': 0, 534 'Respawn Times': 1.0, 535 'Score to Win': 3, 536 'Time Limit': 300, 537 'map': 'Happy Thoughts', 538 }, 539 'type': 'bs_capture_the_flag.CTFGame', 540 }, 541 { 542 'settings': { 543 'Bomb Spawning': 1000, 544 'Epic Mode': True, 545 'Laps': 1, 546 'Mine Spawning': 2000, 547 'Time Limit': 300, 548 'map': 'Big G', 549 }, 550 'type': 'bs_race.RaceGame', 551 }, 552 { 553 'settings': { 554 'Epic Mode': False, 555 'Kills to Win Per Player': 5, 556 'Respawn Times': 1.0, 557 'Time Limit': 300, 558 'map': 'Monkey Face', 559 }, 560 'type': 'bs_death_match.DeathMatchGame', 561 }, 562 { 563 'settings': { 564 'Hold Time': 30, 565 'Respawn Times': 1.0, 566 'Time Limit': 300, 567 'map': 'Lake Frigid', 568 }, 569 'type': 'bs_keep_away.KeepAwayGame', 570 }, 571 { 572 'settings': { 573 'Epic Mode': False, 574 'Flag Idle Return Time': 30, 575 'Flag Touch Return Time': 3, 576 'Respawn Times': 1.0, 577 'Score to Win': 2, 578 'Time Limit': 300, 579 'map': 'Tip Top', 580 }, 581 'type': 'bs_capture_the_flag.CTFGame', 582 }, 583 { 584 'settings': { 585 'Balance Total Lives': False, 586 'Epic Mode': False, 587 'Lives Per Player': 3, 588 'Respawn Times': 1.0, 589 'Solo Mode': False, 590 'Time Limit': 300, 591 'map': 'Crag Castle', 592 }, 593 'type': 'bs_elimination.EliminationGame', 594 }, 595 { 596 'settings': { 597 'Epic Mode': True, 598 'Respawn Times': 0.25, 599 'Time Limit': 120, 600 'map': 'Zigzag', 601 }, 602 'type': 'bs_conquest.ConquestGame', 603 }, 604 ]
Return a default playlist for teams mode.
46def get_default_powerup_distribution() -> Sequence[tuple[str, int]]: 47 """Standard set of powerups.""" 48 return ( 49 ('triple_bombs', 3), 50 ('ice_bombs', 3), 51 ('punch', 3), 52 ('impact_bombs', 3), 53 ('land_mines', 2), 54 ('sticky_bombs', 3), 55 ('shield', 2), 56 ('health', 1), 57 ('curse', 1), 58 )
Standard set of powerups.
21def get_filtered_map_name(name: str) -> str: 22 """Filter a map name to account for name changes, etc. 23 24 This can be used to support old playlists, etc. 25 """ 26 # Some legacy name fallbacks... can remove these eventually. 27 if name in ('AlwaysLand', 'Happy Land'): 28 name = 'Happy Thoughts' 29 if name == 'Hockey Arena': 30 name = 'Hockey Stadium' 31 return name
Filter a map name to account for name changes, etc.
This can be used to support old playlists, etc.
39def get_map_class(name: str) -> type[Map]: 40 """Return a map type given a name.""" 41 assert babase.app.classic is not None 42 name = get_filtered_map_name(name) 43 try: 44 mapclass: type[Map] = babase.app.classic.maps[name] 45 return mapclass 46 except KeyError: 47 raise babase.NotFoundError(f"Map not found: '{name}'") from None
Return a map type given a name.
34def get_map_display_string(name: str) -> babase.Lstr: 35 """Return a babase.Lstr for displaying a given map's name.""" 36 return babase.Lstr(translate=('mapsNames', name))
Return a babase.Lstr for displaying a given map's name.
37def get_player_colors() -> list[tuple[float, float, float]]: 38 """Return user-selectable player colors.""" 39 return PLAYER_COLORS
Return user-selectable player colors.
63def get_player_profile_colors( 64 profilename: str | None, profiles: dict[str, dict[str, Any]] | None = None 65) -> tuple[tuple[float, float, float], tuple[float, float, float]]: 66 """Given a profile, return colors for them.""" 67 appconfig = babase.app.config 68 if profiles is None: 69 profiles = appconfig['Player Profiles'] 70 71 # Special case: when being asked for a random color in kiosk mode, 72 # always return default purple. 73 if (babase.app.env.demo or babase.app.env.arcade) and profilename is None: 74 color = (0.5, 0.4, 1.0) 75 highlight = (0.4, 0.4, 0.5) 76 else: 77 try: 78 assert profilename is not None 79 color = profiles[profilename]['color'] 80 except (KeyError, AssertionError): 81 # Key off name if possible. 82 if profilename is None: 83 # First 6 are bright-ish. 84 color = PLAYER_COLORS[random.randrange(6)] 85 else: 86 # First 6 are bright-ish. 87 color = PLAYER_COLORS[sum(ord(c) for c in profilename) % 6] 88 89 try: 90 assert profilename is not None 91 highlight = profiles[profilename]['highlight'] 92 except (KeyError, AssertionError): 93 # Key off name if possible. 94 if profilename is None: 95 # Last 2 are grey and white; ignore those or we 96 # get lots of old-looking players. 97 highlight = PLAYER_COLORS[ 98 random.randrange(len(PLAYER_COLORS) - 2) 99 ] 100 else: 101 highlight = PLAYER_COLORS[ 102 sum(ord(c) + 1 for c in profilename) 103 % (len(PLAYER_COLORS) - 2) 104 ] 105 106 return color, highlight
Given a profile, return colors for them.
42def get_player_profile_icon(profilename: str) -> str: 43 """Given a profile name, returns an icon string for it. 44 45 (non-account profiles only) 46 """ 47 appconfig = babase.app.config 48 icon: str 49 try: 50 is_global = appconfig['Player Profiles'][profilename]['global'] 51 except KeyError: 52 is_global = False 53 if is_global: 54 try: 55 icon = appconfig['Player Profiles'][profilename]['icon'] 56 except KeyError: 57 icon = babase.charstr(babase.SpecialChar.LOGO) 58 else: 59 icon = '' 60 return icon
Given a profile name, returns an icon string for it.
(non-account profiles only)
42def get_trophy_string(trophy_id: str) -> str: 43 """Given a trophy id, returns a string to visualize it.""" 44 if trophy_id in TROPHY_CHARS: 45 return babase.charstr(TROPHY_CHARS[trophy_id]) 46 return '?'
Given a trophy id, returns a string to visualize it.
1284def getactivity(doraise: bool = True) -> bascenev1.Activity | None: 1285 """Return the current bascenev1.Activity instance. 1286 1287 Note that this is based on context_ref; thus code run in a timer 1288 generated in Activity 'foo' will properly return 'foo' here, even if 1289 another Activity has since been created or is transitioning in. 1290 If there is no current Activity, raises a babase.ActivityNotFoundError. 1291 If doraise is False, None will be returned instead in that case. 1292 """ 1293 return None
Return the current bascenev1.Activity instance.
Note that this is based on context_ref; 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 babase.ActivityNotFoundError. If doraise is False, None will be returned instead in that case.
Return the in-progress collision.
1296def getcollisionmesh(name: str) -> bascenev1.CollisionMesh: 1297 """Return a collision-mesh, loading it if necessary. 1298 1299 Collision-meshes are used in physics calculations for such things as 1300 terrain. 1301 1302 Note that this function returns immediately even if the asset has yet 1303 to be loaded. To avoid hitches, instantiate your asset objects in 1304 advance of when you will be using them, allowing time for them to 1305 load in the background if necessary. 1306 """ 1307 import bascenev1 # pylint: disable=cyclic-import 1308 1309 return bascenev1.CollisionMesh()
Return a collision-mesh, loading it if necessary.
Collision-meshes are used in physics calculations for such things as terrain.
Note that this function returns immediately even if the asset has yet to be loaded. To avoid hitches, instantiate your asset objects in advance of when you will be using them, allowing time for them to load in the background if necessary.
1312def getdata(name: str) -> bascenev1.Data: 1313 """Return a data, loading it if necessary. 1314 1315 Note that this function returns immediately even if the asset has yet 1316 to be loaded. To avoid hitches, instantiate your asset objects in 1317 advance of when you will be using them, allowing time for them to 1318 load in the background if necessary. 1319 """ 1320 import bascenev1 # pylint: disable=cyclic-import 1321 1322 return bascenev1.Data()
Return a data, loading it if necessary.
Note that this function returns immediately even if the asset has yet to be loaded. To avoid hitches, instantiate your asset objects in advance of when you will be using them, allowing time for them to load in the background if necessary.
1348def getmesh(name: str) -> bascenev1.Mesh: 1349 """Return a mesh, loading it if necessary. 1350 1351 Note that this function returns immediately even if the asset has yet 1352 to be loaded. To avoid hitches, instantiate your asset objects in 1353 advance of when you will be using them, allowing time for them to 1354 load in the background if necessary. 1355 """ 1356 import bascenev1 # pylint: disable=cyclic-import 1357 1358 return bascenev1.Mesh()
Return a mesh, loading it if necessary.
Note that this function returns immediately even if the asset has yet to be loaded. To avoid hitches, instantiate your asset objects in advance of when you will be using them, allowing time for them to load in the background if necessary.
1361def getnodes() -> list: 1362 """Return all nodes in the current bascenev1.Context.""" 1363 return list()
Return all nodes in the current bascenev1.Context.
1375def getsession(doraise: bool = True) -> bascenev1.Session | None: 1376 """Returns the current bascenev1.Session instance. 1377 Note that this is based on context_ref; thus code being run in the UI 1378 context will return the UI context_ref here even if a game Session also 1379 exists, etc. If there is no current Session, an Exception is raised, or 1380 if doraise is False then None is returned instead. 1381 """ 1382 return None
Returns the current bascenev1.Session instance. Note that this is based on context_ref; thus code being run in the UI context will return the UI context_ref 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.
1385def getsound(name: str) -> bascenev1.Sound: 1386 """Return a sound, loading it if necessary. 1387 1388 Note that this function returns immediately even if the asset has yet 1389 to be loaded. To avoid hitches, instantiate your asset objects in 1390 advance of when you will be using them, allowing time for them to 1391 load in the background if necessary. 1392 """ 1393 import bascenev1 # pylint: disable=cyclic-import 1394 1395 return bascenev1.Sound()
Return a sound, loading it if necessary.
Note that this function returns immediately even if the asset has yet to be loaded. To avoid hitches, instantiate your asset objects in advance of when you will be using them, allowing time for them to load in the background if necessary.
1398def gettexture(name: str) -> bascenev1.Texture: 1399 """Return a texture, loading it if necessary. 1400 1401 Note that this function returns immediately even if the asset has yet 1402 to be loaded. To avoid hitches, instantiate your asset objects in 1403 advance of when you will be using them, allowing time for them to 1404 load in the background if necessary. 1405 """ 1406 import bascenev1 # pylint: disable=cyclic-import 1407 1408 return bascenev1.Texture()
Return a texture, loading it if necessary.
Note that this function returns immediately even if the asset has yet to be loaded. To avoid hitches, instantiate your asset objects in advance of when you will be using them, allowing time for them to load in the background if necessary.
198class HitMessage: 199 """Tells an object it has been hit in some way. 200 201 This is used by punches, explosions, etc to convey their effect to a 202 target. 203 """ 204 205 def __init__( 206 self, 207 *, 208 srcnode: bascenev1.Node | None = None, 209 pos: Sequence[float] | None = None, 210 velocity: Sequence[float] | None = None, 211 magnitude: float = 1.0, 212 velocity_magnitude: float = 0.0, 213 radius: float = 1.0, 214 source_player: bascenev1.Player | None = None, 215 kick_back: float = 1.0, 216 flat_damage: float | None = None, 217 hit_type: str = 'generic', 218 force_direction: Sequence[float] | None = None, 219 hit_subtype: str = 'default', 220 ): 221 """Instantiate a message with given values.""" 222 223 self.srcnode = srcnode 224 self.pos = pos if pos is not None else babase.Vec3() 225 self.velocity = velocity if velocity is not None else babase.Vec3() 226 self.magnitude = magnitude 227 self.velocity_magnitude = velocity_magnitude 228 self.radius = radius 229 230 # We should not be getting passed an invalid ref. 231 assert source_player is None or source_player.exists() 232 self._source_player = source_player 233 self.kick_back = kick_back 234 self.flat_damage = flat_damage 235 self.hit_type = hit_type 236 self.hit_subtype = hit_subtype 237 self.force_direction = ( 238 force_direction if force_direction is not None else velocity 239 ) 240 241 def get_source_player(self, playertype: type[PlayerT]) -> PlayerT | None: 242 """Return the source-player if one exists and is the provided type.""" 243 player: Any = self._source_player 244 245 # We should not be delivering invalid refs. 246 # (we could translate to None here but technically we are changing 247 # the message delivered which seems wrong) 248 assert player is None or player.exists() 249 250 # Return the player *only* if they're the type given. 251 return player if isinstance(player, playertype) else None
Tells an object it has been hit in some way.
This is used by punches, explosions, etc to convey their effect to a target.
205 def __init__( 206 self, 207 *, 208 srcnode: bascenev1.Node | None = None, 209 pos: Sequence[float] | None = None, 210 velocity: Sequence[float] | None = None, 211 magnitude: float = 1.0, 212 velocity_magnitude: float = 0.0, 213 radius: float = 1.0, 214 source_player: bascenev1.Player | None = None, 215 kick_back: float = 1.0, 216 flat_damage: float | None = None, 217 hit_type: str = 'generic', 218 force_direction: Sequence[float] | None = None, 219 hit_subtype: str = 'default', 220 ): 221 """Instantiate a message with given values.""" 222 223 self.srcnode = srcnode 224 self.pos = pos if pos is not None else babase.Vec3() 225 self.velocity = velocity if velocity is not None else babase.Vec3() 226 self.magnitude = magnitude 227 self.velocity_magnitude = velocity_magnitude 228 self.radius = radius 229 230 # We should not be getting passed an invalid ref. 231 assert source_player is None or source_player.exists() 232 self._source_player = source_player 233 self.kick_back = kick_back 234 self.flat_damage = flat_damage 235 self.hit_type = hit_type 236 self.hit_subtype = hit_subtype 237 self.force_direction = ( 238 force_direction if force_direction is not None else velocity 239 )
Instantiate a message with given values.
241 def get_source_player(self, playertype: type[PlayerT]) -> PlayerT | None: 242 """Return the source-player if one exists and is the provided type.""" 243 player: Any = self._source_player 244 245 # We should not be delivering invalid refs. 246 # (we could translate to None here but technically we are changing 247 # the message delivered which seems wrong) 248 assert player is None or player.exists() 249 250 # Return the player *only* if they're the type given. 251 return player if isinstance(player, playertype) else None
Return the source-player if one exists and is the provided type.
14@dataclass 15class HostInfo: 16 """Info about a host.""" 17 18 name: str 19 build_number: int 20 21 # Note this can be None for non-ip hosts such as bluetooth. 22 address: str | None 23 24 # Note this can be None for non-ip hosts such as bluetooth. 25 port: int | None
Info about a host.
166@dataclass 167class ImpactDamageMessage: 168 """Tells an object that it has been jarred violently.""" 169 170 intensity: float 171 """The intensity of the impact."""
Tells an object that it has been jarred violently.
102def init_campaigns() -> None: 103 """Fill out initial default Campaigns.""" 104 # pylint: disable=cyclic-import 105 from bascenev1._level import Level 106 from bascenev1lib.game.onslaught import OnslaughtGame 107 from bascenev1lib.game.football import FootballCoopGame 108 from bascenev1lib.game.runaround import RunaroundGame 109 from bascenev1lib.game.thelaststand import TheLastStandGame 110 from bascenev1lib.game.race import RaceGame 111 from bascenev1lib.game.targetpractice import TargetPracticeGame 112 from bascenev1lib.game.meteorshower import MeteorShowerGame 113 from bascenev1lib.game.easteregghunt import EasterEggHuntGame 114 from bascenev1lib.game.ninjafight import NinjaFightGame 115 116 # TODO: Campaigns should be load-on-demand; not all imported at launch 117 # like this. 118 119 # FIXME: Once translations catch up, we can convert these to use the 120 # generic display-name '${GAME} Training' type stuff. 121 register_campaign( 122 Campaign( 123 'Easy', 124 levels=[ 125 Level( 126 'Onslaught Training', 127 gametype=OnslaughtGame, 128 settings={'preset': 'training_easy'}, 129 preview_texture_name='doomShroomPreview', 130 ), 131 Level( 132 'Rookie Onslaught', 133 gametype=OnslaughtGame, 134 settings={'preset': 'rookie_easy'}, 135 preview_texture_name='courtyardPreview', 136 ), 137 Level( 138 'Rookie Football', 139 gametype=FootballCoopGame, 140 settings={'preset': 'rookie_easy'}, 141 preview_texture_name='footballStadiumPreview', 142 ), 143 Level( 144 'Pro Onslaught', 145 gametype=OnslaughtGame, 146 settings={'preset': 'pro_easy'}, 147 preview_texture_name='doomShroomPreview', 148 ), 149 Level( 150 'Pro Football', 151 gametype=FootballCoopGame, 152 settings={'preset': 'pro_easy'}, 153 preview_texture_name='footballStadiumPreview', 154 ), 155 Level( 156 'Pro Runaround', 157 gametype=RunaroundGame, 158 settings={'preset': 'pro_easy'}, 159 preview_texture_name='towerDPreview', 160 ), 161 Level( 162 'Uber Onslaught', 163 gametype=OnslaughtGame, 164 settings={'preset': 'uber_easy'}, 165 preview_texture_name='courtyardPreview', 166 ), 167 Level( 168 'Uber Football', 169 gametype=FootballCoopGame, 170 settings={'preset': 'uber_easy'}, 171 preview_texture_name='footballStadiumPreview', 172 ), 173 Level( 174 'Uber Runaround', 175 gametype=RunaroundGame, 176 settings={'preset': 'uber_easy'}, 177 preview_texture_name='towerDPreview', 178 ), 179 ], 180 ) 181 ) 182 183 # "hard" mode 184 register_campaign( 185 Campaign( 186 'Default', 187 levels=[ 188 Level( 189 'Onslaught Training', 190 gametype=OnslaughtGame, 191 settings={'preset': 'training'}, 192 preview_texture_name='doomShroomPreview', 193 ), 194 Level( 195 'Rookie Onslaught', 196 gametype=OnslaughtGame, 197 settings={'preset': 'rookie'}, 198 preview_texture_name='courtyardPreview', 199 ), 200 Level( 201 'Rookie Football', 202 gametype=FootballCoopGame, 203 settings={'preset': 'rookie'}, 204 preview_texture_name='footballStadiumPreview', 205 ), 206 Level( 207 'Pro Onslaught', 208 gametype=OnslaughtGame, 209 settings={'preset': 'pro'}, 210 preview_texture_name='doomShroomPreview', 211 ), 212 Level( 213 'Pro Football', 214 gametype=FootballCoopGame, 215 settings={'preset': 'pro'}, 216 preview_texture_name='footballStadiumPreview', 217 ), 218 Level( 219 'Pro Runaround', 220 gametype=RunaroundGame, 221 settings={'preset': 'pro'}, 222 preview_texture_name='towerDPreview', 223 ), 224 Level( 225 'Uber Onslaught', 226 gametype=OnslaughtGame, 227 settings={'preset': 'uber'}, 228 preview_texture_name='courtyardPreview', 229 ), 230 Level( 231 'Uber Football', 232 gametype=FootballCoopGame, 233 settings={'preset': 'uber'}, 234 preview_texture_name='footballStadiumPreview', 235 ), 236 Level( 237 'Uber Runaround', 238 gametype=RunaroundGame, 239 settings={'preset': 'uber'}, 240 preview_texture_name='towerDPreview', 241 ), 242 Level( 243 'The Last Stand', 244 gametype=TheLastStandGame, 245 settings={}, 246 preview_texture_name='rampagePreview', 247 ), 248 ], 249 ) 250 ) 251 252 # challenges: our 'official' random extra co-op levels 253 register_campaign( 254 Campaign( 255 'Challenges', 256 sequential=False, 257 levels=[ 258 Level( 259 'Infinite Onslaught', 260 gametype=OnslaughtGame, 261 settings={'preset': 'endless'}, 262 preview_texture_name='doomShroomPreview', 263 ), 264 Level( 265 'Infinite Runaround', 266 gametype=RunaroundGame, 267 settings={'preset': 'endless'}, 268 preview_texture_name='towerDPreview', 269 ), 270 Level( 271 'Race', 272 displayname='${GAME}', 273 gametype=RaceGame, 274 settings={'map': 'Big G', 'Laps': 3, 'Bomb Spawning': 0}, 275 preview_texture_name='bigGPreview', 276 ), 277 Level( 278 'Pro Race', 279 displayname='Pro ${GAME}', 280 gametype=RaceGame, 281 settings={'map': 'Big G', 'Laps': 3, 'Bomb Spawning': 1000}, 282 preview_texture_name='bigGPreview', 283 ), 284 Level( 285 'Lake Frigid Race', 286 displayname='${GAME}', 287 gametype=RaceGame, 288 settings={ 289 'map': 'Lake Frigid', 290 'Laps': 6, 291 'Mine Spawning': 2000, 292 'Bomb Spawning': 0, 293 }, 294 preview_texture_name='lakeFrigidPreview', 295 ), 296 Level( 297 'Football', 298 displayname='${GAME}', 299 gametype=FootballCoopGame, 300 settings={'preset': 'tournament'}, 301 preview_texture_name='footballStadiumPreview', 302 ), 303 Level( 304 'Pro Football', 305 displayname='Pro ${GAME}', 306 gametype=FootballCoopGame, 307 settings={'preset': 'tournament_pro'}, 308 preview_texture_name='footballStadiumPreview', 309 ), 310 Level( 311 'Runaround', 312 displayname='${GAME}', 313 gametype=RunaroundGame, 314 settings={'preset': 'tournament'}, 315 preview_texture_name='towerDPreview', 316 ), 317 Level( 318 'Uber Runaround', 319 displayname='Uber ${GAME}', 320 gametype=RunaroundGame, 321 settings={'preset': 'tournament_uber'}, 322 preview_texture_name='towerDPreview', 323 ), 324 Level( 325 'The Last Stand', 326 displayname='${GAME}', 327 gametype=TheLastStandGame, 328 settings={'preset': 'tournament'}, 329 preview_texture_name='rampagePreview', 330 ), 331 Level( 332 'Tournament Infinite Onslaught', 333 displayname='Infinite Onslaught', 334 gametype=OnslaughtGame, 335 settings={'preset': 'endless_tournament'}, 336 preview_texture_name='doomShroomPreview', 337 ), 338 Level( 339 'Tournament Infinite Runaround', 340 displayname='Infinite Runaround', 341 gametype=RunaroundGame, 342 settings={'preset': 'endless_tournament'}, 343 preview_texture_name='towerDPreview', 344 ), 345 Level( 346 'Target Practice', 347 displayname='Pro ${GAME}', 348 gametype=TargetPracticeGame, 349 settings={}, 350 preview_texture_name='doomShroomPreview', 351 ), 352 Level( 353 'Target Practice B', 354 displayname='${GAME}', 355 gametype=TargetPracticeGame, 356 settings={ 357 'Target Count': 2, 358 'Enable Impact Bombs': False, 359 'Enable Triple Bombs': False, 360 }, 361 preview_texture_name='doomShroomPreview', 362 ), 363 Level( 364 'Meteor Shower', 365 displayname='${GAME}', 366 gametype=MeteorShowerGame, 367 settings={}, 368 preview_texture_name='rampagePreview', 369 ), 370 Level( 371 'Epic Meteor Shower', 372 displayname='${GAME}', 373 gametype=MeteorShowerGame, 374 settings={'Epic Mode': True}, 375 preview_texture_name='rampagePreview', 376 ), 377 Level( 378 'Easter Egg Hunt', 379 displayname='${GAME}', 380 gametype=EasterEggHuntGame, 381 settings={}, 382 preview_texture_name='towerDPreview', 383 ), 384 Level( 385 'Pro Easter Egg Hunt', 386 displayname='Pro ${GAME}', 387 gametype=EasterEggHuntGame, 388 settings={'Pro Mode': True}, 389 preview_texture_name='towerDPreview', 390 ), 391 Level( 392 name='Ninja Fight', # (unique id not seen by player) 393 displayname='${GAME}', # (readable name seen by player) 394 gametype=NinjaFightGame, 395 settings={'preset': 'regular'}, 396 preview_texture_name='courtyardPreview', 397 ), 398 Level( 399 name='Pro Ninja Fight', 400 displayname='Pro ${GAME}', 401 gametype=NinjaFightGame, 402 settings={'preset': 'pro'}, 403 preview_texture_name='courtyardPreview', 404 ), 405 ], 406 ) 407 )
Fill out initial default Campaigns.
155class InputDevice: 156 """An input-device such as a gamepad, touchscreen, or keyboard.""" 157 158 allows_configuring: bool 159 """Whether the input-device can be configured in the app.""" 160 161 allows_configuring_in_system_settings: bool 162 """Whether the input-device can be configured in the system. 163 setings app. This can be used to redirect the user to go there 164 if they attempt to configure the device.""" 165 166 has_meaningful_button_names: bool 167 """Whether button names returned by this instance match labels 168 on the actual device. (Can be used to determine whether to show 169 them in controls-overlays, etc.).""" 170 171 player: bascenev1.SessionPlayer | None 172 """The player associated with this input device.""" 173 174 client_id: int 175 """The numeric client-id this device is associated with. 176 This is only meaningful for remote client inputs; for 177 all local devices this will be -1.""" 178 179 name: str 180 """The name of the device.""" 181 182 unique_identifier: str 183 """A string that can be used to persistently identify the device, 184 even among other devices of the same type. Used for saving 185 prefs, etc.""" 186 187 id: int 188 """The unique numeric id of this device.""" 189 190 instance_number: int 191 """The number of this device among devices of the same type.""" 192 193 is_controller_app: bool 194 """Whether this input-device represents a locally-connected 195 controller-app.""" 196 197 is_remote_client: bool 198 """Whether this input-device represents a remotely-connected 199 client.""" 200 201 is_test_input: bool 202 """Whether this input-device is a dummy device for testing.""" 203 204 def __bool__(self) -> bool: 205 """Support for bool evaluation.""" 206 return bool(True) # Slight obfuscation. 207 208 def detach_from_player(self) -> None: 209 """Detach the device from any player it is controlling. 210 211 This applies both to local players and remote players. 212 """ 213 return None 214 215 def exists(self) -> bool: 216 """Return whether the underlying device for this object is 217 still present. 218 """ 219 return bool() 220 221 def get_axis_name(self, axis_id: int) -> str: 222 """Given an axis ID, return the name of the axis on this device. 223 224 Can return an empty string if the value is not meaningful to humans. 225 """ 226 return str() 227 228 def get_button_name(self, button_id: int) -> babase.Lstr: 229 """Given a button ID, return a human-readable name for that key/button. 230 231 Can return an empty string if the value is not meaningful to humans. 232 """ 233 import babase # pylint: disable=cyclic-import 234 235 return babase.Lstr(value='') 236 237 def get_default_player_name(self) -> str: 238 """(internal) 239 240 Returns the default player name for this device. (used for the 'random' 241 profile) 242 """ 243 return str() 244 245 def get_player_profiles(self) -> dict: 246 """(internal)""" 247 return dict() 248 249 def get_v1_account_name(self, full: bool) -> str: 250 """Returns the account name associated with this device. 251 252 (can be used to get account names for remote players) 253 """ 254 return str() 255 256 def is_attached_to_player(self) -> bool: 257 """Return whether this device is controlling a player of some sort. 258 259 This can mean either a local player or a remote player. 260 """ 261 return bool()
An input-device such as a gamepad, touchscreen, or keyboard.
Whether the input-device can be configured in the system. setings app. This can be used to redirect the user to go there if they attempt to configure the device.
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.
208 def detach_from_player(self) -> None: 209 """Detach the device from any player it is controlling. 210 211 This applies both to local players and remote players. 212 """ 213 return None
Detach the device from any player it is controlling.
This applies both to local players and remote players.
215 def exists(self) -> bool: 216 """Return whether the underlying device for this object is 217 still present. 218 """ 219 return bool()
Return whether the underlying device for this object is still present.
221 def get_axis_name(self, axis_id: int) -> str: 222 """Given an axis ID, return the name of the axis on this device. 223 224 Can return an empty string if the value is not meaningful to humans. 225 """ 226 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.
249 def get_v1_account_name(self, full: bool) -> str: 250 """Returns the account name associated with this device. 251 252 (can be used to get account names for remote players) 253 """ 254 return str()
Returns the account name associated with this device.
(can be used to get account names for remote players)
256 def is_attached_to_player(self) -> bool: 257 """Return whether this device is controlling a player of some sort. 258 259 This can mean either a local player or a remote player. 260 """ 261 return bool()
Return whether this device is controlling a player of some sort.
This can mean either a local player or a remote player.
8class InputType(Enum): 9 """Types of input a controller can send to the game. 10 11 """ 12 13 UP_DOWN = 2 14 LEFT_RIGHT = 3 15 JUMP_PRESS = 4 16 JUMP_RELEASE = 5 17 PUNCH_PRESS = 6 18 PUNCH_RELEASE = 7 19 BOMB_PRESS = 8 20 BOMB_RELEASE = 9 21 PICK_UP_PRESS = 10 22 PICK_UP_RELEASE = 11 23 RUN = 12 24 FLY_PRESS = 13 25 FLY_RELEASE = 14 26 START_PRESS = 15 27 START_RELEASE = 16 28 HOLD_POSITION_PRESS = 17 29 HOLD_POSITION_RELEASE = 18 30 LEFT_PRESS = 19 31 LEFT_RELEASE = 20 32 RIGHT_PRESS = 21 33 RIGHT_RELEASE = 22 34 UP_PRESS = 23 35 UP_RELEASE = 24 36 DOWN_PRESS = 25 37 DOWN_RELEASE = 26
Types of input a controller can send to the game.
57@dataclass 58class IntChoiceSetting(ChoiceSetting): 59 """An int setting with multiple choices.""" 60 61 default: int 62 choices: list[tuple[str, int]]
An int setting with multiple choices.
30@dataclass 31class IntSetting(Setting): 32 """An integer game setting.""" 33 34 default: int 35 min_value: int = 0 36 max_value: int = 9999 37 increment: int = 1
An integer game setting.
38def is_point_in_box(pnt: Sequence[float], box: Sequence[float]) -> bool: 39 """Return whether a given point is within a given box. 40 41 category: General Utility Functions 42 43 For use with standard def boxes (position|rotate|scale). 44 """ 45 return ( 46 (abs(pnt[0] - box[0]) <= box[6] * 0.5) 47 and (abs(pnt[1] - box[1]) <= box[7] * 0.5) 48 and (abs(pnt[2] - box[2]) <= box[8] * 0.5) 49 )
Return whether a given point is within a given box.
category: General Utility Functions
For use with standard def boxes (position|rotate|scale).
59class JoinActivity(Activity[EmptyPlayer, EmptyTeam]): 60 """Standard activity for waiting for players to join. 61 62 It shows tips and other info and waits for all players to check ready. 63 """ 64 65 def __init__(self, settings: dict): 66 super().__init__(settings) 67 68 # This activity is a special 'joiner' activity. 69 # It will get shut down as soon as all players have checked ready. 70 self.is_joining_activity = True 71 72 # Players may be idle waiting for joiners; lets not kick them for it. 73 self.allow_kick_idle_players = False 74 75 # In vr mode we don't want stuff moving around. 76 self.use_fixed_vr_overlay = True 77 78 self._background: bascenev1.Actor | None = None 79 self._tips_text: bascenev1.Actor | None = None 80 self._join_info: JoinInfo | None = None 81 82 @override 83 def on_transition_in(self) -> None: 84 # pylint: disable=cyclic-import 85 from bascenev1lib.actor.tipstext import TipsText 86 from bascenev1lib.actor.background import Background 87 88 super().on_transition_in() 89 self._background = Background( 90 fade_time=0.5, start_faded=True, show_logo=True 91 ) 92 self._tips_text = TipsText() 93 setmusic(MusicType.CHAR_SELECT) 94 self._join_info = self.session.lobby.create_join_info() 95 babase.set_analytics_screen('Joining Screen')
Standard activity for waiting for players to join.
It shows tips and other info and waits for all players to check ready.
65 def __init__(self, settings: dict): 66 super().__init__(settings) 67 68 # This activity is a special 'joiner' activity. 69 # It will get shut down as soon as all players have checked ready. 70 self.is_joining_activity = True 71 72 # Players may be idle waiting for joiners; lets not kick them for it. 73 self.allow_kick_idle_players = False 74 75 # In vr mode we don't want stuff moving around. 76 self.use_fixed_vr_overlay = True 77 78 self._background: bascenev1.Actor | None = None 79 self._tips_text: bascenev1.Actor | None = None 80 self._join_info: JoinInfo | None = None
Creates an Activity in the current bascenev1.Session.
The activity will not be actually run until bascenev1.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.
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.
82 @override 83 def on_transition_in(self) -> None: 84 # pylint: disable=cyclic-import 85 from bascenev1lib.actor.tipstext import TipsText 86 from bascenev1lib.actor.background import Background 87 88 super().on_transition_in() 89 self._background = Background( 90 fade_time=0.5, start_faded=True, show_logo=True 91 ) 92 self._tips_text = TipsText() 93 setmusic(MusicType.CHAR_SELECT) 94 self._join_info = self.session.lobby.create_join_info() 95 babase.set_analytics_screen('Joining Screen')
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 bascenev1.Activity.on_begin() is called.
19class Level: 20 """An entry in a bascenev1.Campaign.""" 21 22 def __init__( 23 self, 24 name: str, 25 gametype: type[bascenev1.GameActivity], 26 settings: dict, 27 preview_texture_name: str, 28 *, 29 displayname: str | None = None, 30 ): 31 self._name = name 32 self._gametype = gametype 33 self._settings = settings 34 self._preview_texture_name = preview_texture_name 35 self._displayname = displayname 36 self._campaign: weakref.ref[bascenev1.Campaign] | None = None 37 self._index: int | None = None 38 self._score_version_string: str | None = None 39 40 @override 41 def __repr__(self) -> str: 42 cls = type(self) 43 return f"<{cls.__module__}.{cls.__name__} '{self._name}'>" 44 45 @property 46 def name(self) -> str: 47 """The unique name for this Level.""" 48 return self._name 49 50 def get_settings(self) -> dict[str, Any]: 51 """Returns the settings for this Level.""" 52 settings = copy.deepcopy(self._settings) 53 54 # So the game knows what the level is called. 55 # Hmm; seems hacky; I think we should take this out. 56 settings['name'] = self._name 57 return settings 58 59 @property 60 def preview_texture_name(self) -> str: 61 """The preview texture name for this Level.""" 62 return self._preview_texture_name 63 64 # def get_preview_texture(self) -> bauiv1.Texture: 65 # """Load/return the preview Texture for this Level.""" 66 # return _bauiv1.gettexture(self._preview_texture_name) 67 68 @property 69 def displayname(self) -> bascenev1.Lstr: 70 """The localized name for this Level.""" 71 return babase.Lstr( 72 translate=( 73 'coopLevelNames', 74 ( 75 self._displayname 76 if self._displayname is not None 77 else self._name 78 ), 79 ), 80 subs=[ 81 ('${GAME}', self._gametype.get_display_string(self._settings)) 82 ], 83 ) 84 85 @property 86 def gametype(self) -> type[bascenev1.GameActivity]: 87 """The type of game used for this Level.""" 88 return self._gametype 89 90 @property 91 def campaign(self) -> bascenev1.Campaign | None: 92 """The baclassic.Campaign this Level is associated with, or None.""" 93 return None if self._campaign is None else self._campaign() 94 95 @property 96 def index(self) -> int: 97 """The zero-based index of this Level in its baclassic.Campaign. 98 99 Access results in a RuntimeError if the Level is not assigned to a 100 Campaign. 101 """ 102 if self._index is None: 103 raise RuntimeError('Level is not part of a Campaign') 104 return self._index 105 106 @property 107 def complete(self) -> bool: 108 """Whether this Level has been completed.""" 109 config = self._get_config_dict() 110 val = config.get('Complete', False) 111 assert isinstance(val, bool) 112 return val 113 114 def set_complete(self, val: bool) -> None: 115 """Set whether or not this level is complete.""" 116 old_val = self.complete 117 assert isinstance(old_val, bool) 118 assert isinstance(val, bool) 119 if val != old_val: 120 config = self._get_config_dict() 121 config['Complete'] = val 122 123 def get_high_scores(self) -> dict: 124 """Return the current high scores for this Level.""" 125 config = self._get_config_dict() 126 high_scores_key = 'High Scores' + self.get_score_version_string() 127 if high_scores_key not in config: 128 return {} 129 return copy.deepcopy(config[high_scores_key]) 130 131 def set_high_scores(self, high_scores: dict) -> None: 132 """Set high scores for this level.""" 133 config = self._get_config_dict() 134 high_scores_key = 'High Scores' + self.get_score_version_string() 135 config[high_scores_key] = high_scores 136 137 def get_score_version_string(self) -> str: 138 """Return the score version string for this Level. 139 140 If a Level's gameplay changes significantly, its version string 141 can be changed to separate its new high score lists/etc. from the old. 142 """ 143 if self._score_version_string is None: 144 scorever = self._gametype.getscoreconfig().version 145 if scorever != '': 146 scorever = ' ' + scorever 147 self._score_version_string = scorever 148 assert self._score_version_string is not None 149 return self._score_version_string 150 151 @property 152 def rating(self) -> float: 153 """The current rating for this Level.""" 154 val = self._get_config_dict().get('Rating', 0.0) 155 assert isinstance(val, float) 156 return val 157 158 def set_rating(self, rating: float) -> None: 159 """Set a rating for this Level, replacing the old ONLY IF higher.""" 160 old_rating = self.rating 161 config = self._get_config_dict() 162 config['Rating'] = max(old_rating, rating) 163 164 def _get_config_dict(self) -> dict[str, Any]: 165 """Return/create the persistent state dict for this level. 166 167 The referenced dict exists under the game's config dict and 168 can be modified in place.""" 169 campaign = self.campaign 170 if campaign is None: 171 raise RuntimeError('Level is not in a campaign.') 172 configdict = campaign.configdict 173 val: dict[str, Any] = configdict.setdefault( 174 self._name, {'Rating': 0.0, 'Complete': False} 175 ) 176 assert isinstance(val, dict) 177 return val 178 179 def set_campaign(self, campaign: bascenev1.Campaign, index: int) -> None: 180 """For use by baclassic.Campaign when adding levels to itself. 181 182 (internal)""" 183 self._campaign = weakref.ref(campaign) 184 self._index = index
An entry in a bascenev1.Campaign.
22 def __init__( 23 self, 24 name: str, 25 gametype: type[bascenev1.GameActivity], 26 settings: dict, 27 preview_texture_name: str, 28 *, 29 displayname: str | None = None, 30 ): 31 self._name = name 32 self._gametype = gametype 33 self._settings = settings 34 self._preview_texture_name = preview_texture_name 35 self._displayname = displayname 36 self._campaign: weakref.ref[bascenev1.Campaign] | None = None 37 self._index: int | None = None 38 self._score_version_string: str | None = None
45 @property 46 def name(self) -> str: 47 """The unique name for this Level.""" 48 return self._name
The unique name for this Level.
50 def get_settings(self) -> dict[str, Any]: 51 """Returns the settings for this Level.""" 52 settings = copy.deepcopy(self._settings) 53 54 # So the game knows what the level is called. 55 # Hmm; seems hacky; I think we should take this out. 56 settings['name'] = self._name 57 return settings
Returns the settings for this Level.
59 @property 60 def preview_texture_name(self) -> str: 61 """The preview texture name for this Level.""" 62 return self._preview_texture_name
The preview texture name for this Level.
68 @property 69 def displayname(self) -> bascenev1.Lstr: 70 """The localized name for this Level.""" 71 return babase.Lstr( 72 translate=( 73 'coopLevelNames', 74 ( 75 self._displayname 76 if self._displayname is not None 77 else self._name 78 ), 79 ), 80 subs=[ 81 ('${GAME}', self._gametype.get_display_string(self._settings)) 82 ], 83 )
The localized name for this Level.
85 @property 86 def gametype(self) -> type[bascenev1.GameActivity]: 87 """The type of game used for this Level.""" 88 return self._gametype
The type of game used for this Level.
90 @property 91 def campaign(self) -> bascenev1.Campaign | None: 92 """The baclassic.Campaign this Level is associated with, or None.""" 93 return None if self._campaign is None else self._campaign()
The baclassic.Campaign this Level is associated with, or None.
95 @property 96 def index(self) -> int: 97 """The zero-based index of this Level in its baclassic.Campaign. 98 99 Access results in a RuntimeError if the Level is not assigned to a 100 Campaign. 101 """ 102 if self._index is None: 103 raise RuntimeError('Level is not part of a Campaign') 104 return self._index
The zero-based index of this Level in its baclassic.Campaign.
Access results in a RuntimeError if the Level is not assigned to a Campaign.
106 @property 107 def complete(self) -> bool: 108 """Whether this Level has been completed.""" 109 config = self._get_config_dict() 110 val = config.get('Complete', False) 111 assert isinstance(val, bool) 112 return val
Whether this Level has been completed.
114 def set_complete(self, val: bool) -> None: 115 """Set whether or not this level is complete.""" 116 old_val = self.complete 117 assert isinstance(old_val, bool) 118 assert isinstance(val, bool) 119 if val != old_val: 120 config = self._get_config_dict() 121 config['Complete'] = val
Set whether or not this level is complete.
123 def get_high_scores(self) -> dict: 124 """Return the current high scores for this Level.""" 125 config = self._get_config_dict() 126 high_scores_key = 'High Scores' + self.get_score_version_string() 127 if high_scores_key not in config: 128 return {} 129 return copy.deepcopy(config[high_scores_key])
Return the current high scores for this Level.
131 def set_high_scores(self, high_scores: dict) -> None: 132 """Set high scores for this level.""" 133 config = self._get_config_dict() 134 high_scores_key = 'High Scores' + self.get_score_version_string() 135 config[high_scores_key] = high_scores
Set high scores for this level.
137 def get_score_version_string(self) -> str: 138 """Return the score version string for this Level. 139 140 If a Level's gameplay changes significantly, its version string 141 can be changed to separate its new high score lists/etc. from the old. 142 """ 143 if self._score_version_string is None: 144 scorever = self._gametype.getscoreconfig().version 145 if scorever != '': 146 scorever = ' ' + scorever 147 self._score_version_string = scorever 148 assert self._score_version_string is not None 149 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.
151 @property 152 def rating(self) -> float: 153 """The current rating for this Level.""" 154 val = self._get_config_dict().get('Rating', 0.0) 155 assert isinstance(val, float) 156 return val
The current rating for this Level.
158 def set_rating(self, rating: float) -> None: 159 """Set a rating for this Level, replacing the old ONLY IF higher.""" 160 old_rating = self.rating 161 config = self._get_config_dict() 162 config['Rating'] = max(old_rating, rating)
Set a rating for this Level, replacing the old ONLY IF higher.
940class Lobby: 941 """Container for baclassic.Choosers.""" 942 943 def __del__(self) -> None: 944 # Reset any players that still have a chooser in us. 945 # (should allow the choosers to die). 946 sessionplayers = [ 947 c.sessionplayer for c in self.choosers if c.sessionplayer 948 ] 949 for sessionplayer in sessionplayers: 950 sessionplayer.resetinput() 951 952 def __init__(self) -> None: 953 from bascenev1._team import SessionTeam 954 from bascenev1._coopsession import CoopSession 955 956 session = _bascenev1.getsession() 957 self._use_team_colors = session.use_team_colors 958 if session.use_teams: 959 self._sessionteams = [ 960 weakref.ref(team) for team in session.sessionteams 961 ] 962 else: 963 self._dummy_teams = SessionTeam() 964 self._sessionteams = [weakref.ref(self._dummy_teams)] 965 v_offset = -150 if isinstance(session, CoopSession) else -50 966 self.choosers: list[Chooser] = [] 967 self.base_v_offset = v_offset 968 self.update_positions() 969 self._next_add_team = 0 970 self.character_names_local_unlocked: list[str] = [] 971 self._vpos = 0 972 973 # Grab available profiles. 974 self.reload_profiles() 975 976 self._join_info_text = None 977 978 @property 979 def next_add_team(self) -> int: 980 """(internal)""" 981 return self._next_add_team 982 983 @property 984 def use_team_colors(self) -> bool: 985 """A bool for whether this lobby is using team colors. 986 987 If False, inidividual player colors are used instead. 988 """ 989 return self._use_team_colors 990 991 @property 992 def sessionteams(self) -> list[bascenev1.SessionTeam]: 993 """bascenev1.SessionTeams available in this lobby.""" 994 allteams = [] 995 for tref in self._sessionteams: 996 team = tref() 997 assert team is not None 998 allteams.append(team) 999 return allteams 1000 1001 def get_choosers(self) -> list[Chooser]: 1002 """Return the lobby's current choosers.""" 1003 return self.choosers 1004 1005 def create_join_info(self) -> JoinInfo: 1006 """Create a display of on-screen information for joiners. 1007 1008 (how to switch teams, players, etc.) 1009 Intended for use in initial joining-screens. 1010 """ 1011 return JoinInfo(self) 1012 1013 def reload_profiles(self) -> None: 1014 """Reload available player profiles.""" 1015 # pylint: disable=cyclic-import 1016 from bascenev1lib.actor.spazappearance import get_appearances 1017 1018 assert babase.app.classic is not None 1019 1020 # We may have gained or lost character names if the user 1021 # bought something; reload these too. 1022 self.character_names_local_unlocked = get_appearances() 1023 self.character_names_local_unlocked.sort(key=lambda x: x.lower()) 1024 1025 # Do any overall prep we need to such as creating account profile. 1026 babase.app.classic.accounts.ensure_have_account_player_profile() 1027 for chooser in self.choosers: 1028 try: 1029 chooser.reload_profiles() 1030 chooser.update_from_profile() 1031 except Exception: 1032 logging.exception('Error reloading profiles.') 1033 1034 def update_positions(self) -> None: 1035 """Update positions for all choosers.""" 1036 self._vpos = -100 + self.base_v_offset 1037 for chooser in self.choosers: 1038 chooser.set_vpos(self._vpos) 1039 chooser.update_position() 1040 self._vpos -= 48 1041 1042 def check_all_ready(self) -> bool: 1043 """Return whether all choosers are marked ready.""" 1044 return all(chooser.ready for chooser in self.choosers) 1045 1046 def add_chooser(self, sessionplayer: bascenev1.SessionPlayer) -> None: 1047 """Add a chooser to the lobby for the provided player.""" 1048 self.choosers.append( 1049 Chooser(vpos=self._vpos, sessionplayer=sessionplayer, lobby=self) 1050 ) 1051 self._next_add_team = (self._next_add_team + 1) % len( 1052 self._sessionteams 1053 ) 1054 self._vpos -= 48 1055 1056 def remove_chooser(self, player: bascenev1.SessionPlayer) -> None: 1057 """Remove a single player's chooser; does not kick them. 1058 1059 This is used when a player enters the game and no longer 1060 needs a chooser.""" 1061 found = False 1062 chooser = None 1063 for chooser in self.choosers: 1064 if chooser.getplayer() is player: 1065 found = True 1066 1067 # Mark it as dead since there could be more 1068 # change-commands/etc coming in still for it; 1069 # want to avoid duplicate player-adds/etc. 1070 chooser.set_dead(True) 1071 self.choosers.remove(chooser) 1072 break 1073 if not found: 1074 logging.exception('remove_chooser did not find player %s.', player) 1075 elif chooser in self.choosers: 1076 logging.exception('chooser remains after removal for %s.', player) 1077 self.update_positions() 1078 1079 def remove_all_choosers(self) -> None: 1080 """Remove all choosers without kicking players. 1081 1082 This is called after all players check in and enter a game. 1083 """ 1084 self.choosers = [] 1085 self.update_positions() 1086 1087 def remove_all_choosers_and_kick_players(self) -> None: 1088 """Remove all player choosers and kick attached players.""" 1089 1090 # Copy the list; it can change under us otherwise. 1091 for chooser in list(self.choosers): 1092 if chooser.sessionplayer: 1093 chooser.sessionplayer.remove_from_game() 1094 self.remove_all_choosers()
Container for baclassic.Choosers.
983 @property 984 def use_team_colors(self) -> bool: 985 """A bool for whether this lobby is using team colors. 986 987 If False, inidividual player colors are used instead. 988 """ 989 return self._use_team_colors
A bool for whether this lobby is using team colors.
If False, inidividual player colors are used instead.
991 @property 992 def sessionteams(self) -> list[bascenev1.SessionTeam]: 993 """bascenev1.SessionTeams available in this lobby.""" 994 allteams = [] 995 for tref in self._sessionteams: 996 team = tref() 997 assert team is not None 998 allteams.append(team) 999 return allteams
bascenev1.SessionTeams available in this lobby.
1001 def get_choosers(self) -> list[Chooser]: 1002 """Return the lobby's current choosers.""" 1003 return self.choosers
Return the lobby's current choosers.
1005 def create_join_info(self) -> JoinInfo: 1006 """Create a display of on-screen information for joiners. 1007 1008 (how to switch teams, players, etc.) 1009 Intended for use in initial joining-screens. 1010 """ 1011 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.
1013 def reload_profiles(self) -> None: 1014 """Reload available player profiles.""" 1015 # pylint: disable=cyclic-import 1016 from bascenev1lib.actor.spazappearance import get_appearances 1017 1018 assert babase.app.classic is not None 1019 1020 # We may have gained or lost character names if the user 1021 # bought something; reload these too. 1022 self.character_names_local_unlocked = get_appearances() 1023 self.character_names_local_unlocked.sort(key=lambda x: x.lower()) 1024 1025 # Do any overall prep we need to such as creating account profile. 1026 babase.app.classic.accounts.ensure_have_account_player_profile() 1027 for chooser in self.choosers: 1028 try: 1029 chooser.reload_profiles() 1030 chooser.update_from_profile() 1031 except Exception: 1032 logging.exception('Error reloading profiles.')
Reload available player profiles.
1034 def update_positions(self) -> None: 1035 """Update positions for all choosers.""" 1036 self._vpos = -100 + self.base_v_offset 1037 for chooser in self.choosers: 1038 chooser.set_vpos(self._vpos) 1039 chooser.update_position() 1040 self._vpos -= 48
Update positions for all choosers.
1042 def check_all_ready(self) -> bool: 1043 """Return whether all choosers are marked ready.""" 1044 return all(chooser.ready for chooser in self.choosers)
Return whether all choosers are marked ready.
1046 def add_chooser(self, sessionplayer: bascenev1.SessionPlayer) -> None: 1047 """Add a chooser to the lobby for the provided player.""" 1048 self.choosers.append( 1049 Chooser(vpos=self._vpos, sessionplayer=sessionplayer, lobby=self) 1050 ) 1051 self._next_add_team = (self._next_add_team + 1) % len( 1052 self._sessionteams 1053 ) 1054 self._vpos -= 48
Add a chooser to the lobby for the provided player.
1056 def remove_chooser(self, player: bascenev1.SessionPlayer) -> None: 1057 """Remove a single player's chooser; does not kick them. 1058 1059 This is used when a player enters the game and no longer 1060 needs a chooser.""" 1061 found = False 1062 chooser = None 1063 for chooser in self.choosers: 1064 if chooser.getplayer() is player: 1065 found = True 1066 1067 # Mark it as dead since there could be more 1068 # change-commands/etc coming in still for it; 1069 # want to avoid duplicate player-adds/etc. 1070 chooser.set_dead(True) 1071 self.choosers.remove(chooser) 1072 break 1073 if not found: 1074 logging.exception('remove_chooser did not find player %s.', player) 1075 elif chooser in self.choosers: 1076 logging.exception('chooser remains after removal for %s.', player) 1077 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.
1079 def remove_all_choosers(self) -> None: 1080 """Remove all choosers without kicking players. 1081 1082 This is called after all players check in and enter a game. 1083 """ 1084 self.choosers = [] 1085 self.update_positions()
Remove all choosers without kicking players.
This is called after all players check in and enter a game.
1087 def remove_all_choosers_and_kick_players(self) -> None: 1088 """Remove all player choosers and kick attached players.""" 1089 1090 # Copy the list; it can change under us otherwise. 1091 for chooser in list(self.choosers): 1092 if chooser.sessionplayer: 1093 chooser.sessionplayer.remove_from_game() 1094 self.remove_all_choosers()
Remove all player choosers and kick attached players.
1442def ls_input_devices() -> None: 1443 """Print debugging info about game objects. 1444 1445 This call only functions in debug builds of the game. 1446 It prints various info about the current object count, etc. 1447 """ 1448 return None
Print debugging info about game objects.
This call only functions in debug builds of the game. It prints various info about the current object count, etc.
1451def ls_objects() -> None: 1452 """Log debugging info about C++ level objects. 1453 1454 This call only functions in debug builds of the game. 1455 It prints various info about the current object count, etc. 1456 """ 1457 return None
Log debugging info about C++ level objects.
This call only functions in debug builds of the game. It prints various info about the current object count, etc.
494class Lstr: 495 """Used to define strings in a language-independent way. 496 497 These should be used whenever possible in place of hard-coded 498 strings so that in-game or UI elements show up correctly on all 499 clients in their currently active language. 500 501 To see available resource keys, look at any of the 502 ``bs_language_*.py`` files in the game or the translations pages at 503 `legacy.ballistica.net/translate 504 <https://legacy.ballistica.net/translate>`. 505 506 Examples 507 -------- 508 509 **Example 1: Specify a String from a Resource Path**:: 510 511 mynode.text = babase.Lstr(resource='audioSettingsWindow.titleText') 512 513 **Example 2: Specify a Translated String via a Category and English Value** 514 515 If a translated value is available, it will be used; otherwise, the 516 English value will be. To see available translation categories, look 517 under the ``translations`` resource section:: 518 519 mynode.text = babase.Lstr(translate=('gameDescriptions', 520 'Defeat all enemies')) 521 522 **Example 3: Specify a Raw Value with Substitutions** 523 524 Substitutions can be used with ``resource`` and ``translate`` modes 525 as well:: 526 527 mynode.text = babase.Lstr(value='${A} / ${B}', 528 subs=[('${A}', str(score)), 529 ('${B}', str(total))]) 530 531 **Example 4: Nesting** 532 533 :class:`~babase.Lstr` instances can be nested. This example would display 534 the resource at ``res_a`` but replace ``${NAME}`` with the value of 535 the resource at ``res_b``:: 536 537 mytextnode.text = babase.Lstr( 538 resource='res_a', 539 subs=[('${NAME}', babase.Lstr(resource='res_b'))]) 540 """ 541 542 # This class is used a lot in UI stuff and doesn't need to be 543 # flexible, so let's optimize its performance a bit. 544 __slots__ = ['args'] 545 546 @overload 547 def __init__( 548 self, 549 *, 550 resource: str, 551 fallback_resource: str = '', 552 fallback_value: str = '', 553 subs: Sequence[tuple[str, str | Lstr]] | None = None, 554 ) -> None: 555 """Create an Lstr from a string resource.""" 556 557 @overload 558 def __init__( 559 self, 560 *, 561 translate: tuple[str, str], 562 subs: Sequence[tuple[str, str | Lstr]] | None = None, 563 ) -> None: 564 """Create an Lstr by translating a string in a category.""" 565 566 @overload 567 def __init__( 568 self, 569 *, 570 value: str, 571 subs: Sequence[tuple[str, str | Lstr]] | None = None, 572 ) -> None: 573 """Create an Lstr from a raw string value.""" 574 575 def __init__(self, *args: Any, **keywds: Any) -> None: 576 """Instantiate a Lstr. 577 578 Pass a value for either 'resource', 'translate', 579 or 'value'. (see Lstr help for examples). 580 'subs' can be a sequence of 2-member sequences consisting of values 581 and replacements. 582 'fallback_resource' can be a resource key that will be used if the 583 main one is not present for 584 the current language in place of falling back to the english value 585 ('resource' mode only). 586 'fallback_value' can be a literal string that will be used if neither 587 the resource nor the fallback resource is found ('resource' mode only). 588 """ 589 # pylint: disable=too-many-branches 590 if args: 591 raise TypeError('Lstr accepts only keyword arguments') 592 593 # Basically just store the exact args they passed. However if 594 # they passed any Lstr values for subs, replace them with that 595 # Lstr's dict. 596 self.args = keywds 597 our_type = type(self) 598 599 if isinstance(self.args.get('value'), our_type): 600 raise TypeError("'value' must be a regular string; not an Lstr") 601 602 if 'subs' in keywds: 603 subs = keywds.get('subs') 604 subs_filtered = [] 605 if subs is not None: 606 for key, value in keywds['subs']: 607 if isinstance(value, our_type): 608 subs_filtered.append((key, value.args)) 609 else: 610 subs_filtered.append((key, value)) 611 self.args['subs'] = subs_filtered 612 613 # As of protocol 31 we support compact key names ('t' instead of 614 # 'translate', etc). Convert as needed. 615 if 'translate' in keywds: 616 keywds['t'] = keywds['translate'] 617 del keywds['translate'] 618 if 'resource' in keywds: 619 keywds['r'] = keywds['resource'] 620 del keywds['resource'] 621 if 'value' in keywds: 622 keywds['v'] = keywds['value'] 623 del keywds['value'] 624 if 'fallback' in keywds: 625 from babase import _error 626 627 _error.print_error( 628 'deprecated "fallback" arg passed to Lstr(); use ' 629 'either "fallback_resource" or "fallback_value"', 630 once=True, 631 ) 632 keywds['f'] = keywds['fallback'] 633 del keywds['fallback'] 634 if 'fallback_resource' in keywds: 635 keywds['f'] = keywds['fallback_resource'] 636 del keywds['fallback_resource'] 637 if 'subs' in keywds: 638 keywds['s'] = keywds['subs'] 639 del keywds['subs'] 640 if 'fallback_value' in keywds: 641 keywds['fv'] = keywds['fallback_value'] 642 del keywds['fallback_value'] 643 644 def evaluate(self) -> str: 645 """Evaluate the Lstr and returns a flat string in the current language. 646 647 You should avoid doing this as much as possible and instead pass 648 and store Lstr values. 649 """ 650 return _babase.evaluate_lstr(self._get_json()) 651 652 def is_flat_value(self) -> bool: 653 """Return whether the Lstr is a 'flat' value. 654 655 This is defined as a simple string value incorporating no 656 translations, resources, or substitutions. In this case it may 657 be reasonable to replace it with a raw string value, perform 658 string manipulation on it, etc. 659 """ 660 return bool('v' in self.args and not self.args.get('s', [])) 661 662 def _get_json(self) -> str: 663 try: 664 return json.dumps(self.args, separators=(',', ':')) 665 except Exception: 666 from babase import _error 667 668 _error.print_exception('_get_json failed for', self.args) 669 return 'JSON_ERR' 670 671 @override 672 def __str__(self) -> str: 673 return '<ba.Lstr: ' + self._get_json() + '>' 674 675 @override 676 def __repr__(self) -> str: 677 return '<ba.Lstr: ' + self._get_json() + '>' 678 679 @staticmethod 680 def from_json(json_string: str) -> babase.Lstr: 681 """Given a json string, returns a babase.Lstr. Does no validation.""" 682 lstr = Lstr(value='') 683 lstr.args = json.loads(json_string) 684 return lstr
Used to define strings in a language-independent way.
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
<https://legacy.ballistica.net/translate>
.
Examples
Example 1: Specify a String from a Resource Path::
mynode.text = babase.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 = babase.Lstr(translate=('gameDescriptions',
'Defeat all enemies'))
Example 3: Specify a Raw Value with Substitutions
Substitutions can be used with resource
and translate
modes
as well::
mynode.text = babase.Lstr(value='${A} / ${B}',
subs=[('${A}', str(score)),
('${B}', str(total))])
Example 4: Nesting
~babase.Lstr
instances can be nested. This example would display
the resource at res_a
but replace ${NAME}
with the value of
the resource at res_b
::
mytextnode.text = babase.Lstr(
resource='res_a',
subs=[('${NAME}', babase.Lstr(resource='res_b'))])
575 def __init__(self, *args: Any, **keywds: Any) -> None: 576 """Instantiate a Lstr. 577 578 Pass a value for either 'resource', 'translate', 579 or 'value'. (see Lstr help for examples). 580 'subs' can be a sequence of 2-member sequences consisting of values 581 and replacements. 582 'fallback_resource' can be a resource key that will be used if the 583 main one is not present for 584 the current language in place of falling back to the english value 585 ('resource' mode only). 586 'fallback_value' can be a literal string that will be used if neither 587 the resource nor the fallback resource is found ('resource' mode only). 588 """ 589 # pylint: disable=too-many-branches 590 if args: 591 raise TypeError('Lstr accepts only keyword arguments') 592 593 # Basically just store the exact args they passed. However if 594 # they passed any Lstr values for subs, replace them with that 595 # Lstr's dict. 596 self.args = keywds 597 our_type = type(self) 598 599 if isinstance(self.args.get('value'), our_type): 600 raise TypeError("'value' must be a regular string; not an Lstr") 601 602 if 'subs' in keywds: 603 subs = keywds.get('subs') 604 subs_filtered = [] 605 if subs is not None: 606 for key, value in keywds['subs']: 607 if isinstance(value, our_type): 608 subs_filtered.append((key, value.args)) 609 else: 610 subs_filtered.append((key, value)) 611 self.args['subs'] = subs_filtered 612 613 # As of protocol 31 we support compact key names ('t' instead of 614 # 'translate', etc). Convert as needed. 615 if 'translate' in keywds: 616 keywds['t'] = keywds['translate'] 617 del keywds['translate'] 618 if 'resource' in keywds: 619 keywds['r'] = keywds['resource'] 620 del keywds['resource'] 621 if 'value' in keywds: 622 keywds['v'] = keywds['value'] 623 del keywds['value'] 624 if 'fallback' in keywds: 625 from babase import _error 626 627 _error.print_error( 628 'deprecated "fallback" arg passed to Lstr(); use ' 629 'either "fallback_resource" or "fallback_value"', 630 once=True, 631 ) 632 keywds['f'] = keywds['fallback'] 633 del keywds['fallback'] 634 if 'fallback_resource' in keywds: 635 keywds['f'] = keywds['fallback_resource'] 636 del keywds['fallback_resource'] 637 if 'subs' in keywds: 638 keywds['s'] = keywds['subs'] 639 del keywds['subs'] 640 if 'fallback_value' in keywds: 641 keywds['fv'] = keywds['fallback_value'] 642 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).
644 def evaluate(self) -> str: 645 """Evaluate the Lstr and returns a flat string in the current language. 646 647 You should avoid doing this as much as possible and instead pass 648 and store Lstr values. 649 """ 650 return _babase.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.
652 def is_flat_value(self) -> bool: 653 """Return whether the Lstr is a 'flat' value. 654 655 This is defined as a simple string value incorporating no 656 translations, resources, or substitutions. In this case it may 657 be reasonable to replace it with a raw string value, perform 658 string manipulation on it, etc. 659 """ 660 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.
50class Map(Actor): 51 """A game map. 52 53 Consists of a collection of terrain nodes, metadata, and other 54 functionality comprising a game map. 55 """ 56 57 defs: Any = None 58 name = 'Map' 59 _playtypes: list[str] = [] 60 61 @classmethod 62 def preload(cls) -> None: 63 """Preload map media. 64 65 This runs the class's on_preload() method as needed to prep it to run. 66 Preloading should generally be done in a bascenev1.Activity's 67 __init__ method. Note that this is a classmethod since it is not 68 operate on map instances but rather on the class itself before 69 instances are made 70 """ 71 activity = _bascenev1.getactivity() 72 if cls not in activity.preloads: 73 activity.preloads[cls] = cls.on_preload() 74 75 @classmethod 76 def get_play_types(cls) -> list[str]: 77 """Return valid play types for this map.""" 78 return [] 79 80 @classmethod 81 def get_preview_texture_name(cls) -> str | None: 82 """Return the name of the preview texture for this map.""" 83 return None 84 85 @classmethod 86 def on_preload(cls) -> Any: 87 """Called when the map is being preloaded. 88 89 It should return any media/data it requires to operate 90 """ 91 return None 92 93 @classmethod 94 def getname(cls) -> str: 95 """Return the unique name of this map, in English.""" 96 return cls.name 97 98 @classmethod 99 def get_music_type(cls) -> bascenev1.MusicType | None: 100 """Return a music-type string that should be played on this map. 101 102 If None is returned, default music will be used. 103 """ 104 return None 105 106 def __init__( 107 self, vr_overlay_offset: Sequence[float] | None = None 108 ) -> None: 109 """Instantiate a map.""" 110 super().__init__() 111 112 # This is expected to always be a bascenev1.Node object 113 # (whether valid or not) should be set to something meaningful 114 # by child classes. 115 self.node: _bascenev1.Node | None = None 116 117 # Make our class' preload-data available to us 118 # (and instruct the user if we weren't preloaded properly). 119 try: 120 self.preloaddata = _bascenev1.getactivity().preloads[type(self)] 121 except Exception as exc: 122 raise babase.NotFoundError( 123 'Preload data not found for ' 124 + str(type(self)) 125 + '; make sure to call the type\'s preload()' 126 ' staticmethod in the activity constructor' 127 ) from exc 128 129 # Set various globals. 130 gnode = _bascenev1.getactivity().globalsnode 131 132 # Set area-of-interest bounds. 133 aoi_bounds = self.get_def_bound_box('area_of_interest_bounds') 134 if aoi_bounds is None: 135 print('WARNING: no "aoi_bounds" found for map:', self.getname()) 136 aoi_bounds = (-1, -1, -1, 1, 1, 1) 137 gnode.area_of_interest_bounds = aoi_bounds 138 139 # Set map bounds. 140 map_bounds = self.get_def_bound_box('map_bounds') 141 if map_bounds is None: 142 print('WARNING: no "map_bounds" found for map:', self.getname()) 143 map_bounds = (-30, -10, -30, 30, 100, 30) 144 _bascenev1.set_map_bounds(map_bounds) 145 146 # Set shadow ranges. 147 try: 148 gnode.shadow_range = [ 149 self.defs.points[v][1] 150 for v in [ 151 'shadow_lower_bottom', 152 'shadow_lower_top', 153 'shadow_upper_bottom', 154 'shadow_upper_top', 155 ] 156 ] 157 except Exception: 158 pass 159 160 # In vr, set a fixed point in space for the overlay to show up at. 161 # By default we use the bounds center but allow the map to override it. 162 center = ( 163 (aoi_bounds[0] + aoi_bounds[3]) * 0.5, 164 (aoi_bounds[1] + aoi_bounds[4]) * 0.5, 165 (aoi_bounds[2] + aoi_bounds[5]) * 0.5, 166 ) 167 if vr_overlay_offset is not None: 168 center = ( 169 center[0] + vr_overlay_offset[0], 170 center[1] + vr_overlay_offset[1], 171 center[2] + vr_overlay_offset[2], 172 ) 173 gnode.vr_overlay_center = center 174 gnode.vr_overlay_center_enabled = True 175 176 self.spawn_points = self.get_def_points('spawn') or [(0, 0, 0, 0, 0, 0)] 177 self.ffa_spawn_points = self.get_def_points('ffa_spawn') or [ 178 (0, 0, 0, 0, 0, 0) 179 ] 180 self.spawn_by_flag_points = self.get_def_points('spawn_by_flag') or [ 181 (0, 0, 0, 0, 0, 0) 182 ] 183 self.flag_points = self.get_def_points('flag') or [(0, 0, 0)] 184 185 # We just want points. 186 self.flag_points = [p[:3] for p in self.flag_points] 187 self.flag_points_default = self.get_def_point('flag_default') or ( 188 0, 189 1, 190 0, 191 ) 192 self.powerup_spawn_points = self.get_def_points('powerup_spawn') or [ 193 (0, 0, 0) 194 ] 195 196 # We just want points. 197 self.powerup_spawn_points = [p[:3] for p in self.powerup_spawn_points] 198 self.tnt_points = self.get_def_points('tnt') or [] 199 200 # We just want points. 201 self.tnt_points = [p[:3] for p in self.tnt_points] 202 203 self.is_hockey = False 204 self.is_flying = False 205 206 # FIXME: this should be part of game; not map. 207 # Let's select random index for first spawn point, 208 # so that no one is offended by the constant spawn on the edge. 209 self._next_ffa_start_index = random.randrange( 210 len(self.ffa_spawn_points) 211 ) 212 213 def is_point_near_edge( 214 self, point: babase.Vec3, running: bool = False 215 ) -> bool: 216 """Return whether the provided point is near an edge of the map. 217 218 Simple bot logic uses this call to determine if they 219 are approaching a cliff or wall. If this returns True they will 220 generally not walk/run any farther away from the origin. 221 If 'running' is True, the buffer should be a bit larger. 222 """ 223 del point, running # Unused. 224 return False 225 226 def get_def_bound_box( 227 self, name: str 228 ) -> tuple[float, float, float, float, float, float] | None: 229 """Return a 6 member bounds tuple or None if it is not defined.""" 230 try: 231 box = self.defs.boxes[name] 232 return ( 233 box[0] - box[6] / 2.0, 234 box[1] - box[7] / 2.0, 235 box[2] - box[8] / 2.0, 236 box[0] + box[6] / 2.0, 237 box[1] + box[7] / 2.0, 238 box[2] + box[8] / 2.0, 239 ) 240 except Exception: 241 return None 242 243 def get_def_point(self, name: str) -> Sequence[float] | None: 244 """Return a single defined point or a default value in its absence.""" 245 val = self.defs.points.get(name) 246 return ( 247 None 248 if val is None 249 else babase.vec3validate(val) if __debug__ else val 250 ) 251 252 def get_def_points(self, name: str) -> list[Sequence[float]]: 253 """Return a list of named points. 254 255 Return as many sequential ones are defined (flag1, flag2, flag3), etc. 256 If none are defined, returns an empty list. 257 """ 258 point_list = [] 259 if self.defs and name + '1' in self.defs.points: 260 i = 1 261 while name + str(i) in self.defs.points: 262 pts = self.defs.points[name + str(i)] 263 if len(pts) == 6: 264 point_list.append(pts) 265 else: 266 if len(pts) != 3: 267 raise ValueError('invalid point') 268 point_list.append(pts + (0, 0, 0)) 269 i += 1 270 return point_list 271 272 def get_start_position(self, team_index: int) -> Sequence[float]: 273 """Return a random starting position for the given team index.""" 274 pnt = self.spawn_points[team_index % len(self.spawn_points)] 275 x_range = (-0.5, 0.5) if pnt[3] == 0.0 else (-pnt[3], pnt[3]) 276 z_range = (-0.5, 0.5) if pnt[5] == 0.0 else (-pnt[5], pnt[5]) 277 pnt = ( 278 pnt[0] + random.uniform(*x_range), 279 pnt[1], 280 pnt[2] + random.uniform(*z_range), 281 ) 282 return pnt 283 284 def get_ffa_start_position( 285 self, players: Sequence[bascenev1.Player] 286 ) -> Sequence[float]: 287 """Return a random starting position in one of the FFA spawn areas. 288 289 If a list of bascenev1.Player-s is provided; the returned points 290 will be as far from these players as possible. 291 """ 292 293 # Get positions for existing players. 294 player_pts = [] 295 for player in players: 296 if player.is_alive(): 297 player_pts.append(player.position) 298 299 def _getpt() -> Sequence[float]: 300 point = self.ffa_spawn_points[self._next_ffa_start_index] 301 self._next_ffa_start_index = (self._next_ffa_start_index + 1) % len( 302 self.ffa_spawn_points 303 ) 304 x_range = (-0.5, 0.5) if point[3] == 0.0 else (-point[3], point[3]) 305 z_range = (-0.5, 0.5) if point[5] == 0.0 else (-point[5], point[5]) 306 point = ( 307 point[0] + random.uniform(*x_range), 308 point[1], 309 point[2] + random.uniform(*z_range), 310 ) 311 return point 312 313 if not player_pts: 314 return _getpt() 315 316 # Let's calc several start points and then pick whichever is 317 # farthest from all existing players. 318 farthestpt_dist = -1.0 319 farthestpt = None 320 for _i in range(10): 321 testpt = babase.Vec3(_getpt()) 322 closest_player_dist = 9999.0 323 for ppt in player_pts: 324 dist = (ppt - testpt).length() 325 closest_player_dist = min(dist, closest_player_dist) 326 if closest_player_dist > farthestpt_dist: 327 farthestpt_dist = closest_player_dist 328 farthestpt = testpt 329 assert farthestpt is not None 330 return tuple(farthestpt) 331 332 def get_flag_position( 333 self, team_index: int | None = None 334 ) -> Sequence[float]: 335 """Return a flag position on the map for the given team index. 336 337 Pass None to get the default flag point. 338 (used for things such as king-of-the-hill) 339 """ 340 if team_index is None: 341 return self.flag_points_default[:3] 342 return self.flag_points[team_index % len(self.flag_points)][:3] 343 344 @override 345 def exists(self) -> bool: 346 return bool(self.node) 347 348 @override 349 def handlemessage(self, msg: Any) -> Any: 350 from bascenev1 import _messages 351 352 if isinstance(msg, _messages.DieMessage): 353 if self.node: 354 self.node.delete() 355 else: 356 return super().handlemessage(msg) 357 return None
A game map.
Consists of a collection of terrain nodes, metadata, and other functionality comprising a game map.
106 def __init__( 107 self, vr_overlay_offset: Sequence[float] | None = None 108 ) -> None: 109 """Instantiate a map.""" 110 super().__init__() 111 112 # This is expected to always be a bascenev1.Node object 113 # (whether valid or not) should be set to something meaningful 114 # by child classes. 115 self.node: _bascenev1.Node | None = None 116 117 # Make our class' preload-data available to us 118 # (and instruct the user if we weren't preloaded properly). 119 try: 120 self.preloaddata = _bascenev1.getactivity().preloads[type(self)] 121 except Exception as exc: 122 raise babase.NotFoundError( 123 'Preload data not found for ' 124 + str(type(self)) 125 + '; make sure to call the type\'s preload()' 126 ' staticmethod in the activity constructor' 127 ) from exc 128 129 # Set various globals. 130 gnode = _bascenev1.getactivity().globalsnode 131 132 # Set area-of-interest bounds. 133 aoi_bounds = self.get_def_bound_box('area_of_interest_bounds') 134 if aoi_bounds is None: 135 print('WARNING: no "aoi_bounds" found for map:', self.getname()) 136 aoi_bounds = (-1, -1, -1, 1, 1, 1) 137 gnode.area_of_interest_bounds = aoi_bounds 138 139 # Set map bounds. 140 map_bounds = self.get_def_bound_box('map_bounds') 141 if map_bounds is None: 142 print('WARNING: no "map_bounds" found for map:', self.getname()) 143 map_bounds = (-30, -10, -30, 30, 100, 30) 144 _bascenev1.set_map_bounds(map_bounds) 145 146 # Set shadow ranges. 147 try: 148 gnode.shadow_range = [ 149 self.defs.points[v][1] 150 for v in [ 151 'shadow_lower_bottom', 152 'shadow_lower_top', 153 'shadow_upper_bottom', 154 'shadow_upper_top', 155 ] 156 ] 157 except Exception: 158 pass 159 160 # In vr, set a fixed point in space for the overlay to show up at. 161 # By default we use the bounds center but allow the map to override it. 162 center = ( 163 (aoi_bounds[0] + aoi_bounds[3]) * 0.5, 164 (aoi_bounds[1] + aoi_bounds[4]) * 0.5, 165 (aoi_bounds[2] + aoi_bounds[5]) * 0.5, 166 ) 167 if vr_overlay_offset is not None: 168 center = ( 169 center[0] + vr_overlay_offset[0], 170 center[1] + vr_overlay_offset[1], 171 center[2] + vr_overlay_offset[2], 172 ) 173 gnode.vr_overlay_center = center 174 gnode.vr_overlay_center_enabled = True 175 176 self.spawn_points = self.get_def_points('spawn') or [(0, 0, 0, 0, 0, 0)] 177 self.ffa_spawn_points = self.get_def_points('ffa_spawn') or [ 178 (0, 0, 0, 0, 0, 0) 179 ] 180 self.spawn_by_flag_points = self.get_def_points('spawn_by_flag') or [ 181 (0, 0, 0, 0, 0, 0) 182 ] 183 self.flag_points = self.get_def_points('flag') or [(0, 0, 0)] 184 185 # We just want points. 186 self.flag_points = [p[:3] for p in self.flag_points] 187 self.flag_points_default = self.get_def_point('flag_default') or ( 188 0, 189 1, 190 0, 191 ) 192 self.powerup_spawn_points = self.get_def_points('powerup_spawn') or [ 193 (0, 0, 0) 194 ] 195 196 # We just want points. 197 self.powerup_spawn_points = [p[:3] for p in self.powerup_spawn_points] 198 self.tnt_points = self.get_def_points('tnt') or [] 199 200 # We just want points. 201 self.tnt_points = [p[:3] for p in self.tnt_points] 202 203 self.is_hockey = False 204 self.is_flying = False 205 206 # FIXME: this should be part of game; not map. 207 # Let's select random index for first spawn point, 208 # so that no one is offended by the constant spawn on the edge. 209 self._next_ffa_start_index = random.randrange( 210 len(self.ffa_spawn_points) 211 )
Instantiate a map.
61 @classmethod 62 def preload(cls) -> None: 63 """Preload map media. 64 65 This runs the class's on_preload() method as needed to prep it to run. 66 Preloading should generally be done in a bascenev1.Activity's 67 __init__ method. Note that this is a classmethod since it is not 68 operate on map instances but rather on the class itself before 69 instances are made 70 """ 71 activity = _bascenev1.getactivity() 72 if cls not in activity.preloads: 73 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 bascenev1.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
75 @classmethod 76 def get_play_types(cls) -> list[str]: 77 """Return valid play types for this map.""" 78 return []
Return valid play types for this map.
80 @classmethod 81 def get_preview_texture_name(cls) -> str | None: 82 """Return the name of the preview texture for this map.""" 83 return None
Return the name of the preview texture for this map.
85 @classmethod 86 def on_preload(cls) -> Any: 87 """Called when the map is being preloaded. 88 89 It should return any media/data it requires to operate 90 """ 91 return None
Called when the map is being preloaded.
It should return any media/data it requires to operate
93 @classmethod 94 def getname(cls) -> str: 95 """Return the unique name of this map, in English.""" 96 return cls.name
Return the unique name of this map, in English.
98 @classmethod 99 def get_music_type(cls) -> bascenev1.MusicType | None: 100 """Return a music-type string that should be played on this map. 101 102 If None is returned, default music will be used. 103 """ 104 return None
Return a music-type string that should be played on this map.
If None is returned, default music will be used.
213 def is_point_near_edge( 214 self, point: babase.Vec3, running: bool = False 215 ) -> bool: 216 """Return whether the provided point is near an edge of the map. 217 218 Simple bot logic uses this call to determine if they 219 are approaching a cliff or wall. If this returns True they will 220 generally not walk/run any farther away from the origin. 221 If 'running' is True, the buffer should be a bit larger. 222 """ 223 del point, running # Unused. 224 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.
226 def get_def_bound_box( 227 self, name: str 228 ) -> tuple[float, float, float, float, float, float] | None: 229 """Return a 6 member bounds tuple or None if it is not defined.""" 230 try: 231 box = self.defs.boxes[name] 232 return ( 233 box[0] - box[6] / 2.0, 234 box[1] - box[7] / 2.0, 235 box[2] - box[8] / 2.0, 236 box[0] + box[6] / 2.0, 237 box[1] + box[7] / 2.0, 238 box[2] + box[8] / 2.0, 239 ) 240 except Exception: 241 return None
Return a 6 member bounds tuple or None if it is not defined.
243 def get_def_point(self, name: str) -> Sequence[float] | None: 244 """Return a single defined point or a default value in its absence.""" 245 val = self.defs.points.get(name) 246 return ( 247 None 248 if val is None 249 else babase.vec3validate(val) if __debug__ else val 250 )
Return a single defined point or a default value in its absence.
252 def get_def_points(self, name: str) -> list[Sequence[float]]: 253 """Return a list of named points. 254 255 Return as many sequential ones are defined (flag1, flag2, flag3), etc. 256 If none are defined, returns an empty list. 257 """ 258 point_list = [] 259 if self.defs and name + '1' in self.defs.points: 260 i = 1 261 while name + str(i) in self.defs.points: 262 pts = self.defs.points[name + str(i)] 263 if len(pts) == 6: 264 point_list.append(pts) 265 else: 266 if len(pts) != 3: 267 raise ValueError('invalid point') 268 point_list.append(pts + (0, 0, 0)) 269 i += 1 270 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.
272 def get_start_position(self, team_index: int) -> Sequence[float]: 273 """Return a random starting position for the given team index.""" 274 pnt = self.spawn_points[team_index % len(self.spawn_points)] 275 x_range = (-0.5, 0.5) if pnt[3] == 0.0 else (-pnt[3], pnt[3]) 276 z_range = (-0.5, 0.5) if pnt[5] == 0.0 else (-pnt[5], pnt[5]) 277 pnt = ( 278 pnt[0] + random.uniform(*x_range), 279 pnt[1], 280 pnt[2] + random.uniform(*z_range), 281 ) 282 return pnt
Return a random starting position for the given team index.
284 def get_ffa_start_position( 285 self, players: Sequence[bascenev1.Player] 286 ) -> Sequence[float]: 287 """Return a random starting position in one of the FFA spawn areas. 288 289 If a list of bascenev1.Player-s is provided; the returned points 290 will be as far from these players as possible. 291 """ 292 293 # Get positions for existing players. 294 player_pts = [] 295 for player in players: 296 if player.is_alive(): 297 player_pts.append(player.position) 298 299 def _getpt() -> Sequence[float]: 300 point = self.ffa_spawn_points[self._next_ffa_start_index] 301 self._next_ffa_start_index = (self._next_ffa_start_index + 1) % len( 302 self.ffa_spawn_points 303 ) 304 x_range = (-0.5, 0.5) if point[3] == 0.0 else (-point[3], point[3]) 305 z_range = (-0.5, 0.5) if point[5] == 0.0 else (-point[5], point[5]) 306 point = ( 307 point[0] + random.uniform(*x_range), 308 point[1], 309 point[2] + random.uniform(*z_range), 310 ) 311 return point 312 313 if not player_pts: 314 return _getpt() 315 316 # Let's calc several start points and then pick whichever is 317 # farthest from all existing players. 318 farthestpt_dist = -1.0 319 farthestpt = None 320 for _i in range(10): 321 testpt = babase.Vec3(_getpt()) 322 closest_player_dist = 9999.0 323 for ppt in player_pts: 324 dist = (ppt - testpt).length() 325 closest_player_dist = min(dist, closest_player_dist) 326 if closest_player_dist > farthestpt_dist: 327 farthestpt_dist = closest_player_dist 328 farthestpt = testpt 329 assert farthestpt is not None 330 return tuple(farthestpt)
Return a random starting position in one of the FFA spawn areas.
If a list of bascenev1.Player-s is provided; the returned points will be as far from these players as possible.
332 def get_flag_position( 333 self, team_index: int | None = None 334 ) -> Sequence[float]: 335 """Return a flag position on the map for the given team index. 336 337 Pass None to get the default flag point. 338 (used for things such as king-of-the-hill) 339 """ 340 if team_index is None: 341 return self.flag_points_default[:3] 342 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 bascenev1.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 bascenev1.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.
348 @override 349 def handlemessage(self, msg: Any) -> Any: 350 from bascenev1 import _messages 351 352 if isinstance(msg, _messages.DieMessage): 353 if self.node: 354 self.node.delete() 355 else: 356 return super().handlemessage(msg) 357 return None
General message handling; can be passed any message object.
264class Material: 265 """An entity applied to game objects to modify collision behavior. 266 267 A material can affect physical characteristics, generate sounds, 268 or trigger callback functions when collisions occur. 269 270 Materials are applied to 'parts', which are groups of one or more 271 rigid bodies created as part of a bascenev1.Node. Nodes can have any 272 number of parts, each with its own set of materials. Generally 273 materials are specified as array attributes on the Node. The `spaz` 274 node, for example, has various attributes such as `materials`, 275 `roller_materials`, and `punch_materials`, which correspond 276 to the various parts it creates. 277 278 Use bascenev1.Material to instantiate a blank material, and then use 279 its :meth:`bascenev1.Material.add_actions()` method to define what the 280 material does. 281 """ 282 283 def __init__(self, label: str | None = None) -> None: 284 pass 285 286 label: str 287 """A label for the material; only used for debugging.""" 288 289 def add_actions( 290 self, actions: tuple, conditions: tuple | None = None 291 ) -> None: 292 """Add one or more actions to the material, optionally with conditions. 293 294 Conditions 295 ========== 296 297 Conditions are provided as tuples which can be combined to form 298 boolean logic. A single condition might look like: 299 300 ``('condition_name', cond_arg)`` 301 302 Or a more complex nested one might look like: 303 304 ``(('condition1', cond_arg), 'or', ('condition2', cond2_arg))`` 305 306 The strings ``'and'``, ``'or'``, and ``'xor'`` can chain together 307 two conditions, as seen above. 308 309 Available Conditions 310 -------------------- 311 ``('they_have_material', material)`` 312 Does the part we're hitting have a given 313 :class:`bascenev1.Material`? 314 315 ``('they_dont_have_material', material)`` 316 Does the part we're hitting not have a given 317 :class:`bascenev1.Material`? 318 319 ``('eval_colliding')`` 320 Is ``'collide'`` true at this point 321 in material evaluation? (see the ``modify_part_collision`` action) 322 323 ``('eval_not_colliding')`` 324 Is ``collide`` false at this point 325 in material evaluation? (see the ``modify_part_collision`` action) 326 327 ``('we_are_younger_than', age)`` 328 Is our part younger than ``age`` (in milliseconds)? 329 330 ``('we_are_older_than', age)`` 331 Is our part older than ``age`` (in milliseconds)? 332 333 ``('they_are_younger_than', age)`` 334 Is the part we're hitting younger than ``age`` (in milliseconds)? 335 336 ``('they_are_older_than', age)`` 337 Is the part we're hitting older than ``age`` (in milliseconds)? 338 339 ``('they_are_same_node_as_us')`` 340 Does the part we're hitting belong to the same 341 :class:`bascenev1.Node` 342 as us? 343 344 ``('they_are_different_node_than_us')`` 345 Does the part we're hitting belong to a different 346 :class:`bascenev1.Node`? 347 348 Actions 349 ======= 350 351 In a similar manner, actions are specified as tuples. Multiple 352 actions can be specified by providing a tuple of tuples. 353 354 Available Actions 355 ----------------- 356 357 ``('call', when, callable)`` 358 Calls the provided callable; 359 ``when`` can be either ``'at_connect'`` or ``'at_disconnect'``. 360 ``'at_connect'`` means to fire when the two parts first come in 361 contact; ``'at_disconnect'`` means to fire once they cease being 362 in contact. 363 364 ``('message', who, when, message_obj)`` 365 Sends a message object; ``who`` can be either ``'our_node'`` or 366 ``'their_node'``, ``when`` can be ``'at_connect'`` or 367 ``'at_disconnect'``, and ``message_obj`` is the message object to 368 send. This has the same effect as calling the node's 369 :meth:`bascenev1.Node.handlemessage()` method. 370 371 ``('modify_part_collision', attr, value)`` 372 Changes some characteristic of the physical collision that will 373 occur between our part and their part. This change will remain in 374 effect as long as the two parts remain overlapping. This means if 375 you have a part with a material that turns ``'collide'`` off 376 against parts younger than 100ms, and it touches another part that 377 is 50ms old, it will continue to not collide with that part until 378 they separate, even if the 100ms threshold is passed. Options for 379 attr/value are: 380 ``'physical'`` (boolean value; whether a *physical* response will 381 occur at all), ``'friction'`` (float value; how friction-y the 382 physical response will be), ``'collide'`` (boolean value; 383 whether *any* collision will occur at all, including non-physical 384 stuff like callbacks), ``'use_node_collide'`` 385 (boolean value; whether to honor modify_node_collision 386 overrides for this collision), ``'stiffness'`` (float value, 387 how springy the physical response is), ``'damping'`` (float 388 value, how damped the physical response is), ``'bounce'`` (float 389 value; how bouncy the physical response is). 390 391 ``('modify_node_collision', attr, value)`` 392 Similar to ``modify_part_collision``, but operates at a 393 node-level. Collision attributes set here will remain in effect 394 as long as *anything* from our part's node and their part's node 395 overlap. A key use of this functionality is to prevent new nodes 396 from colliding with each other if they appear overlapped; 397 if ``modify_part_collision`` is used, only the individual 398 parts that were overlapping would avoid contact, but other parts 399 could still contact leaving the two nodes 'tangled up'. Using 400 ``modify_node_collision`` ensures that the nodes must completely 401 separate before they can start colliding. Currently the only attr 402 available here is ``'collide'`` (a boolean value). 403 404 ``('sound', sound, volume)`` 405 Plays a :class:`bascenev1.Sound` when a collision occurs, at a 406 given volume, regardless of the collision speed/etc. 407 408 ``('impact_sound', sound, target_impulse, volume)`` 409 Plays a sound when a collision occurs, based on the speed of 410 impact. Provide a :class:`bascenev1.Sound`, a target-impulse, 411 and a volume. 412 413 ``('skid_sound', sound, target_impulse, volume)`` 414 Plays a sound during a collision when parts are 'scraping' 415 against each other. Provide a :class:`bascenev1.Sound`, 416 a target-impulse, and a volume. 417 418 ``('roll_sound', sound, targetImpulse, volume)`` 419 Plays a sound during a collision when parts are 'rolling' 420 against each other. 421 Provide a :class:`bascenev1.Sound`, a target-impulse, and a 422 volume. 423 424 Examples 425 ======== 426 427 **Example 1:** Create a material that lets us ignore 428 collisions against any nodes we touch in the first 429 100 ms of our existence; handy for preventing us from 430 exploding outward if we spawn on top of another object:: 431 432 m = bascenev1.Material() 433 m.add_actions( 434 conditions=(('we_are_younger_than', 100), 435 'or', ('they_are_younger_than', 100)), 436 actions=('modify_node_collision', 'collide', False)) 437 438 **Example 2:** Send a :class:`bascenev1.DieMessage` to anything we 439 touch, but cause no physical response. This should cause any 440 :class:`bascenev1.Actor` to drop dead:: 441 442 m = bascenev1.Material() 443 m.add_actions( 444 actions=( 445 ('modify_part_collision', 'physical', False), 446 ('message', 'their_node', 'at_connect', bascenev1.DieMessage()) 447 ) 448 ) 449 450 **Example 3:** Play some sounds when we're contacting the 451 ground:: 452 453 m = bascenev1.Material() 454 m.add_actions( 455 conditions=('they_have_material' shared.footing_material), 456 actions=( 457 ('impact_sound', bascenev1.getsound('metalHit'), 2, 5), 458 ('skid_sound', bascenev1.getsound('metalSkid'), 2, 5) 459 ) 460 ) 461 """ 462 return None
An entity applied to game objects to modify collision behavior.
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 bascenev1.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 bascenev1.Material to instantiate a blank material, and then use
its bascenev1.Material.add_actions()()
method to define what the
material does.
289 def add_actions( 290 self, actions: tuple, conditions: tuple | None = None 291 ) -> None: 292 """Add one or more actions to the material, optionally with conditions. 293 294 Conditions 295 ========== 296 297 Conditions are provided as tuples which can be combined to form 298 boolean logic. A single condition might look like: 299 300 ``('condition_name', cond_arg)`` 301 302 Or a more complex nested one might look like: 303 304 ``(('condition1', cond_arg), 'or', ('condition2', cond2_arg))`` 305 306 The strings ``'and'``, ``'or'``, and ``'xor'`` can chain together 307 two conditions, as seen above. 308 309 Available Conditions 310 -------------------- 311 ``('they_have_material', material)`` 312 Does the part we're hitting have a given 313 :class:`bascenev1.Material`? 314 315 ``('they_dont_have_material', material)`` 316 Does the part we're hitting not have a given 317 :class:`bascenev1.Material`? 318 319 ``('eval_colliding')`` 320 Is ``'collide'`` true at this point 321 in material evaluation? (see the ``modify_part_collision`` action) 322 323 ``('eval_not_colliding')`` 324 Is ``collide`` false at this point 325 in material evaluation? (see the ``modify_part_collision`` action) 326 327 ``('we_are_younger_than', age)`` 328 Is our part younger than ``age`` (in milliseconds)? 329 330 ``('we_are_older_than', age)`` 331 Is our part older than ``age`` (in milliseconds)? 332 333 ``('they_are_younger_than', age)`` 334 Is the part we're hitting younger than ``age`` (in milliseconds)? 335 336 ``('they_are_older_than', age)`` 337 Is the part we're hitting older than ``age`` (in milliseconds)? 338 339 ``('they_are_same_node_as_us')`` 340 Does the part we're hitting belong to the same 341 :class:`bascenev1.Node` 342 as us? 343 344 ``('they_are_different_node_than_us')`` 345 Does the part we're hitting belong to a different 346 :class:`bascenev1.Node`? 347 348 Actions 349 ======= 350 351 In a similar manner, actions are specified as tuples. Multiple 352 actions can be specified by providing a tuple of tuples. 353 354 Available Actions 355 ----------------- 356 357 ``('call', when, callable)`` 358 Calls the provided callable; 359 ``when`` can be either ``'at_connect'`` or ``'at_disconnect'``. 360 ``'at_connect'`` means to fire when the two parts first come in 361 contact; ``'at_disconnect'`` means to fire once they cease being 362 in contact. 363 364 ``('message', who, when, message_obj)`` 365 Sends a message object; ``who`` can be either ``'our_node'`` or 366 ``'their_node'``, ``when`` can be ``'at_connect'`` or 367 ``'at_disconnect'``, and ``message_obj`` is the message object to 368 send. This has the same effect as calling the node's 369 :meth:`bascenev1.Node.handlemessage()` method. 370 371 ``('modify_part_collision', attr, value)`` 372 Changes some characteristic of the physical collision that will 373 occur between our part and their part. This change will remain in 374 effect as long as the two parts remain overlapping. This means if 375 you have a part with a material that turns ``'collide'`` off 376 against parts younger than 100ms, and it touches another part that 377 is 50ms old, it will continue to not collide with that part until 378 they separate, even if the 100ms threshold is passed. Options for 379 attr/value are: 380 ``'physical'`` (boolean value; whether a *physical* response will 381 occur at all), ``'friction'`` (float value; how friction-y the 382 physical response will be), ``'collide'`` (boolean value; 383 whether *any* collision will occur at all, including non-physical 384 stuff like callbacks), ``'use_node_collide'`` 385 (boolean value; whether to honor modify_node_collision 386 overrides for this collision), ``'stiffness'`` (float value, 387 how springy the physical response is), ``'damping'`` (float 388 value, how damped the physical response is), ``'bounce'`` (float 389 value; how bouncy the physical response is). 390 391 ``('modify_node_collision', attr, value)`` 392 Similar to ``modify_part_collision``, but operates at a 393 node-level. Collision attributes set here will remain in effect 394 as long as *anything* from our part's node and their part's node 395 overlap. A key use of this functionality is to prevent new nodes 396 from colliding with each other if they appear overlapped; 397 if ``modify_part_collision`` is used, only the individual 398 parts that were overlapping would avoid contact, but other parts 399 could still contact leaving the two nodes 'tangled up'. Using 400 ``modify_node_collision`` ensures that the nodes must completely 401 separate before they can start colliding. Currently the only attr 402 available here is ``'collide'`` (a boolean value). 403 404 ``('sound', sound, volume)`` 405 Plays a :class:`bascenev1.Sound` when a collision occurs, at a 406 given volume, regardless of the collision speed/etc. 407 408 ``('impact_sound', sound, target_impulse, volume)`` 409 Plays a sound when a collision occurs, based on the speed of 410 impact. Provide a :class:`bascenev1.Sound`, a target-impulse, 411 and a volume. 412 413 ``('skid_sound', sound, target_impulse, volume)`` 414 Plays a sound during a collision when parts are 'scraping' 415 against each other. Provide a :class:`bascenev1.Sound`, 416 a target-impulse, and a volume. 417 418 ``('roll_sound', sound, targetImpulse, volume)`` 419 Plays a sound during a collision when parts are 'rolling' 420 against each other. 421 Provide a :class:`bascenev1.Sound`, a target-impulse, and a 422 volume. 423 424 Examples 425 ======== 426 427 **Example 1:** Create a material that lets us ignore 428 collisions against any nodes we touch in the first 429 100 ms of our existence; handy for preventing us from 430 exploding outward if we spawn on top of another object:: 431 432 m = bascenev1.Material() 433 m.add_actions( 434 conditions=(('we_are_younger_than', 100), 435 'or', ('they_are_younger_than', 100)), 436 actions=('modify_node_collision', 'collide', False)) 437 438 **Example 2:** Send a :class:`bascenev1.DieMessage` to anything we 439 touch, but cause no physical response. This should cause any 440 :class:`bascenev1.Actor` to drop dead:: 441 442 m = bascenev1.Material() 443 m.add_actions( 444 actions=( 445 ('modify_part_collision', 'physical', False), 446 ('message', 'their_node', 'at_connect', bascenev1.DieMessage()) 447 ) 448 ) 449 450 **Example 3:** Play some sounds when we're contacting the 451 ground:: 452 453 m = bascenev1.Material() 454 m.add_actions( 455 conditions=('they_have_material' shared.footing_material), 456 actions=( 457 ('impact_sound', bascenev1.getsound('metalHit'), 2, 5), 458 ('skid_sound', bascenev1.getsound('metalSkid'), 2, 5) 459 ) 460 ) 461 """ 462 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:
(('condition1', cond_arg), 'or', ('condition2', cond2_arg))
The strings 'and'
, 'or'
, and 'xor'
can chain together
two conditions, as seen above.
Available Conditions
('they_have_material', material)
Does the part we're hitting have a given
bascenev1.Material
?
('they_dont_have_material', material)
Does the part we're hitting not have a given
bascenev1.Material
?
('eval_colliding')
Is 'collide'
true at this point
in material evaluation? (see the modify_part_collision
action)
('eval_not_colliding')
Is collide
false at this point
in material evaluation? (see the modify_part_collision
action)
('we_are_younger_than', age)
Is our part younger than age
(in milliseconds)?
('we_are_older_than', age)
Is our part older than age
(in milliseconds)?
('they_are_younger_than', age)
Is the part we're hitting younger than age
(in milliseconds)?
('they_are_older_than', age)
Is the part we're hitting older than age
(in milliseconds)?
('they_are_same_node_as_us')
Does the part we're hitting belong to the same
bascenev1.Node
as us?
('they_are_different_node_than_us')
Does the part we're hitting belong to a different
bascenev1.Node
?
Actions
In a similar manner, actions are specified as tuples. Multiple actions can be specified by providing a tuple of tuples.
Available Actions
('call', when, callable)
Calls the provided callable;
when
can be either 'at_connect'
or 'at_disconnect'
.
'at_connect'
means to fire when the two parts first come in
contact; 'at_disconnect'
means to fire once they cease being
in contact.
('message', who, when, message_obj)
Sends a message object; who
can be either 'our_node'
or
'their_node'
, when
can be 'at_connect'
or
'at_disconnect'
, and message_obj
is the message object to
send. This has the same effect as calling the node's
bascenev1.Node.handlemessage()()
method.
('modify_part_collision', attr, value)
Changes some characteristic of the physical collision that will
occur between our part and their part. This change will remain in
effect as long as the two parts remain overlapping. This means if
you have a part with a material that turns 'collide'
off
against parts younger than 100ms, and it touches another part that
is 50ms old, it will continue to not collide with that part until
they separate, even if the 100ms threshold is passed. Options for
attr/value are:
'physical'
(boolean value; whether a physical response will
occur at all), 'friction'
(float value; how friction-y the
physical response will be), 'collide'
(boolean value;
whether any collision will occur at all, including non-physical
stuff like callbacks), 'use_node_collide'
(boolean value; whether to honor modify_node_collision
overrides for this collision), 'stiffness'
(float value,
how springy the physical response is), 'damping'
(float
value, how damped the physical response is), 'bounce'
(float
value; how bouncy the physical response is).
('modify_node_collision', attr, value)
Similar to modify_part_collision
, but operates at a
node-level. Collision attributes set here will remain in effect
as long as anything from our part's node and their part's node
overlap. A key use of this functionality is to prevent new nodes
from colliding with each other if they appear overlapped;
if modify_part_collision
is used, only the individual
parts that were overlapping would avoid contact, but other parts
could still contact leaving the two nodes 'tangled up'. Using
modify_node_collision
ensures that the nodes must completely
separate before they can start colliding. Currently the only attr
available here is 'collide'
(a boolean value).
('sound', sound, volume)
Plays a bascenev1.Sound
when a collision occurs, at a
given volume, regardless of the collision speed/etc.
('impact_sound', sound, target_impulse, volume)
Plays a sound when a collision occurs, based on the speed of
impact. Provide a bascenev1.Sound
, a target-impulse,
and a volume.
('skid_sound', sound, target_impulse, volume)
Plays a sound during a collision when parts are 'scraping'
against each other. Provide a bascenev1.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 bascenev1.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 = bascenev1.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 bascenev1.DieMessage
to anything we
touch, but cause no physical response. This should cause any
bascenev1.Actor
to drop dead::
m = bascenev1.Material() m.add_actions( actions=( ('modify_part_collision', 'physical', False), ('message', 'their_node', 'at_connect', bascenev1.DieMessage()) ) )
Example 3: Play some sounds when we're contacting the ground::
m = bascenev1.Material() m.add_actions( conditions=('they_have_material' shared.footing_material), actions=( ('impact_sound', bascenev1.getsound('metalHit'), 2, 5), ('skid_sound', bascenev1.getsound('metalSkid'), 2, 5) ) )
465class Mesh: 466 """A reference to a mesh. 467 468 Meshes are used for drawing. 469 Use bascenev1.getmesh() to instantiate one. 470 """ 471 472 pass
A reference to a mesh.
Meshes are used for drawing. Use bascenev1.getmesh() to instantiate one.
26class MultiTeamSession(Session): 27 """Common base for DualTeamSession and FreeForAllSession. 28 29 Free-for-all-mode is essentially just teams-mode with each 30 bascenev1.Player having their own bascenev1.Team, so there is much 31 overlap in functionality. 32 """ 33 34 # These should be overridden. 35 _playlist_selection_var = 'UNSET Playlist Selection' 36 _playlist_randomize_var = 'UNSET Playlist Randomize' 37 _playlists_var = 'UNSET Playlists' 38 39 def __init__(self) -> None: 40 """Set up playlists & launch a bascenev1.Activity to accept joiners.""" 41 # pylint: disable=cyclic-import 42 from bascenev1 import _playlist 43 from bascenev1lib.activity.multiteamjoin import MultiTeamJoinActivity 44 45 app = babase.app 46 classic = app.classic 47 assert classic is not None 48 cfg = app.config 49 50 if self.use_teams: 51 team_names = cfg.get('Custom Team Names', DEFAULT_TEAM_NAMES) 52 team_colors = cfg.get('Custom Team Colors', DEFAULT_TEAM_COLORS) 53 else: 54 team_names = None 55 team_colors = None 56 57 # print('FIXME: TEAM BASE SESSION WOULD CALC DEPS.') 58 depsets: Sequence[bascenev1.DependencySet] = [] 59 60 super().__init__( 61 depsets, 62 team_names=team_names, 63 team_colors=team_colors, 64 min_players=1, 65 max_players=self.get_max_players(), 66 ) 67 68 self._series_length: int = int(cfg.get('Teams Series Length', 7)) 69 self._ffa_series_length: int = int(cfg.get('FFA Series Length', 24)) 70 71 show_tutorial = cfg.get('Show Tutorial', True) 72 73 # Special case: don't show tutorial while stress testing. 74 if classic.stress_test_update_timer is not None: 75 show_tutorial = False 76 77 self._tutorial_activity_instance: bascenev1.Activity | None 78 if show_tutorial: 79 from bascenev1lib.tutorial import TutorialActivity 80 81 tutorial_activity = TutorialActivity 82 83 # Get this loading. 84 self._tutorial_activity_instance = _bascenev1.newactivity( 85 tutorial_activity 86 ) 87 else: 88 self._tutorial_activity_instance = None 89 90 self._playlist_name = cfg.get( 91 self._playlist_selection_var, '__default__' 92 ) 93 self._playlist_randomize = cfg.get(self._playlist_randomize_var, False) 94 95 # Which game activity we're on. 96 self._game_number = 0 97 98 playlists = cfg.get(self._playlists_var, {}) 99 100 if ( 101 self._playlist_name != '__default__' 102 and self._playlist_name in playlists 103 ): 104 # Make sure to copy this, as we muck with it in place once we've 105 # got it and we don't want that to affect our config. 106 playlist = copy.deepcopy(playlists[self._playlist_name]) 107 else: 108 if self.use_teams: 109 playlist = _playlist.get_default_teams_playlist() 110 else: 111 playlist = _playlist.get_default_free_for_all_playlist() 112 113 # Resolve types and whatnot to get our final playlist. 114 playlist_resolved = _playlist.filter_playlist( 115 playlist, 116 sessiontype=type(self), 117 add_resolved_type=True, 118 name='default teams' if self.use_teams else 'default ffa', 119 ) 120 121 if not playlist_resolved: 122 raise RuntimeError('Playlist contains no valid games.') 123 124 self._playlist = ShuffleList( 125 playlist_resolved, shuffle=self._playlist_randomize 126 ) 127 128 # Get a game on deck ready to go. 129 self._current_game_spec: dict[str, Any] | None = None 130 self._next_game_spec: dict[str, Any] = self._playlist.pull_next() 131 self._next_game: type[bascenev1.GameActivity] = self._next_game_spec[ 132 'resolved_type' 133 ] 134 135 # Go ahead and instantiate the next game we'll 136 # use so it has lots of time to load. 137 self._instantiate_next_game() 138 139 # Start in our custom join screen. 140 self.setactivity(_bascenev1.newactivity(MultiTeamJoinActivity)) 141 142 def get_ffa_series_length(self) -> int: 143 """Return free-for-all series length.""" 144 return self._ffa_series_length 145 146 def get_series_length(self) -> int: 147 """Return teams series length.""" 148 return self._series_length 149 150 def get_next_game_description(self) -> babase.Lstr: 151 """Returns a description of the next game on deck.""" 152 # pylint: disable=cyclic-import 153 from bascenev1._gameactivity import GameActivity 154 155 gametype: type[GameActivity] = self._next_game_spec['resolved_type'] 156 assert issubclass(gametype, GameActivity) 157 return gametype.get_settings_display_string(self._next_game_spec) 158 159 def get_game_number(self) -> int: 160 """Returns which game in the series is currently being played.""" 161 return self._game_number 162 163 @override 164 def on_team_join(self, team: bascenev1.SessionTeam) -> None: 165 team.customdata['previous_score'] = team.customdata['score'] = 0 166 167 def get_max_players(self) -> int: 168 """Return max number of Players allowed to join the game at once.""" 169 if self.use_teams: 170 val = babase.app.config.get('Team Game Max Players', 8) 171 else: 172 val = babase.app.config.get('Free-for-All Max Players', 8) 173 assert isinstance(val, int) 174 return val 175 176 def _instantiate_next_game(self) -> None: 177 self._next_game_instance = _bascenev1.newactivity( 178 self._next_game_spec['resolved_type'], 179 self._next_game_spec['settings'], 180 ) 181 182 @override 183 def on_activity_end( 184 self, activity: bascenev1.Activity, results: Any 185 ) -> None: 186 # pylint: disable=cyclic-import 187 from bascenev1lib.tutorial import TutorialActivity 188 from bascenev1lib.activity.multiteamvictory import ( 189 TeamSeriesVictoryScoreScreenActivity, 190 ) 191 from bascenev1._activitytypes import ( 192 TransitionActivity, 193 JoinActivity, 194 ScoreScreenActivity, 195 ) 196 197 # If we have a tutorial to show, that's the first thing we do no 198 # matter what. 199 if self._tutorial_activity_instance is not None: 200 self.setactivity(self._tutorial_activity_instance) 201 self._tutorial_activity_instance = None 202 203 # If we're leaving the tutorial activity, pop a transition activity 204 # to transition us into a round gracefully (otherwise we'd snap from 205 # one terrain to another instantly). 206 elif isinstance(activity, TutorialActivity): 207 self.setactivity(_bascenev1.newactivity(TransitionActivity)) 208 209 # If we're in a between-round activity or a restart-activity, hop 210 # into a round. 211 elif isinstance( 212 activity, (JoinActivity, TransitionActivity, ScoreScreenActivity) 213 ): 214 # If we're coming from a series-end activity, reset scores. 215 if isinstance(activity, TeamSeriesVictoryScoreScreenActivity): 216 self.stats.reset() 217 self._game_number = 0 218 for team in self.sessionteams: 219 team.customdata['score'] = 0 220 221 # Otherwise just set accum (per-game) scores. 222 else: 223 self.stats.reset_accum() 224 225 next_game = self._next_game_instance 226 227 self._current_game_spec = self._next_game_spec 228 self._next_game_spec = self._playlist.pull_next() 229 self._game_number += 1 230 231 # Instantiate the next now so they have plenty of time to load. 232 self._instantiate_next_game() 233 234 # (Re)register all players and wire stats to our next activity. 235 for player in self.sessionplayers: 236 # ..but only ones who have been placed on a team 237 # (ie: no longer sitting in the lobby). 238 try: 239 has_team = player.sessionteam is not None 240 except babase.NotFoundError: 241 has_team = False 242 if has_team: 243 self.stats.register_sessionplayer(player) 244 self.stats.setactivity(next_game) 245 246 # Now flip the current activity. 247 self.setactivity(next_game) 248 249 # If we're leaving a round, go to the score screen. 250 else: 251 self._switch_to_score_screen(results) 252 253 def _switch_to_score_screen(self, results: Any) -> None: 254 """Switch to a score screen after leaving a round.""" 255 del results # Unused arg. 256 logging.error('This should be overridden.', stack_info=True) 257 258 def announce_game_results( 259 self, 260 activity: bascenev1.GameActivity, 261 results: bascenev1.GameResults, 262 delay: float, 263 announce_winning_team: bool = True, 264 ) -> None: 265 """Show basic game result at the end of a game. 266 267 (before transitioning to a score screen). 268 This will include a zoom-text of 'BLUE WINS' 269 or whatnot, along with a possible audio 270 announcement of the same. 271 """ 272 # pylint: disable=cyclic-import 273 from bascenev1._gameutils import cameraflash 274 from bascenev1._freeforallsession import FreeForAllSession 275 from bascenev1._messages import CelebrateMessage 276 277 _bascenev1.timer(delay, _bascenev1.getsound('boxingBell').play) 278 279 if announce_winning_team: 280 winning_sessionteam = results.winning_sessionteam 281 if winning_sessionteam is not None: 282 # Have all players celebrate. 283 celebrate_msg = CelebrateMessage(duration=10.0) 284 assert winning_sessionteam.activityteam is not None 285 for player in winning_sessionteam.activityteam.players: 286 if player.actor: 287 player.actor.handlemessage(celebrate_msg) 288 cameraflash() 289 290 # Some languages say "FOO WINS" different for teams vs players. 291 if isinstance(self, FreeForAllSession): 292 wins_resource = 'winsPlayerText' 293 else: 294 wins_resource = 'winsTeamText' 295 wins_text = babase.Lstr( 296 resource=wins_resource, 297 subs=[('${NAME}', winning_sessionteam.name)], 298 ) 299 activity.show_zoom_message( 300 wins_text, 301 scale=0.85, 302 color=babase.normalized_color(winning_sessionteam.color), 303 )
Common base for DualTeamSession and FreeForAllSession.
Free-for-all-mode is essentially just teams-mode with each bascenev1.Player having their own bascenev1.Team, so there is much overlap in functionality.
39 def __init__(self) -> None: 40 """Set up playlists & launch a bascenev1.Activity to accept joiners.""" 41 # pylint: disable=cyclic-import 42 from bascenev1 import _playlist 43 from bascenev1lib.activity.multiteamjoin import MultiTeamJoinActivity 44 45 app = babase.app 46 classic = app.classic 47 assert classic is not None 48 cfg = app.config 49 50 if self.use_teams: 51 team_names = cfg.get('Custom Team Names', DEFAULT_TEAM_NAMES) 52 team_colors = cfg.get('Custom Team Colors', DEFAULT_TEAM_COLORS) 53 else: 54 team_names = None 55 team_colors = None 56 57 # print('FIXME: TEAM BASE SESSION WOULD CALC DEPS.') 58 depsets: Sequence[bascenev1.DependencySet] = [] 59 60 super().__init__( 61 depsets, 62 team_names=team_names, 63 team_colors=team_colors, 64 min_players=1, 65 max_players=self.get_max_players(), 66 ) 67 68 self._series_length: int = int(cfg.get('Teams Series Length', 7)) 69 self._ffa_series_length: int = int(cfg.get('FFA Series Length', 24)) 70 71 show_tutorial = cfg.get('Show Tutorial', True) 72 73 # Special case: don't show tutorial while stress testing. 74 if classic.stress_test_update_timer is not None: 75 show_tutorial = False 76 77 self._tutorial_activity_instance: bascenev1.Activity | None 78 if show_tutorial: 79 from bascenev1lib.tutorial import TutorialActivity 80 81 tutorial_activity = TutorialActivity 82 83 # Get this loading. 84 self._tutorial_activity_instance = _bascenev1.newactivity( 85 tutorial_activity 86 ) 87 else: 88 self._tutorial_activity_instance = None 89 90 self._playlist_name = cfg.get( 91 self._playlist_selection_var, '__default__' 92 ) 93 self._playlist_randomize = cfg.get(self._playlist_randomize_var, False) 94 95 # Which game activity we're on. 96 self._game_number = 0 97 98 playlists = cfg.get(self._playlists_var, {}) 99 100 if ( 101 self._playlist_name != '__default__' 102 and self._playlist_name in playlists 103 ): 104 # Make sure to copy this, as we muck with it in place once we've 105 # got it and we don't want that to affect our config. 106 playlist = copy.deepcopy(playlists[self._playlist_name]) 107 else: 108 if self.use_teams: 109 playlist = _playlist.get_default_teams_playlist() 110 else: 111 playlist = _playlist.get_default_free_for_all_playlist() 112 113 # Resolve types and whatnot to get our final playlist. 114 playlist_resolved = _playlist.filter_playlist( 115 playlist, 116 sessiontype=type(self), 117 add_resolved_type=True, 118 name='default teams' if self.use_teams else 'default ffa', 119 ) 120 121 if not playlist_resolved: 122 raise RuntimeError('Playlist contains no valid games.') 123 124 self._playlist = ShuffleList( 125 playlist_resolved, shuffle=self._playlist_randomize 126 ) 127 128 # Get a game on deck ready to go. 129 self._current_game_spec: dict[str, Any] | None = None 130 self._next_game_spec: dict[str, Any] = self._playlist.pull_next() 131 self._next_game: type[bascenev1.GameActivity] = self._next_game_spec[ 132 'resolved_type' 133 ] 134 135 # Go ahead and instantiate the next game we'll 136 # use so it has lots of time to load. 137 self._instantiate_next_game() 138 139 # Start in our custom join screen. 140 self.setactivity(_bascenev1.newactivity(MultiTeamJoinActivity))
Set up playlists & launch a bascenev1.Activity to accept joiners.
142 def get_ffa_series_length(self) -> int: 143 """Return free-for-all series length.""" 144 return self._ffa_series_length
Return free-for-all series length.
146 def get_series_length(self) -> int: 147 """Return teams series length.""" 148 return self._series_length
Return teams series length.
150 def get_next_game_description(self) -> babase.Lstr: 151 """Returns a description of the next game on deck.""" 152 # pylint: disable=cyclic-import 153 from bascenev1._gameactivity import GameActivity 154 155 gametype: type[GameActivity] = self._next_game_spec['resolved_type'] 156 assert issubclass(gametype, GameActivity) 157 return gametype.get_settings_display_string(self._next_game_spec)
Returns a description of the next game on deck.
159 def get_game_number(self) -> int: 160 """Returns which game in the series is currently being played.""" 161 return self._game_number
Returns which game in the series is currently being played.
163 @override 164 def on_team_join(self, team: bascenev1.SessionTeam) -> None: 165 team.customdata['previous_score'] = team.customdata['score'] = 0
Called when a new bascenev1.Team joins the session.
167 def get_max_players(self) -> int: 168 """Return max number of Players allowed to join the game at once.""" 169 if self.use_teams: 170 val = babase.app.config.get('Team Game Max Players', 8) 171 else: 172 val = babase.app.config.get('Free-for-All Max Players', 8) 173 assert isinstance(val, int) 174 return val
Return max number of Players allowed to join the game at once.
182 @override 183 def on_activity_end( 184 self, activity: bascenev1.Activity, results: Any 185 ) -> None: 186 # pylint: disable=cyclic-import 187 from bascenev1lib.tutorial import TutorialActivity 188 from bascenev1lib.activity.multiteamvictory import ( 189 TeamSeriesVictoryScoreScreenActivity, 190 ) 191 from bascenev1._activitytypes import ( 192 TransitionActivity, 193 JoinActivity, 194 ScoreScreenActivity, 195 ) 196 197 # If we have a tutorial to show, that's the first thing we do no 198 # matter what. 199 if self._tutorial_activity_instance is not None: 200 self.setactivity(self._tutorial_activity_instance) 201 self._tutorial_activity_instance = None 202 203 # If we're leaving the tutorial activity, pop a transition activity 204 # to transition us into a round gracefully (otherwise we'd snap from 205 # one terrain to another instantly). 206 elif isinstance(activity, TutorialActivity): 207 self.setactivity(_bascenev1.newactivity(TransitionActivity)) 208 209 # If we're in a between-round activity or a restart-activity, hop 210 # into a round. 211 elif isinstance( 212 activity, (JoinActivity, TransitionActivity, ScoreScreenActivity) 213 ): 214 # If we're coming from a series-end activity, reset scores. 215 if isinstance(activity, TeamSeriesVictoryScoreScreenActivity): 216 self.stats.reset() 217 self._game_number = 0 218 for team in self.sessionteams: 219 team.customdata['score'] = 0 220 221 # Otherwise just set accum (per-game) scores. 222 else: 223 self.stats.reset_accum() 224 225 next_game = self._next_game_instance 226 227 self._current_game_spec = self._next_game_spec 228 self._next_game_spec = self._playlist.pull_next() 229 self._game_number += 1 230 231 # Instantiate the next now so they have plenty of time to load. 232 self._instantiate_next_game() 233 234 # (Re)register all players and wire stats to our next activity. 235 for player in self.sessionplayers: 236 # ..but only ones who have been placed on a team 237 # (ie: no longer sitting in the lobby). 238 try: 239 has_team = player.sessionteam is not None 240 except babase.NotFoundError: 241 has_team = False 242 if has_team: 243 self.stats.register_sessionplayer(player) 244 self.stats.setactivity(next_game) 245 246 # Now flip the current activity. 247 self.setactivity(next_game) 248 249 # If we're leaving a round, go to the score screen. 250 else: 251 self._switch_to_score_screen(results)
Called when the current bascenev1.Activity has ended.
The bascenev1.Session should look at the results and start another bascenev1.Activity.
258 def announce_game_results( 259 self, 260 activity: bascenev1.GameActivity, 261 results: bascenev1.GameResults, 262 delay: float, 263 announce_winning_team: bool = True, 264 ) -> None: 265 """Show basic game result at the end of a game. 266 267 (before transitioning to a score screen). 268 This will include a zoom-text of 'BLUE WINS' 269 or whatnot, along with a possible audio 270 announcement of the same. 271 """ 272 # pylint: disable=cyclic-import 273 from bascenev1._gameutils import cameraflash 274 from bascenev1._freeforallsession import FreeForAllSession 275 from bascenev1._messages import CelebrateMessage 276 277 _bascenev1.timer(delay, _bascenev1.getsound('boxingBell').play) 278 279 if announce_winning_team: 280 winning_sessionteam = results.winning_sessionteam 281 if winning_sessionteam is not None: 282 # Have all players celebrate. 283 celebrate_msg = CelebrateMessage(duration=10.0) 284 assert winning_sessionteam.activityteam is not None 285 for player in winning_sessionteam.activityteam.players: 286 if player.actor: 287 player.actor.handlemessage(celebrate_msg) 288 cameraflash() 289 290 # Some languages say "FOO WINS" different for teams vs players. 291 if isinstance(self, FreeForAllSession): 292 wins_resource = 'winsPlayerText' 293 else: 294 wins_resource = 'winsTeamText' 295 wins_text = babase.Lstr( 296 resource=wins_resource, 297 subs=[('${NAME}', winning_sessionteam.name)], 298 ) 299 activity.show_zoom_message( 300 wins_text, 301 scale=0.85, 302 color=babase.normalized_color(winning_sessionteam.color), 303 )
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.
17class MusicType(Enum): 18 """Types of music available to play in-game. 19 20 These do not correspond to specific pieces of music, but rather to 21 'situations'. The actual music played for each type can be overridden 22 by the game or by the user. 23 """ 24 25 MENU = 'Menu' 26 VICTORY = 'Victory' 27 CHAR_SELECT = 'CharSelect' 28 RUN_AWAY = 'RunAway' 29 ONSLAUGHT = 'Onslaught' 30 KEEP_AWAY = 'Keep Away' 31 RACE = 'Race' 32 EPIC_RACE = 'Epic Race' 33 SCORES = 'Scores' 34 GRAND_ROMP = 'GrandRomp' 35 TO_THE_DEATH = 'ToTheDeath' 36 CHOSEN_ONE = 'Chosen One' 37 FORWARD_MARCH = 'ForwardMarch' 38 FLAG_CATCHER = 'FlagCatcher' 39 SURVIVAL = 'Survival' 40 EPIC = 'Epic' 41 SPORTS = 'Sports' 42 HOCKEY = 'Hockey' 43 FOOTBALL = 'Football' 44 FLYING = 'Flying' 45 SCARY = 'Scary' 46 MARCHING = 'Marching'
Types of music available to play in-game.
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.
1472def newactivity( 1473 activity_type: type[bascenev1.Activity], settings: dict | None = None 1474) -> bascenev1.Activity: 1475 """Instantiates a bascenev1.Activity given a type object. 1476 1477 Activities require special setup and thus cannot be directly 1478 instantiated; you must go through this function. 1479 """ 1480 import bascenev1 # pylint: disable=cyclic-import 1481 1482 return bascenev1.Activity(settings={})
Instantiates a bascenev1.Activity given a type object.
Activities require special setup and thus cannot be directly instantiated; you must go through this function.
1486def newnode( 1487 type: str, 1488 owner: bascenev1.Node | None = None, 1489 attrs: dict | None = None, 1490 name: str | None = None, 1491 delegate: Any = None, 1492) -> bascenev1.Node: 1493 """Add a node of the given type to the game. 1494 1495 If a dict is provided for 'attributes', the node's initial attributes 1496 will be set based on them. 1497 1498 'name', if provided, will be stored with the node purely for debugging 1499 purposes. If no name is provided, an automatic one will be generated 1500 such as 'terrain@foo.py:30'. 1501 1502 If 'delegate' is provided, Python messages sent to the node will go to 1503 that object's handlemessage() method. Note that the delegate is stored 1504 as a weak-ref, so the node itself will not keep the object alive. 1505 1506 if 'owner' is provided, the node will be automatically killed when that 1507 object dies. 'owner' can be another node or a bascenev1.Actor 1508 """ 1509 import bascenev1 # pylint: disable=cyclic-import 1510 1511 return bascenev1.Node()
Add a node of the given type to the game.
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 bascenev1.Actor
476class Node: 477 """Reference to a Node; the low level building block of a game. 478 479 At its core, a game is nothing more than a scene of Nodes 480 with attributes getting interconnected or set over time. 481 482 A bascenev1.Node instance should be thought of as a weak-reference 483 to a game node; *not* the node itself. This means a Node's 484 lifecycle is completely independent of how many Python references 485 to it exist. To explicitly add a new node to the game, use 486 bascenev1.newnode(), and to explicitly delete one, 487 use bascenev1.Node.delete(). 488 bascenev1.Node.exists() can be used to determine if a Node still points 489 to a live node in the game. 490 491 You can use `ba.Node(None)` to instantiate an invalid 492 Node reference (sometimes used as attr values/etc). 493 """ 494 495 # Note attributes: 496 # NOTE: I'm just adding *all* possible node attrs here 497 # now now since we have a single bascenev1.Node type; in the 498 # future I hope to create proper individual classes 499 # corresponding to different node types with correct 500 # attributes per node-type. 501 color: Sequence[float] = (0.0, 0.0, 0.0) 502 size: Sequence[float] = (0.0, 0.0, 0.0) 503 position: Sequence[float] = (0.0, 0.0, 0.0) 504 position_center: Sequence[float] = (0.0, 0.0, 0.0) 505 position_forward: Sequence[float] = (0.0, 0.0, 0.0) 506 punch_position: Sequence[float] = (0.0, 0.0, 0.0) 507 punch_velocity: Sequence[float] = (0.0, 0.0, 0.0) 508 velocity: Sequence[float] = (0.0, 0.0, 0.0) 509 name_color: Sequence[float] = (0.0, 0.0, 0.0) 510 tint_color: Sequence[float] = (0.0, 0.0, 0.0) 511 tint2_color: Sequence[float] = (0.0, 0.0, 0.0) 512 text: babase.Lstr | str = '' 513 texture: bascenev1.Texture | None = None 514 tint_texture: bascenev1.Texture | None = None 515 times: Sequence[int] = (1, 2, 3, 4, 5) 516 values: Sequence[float] = (1.0, 2.0, 3.0, 4.0) 517 offset: float = 0.0 518 input0: float = 0.0 519 input1: float = 0.0 520 input2: float = 0.0 521 input3: float = 0.0 522 flashing: bool = False 523 scale: float | Sequence[float] = 0.0 524 opacity: float = 0.0 525 loop: bool = False 526 time1: int = 0 527 time2: int = 0 528 timemax: int = 0 529 client_only: bool = False 530 materials: Sequence[bascenev1.Material] = () 531 roller_materials: Sequence[bascenev1.Material] = () 532 name: str = '' 533 punch_materials: Sequence[bascenev1.Material] = () 534 pickup_materials: Sequence[bascenev1.Material] = () 535 extras_material: Sequence[bascenev1.Material] = () 536 rotate: float = 0.0 537 hold_node: bascenev1.Node | None = None 538 hold_body: int = 0 539 host_only: bool = False 540 premultiplied: bool = False 541 source_player: bascenev1.Player | None = None 542 mesh_opaque: bascenev1.Mesh | None = None 543 mesh_transparent: bascenev1.Mesh | None = None 544 damage_smoothed: float = 0.0 545 gravity_scale: float = 1.0 546 punch_power: float = 0.0 547 punch_momentum_linear: Sequence[float] = (0.0, 0.0, 0.0) 548 punch_momentum_angular: float = 0.0 549 rate: int = 0 550 vr_depth: float = 0.0 551 is_area_of_interest: bool = False 552 jump_pressed: bool = False 553 pickup_pressed: bool = False 554 punch_pressed: bool = False 555 bomb_pressed: bool = False 556 fly_pressed: bool = False 557 hold_position_pressed: bool = False 558 knockout: float = 0.0 559 invincible: bool = False 560 stick_to_owner: bool = False 561 damage: int = 0 562 run: float = 0.0 563 move_up_down: float = 0.0 564 move_left_right: float = 0.0 565 curse_death_time: int = 0 566 boxing_gloves: bool = False 567 hockey: bool = False 568 use_fixed_vr_overlay: bool = False 569 allow_kick_idle_players: bool = False 570 music_continuous: bool = False 571 music_count: int = 0 572 hurt: float = 0.0 573 always_show_health_bar: bool = False 574 mini_billboard_1_texture: bascenev1.Texture | None = None 575 mini_billboard_1_start_time: int = 0 576 mini_billboard_1_end_time: int = 0 577 mini_billboard_2_texture: bascenev1.Texture | None = None 578 mini_billboard_2_start_time: int = 0 579 mini_billboard_2_end_time: int = 0 580 mini_billboard_3_texture: bascenev1.Texture | None = None 581 mini_billboard_3_start_time: int = 0 582 mini_billboard_3_end_time: int = 0 583 boxing_gloves_flashing: bool = False 584 dead: bool = False 585 floor_reflection: bool = False 586 debris_friction: float = 0.0 587 debris_kill_height: float = 0.0 588 vr_near_clip: float = 0.0 589 shadow_ortho: bool = False 590 happy_thoughts_mode: bool = False 591 shadow_offset: Sequence[float] = (0.0, 0.0) 592 paused: bool = False 593 time: int = 0 594 ambient_color: Sequence[float] = (1.0, 1.0, 1.0) 595 camera_mode: str = 'rotate' 596 frozen: bool = False 597 area_of_interest_bounds: Sequence[float] = (-1, -1, -1, 1, 1, 1) 598 shadow_range: Sequence[float] = (0, 0, 0, 0) 599 counter_text: str = '' 600 counter_texture: bascenev1.Texture | None = None 601 shattered: int = 0 602 billboard_texture: bascenev1.Texture | None = None 603 billboard_cross_out: bool = False 604 billboard_opacity: float = 0.0 605 slow_motion: bool = False 606 music: str = '' 607 vr_camera_offset: Sequence[float] = (0.0, 0.0, 0.0) 608 vr_overlay_center: Sequence[float] = (0.0, 0.0, 0.0) 609 vr_overlay_center_enabled: bool = False 610 vignette_outer: Sequence[float] = (0.0, 0.0) 611 vignette_inner: Sequence[float] = (0.0, 0.0) 612 tint: Sequence[float] = (1.0, 1.0, 1.0) 613 614 def __bool__(self) -> bool: 615 """Support for bool evaluation.""" 616 return bool(True) # Slight obfuscation. 617 618 def add_death_action(self, action: Callable[[], None]) -> None: 619 """Add a callable object to be called upon this node's death. 620 Note that these actions are run just after the node dies, not before. 621 """ 622 return None 623 624 def connectattr(self, srcattr: str, dstnode: Node, dstattr: str) -> None: 625 """Connect one of this node's attributes to an attribute on another 626 node. This will immediately set the target attribute's value to that 627 of the source attribute, and will continue to do so once per step 628 as long as the two nodes exist. The connection can be severed by 629 setting the target attribute to any value or connecting another 630 node attribute to it. 631 632 ##### Example 633 Create a locator and attach a light to it: 634 >>> light = bascenev1.newnode('light') 635 ... loc = bascenev1.newnode('locator', attrs={'position': (0, 10, 0)}) 636 ... loc.connectattr('position', light, 'position') 637 """ 638 return None 639 640 def delete(self, ignore_missing: bool = True) -> None: 641 """Delete the node. Ignores already-deleted nodes if `ignore_missing` 642 is True; otherwise a bascenev1.NodeNotFoundError is thrown. 643 """ 644 return None 645 646 def exists(self) -> bool: 647 """Returns whether the Node still exists. 648 Most functionality will fail on a nonexistent Node, so it's never a bad 649 idea to check this. 650 651 Note that you can also use the boolean operator for this same 652 functionality, so a statement such as "if mynode" will do 653 the right thing both for Node objects and values of None. 654 """ 655 return bool() 656 657 # Show that ur return type varies based on "doraise" value: 658 @overload 659 def getdelegate( 660 self, type: type[_T], doraise: Literal[False] = False 661 ) -> _T | None: ... 662 663 @overload 664 def getdelegate(self, type: type[_T], doraise: Literal[True]) -> _T: ... 665 666 def getdelegate(self, type: Any, doraise: bool = False) -> Any: 667 """Return the node's current delegate object if it matches 668 a certain type. 669 670 If the node has no delegate or it is not an instance of the passed 671 type, then None will be returned. If 'doraise' is True, then an 672 bascenev1.DelegateNotFoundError will be raised instead. 673 """ 674 return None 675 676 def getname(self) -> str: 677 """Return the name assigned to a Node; used mainly for debugging""" 678 return str() 679 680 def getnodetype(self) -> str: 681 """Return the type of Node referenced by this object as a string. 682 (Note this is different from the Python type which is always 683 bascenev1.Node) 684 """ 685 return str() 686 687 def handlemessage(self, *args: Any) -> None: 688 """General message handling; can be passed any message object. 689 690 All standard message objects are forwarded along to the 691 bascenev1.Node's delegate for handling (generally the bascenev1.Actor 692 that made the node). 693 694 bascenev1.Node-s are unique, however, in that they can be passed a 695 second form of message; 'node-messages'. These consist of a string 696 type-name as a first argument along with the args specific to that type 697 name as additional arguments. 698 Node-messages communicate directly with the low-level node layer 699 and are delivered simultaneously on all game clients, 700 acting as an alternative to setting node attributes. 701 """ 702 return None
Reference to a Node; the low level building block of a game.
At its core, a game is nothing more than a scene of Nodes with attributes getting interconnected or set over time.
A bascenev1.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 bascenev1.newnode(), and to explicitly delete one, use bascenev1.Node.delete(). bascenev1.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).
618 def add_death_action(self, action: Callable[[], None]) -> None: 619 """Add a callable object to be called upon this node's death. 620 Note that these actions are run just after the node dies, not before. 621 """ 622 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.
624 def connectattr(self, srcattr: str, dstnode: Node, dstattr: str) -> None: 625 """Connect one of this node's attributes to an attribute on another 626 node. This will immediately set the target attribute's value to that 627 of the source attribute, and will continue to do so once per step 628 as long as the two nodes exist. The connection can be severed by 629 setting the target attribute to any value or connecting another 630 node attribute to it. 631 632 ##### Example 633 Create a locator and attach a light to it: 634 >>> light = bascenev1.newnode('light') 635 ... loc = bascenev1.newnode('locator', attrs={'position': (0, 10, 0)}) 636 ... loc.connectattr('position', light, 'position') 637 """ 638 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 = bascenev1.newnode('light')
... loc = bascenev1.newnode('locator', attrs={'position': (0, 10, 0)})
... loc.connectattr('position', light, 'position')
640 def delete(self, ignore_missing: bool = True) -> None: 641 """Delete the node. Ignores already-deleted nodes if `ignore_missing` 642 is True; otherwise a bascenev1.NodeNotFoundError is thrown. 643 """ 644 return None
Delete the node. Ignores already-deleted nodes if ignore_missing
is True; otherwise a bascenev1.NodeNotFoundError is thrown.
646 def exists(self) -> bool: 647 """Returns whether the Node still exists. 648 Most functionality will fail on a nonexistent Node, so it's never a bad 649 idea to check this. 650 651 Note that you can also use the boolean operator for this same 652 functionality, so a statement such as "if mynode" will do 653 the right thing both for Node objects and values of None. 654 """ 655 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.
666 def getdelegate(self, type: Any, doraise: bool = False) -> Any: 667 """Return the node's current delegate object if it matches 668 a certain type. 669 670 If the node has no delegate or it is not an instance of the passed 671 type, then None will be returned. If 'doraise' is True, then an 672 bascenev1.DelegateNotFoundError will be raised instead. 673 """ 674 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 bascenev1.DelegateNotFoundError will be raised instead.
676 def getname(self) -> str: 677 """Return the name assigned to a Node; used mainly for debugging""" 678 return str()
Return the name assigned to a Node; used mainly for debugging
680 def getnodetype(self) -> str: 681 """Return the type of Node referenced by this object as a string. 682 (Note this is different from the Python type which is always 683 bascenev1.Node) 684 """ 685 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 bascenev1.Node)
687 def handlemessage(self, *args: Any) -> None: 688 """General message handling; can be passed any message object. 689 690 All standard message objects are forwarded along to the 691 bascenev1.Node's delegate for handling (generally the bascenev1.Actor 692 that made the node). 693 694 bascenev1.Node-s are unique, however, in that they can be passed a 695 second form of message; 'node-messages'. These consist of a string 696 type-name as a first argument along with the args specific to that type 697 name as additional arguments. 698 Node-messages communicate directly with the low-level node layer 699 and are delivered simultaneously on all game clients, 700 acting as an alternative to setting node attributes. 701 """ 702 return None
General message handling; can be passed any message object.
All standard message objects are forwarded along to the bascenev1.Node's delegate for handling (generally the bascenev1.Actor that made the node).
bascenev1.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.
19class NodeActor(Actor): 20 """A simple bascenev1.Actor type that wraps a single bascenev1.Node. 21 22 This Actor will delete its Node when told to die, and it's 23 exists() call will return whether the Node still exists or not. 24 """ 25 26 def __init__(self, node: bascenev1.Node): 27 super().__init__() 28 self.node = node 29 30 @override 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 @override 39 def exists(self) -> bool: 40 return bool(self.node)
A simple bascenev1.Actor type that wraps a single bascenev1.Node.
This Actor will delete its Node when told to die, and it's exists() call will return whether the Node still exists or not.
30 @override 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 bascenev1.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 bascenev1.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.
53class NodeNotFoundError(NotFoundError): 54 """Exception raised when an expected Node does not exist."""
Exception raised when an expected Node does not exist.
52def normalized_color(color: Sequence[float]) -> tuple[float, ...]: 53 """Scale a color so its largest value is 1; useful for coloring lights. 54 55 category: General Utility Functions 56 """ 57 color_biased = tuple(max(c, 0.01) for c in color) # account for black 58 mult = 1.0 / max(color_biased) 59 return tuple(c * mult for c in color_biased)
Scale a color so its largest value is 1; useful for coloring lights.
category: General Utility Functions
25class NotFoundError(Exception): 26 """Exception raised when a referenced object does not exist."""
Exception raised when a referenced object does not exist.
30@dataclass 31class OutOfBoundsMessage: 32 """A message telling an object that it is out of bounds."""
A message telling an object that it is out of bounds.
145@dataclass 146class PickedUpMessage: 147 """Tells an object that it has been picked up by something.""" 148 149 node: bascenev1.Node 150 """The bascenev1.Node doing the picking up."""
Tells an object that it has been picked up by something.
132@dataclass 133class PickUpMessage: 134 """Tells an object that it has picked something up.""" 135 136 node: bascenev1.Node 137 """The bascenev1.Node that is getting picked up."""
Tells an object that it has picked something up.
42class Player(Generic[TeamT]): 43 """A player in a specific bascenev1.Activity. 44 45 These correspond to bascenev1.SessionPlayer objects, but are associated 46 with a single bascenev1.Activity instance. This allows activities to 47 specify their own custom bascenev1.Player types. 48 """ 49 50 # These are instance attrs but we define them at the type level so 51 # their type annotations are introspectable (for docs generation). 52 character: str 53 54 actor: bascenev1.Actor | None 55 """The bascenev1.Actor associated with the player.""" 56 57 color: Sequence[float] 58 highlight: Sequence[float] 59 60 _team: TeamT 61 _sessionplayer: bascenev1.SessionPlayer 62 _nodeactor: bascenev1.NodeActor | None 63 _expired: bool 64 _postinited: bool 65 _customdata: dict 66 67 # NOTE: avoiding having any __init__() here since it seems to not 68 # get called by default if a dataclass inherits from us. 69 # This also lets us keep trivial player classes cleaner by skipping 70 # the super().__init__() line. 71 72 def postinit(self, sessionplayer: bascenev1.SessionPlayer) -> None: 73 """Wire up a newly created player. 74 75 (internal) 76 """ 77 from bascenev1._nodeactor import NodeActor 78 79 # Sanity check; if a dataclass is created that inherits from us, 80 # it will define an equality operator by default which will break 81 # internal game logic. So complain loudly if we find one. 82 if type(self).__eq__ is not object.__eq__: 83 raise RuntimeError( 84 f'Player class {type(self)} defines an equality' 85 f' operator (__eq__) which will break internal' 86 f' logic. Please remove it.\n' 87 f'For dataclasses you can do "dataclass(eq=False)"' 88 f' in the class decorator.' 89 ) 90 91 self.actor = None 92 self.character = '' 93 self._nodeactor: bascenev1.NodeActor | None = None 94 self._sessionplayer = sessionplayer 95 self.character = sessionplayer.character 96 self.color = sessionplayer.color 97 self.highlight = sessionplayer.highlight 98 self._team = cast(TeamT, sessionplayer.sessionteam.activityteam) 99 assert self._team is not None 100 self._customdata = {} 101 self._expired = False 102 self._postinited = True 103 node = _bascenev1.newnode( 104 'player', attrs={'playerID': sessionplayer.id} 105 ) 106 self._nodeactor = NodeActor(node) 107 sessionplayer.setnode(node) 108 109 def leave(self) -> None: 110 """Called when the Player leaves a running game. 111 112 (internal) 113 """ 114 assert self._postinited 115 assert not self._expired 116 try: 117 # If they still have an actor, kill it. 118 if self.actor: 119 self.actor.handlemessage(DieMessage(how=DeathType.LEFT_GAME)) 120 self.actor = None 121 except Exception: 122 logging.exception('Error killing actor on leave for %s.', self) 123 self._nodeactor = None 124 del self._team 125 del self._customdata 126 127 def expire(self) -> None: 128 """Called when the Player is expiring (when its Activity does so). 129 130 (internal) 131 """ 132 assert self._postinited 133 assert not self._expired 134 self._expired = True 135 136 try: 137 self.on_expire() 138 except Exception: 139 logging.exception('Error in on_expire for %s.', self) 140 141 self._nodeactor = None 142 self.actor = None 143 del self._team 144 del self._customdata 145 146 def on_expire(self) -> None: 147 """Can be overridden to handle player expiration. 148 149 The player expires when the Activity it is a part of expires. 150 Expired players should no longer run any game logic (which will 151 likely error). They should, however, remove any references to 152 players/teams/games/etc. which could prevent them from being freed. 153 """ 154 155 @property 156 def team(self) -> TeamT: 157 """The bascenev1.Team for this player.""" 158 assert self._postinited 159 assert not self._expired 160 return self._team 161 162 @property 163 def customdata(self) -> dict: 164 """Arbitrary values associated with the player. 165 Though it is encouraged that most player values be properly defined 166 on the bascenev1.Player subclass, it may be useful for player-agnostic 167 objects to store values here. This dict is cleared when the player 168 leaves or expires so objects stored here will be disposed of at 169 the expected time, unlike the Player instance itself which may 170 continue to be referenced after it is no longer part of the game. 171 """ 172 assert self._postinited 173 assert not self._expired 174 return self._customdata 175 176 @property 177 def sessionplayer(self) -> bascenev1.SessionPlayer: 178 """Return the bascenev1.SessionPlayer corresponding to this Player. 179 180 Throws a bascenev1.SessionPlayerNotFoundError if it does not exist. 181 """ 182 assert self._postinited 183 if bool(self._sessionplayer): 184 return self._sessionplayer 185 raise babase.SessionPlayerNotFoundError() 186 187 @property 188 def node(self) -> bascenev1.Node: 189 """A bascenev1.Node of type 'player' associated with this Player. 190 191 This node can be used to get a generic player position/etc. 192 """ 193 assert self._postinited 194 assert not self._expired 195 assert self._nodeactor 196 return self._nodeactor.node 197 198 @property 199 def position(self) -> babase.Vec3: 200 """The position of the player, as defined by its bascenev1.Actor. 201 202 If the player currently has no actor, raises a 203 babase.ActorNotFoundError. 204 """ 205 assert self._postinited 206 assert not self._expired 207 if self.actor is None: 208 raise babase.ActorNotFoundError 209 return babase.Vec3(self.node.position) 210 211 def exists(self) -> bool: 212 """Whether the underlying player still exists. 213 214 This will return False if the underlying bascenev1.SessionPlayer has 215 left the game or if the bascenev1.Activity this player was 216 associated with has ended. 217 Most functionality will fail on a nonexistent player. 218 Note that you can also use the boolean operator for this same 219 functionality, so a statement such as "if player" will do 220 the right thing both for Player objects and values of None. 221 """ 222 assert self._postinited 223 return self._sessionplayer.exists() and not self._expired 224 225 def getname(self, full: bool = False, icon: bool = True) -> str: 226 """ 227 Returns the player's name. If icon is True, the long version of the 228 name may include an icon. 229 """ 230 assert self._postinited 231 assert not self._expired 232 return self._sessionplayer.getname(full=full, icon=icon) 233 234 def is_alive(self) -> bool: 235 """ 236 Returns True if the player has a bascenev1.Actor assigned and its 237 is_alive() method return True. False is returned otherwise. 238 """ 239 assert self._postinited 240 assert not self._expired 241 return self.actor is not None and self.actor.is_alive() 242 243 def get_icon(self) -> dict[str, Any]: 244 """ 245 Returns the character's icon (images, colors, etc contained in a dict) 246 """ 247 assert self._postinited 248 assert not self._expired 249 return self._sessionplayer.get_icon() 250 251 def assigninput( 252 self, 253 inputtype: babase.InputType | tuple[babase.InputType, ...], 254 call: Callable, 255 ) -> None: 256 """ 257 Set the python callable to be run for one or more types of input. 258 """ 259 assert self._postinited 260 assert not self._expired 261 return self._sessionplayer.assigninput(type=inputtype, call=call) 262 263 def resetinput(self) -> None: 264 """ 265 Clears out the player's assigned input actions. 266 """ 267 assert self._postinited 268 assert not self._expired 269 self._sessionplayer.resetinput() 270 271 def __bool__(self) -> bool: 272 return self.exists()
A player in a specific bascenev1.Activity.
These correspond to bascenev1.SessionPlayer objects, but are associated with a single bascenev1.Activity instance. This allows activities to specify their own custom bascenev1.Player types.
146 def on_expire(self) -> None: 147 """Can be overridden to handle player expiration. 148 149 The player expires when the Activity it is a part of expires. 150 Expired players should no longer run any game logic (which will 151 likely error). They should, however, remove any references to 152 players/teams/games/etc. which could prevent them from being freed. 153 """
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.
155 @property 156 def team(self) -> TeamT: 157 """The bascenev1.Team for this player.""" 158 assert self._postinited 159 assert not self._expired 160 return self._team
The bascenev1.Team for this player.
162 @property 163 def customdata(self) -> dict: 164 """Arbitrary values associated with the player. 165 Though it is encouraged that most player values be properly defined 166 on the bascenev1.Player subclass, it may be useful for player-agnostic 167 objects to store values here. This dict is cleared when the player 168 leaves or expires so objects stored here will be disposed of at 169 the expected time, unlike the Player instance itself which may 170 continue to be referenced after it is no longer part of the game. 171 """ 172 assert self._postinited 173 assert not self._expired 174 return self._customdata
Arbitrary values associated with the player. Though it is encouraged that most player values be properly defined on the bascenev1.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.
176 @property 177 def sessionplayer(self) -> bascenev1.SessionPlayer: 178 """Return the bascenev1.SessionPlayer corresponding to this Player. 179 180 Throws a bascenev1.SessionPlayerNotFoundError if it does not exist. 181 """ 182 assert self._postinited 183 if bool(self._sessionplayer): 184 return self._sessionplayer 185 raise babase.SessionPlayerNotFoundError()
Return the bascenev1.SessionPlayer corresponding to this Player.
Throws a bascenev1.SessionPlayerNotFoundError if it does not exist.
187 @property 188 def node(self) -> bascenev1.Node: 189 """A bascenev1.Node of type 'player' associated with this Player. 190 191 This node can be used to get a generic player position/etc. 192 """ 193 assert self._postinited 194 assert not self._expired 195 assert self._nodeactor 196 return self._nodeactor.node
A bascenev1.Node of type 'player' associated with this Player.
This node can be used to get a generic player position/etc.
198 @property 199 def position(self) -> babase.Vec3: 200 """The position of the player, as defined by its bascenev1.Actor. 201 202 If the player currently has no actor, raises a 203 babase.ActorNotFoundError. 204 """ 205 assert self._postinited 206 assert not self._expired 207 if self.actor is None: 208 raise babase.ActorNotFoundError 209 return babase.Vec3(self.node.position)
The position of the player, as defined by its bascenev1.Actor.
If the player currently has no actor, raises a babase.ActorNotFoundError.
211 def exists(self) -> bool: 212 """Whether the underlying player still exists. 213 214 This will return False if the underlying bascenev1.SessionPlayer has 215 left the game or if the bascenev1.Activity this player was 216 associated with has ended. 217 Most functionality will fail on a nonexistent player. 218 Note that you can also use the boolean operator for this same 219 functionality, so a statement such as "if player" will do 220 the right thing both for Player objects and values of None. 221 """ 222 assert self._postinited 223 return self._sessionplayer.exists() and not self._expired
Whether the underlying player still exists.
This will return False if the underlying bascenev1.SessionPlayer has left the game or if the bascenev1.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.
225 def getname(self, full: bool = False, icon: bool = True) -> str: 226 """ 227 Returns the player's name. If icon is True, the long version of the 228 name may include an icon. 229 """ 230 assert self._postinited 231 assert not self._expired 232 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.
234 def is_alive(self) -> bool: 235 """ 236 Returns True if the player has a bascenev1.Actor assigned and its 237 is_alive() method return True. False is returned otherwise. 238 """ 239 assert self._postinited 240 assert not self._expired 241 return self.actor is not None and self.actor.is_alive()
Returns True if the player has a bascenev1.Actor assigned and its is_alive() method return True. False is returned otherwise.
243 def get_icon(self) -> dict[str, Any]: 244 """ 245 Returns the character's icon (images, colors, etc contained in a dict) 246 """ 247 assert self._postinited 248 assert not self._expired 249 return self._sessionplayer.get_icon()
Returns the character's icon (images, colors, etc contained in a dict)
251 def assigninput( 252 self, 253 inputtype: babase.InputType | tuple[babase.InputType, ...], 254 call: Callable, 255 ) -> None: 256 """ 257 Set the python callable to be run for one or more types of input. 258 """ 259 assert self._postinited 260 assert not self._expired 261 return self._sessionplayer.assigninput(type=inputtype, call=call)
Set the python callable to be run for one or more types of input.
66class PlayerDiedMessage: 67 """A message saying a bascenev1.Player has died.""" 68 69 killed: bool 70 """If True, the player was killed; 71 If False, they left the game or the round ended.""" 72 73 how: DeathType 74 """The particular type of death.""" 75 76 def __init__( 77 self, 78 player: bascenev1.Player, 79 was_killed: bool, 80 killerplayer: bascenev1.Player | None, 81 how: DeathType, 82 ): 83 """Instantiate a message with the given values.""" 84 85 # Invalid refs should never be passed as args. 86 assert player.exists() 87 self._player = player 88 89 # Invalid refs should never be passed as args. 90 assert killerplayer is None or killerplayer.exists() 91 self._killerplayer = killerplayer 92 self.killed = was_killed 93 self.how = how 94 95 def getkillerplayer(self, playertype: type[PlayerT]) -> PlayerT | None: 96 """Return the bascenev1.Player responsible for the killing, if any. 97 98 Pass the Player type being used by the current game. 99 """ 100 assert isinstance(self._killerplayer, (playertype, type(None))) 101 return self._killerplayer 102 103 def getplayer(self, playertype: type[PlayerT]) -> PlayerT: 104 """Return the bascenev1.Player that died. 105 106 The type of player for the current activity should be passed so that 107 the type-checker properly identifies the returned value as one. 108 """ 109 player: Any = self._player 110 assert isinstance(player, playertype) 111 112 # We should never be delivering invalid refs. 113 # (could theoretically happen if someone holds on to us) 114 assert player.exists() 115 return player
A message saying a bascenev1.Player has died.
76 def __init__( 77 self, 78 player: bascenev1.Player, 79 was_killed: bool, 80 killerplayer: bascenev1.Player | None, 81 how: DeathType, 82 ): 83 """Instantiate a message with the given values.""" 84 85 # Invalid refs should never be passed as args. 86 assert player.exists() 87 self._player = player 88 89 # Invalid refs should never be passed as args. 90 assert killerplayer is None or killerplayer.exists() 91 self._killerplayer = killerplayer 92 self.killed = was_killed 93 self.how = how
Instantiate a message with the given values.
95 def getkillerplayer(self, playertype: type[PlayerT]) -> PlayerT | None: 96 """Return the bascenev1.Player responsible for the killing, if any. 97 98 Pass the Player type being used by the current game. 99 """ 100 assert isinstance(self._killerplayer, (playertype, type(None))) 101 return self._killerplayer
Return the bascenev1.Player responsible for the killing, if any.
Pass the Player type being used by the current game.
103 def getplayer(self, playertype: type[PlayerT]) -> PlayerT: 104 """Return the bascenev1.Player that died. 105 106 The type of player for the current activity should be passed so that 107 the type-checker properly identifies the returned value as one. 108 """ 109 player: Any = self._player 110 assert isinstance(player, playertype) 111 112 # We should never be delivering invalid refs. 113 # (could theoretically happen if someone holds on to us) 114 assert player.exists() 115 return player
Return the bascenev1.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.
254@dataclass 255class PlayerProfilesChangedMessage: 256 """Signals player profiles may have changed and should be reloaded."""
Signals player profiles may have changed and should be reloaded.
26@dataclass 27class PlayerInfo: 28 """Holds basic info about a player.""" 29 30 name: str 31 character: str
Holds basic info about a player.
29class PlayerNotFoundError(NotFoundError): 30 """Exception raised when an expected player does not exist."""
Exception raised when an expected player does not exist.
32class PlayerRecord: 33 """Stats for an individual player in a bascenev1.Stats object. 34 35 This does not necessarily correspond to a bascenev1.Player that is 36 still present (stats may be retained for players that leave 37 mid-game) 38 """ 39 40 character: str 41 42 def __init__( 43 self, 44 name: str, 45 name_full: str, 46 sessionplayer: bascenev1.SessionPlayer, 47 stats: bascenev1.Stats, 48 ): 49 self.name = name 50 self.name_full = name_full 51 self.score = 0 52 self.accumscore = 0 53 self.kill_count = 0 54 self.accum_kill_count = 0 55 self.killed_count = 0 56 self.accum_killed_count = 0 57 self._multi_kill_timer: bascenev1.Timer | None = None 58 self._multi_kill_count = 0 59 self._stats = weakref.ref(stats) 60 self._last_sessionplayer: bascenev1.SessionPlayer | None = None 61 self._sessionplayer: bascenev1.SessionPlayer | None = None 62 self._sessionteam: weakref.ref[bascenev1.SessionTeam] | None = None 63 self.streak = 0 64 self.associate_with_sessionplayer(sessionplayer) 65 66 @property 67 def team(self) -> bascenev1.SessionTeam: 68 """The bascenev1.SessionTeam the last associated player was last on. 69 70 This can still return a valid result even if the player is gone. 71 Raises a bascenev1.SessionTeamNotFoundError if the team no longer 72 exists. 73 """ 74 assert self._sessionteam is not None 75 team = self._sessionteam() 76 if team is None: 77 raise babase.SessionTeamNotFoundError() 78 return team 79 80 @property 81 def player(self) -> bascenev1.SessionPlayer: 82 """Return the instance's associated bascenev1.SessionPlayer. 83 84 Raises a bascenev1.SessionPlayerNotFoundError if the player 85 no longer exists. 86 """ 87 if not self._sessionplayer: 88 raise babase.SessionPlayerNotFoundError() 89 return self._sessionplayer 90 91 def getname(self, full: bool = False) -> str: 92 """Return the player entry's name.""" 93 return self.name_full if full else self.name 94 95 def get_icon(self) -> dict[str, Any]: 96 """Get the icon for this instance's player.""" 97 player = self._last_sessionplayer 98 assert player is not None 99 return player.get_icon() 100 101 def cancel_multi_kill_timer(self) -> None: 102 """Cancel any multi-kill timer for this player entry.""" 103 self._multi_kill_timer = None 104 105 def getactivity(self) -> bascenev1.Activity | None: 106 """Return the bascenev1.Activity this instance is associated with. 107 108 Returns None if the activity no longer exists.""" 109 stats = self._stats() 110 if stats is not None: 111 return stats.getactivity() 112 return None 113 114 def associate_with_sessionplayer( 115 self, sessionplayer: bascenev1.SessionPlayer 116 ) -> None: 117 """Associate this entry with a bascenev1.SessionPlayer.""" 118 self._sessionteam = weakref.ref(sessionplayer.sessionteam) 119 self.character = sessionplayer.character 120 self._last_sessionplayer = sessionplayer 121 self._sessionplayer = sessionplayer 122 self.streak = 0 123 124 def _end_multi_kill(self) -> None: 125 self._multi_kill_timer = None 126 self._multi_kill_count = 0 127 128 def get_last_sessionplayer(self) -> bascenev1.SessionPlayer: 129 """Return the last bascenev1.Player we were associated with.""" 130 assert self._last_sessionplayer is not None 131 return self._last_sessionplayer 132 133 def submit_kill(self, showpoints: bool = True) -> None: 134 """Submit a kill for this player entry.""" 135 # FIXME Clean this up. 136 # pylint: disable=too-many-statements 137 138 self._multi_kill_count += 1 139 stats = self._stats() 140 assert stats 141 if self._multi_kill_count == 1: 142 score = 0 143 name = None 144 delay = 0.0 145 color = (0.0, 0.0, 0.0, 1.0) 146 scale = 1.0 147 sound = None 148 elif self._multi_kill_count == 2: 149 score = 20 150 name = babase.Lstr(resource='twoKillText') 151 color = (0.1, 1.0, 0.0, 1) 152 scale = 1.0 153 delay = 0.0 154 sound = stats.orchestrahitsound1 155 elif self._multi_kill_count == 3: 156 score = 40 157 name = babase.Lstr(resource='threeKillText') 158 color = (1.0, 0.7, 0.0, 1) 159 scale = 1.1 160 delay = 0.3 161 sound = stats.orchestrahitsound2 162 elif self._multi_kill_count == 4: 163 score = 60 164 name = babase.Lstr(resource='fourKillText') 165 color = (1.0, 1.0, 0.0, 1) 166 scale = 1.2 167 delay = 0.6 168 sound = stats.orchestrahitsound3 169 elif self._multi_kill_count == 5: 170 score = 80 171 name = babase.Lstr(resource='fiveKillText') 172 color = (1.0, 0.5, 0.0, 1) 173 scale = 1.3 174 delay = 0.9 175 sound = stats.orchestrahitsound4 176 else: 177 score = 100 178 name = babase.Lstr( 179 resource='multiKillText', 180 subs=[('${COUNT}', str(self._multi_kill_count))], 181 ) 182 color = (1.0, 0.5, 0.0, 1) 183 scale = 1.3 184 delay = 1.0 185 sound = stats.orchestrahitsound4 186 187 def _apply( 188 name2: babase.Lstr, 189 score2: int, 190 showpoints2: bool, 191 color2: tuple[float, float, float, float], 192 scale2: float, 193 sound2: bascenev1.Sound | None, 194 ) -> None: 195 # pylint: disable=too-many-positional-arguments 196 from bascenev1lib.actor.popuptext import PopupText 197 198 # Only award this if they're still alive and we can get 199 # a current position for them. 200 our_pos: babase.Vec3 | None = None 201 if self._sessionplayer: 202 if self._sessionplayer.activityplayer is not None: 203 try: 204 our_pos = self._sessionplayer.activityplayer.position 205 except babase.NotFoundError: 206 pass 207 if our_pos is None: 208 return 209 210 # Jitter position a bit since these often come in clusters. 211 our_pos = babase.Vec3( 212 our_pos[0] + (random.random() - 0.5) * 2.0, 213 our_pos[1] + (random.random() - 0.5) * 2.0, 214 our_pos[2] + (random.random() - 0.5) * 2.0, 215 ) 216 activity = self.getactivity() 217 if activity is not None: 218 PopupText( 219 babase.Lstr( 220 value=(('+' + str(score2) + ' ') if showpoints2 else '') 221 + '${N}', 222 subs=[('${N}', name2)], 223 ), 224 color=color2, 225 scale=scale2, 226 position=our_pos, 227 ).autoretain() 228 if sound2: 229 sound2.play() 230 231 self.score += score2 232 self.accumscore += score2 233 234 # Inform a running game of the score. 235 if score2 != 0 and activity is not None: 236 activity.handlemessage(PlayerScoredMessage(score=score2)) 237 238 if name is not None: 239 _bascenev1.timer( 240 0.3 + delay, 241 babase.Call( 242 _apply, name, score, showpoints, color, scale, sound 243 ), 244 ) 245 246 # Keep the tally rollin'... 247 # set a timer for a bit in the future. 248 self._multi_kill_timer = _bascenev1.Timer(1.0, self._end_multi_kill)
Stats for an individual player in a bascenev1.Stats object.
This does not necessarily correspond to a bascenev1.Player that is still present (stats may be retained for players that leave mid-game)
42 def __init__( 43 self, 44 name: str, 45 name_full: str, 46 sessionplayer: bascenev1.SessionPlayer, 47 stats: bascenev1.Stats, 48 ): 49 self.name = name 50 self.name_full = name_full 51 self.score = 0 52 self.accumscore = 0 53 self.kill_count = 0 54 self.accum_kill_count = 0 55 self.killed_count = 0 56 self.accum_killed_count = 0 57 self._multi_kill_timer: bascenev1.Timer | None = None 58 self._multi_kill_count = 0 59 self._stats = weakref.ref(stats) 60 self._last_sessionplayer: bascenev1.SessionPlayer | None = None 61 self._sessionplayer: bascenev1.SessionPlayer | None = None 62 self._sessionteam: weakref.ref[bascenev1.SessionTeam] | None = None 63 self.streak = 0 64 self.associate_with_sessionplayer(sessionplayer)
66 @property 67 def team(self) -> bascenev1.SessionTeam: 68 """The bascenev1.SessionTeam the last associated player was last on. 69 70 This can still return a valid result even if the player is gone. 71 Raises a bascenev1.SessionTeamNotFoundError if the team no longer 72 exists. 73 """ 74 assert self._sessionteam is not None 75 team = self._sessionteam() 76 if team is None: 77 raise babase.SessionTeamNotFoundError() 78 return team
The bascenev1.SessionTeam the last associated player was last on.
This can still return a valid result even if the player is gone. Raises a bascenev1.SessionTeamNotFoundError if the team no longer exists.
80 @property 81 def player(self) -> bascenev1.SessionPlayer: 82 """Return the instance's associated bascenev1.SessionPlayer. 83 84 Raises a bascenev1.SessionPlayerNotFoundError if the player 85 no longer exists. 86 """ 87 if not self._sessionplayer: 88 raise babase.SessionPlayerNotFoundError() 89 return self._sessionplayer
Return the instance's associated bascenev1.SessionPlayer.
Raises a bascenev1.SessionPlayerNotFoundError if the player no longer exists.
91 def getname(self, full: bool = False) -> str: 92 """Return the player entry's name.""" 93 return self.name_full if full else self.name
Return the player entry's name.
95 def get_icon(self) -> dict[str, Any]: 96 """Get the icon for this instance's player.""" 97 player = self._last_sessionplayer 98 assert player is not None 99 return player.get_icon()
Get the icon for this instance's player.
101 def cancel_multi_kill_timer(self) -> None: 102 """Cancel any multi-kill timer for this player entry.""" 103 self._multi_kill_timer = None
Cancel any multi-kill timer for this player entry.
105 def getactivity(self) -> bascenev1.Activity | None: 106 """Return the bascenev1.Activity this instance is associated with. 107 108 Returns None if the activity no longer exists.""" 109 stats = self._stats() 110 if stats is not None: 111 return stats.getactivity() 112 return None
Return the bascenev1.Activity this instance is associated with.
Returns None if the activity no longer exists.
114 def associate_with_sessionplayer( 115 self, sessionplayer: bascenev1.SessionPlayer 116 ) -> None: 117 """Associate this entry with a bascenev1.SessionPlayer.""" 118 self._sessionteam = weakref.ref(sessionplayer.sessionteam) 119 self.character = sessionplayer.character 120 self._last_sessionplayer = sessionplayer 121 self._sessionplayer = sessionplayer 122 self.streak = 0
Associate this entry with a bascenev1.SessionPlayer.
128 def get_last_sessionplayer(self) -> bascenev1.SessionPlayer: 129 """Return the last bascenev1.Player we were associated with.""" 130 assert self._last_sessionplayer is not None 131 return self._last_sessionplayer
Return the last bascenev1.Player we were associated with.
133 def submit_kill(self, showpoints: bool = True) -> None: 134 """Submit a kill for this player entry.""" 135 # FIXME Clean this up. 136 # pylint: disable=too-many-statements 137 138 self._multi_kill_count += 1 139 stats = self._stats() 140 assert stats 141 if self._multi_kill_count == 1: 142 score = 0 143 name = None 144 delay = 0.0 145 color = (0.0, 0.0, 0.0, 1.0) 146 scale = 1.0 147 sound = None 148 elif self._multi_kill_count == 2: 149 score = 20 150 name = babase.Lstr(resource='twoKillText') 151 color = (0.1, 1.0, 0.0, 1) 152 scale = 1.0 153 delay = 0.0 154 sound = stats.orchestrahitsound1 155 elif self._multi_kill_count == 3: 156 score = 40 157 name = babase.Lstr(resource='threeKillText') 158 color = (1.0, 0.7, 0.0, 1) 159 scale = 1.1 160 delay = 0.3 161 sound = stats.orchestrahitsound2 162 elif self._multi_kill_count == 4: 163 score = 60 164 name = babase.Lstr(resource='fourKillText') 165 color = (1.0, 1.0, 0.0, 1) 166 scale = 1.2 167 delay = 0.6 168 sound = stats.orchestrahitsound3 169 elif self._multi_kill_count == 5: 170 score = 80 171 name = babase.Lstr(resource='fiveKillText') 172 color = (1.0, 0.5, 0.0, 1) 173 scale = 1.3 174 delay = 0.9 175 sound = stats.orchestrahitsound4 176 else: 177 score = 100 178 name = babase.Lstr( 179 resource='multiKillText', 180 subs=[('${COUNT}', str(self._multi_kill_count))], 181 ) 182 color = (1.0, 0.5, 0.0, 1) 183 scale = 1.3 184 delay = 1.0 185 sound = stats.orchestrahitsound4 186 187 def _apply( 188 name2: babase.Lstr, 189 score2: int, 190 showpoints2: bool, 191 color2: tuple[float, float, float, float], 192 scale2: float, 193 sound2: bascenev1.Sound | None, 194 ) -> None: 195 # pylint: disable=too-many-positional-arguments 196 from bascenev1lib.actor.popuptext import PopupText 197 198 # Only award this if they're still alive and we can get 199 # a current position for them. 200 our_pos: babase.Vec3 | None = None 201 if self._sessionplayer: 202 if self._sessionplayer.activityplayer is not None: 203 try: 204 our_pos = self._sessionplayer.activityplayer.position 205 except babase.NotFoundError: 206 pass 207 if our_pos is None: 208 return 209 210 # Jitter position a bit since these often come in clusters. 211 our_pos = babase.Vec3( 212 our_pos[0] + (random.random() - 0.5) * 2.0, 213 our_pos[1] + (random.random() - 0.5) * 2.0, 214 our_pos[2] + (random.random() - 0.5) * 2.0, 215 ) 216 activity = self.getactivity() 217 if activity is not None: 218 PopupText( 219 babase.Lstr( 220 value=(('+' + str(score2) + ' ') if showpoints2 else '') 221 + '${N}', 222 subs=[('${N}', name2)], 223 ), 224 color=color2, 225 scale=scale2, 226 position=our_pos, 227 ).autoretain() 228 if sound2: 229 sound2.play() 230 231 self.score += score2 232 self.accumscore += score2 233 234 # Inform a running game of the score. 235 if score2 != 0 and activity is not None: 236 activity.handlemessage(PlayerScoredMessage(score=score2)) 237 238 if name is not None: 239 _bascenev1.timer( 240 0.3 + delay, 241 babase.Call( 242 _apply, name, score, showpoints, color, scale, sound 243 ), 244 ) 245 246 # Keep the tally rollin'... 247 # set a timer for a bit in the future. 248 self._multi_kill_timer = _bascenev1.Timer(1.0, self._end_multi_kill)
Submit a kill for this player entry.
24@dataclass 25class PlayerScoredMessage: 26 """Informs something that a bascenev1.Player scored.""" 27 28 score: int 29 """The score value."""
Informs something that a bascenev1.Player scored.
317class Plugin: 318 """A plugin to alter app behavior in some way. 319 320 Plugins are discoverable by the meta-tag system 321 and the user can select which ones they want to enable. 322 Enabled plugins are then called at specific times as the 323 app is running in order to modify its behavior in some way. 324 """ 325 326 def on_app_running(self) -> None: 327 """Called when the app reaches the running state.""" 328 329 def on_app_suspend(self) -> None: 330 """Called when the app enters the suspended state.""" 331 332 def on_app_unsuspend(self) -> None: 333 """Called when the app exits the suspended state.""" 334 335 def on_app_shutdown(self) -> None: 336 """Called when the app is beginning the shutdown process.""" 337 338 def on_app_shutdown_complete(self) -> None: 339 """Called when the app has completed the shutdown process.""" 340 341 def has_settings_ui(self) -> bool: 342 """Called to ask if we have settings UI we can show.""" 343 return False 344 345 def show_settings_ui(self, source_widget: Any | None) -> None: 346 """Called to show our settings UI."""
A plugin to alter app behavior in some way.
Plugins are discoverable by the meta-tag system and the user can select which ones they want to enable. Enabled plugins are then called at specific times as the app is running in order to modify its behavior in some way.
335 def on_app_shutdown(self) -> None: 336 """Called when the app is beginning the shutdown process."""
Called when the app is beginning the shutdown process.
338 def on_app_shutdown_complete(self) -> None: 339 """Called when the app has completed the shutdown process."""
Called when the app has completed the shutdown process.
37@dataclass 38class PowerupAcceptMessage: 39 """A message informing a bascenev1.Powerup that it was accepted. 40 41 This is generally sent in response to a bascenev1.PowerupMessage to 42 inform the box (or whoever granted it) that it can go away. 43 """
A message informing a bascenev1.Powerup that it was accepted.
This is generally sent in response to a bascenev1.PowerupMessage to inform the box (or whoever granted it) that it can go away.
17@dataclass 18class PowerupMessage: 19 """A message telling an object to accept a powerup. 20 21 This message is normally received by touching a 22 bascenev1.PowerupBox. 23 """ 24 25 poweruptype: str 26 """The type of powerup to be granted (a string). 27 See bascenev1.Powerup.poweruptype for available type values.""" 28 29 sourcenode: bascenev1.Node | None = None 30 """The node the powerup game from, or None otherwise. 31 If a powerup is accepted, a bascenev1.PowerupAcceptMessage should be 32 sent back to the sourcenode to inform it of the fact. This will 33 generally cause the powerup box to make a sound and disappear or 34 whatnot."""
A message telling an object to accept a powerup.
This message is normally received by touching a bascenev1.PowerupBox.
The type of powerup to be granted (a string). See bascenev1.Powerup.poweruptype for available type values.
The node the powerup game from, or None otherwise. If a powerup is accepted, a bascenev1.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.
17def print_live_object_warnings( 18 when: Any, 19 ignore_session: bascenev1.Session | None = None, 20 ignore_activity: bascenev1.Activity | None = None, 21) -> None: 22 """Print warnings for remaining objects in the current context. 23 24 IMPORTANT - don't call this in production; usage of gc.get_objects() 25 can bork Python. See notes at top of efro.debug module. 26 """ 27 # pylint: disable=cyclic-import 28 import gc 29 30 from bascenev1._session import Session 31 from bascenev1._actor import Actor 32 from bascenev1._activity import Activity 33 34 assert babase.app.classic is not None 35 36 sessions: list[bascenev1.Session] = [] 37 activities: list[bascenev1.Activity] = [] 38 actors: list[bascenev1.Actor] = [] 39 40 # Once we come across leaked stuff, printing again is probably 41 # redundant. 42 if babase.app.classic.printed_live_object_warning: 43 return 44 for obj in gc.get_objects(): 45 if isinstance(obj, Actor): 46 actors.append(obj) 47 elif isinstance(obj, Session): 48 sessions.append(obj) 49 elif isinstance(obj, Activity): 50 activities.append(obj) 51 52 # Complain about any remaining sessions. 53 for session in sessions: 54 if session is ignore_session: 55 continue 56 babase.app.classic.printed_live_object_warning = True 57 print(f'ERROR: Session found {when}: {session}') 58 59 # Complain about any remaining activities. 60 for activity in activities: 61 if activity is ignore_activity: 62 continue 63 babase.app.classic.printed_live_object_warning = True 64 print(f'ERROR: Activity found {when}: {activity}') 65 66 # Complain about any remaining actors. 67 for actor in actors: 68 babase.app.classic.printed_live_object_warning = True 69 print(f'ERROR: Actor found {when}: {actor}')
Print warnings for remaining objects in the current context.
IMPORTANT - don't call this in production; usage of gc.get_objects() can bork Python. See notes at top of efro.debug module.
1522def printnodes() -> None: 1523 """Print various info about existing nodes; useful for debugging.""" 1524 return None
Print various info about existing nodes; useful for debugging.
1368def pushcall( 1369 call: Callable, 1370 from_other_thread: bool = False, 1371 suppress_other_thread_warning: bool = False, 1372 other_thread_use_fg_context: bool = False, 1373 raw: bool = False, 1374) -> None: 1375 """Push a call to the logic event-loop. 1376 1377 This call expects to be used in the logic thread, and will automatically 1378 save and restore the babase.Context to behave seamlessly. 1379 1380 If you want to push a call from outside of the logic thread, 1381 however, you can pass 'from_other_thread' as True. In this case 1382 the call will always run in the UI context_ref on the logic thread 1383 or whichever context_ref is in the foreground if 1384 other_thread_use_fg_context is True. 1385 Passing raw=True will disable thread checks and context_ref sets/restores. 1386 """ 1387 return None
Push a call to the logic event-loop.
This call expects to be used in the logic thread, and will automatically save and restore the babase.Context to behave seamlessly.
If you want to push a call from outside of the logic thread, however, you can pass 'from_other_thread' as True. In this case the call will always run in the UI context_ref on the logic thread or whichever context_ref is in the foreground if other_thread_use_fg_context is True. Passing raw=True will disable thread checks and context_ref sets/restores.
360def register_map(maptype: type[Map]) -> None: 361 """Register a map class with the game.""" 362 assert babase.app.classic is not None 363 if maptype.name in babase.app.classic.maps: 364 raise RuntimeError(f'Map "{maptype.name}" is already registered.') 365 babase.app.classic.maps[maptype.name] = maptype
Register a map class with the game.
1439def safecolor( 1440 color: Sequence[float], target_intensity: float = 0.6 1441) -> tuple[float, ...]: 1442 """Given a color tuple, return a color safe to display as text. 1443 1444 Accepts tuples of length 3 or 4. This will slightly brighten very 1445 dark colors, etc. 1446 """ 1447 return (0.0, 0.0, 0.0)
Given a color tuple, return a color safe to display as text.
Accepts tuples of length 3 or 4. This will slightly brighten very dark colors, etc.
1450def screenmessage( 1451 message: str | babase.Lstr, 1452 color: Sequence[float] | None = None, 1453 log: bool = False, 1454) -> None: 1455 """Print a message to the local client's screen, in a given color. 1456 1457 Note that this version of the function is purely for local display. 1458 To broadcast screen messages in network play, look for methods such as 1459 broadcastmessage() provided by the scene-version packages. 1460 """ 1461 return None
Print a message to the local client's screen, in a given color.
Note that this version of the function is purely for local display. To broadcast screen messages in network play, look for methods such as broadcastmessage() provided by the scene-version packages.
25@dataclass 26class ScoreConfig: 27 """Settings for how a game handles scores.""" 28 29 label: str = 'Score' 30 """A label show to the user for scores; 'Score', 'Time Survived', etc.""" 31 32 scoretype: bascenev1.ScoreType = ScoreType.POINTS 33 """How the score value should be displayed.""" 34 35 lower_is_better: bool = False 36 """Whether lower scores are preferable. Higher scores are by default.""" 37 38 none_is_winner: bool = False 39 """Whether a value of None is considered better than other scores. 40 By default it is not.""" 41 42 version: str = '' 43 """To change high-score lists used by a game without renaming the game, 44 change this. Defaults to an empty string."""
Settings for how a game handles scores.
134class ScoreScreenActivity(Activity[EmptyPlayer, EmptyTeam]): 135 """A standard score screen that fades in and shows stuff for a while. 136 137 After a specified delay, player input is assigned to end the activity. 138 """ 139 140 transition_time = 0.5 141 inherits_tint = True 142 inherits_vr_camera_offset = True 143 use_fixed_vr_overlay = True 144 145 default_music: MusicType | None = MusicType.SCORES 146 147 def __init__(self, settings: dict): 148 super().__init__(settings) 149 self._birth_time = babase.apptime() 150 self._min_view_time = 5.0 151 self._allow_server_transition = False 152 self._background: bascenev1.Actor | None = None 153 self._tips_text: bascenev1.Actor | None = None 154 self._kicked_off_server_shutdown = False 155 self._kicked_off_server_restart = False 156 self._default_show_tips = True 157 self._custom_continue_message: babase.Lstr | None = None 158 self._server_transitioning: bool | None = None 159 160 @override 161 def on_player_join(self, player: EmptyPlayer) -> None: 162 super().on_player_join(player) 163 time_till_assign = max( 164 0, self._birth_time + self._min_view_time - babase.apptime() 165 ) 166 167 # If we're still kicking at the end of our assign-delay, assign this 168 # guy's input to trigger us. 169 _bascenev1.timer( 170 time_till_assign, babase.WeakCall(self._safe_assign, player) 171 ) 172 173 @override 174 def on_transition_in(self) -> None: 175 from bascenev1lib.actor.tipstext import TipsText 176 from bascenev1lib.actor.background import Background 177 178 super().on_transition_in() 179 self._background = Background( 180 fade_time=0.5, start_faded=False, show_logo=True 181 ) 182 if self._default_show_tips: 183 self._tips_text = TipsText() 184 setmusic(self.default_music) 185 186 @override 187 def on_begin(self) -> None: 188 # pylint: disable=cyclic-import 189 from bascenev1lib.actor.text import Text 190 191 super().on_begin() 192 193 # Pop up a 'press any button to continue' statement after our 194 # min-view-time show a 'press any button to continue..' 195 # thing after a bit. 196 assert babase.app.classic is not None 197 if babase.app.ui_v1.uiscale is babase.UIScale.LARGE: 198 # FIXME: Need a better way to determine whether we've probably 199 # got a keyboard. 200 sval = babase.Lstr(resource='pressAnyKeyButtonText') 201 else: 202 sval = babase.Lstr(resource='pressAnyButtonText') 203 204 Text( 205 ( 206 self._custom_continue_message 207 if self._custom_continue_message is not None 208 else sval 209 ), 210 v_attach=Text.VAttach.BOTTOM, 211 h_align=Text.HAlign.CENTER, 212 flash=True, 213 vr_depth=50, 214 position=(0, 10), 215 scale=0.8, 216 color=(0.5, 0.7, 0.5, 0.5), 217 transition=Text.Transition.IN_BOTTOM_SLOW, 218 transition_delay=self._min_view_time, 219 ).autoretain() 220 221 def _player_press(self) -> None: 222 # If this activity is a good 'end point', ask server-mode just once if 223 # it wants to do anything special like switch sessions or kill the app. 224 if ( 225 self._allow_server_transition 226 and babase.app.classic is not None 227 and babase.app.classic.server is not None 228 and self._server_transitioning is None 229 ): 230 self._server_transitioning = ( 231 babase.app.classic.server.handle_transition() 232 ) 233 assert isinstance(self._server_transitioning, bool) 234 235 # If server-mode is handling this, don't do anything ourself. 236 if self._server_transitioning is True: 237 return 238 239 # Otherwise end the activity normally. 240 self.end() 241 242 def _safe_assign(self, player: EmptyPlayer) -> None: 243 # Just to be extra careful, don't assign if we're transitioning out. 244 # (though theoretically that should be ok). 245 if not self.is_transitioning_out() and player: 246 player.assigninput( 247 ( 248 babase.InputType.JUMP_PRESS, 249 babase.InputType.PUNCH_PRESS, 250 babase.InputType.BOMB_PRESS, 251 babase.InputType.PICK_UP_PRESS, 252 ), 253 self._player_press, 254 )
A standard score screen that fades in and shows stuff for a while.
After a specified delay, player input is assigned to end the activity.
147 def __init__(self, settings: dict): 148 super().__init__(settings) 149 self._birth_time = babase.apptime() 150 self._min_view_time = 5.0 151 self._allow_server_transition = False 152 self._background: bascenev1.Actor | None = None 153 self._tips_text: bascenev1.Actor | None = None 154 self._kicked_off_server_shutdown = False 155 self._kicked_off_server_restart = False 156 self._default_show_tips = True 157 self._custom_continue_message: babase.Lstr | None = None 158 self._server_transitioning: bool | None = None
Creates an Activity in the current bascenev1.Session.
The activity will not be actually run until bascenev1.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.
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.
Set this to true to inherit screen tint/vignette colors from the previous activity (useful to prevent sudden color changes during transitions).
Set this to true to inherit VR camera offsets from the previous activity (useful for preventing sporadic camera movement during transitions).
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.
160 @override 161 def on_player_join(self, player: EmptyPlayer) -> None: 162 super().on_player_join(player) 163 time_till_assign = max( 164 0, self._birth_time + self._min_view_time - babase.apptime() 165 ) 166 167 # If we're still kicking at the end of our assign-delay, assign this 168 # guy's input to trigger us. 169 _bascenev1.timer( 170 time_till_assign, babase.WeakCall(self._safe_assign, player) 171 )
Called when a new bascenev1.Player has joined the Activity.
(including the initial set of Players)
173 @override 174 def on_transition_in(self) -> None: 175 from bascenev1lib.actor.tipstext import TipsText 176 from bascenev1lib.actor.background import Background 177 178 super().on_transition_in() 179 self._background = Background( 180 fade_time=0.5, start_faded=False, show_logo=True 181 ) 182 if self._default_show_tips: 183 self._tips_text = TipsText() 184 setmusic(self.default_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 bascenev1.Activity.on_begin() is called.
186 @override 187 def on_begin(self) -> None: 188 # pylint: disable=cyclic-import 189 from bascenev1lib.actor.text import Text 190 191 super().on_begin() 192 193 # Pop up a 'press any button to continue' statement after our 194 # min-view-time show a 'press any button to continue..' 195 # thing after a bit. 196 assert babase.app.classic is not None 197 if babase.app.ui_v1.uiscale is babase.UIScale.LARGE: 198 # FIXME: Need a better way to determine whether we've probably 199 # got a keyboard. 200 sval = babase.Lstr(resource='pressAnyKeyButtonText') 201 else: 202 sval = babase.Lstr(resource='pressAnyButtonText') 203 204 Text( 205 ( 206 self._custom_continue_message 207 if self._custom_continue_message is not None 208 else sval 209 ), 210 v_attach=Text.VAttach.BOTTOM, 211 h_align=Text.HAlign.CENTER, 212 flash=True, 213 vr_depth=50, 214 position=(0, 10), 215 scale=0.8, 216 color=(0.5, 0.7, 0.5, 0.5), 217 transition=Text.Transition.IN_BOTTOM_SLOW, 218 transition_delay=self._min_view_time, 219 ).autoretain()
Called once the previous 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.
16@unique 17class ScoreType(Enum): 18 """Type of scores.""" 19 20 SECONDS = 's' 21 MILLISECONDS = 'ms' 22 POINTS = 'p'
Type of scores.
65class SessionNotFoundError(NotFoundError): 66 """Exception raised when an expected session does not exist."""
Exception raised when an expected session does not exist.
998def broadcastmessage( 999 message: str | babase.Lstr, 1000 color: Sequence[float] | None = None, 1001 top: bool = False, 1002 image: dict[str, Any] | None = None, 1003 log: bool = False, 1004 clients: Sequence[int] | None = None, 1005 transient: bool = False, 1006) -> None: 1007 """Broadcast a screen-message to clients in the current session. 1008 1009 If 'top' is True, the message will go to the top message area. 1010 For 'top' messages, 'image' must be a dict containing 'texture' 1011 and 'tint_texture' textures and 'tint_color' and 'tint2_color' 1012 colors. This defines an icon to display alongside the message. 1013 If 'log' is True, the message will also be submitted to the log. 1014 'clients' can be a list of client-ids the message should be sent 1015 to, or None to specify that everyone should receive it. 1016 If 'transient' is True, the message will not be included in the 1017 game-stream and thus will not show up when viewing replays. 1018 Currently the 'clients' option only works for transient messages. 1019 """ 1020 return None
Broadcast a screen-message to clients in the current session.
If 'top' is True, the message will go to the top message area. For 'top' messages, 'image' must be a dict containing 'texture' and 'tint_texture' textures and 'tint_color' and 'tint2_color' colors. This defines an icon to display alongside the message. If 'log' is True, the message will also be submitted to the log. 'clients' can be a list of client-ids the message should be sent to, or None to specify that everyone should receive it. If 'transient' is True, the message will not be included in the game-stream and thus will not show up when viewing replays. Currently the 'clients' option only works for transient messages.
43class Session: 44 """Defines a high level series of bascenev1.Activity-es. 45 46 Examples of sessions are bascenev1.FreeForAllSession, 47 bascenev1.DualTeamSession, and bascenev1.CoopSession. 48 49 A Session is responsible for wrangling and transitioning between various 50 bascenev1.Activity instances such as mini-games and score-screens, and for 51 maintaining state between them (players, teams, score tallies, etc). 52 """ 53 54 use_teams: bool = False 55 """Whether this session groups players into an explicit set of 56 teams. If this is off, a unique team is generated for each 57 player that joins.""" 58 59 use_team_colors: bool = True 60 """Whether players on a team should all adopt the colors of that 61 team instead of their own profile colors. This only applies if 62 use_teams is enabled.""" 63 64 # Note: even though these are instance vars, we annotate and document them 65 # at the class level so that looks better and nobody get lost while 66 # reading large __init__ 67 68 lobby: bascenev1.Lobby 69 """The baclassic.Lobby instance where new bascenev1.Player-s go to select 70 a Profile/Team/etc. before being added to games. 71 Be aware this value may be None if a Session does not allow 72 any such selection.""" 73 74 max_players: int 75 """The maximum number of players allowed in the Session.""" 76 77 min_players: int 78 """The minimum number of players who must be present for the Session 79 to proceed past the initial joining screen""" 80 81 sessionplayers: list[bascenev1.SessionPlayer] 82 """All bascenev1.SessionPlayers in the Session. Most things should use 83 the list of bascenev1.Player-s in bascenev1.Activity; not this. Some 84 players, such as those who have not yet selected a character, will 85 only be found on this list.""" 86 87 customdata: dict 88 """A shared dictionary for objects to use as storage on this session. 89 Ensure that keys here are unique to avoid collisions.""" 90 91 sessionteams: list[bascenev1.SessionTeam] 92 """All the bascenev1.SessionTeams in the Session. Most things should 93 use the list of bascenev1.Team-s in bascenev1.Activity; not this.""" 94 95 def __init__( 96 self, 97 depsets: Sequence[bascenev1.DependencySet], 98 *, 99 team_names: Sequence[str] | None = None, 100 team_colors: Sequence[Sequence[float]] | None = None, 101 min_players: int = 1, 102 max_players: int = 8, 103 submit_score: bool = True, 104 ): 105 """Instantiate a session. 106 107 depsets should be a sequence of successfully resolved 108 bascenev1.DependencySet instances; one for each bascenev1.Activity 109 the session may potentially run. 110 """ 111 # pylint: disable=too-many-statements 112 # pylint: disable=too-many-locals 113 # pylint: disable=cyclic-import 114 # pylint: disable=too-many-branches 115 from efro.util import empty_weakref 116 from bascenev1._dependency import ( 117 Dependency, 118 AssetPackage, 119 DependencyError, 120 ) 121 from bascenev1._lobby import Lobby 122 from bascenev1._stats import Stats 123 from bascenev1._gameactivity import GameActivity 124 from bascenev1._activity import Activity 125 from bascenev1._team import SessionTeam 126 127 # First off, resolve all dependency-sets we were passed. 128 # If things are missing, we'll try to gather them into a single 129 # missing-deps exception if possible to give the caller a clean 130 # path to download missing stuff and try again. 131 missing_asset_packages: set[str] = set() 132 for depset in depsets: 133 try: 134 depset.resolve() 135 except DependencyError as exc: 136 # Gather/report missing assets only; barf on anything else. 137 if all(issubclass(d.cls, AssetPackage) for d in exc.deps): 138 for dep in exc.deps: 139 assert isinstance(dep.config, str) 140 missing_asset_packages.add(dep.config) 141 else: 142 missing_info = [(d.cls, d.config) for d in exc.deps] 143 raise RuntimeError( 144 f'Missing non-asset dependencies: {missing_info}' 145 ) from exc 146 147 # Throw a combined exception if we found anything missing. 148 if missing_asset_packages: 149 raise DependencyError( 150 [ 151 Dependency(AssetPackage, set_id) 152 for set_id in missing_asset_packages 153 ] 154 ) 155 156 # Ok; looks like our dependencies check out. 157 # Now give the engine a list of asset-set-ids to pass along to clients. 158 required_asset_packages: set[str] = set() 159 for depset in depsets: 160 required_asset_packages.update(depset.get_asset_package_ids()) 161 162 # print('Would set host-session asset-reqs to:', 163 # required_asset_packages) 164 165 # Init our C++ layer data. 166 self._sessiondata = _bascenev1.register_session(self) 167 168 # Should remove this if possible. 169 self.tournament_id: str | None = None 170 171 self.sessionteams = [] 172 self.sessionplayers = [] 173 self.min_players = min_players 174 self.max_players = ( 175 max_players 176 if _max_players_override is None 177 else _max_players_override 178 ) 179 self.submit_score = submit_score 180 181 self.customdata = {} 182 self._in_set_activity = False 183 self._next_team_id = 0 184 self._activity_retained: bascenev1.Activity | None = None 185 self._launch_end_session_activity_time: float | None = None 186 self._activity_end_timer: bascenev1.BaseTimer | None = None 187 self._activity_weak = empty_weakref(Activity) 188 self._next_activity: bascenev1.Activity | None = None 189 self._wants_to_end = False 190 self._ending = False 191 self._activity_should_end_immediately = False 192 self._activity_should_end_immediately_results: ( 193 bascenev1.GameResults | None 194 ) = None 195 self._activity_should_end_immediately_delay = 0.0 196 197 # Create static teams if we're using them. 198 if self.use_teams: 199 if team_names is None: 200 raise RuntimeError( 201 'use_teams is True but team_names not provided.' 202 ) 203 if team_colors is None: 204 raise RuntimeError( 205 'use_teams is True but team_colors not provided.' 206 ) 207 if len(team_colors) != len(team_names): 208 raise RuntimeError( 209 f'Got {len(team_names)} team_names' 210 f' and {len(team_colors)} team_colors;' 211 f' these numbers must match.' 212 ) 213 for i, color in enumerate(team_colors): 214 team = SessionTeam( 215 team_id=self._next_team_id, 216 name=GameActivity.get_team_display_string(team_names[i]), 217 color=color, 218 ) 219 self.sessionteams.append(team) 220 self._next_team_id += 1 221 try: 222 with self.context: 223 self.on_team_join(team) 224 except Exception: 225 logging.exception('Error in on_team_join for %s.', self) 226 227 self.lobby = Lobby() 228 self.stats = Stats() 229 230 # Instantiate our session globals node which will apply its settings. 231 self._sessionglobalsnode = _bascenev1.newnode('sessionglobals') 232 233 # Rejoin cooldown stuff. 234 self._players_on_wait: dict = {} 235 self._player_requested_identifiers: dict = {} 236 self._waitlist_timers: dict = {} 237 238 @property 239 def context(self) -> bascenev1.ContextRef: 240 """A context-ref pointing at this activity.""" 241 return self._sessiondata.context() 242 243 @property 244 def sessionglobalsnode(self) -> bascenev1.Node: 245 """The sessionglobals bascenev1.Node for the session.""" 246 node = self._sessionglobalsnode 247 if not node: 248 raise babase.NodeNotFoundError() 249 return node 250 251 def should_allow_mid_activity_joins( 252 self, activity: bascenev1.Activity 253 ) -> bool: 254 """Ask ourself if we should allow joins during an Activity. 255 256 Note that for a join to be allowed, both the Session and Activity 257 have to be ok with it (via this function and the 258 Activity.allow_mid_activity_joins property. 259 """ 260 del activity # Unused. 261 return True 262 263 def on_player_request(self, player: bascenev1.SessionPlayer) -> bool: 264 """Called when a new bascenev1.Player wants to join the Session. 265 266 This should return True or False to accept/reject. 267 """ 268 # Limit player counts *unless* we're in a stress test. 269 if ( 270 babase.app.classic is not None 271 and babase.app.classic.stress_test_update_timer is None 272 ): 273 if len(self.sessionplayers) >= self.max_players >= 0: 274 # Print a rejection message *only* to the client trying to 275 # join (prevents spamming everyone else in the game). 276 _bascenev1.getsound('error').play() 277 _bascenev1.broadcastmessage( 278 babase.Lstr( 279 resource='playerLimitReachedText', 280 subs=[('${COUNT}', str(self.max_players))], 281 ), 282 color=(0.8, 0.0, 0.0), 283 clients=[player.inputdevice.client_id], 284 transient=True, 285 ) 286 return False 287 288 # Rejoin cooldown. 289 identifier = player.get_v1_account_id() 290 if identifier: 291 leave_time = self._players_on_wait.get(identifier) 292 if leave_time: 293 diff = str( 294 math.ceil( 295 _g_player_rejoin_cooldown 296 - babase.apptime() 297 + leave_time 298 ) 299 ) 300 _bascenev1.broadcastmessage( 301 babase.Lstr( 302 translate=( 303 'serverResponses', 304 'You can join in ${COUNT} seconds.', 305 ), 306 subs=[('${COUNT}', diff)], 307 ), 308 color=(1, 1, 0), 309 clients=[player.inputdevice.client_id], 310 transient=True, 311 ) 312 return False 313 self._player_requested_identifiers[player.id] = identifier 314 315 _bascenev1.getsound('dripity').play() 316 return True 317 318 def on_player_leave(self, sessionplayer: bascenev1.SessionPlayer) -> None: 319 """Called when a previously-accepted bascenev1.SessionPlayer leaves.""" 320 321 if sessionplayer not in self.sessionplayers: 322 print( 323 'ERROR: Session.on_player_leave called' 324 ' for player not in our list.' 325 ) 326 return 327 328 _bascenev1.getsound('playerLeft').play() 329 330 activity = self._activity_weak() 331 332 # Rejoin cooldown. 333 identifier = self._player_requested_identifiers.get(sessionplayer.id) 334 if identifier: 335 self._players_on_wait[identifier] = babase.apptime() 336 with babase.ContextRef.empty(): 337 self._waitlist_timers[identifier] = babase.AppTimer( 338 _g_player_rejoin_cooldown, 339 babase.Call(self._remove_player_from_waitlist, identifier), 340 ) 341 342 if not sessionplayer.in_game: 343 # Ok, the player is still in the lobby; simply remove them. 344 with self.context: 345 try: 346 self.lobby.remove_chooser(sessionplayer) 347 except Exception: 348 logging.exception('Error in Lobby.remove_chooser().') 349 else: 350 # Ok, they've already entered the game. Remove them from 351 # teams/activities/etc. 352 sessionteam = sessionplayer.sessionteam 353 assert sessionteam is not None 354 355 _bascenev1.broadcastmessage( 356 babase.Lstr( 357 resource='playerLeftText', 358 subs=[('${PLAYER}', sessionplayer.getname(full=True))], 359 ) 360 ) 361 362 # Remove them from their SessionTeam. 363 if sessionplayer in sessionteam.players: 364 sessionteam.players.remove(sessionplayer) 365 else: 366 print( 367 'SessionPlayer not found in SessionTeam' 368 ' in on_player_leave.' 369 ) 370 371 # Grab their activity-specific player instance. 372 player = sessionplayer.activityplayer 373 assert isinstance(player, (Player, type(None))) 374 375 # Remove them from any current Activity. 376 if player is not None and activity is not None: 377 if player in activity.players: 378 activity.remove_player(sessionplayer) 379 else: 380 print('Player not found in Activity in on_player_leave.') 381 382 # If we're a non-team session, remove their team too. 383 if not self.use_teams: 384 self._remove_player_team(sessionteam, activity) 385 386 # Now remove them from the session list. 387 self.sessionplayers.remove(sessionplayer) 388 389 def _remove_player_team( 390 self, 391 sessionteam: bascenev1.SessionTeam, 392 activity: bascenev1.Activity | None, 393 ) -> None: 394 """Remove the player-specific team in non-teams mode.""" 395 396 # They should have been the only one on their team. 397 assert not sessionteam.players 398 399 # Remove their Team from the Activity. 400 if activity is not None: 401 if sessionteam.activityteam in activity.teams: 402 activity.remove_team(sessionteam) 403 else: 404 print('Team not found in Activity in on_player_leave.') 405 406 # And then from the Session. 407 with self.context: 408 if sessionteam in self.sessionteams: 409 try: 410 self.sessionteams.remove(sessionteam) 411 self.on_team_leave(sessionteam) 412 except Exception: 413 logging.exception( 414 'Error in on_team_leave for Session %s.', self 415 ) 416 else: 417 print('Team no in Session teams in on_player_leave.') 418 try: 419 sessionteam.leave() 420 except Exception: 421 logging.exception( 422 'Error clearing sessiondata for team %s in session %s.', 423 sessionteam, 424 self, 425 ) 426 427 def end(self) -> None: 428 """Initiates an end to the session and a return to the main menu. 429 430 Note that this happens asynchronously, allowing the 431 session and its activities to shut down gracefully. 432 """ 433 self._wants_to_end = True 434 if self._next_activity is None: 435 self._launch_end_session_activity() 436 437 def _launch_end_session_activity(self) -> None: 438 """(internal)""" 439 from bascenev1._activitytypes import EndSessionActivity 440 441 with self.context: 442 curtime = babase.apptime() 443 if self._ending: 444 # Ignore repeats unless its been a while. 445 assert self._launch_end_session_activity_time is not None 446 since_last = curtime - self._launch_end_session_activity_time 447 if since_last < 30.0: 448 return 449 logging.error( 450 '_launch_end_session_activity called twice (since_last=%s)', 451 since_last, 452 ) 453 self._launch_end_session_activity_time = curtime 454 self.setactivity(_bascenev1.newactivity(EndSessionActivity)) 455 self._wants_to_end = False 456 self._ending = True # Prevent further actions. 457 458 def on_team_join(self, team: bascenev1.SessionTeam) -> None: 459 """Called when a new bascenev1.Team joins the session.""" 460 461 def on_team_leave(self, team: bascenev1.SessionTeam) -> None: 462 """Called when a bascenev1.Team is leaving the session.""" 463 464 def end_activity( 465 self, 466 activity: bascenev1.Activity, 467 results: Any, 468 delay: float, 469 force: bool, 470 ) -> None: 471 """Commence shutdown of a bascenev1.Activity (if not already occurring). 472 473 'delay' is the time delay before the Activity actually ends 474 (in seconds). Further calls to end() will be ignored up until 475 this time, unless 'force' is True, in which case the new results 476 will replace the old. 477 """ 478 # Only pay attention if this is coming from our current activity. 479 if activity is not self._activity_retained: 480 return 481 482 # If this activity hasn't begun yet, just set it up to end immediately 483 # once it does. 484 if not activity.has_begun(): 485 # activity.set_immediate_end(results, delay, force) 486 if not self._activity_should_end_immediately or force: 487 self._activity_should_end_immediately = True 488 self._activity_should_end_immediately_results = results 489 self._activity_should_end_immediately_delay = delay 490 491 # The activity has already begun; get ready to end it. 492 else: 493 if (not activity.has_ended()) or force: 494 activity.set_has_ended(True) 495 496 # Set a timer to set in motion this activity's demise. 497 self._activity_end_timer = _bascenev1.BaseTimer( 498 delay, 499 babase.Call(self._complete_end_activity, activity, results), 500 ) 501 502 def handlemessage(self, msg: Any) -> Any: 503 """General message handling; can be passed any message object.""" 504 from bascenev1._lobby import PlayerReadyMessage 505 from bascenev1._messages import PlayerProfilesChangedMessage, UNHANDLED 506 507 if isinstance(msg, PlayerReadyMessage): 508 self._on_player_ready(msg.chooser) 509 510 elif isinstance(msg, PlayerProfilesChangedMessage): 511 # If we have a current activity with a lobby, ask it to reload 512 # profiles. 513 with self.context: 514 self.lobby.reload_profiles() 515 return None 516 517 else: 518 return UNHANDLED 519 return None 520 521 class _SetActivityScopedLock: 522 def __init__(self, session: Session) -> None: 523 self._session = session 524 if session._in_set_activity: 525 raise RuntimeError('Session.setactivity() called recursively.') 526 self._session._in_set_activity = True 527 528 def __del__(self) -> None: 529 self._session._in_set_activity = False 530 531 def setactivity(self, activity: bascenev1.Activity) -> None: 532 """Assign a new current bascenev1.Activity for the session. 533 534 Note that this will not change the current context to the new 535 Activity's. Code must be run in the new activity's methods 536 (on_transition_in, etc) to get it. (so you can't do 537 session.setactivity(foo) and then bascenev1.newnode() to add a node 538 to foo) 539 """ 540 541 # Make sure we don't get called recursively. 542 _rlock = self._SetActivityScopedLock(self) 543 544 if activity.session is not _bascenev1.getsession(): 545 raise RuntimeError("Provided Activity's Session is not current.") 546 547 # Quietly ignore this if the whole session is going down. 548 if self._ending: 549 return 550 551 if activity is self._activity_retained: 552 logging.error('Activity set to already-current activity.') 553 return 554 555 if self._next_activity is not None: 556 raise RuntimeError( 557 'Activity switch already in progress (to ' 558 + str(self._next_activity) 559 + ')' 560 ) 561 562 prev_activity = self._activity_retained 563 prev_globals = ( 564 prev_activity.globalsnode if prev_activity is not None else None 565 ) 566 567 # Let the activity do its thing. 568 activity.transition_in(prev_globals) 569 570 self._next_activity = activity 571 572 # If we have a current activity, tell it it's transitioning out; 573 # the next one will become current once this one dies. 574 if prev_activity is not None: 575 prev_activity.transition_out() 576 577 # Setting this to None should free up the old activity to die, 578 # which will call begin_next_activity. 579 # We can still access our old activity through 580 # self._activity_weak() to keep it up to date on player 581 # joins/departures/etc until it dies. 582 self._activity_retained = None 583 584 # There's no existing activity; lets just go ahead with the begin call. 585 else: 586 self.begin_next_activity() 587 588 # We want to call destroy() for the previous activity once it should 589 # tear itself down, clear out any self-refs, etc. After this call 590 # the activity should have no refs left to it and should die (which 591 # will trigger the next activity to run). 592 if prev_activity is not None: 593 with babase.ContextRef.empty(): 594 babase.apptimer( 595 max(0.0, activity.transition_time), prev_activity.expire 596 ) 597 self._in_set_activity = False 598 599 def getactivity(self) -> bascenev1.Activity | None: 600 """Return the current foreground activity for this session.""" 601 return self._activity_weak() 602 603 def get_custom_menu_entries(self) -> list[dict[str, Any]]: 604 """Subclasses can override this to provide custom menu entries. 605 606 The returned value should be a list of dicts, each containing 607 a 'label' and 'call' entry, with 'label' being the text for 608 the entry and 'call' being the callable to trigger if the entry 609 is pressed. 610 """ 611 return [] 612 613 def _complete_end_activity( 614 self, activity: bascenev1.Activity, results: Any 615 ) -> None: 616 # Run the subclass callback in the session context. 617 try: 618 with self.context: 619 self.on_activity_end(activity, results) 620 except Exception: 621 logging.error( 622 'Error in on_activity_end() for session %s' 623 ' activity %s with results %s', 624 self, 625 activity, 626 results, 627 ) 628 629 def _request_player(self, sessionplayer: bascenev1.SessionPlayer) -> bool: 630 """Called by the native layer when a player wants to join.""" 631 632 # If we're ending, allow no new players. 633 if self._ending: 634 return False 635 636 # Ask the bascenev1.Session subclass to approve/deny this request. 637 try: 638 with self.context: 639 result = self.on_player_request(sessionplayer) 640 except Exception: 641 logging.exception('Error in on_player_request for %s.', self) 642 result = False 643 644 # If they said yes, add the player to the lobby. 645 if result: 646 self.sessionplayers.append(sessionplayer) 647 with self.context: 648 try: 649 self.lobby.add_chooser(sessionplayer) 650 except Exception: 651 logging.exception('Error in lobby.add_chooser().') 652 653 return result 654 655 def on_activity_end( 656 self, activity: bascenev1.Activity, results: Any 657 ) -> None: 658 """Called when the current bascenev1.Activity has ended. 659 660 The bascenev1.Session should look at the results and start 661 another bascenev1.Activity. 662 """ 663 664 def begin_next_activity(self) -> None: 665 """Called once the previous activity has been totally torn down. 666 667 This means we're ready to begin the next one 668 """ 669 if self._next_activity is None: 670 # Should this ever happen? 671 logging.error('begin_next_activity() called with no _next_activity') 672 return 673 674 # We store both a weak and a strong ref to the new activity; 675 # the strong is to keep it alive and the weak is so we can access 676 # it even after we've released the strong-ref to allow it to die. 677 self._activity_retained = self._next_activity 678 self._activity_weak = weakref.ref(self._next_activity) 679 self._next_activity = None 680 self._activity_should_end_immediately = False 681 682 # Kick out anyone loitering in the lobby. 683 self.lobby.remove_all_choosers_and_kick_players() 684 685 # Kick off the activity. 686 self._activity_retained.begin(self) 687 688 # If we want to completely end the session, we can now kick that off. 689 if self._wants_to_end: 690 self._launch_end_session_activity() 691 else: 692 # Otherwise, if the activity has already been told to end, 693 # do so now. 694 if self._activity_should_end_immediately: 695 self._activity_retained.end( 696 self._activity_should_end_immediately_results, 697 self._activity_should_end_immediately_delay, 698 ) 699 700 def _on_player_ready(self, chooser: bascenev1.Chooser) -> None: 701 """Called when a bascenev1.Player has checked themself ready.""" 702 lobby = chooser.lobby 703 activity = self._activity_weak() 704 705 # This happens sometimes. That seems like it shouldn't be happening; 706 # when would we have a session and a chooser with players but no 707 # active activity? 708 if activity is None: 709 print('_on_player_ready called with no activity.') 710 return 711 712 # In joining-activities, we wait till all choosers are ready 713 # and then create all players at once. 714 if activity.is_joining_activity: 715 if not lobby.check_all_ready(): 716 return 717 choosers = lobby.get_choosers() 718 min_players = self.min_players 719 if len(choosers) >= min_players: 720 for lch in lobby.get_choosers(): 721 self._add_chosen_player(lch) 722 lobby.remove_all_choosers() 723 724 # Get our next activity going. 725 self._complete_end_activity(activity, {}) 726 else: 727 _bascenev1.broadcastmessage( 728 babase.Lstr( 729 resource='notEnoughPlayersText', 730 subs=[('${COUNT}', str(min_players))], 731 ), 732 color=(1, 1, 0), 733 ) 734 _bascenev1.getsound('error').play() 735 736 # Otherwise just add players on the fly. 737 else: 738 self._add_chosen_player(chooser) 739 lobby.remove_chooser(chooser.getplayer()) 740 741 def transitioning_out_activity_was_freed( 742 self, can_show_ad_on_death: bool 743 ) -> None: 744 """(internal)""" 745 # pylint: disable=cyclic-import 746 747 # Since things should be generally still right now, it's a good time 748 # to run garbage collection to clear out any circular dependency 749 # loops. We keep this disabled normally to avoid non-deterministic 750 # hitches. 751 babase.garbage_collect() 752 753 assert babase.app.classic is not None 754 with self.context: 755 if can_show_ad_on_death: 756 babase.app.classic.ads.call_after_ad(self.begin_next_activity) 757 else: 758 babase.pushcall(self.begin_next_activity) 759 760 def _add_chosen_player( 761 self, chooser: bascenev1.Chooser 762 ) -> bascenev1.SessionPlayer: 763 from bascenev1._team import SessionTeam 764 765 sessionplayer = chooser.getplayer() 766 assert sessionplayer in self.sessionplayers, ( 767 'SessionPlayer not found in session ' 768 'player-list after chooser selection.' 769 ) 770 771 activity = self._activity_weak() 772 assert activity is not None 773 774 # Reset the player's input here, as it is probably 775 # referencing the chooser which could inadvertently keep it alive. 776 sessionplayer.resetinput() 777 778 # We can pass it to the current activity if it has already begun 779 # (otherwise it'll get passed once begin is called). 780 pass_to_activity = ( 781 activity.has_begun() and not activity.is_joining_activity 782 ) 783 784 # However, if we're not allowing mid-game joins, don't actually pass; 785 # just announce the arrival and say they'll partake next round. 786 if pass_to_activity: 787 if not ( 788 activity.allow_mid_activity_joins 789 and self.should_allow_mid_activity_joins(activity) 790 ): 791 pass_to_activity = False 792 with self.context: 793 _bascenev1.broadcastmessage( 794 babase.Lstr( 795 resource='playerDelayedJoinText', 796 subs=[ 797 ('${PLAYER}', sessionplayer.getname(full=True)) 798 ], 799 ), 800 color=(0, 1, 0), 801 ) 802 803 # If we're a non-team session, each player gets their own team. 804 # (keeps mini-game coding simpler if we can always deal with teams). 805 if self.use_teams: 806 sessionteam = chooser.sessionteam 807 else: 808 our_team_id = self._next_team_id 809 self._next_team_id += 1 810 sessionteam = SessionTeam( 811 team_id=our_team_id, 812 color=chooser.get_color(), 813 name=chooser.getplayer().getname(full=True, icon=False), 814 ) 815 816 # Add player's team to the Session. 817 self.sessionteams.append(sessionteam) 818 819 with self.context: 820 try: 821 self.on_team_join(sessionteam) 822 except Exception: 823 logging.exception('Error in on_team_join for %s.', self) 824 825 # Add player's team to the Activity. 826 if pass_to_activity: 827 activity.add_team(sessionteam) 828 829 assert sessionplayer not in sessionteam.players 830 sessionteam.players.append(sessionplayer) 831 sessionplayer.setdata( 832 team=sessionteam, 833 character=chooser.get_character_name(), 834 color=chooser.get_color(), 835 highlight=chooser.get_highlight(), 836 ) 837 838 self.stats.register_sessionplayer(sessionplayer) 839 if pass_to_activity: 840 activity.add_player(sessionplayer) 841 return sessionplayer 842 843 def _remove_player_from_waitlist(self, identifier: str) -> None: 844 try: 845 self._players_on_wait.pop(identifier) 846 except KeyError: 847 pass
Defines a high level series of bascenev1.Activity-es.
Examples of sessions are bascenev1.FreeForAllSession, bascenev1.DualTeamSession, and bascenev1.CoopSession.
A Session is responsible for wrangling and transitioning between various bascenev1.Activity instances such as mini-games and score-screens, and for maintaining state between them (players, teams, score tallies, etc).
95 def __init__( 96 self, 97 depsets: Sequence[bascenev1.DependencySet], 98 *, 99 team_names: Sequence[str] | None = None, 100 team_colors: Sequence[Sequence[float]] | None = None, 101 min_players: int = 1, 102 max_players: int = 8, 103 submit_score: bool = True, 104 ): 105 """Instantiate a session. 106 107 depsets should be a sequence of successfully resolved 108 bascenev1.DependencySet instances; one for each bascenev1.Activity 109 the session may potentially run. 110 """ 111 # pylint: disable=too-many-statements 112 # pylint: disable=too-many-locals 113 # pylint: disable=cyclic-import 114 # pylint: disable=too-many-branches 115 from efro.util import empty_weakref 116 from bascenev1._dependency import ( 117 Dependency, 118 AssetPackage, 119 DependencyError, 120 ) 121 from bascenev1._lobby import Lobby 122 from bascenev1._stats import Stats 123 from bascenev1._gameactivity import GameActivity 124 from bascenev1._activity import Activity 125 from bascenev1._team import SessionTeam 126 127 # First off, resolve all dependency-sets we were passed. 128 # If things are missing, we'll try to gather them into a single 129 # missing-deps exception if possible to give the caller a clean 130 # path to download missing stuff and try again. 131 missing_asset_packages: set[str] = set() 132 for depset in depsets: 133 try: 134 depset.resolve() 135 except DependencyError as exc: 136 # Gather/report missing assets only; barf on anything else. 137 if all(issubclass(d.cls, AssetPackage) for d in exc.deps): 138 for dep in exc.deps: 139 assert isinstance(dep.config, str) 140 missing_asset_packages.add(dep.config) 141 else: 142 missing_info = [(d.cls, d.config) for d in exc.deps] 143 raise RuntimeError( 144 f'Missing non-asset dependencies: {missing_info}' 145 ) from exc 146 147 # Throw a combined exception if we found anything missing. 148 if missing_asset_packages: 149 raise DependencyError( 150 [ 151 Dependency(AssetPackage, set_id) 152 for set_id in missing_asset_packages 153 ] 154 ) 155 156 # Ok; looks like our dependencies check out. 157 # Now give the engine a list of asset-set-ids to pass along to clients. 158 required_asset_packages: set[str] = set() 159 for depset in depsets: 160 required_asset_packages.update(depset.get_asset_package_ids()) 161 162 # print('Would set host-session asset-reqs to:', 163 # required_asset_packages) 164 165 # Init our C++ layer data. 166 self._sessiondata = _bascenev1.register_session(self) 167 168 # Should remove this if possible. 169 self.tournament_id: str | None = None 170 171 self.sessionteams = [] 172 self.sessionplayers = [] 173 self.min_players = min_players 174 self.max_players = ( 175 max_players 176 if _max_players_override is None 177 else _max_players_override 178 ) 179 self.submit_score = submit_score 180 181 self.customdata = {} 182 self._in_set_activity = False 183 self._next_team_id = 0 184 self._activity_retained: bascenev1.Activity | None = None 185 self._launch_end_session_activity_time: float | None = None 186 self._activity_end_timer: bascenev1.BaseTimer | None = None 187 self._activity_weak = empty_weakref(Activity) 188 self._next_activity: bascenev1.Activity | None = None 189 self._wants_to_end = False 190 self._ending = False 191 self._activity_should_end_immediately = False 192 self._activity_should_end_immediately_results: ( 193 bascenev1.GameResults | None 194 ) = None 195 self._activity_should_end_immediately_delay = 0.0 196 197 # Create static teams if we're using them. 198 if self.use_teams: 199 if team_names is None: 200 raise RuntimeError( 201 'use_teams is True but team_names not provided.' 202 ) 203 if team_colors is None: 204 raise RuntimeError( 205 'use_teams is True but team_colors not provided.' 206 ) 207 if len(team_colors) != len(team_names): 208 raise RuntimeError( 209 f'Got {len(team_names)} team_names' 210 f' and {len(team_colors)} team_colors;' 211 f' these numbers must match.' 212 ) 213 for i, color in enumerate(team_colors): 214 team = SessionTeam( 215 team_id=self._next_team_id, 216 name=GameActivity.get_team_display_string(team_names[i]), 217 color=color, 218 ) 219 self.sessionteams.append(team) 220 self._next_team_id += 1 221 try: 222 with self.context: 223 self.on_team_join(team) 224 except Exception: 225 logging.exception('Error in on_team_join for %s.', self) 226 227 self.lobby = Lobby() 228 self.stats = Stats() 229 230 # Instantiate our session globals node which will apply its settings. 231 self._sessionglobalsnode = _bascenev1.newnode('sessionglobals') 232 233 # Rejoin cooldown stuff. 234 self._players_on_wait: dict = {} 235 self._player_requested_identifiers: dict = {} 236 self._waitlist_timers: dict = {}
Instantiate a session.
depsets should be a sequence of successfully resolved bascenev1.DependencySet instances; one for each bascenev1.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 baclassic.Lobby instance where new bascenev1.Player-s go to select a Profile/Team/etc. before being added to games. Be aware this value may be None if a Session does not allow any such selection.
The minimum number of players who must be present for the Session to proceed past the initial joining screen
All bascenev1.SessionPlayers in the Session. Most things should use the list of bascenev1.Player-s in bascenev1.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 bascenev1.SessionTeams in the Session. Most things should use the list of bascenev1.Team-s in bascenev1.Activity; not this.
238 @property 239 def context(self) -> bascenev1.ContextRef: 240 """A context-ref pointing at this activity.""" 241 return self._sessiondata.context()
A context-ref pointing at this activity.
243 @property 244 def sessionglobalsnode(self) -> bascenev1.Node: 245 """The sessionglobals bascenev1.Node for the session.""" 246 node = self._sessionglobalsnode 247 if not node: 248 raise babase.NodeNotFoundError() 249 return node
The sessionglobals bascenev1.Node for the session.
251 def should_allow_mid_activity_joins( 252 self, activity: bascenev1.Activity 253 ) -> bool: 254 """Ask ourself if we should allow joins during an Activity. 255 256 Note that for a join to be allowed, both the Session and Activity 257 have to be ok with it (via this function and the 258 Activity.allow_mid_activity_joins property. 259 """ 260 del activity # Unused. 261 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.
263 def on_player_request(self, player: bascenev1.SessionPlayer) -> bool: 264 """Called when a new bascenev1.Player wants to join the Session. 265 266 This should return True or False to accept/reject. 267 """ 268 # Limit player counts *unless* we're in a stress test. 269 if ( 270 babase.app.classic is not None 271 and babase.app.classic.stress_test_update_timer is None 272 ): 273 if len(self.sessionplayers) >= self.max_players >= 0: 274 # Print a rejection message *only* to the client trying to 275 # join (prevents spamming everyone else in the game). 276 _bascenev1.getsound('error').play() 277 _bascenev1.broadcastmessage( 278 babase.Lstr( 279 resource='playerLimitReachedText', 280 subs=[('${COUNT}', str(self.max_players))], 281 ), 282 color=(0.8, 0.0, 0.0), 283 clients=[player.inputdevice.client_id], 284 transient=True, 285 ) 286 return False 287 288 # Rejoin cooldown. 289 identifier = player.get_v1_account_id() 290 if identifier: 291 leave_time = self._players_on_wait.get(identifier) 292 if leave_time: 293 diff = str( 294 math.ceil( 295 _g_player_rejoin_cooldown 296 - babase.apptime() 297 + leave_time 298 ) 299 ) 300 _bascenev1.broadcastmessage( 301 babase.Lstr( 302 translate=( 303 'serverResponses', 304 'You can join in ${COUNT} seconds.', 305 ), 306 subs=[('${COUNT}', diff)], 307 ), 308 color=(1, 1, 0), 309 clients=[player.inputdevice.client_id], 310 transient=True, 311 ) 312 return False 313 self._player_requested_identifiers[player.id] = identifier 314 315 _bascenev1.getsound('dripity').play() 316 return True
Called when a new bascenev1.Player wants to join the Session.
This should return True or False to accept/reject.
318 def on_player_leave(self, sessionplayer: bascenev1.SessionPlayer) -> None: 319 """Called when a previously-accepted bascenev1.SessionPlayer leaves.""" 320 321 if sessionplayer not in self.sessionplayers: 322 print( 323 'ERROR: Session.on_player_leave called' 324 ' for player not in our list.' 325 ) 326 return 327 328 _bascenev1.getsound('playerLeft').play() 329 330 activity = self._activity_weak() 331 332 # Rejoin cooldown. 333 identifier = self._player_requested_identifiers.get(sessionplayer.id) 334 if identifier: 335 self._players_on_wait[identifier] = babase.apptime() 336 with babase.ContextRef.empty(): 337 self._waitlist_timers[identifier] = babase.AppTimer( 338 _g_player_rejoin_cooldown, 339 babase.Call(self._remove_player_from_waitlist, identifier), 340 ) 341 342 if not sessionplayer.in_game: 343 # Ok, the player is still in the lobby; simply remove them. 344 with self.context: 345 try: 346 self.lobby.remove_chooser(sessionplayer) 347 except Exception: 348 logging.exception('Error in Lobby.remove_chooser().') 349 else: 350 # Ok, they've already entered the game. Remove them from 351 # teams/activities/etc. 352 sessionteam = sessionplayer.sessionteam 353 assert sessionteam is not None 354 355 _bascenev1.broadcastmessage( 356 babase.Lstr( 357 resource='playerLeftText', 358 subs=[('${PLAYER}', sessionplayer.getname(full=True))], 359 ) 360 ) 361 362 # Remove them from their SessionTeam. 363 if sessionplayer in sessionteam.players: 364 sessionteam.players.remove(sessionplayer) 365 else: 366 print( 367 'SessionPlayer not found in SessionTeam' 368 ' in on_player_leave.' 369 ) 370 371 # Grab their activity-specific player instance. 372 player = sessionplayer.activityplayer 373 assert isinstance(player, (Player, type(None))) 374 375 # Remove them from any current Activity. 376 if player is not None and activity is not None: 377 if player in activity.players: 378 activity.remove_player(sessionplayer) 379 else: 380 print('Player not found in Activity in on_player_leave.') 381 382 # If we're a non-team session, remove their team too. 383 if not self.use_teams: 384 self._remove_player_team(sessionteam, activity) 385 386 # Now remove them from the session list. 387 self.sessionplayers.remove(sessionplayer)
Called when a previously-accepted bascenev1.SessionPlayer leaves.
427 def end(self) -> None: 428 """Initiates an end to the session and a return to the main menu. 429 430 Note that this happens asynchronously, allowing the 431 session and its activities to shut down gracefully. 432 """ 433 self._wants_to_end = True 434 if self._next_activity is None: 435 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.
458 def on_team_join(self, team: bascenev1.SessionTeam) -> None: 459 """Called when a new bascenev1.Team joins the session."""
Called when a new bascenev1.Team joins the session.
461 def on_team_leave(self, team: bascenev1.SessionTeam) -> None: 462 """Called when a bascenev1.Team is leaving the session."""
Called when a bascenev1.Team is leaving the session.
464 def end_activity( 465 self, 466 activity: bascenev1.Activity, 467 results: Any, 468 delay: float, 469 force: bool, 470 ) -> None: 471 """Commence shutdown of a bascenev1.Activity (if not already occurring). 472 473 'delay' is the time delay before the Activity actually ends 474 (in seconds). Further calls to end() will be ignored up until 475 this time, unless 'force' is True, in which case the new results 476 will replace the old. 477 """ 478 # Only pay attention if this is coming from our current activity. 479 if activity is not self._activity_retained: 480 return 481 482 # If this activity hasn't begun yet, just set it up to end immediately 483 # once it does. 484 if not activity.has_begun(): 485 # activity.set_immediate_end(results, delay, force) 486 if not self._activity_should_end_immediately or force: 487 self._activity_should_end_immediately = True 488 self._activity_should_end_immediately_results = results 489 self._activity_should_end_immediately_delay = delay 490 491 # The activity has already begun; get ready to end it. 492 else: 493 if (not activity.has_ended()) or force: 494 activity.set_has_ended(True) 495 496 # Set a timer to set in motion this activity's demise. 497 self._activity_end_timer = _bascenev1.BaseTimer( 498 delay, 499 babase.Call(self._complete_end_activity, activity, results), 500 )
Commence shutdown of a bascenev1.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.
502 def handlemessage(self, msg: Any) -> Any: 503 """General message handling; can be passed any message object.""" 504 from bascenev1._lobby import PlayerReadyMessage 505 from bascenev1._messages import PlayerProfilesChangedMessage, UNHANDLED 506 507 if isinstance(msg, PlayerReadyMessage): 508 self._on_player_ready(msg.chooser) 509 510 elif isinstance(msg, PlayerProfilesChangedMessage): 511 # If we have a current activity with a lobby, ask it to reload 512 # profiles. 513 with self.context: 514 self.lobby.reload_profiles() 515 return None 516 517 else: 518 return UNHANDLED 519 return None
General message handling; can be passed any message object.
531 def setactivity(self, activity: bascenev1.Activity) -> None: 532 """Assign a new current bascenev1.Activity for the session. 533 534 Note that this will not change the current context to the new 535 Activity's. Code must be run in the new activity's methods 536 (on_transition_in, etc) to get it. (so you can't do 537 session.setactivity(foo) and then bascenev1.newnode() to add a node 538 to foo) 539 """ 540 541 # Make sure we don't get called recursively. 542 _rlock = self._SetActivityScopedLock(self) 543 544 if activity.session is not _bascenev1.getsession(): 545 raise RuntimeError("Provided Activity's Session is not current.") 546 547 # Quietly ignore this if the whole session is going down. 548 if self._ending: 549 return 550 551 if activity is self._activity_retained: 552 logging.error('Activity set to already-current activity.') 553 return 554 555 if self._next_activity is not None: 556 raise RuntimeError( 557 'Activity switch already in progress (to ' 558 + str(self._next_activity) 559 + ')' 560 ) 561 562 prev_activity = self._activity_retained 563 prev_globals = ( 564 prev_activity.globalsnode if prev_activity is not None else None 565 ) 566 567 # Let the activity do its thing. 568 activity.transition_in(prev_globals) 569 570 self._next_activity = activity 571 572 # If we have a current activity, tell it it's transitioning out; 573 # the next one will become current once this one dies. 574 if prev_activity is not None: 575 prev_activity.transition_out() 576 577 # Setting this to None should free up the old activity to die, 578 # which will call begin_next_activity. 579 # We can still access our old activity through 580 # self._activity_weak() to keep it up to date on player 581 # joins/departures/etc until it dies. 582 self._activity_retained = None 583 584 # There's no existing activity; lets just go ahead with the begin call. 585 else: 586 self.begin_next_activity() 587 588 # We want to call destroy() for the previous activity once it should 589 # tear itself down, clear out any self-refs, etc. After this call 590 # the activity should have no refs left to it and should die (which 591 # will trigger the next activity to run). 592 if prev_activity is not None: 593 with babase.ContextRef.empty(): 594 babase.apptimer( 595 max(0.0, activity.transition_time), prev_activity.expire 596 ) 597 self._in_set_activity = False
Assign a new current bascenev1.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 bascenev1.newnode() to add a node to foo)
599 def getactivity(self) -> bascenev1.Activity | None: 600 """Return the current foreground activity for this session.""" 601 return self._activity_weak()
Return the current foreground activity for this session.
655 def on_activity_end( 656 self, activity: bascenev1.Activity, results: Any 657 ) -> None: 658 """Called when the current bascenev1.Activity has ended. 659 660 The bascenev1.Session should look at the results and start 661 another bascenev1.Activity. 662 """
Called when the current bascenev1.Activity has ended.
The bascenev1.Session should look at the results and start another bascenev1.Activity.
664 def begin_next_activity(self) -> None: 665 """Called once the previous activity has been totally torn down. 666 667 This means we're ready to begin the next one 668 """ 669 if self._next_activity is None: 670 # Should this ever happen? 671 logging.error('begin_next_activity() called with no _next_activity') 672 return 673 674 # We store both a weak and a strong ref to the new activity; 675 # the strong is to keep it alive and the weak is so we can access 676 # it even after we've released the strong-ref to allow it to die. 677 self._activity_retained = self._next_activity 678 self._activity_weak = weakref.ref(self._next_activity) 679 self._next_activity = None 680 self._activity_should_end_immediately = False 681 682 # Kick out anyone loitering in the lobby. 683 self.lobby.remove_all_choosers_and_kick_players() 684 685 # Kick off the activity. 686 self._activity_retained.begin(self) 687 688 # If we want to completely end the session, we can now kick that off. 689 if self._wants_to_end: 690 self._launch_end_session_activity() 691 else: 692 # Otherwise, if the activity has already been told to end, 693 # do so now. 694 if self._activity_should_end_immediately: 695 self._activity_retained.end( 696 self._activity_should_end_immediately_results, 697 self._activity_should_end_immediately_delay, 698 )
Called once the previous activity has been totally torn down.
This means we're ready to begin the next one
722class SessionPlayer: 723 """A reference to a player in the :class:`~bascenev1.Session`. 724 725 These are created and managed internally and provided to your 726 :class:`~bascenev1.Session`/:class:`~bascenev1.Activity` 727 instances. Be aware that, like :class:`~bascenev1.Node` objects, 728 :class:`~bascenev1.SessionPlayer` objects are effectively 'weak' 729 references under-the-hood; a player can leave the game at any point. 730 For this reason, you should make judicious use of the 731 :meth:`bascenev1.SessionPlayer.exists()` method (or boolean operator) to 732 ensure that a :class:`SessionPlayer` is still present if retaining 733 references to one for any length of time. 734 """ 735 736 id: int 737 """The unique numeric ID of the Player. 738 739 Note that you can also use the boolean operator for this same 740 functionality, so a statement such as "if player" will do 741 the right thing both for Player objects and values of None.""" 742 743 in_game: bool 744 """This bool value will be True once the Player has completed 745 any lobby character/team selection.""" 746 747 sessionteam: bascenev1.SessionTeam 748 """The bascenev1.SessionTeam this Player is on. If the 749 SessionPlayer is still in its lobby selecting a team/etc. 750 then a bascenev1.SessionTeamNotFoundError will be raised.""" 751 752 inputdevice: bascenev1.InputDevice 753 """The input device associated with the player.""" 754 755 color: Sequence[float] 756 """The base color for this Player. 757 In team games this will match the bascenev1.SessionTeam's 758 color.""" 759 760 highlight: Sequence[float] 761 """A secondary color for this player. 762 This is used for minor highlights and accents 763 to allow a player to stand apart from his teammates 764 who may all share the same team (primary) color.""" 765 766 character: str 767 """The character this player has selected in their profile.""" 768 769 activityplayer: bascenev1.Player | None 770 """The current game-specific instance for this player.""" 771 772 def __bool__(self) -> bool: 773 """Support for bool evaluation.""" 774 return bool(True) # Slight obfuscation. 775 776 def assigninput( 777 self, 778 type: bascenev1.InputType | tuple[bascenev1.InputType, ...], 779 call: Callable, 780 ) -> None: 781 """Set the python callable to be run for one or more types of input.""" 782 return None 783 784 def exists(self) -> bool: 785 """Return whether the underlying player is still in the game.""" 786 return bool() 787 788 def get_icon(self) -> dict[str, Any]: 789 """Returns the character's icon (images, colors, etc contained 790 in a dict. 791 """ 792 return {'foo': 'bar'} 793 794 def get_icon_info(self) -> dict[str, Any]: 795 """(internal)""" 796 return {'foo': 'bar'} 797 798 def get_v1_account_id(self) -> str: 799 """Return the V1 Account ID this player is signed in under, if 800 there is one and it can be determined with relative certainty. 801 Returns None otherwise. Note that this may require an active 802 internet connection (especially for network-connected players) 803 and may return None for a short while after a player initially 804 joins (while verification occurs). 805 """ 806 return str() 807 808 def getname(self, full: bool = False, icon: bool = True) -> str: 809 """Returns the player's name. If icon is True, the long version of the 810 name may include an icon. 811 """ 812 return str() 813 814 def remove_from_game(self) -> None: 815 """Removes the player from the game.""" 816 return None 817 818 def resetinput(self) -> None: 819 """Clears out the player's assigned input actions.""" 820 return None 821 822 def set_icon_info( 823 self, 824 texture: str, 825 tint_texture: str, 826 tint_color: Sequence[float], 827 tint2_color: Sequence[float], 828 ) -> None: 829 """(internal)""" 830 return None 831 832 def setactivity(self, activity: bascenev1.Activity | None) -> None: 833 """(internal)""" 834 return None 835 836 def setdata( 837 self, 838 team: bascenev1.SessionTeam, 839 character: str, 840 color: Sequence[float], 841 highlight: Sequence[float], 842 ) -> None: 843 """(internal)""" 844 return None 845 846 def setname( 847 self, name: str, full_name: str | None = None, real: bool = True 848 ) -> None: 849 """Set the player's name to the provided string. 850 A number will automatically be appended if the name is not unique from 851 other players. 852 """ 853 return None 854 855 def setnode(self, node: bascenev1.Node | None) -> None: 856 """(internal)""" 857 return None
A reference to a player in the ~bascenev1.Session
.
These are created and managed internally and provided to your
~bascenev1.Session
/~bascenev1.Activity
instances. Be aware that, like ~bascenev1.Node
objects,
~bascenev1.SessionPlayer
objects are effectively 'weak'
references under-the-hood; a player can leave the game at any point.
For this reason, you should make judicious use of the
bascenev1.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 bascenev1.SessionTeam this Player is on. If the SessionPlayer is still in its lobby selecting a team/etc. then a bascenev1.SessionTeamNotFoundError will be raised.
The base color for this Player. In team games this will match the bascenev1.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.
776 def assigninput( 777 self, 778 type: bascenev1.InputType | tuple[bascenev1.InputType, ...], 779 call: Callable, 780 ) -> None: 781 """Set the python callable to be run for one or more types of input.""" 782 return None
Set the python callable to be run for one or more types of input.
784 def exists(self) -> bool: 785 """Return whether the underlying player is still in the game.""" 786 return bool()
Return whether the underlying player is still in the game.
788 def get_icon(self) -> dict[str, Any]: 789 """Returns the character's icon (images, colors, etc contained 790 in a dict. 791 """ 792 return {'foo': 'bar'}
Returns the character's icon (images, colors, etc contained in a dict.
798 def get_v1_account_id(self) -> str: 799 """Return the V1 Account ID this player is signed in under, if 800 there is one and it can be determined with relative certainty. 801 Returns None otherwise. Note that this may require an active 802 internet connection (especially for network-connected players) 803 and may return None for a short while after a player initially 804 joins (while verification occurs). 805 """ 806 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).
808 def getname(self, full: bool = False, icon: bool = True) -> str: 809 """Returns the player's name. If icon is True, the long version of the 810 name may include an icon. 811 """ 812 return str()
Returns the player's name. If icon is True, the long version of the name may include an icon.
818 def resetinput(self) -> None: 819 """Clears out the player's assigned input actions.""" 820 return None
Clears out the player's assigned input actions.
846 def setname( 847 self, name: str, full_name: str | None = None, real: bool = True 848 ) -> None: 849 """Set the player's name to the provided string. 850 A number will automatically be appended if the name is not unique from 851 other players. 852 """ 853 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.
20class SessionTeam: 21 """A team of one or more bascenev1.SessionPlayers. 22 23 Note that a SessionPlayer *always* has a SessionTeam; in some cases, 24 such as free-for-all bascenev1.Sessions, each SessionTeam consists 25 of just one SessionPlayer. 26 """ 27 28 # Annotate our attr types at the class level so they're introspectable. 29 30 name: babase.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[bascenev1.SessionPlayer] 37 """The list of bascenev1.SessionPlayer-s on the team.""" 38 39 customdata: dict 40 """A dict for use by the current bascenev1.Session for 41 storing data associated with this team. 42 Unlike customdata, this persists for the duration 43 of the session.""" 44 45 id: int 46 """The unique numeric id of the team.""" 47 48 def __init__( 49 self, 50 team_id: int = 0, 51 name: babase.Lstr | str = '', 52 color: Sequence[float] = (1.0, 1.0, 1.0), 53 ): 54 """Instantiate a bascenev1.SessionTeam. 55 56 In most cases, all teams are provided to you by the bascenev1.Session, 57 bascenev1.Session, so calling this shouldn't be necessary. 58 """ 59 60 self.id = team_id 61 self.name = name 62 self.color = tuple(color) 63 self.players = [] 64 self.customdata = {} 65 self.activityteam: Team | None = None 66 67 def leave(self) -> None: 68 """(internal)""" 69 self.customdata = {}
A team of one or more bascenev1.SessionPlayers.
Note that a SessionPlayer always has a SessionTeam; in some cases, such as free-for-all bascenev1.Sessions, each SessionTeam consists of just one SessionPlayer.
48 def __init__( 49 self, 50 team_id: int = 0, 51 name: babase.Lstr | str = '', 52 color: Sequence[float] = (1.0, 1.0, 1.0), 53 ): 54 """Instantiate a bascenev1.SessionTeam. 55 56 In most cases, all teams are provided to you by the bascenev1.Session, 57 bascenev1.Session, so calling this shouldn't be necessary. 58 """ 59 60 self.id = team_id 61 self.name = name 62 self.color = tuple(color) 63 self.players = [] 64 self.customdata = {} 65 self.activityteam: Team | None = None
Instantiate a bascenev1.SessionTeam.
In most cases, all teams are provided to you by the bascenev1.Session, bascenev1.Session, so calling this shouldn't be necessary.
A dict for use by the current bascenev1.Session for storing data associated with this team. Unlike customdata, this persists for the duration of the session.
1464def set_analytics_screen(screen: str) -> None: 1465 """Used for analytics to see where in the app players spend their time. 1466 1467 Generally called when opening a new window or entering some UI. 1468 'screen' should be a string description of an app location 1469 ('Main Menu', etc.) 1470 """ 1471 return None
Used for analytics to see where in the app players spend their time.
Generally called when opening a new window or entering some UI. 'screen' should be a string description of an app location ('Main Menu', etc.)
31def set_player_rejoin_cooldown(cooldown: float) -> None: 32 """Set the cooldown for individual players rejoining after leaving.""" 33 global _g_player_rejoin_cooldown # pylint: disable=global-statement 34 _g_player_rejoin_cooldown = max(0.0, cooldown)
Set the cooldown for individual players rejoining after leaving.
37def set_max_players_override(max_players: int | None) -> None: 38 """Set the override for how many players can join a session""" 39 global _max_players_override # pylint: disable=global-statement 40 _max_players_override = max_players
Set the override for how many players can join a session
49def setmusic(musictype: MusicType | None, continuous: bool = False) -> None: 50 """Set the app to play (or stop playing) a certain type of music. 51 52 category: **Gameplay Functions** 53 54 This function will handle loading and playing sound assets as necessary, 55 and also supports custom user soundtracks on specific platforms so the 56 user can override particular game music with their own. 57 58 Pass None to stop music. 59 60 if 'continuous' is True and musictype is the same as what is already 61 playing, the playing track will not be restarted. 62 """ 63 64 # All we do here now is set a few music attrs on the current globals 65 # node. The foreground globals' current playing music then gets fed to 66 # the do_play_music call in our music controller. This way we can 67 # seamlessly support custom soundtracks in replays/etc since we're being 68 # driven purely by node data. 69 gnode = _bascenev1.getactivity().globalsnode 70 gnode.music_continuous = continuous 71 gnode.music = '' if musictype is None else musictype.value 72 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 name: str 20 default: Any
Defines a user-controllable setting for a game or other entity.
Tells an object that it should shatter.
170def show_damage_count( 171 damage: str, position: Sequence[float], direction: Sequence[float] 172) -> None: 173 """Pop up a damage count at a position in space.""" 174 lifespan = 1.0 175 app = babase.app 176 177 # FIXME: Should never vary game elements based on local config. 178 # (connected clients may have differing configs so they won't 179 # get the intended results). 180 assert app.classic is not None 181 do_big = app.ui_v1.uiscale is babase.UIScale.SMALL or app.env.vr 182 txtnode = _bascenev1.newnode( 183 'text', 184 attrs={ 185 'text': damage, 186 'in_world': True, 187 'h_align': 'center', 188 'flatness': 1.0, 189 'shadow': 1.0 if do_big else 0.7, 190 'color': (1, 0.25, 0.25, 1), 191 'scale': 0.015 if do_big else 0.01, 192 }, 193 ) 194 # Translate upward. 195 tcombine = _bascenev1.newnode('combine', owner=txtnode, attrs={'size': 3}) 196 tcombine.connectattr('output', txtnode, 'position') 197 v_vals = [] 198 pval = 0.0 199 vval = 0.07 200 count = 6 201 for i in range(count): 202 v_vals.append((float(i) / count, pval)) 203 pval += vval 204 vval *= 0.5 205 p_start = position[0] 206 p_dir = direction[0] 207 animate( 208 tcombine, 209 'input0', 210 {i[0] * lifespan: p_start + p_dir * i[1] for i in v_vals}, 211 ) 212 p_start = position[1] 213 p_dir = direction[1] 214 animate( 215 tcombine, 216 'input1', 217 {i[0] * lifespan: p_start + p_dir * i[1] for i in v_vals}, 218 ) 219 p_start = position[2] 220 p_dir = direction[2] 221 animate( 222 tcombine, 223 'input2', 224 {i[0] * lifespan: p_start + p_dir * i[1] for i in v_vals}, 225 ) 226 animate(txtnode, 'opacity', {0.7 * lifespan: 1.0, lifespan: 0.0}) 227 _bascenev1.timer(lifespan, txtnode.delete)
Pop up a damage count at a position in space.
860class Sound: 861 """A reference to a sound. 862 863 Use bascenev1.getsound() to instantiate one. 864 """ 865 866 def play( 867 self, 868 volume: float = 1.0, 869 position: Sequence[float] | None = None, 870 host_only: bool = False, 871 ) -> None: 872 """Play the sound a single time. 873 874 If position is not provided, the sound will be at a constant volume 875 everywhere. Position should be a float tuple of size 3. 876 """ 877 return None
A reference to a sound.
Use bascenev1.getsound() to instantiate one.
866 def play( 867 self, 868 volume: float = 1.0, 869 position: Sequence[float] | None = None, 870 host_only: bool = False, 871 ) -> None: 872 """Play the sound a single time. 873 874 If position is not provided, the sound will be at a constant volume 875 everywhere. Position should be a float tuple of size 3. 876 """ 877 return None
Play the sound a single time.
If position is not provided, the sound will be at a constant volume everywhere. Position should be a float tuple of size 3.
34@dataclass 35class StandLocation: 36 """Describes a point in space and an angle to face.""" 37 38 position: babase.Vec3 39 angle: float | None = None
Describes a point in space and an angle to face.
118@dataclass 119class StandMessage: 120 """A message telling an object to move to a position in space. 121 122 Used when teleporting players to home base, etc. 123 """ 124 125 position: Sequence[float] = (0.0, 0.0, 0.0) 126 """Where to move to.""" 127 128 angle: float = 0.0 129 """The angle to face (in degrees)"""
A message telling an object to move to a position in space.
Used when teleporting players to home base, etc.
251class Stats: 252 """Manages scores and statistics for a bascenev1.Session.""" 253 254 def __init__(self) -> None: 255 self._activity: weakref.ref[bascenev1.Activity] | None = None 256 self._player_records: dict[str, PlayerRecord] = {} 257 self.orchestrahitsound1: bascenev1.Sound | None = None 258 self.orchestrahitsound2: bascenev1.Sound | None = None 259 self.orchestrahitsound3: bascenev1.Sound | None = None 260 self.orchestrahitsound4: bascenev1.Sound | None = None 261 262 def setactivity(self, activity: bascenev1.Activity | None) -> None: 263 """Set the current activity for this instance.""" 264 265 self._activity = None if activity is None else weakref.ref(activity) 266 267 # Load our media into this activity's context. 268 if activity is not None: 269 if activity.expired: 270 logging.exception('Unexpected finalized activity.') 271 else: 272 with activity.context: 273 self._load_activity_media() 274 275 def getactivity(self) -> bascenev1.Activity | None: 276 """Get the activity associated with this instance. 277 278 May return None. 279 """ 280 if self._activity is None: 281 return None 282 return self._activity() 283 284 def _load_activity_media(self) -> None: 285 self.orchestrahitsound1 = _bascenev1.getsound('orchestraHit') 286 self.orchestrahitsound2 = _bascenev1.getsound('orchestraHit2') 287 self.orchestrahitsound3 = _bascenev1.getsound('orchestraHit3') 288 self.orchestrahitsound4 = _bascenev1.getsound('orchestraHit4') 289 290 def reset(self) -> None: 291 """Reset the stats instance completely.""" 292 293 # Just to be safe, lets make sure no multi-kill timers are gonna go off 294 # for no-longer-on-the-list players. 295 for p_entry in list(self._player_records.values()): 296 p_entry.cancel_multi_kill_timer() 297 self._player_records = {} 298 299 def reset_accum(self) -> None: 300 """Reset per-sound sub-scores.""" 301 for s_player in list(self._player_records.values()): 302 s_player.cancel_multi_kill_timer() 303 s_player.accumscore = 0 304 s_player.accum_kill_count = 0 305 s_player.accum_killed_count = 0 306 s_player.streak = 0 307 308 def register_sessionplayer(self, player: bascenev1.SessionPlayer) -> None: 309 """Register a bascenev1.SessionPlayer with this score-set.""" 310 assert player.exists() # Invalid refs should never be passed to funcs. 311 name = player.getname() 312 if name in self._player_records: 313 # If the player already exists, update his character and such as 314 # it may have changed. 315 self._player_records[name].associate_with_sessionplayer(player) 316 else: 317 name_full = player.getname(full=True) 318 self._player_records[name] = PlayerRecord( 319 name, name_full, player, self 320 ) 321 322 def get_records(self) -> dict[str, bascenev1.PlayerRecord]: 323 """Get PlayerRecord corresponding to still-existing players.""" 324 records = {} 325 326 # Go through our player records and return ones whose player id still 327 # corresponds to a player with that name. 328 for record_id, record in self._player_records.items(): 329 lastplayer = record.get_last_sessionplayer() 330 if lastplayer and lastplayer.getname() == record_id: 331 records[record_id] = record 332 return records 333 334 def player_scored( 335 self, 336 player: bascenev1.Player, 337 base_points: int = 1, 338 *, 339 target: Sequence[float] | None = None, 340 kill: bool = False, 341 victim_player: bascenev1.Player | None = None, 342 scale: float = 1.0, 343 color: Sequence[float] | None = None, 344 title: str | babase.Lstr | None = None, 345 screenmessage: bool = True, 346 display: bool = True, 347 importance: int = 1, 348 showpoints: bool = True, 349 big_message: bool = False, 350 ) -> int: 351 """Register a score for the player. 352 353 Return value is actual score with multipliers and such factored in. 354 """ 355 # FIXME: Tidy this up. 356 # pylint: disable=cyclic-import 357 # pylint: disable=too-many-branches 358 # pylint: disable=too-many-locals 359 from bascenev1lib.actor.popuptext import PopupText 360 361 from bascenev1._gameactivity import GameActivity 362 363 del victim_player # Currently unused. 364 name = player.getname() 365 s_player = self._player_records[name] 366 367 if kill: 368 s_player.submit_kill(showpoints=showpoints) 369 370 display_color: Sequence[float] = (1.0, 1.0, 1.0, 1.0) 371 372 if color is not None: 373 display_color = color 374 elif importance != 1: 375 display_color = (1.0, 1.0, 0.4, 1.0) 376 points = base_points 377 378 # If they want a big announcement, throw a zoom-text up there. 379 if display and big_message: 380 try: 381 assert self._activity is not None 382 activity = self._activity() 383 if isinstance(activity, GameActivity): 384 name_full = player.getname(full=True, icon=False) 385 activity.show_zoom_message( 386 babase.Lstr( 387 resource='nameScoresText', 388 subs=[('${NAME}', name_full)], 389 ), 390 color=babase.normalized_color(player.team.color), 391 ) 392 except Exception: 393 logging.exception('Error showing big_message.') 394 395 # If we currently have a actor, pop up a score over it. 396 if display and showpoints: 397 our_pos = player.node.position if player.node else None 398 if our_pos is not None: 399 if target is None: 400 target = our_pos 401 402 # If display-pos is *way* lower than us, raise it up 403 # (so we can still see scores from dudes that fell off cliffs). 404 display_pos = ( 405 target[0], 406 max(target[1], our_pos[1] - 2.0), 407 min(target[2], our_pos[2] + 2.0), 408 ) 409 activity = self.getactivity() 410 if activity is not None: 411 if title is not None: 412 sval = babase.Lstr( 413 value='+${A} ${B}', 414 subs=[('${A}', str(points)), ('${B}', title)], 415 ) 416 else: 417 sval = babase.Lstr( 418 value='+${A}', subs=[('${A}', str(points))] 419 ) 420 PopupText( 421 sval, 422 color=display_color, 423 scale=1.2 * scale, 424 position=display_pos, 425 ).autoretain() 426 427 # Tally kills. 428 if kill: 429 s_player.accum_kill_count += 1 430 s_player.kill_count += 1 431 432 # Report non-kill scorings. 433 try: 434 if screenmessage and not kill: 435 _bascenev1.broadcastmessage( 436 babase.Lstr( 437 resource='nameScoresText', subs=[('${NAME}', name)] 438 ), 439 top=True, 440 color=player.color, 441 image=player.get_icon(), 442 ) 443 except Exception: 444 logging.exception('Error announcing score.') 445 446 s_player.score += points 447 s_player.accumscore += points 448 449 # Inform a running game of the score. 450 if points != 0: 451 activity = self._activity() if self._activity is not None else None 452 if activity is not None: 453 activity.handlemessage(PlayerScoredMessage(score=points)) 454 455 return points 456 457 def player_was_killed( 458 self, 459 player: bascenev1.Player, 460 killed: bool = False, 461 killer: bascenev1.Player | None = None, 462 ) -> None: 463 """Should be called when a player is killed.""" 464 name = player.getname() 465 prec = self._player_records[name] 466 prec.streak = 0 467 if killed: 468 prec.accum_killed_count += 1 469 prec.killed_count += 1 470 try: 471 if killed and _bascenev1.getactivity().announce_player_deaths: 472 if killer is player: 473 _bascenev1.broadcastmessage( 474 babase.Lstr( 475 resource='nameSuicideText', subs=[('${NAME}', name)] 476 ), 477 top=True, 478 color=player.color, 479 image=player.get_icon(), 480 ) 481 elif killer is not None: 482 if killer.team is player.team: 483 _bascenev1.broadcastmessage( 484 babase.Lstr( 485 resource='nameBetrayedText', 486 subs=[ 487 ('${NAME}', killer.getname()), 488 ('${VICTIM}', name), 489 ], 490 ), 491 top=True, 492 color=killer.color, 493 image=killer.get_icon(), 494 ) 495 else: 496 _bascenev1.broadcastmessage( 497 babase.Lstr( 498 resource='nameKilledText', 499 subs=[ 500 ('${NAME}', killer.getname()), 501 ('${VICTIM}', name), 502 ], 503 ), 504 top=True, 505 color=killer.color, 506 image=killer.get_icon(), 507 ) 508 else: 509 _bascenev1.broadcastmessage( 510 babase.Lstr( 511 resource='nameDiedText', subs=[('${NAME}', name)] 512 ), 513 top=True, 514 color=player.color, 515 image=player.get_icon(), 516 ) 517 except Exception: 518 logging.exception('Error announcing kill.')
Manages scores and statistics for a bascenev1.Session.
262 def setactivity(self, activity: bascenev1.Activity | None) -> None: 263 """Set the current activity for this instance.""" 264 265 self._activity = None if activity is None else weakref.ref(activity) 266 267 # Load our media into this activity's context. 268 if activity is not None: 269 if activity.expired: 270 logging.exception('Unexpected finalized activity.') 271 else: 272 with activity.context: 273 self._load_activity_media()
Set the current activity for this instance.
275 def getactivity(self) -> bascenev1.Activity | None: 276 """Get the activity associated with this instance. 277 278 May return None. 279 """ 280 if self._activity is None: 281 return None 282 return self._activity()
Get the activity associated with this instance.
May return None.
290 def reset(self) -> None: 291 """Reset the stats instance completely.""" 292 293 # Just to be safe, lets make sure no multi-kill timers are gonna go off 294 # for no-longer-on-the-list players. 295 for p_entry in list(self._player_records.values()): 296 p_entry.cancel_multi_kill_timer() 297 self._player_records = {}
Reset the stats instance completely.
299 def reset_accum(self) -> None: 300 """Reset per-sound sub-scores.""" 301 for s_player in list(self._player_records.values()): 302 s_player.cancel_multi_kill_timer() 303 s_player.accumscore = 0 304 s_player.accum_kill_count = 0 305 s_player.accum_killed_count = 0 306 s_player.streak = 0
Reset per-sound sub-scores.
308 def register_sessionplayer(self, player: bascenev1.SessionPlayer) -> None: 309 """Register a bascenev1.SessionPlayer with this score-set.""" 310 assert player.exists() # Invalid refs should never be passed to funcs. 311 name = player.getname() 312 if name in self._player_records: 313 # If the player already exists, update his character and such as 314 # it may have changed. 315 self._player_records[name].associate_with_sessionplayer(player) 316 else: 317 name_full = player.getname(full=True) 318 self._player_records[name] = PlayerRecord( 319 name, name_full, player, self 320 )
Register a bascenev1.SessionPlayer with this score-set.
322 def get_records(self) -> dict[str, bascenev1.PlayerRecord]: 323 """Get PlayerRecord corresponding to still-existing players.""" 324 records = {} 325 326 # Go through our player records and return ones whose player id still 327 # corresponds to a player with that name. 328 for record_id, record in self._player_records.items(): 329 lastplayer = record.get_last_sessionplayer() 330 if lastplayer and lastplayer.getname() == record_id: 331 records[record_id] = record 332 return records
Get PlayerRecord corresponding to still-existing players.
334 def player_scored( 335 self, 336 player: bascenev1.Player, 337 base_points: int = 1, 338 *, 339 target: Sequence[float] | None = None, 340 kill: bool = False, 341 victim_player: bascenev1.Player | None = None, 342 scale: float = 1.0, 343 color: Sequence[float] | None = None, 344 title: str | babase.Lstr | None = None, 345 screenmessage: bool = True, 346 display: bool = True, 347 importance: int = 1, 348 showpoints: bool = True, 349 big_message: bool = False, 350 ) -> int: 351 """Register a score for the player. 352 353 Return value is actual score with multipliers and such factored in. 354 """ 355 # FIXME: Tidy this up. 356 # pylint: disable=cyclic-import 357 # pylint: disable=too-many-branches 358 # pylint: disable=too-many-locals 359 from bascenev1lib.actor.popuptext import PopupText 360 361 from bascenev1._gameactivity import GameActivity 362 363 del victim_player # Currently unused. 364 name = player.getname() 365 s_player = self._player_records[name] 366 367 if kill: 368 s_player.submit_kill(showpoints=showpoints) 369 370 display_color: Sequence[float] = (1.0, 1.0, 1.0, 1.0) 371 372 if color is not None: 373 display_color = color 374 elif importance != 1: 375 display_color = (1.0, 1.0, 0.4, 1.0) 376 points = base_points 377 378 # If they want a big announcement, throw a zoom-text up there. 379 if display and big_message: 380 try: 381 assert self._activity is not None 382 activity = self._activity() 383 if isinstance(activity, GameActivity): 384 name_full = player.getname(full=True, icon=False) 385 activity.show_zoom_message( 386 babase.Lstr( 387 resource='nameScoresText', 388 subs=[('${NAME}', name_full)], 389 ), 390 color=babase.normalized_color(player.team.color), 391 ) 392 except Exception: 393 logging.exception('Error showing big_message.') 394 395 # If we currently have a actor, pop up a score over it. 396 if display and showpoints: 397 our_pos = player.node.position if player.node else None 398 if our_pos is not None: 399 if target is None: 400 target = our_pos 401 402 # If display-pos is *way* lower than us, raise it up 403 # (so we can still see scores from dudes that fell off cliffs). 404 display_pos = ( 405 target[0], 406 max(target[1], our_pos[1] - 2.0), 407 min(target[2], our_pos[2] + 2.0), 408 ) 409 activity = self.getactivity() 410 if activity is not None: 411 if title is not None: 412 sval = babase.Lstr( 413 value='+${A} ${B}', 414 subs=[('${A}', str(points)), ('${B}', title)], 415 ) 416 else: 417 sval = babase.Lstr( 418 value='+${A}', subs=[('${A}', str(points))] 419 ) 420 PopupText( 421 sval, 422 color=display_color, 423 scale=1.2 * scale, 424 position=display_pos, 425 ).autoretain() 426 427 # Tally kills. 428 if kill: 429 s_player.accum_kill_count += 1 430 s_player.kill_count += 1 431 432 # Report non-kill scorings. 433 try: 434 if screenmessage and not kill: 435 _bascenev1.broadcastmessage( 436 babase.Lstr( 437 resource='nameScoresText', subs=[('${NAME}', name)] 438 ), 439 top=True, 440 color=player.color, 441 image=player.get_icon(), 442 ) 443 except Exception: 444 logging.exception('Error announcing score.') 445 446 s_player.score += points 447 s_player.accumscore += points 448 449 # Inform a running game of the score. 450 if points != 0: 451 activity = self._activity() if self._activity is not None else None 452 if activity is not None: 453 activity.handlemessage(PlayerScoredMessage(score=points)) 454 455 return points
Register a score for the player.
Return value is actual score with multipliers and such factored in.
457 def player_was_killed( 458 self, 459 player: bascenev1.Player, 460 killed: bool = False, 461 killer: bascenev1.Player | None = None, 462 ) -> None: 463 """Should be called when a player is killed.""" 464 name = player.getname() 465 prec = self._player_records[name] 466 prec.streak = 0 467 if killed: 468 prec.accum_killed_count += 1 469 prec.killed_count += 1 470 try: 471 if killed and _bascenev1.getactivity().announce_player_deaths: 472 if killer is player: 473 _bascenev1.broadcastmessage( 474 babase.Lstr( 475 resource='nameSuicideText', subs=[('${NAME}', name)] 476 ), 477 top=True, 478 color=player.color, 479 image=player.get_icon(), 480 ) 481 elif killer is not None: 482 if killer.team is player.team: 483 _bascenev1.broadcastmessage( 484 babase.Lstr( 485 resource='nameBetrayedText', 486 subs=[ 487 ('${NAME}', killer.getname()), 488 ('${VICTIM}', name), 489 ], 490 ), 491 top=True, 492 color=killer.color, 493 image=killer.get_icon(), 494 ) 495 else: 496 _bascenev1.broadcastmessage( 497 babase.Lstr( 498 resource='nameKilledText', 499 subs=[ 500 ('${NAME}', killer.getname()), 501 ('${VICTIM}', name), 502 ], 503 ), 504 top=True, 505 color=killer.color, 506 image=killer.get_icon(), 507 ) 508 else: 509 _bascenev1.broadcastmessage( 510 babase.Lstr( 511 resource='nameDiedText', subs=[('${NAME}', name)] 512 ), 513 top=True, 514 color=player.color, 515 image=player.get_icon(), 516 ) 517 except Exception: 518 logging.exception('Error announcing kill.')
Should be called when a player is killed.
339def storagename(suffix: str | None = None) -> str: 340 """Generate a unique name for storing class data in shared places. 341 342 This consists of a leading underscore, the module path at the 343 call site with dots replaced by underscores, the containing class's 344 qualified name, and the provided suffix. When storing data in public 345 places such as 'customdata' dicts, this minimizes the chance of 346 collisions with other similarly named classes. 347 348 Note that this will function even if called in the class definition. 349 350 ##### Examples 351 Generate a unique name for storage purposes: 352 >>> class MyThingie: 353 ... # This will give something like 354 ... # '_mymodule_submodule_mythingie_data'. 355 ... _STORENAME = babase.storagename('data') 356 ... 357 ... # Use that name to store some data in the Activity we were 358 ... # passed. 359 ... def __init__(self, activity): 360 ... activity.customdata[self._STORENAME] = {} 361 """ 362 frame = inspect.currentframe() 363 if frame is None: 364 raise RuntimeError('Cannot get current stack frame.') 365 fback = frame.f_back 366 367 # Note: We need to explicitly clear frame here to avoid a ref-loop 368 # that keeps all function-dicts in the stack alive until the next 369 # full GC cycle (the stack frame refers to this function's dict, 370 # which refers to the stack frame). 371 del frame 372 373 if fback is None: 374 raise RuntimeError('Cannot get parent stack frame.') 375 modulepath = fback.f_globals.get('__name__') 376 if modulepath is None: 377 raise RuntimeError('Cannot get parent stack module path.') 378 assert isinstance(modulepath, str) 379 qualname = fback.f_locals.get('__qualname__') 380 if qualname is not None: 381 assert isinstance(qualname, str) 382 fullpath = f'_{modulepath}_{qualname.lower()}' 383 else: 384 fullpath = f'_{modulepath}' 385 if suffix is not None: 386 fullpath = f'{fullpath}_{suffix}' 387 return fullpath.replace('.', '_')
Generate a unique name for storing class data in shared places.
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 = babase.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[PlayerT]): 76 """A team in a specific bascenev1.Activity. 77 78 These correspond to bascenev1.SessionTeam objects, but are created 79 per activity so that the activity can use its own custom team 80 subclass. 81 """ 82 83 # Defining these types at the class level instead of in __init__ so 84 # that types are introspectable (these are still instance attrs). 85 players: list[PlayerT] 86 id: int 87 name: babase.Lstr | str 88 color: tuple[float, ...] # FIXME: can't we make this fixed length? 89 _sessionteam: weakref.ref[SessionTeam] 90 _expired: bool 91 _postinited: bool 92 _customdata: dict 93 94 # NOTE: avoiding having any __init__() here since it seems to not 95 # get called by default if a dataclass inherits from us. 96 97 def postinit(self, sessionteam: SessionTeam) -> None: 98 """Wire up a newly created SessionTeam. 99 100 (internal) 101 """ 102 103 # Sanity check; if a dataclass is created that inherits from us, 104 # it will define an equality operator by default which will break 105 # internal game logic. So complain loudly if we find one. 106 if type(self).__eq__ is not object.__eq__: 107 raise RuntimeError( 108 f'Team class {type(self)} defines an equality' 109 f' operator (__eq__) which will break internal' 110 f' logic. Please remove it.\n' 111 f'For dataclasses you can do "dataclass(eq=False)"' 112 f' in the class decorator.' 113 ) 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( 125 self, team_id: int, name: babase.Lstr | str, color: tuple[float, ...] 126 ) -> None: 127 """Manually init a team for uses such as bots.""" 128 self.id = team_id 129 self.name = name 130 self.color = color 131 self._customdata = {} 132 self._expired = False 133 self._postinited = True 134 135 @property 136 def customdata(self) -> dict: 137 """Arbitrary values associated with the team. 138 Though it is encouraged that most player values be properly defined 139 on the bascenev1.Team subclass, it may be useful for player-agnostic 140 objects to store values here. This dict is cleared when the team 141 leaves or expires so objects stored here will be disposed of at 142 the expected time, unlike the Team instance itself which may 143 continue to be referenced after it is no longer part of the game. 144 """ 145 assert self._postinited 146 assert not self._expired 147 return self._customdata 148 149 def leave(self) -> None: 150 """Called when the Team leaves a running game. 151 152 (internal) 153 """ 154 assert self._postinited 155 assert not self._expired 156 del self._customdata 157 del self.players 158 159 def expire(self) -> None: 160 """Called when the Team is expiring (due to the Activity expiring). 161 162 (internal) 163 """ 164 assert self._postinited 165 assert not self._expired 166 self._expired = True 167 168 try: 169 self.on_expire() 170 except Exception: 171 logging.exception('Error in on_expire for %s.', self) 172 173 del self._customdata 174 del self.players 175 176 def on_expire(self) -> None: 177 """Can be overridden to handle team expiration.""" 178 179 @property 180 def sessionteam(self) -> SessionTeam: 181 """Return the bascenev1.SessionTeam corresponding to this Team. 182 183 Throws a babase.SessionTeamNotFoundError if there is none. 184 """ 185 assert self._postinited 186 if self._sessionteam is not None: 187 sessionteam = self._sessionteam() 188 if sessionteam is not None: 189 return sessionteam 190 191 raise babase.SessionTeamNotFoundError()
A team in a specific bascenev1.Activity.
These correspond to bascenev1.SessionTeam objects, but are created per activity so that the activity can use its own custom team subclass.
124 def manual_init( 125 self, team_id: int, name: babase.Lstr | str, color: tuple[float, ...] 126 ) -> None: 127 """Manually init a team for uses such as bots.""" 128 self.id = team_id 129 self.name = name 130 self.color = color 131 self._customdata = {} 132 self._expired = False 133 self._postinited = True
Manually init a team for uses such as bots.
135 @property 136 def customdata(self) -> dict: 137 """Arbitrary values associated with the team. 138 Though it is encouraged that most player values be properly defined 139 on the bascenev1.Team subclass, it may be useful for player-agnostic 140 objects to store values here. This dict is cleared when the team 141 leaves or expires so objects stored here will be disposed of at 142 the expected time, unlike the Team instance itself which may 143 continue to be referenced after it is no longer part of the game. 144 """ 145 assert self._postinited 146 assert not self._expired 147 return self._customdata
Arbitrary values associated with the team. Though it is encouraged that most player values be properly defined on the bascenev1.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.
179 @property 180 def sessionteam(self) -> SessionTeam: 181 """Return the bascenev1.SessionTeam corresponding to this Team. 182 183 Throws a babase.SessionTeamNotFoundError if there is none. 184 """ 185 assert self._postinited 186 if self._sessionteam is not None: 187 sessionteam = self._sessionteam() 188 if sessionteam is not None: 189 return sessionteam 190 191 raise babase.SessionTeamNotFoundError()
Return the bascenev1.SessionTeam corresponding to this Team.
Throws a babase.SessionTeamNotFoundError if there is none.
30class TeamGameActivity(GameActivity[PlayerT, TeamT]): 31 """Base class for teams and free-for-all mode games. 32 33 (Free-for-all is essentially just a special case where every 34 bascenev1.Player has their own bascenev1.Team) 35 """ 36 37 @override 38 @classmethod 39 def supports_session_type( 40 cls, sessiontype: type[bascenev1.Session] 41 ) -> bool: 42 """ 43 Class method override; 44 returns True for ba.DualTeamSessions and ba.FreeForAllSessions; 45 False otherwise. 46 """ 47 return issubclass(sessiontype, DualTeamSession) or issubclass( 48 sessiontype, FreeForAllSession 49 ) 50 51 def __init__(self, settings: dict): 52 super().__init__(settings) 53 54 # By default we don't show kill-points in free-for-all sessions. 55 # (there's usually some activity-specific score and we don't 56 # wanna confuse things) 57 if isinstance(self.session, FreeForAllSession): 58 self.show_kill_points = False 59 60 @override 61 def on_transition_in(self) -> None: 62 # pylint: disable=cyclic-import 63 from bascenev1._coopsession import CoopSession 64 from bascenev1lib.actor.controlsguide import ControlsGuide 65 66 super().on_transition_in() 67 68 # On the first game, show the controls UI momentarily. 69 # (unless we're being run in co-op mode, in which case we leave 70 # it up to them) 71 if not isinstance(self.session, CoopSession) and getattr( 72 self, 'show_controls_guide', True 73 ): 74 attrname = '_have_shown_ctrl_help_overlay' 75 if not getattr(self.session, attrname, False): 76 delay = 4.0 77 lifespan = 10.0 78 if self.slow_motion: 79 lifespan *= 0.3 80 ControlsGuide( 81 delay=delay, 82 lifespan=lifespan, 83 scale=0.8, 84 position=(380, 200), 85 bright=True, 86 ).autoretain() 87 setattr(self.session, attrname, True) 88 89 @override 90 def on_begin(self) -> None: 91 super().on_begin() 92 try: 93 # Award a few (classic) achievements. 94 if isinstance(self.session, FreeForAllSession): 95 if len(self.players) >= 2: 96 if babase.app.classic is not None: 97 babase.app.classic.ach.award_local_achievement( 98 'Free Loader' 99 ) 100 elif isinstance(self.session, DualTeamSession): 101 if len(self.players) >= 4: 102 if babase.app.classic is not None: 103 babase.app.classic.ach.award_local_achievement( 104 'Team Player' 105 ) 106 except Exception: 107 logging.exception('Error in on_begin.') 108 109 @override 110 def spawn_player_spaz( 111 self, 112 player: PlayerT, 113 position: Sequence[float] | None = None, 114 angle: float | None = None, 115 ) -> PlayerSpaz: 116 """ 117 Method override; spawns and wires up a standard bascenev1.PlayerSpaz 118 for a bascenev1.Player. 119 120 If position or angle is not supplied, a default will be chosen based 121 on the bascenev1.Player and their bascenev1.Team. 122 """ 123 if position is None: 124 # In teams-mode get our team-start-location. 125 if isinstance(self.session, DualTeamSession): 126 position = self.map.get_start_position(player.team.id) 127 else: 128 # Otherwise do free-for-all spawn locations. 129 position = self.map.get_ffa_start_position(self.players) 130 131 return super().spawn_player_spaz(player, position, angle) 132 133 # FIXME: need to unify these arguments with GameActivity.end() 134 def end( # type: ignore 135 self, 136 results: Any = None, 137 announce_winning_team: bool = True, 138 announce_delay: float = 0.1, 139 force: bool = False, 140 ) -> None: 141 """ 142 End the game and announce the single winning team 143 unless 'announce_winning_team' is False. 144 (for results without a single most-important winner). 145 """ 146 # pylint: disable=arguments-renamed 147 from bascenev1._coopsession import CoopSession 148 from bascenev1._multiteamsession import MultiTeamSession 149 150 # Announce win (but only for the first finish() call) 151 # (also don't announce in co-op sessions; we leave that up to them). 152 session = self.session 153 if not isinstance(session, CoopSession): 154 do_announce = not self.has_ended() 155 super().end(results, delay=2.0 + announce_delay, force=force) 156 157 # Need to do this *after* end end call so that results is valid. 158 assert isinstance(results, GameResults) 159 if do_announce and isinstance(session, MultiTeamSession): 160 session.announce_game_results( 161 self, 162 results, 163 delay=announce_delay, 164 announce_winning_team=announce_winning_team, 165 ) 166 167 # For co-op we just pass this up the chain with a delay added 168 # (in most cases). Team games expect a delay for the announce 169 # portion in teams/ffa mode so this keeps it consistent. 170 else: 171 # don't want delay on restarts.. 172 if ( 173 isinstance(results, dict) 174 and 'outcome' in results 175 and results['outcome'] == 'restart' 176 ): 177 delay = 0.0 178 else: 179 delay = 2.0 180 _bascenev1.timer(0.1, _bascenev1.getsound('boxingBell').play) 181 super().end(results, delay=delay, force=force)
Base class for teams and free-for-all mode games.
(Free-for-all is essentially just a special case where every bascenev1.Player has their own bascenev1.Team)
51 def __init__(self, settings: dict): 52 super().__init__(settings) 53 54 # By default we don't show kill-points in free-for-all sessions. 55 # (there's usually some activity-specific score and we don't 56 # wanna confuse things) 57 if isinstance(self.session, FreeForAllSession): 58 self.show_kill_points = False
Instantiate the Activity.
37 @override 38 @classmethod 39 def supports_session_type( 40 cls, sessiontype: type[bascenev1.Session] 41 ) -> bool: 42 """ 43 Class method override; 44 returns True for ba.DualTeamSessions and ba.FreeForAllSessions; 45 False otherwise. 46 """ 47 return issubclass(sessiontype, DualTeamSession) or issubclass( 48 sessiontype, FreeForAllSession 49 )
Class method override; returns True for ba.DualTeamSessions and ba.FreeForAllSessions; False otherwise.
60 @override 61 def on_transition_in(self) -> None: 62 # pylint: disable=cyclic-import 63 from bascenev1._coopsession import CoopSession 64 from bascenev1lib.actor.controlsguide import ControlsGuide 65 66 super().on_transition_in() 67 68 # On the first game, show the controls UI momentarily. 69 # (unless we're being run in co-op mode, in which case we leave 70 # it up to them) 71 if not isinstance(self.session, CoopSession) and getattr( 72 self, 'show_controls_guide', True 73 ): 74 attrname = '_have_shown_ctrl_help_overlay' 75 if not getattr(self.session, attrname, False): 76 delay = 4.0 77 lifespan = 10.0 78 if self.slow_motion: 79 lifespan *= 0.3 80 ControlsGuide( 81 delay=delay, 82 lifespan=lifespan, 83 scale=0.8, 84 position=(380, 200), 85 bright=True, 86 ).autoretain() 87 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 bascenev1.Activity.on_begin() is called.
89 @override 90 def on_begin(self) -> None: 91 super().on_begin() 92 try: 93 # Award a few (classic) achievements. 94 if isinstance(self.session, FreeForAllSession): 95 if len(self.players) >= 2: 96 if babase.app.classic is not None: 97 babase.app.classic.ach.award_local_achievement( 98 'Free Loader' 99 ) 100 elif isinstance(self.session, DualTeamSession): 101 if len(self.players) >= 4: 102 if babase.app.classic is not None: 103 babase.app.classic.ach.award_local_achievement( 104 'Team Player' 105 ) 106 except Exception: 107 logging.exception('Error in on_begin.')
Called once the previous 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.
109 @override 110 def spawn_player_spaz( 111 self, 112 player: PlayerT, 113 position: Sequence[float] | None = None, 114 angle: float | None = None, 115 ) -> PlayerSpaz: 116 """ 117 Method override; spawns and wires up a standard bascenev1.PlayerSpaz 118 for a bascenev1.Player. 119 120 If position or angle is not supplied, a default will be chosen based 121 on the bascenev1.Player and their bascenev1.Team. 122 """ 123 if position is None: 124 # In teams-mode get our team-start-location. 125 if isinstance(self.session, DualTeamSession): 126 position = self.map.get_start_position(player.team.id) 127 else: 128 # Otherwise do free-for-all spawn locations. 129 position = self.map.get_ffa_start_position(self.players) 130 131 return super().spawn_player_spaz(player, position, angle)
Method override; spawns and wires up a standard bascenev1.PlayerSpaz for a bascenev1.Player.
If position or angle is not supplied, a default will be chosen based on the bascenev1.Player and their bascenev1.Team.
134 def end( # type: ignore 135 self, 136 results: Any = None, 137 announce_winning_team: bool = True, 138 announce_delay: float = 0.1, 139 force: bool = False, 140 ) -> None: 141 """ 142 End the game and announce the single winning team 143 unless 'announce_winning_team' is False. 144 (for results without a single most-important winner). 145 """ 146 # pylint: disable=arguments-renamed 147 from bascenev1._coopsession import CoopSession 148 from bascenev1._multiteamsession import MultiTeamSession 149 150 # Announce win (but only for the first finish() call) 151 # (also don't announce in co-op sessions; we leave that up to them). 152 session = self.session 153 if not isinstance(session, CoopSession): 154 do_announce = not self.has_ended() 155 super().end(results, delay=2.0 + announce_delay, force=force) 156 157 # Need to do this *after* end end call so that results is valid. 158 assert isinstance(results, GameResults) 159 if do_announce and isinstance(session, MultiTeamSession): 160 session.announce_game_results( 161 self, 162 results, 163 delay=announce_delay, 164 announce_winning_team=announce_winning_team, 165 ) 166 167 # For co-op we just pass this up the chain with a delay added 168 # (in most cases). Team games expect a delay for the announce 169 # portion in teams/ffa mode so this keeps it consistent. 170 else: 171 # don't want delay on restarts.. 172 if ( 173 isinstance(results, dict) 174 and 'outcome' in results 175 and results['outcome'] == 'restart' 176 ): 177 delay = 0.0 178 else: 179 delay = 2.0 180 _bascenev1.timer(0.1, _bascenev1.getsound('boxingBell').play) 181 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).
880class Texture: 881 """A reference to a texture. 882 883 Use bascenev1.gettexture() to instantiate one. 884 """ 885 886 pass
A reference to a texture.
Use bascenev1.gettexture() to instantiate one.
Tells an object to stop being frozen.
1676def time() -> bascenev1.Time: 1677 """Return the current scene time in seconds. 1678 1679 Scene time maps to local simulation time in bascenev1.Activity or 1680 bascenev1.Session Contexts. This means that it may progress slower 1681 in slow-motion play modes, stop when the game is paused, etc. 1682 1683 Note that the value returned here is simply a float; it just has a 1684 unique type in the type-checker's eyes to help prevent it from being 1685 accidentally used with time functionality expecting other time types. 1686 """ 1687 import bascenev1 # pylint: disable=cyclic-import 1688 1689 return bascenev1.Time(0.0)
Return the current scene time in seconds.
Scene time maps to local simulation time in bascenev1.Activity or bascenev1.Session Contexts. This means that it may progress slower in slow-motion play modes, stop when the game is paused, etc.
Note that the value returned here is simply a float; it just has a unique type in the type-checker's eyes to help prevent it from being accidentally used with time functionality expecting other time types.
1693def timer(time: float, call: Callable[[], Any], repeat: bool = False) -> None: 1694 """Schedule a call to run at a later point in time. 1695 1696 This function adds a scene-time timer to the current babase.Context. 1697 This timer cannot be canceled or modified once created. If you 1698 require the ability to do so, use the babase.Timer class instead. 1699 1700 Scene time maps to local simulation time in bascenev1.Activity or 1701 bascenev1.Session Contexts. This means that it may progress slower 1702 in slow-motion play modes, stop when the game is paused, etc. 1703 1704 ##### Arguments 1705 ###### time (float) 1706 > Length of scene time in seconds that the timer will wait 1707 before firing. 1708 1709 ###### call (Callable[[], Any]) 1710 > A callable Python object. Note that the timer will retain a 1711 strong reference to the callable for as long as it exists, so you 1712 may want to look into concepts such as babase.WeakCall if that is not 1713 desired. 1714 1715 ###### repeat (bool) 1716 > If True, the timer will fire repeatedly, with each successive 1717 firing having the same delay as the first. 1718 1719 ##### Examples 1720 Print some stuff through time: 1721 >>> import bascenev1 as bs 1722 >>> bs.screenmessage('hello from now!') 1723 >>> bs.timer(1.0, bs.Call(bs.screenmessage, 'hello from the future!')) 1724 >>> bs.timer(2.0, bs.Call(bs.screenmessage, 1725 ... 'hello from the future 2!')) 1726 """ 1727 return None
Schedule a call to run at a later point in time.
This function adds a scene-time timer to the current babase.Context. This timer cannot be canceled or modified once created. If you require the ability to do so, use the babase.Timer class instead.
Scene time maps to local simulation time in bascenev1.Activity or bascenev1.Session Contexts. This means that it may progress slower in slow-motion play modes, stop when the game is paused, etc.
Arguments
time (float)
Length of scene time in seconds that the timer will wait before firing.
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 babase.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.
Examples
Print some stuff through time:
>>> import bascenev1 as bs
>>> bs.screenmessage('hello from now!')
>>> bs.timer(1.0, bs.Call(bs.screenmessage, 'hello from the future!'))
>>> bs.timer(2.0, bs.Call(bs.screenmessage,
... 'hello from the future 2!'))
890class Timer: 891 """Timers are used to run code at later points in time. 892 893 This class encapsulates a scene-time timer in the current 894 bascenev1.Context. The underlying timer will be destroyed when either 895 this object is no longer referenced or when its Context (Activity, 896 etc.) dies. If you do not want to worry about keeping a reference to 897 your timer around, 898 you should use the bs.timer() function instead. 899 900 Scene time maps to local simulation time in bascenev1.Activity or 901 bascenev1.Session Contexts. This means that it may progress slower 902 in slow-motion play modes, stop when the game is paused, etc. 903 904 ###### time 905 > Length of time (in seconds by default) that the timer will wait 906 before firing. Note that the actual delay experienced may vary 907 depending on the timetype. (see below) 908 909 ###### call 910 > A callable Python object. Note that the timer will retain a 911 strong reference to the callable for as long as it exists, so you 912 may want to look into concepts such as babase.WeakCall if that is not 913 desired. 914 915 ###### repeat 916 > If True, the timer will fire repeatedly, with each successive 917 firing having the same delay as the first. 918 919 ##### Example 920 921 Use a Timer object to print repeatedly for a few seconds: 922 >>> import bascenev1 as bs 923 ... def say_it(): 924 ... bs.screenmessage('BADGER!') 925 ... def stop_saying_it(): 926 ... global g_timer 927 ... g_timer = None 928 ... bs.screenmessage('MUSHROOM MUSHROOM!') 929 ... # Create our timer; it will run as long as we have the self.t ref. 930 ... g_timer = bs.Timer(0.3, say_it, repeat=True) 931 ... # Now fire off a one-shot timer to kill it. 932 ... bs.timer(3.89, stop_saying_it) 933 """ 934 935 def __init__( 936 self, time: float, call: Callable[[], Any], repeat: bool = False 937 ) -> None: 938 pass
Timers are used to run code at later points in time.
This class encapsulates a scene-time timer in the current bascenev1.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 bs.timer() function instead.
Scene time maps to local simulation time in bascenev1.Activity or bascenev1.Session Contexts. This means that it may progress slower in slow-motion play modes, stop when the game is paused, etc.
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 babase.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.
Example
Use a Timer object to print repeatedly for a few seconds:
>>> import bascenev1 as bs
... def say_it():
... bs.screenmessage('BADGER!')
... def stop_saying_it():
... global g_timer
... g_timer = None
... bs.screenmessage('MUSHROOM MUSHROOM!')
... # Create our timer; it will run as long as we have the self.t ref.
... g_timer = bs.Timer(0.3, say_it, repeat=True)
... # Now fire off a one-shot timer to kill it.
... bs.timer(3.89, stop_saying_it)
15def timestring( 16 timeval: float | int, 17 centi: bool = True, 18) -> babase.Lstr: 19 """Generate a babase.Lstr for displaying a time value. 20 21 Given a time value, returns a babase.Lstr with: 22 (hours if > 0 ) : minutes : seconds : (centiseconds if centi=True). 23 24 WARNING: the underlying Lstr value is somewhat large so don't use this 25 to rapidly update Node text values for an onscreen timer or you may 26 consume significant network bandwidth. For that purpose you should 27 use a 'timedisplay' Node and attribute connections. 28 29 """ 30 from babase._language import Lstr 31 32 # We take float seconds but operate on int milliseconds internally. 33 timeval = int(1000 * timeval) 34 bits = [] 35 subs = [] 36 hval = (timeval // 1000) // (60 * 60) 37 if hval != 0: 38 bits.append('${H}') 39 subs.append( 40 ( 41 '${H}', 42 Lstr( 43 resource='timeSuffixHoursText', 44 subs=[('${COUNT}', str(hval))], 45 ), 46 ) 47 ) 48 mval = ((timeval // 1000) // 60) % 60 49 if mval != 0: 50 bits.append('${M}') 51 subs.append( 52 ( 53 '${M}', 54 Lstr( 55 resource='timeSuffixMinutesText', 56 subs=[('${COUNT}', str(mval))], 57 ), 58 ) 59 ) 60 61 # We add seconds if its non-zero *or* we haven't added anything else. 62 if centi: 63 # pylint: disable=consider-using-f-string 64 sval = timeval / 1000.0 % 60.0 65 if sval >= 0.005 or not bits: 66 bits.append('${S}') 67 subs.append( 68 ( 69 '${S}', 70 Lstr( 71 resource='timeSuffixSecondsText', 72 subs=[('${COUNT}', ('%.2f' % sval))], 73 ), 74 ) 75 ) 76 else: 77 sval = timeval // 1000 % 60 78 if sval != 0 or not bits: 79 bits.append('${S}') 80 subs.append( 81 ( 82 '${S}', 83 Lstr( 84 resource='timeSuffixSecondsText', 85 subs=[('${COUNT}', str(sval))], 86 ), 87 ) 88 ) 89 return Lstr(value=' '.join(bits), subs=subs)
Generate a babase.Lstr for displaying a time value.
Given a time value, returns a babase.Lstr with: (hours if > 0 ) : minutes : seconds : (centiseconds if centi=True).
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.
59class UIScale(Enum): 60 """The overall scale the UI is being rendered for. Note that this is 61 independent of pixel resolution. For example, a phone and a desktop PC 62 might render the game at similar pixel resolutions but the size they 63 display content at will vary significantly. 64 65 'large' is used for devices such as desktop PCs where fine details can 66 be clearly seen. UI elements are generally smaller on the screen 67 and more content can be seen at once. 68 69 'medium' is used for devices such as tablets, TVs, or VR headsets. 70 This mode strikes a balance between clean readability and amount of 71 content visible. 72 73 'small' is used primarily for phones or other small devices where 74 content needs to be presented as large and clear in order to remain 75 readable from an average distance. 76 """ 77 78 SMALL = 0 79 MEDIUM = 1 80 LARGE = 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.
'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.
388class Vec3(Sequence[float]): 389 """A vector of 3 floats. 390 391 These can be created the following ways (checked in this order): 392 - with no args, all values are set to 0 393 - with a single numeric arg, all values are set to that value 394 - with a single three-member sequence arg, sequence values are copied 395 - otherwise assumes individual x/y/z args (positional or keywords) 396 """ 397 398 x: float 399 """The vector's X component.""" 400 401 y: float 402 """The vector's Y component.""" 403 404 z: float 405 """The vector's Z component.""" 406 407 # pylint: disable=function-redefined 408 409 @overload 410 def __init__(self) -> None: 411 pass 412 413 @overload 414 def __init__(self, value: float): 415 pass 416 417 @overload 418 def __init__(self, values: Sequence[float]): 419 pass 420 421 @overload 422 def __init__(self, x: float, y: float, z: float): 423 pass 424 425 def __init__(self, *args: Any, **kwds: Any): 426 pass 427 428 def __add__(self, other: Vec3) -> Vec3: 429 return self 430 431 def __sub__(self, other: Vec3) -> Vec3: 432 return self 433 434 @overload 435 def __mul__(self, other: float) -> Vec3: 436 return self 437 438 @overload 439 def __mul__(self, other: Sequence[float]) -> Vec3: 440 return self 441 442 def __mul__(self, other: Any) -> Any: 443 return self 444 445 @overload 446 def __rmul__(self, other: float) -> Vec3: 447 return self 448 449 @overload 450 def __rmul__(self, other: Sequence[float]) -> Vec3: 451 return self 452 453 def __rmul__(self, other: Any) -> Any: 454 return self 455 456 # (for index access) 457 @override 458 def __getitem__(self, typeargs: Any) -> Any: 459 return 0.0 460 461 @override 462 def __len__(self) -> int: 463 return 3 464 465 # (for iterator access) 466 @override 467 def __iter__(self) -> Any: 468 return self 469 470 def __next__(self) -> float: 471 return 0.0 472 473 def __neg__(self) -> Vec3: 474 return self 475 476 def __setitem__(self, index: int, val: float) -> None: 477 pass 478 479 def cross(self, other: Vec3) -> Vec3: 480 """Returns the cross product of this vector and another.""" 481 return Vec3() 482 483 def dot(self, other: Vec3) -> float: 484 """Returns the dot product of this vector and another.""" 485 return float() 486 487 def length(self) -> float: 488 """Returns the length of the vector.""" 489 return float() 490 491 def normalized(self) -> Vec3: 492 """Returns a normalized version of the vector.""" 493 return Vec3()
A vector of 3 floats.
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)
479 def cross(self, other: Vec3) -> Vec3: 480 """Returns the cross product of this vector and another.""" 481 return Vec3()
Returns the cross product of this vector and another.
483 def dot(self, other: Vec3) -> float: 484 """Returns the dot product of this vector and another.""" 485 return float()
Returns the dot product of this vector and another.