bastd.game.race

Defines Race mini-game.

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

Holds info about a mine on the track.

RaceMine(point: Sequence[float], mine: bastd.actor.bomb.Bomb | None)
class RaceRegion(ba._actor.Actor):
33class RaceRegion(ba.Actor):
34    """Region used to track progress during a race."""
35
36    def __init__(self, pt: Sequence[float], index: int):
37        super().__init__()
38        activity = self.activity
39        assert isinstance(activity, RaceGame)
40        self.pos = pt
41        self.index = index
42        self.node = ba.newnode(
43            'region',
44            delegate=self,
45            attrs={
46                'position': pt[:3],
47                'scale': (pt[3] * 2.0, pt[4] * 2.0, pt[5] * 2.0),
48                'type': 'box',
49                'materials': [activity.race_region_material]
50            })

Region used to track progress during a race.

RaceRegion(pt: Sequence[float], index: int)
36    def __init__(self, pt: Sequence[float], index: int):
37        super().__init__()
38        activity = self.activity
39        assert isinstance(activity, RaceGame)
40        self.pos = pt
41        self.index = index
42        self.node = ba.newnode(
43            'region',
44            delegate=self,
45            attrs={
46                'position': pt[:3],
47                'scale': (pt[3] * 2.0, pt[4] * 2.0, pt[5] * 2.0),
48                'type': 'box',
49                'materials': [activity.race_region_material]
50            })

Instantiates an Actor in the current ba.Activity.

Inherited Members
ba._actor.Actor
handlemessage
autoretain
on_expire
expired
exists
is_alive
activity
getactivity
class Player(ba._player.Player[ForwardRef('Team')]):
53class Player(ba.Player['Team']):
54    """Our player type for this game."""
55
56    def __init__(self) -> None:
57        self.distance_txt: ba.Node | None = None
58        self.last_region = 0
59        self.lap = 0
60        self.distance = 0.0
61        self.finished = False
62        self.rank: int | None = None

Our player type for this game.

Player()
56    def __init__(self) -> None:
57        self.distance_txt: ba.Node | None = None
58        self.last_region = 0
59        self.lap = 0
60        self.distance = 0.0
61        self.finished = False
62        self.rank: int | None = None
class Team(ba._team.Team[bastd.game.race.Player]):
65class Team(ba.Team[Player]):
66    """Our team type for this game."""
67
68    def __init__(self) -> None:
69        self.time: float | None = None
70        self.lap = 0
71        self.finished = False

Our team type for this game.

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

Game of racing around a track.

RaceGame(settings: dict)
139    def __init__(self, settings: dict):
140        self._race_started = False
141        super().__init__(settings)
142        self._scoreboard = Scoreboard()
143        self._score_sound = ba.getsound('score')
144        self._swipsound = ba.getsound('swip')
145        self._last_team_time: float | None = None
146        self._front_race_region: int | None = None
147        self._nub_tex = ba.gettexture('nub')
148        self._beep_1_sound = ba.getsound('raceBeep1')
149        self._beep_2_sound = ba.getsound('raceBeep2')
150        self.race_region_material: ba.Material | None = None
151        self._regions: list[RaceRegion] = []
152        self._team_finish_pts: int | None = None
153        self._time_text: ba.Actor | None = None
154        self._timer: OnScreenTimer | None = None
155        self._race_mines: list[RaceMine] | None = None
156        self._race_mine_timer: ba.Timer | None = None
157        self._scoreboard_timer: ba.Timer | None = None
158        self._player_order_update_timer: ba.Timer | None = None
159        self._start_lights: list[ba.Node] | None = None
160        self._bomb_spawn_timer: ba.Timer | None = None
161        self._laps = int(settings['Laps'])
162        self._entire_team_must_finish = bool(
163            settings.get('Entire Team Must Finish', False))
164        self._time_limit = float(settings['Time Limit'])
165        self._mine_spawning = int(settings['Mine Spawning'])
166        self._bomb_spawning = int(settings['Bomb Spawning'])
167        self._epic_mode = bool(settings['Epic Mode'])
168
169        # Base class overrides.
170        self.slow_motion = self._epic_mode
171        self.default_music = (ba.MusicType.EPIC_RACE
172                              if self._epic_mode else ba.MusicType.RACE)

Instantiate the Activity.

name: str | None = 'Race'
description: str | None = 'Run real fast!'
scoreconfig: ba._score.ScoreConfig | None = ScoreConfig(label='Time', scoretype=<ScoreType.MILLISECONDS: 'ms'>, lower_is_better=True, none_is_winner=False, version='')
@classmethod
def get_available_settings( cls, sessiontype: type[ba._session.Session]) -> list[ba._settings.Setting]:
 84    @classmethod
 85    def get_available_settings(
 86            cls, sessiontype: type[ba.Session]) -> list[ba.Setting]:
 87        settings = [
 88            ba.IntSetting('Laps', min_value=1, default=3, increment=1),
 89            ba.IntChoiceSetting(
 90                'Time Limit',
 91                default=0,
 92                choices=[
 93                    ('None', 0),
 94                    ('1 Minute', 60),
 95                    ('2 Minutes', 120),
 96                    ('5 Minutes', 300),
 97                    ('10 Minutes', 600),
 98                    ('20 Minutes', 1200),
 99                ],
100            ),
101            ba.IntChoiceSetting(
102                'Mine Spawning',
103                default=4000,
104                choices=[
105                    ('No Mines', 0),
106                    ('8 Seconds', 8000),
107                    ('4 Seconds', 4000),
108                    ('2 Seconds', 2000),
109                ],
110            ),
111            ba.IntChoiceSetting(
112                'Bomb Spawning',
113                choices=[
114                    ('None', 0),
115                    ('8 Seconds', 8000),
116                    ('4 Seconds', 4000),
117                    ('2 Seconds', 2000),
118                    ('1 Second', 1000),
119                ],
120                default=2000,
121            ),
122            ba.BoolSetting('Epic Mode', default=False),
123        ]
124
125        # We have some specific settings in teams mode.
126        if issubclass(sessiontype, ba.DualTeamSession):
127            settings.append(
128                ba.BoolSetting('Entire Team Must Finish', default=False))
129        return settings

Return a list of settings relevant to this game type when running under the provided session type.

@classmethod
def supports_session_type(cls, sessiontype: type[ba._session.Session]) -> bool:
131    @classmethod
132    def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool:
133        return issubclass(sessiontype, ba.MultiTeamSession)

Class method override; returns True for ba.DualTeamSessions and ba.FreeForAllSessions; False otherwise.

@classmethod
def get_supported_maps(cls, sessiontype: type[ba._session.Session]) -> list[str]:
135    @classmethod
136    def get_supported_maps(cls, sessiontype: type[ba.Session]) -> list[str]:
137        return ba.getmaps('race')

Called by the default ba.GameActivity.create_settings_ui() implementation; should return a list of map names valid for this game-type for the given ba.Session type.

def get_instance_description(self) -> Union[str, Sequence]:
174    def get_instance_description(self) -> str | Sequence:
175        if (isinstance(self.session, ba.DualTeamSession)
176                and self._entire_team_must_finish):
177            t_str = ' Your entire team has to finish.'
178        else:
179            t_str = ''
180
181        if self._laps > 1:
182            return 'Run ${ARG1} laps.' + t_str, self._laps
183        return 'Run 1 lap.' + t_str

Return a description for this game instance, in English.

This is shown in the center of the screen below the game name at the start of a game. It should start with a capital letter and end with a period, and can be a bit more verbose than the version returned by get_instance_description_short().

Note that translation is applied by looking up the specific returned value as a key, so the number of returned variations should be limited; ideally just one or two. To include arbitrary values in the description, you can return a sequence of values in the following form instead of just a string:

This will give us something like 'Score 3 goals.' in English

and can properly translate to 'Anota 3 goles.' in Spanish.

If we just returned the string 'Score 3 Goals' here, there would

have to be a translation entry for each specific number. ew.

return ['Score ${ARG1} goals.', self.settings_raw['Score to Win']]

This way the first string can be consistently translated, with any arg values then substituted into the result. ${ARG1} will be replaced with the first value, ${ARG2} with the second, etc.

def get_instance_description_short(self) -> Union[str, Sequence]:
185    def get_instance_description_short(self) -> str | Sequence:
186        if self._laps > 1:
187            return 'run ${ARG1} laps', self._laps
188        return 'run 1 lap'

Return a short description for this game instance in English.

This description is used above the game scoreboard in the corner of the screen, so it should be as concise as possible. It should be lowercase and should not contain periods or other punctuation.

Note that translation is applied by looking up the specific returned value as a key, so the number of returned variations should be limited; ideally just one or two. To include arbitrary values in the description, you can return a sequence of values in the following form instead of just a string:

This will give us something like 'score 3 goals' in English

and can properly translate to 'anota 3 goles' in Spanish.

If we just returned the string 'score 3 goals' here, there would

have to be a translation entry for each specific number. ew.

return ['score ${ARG1} goals', self.settings_raw['Score to Win']]

This way the first string can be consistently translated, with any arg values then substituted into the result. ${ARG1} will be replaced with the first value, ${ARG2} with the second, etc.

def on_transition_in(self) -> None:
190    def on_transition_in(self) -> None:
191        super().on_transition_in()
192        shared = SharedObjects.get()
193        pts = self.map.get_def_points('race_point')
194        mat = self.race_region_material = ba.Material()
195        mat.add_actions(conditions=('they_have_material',
196                                    shared.player_material),
197                        actions=(
198                            ('modify_part_collision', 'collide', True),
199                            ('modify_part_collision', 'physical', False),
200                            ('call', 'at_connect',
201                             self._handle_race_point_collide),
202                        ))
203        for rpt in pts:
204            self._regions.append(RaceRegion(rpt, len(self._regions)))

Called when the Activity is first becoming visible.

Upon this call, the Activity should fade in backgrounds, start playing music, etc. It does not yet have access to players or teams, however. They remain owned by the previous Activity up until ba.Activity.on_begin() is called.

def on_team_join(self, team: bastd.game.race.Team) -> None:
355    def on_team_join(self, team: Team) -> None:
356        self._update_scoreboard()

Called when a new ba.Team joins the Activity.

(including the initial set of Teams)

def on_player_leave(self, player: bastd.game.race.Player) -> None:
358    def on_player_leave(self, player: Player) -> None:
359        super().on_player_leave(player)
360
361        # A player leaving disqualifies the team if 'Entire Team Must Finish'
362        # is on (otherwise in teams mode everyone could just leave except the
363        # leading player to win).
364        if (isinstance(self.session, ba.DualTeamSession)
365                and self._entire_team_must_finish):
366            ba.screenmessage(ba.Lstr(
367                translate=('statements',
368                           '${TEAM} is disqualified because ${PLAYER} left'),
369                subs=[('${TEAM}', player.team.name),
370                      ('${PLAYER}', player.getname(full=True))]),
371                             color=(1, 1, 0))
372            player.team.finished = True
373            player.team.time = None
374            player.team.lap = 0
375            ba.playsound(ba.getsound('boo'))
376            for otherplayer in player.team.players:
377                otherplayer.lap = 0
378                otherplayer.finished = True
379                try:
380                    if otherplayer.actor is not None:
381                        otherplayer.actor.handlemessage(ba.DieMessage())
382                except Exception:
383                    ba.print_exception('Error sending DieMessage.')
384
385        # Defer so team/player lists will be updated.
386        ba.pushcall(self._check_end_game)

Called when a ba.Player is leaving the Activity.

def on_begin(self) -> None:
406    def on_begin(self) -> None:
407        from bastd.actor.onscreentimer import OnScreenTimer
408        super().on_begin()
409        self.setup_standard_time_limit(self._time_limit)
410        self.setup_standard_powerup_drops()
411        self._team_finish_pts = 100
412
413        # Throw a timer up on-screen.
414        self._time_text = ba.NodeActor(
415            ba.newnode('text',
416                       attrs={
417                           'v_attach': 'top',
418                           'h_attach': 'center',
419                           'h_align': 'center',
420                           'color': (1, 1, 0.5, 1),
421                           'flatness': 0.5,
422                           'shadow': 0.5,
423                           'position': (0, -50),
424                           'scale': 1.4,
425                           'text': ''
426                       }))
427        self._timer = OnScreenTimer()
428
429        if self._mine_spawning != 0:
430            self._race_mines = [
431                RaceMine(point=p, mine=None)
432                for p in self.map.get_def_points('race_mine')
433            ]
434            if self._race_mines:
435                self._race_mine_timer = ba.Timer(0.001 * self._mine_spawning,
436                                                 self._update_race_mine,
437                                                 repeat=True)
438
439        self._scoreboard_timer = ba.Timer(0.25,
440                                          self._update_scoreboard,
441                                          repeat=True)
442        self._player_order_update_timer = ba.Timer(0.25,
443                                                   self._update_player_order,
444                                                   repeat=True)
445
446        if self.slow_motion:
447            t_scale = 0.4
448            light_y = 50
449        else:
450            t_scale = 1.0
451            light_y = 150
452        lstart = 7.1 * t_scale
453        inc = 1.25 * t_scale
454
455        ba.timer(lstart, self._do_light_1)
456        ba.timer(lstart + inc, self._do_light_2)
457        ba.timer(lstart + 2 * inc, self._do_light_3)
458        ba.timer(lstart + 3 * inc, self._start_race)
459
460        self._start_lights = []
461        for i in range(4):
462            lnub = ba.newnode('image',
463                              attrs={
464                                  'texture': ba.gettexture('nub'),
465                                  'opacity': 1.0,
466                                  'absolute_scale': True,
467                                  'position': (-75 + i * 50, light_y),
468                                  'scale': (50, 50),
469                                  'attach': 'center'
470                              })
471            ba.animate(
472                lnub, 'opacity', {
473                    4.0 * t_scale: 0,
474                    5.0 * t_scale: 1.0,
475                    12.0 * t_scale: 1.0,
476                    12.5 * t_scale: 0.0
477                })
478            ba.timer(13.0 * t_scale, lnub.delete)
479            self._start_lights.append(lnub)
480
481        self._start_lights[0].color = (0.2, 0, 0)
482        self._start_lights[1].color = (0.2, 0, 0)
483        self._start_lights[2].color = (0.2, 0.05, 0)
484        self._start_lights[3].color = (0.0, 0.3, 0)

Called once the previous ba.Activity has finished transitioning out.

At this point the activity's initial players and teams are filled in and it should begin its actual game logic.

def spawn_player(self, player: bastd.game.race.Player) -> ba._actor.Actor:
609    def spawn_player(self, player: Player) -> ba.Actor:
610        if player.team.finished:
611            # FIXME: This is not type-safe!
612            #   This call is expected to always return an Actor!
613            #   Perhaps we need something like can_spawn_player()...
614            # noinspection PyTypeChecker
615            return None  # type: ignore
616        pos = self._regions[player.last_region].pos
617
618        # Don't use the full region so we're less likely to spawn off a cliff.
619        region_scale = 0.8
620        x_range = ((-0.5, 0.5) if pos[3] == 0 else
621                   (-region_scale * pos[3], region_scale * pos[3]))
622        z_range = ((-0.5, 0.5) if pos[5] == 0 else
623                   (-region_scale * pos[5], region_scale * pos[5]))
624        pos = (pos[0] + random.uniform(*x_range), pos[1],
625               pos[2] + random.uniform(*z_range))
626        spaz = self.spawn_player_spaz(
627            player, position=pos, angle=90 if not self._race_started else None)
628        assert spaz.node
629
630        # Prevent controlling of characters before the start of the race.
631        if not self._race_started:
632            spaz.disconnect_controls_from_player()
633
634        mathnode = ba.newnode('math',
635                              owner=spaz.node,
636                              attrs={
637                                  'input1': (0, 1.4, 0),
638                                  'operation': 'add'
639                              })
640        spaz.node.connectattr('torso_position', mathnode, 'input2')
641
642        distance_txt = ba.newnode('text',
643                                  owner=spaz.node,
644                                  attrs={
645                                      'text': '',
646                                      'in_world': True,
647                                      'color': (1, 1, 0.4),
648                                      'scale': 0.02,
649                                      'h_align': 'center'
650                                  })
651        player.distance_txt = distance_txt
652        mathnode.connectattr('output', distance_txt, 'position')
653        return spaz

Spawn something for the provided ba.Player.

The default implementation simply calls spawn_player_spaz().

def end_game(self) -> None:
686    def end_game(self) -> None:
687
688        # Stop updating our time text, and set it to show the exact last
689        # finish time if we have one. (so users don't get upset if their
690        # final time differs from what they see onscreen by a tiny amount)
691        assert self._timer is not None
692        if self._timer.has_started():
693            self._timer.stop(
694                endtime=None if self._last_team_time is None else (
695                    self._timer.getstarttime() + self._last_team_time))
696
697        results = ba.GameResults()
698
699        for team in self.teams:
700            if team.time is not None:
701                # We store time in seconds, but pass a score in milliseconds.
702                results.set_team_score(team, int(team.time * 1000.0))
703            else:
704                results.set_team_score(team, None)
705
706        # We don't announce a winner in ffa mode since its probably been a
707        # while since the first place guy crossed the finish line so it seems
708        # odd to be announcing that now.
709        self.end(results=results,
710                 announce_winning_team=isinstance(self.session,
711                                                  ba.DualTeamSession))

Tell the game to wrap up and call ba.Activity.end() immediately.

This method should be overridden by subclasses. A game should always be prepared to end and deliver results, even if there is no 'winner' yet; this way things like the standard time-limit (ba.GameActivity.setup_standard_time_limit()) will work with the game.

def handlemessage(self, msg: Any) -> Any:
713    def handlemessage(self, msg: Any) -> Any:
714        if isinstance(msg, ba.PlayerDiedMessage):
715            # Augment default behavior.
716            super().handlemessage(msg)
717            player = msg.getplayer(Player)
718            if not player.finished:
719                self.respawn_player(player, respawn_time=1)
720        else:
721            super().handlemessage(msg)

General message handling; can be passed any message object.

Inherited Members
ba._activity.Activity
slow_motion
settings_raw
teams
players
announce_player_deaths
is_joining_activity
use_fixed_vr_overlay
inherits_slow_motion
inherits_music
inherits_vr_camera_offset
inherits_vr_overlay_center
inherits_tint
allow_mid_activity_joins
transition_time
can_show_ad_on_death
globalsnode
stats
on_expire
customdata
expired
playertype
teamtype
retain_actor
add_actor_weak_ref
session
on_team_leave
on_transition_out
has_transitioned_in
has_begun
has_ended
is_transitioning_out
transition_out
create_player
create_team
ba._gameactivity.GameActivity
default_music
tips
available_settings
allow_pausing
allow_kick_idle_players
show_kill_points
create_settings_ui
getscoreconfig
getname
get_display_string
get_team_display_string
get_description
get_description_display_string
get_settings_display_string
map
get_instance_display_string
get_instance_scoreboard_display_string
on_continue
is_waiting_for_continue
continue_or_end_game
on_player_join
respawn_player
spawn_player_if_exists
setup_standard_powerup_drops
setup_standard_time_limit
show_zoom_message
ba._teamgame.TeamGameActivity
spawn_player_spaz
end
ba._dependency.DependencyComponent
dep_is_present
get_dynamic_deps