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