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 8
  6# (see https://ballistica.net/wiki/meta-tag-system)
  7
  8from __future__ import annotations
  9
 10import random
 11import logging
 12from typing import TYPE_CHECKING
 13from dataclasses import dataclass
 14
 15from typing_extensions import override
 16import bascenev1 as bs
 17
 18from bascenev1lib.actor.bomb import Bomb
 19from bascenev1lib.actor.playerspaz import PlayerSpaz
 20from bascenev1lib.actor.scoreboard import Scoreboard
 21from bascenev1lib.gameutils import SharedObjects
 22
 23if TYPE_CHECKING:
 24    from typing import Any, Sequence
 25
 26    from bascenev1lib.actor.onscreentimer import OnScreenTimer
 27
 28
 29@dataclass
 30class RaceMine:
 31    """Holds info about a mine on the track."""
 32
 33    point: Sequence[float]
 34    mine: Bomb | None
 35
 36
 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        )
 56
 57
 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
 68
 69
 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
 77
 78
 79# ba_meta export bascenev1.GameActivity
 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=None
782                if self._last_team_time is None
783                else (self._timer.getstarttime() + self._last_team_time)
784            )
785
786        results = bs.GameResults()
787
788        for team in self.teams:
789            if team.time is not None:
790                # We store time in seconds, but pass a score in milliseconds.
791                results.set_team_score(team, int(team.time * 1000.0))
792            else:
793                results.set_team_score(team, None)
794
795        # We don't announce a winner in ffa mode since its probably been a
796        # while since the first place guy crossed the finish line so it seems
797        # odd to be announcing that now.
798        self.end(
799            results=results,
800            announce_winning_team=isinstance(self.session, bs.DualTeamSession),
801        )
802
803    @override
804    def handlemessage(self, msg: Any) -> Any:
805        if isinstance(msg, bs.PlayerDiedMessage):
806            # Augment default behavior.
807            super().handlemessage(msg)
808            player = msg.getplayer(Player)
809            if not player.finished:
810                self.respawn_player(player, respawn_time=1)
811        else:
812            super().handlemessage(msg)
@dataclass
class RaceMine:
30@dataclass
31class RaceMine:
32    """Holds info about a mine on the track."""
33
34    point: Sequence[float]
35    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):
38class RaceRegion(bs.Actor):
39    """Region used to track progress during a race."""
40
41    def __init__(self, pt: Sequence[float], index: int):
42        super().__init__()
43        activity = self.activity
44        assert isinstance(activity, RaceGame)
45        self.pos = pt
46        self.index = index
47        self.node = bs.newnode(
48            'region',
49            delegate=self,
50            attrs={
51                'position': pt[:3],
52                'scale': (pt[3] * 2.0, pt[4] * 2.0, pt[5] * 2.0),
53                'type': 'box',
54                'materials': [activity.race_region_material],
55            },
56        )

Region used to track progress during a race.

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

Instantiates an Actor in the current bascenev1.Activity.

pos
index
node
Inherited Members
bascenev1._actor.Actor
handlemessage
autoretain
on_expire
expired
exists
is_alive
activity
getactivity
class Player(bascenev1._player.Player[ForwardRef('Team')]):
59class Player(bs.Player['Team']):
60    """Our player type for this game."""
61
62    def __init__(self) -> None:
63        self.distance_txt: bs.Node | None = None
64        self.last_region = 0
65        self.lap = 0
66        self.distance = 0.0
67        self.finished = False
68        self.rank: int | None = None

Our player type for this game.

distance_txt: _bascenev1.Node | None
last_region
lap
distance
finished
rank: int | None
Inherited Members
bascenev1._player.Player
character
actor
color
highlight
on_expire
team
customdata
sessionplayer
node
position
exists
getname
is_alive
get_icon
assigninput
resetinput
class Team(bascenev1._team.Team[bascenev1lib.game.race.Player]):
71class Team(bs.Team[Player]):
72    """Our team type for this game."""
73
74    def __init__(self) -> None:
75        self.time: float | None = None
76        self.lap = 0
77        self.finished = False

Our team type for this game.

time: float | None
lap
finished
Inherited Members
bascenev1._team.Team
players
id
name
color
manual_init
customdata
on_expire
sessionteam
class RaceGame(bascenev1._teamgame.TeamGameActivity[bascenev1lib.game.race.Player, bascenev1lib.game.race.Team]):
 81class RaceGame(bs.TeamGameActivity[Player, Team]):
 82    """Game of racing around a track."""
 83
 84    name = 'Race'
 85    description = 'Run real fast!'
 86    scoreconfig = bs.ScoreConfig(
 87        label='Time', lower_is_better=True, scoretype=bs.ScoreType.MILLISECONDS
 88    )
 89
 90    @override
 91    @classmethod
 92    def get_available_settings(
 93        cls, sessiontype: type[bs.Session]
 94    ) -> list[bs.Setting]:
 95        settings = [
 96            bs.IntSetting('Laps', min_value=1, default=3, increment=1),
 97            bs.IntChoiceSetting(
 98                'Time Limit',
 99                default=0,
100                choices=[
101                    ('None', 0),
102                    ('1 Minute', 60),
103                    ('2 Minutes', 120),
104                    ('5 Minutes', 300),
105                    ('10 Minutes', 600),
106                    ('20 Minutes', 1200),
107                ],
108            ),
109            bs.IntChoiceSetting(
110                'Mine Spawning',
111                default=4000,
112                choices=[
113                    ('No Mines', 0),
114                    ('8 Seconds', 8000),
115                    ('4 Seconds', 4000),
116                    ('2 Seconds', 2000),
117                ],
118            ),
119            bs.IntChoiceSetting(
120                'Bomb Spawning',
121                choices=[
122                    ('None', 0),
123                    ('8 Seconds', 8000),
124                    ('4 Seconds', 4000),
125                    ('2 Seconds', 2000),
126                    ('1 Second', 1000),
127                ],
128                default=2000,
129            ),
130            bs.BoolSetting('Epic Mode', default=False),
131        ]
132
133        # We have some specific settings in teams mode.
134        if issubclass(sessiontype, bs.DualTeamSession):
135            settings.append(
136                bs.BoolSetting('Entire Team Must Finish', default=False)
137            )
138        return settings
139
140    @override
141    @classmethod
142    def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool:
143        return issubclass(sessiontype, bs.MultiTeamSession)
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=None
783                if self._last_team_time is None
784                else (self._timer.getstarttime() + self._last_team_time)
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)

Game of racing around a track.

RaceGame(settings: dict)
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        )

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

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]:
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

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]:
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'

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:
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)))

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:
391    @override
392    def on_team_join(self, team: Team) -> None:
393        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:
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)

Called when a bascenev1.Player is leaving the Activity.

@override
def on_begin(self) -> None:
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)

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.Actor:
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

Spawn something for the provided bascenev1.Player.

The default implementation simply calls spawn_player_spaz().

@override
def end_game(self) -> None:
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=None
783                if self._last_team_time is None
784                else (self._timer.getstarttime() + self._last_team_time)
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        )

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:
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)

General message handling; can be passed any message object.

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