bascenev1lib.game.capturetheflag
Defines a capture-the-flag game.
1# Released under the MIT License. See LICENSE for details. 2# 3"""Defines a capture-the-flag game.""" 4 5# ba_meta require api 9 6# (see https://ballistica.net/wiki/meta-tag-system) 7 8from __future__ import annotations 9 10import logging 11from typing import TYPE_CHECKING, override 12 13import bascenev1 as bs 14 15from bascenev1lib.actor.playerspaz import PlayerSpaz 16from bascenev1lib.actor.scoreboard import Scoreboard 17from bascenev1lib.actor.flag import ( 18 FlagFactory, 19 Flag, 20 FlagPickedUpMessage, 21 FlagDroppedMessage, 22 FlagDiedMessage, 23) 24 25if TYPE_CHECKING: 26 from typing import Any, Sequence 27 28 29class CTFFlag(Flag): 30 """Special flag type for CTF games.""" 31 32 activity: CaptureTheFlagGame 33 34 def __init__(self, team: Team): 35 36 assert team.flagmaterial is not None 37 super().__init__( 38 materials=[team.flagmaterial], 39 position=team.base_pos, 40 color=team.color, 41 ) 42 self._team = team 43 self.held_count = 0 44 self.counter = bs.newnode( 45 'text', 46 owner=self.node, 47 attrs={'in_world': True, 'scale': 0.02, 'h_align': 'center'}, 48 ) 49 self.reset_return_times() 50 self.last_player_to_hold: Player | None = None 51 self.time_out_respawn_time: int | None = None 52 self.touch_return_time: float | None = None 53 54 def reset_return_times(self) -> None: 55 """Clear flag related times in the activity.""" 56 self.time_out_respawn_time = int(self.activity.flag_idle_return_time) 57 self.touch_return_time = float(self.activity.flag_touch_return_time) 58 59 @property 60 def team(self) -> Team: 61 """The flag's team.""" 62 return self._team 63 64 65class Player(bs.Player['Team']): 66 """Our player type for this game.""" 67 68 def __init__(self) -> None: 69 self.touching_own_flag = 0 70 71 72class Team(bs.Team[Player]): 73 """Our team type for this game.""" 74 75 def __init__( 76 self, 77 *, 78 base_pos: Sequence[float], 79 base_region_material: bs.Material, 80 base_region: bs.Node, 81 spaz_material_no_flag_physical: bs.Material, 82 spaz_material_no_flag_collide: bs.Material, 83 flagmaterial: bs.Material, 84 ): 85 self.base_pos = base_pos 86 self.base_region_material = base_region_material 87 self.base_region = base_region 88 self.spaz_material_no_flag_physical = spaz_material_no_flag_physical 89 self.spaz_material_no_flag_collide = spaz_material_no_flag_collide 90 self.flagmaterial = flagmaterial 91 self.score = 0 92 self.flag_return_touches = 0 93 self.home_flag_at_base = True 94 self.touch_return_timer: bs.Timer | None = None 95 self.enemy_flag_at_base = False 96 self.flag: CTFFlag | None = None 97 self.last_flag_leave_time: float | None = None 98 self.touch_return_timer_ticking: bs.NodeActor | None = None 99 100 101# ba_meta export bascenev1.GameActivity 102class CaptureTheFlagGame(bs.TeamGameActivity[Player, Team]): 103 """Game of stealing other team's flag and returning it to your base.""" 104 105 name = 'Capture the Flag' 106 description = 'Return the enemy flag to score.' 107 available_settings = [ 108 bs.IntSetting('Score to Win', min_value=1, default=3), 109 bs.IntSetting( 110 'Flag Touch Return Time', 111 min_value=0, 112 default=0, 113 increment=1, 114 ), 115 bs.IntSetting( 116 'Flag Idle Return Time', 117 min_value=5, 118 default=30, 119 increment=5, 120 ), 121 bs.IntChoiceSetting( 122 'Time Limit', 123 choices=[ 124 ('None', 0), 125 ('1 Minute', 60), 126 ('2 Minutes', 120), 127 ('5 Minutes', 300), 128 ('10 Minutes', 600), 129 ('20 Minutes', 1200), 130 ], 131 default=0, 132 ), 133 bs.FloatChoiceSetting( 134 'Respawn Times', 135 choices=[ 136 ('Shorter', 0.25), 137 ('Short', 0.5), 138 ('Normal', 1.0), 139 ('Long', 2.0), 140 ('Longer', 4.0), 141 ], 142 default=1.0, 143 ), 144 bs.BoolSetting('Epic Mode', default=False), 145 ] 146 147 @override 148 @classmethod 149 def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool: 150 return issubclass(sessiontype, bs.DualTeamSession) 151 152 @override 153 @classmethod 154 def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]: 155 assert bs.app.classic is not None 156 return bs.app.classic.getmaps('team_flag') 157 158 def __init__(self, settings: dict): 159 super().__init__(settings) 160 self._scoreboard = Scoreboard() 161 self._alarmsound = bs.getsound('alarm') 162 self._ticking_sound = bs.getsound('ticking') 163 self._score_sound = bs.getsound('score') 164 self._swipsound = bs.getsound('swip') 165 self._last_score_time = 0 166 self._all_bases_material = bs.Material() 167 self._last_home_flag_notice_print_time = 0.0 168 self._score_to_win = int(settings['Score to Win']) 169 self._epic_mode = bool(settings['Epic Mode']) 170 self._time_limit = float(settings['Time Limit']) 171 172 self.flag_touch_return_time = float(settings['Flag Touch Return Time']) 173 self.flag_idle_return_time = float(settings['Flag Idle Return Time']) 174 175 # Base class overrides. 176 self.slow_motion = self._epic_mode 177 self.default_music = ( 178 bs.MusicType.EPIC if self._epic_mode else bs.MusicType.FLAG_CATCHER 179 ) 180 181 @override 182 def get_instance_description(self) -> str | Sequence: 183 if self._score_to_win == 1: 184 return 'Steal the enemy flag.' 185 return 'Steal the enemy flag ${ARG1} times.', self._score_to_win 186 187 @override 188 def get_instance_description_short(self) -> str | Sequence: 189 if self._score_to_win == 1: 190 return 'return 1 flag' 191 return 'return ${ARG1} flags', self._score_to_win 192 193 @override 194 def create_team(self, sessionteam: bs.SessionTeam) -> Team: 195 # Create our team instance and its initial values. 196 197 base_pos = self.map.get_flag_position(sessionteam.id) 198 Flag.project_stand(base_pos) 199 200 bs.newnode( 201 'light', 202 attrs={ 203 'position': base_pos, 204 'intensity': 0.6, 205 'height_attenuated': False, 206 'volume_intensity_scale': 0.1, 207 'radius': 0.1, 208 'color': sessionteam.color, 209 }, 210 ) 211 212 base_region_mat = bs.Material() 213 pos = base_pos 214 base_region = bs.newnode( 215 'region', 216 attrs={ 217 'position': (pos[0], pos[1] + 0.75, pos[2]), 218 'scale': (0.5, 0.5, 0.5), 219 'type': 'sphere', 220 'materials': [base_region_mat, self._all_bases_material], 221 }, 222 ) 223 224 spaz_mat_no_flag_physical = bs.Material() 225 spaz_mat_no_flag_collide = bs.Material() 226 flagmat = bs.Material() 227 228 team = Team( 229 base_pos=base_pos, 230 base_region_material=base_region_mat, 231 base_region=base_region, 232 spaz_material_no_flag_physical=spaz_mat_no_flag_physical, 233 spaz_material_no_flag_collide=spaz_mat_no_flag_collide, 234 flagmaterial=flagmat, 235 ) 236 237 # Some parts of our spazzes don't collide physically with our 238 # flags but generate callbacks. 239 spaz_mat_no_flag_physical.add_actions( 240 conditions=('they_have_material', flagmat), 241 actions=( 242 ('modify_part_collision', 'physical', False), 243 ( 244 'call', 245 'at_connect', 246 lambda: self._handle_touching_own_flag(team, True), 247 ), 248 ( 249 'call', 250 'at_disconnect', 251 lambda: self._handle_touching_own_flag(team, False), 252 ), 253 ), 254 ) 255 256 # Other parts of our spazzes don't collide with our flags at all. 257 spaz_mat_no_flag_collide.add_actions( 258 conditions=('they_have_material', flagmat), 259 actions=('modify_part_collision', 'collide', False), 260 ) 261 262 # We wanna know when *any* flag enters/leaves our base. 263 base_region_mat.add_actions( 264 conditions=('they_have_material', FlagFactory.get().flagmaterial), 265 actions=( 266 ('modify_part_collision', 'collide', True), 267 ('modify_part_collision', 'physical', False), 268 ( 269 'call', 270 'at_connect', 271 lambda: self._handle_flag_entered_base(team), 272 ), 273 ( 274 'call', 275 'at_disconnect', 276 lambda: self._handle_flag_left_base(team), 277 ), 278 ), 279 ) 280 281 return team 282 283 @override 284 def on_team_join(self, team: Team) -> None: 285 # Can't do this in create_team because the team's color/etc. have 286 # not been wired up yet at that point. 287 self._spawn_flag_for_team(team) 288 self._update_scoreboard() 289 290 @override 291 def on_begin(self) -> None: 292 super().on_begin() 293 self.setup_standard_time_limit(self._time_limit) 294 self.setup_standard_powerup_drops() 295 bs.timer(1.0, call=self._tick, repeat=True) 296 297 def _spawn_flag_for_team(self, team: Team) -> None: 298 team.flag = CTFFlag(team) 299 team.flag_return_touches = 0 300 self._flash_base(team, length=1.0) 301 assert team.flag.node 302 self._swipsound.play(position=team.flag.node.position) 303 304 def _handle_flag_entered_base(self, team: Team) -> None: 305 try: 306 flag = bs.getcollision().opposingnode.getdelegate(CTFFlag, True) 307 except bs.NotFoundError: 308 # Don't think this should logically ever happen. 309 print('Error getting CTFFlag in entering-base callback.') 310 return 311 312 if flag.team is team: 313 team.home_flag_at_base = True 314 315 # If the enemy flag is already here, score! 316 if team.enemy_flag_at_base: 317 # And show team name which scored (but actually we could 318 # show here player who returned enemy flag). 319 self.show_zoom_message( 320 bs.Lstr( 321 resource='nameScoresText', subs=[('${NAME}', team.name)] 322 ), 323 color=team.color, 324 ) 325 self._score(team) 326 else: 327 team.enemy_flag_at_base = True 328 if team.home_flag_at_base: 329 # Award points to whoever was carrying the enemy flag. 330 player = flag.last_player_to_hold 331 if player and player.team is team: 332 self.stats.player_scored(player, 50, big_message=True) 333 334 # Update score and reset flags. 335 self._score(team) 336 337 # If the home-team flag isn't here, print a message to that effect. 338 else: 339 # Don't want slo-mo affecting this 340 curtime = bs.basetime() 341 if curtime - self._last_home_flag_notice_print_time > 5.0: 342 self._last_home_flag_notice_print_time = curtime 343 bpos = team.base_pos 344 tval = bs.Lstr(resource='ownFlagAtYourBaseWarning') 345 tnode = bs.newnode( 346 'text', 347 attrs={ 348 'text': tval, 349 'in_world': True, 350 'scale': 0.013, 351 'color': (1, 1, 0, 1), 352 'h_align': 'center', 353 'position': (bpos[0], bpos[1] + 3.2, bpos[2]), 354 }, 355 ) 356 bs.timer(5.1, tnode.delete) 357 bs.animate( 358 tnode, 'scale', {0.0: 0, 0.2: 0.013, 4.8: 0.013, 5.0: 0} 359 ) 360 361 def _tick(self) -> None: 362 # If either flag is away from base and not being held, tick down its 363 # respawn timer. 364 for team in self.teams: 365 flag = team.flag 366 assert flag is not None 367 368 if not team.home_flag_at_base and flag.held_count == 0: 369 time_out_counting_down = True 370 if flag.time_out_respawn_time is None: 371 flag.reset_return_times() 372 assert flag.time_out_respawn_time is not None 373 flag.time_out_respawn_time -= 1 374 if flag.time_out_respawn_time <= 0: 375 flag.handlemessage(bs.DieMessage()) 376 else: 377 time_out_counting_down = False 378 379 if flag.node and flag.counter: 380 pos = flag.node.position 381 flag.counter.position = (pos[0], pos[1] + 1.3, pos[2]) 382 383 # If there's no self-touches on this flag, set its text 384 # to show its auto-return counter. (if there's self-touches 385 # its showing that time). 386 if team.flag_return_touches == 0: 387 flag.counter.text = ( 388 str(flag.time_out_respawn_time) 389 if ( 390 time_out_counting_down 391 and flag.time_out_respawn_time is not None 392 and flag.time_out_respawn_time <= 10 393 ) 394 else '' 395 ) 396 flag.counter.color = (1, 1, 1, 0.5) 397 flag.counter.scale = 0.014 398 399 def _score(self, team: Team) -> None: 400 team.score += 1 401 self._score_sound.play() 402 self._flash_base(team) 403 self._update_scoreboard() 404 405 # Have teammates celebrate. 406 for player in team.players: 407 if player.actor: 408 player.actor.handlemessage(bs.CelebrateMessage(2.0)) 409 410 # Reset all flags/state. 411 for reset_team in self.teams: 412 if not reset_team.home_flag_at_base: 413 assert reset_team.flag is not None 414 reset_team.flag.handlemessage(bs.DieMessage()) 415 reset_team.enemy_flag_at_base = False 416 if team.score >= self._score_to_win: 417 self.end_game() 418 419 @override 420 def end_game(self) -> None: 421 results = bs.GameResults() 422 for team in self.teams: 423 results.set_team_score(team, team.score) 424 self.end(results=results, announce_delay=0.8) 425 426 def _handle_flag_left_base(self, team: Team) -> None: 427 cur_time = bs.time() 428 try: 429 flag = bs.getcollision().opposingnode.getdelegate(CTFFlag, True) 430 except bs.NotFoundError: 431 # This can happen if the flag stops touching us due to being 432 # deleted; that's ok. 433 return 434 435 if flag.team is team: 436 # Check times here to prevent too much flashing. 437 if ( 438 team.last_flag_leave_time is None 439 or cur_time - team.last_flag_leave_time > 3.0 440 ): 441 self._alarmsound.play(position=team.base_pos) 442 self._flash_base(team) 443 team.last_flag_leave_time = cur_time 444 team.home_flag_at_base = False 445 else: 446 team.enemy_flag_at_base = False 447 448 def _touch_return_update(self, team: Team) -> None: 449 # Count down only while its away from base and not being held. 450 assert team.flag is not None 451 if team.home_flag_at_base or team.flag.held_count > 0: 452 team.touch_return_timer_ticking = None 453 return # No need to return when its at home. 454 if team.touch_return_timer_ticking is None: 455 team.touch_return_timer_ticking = bs.NodeActor( 456 bs.newnode( 457 'sound', 458 attrs={ 459 'sound': self._ticking_sound, 460 'positional': False, 461 'loop': True, 462 }, 463 ) 464 ) 465 flag = team.flag 466 if flag.touch_return_time is not None: 467 flag.touch_return_time -= 0.1 468 if flag.counter: 469 flag.counter.text = f'{flag.touch_return_time:.1f}' 470 flag.counter.color = (1, 1, 0, 1) 471 flag.counter.scale = 0.02 472 473 if flag.touch_return_time <= 0.0: 474 self._award_players_touching_own_flag(team) 475 flag.handlemessage(bs.DieMessage()) 476 477 def _award_players_touching_own_flag(self, team: Team) -> None: 478 for player in team.players: 479 if player.touching_own_flag > 0: 480 return_score = 10 + 5 * int(self.flag_touch_return_time) 481 self.stats.player_scored( 482 player, return_score, screenmessage=False 483 ) 484 485 def _handle_touching_own_flag(self, team: Team, connecting: bool) -> None: 486 """Called when a player touches or stops touching their own team flag. 487 488 We keep track of when each player is touching their own flag so we 489 can award points when returned. 490 """ 491 player: Player | None 492 try: 493 spaz = bs.getcollision().sourcenode.getdelegate(PlayerSpaz, True) 494 except bs.NotFoundError: 495 return 496 497 player = spaz.getplayer(Player, True) 498 499 if player: 500 player.touching_own_flag += 1 if connecting else -1 501 502 # If return-time is zero, just kill it immediately.. otherwise keep 503 # track of touches and count down. 504 if float(self.flag_touch_return_time) <= 0.0: 505 assert team.flag is not None 506 if ( 507 connecting 508 and not team.home_flag_at_base 509 and team.flag.held_count == 0 510 ): 511 self._award_players_touching_own_flag(team) 512 bs.getcollision().opposingnode.handlemessage(bs.DieMessage()) 513 514 # Takes a non-zero amount of time to return. 515 else: 516 if connecting: 517 team.flag_return_touches += 1 518 if team.flag_return_touches == 1: 519 team.touch_return_timer = bs.Timer( 520 0.1, 521 call=bs.Call(self._touch_return_update, team), 522 repeat=True, 523 ) 524 team.touch_return_timer_ticking = None 525 else: 526 team.flag_return_touches -= 1 527 if team.flag_return_touches == 0: 528 team.touch_return_timer = None 529 team.touch_return_timer_ticking = None 530 if team.flag_return_touches < 0: 531 logging.error('CTF flag_return_touches < 0', stack_info=True) 532 533 def _handle_death_flag_capture(self, player: Player) -> None: 534 """Handles flag values when a player dies or leaves the game.""" 535 # Don't do anything if the player hasn't touched the flag at all. 536 if not player.touching_own_flag: 537 return 538 539 team = player.team 540 541 # For each "point" our player has touched theflag (Could be 542 # multiple), deduct one from both our player and the flag's 543 # return touches variable. 544 for _ in range(player.touching_own_flag): 545 # Deduct 546 player.touching_own_flag -= 1 547 548 # (This was only incremented if we have non-zero 549 # return-times). 550 if float(self.flag_touch_return_time) > 0.0: 551 team.flag_return_touches -= 1 552 # Update our flag's timer accordingly 553 # (Prevents immediate resets in case 554 # there might be more people touching it). 555 if team.flag_return_touches == 0: 556 team.touch_return_timer = None 557 team.touch_return_timer_ticking = None 558 # Safety check, just to be sure! 559 if team.flag_return_touches < 0: 560 logging.error( 561 'CTF flag_return_touches < 0', stack_info=True 562 ) 563 564 def _flash_base(self, team: Team, length: float = 2.0) -> None: 565 light = bs.newnode( 566 'light', 567 attrs={ 568 'position': team.base_pos, 569 'height_attenuated': False, 570 'radius': 0.3, 571 'color': team.color, 572 }, 573 ) 574 bs.animate(light, 'intensity', {0.0: 0, 0.25: 2.0, 0.5: 0}, loop=True) 575 bs.timer(length, light.delete) 576 577 @override 578 def spawn_player_spaz( 579 self, 580 player: Player, 581 position: Sequence[float] | None = None, 582 angle: float | None = None, 583 ) -> PlayerSpaz: 584 """Intercept new spazzes and add our team material for them.""" 585 spaz = super().spawn_player_spaz(player, position, angle) 586 player = spaz.getplayer(Player, True) 587 team: Team = player.team 588 player.touching_own_flag = 0 589 no_physical_mats: list[bs.Material] = [ 590 team.spaz_material_no_flag_physical 591 ] 592 no_collide_mats: list[bs.Material] = [ 593 team.spaz_material_no_flag_collide 594 ] 595 596 # Our normal parts should still collide; just not physically 597 # (so we can calc restores). 598 assert spaz.node 599 spaz.node.materials = list(spaz.node.materials) + no_physical_mats 600 spaz.node.roller_materials = ( 601 list(spaz.node.roller_materials) + no_physical_mats 602 ) 603 604 # Pickups and punches shouldn't hit at all though. 605 spaz.node.punch_materials = ( 606 list(spaz.node.punch_materials) + no_collide_mats 607 ) 608 spaz.node.pickup_materials = ( 609 list(spaz.node.pickup_materials) + no_collide_mats 610 ) 611 spaz.node.extras_material = ( 612 list(spaz.node.extras_material) + no_collide_mats 613 ) 614 return spaz 615 616 def _update_scoreboard(self) -> None: 617 for team in self.teams: 618 self._scoreboard.set_team_value( 619 team, team.score, self._score_to_win 620 ) 621 622 @override 623 def handlemessage(self, msg: Any) -> Any: 624 if isinstance(msg, bs.PlayerDiedMessage): 625 super().handlemessage(msg) # Augment standard behavior. 626 self._handle_death_flag_capture(msg.getplayer(Player)) 627 self.respawn_player(msg.getplayer(Player)) 628 629 elif isinstance(msg, FlagDiedMessage): 630 assert isinstance(msg.flag, CTFFlag) 631 bs.timer(0.1, bs.Call(self._spawn_flag_for_team, msg.flag.team)) 632 633 elif isinstance(msg, FlagPickedUpMessage): 634 # Store the last player to hold the flag for scoring purposes. 635 assert isinstance(msg.flag, CTFFlag) 636 try: 637 msg.flag.last_player_to_hold = msg.node.getdelegate( 638 PlayerSpaz, True 639 ).getplayer(Player, True) 640 except bs.NotFoundError: 641 pass 642 643 msg.flag.held_count += 1 644 msg.flag.reset_return_times() 645 646 elif isinstance(msg, FlagDroppedMessage): 647 # Store the last player to hold the flag for scoring purposes. 648 assert isinstance(msg.flag, CTFFlag) 649 msg.flag.held_count -= 1 650 651 else: 652 super().handlemessage(msg) 653 654 @override 655 def on_player_leave(self, player: Player) -> None: 656 """Prevents leaving players from capturing their flag.""" 657 self._handle_death_flag_capture(player)
30class CTFFlag(Flag): 31 """Special flag type for CTF games.""" 32 33 activity: CaptureTheFlagGame 34 35 def __init__(self, team: Team): 36 37 assert team.flagmaterial is not None 38 super().__init__( 39 materials=[team.flagmaterial], 40 position=team.base_pos, 41 color=team.color, 42 ) 43 self._team = team 44 self.held_count = 0 45 self.counter = bs.newnode( 46 'text', 47 owner=self.node, 48 attrs={'in_world': True, 'scale': 0.02, 'h_align': 'center'}, 49 ) 50 self.reset_return_times() 51 self.last_player_to_hold: Player | None = None 52 self.time_out_respawn_time: int | None = None 53 self.touch_return_time: float | None = None 54 55 def reset_return_times(self) -> None: 56 """Clear flag related times in the activity.""" 57 self.time_out_respawn_time = int(self.activity.flag_idle_return_time) 58 self.touch_return_time = float(self.activity.flag_touch_return_time) 59 60 @property 61 def team(self) -> Team: 62 """The flag's team.""" 63 return self._team
Special flag type for CTF games.
35 def __init__(self, team: Team): 36 37 assert team.flagmaterial is not None 38 super().__init__( 39 materials=[team.flagmaterial], 40 position=team.base_pos, 41 color=team.color, 42 ) 43 self._team = team 44 self.held_count = 0 45 self.counter = bs.newnode( 46 'text', 47 owner=self.node, 48 attrs={'in_world': True, 'scale': 0.02, 'h_align': 'center'}, 49 ) 50 self.reset_return_times() 51 self.last_player_to_hold: Player | None = None 52 self.time_out_respawn_time: int | None = None 53 self.touch_return_time: float | None = None
Instantiate a flag.
If 'touchable' is False, the flag will only touch terrain; useful for things like king-of-the-hill where players should not be moving the flag around.
'materials can be a list of extra bs.Material
s to apply to the flag.
If 'dropped_timeout' is provided (in seconds), the flag will die after remaining untouched for that long once it has been moved from its initial position.
187 @property 188 def activity(self) -> bascenev1.Activity: 189 """The Activity this Actor was created in. 190 191 Raises a bascenev1.ActivityNotFoundError if the Activity no longer 192 exists. 193 """ 194 activity = self._activity() 195 if activity is None: 196 raise babase.ActivityNotFoundError() 197 return activity
The Activity this Actor was created in.
Raises a bascenev1.ActivityNotFoundError if the Activity no longer exists.
55 def reset_return_times(self) -> None: 56 """Clear flag related times in the activity.""" 57 self.time_out_respawn_time = int(self.activity.flag_idle_return_time) 58 self.touch_return_time = float(self.activity.flag_touch_return_time)
Clear flag related times in the activity.
Inherited Members
66class Player(bs.Player['Team']): 67 """Our player type for this game.""" 68 69 def __init__(self) -> None: 70 self.touching_own_flag = 0
Our player type for this game.
73class Team(bs.Team[Player]): 74 """Our team type for this game.""" 75 76 def __init__( 77 self, 78 *, 79 base_pos: Sequence[float], 80 base_region_material: bs.Material, 81 base_region: bs.Node, 82 spaz_material_no_flag_physical: bs.Material, 83 spaz_material_no_flag_collide: bs.Material, 84 flagmaterial: bs.Material, 85 ): 86 self.base_pos = base_pos 87 self.base_region_material = base_region_material 88 self.base_region = base_region 89 self.spaz_material_no_flag_physical = spaz_material_no_flag_physical 90 self.spaz_material_no_flag_collide = spaz_material_no_flag_collide 91 self.flagmaterial = flagmaterial 92 self.score = 0 93 self.flag_return_touches = 0 94 self.home_flag_at_base = True 95 self.touch_return_timer: bs.Timer | None = None 96 self.enemy_flag_at_base = False 97 self.flag: CTFFlag | None = None 98 self.last_flag_leave_time: float | None = None 99 self.touch_return_timer_ticking: bs.NodeActor | None = None
Our team type for this game.
76 def __init__( 77 self, 78 *, 79 base_pos: Sequence[float], 80 base_region_material: bs.Material, 81 base_region: bs.Node, 82 spaz_material_no_flag_physical: bs.Material, 83 spaz_material_no_flag_collide: bs.Material, 84 flagmaterial: bs.Material, 85 ): 86 self.base_pos = base_pos 87 self.base_region_material = base_region_material 88 self.base_region = base_region 89 self.spaz_material_no_flag_physical = spaz_material_no_flag_physical 90 self.spaz_material_no_flag_collide = spaz_material_no_flag_collide 91 self.flagmaterial = flagmaterial 92 self.score = 0 93 self.flag_return_touches = 0 94 self.home_flag_at_base = True 95 self.touch_return_timer: bs.Timer | None = None 96 self.enemy_flag_at_base = False 97 self.flag: CTFFlag | None = None 98 self.last_flag_leave_time: float | None = None 99 self.touch_return_timer_ticking: bs.NodeActor | None = None
103class CaptureTheFlagGame(bs.TeamGameActivity[Player, Team]): 104 """Game of stealing other team's flag and returning it to your base.""" 105 106 name = 'Capture the Flag' 107 description = 'Return the enemy flag to score.' 108 available_settings = [ 109 bs.IntSetting('Score to Win', min_value=1, default=3), 110 bs.IntSetting( 111 'Flag Touch Return Time', 112 min_value=0, 113 default=0, 114 increment=1, 115 ), 116 bs.IntSetting( 117 'Flag Idle Return Time', 118 min_value=5, 119 default=30, 120 increment=5, 121 ), 122 bs.IntChoiceSetting( 123 'Time Limit', 124 choices=[ 125 ('None', 0), 126 ('1 Minute', 60), 127 ('2 Minutes', 120), 128 ('5 Minutes', 300), 129 ('10 Minutes', 600), 130 ('20 Minutes', 1200), 131 ], 132 default=0, 133 ), 134 bs.FloatChoiceSetting( 135 'Respawn Times', 136 choices=[ 137 ('Shorter', 0.25), 138 ('Short', 0.5), 139 ('Normal', 1.0), 140 ('Long', 2.0), 141 ('Longer', 4.0), 142 ], 143 default=1.0, 144 ), 145 bs.BoolSetting('Epic Mode', default=False), 146 ] 147 148 @override 149 @classmethod 150 def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool: 151 return issubclass(sessiontype, bs.DualTeamSession) 152 153 @override 154 @classmethod 155 def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]: 156 assert bs.app.classic is not None 157 return bs.app.classic.getmaps('team_flag') 158 159 def __init__(self, settings: dict): 160 super().__init__(settings) 161 self._scoreboard = Scoreboard() 162 self._alarmsound = bs.getsound('alarm') 163 self._ticking_sound = bs.getsound('ticking') 164 self._score_sound = bs.getsound('score') 165 self._swipsound = bs.getsound('swip') 166 self._last_score_time = 0 167 self._all_bases_material = bs.Material() 168 self._last_home_flag_notice_print_time = 0.0 169 self._score_to_win = int(settings['Score to Win']) 170 self._epic_mode = bool(settings['Epic Mode']) 171 self._time_limit = float(settings['Time Limit']) 172 173 self.flag_touch_return_time = float(settings['Flag Touch Return Time']) 174 self.flag_idle_return_time = float(settings['Flag Idle Return Time']) 175 176 # Base class overrides. 177 self.slow_motion = self._epic_mode 178 self.default_music = ( 179 bs.MusicType.EPIC if self._epic_mode else bs.MusicType.FLAG_CATCHER 180 ) 181 182 @override 183 def get_instance_description(self) -> str | Sequence: 184 if self._score_to_win == 1: 185 return 'Steal the enemy flag.' 186 return 'Steal the enemy flag ${ARG1} times.', self._score_to_win 187 188 @override 189 def get_instance_description_short(self) -> str | Sequence: 190 if self._score_to_win == 1: 191 return 'return 1 flag' 192 return 'return ${ARG1} flags', self._score_to_win 193 194 @override 195 def create_team(self, sessionteam: bs.SessionTeam) -> Team: 196 # Create our team instance and its initial values. 197 198 base_pos = self.map.get_flag_position(sessionteam.id) 199 Flag.project_stand(base_pos) 200 201 bs.newnode( 202 'light', 203 attrs={ 204 'position': base_pos, 205 'intensity': 0.6, 206 'height_attenuated': False, 207 'volume_intensity_scale': 0.1, 208 'radius': 0.1, 209 'color': sessionteam.color, 210 }, 211 ) 212 213 base_region_mat = bs.Material() 214 pos = base_pos 215 base_region = bs.newnode( 216 'region', 217 attrs={ 218 'position': (pos[0], pos[1] + 0.75, pos[2]), 219 'scale': (0.5, 0.5, 0.5), 220 'type': 'sphere', 221 'materials': [base_region_mat, self._all_bases_material], 222 }, 223 ) 224 225 spaz_mat_no_flag_physical = bs.Material() 226 spaz_mat_no_flag_collide = bs.Material() 227 flagmat = bs.Material() 228 229 team = Team( 230 base_pos=base_pos, 231 base_region_material=base_region_mat, 232 base_region=base_region, 233 spaz_material_no_flag_physical=spaz_mat_no_flag_physical, 234 spaz_material_no_flag_collide=spaz_mat_no_flag_collide, 235 flagmaterial=flagmat, 236 ) 237 238 # Some parts of our spazzes don't collide physically with our 239 # flags but generate callbacks. 240 spaz_mat_no_flag_physical.add_actions( 241 conditions=('they_have_material', flagmat), 242 actions=( 243 ('modify_part_collision', 'physical', False), 244 ( 245 'call', 246 'at_connect', 247 lambda: self._handle_touching_own_flag(team, True), 248 ), 249 ( 250 'call', 251 'at_disconnect', 252 lambda: self._handle_touching_own_flag(team, False), 253 ), 254 ), 255 ) 256 257 # Other parts of our spazzes don't collide with our flags at all. 258 spaz_mat_no_flag_collide.add_actions( 259 conditions=('they_have_material', flagmat), 260 actions=('modify_part_collision', 'collide', False), 261 ) 262 263 # We wanna know when *any* flag enters/leaves our base. 264 base_region_mat.add_actions( 265 conditions=('they_have_material', FlagFactory.get().flagmaterial), 266 actions=( 267 ('modify_part_collision', 'collide', True), 268 ('modify_part_collision', 'physical', False), 269 ( 270 'call', 271 'at_connect', 272 lambda: self._handle_flag_entered_base(team), 273 ), 274 ( 275 'call', 276 'at_disconnect', 277 lambda: self._handle_flag_left_base(team), 278 ), 279 ), 280 ) 281 282 return team 283 284 @override 285 def on_team_join(self, team: Team) -> None: 286 # Can't do this in create_team because the team's color/etc. have 287 # not been wired up yet at that point. 288 self._spawn_flag_for_team(team) 289 self._update_scoreboard() 290 291 @override 292 def on_begin(self) -> None: 293 super().on_begin() 294 self.setup_standard_time_limit(self._time_limit) 295 self.setup_standard_powerup_drops() 296 bs.timer(1.0, call=self._tick, repeat=True) 297 298 def _spawn_flag_for_team(self, team: Team) -> None: 299 team.flag = CTFFlag(team) 300 team.flag_return_touches = 0 301 self._flash_base(team, length=1.0) 302 assert team.flag.node 303 self._swipsound.play(position=team.flag.node.position) 304 305 def _handle_flag_entered_base(self, team: Team) -> None: 306 try: 307 flag = bs.getcollision().opposingnode.getdelegate(CTFFlag, True) 308 except bs.NotFoundError: 309 # Don't think this should logically ever happen. 310 print('Error getting CTFFlag in entering-base callback.') 311 return 312 313 if flag.team is team: 314 team.home_flag_at_base = True 315 316 # If the enemy flag is already here, score! 317 if team.enemy_flag_at_base: 318 # And show team name which scored (but actually we could 319 # show here player who returned enemy flag). 320 self.show_zoom_message( 321 bs.Lstr( 322 resource='nameScoresText', subs=[('${NAME}', team.name)] 323 ), 324 color=team.color, 325 ) 326 self._score(team) 327 else: 328 team.enemy_flag_at_base = True 329 if team.home_flag_at_base: 330 # Award points to whoever was carrying the enemy flag. 331 player = flag.last_player_to_hold 332 if player and player.team is team: 333 self.stats.player_scored(player, 50, big_message=True) 334 335 # Update score and reset flags. 336 self._score(team) 337 338 # If the home-team flag isn't here, print a message to that effect. 339 else: 340 # Don't want slo-mo affecting this 341 curtime = bs.basetime() 342 if curtime - self._last_home_flag_notice_print_time > 5.0: 343 self._last_home_flag_notice_print_time = curtime 344 bpos = team.base_pos 345 tval = bs.Lstr(resource='ownFlagAtYourBaseWarning') 346 tnode = bs.newnode( 347 'text', 348 attrs={ 349 'text': tval, 350 'in_world': True, 351 'scale': 0.013, 352 'color': (1, 1, 0, 1), 353 'h_align': 'center', 354 'position': (bpos[0], bpos[1] + 3.2, bpos[2]), 355 }, 356 ) 357 bs.timer(5.1, tnode.delete) 358 bs.animate( 359 tnode, 'scale', {0.0: 0, 0.2: 0.013, 4.8: 0.013, 5.0: 0} 360 ) 361 362 def _tick(self) -> None: 363 # If either flag is away from base and not being held, tick down its 364 # respawn timer. 365 for team in self.teams: 366 flag = team.flag 367 assert flag is not None 368 369 if not team.home_flag_at_base and flag.held_count == 0: 370 time_out_counting_down = True 371 if flag.time_out_respawn_time is None: 372 flag.reset_return_times() 373 assert flag.time_out_respawn_time is not None 374 flag.time_out_respawn_time -= 1 375 if flag.time_out_respawn_time <= 0: 376 flag.handlemessage(bs.DieMessage()) 377 else: 378 time_out_counting_down = False 379 380 if flag.node and flag.counter: 381 pos = flag.node.position 382 flag.counter.position = (pos[0], pos[1] + 1.3, pos[2]) 383 384 # If there's no self-touches on this flag, set its text 385 # to show its auto-return counter. (if there's self-touches 386 # its showing that time). 387 if team.flag_return_touches == 0: 388 flag.counter.text = ( 389 str(flag.time_out_respawn_time) 390 if ( 391 time_out_counting_down 392 and flag.time_out_respawn_time is not None 393 and flag.time_out_respawn_time <= 10 394 ) 395 else '' 396 ) 397 flag.counter.color = (1, 1, 1, 0.5) 398 flag.counter.scale = 0.014 399 400 def _score(self, team: Team) -> None: 401 team.score += 1 402 self._score_sound.play() 403 self._flash_base(team) 404 self._update_scoreboard() 405 406 # Have teammates celebrate. 407 for player in team.players: 408 if player.actor: 409 player.actor.handlemessage(bs.CelebrateMessage(2.0)) 410 411 # Reset all flags/state. 412 for reset_team in self.teams: 413 if not reset_team.home_flag_at_base: 414 assert reset_team.flag is not None 415 reset_team.flag.handlemessage(bs.DieMessage()) 416 reset_team.enemy_flag_at_base = False 417 if team.score >= self._score_to_win: 418 self.end_game() 419 420 @override 421 def end_game(self) -> None: 422 results = bs.GameResults() 423 for team in self.teams: 424 results.set_team_score(team, team.score) 425 self.end(results=results, announce_delay=0.8) 426 427 def _handle_flag_left_base(self, team: Team) -> None: 428 cur_time = bs.time() 429 try: 430 flag = bs.getcollision().opposingnode.getdelegate(CTFFlag, True) 431 except bs.NotFoundError: 432 # This can happen if the flag stops touching us due to being 433 # deleted; that's ok. 434 return 435 436 if flag.team is team: 437 # Check times here to prevent too much flashing. 438 if ( 439 team.last_flag_leave_time is None 440 or cur_time - team.last_flag_leave_time > 3.0 441 ): 442 self._alarmsound.play(position=team.base_pos) 443 self._flash_base(team) 444 team.last_flag_leave_time = cur_time 445 team.home_flag_at_base = False 446 else: 447 team.enemy_flag_at_base = False 448 449 def _touch_return_update(self, team: Team) -> None: 450 # Count down only while its away from base and not being held. 451 assert team.flag is not None 452 if team.home_flag_at_base or team.flag.held_count > 0: 453 team.touch_return_timer_ticking = None 454 return # No need to return when its at home. 455 if team.touch_return_timer_ticking is None: 456 team.touch_return_timer_ticking = bs.NodeActor( 457 bs.newnode( 458 'sound', 459 attrs={ 460 'sound': self._ticking_sound, 461 'positional': False, 462 'loop': True, 463 }, 464 ) 465 ) 466 flag = team.flag 467 if flag.touch_return_time is not None: 468 flag.touch_return_time -= 0.1 469 if flag.counter: 470 flag.counter.text = f'{flag.touch_return_time:.1f}' 471 flag.counter.color = (1, 1, 0, 1) 472 flag.counter.scale = 0.02 473 474 if flag.touch_return_time <= 0.0: 475 self._award_players_touching_own_flag(team) 476 flag.handlemessage(bs.DieMessage()) 477 478 def _award_players_touching_own_flag(self, team: Team) -> None: 479 for player in team.players: 480 if player.touching_own_flag > 0: 481 return_score = 10 + 5 * int(self.flag_touch_return_time) 482 self.stats.player_scored( 483 player, return_score, screenmessage=False 484 ) 485 486 def _handle_touching_own_flag(self, team: Team, connecting: bool) -> None: 487 """Called when a player touches or stops touching their own team flag. 488 489 We keep track of when each player is touching their own flag so we 490 can award points when returned. 491 """ 492 player: Player | None 493 try: 494 spaz = bs.getcollision().sourcenode.getdelegate(PlayerSpaz, True) 495 except bs.NotFoundError: 496 return 497 498 player = spaz.getplayer(Player, True) 499 500 if player: 501 player.touching_own_flag += 1 if connecting else -1 502 503 # If return-time is zero, just kill it immediately.. otherwise keep 504 # track of touches and count down. 505 if float(self.flag_touch_return_time) <= 0.0: 506 assert team.flag is not None 507 if ( 508 connecting 509 and not team.home_flag_at_base 510 and team.flag.held_count == 0 511 ): 512 self._award_players_touching_own_flag(team) 513 bs.getcollision().opposingnode.handlemessage(bs.DieMessage()) 514 515 # Takes a non-zero amount of time to return. 516 else: 517 if connecting: 518 team.flag_return_touches += 1 519 if team.flag_return_touches == 1: 520 team.touch_return_timer = bs.Timer( 521 0.1, 522 call=bs.Call(self._touch_return_update, team), 523 repeat=True, 524 ) 525 team.touch_return_timer_ticking = None 526 else: 527 team.flag_return_touches -= 1 528 if team.flag_return_touches == 0: 529 team.touch_return_timer = None 530 team.touch_return_timer_ticking = None 531 if team.flag_return_touches < 0: 532 logging.error('CTF flag_return_touches < 0', stack_info=True) 533 534 def _handle_death_flag_capture(self, player: Player) -> None: 535 """Handles flag values when a player dies or leaves the game.""" 536 # Don't do anything if the player hasn't touched the flag at all. 537 if not player.touching_own_flag: 538 return 539 540 team = player.team 541 542 # For each "point" our player has touched theflag (Could be 543 # multiple), deduct one from both our player and the flag's 544 # return touches variable. 545 for _ in range(player.touching_own_flag): 546 # Deduct 547 player.touching_own_flag -= 1 548 549 # (This was only incremented if we have non-zero 550 # return-times). 551 if float(self.flag_touch_return_time) > 0.0: 552 team.flag_return_touches -= 1 553 # Update our flag's timer accordingly 554 # (Prevents immediate resets in case 555 # there might be more people touching it). 556 if team.flag_return_touches == 0: 557 team.touch_return_timer = None 558 team.touch_return_timer_ticking = None 559 # Safety check, just to be sure! 560 if team.flag_return_touches < 0: 561 logging.error( 562 'CTF flag_return_touches < 0', stack_info=True 563 ) 564 565 def _flash_base(self, team: Team, length: float = 2.0) -> None: 566 light = bs.newnode( 567 'light', 568 attrs={ 569 'position': team.base_pos, 570 'height_attenuated': False, 571 'radius': 0.3, 572 'color': team.color, 573 }, 574 ) 575 bs.animate(light, 'intensity', {0.0: 0, 0.25: 2.0, 0.5: 0}, loop=True) 576 bs.timer(length, light.delete) 577 578 @override 579 def spawn_player_spaz( 580 self, 581 player: Player, 582 position: Sequence[float] | None = None, 583 angle: float | None = None, 584 ) -> PlayerSpaz: 585 """Intercept new spazzes and add our team material for them.""" 586 spaz = super().spawn_player_spaz(player, position, angle) 587 player = spaz.getplayer(Player, True) 588 team: Team = player.team 589 player.touching_own_flag = 0 590 no_physical_mats: list[bs.Material] = [ 591 team.spaz_material_no_flag_physical 592 ] 593 no_collide_mats: list[bs.Material] = [ 594 team.spaz_material_no_flag_collide 595 ] 596 597 # Our normal parts should still collide; just not physically 598 # (so we can calc restores). 599 assert spaz.node 600 spaz.node.materials = list(spaz.node.materials) + no_physical_mats 601 spaz.node.roller_materials = ( 602 list(spaz.node.roller_materials) + no_physical_mats 603 ) 604 605 # Pickups and punches shouldn't hit at all though. 606 spaz.node.punch_materials = ( 607 list(spaz.node.punch_materials) + no_collide_mats 608 ) 609 spaz.node.pickup_materials = ( 610 list(spaz.node.pickup_materials) + no_collide_mats 611 ) 612 spaz.node.extras_material = ( 613 list(spaz.node.extras_material) + no_collide_mats 614 ) 615 return spaz 616 617 def _update_scoreboard(self) -> None: 618 for team in self.teams: 619 self._scoreboard.set_team_value( 620 team, team.score, self._score_to_win 621 ) 622 623 @override 624 def handlemessage(self, msg: Any) -> Any: 625 if isinstance(msg, bs.PlayerDiedMessage): 626 super().handlemessage(msg) # Augment standard behavior. 627 self._handle_death_flag_capture(msg.getplayer(Player)) 628 self.respawn_player(msg.getplayer(Player)) 629 630 elif isinstance(msg, FlagDiedMessage): 631 assert isinstance(msg.flag, CTFFlag) 632 bs.timer(0.1, bs.Call(self._spawn_flag_for_team, msg.flag.team)) 633 634 elif isinstance(msg, FlagPickedUpMessage): 635 # Store the last player to hold the flag for scoring purposes. 636 assert isinstance(msg.flag, CTFFlag) 637 try: 638 msg.flag.last_player_to_hold = msg.node.getdelegate( 639 PlayerSpaz, True 640 ).getplayer(Player, True) 641 except bs.NotFoundError: 642 pass 643 644 msg.flag.held_count += 1 645 msg.flag.reset_return_times() 646 647 elif isinstance(msg, FlagDroppedMessage): 648 # Store the last player to hold the flag for scoring purposes. 649 assert isinstance(msg.flag, CTFFlag) 650 msg.flag.held_count -= 1 651 652 else: 653 super().handlemessage(msg) 654 655 @override 656 def on_player_leave(self, player: Player) -> None: 657 """Prevents leaving players from capturing their flag.""" 658 self._handle_death_flag_capture(player)
Game of stealing other team's flag and returning it to your base.
159 def __init__(self, settings: dict): 160 super().__init__(settings) 161 self._scoreboard = Scoreboard() 162 self._alarmsound = bs.getsound('alarm') 163 self._ticking_sound = bs.getsound('ticking') 164 self._score_sound = bs.getsound('score') 165 self._swipsound = bs.getsound('swip') 166 self._last_score_time = 0 167 self._all_bases_material = bs.Material() 168 self._last_home_flag_notice_print_time = 0.0 169 self._score_to_win = int(settings['Score to Win']) 170 self._epic_mode = bool(settings['Epic Mode']) 171 self._time_limit = float(settings['Time Limit']) 172 173 self.flag_touch_return_time = float(settings['Flag Touch Return Time']) 174 self.flag_idle_return_time = float(settings['Flag Idle Return Time']) 175 176 # Base class overrides. 177 self.slow_motion = self._epic_mode 178 self.default_music = ( 179 bs.MusicType.EPIC if self._epic_mode else bs.MusicType.FLAG_CATCHER 180 )
Instantiate the Activity.
148 @override 149 @classmethod 150 def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool: 151 return issubclass(sessiontype, bs.DualTeamSession)
Class method override; returns True for ba.DualTeamSessions and ba.FreeForAllSessions; False otherwise.
153 @override 154 @classmethod 155 def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]: 156 assert bs.app.classic is not None 157 return bs.app.classic.getmaps('team_flag')
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.
182 @override 183 def get_instance_description(self) -> str | Sequence: 184 if self._score_to_win == 1: 185 return 'Steal the enemy flag.' 186 return 'Steal the enemy flag ${ARG1} times.', self._score_to_win
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.
188 @override 189 def get_instance_description_short(self) -> str | Sequence: 190 if self._score_to_win == 1: 191 return 'return 1 flag' 192 return 'return ${ARG1} flags', self._score_to_win
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.
194 @override 195 def create_team(self, sessionteam: bs.SessionTeam) -> Team: 196 # Create our team instance and its initial values. 197 198 base_pos = self.map.get_flag_position(sessionteam.id) 199 Flag.project_stand(base_pos) 200 201 bs.newnode( 202 'light', 203 attrs={ 204 'position': base_pos, 205 'intensity': 0.6, 206 'height_attenuated': False, 207 'volume_intensity_scale': 0.1, 208 'radius': 0.1, 209 'color': sessionteam.color, 210 }, 211 ) 212 213 base_region_mat = bs.Material() 214 pos = base_pos 215 base_region = bs.newnode( 216 'region', 217 attrs={ 218 'position': (pos[0], pos[1] + 0.75, pos[2]), 219 'scale': (0.5, 0.5, 0.5), 220 'type': 'sphere', 221 'materials': [base_region_mat, self._all_bases_material], 222 }, 223 ) 224 225 spaz_mat_no_flag_physical = bs.Material() 226 spaz_mat_no_flag_collide = bs.Material() 227 flagmat = bs.Material() 228 229 team = Team( 230 base_pos=base_pos, 231 base_region_material=base_region_mat, 232 base_region=base_region, 233 spaz_material_no_flag_physical=spaz_mat_no_flag_physical, 234 spaz_material_no_flag_collide=spaz_mat_no_flag_collide, 235 flagmaterial=flagmat, 236 ) 237 238 # Some parts of our spazzes don't collide physically with our 239 # flags but generate callbacks. 240 spaz_mat_no_flag_physical.add_actions( 241 conditions=('they_have_material', flagmat), 242 actions=( 243 ('modify_part_collision', 'physical', False), 244 ( 245 'call', 246 'at_connect', 247 lambda: self._handle_touching_own_flag(team, True), 248 ), 249 ( 250 'call', 251 'at_disconnect', 252 lambda: self._handle_touching_own_flag(team, False), 253 ), 254 ), 255 ) 256 257 # Other parts of our spazzes don't collide with our flags at all. 258 spaz_mat_no_flag_collide.add_actions( 259 conditions=('they_have_material', flagmat), 260 actions=('modify_part_collision', 'collide', False), 261 ) 262 263 # We wanna know when *any* flag enters/leaves our base. 264 base_region_mat.add_actions( 265 conditions=('they_have_material', FlagFactory.get().flagmaterial), 266 actions=( 267 ('modify_part_collision', 'collide', True), 268 ('modify_part_collision', 'physical', False), 269 ( 270 'call', 271 'at_connect', 272 lambda: self._handle_flag_entered_base(team), 273 ), 274 ( 275 'call', 276 'at_disconnect', 277 lambda: self._handle_flag_left_base(team), 278 ), 279 ), 280 ) 281 282 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.
284 @override 285 def on_team_join(self, team: Team) -> None: 286 # Can't do this in create_team because the team's color/etc. have 287 # not been wired up yet at that point. 288 self._spawn_flag_for_team(team) 289 self._update_scoreboard()
Called when a new bascenev1.Team joins the Activity.
(including the initial set of Teams)
291 @override 292 def on_begin(self) -> None: 293 super().on_begin() 294 self.setup_standard_time_limit(self._time_limit) 295 self.setup_standard_powerup_drops() 296 bs.timer(1.0, call=self._tick, repeat=True)
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.
420 @override 421 def end_game(self) -> None: 422 results = bs.GameResults() 423 for team in self.teams: 424 results.set_team_score(team, team.score) 425 self.end(results=results, announce_delay=0.8)
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.
578 @override 579 def spawn_player_spaz( 580 self, 581 player: Player, 582 position: Sequence[float] | None = None, 583 angle: float | None = None, 584 ) -> PlayerSpaz: 585 """Intercept new spazzes and add our team material for them.""" 586 spaz = super().spawn_player_spaz(player, position, angle) 587 player = spaz.getplayer(Player, True) 588 team: Team = player.team 589 player.touching_own_flag = 0 590 no_physical_mats: list[bs.Material] = [ 591 team.spaz_material_no_flag_physical 592 ] 593 no_collide_mats: list[bs.Material] = [ 594 team.spaz_material_no_flag_collide 595 ] 596 597 # Our normal parts should still collide; just not physically 598 # (so we can calc restores). 599 assert spaz.node 600 spaz.node.materials = list(spaz.node.materials) + no_physical_mats 601 spaz.node.roller_materials = ( 602 list(spaz.node.roller_materials) + no_physical_mats 603 ) 604 605 # Pickups and punches shouldn't hit at all though. 606 spaz.node.punch_materials = ( 607 list(spaz.node.punch_materials) + no_collide_mats 608 ) 609 spaz.node.pickup_materials = ( 610 list(spaz.node.pickup_materials) + no_collide_mats 611 ) 612 spaz.node.extras_material = ( 613 list(spaz.node.extras_material) + no_collide_mats 614 ) 615 return spaz
Intercept new spazzes and add our team material for them.
623 @override 624 def handlemessage(self, msg: Any) -> Any: 625 if isinstance(msg, bs.PlayerDiedMessage): 626 super().handlemessage(msg) # Augment standard behavior. 627 self._handle_death_flag_capture(msg.getplayer(Player)) 628 self.respawn_player(msg.getplayer(Player)) 629 630 elif isinstance(msg, FlagDiedMessage): 631 assert isinstance(msg.flag, CTFFlag) 632 bs.timer(0.1, bs.Call(self._spawn_flag_for_team, msg.flag.team)) 633 634 elif isinstance(msg, FlagPickedUpMessage): 635 # Store the last player to hold the flag for scoring purposes. 636 assert isinstance(msg.flag, CTFFlag) 637 try: 638 msg.flag.last_player_to_hold = msg.node.getdelegate( 639 PlayerSpaz, True 640 ).getplayer(Player, True) 641 except bs.NotFoundError: 642 pass 643 644 msg.flag.held_count += 1 645 msg.flag.reset_return_times() 646 647 elif isinstance(msg, FlagDroppedMessage): 648 # Store the last player to hold the flag for scoring purposes. 649 assert isinstance(msg.flag, CTFFlag) 650 msg.flag.held_count -= 1 651 652 else: 653 super().handlemessage(msg)
General message handling; can be passed any message object.