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

Empty space in a wave.

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

A delay between events in a wave.

Delay(duration: float)
class Preset(enum.Enum):
86class Preset(Enum):
87    """Game presets we support."""
88
89    TRAINING = 'training'
90    TRAINING_EASY = 'training_easy'
91    ROOKIE = 'rookie'
92    ROOKIE_EASY = 'rookie_easy'
93    PRO = 'pro'
94    PRO_EASY = 'pro_easy'
95    UBER = 'uber'
96    UBER_EASY = 'uber_easy'
97    ENDLESS = 'endless'
98    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):
101@unique
102class Point(Enum):
103    """Points on the map we can spawn at."""
104
105    LEFT_UPPER_MORE = 'bot_spawn_left_upper_more'
106    LEFT_UPPER = 'bot_spawn_left_upper'
107    TURRET_TOP_RIGHT = 'bot_spawn_turret_top_right'
108    RIGHT_UPPER = 'bot_spawn_right_upper'
109    TURRET_TOP_MIDDLE_LEFT = 'bot_spawn_turret_top_middle_left'
110    TURRET_TOP_MIDDLE_RIGHT = 'bot_spawn_turret_top_middle_right'
111    TURRET_TOP_LEFT = 'bot_spawn_turret_top_left'
112    TOP_RIGHT = 'bot_spawn_top_right'
113    TOP_LEFT = 'bot_spawn_top_left'
114    TOP = 'bot_spawn_top'
115    BOTTOM = 'bot_spawn_bottom'
116    LEFT = 'bot_spawn_left'
117    RIGHT = 'bot_spawn_right'
118    RIGHT_UPPER_MORE = 'bot_spawn_right_upper_more'
119    RIGHT_LOWER = 'bot_spawn_right_lower'
120    RIGHT_LOWER_MORE = 'bot_spawn_right_lower_more'
121    BOTTOM_RIGHT = 'bot_spawn_bottom_right'
122    BOTTOM_LEFT = 'bot_spawn_bottom_left'
123    TURRET_BOTTOM_RIGHT = 'bot_spawn_turret_bottom_right'
124    TURRET_BOTTOM_LEFT = 'bot_spawn_turret_bottom_left'
125    LEFT_LOWER = 'bot_spawn_left_lower'
126    LEFT_LOWER_MORE = 'bot_spawn_left_lower_more'
127    TURRET_TOP_MIDDLE = 'bot_spawn_turret_top_middle'
128    BOTTOM_HALF_RIGHT = 'bot_spawn_bottom_half_right'
129    BOTTOM_HALF_LEFT = 'bot_spawn_bottom_half_left'
130    TOP_HALF_RIGHT = 'bot_spawn_top_half_right'
131    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')]):
134class Player(ba.Player['Team']):
135    """Our player type for this game."""
136
137    def __init__(self) -> None:
138        self.has_been_hurt = False
139        self.respawn_wave = 0

Our player type for this game.

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

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

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

Instantiate the Activity.

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

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:
290    def on_begin(self) -> None:
291        super().on_begin()
292        player_count = len(self.players)
293        hard = self._preset not in {
294            Preset.TRAINING_EASY,
295            Preset.ROOKIE_EASY,
296            Preset.PRO_EASY,
297            Preset.UBER_EASY,
298        }
299        if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}:
300            ControlsGuide(delay=3.0, lifespan=10.0, bright=True).autoretain()
301
302            self._have_tnt = False
303            self._excluded_powerups = ['curse', 'land_mines']
304            self._waves = [
305                Wave(
306                    base_angle=195,
307                    entries=[
308                        Spawn(BomberBotLite, spacing=5),
309                    ]
310                    * player_count,
311                ),
312                Wave(
313                    base_angle=130,
314                    entries=[
315                        Spawn(BrawlerBotLite, spacing=5),
316                    ]
317                    * player_count,
318                ),
319                Wave(
320                    base_angle=195,
321                    entries=[Spawn(BomberBotLite, spacing=10)]
322                    * (player_count + 1),
323                ),
324                Wave(
325                    base_angle=130,
326                    entries=[
327                        Spawn(BrawlerBotLite, spacing=10),
328                    ]
329                    * (player_count + 1),
330                ),
331                Wave(
332                    base_angle=130,
333                    entries=[
334                        Spawn(BrawlerBotLite, spacing=5)
335                        if player_count > 1
336                        else None,
337                        Spawn(BrawlerBotLite, spacing=5),
338                        Spacing(30),
339                        Spawn(BomberBotLite, spacing=5)
340                        if player_count > 3
341                        else None,
342                        Spawn(BomberBotLite, spacing=5),
343                        Spacing(30),
344                        Spawn(BrawlerBotLite, spacing=5),
345                        Spawn(BrawlerBotLite, spacing=5)
346                        if player_count > 2
347                        else None,
348                    ],
349                ),
350                Wave(
351                    base_angle=195,
352                    entries=[
353                        Spawn(TriggerBot, spacing=90),
354                        Spawn(TriggerBot, spacing=90)
355                        if player_count > 1
356                        else None,
357                    ],
358                ),
359            ]
360
361        elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}:
362            self._have_tnt = False
363            self._excluded_powerups = ['curse']
364            self._waves = [
365                Wave(
366                    entries=[
367                        Spawn(ChargerBot, Point.LEFT_UPPER_MORE)
368                        if player_count > 2
369                        else None,
370                        Spawn(ChargerBot, Point.LEFT_UPPER),
371                    ]
372                ),
373                Wave(
374                    entries=[
375                        Spawn(BomberBotStaticLite, Point.TURRET_TOP_RIGHT),
376                        Spawn(BrawlerBotLite, Point.RIGHT_UPPER),
377                        Spawn(BrawlerBotLite, Point.RIGHT_LOWER)
378                        if player_count > 1
379                        else None,
380                        Spawn(BomberBotStaticLite, Point.TURRET_BOTTOM_RIGHT)
381                        if player_count > 2
382                        else None,
383                    ]
384                ),
385                Wave(
386                    entries=[
387                        Spawn(BomberBotStaticLite, Point.TURRET_BOTTOM_LEFT),
388                        Spawn(TriggerBot, Point.LEFT),
389                        Spawn(TriggerBot, Point.LEFT_LOWER)
390                        if player_count > 1
391                        else None,
392                        Spawn(TriggerBot, Point.LEFT_UPPER)
393                        if player_count > 2
394                        else None,
395                    ]
396                ),
397                Wave(
398                    entries=[
399                        Spawn(BrawlerBotLite, Point.TOP_RIGHT),
400                        Spawn(BrawlerBot, Point.TOP_HALF_RIGHT)
401                        if player_count > 1
402                        else None,
403                        Spawn(BrawlerBotLite, Point.TOP_LEFT),
404                        Spawn(BrawlerBotLite, Point.TOP_HALF_LEFT)
405                        if player_count > 2
406                        else None,
407                        Spawn(BrawlerBot, Point.TOP),
408                        Spawn(BomberBotStaticLite, Point.TURRET_TOP_MIDDLE),
409                    ]
410                ),
411                Wave(
412                    entries=[
413                        Spawn(TriggerBotStatic, Point.TURRET_BOTTOM_LEFT),
414                        Spawn(TriggerBotStatic, Point.TURRET_BOTTOM_RIGHT),
415                        Spawn(TriggerBot, Point.BOTTOM),
416                        Spawn(TriggerBot, Point.BOTTOM_HALF_RIGHT)
417                        if player_count > 1
418                        else None,
419                        Spawn(TriggerBot, Point.BOTTOM_HALF_LEFT)
420                        if player_count > 2
421                        else None,
422                    ]
423                ),
424                Wave(
425                    entries=[
426                        Spawn(BomberBotStaticLite, Point.TURRET_TOP_LEFT),
427                        Spawn(BomberBotStaticLite, Point.TURRET_TOP_RIGHT),
428                        Spawn(ChargerBot, Point.BOTTOM),
429                        Spawn(ChargerBot, Point.BOTTOM_HALF_LEFT)
430                        if player_count > 1
431                        else None,
432                        Spawn(ChargerBot, Point.BOTTOM_HALF_RIGHT)
433                        if player_count > 2
434                        else None,
435                    ]
436                ),
437            ]
438
439        elif self._preset in {Preset.PRO, Preset.PRO_EASY}:
440            self._excluded_powerups = ['curse']
441            self._have_tnt = True
442            self._waves = [
443                Wave(
444                    base_angle=-50,
445                    entries=[
446                        Spawn(BrawlerBot, spacing=12)
447                        if player_count > 3
448                        else None,
449                        Spawn(BrawlerBot, spacing=12),
450                        Spawn(BomberBot, spacing=6),
451                        Spawn(BomberBot, spacing=6)
452                        if self._preset is Preset.PRO
453                        else None,
454                        Spawn(BomberBot, spacing=6)
455                        if player_count > 1
456                        else None,
457                        Spawn(BrawlerBot, spacing=12),
458                        Spawn(BrawlerBot, spacing=12)
459                        if player_count > 2
460                        else None,
461                    ],
462                ),
463                Wave(
464                    base_angle=180,
465                    entries=[
466                        Spawn(BrawlerBot, spacing=6)
467                        if player_count > 3
468                        else None,
469                        Spawn(BrawlerBot, spacing=6)
470                        if self._preset is Preset.PRO
471                        else None,
472                        Spawn(BrawlerBot, spacing=6),
473                        Spawn(ChargerBot, spacing=45),
474                        Spawn(ChargerBot, spacing=45)
475                        if player_count > 1
476                        else None,
477                        Spawn(BrawlerBot, spacing=6),
478                        Spawn(BrawlerBot, spacing=6)
479                        if self._preset is Preset.PRO
480                        else None,
481                        Spawn(BrawlerBot, spacing=6)
482                        if player_count > 2
483                        else None,
484                    ],
485                ),
486                Wave(
487                    base_angle=0,
488                    entries=[
489                        Spawn(ChargerBot, spacing=30),
490                        Spawn(TriggerBot, spacing=30),
491                        Spawn(TriggerBot, spacing=30),
492                        Spawn(TriggerBot, spacing=30)
493                        if self._preset is Preset.PRO
494                        else None,
495                        Spawn(TriggerBot, spacing=30)
496                        if player_count > 1
497                        else None,
498                        Spawn(TriggerBot, spacing=30)
499                        if player_count > 3
500                        else None,
501                        Spawn(ChargerBot, spacing=30),
502                    ],
503                ),
504                Wave(
505                    base_angle=90,
506                    entries=[
507                        Spawn(StickyBot, spacing=50),
508                        Spawn(StickyBot, spacing=50)
509                        if self._preset is Preset.PRO
510                        else None,
511                        Spawn(StickyBot, spacing=50),
512                        Spawn(StickyBot, spacing=50)
513                        if player_count > 1
514                        else None,
515                        Spawn(StickyBot, spacing=50)
516                        if player_count > 3
517                        else None,
518                    ],
519                ),
520                Wave(
521                    base_angle=0,
522                    entries=[
523                        Spawn(TriggerBot, spacing=72),
524                        Spawn(TriggerBot, spacing=72),
525                        Spawn(TriggerBot, spacing=72)
526                        if self._preset is Preset.PRO
527                        else None,
528                        Spawn(TriggerBot, spacing=72),
529                        Spawn(TriggerBot, spacing=72),
530                        Spawn(TriggerBot, spacing=36)
531                        if player_count > 2
532                        else None,
533                    ],
534                ),
535                Wave(
536                    base_angle=30,
537                    entries=[
538                        Spawn(ChargerBotProShielded, spacing=50),
539                        Spawn(ChargerBotProShielded, spacing=50),
540                        Spawn(ChargerBotProShielded, spacing=50)
541                        if self._preset is Preset.PRO
542                        else None,
543                        Spawn(ChargerBotProShielded, spacing=50)
544                        if player_count > 1
545                        else None,
546                        Spawn(ChargerBotProShielded, spacing=50)
547                        if player_count > 2
548                        else None,
549                    ],
550                ),
551            ]
552
553        elif self._preset in {Preset.UBER, Preset.UBER_EASY}:
554
555            # Show controls help in demo/arcade modes.
556            if ba.app.demo_mode or ba.app.arcade_mode:
557                ControlsGuide(
558                    delay=3.0, lifespan=10.0, bright=True
559                ).autoretain()
560
561            self._have_tnt = True
562            self._excluded_powerups = []
563            self._waves = [
564                Wave(
565                    entries=[
566                        Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE_LEFT)
567                        if hard
568                        else None,
569                        Spawn(
570                            BomberBotProStatic, Point.TURRET_TOP_MIDDLE_RIGHT
571                        ),
572                        Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT)
573                        if player_count > 2
574                        else None,
575                        Spawn(ExplodeyBot, Point.TOP_RIGHT),
576                        Delay(4.0),
577                        Spawn(ExplodeyBot, Point.TOP_LEFT),
578                    ]
579                ),
580                Wave(
581                    entries=[
582                        Spawn(ChargerBot, Point.LEFT),
583                        Spawn(ChargerBot, Point.RIGHT),
584                        Spawn(ChargerBot, Point.RIGHT_UPPER_MORE)
585                        if player_count > 2
586                        else None,
587                        Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT),
588                        Spawn(BomberBotProStatic, Point.TURRET_TOP_RIGHT),
589                    ]
590                ),
591                Wave(
592                    entries=[
593                        Spawn(TriggerBotPro, Point.TOP_RIGHT),
594                        Spawn(TriggerBotPro, Point.RIGHT_UPPER_MORE)
595                        if player_count > 1
596                        else None,
597                        Spawn(TriggerBotPro, Point.RIGHT_UPPER),
598                        Spawn(TriggerBotPro, Point.RIGHT_LOWER)
599                        if hard
600                        else None,
601                        Spawn(TriggerBotPro, Point.RIGHT_LOWER_MORE)
602                        if player_count > 2
603                        else None,
604                        Spawn(TriggerBotPro, Point.BOTTOM_RIGHT),
605                    ]
606                ),
607                Wave(
608                    entries=[
609                        Spawn(ChargerBotProShielded, Point.BOTTOM_RIGHT),
610                        Spawn(ChargerBotProShielded, Point.BOTTOM)
611                        if player_count > 2
612                        else None,
613                        Spawn(ChargerBotProShielded, Point.BOTTOM_LEFT),
614                        Spawn(ChargerBotProShielded, Point.TOP)
615                        if hard
616                        else None,
617                        Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE),
618                    ]
619                ),
620                Wave(
621                    entries=[
622                        Spawn(ExplodeyBot, Point.LEFT_UPPER),
623                        Delay(1.0),
624                        Spawn(BrawlerBotProShielded, Point.LEFT_LOWER),
625                        Spawn(BrawlerBotProShielded, Point.LEFT_LOWER_MORE),
626                        Delay(4.0),
627                        Spawn(ExplodeyBot, Point.RIGHT_UPPER),
628                        Delay(1.0),
629                        Spawn(BrawlerBotProShielded, Point.RIGHT_LOWER),
630                        Spawn(BrawlerBotProShielded, Point.RIGHT_UPPER_MORE),
631                        Delay(4.0),
632                        Spawn(ExplodeyBot, Point.LEFT),
633                        Delay(5.0),
634                        Spawn(ExplodeyBot, Point.RIGHT),
635                    ]
636                ),
637                Wave(
638                    entries=[
639                        Spawn(BomberBotProStatic, Point.TURRET_TOP_LEFT),
640                        Spawn(BomberBotProStatic, Point.TURRET_TOP_RIGHT),
641                        Spawn(BomberBotProStatic, Point.TURRET_BOTTOM_LEFT),
642                        Spawn(BomberBotProStatic, Point.TURRET_BOTTOM_RIGHT),
643                        Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE_LEFT)
644                        if hard
645                        else None,
646                        Spawn(BomberBotProStatic, Point.TURRET_TOP_MIDDLE_RIGHT)
647                        if hard
648                        else None,
649                    ]
650                ),
651            ]
652
653        # We generate these on the fly in endless.
654        elif self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
655            self._have_tnt = True
656            self._excluded_powerups = []
657            self._waves = []
658
659        else:
660            raise RuntimeError(f'Invalid preset: {self._preset}')
661
662        # FIXME: Should migrate to use setup_standard_powerup_drops().
663
664        # Spit out a few powerups and start dropping more shortly.
665        self._drop_powerups(
666            standard_points=True,
667            poweruptype='curse'
668            if self._preset in [Preset.UBER, Preset.UBER_EASY]
669            else (
670                'land_mines'
671                if self._preset in [Preset.ROOKIE, Preset.ROOKIE_EASY]
672                else None
673            ),
674        )
675        ba.timer(4.0, self._start_powerup_drops)
676
677        # Our TNT spawner (if applicable).
678        if self._have_tnt:
679            self._tntspawner = TNTSpawner(position=self._tntspawnpos)
680
681        self.setup_low_life_warning_sound()
682        self._update_scores()
683        self._bots = SpazBotSet()
684        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:
829    def spawn_player(self, player: Player) -> ba.Actor:
830
831        # We keep track of who got hurt each wave for score purposes.
832        player.has_been_hurt = False
833        pos = (
834            self._spawn_center[0] + random.uniform(-1.5, 1.5),
835            self._spawn_center[1],
836            self._spawn_center[2] + random.uniform(-1.5, 1.5),
837        )
838        spaz = self.spawn_player_spaz(player, position=pos)
839        if self._preset in {
840            Preset.TRAINING_EASY,
841            Preset.ROOKIE_EASY,
842            Preset.PRO_EASY,
843            Preset.UBER_EASY,
844        }:
845            spaz.impact_scale = 0.25
846        spaz.add_dropped_bomb_callback(self._handle_player_dropped_bomb)
847        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:
904    def do_end(self, outcome: str, delay: float = 0.0) -> None:
905        """End the game with the specified outcome."""
906        if outcome == 'defeat':
907            self.fade_to_red()
908        score: int | None
909        if self._wavenum >= 2:
910            score = self._score
911            fail_message = None
912        else:
913            score = None
914            fail_message = ba.Lstr(resource='reachWave2Text')
915        self.end(
916            {
917                'outcome': outcome,
918                'score': score,
919                'fail_message': fail_message,
920                'playerinfos': self.initialplayerinfos,
921            },
922            delay=delay,
923        )

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:
1368    def add_bot_at_point(
1369        self, point: Point, spaz_type: type[SpazBot], spawn_time: float = 1.0
1370    ) -> None:
1371        """Add a new bot at a specified named point."""
1372        if self._game_over:
1373            return
1374        assert isinstance(point.value, str)
1375        pointpos = self.map.defs.points[point.value]
1376        assert self._bots is not None
1377        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:
1379    def add_bot_at_angle(
1380        self, angle: float, spaz_type: type[SpazBot], spawn_time: float = 1.0
1381    ) -> None:
1382        """Add a new bot at a specified angle (for circular maps)."""
1383        if self._game_over:
1384            return
1385        angle_radians = angle / 57.2957795
1386        xval = math.sin(angle_radians) * 1.06
1387        zval = math.cos(angle_radians) * 1.06
1388        point = (xval / 0.125, 2.3, (zval / 0.2) - 3.7)
1389        assert self._bots is not None
1390        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:
1423    def handlemessage(self, msg: Any) -> Any:
1424
1425        if isinstance(msg, PlayerSpazHurtMessage):
1426            msg.spaz.getplayer(Player, True).has_been_hurt = True
1427            self._a_player_has_been_hurt = True
1428
1429        elif isinstance(msg, ba.PlayerScoredMessage):
1430            self._score += msg.score
1431            self._update_scores()
1432
1433        elif isinstance(msg, ba.PlayerDiedMessage):
1434            super().handlemessage(msg)  # Augment standard behavior.
1435            player = msg.getplayer(Player)
1436            self._a_player_has_been_hurt = True
1437
1438            # Make note with the player when they can respawn:
1439            if self._wavenum < 10:
1440                player.respawn_wave = max(2, self._wavenum + 1)
1441            elif self._wavenum < 15:
1442                player.respawn_wave = max(2, self._wavenum + 2)
1443            else:
1444                player.respawn_wave = max(2, self._wavenum + 3)
1445            ba.timer(0.1, self._update_player_spawn_info)
1446            ba.timer(0.1, self._checkroundover)
1447
1448        elif isinstance(msg, SpazBotDiedMessage):
1449            pts, importance = msg.spazbot.get_death_points(msg.how)
1450            if msg.killerplayer is not None:
1451                self._handle_kill_achievements(msg)
1452                target: Sequence[float] | None
1453                if msg.spazbot.node:
1454                    target = msg.spazbot.node.position
1455                else:
1456                    target = None
1457
1458                killerplayer = msg.killerplayer
1459                self.stats.player_scored(
1460                    killerplayer,
1461                    pts,
1462                    target=target,
1463                    kill=True,
1464                    screenmessage=False,
1465                    importance=importance,
1466                )
1467                ba.playsound(
1468                    self._dingsound if importance == 1 else self._dingsoundhigh,
1469                    volume=0.6,
1470                )
1471
1472            # Normally we pull scores from the score-set, but if there's
1473            # no player lets be explicit.
1474            else:
1475                self._score += pts
1476            self._update_scores()
1477        else:
1478            super().handlemessage(msg)

General message handling; can be passed any message object.

def end_game(self) -> None:
1538    def end_game(self) -> None:
1539        # Tell our bots to celebrate just to rub it in.
1540        assert self._bots is not None
1541        self._bots.final_celebrate()
1542        self._game_over = True
1543        self.do_end('defeat', delay=2.0)
1544        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:
1546    def on_continue(self) -> None:
1547        for player in self.players:
1548            if not player.is_alive():
1549                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
allow_pausing
allow_kick_idle_players
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