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