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

Game of racing around a track.

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

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) or issubclass(
143            sessiontype, bs.CoopSession
144        )

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]:
146    @override
147    @classmethod
148    def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]:
149        assert bs.app.classic is not None
150        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]:
189    @override
190    def get_instance_description(self) -> str | Sequence:
191        if (
192            isinstance(self.session, bs.DualTeamSession)
193            and self._entire_team_must_finish
194        ):
195            t_str = ' Your entire team has to finish.'
196        else:
197            t_str = ''
198
199        if self._laps > 1:
200            return 'Run ${ARG1} laps.' + t_str, self._laps
201        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]:
203    @override
204    def get_instance_description_short(self) -> str | Sequence:
205        if self._laps > 1:
206            return 'run ${ARG1} laps', self._laps
207        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:
209    @override
210    def on_transition_in(self) -> None:
211        super().on_transition_in()
212        shared = SharedObjects.get()
213        pts = self.map.get_def_points('race_point')
214        mat = self.race_region_material = bs.Material()
215        mat.add_actions(
216            conditions=('they_have_material', shared.player_material),
217            actions=(
218                ('modify_part_collision', 'collide', True),
219                ('modify_part_collision', 'physical', False),
220                ('call', 'at_connect', self._handle_race_point_collide),
221            ),
222        )
223        for rpt in pts:
224            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:
392    @override
393    def on_team_join(self, team: Team) -> None:
394        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:
396    @override
397    def on_player_leave(self, player: Player) -> None:
398        super().on_player_leave(player)
399
400        # A player leaving disqualifies the team if 'Entire Team Must Finish'
401        # is on (otherwise in teams mode everyone could just leave except the
402        # leading player to win).
403        if (
404            isinstance(self.session, bs.DualTeamSession)
405            and self._entire_team_must_finish
406        ):
407            bs.broadcastmessage(
408                bs.Lstr(
409                    translate=(
410                        'statements',
411                        '${TEAM} is disqualified because ${PLAYER} left',
412                    ),
413                    subs=[
414                        ('${TEAM}', player.team.name),
415                        ('${PLAYER}', player.getname(full=True)),
416                    ],
417                ),
418                color=(1, 1, 0),
419            )
420            player.team.finished = True
421            player.team.time = None
422            player.team.lap = 0
423            bs.getsound('boo').play()
424            for otherplayer in player.team.players:
425                otherplayer.lap = 0
426                otherplayer.finished = True
427                try:
428                    if otherplayer.actor is not None:
429                        otherplayer.actor.handlemessage(bs.DieMessage())
430                except Exception:
431                    logging.exception('Error sending DieMessage.')
432
433        # Defer so team/player lists will be updated.
434        bs.pushcall(self._check_end_game)

Called when a bascenev1.Player is leaving the Activity.

@override
def on_begin(self) -> None:
457    @override
458    def on_begin(self) -> None:
459        from bascenev1lib.actor.onscreentimer import OnScreenTimer
460
461        super().on_begin()
462        self.setup_standard_time_limit(self._time_limit)
463        self.setup_standard_powerup_drops()
464        self._team_finish_pts = 100
465
466        # Throw a timer up on-screen.
467        self._time_text = bs.NodeActor(
468            bs.newnode(
469                'text',
470                attrs={
471                    'v_attach': 'top',
472                    'h_attach': 'center',
473                    'h_align': 'center',
474                    'color': (1, 1, 0.5, 1),
475                    'flatness': 0.5,
476                    'shadow': 0.5,
477                    'position': (0, -50),
478                    'scale': 1.4,
479                    'text': '',
480                },
481            )
482        )
483        self._timer = OnScreenTimer()
484
485        if self._mine_spawning != 0:
486            self._race_mines = [
487                RaceMine(point=p, mine=None)
488                for p in self.map.get_def_points('race_mine')
489            ]
490            if self._race_mines:
491                self._race_mine_timer = bs.Timer(
492                    0.001 * self._mine_spawning,
493                    self._update_race_mine,
494                    repeat=True,
495                )
496
497        self._scoreboard_timer = bs.Timer(
498            0.25, self._update_scoreboard, repeat=True
499        )
500        self._player_order_update_timer = bs.Timer(
501            0.25, self._update_player_order, repeat=True
502        )
503
504        if self.slow_motion:
505            t_scale = 0.4
506            light_y = 50
507        else:
508            t_scale = 1.0
509            light_y = 150
510        lstart = 7.1 * t_scale
511        inc = 1.25 * t_scale
512
513        bs.timer(lstart, self._do_light_1)
514        bs.timer(lstart + inc, self._do_light_2)
515        bs.timer(lstart + 2 * inc, self._do_light_3)
516        bs.timer(lstart + 3 * inc, self._start_race)
517
518        self._start_lights = []
519        for i in range(4):
520            lnub = bs.newnode(
521                'image',
522                attrs={
523                    'texture': bs.gettexture('nub'),
524                    'opacity': 1.0,
525                    'absolute_scale': True,
526                    'position': (-75 + i * 50, light_y),
527                    'scale': (50, 50),
528                    'attach': 'center',
529                },
530            )
531            bs.animate(
532                lnub,
533                'opacity',
534                {
535                    4.0 * t_scale: 0,
536                    5.0 * t_scale: 1.0,
537                    12.0 * t_scale: 1.0,
538                    12.5 * t_scale: 0.0,
539                },
540            )
541            bs.timer(13.0 * t_scale, lnub.delete)
542            self._start_lights.append(lnub)
543
544        self._start_lights[0].color = (0.2, 0, 0)
545        self._start_lights[1].color = (0.2, 0, 0)
546        self._start_lights[2].color = (0.2, 0.05, 0)
547        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:
686    @override
687    def spawn_player(self, player: Player) -> bs.Actor:
688        if player.team.finished:
689            # FIXME: This is not type-safe!
690            #   This call is expected to always return an Actor!
691            #   Perhaps we need something like can_spawn_player()...
692            # noinspection PyTypeChecker
693            return None  # type: ignore
694        pos = self._regions[player.last_region].pos
695
696        # Don't use the full region so we're less likely to spawn off a cliff.
697        region_scale = 0.8
698        x_range = (
699            (-0.5, 0.5)
700            if pos[3] == 0
701            else (-region_scale * pos[3], region_scale * pos[3])
702        )
703        z_range = (
704            (-0.5, 0.5)
705            if pos[5] == 0
706            else (-region_scale * pos[5], region_scale * pos[5])
707        )
708        pos = (
709            pos[0] + random.uniform(*x_range),
710            pos[1],
711            pos[2] + random.uniform(*z_range),
712        )
713        spaz = self.spawn_player_spaz(
714            player, position=pos, angle=90 if not self._race_started else None
715        )
716        assert spaz.node
717
718        # Prevent controlling of characters before the start of the race.
719        if not self._race_started:
720            spaz.disconnect_controls_from_player()
721
722        mathnode = bs.newnode(
723            'math',
724            owner=spaz.node,
725            attrs={'input1': (0, 1.4, 0), 'operation': 'add'},
726        )
727        spaz.node.connectattr('torso_position', mathnode, 'input2')
728
729        distance_txt = bs.newnode(
730            'text',
731            owner=spaz.node,
732            attrs={
733                'text': '',
734                'in_world': True,
735                'color': (1, 1, 0.4),
736                'scale': 0.02,
737                'h_align': 'center',
738            },
739        )
740        player.distance_txt = distance_txt
741        mathnode.connectattr('output', distance_txt, 'position')
742        return spaz

Spawn something for the provided bascenev1.Player.

The default implementation simply calls spawn_player_spaz().

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

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

General message handling; can be passed any message object.