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