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

Region used to track progress during a race.

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

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')]):
55class Player(ba.Player['Team']):
56    """Our player type for this game."""
57
58    def __init__(self) -> None:
59        self.distance_txt: ba.Node | None = None
60        self.last_region = 0
61        self.lap = 0
62        self.distance = 0.0
63        self.finished = False
64        self.rank: int | None = None

Our player type for this game.

Player()
58    def __init__(self) -> None:
59        self.distance_txt: ba.Node | None = None
60        self.last_region = 0
61        self.lap = 0
62        self.distance = 0.0
63        self.finished = False
64        self.rank: int | None = None
Inherited Members
ba._player.Player
actor
on_expire
team
customdata
sessionplayer
node
position
exists
getname
is_alive
get_icon
assigninput
resetinput
class Team(ba._team.Team[bastd.game.race.Player]):
67class Team(ba.Team[Player]):
68    """Our team type for this game."""
69
70    def __init__(self) -> None:
71        self.time: float | None = None
72        self.lap = 0
73        self.finished = False

Our team type for this game.

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

Game of racing around a track.

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

Instantiate the Activity.

@classmethod
def get_available_settings( cls, sessiontype: type[ba._session.Session]) -> list[ba._settings.Setting]:
 86    @classmethod
 87    def get_available_settings(
 88        cls, sessiontype: type[ba.Session]
 89    ) -> list[ba.Setting]:
 90        settings = [
 91            ba.IntSetting('Laps', min_value=1, default=3, increment=1),
 92            ba.IntChoiceSetting(
 93                'Time Limit',
 94                default=0,
 95                choices=[
 96                    ('None', 0),
 97                    ('1 Minute', 60),
 98                    ('2 Minutes', 120),
 99                    ('5 Minutes', 300),
100                    ('10 Minutes', 600),
101                    ('20 Minutes', 1200),
102                ],
103            ),
104            ba.IntChoiceSetting(
105                'Mine Spawning',
106                default=4000,
107                choices=[
108                    ('No Mines', 0),
109                    ('8 Seconds', 8000),
110                    ('4 Seconds', 4000),
111                    ('2 Seconds', 2000),
112                ],
113            ),
114            ba.IntChoiceSetting(
115                'Bomb Spawning',
116                choices=[
117                    ('None', 0),
118                    ('8 Seconds', 8000),
119                    ('4 Seconds', 4000),
120                    ('2 Seconds', 2000),
121                    ('1 Second', 1000),
122                ],
123                default=2000,
124            ),
125            ba.BoolSetting('Epic Mode', default=False),
126        ]
127
128        # We have some specific settings in teams mode.
129        if issubclass(sessiontype, ba.DualTeamSession):
130            settings.append(
131                ba.BoolSetting('Entire Team Must Finish', default=False)
132            )
133        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:
135    @classmethod
136    def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool:
137        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]:
139    @classmethod
140    def get_supported_maps(cls, sessiontype: type[ba.Session]) -> list[str]:
141        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.

slow_motion = False

If True, runs in slow motion and turns down sound pitch.

def get_instance_description(self) -> Union[str, Sequence]:
180    def get_instance_description(self) -> str | Sequence:
181        if (
182            isinstance(self.session, ba.DualTeamSession)
183            and self._entire_team_must_finish
184        ):
185            t_str = ' Your entire team has to finish.'
186        else:
187            t_str = ''
188
189        if self._laps > 1:
190            return 'Run ${ARG1} laps.' + t_str, self._laps
191        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]:
193    def get_instance_description_short(self) -> str | Sequence:
194        if self._laps > 1:
195            return 'run ${ARG1} laps', self._laps
196        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:
198    def on_transition_in(self) -> None:
199        super().on_transition_in()
200        shared = SharedObjects.get()
201        pts = self.map.get_def_points('race_point')
202        mat = self.race_region_material = ba.Material()
203        mat.add_actions(
204            conditions=('they_have_material', shared.player_material),
205            actions=(
206                ('modify_part_collision', 'collide', True),
207                ('modify_part_collision', 'physical', False),
208                ('call', 'at_connect', self._handle_race_point_collide),
209            ),
210        )
211        for rpt in pts:
212            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:
382    def on_team_join(self, team: Team) -> None:
383        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:
385    def on_player_leave(self, player: Player) -> None:
386        super().on_player_leave(player)
387
388        # A player leaving disqualifies the team if 'Entire Team Must Finish'
389        # is on (otherwise in teams mode everyone could just leave except the
390        # leading player to win).
391        if (
392            isinstance(self.session, ba.DualTeamSession)
393            and self._entire_team_must_finish
394        ):
395            ba.screenmessage(
396                ba.Lstr(
397                    translate=(
398                        'statements',
399                        '${TEAM} is disqualified because ${PLAYER} left',
400                    ),
401                    subs=[
402                        ('${TEAM}', player.team.name),
403                        ('${PLAYER}', player.getname(full=True)),
404                    ],
405                ),
406                color=(1, 1, 0),
407            )
408            player.team.finished = True
409            player.team.time = None
410            player.team.lap = 0
411            ba.playsound(ba.getsound('boo'))
412            for otherplayer in player.team.players:
413                otherplayer.lap = 0
414                otherplayer.finished = True
415                try:
416                    if otherplayer.actor is not None:
417                        otherplayer.actor.handlemessage(ba.DieMessage())
418                except Exception:
419                    ba.print_exception('Error sending DieMessage.')
420
421        # Defer so team/player lists will be updated.
422        ba.pushcall(self._check_end_game)

Called when a ba.Player is leaving the Activity.

def on_begin(self) -> None:
445    def on_begin(self) -> None:
446        from bastd.actor.onscreentimer import OnScreenTimer
447
448        super().on_begin()
449        self.setup_standard_time_limit(self._time_limit)
450        self.setup_standard_powerup_drops()
451        self._team_finish_pts = 100
452
453        # Throw a timer up on-screen.
454        self._time_text = ba.NodeActor(
455            ba.newnode(
456                'text',
457                attrs={
458                    'v_attach': 'top',
459                    'h_attach': 'center',
460                    'h_align': 'center',
461                    'color': (1, 1, 0.5, 1),
462                    'flatness': 0.5,
463                    'shadow': 0.5,
464                    'position': (0, -50),
465                    'scale': 1.4,
466                    'text': '',
467                },
468            )
469        )
470        self._timer = OnScreenTimer()
471
472        if self._mine_spawning != 0:
473            self._race_mines = [
474                RaceMine(point=p, mine=None)
475                for p in self.map.get_def_points('race_mine')
476            ]
477            if self._race_mines:
478                self._race_mine_timer = ba.Timer(
479                    0.001 * self._mine_spawning,
480                    self._update_race_mine,
481                    repeat=True,
482                )
483
484        self._scoreboard_timer = ba.Timer(
485            0.25, self._update_scoreboard, repeat=True
486        )
487        self._player_order_update_timer = ba.Timer(
488            0.25, self._update_player_order, repeat=True
489        )
490
491        if self.slow_motion:
492            t_scale = 0.4
493            light_y = 50
494        else:
495            t_scale = 1.0
496            light_y = 150
497        lstart = 7.1 * t_scale
498        inc = 1.25 * t_scale
499
500        ba.timer(lstart, self._do_light_1)
501        ba.timer(lstart + inc, self._do_light_2)
502        ba.timer(lstart + 2 * inc, self._do_light_3)
503        ba.timer(lstart + 3 * inc, self._start_race)
504
505        self._start_lights = []
506        for i in range(4):
507            lnub = ba.newnode(
508                'image',
509                attrs={
510                    'texture': ba.gettexture('nub'),
511                    'opacity': 1.0,
512                    'absolute_scale': True,
513                    'position': (-75 + i * 50, light_y),
514                    'scale': (50, 50),
515                    'attach': 'center',
516                },
517            )
518            ba.animate(
519                lnub,
520                'opacity',
521                {
522                    4.0 * t_scale: 0,
523                    5.0 * t_scale: 1.0,
524                    12.0 * t_scale: 1.0,
525                    12.5 * t_scale: 0.0,
526                },
527            )
528            ba.timer(13.0 * t_scale, lnub.delete)
529            self._start_lights.append(lnub)
530
531        self._start_lights[0].color = (0.2, 0, 0)
532        self._start_lights[1].color = (0.2, 0, 0)
533        self._start_lights[2].color = (0.2, 0.05, 0)
534        self._start_lights[3].color = (0.0, 0.3, 0)

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:
674    def spawn_player(self, player: Player) -> ba.Actor:
675        if player.team.finished:
676            # FIXME: This is not type-safe!
677            #   This call is expected to always return an Actor!
678            #   Perhaps we need something like can_spawn_player()...
679            # noinspection PyTypeChecker
680            return None  # type: ignore
681        pos = self._regions[player.last_region].pos
682
683        # Don't use the full region so we're less likely to spawn off a cliff.
684        region_scale = 0.8
685        x_range = (
686            (-0.5, 0.5)
687            if pos[3] == 0
688            else (-region_scale * pos[3], region_scale * pos[3])
689        )
690        z_range = (
691            (-0.5, 0.5)
692            if pos[5] == 0
693            else (-region_scale * pos[5], region_scale * pos[5])
694        )
695        pos = (
696            pos[0] + random.uniform(*x_range),
697            pos[1],
698            pos[2] + random.uniform(*z_range),
699        )
700        spaz = self.spawn_player_spaz(
701            player, position=pos, angle=90 if not self._race_started else None
702        )
703        assert spaz.node
704
705        # Prevent controlling of characters before the start of the race.
706        if not self._race_started:
707            spaz.disconnect_controls_from_player()
708
709        mathnode = ba.newnode(
710            'math',
711            owner=spaz.node,
712            attrs={'input1': (0, 1.4, 0), 'operation': 'add'},
713        )
714        spaz.node.connectattr('torso_position', mathnode, 'input2')
715
716        distance_txt = ba.newnode(
717            'text',
718            owner=spaz.node,
719            attrs={
720                'text': '',
721                'in_world': True,
722                'color': (1, 1, 0.4),
723                'scale': 0.02,
724                'h_align': 'center',
725            },
726        )
727        player.distance_txt = distance_txt
728        mathnode.connectattr('output', distance_txt, 'position')
729        return spaz

Spawn something for the provided ba.Player.

The default implementation simply calls spawn_player_spaz().

def end_game(self) -> None:
763    def end_game(self) -> None:
764
765        # Stop updating our time text, and set it to show the exact last
766        # finish time if we have one. (so users don't get upset if their
767        # final time differs from what they see onscreen by a tiny amount)
768        assert self._timer is not None
769        if self._timer.has_started():
770            self._timer.stop(
771                endtime=None
772                if self._last_team_time is None
773                else (self._timer.getstarttime() + self._last_team_time)
774            )
775
776        results = ba.GameResults()
777
778        for team in self.teams:
779            if team.time is not None:
780                # We store time in seconds, but pass a score in milliseconds.
781                results.set_team_score(team, int(team.time * 1000.0))
782            else:
783                results.set_team_score(team, None)
784
785        # We don't announce a winner in ffa mode since its probably been a
786        # while since the first place guy crossed the finish line so it seems
787        # odd to be announcing that now.
788        self.end(
789            results=results,
790            announce_winning_team=isinstance(self.session, ba.DualTeamSession),
791        )

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:
793    def handlemessage(self, msg: Any) -> Any:
794        if isinstance(msg, ba.PlayerDiedMessage):
795            # Augment default behavior.
796            super().handlemessage(msg)
797            player = msg.getplayer(Player)
798            if not player.finished:
799                self.respawn_player(player, respawn_time=1)
800        else:
801            super().handlemessage(msg)

General message handling; can be passed any message object.

Inherited Members
ba._teamgame.TeamGameActivity
spawn_player_spaz
end
ba._gameactivity.GameActivity
allow_pausing
allow_kick_idle_players
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._activity.Activity
settings_raw
teams
players
announce_player_deaths
is_joining_activity
use_fixed_vr_overlay
inherits_slow_motion
inherits_music
inherits_vr_camera_offset
inherits_vr_overlay_center
inherits_tint
allow_mid_activity_joins
transition_time
can_show_ad_on_death
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._dependency.DependencyComponent
dep_is_present
get_dynamic_deps