bascenev1lib.game.onslaught

Provides Onslaught Co-op game.

   1# Released under the MIT License. See LICENSE for details.
   2#
   3"""Provides Onslaught Co-op game."""
   4
   5# Yes this is a long one..
   6# pylint: disable=too-many-lines
   7
   8# ba_meta require api 9
   9# (see https://ballistica.net/wiki/meta-tag-system)
  10
  11from __future__ import annotations
  12
  13import math
  14import random
  15import logging
  16from enum import Enum, unique
  17from dataclasses import dataclass
  18from typing import TYPE_CHECKING, override
  19
  20import bascenev1 as bs
  21
  22from bascenev1lib.actor.popuptext import PopupText
  23from bascenev1lib.actor.bomb import TNTSpawner
  24from bascenev1lib.actor.playerspaz import PlayerSpazHurtMessage
  25from bascenev1lib.actor.scoreboard import Scoreboard
  26from bascenev1lib.actor.controlsguide import ControlsGuide
  27from bascenev1lib.actor.powerupbox import PowerupBox, PowerupBoxFactory
  28from bascenev1lib.actor.spazbot import (
  29    SpazBotDiedMessage,
  30    SpazBotSet,
  31    ChargerBot,
  32    StickyBot,
  33    BomberBot,
  34    BomberBotLite,
  35    BrawlerBot,
  36    BrawlerBotLite,
  37    TriggerBot,
  38    BomberBotStaticLite,
  39    TriggerBotStatic,
  40    BomberBotProStatic,
  41    TriggerBotPro,
  42    ExplodeyBot,
  43    BrawlerBotProShielded,
  44    ChargerBotProShielded,
  45    BomberBotPro,
  46    TriggerBotProShielded,
  47    BrawlerBotPro,
  48    BomberBotProShielded,
  49)
  50
  51if TYPE_CHECKING:
  52    from typing import Any, Sequence
  53    from bascenev1lib.actor.spazbot import SpazBot
  54
  55
  56@dataclass
  57class Wave:
  58    """A wave of enemies."""
  59
  60    entries: list[Spawn | Spacing | Delay | None]
  61    base_angle: float = 0.0
  62
  63
  64@dataclass
  65class Spawn:
  66    """A bot spawn event in a wave."""
  67
  68    bottype: type[SpazBot] | str
  69    point: Point | None = None
  70    spacing: float = 5.0
  71
  72
  73@dataclass
  74class Spacing:
  75    """Empty space in a wave."""
  76
  77    spacing: float = 5.0
  78
  79
  80@dataclass
  81class Delay:
  82    """A delay between events in a wave."""
  83
  84    duration: float
  85
  86
  87class Preset(Enum):
  88    """Game presets we support."""
  89
  90    TRAINING = 'training'
  91    TRAINING_EASY = 'training_easy'
  92    ROOKIE = 'rookie'
  93    ROOKIE_EASY = 'rookie_easy'
  94    PRO = 'pro'
  95    PRO_EASY = 'pro_easy'
  96    UBER = 'uber'
  97    UBER_EASY = 'uber_easy'
  98    ENDLESS = 'endless'
  99    ENDLESS_TOURNAMENT = 'endless_tournament'
 100
 101
 102@unique
 103class Point(Enum):
 104    """Points on the map we can spawn at."""
 105
 106    LEFT_UPPER_MORE = 'bot_spawn_left_upper_more'
 107    LEFT_UPPER = 'bot_spawn_left_upper'
 108    TURRET_TOP_RIGHT = 'bot_spawn_turret_top_right'
 109    RIGHT_UPPER = 'bot_spawn_right_upper'
 110    TURRET_TOP_MIDDLE_LEFT = 'bot_spawn_turret_top_middle_left'
 111    TURRET_TOP_MIDDLE_RIGHT = 'bot_spawn_turret_top_middle_right'
 112    TURRET_TOP_LEFT = 'bot_spawn_turret_top_left'
 113    TOP_RIGHT = 'bot_spawn_top_right'
 114    TOP_LEFT = 'bot_spawn_top_left'
 115    TOP = 'bot_spawn_top'
 116    BOTTOM = 'bot_spawn_bottom'
 117    LEFT = 'bot_spawn_left'
 118    RIGHT = 'bot_spawn_right'
 119    RIGHT_UPPER_MORE = 'bot_spawn_right_upper_more'
 120    RIGHT_LOWER = 'bot_spawn_right_lower'
 121    RIGHT_LOWER_MORE = 'bot_spawn_right_lower_more'
 122    BOTTOM_RIGHT = 'bot_spawn_bottom_right'
 123    BOTTOM_LEFT = 'bot_spawn_bottom_left'
 124    TURRET_BOTTOM_RIGHT = 'bot_spawn_turret_bottom_right'
 125    TURRET_BOTTOM_LEFT = 'bot_spawn_turret_bottom_left'
 126    LEFT_LOWER = 'bot_spawn_left_lower'
 127    LEFT_LOWER_MORE = 'bot_spawn_left_lower_more'
 128    TURRET_TOP_MIDDLE = 'bot_spawn_turret_top_middle'
 129    BOTTOM_HALF_RIGHT = 'bot_spawn_bottom_half_right'
 130    BOTTOM_HALF_LEFT = 'bot_spawn_bottom_half_left'
 131    TOP_HALF_RIGHT = 'bot_spawn_top_half_right'
 132    TOP_HALF_LEFT = 'bot_spawn_top_half_left'
 133
 134
 135class Player(bs.Player['Team']):
 136    """Our player type for this game."""
 137
 138    def __init__(self) -> None:
 139        self.has_been_hurt = False
 140        self.respawn_wave = 0
 141
 142
 143class Team(bs.Team[Player]):
 144    """Our team type for this game."""
 145
 146
 147class OnslaughtGame(bs.CoopGameActivity[Player, Team]):
 148    """Co-op game where players try to survive attacking waves of enemies."""
 149
 150    name = 'Onslaught'
 151    description = 'Defeat all enemies.'
 152
 153    tips: list[str | bs.GameTip] = [
 154        'Hold any button to run.'
 155        '  (Trigger buttons work well if you have them)',
 156        'Try tricking enemies into killing eachother or running off cliffs.',
 157        'Try \'Cooking off\' bombs for a second or two before throwing them.',
 158        'It\'s easier to win with a friend or two helping.',
 159        'If you stay in one place, you\'re toast. Run and dodge to survive..',
 160        'Practice using your momentum to throw bombs more accurately.',
 161        'Your punches do much more damage if you are running or spinning.',
 162    ]
 163
 164    # Show messages when players die since it matters here.
 165    announce_player_deaths = True
 166
 167    def __init__(self, settings: dict):
 168        self._preset = Preset(settings.get('preset', 'training'))
 169        if self._preset in {
 170            Preset.TRAINING,
 171            Preset.TRAINING_EASY,
 172            Preset.PRO,
 173            Preset.PRO_EASY,
 174            Preset.ENDLESS,
 175            Preset.ENDLESS_TOURNAMENT,
 176        }:
 177            settings['map'] = 'Doom Shroom'
 178        else:
 179            settings['map'] = 'Courtyard'
 180
 181        super().__init__(settings)
 182
 183        self._new_wave_sound = bs.getsound('scoreHit01')
 184        self._winsound = bs.getsound('score')
 185        self._cashregistersound = bs.getsound('cashRegister')
 186        self._a_player_has_been_hurt = False
 187        self._player_has_dropped_bomb = False
 188
 189        # FIXME: should use standard map defs.
 190        if settings['map'] == 'Doom Shroom':
 191            self._spawn_center = (0, 3, -5)
 192            self._tntspawnpos = (0.0, 3.0, -5.0)
 193            self._powerup_center = (0, 5, -3.6)
 194            self._powerup_spread = (6.0, 4.0)
 195        elif settings['map'] == 'Courtyard':
 196            self._spawn_center = (0, 3, -2)
 197            self._tntspawnpos = (0.0, 3.0, 2.1)
 198            self._powerup_center = (0, 5, -1.6)
 199            self._powerup_spread = (4.6, 2.7)
 200        else:
 201            raise RuntimeError('Unsupported map: ' + str(settings['map']))
 202        self._scoreboard: Scoreboard | None = None
 203        self._game_over = False
 204        self._wavenum = 0
 205        self._can_end_wave = True
 206        self._score = 0
 207        self._time_bonus = 0
 208        self._spawn_info_text: bs.NodeActor | None = None
 209        self._dingsound = bs.getsound('dingSmall')
 210        self._dingsoundhigh = bs.getsound('dingSmallHigh')
 211        self._have_tnt = False
 212        self._excluded_powerups: list[str] | None = None
 213        self._waves: list[Wave] = []
 214        self._tntspawner: TNTSpawner | None = None
 215        self._bots: SpazBotSet | None = None
 216        self._powerup_drop_timer: bs.Timer | None = None
 217        self._time_bonus_timer: bs.Timer | None = None
 218        self._time_bonus_text: bs.NodeActor | None = None
 219        self._flawless_bonus: int | None = None
 220        self._wave_text: bs.NodeActor | None = None
 221        self._wave_update_timer: bs.Timer | None = None
 222        self._throw_off_kills = 0
 223        self._land_mine_kills = 0
 224        self._tnt_kills = 0
 225
 226    @override
 227    def on_transition_in(self) -> None:
 228        super().on_transition_in()
 229        customdata = bs.getsession().customdata
 230
 231        # Show special landmine tip on rookie preset.
 232        if self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}:
 233            # Show once per session only (then we revert to regular tips).
 234            if not customdata.get('_showed_onslaught_landmine_tip', False):
 235                customdata['_showed_onslaught_landmine_tip'] = True
 236                self.tips = [
 237                    bs.GameTip(
 238                        'Land-mines are a good way to stop speedy enemies.',
 239                        icon=bs.gettexture('powerupLandMines'),
 240                        sound=bs.getsound('ding'),
 241                    )
 242                ]
 243
 244        # Show special tnt tip on pro preset.
 245        if self._preset in {Preset.PRO, Preset.PRO_EASY}:
 246            # Show once per session only (then we revert to regular tips).
 247            if not customdata.get('_showed_onslaught_tnt_tip', False):
 248                customdata['_showed_onslaught_tnt_tip'] = True
 249                self.tips = [
 250                    bs.GameTip(
 251                        'Take out a group of enemies by\n'
 252                        'setting off a bomb near a TNT box.',
 253                        icon=bs.gettexture('tnt'),
 254                        sound=bs.getsound('ding'),
 255                    )
 256                ]
 257
 258        # Show special curse tip on uber preset.
 259        if self._preset in {Preset.UBER, Preset.UBER_EASY}:
 260            # Show once per session only (then we revert to regular tips).
 261            if not customdata.get('_showed_onslaught_curse_tip', False):
 262                customdata['_showed_onslaught_curse_tip'] = True
 263                self.tips = [
 264                    bs.GameTip(
 265                        'Curse boxes turn you into a ticking time bomb.\n'
 266                        'The only cure is to quickly grab a health-pack.',
 267                        icon=bs.gettexture('powerupCurse'),
 268                        sound=bs.getsound('ding'),
 269                    )
 270                ]
 271
 272        self._spawn_info_text = bs.NodeActor(
 273            bs.newnode(
 274                'text',
 275                attrs={
 276                    'position': (15, -130),
 277                    'h_attach': 'left',
 278                    'v_attach': 'top',
 279                    'scale': 0.55,
 280                    'color': (0.3, 0.8, 0.3, 1.0),
 281                    'text': '',
 282                },
 283            )
 284        )
 285        bs.setmusic(bs.MusicType.ONSLAUGHT)
 286
 287        self._scoreboard = Scoreboard(
 288            label=bs.Lstr(resource='scoreText'), score_split=0.5
 289        )
 290
 291    @override
 292    def on_begin(self) -> None:
 293        super().on_begin()
 294        player_count = len(self.players)
 295        hard = self._preset not in {
 296            Preset.TRAINING_EASY,
 297            Preset.ROOKIE_EASY,
 298            Preset.PRO_EASY,
 299            Preset.UBER_EASY,
 300        }
 301        if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}:
 302            ControlsGuide(delay=3.0, lifespan=10.0, bright=True).autoretain()
 303
 304            self._have_tnt = False
 305            self._excluded_powerups = ['curse', 'land_mines']
 306            self._waves = [
 307                Wave(
 308                    base_angle=195,
 309                    entries=[
 310                        Spawn(BomberBotLite, spacing=5),
 311                    ]
 312                    * player_count,
 313                ),
 314                Wave(
 315                    base_angle=130,
 316                    entries=[
 317                        Spawn(BrawlerBotLite, spacing=5),
 318                    ]
 319                    * player_count,
 320                ),
 321                Wave(
 322                    base_angle=195,
 323                    entries=[Spawn(BomberBotLite, spacing=10)]
 324                    * (player_count + 1),
 325                ),
 326                Wave(
 327                    base_angle=130,
 328                    entries=[
 329                        Spawn(BrawlerBotLite, spacing=10),
 330                    ]
 331                    * (player_count + 1),
 332                ),
 333                Wave(
 334                    base_angle=130,
 335                    entries=[
 336                        (
 337                            Spawn(BrawlerBotLite, spacing=5)
 338                            if player_count > 1
 339                            else None
 340                        ),
 341                        Spawn(BrawlerBotLite, spacing=5),
 342                        Spacing(30),
 343                        (
 344                            Spawn(BomberBotLite, spacing=5)
 345                            if player_count > 3
 346                            else None
 347                        ),
 348                        Spawn(BomberBotLite, spacing=5),
 349                        Spacing(30),
 350                        Spawn(BrawlerBotLite, spacing=5),
 351                        (
 352                            Spawn(BrawlerBotLite, spacing=5)
 353                            if player_count > 2
 354                            else None
 355                        ),
 356                    ],
 357                ),
 358                Wave(
 359                    base_angle=195,
 360                    entries=[
 361                        Spawn(TriggerBot, spacing=90),
 362                        (
 363                            Spawn(TriggerBot, spacing=90)
 364                            if player_count > 1
 365                            else None
 366                        ),
 367                    ],
 368                ),
 369            ]
 370
 371        elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}:
 372            self._have_tnt = False
 373            self._excluded_powerups = ['curse']
 374            self._waves = [
 375                Wave(
 376                    entries=[
 377                        (
 378                            Spawn(ChargerBot, Point.LEFT_UPPER_MORE)
 379                            if player_count > 2
 380                            else None
 381                        ),
 382                        Spawn(ChargerBot, Point.LEFT_UPPER),
 383                    ]
 384                ),
 385                Wave(
 386                    entries=[
 387                        Spawn(BomberBotStaticLite, Point.TURRET_TOP_RIGHT),
 388                        Spawn(BrawlerBotLite, Point.RIGHT_UPPER),
 389                        (
 390                            Spawn(BrawlerBotLite, Point.RIGHT_LOWER)
 391                            if player_count > 1
 392                            else None
 393                        ),
 394                        (
 395                            Spawn(
 396                                BomberBotStaticLite, Point.TURRET_BOTTOM_RIGHT
 397                            )
 398                            if player_count > 2
 399                            else None
 400                        ),
 401                    ]
 402                ),
 403                Wave(
 404                    entries=[
 405                        Spawn(BomberBotStaticLite, Point.TURRET_BOTTOM_LEFT),
 406                        Spawn(TriggerBot, Point.LEFT),
 407                        (
 408                            Spawn(TriggerBot, Point.LEFT_LOWER)
 409                            if player_count > 1
 410                            else None
 411                        ),
 412                        (
 413                            Spawn(TriggerBot, Point.LEFT_UPPER)
 414                            if player_count > 2
 415                            else None
 416                        ),
 417                    ]
 418                ),
 419                Wave(
 420                    entries=[
 421                        Spawn(BrawlerBotLite, Point.TOP_RIGHT),
 422                        (
 423                            Spawn(BrawlerBot, Point.TOP_HALF_RIGHT)
 424                            if player_count > 1
 425                            else None
 426                        ),
 427                        Spawn(BrawlerBotLite, Point.TOP_LEFT),
 428                        (
 429                            Spawn(BrawlerBotLite, Point.TOP_HALF_LEFT)
 430                            if player_count > 2
 431                            else None
 432                        ),
 433                        Spawn(BrawlerBot, Point.TOP),
 434                        Spawn(BomberBotStaticLite, Point.TURRET_TOP_MIDDLE),
 435                    ]
 436                ),
 437                Wave(
 438                    entries=[
 439                        Spawn(TriggerBotStatic, Point.TURRET_BOTTOM_LEFT),
 440                        Spawn(TriggerBotStatic, Point.TURRET_BOTTOM_RIGHT),
 441                        Spawn(TriggerBot, Point.BOTTOM),
 442                        (
 443                            Spawn(TriggerBot, Point.BOTTOM_HALF_RIGHT)
 444                            if player_count > 1
 445                            else None
 446                        ),
 447                        (
 448                            Spawn(TriggerBot, Point.BOTTOM_HALF_LEFT)
 449                            if player_count > 2
 450                            else None
 451                        ),
 452                    ]
 453                ),
 454                Wave(
 455                    entries=[
 456                        Spawn(BomberBotStaticLite, Point.TURRET_TOP_LEFT),
 457                        Spawn(BomberBotStaticLite, Point.TURRET_TOP_RIGHT),
 458                        Spawn(ChargerBot, Point.BOTTOM),
 459                        (
 460                            Spawn(ChargerBot, Point.BOTTOM_HALF_LEFT)
 461                            if player_count > 1
 462                            else None
 463                        ),
 464                        (
 465                            Spawn(ChargerBot, Point.BOTTOM_HALF_RIGHT)
 466                            if player_count > 2
 467                            else None
 468                        ),
 469                    ]
 470                ),
 471            ]
 472
 473        elif self._preset in {Preset.PRO, Preset.PRO_EASY}:
 474            self._excluded_powerups = ['curse']
 475            self._have_tnt = True
 476            self._waves = [
 477                Wave(
 478                    base_angle=-50,
 479                    entries=[
 480                        (
 481                            Spawn(BrawlerBot, spacing=12)
 482                            if player_count > 3
 483                            else None
 484                        ),
 485                        Spawn(BrawlerBot, spacing=12),
 486                        Spawn(BomberBot, spacing=6),
 487                        (
 488                            Spawn(BomberBot, spacing=6)
 489                            if self._preset is Preset.PRO
 490                            else None
 491                        ),
 492                        (
 493                            Spawn(BomberBot, spacing=6)
 494                            if player_count > 1
 495                            else None
 496                        ),
 497                        Spawn(BrawlerBot, spacing=12),
 498                        (
 499                            Spawn(BrawlerBot, spacing=12)
 500                            if player_count > 2
 501                            else None
 502                        ),
 503                    ],
 504                ),
 505                Wave(
 506                    base_angle=180,
 507                    entries=[
 508                        (
 509                            Spawn(BrawlerBot, spacing=6)
 510                            if player_count > 3
 511                            else None
 512                        ),
 513                        (
 514                            Spawn(BrawlerBot, spacing=6)
 515                            if self._preset is Preset.PRO
 516                            else None
 517                        ),
 518                        Spawn(BrawlerBot, spacing=6),
 519                        Spawn(ChargerBot, spacing=45),
 520                        (
 521                            Spawn(ChargerBot, spacing=45)
 522                            if player_count > 1
 523                            else None
 524                        ),
 525                        Spawn(BrawlerBot, spacing=6),
 526                        (
 527                            Spawn(BrawlerBot, spacing=6)
 528                            if self._preset is Preset.PRO
 529                            else None
 530                        ),
 531                        (
 532                            Spawn(BrawlerBot, spacing=6)
 533                            if player_count > 2
 534                            else None
 535                        ),
 536                    ],
 537                ),
 538                Wave(
 539                    base_angle=0,
 540                    entries=[
 541                        Spawn(ChargerBot, spacing=30),
 542                        Spawn(TriggerBot, spacing=30),
 543                        Spawn(TriggerBot, spacing=30),
 544                        (
 545                            Spawn(TriggerBot, spacing=30)
 546                            if self._preset is Preset.PRO
 547                            else None
 548                        ),
 549                        (
 550                            Spawn(TriggerBot, spacing=30)
 551                            if player_count > 1
 552                            else None
 553                        ),
 554                        (
 555                            Spawn(TriggerBot, spacing=30)
 556                            if player_count > 3
 557                            else None
 558                        ),
 559                        Spawn(ChargerBot, spacing=30),
 560                    ],
 561                ),
 562                Wave(
 563                    base_angle=90,
 564                    entries=[
 565                        Spawn(StickyBot, spacing=50),
 566                        (
 567                            Spawn(StickyBot, spacing=50)
 568                            if self._preset is Preset.PRO
 569                            else None
 570                        ),
 571                        Spawn(StickyBot, spacing=50),
 572                        (
 573                            Spawn(StickyBot, spacing=50)
 574                            if player_count > 1
 575                            else None
 576                        ),
 577                        (
 578                            Spawn(StickyBot, spacing=50)
 579                            if player_count > 3
 580                            else None
 581                        ),
 582                    ],
 583                ),
 584                Wave(
 585                    base_angle=0,
 586                    entries=[
 587                        Spawn(TriggerBot, spacing=72),
 588                        Spawn(TriggerBot, spacing=72),
 589                        (
 590                            Spawn(TriggerBot, spacing=72)
 591                            if self._preset is Preset.PRO
 592                            else None
 593                        ),
 594                        Spawn(TriggerBot, spacing=72),
 595                        Spawn(TriggerBot, spacing=72),
 596                        (
 597                            Spawn(TriggerBot, spacing=36)
 598                            if player_count > 2
 599                            else None
 600                        ),
 601                    ],
 602                ),
 603                Wave(
 604                    base_angle=30,
 605                    entries=[
 606                        Spawn(ChargerBotProShielded, spacing=50),
 607                        Spawn(ChargerBotProShielded, spacing=50),
 608                        (
 609                            Spawn(ChargerBotProShielded, spacing=50)
 610                            if self._preset is Preset.PRO
 611                            else None
 612                        ),
 613                        (
 614                            Spawn(ChargerBotProShielded, spacing=50)
 615                            if player_count > 1
 616                            else None
 617                        ),
 618                        (
 619                            Spawn(ChargerBotProShielded, spacing=50)
 620                            if player_count > 2
 621                            else None
 622                        ),
 623                    ],
 624                ),
 625            ]
 626
 627        elif self._preset in {Preset.UBER, Preset.UBER_EASY}:
 628            # Show controls help in demo or arcade modes.
 629            env = bs.app.env
 630            if env.demo or env.arcade:
 631                ControlsGuide(
 632                    delay=3.0, lifespan=10.0, bright=True
 633                ).autoretain()
 634
 635            self._have_tnt = True
 636            self._excluded_powerups = []
 637            self._waves = [
 638                Wave(
 639                    entries=[
 640                        (
 641                            Spawn(
 642                                BomberBotProStatic, Point.TURRET_TOP_MIDDLE_LEFT
 643                            )
 644                            if hard
 645                            else None
 646                        ),
 647                        Spawn(
 648                            BomberBotProStatic, Point.TURRET_TOP_MIDDLE_RIGHT
 649                        ),
 650                        (
 651                            Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT)
 652                            if player_count > 2
 653                            else None
 654                        ),
 655                        Spawn(ExplodeyBot, Point.TOP_RIGHT),
 656                        Delay(4.0),
 657                        Spawn(ExplodeyBot, Point.TOP_LEFT),
 658                    ]
 659                ),
 660                Wave(
 661                    entries=[
 662                        Spawn(ChargerBot, Point.LEFT),
 663                        Spawn(ChargerBot, Point.RIGHT),
 664                        (
 665                            Spawn(ChargerBot, Point.RIGHT_UPPER_MORE)
 666                            if player_count > 2
 667                            else None
 668                        ),
 669                        Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT),
 670                        Spawn(BomberBotProStatic, Point.TURRET_TOP_RIGHT),
 671                    ]
 672                ),
 673                Wave(
 674                    entries=[
 675                        Spawn(TriggerBotPro, Point.TOP_RIGHT),
 676                        (
 677                            Spawn(TriggerBotPro, Point.RIGHT_UPPER_MORE)
 678                            if player_count > 1
 679                            else None
 680                        ),
 681                        Spawn(TriggerBotPro, Point.RIGHT_UPPER),
 682                        (
 683                            Spawn(TriggerBotPro, Point.RIGHT_LOWER)
 684                            if hard
 685                            else None
 686                        ),
 687                        (
 688                            Spawn(TriggerBotPro, Point.RIGHT_LOWER_MORE)
 689                            if player_count > 2
 690                            else None
 691                        ),
 692                        Spawn(TriggerBotPro, Point.BOTTOM_RIGHT),
 693                    ]
 694                ),
 695                Wave(
 696                    entries=[
 697                        Spawn(ChargerBotProShielded, Point.BOTTOM_RIGHT),
 698                        (
 699                            Spawn(ChargerBotProShielded, Point.BOTTOM)
 700                            if player_count > 2
 701                            else None
 702                        ),
 703                        Spawn(ChargerBotProShielded, Point.BOTTOM_LEFT),
 704                        (
 705                            Spawn(ChargerBotProShielded, Point.TOP)
 706                            if hard
 707                            else None
 708                        ),
 709                        Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE),
 710                    ]
 711                ),
 712                Wave(
 713                    entries=[
 714                        Spawn(ExplodeyBot, Point.LEFT_UPPER),
 715                        Delay(1.0),
 716                        Spawn(BrawlerBotProShielded, Point.LEFT_LOWER),
 717                        Spawn(BrawlerBotProShielded, Point.LEFT_LOWER_MORE),
 718                        Delay(4.0),
 719                        Spawn(ExplodeyBot, Point.RIGHT_UPPER),
 720                        Delay(1.0),
 721                        Spawn(BrawlerBotProShielded, Point.RIGHT_LOWER),
 722                        Spawn(BrawlerBotProShielded, Point.RIGHT_UPPER_MORE),
 723                        Delay(4.0),
 724                        Spawn(ExplodeyBot, Point.LEFT),
 725                        Delay(5.0),
 726                        Spawn(ExplodeyBot, Point.RIGHT),
 727                    ]
 728                ),
 729                Wave(
 730                    entries=[
 731                        Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT),
 732                        Spawn(BomberBotProStatic, Point.TURRET_TOP_RIGHT),
 733                        Spawn(BomberBotProStatic, Point.TURRET_BOTTOM_LEFT),
 734                        Spawn(BomberBotProStatic, Point.TURRET_BOTTOM_RIGHT),
 735                        (
 736                            Spawn(
 737                                BomberBotProStatic, Point.TURRET_TOP_MIDDLE_LEFT
 738                            )
 739                            if hard
 740                            else None
 741                        ),
 742                        (
 743                            Spawn(
 744                                BomberBotProStatic,
 745                                Point.TURRET_TOP_MIDDLE_RIGHT,
 746                            )
 747                            if hard
 748                            else None
 749                        ),
 750                    ]
 751                ),
 752            ]
 753
 754        # We generate these on the fly in endless.
 755        elif self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
 756            self._have_tnt = True
 757            self._excluded_powerups = []
 758            self._waves = []
 759
 760        else:
 761            raise RuntimeError(f'Invalid preset: {self._preset}')
 762
 763        # FIXME: Should migrate to use setup_standard_powerup_drops().
 764
 765        # Spit out a few powerups and start dropping more shortly.
 766        self._drop_powerups(
 767            standard_points=True,
 768            poweruptype=(
 769                'curse'
 770                if self._preset in [Preset.UBER, Preset.UBER_EASY]
 771                else (
 772                    'land_mines'
 773                    if self._preset in [Preset.ROOKIE, Preset.ROOKIE_EASY]
 774                    else None
 775                )
 776            ),
 777        )
 778        bs.timer(4.0, self._start_powerup_drops)
 779
 780        # Our TNT spawner (if applicable).
 781        if self._have_tnt:
 782            self._tntspawner = TNTSpawner(position=self._tntspawnpos)
 783
 784        self.setup_low_life_warning_sound()
 785        self._update_scores()
 786        self._bots = SpazBotSet()
 787        bs.timer(4.0, self._start_updating_waves)
 788
 789    def _get_dist_grp_totals(self, grps: list[Any]) -> tuple[int, int]:
 790        totalpts = 0
 791        totaldudes = 0
 792        for grp in grps:
 793            for grpentry in grp:
 794                dudes = grpentry[1]
 795                totalpts += grpentry[0] * dudes
 796                totaldudes += dudes
 797        return totalpts, totaldudes
 798
 799    def _get_distribution(
 800        self,
 801        target_points: int,
 802        min_dudes: int,
 803        max_dudes: int,
 804        group_count: int,
 805        max_level: int,
 806    ) -> list[list[tuple[int, int]]]:
 807        """Calculate a distribution of bad guys given some params."""
 808        # pylint: disable=too-many-positional-arguments
 809        max_iterations = 10 + max_dudes * 2
 810
 811        groups: list[list[tuple[int, int]]] = []
 812        for _g in range(group_count):
 813            groups.append([])
 814        types = [1]
 815        if max_level > 1:
 816            types.append(2)
 817        if max_level > 2:
 818            types.append(3)
 819        if max_level > 3:
 820            types.append(4)
 821        for iteration in range(max_iterations):
 822            diff = self._add_dist_entry_if_possible(
 823                groups, max_dudes, target_points, types
 824            )
 825
 826            total_points, total_dudes = self._get_dist_grp_totals(groups)
 827            full = total_points >= target_points
 828
 829            if full:
 830                # Every so often, delete a random entry just to
 831                # shake up our distribution.
 832                if random.random() < 0.2 and iteration != max_iterations - 1:
 833                    self._delete_random_dist_entry(groups)
 834
 835                # If we don't have enough dudes, kill the group with
 836                # the biggest point value.
 837                elif (
 838                    total_dudes < min_dudes and iteration != max_iterations - 1
 839                ):
 840                    self._delete_biggest_dist_entry(groups)
 841
 842                # If we've got too many dudes, kill the group with the
 843                # smallest point value.
 844                elif (
 845                    total_dudes > max_dudes and iteration != max_iterations - 1
 846                ):
 847                    self._delete_smallest_dist_entry(groups)
 848
 849                # Close enough.. we're done.
 850                else:
 851                    if diff == 0:
 852                        break
 853
 854        return groups
 855
 856    def _add_dist_entry_if_possible(
 857        self,
 858        groups: list[list[tuple[int, int]]],
 859        max_dudes: int,
 860        target_points: int,
 861        types: list[int],
 862    ) -> int:
 863        # See how much we're off our target by.
 864        total_points, total_dudes = self._get_dist_grp_totals(groups)
 865        diff = target_points - total_points
 866        dudes_diff = max_dudes - total_dudes
 867
 868        # Add an entry if one will fit.
 869        value = types[random.randrange(len(types))]
 870        group = groups[random.randrange(len(groups))]
 871        if not group:
 872            max_count = random.randint(1, 6)
 873        else:
 874            max_count = 2 * random.randint(1, 3)
 875        max_count = min(max_count, dudes_diff)
 876        count = min(max_count, diff // value)
 877        if count > 0:
 878            group.append((value, count))
 879            total_points += value * count
 880            total_dudes += count
 881            diff = target_points - total_points
 882        return diff
 883
 884    def _delete_smallest_dist_entry(
 885        self, groups: list[list[tuple[int, int]]]
 886    ) -> None:
 887        smallest_value = 9999
 888        smallest_entry = None
 889        smallest_entry_group = None
 890        for group in groups:
 891            for entry in group:
 892                if entry[0] < smallest_value or smallest_entry is None:
 893                    smallest_value = entry[0]
 894                    smallest_entry = entry
 895                    smallest_entry_group = group
 896        assert smallest_entry is not None
 897        assert smallest_entry_group is not None
 898        smallest_entry_group.remove(smallest_entry)
 899
 900    def _delete_biggest_dist_entry(
 901        self, groups: list[list[tuple[int, int]]]
 902    ) -> None:
 903        biggest_value = 9999
 904        biggest_entry = None
 905        biggest_entry_group = None
 906        for group in groups:
 907            for entry in group:
 908                if entry[0] > biggest_value or biggest_entry is None:
 909                    biggest_value = entry[0]
 910                    biggest_entry = entry
 911                    biggest_entry_group = group
 912        if biggest_entry is not None:
 913            assert biggest_entry_group is not None
 914            biggest_entry_group.remove(biggest_entry)
 915
 916    def _delete_random_dist_entry(
 917        self, groups: list[list[tuple[int, int]]]
 918    ) -> None:
 919        entry_count = 0
 920        for group in groups:
 921            for _ in group:
 922                entry_count += 1
 923        if entry_count > 1:
 924            del_entry = random.randrange(entry_count)
 925            entry_count = 0
 926            for group in groups:
 927                for entry in group:
 928                    if entry_count == del_entry:
 929                        group.remove(entry)
 930                        break
 931                    entry_count += 1
 932
 933    @override
 934    def spawn_player(self, player: Player) -> bs.Actor:
 935        # We keep track of who got hurt each wave for score purposes.
 936        player.has_been_hurt = False
 937        pos = (
 938            self._spawn_center[0] + random.uniform(-1.5, 1.5),
 939            self._spawn_center[1],
 940            self._spawn_center[2] + random.uniform(-1.5, 1.5),
 941        )
 942        spaz = self.spawn_player_spaz(player, position=pos)
 943        if self._preset in {
 944            Preset.TRAINING_EASY,
 945            Preset.ROOKIE_EASY,
 946            Preset.PRO_EASY,
 947            Preset.UBER_EASY,
 948        }:
 949            spaz.impact_scale = 0.25
 950        spaz.add_dropped_bomb_callback(self._handle_player_dropped_bomb)
 951        return spaz
 952
 953    def _handle_player_dropped_bomb(
 954        self, player: bs.Actor, bomb: bs.Actor
 955    ) -> None:
 956        del player, bomb  # Unused.
 957        self._player_has_dropped_bomb = True
 958
 959    def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None:
 960        poweruptype = PowerupBoxFactory.get().get_random_powerup_type(
 961            forcetype=poweruptype, excludetypes=self._excluded_powerups
 962        )
 963        PowerupBox(
 964            position=self.map.powerup_spawn_points[index],
 965            poweruptype=poweruptype,
 966        ).autoretain()
 967
 968    def _start_powerup_drops(self) -> None:
 969        self._powerup_drop_timer = bs.Timer(
 970            3.0, bs.WeakCall(self._drop_powerups), repeat=True
 971        )
 972
 973    def _drop_powerups(
 974        self, standard_points: bool = False, poweruptype: str | None = None
 975    ) -> None:
 976        """Generic powerup drop."""
 977        if standard_points:
 978            points = self.map.powerup_spawn_points
 979            for i in range(len(points)):
 980                bs.timer(
 981                    1.0 + i * 0.5,
 982                    bs.WeakCall(
 983                        self._drop_powerup, i, poweruptype if i == 0 else None
 984                    ),
 985                )
 986        else:
 987            point = (
 988                self._powerup_center[0]
 989                + random.uniform(
 990                    -1.0 * self._powerup_spread[0],
 991                    1.0 * self._powerup_spread[0],
 992                ),
 993                self._powerup_center[1],
 994                self._powerup_center[2]
 995                + random.uniform(
 996                    -self._powerup_spread[1], self._powerup_spread[1]
 997                ),
 998            )
 999
1000            # Drop one random one somewhere.
1001            PowerupBox(
1002                position=point,
1003                poweruptype=PowerupBoxFactory.get().get_random_powerup_type(
1004                    excludetypes=self._excluded_powerups
1005                ),
1006            ).autoretain()
1007
1008    def do_end(self, outcome: str, delay: float = 0.0) -> None:
1009        """End the game with the specified outcome."""
1010        if outcome == 'defeat':
1011            self.fade_to_red()
1012        score: int | None
1013        if self._wavenum >= 2:
1014            score = self._score
1015            fail_message = None
1016        else:
1017            score = None
1018            fail_message = bs.Lstr(resource='reachWave2Text')
1019        self.end(
1020            {
1021                'outcome': outcome,
1022                'score': score,
1023                'fail_message': fail_message,
1024                'playerinfos': self.initialplayerinfos,
1025            },
1026            delay=delay,
1027        )
1028
1029    def _award_completion_achievements(self) -> None:
1030        if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}:
1031            self._award_achievement('Onslaught Training Victory', sound=False)
1032            if not self._player_has_dropped_bomb:
1033                self._award_achievement('Boxer', sound=False)
1034        elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}:
1035            self._award_achievement('Rookie Onslaught Victory', sound=False)
1036            if not self._a_player_has_been_hurt:
1037                self._award_achievement('Flawless Victory', sound=False)
1038        elif self._preset in {Preset.PRO, Preset.PRO_EASY}:
1039            self._award_achievement('Pro Onslaught Victory', sound=False)
1040            if not self._player_has_dropped_bomb:
1041                self._award_achievement('Pro Boxer', sound=False)
1042        elif self._preset in {Preset.UBER, Preset.UBER_EASY}:
1043            self._award_achievement('Uber Onslaught Victory', sound=False)
1044
1045    def _update_waves(self) -> None:
1046        # If we have no living bots, go to the next wave.
1047        assert self._bots is not None
1048        if (
1049            self._can_end_wave
1050            and not self._bots.have_living_bots()
1051            and not self._game_over
1052        ):
1053            self._can_end_wave = False
1054            self._time_bonus_timer = None
1055            self._time_bonus_text = None
1056            if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
1057                won = False
1058            else:
1059                won = self._wavenum == len(self._waves)
1060
1061            base_delay = 4.0 if won else 0.0
1062
1063            # Reward time bonus.
1064            if self._time_bonus > 0:
1065                bs.timer(0, self._cashregistersound.play)
1066                bs.timer(
1067                    base_delay,
1068                    bs.WeakCall(self._award_time_bonus, self._time_bonus),
1069                )
1070                base_delay += 1.0
1071
1072            # Reward flawless bonus.
1073            if self._wavenum > 0:
1074                have_flawless = False
1075                for player in self.players:
1076                    if player.is_alive() and not player.has_been_hurt:
1077                        have_flawless = True
1078                        bs.timer(
1079                            base_delay,
1080                            bs.WeakCall(self._award_flawless_bonus, player),
1081                        )
1082                    player.has_been_hurt = False  # reset
1083                if have_flawless:
1084                    base_delay += 1.0
1085
1086            if won:
1087                self.show_zoom_message(
1088                    bs.Lstr(resource='victoryText'), scale=1.0, duration=4.0
1089                )
1090                self.celebrate(20.0)
1091                self._award_completion_achievements()
1092                bs.timer(base_delay, bs.WeakCall(self._award_completion_bonus))
1093                base_delay += 0.85
1094                self._winsound.play()
1095                bs.cameraflash()
1096                bs.setmusic(bs.MusicType.VICTORY)
1097                self._game_over = True
1098
1099                # Can't just pass delay to do_end because our extra bonuses
1100                # haven't been added yet (once we call do_end the score
1101                # gets locked in).
1102                bs.timer(base_delay, bs.WeakCall(self.do_end, 'victory'))
1103                return
1104
1105            self._wavenum += 1
1106
1107            # Short celebration after waves.
1108            if self._wavenum > 1:
1109                self.celebrate(0.5)
1110            bs.timer(base_delay, bs.WeakCall(self._start_next_wave))
1111
1112    def _award_completion_bonus(self) -> None:
1113        self._cashregistersound.play()
1114        for player in self.players:
1115            try:
1116                if player.is_alive():
1117                    assert self.initialplayerinfos is not None
1118                    self.stats.player_scored(
1119                        player,
1120                        int(100 / len(self.initialplayerinfos)),
1121                        scale=1.4,
1122                        color=(0.6, 0.6, 1.0, 1.0),
1123                        title=bs.Lstr(resource='completionBonusText'),
1124                        screenmessage=False,
1125                    )
1126            except Exception:
1127                logging.exception('error in _award_completion_bonus')
1128
1129    def _award_time_bonus(self, bonus: int) -> None:
1130        self._cashregistersound.play()
1131        PopupText(
1132            bs.Lstr(
1133                value='+${A} ${B}',
1134                subs=[
1135                    ('${A}', str(bonus)),
1136                    ('${B}', bs.Lstr(resource='timeBonusText')),
1137                ],
1138            ),
1139            color=(1, 1, 0.5, 1),
1140            scale=1.0,
1141            position=(0, 3, -1),
1142        ).autoretain()
1143        self._score += self._time_bonus
1144        self._update_scores()
1145
1146    def _award_flawless_bonus(self, player: Player) -> None:
1147        self._cashregistersound.play()
1148        try:
1149            if player.is_alive():
1150                assert self._flawless_bonus is not None
1151                self.stats.player_scored(
1152                    player,
1153                    self._flawless_bonus,
1154                    scale=1.2,
1155                    color=(0.6, 1.0, 0.6, 1.0),
1156                    title=bs.Lstr(resource='flawlessWaveText'),
1157                    screenmessage=False,
1158                )
1159        except Exception:
1160            logging.exception('error in _award_flawless_bonus')
1161
1162    def _start_time_bonus_timer(self) -> None:
1163        self._time_bonus_timer = bs.Timer(
1164            1.0, bs.WeakCall(self._update_time_bonus), repeat=True
1165        )
1166
1167    def _update_player_spawn_info(self) -> None:
1168        # If we have no living players lets just blank this.
1169        assert self._spawn_info_text is not None
1170        assert self._spawn_info_text.node
1171        if not any(player.is_alive() for player in self.teams[0].players):
1172            self._spawn_info_text.node.text = ''
1173        else:
1174            text: str | bs.Lstr = ''
1175            for player in self.players:
1176                if not player.is_alive() and (
1177                    self._preset in [Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT]
1178                    or (player.respawn_wave <= len(self._waves))
1179                ):
1180                    rtxt = bs.Lstr(
1181                        resource='onslaughtRespawnText',
1182                        subs=[
1183                            ('${PLAYER}', player.getname()),
1184                            ('${WAVE}', str(player.respawn_wave)),
1185                        ],
1186                    )
1187                    text = bs.Lstr(
1188                        value='${A}${B}\n',
1189                        subs=[
1190                            ('${A}', text),
1191                            ('${B}', rtxt),
1192                        ],
1193                    )
1194            self._spawn_info_text.node.text = text
1195
1196    def _respawn_players_for_wave(self) -> None:
1197        # Respawn applicable players.
1198        if self._wavenum > 1:
1199            for player in self.players:
1200                if (
1201                    not player.is_alive()
1202                    and player.respawn_wave == self._wavenum
1203                ):
1204                    self.spawn_player(player)
1205        self._update_player_spawn_info()
1206
1207    def _setup_wave_spawns(self, wave: Wave) -> None:
1208        tval = 0.0
1209        dtime = 0.2
1210        if self._wavenum == 1:
1211            spawn_time = 3.973
1212            tval += 0.5
1213        else:
1214            spawn_time = 2.648
1215
1216        bot_angle = wave.base_angle
1217        self._time_bonus = 0
1218        self._flawless_bonus = 0
1219        for info in wave.entries:
1220            if info is None:
1221                continue
1222            if isinstance(info, Delay):
1223                spawn_time += info.duration
1224                continue
1225            if isinstance(info, Spacing):
1226                bot_angle += info.spacing
1227                continue
1228            bot_type_2 = info.bottype
1229            if bot_type_2 is not None:
1230                assert not isinstance(bot_type_2, str)
1231                self._time_bonus += bot_type_2.points_mult * 20
1232                self._flawless_bonus += bot_type_2.points_mult * 5
1233
1234            # If its got a position, use that.
1235            point = info.point
1236            if point is not None:
1237                assert bot_type_2 is not None
1238                spcall = bs.WeakCall(
1239                    self.add_bot_at_point, point, bot_type_2, spawn_time
1240                )
1241                bs.timer(tval, spcall)
1242                tval += dtime
1243            else:
1244                spacing = info.spacing
1245                bot_angle += spacing * 0.5
1246                if bot_type_2 is not None:
1247                    tcall = bs.WeakCall(
1248                        self.add_bot_at_angle, bot_angle, bot_type_2, spawn_time
1249                    )
1250                    bs.timer(tval, tcall)
1251                    tval += dtime
1252                bot_angle += spacing * 0.5
1253
1254        # We can end the wave after all the spawning happens.
1255        bs.timer(
1256            tval + spawn_time - dtime + 0.01,
1257            bs.WeakCall(self._set_can_end_wave),
1258        )
1259
1260    def _start_next_wave(self) -> None:
1261        # This can happen if we beat a wave as we die.
1262        # We don't wanna respawn players and whatnot if this happens.
1263        if self._game_over:
1264            return
1265
1266        self._respawn_players_for_wave()
1267        if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
1268            wave = self._generate_random_wave()
1269        else:
1270            wave = self._waves[self._wavenum - 1]
1271        self._setup_wave_spawns(wave)
1272        self._update_wave_ui_and_bonuses()
1273        bs.timer(0.4, self._new_wave_sound.play)
1274
1275    def _update_wave_ui_and_bonuses(self) -> None:
1276        self.show_zoom_message(
1277            bs.Lstr(
1278                value='${A} ${B}',
1279                subs=[
1280                    ('${A}', bs.Lstr(resource='waveText')),
1281                    ('${B}', str(self._wavenum)),
1282                ],
1283            ),
1284            scale=1.0,
1285            duration=1.0,
1286            trail=True,
1287        )
1288
1289        # Reset our time bonus.
1290        tbtcolor = (1, 1, 0, 1)
1291        tbttxt = bs.Lstr(
1292            value='${A}: ${B}',
1293            subs=[
1294                ('${A}', bs.Lstr(resource='timeBonusText')),
1295                ('${B}', str(self._time_bonus)),
1296            ],
1297        )
1298        self._time_bonus_text = bs.NodeActor(
1299            bs.newnode(
1300                'text',
1301                attrs={
1302                    'v_attach': 'top',
1303                    'h_attach': 'center',
1304                    'h_align': 'center',
1305                    'vr_depth': -30,
1306                    'color': tbtcolor,
1307                    'shadow': 1.0,
1308                    'flatness': 1.0,
1309                    'position': (0, -60),
1310                    'scale': 0.8,
1311                    'text': tbttxt,
1312                },
1313            )
1314        )
1315
1316        bs.timer(5.0, bs.WeakCall(self._start_time_bonus_timer))
1317        wtcolor = (1, 1, 1, 1)
1318        wttxt = bs.Lstr(
1319            value='${A} ${B}',
1320            subs=[
1321                ('${A}', bs.Lstr(resource='waveText')),
1322                (
1323                    '${B}',
1324                    str(self._wavenum)
1325                    + (
1326                        ''
1327                        if self._preset
1328                        in [Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT]
1329                        else ('/' + str(len(self._waves)))
1330                    ),
1331                ),
1332            ],
1333        )
1334        self._wave_text = bs.NodeActor(
1335            bs.newnode(
1336                'text',
1337                attrs={
1338                    'v_attach': 'top',
1339                    'h_attach': 'center',
1340                    'h_align': 'center',
1341                    'vr_depth': -10,
1342                    'color': wtcolor,
1343                    'shadow': 1.0,
1344                    'flatness': 1.0,
1345                    'position': (0, -40),
1346                    'scale': 1.3,
1347                    'text': wttxt,
1348                },
1349            )
1350        )
1351
1352    def _bot_levels_for_wave(self) -> list[list[type[SpazBot]]]:
1353        level = self._wavenum
1354        bot_types = [
1355            BomberBot,
1356            BrawlerBot,
1357            TriggerBot,
1358            ChargerBot,
1359            BomberBotPro,
1360            BrawlerBotPro,
1361            TriggerBotPro,
1362            BomberBotProShielded,
1363            ExplodeyBot,
1364            ChargerBotProShielded,
1365            StickyBot,
1366            BrawlerBotProShielded,
1367            TriggerBotProShielded,
1368        ]
1369        if level > 5:
1370            bot_types += [
1371                ExplodeyBot,
1372                TriggerBotProShielded,
1373                BrawlerBotProShielded,
1374                ChargerBotProShielded,
1375            ]
1376        if level > 7:
1377            bot_types += [
1378                ExplodeyBot,
1379                TriggerBotProShielded,
1380                BrawlerBotProShielded,
1381                ChargerBotProShielded,
1382            ]
1383        if level > 10:
1384            bot_types += [
1385                TriggerBotProShielded,
1386                TriggerBotProShielded,
1387                TriggerBotProShielded,
1388                TriggerBotProShielded,
1389            ]
1390        if level > 13:
1391            bot_types += [
1392                TriggerBotProShielded,
1393                TriggerBotProShielded,
1394                TriggerBotProShielded,
1395                TriggerBotProShielded,
1396            ]
1397        bot_levels = [
1398            [b for b in bot_types if b.points_mult == 1],
1399            [b for b in bot_types if b.points_mult == 2],
1400            [b for b in bot_types if b.points_mult == 3],
1401            [b for b in bot_types if b.points_mult == 4],
1402        ]
1403
1404        # Make sure all lists have something in them
1405        if not all(bot_levels):
1406            raise RuntimeError('Got empty bot level')
1407        return bot_levels
1408
1409    def _add_entries_for_distribution_group(
1410        self,
1411        group: list[tuple[int, int]],
1412        bot_levels: list[list[type[SpazBot]]],
1413        all_entries: list[Spawn | Spacing | Delay | None],
1414    ) -> None:
1415        entries: list[Spawn | Spacing | Delay | None] = []
1416        for entry in group:
1417            bot_level = bot_levels[entry[0] - 1]
1418            bot_type = bot_level[random.randrange(len(bot_level))]
1419            rval = random.random()
1420            if rval < 0.5:
1421                spacing = 10.0
1422            elif rval < 0.9:
1423                spacing = 20.0
1424            else:
1425                spacing = 40.0
1426            split = random.random() > 0.3
1427            for i in range(entry[1]):
1428                if split and i % 2 == 0:
1429                    entries.insert(0, Spawn(bot_type, spacing=spacing))
1430                else:
1431                    entries.append(Spawn(bot_type, spacing=spacing))
1432        if entries:
1433            all_entries += entries
1434            all_entries.append(Spacing(40.0 if random.random() < 0.5 else 80.0))
1435
1436    def _generate_random_wave(self) -> Wave:
1437        level = self._wavenum
1438        bot_levels = self._bot_levels_for_wave()
1439
1440        target_points = level * 3 - 2
1441        min_dudes = min(1 + level // 3, 10)
1442        max_dudes = min(10, level + 1)
1443        max_level = (
1444            4 if level > 6 else (3 if level > 3 else (2 if level > 2 else 1))
1445        )
1446        group_count = 3
1447        distribution = self._get_distribution(
1448            target_points, min_dudes, max_dudes, group_count, max_level
1449        )
1450        all_entries: list[Spawn | Spacing | Delay | None] = []
1451        for group in distribution:
1452            self._add_entries_for_distribution_group(
1453                group, bot_levels, all_entries
1454            )
1455        angle_rand = random.random()
1456        if angle_rand > 0.75:
1457            base_angle = 130.0
1458        elif angle_rand > 0.5:
1459            base_angle = 210.0
1460        elif angle_rand > 0.25:
1461            base_angle = 20.0
1462        else:
1463            base_angle = -30.0
1464        base_angle += (0.5 - random.random()) * 20.0
1465        wave = Wave(base_angle=base_angle, entries=all_entries)
1466        return wave
1467
1468    def add_bot_at_point(
1469        self, point: Point, spaz_type: type[SpazBot], spawn_time: float = 1.0
1470    ) -> None:
1471        """Add a new bot at a specified named point."""
1472        if self._game_over:
1473            return
1474        assert isinstance(point.value, str)
1475        pointpos = self.map.defs.points[point.value]
1476        assert self._bots is not None
1477        self._bots.spawn_bot(spaz_type, pos=pointpos, spawn_time=spawn_time)
1478
1479    def add_bot_at_angle(
1480        self, angle: float, spaz_type: type[SpazBot], spawn_time: float = 1.0
1481    ) -> None:
1482        """Add a new bot at a specified angle (for circular maps)."""
1483        if self._game_over:
1484            return
1485        angle_radians = angle / 57.2957795
1486        xval = math.sin(angle_radians) * 1.06
1487        zval = math.cos(angle_radians) * 1.06
1488        point = (xval / 0.125, 2.3, (zval / 0.2) - 3.7)
1489        assert self._bots is not None
1490        self._bots.spawn_bot(spaz_type, pos=point, spawn_time=spawn_time)
1491
1492    def _update_time_bonus(self) -> None:
1493        self._time_bonus = int(self._time_bonus * 0.93)
1494        if self._time_bonus > 0 and self._time_bonus_text is not None:
1495            assert self._time_bonus_text.node
1496            self._time_bonus_text.node.text = bs.Lstr(
1497                value='${A}: ${B}',
1498                subs=[
1499                    ('${A}', bs.Lstr(resource='timeBonusText')),
1500                    ('${B}', str(self._time_bonus)),
1501                ],
1502            )
1503        else:
1504            self._time_bonus_text = None
1505
1506    def _start_updating_waves(self) -> None:
1507        self._wave_update_timer = bs.Timer(
1508            2.0, bs.WeakCall(self._update_waves), repeat=True
1509        )
1510
1511    def _update_scores(self) -> None:
1512        score = self._score
1513        if self._preset is Preset.ENDLESS:
1514            if score >= 500:
1515                self._award_achievement('Onslaught Master')
1516            if score >= 1000:
1517                self._award_achievement('Onslaught Wizard')
1518            if score >= 5000:
1519                self._award_achievement('Onslaught God')
1520        assert self._scoreboard is not None
1521        self._scoreboard.set_team_value(self.teams[0], score, max_score=None)
1522
1523    @override
1524    def handlemessage(self, msg: Any) -> Any:
1525        if isinstance(msg, PlayerSpazHurtMessage):
1526            msg.spaz.getplayer(Player, True).has_been_hurt = True
1527            self._a_player_has_been_hurt = True
1528
1529        elif isinstance(msg, bs.PlayerScoredMessage):
1530            self._score += msg.score
1531            self._update_scores()
1532
1533        elif isinstance(msg, bs.PlayerDiedMessage):
1534            super().handlemessage(msg)  # Augment standard behavior.
1535            player = msg.getplayer(Player)
1536            self._a_player_has_been_hurt = True
1537
1538            # Make note with the player when they can respawn:
1539            if self._wavenum < 10:
1540                player.respawn_wave = max(2, self._wavenum + 1)
1541            elif self._wavenum < 15:
1542                player.respawn_wave = max(2, self._wavenum + 2)
1543            else:
1544                player.respawn_wave = max(2, self._wavenum + 3)
1545            bs.timer(0.1, self._update_player_spawn_info)
1546            bs.timer(0.1, self._checkroundover)
1547
1548        elif isinstance(msg, SpazBotDiedMessage):
1549            pts, importance = msg.spazbot.get_death_points(msg.how)
1550            if msg.killerplayer is not None:
1551                self._handle_kill_achievements(msg)
1552                target: Sequence[float] | None
1553                if msg.spazbot.node:
1554                    target = msg.spazbot.node.position
1555                else:
1556                    target = None
1557
1558                killerplayer = msg.killerplayer
1559                self.stats.player_scored(
1560                    killerplayer,
1561                    pts,
1562                    target=target,
1563                    kill=True,
1564                    screenmessage=False,
1565                    importance=importance,
1566                )
1567                dingsound = (
1568                    self._dingsound if importance == 1 else self._dingsoundhigh
1569                )
1570                dingsound.play(volume=0.6)
1571
1572            # Normally we pull scores from the score-set, but if there's
1573            # no player lets be explicit.
1574            else:
1575                self._score += pts
1576            self._update_scores()
1577        else:
1578            super().handlemessage(msg)
1579
1580    def _handle_kill_achievements(self, msg: SpazBotDiedMessage) -> None:
1581        if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}:
1582            self._handle_training_kill_achievements(msg)
1583        elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}:
1584            self._handle_rookie_kill_achievements(msg)
1585        elif self._preset in {Preset.PRO, Preset.PRO_EASY}:
1586            self._handle_pro_kill_achievements(msg)
1587        elif self._preset in {Preset.UBER, Preset.UBER_EASY}:
1588            self._handle_uber_kill_achievements(msg)
1589
1590    def _handle_uber_kill_achievements(self, msg: SpazBotDiedMessage) -> None:
1591        # Uber mine achievement:
1592        if msg.spazbot.last_attacked_type == ('explosion', 'land_mine'):
1593            self._land_mine_kills += 1
1594            if self._land_mine_kills >= 6:
1595                self._award_achievement('Gold Miner')
1596
1597        # Uber tnt achievement:
1598        if msg.spazbot.last_attacked_type == ('explosion', 'tnt'):
1599            self._tnt_kills += 1
1600            if self._tnt_kills >= 6:
1601                bs.timer(
1602                    0.5, bs.WeakCall(self._award_achievement, 'TNT Terror')
1603                )
1604
1605    def _handle_pro_kill_achievements(self, msg: SpazBotDiedMessage) -> None:
1606        # TNT achievement:
1607        if msg.spazbot.last_attacked_type == ('explosion', 'tnt'):
1608            self._tnt_kills += 1
1609            if self._tnt_kills >= 3:
1610                bs.timer(
1611                    0.5,
1612                    bs.WeakCall(
1613                        self._award_achievement, 'Boom Goes the Dynamite'
1614                    ),
1615                )
1616
1617    def _handle_rookie_kill_achievements(self, msg: SpazBotDiedMessage) -> None:
1618        # Land-mine achievement:
1619        if msg.spazbot.last_attacked_type == ('explosion', 'land_mine'):
1620            self._land_mine_kills += 1
1621            if self._land_mine_kills >= 3:
1622                self._award_achievement('Mine Games')
1623
1624    def _handle_training_kill_achievements(
1625        self, msg: SpazBotDiedMessage
1626    ) -> None:
1627        # Toss-off-map achievement:
1628        if msg.spazbot.last_attacked_type == ('picked_up', 'default'):
1629            self._throw_off_kills += 1
1630            if self._throw_off_kills >= 3:
1631                self._award_achievement('Off You Go Then')
1632
1633    def _set_can_end_wave(self) -> None:
1634        self._can_end_wave = True
1635
1636    @override
1637    def end_game(self) -> None:
1638        # Tell our bots to celebrate just to rub it in.
1639        assert self._bots is not None
1640        self._bots.final_celebrate()
1641        self._game_over = True
1642        self.do_end('defeat', delay=2.0)
1643        bs.setmusic(None)
1644
1645    def _checkroundover(self) -> None:
1646        """Potentially end the round based on the state of the game."""
1647        if self.has_ended():
1648            return
1649        if not any(player.is_alive() for player in self.teams[0].players):
1650            self.end_game()
@dataclass
class Wave:
57@dataclass
58class Wave:
59    """A wave of enemies."""
60
61    entries: list[Spawn | Spacing | Delay | None]
62    base_angle: float = 0.0

A wave of enemies.

Wave( entries: list[Spawn | Spacing | Delay | None], base_angle: float = 0.0)
entries: list[Spawn | Spacing | Delay | None]
base_angle: float = 0.0
@dataclass
class Spawn:
65@dataclass
66class Spawn:
67    """A bot spawn event in a wave."""
68
69    bottype: type[SpazBot] | str
70    point: Point | None = None
71    spacing: float = 5.0

A bot spawn event in a wave.

Spawn( bottype: type[bascenev1lib.actor.spazbot.SpazBot] | str, point: Point | None = None, spacing: float = 5.0)
bottype: type[bascenev1lib.actor.spazbot.SpazBot] | str
point: Point | None = None
spacing: float = 5.0
@dataclass
class Spacing:
74@dataclass
75class Spacing:
76    """Empty space in a wave."""
77
78    spacing: float = 5.0

Empty space in a wave.

Spacing(spacing: float = 5.0)
spacing: float = 5.0
@dataclass
class Delay:
81@dataclass
82class Delay:
83    """A delay between events in a wave."""
84
85    duration: float

A delay between events in a wave.

Delay(duration: float)
duration: float
class Preset(enum.Enum):
 88class Preset(Enum):
 89    """Game presets we support."""
 90
 91    TRAINING = 'training'
 92    TRAINING_EASY = 'training_easy'
 93    ROOKIE = 'rookie'
 94    ROOKIE_EASY = 'rookie_easy'
 95    PRO = 'pro'
 96    PRO_EASY = 'pro_easy'
 97    UBER = 'uber'
 98    UBER_EASY = 'uber_easy'
 99    ENDLESS = 'endless'
100    ENDLESS_TOURNAMENT = 'endless_tournament'

Game presets we support.

TRAINING = <Preset.TRAINING: 'training'>
TRAINING_EASY = <Preset.TRAINING_EASY: 'training_easy'>
ROOKIE = <Preset.ROOKIE: 'rookie'>
ROOKIE_EASY = <Preset.ROOKIE_EASY: 'rookie_easy'>
PRO = <Preset.PRO: 'pro'>
PRO_EASY = <Preset.PRO_EASY: 'pro_easy'>
UBER = <Preset.UBER: 'uber'>
UBER_EASY = <Preset.UBER_EASY: 'uber_easy'>
ENDLESS = <Preset.ENDLESS: 'endless'>
ENDLESS_TOURNAMENT = <Preset.ENDLESS_TOURNAMENT: 'endless_tournament'>
@unique
class Point(enum.Enum):
103@unique
104class Point(Enum):
105    """Points on the map we can spawn at."""
106
107    LEFT_UPPER_MORE = 'bot_spawn_left_upper_more'
108    LEFT_UPPER = 'bot_spawn_left_upper'
109    TURRET_TOP_RIGHT = 'bot_spawn_turret_top_right'
110    RIGHT_UPPER = 'bot_spawn_right_upper'
111    TURRET_TOP_MIDDLE_LEFT = 'bot_spawn_turret_top_middle_left'
112    TURRET_TOP_MIDDLE_RIGHT = 'bot_spawn_turret_top_middle_right'
113    TURRET_TOP_LEFT = 'bot_spawn_turret_top_left'
114    TOP_RIGHT = 'bot_spawn_top_right'
115    TOP_LEFT = 'bot_spawn_top_left'
116    TOP = 'bot_spawn_top'
117    BOTTOM = 'bot_spawn_bottom'
118    LEFT = 'bot_spawn_left'
119    RIGHT = 'bot_spawn_right'
120    RIGHT_UPPER_MORE = 'bot_spawn_right_upper_more'
121    RIGHT_LOWER = 'bot_spawn_right_lower'
122    RIGHT_LOWER_MORE = 'bot_spawn_right_lower_more'
123    BOTTOM_RIGHT = 'bot_spawn_bottom_right'
124    BOTTOM_LEFT = 'bot_spawn_bottom_left'
125    TURRET_BOTTOM_RIGHT = 'bot_spawn_turret_bottom_right'
126    TURRET_BOTTOM_LEFT = 'bot_spawn_turret_bottom_left'
127    LEFT_LOWER = 'bot_spawn_left_lower'
128    LEFT_LOWER_MORE = 'bot_spawn_left_lower_more'
129    TURRET_TOP_MIDDLE = 'bot_spawn_turret_top_middle'
130    BOTTOM_HALF_RIGHT = 'bot_spawn_bottom_half_right'
131    BOTTOM_HALF_LEFT = 'bot_spawn_bottom_half_left'
132    TOP_HALF_RIGHT = 'bot_spawn_top_half_right'
133    TOP_HALF_LEFT = 'bot_spawn_top_half_left'

Points on the map we can spawn at.

LEFT_UPPER_MORE = <Point.LEFT_UPPER_MORE: 'bot_spawn_left_upper_more'>
LEFT_UPPER = <Point.LEFT_UPPER: 'bot_spawn_left_upper'>
TURRET_TOP_RIGHT = <Point.TURRET_TOP_RIGHT: 'bot_spawn_turret_top_right'>
RIGHT_UPPER = <Point.RIGHT_UPPER: 'bot_spawn_right_upper'>
TURRET_TOP_MIDDLE_LEFT = <Point.TURRET_TOP_MIDDLE_LEFT: 'bot_spawn_turret_top_middle_left'>
TURRET_TOP_MIDDLE_RIGHT = <Point.TURRET_TOP_MIDDLE_RIGHT: 'bot_spawn_turret_top_middle_right'>
TURRET_TOP_LEFT = <Point.TURRET_TOP_LEFT: 'bot_spawn_turret_top_left'>
TOP_RIGHT = <Point.TOP_RIGHT: 'bot_spawn_top_right'>
TOP_LEFT = <Point.TOP_LEFT: 'bot_spawn_top_left'>
TOP = <Point.TOP: 'bot_spawn_top'>
BOTTOM = <Point.BOTTOM: 'bot_spawn_bottom'>
LEFT = <Point.LEFT: 'bot_spawn_left'>
RIGHT = <Point.RIGHT: 'bot_spawn_right'>
RIGHT_UPPER_MORE = <Point.RIGHT_UPPER_MORE: 'bot_spawn_right_upper_more'>
RIGHT_LOWER = <Point.RIGHT_LOWER: 'bot_spawn_right_lower'>
RIGHT_LOWER_MORE = <Point.RIGHT_LOWER_MORE: 'bot_spawn_right_lower_more'>
BOTTOM_RIGHT = <Point.BOTTOM_RIGHT: 'bot_spawn_bottom_right'>
BOTTOM_LEFT = <Point.BOTTOM_LEFT: 'bot_spawn_bottom_left'>
TURRET_BOTTOM_RIGHT = <Point.TURRET_BOTTOM_RIGHT: 'bot_spawn_turret_bottom_right'>
TURRET_BOTTOM_LEFT = <Point.TURRET_BOTTOM_LEFT: 'bot_spawn_turret_bottom_left'>
LEFT_LOWER = <Point.LEFT_LOWER: 'bot_spawn_left_lower'>
LEFT_LOWER_MORE = <Point.LEFT_LOWER_MORE: 'bot_spawn_left_lower_more'>
TURRET_TOP_MIDDLE = <Point.TURRET_TOP_MIDDLE: 'bot_spawn_turret_top_middle'>
BOTTOM_HALF_RIGHT = <Point.BOTTOM_HALF_RIGHT: 'bot_spawn_bottom_half_right'>
BOTTOM_HALF_LEFT = <Point.BOTTOM_HALF_LEFT: 'bot_spawn_bottom_half_left'>
TOP_HALF_RIGHT = <Point.TOP_HALF_RIGHT: 'bot_spawn_top_half_right'>
TOP_HALF_LEFT = <Point.TOP_HALF_LEFT: 'bot_spawn_top_half_left'>
class Player(bascenev1._player.Player[ForwardRef('Team')]):
136class Player(bs.Player['Team']):
137    """Our player type for this game."""
138
139    def __init__(self) -> None:
140        self.has_been_hurt = False
141        self.respawn_wave = 0

Our player type for this game.

has_been_hurt
respawn_wave
class Team(bascenev1._team.Team[bascenev1lib.game.onslaught.Player]):
144class Team(bs.Team[Player]):
145    """Our team type for this game."""

Our team type for this game.

class OnslaughtGame(bascenev1._coopgame.CoopGameActivity[bascenev1lib.game.onslaught.Player, bascenev1lib.game.onslaught.Team]):
 148class OnslaughtGame(bs.CoopGameActivity[Player, Team]):
 149    """Co-op game where players try to survive attacking waves of enemies."""
 150
 151    name = 'Onslaught'
 152    description = 'Defeat all enemies.'
 153
 154    tips: list[str | bs.GameTip] = [
 155        'Hold any button to run.'
 156        '  (Trigger buttons work well if you have them)',
 157        'Try tricking enemies into killing eachother or running off cliffs.',
 158        'Try \'Cooking off\' bombs for a second or two before throwing them.',
 159        'It\'s easier to win with a friend or two helping.',
 160        'If you stay in one place, you\'re toast. Run and dodge to survive..',
 161        'Practice using your momentum to throw bombs more accurately.',
 162        'Your punches do much more damage if you are running or spinning.',
 163    ]
 164
 165    # Show messages when players die since it matters here.
 166    announce_player_deaths = True
 167
 168    def __init__(self, settings: dict):
 169        self._preset = Preset(settings.get('preset', 'training'))
 170        if self._preset in {
 171            Preset.TRAINING,
 172            Preset.TRAINING_EASY,
 173            Preset.PRO,
 174            Preset.PRO_EASY,
 175            Preset.ENDLESS,
 176            Preset.ENDLESS_TOURNAMENT,
 177        }:
 178            settings['map'] = 'Doom Shroom'
 179        else:
 180            settings['map'] = 'Courtyard'
 181
 182        super().__init__(settings)
 183
 184        self._new_wave_sound = bs.getsound('scoreHit01')
 185        self._winsound = bs.getsound('score')
 186        self._cashregistersound = bs.getsound('cashRegister')
 187        self._a_player_has_been_hurt = False
 188        self._player_has_dropped_bomb = False
 189
 190        # FIXME: should use standard map defs.
 191        if settings['map'] == 'Doom Shroom':
 192            self._spawn_center = (0, 3, -5)
 193            self._tntspawnpos = (0.0, 3.0, -5.0)
 194            self._powerup_center = (0, 5, -3.6)
 195            self._powerup_spread = (6.0, 4.0)
 196        elif settings['map'] == 'Courtyard':
 197            self._spawn_center = (0, 3, -2)
 198            self._tntspawnpos = (0.0, 3.0, 2.1)
 199            self._powerup_center = (0, 5, -1.6)
 200            self._powerup_spread = (4.6, 2.7)
 201        else:
 202            raise RuntimeError('Unsupported map: ' + str(settings['map']))
 203        self._scoreboard: Scoreboard | None = None
 204        self._game_over = False
 205        self._wavenum = 0
 206        self._can_end_wave = True
 207        self._score = 0
 208        self._time_bonus = 0
 209        self._spawn_info_text: bs.NodeActor | None = None
 210        self._dingsound = bs.getsound('dingSmall')
 211        self._dingsoundhigh = bs.getsound('dingSmallHigh')
 212        self._have_tnt = False
 213        self._excluded_powerups: list[str] | None = None
 214        self._waves: list[Wave] = []
 215        self._tntspawner: TNTSpawner | None = None
 216        self._bots: SpazBotSet | None = None
 217        self._powerup_drop_timer: bs.Timer | None = None
 218        self._time_bonus_timer: bs.Timer | None = None
 219        self._time_bonus_text: bs.NodeActor | None = None
 220        self._flawless_bonus: int | None = None
 221        self._wave_text: bs.NodeActor | None = None
 222        self._wave_update_timer: bs.Timer | None = None
 223        self._throw_off_kills = 0
 224        self._land_mine_kills = 0
 225        self._tnt_kills = 0
 226
 227    @override
 228    def on_transition_in(self) -> None:
 229        super().on_transition_in()
 230        customdata = bs.getsession().customdata
 231
 232        # Show special landmine tip on rookie preset.
 233        if self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}:
 234            # Show once per session only (then we revert to regular tips).
 235            if not customdata.get('_showed_onslaught_landmine_tip', False):
 236                customdata['_showed_onslaught_landmine_tip'] = True
 237                self.tips = [
 238                    bs.GameTip(
 239                        'Land-mines are a good way to stop speedy enemies.',
 240                        icon=bs.gettexture('powerupLandMines'),
 241                        sound=bs.getsound('ding'),
 242                    )
 243                ]
 244
 245        # Show special tnt tip on pro preset.
 246        if self._preset in {Preset.PRO, Preset.PRO_EASY}:
 247            # Show once per session only (then we revert to regular tips).
 248            if not customdata.get('_showed_onslaught_tnt_tip', False):
 249                customdata['_showed_onslaught_tnt_tip'] = True
 250                self.tips = [
 251                    bs.GameTip(
 252                        'Take out a group of enemies by\n'
 253                        'setting off a bomb near a TNT box.',
 254                        icon=bs.gettexture('tnt'),
 255                        sound=bs.getsound('ding'),
 256                    )
 257                ]
 258
 259        # Show special curse tip on uber preset.
 260        if self._preset in {Preset.UBER, Preset.UBER_EASY}:
 261            # Show once per session only (then we revert to regular tips).
 262            if not customdata.get('_showed_onslaught_curse_tip', False):
 263                customdata['_showed_onslaught_curse_tip'] = True
 264                self.tips = [
 265                    bs.GameTip(
 266                        'Curse boxes turn you into a ticking time bomb.\n'
 267                        'The only cure is to quickly grab a health-pack.',
 268                        icon=bs.gettexture('powerupCurse'),
 269                        sound=bs.getsound('ding'),
 270                    )
 271                ]
 272
 273        self._spawn_info_text = bs.NodeActor(
 274            bs.newnode(
 275                'text',
 276                attrs={
 277                    'position': (15, -130),
 278                    'h_attach': 'left',
 279                    'v_attach': 'top',
 280                    'scale': 0.55,
 281                    'color': (0.3, 0.8, 0.3, 1.0),
 282                    'text': '',
 283                },
 284            )
 285        )
 286        bs.setmusic(bs.MusicType.ONSLAUGHT)
 287
 288        self._scoreboard = Scoreboard(
 289            label=bs.Lstr(resource='scoreText'), score_split=0.5
 290        )
 291
 292    @override
 293    def on_begin(self) -> None:
 294        super().on_begin()
 295        player_count = len(self.players)
 296        hard = self._preset not in {
 297            Preset.TRAINING_EASY,
 298            Preset.ROOKIE_EASY,
 299            Preset.PRO_EASY,
 300            Preset.UBER_EASY,
 301        }
 302        if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}:
 303            ControlsGuide(delay=3.0, lifespan=10.0, bright=True).autoretain()
 304
 305            self._have_tnt = False
 306            self._excluded_powerups = ['curse', 'land_mines']
 307            self._waves = [
 308                Wave(
 309                    base_angle=195,
 310                    entries=[
 311                        Spawn(BomberBotLite, spacing=5),
 312                    ]
 313                    * player_count,
 314                ),
 315                Wave(
 316                    base_angle=130,
 317                    entries=[
 318                        Spawn(BrawlerBotLite, spacing=5),
 319                    ]
 320                    * player_count,
 321                ),
 322                Wave(
 323                    base_angle=195,
 324                    entries=[Spawn(BomberBotLite, spacing=10)]
 325                    * (player_count + 1),
 326                ),
 327                Wave(
 328                    base_angle=130,
 329                    entries=[
 330                        Spawn(BrawlerBotLite, spacing=10),
 331                    ]
 332                    * (player_count + 1),
 333                ),
 334                Wave(
 335                    base_angle=130,
 336                    entries=[
 337                        (
 338                            Spawn(BrawlerBotLite, spacing=5)
 339                            if player_count > 1
 340                            else None
 341                        ),
 342                        Spawn(BrawlerBotLite, spacing=5),
 343                        Spacing(30),
 344                        (
 345                            Spawn(BomberBotLite, spacing=5)
 346                            if player_count > 3
 347                            else None
 348                        ),
 349                        Spawn(BomberBotLite, spacing=5),
 350                        Spacing(30),
 351                        Spawn(BrawlerBotLite, spacing=5),
 352                        (
 353                            Spawn(BrawlerBotLite, spacing=5)
 354                            if player_count > 2
 355                            else None
 356                        ),
 357                    ],
 358                ),
 359                Wave(
 360                    base_angle=195,
 361                    entries=[
 362                        Spawn(TriggerBot, spacing=90),
 363                        (
 364                            Spawn(TriggerBot, spacing=90)
 365                            if player_count > 1
 366                            else None
 367                        ),
 368                    ],
 369                ),
 370            ]
 371
 372        elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}:
 373            self._have_tnt = False
 374            self._excluded_powerups = ['curse']
 375            self._waves = [
 376                Wave(
 377                    entries=[
 378                        (
 379                            Spawn(ChargerBot, Point.LEFT_UPPER_MORE)
 380                            if player_count > 2
 381                            else None
 382                        ),
 383                        Spawn(ChargerBot, Point.LEFT_UPPER),
 384                    ]
 385                ),
 386                Wave(
 387                    entries=[
 388                        Spawn(BomberBotStaticLite, Point.TURRET_TOP_RIGHT),
 389                        Spawn(BrawlerBotLite, Point.RIGHT_UPPER),
 390                        (
 391                            Spawn(BrawlerBotLite, Point.RIGHT_LOWER)
 392                            if player_count > 1
 393                            else None
 394                        ),
 395                        (
 396                            Spawn(
 397                                BomberBotStaticLite, Point.TURRET_BOTTOM_RIGHT
 398                            )
 399                            if player_count > 2
 400                            else None
 401                        ),
 402                    ]
 403                ),
 404                Wave(
 405                    entries=[
 406                        Spawn(BomberBotStaticLite, Point.TURRET_BOTTOM_LEFT),
 407                        Spawn(TriggerBot, Point.LEFT),
 408                        (
 409                            Spawn(TriggerBot, Point.LEFT_LOWER)
 410                            if player_count > 1
 411                            else None
 412                        ),
 413                        (
 414                            Spawn(TriggerBot, Point.LEFT_UPPER)
 415                            if player_count > 2
 416                            else None
 417                        ),
 418                    ]
 419                ),
 420                Wave(
 421                    entries=[
 422                        Spawn(BrawlerBotLite, Point.TOP_RIGHT),
 423                        (
 424                            Spawn(BrawlerBot, Point.TOP_HALF_RIGHT)
 425                            if player_count > 1
 426                            else None
 427                        ),
 428                        Spawn(BrawlerBotLite, Point.TOP_LEFT),
 429                        (
 430                            Spawn(BrawlerBotLite, Point.TOP_HALF_LEFT)
 431                            if player_count > 2
 432                            else None
 433                        ),
 434                        Spawn(BrawlerBot, Point.TOP),
 435                        Spawn(BomberBotStaticLite, Point.TURRET_TOP_MIDDLE),
 436                    ]
 437                ),
 438                Wave(
 439                    entries=[
 440                        Spawn(TriggerBotStatic, Point.TURRET_BOTTOM_LEFT),
 441                        Spawn(TriggerBotStatic, Point.TURRET_BOTTOM_RIGHT),
 442                        Spawn(TriggerBot, Point.BOTTOM),
 443                        (
 444                            Spawn(TriggerBot, Point.BOTTOM_HALF_RIGHT)
 445                            if player_count > 1
 446                            else None
 447                        ),
 448                        (
 449                            Spawn(TriggerBot, Point.BOTTOM_HALF_LEFT)
 450                            if player_count > 2
 451                            else None
 452                        ),
 453                    ]
 454                ),
 455                Wave(
 456                    entries=[
 457                        Spawn(BomberBotStaticLite, Point.TURRET_TOP_LEFT),
 458                        Spawn(BomberBotStaticLite, Point.TURRET_TOP_RIGHT),
 459                        Spawn(ChargerBot, Point.BOTTOM),
 460                        (
 461                            Spawn(ChargerBot, Point.BOTTOM_HALF_LEFT)
 462                            if player_count > 1
 463                            else None
 464                        ),
 465                        (
 466                            Spawn(ChargerBot, Point.BOTTOM_HALF_RIGHT)
 467                            if player_count > 2
 468                            else None
 469                        ),
 470                    ]
 471                ),
 472            ]
 473
 474        elif self._preset in {Preset.PRO, Preset.PRO_EASY}:
 475            self._excluded_powerups = ['curse']
 476            self._have_tnt = True
 477            self._waves = [
 478                Wave(
 479                    base_angle=-50,
 480                    entries=[
 481                        (
 482                            Spawn(BrawlerBot, spacing=12)
 483                            if player_count > 3
 484                            else None
 485                        ),
 486                        Spawn(BrawlerBot, spacing=12),
 487                        Spawn(BomberBot, spacing=6),
 488                        (
 489                            Spawn(BomberBot, spacing=6)
 490                            if self._preset is Preset.PRO
 491                            else None
 492                        ),
 493                        (
 494                            Spawn(BomberBot, spacing=6)
 495                            if player_count > 1
 496                            else None
 497                        ),
 498                        Spawn(BrawlerBot, spacing=12),
 499                        (
 500                            Spawn(BrawlerBot, spacing=12)
 501                            if player_count > 2
 502                            else None
 503                        ),
 504                    ],
 505                ),
 506                Wave(
 507                    base_angle=180,
 508                    entries=[
 509                        (
 510                            Spawn(BrawlerBot, spacing=6)
 511                            if player_count > 3
 512                            else None
 513                        ),
 514                        (
 515                            Spawn(BrawlerBot, spacing=6)
 516                            if self._preset is Preset.PRO
 517                            else None
 518                        ),
 519                        Spawn(BrawlerBot, spacing=6),
 520                        Spawn(ChargerBot, spacing=45),
 521                        (
 522                            Spawn(ChargerBot, spacing=45)
 523                            if player_count > 1
 524                            else None
 525                        ),
 526                        Spawn(BrawlerBot, spacing=6),
 527                        (
 528                            Spawn(BrawlerBot, spacing=6)
 529                            if self._preset is Preset.PRO
 530                            else None
 531                        ),
 532                        (
 533                            Spawn(BrawlerBot, spacing=6)
 534                            if player_count > 2
 535                            else None
 536                        ),
 537                    ],
 538                ),
 539                Wave(
 540                    base_angle=0,
 541                    entries=[
 542                        Spawn(ChargerBot, spacing=30),
 543                        Spawn(TriggerBot, spacing=30),
 544                        Spawn(TriggerBot, spacing=30),
 545                        (
 546                            Spawn(TriggerBot, spacing=30)
 547                            if self._preset is Preset.PRO
 548                            else None
 549                        ),
 550                        (
 551                            Spawn(TriggerBot, spacing=30)
 552                            if player_count > 1
 553                            else None
 554                        ),
 555                        (
 556                            Spawn(TriggerBot, spacing=30)
 557                            if player_count > 3
 558                            else None
 559                        ),
 560                        Spawn(ChargerBot, spacing=30),
 561                    ],
 562                ),
 563                Wave(
 564                    base_angle=90,
 565                    entries=[
 566                        Spawn(StickyBot, spacing=50),
 567                        (
 568                            Spawn(StickyBot, spacing=50)
 569                            if self._preset is Preset.PRO
 570                            else None
 571                        ),
 572                        Spawn(StickyBot, spacing=50),
 573                        (
 574                            Spawn(StickyBot, spacing=50)
 575                            if player_count > 1
 576                            else None
 577                        ),
 578                        (
 579                            Spawn(StickyBot, spacing=50)
 580                            if player_count > 3
 581                            else None
 582                        ),
 583                    ],
 584                ),
 585                Wave(
 586                    base_angle=0,
 587                    entries=[
 588                        Spawn(TriggerBot, spacing=72),
 589                        Spawn(TriggerBot, spacing=72),
 590                        (
 591                            Spawn(TriggerBot, spacing=72)
 592                            if self._preset is Preset.PRO
 593                            else None
 594                        ),
 595                        Spawn(TriggerBot, spacing=72),
 596                        Spawn(TriggerBot, spacing=72),
 597                        (
 598                            Spawn(TriggerBot, spacing=36)
 599                            if player_count > 2
 600                            else None
 601                        ),
 602                    ],
 603                ),
 604                Wave(
 605                    base_angle=30,
 606                    entries=[
 607                        Spawn(ChargerBotProShielded, spacing=50),
 608                        Spawn(ChargerBotProShielded, spacing=50),
 609                        (
 610                            Spawn(ChargerBotProShielded, spacing=50)
 611                            if self._preset is Preset.PRO
 612                            else None
 613                        ),
 614                        (
 615                            Spawn(ChargerBotProShielded, spacing=50)
 616                            if player_count > 1
 617                            else None
 618                        ),
 619                        (
 620                            Spawn(ChargerBotProShielded, spacing=50)
 621                            if player_count > 2
 622                            else None
 623                        ),
 624                    ],
 625                ),
 626            ]
 627
 628        elif self._preset in {Preset.UBER, Preset.UBER_EASY}:
 629            # Show controls help in demo or arcade modes.
 630            env = bs.app.env
 631            if env.demo or env.arcade:
 632                ControlsGuide(
 633                    delay=3.0, lifespan=10.0, bright=True
 634                ).autoretain()
 635
 636            self._have_tnt = True
 637            self._excluded_powerups = []
 638            self._waves = [
 639                Wave(
 640                    entries=[
 641                        (
 642                            Spawn(
 643                                BomberBotProStatic, Point.TURRET_TOP_MIDDLE_LEFT
 644                            )
 645                            if hard
 646                            else None
 647                        ),
 648                        Spawn(
 649                            BomberBotProStatic, Point.TURRET_TOP_MIDDLE_RIGHT
 650                        ),
 651                        (
 652                            Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT)
 653                            if player_count > 2
 654                            else None
 655                        ),
 656                        Spawn(ExplodeyBot, Point.TOP_RIGHT),
 657                        Delay(4.0),
 658                        Spawn(ExplodeyBot, Point.TOP_LEFT),
 659                    ]
 660                ),
 661                Wave(
 662                    entries=[
 663                        Spawn(ChargerBot, Point.LEFT),
 664                        Spawn(ChargerBot, Point.RIGHT),
 665                        (
 666                            Spawn(ChargerBot, Point.RIGHT_UPPER_MORE)
 667                            if player_count > 2
 668                            else None
 669                        ),
 670                        Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT),
 671                        Spawn(BomberBotProStatic, Point.TURRET_TOP_RIGHT),
 672                    ]
 673                ),
 674                Wave(
 675                    entries=[
 676                        Spawn(TriggerBotPro, Point.TOP_RIGHT),
 677                        (
 678                            Spawn(TriggerBotPro, Point.RIGHT_UPPER_MORE)
 679                            if player_count > 1
 680                            else None
 681                        ),
 682                        Spawn(TriggerBotPro, Point.RIGHT_UPPER),
 683                        (
 684                            Spawn(TriggerBotPro, Point.RIGHT_LOWER)
 685                            if hard
 686                            else None
 687                        ),
 688                        (
 689                            Spawn(TriggerBotPro, Point.RIGHT_LOWER_MORE)
 690                            if player_count > 2
 691                            else None
 692                        ),
 693                        Spawn(TriggerBotPro, Point.BOTTOM_RIGHT),
 694                    ]
 695                ),
 696                Wave(
 697                    entries=[
 698                        Spawn(ChargerBotProShielded, Point.BOTTOM_RIGHT),
 699                        (
 700                            Spawn(ChargerBotProShielded, Point.BOTTOM)
 701                            if player_count > 2
 702                            else None
 703                        ),
 704                        Spawn(ChargerBotProShielded, Point.BOTTOM_LEFT),
 705                        (
 706                            Spawn(ChargerBotProShielded, Point.TOP)
 707                            if hard
 708                            else None
 709                        ),
 710                        Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE),
 711                    ]
 712                ),
 713                Wave(
 714                    entries=[
 715                        Spawn(ExplodeyBot, Point.LEFT_UPPER),
 716                        Delay(1.0),
 717                        Spawn(BrawlerBotProShielded, Point.LEFT_LOWER),
 718                        Spawn(BrawlerBotProShielded, Point.LEFT_LOWER_MORE),
 719                        Delay(4.0),
 720                        Spawn(ExplodeyBot, Point.RIGHT_UPPER),
 721                        Delay(1.0),
 722                        Spawn(BrawlerBotProShielded, Point.RIGHT_LOWER),
 723                        Spawn(BrawlerBotProShielded, Point.RIGHT_UPPER_MORE),
 724                        Delay(4.0),
 725                        Spawn(ExplodeyBot, Point.LEFT),
 726                        Delay(5.0),
 727                        Spawn(ExplodeyBot, Point.RIGHT),
 728                    ]
 729                ),
 730                Wave(
 731                    entries=[
 732                        Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT),
 733                        Spawn(BomberBotProStatic, Point.TURRET_TOP_RIGHT),
 734                        Spawn(BomberBotProStatic, Point.TURRET_BOTTOM_LEFT),
 735                        Spawn(BomberBotProStatic, Point.TURRET_BOTTOM_RIGHT),
 736                        (
 737                            Spawn(
 738                                BomberBotProStatic, Point.TURRET_TOP_MIDDLE_LEFT
 739                            )
 740                            if hard
 741                            else None
 742                        ),
 743                        (
 744                            Spawn(
 745                                BomberBotProStatic,
 746                                Point.TURRET_TOP_MIDDLE_RIGHT,
 747                            )
 748                            if hard
 749                            else None
 750                        ),
 751                    ]
 752                ),
 753            ]
 754
 755        # We generate these on the fly in endless.
 756        elif self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
 757            self._have_tnt = True
 758            self._excluded_powerups = []
 759            self._waves = []
 760
 761        else:
 762            raise RuntimeError(f'Invalid preset: {self._preset}')
 763
 764        # FIXME: Should migrate to use setup_standard_powerup_drops().
 765
 766        # Spit out a few powerups and start dropping more shortly.
 767        self._drop_powerups(
 768            standard_points=True,
 769            poweruptype=(
 770                'curse'
 771                if self._preset in [Preset.UBER, Preset.UBER_EASY]
 772                else (
 773                    'land_mines'
 774                    if self._preset in [Preset.ROOKIE, Preset.ROOKIE_EASY]
 775                    else None
 776                )
 777            ),
 778        )
 779        bs.timer(4.0, self._start_powerup_drops)
 780
 781        # Our TNT spawner (if applicable).
 782        if self._have_tnt:
 783            self._tntspawner = TNTSpawner(position=self._tntspawnpos)
 784
 785        self.setup_low_life_warning_sound()
 786        self._update_scores()
 787        self._bots = SpazBotSet()
 788        bs.timer(4.0, self._start_updating_waves)
 789
 790    def _get_dist_grp_totals(self, grps: list[Any]) -> tuple[int, int]:
 791        totalpts = 0
 792        totaldudes = 0
 793        for grp in grps:
 794            for grpentry in grp:
 795                dudes = grpentry[1]
 796                totalpts += grpentry[0] * dudes
 797                totaldudes += dudes
 798        return totalpts, totaldudes
 799
 800    def _get_distribution(
 801        self,
 802        target_points: int,
 803        min_dudes: int,
 804        max_dudes: int,
 805        group_count: int,
 806        max_level: int,
 807    ) -> list[list[tuple[int, int]]]:
 808        """Calculate a distribution of bad guys given some params."""
 809        # pylint: disable=too-many-positional-arguments
 810        max_iterations = 10 + max_dudes * 2
 811
 812        groups: list[list[tuple[int, int]]] = []
 813        for _g in range(group_count):
 814            groups.append([])
 815        types = [1]
 816        if max_level > 1:
 817            types.append(2)
 818        if max_level > 2:
 819            types.append(3)
 820        if max_level > 3:
 821            types.append(4)
 822        for iteration in range(max_iterations):
 823            diff = self._add_dist_entry_if_possible(
 824                groups, max_dudes, target_points, types
 825            )
 826
 827            total_points, total_dudes = self._get_dist_grp_totals(groups)
 828            full = total_points >= target_points
 829
 830            if full:
 831                # Every so often, delete a random entry just to
 832                # shake up our distribution.
 833                if random.random() < 0.2 and iteration != max_iterations - 1:
 834                    self._delete_random_dist_entry(groups)
 835
 836                # If we don't have enough dudes, kill the group with
 837                # the biggest point value.
 838                elif (
 839                    total_dudes < min_dudes and iteration != max_iterations - 1
 840                ):
 841                    self._delete_biggest_dist_entry(groups)
 842
 843                # If we've got too many dudes, kill the group with the
 844                # smallest point value.
 845                elif (
 846                    total_dudes > max_dudes and iteration != max_iterations - 1
 847                ):
 848                    self._delete_smallest_dist_entry(groups)
 849
 850                # Close enough.. we're done.
 851                else:
 852                    if diff == 0:
 853                        break
 854
 855        return groups
 856
 857    def _add_dist_entry_if_possible(
 858        self,
 859        groups: list[list[tuple[int, int]]],
 860        max_dudes: int,
 861        target_points: int,
 862        types: list[int],
 863    ) -> int:
 864        # See how much we're off our target by.
 865        total_points, total_dudes = self._get_dist_grp_totals(groups)
 866        diff = target_points - total_points
 867        dudes_diff = max_dudes - total_dudes
 868
 869        # Add an entry if one will fit.
 870        value = types[random.randrange(len(types))]
 871        group = groups[random.randrange(len(groups))]
 872        if not group:
 873            max_count = random.randint(1, 6)
 874        else:
 875            max_count = 2 * random.randint(1, 3)
 876        max_count = min(max_count, dudes_diff)
 877        count = min(max_count, diff // value)
 878        if count > 0:
 879            group.append((value, count))
 880            total_points += value * count
 881            total_dudes += count
 882            diff = target_points - total_points
 883        return diff
 884
 885    def _delete_smallest_dist_entry(
 886        self, groups: list[list[tuple[int, int]]]
 887    ) -> None:
 888        smallest_value = 9999
 889        smallest_entry = None
 890        smallest_entry_group = None
 891        for group in groups:
 892            for entry in group:
 893                if entry[0] < smallest_value or smallest_entry is None:
 894                    smallest_value = entry[0]
 895                    smallest_entry = entry
 896                    smallest_entry_group = group
 897        assert smallest_entry is not None
 898        assert smallest_entry_group is not None
 899        smallest_entry_group.remove(smallest_entry)
 900
 901    def _delete_biggest_dist_entry(
 902        self, groups: list[list[tuple[int, int]]]
 903    ) -> None:
 904        biggest_value = 9999
 905        biggest_entry = None
 906        biggest_entry_group = None
 907        for group in groups:
 908            for entry in group:
 909                if entry[0] > biggest_value or biggest_entry is None:
 910                    biggest_value = entry[0]
 911                    biggest_entry = entry
 912                    biggest_entry_group = group
 913        if biggest_entry is not None:
 914            assert biggest_entry_group is not None
 915            biggest_entry_group.remove(biggest_entry)
 916
 917    def _delete_random_dist_entry(
 918        self, groups: list[list[tuple[int, int]]]
 919    ) -> None:
 920        entry_count = 0
 921        for group in groups:
 922            for _ in group:
 923                entry_count += 1
 924        if entry_count > 1:
 925            del_entry = random.randrange(entry_count)
 926            entry_count = 0
 927            for group in groups:
 928                for entry in group:
 929                    if entry_count == del_entry:
 930                        group.remove(entry)
 931                        break
 932                    entry_count += 1
 933
 934    @override
 935    def spawn_player(self, player: Player) -> bs.Actor:
 936        # We keep track of who got hurt each wave for score purposes.
 937        player.has_been_hurt = False
 938        pos = (
 939            self._spawn_center[0] + random.uniform(-1.5, 1.5),
 940            self._spawn_center[1],
 941            self._spawn_center[2] + random.uniform(-1.5, 1.5),
 942        )
 943        spaz = self.spawn_player_spaz(player, position=pos)
 944        if self._preset in {
 945            Preset.TRAINING_EASY,
 946            Preset.ROOKIE_EASY,
 947            Preset.PRO_EASY,
 948            Preset.UBER_EASY,
 949        }:
 950            spaz.impact_scale = 0.25
 951        spaz.add_dropped_bomb_callback(self._handle_player_dropped_bomb)
 952        return spaz
 953
 954    def _handle_player_dropped_bomb(
 955        self, player: bs.Actor, bomb: bs.Actor
 956    ) -> None:
 957        del player, bomb  # Unused.
 958        self._player_has_dropped_bomb = True
 959
 960    def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None:
 961        poweruptype = PowerupBoxFactory.get().get_random_powerup_type(
 962            forcetype=poweruptype, excludetypes=self._excluded_powerups
 963        )
 964        PowerupBox(
 965            position=self.map.powerup_spawn_points[index],
 966            poweruptype=poweruptype,
 967        ).autoretain()
 968
 969    def _start_powerup_drops(self) -> None:
 970        self._powerup_drop_timer = bs.Timer(
 971            3.0, bs.WeakCall(self._drop_powerups), repeat=True
 972        )
 973
 974    def _drop_powerups(
 975        self, standard_points: bool = False, poweruptype: str | None = None
 976    ) -> None:
 977        """Generic powerup drop."""
 978        if standard_points:
 979            points = self.map.powerup_spawn_points
 980            for i in range(len(points)):
 981                bs.timer(
 982                    1.0 + i * 0.5,
 983                    bs.WeakCall(
 984                        self._drop_powerup, i, poweruptype if i == 0 else None
 985                    ),
 986                )
 987        else:
 988            point = (
 989                self._powerup_center[0]
 990                + random.uniform(
 991                    -1.0 * self._powerup_spread[0],
 992                    1.0 * self._powerup_spread[0],
 993                ),
 994                self._powerup_center[1],
 995                self._powerup_center[2]
 996                + random.uniform(
 997                    -self._powerup_spread[1], self._powerup_spread[1]
 998                ),
 999            )
1000
1001            # Drop one random one somewhere.
1002            PowerupBox(
1003                position=point,
1004                poweruptype=PowerupBoxFactory.get().get_random_powerup_type(
1005                    excludetypes=self._excluded_powerups
1006                ),
1007            ).autoretain()
1008
1009    def do_end(self, outcome: str, delay: float = 0.0) -> None:
1010        """End the game with the specified outcome."""
1011        if outcome == 'defeat':
1012            self.fade_to_red()
1013        score: int | None
1014        if self._wavenum >= 2:
1015            score = self._score
1016            fail_message = None
1017        else:
1018            score = None
1019            fail_message = bs.Lstr(resource='reachWave2Text')
1020        self.end(
1021            {
1022                'outcome': outcome,
1023                'score': score,
1024                'fail_message': fail_message,
1025                'playerinfos': self.initialplayerinfos,
1026            },
1027            delay=delay,
1028        )
1029
1030    def _award_completion_achievements(self) -> None:
1031        if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}:
1032            self._award_achievement('Onslaught Training Victory', sound=False)
1033            if not self._player_has_dropped_bomb:
1034                self._award_achievement('Boxer', sound=False)
1035        elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}:
1036            self._award_achievement('Rookie Onslaught Victory', sound=False)
1037            if not self._a_player_has_been_hurt:
1038                self._award_achievement('Flawless Victory', sound=False)
1039        elif self._preset in {Preset.PRO, Preset.PRO_EASY}:
1040            self._award_achievement('Pro Onslaught Victory', sound=False)
1041            if not self._player_has_dropped_bomb:
1042                self._award_achievement('Pro Boxer', sound=False)
1043        elif self._preset in {Preset.UBER, Preset.UBER_EASY}:
1044            self._award_achievement('Uber Onslaught Victory', sound=False)
1045
1046    def _update_waves(self) -> None:
1047        # If we have no living bots, go to the next wave.
1048        assert self._bots is not None
1049        if (
1050            self._can_end_wave
1051            and not self._bots.have_living_bots()
1052            and not self._game_over
1053        ):
1054            self._can_end_wave = False
1055            self._time_bonus_timer = None
1056            self._time_bonus_text = None
1057            if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
1058                won = False
1059            else:
1060                won = self._wavenum == len(self._waves)
1061
1062            base_delay = 4.0 if won else 0.0
1063
1064            # Reward time bonus.
1065            if self._time_bonus > 0:
1066                bs.timer(0, self._cashregistersound.play)
1067                bs.timer(
1068                    base_delay,
1069                    bs.WeakCall(self._award_time_bonus, self._time_bonus),
1070                )
1071                base_delay += 1.0
1072
1073            # Reward flawless bonus.
1074            if self._wavenum > 0:
1075                have_flawless = False
1076                for player in self.players:
1077                    if player.is_alive() and not player.has_been_hurt:
1078                        have_flawless = True
1079                        bs.timer(
1080                            base_delay,
1081                            bs.WeakCall(self._award_flawless_bonus, player),
1082                        )
1083                    player.has_been_hurt = False  # reset
1084                if have_flawless:
1085                    base_delay += 1.0
1086
1087            if won:
1088                self.show_zoom_message(
1089                    bs.Lstr(resource='victoryText'), scale=1.0, duration=4.0
1090                )
1091                self.celebrate(20.0)
1092                self._award_completion_achievements()
1093                bs.timer(base_delay, bs.WeakCall(self._award_completion_bonus))
1094                base_delay += 0.85
1095                self._winsound.play()
1096                bs.cameraflash()
1097                bs.setmusic(bs.MusicType.VICTORY)
1098                self._game_over = True
1099
1100                # Can't just pass delay to do_end because our extra bonuses
1101                # haven't been added yet (once we call do_end the score
1102                # gets locked in).
1103                bs.timer(base_delay, bs.WeakCall(self.do_end, 'victory'))
1104                return
1105
1106            self._wavenum += 1
1107
1108            # Short celebration after waves.
1109            if self._wavenum > 1:
1110                self.celebrate(0.5)
1111            bs.timer(base_delay, bs.WeakCall(self._start_next_wave))
1112
1113    def _award_completion_bonus(self) -> None:
1114        self._cashregistersound.play()
1115        for player in self.players:
1116            try:
1117                if player.is_alive():
1118                    assert self.initialplayerinfos is not None
1119                    self.stats.player_scored(
1120                        player,
1121                        int(100 / len(self.initialplayerinfos)),
1122                        scale=1.4,
1123                        color=(0.6, 0.6, 1.0, 1.0),
1124                        title=bs.Lstr(resource='completionBonusText'),
1125                        screenmessage=False,
1126                    )
1127            except Exception:
1128                logging.exception('error in _award_completion_bonus')
1129
1130    def _award_time_bonus(self, bonus: int) -> None:
1131        self._cashregistersound.play()
1132        PopupText(
1133            bs.Lstr(
1134                value='+${A} ${B}',
1135                subs=[
1136                    ('${A}', str(bonus)),
1137                    ('${B}', bs.Lstr(resource='timeBonusText')),
1138                ],
1139            ),
1140            color=(1, 1, 0.5, 1),
1141            scale=1.0,
1142            position=(0, 3, -1),
1143        ).autoretain()
1144        self._score += self._time_bonus
1145        self._update_scores()
1146
1147    def _award_flawless_bonus(self, player: Player) -> None:
1148        self._cashregistersound.play()
1149        try:
1150            if player.is_alive():
1151                assert self._flawless_bonus is not None
1152                self.stats.player_scored(
1153                    player,
1154                    self._flawless_bonus,
1155                    scale=1.2,
1156                    color=(0.6, 1.0, 0.6, 1.0),
1157                    title=bs.Lstr(resource='flawlessWaveText'),
1158                    screenmessage=False,
1159                )
1160        except Exception:
1161            logging.exception('error in _award_flawless_bonus')
1162
1163    def _start_time_bonus_timer(self) -> None:
1164        self._time_bonus_timer = bs.Timer(
1165            1.0, bs.WeakCall(self._update_time_bonus), repeat=True
1166        )
1167
1168    def _update_player_spawn_info(self) -> None:
1169        # If we have no living players lets just blank this.
1170        assert self._spawn_info_text is not None
1171        assert self._spawn_info_text.node
1172        if not any(player.is_alive() for player in self.teams[0].players):
1173            self._spawn_info_text.node.text = ''
1174        else:
1175            text: str | bs.Lstr = ''
1176            for player in self.players:
1177                if not player.is_alive() and (
1178                    self._preset in [Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT]
1179                    or (player.respawn_wave <= len(self._waves))
1180                ):
1181                    rtxt = bs.Lstr(
1182                        resource='onslaughtRespawnText',
1183                        subs=[
1184                            ('${PLAYER}', player.getname()),
1185                            ('${WAVE}', str(player.respawn_wave)),
1186                        ],
1187                    )
1188                    text = bs.Lstr(
1189                        value='${A}${B}\n',
1190                        subs=[
1191                            ('${A}', text),
1192                            ('${B}', rtxt),
1193                        ],
1194                    )
1195            self._spawn_info_text.node.text = text
1196
1197    def _respawn_players_for_wave(self) -> None:
1198        # Respawn applicable players.
1199        if self._wavenum > 1:
1200            for player in self.players:
1201                if (
1202                    not player.is_alive()
1203                    and player.respawn_wave == self._wavenum
1204                ):
1205                    self.spawn_player(player)
1206        self._update_player_spawn_info()
1207
1208    def _setup_wave_spawns(self, wave: Wave) -> None:
1209        tval = 0.0
1210        dtime = 0.2
1211        if self._wavenum == 1:
1212            spawn_time = 3.973
1213            tval += 0.5
1214        else:
1215            spawn_time = 2.648
1216
1217        bot_angle = wave.base_angle
1218        self._time_bonus = 0
1219        self._flawless_bonus = 0
1220        for info in wave.entries:
1221            if info is None:
1222                continue
1223            if isinstance(info, Delay):
1224                spawn_time += info.duration
1225                continue
1226            if isinstance(info, Spacing):
1227                bot_angle += info.spacing
1228                continue
1229            bot_type_2 = info.bottype
1230            if bot_type_2 is not None:
1231                assert not isinstance(bot_type_2, str)
1232                self._time_bonus += bot_type_2.points_mult * 20
1233                self._flawless_bonus += bot_type_2.points_mult * 5
1234
1235            # If its got a position, use that.
1236            point = info.point
1237            if point is not None:
1238                assert bot_type_2 is not None
1239                spcall = bs.WeakCall(
1240                    self.add_bot_at_point, point, bot_type_2, spawn_time
1241                )
1242                bs.timer(tval, spcall)
1243                tval += dtime
1244            else:
1245                spacing = info.spacing
1246                bot_angle += spacing * 0.5
1247                if bot_type_2 is not None:
1248                    tcall = bs.WeakCall(
1249                        self.add_bot_at_angle, bot_angle, bot_type_2, spawn_time
1250                    )
1251                    bs.timer(tval, tcall)
1252                    tval += dtime
1253                bot_angle += spacing * 0.5
1254
1255        # We can end the wave after all the spawning happens.
1256        bs.timer(
1257            tval + spawn_time - dtime + 0.01,
1258            bs.WeakCall(self._set_can_end_wave),
1259        )
1260
1261    def _start_next_wave(self) -> None:
1262        # This can happen if we beat a wave as we die.
1263        # We don't wanna respawn players and whatnot if this happens.
1264        if self._game_over:
1265            return
1266
1267        self._respawn_players_for_wave()
1268        if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
1269            wave = self._generate_random_wave()
1270        else:
1271            wave = self._waves[self._wavenum - 1]
1272        self._setup_wave_spawns(wave)
1273        self._update_wave_ui_and_bonuses()
1274        bs.timer(0.4, self._new_wave_sound.play)
1275
1276    def _update_wave_ui_and_bonuses(self) -> None:
1277        self.show_zoom_message(
1278            bs.Lstr(
1279                value='${A} ${B}',
1280                subs=[
1281                    ('${A}', bs.Lstr(resource='waveText')),
1282                    ('${B}', str(self._wavenum)),
1283                ],
1284            ),
1285            scale=1.0,
1286            duration=1.0,
1287            trail=True,
1288        )
1289
1290        # Reset our time bonus.
1291        tbtcolor = (1, 1, 0, 1)
1292        tbttxt = bs.Lstr(
1293            value='${A}: ${B}',
1294            subs=[
1295                ('${A}', bs.Lstr(resource='timeBonusText')),
1296                ('${B}', str(self._time_bonus)),
1297            ],
1298        )
1299        self._time_bonus_text = bs.NodeActor(
1300            bs.newnode(
1301                'text',
1302                attrs={
1303                    'v_attach': 'top',
1304                    'h_attach': 'center',
1305                    'h_align': 'center',
1306                    'vr_depth': -30,
1307                    'color': tbtcolor,
1308                    'shadow': 1.0,
1309                    'flatness': 1.0,
1310                    'position': (0, -60),
1311                    'scale': 0.8,
1312                    'text': tbttxt,
1313                },
1314            )
1315        )
1316
1317        bs.timer(5.0, bs.WeakCall(self._start_time_bonus_timer))
1318        wtcolor = (1, 1, 1, 1)
1319        wttxt = bs.Lstr(
1320            value='${A} ${B}',
1321            subs=[
1322                ('${A}', bs.Lstr(resource='waveText')),
1323                (
1324                    '${B}',
1325                    str(self._wavenum)
1326                    + (
1327                        ''
1328                        if self._preset
1329                        in [Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT]
1330                        else ('/' + str(len(self._waves)))
1331                    ),
1332                ),
1333            ],
1334        )
1335        self._wave_text = bs.NodeActor(
1336            bs.newnode(
1337                'text',
1338                attrs={
1339                    'v_attach': 'top',
1340                    'h_attach': 'center',
1341                    'h_align': 'center',
1342                    'vr_depth': -10,
1343                    'color': wtcolor,
1344                    'shadow': 1.0,
1345                    'flatness': 1.0,
1346                    'position': (0, -40),
1347                    'scale': 1.3,
1348                    'text': wttxt,
1349                },
1350            )
1351        )
1352
1353    def _bot_levels_for_wave(self) -> list[list[type[SpazBot]]]:
1354        level = self._wavenum
1355        bot_types = [
1356            BomberBot,
1357            BrawlerBot,
1358            TriggerBot,
1359            ChargerBot,
1360            BomberBotPro,
1361            BrawlerBotPro,
1362            TriggerBotPro,
1363            BomberBotProShielded,
1364            ExplodeyBot,
1365            ChargerBotProShielded,
1366            StickyBot,
1367            BrawlerBotProShielded,
1368            TriggerBotProShielded,
1369        ]
1370        if level > 5:
1371            bot_types += [
1372                ExplodeyBot,
1373                TriggerBotProShielded,
1374                BrawlerBotProShielded,
1375                ChargerBotProShielded,
1376            ]
1377        if level > 7:
1378            bot_types += [
1379                ExplodeyBot,
1380                TriggerBotProShielded,
1381                BrawlerBotProShielded,
1382                ChargerBotProShielded,
1383            ]
1384        if level > 10:
1385            bot_types += [
1386                TriggerBotProShielded,
1387                TriggerBotProShielded,
1388                TriggerBotProShielded,
1389                TriggerBotProShielded,
1390            ]
1391        if level > 13:
1392            bot_types += [
1393                TriggerBotProShielded,
1394                TriggerBotProShielded,
1395                TriggerBotProShielded,
1396                TriggerBotProShielded,
1397            ]
1398        bot_levels = [
1399            [b for b in bot_types if b.points_mult == 1],
1400            [b for b in bot_types if b.points_mult == 2],
1401            [b for b in bot_types if b.points_mult == 3],
1402            [b for b in bot_types if b.points_mult == 4],
1403        ]
1404
1405        # Make sure all lists have something in them
1406        if not all(bot_levels):
1407            raise RuntimeError('Got empty bot level')
1408        return bot_levels
1409
1410    def _add_entries_for_distribution_group(
1411        self,
1412        group: list[tuple[int, int]],
1413        bot_levels: list[list[type[SpazBot]]],
1414        all_entries: list[Spawn | Spacing | Delay | None],
1415    ) -> None:
1416        entries: list[Spawn | Spacing | Delay | None] = []
1417        for entry in group:
1418            bot_level = bot_levels[entry[0] - 1]
1419            bot_type = bot_level[random.randrange(len(bot_level))]
1420            rval = random.random()
1421            if rval < 0.5:
1422                spacing = 10.0
1423            elif rval < 0.9:
1424                spacing = 20.0
1425            else:
1426                spacing = 40.0
1427            split = random.random() > 0.3
1428            for i in range(entry[1]):
1429                if split and i % 2 == 0:
1430                    entries.insert(0, Spawn(bot_type, spacing=spacing))
1431                else:
1432                    entries.append(Spawn(bot_type, spacing=spacing))
1433        if entries:
1434            all_entries += entries
1435            all_entries.append(Spacing(40.0 if random.random() < 0.5 else 80.0))
1436
1437    def _generate_random_wave(self) -> Wave:
1438        level = self._wavenum
1439        bot_levels = self._bot_levels_for_wave()
1440
1441        target_points = level * 3 - 2
1442        min_dudes = min(1 + level // 3, 10)
1443        max_dudes = min(10, level + 1)
1444        max_level = (
1445            4 if level > 6 else (3 if level > 3 else (2 if level > 2 else 1))
1446        )
1447        group_count = 3
1448        distribution = self._get_distribution(
1449            target_points, min_dudes, max_dudes, group_count, max_level
1450        )
1451        all_entries: list[Spawn | Spacing | Delay | None] = []
1452        for group in distribution:
1453            self._add_entries_for_distribution_group(
1454                group, bot_levels, all_entries
1455            )
1456        angle_rand = random.random()
1457        if angle_rand > 0.75:
1458            base_angle = 130.0
1459        elif angle_rand > 0.5:
1460            base_angle = 210.0
1461        elif angle_rand > 0.25:
1462            base_angle = 20.0
1463        else:
1464            base_angle = -30.0
1465        base_angle += (0.5 - random.random()) * 20.0
1466        wave = Wave(base_angle=base_angle, entries=all_entries)
1467        return wave
1468
1469    def add_bot_at_point(
1470        self, point: Point, spaz_type: type[SpazBot], spawn_time: float = 1.0
1471    ) -> None:
1472        """Add a new bot at a specified named point."""
1473        if self._game_over:
1474            return
1475        assert isinstance(point.value, str)
1476        pointpos = self.map.defs.points[point.value]
1477        assert self._bots is not None
1478        self._bots.spawn_bot(spaz_type, pos=pointpos, spawn_time=spawn_time)
1479
1480    def add_bot_at_angle(
1481        self, angle: float, spaz_type: type[SpazBot], spawn_time: float = 1.0
1482    ) -> None:
1483        """Add a new bot at a specified angle (for circular maps)."""
1484        if self._game_over:
1485            return
1486        angle_radians = angle / 57.2957795
1487        xval = math.sin(angle_radians) * 1.06
1488        zval = math.cos(angle_radians) * 1.06
1489        point = (xval / 0.125, 2.3, (zval / 0.2) - 3.7)
1490        assert self._bots is not None
1491        self._bots.spawn_bot(spaz_type, pos=point, spawn_time=spawn_time)
1492
1493    def _update_time_bonus(self) -> None:
1494        self._time_bonus = int(self._time_bonus * 0.93)
1495        if self._time_bonus > 0 and self._time_bonus_text is not None:
1496            assert self._time_bonus_text.node
1497            self._time_bonus_text.node.text = bs.Lstr(
1498                value='${A}: ${B}',
1499                subs=[
1500                    ('${A}', bs.Lstr(resource='timeBonusText')),
1501                    ('${B}', str(self._time_bonus)),
1502                ],
1503            )
1504        else:
1505            self._time_bonus_text = None
1506
1507    def _start_updating_waves(self) -> None:
1508        self._wave_update_timer = bs.Timer(
1509            2.0, bs.WeakCall(self._update_waves), repeat=True
1510        )
1511
1512    def _update_scores(self) -> None:
1513        score = self._score
1514        if self._preset is Preset.ENDLESS:
1515            if score >= 500:
1516                self._award_achievement('Onslaught Master')
1517            if score >= 1000:
1518                self._award_achievement('Onslaught Wizard')
1519            if score >= 5000:
1520                self._award_achievement('Onslaught God')
1521        assert self._scoreboard is not None
1522        self._scoreboard.set_team_value(self.teams[0], score, max_score=None)
1523
1524    @override
1525    def handlemessage(self, msg: Any) -> Any:
1526        if isinstance(msg, PlayerSpazHurtMessage):
1527            msg.spaz.getplayer(Player, True).has_been_hurt = True
1528            self._a_player_has_been_hurt = True
1529
1530        elif isinstance(msg, bs.PlayerScoredMessage):
1531            self._score += msg.score
1532            self._update_scores()
1533
1534        elif isinstance(msg, bs.PlayerDiedMessage):
1535            super().handlemessage(msg)  # Augment standard behavior.
1536            player = msg.getplayer(Player)
1537            self._a_player_has_been_hurt = True
1538
1539            # Make note with the player when they can respawn:
1540            if self._wavenum < 10:
1541                player.respawn_wave = max(2, self._wavenum + 1)
1542            elif self._wavenum < 15:
1543                player.respawn_wave = max(2, self._wavenum + 2)
1544            else:
1545                player.respawn_wave = max(2, self._wavenum + 3)
1546            bs.timer(0.1, self._update_player_spawn_info)
1547            bs.timer(0.1, self._checkroundover)
1548
1549        elif isinstance(msg, SpazBotDiedMessage):
1550            pts, importance = msg.spazbot.get_death_points(msg.how)
1551            if msg.killerplayer is not None:
1552                self._handle_kill_achievements(msg)
1553                target: Sequence[float] | None
1554                if msg.spazbot.node:
1555                    target = msg.spazbot.node.position
1556                else:
1557                    target = None
1558
1559                killerplayer = msg.killerplayer
1560                self.stats.player_scored(
1561                    killerplayer,
1562                    pts,
1563                    target=target,
1564                    kill=True,
1565                    screenmessage=False,
1566                    importance=importance,
1567                )
1568                dingsound = (
1569                    self._dingsound if importance == 1 else self._dingsoundhigh
1570                )
1571                dingsound.play(volume=0.6)
1572
1573            # Normally we pull scores from the score-set, but if there's
1574            # no player lets be explicit.
1575            else:
1576                self._score += pts
1577            self._update_scores()
1578        else:
1579            super().handlemessage(msg)
1580
1581    def _handle_kill_achievements(self, msg: SpazBotDiedMessage) -> None:
1582        if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}:
1583            self._handle_training_kill_achievements(msg)
1584        elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}:
1585            self._handle_rookie_kill_achievements(msg)
1586        elif self._preset in {Preset.PRO, Preset.PRO_EASY}:
1587            self._handle_pro_kill_achievements(msg)
1588        elif self._preset in {Preset.UBER, Preset.UBER_EASY}:
1589            self._handle_uber_kill_achievements(msg)
1590
1591    def _handle_uber_kill_achievements(self, msg: SpazBotDiedMessage) -> None:
1592        # Uber mine achievement:
1593        if msg.spazbot.last_attacked_type == ('explosion', 'land_mine'):
1594            self._land_mine_kills += 1
1595            if self._land_mine_kills >= 6:
1596                self._award_achievement('Gold Miner')
1597
1598        # Uber tnt achievement:
1599        if msg.spazbot.last_attacked_type == ('explosion', 'tnt'):
1600            self._tnt_kills += 1
1601            if self._tnt_kills >= 6:
1602                bs.timer(
1603                    0.5, bs.WeakCall(self._award_achievement, 'TNT Terror')
1604                )
1605
1606    def _handle_pro_kill_achievements(self, msg: SpazBotDiedMessage) -> None:
1607        # TNT achievement:
1608        if msg.spazbot.last_attacked_type == ('explosion', 'tnt'):
1609            self._tnt_kills += 1
1610            if self._tnt_kills >= 3:
1611                bs.timer(
1612                    0.5,
1613                    bs.WeakCall(
1614                        self._award_achievement, 'Boom Goes the Dynamite'
1615                    ),
1616                )
1617
1618    def _handle_rookie_kill_achievements(self, msg: SpazBotDiedMessage) -> None:
1619        # Land-mine achievement:
1620        if msg.spazbot.last_attacked_type == ('explosion', 'land_mine'):
1621            self._land_mine_kills += 1
1622            if self._land_mine_kills >= 3:
1623                self._award_achievement('Mine Games')
1624
1625    def _handle_training_kill_achievements(
1626        self, msg: SpazBotDiedMessage
1627    ) -> None:
1628        # Toss-off-map achievement:
1629        if msg.spazbot.last_attacked_type == ('picked_up', 'default'):
1630            self._throw_off_kills += 1
1631            if self._throw_off_kills >= 3:
1632                self._award_achievement('Off You Go Then')
1633
1634    def _set_can_end_wave(self) -> None:
1635        self._can_end_wave = True
1636
1637    @override
1638    def end_game(self) -> None:
1639        # Tell our bots to celebrate just to rub it in.
1640        assert self._bots is not None
1641        self._bots.final_celebrate()
1642        self._game_over = True
1643        self.do_end('defeat', delay=2.0)
1644        bs.setmusic(None)
1645
1646    def _checkroundover(self) -> None:
1647        """Potentially end the round based on the state of the game."""
1648        if self.has_ended():
1649            return
1650        if not any(player.is_alive() for player in self.teams[0].players):
1651            self.end_game()

Co-op game where players try to survive attacking waves of enemies.

OnslaughtGame(settings: dict)
168    def __init__(self, settings: dict):
169        self._preset = Preset(settings.get('preset', 'training'))
170        if self._preset in {
171            Preset.TRAINING,
172            Preset.TRAINING_EASY,
173            Preset.PRO,
174            Preset.PRO_EASY,
175            Preset.ENDLESS,
176            Preset.ENDLESS_TOURNAMENT,
177        }:
178            settings['map'] = 'Doom Shroom'
179        else:
180            settings['map'] = 'Courtyard'
181
182        super().__init__(settings)
183
184        self._new_wave_sound = bs.getsound('scoreHit01')
185        self._winsound = bs.getsound('score')
186        self._cashregistersound = bs.getsound('cashRegister')
187        self._a_player_has_been_hurt = False
188        self._player_has_dropped_bomb = False
189
190        # FIXME: should use standard map defs.
191        if settings['map'] == 'Doom Shroom':
192            self._spawn_center = (0, 3, -5)
193            self._tntspawnpos = (0.0, 3.0, -5.0)
194            self._powerup_center = (0, 5, -3.6)
195            self._powerup_spread = (6.0, 4.0)
196        elif settings['map'] == 'Courtyard':
197            self._spawn_center = (0, 3, -2)
198            self._tntspawnpos = (0.0, 3.0, 2.1)
199            self._powerup_center = (0, 5, -1.6)
200            self._powerup_spread = (4.6, 2.7)
201        else:
202            raise RuntimeError('Unsupported map: ' + str(settings['map']))
203        self._scoreboard: Scoreboard | None = None
204        self._game_over = False
205        self._wavenum = 0
206        self._can_end_wave = True
207        self._score = 0
208        self._time_bonus = 0
209        self._spawn_info_text: bs.NodeActor | None = None
210        self._dingsound = bs.getsound('dingSmall')
211        self._dingsoundhigh = bs.getsound('dingSmallHigh')
212        self._have_tnt = False
213        self._excluded_powerups: list[str] | None = None
214        self._waves: list[Wave] = []
215        self._tntspawner: TNTSpawner | None = None
216        self._bots: SpazBotSet | None = None
217        self._powerup_drop_timer: bs.Timer | None = None
218        self._time_bonus_timer: bs.Timer | None = None
219        self._time_bonus_text: bs.NodeActor | None = None
220        self._flawless_bonus: int | None = None
221        self._wave_text: bs.NodeActor | None = None
222        self._wave_update_timer: bs.Timer | None = None
223        self._throw_off_kills = 0
224        self._land_mine_kills = 0
225        self._tnt_kills = 0

Instantiate the Activity.

name = 'Onslaught'
description = 'Defeat all enemies.'
tips: list[str | bascenev1.GameTip] = ['Hold any button to run. (Trigger buttons work well if you have them)', 'Try tricking enemies into killing eachother or running off cliffs.', "Try 'Cooking off' bombs for a second or two before throwing them.", "It's easier to win with a friend or two helping.", "If you stay in one place, you're toast. Run and dodge to survive..", 'Practice using your momentum to throw bombs more accurately.', 'Your punches do much more damage if you are running or spinning.']
announce_player_deaths = True

Whether to print every time a player dies. This can be pertinent in games such as Death-Match but can be annoying in games where it doesn't matter.

@override
def on_transition_in(self) -> None:
227    @override
228    def on_transition_in(self) -> None:
229        super().on_transition_in()
230        customdata = bs.getsession().customdata
231
232        # Show special landmine tip on rookie preset.
233        if self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}:
234            # Show once per session only (then we revert to regular tips).
235            if not customdata.get('_showed_onslaught_landmine_tip', False):
236                customdata['_showed_onslaught_landmine_tip'] = True
237                self.tips = [
238                    bs.GameTip(
239                        'Land-mines are a good way to stop speedy enemies.',
240                        icon=bs.gettexture('powerupLandMines'),
241                        sound=bs.getsound('ding'),
242                    )
243                ]
244
245        # Show special tnt tip on pro preset.
246        if self._preset in {Preset.PRO, Preset.PRO_EASY}:
247            # Show once per session only (then we revert to regular tips).
248            if not customdata.get('_showed_onslaught_tnt_tip', False):
249                customdata['_showed_onslaught_tnt_tip'] = True
250                self.tips = [
251                    bs.GameTip(
252                        'Take out a group of enemies by\n'
253                        'setting off a bomb near a TNT box.',
254                        icon=bs.gettexture('tnt'),
255                        sound=bs.getsound('ding'),
256                    )
257                ]
258
259        # Show special curse tip on uber preset.
260        if self._preset in {Preset.UBER, Preset.UBER_EASY}:
261            # Show once per session only (then we revert to regular tips).
262            if not customdata.get('_showed_onslaught_curse_tip', False):
263                customdata['_showed_onslaught_curse_tip'] = True
264                self.tips = [
265                    bs.GameTip(
266                        'Curse boxes turn you into a ticking time bomb.\n'
267                        'The only cure is to quickly grab a health-pack.',
268                        icon=bs.gettexture('powerupCurse'),
269                        sound=bs.getsound('ding'),
270                    )
271                ]
272
273        self._spawn_info_text = bs.NodeActor(
274            bs.newnode(
275                'text',
276                attrs={
277                    'position': (15, -130),
278                    'h_attach': 'left',
279                    'v_attach': 'top',
280                    'scale': 0.55,
281                    'color': (0.3, 0.8, 0.3, 1.0),
282                    'text': '',
283                },
284            )
285        )
286        bs.setmusic(bs.MusicType.ONSLAUGHT)
287
288        self._scoreboard = Scoreboard(
289            label=bs.Lstr(resource='scoreText'), score_split=0.5
290        )

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:
292    @override
293    def on_begin(self) -> None:
294        super().on_begin()
295        player_count = len(self.players)
296        hard = self._preset not in {
297            Preset.TRAINING_EASY,
298            Preset.ROOKIE_EASY,
299            Preset.PRO_EASY,
300            Preset.UBER_EASY,
301        }
302        if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}:
303            ControlsGuide(delay=3.0, lifespan=10.0, bright=True).autoretain()
304
305            self._have_tnt = False
306            self._excluded_powerups = ['curse', 'land_mines']
307            self._waves = [
308                Wave(
309                    base_angle=195,
310                    entries=[
311                        Spawn(BomberBotLite, spacing=5),
312                    ]
313                    * player_count,
314                ),
315                Wave(
316                    base_angle=130,
317                    entries=[
318                        Spawn(BrawlerBotLite, spacing=5),
319                    ]
320                    * player_count,
321                ),
322                Wave(
323                    base_angle=195,
324                    entries=[Spawn(BomberBotLite, spacing=10)]
325                    * (player_count + 1),
326                ),
327                Wave(
328                    base_angle=130,
329                    entries=[
330                        Spawn(BrawlerBotLite, spacing=10),
331                    ]
332                    * (player_count + 1),
333                ),
334                Wave(
335                    base_angle=130,
336                    entries=[
337                        (
338                            Spawn(BrawlerBotLite, spacing=5)
339                            if player_count > 1
340                            else None
341                        ),
342                        Spawn(BrawlerBotLite, spacing=5),
343                        Spacing(30),
344                        (
345                            Spawn(BomberBotLite, spacing=5)
346                            if player_count > 3
347                            else None
348                        ),
349                        Spawn(BomberBotLite, spacing=5),
350                        Spacing(30),
351                        Spawn(BrawlerBotLite, spacing=5),
352                        (
353                            Spawn(BrawlerBotLite, spacing=5)
354                            if player_count > 2
355                            else None
356                        ),
357                    ],
358                ),
359                Wave(
360                    base_angle=195,
361                    entries=[
362                        Spawn(TriggerBot, spacing=90),
363                        (
364                            Spawn(TriggerBot, spacing=90)
365                            if player_count > 1
366                            else None
367                        ),
368                    ],
369                ),
370            ]
371
372        elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}:
373            self._have_tnt = False
374            self._excluded_powerups = ['curse']
375            self._waves = [
376                Wave(
377                    entries=[
378                        (
379                            Spawn(ChargerBot, Point.LEFT_UPPER_MORE)
380                            if player_count > 2
381                            else None
382                        ),
383                        Spawn(ChargerBot, Point.LEFT_UPPER),
384                    ]
385                ),
386                Wave(
387                    entries=[
388                        Spawn(BomberBotStaticLite, Point.TURRET_TOP_RIGHT),
389                        Spawn(BrawlerBotLite, Point.RIGHT_UPPER),
390                        (
391                            Spawn(BrawlerBotLite, Point.RIGHT_LOWER)
392                            if player_count > 1
393                            else None
394                        ),
395                        (
396                            Spawn(
397                                BomberBotStaticLite, Point.TURRET_BOTTOM_RIGHT
398                            )
399                            if player_count > 2
400                            else None
401                        ),
402                    ]
403                ),
404                Wave(
405                    entries=[
406                        Spawn(BomberBotStaticLite, Point.TURRET_BOTTOM_LEFT),
407                        Spawn(TriggerBot, Point.LEFT),
408                        (
409                            Spawn(TriggerBot, Point.LEFT_LOWER)
410                            if player_count > 1
411                            else None
412                        ),
413                        (
414                            Spawn(TriggerBot, Point.LEFT_UPPER)
415                            if player_count > 2
416                            else None
417                        ),
418                    ]
419                ),
420                Wave(
421                    entries=[
422                        Spawn(BrawlerBotLite, Point.TOP_RIGHT),
423                        (
424                            Spawn(BrawlerBot, Point.TOP_HALF_RIGHT)
425                            if player_count > 1
426                            else None
427                        ),
428                        Spawn(BrawlerBotLite, Point.TOP_LEFT),
429                        (
430                            Spawn(BrawlerBotLite, Point.TOP_HALF_LEFT)
431                            if player_count > 2
432                            else None
433                        ),
434                        Spawn(BrawlerBot, Point.TOP),
435                        Spawn(BomberBotStaticLite, Point.TURRET_TOP_MIDDLE),
436                    ]
437                ),
438                Wave(
439                    entries=[
440                        Spawn(TriggerBotStatic, Point.TURRET_BOTTOM_LEFT),
441                        Spawn(TriggerBotStatic, Point.TURRET_BOTTOM_RIGHT),
442                        Spawn(TriggerBot, Point.BOTTOM),
443                        (
444                            Spawn(TriggerBot, Point.BOTTOM_HALF_RIGHT)
445                            if player_count > 1
446                            else None
447                        ),
448                        (
449                            Spawn(TriggerBot, Point.BOTTOM_HALF_LEFT)
450                            if player_count > 2
451                            else None
452                        ),
453                    ]
454                ),
455                Wave(
456                    entries=[
457                        Spawn(BomberBotStaticLite, Point.TURRET_TOP_LEFT),
458                        Spawn(BomberBotStaticLite, Point.TURRET_TOP_RIGHT),
459                        Spawn(ChargerBot, Point.BOTTOM),
460                        (
461                            Spawn(ChargerBot, Point.BOTTOM_HALF_LEFT)
462                            if player_count > 1
463                            else None
464                        ),
465                        (
466                            Spawn(ChargerBot, Point.BOTTOM_HALF_RIGHT)
467                            if player_count > 2
468                            else None
469                        ),
470                    ]
471                ),
472            ]
473
474        elif self._preset in {Preset.PRO, Preset.PRO_EASY}:
475            self._excluded_powerups = ['curse']
476            self._have_tnt = True
477            self._waves = [
478                Wave(
479                    base_angle=-50,
480                    entries=[
481                        (
482                            Spawn(BrawlerBot, spacing=12)
483                            if player_count > 3
484                            else None
485                        ),
486                        Spawn(BrawlerBot, spacing=12),
487                        Spawn(BomberBot, spacing=6),
488                        (
489                            Spawn(BomberBot, spacing=6)
490                            if self._preset is Preset.PRO
491                            else None
492                        ),
493                        (
494                            Spawn(BomberBot, spacing=6)
495                            if player_count > 1
496                            else None
497                        ),
498                        Spawn(BrawlerBot, spacing=12),
499                        (
500                            Spawn(BrawlerBot, spacing=12)
501                            if player_count > 2
502                            else None
503                        ),
504                    ],
505                ),
506                Wave(
507                    base_angle=180,
508                    entries=[
509                        (
510                            Spawn(BrawlerBot, spacing=6)
511                            if player_count > 3
512                            else None
513                        ),
514                        (
515                            Spawn(BrawlerBot, spacing=6)
516                            if self._preset is Preset.PRO
517                            else None
518                        ),
519                        Spawn(BrawlerBot, spacing=6),
520                        Spawn(ChargerBot, spacing=45),
521                        (
522                            Spawn(ChargerBot, spacing=45)
523                            if player_count > 1
524                            else None
525                        ),
526                        Spawn(BrawlerBot, spacing=6),
527                        (
528                            Spawn(BrawlerBot, spacing=6)
529                            if self._preset is Preset.PRO
530                            else None
531                        ),
532                        (
533                            Spawn(BrawlerBot, spacing=6)
534                            if player_count > 2
535                            else None
536                        ),
537                    ],
538                ),
539                Wave(
540                    base_angle=0,
541                    entries=[
542                        Spawn(ChargerBot, spacing=30),
543                        Spawn(TriggerBot, spacing=30),
544                        Spawn(TriggerBot, spacing=30),
545                        (
546                            Spawn(TriggerBot, spacing=30)
547                            if self._preset is Preset.PRO
548                            else None
549                        ),
550                        (
551                            Spawn(TriggerBot, spacing=30)
552                            if player_count > 1
553                            else None
554                        ),
555                        (
556                            Spawn(TriggerBot, spacing=30)
557                            if player_count > 3
558                            else None
559                        ),
560                        Spawn(ChargerBot, spacing=30),
561                    ],
562                ),
563                Wave(
564                    base_angle=90,
565                    entries=[
566                        Spawn(StickyBot, spacing=50),
567                        (
568                            Spawn(StickyBot, spacing=50)
569                            if self._preset is Preset.PRO
570                            else None
571                        ),
572                        Spawn(StickyBot, spacing=50),
573                        (
574                            Spawn(StickyBot, spacing=50)
575                            if player_count > 1
576                            else None
577                        ),
578                        (
579                            Spawn(StickyBot, spacing=50)
580                            if player_count > 3
581                            else None
582                        ),
583                    ],
584                ),
585                Wave(
586                    base_angle=0,
587                    entries=[
588                        Spawn(TriggerBot, spacing=72),
589                        Spawn(TriggerBot, spacing=72),
590                        (
591                            Spawn(TriggerBot, spacing=72)
592                            if self._preset is Preset.PRO
593                            else None
594                        ),
595                        Spawn(TriggerBot, spacing=72),
596                        Spawn(TriggerBot, spacing=72),
597                        (
598                            Spawn(TriggerBot, spacing=36)
599                            if player_count > 2
600                            else None
601                        ),
602                    ],
603                ),
604                Wave(
605                    base_angle=30,
606                    entries=[
607                        Spawn(ChargerBotProShielded, spacing=50),
608                        Spawn(ChargerBotProShielded, spacing=50),
609                        (
610                            Spawn(ChargerBotProShielded, spacing=50)
611                            if self._preset is Preset.PRO
612                            else None
613                        ),
614                        (
615                            Spawn(ChargerBotProShielded, spacing=50)
616                            if player_count > 1
617                            else None
618                        ),
619                        (
620                            Spawn(ChargerBotProShielded, spacing=50)
621                            if player_count > 2
622                            else None
623                        ),
624                    ],
625                ),
626            ]
627
628        elif self._preset in {Preset.UBER, Preset.UBER_EASY}:
629            # Show controls help in demo or arcade modes.
630            env = bs.app.env
631            if env.demo or env.arcade:
632                ControlsGuide(
633                    delay=3.0, lifespan=10.0, bright=True
634                ).autoretain()
635
636            self._have_tnt = True
637            self._excluded_powerups = []
638            self._waves = [
639                Wave(
640                    entries=[
641                        (
642                            Spawn(
643                                BomberBotProStatic, Point.TURRET_TOP_MIDDLE_LEFT
644                            )
645                            if hard
646                            else None
647                        ),
648                        Spawn(
649                            BomberBotProStatic, Point.TURRET_TOP_MIDDLE_RIGHT
650                        ),
651                        (
652                            Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT)
653                            if player_count > 2
654                            else None
655                        ),
656                        Spawn(ExplodeyBot, Point.TOP_RIGHT),
657                        Delay(4.0),
658                        Spawn(ExplodeyBot, Point.TOP_LEFT),
659                    ]
660                ),
661                Wave(
662                    entries=[
663                        Spawn(ChargerBot, Point.LEFT),
664                        Spawn(ChargerBot, Point.RIGHT),
665                        (
666                            Spawn(ChargerBot, Point.RIGHT_UPPER_MORE)
667                            if player_count > 2
668                            else None
669                        ),
670                        Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT),
671                        Spawn(BomberBotProStatic, Point.TURRET_TOP_RIGHT),
672                    ]
673                ),
674                Wave(
675                    entries=[
676                        Spawn(TriggerBotPro, Point.TOP_RIGHT),
677                        (
678                            Spawn(TriggerBotPro, Point.RIGHT_UPPER_MORE)
679                            if player_count > 1
680                            else None
681                        ),
682                        Spawn(TriggerBotPro, Point.RIGHT_UPPER),
683                        (
684                            Spawn(TriggerBotPro, Point.RIGHT_LOWER)
685                            if hard
686                            else None
687                        ),
688                        (
689                            Spawn(TriggerBotPro, Point.RIGHT_LOWER_MORE)
690                            if player_count > 2
691                            else None
692                        ),
693                        Spawn(TriggerBotPro, Point.BOTTOM_RIGHT),
694                    ]
695                ),
696                Wave(
697                    entries=[
698                        Spawn(ChargerBotProShielded, Point.BOTTOM_RIGHT),
699                        (
700                            Spawn(ChargerBotProShielded, Point.BOTTOM)
701                            if player_count > 2
702                            else None
703                        ),
704                        Spawn(ChargerBotProShielded, Point.BOTTOM_LEFT),
705                        (
706                            Spawn(ChargerBotProShielded, Point.TOP)
707                            if hard
708                            else None
709                        ),
710                        Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE),
711                    ]
712                ),
713                Wave(
714                    entries=[
715                        Spawn(ExplodeyBot, Point.LEFT_UPPER),
716                        Delay(1.0),
717                        Spawn(BrawlerBotProShielded, Point.LEFT_LOWER),
718                        Spawn(BrawlerBotProShielded, Point.LEFT_LOWER_MORE),
719                        Delay(4.0),
720                        Spawn(ExplodeyBot, Point.RIGHT_UPPER),
721                        Delay(1.0),
722                        Spawn(BrawlerBotProShielded, Point.RIGHT_LOWER),
723                        Spawn(BrawlerBotProShielded, Point.RIGHT_UPPER_MORE),
724                        Delay(4.0),
725                        Spawn(ExplodeyBot, Point.LEFT),
726                        Delay(5.0),
727                        Spawn(ExplodeyBot, Point.RIGHT),
728                    ]
729                ),
730                Wave(
731                    entries=[
732                        Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT),
733                        Spawn(BomberBotProStatic, Point.TURRET_TOP_RIGHT),
734                        Spawn(BomberBotProStatic, Point.TURRET_BOTTOM_LEFT),
735                        Spawn(BomberBotProStatic, Point.TURRET_BOTTOM_RIGHT),
736                        (
737                            Spawn(
738                                BomberBotProStatic, Point.TURRET_TOP_MIDDLE_LEFT
739                            )
740                            if hard
741                            else None
742                        ),
743                        (
744                            Spawn(
745                                BomberBotProStatic,
746                                Point.TURRET_TOP_MIDDLE_RIGHT,
747                            )
748                            if hard
749                            else None
750                        ),
751                    ]
752                ),
753            ]
754
755        # We generate these on the fly in endless.
756        elif self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
757            self._have_tnt = True
758            self._excluded_powerups = []
759            self._waves = []
760
761        else:
762            raise RuntimeError(f'Invalid preset: {self._preset}')
763
764        # FIXME: Should migrate to use setup_standard_powerup_drops().
765
766        # Spit out a few powerups and start dropping more shortly.
767        self._drop_powerups(
768            standard_points=True,
769            poweruptype=(
770                'curse'
771                if self._preset in [Preset.UBER, Preset.UBER_EASY]
772                else (
773                    'land_mines'
774                    if self._preset in [Preset.ROOKIE, Preset.ROOKIE_EASY]
775                    else None
776                )
777            ),
778        )
779        bs.timer(4.0, self._start_powerup_drops)
780
781        # Our TNT spawner (if applicable).
782        if self._have_tnt:
783            self._tntspawner = TNTSpawner(position=self._tntspawnpos)
784
785        self.setup_low_life_warning_sound()
786        self._update_scores()
787        self._bots = SpazBotSet()
788        bs.timer(4.0, self._start_updating_waves)

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 spawn_player( self, player: Player) -> bascenev1.Actor:
934    @override
935    def spawn_player(self, player: Player) -> bs.Actor:
936        # We keep track of who got hurt each wave for score purposes.
937        player.has_been_hurt = False
938        pos = (
939            self._spawn_center[0] + random.uniform(-1.5, 1.5),
940            self._spawn_center[1],
941            self._spawn_center[2] + random.uniform(-1.5, 1.5),
942        )
943        spaz = self.spawn_player_spaz(player, position=pos)
944        if self._preset in {
945            Preset.TRAINING_EASY,
946            Preset.ROOKIE_EASY,
947            Preset.PRO_EASY,
948            Preset.UBER_EASY,
949        }:
950            spaz.impact_scale = 0.25
951        spaz.add_dropped_bomb_callback(self._handle_player_dropped_bomb)
952        return spaz

Spawn something for the provided bascenev1.Player.

The default implementation simply calls spawn_player_spaz().

def do_end(self, outcome: str, delay: float = 0.0) -> None:
1009    def do_end(self, outcome: str, delay: float = 0.0) -> None:
1010        """End the game with the specified outcome."""
1011        if outcome == 'defeat':
1012            self.fade_to_red()
1013        score: int | None
1014        if self._wavenum >= 2:
1015            score = self._score
1016            fail_message = None
1017        else:
1018            score = None
1019            fail_message = bs.Lstr(resource='reachWave2Text')
1020        self.end(
1021            {
1022                'outcome': outcome,
1023                'score': score,
1024                'fail_message': fail_message,
1025                'playerinfos': self.initialplayerinfos,
1026            },
1027            delay=delay,
1028        )

End the game with the specified outcome.

def add_bot_at_point( self, point: Point, spaz_type: type[bascenev1lib.actor.spazbot.SpazBot], spawn_time: float = 1.0) -> None:
1469    def add_bot_at_point(
1470        self, point: Point, spaz_type: type[SpazBot], spawn_time: float = 1.0
1471    ) -> None:
1472        """Add a new bot at a specified named point."""
1473        if self._game_over:
1474            return
1475        assert isinstance(point.value, str)
1476        pointpos = self.map.defs.points[point.value]
1477        assert self._bots is not None
1478        self._bots.spawn_bot(spaz_type, pos=pointpos, spawn_time=spawn_time)

Add a new bot at a specified named point.

def add_bot_at_angle( self, angle: float, spaz_type: type[bascenev1lib.actor.spazbot.SpazBot], spawn_time: float = 1.0) -> None:
1480    def add_bot_at_angle(
1481        self, angle: float, spaz_type: type[SpazBot], spawn_time: float = 1.0
1482    ) -> None:
1483        """Add a new bot at a specified angle (for circular maps)."""
1484        if self._game_over:
1485            return
1486        angle_radians = angle / 57.2957795
1487        xval = math.sin(angle_radians) * 1.06
1488        zval = math.cos(angle_radians) * 1.06
1489        point = (xval / 0.125, 2.3, (zval / 0.2) - 3.7)
1490        assert self._bots is not None
1491        self._bots.spawn_bot(spaz_type, pos=point, spawn_time=spawn_time)

Add a new bot at a specified angle (for circular maps).

@override
def handlemessage(self, msg: Any) -> Any:
1524    @override
1525    def handlemessage(self, msg: Any) -> Any:
1526        if isinstance(msg, PlayerSpazHurtMessage):
1527            msg.spaz.getplayer(Player, True).has_been_hurt = True
1528            self._a_player_has_been_hurt = True
1529
1530        elif isinstance(msg, bs.PlayerScoredMessage):
1531            self._score += msg.score
1532            self._update_scores()
1533
1534        elif isinstance(msg, bs.PlayerDiedMessage):
1535            super().handlemessage(msg)  # Augment standard behavior.
1536            player = msg.getplayer(Player)
1537            self._a_player_has_been_hurt = True
1538
1539            # Make note with the player when they can respawn:
1540            if self._wavenum < 10:
1541                player.respawn_wave = max(2, self._wavenum + 1)
1542            elif self._wavenum < 15:
1543                player.respawn_wave = max(2, self._wavenum + 2)
1544            else:
1545                player.respawn_wave = max(2, self._wavenum + 3)
1546            bs.timer(0.1, self._update_player_spawn_info)
1547            bs.timer(0.1, self._checkroundover)
1548
1549        elif isinstance(msg, SpazBotDiedMessage):
1550            pts, importance = msg.spazbot.get_death_points(msg.how)
1551            if msg.killerplayer is not None:
1552                self._handle_kill_achievements(msg)
1553                target: Sequence[float] | None
1554                if msg.spazbot.node:
1555                    target = msg.spazbot.node.position
1556                else:
1557                    target = None
1558
1559                killerplayer = msg.killerplayer
1560                self.stats.player_scored(
1561                    killerplayer,
1562                    pts,
1563                    target=target,
1564                    kill=True,
1565                    screenmessage=False,
1566                    importance=importance,
1567                )
1568                dingsound = (
1569                    self._dingsound if importance == 1 else self._dingsoundhigh
1570                )
1571                dingsound.play(volume=0.6)
1572
1573            # Normally we pull scores from the score-set, but if there's
1574            # no player lets be explicit.
1575            else:
1576                self._score += pts
1577            self._update_scores()
1578        else:
1579            super().handlemessage(msg)

General message handling; can be passed any message object.

@override
def end_game(self) -> None:
1637    @override
1638    def end_game(self) -> None:
1639        # Tell our bots to celebrate just to rub it in.
1640        assert self._bots is not None
1641        self._bots.final_celebrate()
1642        self._game_over = True
1643        self.do_end('defeat', delay=2.0)
1644        bs.setmusic(None)

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.