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

Co-op variant of football.

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

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

@override
def get_instance_description(self) -> Union[str, Sequence]:
396    @override
397    def get_instance_description(self) -> str | Sequence:
398        touchdowns = self._score_to_win / 7
399        touchdowns = math.ceil(touchdowns)
400        if touchdowns > 1:
401            return 'Score ${ARG1} touchdowns.', touchdowns
402        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]:
404    @override
405    def get_instance_description_short(self) -> str | Sequence:
406        touchdowns = self._score_to_win / 7
407        touchdowns = math.ceil(touchdowns)
408        if touchdowns > 1:
409            return 'score ${ARG1} touchdowns', touchdowns
410        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:
460    @override
461    def on_transition_in(self) -> None:
462        super().on_transition_in()
463        self._scoreboard = Scoreboard()
464        self._flag_spawn_pos = self.map.get_flag_position(None)
465        self._spawn_flag()
466
467        # Set up the two score regions.
468        defs = self.map.defs
469        self._score_regions.append(
470            bs.NodeActor(
471                bs.newnode(
472                    'region',
473                    attrs={
474                        'position': defs.boxes['goal1'][0:3],
475                        'scale': defs.boxes['goal1'][6:9],
476                        'type': 'box',
477                        'materials': [self._score_region_material],
478                    },
479                )
480            )
481        )
482        self._score_regions.append(
483            bs.NodeActor(
484                bs.newnode(
485                    'region',
486                    attrs={
487                        'position': defs.boxes['goal2'][0:3],
488                        'scale': defs.boxes['goal2'][6:9],
489                        'type': 'box',
490                        'materials': [self._score_region_material],
491                    },
492                )
493            )
494        )
495        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:
497    @override
498    def on_begin(self) -> None:
499        # FIXME: Split this up a bit.
500        # pylint: disable=too-many-statements
501        from bascenev1lib.actor import controlsguide
502
503        super().on_begin()
504
505        # Show controls help in demo or arcade mode.
506        if bs.app.env.demo or bs.app.env.arcade:
507            controlsguide.ControlsGuide(
508                delay=3.0, lifespan=10.0, bright=True
509            ).autoretain()
510        assert self.initialplayerinfos is not None
511        abot: type[SpazBot]
512        bbot: type[SpazBot]
513        cbot: type[SpazBot]
514        if self._preset in ['rookie', 'rookie_easy']:
515            self._exclude_powerups = ['curse']
516            self._have_tnt = False
517            abot = (
518                BrawlerBotLite if self._preset == 'rookie_easy' else BrawlerBot
519            )
520            self._bot_types_initial = [abot] * len(self.initialplayerinfos)
521            bbot = BomberBotLite if self._preset == 'rookie_easy' else BomberBot
522            self._bot_types_7 = [bbot] * (
523                1 if len(self.initialplayerinfos) < 3 else 2
524            )
525            cbot = BomberBot if self._preset == 'rookie_easy' else TriggerBot
526            self._bot_types_14 = [cbot] * (
527                1 if len(self.initialplayerinfos) < 3 else 2
528            )
529        elif self._preset == 'tournament':
530            self._exclude_powerups = []
531            self._have_tnt = True
532            self._bot_types_initial = [BrawlerBot] * (
533                1 if len(self.initialplayerinfos) < 2 else 2
534            )
535            self._bot_types_7 = [TriggerBot] * (
536                1 if len(self.initialplayerinfos) < 3 else 2
537            )
538            self._bot_types_14 = [ChargerBot] * (
539                1 if len(self.initialplayerinfos) < 4 else 2
540            )
541        elif self._preset in ['pro', 'pro_easy', 'tournament_pro']:
542            self._exclude_powerups = ['curse']
543            self._have_tnt = True
544            self._bot_types_initial = [ChargerBot] * len(
545                self.initialplayerinfos
546            )
547            abot = BrawlerBot if self._preset == 'pro' else BrawlerBotLite
548            typed_bot_list: list[type[SpazBot]] = []
549            self._bot_types_7 = (
550                typed_bot_list
551                + [abot]
552                + [BomberBot] * (1 if len(self.initialplayerinfos) < 3 else 2)
553            )
554            bbot = TriggerBotPro if self._preset == 'pro' else TriggerBot
555            self._bot_types_14 = [bbot] * (
556                1 if len(self.initialplayerinfos) < 3 else 2
557            )
558        elif self._preset in ['uber', 'uber_easy']:
559            self._exclude_powerups = []
560            self._have_tnt = True
561            abot = BrawlerBotPro if self._preset == 'uber' else BrawlerBot
562            bbot = TriggerBotPro if self._preset == 'uber' else TriggerBot
563            typed_bot_list_2: list[type[SpazBot]] = []
564            self._bot_types_initial = (
565                typed_bot_list_2
566                + [StickyBot]
567                + [abot] * len(self.initialplayerinfos)
568            )
569            self._bot_types_7 = [bbot] * (
570                1 if len(self.initialplayerinfos) < 3 else 2
571            )
572            self._bot_types_14 = [ExplodeyBot] * (
573                1 if len(self.initialplayerinfos) < 3 else 2
574            )
575        else:
576            raise RuntimeError()
577
578        self.setup_low_life_warning_sound()
579
580        self._drop_powerups(standard_points=True)
581        bs.timer(4.0, self._start_powerup_drops)
582
583        # Make a bogus team for our bots.
584        bad_team_name = self.get_team_display_string('Bad Guys')
585        self._bot_team = Team()
586        self._bot_team.manual_init(
587            team_id=1, name=bad_team_name, color=(0.5, 0.4, 0.4)
588        )
589
590        for team in [self.teams[0], self._bot_team]:
591            team.score = 0
592
593        self.update_scores()
594
595        # Time display.
596        starttime_ms = int(bs.time() * 1000.0)
597        assert isinstance(starttime_ms, int)
598        self._starttime_ms = starttime_ms
599        self._time_text = bs.NodeActor(
600            bs.newnode(
601                'text',
602                attrs={
603                    'v_attach': 'top',
604                    'h_attach': 'center',
605                    'h_align': 'center',
606                    'color': (1, 1, 0.5, 1),
607                    'flatness': 0.5,
608                    'shadow': 0.5,
609                    'position': (0, -50),
610                    'scale': 1.3,
611                    'text': '',
612                },
613            )
614        )
615        self._time_text_input = bs.NodeActor(
616            bs.newnode('timedisplay', attrs={'showsubseconds': True})
617        )
618        self.globalsnode.connectattr(
619            'time', self._time_text_input.node, 'time2'
620        )
621        assert self._time_text_input.node
622        assert self._time_text.node
623        self._time_text_input.node.connectattr(
624            'output', self._time_text.node, 'text'
625        )
626
627        # Our TNT spawner (if applicable).
628        if self._have_tnt:
629            self._tntspawner = TNTSpawner(position=(0, 1, -1))
630
631        self._bots = SpazBotSet()
632        self._bot_spawn_timer = bs.Timer(1.0, self._update_bots, repeat=True)
633
634        for bottype in self._bot_types_initial:
635            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:
808    @override
809    def end_game(self) -> None:
810        bs.setmusic(None)
811        self._bots.final_celebrate()
812        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:
814    def update_scores(self) -> None:
815        """update scoreboard and check for winners"""
816        # FIXME: tidy this up
817        # pylint: disable=too-many-nested-blocks
818        have_scoring_team = False
819        win_score = self._score_to_win
820        for team in [self.teams[0], self._bot_team]:
821            assert team is not None
822            assert self._scoreboard is not None
823            self._scoreboard.set_team_value(team, team.score, win_score)
824            if team.score >= win_score:
825                if not have_scoring_team:
826                    self._scoring_team = team
827                    if team is self._bot_team:
828                        self.end_game()
829                    else:
830                        bs.setmusic(bs.MusicType.VICTORY)
831
832                        # Completion achievements.
833                        assert self._bot_team is not None
834                        if self._preset in ['rookie', 'rookie_easy']:
835                            self._award_achievement(
836                                'Rookie Football Victory', sound=False
837                            )
838                            if self._bot_team.score == 0:
839                                self._award_achievement(
840                                    'Rookie Football Shutout', sound=False
841                                )
842                        elif self._preset in ['pro', 'pro_easy']:
843                            self._award_achievement(
844                                'Pro Football Victory', sound=False
845                            )
846                            if self._bot_team.score == 0:
847                                self._award_achievement(
848                                    'Pro Football Shutout', sound=False
849                                )
850                        elif self._preset in ['uber', 'uber_easy']:
851                            self._award_achievement(
852                                'Uber Football Victory', sound=False
853                            )
854                            if self._bot_team.score == 0:
855                                self._award_achievement(
856                                    'Uber Football Shutout', sound=False
857                                )
858                            if (
859                                not self._player_has_dropped_bomb
860                                and not self._player_has_punched
861                            ):
862                                self._award_achievement(
863                                    'Got the Moves', sound=False
864                                )
865                        self._bots.stop_moving()
866                        self.show_zoom_message(
867                            bs.Lstr(resource='victoryText'),
868                            scale=1.0,
869                            duration=4.0,
870                        )
871                        self.celebrate(10.0)
872                        assert self._starttime_ms is not None
873                        self._final_time_ms = int(
874                            int(bs.time() * 1000.0) - self._starttime_ms
875                        )
876                        self._time_text_timer = None
877                        assert (
878                            self._time_text_input is not None
879                            and self._time_text_input.node
880                        )
881                        self._time_text_input.node.timemax = self._final_time_ms
882
883                        # FIXME: Does this still need to be deferred?
884                        bs.pushcall(bs.Call(self.do_end, 'victory'))

update scoreboard and check for winners

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

End the game with the specified outcome.

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

handle high-level game messages

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

Spawn something for the provided bascenev1.Player.

The default implementation simply calls spawn_player_spaz().