bastd.game.race
Defines Race mini-game.
1# Released under the MIT License. See LICENSE for details. 2# 3"""Defines Race mini-game.""" 4 5# ba_meta require api 7 6# (see https://ballistica.net/wiki/meta-tag-system) 7 8from __future__ import annotations 9 10import random 11from typing import TYPE_CHECKING 12from dataclasses import dataclass 13 14import ba 15from bastd.actor.bomb import Bomb 16from bastd.actor.playerspaz import PlayerSpaz 17from bastd.actor.scoreboard import Scoreboard 18from bastd.gameutils import SharedObjects 19 20if TYPE_CHECKING: 21 from typing import Any, Sequence 22 from bastd.actor.onscreentimer import OnScreenTimer 23 24 25@dataclass 26class RaceMine: 27 """Holds info about a mine on the track.""" 28 29 point: Sequence[float] 30 mine: Bomb | None 31 32 33class RaceRegion(ba.Actor): 34 """Region used to track progress during a race.""" 35 36 def __init__(self, pt: Sequence[float], index: int): 37 super().__init__() 38 activity = self.activity 39 assert isinstance(activity, RaceGame) 40 self.pos = pt 41 self.index = index 42 self.node = ba.newnode( 43 'region', 44 delegate=self, 45 attrs={ 46 'position': pt[:3], 47 'scale': (pt[3] * 2.0, pt[4] * 2.0, pt[5] * 2.0), 48 'type': 'box', 49 'materials': [activity.race_region_material], 50 }, 51 ) 52 53 54class Player(ba.Player['Team']): 55 """Our player type for this game.""" 56 57 def __init__(self) -> None: 58 self.distance_txt: ba.Node | None = None 59 self.last_region = 0 60 self.lap = 0 61 self.distance = 0.0 62 self.finished = False 63 self.rank: int | None = None 64 65 66class Team(ba.Team[Player]): 67 """Our team type for this game.""" 68 69 def __init__(self) -> None: 70 self.time: float | None = None 71 self.lap = 0 72 self.finished = False 73 74 75# ba_meta export game 76class RaceGame(ba.TeamGameActivity[Player, Team]): 77 """Game of racing around a track.""" 78 79 name = 'Race' 80 description = 'Run real fast!' 81 scoreconfig = ba.ScoreConfig( 82 label='Time', lower_is_better=True, scoretype=ba.ScoreType.MILLISECONDS 83 ) 84 85 @classmethod 86 def get_available_settings( 87 cls, sessiontype: type[ba.Session] 88 ) -> list[ba.Setting]: 89 settings = [ 90 ba.IntSetting('Laps', min_value=1, default=3, increment=1), 91 ba.IntChoiceSetting( 92 'Time Limit', 93 default=0, 94 choices=[ 95 ('None', 0), 96 ('1 Minute', 60), 97 ('2 Minutes', 120), 98 ('5 Minutes', 300), 99 ('10 Minutes', 600), 100 ('20 Minutes', 1200), 101 ], 102 ), 103 ba.IntChoiceSetting( 104 'Mine Spawning', 105 default=4000, 106 choices=[ 107 ('No Mines', 0), 108 ('8 Seconds', 8000), 109 ('4 Seconds', 4000), 110 ('2 Seconds', 2000), 111 ], 112 ), 113 ba.IntChoiceSetting( 114 'Bomb Spawning', 115 choices=[ 116 ('None', 0), 117 ('8 Seconds', 8000), 118 ('4 Seconds', 4000), 119 ('2 Seconds', 2000), 120 ('1 Second', 1000), 121 ], 122 default=2000, 123 ), 124 ba.BoolSetting('Epic Mode', default=False), 125 ] 126 127 # We have some specific settings in teams mode. 128 if issubclass(sessiontype, ba.DualTeamSession): 129 settings.append( 130 ba.BoolSetting('Entire Team Must Finish', default=False) 131 ) 132 return settings 133 134 @classmethod 135 def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool: 136 return issubclass(sessiontype, ba.MultiTeamSession) 137 138 @classmethod 139 def get_supported_maps(cls, sessiontype: type[ba.Session]) -> list[str]: 140 return ba.getmaps('race') 141 142 def __init__(self, settings: dict): 143 self._race_started = False 144 super().__init__(settings) 145 self._scoreboard = Scoreboard() 146 self._score_sound = ba.getsound('score') 147 self._swipsound = ba.getsound('swip') 148 self._last_team_time: float | None = None 149 self._front_race_region: int | None = None 150 self._nub_tex = ba.gettexture('nub') 151 self._beep_1_sound = ba.getsound('raceBeep1') 152 self._beep_2_sound = ba.getsound('raceBeep2') 153 self.race_region_material: ba.Material | None = None 154 self._regions: list[RaceRegion] = [] 155 self._team_finish_pts: int | None = None 156 self._time_text: ba.Actor | None = None 157 self._timer: OnScreenTimer | None = None 158 self._race_mines: list[RaceMine] | None = None 159 self._race_mine_timer: ba.Timer | None = None 160 self._scoreboard_timer: ba.Timer | None = None 161 self._player_order_update_timer: ba.Timer | None = None 162 self._start_lights: list[ba.Node] | None = None 163 self._bomb_spawn_timer: ba.Timer | None = None 164 self._laps = int(settings['Laps']) 165 self._entire_team_must_finish = bool( 166 settings.get('Entire Team Must Finish', False) 167 ) 168 self._time_limit = float(settings['Time Limit']) 169 self._mine_spawning = int(settings['Mine Spawning']) 170 self._bomb_spawning = int(settings['Bomb Spawning']) 171 self._epic_mode = bool(settings['Epic Mode']) 172 173 # Base class overrides. 174 self.slow_motion = self._epic_mode 175 self.default_music = ( 176 ba.MusicType.EPIC_RACE if self._epic_mode else ba.MusicType.RACE 177 ) 178 179 def get_instance_description(self) -> str | Sequence: 180 if ( 181 isinstance(self.session, ba.DualTeamSession) 182 and self._entire_team_must_finish 183 ): 184 t_str = ' Your entire team has to finish.' 185 else: 186 t_str = '' 187 188 if self._laps > 1: 189 return 'Run ${ARG1} laps.' + t_str, self._laps 190 return 'Run 1 lap.' + t_str 191 192 def get_instance_description_short(self) -> str | Sequence: 193 if self._laps > 1: 194 return 'run ${ARG1} laps', self._laps 195 return 'run 1 lap' 196 197 def on_transition_in(self) -> None: 198 super().on_transition_in() 199 shared = SharedObjects.get() 200 pts = self.map.get_def_points('race_point') 201 mat = self.race_region_material = ba.Material() 202 mat.add_actions( 203 conditions=('they_have_material', shared.player_material), 204 actions=( 205 ('modify_part_collision', 'collide', True), 206 ('modify_part_collision', 'physical', False), 207 ('call', 'at_connect', self._handle_race_point_collide), 208 ), 209 ) 210 for rpt in pts: 211 self._regions.append(RaceRegion(rpt, len(self._regions))) 212 213 def _flash_player(self, player: Player, scale: float) -> None: 214 assert isinstance(player.actor, PlayerSpaz) 215 assert player.actor.node 216 pos = player.actor.node.position 217 light = ba.newnode( 218 'light', 219 attrs={ 220 'position': pos, 221 'color': (1, 1, 0), 222 'height_attenuated': False, 223 'radius': 0.4, 224 }, 225 ) 226 ba.timer(0.5, light.delete) 227 ba.animate(light, 'intensity', {0: 0, 0.1: 1.0 * scale, 0.5: 0}) 228 229 def _handle_race_point_collide(self) -> None: 230 # FIXME: Tidy this up. 231 # pylint: disable=too-many-statements 232 # pylint: disable=too-many-branches 233 # pylint: disable=too-many-nested-blocks 234 collision = ba.getcollision() 235 try: 236 region = collision.sourcenode.getdelegate(RaceRegion, True) 237 spaz = collision.opposingnode.getdelegate(PlayerSpaz, True) 238 except ba.NotFoundError: 239 return 240 241 if not spaz.is_alive(): 242 return 243 244 try: 245 player = spaz.getplayer(Player, True) 246 except ba.NotFoundError: 247 return 248 249 last_region = player.last_region 250 this_region = region.index 251 252 if last_region != this_region: 253 254 # If a player tries to skip regions, smite them. 255 # Allow a one region leeway though (its plausible players can get 256 # blown over a region, etc). 257 if this_region > last_region + 2: 258 if player.is_alive(): 259 assert player.actor 260 player.actor.handlemessage(ba.DieMessage()) 261 ba.screenmessage( 262 ba.Lstr( 263 translate=( 264 'statements', 265 'Killing ${NAME} for' 266 ' skipping part of the track!', 267 ), 268 subs=[('${NAME}', player.getname(full=True))], 269 ), 270 color=(1, 0, 0), 271 ) 272 else: 273 # If this player is in first, note that this is the 274 # front-most race-point. 275 if player.rank == 0: 276 self._front_race_region = this_region 277 278 player.last_region = this_region 279 if last_region >= len(self._regions) - 2 and this_region == 0: 280 team = player.team 281 player.lap = min(self._laps, player.lap + 1) 282 283 # In teams mode with all-must-finish on, the team lap 284 # value is the min of all team players. 285 # Otherwise its the max. 286 if ( 287 isinstance(self.session, ba.DualTeamSession) 288 and self._entire_team_must_finish 289 ): 290 team.lap = min(p.lap for p in team.players) 291 else: 292 team.lap = max(p.lap for p in team.players) 293 294 # A player is finishing. 295 if player.lap == self._laps: 296 297 # In teams mode, hand out points based on the order 298 # players come in. 299 if isinstance(self.session, ba.DualTeamSession): 300 assert self._team_finish_pts is not None 301 if self._team_finish_pts > 0: 302 self.stats.player_scored( 303 player, 304 self._team_finish_pts, 305 screenmessage=False, 306 ) 307 self._team_finish_pts -= 25 308 309 # Flash where the player is. 310 self._flash_player(player, 1.0) 311 player.finished = True 312 assert player.actor 313 player.actor.handlemessage( 314 ba.DieMessage(immediate=True) 315 ) 316 317 # Makes sure noone behind them passes them in rank 318 # while finishing. 319 player.distance = 9999.0 320 321 # If the whole team has finished the race. 322 if team.lap == self._laps: 323 ba.playsound(self._score_sound) 324 player.team.finished = True 325 assert self._timer is not None 326 elapsed = ba.time() - self._timer.getstarttime() 327 self._last_team_time = player.team.time = elapsed 328 self._check_end_game() 329 330 # Team has yet to finish. 331 else: 332 ba.playsound(self._swipsound) 333 334 # They've just finished a lap but not the race. 335 else: 336 ba.playsound(self._swipsound) 337 self._flash_player(player, 0.3) 338 339 # Print their lap number over their head. 340 try: 341 assert isinstance(player.actor, PlayerSpaz) 342 mathnode = ba.newnode( 343 'math', 344 owner=player.actor.node, 345 attrs={ 346 'input1': (0, 1.9, 0), 347 'operation': 'add', 348 }, 349 ) 350 player.actor.node.connectattr( 351 'torso_position', mathnode, 'input2' 352 ) 353 tstr = ba.Lstr( 354 resource='lapNumberText', 355 subs=[ 356 ('${CURRENT}', str(player.lap + 1)), 357 ('${TOTAL}', str(self._laps)), 358 ], 359 ) 360 txtnode = ba.newnode( 361 'text', 362 owner=mathnode, 363 attrs={ 364 'text': tstr, 365 'in_world': True, 366 'color': (1, 1, 0, 1), 367 'scale': 0.015, 368 'h_align': 'center', 369 }, 370 ) 371 mathnode.connectattr('output', txtnode, 'position') 372 ba.animate( 373 txtnode, 374 'scale', 375 {0.0: 0, 0.2: 0.019, 2.0: 0.019, 2.2: 0}, 376 ) 377 ba.timer(2.3, mathnode.delete) 378 except Exception: 379 ba.print_exception('Error printing lap.') 380 381 def on_team_join(self, team: Team) -> None: 382 self._update_scoreboard() 383 384 def on_player_leave(self, player: Player) -> None: 385 super().on_player_leave(player) 386 387 # A player leaving disqualifies the team if 'Entire Team Must Finish' 388 # is on (otherwise in teams mode everyone could just leave except the 389 # leading player to win). 390 if ( 391 isinstance(self.session, ba.DualTeamSession) 392 and self._entire_team_must_finish 393 ): 394 ba.screenmessage( 395 ba.Lstr( 396 translate=( 397 'statements', 398 '${TEAM} is disqualified because ${PLAYER} left', 399 ), 400 subs=[ 401 ('${TEAM}', player.team.name), 402 ('${PLAYER}', player.getname(full=True)), 403 ], 404 ), 405 color=(1, 1, 0), 406 ) 407 player.team.finished = True 408 player.team.time = None 409 player.team.lap = 0 410 ba.playsound(ba.getsound('boo')) 411 for otherplayer in player.team.players: 412 otherplayer.lap = 0 413 otherplayer.finished = True 414 try: 415 if otherplayer.actor is not None: 416 otherplayer.actor.handlemessage(ba.DieMessage()) 417 except Exception: 418 ba.print_exception('Error sending DieMessage.') 419 420 # Defer so team/player lists will be updated. 421 ba.pushcall(self._check_end_game) 422 423 def _update_scoreboard(self) -> None: 424 for team in self.teams: 425 distances = [player.distance for player in team.players] 426 if not distances: 427 teams_dist = 0.0 428 else: 429 if ( 430 isinstance(self.session, ba.DualTeamSession) 431 and self._entire_team_must_finish 432 ): 433 teams_dist = min(distances) 434 else: 435 teams_dist = max(distances) 436 self._scoreboard.set_team_value( 437 team, 438 teams_dist, 439 self._laps, 440 flash=(teams_dist >= float(self._laps)), 441 show_value=False, 442 ) 443 444 def on_begin(self) -> None: 445 from bastd.actor.onscreentimer import OnScreenTimer 446 447 super().on_begin() 448 self.setup_standard_time_limit(self._time_limit) 449 self.setup_standard_powerup_drops() 450 self._team_finish_pts = 100 451 452 # Throw a timer up on-screen. 453 self._time_text = ba.NodeActor( 454 ba.newnode( 455 'text', 456 attrs={ 457 'v_attach': 'top', 458 'h_attach': 'center', 459 'h_align': 'center', 460 'color': (1, 1, 0.5, 1), 461 'flatness': 0.5, 462 'shadow': 0.5, 463 'position': (0, -50), 464 'scale': 1.4, 465 'text': '', 466 }, 467 ) 468 ) 469 self._timer = OnScreenTimer() 470 471 if self._mine_spawning != 0: 472 self._race_mines = [ 473 RaceMine(point=p, mine=None) 474 for p in self.map.get_def_points('race_mine') 475 ] 476 if self._race_mines: 477 self._race_mine_timer = ba.Timer( 478 0.001 * self._mine_spawning, 479 self._update_race_mine, 480 repeat=True, 481 ) 482 483 self._scoreboard_timer = ba.Timer( 484 0.25, self._update_scoreboard, repeat=True 485 ) 486 self._player_order_update_timer = ba.Timer( 487 0.25, self._update_player_order, repeat=True 488 ) 489 490 if self.slow_motion: 491 t_scale = 0.4 492 light_y = 50 493 else: 494 t_scale = 1.0 495 light_y = 150 496 lstart = 7.1 * t_scale 497 inc = 1.25 * t_scale 498 499 ba.timer(lstart, self._do_light_1) 500 ba.timer(lstart + inc, self._do_light_2) 501 ba.timer(lstart + 2 * inc, self._do_light_3) 502 ba.timer(lstart + 3 * inc, self._start_race) 503 504 self._start_lights = [] 505 for i in range(4): 506 lnub = ba.newnode( 507 'image', 508 attrs={ 509 'texture': ba.gettexture('nub'), 510 'opacity': 1.0, 511 'absolute_scale': True, 512 'position': (-75 + i * 50, light_y), 513 'scale': (50, 50), 514 'attach': 'center', 515 }, 516 ) 517 ba.animate( 518 lnub, 519 'opacity', 520 { 521 4.0 * t_scale: 0, 522 5.0 * t_scale: 1.0, 523 12.0 * t_scale: 1.0, 524 12.5 * t_scale: 0.0, 525 }, 526 ) 527 ba.timer(13.0 * t_scale, lnub.delete) 528 self._start_lights.append(lnub) 529 530 self._start_lights[0].color = (0.2, 0, 0) 531 self._start_lights[1].color = (0.2, 0, 0) 532 self._start_lights[2].color = (0.2, 0.05, 0) 533 self._start_lights[3].color = (0.0, 0.3, 0) 534 535 def _do_light_1(self) -> None: 536 assert self._start_lights is not None 537 self._start_lights[0].color = (1.0, 0, 0) 538 ba.playsound(self._beep_1_sound) 539 540 def _do_light_2(self) -> None: 541 assert self._start_lights is not None 542 self._start_lights[1].color = (1.0, 0, 0) 543 ba.playsound(self._beep_1_sound) 544 545 def _do_light_3(self) -> None: 546 assert self._start_lights is not None 547 self._start_lights[2].color = (1.0, 0.3, 0) 548 ba.playsound(self._beep_1_sound) 549 550 def _start_race(self) -> None: 551 assert self._start_lights is not None 552 self._start_lights[3].color = (0.0, 1.0, 0) 553 ba.playsound(self._beep_2_sound) 554 for player in self.players: 555 if player.actor is not None: 556 try: 557 assert isinstance(player.actor, PlayerSpaz) 558 player.actor.connect_controls_to_player() 559 except Exception: 560 ba.print_exception('Error in race player connects.') 561 assert self._timer is not None 562 self._timer.start() 563 564 if self._bomb_spawning != 0: 565 self._bomb_spawn_timer = ba.Timer( 566 0.001 * self._bomb_spawning, self._spawn_bomb, repeat=True 567 ) 568 569 self._race_started = True 570 571 def _update_player_order(self) -> None: 572 573 # Calc all player distances. 574 for player in self.players: 575 pos: ba.Vec3 | None 576 try: 577 pos = player.position 578 except ba.NotFoundError: 579 pos = None 580 if pos is not None: 581 r_index = player.last_region 582 rg1 = self._regions[r_index] 583 r1pt = ba.Vec3(rg1.pos[:3]) 584 rg2 = ( 585 self._regions[0] 586 if r_index == len(self._regions) - 1 587 else self._regions[r_index + 1] 588 ) 589 r2pt = ba.Vec3(rg2.pos[:3]) 590 r2dist = (pos - r2pt).length() 591 amt = 1.0 - (r2dist / (r2pt - r1pt).length()) 592 amt = player.lap + (r_index + amt) * (1.0 / len(self._regions)) 593 player.distance = amt 594 595 # Sort players by distance and update their ranks. 596 p_list = [(player.distance, player) for player in self.players] 597 598 p_list.sort(reverse=True, key=lambda x: x[0]) 599 for i, plr in enumerate(p_list): 600 plr[1].rank = i 601 if plr[1].actor: 602 node = plr[1].distance_txt 603 if node: 604 node.text = str(i + 1) if plr[1].is_alive() else '' 605 606 def _spawn_bomb(self) -> None: 607 if self._front_race_region is None: 608 return 609 region = (self._front_race_region + 3) % len(self._regions) 610 pos = self._regions[region].pos 611 612 # Don't use the full region so we're less likely to spawn off a cliff. 613 region_scale = 0.8 614 x_range = ( 615 (-0.5, 0.5) 616 if pos[3] == 0 617 else (-region_scale * pos[3], region_scale * pos[3]) 618 ) 619 z_range = ( 620 (-0.5, 0.5) 621 if pos[5] == 0 622 else (-region_scale * pos[5], region_scale * pos[5]) 623 ) 624 pos = ( 625 pos[0] + random.uniform(*x_range), 626 pos[1] + 1.0, 627 pos[2] + random.uniform(*z_range), 628 ) 629 ba.timer( 630 random.uniform(0.0, 2.0), ba.WeakCall(self._spawn_bomb_at_pos, pos) 631 ) 632 633 def _spawn_bomb_at_pos(self, pos: Sequence[float]) -> None: 634 if self.has_ended(): 635 return 636 Bomb(position=pos, bomb_type='normal').autoretain() 637 638 def _make_mine(self, i: int) -> None: 639 assert self._race_mines is not None 640 rmine = self._race_mines[i] 641 rmine.mine = Bomb(position=rmine.point[:3], bomb_type='land_mine') 642 rmine.mine.arm() 643 644 def _flash_mine(self, i: int) -> None: 645 assert self._race_mines is not None 646 rmine = self._race_mines[i] 647 light = ba.newnode( 648 'light', 649 attrs={ 650 'position': rmine.point[:3], 651 'color': (1, 0.2, 0.2), 652 'radius': 0.1, 653 'height_attenuated': False, 654 }, 655 ) 656 ba.animate(light, 'intensity', {0.0: 0, 0.1: 1.0, 0.2: 0}, loop=True) 657 ba.timer(1.0, light.delete) 658 659 def _update_race_mine(self) -> None: 660 assert self._race_mines is not None 661 m_index = -1 662 rmine = None 663 for _i in range(3): 664 m_index = random.randrange(len(self._race_mines)) 665 rmine = self._race_mines[m_index] 666 if not rmine.mine: 667 break 668 assert rmine is not None 669 if not rmine.mine: 670 self._flash_mine(m_index) 671 ba.timer(0.95, ba.Call(self._make_mine, m_index)) 672 673 def spawn_player(self, player: Player) -> ba.Actor: 674 if player.team.finished: 675 # FIXME: This is not type-safe! 676 # This call is expected to always return an Actor! 677 # Perhaps we need something like can_spawn_player()... 678 # noinspection PyTypeChecker 679 return None # type: ignore 680 pos = self._regions[player.last_region].pos 681 682 # Don't use the full region so we're less likely to spawn off a cliff. 683 region_scale = 0.8 684 x_range = ( 685 (-0.5, 0.5) 686 if pos[3] == 0 687 else (-region_scale * pos[3], region_scale * pos[3]) 688 ) 689 z_range = ( 690 (-0.5, 0.5) 691 if pos[5] == 0 692 else (-region_scale * pos[5], region_scale * pos[5]) 693 ) 694 pos = ( 695 pos[0] + random.uniform(*x_range), 696 pos[1], 697 pos[2] + random.uniform(*z_range), 698 ) 699 spaz = self.spawn_player_spaz( 700 player, position=pos, angle=90 if not self._race_started else None 701 ) 702 assert spaz.node 703 704 # Prevent controlling of characters before the start of the race. 705 if not self._race_started: 706 spaz.disconnect_controls_from_player() 707 708 mathnode = ba.newnode( 709 'math', 710 owner=spaz.node, 711 attrs={'input1': (0, 1.4, 0), 'operation': 'add'}, 712 ) 713 spaz.node.connectattr('torso_position', mathnode, 'input2') 714 715 distance_txt = ba.newnode( 716 'text', 717 owner=spaz.node, 718 attrs={ 719 'text': '', 720 'in_world': True, 721 'color': (1, 1, 0.4), 722 'scale': 0.02, 723 'h_align': 'center', 724 }, 725 ) 726 player.distance_txt = distance_txt 727 mathnode.connectattr('output', distance_txt, 'position') 728 return spaz 729 730 def _check_end_game(self) -> None: 731 732 # If there's no teams left racing, finish. 733 teams_still_in = len([t for t in self.teams if not t.finished]) 734 if teams_still_in == 0: 735 self.end_game() 736 return 737 738 # Count the number of teams that have completed the race. 739 teams_completed = len( 740 [t for t in self.teams if t.finished and t.time is not None] 741 ) 742 743 if teams_completed > 0: 744 session = self.session 745 746 # In teams mode its over as soon as any team finishes the race 747 748 # FIXME: The get_ffa_point_awards code looks dangerous. 749 if isinstance(session, ba.DualTeamSession): 750 self.end_game() 751 else: 752 # In ffa we keep the race going while there's still any points 753 # to be handed out. Find out how many points we have to award 754 # and how many teams have finished, and once that matches 755 # we're done. 756 assert isinstance(session, ba.FreeForAllSession) 757 points_to_award = len(session.get_ffa_point_awards()) 758 if teams_completed >= points_to_award - teams_completed: 759 self.end_game() 760 return 761 762 def end_game(self) -> None: 763 764 # Stop updating our time text, and set it to show the exact last 765 # finish time if we have one. (so users don't get upset if their 766 # final time differs from what they see onscreen by a tiny amount) 767 assert self._timer is not None 768 if self._timer.has_started(): 769 self._timer.stop( 770 endtime=None 771 if self._last_team_time is None 772 else (self._timer.getstarttime() + self._last_team_time) 773 ) 774 775 results = ba.GameResults() 776 777 for team in self.teams: 778 if team.time is not None: 779 # We store time in seconds, but pass a score in milliseconds. 780 results.set_team_score(team, int(team.time * 1000.0)) 781 else: 782 results.set_team_score(team, None) 783 784 # We don't announce a winner in ffa mode since its probably been a 785 # while since the first place guy crossed the finish line so it seems 786 # odd to be announcing that now. 787 self.end( 788 results=results, 789 announce_winning_team=isinstance(self.session, ba.DualTeamSession), 790 ) 791 792 def handlemessage(self, msg: Any) -> Any: 793 if isinstance(msg, ba.PlayerDiedMessage): 794 # Augment default behavior. 795 super().handlemessage(msg) 796 player = msg.getplayer(Player) 797 if not player.finished: 798 self.respawn_player(player, respawn_time=1) 799 else: 800 super().handlemessage(msg)
26@dataclass 27class RaceMine: 28 """Holds info about a mine on the track.""" 29 30 point: Sequence[float] 31 mine: Bomb | None
Holds info about a mine on the track.
34class RaceRegion(ba.Actor): 35 """Region used to track progress during a race.""" 36 37 def __init__(self, pt: Sequence[float], index: int): 38 super().__init__() 39 activity = self.activity 40 assert isinstance(activity, RaceGame) 41 self.pos = pt 42 self.index = index 43 self.node = ba.newnode( 44 'region', 45 delegate=self, 46 attrs={ 47 'position': pt[:3], 48 'scale': (pt[3] * 2.0, pt[4] * 2.0, pt[5] * 2.0), 49 'type': 'box', 50 'materials': [activity.race_region_material], 51 }, 52 )
Region used to track progress during a race.
37 def __init__(self, pt: Sequence[float], index: int): 38 super().__init__() 39 activity = self.activity 40 assert isinstance(activity, RaceGame) 41 self.pos = pt 42 self.index = index 43 self.node = ba.newnode( 44 'region', 45 delegate=self, 46 attrs={ 47 'position': pt[:3], 48 'scale': (pt[3] * 2.0, pt[4] * 2.0, pt[5] * 2.0), 49 'type': 'box', 50 'materials': [activity.race_region_material], 51 }, 52 )
Instantiates an Actor in the current ba.Activity.
Inherited Members
- ba._actor.Actor
- handlemessage
- autoretain
- on_expire
- expired
- exists
- is_alive
- activity
- getactivity
55class Player(ba.Player['Team']): 56 """Our player type for this game.""" 57 58 def __init__(self) -> None: 59 self.distance_txt: ba.Node | None = None 60 self.last_region = 0 61 self.lap = 0 62 self.distance = 0.0 63 self.finished = False 64 self.rank: int | None = None
Our player type for this game.
Inherited Members
- ba._player.Player
- actor
- on_expire
- team
- customdata
- sessionplayer
- node
- position
- exists
- getname
- is_alive
- get_icon
- assigninput
- resetinput
67class Team(ba.Team[Player]): 68 """Our team type for this game.""" 69 70 def __init__(self) -> None: 71 self.time: float | None = None 72 self.lap = 0 73 self.finished = False
Our team type for this game.
Inherited Members
- ba._team.Team
- manual_init
- customdata
- on_expire
- sessionteam
77class RaceGame(ba.TeamGameActivity[Player, Team]): 78 """Game of racing around a track.""" 79 80 name = 'Race' 81 description = 'Run real fast!' 82 scoreconfig = ba.ScoreConfig( 83 label='Time', lower_is_better=True, scoretype=ba.ScoreType.MILLISECONDS 84 ) 85 86 @classmethod 87 def get_available_settings( 88 cls, sessiontype: type[ba.Session] 89 ) -> list[ba.Setting]: 90 settings = [ 91 ba.IntSetting('Laps', min_value=1, default=3, increment=1), 92 ba.IntChoiceSetting( 93 'Time Limit', 94 default=0, 95 choices=[ 96 ('None', 0), 97 ('1 Minute', 60), 98 ('2 Minutes', 120), 99 ('5 Minutes', 300), 100 ('10 Minutes', 600), 101 ('20 Minutes', 1200), 102 ], 103 ), 104 ba.IntChoiceSetting( 105 'Mine Spawning', 106 default=4000, 107 choices=[ 108 ('No Mines', 0), 109 ('8 Seconds', 8000), 110 ('4 Seconds', 4000), 111 ('2 Seconds', 2000), 112 ], 113 ), 114 ba.IntChoiceSetting( 115 'Bomb Spawning', 116 choices=[ 117 ('None', 0), 118 ('8 Seconds', 8000), 119 ('4 Seconds', 4000), 120 ('2 Seconds', 2000), 121 ('1 Second', 1000), 122 ], 123 default=2000, 124 ), 125 ba.BoolSetting('Epic Mode', default=False), 126 ] 127 128 # We have some specific settings in teams mode. 129 if issubclass(sessiontype, ba.DualTeamSession): 130 settings.append( 131 ba.BoolSetting('Entire Team Must Finish', default=False) 132 ) 133 return settings 134 135 @classmethod 136 def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool: 137 return issubclass(sessiontype, ba.MultiTeamSession) 138 139 @classmethod 140 def get_supported_maps(cls, sessiontype: type[ba.Session]) -> list[str]: 141 return ba.getmaps('race') 142 143 def __init__(self, settings: dict): 144 self._race_started = False 145 super().__init__(settings) 146 self._scoreboard = Scoreboard() 147 self._score_sound = ba.getsound('score') 148 self._swipsound = ba.getsound('swip') 149 self._last_team_time: float | None = None 150 self._front_race_region: int | None = None 151 self._nub_tex = ba.gettexture('nub') 152 self._beep_1_sound = ba.getsound('raceBeep1') 153 self._beep_2_sound = ba.getsound('raceBeep2') 154 self.race_region_material: ba.Material | None = None 155 self._regions: list[RaceRegion] = [] 156 self._team_finish_pts: int | None = None 157 self._time_text: ba.Actor | None = None 158 self._timer: OnScreenTimer | None = None 159 self._race_mines: list[RaceMine] | None = None 160 self._race_mine_timer: ba.Timer | None = None 161 self._scoreboard_timer: ba.Timer | None = None 162 self._player_order_update_timer: ba.Timer | None = None 163 self._start_lights: list[ba.Node] | None = None 164 self._bomb_spawn_timer: ba.Timer | None = None 165 self._laps = int(settings['Laps']) 166 self._entire_team_must_finish = bool( 167 settings.get('Entire Team Must Finish', False) 168 ) 169 self._time_limit = float(settings['Time Limit']) 170 self._mine_spawning = int(settings['Mine Spawning']) 171 self._bomb_spawning = int(settings['Bomb Spawning']) 172 self._epic_mode = bool(settings['Epic Mode']) 173 174 # Base class overrides. 175 self.slow_motion = self._epic_mode 176 self.default_music = ( 177 ba.MusicType.EPIC_RACE if self._epic_mode else ba.MusicType.RACE 178 ) 179 180 def get_instance_description(self) -> str | Sequence: 181 if ( 182 isinstance(self.session, ba.DualTeamSession) 183 and self._entire_team_must_finish 184 ): 185 t_str = ' Your entire team has to finish.' 186 else: 187 t_str = '' 188 189 if self._laps > 1: 190 return 'Run ${ARG1} laps.' + t_str, self._laps 191 return 'Run 1 lap.' + t_str 192 193 def get_instance_description_short(self) -> str | Sequence: 194 if self._laps > 1: 195 return 'run ${ARG1} laps', self._laps 196 return 'run 1 lap' 197 198 def on_transition_in(self) -> None: 199 super().on_transition_in() 200 shared = SharedObjects.get() 201 pts = self.map.get_def_points('race_point') 202 mat = self.race_region_material = ba.Material() 203 mat.add_actions( 204 conditions=('they_have_material', shared.player_material), 205 actions=( 206 ('modify_part_collision', 'collide', True), 207 ('modify_part_collision', 'physical', False), 208 ('call', 'at_connect', self._handle_race_point_collide), 209 ), 210 ) 211 for rpt in pts: 212 self._regions.append(RaceRegion(rpt, len(self._regions))) 213 214 def _flash_player(self, player: Player, scale: float) -> None: 215 assert isinstance(player.actor, PlayerSpaz) 216 assert player.actor.node 217 pos = player.actor.node.position 218 light = ba.newnode( 219 'light', 220 attrs={ 221 'position': pos, 222 'color': (1, 1, 0), 223 'height_attenuated': False, 224 'radius': 0.4, 225 }, 226 ) 227 ba.timer(0.5, light.delete) 228 ba.animate(light, 'intensity', {0: 0, 0.1: 1.0 * scale, 0.5: 0}) 229 230 def _handle_race_point_collide(self) -> None: 231 # FIXME: Tidy this up. 232 # pylint: disable=too-many-statements 233 # pylint: disable=too-many-branches 234 # pylint: disable=too-many-nested-blocks 235 collision = ba.getcollision() 236 try: 237 region = collision.sourcenode.getdelegate(RaceRegion, True) 238 spaz = collision.opposingnode.getdelegate(PlayerSpaz, True) 239 except ba.NotFoundError: 240 return 241 242 if not spaz.is_alive(): 243 return 244 245 try: 246 player = spaz.getplayer(Player, True) 247 except ba.NotFoundError: 248 return 249 250 last_region = player.last_region 251 this_region = region.index 252 253 if last_region != this_region: 254 255 # If a player tries to skip regions, smite them. 256 # Allow a one region leeway though (its plausible players can get 257 # blown over a region, etc). 258 if this_region > last_region + 2: 259 if player.is_alive(): 260 assert player.actor 261 player.actor.handlemessage(ba.DieMessage()) 262 ba.screenmessage( 263 ba.Lstr( 264 translate=( 265 'statements', 266 'Killing ${NAME} for' 267 ' skipping part of the track!', 268 ), 269 subs=[('${NAME}', player.getname(full=True))], 270 ), 271 color=(1, 0, 0), 272 ) 273 else: 274 # If this player is in first, note that this is the 275 # front-most race-point. 276 if player.rank == 0: 277 self._front_race_region = this_region 278 279 player.last_region = this_region 280 if last_region >= len(self._regions) - 2 and this_region == 0: 281 team = player.team 282 player.lap = min(self._laps, player.lap + 1) 283 284 # In teams mode with all-must-finish on, the team lap 285 # value is the min of all team players. 286 # Otherwise its the max. 287 if ( 288 isinstance(self.session, ba.DualTeamSession) 289 and self._entire_team_must_finish 290 ): 291 team.lap = min(p.lap for p in team.players) 292 else: 293 team.lap = max(p.lap for p in team.players) 294 295 # A player is finishing. 296 if player.lap == self._laps: 297 298 # In teams mode, hand out points based on the order 299 # players come in. 300 if isinstance(self.session, ba.DualTeamSession): 301 assert self._team_finish_pts is not None 302 if self._team_finish_pts > 0: 303 self.stats.player_scored( 304 player, 305 self._team_finish_pts, 306 screenmessage=False, 307 ) 308 self._team_finish_pts -= 25 309 310 # Flash where the player is. 311 self._flash_player(player, 1.0) 312 player.finished = True 313 assert player.actor 314 player.actor.handlemessage( 315 ba.DieMessage(immediate=True) 316 ) 317 318 # Makes sure noone behind them passes them in rank 319 # while finishing. 320 player.distance = 9999.0 321 322 # If the whole team has finished the race. 323 if team.lap == self._laps: 324 ba.playsound(self._score_sound) 325 player.team.finished = True 326 assert self._timer is not None 327 elapsed = ba.time() - self._timer.getstarttime() 328 self._last_team_time = player.team.time = elapsed 329 self._check_end_game() 330 331 # Team has yet to finish. 332 else: 333 ba.playsound(self._swipsound) 334 335 # They've just finished a lap but not the race. 336 else: 337 ba.playsound(self._swipsound) 338 self._flash_player(player, 0.3) 339 340 # Print their lap number over their head. 341 try: 342 assert isinstance(player.actor, PlayerSpaz) 343 mathnode = ba.newnode( 344 'math', 345 owner=player.actor.node, 346 attrs={ 347 'input1': (0, 1.9, 0), 348 'operation': 'add', 349 }, 350 ) 351 player.actor.node.connectattr( 352 'torso_position', mathnode, 'input2' 353 ) 354 tstr = ba.Lstr( 355 resource='lapNumberText', 356 subs=[ 357 ('${CURRENT}', str(player.lap + 1)), 358 ('${TOTAL}', str(self._laps)), 359 ], 360 ) 361 txtnode = ba.newnode( 362 'text', 363 owner=mathnode, 364 attrs={ 365 'text': tstr, 366 'in_world': True, 367 'color': (1, 1, 0, 1), 368 'scale': 0.015, 369 'h_align': 'center', 370 }, 371 ) 372 mathnode.connectattr('output', txtnode, 'position') 373 ba.animate( 374 txtnode, 375 'scale', 376 {0.0: 0, 0.2: 0.019, 2.0: 0.019, 2.2: 0}, 377 ) 378 ba.timer(2.3, mathnode.delete) 379 except Exception: 380 ba.print_exception('Error printing lap.') 381 382 def on_team_join(self, team: Team) -> None: 383 self._update_scoreboard() 384 385 def on_player_leave(self, player: Player) -> None: 386 super().on_player_leave(player) 387 388 # A player leaving disqualifies the team if 'Entire Team Must Finish' 389 # is on (otherwise in teams mode everyone could just leave except the 390 # leading player to win). 391 if ( 392 isinstance(self.session, ba.DualTeamSession) 393 and self._entire_team_must_finish 394 ): 395 ba.screenmessage( 396 ba.Lstr( 397 translate=( 398 'statements', 399 '${TEAM} is disqualified because ${PLAYER} left', 400 ), 401 subs=[ 402 ('${TEAM}', player.team.name), 403 ('${PLAYER}', player.getname(full=True)), 404 ], 405 ), 406 color=(1, 1, 0), 407 ) 408 player.team.finished = True 409 player.team.time = None 410 player.team.lap = 0 411 ba.playsound(ba.getsound('boo')) 412 for otherplayer in player.team.players: 413 otherplayer.lap = 0 414 otherplayer.finished = True 415 try: 416 if otherplayer.actor is not None: 417 otherplayer.actor.handlemessage(ba.DieMessage()) 418 except Exception: 419 ba.print_exception('Error sending DieMessage.') 420 421 # Defer so team/player lists will be updated. 422 ba.pushcall(self._check_end_game) 423 424 def _update_scoreboard(self) -> None: 425 for team in self.teams: 426 distances = [player.distance for player in team.players] 427 if not distances: 428 teams_dist = 0.0 429 else: 430 if ( 431 isinstance(self.session, ba.DualTeamSession) 432 and self._entire_team_must_finish 433 ): 434 teams_dist = min(distances) 435 else: 436 teams_dist = max(distances) 437 self._scoreboard.set_team_value( 438 team, 439 teams_dist, 440 self._laps, 441 flash=(teams_dist >= float(self._laps)), 442 show_value=False, 443 ) 444 445 def on_begin(self) -> None: 446 from bastd.actor.onscreentimer import OnScreenTimer 447 448 super().on_begin() 449 self.setup_standard_time_limit(self._time_limit) 450 self.setup_standard_powerup_drops() 451 self._team_finish_pts = 100 452 453 # Throw a timer up on-screen. 454 self._time_text = ba.NodeActor( 455 ba.newnode( 456 'text', 457 attrs={ 458 'v_attach': 'top', 459 'h_attach': 'center', 460 'h_align': 'center', 461 'color': (1, 1, 0.5, 1), 462 'flatness': 0.5, 463 'shadow': 0.5, 464 'position': (0, -50), 465 'scale': 1.4, 466 'text': '', 467 }, 468 ) 469 ) 470 self._timer = OnScreenTimer() 471 472 if self._mine_spawning != 0: 473 self._race_mines = [ 474 RaceMine(point=p, mine=None) 475 for p in self.map.get_def_points('race_mine') 476 ] 477 if self._race_mines: 478 self._race_mine_timer = ba.Timer( 479 0.001 * self._mine_spawning, 480 self._update_race_mine, 481 repeat=True, 482 ) 483 484 self._scoreboard_timer = ba.Timer( 485 0.25, self._update_scoreboard, repeat=True 486 ) 487 self._player_order_update_timer = ba.Timer( 488 0.25, self._update_player_order, repeat=True 489 ) 490 491 if self.slow_motion: 492 t_scale = 0.4 493 light_y = 50 494 else: 495 t_scale = 1.0 496 light_y = 150 497 lstart = 7.1 * t_scale 498 inc = 1.25 * t_scale 499 500 ba.timer(lstart, self._do_light_1) 501 ba.timer(lstart + inc, self._do_light_2) 502 ba.timer(lstart + 2 * inc, self._do_light_3) 503 ba.timer(lstart + 3 * inc, self._start_race) 504 505 self._start_lights = [] 506 for i in range(4): 507 lnub = ba.newnode( 508 'image', 509 attrs={ 510 'texture': ba.gettexture('nub'), 511 'opacity': 1.0, 512 'absolute_scale': True, 513 'position': (-75 + i * 50, light_y), 514 'scale': (50, 50), 515 'attach': 'center', 516 }, 517 ) 518 ba.animate( 519 lnub, 520 'opacity', 521 { 522 4.0 * t_scale: 0, 523 5.0 * t_scale: 1.0, 524 12.0 * t_scale: 1.0, 525 12.5 * t_scale: 0.0, 526 }, 527 ) 528 ba.timer(13.0 * t_scale, lnub.delete) 529 self._start_lights.append(lnub) 530 531 self._start_lights[0].color = (0.2, 0, 0) 532 self._start_lights[1].color = (0.2, 0, 0) 533 self._start_lights[2].color = (0.2, 0.05, 0) 534 self._start_lights[3].color = (0.0, 0.3, 0) 535 536 def _do_light_1(self) -> None: 537 assert self._start_lights is not None 538 self._start_lights[0].color = (1.0, 0, 0) 539 ba.playsound(self._beep_1_sound) 540 541 def _do_light_2(self) -> None: 542 assert self._start_lights is not None 543 self._start_lights[1].color = (1.0, 0, 0) 544 ba.playsound(self._beep_1_sound) 545 546 def _do_light_3(self) -> None: 547 assert self._start_lights is not None 548 self._start_lights[2].color = (1.0, 0.3, 0) 549 ba.playsound(self._beep_1_sound) 550 551 def _start_race(self) -> None: 552 assert self._start_lights is not None 553 self._start_lights[3].color = (0.0, 1.0, 0) 554 ba.playsound(self._beep_2_sound) 555 for player in self.players: 556 if player.actor is not None: 557 try: 558 assert isinstance(player.actor, PlayerSpaz) 559 player.actor.connect_controls_to_player() 560 except Exception: 561 ba.print_exception('Error in race player connects.') 562 assert self._timer is not None 563 self._timer.start() 564 565 if self._bomb_spawning != 0: 566 self._bomb_spawn_timer = ba.Timer( 567 0.001 * self._bomb_spawning, self._spawn_bomb, repeat=True 568 ) 569 570 self._race_started = True 571 572 def _update_player_order(self) -> None: 573 574 # Calc all player distances. 575 for player in self.players: 576 pos: ba.Vec3 | None 577 try: 578 pos = player.position 579 except ba.NotFoundError: 580 pos = None 581 if pos is not None: 582 r_index = player.last_region 583 rg1 = self._regions[r_index] 584 r1pt = ba.Vec3(rg1.pos[:3]) 585 rg2 = ( 586 self._regions[0] 587 if r_index == len(self._regions) - 1 588 else self._regions[r_index + 1] 589 ) 590 r2pt = ba.Vec3(rg2.pos[:3]) 591 r2dist = (pos - r2pt).length() 592 amt = 1.0 - (r2dist / (r2pt - r1pt).length()) 593 amt = player.lap + (r_index + amt) * (1.0 / len(self._regions)) 594 player.distance = amt 595 596 # Sort players by distance and update their ranks. 597 p_list = [(player.distance, player) for player in self.players] 598 599 p_list.sort(reverse=True, key=lambda x: x[0]) 600 for i, plr in enumerate(p_list): 601 plr[1].rank = i 602 if plr[1].actor: 603 node = plr[1].distance_txt 604 if node: 605 node.text = str(i + 1) if plr[1].is_alive() else '' 606 607 def _spawn_bomb(self) -> None: 608 if self._front_race_region is None: 609 return 610 region = (self._front_race_region + 3) % len(self._regions) 611 pos = self._regions[region].pos 612 613 # Don't use the full region so we're less likely to spawn off a cliff. 614 region_scale = 0.8 615 x_range = ( 616 (-0.5, 0.5) 617 if pos[3] == 0 618 else (-region_scale * pos[3], region_scale * pos[3]) 619 ) 620 z_range = ( 621 (-0.5, 0.5) 622 if pos[5] == 0 623 else (-region_scale * pos[5], region_scale * pos[5]) 624 ) 625 pos = ( 626 pos[0] + random.uniform(*x_range), 627 pos[1] + 1.0, 628 pos[2] + random.uniform(*z_range), 629 ) 630 ba.timer( 631 random.uniform(0.0, 2.0), ba.WeakCall(self._spawn_bomb_at_pos, pos) 632 ) 633 634 def _spawn_bomb_at_pos(self, pos: Sequence[float]) -> None: 635 if self.has_ended(): 636 return 637 Bomb(position=pos, bomb_type='normal').autoretain() 638 639 def _make_mine(self, i: int) -> None: 640 assert self._race_mines is not None 641 rmine = self._race_mines[i] 642 rmine.mine = Bomb(position=rmine.point[:3], bomb_type='land_mine') 643 rmine.mine.arm() 644 645 def _flash_mine(self, i: int) -> None: 646 assert self._race_mines is not None 647 rmine = self._race_mines[i] 648 light = ba.newnode( 649 'light', 650 attrs={ 651 'position': rmine.point[:3], 652 'color': (1, 0.2, 0.2), 653 'radius': 0.1, 654 'height_attenuated': False, 655 }, 656 ) 657 ba.animate(light, 'intensity', {0.0: 0, 0.1: 1.0, 0.2: 0}, loop=True) 658 ba.timer(1.0, light.delete) 659 660 def _update_race_mine(self) -> None: 661 assert self._race_mines is not None 662 m_index = -1 663 rmine = None 664 for _i in range(3): 665 m_index = random.randrange(len(self._race_mines)) 666 rmine = self._race_mines[m_index] 667 if not rmine.mine: 668 break 669 assert rmine is not None 670 if not rmine.mine: 671 self._flash_mine(m_index) 672 ba.timer(0.95, ba.Call(self._make_mine, m_index)) 673 674 def spawn_player(self, player: Player) -> ba.Actor: 675 if player.team.finished: 676 # FIXME: This is not type-safe! 677 # This call is expected to always return an Actor! 678 # Perhaps we need something like can_spawn_player()... 679 # noinspection PyTypeChecker 680 return None # type: ignore 681 pos = self._regions[player.last_region].pos 682 683 # Don't use the full region so we're less likely to spawn off a cliff. 684 region_scale = 0.8 685 x_range = ( 686 (-0.5, 0.5) 687 if pos[3] == 0 688 else (-region_scale * pos[3], region_scale * pos[3]) 689 ) 690 z_range = ( 691 (-0.5, 0.5) 692 if pos[5] == 0 693 else (-region_scale * pos[5], region_scale * pos[5]) 694 ) 695 pos = ( 696 pos[0] + random.uniform(*x_range), 697 pos[1], 698 pos[2] + random.uniform(*z_range), 699 ) 700 spaz = self.spawn_player_spaz( 701 player, position=pos, angle=90 if not self._race_started else None 702 ) 703 assert spaz.node 704 705 # Prevent controlling of characters before the start of the race. 706 if not self._race_started: 707 spaz.disconnect_controls_from_player() 708 709 mathnode = ba.newnode( 710 'math', 711 owner=spaz.node, 712 attrs={'input1': (0, 1.4, 0), 'operation': 'add'}, 713 ) 714 spaz.node.connectattr('torso_position', mathnode, 'input2') 715 716 distance_txt = ba.newnode( 717 'text', 718 owner=spaz.node, 719 attrs={ 720 'text': '', 721 'in_world': True, 722 'color': (1, 1, 0.4), 723 'scale': 0.02, 724 'h_align': 'center', 725 }, 726 ) 727 player.distance_txt = distance_txt 728 mathnode.connectattr('output', distance_txt, 'position') 729 return spaz 730 731 def _check_end_game(self) -> None: 732 733 # If there's no teams left racing, finish. 734 teams_still_in = len([t for t in self.teams if not t.finished]) 735 if teams_still_in == 0: 736 self.end_game() 737 return 738 739 # Count the number of teams that have completed the race. 740 teams_completed = len( 741 [t for t in self.teams if t.finished and t.time is not None] 742 ) 743 744 if teams_completed > 0: 745 session = self.session 746 747 # In teams mode its over as soon as any team finishes the race 748 749 # FIXME: The get_ffa_point_awards code looks dangerous. 750 if isinstance(session, ba.DualTeamSession): 751 self.end_game() 752 else: 753 # In ffa we keep the race going while there's still any points 754 # to be handed out. Find out how many points we have to award 755 # and how many teams have finished, and once that matches 756 # we're done. 757 assert isinstance(session, ba.FreeForAllSession) 758 points_to_award = len(session.get_ffa_point_awards()) 759 if teams_completed >= points_to_award - teams_completed: 760 self.end_game() 761 return 762 763 def end_game(self) -> None: 764 765 # Stop updating our time text, and set it to show the exact last 766 # finish time if we have one. (so users don't get upset if their 767 # final time differs from what they see onscreen by a tiny amount) 768 assert self._timer is not None 769 if self._timer.has_started(): 770 self._timer.stop( 771 endtime=None 772 if self._last_team_time is None 773 else (self._timer.getstarttime() + self._last_team_time) 774 ) 775 776 results = ba.GameResults() 777 778 for team in self.teams: 779 if team.time is not None: 780 # We store time in seconds, but pass a score in milliseconds. 781 results.set_team_score(team, int(team.time * 1000.0)) 782 else: 783 results.set_team_score(team, None) 784 785 # We don't announce a winner in ffa mode since its probably been a 786 # while since the first place guy crossed the finish line so it seems 787 # odd to be announcing that now. 788 self.end( 789 results=results, 790 announce_winning_team=isinstance(self.session, ba.DualTeamSession), 791 ) 792 793 def handlemessage(self, msg: Any) -> Any: 794 if isinstance(msg, ba.PlayerDiedMessage): 795 # Augment default behavior. 796 super().handlemessage(msg) 797 player = msg.getplayer(Player) 798 if not player.finished: 799 self.respawn_player(player, respawn_time=1) 800 else: 801 super().handlemessage(msg)
Game of racing around a track.
143 def __init__(self, settings: dict): 144 self._race_started = False 145 super().__init__(settings) 146 self._scoreboard = Scoreboard() 147 self._score_sound = ba.getsound('score') 148 self._swipsound = ba.getsound('swip') 149 self._last_team_time: float | None = None 150 self._front_race_region: int | None = None 151 self._nub_tex = ba.gettexture('nub') 152 self._beep_1_sound = ba.getsound('raceBeep1') 153 self._beep_2_sound = ba.getsound('raceBeep2') 154 self.race_region_material: ba.Material | None = None 155 self._regions: list[RaceRegion] = [] 156 self._team_finish_pts: int | None = None 157 self._time_text: ba.Actor | None = None 158 self._timer: OnScreenTimer | None = None 159 self._race_mines: list[RaceMine] | None = None 160 self._race_mine_timer: ba.Timer | None = None 161 self._scoreboard_timer: ba.Timer | None = None 162 self._player_order_update_timer: ba.Timer | None = None 163 self._start_lights: list[ba.Node] | None = None 164 self._bomb_spawn_timer: ba.Timer | None = None 165 self._laps = int(settings['Laps']) 166 self._entire_team_must_finish = bool( 167 settings.get('Entire Team Must Finish', False) 168 ) 169 self._time_limit = float(settings['Time Limit']) 170 self._mine_spawning = int(settings['Mine Spawning']) 171 self._bomb_spawning = int(settings['Bomb Spawning']) 172 self._epic_mode = bool(settings['Epic Mode']) 173 174 # Base class overrides. 175 self.slow_motion = self._epic_mode 176 self.default_music = ( 177 ba.MusicType.EPIC_RACE if self._epic_mode else ba.MusicType.RACE 178 )
Instantiate the Activity.
86 @classmethod 87 def get_available_settings( 88 cls, sessiontype: type[ba.Session] 89 ) -> list[ba.Setting]: 90 settings = [ 91 ba.IntSetting('Laps', min_value=1, default=3, increment=1), 92 ba.IntChoiceSetting( 93 'Time Limit', 94 default=0, 95 choices=[ 96 ('None', 0), 97 ('1 Minute', 60), 98 ('2 Minutes', 120), 99 ('5 Minutes', 300), 100 ('10 Minutes', 600), 101 ('20 Minutes', 1200), 102 ], 103 ), 104 ba.IntChoiceSetting( 105 'Mine Spawning', 106 default=4000, 107 choices=[ 108 ('No Mines', 0), 109 ('8 Seconds', 8000), 110 ('4 Seconds', 4000), 111 ('2 Seconds', 2000), 112 ], 113 ), 114 ba.IntChoiceSetting( 115 'Bomb Spawning', 116 choices=[ 117 ('None', 0), 118 ('8 Seconds', 8000), 119 ('4 Seconds', 4000), 120 ('2 Seconds', 2000), 121 ('1 Second', 1000), 122 ], 123 default=2000, 124 ), 125 ba.BoolSetting('Epic Mode', default=False), 126 ] 127 128 # We have some specific settings in teams mode. 129 if issubclass(sessiontype, ba.DualTeamSession): 130 settings.append( 131 ba.BoolSetting('Entire Team Must Finish', default=False) 132 ) 133 return settings
Return a list of settings relevant to this game type when running under the provided session type.
135 @classmethod 136 def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool: 137 return issubclass(sessiontype, ba.MultiTeamSession)
Class method override; returns True for ba.DualTeamSessions and ba.FreeForAllSessions; False otherwise.
139 @classmethod 140 def get_supported_maps(cls, sessiontype: type[ba.Session]) -> list[str]: 141 return ba.getmaps('race')
Called by the default ba.GameActivity.create_settings_ui() implementation; should return a list of map names valid for this game-type for the given ba.Session type.
180 def get_instance_description(self) -> str | Sequence: 181 if ( 182 isinstance(self.session, ba.DualTeamSession) 183 and self._entire_team_must_finish 184 ): 185 t_str = ' Your entire team has to finish.' 186 else: 187 t_str = '' 188 189 if self._laps > 1: 190 return 'Run ${ARG1} laps.' + t_str, self._laps 191 return 'Run 1 lap.' + t_str
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.
193 def get_instance_description_short(self) -> str | Sequence: 194 if self._laps > 1: 195 return 'run ${ARG1} laps', self._laps 196 return 'run 1 lap'
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.
198 def on_transition_in(self) -> None: 199 super().on_transition_in() 200 shared = SharedObjects.get() 201 pts = self.map.get_def_points('race_point') 202 mat = self.race_region_material = ba.Material() 203 mat.add_actions( 204 conditions=('they_have_material', shared.player_material), 205 actions=( 206 ('modify_part_collision', 'collide', True), 207 ('modify_part_collision', 'physical', False), 208 ('call', 'at_connect', self._handle_race_point_collide), 209 ), 210 ) 211 for rpt in pts: 212 self._regions.append(RaceRegion(rpt, len(self._regions)))
Called when the Activity is first becoming visible.
Upon this call, the Activity should fade in backgrounds, start playing music, etc. It does not yet have access to players or teams, however. They remain owned by the previous Activity up until ba.Activity.on_begin() is called.
Called when a new ba.Team joins the Activity.
(including the initial set of Teams)
385 def on_player_leave(self, player: Player) -> None: 386 super().on_player_leave(player) 387 388 # A player leaving disqualifies the team if 'Entire Team Must Finish' 389 # is on (otherwise in teams mode everyone could just leave except the 390 # leading player to win). 391 if ( 392 isinstance(self.session, ba.DualTeamSession) 393 and self._entire_team_must_finish 394 ): 395 ba.screenmessage( 396 ba.Lstr( 397 translate=( 398 'statements', 399 '${TEAM} is disqualified because ${PLAYER} left', 400 ), 401 subs=[ 402 ('${TEAM}', player.team.name), 403 ('${PLAYER}', player.getname(full=True)), 404 ], 405 ), 406 color=(1, 1, 0), 407 ) 408 player.team.finished = True 409 player.team.time = None 410 player.team.lap = 0 411 ba.playsound(ba.getsound('boo')) 412 for otherplayer in player.team.players: 413 otherplayer.lap = 0 414 otherplayer.finished = True 415 try: 416 if otherplayer.actor is not None: 417 otherplayer.actor.handlemessage(ba.DieMessage()) 418 except Exception: 419 ba.print_exception('Error sending DieMessage.') 420 421 # Defer so team/player lists will be updated. 422 ba.pushcall(self._check_end_game)
Called when a ba.Player is leaving the Activity.
445 def on_begin(self) -> None: 446 from bastd.actor.onscreentimer import OnScreenTimer 447 448 super().on_begin() 449 self.setup_standard_time_limit(self._time_limit) 450 self.setup_standard_powerup_drops() 451 self._team_finish_pts = 100 452 453 # Throw a timer up on-screen. 454 self._time_text = ba.NodeActor( 455 ba.newnode( 456 'text', 457 attrs={ 458 'v_attach': 'top', 459 'h_attach': 'center', 460 'h_align': 'center', 461 'color': (1, 1, 0.5, 1), 462 'flatness': 0.5, 463 'shadow': 0.5, 464 'position': (0, -50), 465 'scale': 1.4, 466 'text': '', 467 }, 468 ) 469 ) 470 self._timer = OnScreenTimer() 471 472 if self._mine_spawning != 0: 473 self._race_mines = [ 474 RaceMine(point=p, mine=None) 475 for p in self.map.get_def_points('race_mine') 476 ] 477 if self._race_mines: 478 self._race_mine_timer = ba.Timer( 479 0.001 * self._mine_spawning, 480 self._update_race_mine, 481 repeat=True, 482 ) 483 484 self._scoreboard_timer = ba.Timer( 485 0.25, self._update_scoreboard, repeat=True 486 ) 487 self._player_order_update_timer = ba.Timer( 488 0.25, self._update_player_order, repeat=True 489 ) 490 491 if self.slow_motion: 492 t_scale = 0.4 493 light_y = 50 494 else: 495 t_scale = 1.0 496 light_y = 150 497 lstart = 7.1 * t_scale 498 inc = 1.25 * t_scale 499 500 ba.timer(lstart, self._do_light_1) 501 ba.timer(lstart + inc, self._do_light_2) 502 ba.timer(lstart + 2 * inc, self._do_light_3) 503 ba.timer(lstart + 3 * inc, self._start_race) 504 505 self._start_lights = [] 506 for i in range(4): 507 lnub = ba.newnode( 508 'image', 509 attrs={ 510 'texture': ba.gettexture('nub'), 511 'opacity': 1.0, 512 'absolute_scale': True, 513 'position': (-75 + i * 50, light_y), 514 'scale': (50, 50), 515 'attach': 'center', 516 }, 517 ) 518 ba.animate( 519 lnub, 520 'opacity', 521 { 522 4.0 * t_scale: 0, 523 5.0 * t_scale: 1.0, 524 12.0 * t_scale: 1.0, 525 12.5 * t_scale: 0.0, 526 }, 527 ) 528 ba.timer(13.0 * t_scale, lnub.delete) 529 self._start_lights.append(lnub) 530 531 self._start_lights[0].color = (0.2, 0, 0) 532 self._start_lights[1].color = (0.2, 0, 0) 533 self._start_lights[2].color = (0.2, 0.05, 0) 534 self._start_lights[3].color = (0.0, 0.3, 0)
Called once the previous ba.Activity has finished transitioning out.
At this point the activity's initial players and teams are filled in and it should begin its actual game logic.
674 def spawn_player(self, player: Player) -> ba.Actor: 675 if player.team.finished: 676 # FIXME: This is not type-safe! 677 # This call is expected to always return an Actor! 678 # Perhaps we need something like can_spawn_player()... 679 # noinspection PyTypeChecker 680 return None # type: ignore 681 pos = self._regions[player.last_region].pos 682 683 # Don't use the full region so we're less likely to spawn off a cliff. 684 region_scale = 0.8 685 x_range = ( 686 (-0.5, 0.5) 687 if pos[3] == 0 688 else (-region_scale * pos[3], region_scale * pos[3]) 689 ) 690 z_range = ( 691 (-0.5, 0.5) 692 if pos[5] == 0 693 else (-region_scale * pos[5], region_scale * pos[5]) 694 ) 695 pos = ( 696 pos[0] + random.uniform(*x_range), 697 pos[1], 698 pos[2] + random.uniform(*z_range), 699 ) 700 spaz = self.spawn_player_spaz( 701 player, position=pos, angle=90 if not self._race_started else None 702 ) 703 assert spaz.node 704 705 # Prevent controlling of characters before the start of the race. 706 if not self._race_started: 707 spaz.disconnect_controls_from_player() 708 709 mathnode = ba.newnode( 710 'math', 711 owner=spaz.node, 712 attrs={'input1': (0, 1.4, 0), 'operation': 'add'}, 713 ) 714 spaz.node.connectattr('torso_position', mathnode, 'input2') 715 716 distance_txt = ba.newnode( 717 'text', 718 owner=spaz.node, 719 attrs={ 720 'text': '', 721 'in_world': True, 722 'color': (1, 1, 0.4), 723 'scale': 0.02, 724 'h_align': 'center', 725 }, 726 ) 727 player.distance_txt = distance_txt 728 mathnode.connectattr('output', distance_txt, 'position') 729 return spaz
Spawn something for the provided ba.Player.
The default implementation simply calls spawn_player_spaz().
763 def end_game(self) -> None: 764 765 # Stop updating our time text, and set it to show the exact last 766 # finish time if we have one. (so users don't get upset if their 767 # final time differs from what they see onscreen by a tiny amount) 768 assert self._timer is not None 769 if self._timer.has_started(): 770 self._timer.stop( 771 endtime=None 772 if self._last_team_time is None 773 else (self._timer.getstarttime() + self._last_team_time) 774 ) 775 776 results = ba.GameResults() 777 778 for team in self.teams: 779 if team.time is not None: 780 # We store time in seconds, but pass a score in milliseconds. 781 results.set_team_score(team, int(team.time * 1000.0)) 782 else: 783 results.set_team_score(team, None) 784 785 # We don't announce a winner in ffa mode since its probably been a 786 # while since the first place guy crossed the finish line so it seems 787 # odd to be announcing that now. 788 self.end( 789 results=results, 790 announce_winning_team=isinstance(self.session, ba.DualTeamSession), 791 )
Tell the game to wrap up and call ba.Activity.end() immediately.
This method should be overridden by subclasses. A game should always be prepared to end and deliver results, even if there is no 'winner' yet; this way things like the standard time-limit (ba.GameActivity.setup_standard_time_limit()) will work with the game.
793 def handlemessage(self, msg: Any) -> Any: 794 if isinstance(msg, ba.PlayerDiedMessage): 795 # Augment default behavior. 796 super().handlemessage(msg) 797 player = msg.getplayer(Player) 798 if not player.finished: 799 self.respawn_player(player, respawn_time=1) 800 else: 801 super().handlemessage(msg)
General message handling; can be passed any message object.
Inherited Members
- ba._teamgame.TeamGameActivity
- spawn_player_spaz
- end
- ba._gameactivity.GameActivity
- allow_pausing
- allow_kick_idle_players
- create_settings_ui
- getscoreconfig
- getname
- get_display_string
- get_team_display_string
- get_description
- get_description_display_string
- get_settings_display_string
- map
- get_instance_display_string
- get_instance_scoreboard_display_string
- on_continue
- is_waiting_for_continue
- continue_or_end_game
- on_player_join
- respawn_player
- spawn_player_if_exists
- setup_standard_powerup_drops
- setup_standard_time_limit
- show_zoom_message
- ba._activity.Activity
- settings_raw
- teams
- players
- announce_player_deaths
- is_joining_activity
- use_fixed_vr_overlay
- inherits_slow_motion
- inherits_music
- inherits_vr_camera_offset
- inherits_vr_overlay_center
- inherits_tint
- allow_mid_activity_joins
- transition_time
- can_show_ad_on_death
- globalsnode
- stats
- on_expire
- customdata
- expired
- playertype
- teamtype
- retain_actor
- add_actor_weak_ref
- session
- on_team_leave
- on_transition_out
- has_transitioned_in
- has_begun
- has_ended
- is_transitioning_out
- transition_out
- create_player
- create_team
- ba._dependency.DependencyComponent
- dep_is_present
- get_dynamic_deps