bascenev1lib.game.race

Defines Race mini-game.

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

Holds info about a mine on the track.

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

Region used to track progress during a race.

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

Instantiates an Actor in the current bascenev1.Activity.

pos
index
node
class Player(bascenev1._player.Player[ForwardRef('Team')]):
58class Player(bs.Player['Team']):
59    """Our player type for this game."""
60
61    def __init__(self) -> None:
62        self.distance_txt: bs.Node | None = None
63        self.last_region = 0
64        self.lap = 0
65        self.distance = 0.0
66        self.finished = False
67        self.rank: int | None = None

Our player type for this game.

distance_txt: _bascenev1.Node | None
last_region
lap
distance
finished
rank: int | None
class Team(bascenev1._team.Team[bascenev1lib.game.race.Player]):
70class Team(bs.Team[Player]):
71    """Our team type for this game."""
72
73    def __init__(self) -> None:
74        self.time: float | None = None
75        self.lap = 0
76        self.finished = False

Our team type for this game.

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

Game of racing around a track.

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

Instantiate the Activity.

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

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

@override
@classmethod
def supports_session_type(cls, sessiontype: type[bascenev1.Session]) -> bool:
139    @override
140    @classmethod
141    def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool:
142        return issubclass(sessiontype, bs.MultiTeamSession)

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

@override
@classmethod
def get_supported_maps(cls, sessiontype: type[bascenev1.Session]) -> list[str]:
144    @override
145    @classmethod
146    def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]:
147        assert bs.app.classic is not None
148        return bs.app.classic.getmaps('race')

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

race_region_material: _bascenev1.Material | None
slow_motion = False

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

default_music = None
@override
def get_instance_description(self) -> Union[str, Sequence]:
187    @override
188    def get_instance_description(self) -> str | Sequence:
189        if (
190            isinstance(self.session, bs.DualTeamSession)
191            and self._entire_team_must_finish
192        ):
193            t_str = ' Your entire team has to finish.'
194        else:
195            t_str = ''
196
197        if self._laps > 1:
198            return 'Run ${ARG1} laps.' + t_str, self._laps
199        return 'Run 1 lap.' + t_str

Return a description for this game instance, in English.

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

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

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

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

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

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

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

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

@override
def get_instance_description_short(self) -> Union[str, Sequence]:
201    @override
202    def get_instance_description_short(self) -> str | Sequence:
203        if self._laps > 1:
204            return 'run ${ARG1} laps', self._laps
205        return 'run 1 lap'

Return a short description for this game instance in English.

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

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

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

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

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

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

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

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

@override
def on_transition_in(self) -> None:
207    @override
208    def on_transition_in(self) -> None:
209        super().on_transition_in()
210        shared = SharedObjects.get()
211        pts = self.map.get_def_points('race_point')
212        mat = self.race_region_material = bs.Material()
213        mat.add_actions(
214            conditions=('they_have_material', shared.player_material),
215            actions=(
216                ('modify_part_collision', 'collide', True),
217                ('modify_part_collision', 'physical', False),
218                ('call', 'at_connect', self._handle_race_point_collide),
219            ),
220        )
221        for rpt in pts:
222            self._regions.append(RaceRegion(rpt, len(self._regions)))

Called when the Activity is first becoming visible.

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

@override
def on_team_join(self, team: Team) -> None:
390    @override
391    def on_team_join(self, team: Team) -> None:
392        self._update_scoreboard()

Called when a new bascenev1.Team joins the Activity.

(including the initial set of Teams)

@override
def on_player_leave(self, player: Player) -> None:
394    @override
395    def on_player_leave(self, player: Player) -> None:
396        super().on_player_leave(player)
397
398        # A player leaving disqualifies the team if 'Entire Team Must Finish'
399        # is on (otherwise in teams mode everyone could just leave except the
400        # leading player to win).
401        if (
402            isinstance(self.session, bs.DualTeamSession)
403            and self._entire_team_must_finish
404        ):
405            bs.broadcastmessage(
406                bs.Lstr(
407                    translate=(
408                        'statements',
409                        '${TEAM} is disqualified because ${PLAYER} left',
410                    ),
411                    subs=[
412                        ('${TEAM}', player.team.name),
413                        ('${PLAYER}', player.getname(full=True)),
414                    ],
415                ),
416                color=(1, 1, 0),
417            )
418            player.team.finished = True
419            player.team.time = None
420            player.team.lap = 0
421            bs.getsound('boo').play()
422            for otherplayer in player.team.players:
423                otherplayer.lap = 0
424                otherplayer.finished = True
425                try:
426                    if otherplayer.actor is not None:
427                        otherplayer.actor.handlemessage(bs.DieMessage())
428                except Exception:
429                    logging.exception('Error sending DieMessage.')
430
431        # Defer so team/player lists will be updated.
432        bs.pushcall(self._check_end_game)

Called when a bascenev1.Player is leaving the Activity.

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

Called once the previous Activity has finished transitioning out.

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

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

Spawn something for the provided bascenev1.Player.

The default implementation simply calls spawn_player_spaz().

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

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

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

@override
def handlemessage(self, msg: Any) -> Any:
805    @override
806    def handlemessage(self, msg: Any) -> Any:
807        if isinstance(msg, bs.PlayerDiedMessage):
808            # Augment default behavior.
809            super().handlemessage(msg)
810            player = msg.getplayer(Player)
811            if not player.finished:
812                self.respawn_player(player, respawn_time=1)
813        else:
814            super().handlemessage(msg)

General message handling; can be passed any message object.