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

Region used to track progress during a race.

RaceRegion(pt: Sequence[float], index: int)
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        )

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

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

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

Game of racing around a track.

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

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

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

@classmethod
def supports_session_type(cls, sessiontype: type[bascenev1._session.Session]) -> bool:
137    @classmethod
138    def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool:
139        return issubclass(sessiontype, bs.MultiTeamSession)

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

@classmethod
def get_supported_maps(cls, sessiontype: type[bascenev1._session.Session]) -> list[str]:
141    @classmethod
142    def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]:
143        assert bs.app.classic is not None
144        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
def get_instance_description(self) -> Union[str, Sequence]:
183    def get_instance_description(self) -> str | Sequence:
184        if (
185            isinstance(self.session, bs.DualTeamSession)
186            and self._entire_team_must_finish
187        ):
188            t_str = ' Your entire team has to finish.'
189        else:
190            t_str = ''
191
192        if self._laps > 1:
193            return 'Run ${ARG1} laps.' + t_str, self._laps
194        return 'Run 1 lap.' + t_str

Return a description for this game instance, in English.

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

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

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

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

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

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

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

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

def get_instance_description_short(self) -> Union[str, Sequence]:
196    def get_instance_description_short(self) -> str | Sequence:
197        if self._laps > 1:
198            return 'run ${ARG1} laps', self._laps
199        return 'run 1 lap'

Return a short description for this game instance in English.

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

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

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

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

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

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

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

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

def on_transition_in(self) -> None:
201    def on_transition_in(self) -> None:
202        super().on_transition_in()
203        shared = SharedObjects.get()
204        pts = self.map.get_def_points('race_point')
205        mat = self.race_region_material = bs.Material()
206        mat.add_actions(
207            conditions=('they_have_material', shared.player_material),
208            actions=(
209                ('modify_part_collision', 'collide', True),
210                ('modify_part_collision', 'physical', False),
211                ('call', 'at_connect', self._handle_race_point_collide),
212            ),
213        )
214        for rpt in pts:
215            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.

def on_team_join(self, team: Team) -> None:
383    def on_team_join(self, team: Team) -> None:
384        self._update_scoreboard()

Called when a new bascenev1.Team joins the Activity.

(including the initial set of Teams)

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

Called when a bascenev1.Player is leaving the Activity.

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

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

Spawn something for the provided bascenev1.Player.

The default implementation simply calls spawn_player_spaz().

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

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.

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