# Released under the MIT License. See LICENSE for details.
#
"""Implements football games (both co-op and teams varieties)."""

# pylint: disable=too-many-lines

# ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system)

import math
import random
import logging
from typing import TYPE_CHECKING, override

import bascenev1 as bs
from bascenev1 import stdassets

from bascenev1lib.actor.bomb import TNTSpawner
from bascenev1lib.actor.playerspaz import PlayerSpaz
from bascenev1lib.actor.scoreboard import Scoreboard
from bascenev1lib.actor.respawnicon import RespawnIcon
from bascenev1lib.actor.powerupbox import PowerupBoxFactory, PowerupBox
from bascenev1lib.actor.flag import (
    FlagFactory,
    Flag,
    FlagPickedUpMessage,
    FlagDroppedMessage,
    FlagDiedMessage,
)
from bascenev1lib.actor.spazbot import (
    SpazBotDiedMessage,
    SpazBotPunchedMessage,
    SpazBotSet,
    BrawlerBotLite,
    BrawlerBot,
    BomberBotLite,
    BomberBot,
    TriggerBot,
    ChargerBot,
    TriggerBotPro,
    BrawlerBotPro,
    StickyBot,
    ExplodeyBot,
)

if TYPE_CHECKING:
    from typing import Any, Sequence

    from bascenev1lib.actor.spaz import Spaz
    from bascenev1lib.actor.spazbot import SpazBot


class FootballFlag(Flag):
    """Custom flag class for football games."""

    def __init__(self, position: Sequence[float]):
        super().__init__(
            position=position, dropped_timeout=20, color=(1.0, 1.0, 0.3)
        )
        assert self.node
        self.last_holding_player: bs.Player | None = None
        self.node.is_area_of_interest = True
        self.respawn_timer: bs.Timer | None = None
        self.scored = False
        self.held_count = 0
        self.light = bs.newnode(
            'light',
            owner=self.node,
            attrs={
                'intensity': 0.25,
                'height_attenuated': False,
                'radius': 0.2,
                'color': (0.9, 0.7, 0.0),
            },
        )
        self.node.connectattr('position', self.light, 'position')


class Player(bs.Player['Team']):
    """Our player type for this game."""

    def __init__(self) -> None:
        self.respawn_timer: bs.Timer | None = None
        self.respawn_icon: RespawnIcon | None = None


class Team(bs.Team[Player]):
    """Our team type for this game."""

    def __init__(self) -> None:
        self.score = 0


# ba_meta export bascenev1.GameActivity
class FootballTeamGame(bs.TeamGameActivity[Player, Team]):
    """Football game for teams mode."""

    name = 'Football'
    description = 'Get the flag to the enemy end zone.'
    available_settings = [
        bs.IntSetting(
            'Score to Win',
            min_value=7,
            default=21,
            increment=7,
        ),
        bs.IntChoiceSetting(
            'Time Limit',
            choices=[
                ('None', 0),
                ('1 Minute', 60),
                ('2 Minutes', 120),
                ('5 Minutes', 300),
                ('10 Minutes', 600),
                ('20 Minutes', 1200),
            ],
            default=0,
        ),
        bs.FloatChoiceSetting(
            'Respawn Times',
            choices=[
                ('Shorter', 0.25),
                ('Short', 0.5),
                ('Normal', 1.0),
                ('Long', 2.0),
                ('Longer', 4.0),
            ],
            default=1.0,
        ),
        bs.BoolSetting('Epic Mode', default=False),
    ]

    @override
    @classmethod
    def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool:
        # We only support two-team play.
        return issubclass(sessiontype, bs.DualTeamSession)

    @override
    @classmethod
    def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]:
        # (Pylint Bug?) pylint: disable=missing-function-docstring

        assert bs.app.classic is not None
        return bs.app.classic.getmaps('football')

    def __init__(self, settings: dict):
        super().__init__(settings)
        self._scoreboard: Scoreboard | None = Scoreboard()

        # Load some media we need.
        self._cheer_sound = stdassets.audio.cheer
        self._chant_sound = stdassets.audio.crowd_chant
        self._score_sound = stdassets.audio.score
        self._swipsound = stdassets.audio.swip
        self._whistle_sound = stdassets.audio.ref_whistle
        self._score_region_material = bs.Material()
        self._score_region_material.add_actions(
            conditions=('they_have_material', FlagFactory.get().flagmaterial),
            actions=(
                ('modify_part_collision', 'collide', True),
                ('modify_part_collision', 'physical', False),
                ('call', 'at_connect', self._handle_score),
            ),
        )
        self._flag_spawn_pos: Sequence[float] | None = None
        self._score_regions: list[bs.NodeActor] = []
        self._flag: FootballFlag | None = None
        self._flag_respawn_timer: bs.Timer | None = None
        self._flag_respawn_light: bs.NodeActor | None = None
        self._score_to_win = int(settings['Score to Win'])
        self._time_limit = float(settings['Time Limit'])
        self._epic_mode = bool(settings['Epic Mode'])
        self.slow_motion = self._epic_mode
        self.default_music = (
            bs.MusicType.EPIC if self._epic_mode else bs.MusicType.FOOTBALL
        )

    @override
    def get_instance_description(self) -> str | Sequence:
        # (Pylint Bug?) pylint: disable=missing-function-docstring

        touchdowns = self._score_to_win / 7

        # NOTE: if use just touchdowns = self._score_to_win // 7
        # and we will need to score, for example, 27 points,
        # we will be required to score 3 (not 4) goals ..
        touchdowns = math.ceil(touchdowns)
        if touchdowns > 1:
            return 'Score ${ARG1} touchdowns.', touchdowns
        return 'Score a touchdown.'

    @override
    def get_instance_description_short(self) -> str | Sequence:
        # (Pylint Bug?) pylint: disable=missing-function-docstring

        touchdowns = self._score_to_win / 7
        touchdowns = math.ceil(touchdowns)
        if touchdowns > 1:
            return 'score ${ARG1} touchdowns', touchdowns
        return 'score a touchdown'

    @override
    def on_begin(self) -> None:
        super().on_begin()
        self.setup_standard_time_limit(self._time_limit)
        self.setup_standard_powerup_drops()
        self._flag_spawn_pos = self.map.get_flag_position(None)
        self._spawn_flag()
        defs = self.map.defs
        self._score_regions.append(
            bs.NodeActor(
                bs.newnode(
                    'region',
                    attrs={
                        'position': defs.boxes['goal1'][0:3],
                        'scale': defs.boxes['goal1'][6:9],
                        'type': 'box',
                        'materials': (self._score_region_material,),
                    },
                )
            )
        )
        self._score_regions.append(
            bs.NodeActor(
                bs.newnode(
                    'region',
                    attrs={
                        'position': defs.boxes['goal2'][0:3],
                        'scale': defs.boxes['goal2'][6:9],
                        'type': 'box',
                        'materials': (self._score_region_material,),
                    },
                )
            )
        )
        self._update_scoreboard()
        self._chant_sound.play()

    @override
    def on_team_join(self, team: Team) -> None:
        # (Pylint Bug?) pylint: disable=missing-function-docstring

        self._update_scoreboard()

    def _kill_flag(self) -> None:
        self._flag = None

    def _handle_score(self) -> None:
        """A point has been scored."""

        # Our flag might stick around for a second or two
        # make sure it doesn't score again.
        assert self._flag is not None
        if self._flag.scored:
            return
        region = bs.getcollision().sourcenode
        i = None
        for i, score_region in enumerate(self._score_regions):
            if region == score_region.node:
                break
        for team in self.teams:
            if team.id == i:
                team.score += 7

                # Tell all players to celebrate.
                for player in team.players:
                    if player.actor:
                        player.actor.handlemessage(bs.CelebrateMessage(2.0))

                # If someone on this team was last to touch it,
                # give them points.
                assert self._flag is not None
                if (
                    self._flag.last_holding_player
                    and team == self._flag.last_holding_player.team
                ):
                    self.stats.player_scored(
                        self._flag.last_holding_player, 50, big_message=True
                    )
                # End the game if we won.
                if team.score >= self._score_to_win:
                    self.end_game()
        self._score_sound.play()
        self._cheer_sound.play()
        assert self._flag
        self._flag.scored = True

        # Kill the flag (it'll respawn shortly).
        bs.timer(1.0, self._kill_flag)
        light = bs.newnode(
            'light',
            attrs={
                'position': bs.getcollision().position,
                'height_attenuated': False,
                'color': (1, 0, 0),
            },
        )
        bs.animate(light, 'intensity', {0.0: 0, 0.5: 1, 1.0: 0}, loop=True)
        bs.timer(1.0, light.delete)
        bs.cameraflash(duration=10.0)
        self._update_scoreboard()

    @override
    def end_game(self) -> None:
        # (Pylint Bug?) pylint: disable=missing-function-docstring

        results = bs.GameResults()
        for team in self.teams:
            results.set_team_score(team, team.score)
        self.end(results=results, announce_delay=0.8)

    def _update_scoreboard(self) -> None:
        assert self._scoreboard is not None
        for team in self.teams:
            self._scoreboard.set_team_value(
                team, team.score, self._score_to_win
            )

    @override
    def handlemessage(self, msg: Any) -> Any:
        # (Pylint Bug?) pylint: disable=missing-function-docstring

        if isinstance(msg, FlagPickedUpMessage):
            assert isinstance(msg.flag, FootballFlag)
            try:
                msg.flag.last_holding_player = msg.node.getdelegate(
                    PlayerSpaz, True
                ).getplayer(Player, True)
            except bs.NotFoundError:
                pass
            msg.flag.held_count += 1

        elif isinstance(msg, FlagDroppedMessage):
            assert isinstance(msg.flag, FootballFlag)
            msg.flag.held_count -= 1

        # Respawn dead players if they're still in the game.
        elif isinstance(msg, bs.PlayerDiedMessage):
            # Augment standard behavior.
            super().handlemessage(msg)
            self.respawn_player(msg.getplayer(Player))

        # Respawn dead flags.
        elif isinstance(msg, FlagDiedMessage):
            if not self.has_ended():
                if msg.self_kill:
                    self._spawn_flag()
                else:
                    self._flag_respawn_timer = bs.Timer(3.0, self._spawn_flag)
                self._flag_respawn_light = bs.NodeActor(
                    bs.newnode(
                        'light',
                        attrs={
                            'position': self._flag_spawn_pos,
                            'height_attenuated': False,
                            'radius': 0.15,
                            'color': (1.0, 1.0, 0.3),
                        },
                    )
                )
                assert self._flag_respawn_light.node
                bs.animate(
                    self._flag_respawn_light.node,
                    'intensity',
                    {0.0: 0, 0.25: 0.15, 0.5: 0},
                    loop=True,
                )
                bs.timer(3.0, self._flag_respawn_light.node.delete)

        else:
            # Augment standard behavior.
            super().handlemessage(msg)

    def _flash_flag_spawn(self) -> None:
        light = bs.newnode(
            'light',
            attrs={
                'position': self._flag_spawn_pos,
                'height_attenuated': False,
                'color': (1, 1, 0),
            },
        )
        bs.animate(light, 'intensity', {0: 0, 0.25: 0.25, 0.5: 0}, loop=True)
        bs.timer(1.0, light.delete)

    def _spawn_flag(self) -> None:
        self._swipsound.play()
        self._whistle_sound.play()
        self._flash_flag_spawn()
        assert self._flag_spawn_pos is not None
        self._flag = FootballFlag(position=self._flag_spawn_pos)


class FootballCoopGame(bs.CoopGameActivity[Player, Team]):
    """Co-op variant of football."""

    name = 'Football'
    tips = ['Use the pick-up button to grab the flag < ${PICKUP} >']
    scoreconfig = bs.ScoreConfig(
        scoretype=bs.ScoreType.MILLISECONDS, version='B'
    )

    default_music = bs.MusicType.FOOTBALL

    # FIXME: Need to update co-op games to use getscoreconfig.
    @override
    def get_score_type(self) -> str:
        return 'time'

    @override
    def get_instance_description(self) -> str | Sequence:
        # (Pylint Bug?) pylint: disable=missing-function-docstring

        touchdowns = self._score_to_win / 7
        touchdowns = math.ceil(touchdowns)
        if touchdowns > 1:
            return 'Score ${ARG1} touchdowns.', touchdowns
        return 'Score a touchdown.'

    @override
    def get_instance_description_short(self) -> str | Sequence:
        # (Pylint Bug?) pylint: disable=missing-function-docstring

        touchdowns = self._score_to_win / 7
        touchdowns = math.ceil(touchdowns)
        if touchdowns > 1:
            return 'score ${ARG1} touchdowns', touchdowns
        return 'score a touchdown'

    def __init__(self, settings: dict):
        settings['map'] = 'Football Stadium'
        super().__init__(settings)
        self._preset = settings.get('preset', 'rookie')

        # Load some media we need.
        self._cheer_sound = stdassets.audio.cheer
        self._boo_sound = stdassets.audio.boo
        self._chant_sound = stdassets.audio.crowd_chant
        self._score_sound = stdassets.audio.score
        self._swipsound = stdassets.audio.swip
        self._whistle_sound = stdassets.audio.ref_whistle
        self._score_to_win = 21
        self._score_region_material = bs.Material()
        self._score_region_material.add_actions(
            conditions=('they_have_material', FlagFactory.get().flagmaterial),
            actions=(
                ('modify_part_collision', 'collide', True),
                ('modify_part_collision', 'physical', False),
                ('call', 'at_connect', self._handle_score),
            ),
        )
        self._powerup_center = (0, 2, 0)
        self._powerup_spread = (10, 5.5)
        self._player_has_dropped_bomb = False
        self._player_has_punched = False
        self._scoreboard: Scoreboard | None = None
        self._flag_spawn_pos: Sequence[float] | None = None
        self._score_regions: list[bs.NodeActor] = []
        self._exclude_powerups: list[str] = []
        self._have_tnt = False
        self._bot_types_initial: list[type[SpazBot]] | None = None
        self._bot_types_7: list[type[SpazBot]] | None = None
        self._bot_types_14: list[type[SpazBot]] | None = None
        self._bot_team: Team | None = None
        self._starttime_ms: int | None = None
        self._time_text: bs.NodeActor | None = None
        self._time_text_input: bs.NodeActor | None = None
        self._tntspawner: TNTSpawner | None = None
        self._bots = SpazBotSet()
        self._bot_spawn_timer: bs.Timer | None = None
        self._powerup_drop_timer: bs.Timer | None = None
        self._scoring_team: Team | None = None
        self._final_time_ms: int | None = None
        self._time_text_timer: bs.Timer | None = None
        self._flag_respawn_light: bs.Actor | None = None
        self._flag: FootballFlag | None = None

    @override
    def on_transition_in(self) -> None:
        # (Pylint Bug?) pylint: disable=missing-function-docstring

        super().on_transition_in()
        self._scoreboard = Scoreboard()
        self._flag_spawn_pos = self.map.get_flag_position(None)
        self._spawn_flag()

        # Set up the two score regions.
        defs = self.map.defs
        self._score_regions.append(
            bs.NodeActor(
                bs.newnode(
                    'region',
                    attrs={
                        'position': defs.boxes['goal1'][0:3],
                        'scale': defs.boxes['goal1'][6:9],
                        'type': 'box',
                        'materials': [self._score_region_material],
                    },
                )
            )
        )
        self._score_regions.append(
            bs.NodeActor(
                bs.newnode(
                    'region',
                    attrs={
                        'position': defs.boxes['goal2'][0:3],
                        'scale': defs.boxes['goal2'][6:9],
                        'type': 'box',
                        'materials': [self._score_region_material],
                    },
                )
            )
        )
        self._chant_sound.play()

    @override
    def on_begin(self) -> None:
        # pylint: disable=too-many-statements

        from bascenev1lib.actor import controlsguide

        super().on_begin()

        variant = bs.app.env.variant
        vart = type(variant)
        arcade_or_demo = variant is vart.ARCADE or variant is vart.DEMO

        # Show controls help in demo or arcade variants.
        if arcade_or_demo:
            controlsguide.ControlsGuide(
                delay=3.0, lifespan=10.0, bright=True
            ).autoretain()
        assert self.initialplayerinfos is not None
        abot: type[SpazBot]
        bbot: type[SpazBot]
        cbot: type[SpazBot]
        if self._preset in ['rookie', 'rookie_easy']:
            self._exclude_powerups = ['curse']
            self._have_tnt = False
            abot = (
                BrawlerBotLite if self._preset == 'rookie_easy' else BrawlerBot
            )
            self._bot_types_initial = [abot] * len(self.initialplayerinfos)
            bbot = BomberBotLite if self._preset == 'rookie_easy' else BomberBot
            self._bot_types_7 = [bbot] * (
                1 if len(self.initialplayerinfos) < 3 else 2
            )
            cbot = BomberBot if self._preset == 'rookie_easy' else TriggerBot
            self._bot_types_14 = [cbot] * (
                1 if len(self.initialplayerinfos) < 3 else 2
            )
        elif self._preset == 'tournament':
            self._exclude_powerups = []
            self._have_tnt = True
            self._bot_types_initial = [BrawlerBot] * (
                1 if len(self.initialplayerinfos) < 2 else 2
            )
            self._bot_types_7 = [TriggerBot] * (
                1 if len(self.initialplayerinfos) < 3 else 2
            )
            self._bot_types_14 = [ChargerBot] * (
                1 if len(self.initialplayerinfos) < 4 else 2
            )
        elif self._preset in ['pro', 'pro_easy', 'tournament_pro']:
            self._exclude_powerups = ['curse']
            self._have_tnt = True
            self._bot_types_initial = [ChargerBot] * len(
                self.initialplayerinfos
            )
            abot = BrawlerBot if self._preset == 'pro' else BrawlerBotLite
            typed_bot_list: list[type[SpazBot]] = []
            bomberbots: list[type[SpazBot]] = [BomberBot] * (
                1 if len(self.initialplayerinfos) < 3 else 2
            )
            self._bot_types_7 = typed_bot_list + [abot] + bomberbots
            bbot = TriggerBotPro if self._preset == 'pro' else TriggerBot
            self._bot_types_14 = [bbot] * (
                1 if len(self.initialplayerinfos) < 3 else 2
            )
        elif self._preset in ['uber', 'uber_easy']:
            self._exclude_powerups = []
            self._have_tnt = True
            abot = BrawlerBotPro if self._preset == 'uber' else BrawlerBot
            bbot = TriggerBotPro if self._preset == 'uber' else TriggerBot
            typed_bot_list_2: list[type[SpazBot]] = []
            stickybots: list[type[SpazBot]] = [StickyBot]
            abots: list[type[SpazBot]] = [abot] * len(self.initialplayerinfos)
            self._bot_types_initial = typed_bot_list_2 + stickybots + abots
            self._bot_types_7 = [bbot] * (
                1 if len(self.initialplayerinfos) < 3 else 2
            )
            self._bot_types_14 = [ExplodeyBot] * (
                1 if len(self.initialplayerinfos) < 3 else 2
            )
        else:
            raise RuntimeError()

        self.setup_low_life_warning_sound()

        self._drop_powerups(standard_points=True)
        bs.timer(4.0, self._start_powerup_drops)

        # Make a bogus team for our bots.
        bad_team_name = self.get_team_display_string('Bad Guys')
        self._bot_team = Team()
        self._bot_team.manual_init(
            team_id=1, name=bad_team_name, color=(0.5, 0.4, 0.4)
        )

        for team in [self.teams[0], self._bot_team]:
            team.score = 0

        self.update_scores()

        # Time display.
        starttime_ms = int(bs.time() * 1000.0)
        assert isinstance(starttime_ms, int)
        self._starttime_ms = starttime_ms
        self._time_text = bs.NodeActor(
            bs.newnode(
                'text',
                attrs={
                    'v_attach': 'top',
                    'h_attach': 'center',
                    'h_align': 'center',
                    'color': (1, 1, 0.5, 1),
                    'flatness': 0.5,
                    'shadow': 0.5,
                    'position': (0, -50),
                    'scale': 1.3,
                    'text': '',
                },
            )
        )
        self._time_text_input = bs.NodeActor(
            bs.newnode('timedisplay', attrs={'showsubseconds': True})
        )
        self.globalsnode.connectattr(
            'time', self._time_text_input.node, 'time2'
        )
        assert self._time_text_input.node
        assert self._time_text.node
        self._time_text_input.node.connectattr(
            'output', self._time_text.node, 'text'
        )

        # Our TNT spawner (if applicable).
        if self._have_tnt:
            self._tntspawner = TNTSpawner(position=(0, 1, -1))

        self._bots = SpazBotSet()
        self._bot_spawn_timer = bs.Timer(1.0, self._update_bots, repeat=True)

        for bottype in self._bot_types_initial:
            self._spawn_bot(bottype)

    def _on_bot_spawn(self, spaz: SpazBot) -> None:
        # We want to move to the left by default.
        spaz.target_point_default = bs.Vec3(0, 0, 0)

    def _spawn_bot(
        self, spaz_type: type[SpazBot], immediate: bool = False
    ) -> None:
        assert self._bot_team is not None
        pos = self.map.get_start_position(self._bot_team.id)
        self._bots.spawn_bot(
            spaz_type,
            pos=pos,
            spawn_time=0.001 if immediate else 3.0,
            on_spawn_call=self._on_bot_spawn,
        )

    def _update_bots(self) -> None:
        bots = self._bots.get_living_bots()
        for bot in bots:
            bot.target_flag = None

        # If we've got a flag and no player are holding it, find the closest
        # bot to it, and make them the designated flag-bearer.
        assert self._flag is not None
        if self._flag.node:
            for player in self.players:
                if player.actor:
                    assert isinstance(player.actor, PlayerSpaz)
                    if (
                        player.actor.is_alive()
                        and player.actor.node.hold_node == self._flag.node
                    ):
                        return

            flagpos = bs.Vec3(self._flag.node.position)
            closest_bot: SpazBot | None = None
            closest_dist = 0.0  # Always gets assigned first time through.
            for bot in bots:
                # If a bot is picked up, he should forget about the flag.
                if bot.held_count > 0:
                    continue
                assert bot.node
                botpos = bs.Vec3(bot.node.position)
                botdist = (botpos - flagpos).length()
                if closest_bot is None or botdist < closest_dist:
                    closest_bot = bot
                    closest_dist = botdist
            if closest_bot is not None:
                closest_bot.target_flag = self._flag

    def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None:
        if poweruptype is None:
            poweruptype = PowerupBoxFactory.get().get_random_powerup_type(
                excludetypes=self._exclude_powerups
            )
        PowerupBox(
            position=self.map.powerup_spawn_points[index],
            poweruptype=poweruptype,
        ).autoretain()

    def _start_powerup_drops(self) -> None:
        self._powerup_drop_timer = bs.Timer(
            3.0, self._drop_powerups, repeat=True
        )

    def _drop_powerups(
        self, standard_points: bool = False, poweruptype: str | None = None
    ) -> None:
        """Generic powerup drop."""
        if standard_points:
            spawnpoints = self.map.powerup_spawn_points
            for i, _point in enumerate(spawnpoints):
                bs.timer(
                    1.0 + i * 0.5,
                    bs.CallStrict(self._drop_powerup, i, poweruptype),
                )
        else:
            point = (
                self._powerup_center[0]
                + random.uniform(
                    -1.0 * self._powerup_spread[0],
                    1.0 * self._powerup_spread[0],
                ),
                self._powerup_center[1],
                self._powerup_center[2]
                + random.uniform(
                    -self._powerup_spread[1], self._powerup_spread[1]
                ),
            )

            # Drop one random one somewhere.
            PowerupBox(
                position=point,
                poweruptype=PowerupBoxFactory.get().get_random_powerup_type(
                    excludetypes=self._exclude_powerups
                ),
            ).autoretain()

    def _kill_flag(self) -> None:
        try:
            assert self._flag is not None
            self._flag.handlemessage(bs.DieMessage())
        except Exception:
            logging.exception('Error in _kill_flag.')

    def _handle_score(self) -> None:
        """a point has been scored"""
        # FIXME tidy this up
        # pylint: disable=too-many-branches

        # Our flag might stick around for a second or two;
        # we don't want it to be able to score again.
        assert self._flag is not None
        if self._flag.scored:
            return

        # See which score region it was.
        region = bs.getcollision().sourcenode
        i = None
        for i, score_region in enumerate(self._score_regions):
            if region == score_region.node:
                break

        for team in [self.teams[0], self._bot_team]:
            assert team is not None
            if team.id == i:
                team.score += 7

                # Tell all players (or bots) to celebrate.
                if i == 0:
                    for player in team.players:
                        if player.actor:
                            player.actor.handlemessage(bs.CelebrateMessage(2.0))
                else:
                    self._bots.celebrate(2.0)

        # If the good guys scored, add more enemies.
        if i == 0:
            if self.teams[0].score == 7:
                assert self._bot_types_7 is not None
                for bottype in self._bot_types_7:
                    self._spawn_bot(bottype)
            elif self.teams[0].score == 14:
                assert self._bot_types_14 is not None
                for bottype in self._bot_types_14:
                    self._spawn_bot(bottype)

        self._score_sound.play()
        if i == 0:
            self._cheer_sound.play()
        else:
            self._boo_sound.play()

        # Kill the flag (it'll respawn shortly).
        self._flag.scored = True

        bs.timer(0.2, self._kill_flag)

        self.update_scores()
        light = bs.newnode(
            'light',
            attrs={
                'position': bs.getcollision().position,
                'height_attenuated': False,
                'color': (1, 0, 0),
            },
        )
        bs.animate(light, 'intensity', {0: 0, 0.5: 1, 1.0: 0}, loop=True)
        bs.timer(1.0, light.delete)
        if i == 0:
            bs.cameraflash(duration=10.0)

    @override
    def end_game(self) -> None:
        # (Pylint Bug?) pylint: disable=missing-function-docstring

        bs.setmusic(None)
        self._bots.final_celebrate()
        bs.timer(0.001, bs.CallStrict(self.do_end, 'defeat'))

    def update_scores(self) -> None:
        """update scoreboard and check for winners"""
        # FIXME: tidy this up
        # pylint: disable=too-many-nested-blocks
        have_scoring_team = False
        win_score = self._score_to_win
        for team in [self.teams[0], self._bot_team]:
            assert team is not None
            assert self._scoreboard is not None
            self._scoreboard.set_team_value(team, team.score, win_score)
            if team.score >= win_score:
                if not have_scoring_team:
                    self._scoring_team = team
                    if team is self._bot_team:
                        self.end_game()
                    else:
                        bs.setmusic(bs.MusicType.VICTORY)

                        # Completion achievements.
                        assert self._bot_team is not None
                        if self._preset in ['rookie', 'rookie_easy']:
                            self._award_achievement(
                                'Rookie Football Victory', sound=False
                            )
                            if self._bot_team.score == 0:
                                self._award_achievement(
                                    'Rookie Football Shutout', sound=False
                                )
                        elif self._preset in ['pro', 'pro_easy']:
                            self._award_achievement(
                                'Pro Football Victory', sound=False
                            )
                            if self._bot_team.score == 0:
                                self._award_achievement(
                                    'Pro Football Shutout', sound=False
                                )
                        elif self._preset in ['uber', 'uber_easy']:
                            self._award_achievement(
                                'Uber Football Victory', sound=False
                            )
                            if self._bot_team.score == 0:
                                self._award_achievement(
                                    'Uber Football Shutout', sound=False
                                )
                            if (
                                not self._player_has_dropped_bomb
                                and not self._player_has_punched
                            ):
                                self._award_achievement(
                                    'Got the Moves', sound=False
                                )
                        self._bots.stop_moving()
                        self.show_zoom_message(
                            bs.Lstr(resource='victoryText'),
                            scale=1.0,
                            duration=4.0,
                        )
                        self.celebrate(10.0)
                        assert self._starttime_ms is not None
                        self._final_time_ms = int(
                            int(bs.time() * 1000.0) - self._starttime_ms
                        )
                        self._time_text_timer = None
                        assert (
                            self._time_text_input is not None
                            and self._time_text_input.node
                        )
                        self._time_text_input.node.timemax = self._final_time_ms

                        self.do_end('victory')

    def do_end(self, outcome: str) -> None:
        """End the game with the specified outcome."""
        if outcome == 'defeat':
            self.fade_to_red()
        assert self._final_time_ms is not None
        scoreval = (
            None if outcome == 'defeat' else int(self._final_time_ms // 10)
        )
        self.end(
            delay=3.0,
            results={
                'outcome': outcome,
                'score': scoreval,
                'score_order': 'decreasing',
                'playerinfos': self.initialplayerinfos,
            },
        )

    @override
    def handlemessage(self, msg: Any) -> Any:
        """handle high-level game messages"""
        if isinstance(msg, bs.PlayerDiedMessage):
            # Augment standard behavior.
            super().handlemessage(msg)

            # Respawn them shortly.
            player = msg.getplayer(Player)
            assert self.initialplayerinfos is not None
            respawn_time = 2.0 + len(self.initialplayerinfos) * 1.0
            player.respawn_timer = bs.Timer(
                respawn_time, bs.CallStrict(self.spawn_player_if_exists, player)
            )
            player.respawn_icon = RespawnIcon(player, respawn_time)

        elif isinstance(msg, SpazBotDiedMessage):
            # Every time a bad guy dies, spawn a new one.
            bs.timer(3.0, bs.CallStrict(self._spawn_bot, (type(msg.spazbot))))

        elif isinstance(msg, SpazBotPunchedMessage):
            if self._preset in ['rookie', 'rookie_easy']:
                if msg.damage >= 500:
                    self._award_achievement('Super Punch')
            elif self._preset in ['pro', 'pro_easy']:
                if msg.damage >= 1000:
                    self._award_achievement('Super Mega Punch')

        # Respawn dead flags.
        elif isinstance(msg, FlagDiedMessage):
            assert isinstance(msg.flag, FootballFlag)
            if msg.self_kill:
                self._spawn_flag()
            else:
                msg.flag.respawn_timer = bs.Timer(3.0, self._spawn_flag)
            self._flag_respawn_light = bs.NodeActor(
                bs.newnode(
                    'light',
                    attrs={
                        'position': self._flag_spawn_pos,
                        'height_attenuated': False,
                        'radius': 0.15,
                        'color': (1.0, 1.0, 0.3),
                    },
                )
            )
            assert self._flag_respawn_light.node
            bs.animate(
                self._flag_respawn_light.node,
                'intensity',
                {0: 0, 0.25: 0.15, 0.5: 0},
                loop=(not msg.self_kill),
            )
            bs.timer(3.0, self._flag_respawn_light.node.delete)
        else:
            return super().handlemessage(msg)
        return None

    def _handle_player_dropped_bomb(self, player: Spaz, bomb: bs.Actor) -> None:
        del player, bomb  # Unused.
        self._player_has_dropped_bomb = True

    def _handle_player_punched(self, player: Spaz) -> None:
        del player  # Unused.
        self._player_has_punched = True

    @override
    def spawn_player(self, player: Player) -> bs.Actor:
        # (Pylint Bug?) pylint: disable=missing-function-docstring

        spaz = self.spawn_player_spaz(
            player, position=self.map.get_start_position(player.team.id)
        )
        if self._preset in ['rookie_easy', 'pro_easy', 'uber_easy']:
            spaz.impact_scale = 0.25
        spaz.add_dropped_bomb_callback(self._handle_player_dropped_bomb)
        spaz.punch_callback = self._handle_player_punched
        return spaz

    def _flash_flag_spawn(self) -> None:
        light = bs.newnode(
            'light',
            attrs={
                'position': self._flag_spawn_pos,
                'height_attenuated': False,
                'color': (1, 1, 0),
            },
        )
        bs.animate(light, 'intensity', {0: 0, 0.25: 0.25, 0.5: 0}, loop=True)
        bs.timer(1.0, light.delete)

    def _spawn_flag(self) -> None:
        self._swipsound.play()
        self._whistle_sound.play()
        self._flash_flag_spawn()
        assert self._flag_spawn_pos is not None
        self._flag = FootballFlag(position=self._flag_spawn_pos)
