bascenev1lib.game.capturetheflag

Defines a capture-the-flag game.

  1# Released under the MIT License. See LICENSE for details.
  2#
  3"""Defines a capture-the-flag game."""
  4
  5# ba_meta require api 9
  6# (see https://ballistica.net/wiki/meta-tag-system)
  7
  8from __future__ import annotations
  9
 10import logging
 11from typing import TYPE_CHECKING, override
 12
 13import bascenev1 as bs
 14
 15from bascenev1lib.actor.playerspaz import PlayerSpaz
 16from bascenev1lib.actor.scoreboard import Scoreboard
 17from bascenev1lib.actor.flag import (
 18    FlagFactory,
 19    Flag,
 20    FlagPickedUpMessage,
 21    FlagDroppedMessage,
 22    FlagDiedMessage,
 23)
 24
 25if TYPE_CHECKING:
 26    from typing import Any, Sequence
 27
 28
 29class CTFFlag(Flag):
 30    """Special flag type for CTF games."""
 31
 32    activity: CaptureTheFlagGame
 33
 34    def __init__(self, team: Team):
 35
 36        assert team.flagmaterial is not None
 37        super().__init__(
 38            materials=[team.flagmaterial],
 39            position=team.base_pos,
 40            color=team.color,
 41        )
 42        self._team = team
 43        self.held_count = 0
 44        self.counter = bs.newnode(
 45            'text',
 46            owner=self.node,
 47            attrs={'in_world': True, 'scale': 0.02, 'h_align': 'center'},
 48        )
 49        self.reset_return_times()
 50        self.last_player_to_hold: Player | None = None
 51        self.time_out_respawn_time: int | None = None
 52        self.touch_return_time: float | None = None
 53
 54    def reset_return_times(self) -> None:
 55        """Clear flag related times in the activity."""
 56        self.time_out_respawn_time = int(self.activity.flag_idle_return_time)
 57        self.touch_return_time = float(self.activity.flag_touch_return_time)
 58
 59    @property
 60    def team(self) -> Team:
 61        """The flag's team."""
 62        return self._team
 63
 64
 65class Player(bs.Player['Team']):
 66    """Our player type for this game."""
 67
 68    def __init__(self) -> None:
 69        self.touching_own_flag = 0
 70
 71
 72class Team(bs.Team[Player]):
 73    """Our team type for this game."""
 74
 75    def __init__(
 76        self,
 77        *,
 78        base_pos: Sequence[float],
 79        base_region_material: bs.Material,
 80        base_region: bs.Node,
 81        spaz_material_no_flag_physical: bs.Material,
 82        spaz_material_no_flag_collide: bs.Material,
 83        flagmaterial: bs.Material,
 84    ):
 85        self.base_pos = base_pos
 86        self.base_region_material = base_region_material
 87        self.base_region = base_region
 88        self.spaz_material_no_flag_physical = spaz_material_no_flag_physical
 89        self.spaz_material_no_flag_collide = spaz_material_no_flag_collide
 90        self.flagmaterial = flagmaterial
 91        self.score = 0
 92        self.flag_return_touches = 0
 93        self.home_flag_at_base = True
 94        self.touch_return_timer: bs.Timer | None = None
 95        self.enemy_flag_at_base = False
 96        self.flag: CTFFlag | None = None
 97        self.last_flag_leave_time: float | None = None
 98        self.touch_return_timer_ticking: bs.NodeActor | None = None
 99
100
101# ba_meta export bascenev1.GameActivity
102class CaptureTheFlagGame(bs.TeamGameActivity[Player, Team]):
103    """Game of stealing other team's flag and returning it to your base."""
104
105    name = 'Capture the Flag'
106    description = 'Return the enemy flag to score.'
107    available_settings = [
108        bs.IntSetting('Score to Win', min_value=1, default=3),
109        bs.IntSetting(
110            'Flag Touch Return Time',
111            min_value=0,
112            default=0,
113            increment=1,
114        ),
115        bs.IntSetting(
116            'Flag Idle Return Time',
117            min_value=5,
118            default=30,
119            increment=5,
120        ),
121        bs.IntChoiceSetting(
122            'Time Limit',
123            choices=[
124                ('None', 0),
125                ('1 Minute', 60),
126                ('2 Minutes', 120),
127                ('5 Minutes', 300),
128                ('10 Minutes', 600),
129                ('20 Minutes', 1200),
130            ],
131            default=0,
132        ),
133        bs.FloatChoiceSetting(
134            'Respawn Times',
135            choices=[
136                ('Shorter', 0.25),
137                ('Short', 0.5),
138                ('Normal', 1.0),
139                ('Long', 2.0),
140                ('Longer', 4.0),
141            ],
142            default=1.0,
143        ),
144        bs.BoolSetting('Epic Mode', default=False),
145    ]
146
147    @override
148    @classmethod
149    def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool:
150        return issubclass(sessiontype, bs.DualTeamSession)
151
152    @override
153    @classmethod
154    def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]:
155        assert bs.app.classic is not None
156        return bs.app.classic.getmaps('team_flag')
157
158    def __init__(self, settings: dict):
159        super().__init__(settings)
160        self._scoreboard = Scoreboard()
161        self._alarmsound = bs.getsound('alarm')
162        self._ticking_sound = bs.getsound('ticking')
163        self._score_sound = bs.getsound('score')
164        self._swipsound = bs.getsound('swip')
165        self._last_score_time = 0
166        self._all_bases_material = bs.Material()
167        self._last_home_flag_notice_print_time = 0.0
168        self._score_to_win = int(settings['Score to Win'])
169        self._epic_mode = bool(settings['Epic Mode'])
170        self._time_limit = float(settings['Time Limit'])
171
172        self.flag_touch_return_time = float(settings['Flag Touch Return Time'])
173        self.flag_idle_return_time = float(settings['Flag Idle Return Time'])
174
175        # Base class overrides.
176        self.slow_motion = self._epic_mode
177        self.default_music = (
178            bs.MusicType.EPIC if self._epic_mode else bs.MusicType.FLAG_CATCHER
179        )
180
181    @override
182    def get_instance_description(self) -> str | Sequence:
183        if self._score_to_win == 1:
184            return 'Steal the enemy flag.'
185        return 'Steal the enemy flag ${ARG1} times.', self._score_to_win
186
187    @override
188    def get_instance_description_short(self) -> str | Sequence:
189        if self._score_to_win == 1:
190            return 'return 1 flag'
191        return 'return ${ARG1} flags', self._score_to_win
192
193    @override
194    def create_team(self, sessionteam: bs.SessionTeam) -> Team:
195        # Create our team instance and its initial values.
196
197        base_pos = self.map.get_flag_position(sessionteam.id)
198        Flag.project_stand(base_pos)
199
200        bs.newnode(
201            'light',
202            attrs={
203                'position': base_pos,
204                'intensity': 0.6,
205                'height_attenuated': False,
206                'volume_intensity_scale': 0.1,
207                'radius': 0.1,
208                'color': sessionteam.color,
209            },
210        )
211
212        base_region_mat = bs.Material()
213        pos = base_pos
214        base_region = bs.newnode(
215            'region',
216            attrs={
217                'position': (pos[0], pos[1] + 0.75, pos[2]),
218                'scale': (0.5, 0.5, 0.5),
219                'type': 'sphere',
220                'materials': [base_region_mat, self._all_bases_material],
221            },
222        )
223
224        spaz_mat_no_flag_physical = bs.Material()
225        spaz_mat_no_flag_collide = bs.Material()
226        flagmat = bs.Material()
227
228        team = Team(
229            base_pos=base_pos,
230            base_region_material=base_region_mat,
231            base_region=base_region,
232            spaz_material_no_flag_physical=spaz_mat_no_flag_physical,
233            spaz_material_no_flag_collide=spaz_mat_no_flag_collide,
234            flagmaterial=flagmat,
235        )
236
237        # Some parts of our spazzes don't collide physically with our
238        # flags but generate callbacks.
239        spaz_mat_no_flag_physical.add_actions(
240            conditions=('they_have_material', flagmat),
241            actions=(
242                ('modify_part_collision', 'physical', False),
243                (
244                    'call',
245                    'at_connect',
246                    lambda: self._handle_touching_own_flag(team, True),
247                ),
248                (
249                    'call',
250                    'at_disconnect',
251                    lambda: self._handle_touching_own_flag(team, False),
252                ),
253            ),
254        )
255
256        # Other parts of our spazzes don't collide with our flags at all.
257        spaz_mat_no_flag_collide.add_actions(
258            conditions=('they_have_material', flagmat),
259            actions=('modify_part_collision', 'collide', False),
260        )
261
262        # We wanna know when *any* flag enters/leaves our base.
263        base_region_mat.add_actions(
264            conditions=('they_have_material', FlagFactory.get().flagmaterial),
265            actions=(
266                ('modify_part_collision', 'collide', True),
267                ('modify_part_collision', 'physical', False),
268                (
269                    'call',
270                    'at_connect',
271                    lambda: self._handle_flag_entered_base(team),
272                ),
273                (
274                    'call',
275                    'at_disconnect',
276                    lambda: self._handle_flag_left_base(team),
277                ),
278            ),
279        )
280
281        return team
282
283    @override
284    def on_team_join(self, team: Team) -> None:
285        # Can't do this in create_team because the team's color/etc. have
286        # not been wired up yet at that point.
287        self._spawn_flag_for_team(team)
288        self._update_scoreboard()
289
290    @override
291    def on_begin(self) -> None:
292        super().on_begin()
293        self.setup_standard_time_limit(self._time_limit)
294        self.setup_standard_powerup_drops()
295        bs.timer(1.0, call=self._tick, repeat=True)
296
297    def _spawn_flag_for_team(self, team: Team) -> None:
298        team.flag = CTFFlag(team)
299        team.flag_return_touches = 0
300        self._flash_base(team, length=1.0)
301        assert team.flag.node
302        self._swipsound.play(position=team.flag.node.position)
303
304    def _handle_flag_entered_base(self, team: Team) -> None:
305        try:
306            flag = bs.getcollision().opposingnode.getdelegate(CTFFlag, True)
307        except bs.NotFoundError:
308            # Don't think this should logically ever happen.
309            print('Error getting CTFFlag in entering-base callback.')
310            return
311
312        if flag.team is team:
313            team.home_flag_at_base = True
314
315            # If the enemy flag is already here, score!
316            if team.enemy_flag_at_base:
317                # And show team name which scored (but actually we could
318                # show here player who returned enemy flag).
319                self.show_zoom_message(
320                    bs.Lstr(
321                        resource='nameScoresText', subs=[('${NAME}', team.name)]
322                    ),
323                    color=team.color,
324                )
325                self._score(team)
326        else:
327            team.enemy_flag_at_base = True
328            if team.home_flag_at_base:
329                # Award points to whoever was carrying the enemy flag.
330                player = flag.last_player_to_hold
331                if player and player.team is team:
332                    self.stats.player_scored(player, 50, big_message=True)
333
334                # Update score and reset flags.
335                self._score(team)
336
337            # If the home-team flag isn't here, print a message to that effect.
338            else:
339                # Don't want slo-mo affecting this
340                curtime = bs.basetime()
341                if curtime - self._last_home_flag_notice_print_time > 5.0:
342                    self._last_home_flag_notice_print_time = curtime
343                    bpos = team.base_pos
344                    tval = bs.Lstr(resource='ownFlagAtYourBaseWarning')
345                    tnode = bs.newnode(
346                        'text',
347                        attrs={
348                            'text': tval,
349                            'in_world': True,
350                            'scale': 0.013,
351                            'color': (1, 1, 0, 1),
352                            'h_align': 'center',
353                            'position': (bpos[0], bpos[1] + 3.2, bpos[2]),
354                        },
355                    )
356                    bs.timer(5.1, tnode.delete)
357                    bs.animate(
358                        tnode, 'scale', {0.0: 0, 0.2: 0.013, 4.8: 0.013, 5.0: 0}
359                    )
360
361    def _tick(self) -> None:
362        # If either flag is away from base and not being held, tick down its
363        # respawn timer.
364        for team in self.teams:
365            flag = team.flag
366            assert flag is not None
367
368            if not team.home_flag_at_base and flag.held_count == 0:
369                time_out_counting_down = True
370                if flag.time_out_respawn_time is None:
371                    flag.reset_return_times()
372                assert flag.time_out_respawn_time is not None
373                flag.time_out_respawn_time -= 1
374                if flag.time_out_respawn_time <= 0:
375                    flag.handlemessage(bs.DieMessage())
376            else:
377                time_out_counting_down = False
378
379            if flag.node and flag.counter:
380                pos = flag.node.position
381                flag.counter.position = (pos[0], pos[1] + 1.3, pos[2])
382
383                # If there's no self-touches on this flag, set its text
384                # to show its auto-return counter.  (if there's self-touches
385                # its showing that time).
386                if team.flag_return_touches == 0:
387                    flag.counter.text = (
388                        str(flag.time_out_respawn_time)
389                        if (
390                            time_out_counting_down
391                            and flag.time_out_respawn_time is not None
392                            and flag.time_out_respawn_time <= 10
393                        )
394                        else ''
395                    )
396                    flag.counter.color = (1, 1, 1, 0.5)
397                    flag.counter.scale = 0.014
398
399    def _score(self, team: Team) -> None:
400        team.score += 1
401        self._score_sound.play()
402        self._flash_base(team)
403        self._update_scoreboard()
404
405        # Have teammates celebrate.
406        for player in team.players:
407            if player.actor:
408                player.actor.handlemessage(bs.CelebrateMessage(2.0))
409
410        # Reset all flags/state.
411        for reset_team in self.teams:
412            if not reset_team.home_flag_at_base:
413                assert reset_team.flag is not None
414                reset_team.flag.handlemessage(bs.DieMessage())
415            reset_team.enemy_flag_at_base = False
416        if team.score >= self._score_to_win:
417            self.end_game()
418
419    @override
420    def end_game(self) -> None:
421        results = bs.GameResults()
422        for team in self.teams:
423            results.set_team_score(team, team.score)
424        self.end(results=results, announce_delay=0.8)
425
426    def _handle_flag_left_base(self, team: Team) -> None:
427        cur_time = bs.time()
428        try:
429            flag = bs.getcollision().opposingnode.getdelegate(CTFFlag, True)
430        except bs.NotFoundError:
431            # This can happen if the flag stops touching us due to being
432            # deleted; that's ok.
433            return
434
435        if flag.team is team:
436            # Check times here to prevent too much flashing.
437            if (
438                team.last_flag_leave_time is None
439                or cur_time - team.last_flag_leave_time > 3.0
440            ):
441                self._alarmsound.play(position=team.base_pos)
442                self._flash_base(team)
443            team.last_flag_leave_time = cur_time
444            team.home_flag_at_base = False
445        else:
446            team.enemy_flag_at_base = False
447
448    def _touch_return_update(self, team: Team) -> None:
449        # Count down only while its away from base and not being held.
450        assert team.flag is not None
451        if team.home_flag_at_base or team.flag.held_count > 0:
452            team.touch_return_timer_ticking = None
453            return  # No need to return when its at home.
454        if team.touch_return_timer_ticking is None:
455            team.touch_return_timer_ticking = bs.NodeActor(
456                bs.newnode(
457                    'sound',
458                    attrs={
459                        'sound': self._ticking_sound,
460                        'positional': False,
461                        'loop': True,
462                    },
463                )
464            )
465        flag = team.flag
466        if flag.touch_return_time is not None:
467            flag.touch_return_time -= 0.1
468            if flag.counter:
469                flag.counter.text = f'{flag.touch_return_time:.1f}'
470                flag.counter.color = (1, 1, 0, 1)
471                flag.counter.scale = 0.02
472
473            if flag.touch_return_time <= 0.0:
474                self._award_players_touching_own_flag(team)
475                flag.handlemessage(bs.DieMessage())
476
477    def _award_players_touching_own_flag(self, team: Team) -> None:
478        for player in team.players:
479            if player.touching_own_flag > 0:
480                return_score = 10 + 5 * int(self.flag_touch_return_time)
481                self.stats.player_scored(
482                    player, return_score, screenmessage=False
483                )
484
485    def _handle_touching_own_flag(self, team: Team, connecting: bool) -> None:
486        """Called when a player touches or stops touching their own team flag.
487
488        We keep track of when each player is touching their own flag so we
489        can award points when returned.
490        """
491        player: Player | None
492        try:
493            spaz = bs.getcollision().sourcenode.getdelegate(PlayerSpaz, True)
494        except bs.NotFoundError:
495            return
496
497        player = spaz.getplayer(Player, True)
498
499        if player:
500            player.touching_own_flag += 1 if connecting else -1
501
502        # If return-time is zero, just kill it immediately.. otherwise keep
503        # track of touches and count down.
504        if float(self.flag_touch_return_time) <= 0.0:
505            assert team.flag is not None
506            if (
507                connecting
508                and not team.home_flag_at_base
509                and team.flag.held_count == 0
510            ):
511                self._award_players_touching_own_flag(team)
512                bs.getcollision().opposingnode.handlemessage(bs.DieMessage())
513
514        # Takes a non-zero amount of time to return.
515        else:
516            if connecting:
517                team.flag_return_touches += 1
518                if team.flag_return_touches == 1:
519                    team.touch_return_timer = bs.Timer(
520                        0.1,
521                        call=bs.Call(self._touch_return_update, team),
522                        repeat=True,
523                    )
524                    team.touch_return_timer_ticking = None
525            else:
526                team.flag_return_touches -= 1
527                if team.flag_return_touches == 0:
528                    team.touch_return_timer = None
529                    team.touch_return_timer_ticking = None
530            if team.flag_return_touches < 0:
531                logging.error('CTF flag_return_touches < 0', stack_info=True)
532
533    def _handle_death_flag_capture(self, player: Player) -> None:
534        """Handles flag values when a player dies or leaves the game."""
535        # Don't do anything if the player hasn't touched the flag at all.
536        if not player.touching_own_flag:
537            return
538
539        team = player.team
540
541        # For each "point" our player has touched theflag (Could be
542        # multiple), deduct one from both our player and the flag's
543        # return touches variable.
544        for _ in range(player.touching_own_flag):
545            # Deduct
546            player.touching_own_flag -= 1
547
548            # (This was only incremented if we have non-zero
549            # return-times).
550            if float(self.flag_touch_return_time) > 0.0:
551                team.flag_return_touches -= 1
552                # Update our flag's timer accordingly
553                # (Prevents immediate resets in case
554                # there might be more people touching it).
555                if team.flag_return_touches == 0:
556                    team.touch_return_timer = None
557                    team.touch_return_timer_ticking = None
558                # Safety check, just to be sure!
559                if team.flag_return_touches < 0:
560                    logging.error(
561                        'CTF flag_return_touches < 0', stack_info=True
562                    )
563
564    def _flash_base(self, team: Team, length: float = 2.0) -> None:
565        light = bs.newnode(
566            'light',
567            attrs={
568                'position': team.base_pos,
569                'height_attenuated': False,
570                'radius': 0.3,
571                'color': team.color,
572            },
573        )
574        bs.animate(light, 'intensity', {0.0: 0, 0.25: 2.0, 0.5: 0}, loop=True)
575        bs.timer(length, light.delete)
576
577    @override
578    def spawn_player_spaz(
579        self,
580        player: Player,
581        position: Sequence[float] | None = None,
582        angle: float | None = None,
583    ) -> PlayerSpaz:
584        """Intercept new spazzes and add our team material for them."""
585        spaz = super().spawn_player_spaz(player, position, angle)
586        player = spaz.getplayer(Player, True)
587        team: Team = player.team
588        player.touching_own_flag = 0
589        no_physical_mats: list[bs.Material] = [
590            team.spaz_material_no_flag_physical
591        ]
592        no_collide_mats: list[bs.Material] = [
593            team.spaz_material_no_flag_collide
594        ]
595
596        # Our normal parts should still collide; just not physically
597        # (so we can calc restores).
598        assert spaz.node
599        spaz.node.materials = list(spaz.node.materials) + no_physical_mats
600        spaz.node.roller_materials = (
601            list(spaz.node.roller_materials) + no_physical_mats
602        )
603
604        # Pickups and punches shouldn't hit at all though.
605        spaz.node.punch_materials = (
606            list(spaz.node.punch_materials) + no_collide_mats
607        )
608        spaz.node.pickup_materials = (
609            list(spaz.node.pickup_materials) + no_collide_mats
610        )
611        spaz.node.extras_material = (
612            list(spaz.node.extras_material) + no_collide_mats
613        )
614        return spaz
615
616    def _update_scoreboard(self) -> None:
617        for team in self.teams:
618            self._scoreboard.set_team_value(
619                team, team.score, self._score_to_win
620            )
621
622    @override
623    def handlemessage(self, msg: Any) -> Any:
624        if isinstance(msg, bs.PlayerDiedMessage):
625            super().handlemessage(msg)  # Augment standard behavior.
626            self._handle_death_flag_capture(msg.getplayer(Player))
627            self.respawn_player(msg.getplayer(Player))
628
629        elif isinstance(msg, FlagDiedMessage):
630            assert isinstance(msg.flag, CTFFlag)
631            bs.timer(0.1, bs.Call(self._spawn_flag_for_team, msg.flag.team))
632
633        elif isinstance(msg, FlagPickedUpMessage):
634            # Store the last player to hold the flag for scoring purposes.
635            assert isinstance(msg.flag, CTFFlag)
636            try:
637                msg.flag.last_player_to_hold = msg.node.getdelegate(
638                    PlayerSpaz, True
639                ).getplayer(Player, True)
640            except bs.NotFoundError:
641                pass
642
643            msg.flag.held_count += 1
644            msg.flag.reset_return_times()
645
646        elif isinstance(msg, FlagDroppedMessage):
647            # Store the last player to hold the flag for scoring purposes.
648            assert isinstance(msg.flag, CTFFlag)
649            msg.flag.held_count -= 1
650
651        else:
652            super().handlemessage(msg)
653
654    @override
655    def on_player_leave(self, player: Player) -> None:
656        """Prevents leaving players from capturing their flag."""
657        self._handle_death_flag_capture(player)
class CTFFlag(bascenev1lib.actor.flag.Flag):
30class CTFFlag(Flag):
31    """Special flag type for CTF games."""
32
33    activity: CaptureTheFlagGame
34
35    def __init__(self, team: Team):
36
37        assert team.flagmaterial is not None
38        super().__init__(
39            materials=[team.flagmaterial],
40            position=team.base_pos,
41            color=team.color,
42        )
43        self._team = team
44        self.held_count = 0
45        self.counter = bs.newnode(
46            'text',
47            owner=self.node,
48            attrs={'in_world': True, 'scale': 0.02, 'h_align': 'center'},
49        )
50        self.reset_return_times()
51        self.last_player_to_hold: Player | None = None
52        self.time_out_respawn_time: int | None = None
53        self.touch_return_time: float | None = None
54
55    def reset_return_times(self) -> None:
56        """Clear flag related times in the activity."""
57        self.time_out_respawn_time = int(self.activity.flag_idle_return_time)
58        self.touch_return_time = float(self.activity.flag_touch_return_time)
59
60    @property
61    def team(self) -> Team:
62        """The flag's team."""
63        return self._team

Special flag type for CTF games.

CTFFlag(team: Team)
35    def __init__(self, team: Team):
36
37        assert team.flagmaterial is not None
38        super().__init__(
39            materials=[team.flagmaterial],
40            position=team.base_pos,
41            color=team.color,
42        )
43        self._team = team
44        self.held_count = 0
45        self.counter = bs.newnode(
46            'text',
47            owner=self.node,
48            attrs={'in_world': True, 'scale': 0.02, 'h_align': 'center'},
49        )
50        self.reset_return_times()
51        self.last_player_to_hold: Player | None = None
52        self.time_out_respawn_time: int | None = None
53        self.touch_return_time: float | None = None

Instantiate a flag.

If 'touchable' is False, the flag will only touch terrain; useful for things like king-of-the-hill where players should not be moving the flag around.

'materials can be a list of extra bs.Materials to apply to the flag.

If 'dropped_timeout' is provided (in seconds), the flag will die after remaining untouched for that long once it has been moved from its initial position.

activity: bascenev1.Activity
187    @property
188    def activity(self) -> bascenev1.Activity:
189        """The Activity this Actor was created in.
190
191        Raises a bascenev1.ActivityNotFoundError if the Activity no longer
192        exists.
193        """
194        activity = self._activity()
195        if activity is None:
196            raise babase.ActivityNotFoundError()
197        return activity

The Activity this Actor was created in.

Raises a bascenev1.ActivityNotFoundError if the Activity no longer exists.

held_count
counter
last_player_to_hold: Player | None
time_out_respawn_time: int | None
touch_return_time: float | None
def reset_return_times(self) -> None:
55    def reset_return_times(self) -> None:
56        """Clear flag related times in the activity."""
57        self.time_out_respawn_time = int(self.activity.flag_idle_return_time)
58        self.touch_return_time = float(self.activity.flag_touch_return_time)

Clear flag related times in the activity.

team: Team
60    @property
61    def team(self) -> Team:
62        """The flag's team."""
63        return self._team

The flag's team.

class Player(bascenev1._player.Player[ForwardRef('Team')]):
66class Player(bs.Player['Team']):
67    """Our player type for this game."""
68
69    def __init__(self) -> None:
70        self.touching_own_flag = 0

Our player type for this game.

touching_own_flag
class Team(bascenev1._team.Team[bascenev1lib.game.capturetheflag.Player]):
73class Team(bs.Team[Player]):
74    """Our team type for this game."""
75
76    def __init__(
77        self,
78        *,
79        base_pos: Sequence[float],
80        base_region_material: bs.Material,
81        base_region: bs.Node,
82        spaz_material_no_flag_physical: bs.Material,
83        spaz_material_no_flag_collide: bs.Material,
84        flagmaterial: bs.Material,
85    ):
86        self.base_pos = base_pos
87        self.base_region_material = base_region_material
88        self.base_region = base_region
89        self.spaz_material_no_flag_physical = spaz_material_no_flag_physical
90        self.spaz_material_no_flag_collide = spaz_material_no_flag_collide
91        self.flagmaterial = flagmaterial
92        self.score = 0
93        self.flag_return_touches = 0
94        self.home_flag_at_base = True
95        self.touch_return_timer: bs.Timer | None = None
96        self.enemy_flag_at_base = False
97        self.flag: CTFFlag | None = None
98        self.last_flag_leave_time: float | None = None
99        self.touch_return_timer_ticking: bs.NodeActor | None = None

Our team type for this game.

Team( *, base_pos: Sequence[float], base_region_material: _bascenev1.Material, base_region: _bascenev1.Node, spaz_material_no_flag_physical: _bascenev1.Material, spaz_material_no_flag_collide: _bascenev1.Material, flagmaterial: _bascenev1.Material)
76    def __init__(
77        self,
78        *,
79        base_pos: Sequence[float],
80        base_region_material: bs.Material,
81        base_region: bs.Node,
82        spaz_material_no_flag_physical: bs.Material,
83        spaz_material_no_flag_collide: bs.Material,
84        flagmaterial: bs.Material,
85    ):
86        self.base_pos = base_pos
87        self.base_region_material = base_region_material
88        self.base_region = base_region
89        self.spaz_material_no_flag_physical = spaz_material_no_flag_physical
90        self.spaz_material_no_flag_collide = spaz_material_no_flag_collide
91        self.flagmaterial = flagmaterial
92        self.score = 0
93        self.flag_return_touches = 0
94        self.home_flag_at_base = True
95        self.touch_return_timer: bs.Timer | None = None
96        self.enemy_flag_at_base = False
97        self.flag: CTFFlag | None = None
98        self.last_flag_leave_time: float | None = None
99        self.touch_return_timer_ticking: bs.NodeActor | None = None
base_pos
base_region_material
base_region
spaz_material_no_flag_physical
spaz_material_no_flag_collide
flagmaterial
score
flag_return_touches
home_flag_at_base
touch_return_timer: _bascenev1.Timer | None
enemy_flag_at_base
flag: CTFFlag | None
last_flag_leave_time: float | None
touch_return_timer_ticking: bascenev1.NodeActor | None
class CaptureTheFlagGame(bascenev1._teamgame.TeamGameActivity[bascenev1lib.game.capturetheflag.Player, bascenev1lib.game.capturetheflag.Team]):
103class CaptureTheFlagGame(bs.TeamGameActivity[Player, Team]):
104    """Game of stealing other team's flag and returning it to your base."""
105
106    name = 'Capture the Flag'
107    description = 'Return the enemy flag to score.'
108    available_settings = [
109        bs.IntSetting('Score to Win', min_value=1, default=3),
110        bs.IntSetting(
111            'Flag Touch Return Time',
112            min_value=0,
113            default=0,
114            increment=1,
115        ),
116        bs.IntSetting(
117            'Flag Idle Return Time',
118            min_value=5,
119            default=30,
120            increment=5,
121        ),
122        bs.IntChoiceSetting(
123            'Time Limit',
124            choices=[
125                ('None', 0),
126                ('1 Minute', 60),
127                ('2 Minutes', 120),
128                ('5 Minutes', 300),
129                ('10 Minutes', 600),
130                ('20 Minutes', 1200),
131            ],
132            default=0,
133        ),
134        bs.FloatChoiceSetting(
135            'Respawn Times',
136            choices=[
137                ('Shorter', 0.25),
138                ('Short', 0.5),
139                ('Normal', 1.0),
140                ('Long', 2.0),
141                ('Longer', 4.0),
142            ],
143            default=1.0,
144        ),
145        bs.BoolSetting('Epic Mode', default=False),
146    ]
147
148    @override
149    @classmethod
150    def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool:
151        return issubclass(sessiontype, bs.DualTeamSession)
152
153    @override
154    @classmethod
155    def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]:
156        assert bs.app.classic is not None
157        return bs.app.classic.getmaps('team_flag')
158
159    def __init__(self, settings: dict):
160        super().__init__(settings)
161        self._scoreboard = Scoreboard()
162        self._alarmsound = bs.getsound('alarm')
163        self._ticking_sound = bs.getsound('ticking')
164        self._score_sound = bs.getsound('score')
165        self._swipsound = bs.getsound('swip')
166        self._last_score_time = 0
167        self._all_bases_material = bs.Material()
168        self._last_home_flag_notice_print_time = 0.0
169        self._score_to_win = int(settings['Score to Win'])
170        self._epic_mode = bool(settings['Epic Mode'])
171        self._time_limit = float(settings['Time Limit'])
172
173        self.flag_touch_return_time = float(settings['Flag Touch Return Time'])
174        self.flag_idle_return_time = float(settings['Flag Idle Return Time'])
175
176        # Base class overrides.
177        self.slow_motion = self._epic_mode
178        self.default_music = (
179            bs.MusicType.EPIC if self._epic_mode else bs.MusicType.FLAG_CATCHER
180        )
181
182    @override
183    def get_instance_description(self) -> str | Sequence:
184        if self._score_to_win == 1:
185            return 'Steal the enemy flag.'
186        return 'Steal the enemy flag ${ARG1} times.', self._score_to_win
187
188    @override
189    def get_instance_description_short(self) -> str | Sequence:
190        if self._score_to_win == 1:
191            return 'return 1 flag'
192        return 'return ${ARG1} flags', self._score_to_win
193
194    @override
195    def create_team(self, sessionteam: bs.SessionTeam) -> Team:
196        # Create our team instance and its initial values.
197
198        base_pos = self.map.get_flag_position(sessionteam.id)
199        Flag.project_stand(base_pos)
200
201        bs.newnode(
202            'light',
203            attrs={
204                'position': base_pos,
205                'intensity': 0.6,
206                'height_attenuated': False,
207                'volume_intensity_scale': 0.1,
208                'radius': 0.1,
209                'color': sessionteam.color,
210            },
211        )
212
213        base_region_mat = bs.Material()
214        pos = base_pos
215        base_region = bs.newnode(
216            'region',
217            attrs={
218                'position': (pos[0], pos[1] + 0.75, pos[2]),
219                'scale': (0.5, 0.5, 0.5),
220                'type': 'sphere',
221                'materials': [base_region_mat, self._all_bases_material],
222            },
223        )
224
225        spaz_mat_no_flag_physical = bs.Material()
226        spaz_mat_no_flag_collide = bs.Material()
227        flagmat = bs.Material()
228
229        team = Team(
230            base_pos=base_pos,
231            base_region_material=base_region_mat,
232            base_region=base_region,
233            spaz_material_no_flag_physical=spaz_mat_no_flag_physical,
234            spaz_material_no_flag_collide=spaz_mat_no_flag_collide,
235            flagmaterial=flagmat,
236        )
237
238        # Some parts of our spazzes don't collide physically with our
239        # flags but generate callbacks.
240        spaz_mat_no_flag_physical.add_actions(
241            conditions=('they_have_material', flagmat),
242            actions=(
243                ('modify_part_collision', 'physical', False),
244                (
245                    'call',
246                    'at_connect',
247                    lambda: self._handle_touching_own_flag(team, True),
248                ),
249                (
250                    'call',
251                    'at_disconnect',
252                    lambda: self._handle_touching_own_flag(team, False),
253                ),
254            ),
255        )
256
257        # Other parts of our spazzes don't collide with our flags at all.
258        spaz_mat_no_flag_collide.add_actions(
259            conditions=('they_have_material', flagmat),
260            actions=('modify_part_collision', 'collide', False),
261        )
262
263        # We wanna know when *any* flag enters/leaves our base.
264        base_region_mat.add_actions(
265            conditions=('they_have_material', FlagFactory.get().flagmaterial),
266            actions=(
267                ('modify_part_collision', 'collide', True),
268                ('modify_part_collision', 'physical', False),
269                (
270                    'call',
271                    'at_connect',
272                    lambda: self._handle_flag_entered_base(team),
273                ),
274                (
275                    'call',
276                    'at_disconnect',
277                    lambda: self._handle_flag_left_base(team),
278                ),
279            ),
280        )
281
282        return team
283
284    @override
285    def on_team_join(self, team: Team) -> None:
286        # Can't do this in create_team because the team's color/etc. have
287        # not been wired up yet at that point.
288        self._spawn_flag_for_team(team)
289        self._update_scoreboard()
290
291    @override
292    def on_begin(self) -> None:
293        super().on_begin()
294        self.setup_standard_time_limit(self._time_limit)
295        self.setup_standard_powerup_drops()
296        bs.timer(1.0, call=self._tick, repeat=True)
297
298    def _spawn_flag_for_team(self, team: Team) -> None:
299        team.flag = CTFFlag(team)
300        team.flag_return_touches = 0
301        self._flash_base(team, length=1.0)
302        assert team.flag.node
303        self._swipsound.play(position=team.flag.node.position)
304
305    def _handle_flag_entered_base(self, team: Team) -> None:
306        try:
307            flag = bs.getcollision().opposingnode.getdelegate(CTFFlag, True)
308        except bs.NotFoundError:
309            # Don't think this should logically ever happen.
310            print('Error getting CTFFlag in entering-base callback.')
311            return
312
313        if flag.team is team:
314            team.home_flag_at_base = True
315
316            # If the enemy flag is already here, score!
317            if team.enemy_flag_at_base:
318                # And show team name which scored (but actually we could
319                # show here player who returned enemy flag).
320                self.show_zoom_message(
321                    bs.Lstr(
322                        resource='nameScoresText', subs=[('${NAME}', team.name)]
323                    ),
324                    color=team.color,
325                )
326                self._score(team)
327        else:
328            team.enemy_flag_at_base = True
329            if team.home_flag_at_base:
330                # Award points to whoever was carrying the enemy flag.
331                player = flag.last_player_to_hold
332                if player and player.team is team:
333                    self.stats.player_scored(player, 50, big_message=True)
334
335                # Update score and reset flags.
336                self._score(team)
337
338            # If the home-team flag isn't here, print a message to that effect.
339            else:
340                # Don't want slo-mo affecting this
341                curtime = bs.basetime()
342                if curtime - self._last_home_flag_notice_print_time > 5.0:
343                    self._last_home_flag_notice_print_time = curtime
344                    bpos = team.base_pos
345                    tval = bs.Lstr(resource='ownFlagAtYourBaseWarning')
346                    tnode = bs.newnode(
347                        'text',
348                        attrs={
349                            'text': tval,
350                            'in_world': True,
351                            'scale': 0.013,
352                            'color': (1, 1, 0, 1),
353                            'h_align': 'center',
354                            'position': (bpos[0], bpos[1] + 3.2, bpos[2]),
355                        },
356                    )
357                    bs.timer(5.1, tnode.delete)
358                    bs.animate(
359                        tnode, 'scale', {0.0: 0, 0.2: 0.013, 4.8: 0.013, 5.0: 0}
360                    )
361
362    def _tick(self) -> None:
363        # If either flag is away from base and not being held, tick down its
364        # respawn timer.
365        for team in self.teams:
366            flag = team.flag
367            assert flag is not None
368
369            if not team.home_flag_at_base and flag.held_count == 0:
370                time_out_counting_down = True
371                if flag.time_out_respawn_time is None:
372                    flag.reset_return_times()
373                assert flag.time_out_respawn_time is not None
374                flag.time_out_respawn_time -= 1
375                if flag.time_out_respawn_time <= 0:
376                    flag.handlemessage(bs.DieMessage())
377            else:
378                time_out_counting_down = False
379
380            if flag.node and flag.counter:
381                pos = flag.node.position
382                flag.counter.position = (pos[0], pos[1] + 1.3, pos[2])
383
384                # If there's no self-touches on this flag, set its text
385                # to show its auto-return counter.  (if there's self-touches
386                # its showing that time).
387                if team.flag_return_touches == 0:
388                    flag.counter.text = (
389                        str(flag.time_out_respawn_time)
390                        if (
391                            time_out_counting_down
392                            and flag.time_out_respawn_time is not None
393                            and flag.time_out_respawn_time <= 10
394                        )
395                        else ''
396                    )
397                    flag.counter.color = (1, 1, 1, 0.5)
398                    flag.counter.scale = 0.014
399
400    def _score(self, team: Team) -> None:
401        team.score += 1
402        self._score_sound.play()
403        self._flash_base(team)
404        self._update_scoreboard()
405
406        # Have teammates celebrate.
407        for player in team.players:
408            if player.actor:
409                player.actor.handlemessage(bs.CelebrateMessage(2.0))
410
411        # Reset all flags/state.
412        for reset_team in self.teams:
413            if not reset_team.home_flag_at_base:
414                assert reset_team.flag is not None
415                reset_team.flag.handlemessage(bs.DieMessage())
416            reset_team.enemy_flag_at_base = False
417        if team.score >= self._score_to_win:
418            self.end_game()
419
420    @override
421    def end_game(self) -> None:
422        results = bs.GameResults()
423        for team in self.teams:
424            results.set_team_score(team, team.score)
425        self.end(results=results, announce_delay=0.8)
426
427    def _handle_flag_left_base(self, team: Team) -> None:
428        cur_time = bs.time()
429        try:
430            flag = bs.getcollision().opposingnode.getdelegate(CTFFlag, True)
431        except bs.NotFoundError:
432            # This can happen if the flag stops touching us due to being
433            # deleted; that's ok.
434            return
435
436        if flag.team is team:
437            # Check times here to prevent too much flashing.
438            if (
439                team.last_flag_leave_time is None
440                or cur_time - team.last_flag_leave_time > 3.0
441            ):
442                self._alarmsound.play(position=team.base_pos)
443                self._flash_base(team)
444            team.last_flag_leave_time = cur_time
445            team.home_flag_at_base = False
446        else:
447            team.enemy_flag_at_base = False
448
449    def _touch_return_update(self, team: Team) -> None:
450        # Count down only while its away from base and not being held.
451        assert team.flag is not None
452        if team.home_flag_at_base or team.flag.held_count > 0:
453            team.touch_return_timer_ticking = None
454            return  # No need to return when its at home.
455        if team.touch_return_timer_ticking is None:
456            team.touch_return_timer_ticking = bs.NodeActor(
457                bs.newnode(
458                    'sound',
459                    attrs={
460                        'sound': self._ticking_sound,
461                        'positional': False,
462                        'loop': True,
463                    },
464                )
465            )
466        flag = team.flag
467        if flag.touch_return_time is not None:
468            flag.touch_return_time -= 0.1
469            if flag.counter:
470                flag.counter.text = f'{flag.touch_return_time:.1f}'
471                flag.counter.color = (1, 1, 0, 1)
472                flag.counter.scale = 0.02
473
474            if flag.touch_return_time <= 0.0:
475                self._award_players_touching_own_flag(team)
476                flag.handlemessage(bs.DieMessage())
477
478    def _award_players_touching_own_flag(self, team: Team) -> None:
479        for player in team.players:
480            if player.touching_own_flag > 0:
481                return_score = 10 + 5 * int(self.flag_touch_return_time)
482                self.stats.player_scored(
483                    player, return_score, screenmessage=False
484                )
485
486    def _handle_touching_own_flag(self, team: Team, connecting: bool) -> None:
487        """Called when a player touches or stops touching their own team flag.
488
489        We keep track of when each player is touching their own flag so we
490        can award points when returned.
491        """
492        player: Player | None
493        try:
494            spaz = bs.getcollision().sourcenode.getdelegate(PlayerSpaz, True)
495        except bs.NotFoundError:
496            return
497
498        player = spaz.getplayer(Player, True)
499
500        if player:
501            player.touching_own_flag += 1 if connecting else -1
502
503        # If return-time is zero, just kill it immediately.. otherwise keep
504        # track of touches and count down.
505        if float(self.flag_touch_return_time) <= 0.0:
506            assert team.flag is not None
507            if (
508                connecting
509                and not team.home_flag_at_base
510                and team.flag.held_count == 0
511            ):
512                self._award_players_touching_own_flag(team)
513                bs.getcollision().opposingnode.handlemessage(bs.DieMessage())
514
515        # Takes a non-zero amount of time to return.
516        else:
517            if connecting:
518                team.flag_return_touches += 1
519                if team.flag_return_touches == 1:
520                    team.touch_return_timer = bs.Timer(
521                        0.1,
522                        call=bs.Call(self._touch_return_update, team),
523                        repeat=True,
524                    )
525                    team.touch_return_timer_ticking = None
526            else:
527                team.flag_return_touches -= 1
528                if team.flag_return_touches == 0:
529                    team.touch_return_timer = None
530                    team.touch_return_timer_ticking = None
531            if team.flag_return_touches < 0:
532                logging.error('CTF flag_return_touches < 0', stack_info=True)
533
534    def _handle_death_flag_capture(self, player: Player) -> None:
535        """Handles flag values when a player dies or leaves the game."""
536        # Don't do anything if the player hasn't touched the flag at all.
537        if not player.touching_own_flag:
538            return
539
540        team = player.team
541
542        # For each "point" our player has touched theflag (Could be
543        # multiple), deduct one from both our player and the flag's
544        # return touches variable.
545        for _ in range(player.touching_own_flag):
546            # Deduct
547            player.touching_own_flag -= 1
548
549            # (This was only incremented if we have non-zero
550            # return-times).
551            if float(self.flag_touch_return_time) > 0.0:
552                team.flag_return_touches -= 1
553                # Update our flag's timer accordingly
554                # (Prevents immediate resets in case
555                # there might be more people touching it).
556                if team.flag_return_touches == 0:
557                    team.touch_return_timer = None
558                    team.touch_return_timer_ticking = None
559                # Safety check, just to be sure!
560                if team.flag_return_touches < 0:
561                    logging.error(
562                        'CTF flag_return_touches < 0', stack_info=True
563                    )
564
565    def _flash_base(self, team: Team, length: float = 2.0) -> None:
566        light = bs.newnode(
567            'light',
568            attrs={
569                'position': team.base_pos,
570                'height_attenuated': False,
571                'radius': 0.3,
572                'color': team.color,
573            },
574        )
575        bs.animate(light, 'intensity', {0.0: 0, 0.25: 2.0, 0.5: 0}, loop=True)
576        bs.timer(length, light.delete)
577
578    @override
579    def spawn_player_spaz(
580        self,
581        player: Player,
582        position: Sequence[float] | None = None,
583        angle: float | None = None,
584    ) -> PlayerSpaz:
585        """Intercept new spazzes and add our team material for them."""
586        spaz = super().spawn_player_spaz(player, position, angle)
587        player = spaz.getplayer(Player, True)
588        team: Team = player.team
589        player.touching_own_flag = 0
590        no_physical_mats: list[bs.Material] = [
591            team.spaz_material_no_flag_physical
592        ]
593        no_collide_mats: list[bs.Material] = [
594            team.spaz_material_no_flag_collide
595        ]
596
597        # Our normal parts should still collide; just not physically
598        # (so we can calc restores).
599        assert spaz.node
600        spaz.node.materials = list(spaz.node.materials) + no_physical_mats
601        spaz.node.roller_materials = (
602            list(spaz.node.roller_materials) + no_physical_mats
603        )
604
605        # Pickups and punches shouldn't hit at all though.
606        spaz.node.punch_materials = (
607            list(spaz.node.punch_materials) + no_collide_mats
608        )
609        spaz.node.pickup_materials = (
610            list(spaz.node.pickup_materials) + no_collide_mats
611        )
612        spaz.node.extras_material = (
613            list(spaz.node.extras_material) + no_collide_mats
614        )
615        return spaz
616
617    def _update_scoreboard(self) -> None:
618        for team in self.teams:
619            self._scoreboard.set_team_value(
620                team, team.score, self._score_to_win
621            )
622
623    @override
624    def handlemessage(self, msg: Any) -> Any:
625        if isinstance(msg, bs.PlayerDiedMessage):
626            super().handlemessage(msg)  # Augment standard behavior.
627            self._handle_death_flag_capture(msg.getplayer(Player))
628            self.respawn_player(msg.getplayer(Player))
629
630        elif isinstance(msg, FlagDiedMessage):
631            assert isinstance(msg.flag, CTFFlag)
632            bs.timer(0.1, bs.Call(self._spawn_flag_for_team, msg.flag.team))
633
634        elif isinstance(msg, FlagPickedUpMessage):
635            # Store the last player to hold the flag for scoring purposes.
636            assert isinstance(msg.flag, CTFFlag)
637            try:
638                msg.flag.last_player_to_hold = msg.node.getdelegate(
639                    PlayerSpaz, True
640                ).getplayer(Player, True)
641            except bs.NotFoundError:
642                pass
643
644            msg.flag.held_count += 1
645            msg.flag.reset_return_times()
646
647        elif isinstance(msg, FlagDroppedMessage):
648            # Store the last player to hold the flag for scoring purposes.
649            assert isinstance(msg.flag, CTFFlag)
650            msg.flag.held_count -= 1
651
652        else:
653            super().handlemessage(msg)
654
655    @override
656    def on_player_leave(self, player: Player) -> None:
657        """Prevents leaving players from capturing their flag."""
658        self._handle_death_flag_capture(player)

Game of stealing other team's flag and returning it to your base.

CaptureTheFlagGame(settings: dict)
159    def __init__(self, settings: dict):
160        super().__init__(settings)
161        self._scoreboard = Scoreboard()
162        self._alarmsound = bs.getsound('alarm')
163        self._ticking_sound = bs.getsound('ticking')
164        self._score_sound = bs.getsound('score')
165        self._swipsound = bs.getsound('swip')
166        self._last_score_time = 0
167        self._all_bases_material = bs.Material()
168        self._last_home_flag_notice_print_time = 0.0
169        self._score_to_win = int(settings['Score to Win'])
170        self._epic_mode = bool(settings['Epic Mode'])
171        self._time_limit = float(settings['Time Limit'])
172
173        self.flag_touch_return_time = float(settings['Flag Touch Return Time'])
174        self.flag_idle_return_time = float(settings['Flag Idle Return Time'])
175
176        # Base class overrides.
177        self.slow_motion = self._epic_mode
178        self.default_music = (
179            bs.MusicType.EPIC if self._epic_mode else bs.MusicType.FLAG_CATCHER
180        )

Instantiate the Activity.

name = 'Capture the Flag'
description = 'Return the enemy flag to score.'
available_settings = [IntSetting(name='Score to Win', default=3, min_value=1, max_value=9999, increment=1), IntSetting(name='Flag Touch Return Time', default=0, min_value=0, max_value=9999, increment=1), IntSetting(name='Flag Idle Return Time', default=30, min_value=5, max_value=9999, increment=5), IntChoiceSetting(name='Time Limit', default=0, choices=[('None', 0), ('1 Minute', 60), ('2 Minutes', 120), ('5 Minutes', 300), ('10 Minutes', 600), ('20 Minutes', 1200)]), FloatChoiceSetting(name='Respawn Times', default=1.0, choices=[('Shorter', 0.25), ('Short', 0.5), ('Normal', 1.0), ('Long', 2.0), ('Longer', 4.0)]), BoolSetting(name='Epic Mode', default=False)]
@override
@classmethod
def supports_session_type(cls, sessiontype: type[bascenev1.Session]) -> bool:
148    @override
149    @classmethod
150    def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool:
151        return issubclass(sessiontype, bs.DualTeamSession)

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

@override
@classmethod
def get_supported_maps(cls, sessiontype: type[bascenev1.Session]) -> list[str]:
153    @override
154    @classmethod
155    def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]:
156        assert bs.app.classic is not None
157        return bs.app.classic.getmaps('team_flag')

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.

flag_touch_return_time
flag_idle_return_time
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]:
182    @override
183    def get_instance_description(self) -> str | Sequence:
184        if self._score_to_win == 1:
185            return 'Steal the enemy flag.'
186        return 'Steal the enemy flag ${ARG1} times.', self._score_to_win

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]:
188    @override
189    def get_instance_description_short(self) -> str | Sequence:
190        if self._score_to_win == 1:
191            return 'return 1 flag'
192        return 'return ${ARG1} flags', self._score_to_win

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 create_team( self, sessionteam: bascenev1.SessionTeam) -> Team:
194    @override
195    def create_team(self, sessionteam: bs.SessionTeam) -> Team:
196        # Create our team instance and its initial values.
197
198        base_pos = self.map.get_flag_position(sessionteam.id)
199        Flag.project_stand(base_pos)
200
201        bs.newnode(
202            'light',
203            attrs={
204                'position': base_pos,
205                'intensity': 0.6,
206                'height_attenuated': False,
207                'volume_intensity_scale': 0.1,
208                'radius': 0.1,
209                'color': sessionteam.color,
210            },
211        )
212
213        base_region_mat = bs.Material()
214        pos = base_pos
215        base_region = bs.newnode(
216            'region',
217            attrs={
218                'position': (pos[0], pos[1] + 0.75, pos[2]),
219                'scale': (0.5, 0.5, 0.5),
220                'type': 'sphere',
221                'materials': [base_region_mat, self._all_bases_material],
222            },
223        )
224
225        spaz_mat_no_flag_physical = bs.Material()
226        spaz_mat_no_flag_collide = bs.Material()
227        flagmat = bs.Material()
228
229        team = Team(
230            base_pos=base_pos,
231            base_region_material=base_region_mat,
232            base_region=base_region,
233            spaz_material_no_flag_physical=spaz_mat_no_flag_physical,
234            spaz_material_no_flag_collide=spaz_mat_no_flag_collide,
235            flagmaterial=flagmat,
236        )
237
238        # Some parts of our spazzes don't collide physically with our
239        # flags but generate callbacks.
240        spaz_mat_no_flag_physical.add_actions(
241            conditions=('they_have_material', flagmat),
242            actions=(
243                ('modify_part_collision', 'physical', False),
244                (
245                    'call',
246                    'at_connect',
247                    lambda: self._handle_touching_own_flag(team, True),
248                ),
249                (
250                    'call',
251                    'at_disconnect',
252                    lambda: self._handle_touching_own_flag(team, False),
253                ),
254            ),
255        )
256
257        # Other parts of our spazzes don't collide with our flags at all.
258        spaz_mat_no_flag_collide.add_actions(
259            conditions=('they_have_material', flagmat),
260            actions=('modify_part_collision', 'collide', False),
261        )
262
263        # We wanna know when *any* flag enters/leaves our base.
264        base_region_mat.add_actions(
265            conditions=('they_have_material', FlagFactory.get().flagmaterial),
266            actions=(
267                ('modify_part_collision', 'collide', True),
268                ('modify_part_collision', 'physical', False),
269                (
270                    'call',
271                    'at_connect',
272                    lambda: self._handle_flag_entered_base(team),
273                ),
274                (
275                    'call',
276                    'at_disconnect',
277                    lambda: self._handle_flag_left_base(team),
278                ),
279            ),
280        )
281
282        return team

Create the Team instance for this Activity.

Subclasses can override this if the activity's team class requires a custom constructor; otherwise it will be called with no args. Note that the team object should not be used at this point as it is not yet fully wired up; wait for on_team_join() for that.

@override
def on_team_join(self, team: Team) -> None:
284    @override
285    def on_team_join(self, team: Team) -> None:
286        # Can't do this in create_team because the team's color/etc. have
287        # not been wired up yet at that point.
288        self._spawn_flag_for_team(team)
289        self._update_scoreboard()

Called when a new bascenev1.Team joins the Activity.

(including the initial set of Teams)

@override
def on_begin(self) -> None:
291    @override
292    def on_begin(self) -> None:
293        super().on_begin()
294        self.setup_standard_time_limit(self._time_limit)
295        self.setup_standard_powerup_drops()
296        bs.timer(1.0, call=self._tick, repeat=True)

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 end_game(self) -> None:
420    @override
421    def end_game(self) -> None:
422        results = bs.GameResults()
423        for team in self.teams:
424            results.set_team_score(team, team.score)
425        self.end(results=results, announce_delay=0.8)

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 spawn_player_spaz( self, player: Player, position: Optional[Sequence[float]] = None, angle: float | None = None) -> bascenev1lib.actor.playerspaz.PlayerSpaz:
578    @override
579    def spawn_player_spaz(
580        self,
581        player: Player,
582        position: Sequence[float] | None = None,
583        angle: float | None = None,
584    ) -> PlayerSpaz:
585        """Intercept new spazzes and add our team material for them."""
586        spaz = super().spawn_player_spaz(player, position, angle)
587        player = spaz.getplayer(Player, True)
588        team: Team = player.team
589        player.touching_own_flag = 0
590        no_physical_mats: list[bs.Material] = [
591            team.spaz_material_no_flag_physical
592        ]
593        no_collide_mats: list[bs.Material] = [
594            team.spaz_material_no_flag_collide
595        ]
596
597        # Our normal parts should still collide; just not physically
598        # (so we can calc restores).
599        assert spaz.node
600        spaz.node.materials = list(spaz.node.materials) + no_physical_mats
601        spaz.node.roller_materials = (
602            list(spaz.node.roller_materials) + no_physical_mats
603        )
604
605        # Pickups and punches shouldn't hit at all though.
606        spaz.node.punch_materials = (
607            list(spaz.node.punch_materials) + no_collide_mats
608        )
609        spaz.node.pickup_materials = (
610            list(spaz.node.pickup_materials) + no_collide_mats
611        )
612        spaz.node.extras_material = (
613            list(spaz.node.extras_material) + no_collide_mats
614        )
615        return spaz

Intercept new spazzes and add our team material for them.

@override
def handlemessage(self, msg: Any) -> Any:
623    @override
624    def handlemessage(self, msg: Any) -> Any:
625        if isinstance(msg, bs.PlayerDiedMessage):
626            super().handlemessage(msg)  # Augment standard behavior.
627            self._handle_death_flag_capture(msg.getplayer(Player))
628            self.respawn_player(msg.getplayer(Player))
629
630        elif isinstance(msg, FlagDiedMessage):
631            assert isinstance(msg.flag, CTFFlag)
632            bs.timer(0.1, bs.Call(self._spawn_flag_for_team, msg.flag.team))
633
634        elif isinstance(msg, FlagPickedUpMessage):
635            # Store the last player to hold the flag for scoring purposes.
636            assert isinstance(msg.flag, CTFFlag)
637            try:
638                msg.flag.last_player_to_hold = msg.node.getdelegate(
639                    PlayerSpaz, True
640                ).getplayer(Player, True)
641            except bs.NotFoundError:
642                pass
643
644            msg.flag.held_count += 1
645            msg.flag.reset_return_times()
646
647        elif isinstance(msg, FlagDroppedMessage):
648            # Store the last player to hold the flag for scoring purposes.
649            assert isinstance(msg.flag, CTFFlag)
650            msg.flag.held_count -= 1
651
652        else:
653            super().handlemessage(msg)

General message handling; can be passed any message object.

@override
def on_player_leave(self, player: Player) -> None:
655    @override
656    def on_player_leave(self, player: Player) -> None:
657        """Prevents leaving players from capturing their flag."""
658        self._handle_death_flag_capture(player)

Prevents leaving players from capturing their flag.