bascenev1lib.game.onslaught

Provides Onslaught Co-op game.

   1# Released under the MIT License. See LICENSE for details.
   2#
   3"""Provides Onslaught Co-op game."""
   4
   5# Yes this is a long one..
   6# pylint: disable=too-many-lines
   7
   8# ba_meta require api 8
   9# (see https://ballistica.net/wiki/meta-tag-system)
  10
  11from __future__ import annotations
  12
  13import math
  14import random
  15import logging
  16from enum import Enum, unique
  17from dataclasses import dataclass
  18from typing import TYPE_CHECKING
  19
  20from bascenev1lib.actor.popuptext import PopupText
  21from bascenev1lib.actor.bomb import TNTSpawner
  22from bascenev1lib.actor.playerspaz import PlayerSpazHurtMessage
  23from bascenev1lib.actor.scoreboard import Scoreboard
  24from bascenev1lib.actor.controlsguide import ControlsGuide
  25from bascenev1lib.actor.powerupbox import PowerupBox, PowerupBoxFactory
  26from bascenev1lib.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)
  48import bascenev1 as bs
  49
  50if TYPE_CHECKING:
  51    from typing import Any, Sequence
  52    from bascenev1lib.actor.spazbot import SpazBot
  53
  54
  55@dataclass
  56class Wave:
  57    """A wave of enemies."""
  58
  59    entries: list[Spawn | Spacing | Delay | None]
  60    base_angle: float = 0.0
  61
  62
  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
  70
  71
  72@dataclass
  73class Spacing:
  74    """Empty space in a wave."""
  75
  76    spacing: float = 5.0
  77
  78
  79@dataclass
  80class Delay:
  81    """A delay between events in a wave."""
  82
  83    duration: float
  84
  85
  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'
  99
 100
 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'
 132
 133
 134class Player(bs.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
 140
 141
 142class Team(bs.Team[Player]):
 143    """Our team type for this game."""
 144
 145
 146class OnslaughtGame(bs.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 | bs.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        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 = bs.getsound('scoreHit01')
 183        self._winsound = bs.getsound('score')
 184        self._cashregistersound = bs.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 RuntimeError('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: bs.NodeActor | None = None
 208        self._dingsound = bs.getsound('dingSmall')
 209        self._dingsoundhigh = bs.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: bs.Timer | None = None
 216        self._time_bonus_timer: bs.Timer | None = None
 217        self._time_bonus_text: bs.NodeActor | None = None
 218        self._flawless_bonus: int | None = None
 219        self._wave_text: bs.NodeActor | None = None
 220        self._wave_update_timer: bs.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 = bs.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                    bs.GameTip(
 236                        'Land-mines are a good way to stop speedy enemies.',
 237                        icon=bs.gettexture('powerupLandMines'),
 238                        sound=bs.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                    bs.GameTip(
 249                        'Take out a group of enemies by\n'
 250                        'setting off a bomb near a TNT box.',
 251                        icon=bs.gettexture('tnt'),
 252                        sound=bs.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                    bs.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=bs.gettexture('powerupCurse'),
 266                        sound=bs.getsound('ding'),
 267                    )
 268                ]
 269
 270        self._spawn_info_text = bs.NodeActor(
 271            bs.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        bs.setmusic(bs.MusicType.ONSLAUGHT)
 284
 285        self._scoreboard = Scoreboard(
 286            label=bs.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            # Show controls help in demo or arcade modes.
 554            env = bs.app.env
 555            if env.demo or env.arcade:
 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        bs.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        bs.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) -> bs.Actor:
 829        # We keep track of who got hurt each wave for score purposes.
 830        player.has_been_hurt = False
 831        pos = (
 832            self._spawn_center[0] + random.uniform(-1.5, 1.5),
 833            self._spawn_center[1],
 834            self._spawn_center[2] + random.uniform(-1.5, 1.5),
 835        )
 836        spaz = self.spawn_player_spaz(player, position=pos)
 837        if self._preset in {
 838            Preset.TRAINING_EASY,
 839            Preset.ROOKIE_EASY,
 840            Preset.PRO_EASY,
 841            Preset.UBER_EASY,
 842        }:
 843            spaz.impact_scale = 0.25
 844        spaz.add_dropped_bomb_callback(self._handle_player_dropped_bomb)
 845        return spaz
 846
 847    def _handle_player_dropped_bomb(
 848        self, player: bs.Actor, bomb: bs.Actor
 849    ) -> None:
 850        del player, bomb  # Unused.
 851        self._player_has_dropped_bomb = True
 852
 853    def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None:
 854        poweruptype = PowerupBoxFactory.get().get_random_powerup_type(
 855            forcetype=poweruptype, excludetypes=self._excluded_powerups
 856        )
 857        PowerupBox(
 858            position=self.map.powerup_spawn_points[index],
 859            poweruptype=poweruptype,
 860        ).autoretain()
 861
 862    def _start_powerup_drops(self) -> None:
 863        self._powerup_drop_timer = bs.Timer(
 864            3.0, bs.WeakCall(self._drop_powerups), repeat=True
 865        )
 866
 867    def _drop_powerups(
 868        self, standard_points: bool = False, poweruptype: str | None = None
 869    ) -> None:
 870        """Generic powerup drop."""
 871        if standard_points:
 872            points = self.map.powerup_spawn_points
 873            for i in range(len(points)):
 874                bs.timer(
 875                    1.0 + i * 0.5,
 876                    bs.WeakCall(
 877                        self._drop_powerup, i, poweruptype if i == 0 else None
 878                    ),
 879                )
 880        else:
 881            point = (
 882                self._powerup_center[0]
 883                + random.uniform(
 884                    -1.0 * self._powerup_spread[0],
 885                    1.0 * self._powerup_spread[0],
 886                ),
 887                self._powerup_center[1],
 888                self._powerup_center[2]
 889                + random.uniform(
 890                    -self._powerup_spread[1], self._powerup_spread[1]
 891                ),
 892            )
 893
 894            # Drop one random one somewhere.
 895            PowerupBox(
 896                position=point,
 897                poweruptype=PowerupBoxFactory.get().get_random_powerup_type(
 898                    excludetypes=self._excluded_powerups
 899                ),
 900            ).autoretain()
 901
 902    def do_end(self, outcome: str, delay: float = 0.0) -> None:
 903        """End the game with the specified outcome."""
 904        if outcome == 'defeat':
 905            self.fade_to_red()
 906        score: int | None
 907        if self._wavenum >= 2:
 908            score = self._score
 909            fail_message = None
 910        else:
 911            score = None
 912            fail_message = bs.Lstr(resource='reachWave2Text')
 913        self.end(
 914            {
 915                'outcome': outcome,
 916                'score': score,
 917                'fail_message': fail_message,
 918                'playerinfos': self.initialplayerinfos,
 919            },
 920            delay=delay,
 921        )
 922
 923    def _award_completion_achievements(self) -> None:
 924        if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}:
 925            self._award_achievement('Onslaught Training Victory', sound=False)
 926            if not self._player_has_dropped_bomb:
 927                self._award_achievement('Boxer', sound=False)
 928        elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}:
 929            self._award_achievement('Rookie Onslaught Victory', sound=False)
 930            if not self._a_player_has_been_hurt:
 931                self._award_achievement('Flawless Victory', sound=False)
 932        elif self._preset in {Preset.PRO, Preset.PRO_EASY}:
 933            self._award_achievement('Pro Onslaught Victory', sound=False)
 934            if not self._player_has_dropped_bomb:
 935                self._award_achievement('Pro Boxer', sound=False)
 936        elif self._preset in {Preset.UBER, Preset.UBER_EASY}:
 937            self._award_achievement('Uber Onslaught Victory', sound=False)
 938
 939    def _update_waves(self) -> None:
 940        # If we have no living bots, go to the next wave.
 941        assert self._bots is not None
 942        if (
 943            self._can_end_wave
 944            and not self._bots.have_living_bots()
 945            and not self._game_over
 946        ):
 947            self._can_end_wave = False
 948            self._time_bonus_timer = None
 949            self._time_bonus_text = None
 950            if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
 951                won = False
 952            else:
 953                won = self._wavenum == len(self._waves)
 954
 955            base_delay = 4.0 if won else 0.0
 956
 957            # Reward time bonus.
 958            if self._time_bonus > 0:
 959                bs.timer(0, self._cashregistersound.play)
 960                bs.timer(
 961                    base_delay,
 962                    bs.WeakCall(self._award_time_bonus, self._time_bonus),
 963                )
 964                base_delay += 1.0
 965
 966            # Reward flawless bonus.
 967            if self._wavenum > 0:
 968                have_flawless = False
 969                for player in self.players:
 970                    if player.is_alive() and not player.has_been_hurt:
 971                        have_flawless = True
 972                        bs.timer(
 973                            base_delay,
 974                            bs.WeakCall(self._award_flawless_bonus, player),
 975                        )
 976                    player.has_been_hurt = False  # reset
 977                if have_flawless:
 978                    base_delay += 1.0
 979
 980            if won:
 981                self.show_zoom_message(
 982                    bs.Lstr(resource='victoryText'), scale=1.0, duration=4.0
 983                )
 984                self.celebrate(20.0)
 985                self._award_completion_achievements()
 986                bs.timer(base_delay, bs.WeakCall(self._award_completion_bonus))
 987                base_delay += 0.85
 988                self._winsound.play()
 989                bs.cameraflash()
 990                bs.setmusic(bs.MusicType.VICTORY)
 991                self._game_over = True
 992
 993                # Can't just pass delay to do_end because our extra bonuses
 994                # haven't been added yet (once we call do_end the score
 995                # gets locked in).
 996                bs.timer(base_delay, bs.WeakCall(self.do_end, 'victory'))
 997                return
 998
 999            self._wavenum += 1
1000
1001            # Short celebration after waves.
1002            if self._wavenum > 1:
1003                self.celebrate(0.5)
1004            bs.timer(base_delay, bs.WeakCall(self._start_next_wave))
1005
1006    def _award_completion_bonus(self) -> None:
1007        self._cashregistersound.play()
1008        for player in self.players:
1009            try:
1010                if player.is_alive():
1011                    assert self.initialplayerinfos is not None
1012                    self.stats.player_scored(
1013                        player,
1014                        int(100 / len(self.initialplayerinfos)),
1015                        scale=1.4,
1016                        color=(0.6, 0.6, 1.0, 1.0),
1017                        title=bs.Lstr(resource='completionBonusText'),
1018                        screenmessage=False,
1019                    )
1020            except Exception:
1021                logging.exception('error in _award_completion_bonus')
1022
1023    def _award_time_bonus(self, bonus: int) -> None:
1024        self._cashregistersound.play()
1025        PopupText(
1026            bs.Lstr(
1027                value='+${A} ${B}',
1028                subs=[
1029                    ('${A}', str(bonus)),
1030                    ('${B}', bs.Lstr(resource='timeBonusText')),
1031                ],
1032            ),
1033            color=(1, 1, 0.5, 1),
1034            scale=1.0,
1035            position=(0, 3, -1),
1036        ).autoretain()
1037        self._score += self._time_bonus
1038        self._update_scores()
1039
1040    def _award_flawless_bonus(self, player: Player) -> None:
1041        self._cashregistersound.play()
1042        try:
1043            if player.is_alive():
1044                assert self._flawless_bonus is not None
1045                self.stats.player_scored(
1046                    player,
1047                    self._flawless_bonus,
1048                    scale=1.2,
1049                    color=(0.6, 1.0, 0.6, 1.0),
1050                    title=bs.Lstr(resource='flawlessWaveText'),
1051                    screenmessage=False,
1052                )
1053        except Exception:
1054            logging.exception('error in _award_flawless_bonus')
1055
1056    def _start_time_bonus_timer(self) -> None:
1057        self._time_bonus_timer = bs.Timer(
1058            1.0, bs.WeakCall(self._update_time_bonus), repeat=True
1059        )
1060
1061    def _update_player_spawn_info(self) -> None:
1062        # If we have no living players lets just blank this.
1063        assert self._spawn_info_text is not None
1064        assert self._spawn_info_text.node
1065        if not any(player.is_alive() for player in self.teams[0].players):
1066            self._spawn_info_text.node.text = ''
1067        else:
1068            text: str | bs.Lstr = ''
1069            for player in self.players:
1070                if not player.is_alive() and (
1071                    self._preset in [Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT]
1072                    or (player.respawn_wave <= len(self._waves))
1073                ):
1074                    rtxt = bs.Lstr(
1075                        resource='onslaughtRespawnText',
1076                        subs=[
1077                            ('${PLAYER}', player.getname()),
1078                            ('${WAVE}', str(player.respawn_wave)),
1079                        ],
1080                    )
1081                    text = bs.Lstr(
1082                        value='${A}${B}\n',
1083                        subs=[
1084                            ('${A}', text),
1085                            ('${B}', rtxt),
1086                        ],
1087                    )
1088            self._spawn_info_text.node.text = text
1089
1090    def _respawn_players_for_wave(self) -> None:
1091        # Respawn applicable players.
1092        if self._wavenum > 1 and not self.is_waiting_for_continue():
1093            for player in self.players:
1094                if (
1095                    not player.is_alive()
1096                    and player.respawn_wave == self._wavenum
1097                ):
1098                    self.spawn_player(player)
1099        self._update_player_spawn_info()
1100
1101    def _setup_wave_spawns(self, wave: Wave) -> None:
1102        tval = 0.0
1103        dtime = 0.2
1104        if self._wavenum == 1:
1105            spawn_time = 3.973
1106            tval += 0.5
1107        else:
1108            spawn_time = 2.648
1109
1110        bot_angle = wave.base_angle
1111        self._time_bonus = 0
1112        self._flawless_bonus = 0
1113        for info in wave.entries:
1114            if info is None:
1115                continue
1116            if isinstance(info, Delay):
1117                spawn_time += info.duration
1118                continue
1119            if isinstance(info, Spacing):
1120                bot_angle += info.spacing
1121                continue
1122            bot_type_2 = info.bottype
1123            if bot_type_2 is not None:
1124                assert not isinstance(bot_type_2, str)
1125                self._time_bonus += bot_type_2.points_mult * 20
1126                self._flawless_bonus += bot_type_2.points_mult * 5
1127
1128            # If its got a position, use that.
1129            point = info.point
1130            if point is not None:
1131                assert bot_type_2 is not None
1132                spcall = bs.WeakCall(
1133                    self.add_bot_at_point, point, bot_type_2, spawn_time
1134                )
1135                bs.timer(tval, spcall)
1136                tval += dtime
1137            else:
1138                spacing = info.spacing
1139                bot_angle += spacing * 0.5
1140                if bot_type_2 is not None:
1141                    tcall = bs.WeakCall(
1142                        self.add_bot_at_angle, bot_angle, bot_type_2, spawn_time
1143                    )
1144                    bs.timer(tval, tcall)
1145                    tval += dtime
1146                bot_angle += spacing * 0.5
1147
1148        # We can end the wave after all the spawning happens.
1149        bs.timer(
1150            tval + spawn_time - dtime + 0.01,
1151            bs.WeakCall(self._set_can_end_wave),
1152        )
1153
1154    def _start_next_wave(self) -> None:
1155        # This can happen if we beat a wave as we die.
1156        # We don't wanna respawn players and whatnot if this happens.
1157        if self._game_over:
1158            return
1159
1160        self._respawn_players_for_wave()
1161        if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
1162            wave = self._generate_random_wave()
1163        else:
1164            wave = self._waves[self._wavenum - 1]
1165        self._setup_wave_spawns(wave)
1166        self._update_wave_ui_and_bonuses()
1167        bs.timer(0.4, self._new_wave_sound.play)
1168
1169    def _update_wave_ui_and_bonuses(self) -> None:
1170        self.show_zoom_message(
1171            bs.Lstr(
1172                value='${A} ${B}',
1173                subs=[
1174                    ('${A}', bs.Lstr(resource='waveText')),
1175                    ('${B}', str(self._wavenum)),
1176                ],
1177            ),
1178            scale=1.0,
1179            duration=1.0,
1180            trail=True,
1181        )
1182
1183        # Reset our time bonus.
1184        tbtcolor = (1, 1, 0, 1)
1185        tbttxt = bs.Lstr(
1186            value='${A}: ${B}',
1187            subs=[
1188                ('${A}', bs.Lstr(resource='timeBonusText')),
1189                ('${B}', str(self._time_bonus)),
1190            ],
1191        )
1192        self._time_bonus_text = bs.NodeActor(
1193            bs.newnode(
1194                'text',
1195                attrs={
1196                    'v_attach': 'top',
1197                    'h_attach': 'center',
1198                    'h_align': 'center',
1199                    'vr_depth': -30,
1200                    'color': tbtcolor,
1201                    'shadow': 1.0,
1202                    'flatness': 1.0,
1203                    'position': (0, -60),
1204                    'scale': 0.8,
1205                    'text': tbttxt,
1206                },
1207            )
1208        )
1209
1210        bs.timer(5.0, bs.WeakCall(self._start_time_bonus_timer))
1211        wtcolor = (1, 1, 1, 1)
1212        wttxt = bs.Lstr(
1213            value='${A} ${B}',
1214            subs=[
1215                ('${A}', bs.Lstr(resource='waveText')),
1216                (
1217                    '${B}',
1218                    str(self._wavenum)
1219                    + (
1220                        ''
1221                        if self._preset
1222                        in [Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT]
1223                        else ('/' + str(len(self._waves)))
1224                    ),
1225                ),
1226            ],
1227        )
1228        self._wave_text = bs.NodeActor(
1229            bs.newnode(
1230                'text',
1231                attrs={
1232                    'v_attach': 'top',
1233                    'h_attach': 'center',
1234                    'h_align': 'center',
1235                    'vr_depth': -10,
1236                    'color': wtcolor,
1237                    'shadow': 1.0,
1238                    'flatness': 1.0,
1239                    'position': (0, -40),
1240                    'scale': 1.3,
1241                    'text': wttxt,
1242                },
1243            )
1244        )
1245
1246    def _bot_levels_for_wave(self) -> list[list[type[SpazBot]]]:
1247        level = self._wavenum
1248        bot_types = [
1249            BomberBot,
1250            BrawlerBot,
1251            TriggerBot,
1252            ChargerBot,
1253            BomberBotPro,
1254            BrawlerBotPro,
1255            TriggerBotPro,
1256            BomberBotProShielded,
1257            ExplodeyBot,
1258            ChargerBotProShielded,
1259            StickyBot,
1260            BrawlerBotProShielded,
1261            TriggerBotProShielded,
1262        ]
1263        if level > 5:
1264            bot_types += [
1265                ExplodeyBot,
1266                TriggerBotProShielded,
1267                BrawlerBotProShielded,
1268                ChargerBotProShielded,
1269            ]
1270        if level > 7:
1271            bot_types += [
1272                ExplodeyBot,
1273                TriggerBotProShielded,
1274                BrawlerBotProShielded,
1275                ChargerBotProShielded,
1276            ]
1277        if level > 10:
1278            bot_types += [
1279                TriggerBotProShielded,
1280                TriggerBotProShielded,
1281                TriggerBotProShielded,
1282                TriggerBotProShielded,
1283            ]
1284        if level > 13:
1285            bot_types += [
1286                TriggerBotProShielded,
1287                TriggerBotProShielded,
1288                TriggerBotProShielded,
1289                TriggerBotProShielded,
1290            ]
1291        bot_levels = [
1292            [b for b in bot_types if b.points_mult == 1],
1293            [b for b in bot_types if b.points_mult == 2],
1294            [b for b in bot_types if b.points_mult == 3],
1295            [b for b in bot_types if b.points_mult == 4],
1296        ]
1297
1298        # Make sure all lists have something in them
1299        if not all(bot_levels):
1300            raise RuntimeError('Got empty bot level')
1301        return bot_levels
1302
1303    def _add_entries_for_distribution_group(
1304        self,
1305        group: list[tuple[int, int]],
1306        bot_levels: list[list[type[SpazBot]]],
1307        all_entries: list[Spawn | Spacing | Delay | None],
1308    ) -> None:
1309        entries: list[Spawn | Spacing | Delay | None] = []
1310        for entry in group:
1311            bot_level = bot_levels[entry[0] - 1]
1312            bot_type = bot_level[random.randrange(len(bot_level))]
1313            rval = random.random()
1314            if rval < 0.5:
1315                spacing = 10.0
1316            elif rval < 0.9:
1317                spacing = 20.0
1318            else:
1319                spacing = 40.0
1320            split = random.random() > 0.3
1321            for i in range(entry[1]):
1322                if split and i % 2 == 0:
1323                    entries.insert(0, Spawn(bot_type, spacing=spacing))
1324                else:
1325                    entries.append(Spawn(bot_type, spacing=spacing))
1326        if entries:
1327            all_entries += entries
1328            all_entries.append(Spacing(40.0 if random.random() < 0.5 else 80.0))
1329
1330    def _generate_random_wave(self) -> Wave:
1331        level = self._wavenum
1332        bot_levels = self._bot_levels_for_wave()
1333
1334        target_points = level * 3 - 2
1335        min_dudes = min(1 + level // 3, 10)
1336        max_dudes = min(10, level + 1)
1337        max_level = (
1338            4 if level > 6 else (3 if level > 3 else (2 if level > 2 else 1))
1339        )
1340        group_count = 3
1341        distribution = self._get_distribution(
1342            target_points, min_dudes, max_dudes, group_count, max_level
1343        )
1344        all_entries: list[Spawn | Spacing | Delay | None] = []
1345        for group in distribution:
1346            self._add_entries_for_distribution_group(
1347                group, bot_levels, all_entries
1348            )
1349        angle_rand = random.random()
1350        if angle_rand > 0.75:
1351            base_angle = 130.0
1352        elif angle_rand > 0.5:
1353            base_angle = 210.0
1354        elif angle_rand > 0.25:
1355            base_angle = 20.0
1356        else:
1357            base_angle = -30.0
1358        base_angle += (0.5 - random.random()) * 20.0
1359        wave = Wave(base_angle=base_angle, entries=all_entries)
1360        return wave
1361
1362    def add_bot_at_point(
1363        self, point: Point, spaz_type: type[SpazBot], spawn_time: float = 1.0
1364    ) -> None:
1365        """Add a new bot at a specified named point."""
1366        if self._game_over:
1367            return
1368        assert isinstance(point.value, str)
1369        pointpos = self.map.defs.points[point.value]
1370        assert self._bots is not None
1371        self._bots.spawn_bot(spaz_type, pos=pointpos, spawn_time=spawn_time)
1372
1373    def add_bot_at_angle(
1374        self, angle: float, spaz_type: type[SpazBot], spawn_time: float = 1.0
1375    ) -> None:
1376        """Add a new bot at a specified angle (for circular maps)."""
1377        if self._game_over:
1378            return
1379        angle_radians = angle / 57.2957795
1380        xval = math.sin(angle_radians) * 1.06
1381        zval = math.cos(angle_radians) * 1.06
1382        point = (xval / 0.125, 2.3, (zval / 0.2) - 3.7)
1383        assert self._bots is not None
1384        self._bots.spawn_bot(spaz_type, pos=point, spawn_time=spawn_time)
1385
1386    def _update_time_bonus(self) -> None:
1387        self._time_bonus = int(self._time_bonus * 0.93)
1388        if self._time_bonus > 0 and self._time_bonus_text is not None:
1389            assert self._time_bonus_text.node
1390            self._time_bonus_text.node.text = bs.Lstr(
1391                value='${A}: ${B}',
1392                subs=[
1393                    ('${A}', bs.Lstr(resource='timeBonusText')),
1394                    ('${B}', str(self._time_bonus)),
1395                ],
1396            )
1397        else:
1398            self._time_bonus_text = None
1399
1400    def _start_updating_waves(self) -> None:
1401        self._wave_update_timer = bs.Timer(
1402            2.0, bs.WeakCall(self._update_waves), repeat=True
1403        )
1404
1405    def _update_scores(self) -> None:
1406        score = self._score
1407        if self._preset is Preset.ENDLESS:
1408            if score >= 500:
1409                self._award_achievement('Onslaught Master')
1410            if score >= 1000:
1411                self._award_achievement('Onslaught Wizard')
1412            if score >= 5000:
1413                self._award_achievement('Onslaught God')
1414        assert self._scoreboard is not None
1415        self._scoreboard.set_team_value(self.teams[0], score, max_score=None)
1416
1417    def handlemessage(self, msg: Any) -> Any:
1418        if isinstance(msg, PlayerSpazHurtMessage):
1419            msg.spaz.getplayer(Player, True).has_been_hurt = True
1420            self._a_player_has_been_hurt = True
1421
1422        elif isinstance(msg, bs.PlayerScoredMessage):
1423            self._score += msg.score
1424            self._update_scores()
1425
1426        elif isinstance(msg, bs.PlayerDiedMessage):
1427            super().handlemessage(msg)  # Augment standard behavior.
1428            player = msg.getplayer(Player)
1429            self._a_player_has_been_hurt = True
1430
1431            # Make note with the player when they can respawn:
1432            if self._wavenum < 10:
1433                player.respawn_wave = max(2, self._wavenum + 1)
1434            elif self._wavenum < 15:
1435                player.respawn_wave = max(2, self._wavenum + 2)
1436            else:
1437                player.respawn_wave = max(2, self._wavenum + 3)
1438            bs.timer(0.1, self._update_player_spawn_info)
1439            bs.timer(0.1, self._checkroundover)
1440
1441        elif isinstance(msg, SpazBotDiedMessage):
1442            pts, importance = msg.spazbot.get_death_points(msg.how)
1443            if msg.killerplayer is not None:
1444                self._handle_kill_achievements(msg)
1445                target: Sequence[float] | None
1446                if msg.spazbot.node:
1447                    target = msg.spazbot.node.position
1448                else:
1449                    target = None
1450
1451                killerplayer = msg.killerplayer
1452                self.stats.player_scored(
1453                    killerplayer,
1454                    pts,
1455                    target=target,
1456                    kill=True,
1457                    screenmessage=False,
1458                    importance=importance,
1459                )
1460                dingsound = (
1461                    self._dingsound if importance == 1 else self._dingsoundhigh
1462                )
1463                dingsound.play(volume=0.6)
1464
1465            # Normally we pull scores from the score-set, but if there's
1466            # no player lets be explicit.
1467            else:
1468                self._score += pts
1469            self._update_scores()
1470        else:
1471            super().handlemessage(msg)
1472
1473    def _handle_kill_achievements(self, msg: SpazBotDiedMessage) -> None:
1474        if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}:
1475            self._handle_training_kill_achievements(msg)
1476        elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}:
1477            self._handle_rookie_kill_achievements(msg)
1478        elif self._preset in {Preset.PRO, Preset.PRO_EASY}:
1479            self._handle_pro_kill_achievements(msg)
1480        elif self._preset in {Preset.UBER, Preset.UBER_EASY}:
1481            self._handle_uber_kill_achievements(msg)
1482
1483    def _handle_uber_kill_achievements(self, msg: SpazBotDiedMessage) -> None:
1484        # Uber mine achievement:
1485        if msg.spazbot.last_attacked_type == ('explosion', 'land_mine'):
1486            self._land_mine_kills += 1
1487            if self._land_mine_kills >= 6:
1488                self._award_achievement('Gold Miner')
1489
1490        # Uber tnt achievement:
1491        if msg.spazbot.last_attacked_type == ('explosion', 'tnt'):
1492            self._tnt_kills += 1
1493            if self._tnt_kills >= 6:
1494                bs.timer(
1495                    0.5, bs.WeakCall(self._award_achievement, 'TNT Terror')
1496                )
1497
1498    def _handle_pro_kill_achievements(self, msg: SpazBotDiedMessage) -> None:
1499        # TNT achievement:
1500        if msg.spazbot.last_attacked_type == ('explosion', 'tnt'):
1501            self._tnt_kills += 1
1502            if self._tnt_kills >= 3:
1503                bs.timer(
1504                    0.5,
1505                    bs.WeakCall(
1506                        self._award_achievement, 'Boom Goes the Dynamite'
1507                    ),
1508                )
1509
1510    def _handle_rookie_kill_achievements(self, msg: SpazBotDiedMessage) -> None:
1511        # Land-mine achievement:
1512        if msg.spazbot.last_attacked_type == ('explosion', 'land_mine'):
1513            self._land_mine_kills += 1
1514            if self._land_mine_kills >= 3:
1515                self._award_achievement('Mine Games')
1516
1517    def _handle_training_kill_achievements(
1518        self, msg: SpazBotDiedMessage
1519    ) -> None:
1520        # Toss-off-map achievement:
1521        if msg.spazbot.last_attacked_type == ('picked_up', 'default'):
1522            self._throw_off_kills += 1
1523            if self._throw_off_kills >= 3:
1524                self._award_achievement('Off You Go Then')
1525
1526    def _set_can_end_wave(self) -> None:
1527        self._can_end_wave = True
1528
1529    def end_game(self) -> None:
1530        # Tell our bots to celebrate just to rub it in.
1531        assert self._bots is not None
1532        self._bots.final_celebrate()
1533        self._game_over = True
1534        self.do_end('defeat', delay=2.0)
1535        bs.setmusic(None)
1536
1537    def on_continue(self) -> None:
1538        for player in self.players:
1539            if not player.is_alive():
1540                self.spawn_player(player)
1541
1542    def _checkroundover(self) -> None:
1543        """Potentially end the round based on the state of the game."""
1544        if self.has_ended():
1545            return
1546        if not any(player.is_alive() for player in self.teams[0].players):
1547            # Allow continuing after wave 1.
1548            if self._wavenum > 1:
1549                self.continue_or_end_game()
1550            else:
1551                self.end_game()
@dataclass
class Wave:
56@dataclass
57class Wave:
58    """A wave of enemies."""
59
60    entries: list[Spawn | Spacing | Delay | None]
61    base_angle: float = 0.0

A wave of enemies.

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

A bot spawn event in a wave.

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

Empty space in a wave.

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

A delay between events in a wave.

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

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):
102@unique
103class Point(Enum):
104    """Points on the map we can spawn at."""
105
106    LEFT_UPPER_MORE = 'bot_spawn_left_upper_more'
107    LEFT_UPPER = 'bot_spawn_left_upper'
108    TURRET_TOP_RIGHT = 'bot_spawn_turret_top_right'
109    RIGHT_UPPER = 'bot_spawn_right_upper'
110    TURRET_TOP_MIDDLE_LEFT = 'bot_spawn_turret_top_middle_left'
111    TURRET_TOP_MIDDLE_RIGHT = 'bot_spawn_turret_top_middle_right'
112    TURRET_TOP_LEFT = 'bot_spawn_turret_top_left'
113    TOP_RIGHT = 'bot_spawn_top_right'
114    TOP_LEFT = 'bot_spawn_top_left'
115    TOP = 'bot_spawn_top'
116    BOTTOM = 'bot_spawn_bottom'
117    LEFT = 'bot_spawn_left'
118    RIGHT = 'bot_spawn_right'
119    RIGHT_UPPER_MORE = 'bot_spawn_right_upper_more'
120    RIGHT_LOWER = 'bot_spawn_right_lower'
121    RIGHT_LOWER_MORE = 'bot_spawn_right_lower_more'
122    BOTTOM_RIGHT = 'bot_spawn_bottom_right'
123    BOTTOM_LEFT = 'bot_spawn_bottom_left'
124    TURRET_BOTTOM_RIGHT = 'bot_spawn_turret_bottom_right'
125    TURRET_BOTTOM_LEFT = 'bot_spawn_turret_bottom_left'
126    LEFT_LOWER = 'bot_spawn_left_lower'
127    LEFT_LOWER_MORE = 'bot_spawn_left_lower_more'
128    TURRET_TOP_MIDDLE = 'bot_spawn_turret_top_middle'
129    BOTTOM_HALF_RIGHT = 'bot_spawn_bottom_half_right'
130    BOTTOM_HALF_LEFT = 'bot_spawn_bottom_half_left'
131    TOP_HALF_RIGHT = 'bot_spawn_top_half_right'
132    TOP_HALF_LEFT = 'bot_spawn_top_half_left'

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(bascenev1._player.Player[ForwardRef('Team')]):
135class Player(bs.Player['Team']):
136    """Our player type for this game."""
137
138    def __init__(self) -> None:
139        self.has_been_hurt = False
140        self.respawn_wave = 0

Our player type for this game.

has_been_hurt
respawn_wave
Inherited Members
bascenev1._player.Player
character
actor
color
highlight
on_expire
team
customdata
sessionplayer
node
position
exists
getname
is_alive
get_icon
assigninput
resetinput
class Team(bascenev1._team.Team[bascenev1lib.game.onslaught.Player]):
143class Team(bs.Team[Player]):
144    """Our team type for this game."""

Our team type for this game.

Inherited Members
bascenev1._team.Team
players
id
name
color
manual_init
customdata
on_expire
sessionteam
class OnslaughtGame(bascenev1._coopgame.CoopGameActivity[bascenev1lib.game.onslaught.Player, bascenev1lib.game.onslaught.Team]):
 147class OnslaughtGame(bs.CoopGameActivity[Player, Team]):
 148    """Co-op game where players try to survive attacking waves of enemies."""
 149
 150    name = 'Onslaught'
 151    description = 'Defeat all enemies.'
 152
 153    tips: list[str | bs.GameTip] = [
 154        'Hold any button to run.'
 155        '  (Trigger buttons work well if you have them)',
 156        'Try tricking enemies into killing eachother or running off cliffs.',
 157        'Try \'Cooking off\' bombs for a second or two before throwing them.',
 158        'It\'s easier to win with a friend or two helping.',
 159        'If you stay in one place, you\'re toast. Run and dodge to survive..',
 160        'Practice using your momentum to throw bombs more accurately.',
 161        'Your punches do much more damage if you are running or spinning.',
 162    ]
 163
 164    # Show messages when players die since it matters here.
 165    announce_player_deaths = True
 166
 167    def __init__(self, settings: dict):
 168        self._preset = Preset(settings.get('preset', 'training'))
 169        if self._preset in {
 170            Preset.TRAINING,
 171            Preset.TRAINING_EASY,
 172            Preset.PRO,
 173            Preset.PRO_EASY,
 174            Preset.ENDLESS,
 175            Preset.ENDLESS_TOURNAMENT,
 176        }:
 177            settings['map'] = 'Doom Shroom'
 178        else:
 179            settings['map'] = 'Courtyard'
 180
 181        super().__init__(settings)
 182
 183        self._new_wave_sound = bs.getsound('scoreHit01')
 184        self._winsound = bs.getsound('score')
 185        self._cashregistersound = bs.getsound('cashRegister')
 186        self._a_player_has_been_hurt = False
 187        self._player_has_dropped_bomb = False
 188
 189        # FIXME: should use standard map defs.
 190        if settings['map'] == 'Doom Shroom':
 191            self._spawn_center = (0, 3, -5)
 192            self._tntspawnpos = (0.0, 3.0, -5.0)
 193            self._powerup_center = (0, 5, -3.6)
 194            self._powerup_spread = (6.0, 4.0)
 195        elif settings['map'] == 'Courtyard':
 196            self._spawn_center = (0, 3, -2)
 197            self._tntspawnpos = (0.0, 3.0, 2.1)
 198            self._powerup_center = (0, 5, -1.6)
 199            self._powerup_spread = (4.6, 2.7)
 200        else:
 201            raise RuntimeError('Unsupported map: ' + str(settings['map']))
 202        self._scoreboard: Scoreboard | None = None
 203        self._game_over = False
 204        self._wavenum = 0
 205        self._can_end_wave = True
 206        self._score = 0
 207        self._time_bonus = 0
 208        self._spawn_info_text: bs.NodeActor | None = None
 209        self._dingsound = bs.getsound('dingSmall')
 210        self._dingsoundhigh = bs.getsound('dingSmallHigh')
 211        self._have_tnt = False
 212        self._excluded_powerups: list[str] | None = None
 213        self._waves: list[Wave] = []
 214        self._tntspawner: TNTSpawner | None = None
 215        self._bots: SpazBotSet | None = None
 216        self._powerup_drop_timer: bs.Timer | None = None
 217        self._time_bonus_timer: bs.Timer | None = None
 218        self._time_bonus_text: bs.NodeActor | None = None
 219        self._flawless_bonus: int | None = None
 220        self._wave_text: bs.NodeActor | None = None
 221        self._wave_update_timer: bs.Timer | None = None
 222        self._throw_off_kills = 0
 223        self._land_mine_kills = 0
 224        self._tnt_kills = 0
 225
 226    def on_transition_in(self) -> None:
 227        super().on_transition_in()
 228        customdata = bs.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                    bs.GameTip(
 237                        'Land-mines are a good way to stop speedy enemies.',
 238                        icon=bs.gettexture('powerupLandMines'),
 239                        sound=bs.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                    bs.GameTip(
 250                        'Take out a group of enemies by\n'
 251                        'setting off a bomb near a TNT box.',
 252                        icon=bs.gettexture('tnt'),
 253                        sound=bs.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                    bs.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=bs.gettexture('powerupCurse'),
 267                        sound=bs.getsound('ding'),
 268                    )
 269                ]
 270
 271        self._spawn_info_text = bs.NodeActor(
 272            bs.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        bs.setmusic(bs.MusicType.ONSLAUGHT)
 285
 286        self._scoreboard = Scoreboard(
 287            label=bs.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            # Show controls help in demo or arcade modes.
 555            env = bs.app.env
 556            if env.demo or env.arcade:
 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        bs.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        bs.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) -> bs.Actor:
 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: bs.Actor, bomb: bs.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 = bs.Timer(
 865            3.0, bs.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                bs.timer(
 876                    1.0 + i * 0.5,
 877                    bs.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 = bs.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        # If we have no living bots, go to the next wave.
 942        assert self._bots is not None
 943        if (
 944            self._can_end_wave
 945            and not self._bots.have_living_bots()
 946            and not self._game_over
 947        ):
 948            self._can_end_wave = False
 949            self._time_bonus_timer = None
 950            self._time_bonus_text = None
 951            if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
 952                won = False
 953            else:
 954                won = self._wavenum == len(self._waves)
 955
 956            base_delay = 4.0 if won else 0.0
 957
 958            # Reward time bonus.
 959            if self._time_bonus > 0:
 960                bs.timer(0, self._cashregistersound.play)
 961                bs.timer(
 962                    base_delay,
 963                    bs.WeakCall(self._award_time_bonus, self._time_bonus),
 964                )
 965                base_delay += 1.0
 966
 967            # Reward flawless bonus.
 968            if self._wavenum > 0:
 969                have_flawless = False
 970                for player in self.players:
 971                    if player.is_alive() and not player.has_been_hurt:
 972                        have_flawless = True
 973                        bs.timer(
 974                            base_delay,
 975                            bs.WeakCall(self._award_flawless_bonus, player),
 976                        )
 977                    player.has_been_hurt = False  # reset
 978                if have_flawless:
 979                    base_delay += 1.0
 980
 981            if won:
 982                self.show_zoom_message(
 983                    bs.Lstr(resource='victoryText'), scale=1.0, duration=4.0
 984                )
 985                self.celebrate(20.0)
 986                self._award_completion_achievements()
 987                bs.timer(base_delay, bs.WeakCall(self._award_completion_bonus))
 988                base_delay += 0.85
 989                self._winsound.play()
 990                bs.cameraflash()
 991                bs.setmusic(bs.MusicType.VICTORY)
 992                self._game_over = True
 993
 994                # Can't just pass delay to do_end because our extra bonuses
 995                # haven't been added yet (once we call do_end the score
 996                # gets locked in).
 997                bs.timer(base_delay, bs.WeakCall(self.do_end, 'victory'))
 998                return
 999
1000            self._wavenum += 1
1001
1002            # Short celebration after waves.
1003            if self._wavenum > 1:
1004                self.celebrate(0.5)
1005            bs.timer(base_delay, bs.WeakCall(self._start_next_wave))
1006
1007    def _award_completion_bonus(self) -> None:
1008        self._cashregistersound.play()
1009        for player in self.players:
1010            try:
1011                if player.is_alive():
1012                    assert self.initialplayerinfos is not None
1013                    self.stats.player_scored(
1014                        player,
1015                        int(100 / len(self.initialplayerinfos)),
1016                        scale=1.4,
1017                        color=(0.6, 0.6, 1.0, 1.0),
1018                        title=bs.Lstr(resource='completionBonusText'),
1019                        screenmessage=False,
1020                    )
1021            except Exception:
1022                logging.exception('error in _award_completion_bonus')
1023
1024    def _award_time_bonus(self, bonus: int) -> None:
1025        self._cashregistersound.play()
1026        PopupText(
1027            bs.Lstr(
1028                value='+${A} ${B}',
1029                subs=[
1030                    ('${A}', str(bonus)),
1031                    ('${B}', bs.Lstr(resource='timeBonusText')),
1032                ],
1033            ),
1034            color=(1, 1, 0.5, 1),
1035            scale=1.0,
1036            position=(0, 3, -1),
1037        ).autoretain()
1038        self._score += self._time_bonus
1039        self._update_scores()
1040
1041    def _award_flawless_bonus(self, player: Player) -> None:
1042        self._cashregistersound.play()
1043        try:
1044            if player.is_alive():
1045                assert self._flawless_bonus is not None
1046                self.stats.player_scored(
1047                    player,
1048                    self._flawless_bonus,
1049                    scale=1.2,
1050                    color=(0.6, 1.0, 0.6, 1.0),
1051                    title=bs.Lstr(resource='flawlessWaveText'),
1052                    screenmessage=False,
1053                )
1054        except Exception:
1055            logging.exception('error in _award_flawless_bonus')
1056
1057    def _start_time_bonus_timer(self) -> None:
1058        self._time_bonus_timer = bs.Timer(
1059            1.0, bs.WeakCall(self._update_time_bonus), repeat=True
1060        )
1061
1062    def _update_player_spawn_info(self) -> None:
1063        # If we have no living players lets just blank this.
1064        assert self._spawn_info_text is not None
1065        assert self._spawn_info_text.node
1066        if not any(player.is_alive() for player in self.teams[0].players):
1067            self._spawn_info_text.node.text = ''
1068        else:
1069            text: str | bs.Lstr = ''
1070            for player in self.players:
1071                if not player.is_alive() and (
1072                    self._preset in [Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT]
1073                    or (player.respawn_wave <= len(self._waves))
1074                ):
1075                    rtxt = bs.Lstr(
1076                        resource='onslaughtRespawnText',
1077                        subs=[
1078                            ('${PLAYER}', player.getname()),
1079                            ('${WAVE}', str(player.respawn_wave)),
1080                        ],
1081                    )
1082                    text = bs.Lstr(
1083                        value='${A}${B}\n',
1084                        subs=[
1085                            ('${A}', text),
1086                            ('${B}', rtxt),
1087                        ],
1088                    )
1089            self._spawn_info_text.node.text = text
1090
1091    def _respawn_players_for_wave(self) -> None:
1092        # Respawn applicable players.
1093        if self._wavenum > 1 and not self.is_waiting_for_continue():
1094            for player in self.players:
1095                if (
1096                    not player.is_alive()
1097                    and player.respawn_wave == self._wavenum
1098                ):
1099                    self.spawn_player(player)
1100        self._update_player_spawn_info()
1101
1102    def _setup_wave_spawns(self, wave: Wave) -> None:
1103        tval = 0.0
1104        dtime = 0.2
1105        if self._wavenum == 1:
1106            spawn_time = 3.973
1107            tval += 0.5
1108        else:
1109            spawn_time = 2.648
1110
1111        bot_angle = wave.base_angle
1112        self._time_bonus = 0
1113        self._flawless_bonus = 0
1114        for info in wave.entries:
1115            if info is None:
1116                continue
1117            if isinstance(info, Delay):
1118                spawn_time += info.duration
1119                continue
1120            if isinstance(info, Spacing):
1121                bot_angle += info.spacing
1122                continue
1123            bot_type_2 = info.bottype
1124            if bot_type_2 is not None:
1125                assert not isinstance(bot_type_2, str)
1126                self._time_bonus += bot_type_2.points_mult * 20
1127                self._flawless_bonus += bot_type_2.points_mult * 5
1128
1129            # If its got a position, use that.
1130            point = info.point
1131            if point is not None:
1132                assert bot_type_2 is not None
1133                spcall = bs.WeakCall(
1134                    self.add_bot_at_point, point, bot_type_2, spawn_time
1135                )
1136                bs.timer(tval, spcall)
1137                tval += dtime
1138            else:
1139                spacing = info.spacing
1140                bot_angle += spacing * 0.5
1141                if bot_type_2 is not None:
1142                    tcall = bs.WeakCall(
1143                        self.add_bot_at_angle, bot_angle, bot_type_2, spawn_time
1144                    )
1145                    bs.timer(tval, tcall)
1146                    tval += dtime
1147                bot_angle += spacing * 0.5
1148
1149        # We can end the wave after all the spawning happens.
1150        bs.timer(
1151            tval + spawn_time - dtime + 0.01,
1152            bs.WeakCall(self._set_can_end_wave),
1153        )
1154
1155    def _start_next_wave(self) -> None:
1156        # This can happen if we beat a wave as we die.
1157        # We don't wanna respawn players and whatnot if this happens.
1158        if self._game_over:
1159            return
1160
1161        self._respawn_players_for_wave()
1162        if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
1163            wave = self._generate_random_wave()
1164        else:
1165            wave = self._waves[self._wavenum - 1]
1166        self._setup_wave_spawns(wave)
1167        self._update_wave_ui_and_bonuses()
1168        bs.timer(0.4, self._new_wave_sound.play)
1169
1170    def _update_wave_ui_and_bonuses(self) -> None:
1171        self.show_zoom_message(
1172            bs.Lstr(
1173                value='${A} ${B}',
1174                subs=[
1175                    ('${A}', bs.Lstr(resource='waveText')),
1176                    ('${B}', str(self._wavenum)),
1177                ],
1178            ),
1179            scale=1.0,
1180            duration=1.0,
1181            trail=True,
1182        )
1183
1184        # Reset our time bonus.
1185        tbtcolor = (1, 1, 0, 1)
1186        tbttxt = bs.Lstr(
1187            value='${A}: ${B}',
1188            subs=[
1189                ('${A}', bs.Lstr(resource='timeBonusText')),
1190                ('${B}', str(self._time_bonus)),
1191            ],
1192        )
1193        self._time_bonus_text = bs.NodeActor(
1194            bs.newnode(
1195                'text',
1196                attrs={
1197                    'v_attach': 'top',
1198                    'h_attach': 'center',
1199                    'h_align': 'center',
1200                    'vr_depth': -30,
1201                    'color': tbtcolor,
1202                    'shadow': 1.0,
1203                    'flatness': 1.0,
1204                    'position': (0, -60),
1205                    'scale': 0.8,
1206                    'text': tbttxt,
1207                },
1208            )
1209        )
1210
1211        bs.timer(5.0, bs.WeakCall(self._start_time_bonus_timer))
1212        wtcolor = (1, 1, 1, 1)
1213        wttxt = bs.Lstr(
1214            value='${A} ${B}',
1215            subs=[
1216                ('${A}', bs.Lstr(resource='waveText')),
1217                (
1218                    '${B}',
1219                    str(self._wavenum)
1220                    + (
1221                        ''
1222                        if self._preset
1223                        in [Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT]
1224                        else ('/' + str(len(self._waves)))
1225                    ),
1226                ),
1227            ],
1228        )
1229        self._wave_text = bs.NodeActor(
1230            bs.newnode(
1231                'text',
1232                attrs={
1233                    'v_attach': 'top',
1234                    'h_attach': 'center',
1235                    'h_align': 'center',
1236                    'vr_depth': -10,
1237                    'color': wtcolor,
1238                    'shadow': 1.0,
1239                    'flatness': 1.0,
1240                    'position': (0, -40),
1241                    'scale': 1.3,
1242                    'text': wttxt,
1243                },
1244            )
1245        )
1246
1247    def _bot_levels_for_wave(self) -> list[list[type[SpazBot]]]:
1248        level = self._wavenum
1249        bot_types = [
1250            BomberBot,
1251            BrawlerBot,
1252            TriggerBot,
1253            ChargerBot,
1254            BomberBotPro,
1255            BrawlerBotPro,
1256            TriggerBotPro,
1257            BomberBotProShielded,
1258            ExplodeyBot,
1259            ChargerBotProShielded,
1260            StickyBot,
1261            BrawlerBotProShielded,
1262            TriggerBotProShielded,
1263        ]
1264        if level > 5:
1265            bot_types += [
1266                ExplodeyBot,
1267                TriggerBotProShielded,
1268                BrawlerBotProShielded,
1269                ChargerBotProShielded,
1270            ]
1271        if level > 7:
1272            bot_types += [
1273                ExplodeyBot,
1274                TriggerBotProShielded,
1275                BrawlerBotProShielded,
1276                ChargerBotProShielded,
1277            ]
1278        if level > 10:
1279            bot_types += [
1280                TriggerBotProShielded,
1281                TriggerBotProShielded,
1282                TriggerBotProShielded,
1283                TriggerBotProShielded,
1284            ]
1285        if level > 13:
1286            bot_types += [
1287                TriggerBotProShielded,
1288                TriggerBotProShielded,
1289                TriggerBotProShielded,
1290                TriggerBotProShielded,
1291            ]
1292        bot_levels = [
1293            [b for b in bot_types if b.points_mult == 1],
1294            [b for b in bot_types if b.points_mult == 2],
1295            [b for b in bot_types if b.points_mult == 3],
1296            [b for b in bot_types if b.points_mult == 4],
1297        ]
1298
1299        # Make sure all lists have something in them
1300        if not all(bot_levels):
1301            raise RuntimeError('Got empty bot level')
1302        return bot_levels
1303
1304    def _add_entries_for_distribution_group(
1305        self,
1306        group: list[tuple[int, int]],
1307        bot_levels: list[list[type[SpazBot]]],
1308        all_entries: list[Spawn | Spacing | Delay | None],
1309    ) -> None:
1310        entries: list[Spawn | Spacing | Delay | None] = []
1311        for entry in group:
1312            bot_level = bot_levels[entry[0] - 1]
1313            bot_type = bot_level[random.randrange(len(bot_level))]
1314            rval = random.random()
1315            if rval < 0.5:
1316                spacing = 10.0
1317            elif rval < 0.9:
1318                spacing = 20.0
1319            else:
1320                spacing = 40.0
1321            split = random.random() > 0.3
1322            for i in range(entry[1]):
1323                if split and i % 2 == 0:
1324                    entries.insert(0, Spawn(bot_type, spacing=spacing))
1325                else:
1326                    entries.append(Spawn(bot_type, spacing=spacing))
1327        if entries:
1328            all_entries += entries
1329            all_entries.append(Spacing(40.0 if random.random() < 0.5 else 80.0))
1330
1331    def _generate_random_wave(self) -> Wave:
1332        level = self._wavenum
1333        bot_levels = self._bot_levels_for_wave()
1334
1335        target_points = level * 3 - 2
1336        min_dudes = min(1 + level // 3, 10)
1337        max_dudes = min(10, level + 1)
1338        max_level = (
1339            4 if level > 6 else (3 if level > 3 else (2 if level > 2 else 1))
1340        )
1341        group_count = 3
1342        distribution = self._get_distribution(
1343            target_points, min_dudes, max_dudes, group_count, max_level
1344        )
1345        all_entries: list[Spawn | Spacing | Delay | None] = []
1346        for group in distribution:
1347            self._add_entries_for_distribution_group(
1348                group, bot_levels, all_entries
1349            )
1350        angle_rand = random.random()
1351        if angle_rand > 0.75:
1352            base_angle = 130.0
1353        elif angle_rand > 0.5:
1354            base_angle = 210.0
1355        elif angle_rand > 0.25:
1356            base_angle = 20.0
1357        else:
1358            base_angle = -30.0
1359        base_angle += (0.5 - random.random()) * 20.0
1360        wave = Wave(base_angle=base_angle, entries=all_entries)
1361        return wave
1362
1363    def add_bot_at_point(
1364        self, point: Point, spaz_type: type[SpazBot], spawn_time: float = 1.0
1365    ) -> None:
1366        """Add a new bot at a specified named point."""
1367        if self._game_over:
1368            return
1369        assert isinstance(point.value, str)
1370        pointpos = self.map.defs.points[point.value]
1371        assert self._bots is not None
1372        self._bots.spawn_bot(spaz_type, pos=pointpos, spawn_time=spawn_time)
1373
1374    def add_bot_at_angle(
1375        self, angle: float, spaz_type: type[SpazBot], spawn_time: float = 1.0
1376    ) -> None:
1377        """Add a new bot at a specified angle (for circular maps)."""
1378        if self._game_over:
1379            return
1380        angle_radians = angle / 57.2957795
1381        xval = math.sin(angle_radians) * 1.06
1382        zval = math.cos(angle_radians) * 1.06
1383        point = (xval / 0.125, 2.3, (zval / 0.2) - 3.7)
1384        assert self._bots is not None
1385        self._bots.spawn_bot(spaz_type, pos=point, spawn_time=spawn_time)
1386
1387    def _update_time_bonus(self) -> None:
1388        self._time_bonus = int(self._time_bonus * 0.93)
1389        if self._time_bonus > 0 and self._time_bonus_text is not None:
1390            assert self._time_bonus_text.node
1391            self._time_bonus_text.node.text = bs.Lstr(
1392                value='${A}: ${B}',
1393                subs=[
1394                    ('${A}', bs.Lstr(resource='timeBonusText')),
1395                    ('${B}', str(self._time_bonus)),
1396                ],
1397            )
1398        else:
1399            self._time_bonus_text = None
1400
1401    def _start_updating_waves(self) -> None:
1402        self._wave_update_timer = bs.Timer(
1403            2.0, bs.WeakCall(self._update_waves), repeat=True
1404        )
1405
1406    def _update_scores(self) -> None:
1407        score = self._score
1408        if self._preset is Preset.ENDLESS:
1409            if score >= 500:
1410                self._award_achievement('Onslaught Master')
1411            if score >= 1000:
1412                self._award_achievement('Onslaught Wizard')
1413            if score >= 5000:
1414                self._award_achievement('Onslaught God')
1415        assert self._scoreboard is not None
1416        self._scoreboard.set_team_value(self.teams[0], score, max_score=None)
1417
1418    def handlemessage(self, msg: Any) -> Any:
1419        if isinstance(msg, PlayerSpazHurtMessage):
1420            msg.spaz.getplayer(Player, True).has_been_hurt = True
1421            self._a_player_has_been_hurt = True
1422
1423        elif isinstance(msg, bs.PlayerScoredMessage):
1424            self._score += msg.score
1425            self._update_scores()
1426
1427        elif isinstance(msg, bs.PlayerDiedMessage):
1428            super().handlemessage(msg)  # Augment standard behavior.
1429            player = msg.getplayer(Player)
1430            self._a_player_has_been_hurt = True
1431
1432            # Make note with the player when they can respawn:
1433            if self._wavenum < 10:
1434                player.respawn_wave = max(2, self._wavenum + 1)
1435            elif self._wavenum < 15:
1436                player.respawn_wave = max(2, self._wavenum + 2)
1437            else:
1438                player.respawn_wave = max(2, self._wavenum + 3)
1439            bs.timer(0.1, self._update_player_spawn_info)
1440            bs.timer(0.1, self._checkroundover)
1441
1442        elif isinstance(msg, SpazBotDiedMessage):
1443            pts, importance = msg.spazbot.get_death_points(msg.how)
1444            if msg.killerplayer is not None:
1445                self._handle_kill_achievements(msg)
1446                target: Sequence[float] | None
1447                if msg.spazbot.node:
1448                    target = msg.spazbot.node.position
1449                else:
1450                    target = None
1451
1452                killerplayer = msg.killerplayer
1453                self.stats.player_scored(
1454                    killerplayer,
1455                    pts,
1456                    target=target,
1457                    kill=True,
1458                    screenmessage=False,
1459                    importance=importance,
1460                )
1461                dingsound = (
1462                    self._dingsound if importance == 1 else self._dingsoundhigh
1463                )
1464                dingsound.play(volume=0.6)
1465
1466            # Normally we pull scores from the score-set, but if there's
1467            # no player lets be explicit.
1468            else:
1469                self._score += pts
1470            self._update_scores()
1471        else:
1472            super().handlemessage(msg)
1473
1474    def _handle_kill_achievements(self, msg: SpazBotDiedMessage) -> None:
1475        if self._preset in {Preset.TRAINING, Preset.TRAINING_EASY}:
1476            self._handle_training_kill_achievements(msg)
1477        elif self._preset in {Preset.ROOKIE, Preset.ROOKIE_EASY}:
1478            self._handle_rookie_kill_achievements(msg)
1479        elif self._preset in {Preset.PRO, Preset.PRO_EASY}:
1480            self._handle_pro_kill_achievements(msg)
1481        elif self._preset in {Preset.UBER, Preset.UBER_EASY}:
1482            self._handle_uber_kill_achievements(msg)
1483
1484    def _handle_uber_kill_achievements(self, msg: SpazBotDiedMessage) -> None:
1485        # Uber mine achievement:
1486        if msg.spazbot.last_attacked_type == ('explosion', 'land_mine'):
1487            self._land_mine_kills += 1
1488            if self._land_mine_kills >= 6:
1489                self._award_achievement('Gold Miner')
1490
1491        # Uber tnt achievement:
1492        if msg.spazbot.last_attacked_type == ('explosion', 'tnt'):
1493            self._tnt_kills += 1
1494            if self._tnt_kills >= 6:
1495                bs.timer(
1496                    0.5, bs.WeakCall(self._award_achievement, 'TNT Terror')
1497                )
1498
1499    def _handle_pro_kill_achievements(self, msg: SpazBotDiedMessage) -> None:
1500        # TNT achievement:
1501        if msg.spazbot.last_attacked_type == ('explosion', 'tnt'):
1502            self._tnt_kills += 1
1503            if self._tnt_kills >= 3:
1504                bs.timer(
1505                    0.5,
1506                    bs.WeakCall(
1507                        self._award_achievement, 'Boom Goes the Dynamite'
1508                    ),
1509                )
1510
1511    def _handle_rookie_kill_achievements(self, msg: SpazBotDiedMessage) -> None:
1512        # Land-mine achievement:
1513        if msg.spazbot.last_attacked_type == ('explosion', 'land_mine'):
1514            self._land_mine_kills += 1
1515            if self._land_mine_kills >= 3:
1516                self._award_achievement('Mine Games')
1517
1518    def _handle_training_kill_achievements(
1519        self, msg: SpazBotDiedMessage
1520    ) -> None:
1521        # Toss-off-map achievement:
1522        if msg.spazbot.last_attacked_type == ('picked_up', 'default'):
1523            self._throw_off_kills += 1
1524            if self._throw_off_kills >= 3:
1525                self._award_achievement('Off You Go Then')
1526
1527    def _set_can_end_wave(self) -> None:
1528        self._can_end_wave = True
1529
1530    def end_game(self) -> None:
1531        # Tell our bots to celebrate just to rub it in.
1532        assert self._bots is not None
1533        self._bots.final_celebrate()
1534        self._game_over = True
1535        self.do_end('defeat', delay=2.0)
1536        bs.setmusic(None)
1537
1538    def on_continue(self) -> None:
1539        for player in self.players:
1540            if not player.is_alive():
1541                self.spawn_player(player)
1542
1543    def _checkroundover(self) -> None:
1544        """Potentially end the round based on the state of the game."""
1545        if self.has_ended():
1546            return
1547        if not any(player.is_alive() for player in self.teams[0].players):
1548            # Allow continuing after wave 1.
1549            if self._wavenum > 1:
1550                self.continue_or_end_game()
1551            else:
1552                self.end_game()

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

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

Instantiate the Activity.

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

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

def on_transition_in(self) -> None:
226    def on_transition_in(self) -> None:
227        super().on_transition_in()
228        customdata = bs.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                    bs.GameTip(
237                        'Land-mines are a good way to stop speedy enemies.',
238                        icon=bs.gettexture('powerupLandMines'),
239                        sound=bs.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                    bs.GameTip(
250                        'Take out a group of enemies by\n'
251                        'setting off a bomb near a TNT box.',
252                        icon=bs.gettexture('tnt'),
253                        sound=bs.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                    bs.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=bs.gettexture('powerupCurse'),
267                        sound=bs.getsound('ding'),
268                    )
269                ]
270
271        self._spawn_info_text = bs.NodeActor(
272            bs.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        bs.setmusic(bs.MusicType.ONSLAUGHT)
285
286        self._scoreboard = Scoreboard(
287            label=bs.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 bascenev1.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            # Show controls help in demo or arcade modes.
555            env = bs.app.env
556            if env.demo or env.arcade:
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        bs.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        bs.timer(4.0, self._start_updating_waves)

Called once the previous Activity has finished transitioning out.

At this point the activity's initial players and teams are filled in and it should begin its actual game logic.

def spawn_player( self, player: Player) -> bascenev1._actor.Actor:
829    def spawn_player(self, player: Player) -> bs.Actor:
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

Spawn something for the provided bascenev1.Player.

The default implementation simply calls spawn_player_spaz().

def do_end(self, outcome: str, delay: float = 0.0) -> None:
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 = bs.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        )

End the game with the specified outcome.

def add_bot_at_point( self, point: Point, spaz_type: type[bascenev1lib.actor.spazbot.SpazBot], spawn_time: float = 1.0) -> None:
1363    def add_bot_at_point(
1364        self, point: Point, spaz_type: type[SpazBot], spawn_time: float = 1.0
1365    ) -> None:
1366        """Add a new bot at a specified named point."""
1367        if self._game_over:
1368            return
1369        assert isinstance(point.value, str)
1370        pointpos = self.map.defs.points[point.value]
1371        assert self._bots is not None
1372        self._bots.spawn_bot(spaz_type, pos=pointpos, spawn_time=spawn_time)

Add a new bot at a specified named point.

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

General message handling; can be passed any message object.

def end_game(self) -> None:
1530    def end_game(self) -> None:
1531        # Tell our bots to celebrate just to rub it in.
1532        assert self._bots is not None
1533        self._bots.final_celebrate()
1534        self._game_over = True
1535        self.do_end('defeat', delay=2.0)
1536        bs.setmusic(None)

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

This method should be overridden by subclasses. A game should always be prepared to end and deliver results, even if there is no 'winner' yet; this way things like the standard time-limit (bascenev1.GameActivity.setup_standard_time_limit()) will work with the game.

def on_continue(self) -> None:
1538    def on_continue(self) -> None:
1539        for player in self.players:
1540            if not player.is_alive():
1541                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
bascenev1._coopgame.CoopGameActivity
session
supports_session_type
get_score_type
celebrate
spawn_player_spaz
fade_to_red
setup_low_life_warning_sound
bascenev1._gameactivity.GameActivity
available_settings
scoreconfig
allow_pausing
allow_kick_idle_players
show_kill_points
default_music
create_settings_ui
getscoreconfig
getname
get_display_string
get_team_display_string
get_description
get_description_display_string
get_available_settings
get_supported_maps
get_settings_display_string
initialplayerinfos
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
bascenev1._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
paused_text
preloads
lobby
context
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
bascenev1._dependency.DependencyComponent
dep_is_present
get_dynamic_deps