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

A wave of enemies.

Wave( entries: list[bastd.game.onslaught.Spawn | bastd.game.onslaught.Spacing | bastd.game.onslaught.Delay | None], base_angle: float = 0.0)
base_angle: float = 0.0
@dataclass
class Spawn:
46@dataclass
47class Spawn:
48    """A bot spawn event in a wave."""
49    bottype: type[SpazBot] | str
50    point: Point | None = None
51    spacing: float = 5.0

A bot spawn event in a wave.

Spawn( bottype: type[bastd.actor.spazbot.SpazBot] | str, point: bastd.game.onslaught.Point | None = None, spacing: float = 5.0)
point: bastd.game.onslaught.Point | None = None
spacing: float = 5.0
@dataclass
class Spacing:
54@dataclass
55class Spacing:
56    """Empty space in a wave."""
57    spacing: float = 5.0

Empty space in a wave.

Spacing(spacing: float = 5.0)
spacing: float = 5.0
@dataclass
class Delay:
60@dataclass
61class Delay:
62    """A delay between events in a wave."""
63    duration: float

A delay between events in a wave.

Delay(duration: float)
class Preset(enum.Enum):
66class Preset(Enum):
67    """Game presets we support."""
68    TRAINING = 'training'
69    TRAINING_EASY = 'training_easy'
70    ROOKIE = 'rookie'
71    ROOKIE_EASY = 'rookie_easy'
72    PRO = 'pro'
73    PRO_EASY = 'pro_easy'
74    UBER = 'uber'
75    UBER_EASY = 'uber_easy'
76    ENDLESS = 'endless'
77    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'>
Inherited Members
enum.Enum
name
value
@unique
class Point(enum.Enum):
 80@unique
 81class Point(Enum):
 82    """Points on the map we can spawn at."""
 83    LEFT_UPPER_MORE = 'bot_spawn_left_upper_more'
 84    LEFT_UPPER = 'bot_spawn_left_upper'
 85    TURRET_TOP_RIGHT = 'bot_spawn_turret_top_right'
 86    RIGHT_UPPER = 'bot_spawn_right_upper'
 87    TURRET_TOP_MIDDLE_LEFT = 'bot_spawn_turret_top_middle_left'
 88    TURRET_TOP_MIDDLE_RIGHT = 'bot_spawn_turret_top_middle_right'
 89    TURRET_TOP_LEFT = 'bot_spawn_turret_top_left'
 90    TOP_RIGHT = 'bot_spawn_top_right'
 91    TOP_LEFT = 'bot_spawn_top_left'
 92    TOP = 'bot_spawn_top'
 93    BOTTOM = 'bot_spawn_bottom'
 94    LEFT = 'bot_spawn_left'
 95    RIGHT = 'bot_spawn_right'
 96    RIGHT_UPPER_MORE = 'bot_spawn_right_upper_more'
 97    RIGHT_LOWER = 'bot_spawn_right_lower'
 98    RIGHT_LOWER_MORE = 'bot_spawn_right_lower_more'
 99    BOTTOM_RIGHT = 'bot_spawn_bottom_right'
100    BOTTOM_LEFT = 'bot_spawn_bottom_left'
101    TURRET_BOTTOM_RIGHT = 'bot_spawn_turret_bottom_right'
102    TURRET_BOTTOM_LEFT = 'bot_spawn_turret_bottom_left'
103    LEFT_LOWER = 'bot_spawn_left_lower'
104    LEFT_LOWER_MORE = 'bot_spawn_left_lower_more'
105    TURRET_TOP_MIDDLE = 'bot_spawn_turret_top_middle'
106    BOTTOM_HALF_RIGHT = 'bot_spawn_bottom_half_right'
107    BOTTOM_HALF_LEFT = 'bot_spawn_bottom_half_left'
108    TOP_HALF_RIGHT = 'bot_spawn_top_half_right'
109    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'>
Inherited Members
enum.Enum
name
value
class Player(ba._player.Player[ForwardRef('Team')]):
112class Player(ba.Player['Team']):
113    """Our player type for this game."""
114
115    def __init__(self) -> None:
116        self.has_been_hurt = False
117        self.respawn_wave = 0

Our player type for this game.

Player()
115    def __init__(self) -> None:
116        self.has_been_hurt = False
117        self.respawn_wave = 0
class Team(ba._team.Team[bastd.game.onslaught.Player]):
120class Team(ba.Team[Player]):
121    """Our team type for this game."""

Our team type for this game.

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

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

OnslaughtGame(settings: dict)
144    def __init__(self, settings: dict):
145
146        self._preset = Preset(settings.get('preset', 'training'))
147        if self._preset in {
148                Preset.TRAINING, Preset.TRAINING_EASY, Preset.PRO,
149                Preset.PRO_EASY, Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT
150        }:
151            settings['map'] = 'Doom Shroom'
152        else:
153            settings['map'] = 'Courtyard'
154
155        super().__init__(settings)
156
157        self._new_wave_sound = ba.getsound('scoreHit01')
158        self._winsound = ba.getsound('score')
159        self._cashregistersound = ba.getsound('cashRegister')
160        self._a_player_has_been_hurt = False
161        self._player_has_dropped_bomb = False
162
163        # FIXME: should use standard map defs.
164        if settings['map'] == 'Doom Shroom':
165            self._spawn_center = (0, 3, -5)
166            self._tntspawnpos = (0.0, 3.0, -5.0)
167            self._powerup_center = (0, 5, -3.6)
168            self._powerup_spread = (6.0, 4.0)
169        elif settings['map'] == 'Courtyard':
170            self._spawn_center = (0, 3, -2)
171            self._tntspawnpos = (0.0, 3.0, 2.1)
172            self._powerup_center = (0, 5, -1.6)
173            self._powerup_spread = (4.6, 2.7)
174        else:
175            raise Exception('Unsupported map: ' + str(settings['map']))
176        self._scoreboard: Scoreboard | None = None
177        self._game_over = False
178        self._wavenum = 0
179        self._can_end_wave = True
180        self._score = 0
181        self._time_bonus = 0
182        self._spawn_info_text: ba.NodeActor | None = None
183        self._dingsound = ba.getsound('dingSmall')
184        self._dingsoundhigh = ba.getsound('dingSmallHigh')
185        self._have_tnt = False
186        self._excluded_powerups: list[str] | None = None
187        self._waves: list[Wave] = []
188        self._tntspawner: TNTSpawner | None = None
189        self._bots: SpazBotSet | None = None
190        self._powerup_drop_timer: ba.Timer | None = None
191        self._time_bonus_timer: ba.Timer | None = None
192        self._time_bonus_text: ba.NodeActor | None = None
193        self._flawless_bonus: int | None = None
194        self._wave_text: ba.NodeActor | None = None
195        self._wave_update_timer: ba.Timer | None = None
196        self._throw_off_kills = 0
197        self._land_mine_kills = 0
198        self._tnt_kills = 0

Instantiate the Activity.

name: str | None = 'Onslaught'
description: str | None = 'Defeat all enemies.'
tips: list[str | ba._gameutils.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.

def on_transition_in(self) -> None:
200    def on_transition_in(self) -> None:
201        super().on_transition_in()
202        customdata = ba.getsession().customdata
203
204        # Show special landmine tip on rookie preset.
205        if self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}:
206            # Show once per session only (then we revert to regular tips).
207            if not customdata.get('_showed_onslaught_landmine_tip', False):
208                customdata['_showed_onslaught_landmine_tip'] = True
209                self.tips = [
210                    ba.GameTip(
211                        'Land-mines are a good way to stop speedy enemies.',
212                        icon=ba.gettexture('powerupLandMines'),
213                        sound=ba.getsound('ding'))
214                ]
215
216        # Show special tnt tip on pro preset.
217        if self._preset in {Preset.PRO, Preset.PRO_EASY}:
218            # Show once per session only (then we revert to regular tips).
219            if not customdata.get('_showed_onslaught_tnt_tip', False):
220                customdata['_showed_onslaught_tnt_tip'] = True
221                self.tips = [
222                    ba.GameTip(
223                        'Take out a group of enemies by\n'
224                        'setting off a bomb near a TNT box.',
225                        icon=ba.gettexture('tnt'),
226                        sound=ba.getsound('ding'))
227                ]
228
229        # Show special curse tip on uber preset.
230        if self._preset in {Preset.UBER, Preset.UBER_EASY}:
231            # Show once per session only (then we revert to regular tips).
232            if not customdata.get('_showed_onslaught_curse_tip', False):
233                customdata['_showed_onslaught_curse_tip'] = True
234                self.tips = [
235                    ba.GameTip(
236                        'Curse boxes turn you into a ticking time bomb.\n'
237                        'The only cure is to quickly grab a health-pack.',
238                        icon=ba.gettexture('powerupCurse'),
239                        sound=ba.getsound('ding'))
240                ]
241
242        self._spawn_info_text = ba.NodeActor(
243            ba.newnode('text',
244                       attrs={
245                           'position': (15, -130),
246                           'h_attach': 'left',
247                           'v_attach': 'top',
248                           'scale': 0.55,
249                           'color': (0.3, 0.8, 0.3, 1.0),
250                           'text': ''
251                       }))
252        ba.setmusic(ba.MusicType.ONSLAUGHT)
253
254        self._scoreboard = Scoreboard(label=ba.Lstr(resource='scoreText'),
255                                      score_split=0.5)

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 ba.Activity.on_begin() is called.

def on_begin(self) -> None:
257    def on_begin(self) -> None:
258        super().on_begin()
259        player_count = len(self.players)
260        hard = self._preset not in {
261            Preset.TRAINING_EASY, Preset.ROOKIE_EASY, Preset.PRO_EASY,
262            Preset.UBER_EASY
263        }
264        if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}:
265            ControlsGuide(delay=3.0, lifespan=10.0, bright=True).autoretain()
266
267            self._have_tnt = False
268            self._excluded_powerups = ['curse', 'land_mines']
269            self._waves = [
270                Wave(base_angle=195,
271                     entries=[
272                         Spawn(BomberBotLite, spacing=5),
273                     ] * player_count),
274                Wave(base_angle=130,
275                     entries=[
276                         Spawn(BrawlerBotLite, spacing=5),
277                     ] * player_count),
278                Wave(base_angle=195,
279                     entries=[Spawn(BomberBotLite, spacing=10)] *
280                     (player_count + 1)),
281                Wave(base_angle=130,
282                     entries=[
283                         Spawn(BrawlerBotLite, spacing=10),
284                     ] * (player_count + 1)),
285                Wave(base_angle=130,
286                     entries=[
287                         Spawn(BrawlerBotLite, spacing=5)
288                         if player_count > 1 else None,
289                         Spawn(BrawlerBotLite, spacing=5),
290                         Spacing(30),
291                         Spawn(BomberBotLite, spacing=5)
292                         if player_count > 3 else None,
293                         Spawn(BomberBotLite, spacing=5),
294                         Spacing(30),
295                         Spawn(BrawlerBotLite, spacing=5),
296                         Spawn(BrawlerBotLite, spacing=5)
297                         if player_count > 2 else None,
298                     ]),
299                Wave(base_angle=195,
300                     entries=[
301                         Spawn(TriggerBot, spacing=90),
302                         Spawn(TriggerBot, spacing=90)
303                         if player_count > 1 else None,
304                     ]),
305            ]
306
307        elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}:
308            self._have_tnt = False
309            self._excluded_powerups = ['curse']
310            self._waves = [
311                Wave(entries=[
312                    Spawn(ChargerBot, Point.LEFT_UPPER_MORE
313                          ) if player_count > 2 else None,
314                    Spawn(ChargerBot, Point.LEFT_UPPER),
315                ]),
316                Wave(entries=[
317                    Spawn(BomberBotStaticLite, Point.TURRET_TOP_RIGHT),
318                    Spawn(BrawlerBotLite, Point.RIGHT_UPPER),
319                    Spawn(BrawlerBotLite, Point.RIGHT_LOWER
320                          ) if player_count > 1 else None,
321                    Spawn(BomberBotStaticLite, Point.TURRET_BOTTOM_RIGHT
322                          ) if player_count > 2 else None,
323                ]),
324                Wave(entries=[
325                    Spawn(BomberBotStaticLite, Point.TURRET_BOTTOM_LEFT),
326                    Spawn(TriggerBot, Point.LEFT),
327                    Spawn(TriggerBot, Point.LEFT_LOWER
328                          ) if player_count > 1 else None,
329                    Spawn(TriggerBot, Point.LEFT_UPPER
330                          ) if player_count > 2 else None,
331                ]),
332                Wave(entries=[
333                    Spawn(BrawlerBotLite, Point.TOP_RIGHT),
334                    Spawn(BrawlerBot, Point.TOP_HALF_RIGHT
335                          ) if player_count > 1 else None,
336                    Spawn(BrawlerBotLite, Point.TOP_LEFT),
337                    Spawn(BrawlerBotLite, Point.TOP_HALF_LEFT
338                          ) if player_count > 2 else None,
339                    Spawn(BrawlerBot, Point.TOP),
340                    Spawn(BomberBotStaticLite, Point.TURRET_TOP_MIDDLE),
341                ]),
342                Wave(entries=[
343                    Spawn(TriggerBotStatic, Point.TURRET_BOTTOM_LEFT),
344                    Spawn(TriggerBotStatic, Point.TURRET_BOTTOM_RIGHT),
345                    Spawn(TriggerBot, Point.BOTTOM),
346                    Spawn(TriggerBot, Point.BOTTOM_HALF_RIGHT
347                          ) if player_count > 1 else None,
348                    Spawn(TriggerBot, Point.BOTTOM_HALF_LEFT
349                          ) if player_count > 2 else None,
350                ]),
351                Wave(entries=[
352                    Spawn(BomberBotStaticLite, Point.TURRET_TOP_LEFT),
353                    Spawn(BomberBotStaticLite, Point.TURRET_TOP_RIGHT),
354                    Spawn(ChargerBot, Point.BOTTOM),
355                    Spawn(ChargerBot, Point.BOTTOM_HALF_LEFT
356                          ) if player_count > 1 else None,
357                    Spawn(ChargerBot, Point.BOTTOM_HALF_RIGHT
358                          ) if player_count > 2 else None,
359                ]),
360            ]
361
362        elif self._preset in {Preset.PRO, Preset.PRO_EASY}:
363            self._excluded_powerups = ['curse']
364            self._have_tnt = True
365            self._waves = [
366                Wave(base_angle=-50,
367                     entries=[
368                         Spawn(BrawlerBot, spacing=12)
369                         if player_count > 3 else None,
370                         Spawn(BrawlerBot, spacing=12),
371                         Spawn(BomberBot, spacing=6),
372                         Spawn(BomberBot, spacing=6)
373                         if self._preset is Preset.PRO else None,
374                         Spawn(BomberBot, spacing=6)
375                         if player_count > 1 else None,
376                         Spawn(BrawlerBot, spacing=12),
377                         Spawn(BrawlerBot, spacing=12)
378                         if player_count > 2 else None,
379                     ]),
380                Wave(base_angle=180,
381                     entries=[
382                         Spawn(BrawlerBot, spacing=6)
383                         if player_count > 3 else None,
384                         Spawn(BrawlerBot, spacing=6)
385                         if self._preset is Preset.PRO else None,
386                         Spawn(BrawlerBot, spacing=6),
387                         Spawn(ChargerBot, spacing=45),
388                         Spawn(ChargerBot, spacing=45)
389                         if player_count > 1 else None,
390                         Spawn(BrawlerBot, spacing=6),
391                         Spawn(BrawlerBot, spacing=6)
392                         if self._preset is Preset.PRO else None,
393                         Spawn(BrawlerBot, spacing=6)
394                         if player_count > 2 else None,
395                     ]),
396                Wave(base_angle=0,
397                     entries=[
398                         Spawn(ChargerBot, spacing=30),
399                         Spawn(TriggerBot, spacing=30),
400                         Spawn(TriggerBot, spacing=30),
401                         Spawn(TriggerBot, spacing=30)
402                         if self._preset is Preset.PRO else None,
403                         Spawn(TriggerBot, spacing=30)
404                         if player_count > 1 else None,
405                         Spawn(TriggerBot, spacing=30)
406                         if player_count > 3 else None,
407                         Spawn(ChargerBot, spacing=30),
408                     ]),
409                Wave(base_angle=90,
410                     entries=[
411                         Spawn(StickyBot, spacing=50),
412                         Spawn(StickyBot, spacing=50)
413                         if self._preset is Preset.PRO else None,
414                         Spawn(StickyBot, spacing=50),
415                         Spawn(StickyBot, spacing=50)
416                         if player_count > 1 else None,
417                         Spawn(StickyBot, spacing=50)
418                         if player_count > 3 else None,
419                     ]),
420                Wave(base_angle=0,
421                     entries=[
422                         Spawn(TriggerBot, spacing=72),
423                         Spawn(TriggerBot, spacing=72),
424                         Spawn(TriggerBot, spacing=72)
425                         if self._preset is Preset.PRO else None,
426                         Spawn(TriggerBot, spacing=72),
427                         Spawn(TriggerBot, spacing=72),
428                         Spawn(TriggerBot, spacing=36)
429                         if player_count > 2 else None,
430                     ]),
431                Wave(base_angle=30,
432                     entries=[
433                         Spawn(ChargerBotProShielded, spacing=50),
434                         Spawn(ChargerBotProShielded, spacing=50),
435                         Spawn(ChargerBotProShielded, spacing=50)
436                         if self._preset is Preset.PRO else None,
437                         Spawn(ChargerBotProShielded, spacing=50)
438                         if player_count > 1 else None,
439                         Spawn(ChargerBotProShielded, spacing=50)
440                         if player_count > 2 else None,
441                     ])
442            ]
443
444        elif self._preset in {Preset.UBER, Preset.UBER_EASY}:
445
446            # Show controls help in demo/arcade modes.
447            if ba.app.demo_mode or ba.app.arcade_mode:
448                ControlsGuide(delay=3.0, lifespan=10.0,
449                              bright=True).autoretain()
450
451            self._have_tnt = True
452            self._excluded_powerups = []
453            self._waves = [
454                Wave(entries=[
455                    Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE_LEFT
456                          ) if hard else None,
457                    Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE_RIGHT),
458                    Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT
459                          ) if player_count > 2 else None,
460                    Spawn(ExplodeyBot, Point.TOP_RIGHT),
461                    Delay(4.0),
462                    Spawn(ExplodeyBot, Point.TOP_LEFT),
463                ]),
464                Wave(entries=[
465                    Spawn(ChargerBot, Point.LEFT),
466                    Spawn(ChargerBot, Point.RIGHT),
467                    Spawn(ChargerBot, Point.RIGHT_UPPER_MORE
468                          ) if player_count > 2 else None,
469                    Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT),
470                    Spawn(BomberBotProStatic, Point.TURRET_TOP_RIGHT),
471                ]),
472                Wave(entries=[
473                    Spawn(TriggerBotPro, Point.TOP_RIGHT),
474                    Spawn(TriggerBotPro, Point.RIGHT_UPPER_MORE
475                          ) if player_count > 1 else None,
476                    Spawn(TriggerBotPro, Point.RIGHT_UPPER),
477                    Spawn(TriggerBotPro, Point.RIGHT_LOWER) if hard else None,
478                    Spawn(TriggerBotPro, Point.RIGHT_LOWER_MORE
479                          ) if player_count > 2 else None,
480                    Spawn(TriggerBotPro, Point.BOTTOM_RIGHT),
481                ]),
482                Wave(entries=[
483                    Spawn(ChargerBotProShielded, Point.BOTTOM_RIGHT),
484                    Spawn(ChargerBotProShielded, Point.BOTTOM
485                          ) if player_count > 2 else None,
486                    Spawn(ChargerBotProShielded, Point.BOTTOM_LEFT),
487                    Spawn(ChargerBotProShielded, Point.TOP) if hard else None,
488                    Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE),
489                ]),
490                Wave(entries=[
491                    Spawn(ExplodeyBot, Point.LEFT_UPPER),
492                    Delay(1.0),
493                    Spawn(BrawlerBotProShielded, Point.LEFT_LOWER),
494                    Spawn(BrawlerBotProShielded, Point.LEFT_LOWER_MORE),
495                    Delay(4.0),
496                    Spawn(ExplodeyBot, Point.RIGHT_UPPER),
497                    Delay(1.0),
498                    Spawn(BrawlerBotProShielded, Point.RIGHT_LOWER),
499                    Spawn(BrawlerBotProShielded, Point.RIGHT_UPPER_MORE),
500                    Delay(4.0),
501                    Spawn(ExplodeyBot, Point.LEFT),
502                    Delay(5.0),
503                    Spawn(ExplodeyBot, Point.RIGHT),
504                ]),
505                Wave(entries=[
506                    Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT),
507                    Spawn(BomberBotProStatic, Point.TURRET_TOP_RIGHT),
508                    Spawn(BomberBotProStatic, Point.TURRET_BOTTOM_LEFT),
509                    Spawn(BomberBotProStatic, Point.TURRET_BOTTOM_RIGHT),
510                    Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE_LEFT
511                          ) if hard else None,
512                    Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE_RIGHT
513                          ) if hard else None,
514                ])
515            ]
516
517        # We generate these on the fly in endless.
518        elif self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
519            self._have_tnt = True
520            self._excluded_powerups = []
521            self._waves = []
522
523        else:
524            raise RuntimeError(f'Invalid preset: {self._preset}')
525
526        # FIXME: Should migrate to use setup_standard_powerup_drops().
527
528        # Spit out a few powerups and start dropping more shortly.
529        self._drop_powerups(standard_points=True,
530                            poweruptype='curse' if self._preset
531                            in [Preset.UBER, Preset.UBER_EASY] else
532                            ('land_mines' if self._preset
533                             in [Preset.ROOKIE, Preset.ROOKIE_EASY] else None))
534        ba.timer(4.0, self._start_powerup_drops)
535
536        # Our TNT spawner (if applicable).
537        if self._have_tnt:
538            self._tntspawner = TNTSpawner(position=self._tntspawnpos)
539
540        self.setup_low_life_warning_sound()
541        self._update_scores()
542        self._bots = SpazBotSet()
543        ba.timer(4.0, self._start_updating_waves)

Called once the previous ba.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.

def spawn_player(self, player: bastd.game.onslaught.Player) -> ba._actor.Actor:
676    def spawn_player(self, player: Player) -> ba.Actor:
677
678        # We keep track of who got hurt each wave for score purposes.
679        player.has_been_hurt = False
680        pos = (self._spawn_center[0] + random.uniform(-1.5, 1.5),
681               self._spawn_center[1],
682               self._spawn_center[2] + random.uniform(-1.5, 1.5))
683        spaz = self.spawn_player_spaz(player, position=pos)
684        if self._preset in {
685                Preset.TRAINING_EASY, Preset.ROOKIE_EASY, Preset.PRO_EASY,
686                Preset.UBER_EASY
687        }:
688            spaz.impact_scale = 0.25
689        spaz.add_dropped_bomb_callback(self._handle_player_dropped_bomb)
690        return spaz

Spawn something for the provided ba.Player.

The default implementation simply calls spawn_player_spaz().

def do_end(self, outcome: str, delay: float = 0.0) -> None:
734    def do_end(self, outcome: str, delay: float = 0.0) -> None:
735        """End the game with the specified outcome."""
736        if outcome == 'defeat':
737            self.fade_to_red()
738        score: int | None
739        if self._wavenum >= 2:
740            score = self._score
741            fail_message = None
742        else:
743            score = None
744            fail_message = ba.Lstr(resource='reachWave2Text')
745        self.end(
746            {
747                'outcome': outcome,
748                'score': score,
749                'fail_message': fail_message,
750                'playerinfos': self.initialplayerinfos
751            },
752            delay=delay)

End the game with the specified outcome.

def add_bot_at_point( self, point: bastd.game.onslaught.Point, spaz_type: type[bastd.actor.spazbot.SpazBot], spawn_time: float = 1.0) -> None:
1132    def add_bot_at_point(self,
1133                         point: Point,
1134                         spaz_type: type[SpazBot],
1135                         spawn_time: float = 1.0) -> None:
1136        """Add a new bot at a specified named point."""
1137        if self._game_over:
1138            return
1139        assert isinstance(point.value, str)
1140        pointpos = self.map.defs.points[point.value]
1141        assert self._bots is not None
1142        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[bastd.actor.spazbot.SpazBot], spawn_time: float = 1.0) -> None:
1144    def add_bot_at_angle(self,
1145                         angle: float,
1146                         spaz_type: type[SpazBot],
1147                         spawn_time: float = 1.0) -> None:
1148        """Add a new bot at a specified angle (for circular maps)."""
1149        if self._game_over:
1150            return
1151        angle_radians = angle / 57.2957795
1152        xval = math.sin(angle_radians) * 1.06
1153        zval = math.cos(angle_radians) * 1.06
1154        point = (xval / 0.125, 2.3, (zval / 0.2) - 3.7)
1155        assert self._bots is not None
1156        self._bots.spawn_bot(spaz_type, pos=point, spawn_time=spawn_time)

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

def handlemessage(self, msg: Any) -> Any:
1186    def handlemessage(self, msg: Any) -> Any:
1187
1188        if isinstance(msg, PlayerSpazHurtMessage):
1189            msg.spaz.getplayer(Player, True).has_been_hurt = True
1190            self._a_player_has_been_hurt = True
1191
1192        elif isinstance(msg, ba.PlayerScoredMessage):
1193            self._score += msg.score
1194            self._update_scores()
1195
1196        elif isinstance(msg, ba.PlayerDiedMessage):
1197            super().handlemessage(msg)  # Augment standard behavior.
1198            player = msg.getplayer(Player)
1199            self._a_player_has_been_hurt = True
1200
1201            # Make note with the player when they can respawn:
1202            if self._wavenum < 10:
1203                player.respawn_wave = max(2, self._wavenum + 1)
1204            elif self._wavenum < 15:
1205                player.respawn_wave = max(2, self._wavenum + 2)
1206            else:
1207                player.respawn_wave = max(2, self._wavenum + 3)
1208            ba.timer(0.1, self._update_player_spawn_info)
1209            ba.timer(0.1, self._checkroundover)
1210
1211        elif isinstance(msg, SpazBotDiedMessage):
1212            pts, importance = msg.spazbot.get_death_points(msg.how)
1213            if msg.killerplayer is not None:
1214                self._handle_kill_achievements(msg)
1215                target: Sequence[float] | None
1216                if msg.spazbot.node:
1217                    target = msg.spazbot.node.position
1218                else:
1219                    target = None
1220
1221                killerplayer = msg.killerplayer
1222                self.stats.player_scored(killerplayer,
1223                                         pts,
1224                                         target=target,
1225                                         kill=True,
1226                                         screenmessage=False,
1227                                         importance=importance)
1228                ba.playsound(self._dingsound
1229                             if importance == 1 else self._dingsoundhigh,
1230                             volume=0.6)
1231
1232            # Normally we pull scores from the score-set, but if there's
1233            # no player lets be explicit.
1234            else:
1235                self._score += pts
1236            self._update_scores()
1237        else:
1238            super().handlemessage(msg)

General message handling; can be passed any message object.

def end_game(self) -> None:
1295    def end_game(self) -> None:
1296        # Tell our bots to celebrate just to rub it in.
1297        assert self._bots is not None
1298        self._bots.final_celebrate()
1299        self._game_over = True
1300        self.do_end('defeat', delay=2.0)
1301        ba.setmusic(None)

Tell the game to wrap up and call ba.Activity.end() immediately.

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 (ba.GameActivity.setup_standard_time_limit()) will work with the game.

def on_continue(self) -> None:
1303    def on_continue(self) -> None:
1304        for player in self.players:
1305            if not player.is_alive():
1306                self.spawn_player(player)

This is called if a game supports and offers a continue and the player accepts. In this case the player should be given an extra life or whatever is relevant to keep the game going.

Inherited Members
ba._coopgame.CoopGameActivity
session
supports_session_type
get_score_type
celebrate
spawn_player_spaz
fade_to_red
setup_low_life_warning_sound
ba._gameactivity.GameActivity
available_settings
scoreconfig
allow_pausing
allow_kick_idle_players
show_kill_points
default_music
create_settings_ui
getscoreconfig
getname
get_display_string
get_team_display_string
get_description
get_description_display_string
get_available_settings
get_supported_maps
get_settings_display_string
map
get_instance_display_string
get_instance_scoreboard_display_string
get_instance_description
get_instance_description_short
is_waiting_for_continue
continue_or_end_game
on_player_join
end
respawn_player
spawn_player_if_exists
setup_standard_powerup_drops
setup_standard_time_limit
show_zoom_message
ba._activity.Activity
settings_raw
teams
players
is_joining_activity
use_fixed_vr_overlay
slow_motion
inherits_slow_motion
inherits_music
inherits_vr_camera_offset
inherits_vr_overlay_center
inherits_tint
allow_mid_activity_joins
transition_time
can_show_ad_on_death
globalsnode
stats
on_expire
customdata
expired
playertype
teamtype
retain_actor
add_actor_weak_ref
on_player_leave
on_team_join
on_team_leave
on_transition_out
has_transitioned_in
has_begun
has_ended
is_transitioning_out
transition_out
create_player
create_team
ba._dependency.DependencyComponent
dep_is_present
get_dynamic_deps