# Porting to api 8 made easier by baport.(https://github.com/bombsquad-community/baport) # Released under the MIT License. See LICENSE for details. # """Defines Race mini-game.""" # ba_meta require api 8 # (see https://ballistica.net/wiki/meta-tag-system) from __future__ import annotations import random from typing import TYPE_CHECKING from dataclasses import dataclass import babase import bauiv1 as bui import bascenev1 as bs from bascenev1lib.actor.bomb import Bomb, Blast, ExplodeHitMessage from bascenev1lib.actor.playerspaz import PlayerSpaz from bascenev1lib.actor.scoreboard import Scoreboard from bascenev1lib.gameutils import SharedObjects if TYPE_CHECKING: from typing import (Any, Type, Tuple, List, Sequence, Optional, Dict, Union) from bascenev1lib.actor.onscreentimer import OnScreenTimer class NewBlast(Blast): def handlemessage(self, msg: Any) -> Any: if isinstance(msg, ExplodeHitMessage): pass else: return super().handlemessage(msg) @dataclass class RaceMine: """Holds info about a mine on the track.""" point: Sequence[float] mine: Optional[Bomb] class RaceRegion(bs.Actor): """Region used to track progress during a race.""" def __init__(self, pt: Sequence[float], index: int): super().__init__() activity = self.activity assert isinstance(activity, RaceGame) self.pos = pt self.index = index self.node = bs.newnode( 'region', delegate=self, attrs={ 'position': pt[:3], 'scale': (pt[3] * 2.0, pt[4] * 2.0, pt[5] * 2.0), 'type': 'box', 'materials': [activity.race_region_material] }) class Player(bs.Player['Team']): """Our player type for this game.""" def __init__(self) -> None: self.distance_txt: Optional[bs.Node] = None self.last_region = 0 self.lap = 0 self.distance = 0.0 self.finished = False self.rank: Optional[int] = None class Team(bs.Team[Player]): """Our team type for this game.""" def __init__(self) -> None: self.time: Optional[float] = None self.lap = 0 self.finished = False # ba_meta export bascenev1.GameActivity class SquidRaceGame(bs.TeamGameActivity[Player, Team]): """Game of racing around a track.""" name = 'Squid Race' description = 'Run real fast!' scoreconfig = bs.ScoreConfig(label='Time', lower_is_better=True, scoretype=bs.ScoreType.MILLISECONDS) @classmethod def get_available_settings( cls, sessiontype: Type[bs.Session]) -> List[babase.Setting]: settings = [ bs.IntSetting('Laps', min_value=1, default=3, increment=1), bs.IntChoiceSetting( 'Time Limit', default=0, choices=[ ('None', 0), ('1 Minute', 60), ('2 Minutes', 120), ('5 Minutes', 300), ('10 Minutes', 600), ('20 Minutes', 1200), ], ), bs.IntChoiceSetting( 'Mine Spawning', default=4000, choices=[ ('No Mines', 0), ('8 Seconds', 8000), ('4 Seconds', 4000), ('2 Seconds', 2000), ], ), bs.IntChoiceSetting( 'Bomb Spawning', choices=[ ('None', 0), ('8 Seconds', 8000), ('4 Seconds', 4000), ('2 Seconds', 2000), ('1 Second', 1000), ], default=2000, ), bs.BoolSetting('Epic Mode', default=False), ] # We have some specific settings in teams mode. if issubclass(sessiontype, bs.DualTeamSession): settings.append( bs.BoolSetting('Entire Team Must Finish', default=False)) return settings @classmethod def supports_session_type(cls, sessiontype: Type[bs.Session]) -> bool: return issubclass(sessiontype, bs.MultiTeamSession) @classmethod def get_supported_maps(cls, sessiontype: Type[bs.Session]) -> List[str]: return bs.app.classic.getmaps('race') def __init__(self, settings: dict): self._race_started = False super().__init__(settings) self._scoreboard = Scoreboard() self._score_sound = bs.getsound('score') self._swipsound = bs.getsound('swip') self._last_team_time: Optional[float] = None self._front_race_region: Optional[int] = None self._nub_tex = bs.gettexture('nub') self._beep_1_sound = bs.getsound('raceBeep1') self._beep_2_sound = bs.getsound('raceBeep2') self.race_region_material: Optional[bs.Material] = None self._regions: List[RaceRegion] = [] self._team_finish_pts: Optional[int] = None self._time_text: Optional[bs.Actor] = None self._timer: Optional[OnScreenTimer] = None self._race_mines: Optional[List[RaceMine]] = None self._race_mine_timer: Optional[bs.Timer] = None self._scoreboard_timer: Optional[bs.Timer] = None self._player_order_update_timer: Optional[bs.Timer] = None self._start_lights: Optional[List[bs.Node]] = None self._squid_lights: Optional[List[bs.Node]] = None self._countdown_timer: int = 0 self._sq_mode: str = 'Easy' self._tick_timer: Optional[bs.Timer] = None self._bomb_spawn_timer: Optional[bs.Timer] = None self._laps = int(settings['Laps']) self._entire_team_must_finish = bool( settings.get('Entire Team Must Finish', False)) self._time_limit = float(settings['Time Limit']) self._mine_spawning = int(settings['Mine Spawning']) self._bomb_spawning = int(settings['Bomb Spawning']) self._epic_mode = bool(settings['Epic Mode']) self._countdownsounds = { 10: bs.getsound('announceTen'), 9: bs.getsound('announceNine'), 8: bs.getsound('announceEight'), 7: bs.getsound('announceSeven'), 6: bs.getsound('announceSix'), 5: bs.getsound('announceFive'), 4: bs.getsound('announceFour'), 3: bs.getsound('announceThree'), 2: bs.getsound('announceTwo'), 1: bs.getsound('announceOne') } # Base class overrides. self.slow_motion = self._epic_mode self.default_music = (bs.MusicType.EPIC_RACE if self._epic_mode else bs.MusicType.RACE) def get_instance_description(self) -> Union[str, Sequence]: if (isinstance(self.session, bs.DualTeamSession) and self._entire_team_must_finish): t_str = ' Your entire team has to finish.' else: t_str = '' if self._laps > 1: return 'Run ${ARG1} laps.' + t_str, self._laps return 'Run 1 lap.' + t_str def get_instance_description_short(self) -> Union[str, Sequence]: if self._laps > 1: return 'run ${ARG1} laps', self._laps return 'run 1 lap' def on_transition_in(self) -> None: super().on_transition_in() shared = SharedObjects.get() pts = self.map.get_def_points('race_point') mat = self.race_region_material = bs.Material() mat.add_actions(conditions=('they_have_material', shared.player_material), actions=( ('modify_part_collision', 'collide', True), ('modify_part_collision', 'physical', False), ('call', 'at_connect', self._handle_race_point_collide), )) for rpt in pts: self._regions.append(RaceRegion(rpt, len(self._regions))) def _flash_player(self, player: Player, scale: float) -> None: assert isinstance(player.actor, PlayerSpaz) assert player.actor.node pos = player.actor.node.position light = bs.newnode('light', attrs={ 'position': pos, 'color': (1, 1, 0), 'height_attenuated': False, 'radius': 0.4 }) bs.timer(0.5, light.delete) bs.animate(light, 'intensity', {0: 0, 0.1: 1.0 * scale, 0.5: 0}) def _handle_race_point_collide(self) -> None: # FIXME: Tidy this up. # pylint: disable=too-many-statements # pylint: disable=too-many-branches # pylint: disable=too-many-nested-blocks collision = bs.getcollision() try: region = collision.sourcenode.getdelegate(RaceRegion, True) player = collision.opposingnode.getdelegate(PlayerSpaz, True).getplayer( Player, True) except bs.NotFoundError: return last_region = player.last_region this_region = region.index if last_region != this_region: # If a player tries to skip regions, smite them. # Allow a one region leeway though (its plausible players can get # blown over a region, etc). if this_region > last_region + 2: if player.is_alive(): assert player.actor player.actor.handlemessage(bs.DieMessage()) bs.broadcastmessage(babase.Lstr( translate=('statements', 'Killing ${NAME} for' ' skipping part of the track!'), subs=[('${NAME}', player.getname(full=True))]), color=(1, 0, 0)) else: # If this player is in first, note that this is the # front-most race-point. if player.rank == 0: self._front_race_region = this_region player.last_region = this_region if last_region >= len(self._regions) - 2 and this_region == 0: team = player.team player.lap = min(self._laps, player.lap + 1) # In teams mode with all-must-finish on, the team lap # value is the min of all team players. # Otherwise its the max. if isinstance(self.session, bs.DualTeamSession ) and self._entire_team_must_finish: team.lap = min([p.lap for p in team.players]) else: team.lap = max([p.lap for p in team.players]) # A player is finishing. if player.lap == self._laps: # In teams mode, hand out points based on the order # players come in. if isinstance(self.session, bs.DualTeamSession): assert self._team_finish_pts is not None if self._team_finish_pts > 0: self.stats.player_scored(player, self._team_finish_pts, screenmessage=False) self._team_finish_pts -= 25 # Flash where the player is. self._flash_player(player, 1.0) player.finished = True assert player.actor player.actor.handlemessage( bs.DieMessage(immediate=True)) # Makes sure noone behind them passes them in rank # while finishing. player.distance = 9999.0 # If the whole team has finished the race. if team.lap == self._laps: self._score_sound.play() player.team.finished = True assert self._timer is not None elapsed = bs.time() - self._timer.getstarttime() self._last_team_time = player.team.time = elapsed # Team has yet to finish. else: self._swipsound.play() # They've just finished a lap but not the race. else: self._swipsound.play() self._flash_player(player, 0.3) # Print their lap number over their head. try: assert isinstance(player.actor, PlayerSpaz) mathnode = bs.newnode('math', owner=player.actor.node, attrs={ 'input1': (0, 1.9, 0), 'operation': 'add' }) player.actor.node.connectattr( 'torso_position', mathnode, 'input2') tstr = babase.Lstr(resource='lapNumberText', subs=[('${CURRENT}', str(player.lap + 1)), ('${TOTAL}', str(self._laps)) ]) txtnode = bs.newnode('text', owner=mathnode, attrs={ 'text': tstr, 'in_world': True, 'color': (1, 1, 0, 1), 'scale': 0.015, 'h_align': 'center' }) mathnode.connectattr('output', txtnode, 'position') bs.animate(txtnode, 'scale', { 0.0: 0, 0.2: 0.019, 2.0: 0.019, 2.2: 0 }) bs.timer(2.3, mathnode.delete) except Exception: babase.print_exception('Error printing lap.') def on_team_join(self, team: Team) -> None: self._update_scoreboard() def on_player_join(self, player: Player) -> None: # Don't allow joining after we start # (would enable leave/rejoin tomfoolery). if self.has_begun(): bs.broadcastmessage( babase.Lstr(resource='playerDelayedJoinText', subs=[('${PLAYER}', player.getname(full=True))]), color=(0, 1, 0), ) return self.spawn_player(player) def on_player_leave(self, player: Player) -> None: super().on_player_leave(player) # A player leaving disqualifies the team if 'Entire Team Must Finish' # is on (otherwise in teams mode everyone could just leave except the # leading player to win). if (isinstance(self.session, bs.DualTeamSession) and self._entire_team_must_finish): bs.broadcastmessage(babase.Lstr( translate=('statements', '${TEAM} is disqualified because ${PLAYER} left'), subs=[('${TEAM}', player.team.name), ('${PLAYER}', player.getname(full=True))]), color=(1, 1, 0)) player.team.finished = True player.team.time = None player.team.lap = 0 bs.getsound('boo').play() for otherplayer in player.team.players: otherplayer.lap = 0 otherplayer.finished = True try: if otherplayer.actor is not None: otherplayer.actor.handlemessage(bs.DieMessage()) except Exception: babase.print_exception('Error sending DieMessage.') # Defer so team/player lists will be updated. babase.pushcall(self._check_end_game) def _update_scoreboard(self) -> None: for team in self.teams: distances = [player.distance for player in team.players] if not distances: teams_dist = 0.0 else: if (isinstance(self.session, bs.DualTeamSession) and self._entire_team_must_finish): teams_dist = min(distances) else: teams_dist = max(distances) self._scoreboard.set_team_value( team, teams_dist, self._laps, flash=(teams_dist >= float(self._laps)), show_value=False) def on_begin(self) -> None: from bascenev1lib.actor.onscreentimer import OnScreenTimer super().on_begin() self.setup_standard_time_limit(self._time_limit) self.setup_standard_powerup_drops() self._team_finish_pts = 100 # Throw a timer up on-screen. self._time_text = bs.NodeActor( bs.newnode('text', attrs={ 'v_attach': 'top', 'h_attach': 'center', 'h_align': 'center', 'color': (1, 1, 0.5, 1), 'flatness': 0.5, 'shadow': 0.5, 'position': (0, -50), 'scale': 1.4, 'text': '' })) self._timer = OnScreenTimer() if self._mine_spawning != 0: self._race_mines = [ RaceMine(point=p, mine=None) for p in self.map.get_def_points('race_mine') ] if self._race_mines: self._race_mine_timer = bs.Timer(0.001 * self._mine_spawning, self._update_race_mine, repeat=True) self._scoreboard_timer = bs.Timer(0.25, self._update_scoreboard, repeat=True) self._player_order_update_timer = bs.Timer(0.25, self._update_player_order, repeat=True) if self.slow_motion: t_scale = 0.4 light_y = 50 else: t_scale = 1.0 light_y = 150 lstart = 7.1 * t_scale inc = 1.25 * t_scale bs.timer(lstart, self._do_light_1) bs.timer(lstart + inc, self._do_light_2) bs.timer(lstart + 2 * inc, self._do_light_3) bs.timer(lstart + 3 * inc, self._start_race) self._start_lights = [] for i in range(4): lnub = bs.newnode('image', attrs={ 'texture': bs.gettexture('nub'), 'opacity': 1.0, 'absolute_scale': True, 'position': (-75 + i * 50, light_y), 'scale': (50, 50), 'attach': 'center' }) bs.animate( lnub, 'opacity', { 4.0 * t_scale: 0, 5.0 * t_scale: 1.0, 12.0 * t_scale: 1.0, 12.5 * t_scale: 0.0 }) bs.timer(13.0 * t_scale, lnub.delete) self._start_lights.append(lnub) self._start_lights[0].color = (0.2, 0, 0) self._start_lights[1].color = (0.2, 0, 0) self._start_lights[2].color = (0.2, 0.05, 0) self._start_lights[3].color = (0.0, 0.3, 0) self._squid_lights = [] for i in range(2): lnub = bs.newnode('image', attrs={ 'texture': bs.gettexture('nub'), 'opacity': 1.0, 'absolute_scale': True, 'position': (-33 + i * 65, 220), 'scale': (60, 60), 'attach': 'center' }) bs.animate( lnub, 'opacity', { 4.0 * t_scale: 0, 5.0 * t_scale: 1.0}) self._squid_lights.append(lnub) self._squid_lights[0].color = (0.2, 0, 0) self._squid_lights[1].color = (0.0, 0.3, 0) bs.timer(1.0, self._check_squid_end, repeat=True) self._squidgame_countdown() def _squidgame_countdown(self) -> None: self._countdown_timer = 80 * self._laps # 80 bs.newnode( 'image', attrs={ 'opacity': 0.7, 'color': (0.2, 0.2, 0.2), 'attach': 'topCenter', 'position': (-220, -40), 'scale': (135, 45), 'texture': bs.gettexture('bar')}) bs.newnode( 'image', attrs={ 'opacity': 1.0, 'color': (1.0, 0.0, 0.0), 'attach': 'topCenter', 'position': (-220, -38), 'scale': (155, 65), 'texture': bs.gettexture('uiAtlas'), 'mesh_transparent': bs.getmesh('meterTransparent')}) self._sgcountdown_text = bs.newnode( 'text', attrs={ 'v_attach': 'top', 'h_attach': 'center', 'h_align': 'center', 'position': (-220, -57), 'shadow': 0.5, 'flatness': 0.5, 'scale': 1.1, 'text': str(self._countdown_timer)+"s"}) def _update_sgcountdown(self) -> None: self._countdown_timer -= 1 self._countdown_timer if self._countdown_timer <= 0: self._countdown_timer = 0 self._squid_game_all_die() if self._countdown_timer == 20: self._sq_mode = 'Hard' bs.getsound('alarm').play() if self._countdown_timer == 40: self._sq_mode = 'Normal' if self._countdown_timer <= 20: self._sgcountdown_text.color = (1.2, 0.0, 0.0) self._sgcountdown_text.scale = 1.2 if self._countdown_timer in self._countdownsounds: self._countdownsounds[self._countdown_timer].play() else: self._sgcountdown_text.color = (1.0, 1.0, 1.0) self._sgcountdown_text.text = str(self._countdown_timer)+"s" def _squid_game_all_die(self) -> None: for player in self.players: if player.is_alive(): player.actor._cursed = True player.actor.handlemessage(bs.DieMessage()) NewBlast( position=player.actor.node.position, velocity=player.actor.node.velocity, blast_radius=3.0, blast_type='normal').autoretain() player.actor.handlemessage( bs.HitMessage( pos=player.actor.node.position, velocity=player.actor.node.velocity, magnitude=2000, hit_type='explosion', hit_subtype='normal', radius=2.0, source_player=None)) player.actor._cursed = False def _do_ticks(self) -> None: def do_ticks(): if self._ticks: bs.getsound('tick').play() self._tick_timer = bs.timer(1.0, do_ticks, repeat=True) def _start_squid_game(self) -> None: easy = [4.5, 5, 5.5, 6] normal = [4, 4.5, 5] hard = [3, 3.5, 4] random_number = random.choice( hard if self._sq_mode == 'Hard' else normal if self._sq_mode == 'Normal' else easy) # if random_number == 6: # bs.getsound('lrlg_06s').play() # elif random_number == 5.5: # bs.getsound('lrlg_055s').play() # elif random_number == 5: # bs.getsound('lrlg_05s').play() # elif random_number == 4.5: # bs.getsound('lrlg_045s').play() # elif random_number == 4: # bs.getsound('lrlg_04s').play() # elif random_number == 3.5: # bs.getsound('lrlg_035s').play() # elif random_number == 3: # bs.getsound('lrlg_03s').play() self._squid_lights[0].color = (0.2, 0, 0) self._squid_lights[1].color = (0.0, 1.0, 0) self._do_delete = False self._ticks = True bs.timer(random_number, self._stop_squid_game) def _stop_squid_game(self) -> None: self._ticks = False self._squid_lights[0].color = (1.0, 0, 0) self._squid_lights[1].color = (0.0, 0.3, 0) bs.timer(0.2, self._check_delete) def _check_delete(self) -> None: for player in self.players: if player.is_alive(): player.customdata['position'] = None player.customdata['position'] = player.actor.node.position self._do_delete = True bs.timer(3.0 if self._sq_mode == 'Hard' else 4.0, self._start_squid_game) def _start_delete(self) -> None: for player in self.players: if player.is_alive() and self._do_delete: posx = float("%.1f" % player.customdata['position'][0]) posz = float("%.1f" % player.customdata['position'][1]) posy = float("%.1f" % player.customdata['position'][2]) posx_list = [ round(posx, 1), round(posx+0.1, 1), round(posx+0.2, 1), round(posx-0.1, 1), round(posx-0.2, 1)] current_posx = float("%.1f" % player.actor.node.position[0]) posz_list = [ round(posz, 1), round(posz+0.1, 1), round(posz+0.2, 1), round(posz-0.1, 1), round(posz-0.2, 1)] current_posz = float("%.1f" % player.actor.node.position[1]) posy_list = [ round(posy, 1), round(posy+0.1, 1), round(posy+0.2, 1), round(posy-0.1, 1), round(posy-0.2, 1)] current_posy = float("%.1f" % player.actor.node.position[2]) if not (current_posx in posx_list) or not ( current_posz in posz_list) or not ( current_posy in posy_list): player.actor._cursed = True player.actor.handlemessage(bs.DieMessage()) NewBlast( position=player.actor.node.position, velocity=player.actor.node.velocity, blast_radius=3.0, blast_type='normal').autoretain() player.actor.handlemessage( bs.HitMessage( pos=player.actor.node.position, velocity=player.actor.node.velocity, magnitude=2000, hit_type='explosion', hit_subtype='normal', radius=2.0, source_player=None)) player.actor._cursed = False def _check_squid_end(self) -> None: squid_player_alive = 0 for player in self.players: if player.is_alive(): squid_player_alive += 1 break if squid_player_alive < 1: self.end_game() def _do_light_1(self) -> None: assert self._start_lights is not None self._start_lights[0].color = (1.0, 0, 0) self._beep_1_sound.play() def _do_light_2(self) -> None: assert self._start_lights is not None self._start_lights[1].color = (1.0, 0, 0) self._beep_1_sound.play() def _do_light_3(self) -> None: assert self._start_lights is not None self._start_lights[2].color = (1.0, 0.3, 0) self._beep_1_sound.play() def _start_race(self) -> None: assert self._start_lights is not None self._start_lights[3].color = (0.0, 1.0, 0) self._beep_2_sound.play() for player in self.players: if player.actor is not None: try: assert isinstance(player.actor, PlayerSpaz) player.actor.connect_controls_to_player() except Exception: babase.print_exception('Error in race player connects.') assert self._timer is not None self._timer.start() if self._bomb_spawning != 0: self._bomb_spawn_timer = bs.Timer(0.001 * self._bomb_spawning, self._spawn_bomb, repeat=True) self._race_started = True self._squid_lights[1].color = (0.0, 1.0, 0) self._start_squid_game() self._do_ticks() bs.timer(0.2, self._start_delete, repeat=True) bs.timer(1.0, self._update_sgcountdown, repeat=True) def _update_player_order(self) -> None: # Calc all player distances. for player in self.players: pos: Optional[babase.Vec3] try: pos = player.position except bs.NotFoundError: pos = None if pos is not None: r_index = player.last_region rg1 = self._regions[r_index] r1pt = babase.Vec3(rg1.pos[:3]) rg2 = self._regions[0] if r_index == len( self._regions) - 1 else self._regions[r_index + 1] r2pt = babase.Vec3(rg2.pos[:3]) r2dist = (pos - r2pt).length() amt = 1.0 - (r2dist / (r2pt - r1pt).length()) amt = player.lap + (r_index + amt) * (1.0 / len(self._regions)) player.distance = amt # Sort players by distance and update their ranks. p_list = [(player.distance, player) for player in self.players] p_list.sort(reverse=True, key=lambda x: x[0]) for i, plr in enumerate(p_list): plr[1].rank = i if plr[1].actor: node = plr[1].distance_txt if node: node.text = str(i + 1) if plr[1].is_alive() else '' def _spawn_bomb(self) -> None: if self._front_race_region is None: return region = (self._front_race_region + 3) % len(self._regions) pos = self._regions[region].pos # Don't use the full region so we're less likely to spawn off a cliff. region_scale = 0.8 x_range = ((-0.5, 0.5) if pos[3] == 0 else (-region_scale * pos[3], region_scale * pos[3])) z_range = ((-0.5, 0.5) if pos[5] == 0 else (-region_scale * pos[5], region_scale * pos[5])) pos = (pos[0] + random.uniform(*x_range), pos[1] + 1.0, pos[2] + random.uniform(*z_range)) bs.timer(random.uniform(0.0, 2.0), bs.WeakCall(self._spawn_bomb_at_pos, pos)) def _spawn_bomb_at_pos(self, pos: Sequence[float]) -> None: if self.has_ended(): return Bomb(position=pos, bomb_type='normal').autoretain() def _make_mine(self, i: int) -> None: assert self._race_mines is not None rmine = self._race_mines[i] rmine.mine = Bomb(position=rmine.point[:3], bomb_type='land_mine') rmine.mine.arm() def _flash_mine(self, i: int) -> None: assert self._race_mines is not None rmine = self._race_mines[i] light = bs.newnode('light', attrs={ 'position': rmine.point[:3], 'color': (1, 0.2, 0.2), 'radius': 0.1, 'height_attenuated': False }) bs.animate(light, 'intensity', {0.0: 0, 0.1: 1.0, 0.2: 0}, loop=True) bs.timer(1.0, light.delete) def _update_race_mine(self) -> None: assert self._race_mines is not None m_index = -1 rmine = None for _i in range(3): m_index = random.randrange(len(self._race_mines)) rmine = self._race_mines[m_index] if not rmine.mine: break assert rmine is not None if not rmine.mine: self._flash_mine(m_index) bs.timer(0.95, babase.Call(self._make_mine, m_index)) def spawn_player(self, player: Player) -> bs.Actor: if player.team.finished: # FIXME: This is not type-safe! # This call is expected to always return an Actor! # Perhaps we need something like can_spawn_player()... # noinspection PyTypeChecker return None # type: ignore pos = self._regions[player.last_region].pos # Don't use the full region so we're less likely to spawn off a cliff. region_scale = 0.8 x_range = ((-0.5, 0.5) if pos[3] == 0 else (-region_scale * pos[3], region_scale * pos[3])) z_range = ((-0.5, 0.5) if pos[5] == 0 else (-region_scale * pos[5], region_scale * pos[5])) pos = (pos[0] + random.uniform(*x_range), pos[1], pos[2] + random.uniform(*z_range)) spaz = self.spawn_player_spaz( player, position=pos, angle=90 if not self._race_started else None) assert spaz.node # Prevent controlling of characters before the start of the race. if not self._race_started: spaz.disconnect_controls_from_player() mathnode = bs.newnode('math', owner=spaz.node, attrs={ 'input1': (0, 1.4, 0), 'operation': 'add' }) spaz.node.connectattr('torso_position', mathnode, 'input2') distance_txt = bs.newnode('text', owner=spaz.node, attrs={ 'text': '', 'in_world': True, 'color': (1, 1, 0.4), 'scale': 0.02, 'h_align': 'center' }) player.distance_txt = distance_txt mathnode.connectattr('output', distance_txt, 'position') return spaz def _check_end_game(self) -> None: # If there's no teams left racing, finish. teams_still_in = len([t for t in self.teams if not t.finished]) if teams_still_in == 0: self.end_game() return # Count the number of teams that have completed the race. teams_completed = len( [t for t in self.teams if t.finished and t.time is not None]) if teams_completed > 0: session = self.session # In teams mode its over as soon as any team finishes the race # FIXME: The get_ffa_point_awards code looks dangerous. if isinstance(session, bs.DualTeamSession): self.end_game() else: # In ffa we keep the race going while there's still any points # to be handed out. Find out how many points we have to award # and how many teams have finished, and once that matches # we're done. assert isinstance(session, bs.FreeForAllSession) points_to_award = len(session.get_ffa_point_awards()) if teams_completed >= points_to_award - teams_completed: self.end_game() return def end_game(self) -> None: # Stop updating our time text, and set it to show the exact last # finish time if we have one. (so users don't get upset if their # final time differs from what they see onscreen by a tiny amount) assert self._timer is not None if self._timer.has_started(): self._timer.stop( endtime=None if self._last_team_time is None else ( self._timer.getstarttime() + self._last_team_time)) results = bs.GameResults() for team in self.teams: if team.time is not None: # We store time in seconds, but pass a score in milliseconds. results.set_team_score(team, int(team.time * 1000.0)) else: results.set_team_score(team, None) # We don't announce a winner in ffa mode since its probably been a # while since the first place guy crossed the finish line so it seems # odd to be announcing that now. self.end(results=results, announce_winning_team=isinstance(self.session, bs.DualTeamSession)) def handlemessage(self, msg: Any) -> Any: if isinstance(msg, bs.PlayerDiedMessage): # Augment default behavior. super().handlemessage(msg) else: super().handlemessage(msg)