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

A wave of enemies.

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

A bot spawn event in a wave.

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

Empty space in a wave.

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

A delay between events in a wave.

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

Game presets we support.

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

Points on the map we can spawn at.

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

Our player type for this game.

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

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

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

Instantiate the Activity.

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

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

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

Called when the Activity is first becoming visible.

Upon this call, the Activity should fade in backgrounds, start playing music, etc. It does not yet have access to players or teams, however. They remain owned by the previous Activity up until bascenev1.Activity.on_begin() is called.

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

Called once the previous Activity has finished transitioning out.

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

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

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

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

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

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

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

General message handling; can be passed any message object.

@override
def end_game(self