bascenev1lib.game.football

Implements football games (both co-op and teams varieties).

  1# Released under the MIT License. See LICENSE for details.
  2#
  3"""Implements football games (both co-op and teams varieties)."""
  4
  5# ba_meta require api 9
  6# (see https://ballistica.net/wiki/meta-tag-system)
  7
  8from __future__ import annotations
  9
 10import math
 11import random
 12import logging
 13from typing import TYPE_CHECKING, override
 14
 15import bascenev1 as bs
 16
 17from bascenev1lib.actor.bomb import TNTSpawner
 18from bascenev1lib.actor.playerspaz import PlayerSpaz
 19from bascenev1lib.actor.scoreboard import Scoreboard
 20from bascenev1lib.actor.respawnicon import RespawnIcon
 21from bascenev1lib.actor.powerupbox import PowerupBoxFactory, PowerupBox
 22from bascenev1lib.actor.flag import (
 23    FlagFactory,
 24    Flag,
 25    FlagPickedUpMessage,
 26    FlagDroppedMessage,
 27    FlagDiedMessage,
 28)
 29from bascenev1lib.actor.spazbot import (
 30    SpazBotDiedMessage,
 31    SpazBotPunchedMessage,
 32    SpazBotSet,
 33    BrawlerBotLite,
 34    BrawlerBot,
 35    BomberBotLite,
 36    BomberBot,
 37    TriggerBot,
 38    ChargerBot,
 39    TriggerBotPro,
 40    BrawlerBotPro,
 41    StickyBot,
 42    ExplodeyBot,
 43)
 44
 45if TYPE_CHECKING:
 46    from typing import Any, Sequence
 47
 48    from bascenev1lib.actor.spaz import Spaz
 49    from bascenev1lib.actor.spazbot import SpazBot
 50
 51
 52class FootballFlag(Flag):
 53    """Custom flag class for football games."""
 54
 55    def __init__(self, position: Sequence[float]):
 56        super().__init__(
 57            position=position, dropped_timeout=20, color=(1.0, 1.0, 0.3)
 58        )
 59        assert self.node
 60        self.last_holding_player: bs.Player | None = None
 61        self.node.is_area_of_interest = True
 62        self.respawn_timer: bs.Timer | None = None
 63        self.scored = False
 64        self.held_count = 0
 65        self.light = bs.newnode(
 66            'light',
 67            owner=self.node,
 68            attrs={
 69                'intensity': 0.25,
 70                'height_attenuated': False,
 71                'radius': 0.2,
 72                'color': (0.9, 0.7, 0.0),
 73            },
 74        )
 75        self.node.connectattr('position', self.light, 'position')
 76
 77
 78class Player(bs.Player['Team']):
 79    """Our player type for this game."""
 80
 81    def __init__(self) -> None:
 82        self.respawn_timer: bs.Timer | None = None
 83        self.respawn_icon: RespawnIcon | None = None
 84
 85
 86class Team(bs.Team[Player]):
 87    """Our team type for this game."""
 88
 89    def __init__(self) -> None:
 90        self.score = 0
 91
 92
 93# ba_meta export bascenev1.GameActivity
 94class FootballTeamGame(bs.TeamGameActivity[Player, Team]):
 95    """Football game for teams mode."""
 96
 97    name = 'Football'
 98    description = 'Get the flag to the enemy end zone.'
 99    available_settings = [
100        bs.IntSetting(
101            'Score to Win',
102            min_value=7,
103            default=21,
104            increment=7,
105        ),
106        bs.IntChoiceSetting(
107            'Time Limit',
108            choices=[
109                ('None', 0),
110                ('1 Minute', 60),
111                ('2 Minutes', 120),
112                ('5 Minutes', 300),
113                ('10 Minutes', 600),
114                ('20 Minutes', 1200),
115            ],
116            default=0,
117        ),
118        bs.FloatChoiceSetting(
119            'Respawn Times',
120            choices=[
121                ('Shorter', 0.25),
122                ('Short', 0.5),
123                ('Normal', 1.0),
124                ('Long', 2.0),
125                ('Longer', 4.0),
126            ],
127            default=1.0,
128        ),
129        bs.BoolSetting('Epic Mode', default=False),
130    ]
131
132    @override
133    @classmethod
134    def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool:
135        # We only support two-team play.
136        return issubclass(sessiontype, bs.DualTeamSession)
137
138    @override
139    @classmethod
140    def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]:
141        assert bs.app.classic is not None
142        return bs.app.classic.getmaps('football')
143
144    def __init__(self, settings: dict):
145        super().__init__(settings)
146        self._scoreboard: Scoreboard | None = Scoreboard()
147
148        # Load some media we need.
149        self._cheer_sound = bs.getsound('cheer')
150        self._chant_sound = bs.getsound('crowdChant')
151        self._score_sound = bs.getsound('score')
152        self._swipsound = bs.getsound('swip')
153        self._whistle_sound = bs.getsound('refWhistle')
154        self._score_region_material = bs.Material()
155        self._score_region_material.add_actions(
156            conditions=('they_have_material', FlagFactory.get().flagmaterial),
157            actions=(
158                ('modify_part_collision', 'collide', True),
159                ('modify_part_collision', 'physical', False),
160                ('call', 'at_connect', self._handle_score),
161            ),
162        )
163        self._flag_spawn_pos: Sequence[float] | None = None
164        self._score_regions: list[bs.NodeActor] = []
165        self._flag: FootballFlag | None = None
166        self._flag_respawn_timer: bs.Timer | None = None
167        self._flag_respawn_light: bs.NodeActor | None = None
168        self._score_to_win = int(settings['Score to Win'])
169        self._time_limit = float(settings['Time Limit'])
170        self._epic_mode = bool(settings['Epic Mode'])
171        self.slow_motion = self._epic_mode
172        self.default_music = (
173            bs.MusicType.EPIC if self._epic_mode else bs.MusicType.FOOTBALL
174        )
175
176    @override
177    def get_instance_description(self) -> str | Sequence:
178        touchdowns = self._score_to_win / 7
179
180        # NOTE: if use just touchdowns = self._score_to_win // 7
181        # and we will need to score, for example, 27 points,
182        # we will be required to score 3 (not 4) goals ..
183        touchdowns = math.ceil(touchdowns)
184        if touchdowns > 1:
185            return 'Score ${ARG1} touchdowns.', touchdowns
186        return 'Score a touchdown.'
187
188    @override
189    def get_instance_description_short(self) -> str | Sequence:
190        touchdowns = self._score_to_win / 7
191        touchdowns = math.ceil(touchdowns)
192        if touchdowns > 1:
193            return 'score ${ARG1} touchdowns', touchdowns
194        return 'score a touchdown'
195
196    @override
197    def on_begin(self) -> None:
198        super().on_begin()
199        self.setup_standard_time_limit(self._time_limit)
200        self.setup_standard_powerup_drops()
201        self._flag_spawn_pos = self.map.get_flag_position(None)
202        self._spawn_flag()
203        defs = self.map.defs
204        self._score_regions.append(
205            bs.NodeActor(
206                bs.newnode(
207                    'region',
208                    attrs={
209                        'position': defs.boxes['goal1'][0:3],
210                        'scale': defs.boxes['goal1'][6:9],
211                        'type': 'box',
212                        'materials': (self._score_region_material,),
213                    },
214                )
215            )
216        )
217        self._score_regions.append(
218            bs.NodeActor(
219                bs.newnode(
220                    'region',
221                    attrs={
222                        'position': defs.boxes['goal2'][0:3],
223                        'scale': defs.boxes['goal2'][6:9],
224                        'type': 'box',
225                        'materials': (self._score_region_material,),
226                    },
227                )
228            )
229        )
230        self._update_scoreboard()
231        self._chant_sound.play()
232
233    @override
234    def on_team_join(self, team: Team) -> None:
235        self._update_scoreboard()
236
237    def _kill_flag(self) -> None:
238        self._flag = None
239
240    def _handle_score(self) -> None:
241        """A point has been scored."""
242
243        # Our flag might stick around for a second or two
244        # make sure it doesn't score again.
245        assert self._flag is not None
246        if self._flag.scored:
247            return
248        region = bs.getcollision().sourcenode
249        i = None
250        for i, score_region in enumerate(self._score_regions):
251            if region == score_region.node:
252                break
253        for team in self.teams:
254            if team.id == i:
255                team.score += 7
256
257                # Tell all players to celebrate.
258                for player in team.players:
259                    if player.actor:
260                        player.actor.handlemessage(bs.CelebrateMessage(2.0))
261
262                # If someone on this team was last to touch it,
263                # give them points.
264                assert self._flag is not None
265                if (
266                    self._flag.last_holding_player
267                    and team == self._flag.last_holding_player.team
268                ):
269                    self.stats.player_scored(
270                        self._flag.last_holding_player, 50, big_message=True
271                    )
272                # End the game if we won.
273                if team.score >= self._score_to_win:
274                    self.end_game()
275        self._score_sound.play()
276        self._cheer_sound.play()
277        assert self._flag
278        self._flag.scored = True
279
280        # Kill the flag (it'll respawn shortly).
281        bs.timer(1.0, self._kill_flag)
282        light = bs.newnode(
283            'light',
284            attrs={
285                'position': bs.getcollision().position,
286                'height_attenuated': False,
287                'color': (1, 0, 0),
288            },
289        )
290        bs.animate(light, 'intensity', {0.0: 0, 0.5: 1, 1.0: 0}, loop=True)
291        bs.timer(1.0, light.delete)
292        bs.cameraflash(duration=10.0)
293        self._update_scoreboard()
294
295    @override
296    def end_game(self) -> None:
297        results = bs.GameResults()
298        for team in self.teams:
299            results.set_team_score(team, team.score)
300        self.end(results=results, announce_delay=0.8)
301
302    def _update_scoreboard(self) -> None:
303        assert self._scoreboard is not None
304        for team in self.teams:
305            self._scoreboard.set_team_value(
306                team, team.score, self._score_to_win
307            )
308
309    @override
310    def handlemessage(self, msg: Any) -> Any:
311        if isinstance(msg, FlagPickedUpMessage):
312            assert isinstance(msg.flag, FootballFlag)
313            try:
314                msg.flag.last_holding_player = msg.node.getdelegate(
315                    PlayerSpaz, True
316                ).getplayer(Player, True)
317            except bs.NotFoundError:
318                pass
319            msg.flag.held_count += 1
320
321        elif isinstance(msg, FlagDroppedMessage):
322            assert isinstance(msg.flag, FootballFlag)
323            msg.flag.held_count -= 1
324
325        # Respawn dead players if they're still in the game.
326        elif isinstance(msg, bs.PlayerDiedMessage):
327            # Augment standard behavior.
328            super().handlemessage(msg)
329            self.respawn_player(msg.getplayer(Player))
330
331        # Respawn dead flags.
332        elif isinstance(msg, FlagDiedMessage):
333            if not self.has_ended():
334                if msg.self_kill:
335                    self._spawn_flag()
336                else:
337                    self._flag_respawn_timer = bs.Timer(3.0, self._spawn_flag)
338                self._flag_respawn_light = bs.NodeActor(
339                    bs.newnode(
340                        'light',
341                        attrs={
342                            'position': self._flag_spawn_pos,
343                            'height_attenuated': False,
344                            'radius': 0.15,
345                            'color': (1.0, 1.0, 0.3),
346                        },
347                    )
348                )
349                assert self._flag_respawn_light.node
350                bs.animate(
351                    self._flag_respawn_light.node,
352                    'intensity',
353                    {0.0: 0, 0.25: 0.15, 0.5: 0},
354                    loop=True,
355                )
356                bs.timer(3.0, self._flag_respawn_light.node.delete)
357
358        else:
359            # Augment standard behavior.
360            super().handlemessage(msg)
361
362    def _flash_flag_spawn(self) -> None:
363        light = bs.newnode(
364            'light',
365            attrs={
366                'position': self._flag_spawn_pos,
367                'height_attenuated': False,
368                'color': (1, 1, 0),
369            },
370        )
371        bs.animate(light, 'intensity', {0: 0, 0.25: 0.25, 0.5: 0}, loop=True)
372        bs.timer(1.0, light.delete)
373
374    def _spawn_flag(self) -> None:
375        self._swipsound.play()
376        self._whistle_sound.play()
377        self._flash_flag_spawn()
378        assert self._flag_spawn_pos is not None
379        self._flag = FootballFlag(position=self._flag_spawn_pos)
380
381
382class FootballCoopGame(bs.CoopGameActivity[Player, Team]):
383    """Co-op variant of football."""
384
385    name = 'Football'
386    tips = ['Use the pick-up button to grab the flag < ${PICKUP} >']
387    scoreconfig = bs.ScoreConfig(
388        scoretype=bs.ScoreType.MILLISECONDS, version='B'
389    )
390
391    default_music = bs.MusicType.FOOTBALL
392
393    # FIXME: Need to update co-op games to use getscoreconfig.
394    @override
395    def get_score_type(self) -> str:
396        return 'time'
397
398    @override
399    def get_instance_description(self) -> str | Sequence:
400        touchdowns = self._score_to_win / 7
401        touchdowns = math.ceil(touchdowns)
402        if touchdowns > 1:
403            return 'Score ${ARG1} touchdowns.', touchdowns
404        return 'Score a touchdown.'
405
406    @override
407    def get_instance_description_short(self) -> str | Sequence:
408        touchdowns = self._score_to_win / 7
409        touchdowns = math.ceil(touchdowns)
410        if touchdowns > 1:
411            return 'score ${ARG1} touchdowns', touchdowns
412        return 'score a touchdown'
413
414    def __init__(self, settings: dict):
415        settings['map'] = 'Football Stadium'
416        super().__init__(settings)
417        self._preset = settings.get('preset', 'rookie')
418
419        # Load some media we need.
420        self._cheer_sound = bs.getsound('cheer')
421        self._boo_sound = bs.getsound('boo')
422        self._chant_sound = bs.getsound('crowdChant')
423        self._score_sound = bs.getsound('score')
424        self._swipsound = bs.getsound('swip')
425        self._whistle_sound = bs.getsound('refWhistle')
426        self._score_to_win = 21
427        self._score_region_material = bs.Material()
428        self._score_region_material.add_actions(
429            conditions=('they_have_material', FlagFactory.get().flagmaterial),
430            actions=(
431                ('modify_part_collision', 'collide', True),
432                ('modify_part_collision', 'physical', False),
433                ('call', 'at_connect', self._handle_score),
434            ),
435        )
436        self._powerup_center = (0, 2, 0)
437        self._powerup_spread = (10, 5.5)
438        self._player_has_dropped_bomb = False
439        self._player_has_punched = False
440        self._scoreboard: Scoreboard | None = None
441        self._flag_spawn_pos: Sequence[float] | None = None
442        self._score_regions: list[bs.NodeActor] = []
443        self._exclude_powerups: list[str] = []
444        self._have_tnt = False
445        self._bot_types_initial: list[type[SpazBot]] | None = None
446        self._bot_types_7: list[type[SpazBot]] | None = None
447        self._bot_types_14: list[type[SpazBot]] | None = None
448        self._bot_team: Team | None = None
449        self._starttime_ms: int | None = None
450        self._time_text: bs.NodeActor | None = None
451        self._time_text_input: bs.NodeActor | None = None
452        self._tntspawner: TNTSpawner | None = None
453        self._bots = SpazBotSet()
454        self._bot_spawn_timer: bs.Timer | None = None
455        self._powerup_drop_timer: bs.Timer | None = None
456        self._scoring_team: Team | None = None
457        self._final_time_ms: int | None = None
458        self._time_text_timer: bs.Timer | None = None
459        self._flag_respawn_light: bs.Actor | None = None
460        self._flag: FootballFlag | None = None
461
462    @override
463    def on_transition_in(self) -> None:
464        super().on_transition_in()
465        self._scoreboard = Scoreboard()
466        self._flag_spawn_pos = self.map.get_flag_position(None)
467        self._spawn_flag()
468
469        # Set up the two score regions.
470        defs = self.map.defs
471        self._score_regions.append(
472            bs.NodeActor(
473                bs.newnode(
474                    'region',
475                    attrs={
476                        'position': defs.boxes['goal1'][0:3],
477                        'scale': defs.boxes['goal1'][6:9],
478                        'type': 'box',
479                        'materials': [self._score_region_material],
480                    },
481                )
482            )
483        )
484        self._score_regions.append(
485            bs.NodeActor(
486                bs.newnode(
487                    'region',
488                    attrs={
489                        'position': defs.boxes['goal2'][0:3],
490                        'scale': defs.boxes['goal2'][6:9],
491                        'type': 'box',
492                        'materials': [self._score_region_material],
493                    },
494                )
495            )
496        )
497        self._chant_sound.play()
498
499    @override
500    def on_begin(self) -> None:
501        # FIXME: Split this up a bit.
502        # pylint: disable=too-many-statements
503        from bascenev1lib.actor import controlsguide
504
505        super().on_begin()
506
507        # Show controls help in demo or arcade mode.
508        if bs.app.env.demo or bs.app.env.arcade:
509            controlsguide.ControlsGuide(
510                delay=3.0, lifespan=10.0, bright=True
511            ).autoretain()
512        assert self.initialplayerinfos is not None
513        abot: type[SpazBot]
514        bbot: type[SpazBot]
515        cbot: type[SpazBot]
516        if self._preset in ['rookie', 'rookie_easy']:
517            self._exclude_powerups = ['curse']
518            self._have_tnt = False
519            abot = (
520                BrawlerBotLite if self._preset == 'rookie_easy' else BrawlerBot
521            )
522            self._bot_types_initial = [abot] * len(self.initialplayerinfos)
523            bbot = BomberBotLite if self._preset == 'rookie_easy' else BomberBot
524            self._bot_types_7 = [bbot] * (
525                1 if len(self.initialplayerinfos) < 3 else 2
526            )
527            cbot = BomberBot if self._preset == 'rookie_easy' else TriggerBot
528            self._bot_types_14 = [cbot] * (
529                1 if len(self.initialplayerinfos) < 3 else 2
530            )
531        elif self._preset == 'tournament':
532            self._exclude_powerups = []
533            self._have_tnt = True
534            self._bot_types_initial = [BrawlerBot] * (
535                1 if len(self.initialplayerinfos) < 2 else 2
536            )
537            self._bot_types_7 = [TriggerBot] * (
538                1 if len(self.initialplayerinfos) < 3 else 2
539            )
540            self._bot_types_14 = [ChargerBot] * (
541                1 if len(self.initialplayerinfos) < 4 else 2
542            )
543        elif self._preset in ['pro', 'pro_easy', 'tournament_pro']:
544            self._exclude_powerups = ['curse']
545            self._have_tnt = True
546            self._bot_types_initial = [ChargerBot] * len(
547                self.initialplayerinfos
548            )
549            abot = BrawlerBot if self._preset == 'pro' else BrawlerBotLite
550            typed_bot_list: list[type[SpazBot]] = []
551            self._bot_types_7 = (
552                typed_bot_list
553                + [abot]
554                + [BomberBot] * (1 if len(self.initialplayerinfos) < 3 else 2)
555            )
556            bbot = TriggerBotPro if self._preset == 'pro' else TriggerBot
557            self._bot_types_14 = [bbot] * (
558                1 if len(self.initialplayerinfos) < 3 else 2
559            )
560        elif self._preset in ['uber', 'uber_easy']:
561            self._exclude_powerups = []
562            self._have_tnt = True
563            abot = BrawlerBotPro if self._preset == 'uber' else BrawlerBot
564            bbot = TriggerBotPro if self._preset == 'uber' else TriggerBot
565            typed_bot_list_2: list[type[SpazBot]] = []
566            self._bot_types_initial = (
567                typed_bot_list_2
568                + [StickyBot]
569                + [abot] * len(self.initialplayerinfos)
570            )
571            self._bot_types_7 = [bbot] * (
572                1 if len(self.initialplayerinfos) < 3 else 2
573            )
574            self._bot_types_14 = [ExplodeyBot] * (
575                1 if len(self.initialplayerinfos) < 3 else 2
576            )
577        else:
578            raise RuntimeError()
579
580        self.setup_low_life_warning_sound()
581
582        self._drop_powerups(standard_points=True)
583        bs.timer(4.0, self._start_powerup_drops)
584
585        # Make a bogus team for our bots.
586        bad_team_name = self.get_team_display_string('Bad Guys')
587        self._bot_team = Team()
588        self._bot_team.manual_init(
589            team_id=1, name=bad_team_name, color=(0.5, 0.4, 0.4)
590        )
591
592        for team in [self.teams[0], self._bot_team]:
593            team.score = 0
594
595        self.update_scores()
596
597        # Time display.
598        starttime_ms = int(bs.time() * 1000.0)
599        assert isinstance(starttime_ms, int)
600        self._starttime_ms = starttime_ms
601        self._time_text = bs.NodeActor(
602            bs.newnode(
603                'text',
604                attrs={
605                    'v_attach': 'top',
606                    'h_attach': 'center',
607                    'h_align': 'center',
608                    'color': (1, 1, 0.5, 1),
609                    'flatness': 0.5,
610                    'shadow': 0.5,
611                    'position': (0, -50),
612                    'scale': 1.3,
613                    'text': '',
614                },
615            )
616        )
617        self._time_text_input = bs.NodeActor(
618            bs.newnode('timedisplay', attrs={'showsubseconds': True})
619        )
620        self.globalsnode.connectattr(
621            'time', self._time_text_input.node, 'time2'
622        )
623        assert self._time_text_input.node
624        assert self._time_text.node
625        self._time_text_input.node.connectattr(
626            'output', self._time_text.node, 'text'
627        )
628
629        # Our TNT spawner (if applicable).
630        if self._have_tnt:
631            self._tntspawner = TNTSpawner(position=(0, 1, -1))
632
633        self._bots = SpazBotSet()
634        self._bot_spawn_timer = bs.Timer(1.0, self._update_bots, repeat=True)
635
636        for bottype in self._bot_types_initial:
637            self._spawn_bot(bottype)
638
639    def _on_bot_spawn(self, spaz: SpazBot) -> None:
640        # We want to move to the left by default.
641        spaz.target_point_default = bs.Vec3(0, 0, 0)
642
643    def _spawn_bot(
644        self, spaz_type: type[SpazBot], immediate: bool = False
645    ) -> None:
646        assert self._bot_team is not None
647        pos = self.map.get_start_position(self._bot_team.id)
648        self._bots.spawn_bot(
649            spaz_type,
650            pos=pos,
651            spawn_time=0.001 if immediate else 3.0,
652            on_spawn_call=self._on_bot_spawn,
653        )
654
655    def _update_bots(self) -> None:
656        bots = self._bots.get_living_bots()
657        for bot in bots:
658            bot.target_flag = None
659
660        # If we've got a flag and no player are holding it, find the closest
661        # bot to it, and make them the designated flag-bearer.
662        assert self._flag is not None
663        if self._flag.node:
664            for player in self.players:
665                if player.actor:
666                    assert isinstance(player.actor, PlayerSpaz)
667                    if (
668                        player.actor.is_alive()
669                        and player.actor.node.hold_node == self._flag.node
670                    ):
671                        return
672
673            flagpos = bs.Vec3(self._flag.node.position)
674            closest_bot: SpazBot | None = None
675            closest_dist = 0.0  # Always gets assigned first time through.
676            for bot in bots:
677                # If a bot is picked up, he should forget about the flag.
678                if bot.held_count > 0:
679                    continue
680                assert bot.node
681                botpos = bs.Vec3(bot.node.position)
682                botdist = (botpos - flagpos).length()
683                if closest_bot is None or botdist < closest_dist:
684                    closest_bot = bot
685                    closest_dist = botdist
686            if closest_bot is not None:
687                closest_bot.target_flag = self._flag
688
689    def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None:
690        if poweruptype is None:
691            poweruptype = PowerupBoxFactory.get().get_random_powerup_type(
692                excludetypes=self._exclude_powerups
693            )
694        PowerupBox(
695            position=self.map.powerup_spawn_points[index],
696            poweruptype=poweruptype,
697        ).autoretain()
698
699    def _start_powerup_drops(self) -> None:
700        self._powerup_drop_timer = bs.Timer(
701            3.0, self._drop_powerups, repeat=True
702        )
703
704    def _drop_powerups(
705        self, standard_points: bool = False, poweruptype: str | None = None
706    ) -> None:
707        """Generic powerup drop."""
708        if standard_points:
709            spawnpoints = self.map.powerup_spawn_points
710            for i, _point in enumerate(spawnpoints):
711                bs.timer(
712                    1.0 + i * 0.5, bs.Call(self._drop_powerup, i, poweruptype)
713                )
714        else:
715            point = (
716                self._powerup_center[0]
717                + random.uniform(
718                    -1.0 * self._powerup_spread[0],
719                    1.0 * self._powerup_spread[0],
720                ),
721                self._powerup_center[1],
722                self._powerup_center[2]
723                + random.uniform(
724                    -self._powerup_spread[1], self._powerup_spread[1]
725                ),
726            )
727
728            # Drop one random one somewhere.
729            PowerupBox(
730                position=point,
731                poweruptype=PowerupBoxFactory.get().get_random_powerup_type(
732                    excludetypes=self._exclude_powerups
733                ),
734            ).autoretain()
735
736    def _kill_flag(self) -> None:
737        try:
738            assert self._flag is not None
739            self._flag.handlemessage(bs.DieMessage())
740        except Exception:
741            logging.exception('Error in _kill_flag.')
742
743    def _handle_score(self) -> None:
744        """a point has been scored"""
745        # FIXME tidy this up
746        # pylint: disable=too-many-branches
747
748        # Our flag might stick around for a second or two;
749        # we don't want it to be able to score again.
750        assert self._flag is not None
751        if self._flag.scored:
752            return
753
754        # See which score region it was.
755        region = bs.getcollision().sourcenode
756        i = None
757        for i, score_region in enumerate(self._score_regions):
758            if region == score_region.node:
759                break
760
761        for team in [self.teams[0], self._bot_team]:
762            assert team is not None
763            if team.id == i:
764                team.score += 7
765
766                # Tell all players (or bots) to celebrate.
767                if i == 0:
768                    for player in team.players:
769                        if player.actor:
770                            player.actor.handlemessage(bs.CelebrateMessage(2.0))
771                else:
772                    self._bots.celebrate(2.0)
773
774        # If the good guys scored, add more enemies.
775        if i == 0:
776            if self.teams[0].score == 7:
777                assert self._bot_types_7 is not None
778                for bottype in self._bot_types_7:
779                    self._spawn_bot(bottype)
780            elif self.teams[0].score == 14:
781                assert self._bot_types_14 is not None
782                for bottype in self._bot_types_14:
783                    self._spawn_bot(bottype)
784
785        self._score_sound.play()
786        if i == 0:
787            self._cheer_sound.play()
788        else:
789            self._boo_sound.play()
790
791        # Kill the flag (it'll respawn shortly).
792        self._flag.scored = True
793
794        bs.timer(0.2, self._kill_flag)
795
796        self.update_scores()
797        light = bs.newnode(
798            'light',
799            attrs={
800                'position': bs.getcollision().position,
801                'height_attenuated': False,
802                'color': (1, 0, 0),
803            },
804        )
805        bs.animate(light, 'intensity', {0: 0, 0.5: 1, 1.0: 0}, loop=True)
806        bs.timer(1.0, light.delete)
807        if i == 0:
808            bs.cameraflash(duration=10.0)
809
810    @override
811    def end_game(self) -> None:
812        bs.setmusic(None)
813        self._bots.final_celebrate()
814        bs.timer(0.001, bs.Call(self.do_end, 'defeat'))
815
816    def update_scores(self) -> None:
817        """update scoreboard and check for winners"""
818        # FIXME: tidy this up
819        # pylint: disable=too-many-nested-blocks
820        have_scoring_team = False
821        win_score = self._score_to_win
822        for team in [self.teams[0], self._bot_team]:
823            assert team is not None
824            assert self._scoreboard is not None
825            self._scoreboard.set_team_value(team, team.score, win_score)
826            if team.score >= win_score:
827                if not have_scoring_team:
828                    self._scoring_team = team
829                    if team is self._bot_team:
830                        self.end_game()
831                    else:
832                        bs.setmusic(bs.MusicType.VICTORY)
833
834                        # Completion achievements.
835                        assert self._bot_team is not None
836                        if self._preset in ['rookie', 'rookie_easy']:
837                            self._award_achievement(
838                                'Rookie Football Victory', sound=False
839                            )
840                            if self._bot_team.score == 0:
841                                self._award_achievement(
842                                    'Rookie Football Shutout', sound=False
843                                )
844                        elif self._preset in ['pro', 'pro_easy']:
845                            self._award_achievement(
846                                'Pro Football Victory', sound=False
847                            )
848                            if self._bot_team.score == 0:
849                                self._award_achievement(
850                                    'Pro Football Shutout', sound=False
851                                )
852                        elif self._preset in ['uber', 'uber_easy']:
853                            self._award_achievement(
854                                'Uber Football Victory', sound=False
855                            )
856                            if self._bot_team.score == 0:
857                                self._award_achievement(
858                                    'Uber Football Shutout', sound=False
859                                )
860                            if (
861                                not self._player_has_dropped_bomb
862                                and not self._player_has_punched
863                            ):
864                                self._award_achievement(
865                                    'Got the Moves', sound=False
866                                )
867                        self._bots.stop_moving()
868                        self.show_zoom_message(
869                            bs.Lstr(resource='victoryText'),
870                            scale=1.0,
871                            duration=4.0,
872                        )
873                        self.celebrate(10.0)
874                        assert self._starttime_ms is not None
875                        self._final_time_ms = int(
876                            int(bs.time() * 1000.0) - self._starttime_ms
877                        )
878                        self._time_text_timer = None
879                        assert (
880                            self._time_text_input is not None
881                            and self._time_text_input.node
882                        )
883                        self._time_text_input.node.timemax = self._final_time_ms
884
885                        self.do_end('victory')
886
887    def do_end(self, outcome: str) -> None:
888        """End the game with the specified outcome."""
889        if outcome == 'defeat':
890            self.fade_to_red()
891        assert self._final_time_ms is not None
892        scoreval = (
893            None if outcome == 'defeat' else int(self._final_time_ms // 10)
894        )
895        self.end(
896            delay=3.0,
897            results={
898                'outcome': outcome,
899                'score': scoreval,
900                'score_order': 'decreasing',
901                'playerinfos': self.initialplayerinfos,
902            },
903        )
904
905    @override
906    def handlemessage(self, msg: Any) -> Any:
907        """handle high-level game messages"""
908        if isinstance(msg, bs.PlayerDiedMessage):
909            # Augment standard behavior.
910            super().handlemessage(msg)
911
912            # Respawn them shortly.
913            player = msg.getplayer(Player)
914            assert self.initialplayerinfos is not None
915            respawn_time = 2.0 + len(self.initialplayerinfos) * 1.0
916            player.respawn_timer = bs.Timer(
917                respawn_time, bs.Call(self.spawn_player_if_exists, player)
918            )
919            player.respawn_icon = RespawnIcon(player, respawn_time)
920
921        elif isinstance(msg, SpazBotDiedMessage):
922            # Every time a bad guy dies, spawn a new one.
923            bs.timer(3.0, bs.Call(self._spawn_bot, (type(msg.spazbot))))
924
925        elif isinstance(msg, SpazBotPunchedMessage):
926            if self._preset in ['rookie', 'rookie_easy']:
927                if msg.damage >= 500:
928                    self._award_achievement('Super Punch')
929            elif self._preset in ['pro', 'pro_easy']:
930                if msg.damage >= 1000:
931                    self._award_achievement('Super Mega Punch')
932
933        # Respawn dead flags.
934        elif isinstance(msg, FlagDiedMessage):
935            assert isinstance(msg.flag, FootballFlag)
936            if msg.self_kill:
937                self._spawn_flag()
938            else:
939                msg.flag.respawn_timer = bs.Timer(3.0, self._spawn_flag)
940            self._flag_respawn_light = bs.NodeActor(
941                bs.newnode(
942                    'light',
943                    attrs={
944                        'position': self._flag_spawn_pos,
945                        'height_attenuated': False,
946                        'radius': 0.15,
947                        'color': (1.0, 1.0, 0.3),
948                    },
949                )
950            )
951            assert self._flag_respawn_light.node
952            bs.animate(
953                self._flag_respawn_light.node,
954                'intensity',
955                {0: 0, 0.25: 0.15, 0.5: 0},
956                loop=(not msg.self_kill),
957            )
958            bs.timer(3.0, self._flag_respawn_light.node.delete)
959        else:
960            return super().handlemessage(msg)
961        return None
962
963    def _handle_player_dropped_bomb(self, player: Spaz, bomb: bs.Actor) -> None:
964        del player, bomb  # Unused.
965        self._player_has_dropped_bomb = True
966
967    def _handle_player_punched(self, player: Spaz) -> None:
968        del player  # Unused.
969        self._player_has_punched = True
970
971    @override
972    def spawn_player(self, player: Player) -> bs.Actor:
973        spaz = self.spawn_player_spaz(
974            player, position=self.map.get_start_position(player.team.id)
975        )
976        if self._preset in ['rookie_easy', 'pro_easy', 'uber_easy']:
977            spaz.impact_scale = 0.25
978        spaz.add_dropped_bomb_callback(self._handle_player_dropped_bomb)
979        spaz.punch_callback = self._handle_player_punched
980        return spaz
981
982    def _flash_flag_spawn(self) -> None:
983        light = bs.newnode(
984            'light',
985            attrs={
986                'position': self._flag_spawn_pos,
987                'height_attenuated': False,
988                'color': (1, 1, 0),
989            },
990        )
991        bs.animate(light, 'intensity', {0: 0, 0.25: 0.25, 0.5: 0}, loop=True)
992        bs.timer(1.0, light.delete)
993
994    def _spawn_flag(self) -> None:
995        self._swipsound.play()
996        self._whistle_sound.play()
997        self._flash_flag_spawn()
998        assert self._flag_spawn_pos is not None
999        self._flag = FootballFlag(position=self._flag_spawn_pos)
class FootballFlag(bascenev1lib.actor.flag.Flag):
53class FootballFlag(Flag):
54    """Custom flag class for football games."""
55
56    def __init__(self, position: Sequence[float]):
57        super().__init__(
58            position=position, dropped_timeout=20, color=(1.0, 1.0, 0.3)
59        )
60        assert self.node
61        self.last_holding_player: bs.Player | None = None
62        self.node.is_area_of_interest = True
63        self.respawn_timer: bs.Timer | None = None
64        self.scored = False
65        self.held_count = 0
66        self.light = bs.newnode(
67            'light',
68            owner=self.node,
69            attrs={
70                'intensity': 0.25,
71                'height_attenuated': False,
72                'radius': 0.2,
73                'color': (0.9, 0.7, 0.0),
74            },
75        )
76        self.node.connectattr('position', self.light, 'position')

Custom flag class for football games.

FootballFlag(position: Sequence[float])
56    def __init__(self, position: Sequence[float]):
57        super().__init__(
58            position=position, dropped_timeout=20, color=(1.0, 1.0, 0.3)
59        )
60        assert self.node
61        self.last_holding_player: bs.Player | None = None
62        self.node.is_area_of_interest = True
63        self.respawn_timer: bs.Timer | None = None
64        self.scored = False
65        self.held_count = 0
66        self.light = bs.newnode(
67            'light',
68            owner=self.node,
69            attrs={
70                'intensity': 0.25,
71                'height_attenuated': False,
72                'radius': 0.2,
73                'color': (0.9, 0.7, 0.0),
74            },
75        )
76        self.node.connectattr('position', self.light, 'position')

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.

last_holding_player: Player | None
respawn_timer: _bascenev1.Timer | None
scored
held_count
light
class Player(bascenev1._player.Player[ForwardRef('Team')]):
79class Player(bs.Player['Team']):
80    """Our player type for this game."""
81
82    def __init__(self) -> None:
83        self.respawn_timer: bs.Timer | None = None
84        self.respawn_icon: RespawnIcon | None = None

Our player type for this game.

respawn_timer: _bascenev1.Timer | None
class Team(bascenev1._team.Team[bascenev1lib.game.football.Player]):
87class Team(bs.Team[Player]):
88    """Our team type for this game."""
89
90    def __init__(self) -> None:
91        self.score = 0

Our team type for this game.

score
class FootballTeamGame(bascenev1._teamgame.TeamGameActivity[bascenev1lib.game.football.Player, bascenev1lib.game.football.Team]):
 95class FootballTeamGame(bs.TeamGameActivity[Player, Team]):
 96    """Football game for teams mode."""
 97
 98    name = 'Football'
 99    description = 'Get the flag to the enemy end zone.'
100    available_settings = [
101        bs.IntSetting(
102            'Score to Win',
103            min_value=7,
104            default=21,
105            increment=7,
106        ),
107        bs.IntChoiceSetting(
108            'Time Limit',
109            choices=[
110                ('None', 0),
111                ('1 Minute', 60),
112                ('2 Minutes', 120),
113                ('5 Minutes', 300),
114                ('10 Minutes', 600),
115                ('20 Minutes', 1200),
116            ],
117            default=0,
118        ),
119        bs.FloatChoiceSetting(
120            'Respawn Times',
121            choices=[
122                ('Shorter', 0.25),
123                ('Short', 0.5),
124                ('Normal', 1.0),
125                ('Long', 2.0),
126                ('Longer', 4.0),
127            ],
128            default=1.0,
129        ),
130        bs.BoolSetting('Epic Mode', default=False),
131    ]
132
133    @override
134    @classmethod
135    def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool:
136        # We only support two-team play.
137        return issubclass(sessiontype, bs.DualTeamSession)
138
139    @override
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('football')
144
145    def __init__(self, settings: dict):
146        super().__init__(settings)
147        self._scoreboard: Scoreboard | None = Scoreboard()
148
149        # Load some media we need.
150        self._cheer_sound = bs.getsound('cheer')
151        self._chant_sound = bs.getsound('crowdChant')
152        self._score_sound = bs.getsound('score')
153        self._swipsound = bs.getsound('swip')
154        self._whistle_sound = bs.getsound('refWhistle')
155        self._score_region_material = bs.Material()
156        self._score_region_material.add_actions(
157            conditions=('they_have_material', FlagFactory.get().flagmaterial),
158            actions=(
159                ('modify_part_collision', 'collide', True),
160                ('modify_part_collision', 'physical', False),
161                ('call', 'at_connect', self._handle_score),
162            ),
163        )
164        self._flag_spawn_pos: Sequence[float] | None = None
165        self._score_regions: list[bs.NodeActor] = []
166        self._flag: FootballFlag | None = None
167        self._flag_respawn_timer: bs.Timer | None = None
168        self._flag_respawn_light: bs.NodeActor | None = None
169        self._score_to_win = int(settings['Score to Win'])
170        self._time_limit = float(settings['Time Limit'])
171        self._epic_mode = bool(settings['Epic Mode'])
172        self.slow_motion = self._epic_mode
173        self.default_music = (
174            bs.MusicType.EPIC if self._epic_mode else bs.MusicType.FOOTBALL
175        )
176
177    @override
178    def get_instance_description(self) -> str | Sequence:
179        touchdowns = self._score_to_win / 7
180
181        # NOTE: if use just touchdowns = self._score_to_win // 7
182        # and we will need to score, for example, 27 points,
183        # we will be required to score 3 (not 4) goals ..
184        touchdowns = math.ceil(touchdowns)
185        if touchdowns > 1:
186            return 'Score ${ARG1} touchdowns.', touchdowns
187        return 'Score a touchdown.'
188
189    @override
190    def get_instance_description_short(self) -> str | Sequence:
191        touchdowns = self._score_to_win / 7
192        touchdowns = math.ceil(touchdowns)
193        if touchdowns > 1:
194            return 'score ${ARG1} touchdowns', touchdowns
195        return 'score a touchdown'
196
197    @override
198    def on_begin(self) -> None:
199        super().on_begin()
200        self.setup_standard_time_limit(self._time_limit)
201        self.setup_standard_powerup_drops()
202        self._flag_spawn_pos = self.map.get_flag_position(None)
203        self._spawn_flag()
204        defs = self.map.defs
205        self._score_regions.append(
206            bs.NodeActor(
207                bs.newnode(
208                    'region',
209                    attrs={
210                        'position': defs.boxes['goal1'][0:3],
211                        'scale': defs.boxes['goal1'][6:9],
212                        'type': 'box',
213                        'materials': (self._score_region_material,),
214                    },
215                )
216            )
217        )
218        self._score_regions.append(
219            bs.NodeActor(
220                bs.newnode(
221                    'region',
222                    attrs={
223                        'position': defs.boxes['goal2'][0:3],
224                        'scale': defs.boxes['goal2'][6:9],
225                        'type': 'box',
226                        'materials': (self._score_region_material,),
227                    },
228                )
229            )
230        )
231        self._update_scoreboard()
232        self._chant_sound.play()
233
234    @override
235    def on_team_join(self, team: Team) -> None:
236        self._update_scoreboard()
237
238    def _kill_flag(self) -> None:
239        self._flag = None
240
241    def _handle_score(self) -> None:
242        """A point has been scored."""
243
244        # Our flag might stick around for a second or two
245        # make sure it doesn't score again.
246        assert self._flag is not None
247        if self._flag.scored:
248            return
249        region = bs.getcollision().sourcenode
250        i = None
251        for i, score_region in enumerate(self._score_regions):
252            if region == score_region.node:
253                break
254        for team in self.teams:
255            if team.id == i:
256                team.score += 7
257
258                # Tell all players to celebrate.
259                for player in team.players:
260                    if player.actor:
261                        player.actor.handlemessage(bs.CelebrateMessage(2.0))
262
263                # If someone on this team was last to touch it,
264                # give them points.
265                assert self._flag is not None
266                if (
267                    self._flag.last_holding_player
268                    and team == self._flag.last_holding_player.team
269                ):
270                    self.stats.player_scored(
271                        self._flag.last_holding_player, 50, big_message=True
272                    )
273                # End the game if we won.
274                if team.score >= self._score_to_win:
275                    self.end_game()
276        self._score_sound.play()
277        self._cheer_sound.play()
278        assert self._flag
279        self._flag.scored = True
280
281        # Kill the flag (it'll respawn shortly).
282        bs.timer(1.0, self._kill_flag)
283        light = bs.newnode(
284            'light',
285            attrs={
286                'position': bs.getcollision().position,
287                'height_attenuated': False,
288                'color': (1, 0, 0),
289            },
290        )
291        bs.animate(light, 'intensity', {0.0: 0, 0.5: 1, 1.0: 0}, loop=True)
292        bs.timer(1.0, light.delete)
293        bs.cameraflash(duration=10.0)
294        self._update_scoreboard()
295
296    @override
297    def end_game(self) -> None:
298        results = bs.GameResults()
299        for team in self.teams:
300            results.set_team_score(team, team.score)
301        self.end(results=results, announce_delay=0.8)
302
303    def _update_scoreboard(self) -> None:
304        assert self._scoreboard is not None
305        for team in self.teams:
306            self._scoreboard.set_team_value(
307                team, team.score, self._score_to_win
308            )
309
310    @override
311    def handlemessage(self, msg: Any) -> Any:
312        if isinstance(msg, FlagPickedUpMessage):
313            assert isinstance(msg.flag, FootballFlag)
314            try:
315                msg.flag.last_holding_player = msg.node.getdelegate(
316                    PlayerSpaz, True
317                ).getplayer(Player, True)
318            except bs.NotFoundError:
319                pass
320            msg.flag.held_count += 1
321
322        elif isinstance(msg, FlagDroppedMessage):
323            assert isinstance(msg.flag, FootballFlag)
324            msg.flag.held_count -= 1
325
326        # Respawn dead players if they're still in the game.
327        elif isinstance(msg, bs.PlayerDiedMessage):
328            # Augment standard behavior.
329            super().handlemessage(msg)
330            self.respawn_player(msg.getplayer(Player))
331
332        # Respawn dead flags.
333        elif isinstance(msg, FlagDiedMessage):
334            if not self.has_ended():
335                if msg.self_kill:
336                    self._spawn_flag()
337                else:
338                    self._flag_respawn_timer = bs.Timer(3.0, self._spawn_flag)
339                self._flag_respawn_light = bs.NodeActor(
340                    bs.newnode(
341                        'light',
342                        attrs={
343                            'position': self._flag_spawn_pos,
344                            'height_attenuated': False,
345                            'radius': 0.15,
346                            'color': (1.0, 1.0, 0.3),
347                        },
348                    )
349                )
350                assert self._flag_respawn_light.node
351                bs.animate(
352                    self._flag_respawn_light.node,
353                    'intensity',
354                    {0.0: 0, 0.25: 0.15, 0.5: 0},
355                    loop=True,
356                )
357                bs.timer(3.0, self._flag_respawn_light.node.delete)
358
359        else:
360            # Augment standard behavior.
361            super().handlemessage(msg)
362
363    def _flash_flag_spawn(self) -> None:
364        light = bs.newnode(
365            'light',
366            attrs={
367                'position': self._flag_spawn_pos,
368                'height_attenuated': False,
369                'color': (1, 1, 0),
370            },
371        )
372        bs.animate(light, 'intensity', {0: 0, 0.25: 0.25, 0.5: 0}, loop=True)
373        bs.timer(1.0, light.delete)
374
375    def _spawn_flag(self) -> None:
376        self._swipsound.play()
377        self._whistle_sound.play()
378        self._flash_flag_spawn()
379        assert self._flag_spawn_pos is not None
380        self._flag = FootballFlag(position=self._flag_spawn_pos)

Football game for teams mode.

FootballTeamGame(settings: dict)
145    def __init__(self, settings: dict):
146        super().__init__(settings)
147        self._scoreboard: Scoreboard | None = Scoreboard()
148
149        # Load some media we need.
150        self._cheer_sound = bs.getsound('cheer')
151        self._chant_sound = bs.getsound('crowdChant')
152        self._score_sound = bs.getsound('score')
153        self._swipsound = bs.getsound('swip')
154        self._whistle_sound = bs.getsound('refWhistle')
155        self._score_region_material = bs.Material()
156        self._score_region_material.add_actions(
157            conditions=('they_have_material', FlagFactory.get().flagmaterial),
158            actions=(
159                ('modify_part_collision', 'collide', True),
160                ('modify_part_collision', 'physical', False),
161                ('call', 'at_connect', self._handle_score),
162            ),
163        )
164        self._flag_spawn_pos: Sequence[float] | None = None
165        self._score_regions: list[bs.NodeActor] = []
166        self._flag: FootballFlag | None = None
167        self._flag_respawn_timer: bs.Timer | None = None
168        self._flag_respawn_light: bs.NodeActor | None = None
169        self._score_to_win = int(settings['Score to Win'])
170        self._time_limit = float(settings['Time Limit'])
171        self._epic_mode = bool(settings['Epic Mode'])
172        self.slow_motion = self._epic_mode
173        self.default_music = (
174            bs.MusicType.EPIC if self._epic_mode else bs.MusicType.FOOTBALL
175        )

Instantiate the Activity.

name = 'Football'
description = 'Get the flag to the enemy end zone.'
available_settings = [IntSetting(name='Score to Win', default=21, min_value=7, max_value=9999, increment=7), 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:
133    @override
134    @classmethod
135    def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool:
136        # We only support two-team play.
137        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]:
139    @override
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('football')

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.

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]:
177    @override
178    def get_instance_description(self) -> str | Sequence:
179        touchdowns = self._score_to_win / 7
180
181        # NOTE: if use just touchdowns = self._score_to_win // 7
182        # and we will need to score, for example, 27 points,
183        # we will be required to score 3 (not 4) goals ..
184        touchdowns = math.ceil(touchdowns)
185        if touchdowns > 1:
186            return 'Score ${ARG1} touchdowns.', touchdowns
187        return 'Score a touchdown.'

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]:
189    @override
190    def get_instance_description_short(self) -> str | Sequence:
191        touchdowns = self._score_to_win / 7
192        touchdowns = math.ceil(touchdowns)
193        if touchdowns > 1:
194            return 'score ${ARG1} touchdowns', touchdowns
195        return 'score a touchdown'

Return a short description for this game instance in English.

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

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

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

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

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

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

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

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

@override
def on_begin(self) -> None:
197    @override
198    def on_begin(self) -> None:
199        super().on_begin()
200        self.setup_standard_time_limit(self._time_limit)
201        self.setup_standard_powerup_drops()
202        self._flag_spawn_pos = self.map.get_flag_position(None)
203        self._spawn_flag()
204        defs = self.map.defs
205        self._score_regions.append(
206            bs.NodeActor(
207                bs.newnode(
208                    'region',
209                    attrs={
210                        'position': defs.boxes['goal1'][0:3],
211                        'scale': defs.boxes['goal1'][6:9],
212                        'type': 'box',
213                        'materials': (self._score_region_material,),
214                    },
215                )
216            )
217        )
218        self._score_regions.append(
219            bs.NodeActor(
220                bs.newnode(
221                    'region',
222                    attrs={
223                        'position': defs.boxes['goal2'][0:3],
224                        'scale': defs.boxes['goal2'][6:9],
225                        'type': 'box',
226                        'materials': (self._score_region_material,),
227                    },
228                )
229            )
230        )
231        self._update_scoreboard()
232        self._chant_sound.play()

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 on_team_join(self, team: Team) -> None:
234    @override
235    def on_team_join(self, team: Team) -> None:
236        self._update_scoreboard()

Called when a new bascenev1.Team joins the Activity.

(including the initial set of Teams)

@override
def end_game(self) -> None:
296    @override
297    def end_game(self) -> None:
298        results = bs.GameResults()
299        for team in self.teams:
300            results.set_team_score(team, team.score)
301        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 handlemessage(self, msg: Any) -> Any:
310    @override
311    def handlemessage(self, msg: Any) -> Any:
312        if isinstance(msg, FlagPickedUpMessage):
313            assert isinstance(msg.flag, FootballFlag)
314            try:
315                msg.flag.last_holding_player = msg.node.getdelegate(
316                    PlayerSpaz, True
317                ).getplayer(Player, True)
318            except bs.NotFoundError:
319                pass
320            msg.flag.held_count += 1
321
322        elif isinstance(msg, FlagDroppedMessage):
323            assert isinstance(msg.flag, FootballFlag)
324            msg.flag.held_count -= 1
325
326        # Respawn dead players if they're still in the game.
327        elif isinstance(msg, bs.PlayerDiedMessage):
328            # Augment standard behavior.
329            super().handlemessage(msg)
330            self.respawn_player(msg.getplayer(Player))
331
332        # Respawn dead flags.
333        elif isinstance(msg, FlagDiedMessage):
334            if not self.has_ended():
335                if msg.self_kill:
336                    self._spawn_flag()
337                else:
338                    self._flag_respawn_timer = bs.Timer(3.0, self._spawn_flag)
339                self._flag_respawn_light = bs.NodeActor(
340                    bs.newnode(
341                        'light',
342                        attrs={
343                            'position': self._flag_spawn_pos,
344                            'height_attenuated': False,
345                            'radius': 0.15,
346                            'color': (1.0, 1.0, 0.3),
347                        },
348                    )
349                )
350                assert self._flag_respawn_light.node
351                bs.animate(
352                    self._flag_respawn_light.node,
353                    'intensity',
354                    {0.0: 0, 0.25: 0.15, 0.5: 0},
355                    loop=True,
356                )
357                bs.timer(3.0, self._flag_respawn_light.node.delete)
358
359        else:
360            # Augment standard behavior.
361            super().handlemessage(msg)

General message handling; can be passed any message object.

class FootballCoopGame(bascenev1._coopgame.CoopGameActivity[bascenev1lib.game.football.Player, bascenev1lib.game.football.Team]):
 383class FootballCoopGame(bs.CoopGameActivity[Player, Team]):
 384    """Co-op variant of football."""
 385
 386    name = 'Football'
 387    tips = ['Use the pick-up button to grab the flag < ${PICKUP} >']
 388    scoreconfig = bs.ScoreConfig(
 389        scoretype=bs.ScoreType.MILLISECONDS, version='B'
 390    )
 391
 392    default_music = bs.MusicType.FOOTBALL
 393
 394    # FIXME: Need to update co-op games to use getscoreconfig.
 395    @override
 396    def get_score_type(self) -> str:
 397        return 'time'
 398
 399    @override
 400    def get_instance_description(self) -> str | Sequence:
 401        touchdowns = self._score_to_win / 7
 402        touchdowns = math.ceil(touchdowns)
 403        if touchdowns > 1:
 404            return 'Score ${ARG1} touchdowns.', touchdowns
 405        return 'Score a touchdown.'
 406
 407    @override
 408    def get_instance_description_short(self) -> str | Sequence:
 409        touchdowns = self._score_to_win / 7
 410        touchdowns = math.ceil(touchdowns)
 411        if touchdowns > 1:
 412            return 'score ${ARG1} touchdowns', touchdowns
 413        return 'score a touchdown'
 414
 415    def __init__(self, settings: dict):
 416        settings['map'] = 'Football Stadium'
 417        super().__init__(settings)
 418        self._preset = settings.get('preset', 'rookie')
 419
 420        # Load some media we need.
 421        self._cheer_sound = bs.getsound('cheer')
 422        self._boo_sound = bs.getsound('boo')
 423        self._chant_sound = bs.getsound('crowdChant')
 424        self._score_sound = bs.getsound('score')
 425        self._swipsound = bs.getsound('swip')
 426        self._whistle_sound = bs.getsound('refWhistle')
 427        self._score_to_win = 21
 428        self._score_region_material = bs.Material()
 429        self._score_region_material.add_actions(
 430            conditions=('they_have_material', FlagFactory.get().flagmaterial),
 431            actions=(
 432                ('modify_part_collision', 'collide', True),
 433                ('modify_part_collision', 'physical', False),
 434                ('call', 'at_connect', self._handle_score),
 435            ),
 436        )
 437        self._powerup_center = (0, 2, 0)
 438        self._powerup_spread = (10, 5.5)
 439        self._player_has_dropped_bomb = False
 440        self._player_has_punched = False
 441        self._scoreboard: Scoreboard | None = None
 442        self._flag_spawn_pos: Sequence[float] | None = None
 443        self._score_regions: list[bs.NodeActor] = []
 444        self._exclude_powerups: list[str] = []
 445        self._have_tnt = False
 446        self._bot_types_initial: list[type[SpazBot]] | None = None
 447        self._bot_types_7: list[type[SpazBot]] | None = None
 448        self._bot_types_14: list[type[SpazBot]] | None = None
 449        self._bot_team: Team | None = None
 450        self._starttime_ms: int | None = None
 451        self._time_text: bs.NodeActor | None = None
 452        self._time_text_input: bs.NodeActor | None = None
 453        self._tntspawner: TNTSpawner | None = None
 454        self._bots = SpazBotSet()
 455        self._bot_spawn_timer: bs.Timer | None = None
 456        self._powerup_drop_timer: bs.Timer | None = None
 457        self._scoring_team: Team | None = None
 458        self._final_time_ms: int | None = None
 459        self._time_text_timer: bs.Timer | None = None
 460        self._flag_respawn_light: bs.Actor | None = None
 461        self._flag: FootballFlag | None = None
 462
 463    @override
 464    def on_transition_in(self) -> None:
 465        super().on_transition_in()
 466        self._scoreboard = Scoreboard()
 467        self._flag_spawn_pos = self.map.get_flag_position(None)
 468        self._spawn_flag()
 469
 470        # Set up the two score regions.
 471        defs = self.map.defs
 472        self._score_regions.append(
 473            bs.NodeActor(
 474                bs.newnode(
 475                    'region',
 476                    attrs={
 477                        'position': defs.boxes['goal1'][0:3],
 478                        'scale': defs.boxes['goal1'][6:9],
 479                        'type': 'box',
 480                        'materials': [self._score_region_material],
 481                    },
 482                )
 483            )
 484        )
 485        self._score_regions.append(
 486            bs.NodeActor(
 487                bs.newnode(
 488                    'region',
 489                    attrs={
 490                        'position': defs.boxes['goal2'][0:3],
 491                        'scale': defs.boxes['goal2'][6:9],
 492                        'type': 'box',
 493                        'materials': [self._score_region_material],
 494                    },
 495                )
 496            )
 497        )
 498        self._chant_sound.play()
 499
 500    @override
 501    def on_begin(self) -> None:
 502        # FIXME: Split this up a bit.
 503        # pylint: disable=too-many-statements
 504        from bascenev1lib.actor import controlsguide
 505
 506        super().on_begin()
 507
 508        # Show controls help in demo or arcade mode.
 509        if bs.app.env.demo or bs.app.env.arcade:
 510            controlsguide.ControlsGuide(
 511                delay=3.0, lifespan=10.0, bright=True
 512            ).autoretain()
 513        assert self.initialplayerinfos is not None
 514        abot: type[SpazBot]
 515        bbot: type[SpazBot]
 516        cbot: type[SpazBot]
 517        if self._preset in ['rookie', 'rookie_easy']:
 518            self._exclude_powerups = ['curse']
 519            self._have_tnt = False
 520            abot = (
 521                BrawlerBotLite if self._preset == 'rookie_easy' else BrawlerBot
 522            )
 523            self._bot_types_initial = [abot] * len(self.initialplayerinfos)
 524            bbot = BomberBotLite if self._preset == 'rookie_easy' else BomberBot
 525            self._bot_types_7 = [bbot] * (
 526                1 if len(self.initialplayerinfos) < 3 else 2
 527            )
 528            cbot = BomberBot if self._preset == 'rookie_easy' else TriggerBot
 529            self._bot_types_14 = [cbot] * (
 530                1 if len(self.initialplayerinfos) < 3 else 2
 531            )
 532        elif self._preset == 'tournament':
 533            self._exclude_powerups = []
 534            self._have_tnt = True
 535            self._bot_types_initial = [BrawlerBot] * (
 536                1 if len(self.initialplayerinfos) < 2 else 2
 537            )
 538            self._bot_types_7 = [TriggerBot] * (
 539                1 if len(self.initialplayerinfos) < 3 else 2
 540            )
 541            self._bot_types_14 = [ChargerBot] * (
 542                1 if len(self.initialplayerinfos) < 4 else 2
 543            )
 544        elif self._preset in ['pro', 'pro_easy', 'tournament_pro']:
 545            self._exclude_powerups = ['curse']
 546            self._have_tnt = True
 547            self._bot_types_initial = [ChargerBot] * len(
 548                self.initialplayerinfos
 549            )
 550            abot = BrawlerBot if self._preset == 'pro' else BrawlerBotLite
 551            typed_bot_list: list[type[SpazBot]] = []
 552            self._bot_types_7 = (
 553                typed_bot_list
 554                + [abot]
 555                + [BomberBot] * (1 if len(self.initialplayerinfos) < 3 else 2)
 556            )
 557            bbot = TriggerBotPro if self._preset == 'pro' else TriggerBot
 558            self._bot_types_14 = [bbot] * (
 559                1 if len(self.initialplayerinfos) < 3 else 2
 560            )
 561        elif self._preset in ['uber', 'uber_easy']:
 562            self._exclude_powerups = []
 563            self._have_tnt = True
 564            abot = BrawlerBotPro if self._preset == 'uber' else BrawlerBot
 565            bbot = TriggerBotPro if self._preset == 'uber' else TriggerBot
 566            typed_bot_list_2: list[type[SpazBot]] = []
 567            self._bot_types_initial = (
 568                typed_bot_list_2
 569                + [StickyBot]
 570                + [abot] * len(self.initialplayerinfos)
 571            )
 572            self._bot_types_7 = [bbot] * (
 573                1 if len(self.initialplayerinfos) < 3 else 2
 574            )
 575            self._bot_types_14 = [ExplodeyBot] * (
 576                1 if len(self.initialplayerinfos) < 3 else 2
 577            )
 578        else:
 579            raise RuntimeError()
 580
 581        self.setup_low_life_warning_sound()
 582
 583        self._drop_powerups(standard_points=True)
 584        bs.timer(4.0, self._start_powerup_drops)
 585
 586        # Make a bogus team for our bots.
 587        bad_team_name = self.get_team_display_string('Bad Guys')
 588        self._bot_team = Team()
 589        self._bot_team.manual_init(
 590            team_id=1, name=bad_team_name, color=(0.5, 0.4, 0.4)
 591        )
 592
 593        for team in [self.teams[0], self._bot_team]:
 594            team.score = 0
 595
 596        self.update_scores()
 597
 598        # Time display.
 599        starttime_ms = int(bs.time() * 1000.0)
 600        assert isinstance(starttime_ms, int)
 601        self._starttime_ms = starttime_ms
 602        self._time_text = bs.NodeActor(
 603            bs.newnode(
 604                'text',
 605                attrs={
 606                    'v_attach': 'top',
 607                    'h_attach': 'center',
 608                    'h_align': 'center',
 609                    'color': (1, 1, 0.5, 1),
 610                    'flatness': 0.5,
 611                    'shadow': 0.5,
 612                    'position': (0, -50),
 613                    'scale': 1.3,
 614                    'text': '',
 615                },
 616            )
 617        )
 618        self._time_text_input = bs.NodeActor(
 619            bs.newnode('timedisplay', attrs={'showsubseconds': True})
 620        )
 621        self.globalsnode.connectattr(
 622            'time', self._time_text_input.node, 'time2'
 623        )
 624        assert self._time_text_input.node
 625        assert self._time_text.node
 626        self._time_text_input.node.connectattr(
 627            'output', self._time_text.node, 'text'
 628        )
 629
 630        # Our TNT spawner (if applicable).
 631        if self._have_tnt:
 632            self._tntspawner = TNTSpawner(position=(0, 1, -1))
 633
 634        self._bots = SpazBotSet()
 635        self._bot_spawn_timer = bs.Timer(1.0, self._update_bots, repeat=True)
 636
 637        for bottype in self._bot_types_initial:
 638            self._spawn_bot(bottype)
 639
 640    def _on_bot_spawn(self, spaz: SpazBot) -> None:
 641        # We want to move to the left by default.
 642        spaz.target_point_default = bs.Vec3(0, 0, 0)
 643
 644    def _spawn_bot(
 645        self, spaz_type: type[SpazBot], immediate: bool = False
 646    ) -> None:
 647        assert self._bot_team is not None
 648        pos = self.map.get_start_position(self._bot_team.id)
 649        self._bots.spawn_bot(
 650            spaz_type,
 651            pos=pos,
 652            spawn_time=0.001 if immediate else 3.0,
 653            on_spawn_call=self._on_bot_spawn,
 654        )
 655
 656    def _update_bots(self) -> None:
 657        bots = self._bots.get_living_bots()
 658        for bot in bots:
 659            bot.target_flag = None
 660
 661        # If we've got a flag and no player are holding it, find the closest
 662        # bot to it, and make them the designated flag-bearer.
 663        assert self._flag is not None
 664        if self._flag.node:
 665            for player in self.players:
 666                if player.actor:
 667                    assert isinstance(player.actor, PlayerSpaz)
 668                    if (
 669                        player.actor.is_alive()
 670                        and player.actor.node.hold_node == self._flag.node
 671                    ):
 672                        return
 673
 674            flagpos = bs.Vec3(self._flag.node.position)
 675            closest_bot: SpazBot | None = None
 676            closest_dist = 0.0  # Always gets assigned first time through.
 677            for bot in bots:
 678                # If a bot is picked up, he should forget about the flag.
 679                if bot.held_count > 0:
 680                    continue
 681                assert bot.node
 682                botpos = bs.Vec3(bot.node.position)
 683                botdist = (botpos - flagpos).length()
 684                if closest_bot is None or botdist < closest_dist:
 685                    closest_bot = bot
 686                    closest_dist = botdist
 687            if closest_bot is not None:
 688                closest_bot.target_flag = self._flag
 689
 690    def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None:
 691        if poweruptype is None:
 692            poweruptype = PowerupBoxFactory.get().get_random_powerup_type(
 693                excludetypes=self._exclude_powerups
 694            )
 695        PowerupBox(
 696            position=self.map.powerup_spawn_points[index],
 697            poweruptype=poweruptype,
 698        ).autoretain()
 699
 700    def _start_powerup_drops(self) -> None:
 701        self._powerup_drop_timer = bs.Timer(
 702            3.0, self._drop_powerups, repeat=True
 703        )
 704
 705    def _drop_powerups(
 706        self, standard_points: bool = False, poweruptype: str | None = None
 707    ) -> None:
 708        """Generic powerup drop."""
 709        if standard_points:
 710            spawnpoints = self.map.powerup_spawn_points
 711            for i, _point in enumerate(spawnpoints):
 712                bs.timer(
 713                    1.0 + i * 0.5, bs.Call(self._drop_powerup, i, poweruptype)
 714                )
 715        else:
 716            point = (
 717                self._powerup_center[0]
 718                + random.uniform(
 719                    -1.0 * self._powerup_spread[0],
 720                    1.0 * self._powerup_spread[0],
 721                ),
 722                self._powerup_center[1],
 723                self._powerup_center[2]
 724                + random.uniform(
 725                    -self._powerup_spread[1], self._powerup_spread[1]
 726                ),
 727            )
 728
 729            # Drop one random one somewhere.
 730            PowerupBox(
 731                position=point,
 732                poweruptype=PowerupBoxFactory.get().get_random_powerup_type(
 733                    excludetypes=self._exclude_powerups
 734                ),
 735            ).autoretain()
 736
 737    def _kill_flag(self) -> None:
 738        try:
 739            assert self._flag is not None
 740            self._flag.handlemessage(bs.DieMessage())
 741        except Exception:
 742            logging.exception('Error in _kill_flag.')
 743
 744    def _handle_score(self) -> None:
 745        """a point has been scored"""
 746        # FIXME tidy this up
 747        # pylint: disable=too-many-branches
 748
 749        # Our flag might stick around for a second or two;
 750        # we don't want it to be able to score again.
 751        assert self._flag is not None
 752        if self._flag.scored:
 753            return
 754
 755        # See which score region it was.
 756        region = bs.getcollision().sourcenode
 757        i = None
 758        for i, score_region in enumerate(self._score_regions):
 759            if region == score_region.node:
 760                break
 761
 762        for team in [self.teams[0], self._bot_team]:
 763            assert team is not None
 764            if team.id == i:
 765                team.score += 7
 766
 767                # Tell all players (or bots) to celebrate.
 768                if i == 0:
 769                    for player in team.players:
 770                        if player.actor:
 771                            player.actor.handlemessage(bs.CelebrateMessage(2.0))
 772                else:
 773                    self._bots.celebrate(2.0)
 774
 775        # If the good guys scored, add more enemies.
 776        if i == 0:
 777            if self.teams[0].score == 7:
 778                assert self._bot_types_7 is not None
 779                for bottype in self._bot_types_7:
 780                    self._spawn_bot(bottype)
 781            elif self.teams[0].score == 14:
 782                assert self._bot_types_14 is not None
 783                for bottype in self._bot_types_14:
 784                    self._spawn_bot(bottype)
 785
 786        self._score_sound.play()
 787        if i == 0:
 788            self._cheer_sound.play()
 789        else:
 790            self._boo_sound.play()
 791
 792        # Kill the flag (it'll respawn shortly).
 793        self._flag.scored = True
 794
 795        bs.timer(0.2, self._kill_flag)
 796
 797        self.update_scores()
 798        light = bs.newnode(
 799            'light',
 800            attrs={
 801                'position': bs.getcollision().position,
 802                'height_attenuated': False,
 803                'color': (1, 0, 0),
 804            },
 805        )
 806        bs.animate(light, 'intensity', {0: 0, 0.5: 1, 1.0: 0}, loop=True)
 807        bs.timer(1.0, light.delete)
 808        if i == 0:
 809            bs.cameraflash(duration=10.0)
 810
 811    @override
 812    def end_game(self) -> None:
 813        bs.setmusic(None)
 814        self._bots.final_celebrate()
 815        bs.timer(0.001, bs.Call(self.do_end, 'defeat'))
 816
 817    def update_scores(self) -> None:
 818        """update scoreboard and check for winners"""
 819        # FIXME: tidy this up
 820        # pylint: disable=too-many-nested-blocks
 821        have_scoring_team = False
 822        win_score = self._score_to_win
 823        for team in [self.teams[0], self._bot_team]:
 824            assert team is not None
 825            assert self._scoreboard is not None
 826            self._scoreboard.set_team_value(team, team.score, win_score)
 827            if team.score >= win_score:
 828                if not have_scoring_team:
 829                    self._scoring_team = team
 830                    if team is self._bot_team:
 831                        self.end_game()
 832                    else:
 833                        bs.setmusic(bs.MusicType.VICTORY)
 834
 835                        # Completion achievements.
 836                        assert self._bot_team is not None
 837                        if self._preset in ['rookie', 'rookie_easy']:
 838                            self._award_achievement(
 839                                'Rookie Football Victory', sound=False
 840                            )
 841                            if self._bot_team.score == 0:
 842                                self._award_achievement(
 843                                    'Rookie Football Shutout', sound=False
 844                                )
 845                        elif self._preset in ['pro', 'pro_easy']:
 846                            self._award_achievement(
 847                                'Pro Football Victory', sound=False
 848                            )
 849                            if self._bot_team.score == 0:
 850                                self._award_achievement(
 851                                    'Pro Football Shutout', sound=False
 852                                )
 853                        elif self._preset in ['uber', 'uber_easy']:
 854                            self._award_achievement(
 855                                'Uber Football Victory', sound=False
 856                            )
 857                            if self._bot_team.score == 0:
 858                                self._award_achievement(
 859                                    'Uber Football Shutout', sound=False
 860                                )
 861                            if (
 862                                not self._player_has_dropped_bomb
 863                                and not self._player_has_punched
 864                            ):
 865                                self._award_achievement(
 866                                    'Got the Moves', sound=False
 867                                )
 868                        self._bots.stop_moving()
 869                        self.show_zoom_message(
 870                            bs.Lstr(resource='victoryText'),
 871                            scale=1.0,
 872                            duration=4.0,
 873                        )
 874                        self.celebrate(10.0)
 875                        assert self._starttime_ms is not None
 876                        self._final_time_ms = int(
 877                            int(bs.time() * 1000.0) - self._starttime_ms
 878                        )
 879                        self._time_text_timer = None
 880                        assert (
 881                            self._time_text_input is not None
 882                            and self._time_text_input.node
 883                        )
 884                        self._time_text_input.node.timemax = self._final_time_ms
 885
 886                        self.do_end('victory')
 887
 888    def do_end(self, outcome: str) -> None:
 889        """End the game with the specified outcome."""
 890        if outcome == 'defeat':
 891            self.fade_to_red()
 892        assert self._final_time_ms is not None
 893        scoreval = (
 894            None if outcome == 'defeat' else int(self._final_time_ms // 10)
 895        )
 896        self.end(
 897            delay=3.0,
 898            results={
 899                'outcome': outcome,
 900                'score': scoreval,
 901                'score_order': 'decreasing',
 902                'playerinfos': self.initialplayerinfos,
 903            },
 904        )
 905
 906    @override
 907    def handlemessage(self, msg: Any) -> Any:
 908        """handle high-level game messages"""
 909        if isinstance(msg, bs.PlayerDiedMessage):
 910            # Augment standard behavior.
 911            super().handlemessage(msg)
 912
 913            # Respawn them shortly.
 914            player = msg.getplayer(Player)
 915            assert self.initialplayerinfos is not None
 916            respawn_time = 2.0 + len(self.initialplayerinfos) * 1.0
 917            player.respawn_timer = bs.Timer(
 918                respawn_time, bs.Call(self.spawn_player_if_exists, player)
 919            )
 920            player.respawn_icon = RespawnIcon(player, respawn_time)
 921
 922        elif isinstance(msg, SpazBotDiedMessage):
 923            # Every time a bad guy dies, spawn a new one.
 924            bs.timer(3.0, bs.Call(self._spawn_bot, (type(msg.spazbot))))
 925
 926        elif isinstance(msg, SpazBotPunchedMessage):
 927            if self._preset in ['rookie', 'rookie_easy']:
 928                if msg.damage >= 500:
 929                    self._award_achievement('Super Punch')
 930            elif self._preset in ['pro', 'pro_easy']:
 931                if msg.damage >= 1000:
 932                    self._award_achievement('Super Mega Punch')
 933
 934        # Respawn dead flags.
 935        elif isinstance(msg, FlagDiedMessage):
 936            assert isinstance(msg.flag, FootballFlag)
 937            if msg.self_kill:
 938                self._spawn_flag()
 939            else:
 940                msg.flag.respawn_timer = bs.Timer(3.0, self._spawn_flag)
 941            self._flag_respawn_light = bs.NodeActor(
 942                bs.newnode(
 943                    'light',
 944                    attrs={
 945                        'position': self._flag_spawn_pos,
 946                        'height_attenuated': False,
 947                        'radius': 0.15,
 948                        'color': (1.0, 1.0, 0.3),
 949                    },
 950                )
 951            )
 952            assert self._flag_respawn_light.node
 953            bs.animate(
 954                self._flag_respawn_light.node,
 955                'intensity',
 956                {0: 0, 0.25: 0.15, 0.5: 0},
 957                loop=(not msg.self_kill),
 958            )
 959            bs.timer(3.0, self._flag_respawn_light.node.delete)
 960        else:
 961            return super().handlemessage(msg)
 962        return None
 963
 964    def _handle_player_dropped_bomb(self, player: Spaz, bomb: bs.Actor) -> None:
 965        del player, bomb  # Unused.
 966        self._player_has_dropped_bomb = True
 967
 968    def _handle_player_punched(self, player: Spaz) -> None:
 969        del player  # Unused.
 970        self._player_has_punched = True
 971
 972    @override
 973    def spawn_player(self, player: Player) -> bs.Actor:
 974        spaz = self.spawn_player_spaz(
 975            player, position=self.map.get_start_position(player.team.id)
 976        )
 977        if self._preset in ['rookie_easy', 'pro_easy', 'uber_easy']:
 978            spaz.impact_scale = 0.25
 979        spaz.add_dropped_bomb_callback(self._handle_player_dropped_bomb)
 980        spaz.punch_callback = self._handle_player_punched
 981        return spaz
 982
 983    def _flash_flag_spawn(self) -> None:
 984        light = bs.newnode(
 985            'light',
 986            attrs={
 987                'position': self._flag_spawn_pos,
 988                'height_attenuated': False,
 989                'color': (1, 1, 0),
 990            },
 991        )
 992        bs.animate(light, 'intensity', {0: 0, 0.25: 0.25, 0.5: 0}, loop=True)
 993        bs.timer(1.0, light.delete)
 994
 995    def _spawn_flag(self) -> None:
 996        self._swipsound.play()
 997        self._whistle_sound.play()
 998        self._flash_flag_spawn()
 999        assert self._flag_spawn_pos is not None
1000        self._flag = FootballFlag(position=self._flag_spawn_pos)

Co-op variant of football.

FootballCoopGame(settings: dict)
415    def __init__(self, settings: dict):
416        settings['map'] = 'Football Stadium'
417        super().__init__(settings)
418        self._preset = settings.get('preset', 'rookie')
419
420        # Load some media we need.
421        self._cheer_sound = bs.getsound('cheer')
422        self._boo_sound = bs.getsound('boo')
423        self._chant_sound = bs.getsound('crowdChant')
424        self._score_sound = bs.getsound('score')
425        self._swipsound = bs.getsound('swip')
426        self._whistle_sound = bs.getsound('refWhistle')
427        self._score_to_win = 21
428        self._score_region_material = bs.Material()
429        self._score_region_material.add_actions(
430            conditions=('they_have_material', FlagFactory.get().flagmaterial),
431            actions=(
432                ('modify_part_collision', 'collide', True),
433                ('modify_part_collision', 'physical', False),
434                ('call', 'at_connect', self._handle_score),
435            ),
436        )
437        self._powerup_center = (0, 2, 0)
438        self._powerup_spread = (10, 5.5)
439        self._player_has_dropped_bomb = False
440        self._player_has_punched = False
441        self._scoreboard: Scoreboard | None = None
442        self._flag_spawn_pos: Sequence[float] | None = None
443        self._score_regions: list[bs.NodeActor] = []
444        self._exclude_powerups: list[str] = []
445        self._have_tnt = False
446        self._bot_types_initial: list[type[SpazBot]] | None = None
447        self._bot_types_7: list[type[SpazBot]] | None = None
448        self._bot_types_14: list[type[SpazBot]] | None = None
449        self._bot_team: Team | None = None
450        self._starttime_ms: int | None = None
451        self._time_text: bs.NodeActor | None = None
452        self._time_text_input: bs.NodeActor | None = None
453        self._tntspawner: TNTSpawner | None = None
454        self._bots = SpazBotSet()
455        self._bot_spawn_timer: bs.Timer | None = None
456        self._powerup_drop_timer: bs.Timer | None = None
457        self._scoring_team: Team | None = None
458        self._final_time_ms: int | None = None
459        self._time_text_timer: bs.Timer | None = None
460        self._flag_respawn_light: bs.Actor | None = None
461        self._flag: FootballFlag | None = None

Instantiate the Activity.

name = 'Football'
tips = ['Use the pick-up button to grab the flag < ${PICKUP} >']
scoreconfig = ScoreConfig(label='Score', scoretype=<ScoreType.MILLISECONDS: 'ms'>, lower_is_better=False, none_is_winner=False, version='B')
default_music = <MusicType.FOOTBALL: 'Football'>
@override
def get_score_type(self) -> str:
395    @override
396    def get_score_type(self) -> str:
397        return 'time'

Return the score unit this co-op game uses ('point', 'seconds', etc.)

@override
def get_instance_description(self) -> Union[str, Sequence]:
399    @override
400    def get_instance_description(self) -> str | Sequence:
401        touchdowns = self._score_to_win / 7
402        touchdowns = math.ceil(touchdowns)
403        if touchdowns > 1:
404            return 'Score ${ARG1} touchdowns.', touchdowns
405        return 'Score a touchdown.'

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]:
407    @override
408    def get_instance_description_short(self) -> str | Sequence:
409        touchdowns = self._score_to_win / 7
410        touchdowns = math.ceil(touchdowns)
411        if touchdowns > 1:
412            return 'score ${ARG1} touchdowns', touchdowns
413        return 'score a touchdown'

Return a short description for this game instance in English.

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

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

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

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

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

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

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

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

@override
def on_transition_in(self) -> None:
463    @override
464    def on_transition_in(self) -> None:
465        super().on_transition_in()
466        self._scoreboard = Scoreboard()
467        self._flag_spawn_pos = self.map.get_flag_position(None)
468        self._spawn_flag()
469
470        # Set up the two score regions.
471        defs = self.map.defs
472        self._score_regions.append(
473            bs.NodeActor(
474                bs.newnode(
475                    'region',
476                    attrs={
477                        'position': defs.boxes['goal1'][0:3],
478                        'scale': defs.boxes['goal1'][6:9],
479                        'type': 'box',
480                        'materials': [self._score_region_material],
481                    },
482                )
483            )
484        )
485        self._score_regions.append(
486            bs.NodeActor(
487                bs.newnode(
488                    'region',
489                    attrs={
490                        'position': defs.boxes['goal2'][0:3],
491                        'scale': defs.boxes['goal2'][6:9],
492                        'type': 'box',
493                        'materials': [self._score_region_material],
494                    },
495                )
496            )
497        )
498        self._chant_sound.play()

Called when the Activity is first becoming visible.

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

@override
def on_begin(self) -> None:
500    @override
501    def on_begin(self) -> None:
502        # FIXME: Split this up a bit.
503        # pylint: disable=too-many-statements
504        from bascenev1lib.actor import controlsguide
505
506        super().on_begin()
507
508        # Show controls help in demo or arcade mode.
509        if bs.app.env.demo or bs.app.env.arcade:
510            controlsguide.ControlsGuide(
511                delay=3.0, lifespan=10.0, bright=True
512            ).autoretain()
513        assert self.initialplayerinfos is not None
514        abot: type[SpazBot]
515        bbot: type[SpazBot]
516        cbot: type[SpazBot]
517        if self._preset in ['rookie', 'rookie_easy']:
518            self._exclude_powerups = ['curse']
519            self._have_tnt = False
520            abot = (
521                BrawlerBotLite if self._preset == 'rookie_easy' else BrawlerBot
522            )
523            self._bot_types_initial = [abot] * len(self.initialplayerinfos)
524            bbot = BomberBotLite if self._preset == 'rookie_easy' else BomberBot
525            self._bot_types_7 = [bbot] * (
526                1 if len(self.initialplayerinfos) < 3 else 2
527            )
528            cbot = BomberBot if self._preset == 'rookie_easy' else TriggerBot
529            self._bot_types_14 = [cbot] * (
530                1 if len(self.initialplayerinfos) < 3 else 2
531            )
532        elif self._preset == 'tournament':
533            self._exclude_powerups = []
534            self._have_tnt = True
535            self._bot_types_initial = [BrawlerBot] * (
536                1 if len(self.initialplayerinfos) < 2 else 2
537            )
538            self._bot_types_7 = [TriggerBot] * (
539                1 if len(self.initialplayerinfos) < 3 else 2
540            )
541            self._bot_types_14 = [ChargerBot] * (
542                1 if len(self.initialplayerinfos) < 4 else 2
543            )
544        elif self._preset in ['pro', 'pro_easy', 'tournament_pro']:
545            self._exclude_powerups = ['curse']
546            self._have_tnt = True
547            self._bot_types_initial = [ChargerBot] * len(
548                self.initialplayerinfos
549            )
550            abot = BrawlerBot if self._preset == 'pro' else BrawlerBotLite
551            typed_bot_list: list[type[SpazBot]] = []
552            self._bot_types_7 = (
553                typed_bot_list
554                + [abot]
555                + [BomberBot] * (1 if len(self.initialplayerinfos) < 3 else 2)
556            )
557            bbot = TriggerBotPro if self._preset == 'pro' else TriggerBot
558            self._bot_types_14 = [bbot] * (
559                1 if len(self.initialplayerinfos) < 3 else 2
560            )
561        elif self._preset in ['uber', 'uber_easy']:
562            self._exclude_powerups = []
563            self._have_tnt = True
564            abot = BrawlerBotPro if self._preset == 'uber' else BrawlerBot
565            bbot = TriggerBotPro if self._preset == 'uber' else TriggerBot
566            typed_bot_list_2: list[type[SpazBot]] = []
567            self._bot_types_initial = (
568                typed_bot_list_2
569                + [StickyBot]
570                + [abot] * len(self.initialplayerinfos)
571            )
572            self._bot_types_7 = [bbot] * (
573                1 if len(self.initialplayerinfos) < 3 else 2
574            )
575            self._bot_types_14 = [ExplodeyBot] * (
576                1 if len(self.initialplayerinfos) < 3 else 2
577            )
578        else:
579            raise RuntimeError()
580
581        self.setup_low_life_warning_sound()
582
583        self._drop_powerups(standard_points=True)
584        bs.timer(4.0, self._start_powerup_drops)
585
586        # Make a bogus team for our bots.
587        bad_team_name = self.get_team_display_string('Bad Guys')
588        self._bot_team = Team()
589        self._bot_team.manual_init(
590            team_id=1, name=bad_team_name, color=(0.5, 0.4, 0.4)
591        )
592
593        for team in [self.teams[0], self._bot_team]:
594            team.score = 0
595
596        self.update_scores()
597
598        # Time display.
599        starttime_ms = int(bs.time() * 1000.0)
600        assert isinstance(starttime_ms, int)
601        self._starttime_ms = starttime_ms
602        self._time_text = bs.NodeActor(
603            bs.newnode(
604                'text',
605                attrs={
606                    'v_attach': 'top',
607                    'h_attach': 'center',
608                    'h_align': 'center',
609                    'color': (1, 1, 0.5, 1),
610                    'flatness': 0.5,
611                    'shadow': 0.5,
612                    'position': (0, -50),
613                    'scale': 1.3,
614                    'text': '',
615                },
616            )
617        )
618        self._time_text_input = bs.NodeActor(
619            bs.newnode('timedisplay', attrs={'showsubseconds': True})
620        )
621        self.globalsnode.connectattr(
622            'time', self._time_text_input.node, 'time2'
623        )
624        assert self._time_text_input.node
625        assert self._time_text.node
626        self._time_text_input.node.connectattr(
627            'output', self._time_text.node, 'text'
628        )
629
630        # Our TNT spawner (if applicable).
631        if self._have_tnt:
632            self._tntspawner = TNTSpawner(position=(0, 1, -1))
633
634        self._bots = SpazBotSet()
635        self._bot_spawn_timer = bs.Timer(1.0, self._update_bots, repeat=True)
636
637        for bottype in self._bot_types_initial:
638            self._spawn_bot(bottype)

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:
811    @override
812    def end_game(self) -> None:
813        bs.setmusic(None)
814        self._bots.final_celebrate()
815        bs.timer(0.001, bs.Call(self.do_end, 'defeat'))

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 update_scores(self) -> None:
817    def update_scores(self) -> None:
818        """update scoreboard and check for winners"""
819        # FIXME: tidy this up
820        # pylint: disable=too-many-nested-blocks
821        have_scoring_team = False
822        win_score = self._score_to_win
823        for team in [self.teams[0], self._bot_team]:
824            assert team is not None
825            assert self._scoreboard is not None
826            self._scoreboard.set_team_value(team, team.score, win_score)
827            if team.score >= win_score:
828                if not have_scoring_team:
829                    self._scoring_team = team
830                    if team is self._bot_team:
831                        self.end_game()
832                    else:
833                        bs.setmusic(bs.MusicType.VICTORY)
834
835                        # Completion achievements.
836                        assert self._bot_team is not None
837                        if self._preset in ['rookie', 'rookie_easy']:
838                            self._award_achievement(
839                                'Rookie Football Victory', sound=False
840                            )
841                            if self._bot_team.score == 0:
842                                self._award_achievement(
843                                    'Rookie Football Shutout', sound=False
844                                )
845                        elif self._preset in ['pro', 'pro_easy']:
846                            self._award_achievement(
847                                'Pro Football Victory', sound=False
848                            )
849                            if self._bot_team.score == 0:
850                                self._award_achievement(
851                                    'Pro Football Shutout', sound=False
852                                )
853                        elif self._preset in ['uber', 'uber_easy']:
854                            self._award_achievement(
855                                'Uber Football Victory', sound=False
856                            )
857                            if self._bot_team.score == 0:
858                                self._award_achievement(
859                                    'Uber Football Shutout', sound=False
860                                )
861                            if (
862                                not self._player_has_dropped_bomb
863                                and not self._player_has_punched
864                            ):
865                                self._award_achievement(
866                                    'Got the Moves', sound=False
867                                )
868                        self._bots.stop_moving()
869                        self.show_zoom_message(
870                            bs.Lstr(resource='victoryText'),
871                            scale=1.0,
872                            duration=4.0,
873                        )
874                        self.celebrate(10.0)
875                        assert self._starttime_ms is not None
876                        self._final_time_ms = int(
877                            int(bs.time() * 1000.0) - self._starttime_ms
878                        )
879                        self._time_text_timer = None
880                        assert (
881                            self._time_text_input is not None
882                            and self._time_text_input.node
883                        )
884                        self._time_text_input.node.timemax = self._final_time_ms
885
886                        self.do_end('victory')

update scoreboard and check for winners

def do_end(self, outcome: str) -> None:
888    def do_end(self, outcome: str) -> None:
889        """End the game with the specified outcome."""
890        if outcome == 'defeat':
891            self.fade_to_red()
892        assert self._final_time_ms is not None
893        scoreval = (
894            None if outcome == 'defeat' else int(self._final_time_ms // 10)
895        )
896        self.end(
897            delay=3.0,
898            results={
899                'outcome': outcome,
900                'score': scoreval,
901                'score_order': 'decreasing',
902                'playerinfos': self.initialplayerinfos,
903            },
904        )

End the game with the specified outcome.

@override
def handlemessage(self, msg: Any) -> Any:
906    @override
907    def handlemessage(self, msg: Any) -> Any:
908        """handle high-level game messages"""
909        if isinstance(msg, bs.PlayerDiedMessage):
910            # Augment standard behavior.
911            super().handlemessage(msg)
912
913            # Respawn them shortly.
914            player = msg.getplayer(Player)
915            assert self.initialplayerinfos is not None
916            respawn_time = 2.0 + len(self.initialplayerinfos) * 1.0
917            player.respawn_timer = bs.Timer(
918                respawn_time, bs.Call(self.spawn_player_if_exists, player)
919            )
920            player.respawn_icon = RespawnIcon(player, respawn_time)
921
922        elif isinstance(msg, SpazBotDiedMessage):
923            # Every time a bad guy dies, spawn a new one.
924            bs.timer(3.0, bs.Call(self._spawn_bot, (type(msg.spazbot))))
925
926        elif isinstance(msg, SpazBotPunchedMessage):
927            if self._preset in ['rookie', 'rookie_easy']:
928                if msg.damage >= 500:
929                    self._award_achievement('Super Punch')
930            elif self._preset in ['pro', 'pro_easy']:
931                if msg.damage >= 1000:
932                    self._award_achievement('Super Mega Punch')
933
934        # Respawn dead flags.
935        elif isinstance(msg, FlagDiedMessage):
936            assert isinstance(msg.flag, FootballFlag)
937            if msg.self_kill:
938                self._spawn_flag()
939            else:
940                msg.flag.respawn_timer = bs.Timer(3.0, self._spawn_flag)
941            self._flag_respawn_light = bs.NodeActor(
942                bs.newnode(
943                    'light',
944                    attrs={
945                        'position': self._flag_spawn_pos,
946                        'height_attenuated': False,
947                        'radius': 0.15,
948                        'color': (1.0, 1.0, 0.3),
949                    },
950                )
951            )
952            assert self._flag_respawn_light.node
953            bs.animate(
954                self._flag_respawn_light.node,
955                'intensity',
956                {0: 0, 0.25: 0.15, 0.5: 0},
957                loop=(not msg.self_kill),
958            )
959            bs.timer(3.0, self._flag_respawn_light.node.delete)
960        else:
961            return super().handlemessage(msg)
962        return None

handle high-level game messages

@override
def spawn_player( self, player: Player) -> bascenev1.Actor:
972    @override
973    def spawn_player(self, player: Player) -> bs.Actor:
974        spaz = self.spawn_player_spaz(
975            player, position=self.map.get_start_position(player.team.id)
976        )
977        if self._preset in ['rookie_easy', 'pro_easy', 'uber_easy']:
978            spaz.impact_scale = 0.25
979        spaz.add_dropped_bomb_callback(self._handle_player_dropped_bomb)
980        spaz.punch_callback = self._handle_player_punched
981        return spaz

Spawn something for the provided bascenev1.Player.

The default implementation simply calls spawn_player_spaz().