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