bastd.game.runaround

Defines the runaround co-op game.

   1# Released under the MIT License. See LICENSE for details.
   2#
   3"""Defines the runaround co-op game."""
   4
   5# We wear the cone of shame.
   6# pylint: disable=too-many-lines
   7
   8# ba_meta require api 7
   9# (see https://ballistica.net/wiki/meta-tag-system)
  10
  11from __future__ import annotations
  12
  13import random
  14from dataclasses import dataclass
  15from enum import Enum
  16from typing import TYPE_CHECKING
  17
  18import ba
  19from bastd.actor.popuptext import PopupText
  20from bastd.actor.bomb import TNTSpawner
  21from bastd.actor.scoreboard import Scoreboard
  22from bastd.actor.respawnicon import RespawnIcon
  23from bastd.actor.powerupbox import PowerupBox, PowerupBoxFactory
  24from bastd.gameutils import SharedObjects
  25from bastd.actor.spazbot import (
  26    SpazBotSet,
  27    SpazBot,
  28    SpazBotDiedMessage,
  29    BomberBot,
  30    BrawlerBot,
  31    TriggerBot,
  32    TriggerBotPro,
  33    BomberBotProShielded,
  34    TriggerBotProShielded,
  35    ChargerBot,
  36    ChargerBotProShielded,
  37    StickyBot,
  38    ExplodeyBot,
  39    BrawlerBotProShielded,
  40    BomberBotPro,
  41    BrawlerBotPro,
  42)
  43
  44if TYPE_CHECKING:
  45    from typing import Any, Sequence
  46
  47
  48class Preset(Enum):
  49    """Play presets."""
  50
  51    ENDLESS = 'endless'
  52    ENDLESS_TOURNAMENT = 'endless_tournament'
  53    PRO = 'pro'
  54    PRO_EASY = 'pro_easy'
  55    UBER = 'uber'
  56    UBER_EASY = 'uber_easy'
  57    TOURNAMENT = 'tournament'
  58    TOURNAMENT_UBER = 'tournament_uber'
  59
  60
  61class Point(Enum):
  62    """Where we can spawn stuff and the corresponding map attr name."""
  63
  64    BOTTOM_LEFT = 'bot_spawn_bottom_left'
  65    BOTTOM_RIGHT = 'bot_spawn_bottom_right'
  66    START = 'bot_spawn_start'
  67
  68
  69@dataclass
  70class Spawn:
  71    """Defines a bot spawn event."""
  72
  73    # noinspection PyUnresolvedReferences
  74    type: type[SpazBot]
  75    path: int = 0
  76    point: Point | None = None
  77
  78
  79@dataclass
  80class Spacing:
  81    """Defines spacing between spawns."""
  82
  83    duration: float
  84
  85
  86@dataclass
  87class Wave:
  88    """Defines a wave of enemies."""
  89
  90    entries: list[Spawn | Spacing | None]
  91
  92
  93class Player(ba.Player['Team']):
  94    """Our player type for this game."""
  95
  96    def __init__(self) -> None:
  97        self.respawn_timer: ba.Timer | None = None
  98        self.respawn_icon: RespawnIcon | None = None
  99
 100
 101class Team(ba.Team[Player]):
 102    """Our team type for this game."""
 103
 104
 105class RunaroundGame(ba.CoopGameActivity[Player, Team]):
 106    """Game involving trying to bomb bots as they walk through the map."""
 107
 108    name = 'Runaround'
 109    description = 'Prevent enemies from reaching the exit.'
 110    tips = [
 111        'Jump just as you\'re throwing to get bombs up to the highest levels.',
 112        'No, you can\'t get up on the ledge. You have to throw bombs.',
 113        'Whip back and forth to get more distance on your throws..',
 114    ]
 115    default_music = ba.MusicType.MARCHING
 116
 117    # How fast our various bot types walk.
 118    _bot_speed_map: dict[type[SpazBot], float] = {
 119        BomberBot: 0.48,
 120        BomberBotPro: 0.48,
 121        BomberBotProShielded: 0.48,
 122        BrawlerBot: 0.57,
 123        BrawlerBotPro: 0.57,
 124        BrawlerBotProShielded: 0.57,
 125        TriggerBot: 0.73,
 126        TriggerBotPro: 0.78,
 127        TriggerBotProShielded: 0.78,
 128        ChargerBot: 1.0,
 129        ChargerBotProShielded: 1.0,
 130        ExplodeyBot: 1.0,
 131        StickyBot: 0.5,
 132    }
 133
 134    def __init__(self, settings: dict):
 135        settings['map'] = 'Tower D'
 136        super().__init__(settings)
 137        shared = SharedObjects.get()
 138        self._preset = Preset(settings.get('preset', 'pro'))
 139
 140        self._player_death_sound = ba.getsound('playerDeath')
 141        self._new_wave_sound = ba.getsound('scoreHit01')
 142        self._winsound = ba.getsound('score')
 143        self._cashregistersound = ba.getsound('cashRegister')
 144        self._bad_guy_score_sound = ba.getsound('shieldDown')
 145        self._heart_tex = ba.gettexture('heart')
 146        self._heart_model_opaque = ba.getmodel('heartOpaque')
 147        self._heart_model_transparent = ba.getmodel('heartTransparent')
 148
 149        self._a_player_has_been_killed = False
 150        self._spawn_center = self._map_type.defs.points['spawn1'][0:3]
 151        self._tntspawnpos = self._map_type.defs.points['tnt_loc'][0:3]
 152        self._powerup_center = self._map_type.defs.boxes['powerup_region'][0:3]
 153        self._powerup_spread = (
 154            self._map_type.defs.boxes['powerup_region'][6] * 0.5,
 155            self._map_type.defs.boxes['powerup_region'][8] * 0.5,
 156        )
 157
 158        self._score_region_material = ba.Material()
 159        self._score_region_material.add_actions(
 160            conditions=('they_have_material', shared.player_material),
 161            actions=(
 162                ('modify_part_collision', 'collide', True),
 163                ('modify_part_collision', 'physical', False),
 164                ('call', 'at_connect', self._handle_reached_end),
 165            ),
 166        )
 167
 168        self._last_wave_end_time = ba.time()
 169        self._player_has_picked_up_powerup = False
 170        self._scoreboard: Scoreboard | None = None
 171        self._game_over = False
 172        self._wavenum = 0
 173        self._can_end_wave = True
 174        self._score = 0
 175        self._time_bonus = 0
 176        self._score_region: ba.Actor | None = None
 177        self._dingsound = ba.getsound('dingSmall')
 178        self._dingsoundhigh = ba.getsound('dingSmallHigh')
 179        self._exclude_powerups: list[str] | None = None
 180        self._have_tnt: bool | None = None
 181        self._waves: list[Wave] | None = None
 182        self._bots = SpazBotSet()
 183        self._tntspawner: TNTSpawner | None = None
 184        self._lives_bg: ba.NodeActor | None = None
 185        self._start_lives = 10
 186        self._lives = self._start_lives
 187        self._lives_text: ba.NodeActor | None = None
 188        self._flawless = True
 189        self._time_bonus_timer: ba.Timer | None = None
 190        self._time_bonus_text: ba.NodeActor | None = None
 191        self._time_bonus_mult: float | None = None
 192        self._wave_text: ba.NodeActor | None = None
 193        self._flawless_bonus: int | None = None
 194        self._wave_update_timer: ba.Timer | None = None
 195
 196    def on_transition_in(self) -> None:
 197        super().on_transition_in()
 198        self._scoreboard = Scoreboard(
 199            label=ba.Lstr(resource='scoreText'), score_split=0.5
 200        )
 201        self._score_region = ba.NodeActor(
 202            ba.newnode(
 203                'region',
 204                attrs={
 205                    'position': self.map.defs.boxes['score_region'][0:3],
 206                    'scale': self.map.defs.boxes['score_region'][6:9],
 207                    'type': 'box',
 208                    'materials': [self._score_region_material],
 209                },
 210            )
 211        )
 212
 213    def on_begin(self) -> None:
 214        super().on_begin()
 215        player_count = len(self.players)
 216        hard = self._preset not in {Preset.PRO_EASY, Preset.UBER_EASY}
 217
 218        if self._preset in {Preset.PRO, Preset.PRO_EASY, Preset.TOURNAMENT}:
 219            self._exclude_powerups = ['curse']
 220            self._have_tnt = True
 221            self._waves = [
 222                Wave(
 223                    entries=[
 224                        Spawn(BomberBot, path=3 if hard else 2),
 225                        Spawn(BomberBot, path=2),
 226                        Spawn(BomberBot, path=2) if hard else None,
 227                        Spawn(BomberBot, path=2) if player_count > 1 else None,
 228                        Spawn(BomberBot, path=1) if hard else None,
 229                        Spawn(BomberBot, path=1) if player_count > 2 else None,
 230                        Spawn(BomberBot, path=1) if player_count > 3 else None,
 231                    ]
 232                ),
 233                Wave(
 234                    entries=[
 235                        Spawn(BomberBot, path=1) if hard else None,
 236                        Spawn(BomberBot, path=2) if hard else None,
 237                        Spawn(BomberBot, path=2),
 238                        Spawn(BomberBot, path=2),
 239                        Spawn(BomberBot, path=2) if player_count > 3 else None,
 240                        Spawn(BrawlerBot, path=3),
 241                        Spawn(BrawlerBot, path=3),
 242                        Spawn(BrawlerBot, path=3) if hard else None,
 243                        Spawn(BrawlerBot, path=3) if player_count > 1 else None,
 244                        Spawn(BrawlerBot, path=3) if player_count > 2 else None,
 245                    ]
 246                ),
 247                Wave(
 248                    entries=[
 249                        Spawn(ChargerBot, path=2) if hard else None,
 250                        Spawn(ChargerBot, path=2) if player_count > 2 else None,
 251                        Spawn(TriggerBot, path=2),
 252                        Spawn(TriggerBot, path=2) if player_count > 1 else None,
 253                        Spacing(duration=3.0),
 254                        Spawn(BomberBot, path=2) if hard else None,
 255                        Spawn(BomberBot, path=2) if hard else None,
 256                        Spawn(BomberBot, path=2),
 257                        Spawn(BomberBot, path=3) if hard else None,
 258                        Spawn(BomberBot, path=3),
 259                        Spawn(BomberBot, path=3),
 260                        Spawn(BomberBot, path=3) if player_count > 3 else None,
 261                    ]
 262                ),
 263                Wave(
 264                    entries=[
 265                        Spawn(TriggerBot, path=1) if hard else None,
 266                        Spacing(duration=1.0) if hard else None,
 267                        Spawn(TriggerBot, path=2),
 268                        Spacing(duration=1.0),
 269                        Spawn(TriggerBot, path=3),
 270                        Spacing(duration=1.0),
 271                        Spawn(TriggerBot, path=1) if hard else None,
 272                        Spacing(duration=1.0) if hard else None,
 273                        Spawn(TriggerBot, path=2),
 274                        Spacing(duration=1.0),
 275                        Spawn(TriggerBot, path=3),
 276                        Spacing(duration=1.0),
 277                        Spawn(TriggerBot, path=1)
 278                        if (player_count > 1 and hard)
 279                        else None,
 280                        Spacing(duration=1.0),
 281                        Spawn(TriggerBot, path=2) if player_count > 2 else None,
 282                        Spacing(duration=1.0),
 283                        Spawn(TriggerBot, path=3) if player_count > 3 else None,
 284                        Spacing(duration=1.0),
 285                    ]
 286                ),
 287                Wave(
 288                    entries=[
 289                        Spawn(
 290                            ChargerBotProShielded if hard else ChargerBot,
 291                            path=1,
 292                        ),
 293                        Spawn(BrawlerBot, path=2) if hard else None,
 294                        Spawn(BrawlerBot, path=2),
 295                        Spawn(BrawlerBot, path=2),
 296                        Spawn(BrawlerBot, path=3) if hard else None,
 297                        Spawn(BrawlerBot, path=3),
 298                        Spawn(BrawlerBot, path=3),
 299                        Spawn(BrawlerBot, path=3) if player_count > 1 else None,
 300                        Spawn(BrawlerBot, path=3) if player_count > 2 else None,
 301                        Spawn(BrawlerBot, path=3) if player_count > 3 else None,
 302                    ]
 303                ),
 304                Wave(
 305                    entries=[
 306                        Spawn(BomberBotProShielded, path=3),
 307                        Spacing(duration=1.5),
 308                        Spawn(BomberBotProShielded, path=2),
 309                        Spacing(duration=1.5),
 310                        Spawn(BomberBotProShielded, path=1) if hard else None,
 311                        Spacing(duration=1.0) if hard else None,
 312                        Spawn(BomberBotProShielded, path=3),
 313                        Spacing(duration=1.5),
 314                        Spawn(BomberBotProShielded, path=2),
 315                        Spacing(duration=1.5),
 316                        Spawn(BomberBotProShielded, path=1) if hard else None,
 317                        Spacing(duration=1.5) if hard else None,
 318                        Spawn(BomberBotProShielded, path=3)
 319                        if player_count > 1
 320                        else None,
 321                        Spacing(duration=1.5),
 322                        Spawn(BomberBotProShielded, path=2)
 323                        if player_count > 2
 324                        else None,
 325                        Spacing(duration=1.5),
 326                        Spawn(BomberBotProShielded, path=1)
 327                        if player_count > 3
 328                        else None,
 329                    ]
 330                ),
 331            ]
 332        elif self._preset in {
 333            Preset.UBER_EASY,
 334            Preset.UBER,
 335            Preset.TOURNAMENT_UBER,
 336        }:
 337            self._exclude_powerups = []
 338            self._have_tnt = True
 339            self._waves = [
 340                Wave(
 341                    entries=[
 342                        Spawn(TriggerBot, path=1) if hard else None,
 343                        Spawn(TriggerBot, path=2),
 344                        Spawn(TriggerBot, path=2),
 345                        Spawn(TriggerBot, path=3),
 346                        Spawn(
 347                            BrawlerBotPro if hard else BrawlerBot,
 348                            point=Point.BOTTOM_LEFT,
 349                        ),
 350                        Spawn(BrawlerBotPro, point=Point.BOTTOM_RIGHT)
 351                        if player_count > 2
 352                        else None,
 353                    ]
 354                ),
 355                Wave(
 356                    entries=[
 357                        Spawn(ChargerBot, path=2),
 358                        Spawn(ChargerBot, path=3),
 359                        Spawn(ChargerBot, path=1) if hard else None,
 360                        Spawn(ChargerBot, path=2),
 361                        Spawn(ChargerBot, path=3),
 362                        Spawn(ChargerBot, path=1) if player_count > 2 else None,
 363                    ]
 364                ),
 365                Wave(
 366                    entries=[
 367                        Spawn(BomberBotProShielded, path=1) if hard else None,
 368                        Spawn(BomberBotProShielded, path=2),
 369                        Spawn(BomberBotProShielded, path=2),
 370                        Spawn(BomberBotProShielded, path=3),
 371                        Spawn(BomberBotProShielded, path=3),
 372                        Spawn(ChargerBot, point=Point.BOTTOM_RIGHT),
 373                        Spawn(ChargerBot, point=Point.BOTTOM_LEFT)
 374                        if player_count > 2
 375                        else None,
 376                    ]
 377                ),
 378                Wave(
 379                    entries=[
 380                        Spawn(TriggerBotPro, path=1) if hard else None,
 381                        Spawn(TriggerBotPro, path=1 if hard else 2),
 382                        Spawn(TriggerBotPro, path=1 if hard else 2),
 383                        Spawn(TriggerBotPro, path=1 if hard else 2),
 384                        Spawn(TriggerBotPro, path=1 if hard else 2),
 385                        Spawn(TriggerBotPro, path=1 if hard else 2),
 386                        Spawn(TriggerBotPro, path=1 if hard else 2)
 387                        if player_count > 1
 388                        else None,
 389                        Spawn(TriggerBotPro, path=1 if hard else 2)
 390                        if player_count > 3
 391                        else None,
 392                    ]
 393                ),
 394                Wave(
 395                    entries=[
 396                        Spawn(
 397                            TriggerBotProShielded if hard else TriggerBotPro,
 398                            point=Point.BOTTOM_LEFT,
 399                        ),
 400                        Spawn(TriggerBotProShielded, point=Point.BOTTOM_RIGHT)
 401                        if hard
 402                        else None,
 403                        Spawn(TriggerBotProShielded, point=Point.BOTTOM_RIGHT)
 404                        if player_count > 2
 405                        else None,
 406                        Spawn(BomberBot, path=3),
 407                        Spawn(BomberBot, path=3),
 408                        Spacing(duration=5.0),
 409                        Spawn(BrawlerBot, path=2),
 410                        Spawn(BrawlerBot, path=2),
 411                        Spacing(duration=5.0),
 412                        Spawn(TriggerBot, path=1) if hard else None,
 413                        Spawn(TriggerBot, path=1) if hard else None,
 414                    ]
 415                ),
 416                Wave(
 417                    entries=[
 418                        Spawn(BomberBotProShielded, path=2),
 419                        Spawn(BomberBotProShielded, path=2) if hard else None,
 420                        Spawn(StickyBot, point=Point.BOTTOM_RIGHT),
 421                        Spawn(BomberBotProShielded, path=2),
 422                        Spawn(BomberBotProShielded, path=2),
 423                        Spawn(StickyBot, point=Point.BOTTOM_RIGHT)
 424                        if player_count > 2
 425                        else None,
 426                        Spawn(BomberBotProShielded, path=2),
 427                        Spawn(ExplodeyBot, point=Point.BOTTOM_LEFT),
 428                        Spawn(BomberBotProShielded, path=2),
 429                        Spawn(BomberBotProShielded, path=2)
 430                        if player_count > 1
 431                        else None,
 432                        Spacing(duration=5.0),
 433                        Spawn(StickyBot, point=Point.BOTTOM_LEFT),
 434                        Spacing(duration=2.0),
 435                        Spawn(ExplodeyBot, point=Point.BOTTOM_RIGHT),
 436                    ]
 437                ),
 438            ]
 439        elif self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
 440            self._exclude_powerups = []
 441            self._have_tnt = True
 442
 443        # Spit out a few powerups and start dropping more shortly.
 444        self._drop_powerups(standard_points=True)
 445        ba.timer(4.0, self._start_powerup_drops)
 446        self.setup_low_life_warning_sound()
 447        self._update_scores()
 448
 449        # Our TNT spawner (if applicable).
 450        if self._have_tnt:
 451            self._tntspawner = TNTSpawner(position=self._tntspawnpos)
 452
 453        # Make sure to stay out of the way of menu/party buttons in the corner.
 454        uiscale = ba.app.ui.uiscale
 455        l_offs = (
 456            -80
 457            if uiscale is ba.UIScale.SMALL
 458            else -40
 459            if uiscale is ba.UIScale.MEDIUM
 460            else 0
 461        )
 462
 463        self._lives_bg = ba.NodeActor(
 464            ba.newnode(
 465                'image',
 466                attrs={
 467                    'texture': self._heart_tex,
 468                    'model_opaque': self._heart_model_opaque,
 469                    'model_transparent': self._heart_model_transparent,
 470                    'attach': 'topRight',
 471                    'scale': (90, 90),
 472                    'position': (-110 + l_offs, -50),
 473                    'color': (1, 0.2, 0.2),
 474                },
 475            )
 476        )
 477        # FIXME; should not set things based on vr mode.
 478        #  (won't look right to non-vr connected clients, etc)
 479        vrmode = ba.app.vr_mode
 480        self._lives_text = ba.NodeActor(
 481            ba.newnode(
 482                'text',
 483                attrs={
 484                    'v_attach': 'top',
 485                    'h_attach': 'right',
 486                    'h_align': 'center',
 487                    'color': (1, 1, 1, 1) if vrmode else (0.8, 0.8, 0.8, 1.0),
 488                    'flatness': 1.0 if vrmode else 0.5,
 489                    'shadow': 1.0 if vrmode else 0.5,
 490                    'vr_depth': 10,
 491                    'position': (-113 + l_offs, -69),
 492                    'scale': 1.3,
 493                    'text': str(self._lives),
 494                },
 495            )
 496        )
 497
 498        ba.timer(2.0, self._start_updating_waves)
 499
 500    def _handle_reached_end(self) -> None:
 501        spaz = ba.getcollision().opposingnode.getdelegate(SpazBot, True)
 502        if not spaz.is_alive():
 503            return  # Ignore bodies flying in.
 504
 505        self._flawless = False
 506        pos = spaz.node.position
 507        ba.playsound(self._bad_guy_score_sound, position=pos)
 508        light = ba.newnode(
 509            'light', attrs={'position': pos, 'radius': 0.5, 'color': (1, 0, 0)}
 510        )
 511        ba.animate(light, 'intensity', {0.0: 0, 0.1: 1, 0.5: 0}, loop=False)
 512        ba.timer(1.0, light.delete)
 513        spaz.handlemessage(
 514            ba.DieMessage(immediate=True, how=ba.DeathType.REACHED_GOAL)
 515        )
 516
 517        if self._lives > 0:
 518            self._lives -= 1
 519            if self._lives == 0:
 520                self._bots.stop_moving()
 521                self.continue_or_end_game()
 522            assert self._lives_text is not None
 523            assert self._lives_text.node
 524            self._lives_text.node.text = str(self._lives)
 525            delay = 0.0
 526
 527            def _safesetattr(node: ba.Node, attr: str, value: Any) -> None:
 528                if node:
 529                    setattr(node, attr, value)
 530
 531            for _i in range(4):
 532                ba.timer(
 533                    delay,
 534                    ba.Call(
 535                        _safesetattr,
 536                        self._lives_text.node,
 537                        'color',
 538                        (1, 0, 0, 1.0),
 539                    ),
 540                )
 541                assert self._lives_bg is not None
 542                assert self._lives_bg.node
 543                ba.timer(
 544                    delay,
 545                    ba.Call(_safesetattr, self._lives_bg.node, 'opacity', 0.5),
 546                )
 547                delay += 0.125
 548                ba.timer(
 549                    delay,
 550                    ba.Call(
 551                        _safesetattr,
 552                        self._lives_text.node,
 553                        'color',
 554                        (1.0, 1.0, 0.0, 1.0),
 555                    ),
 556                )
 557                ba.timer(
 558                    delay,
 559                    ba.Call(_safesetattr, self._lives_bg.node, 'opacity', 1.0),
 560                )
 561                delay += 0.125
 562            ba.timer(
 563                delay,
 564                ba.Call(
 565                    _safesetattr,
 566                    self._lives_text.node,
 567                    'color',
 568                    (0.8, 0.8, 0.8, 1.0),
 569                ),
 570            )
 571
 572    def on_continue(self) -> None:
 573        self._lives = 3
 574        assert self._lives_text is not None
 575        assert self._lives_text.node
 576        self._lives_text.node.text = str(self._lives)
 577        self._bots.start_moving()
 578
 579    def spawn_player(self, player: Player) -> ba.Actor:
 580        pos = (
 581            self._spawn_center[0] + random.uniform(-1.5, 1.5),
 582            self._spawn_center[1],
 583            self._spawn_center[2] + random.uniform(-1.5, 1.5),
 584        )
 585        spaz = self.spawn_player_spaz(player, position=pos)
 586        if self._preset in {Preset.PRO_EASY, Preset.UBER_EASY}:
 587            spaz.impact_scale = 0.25
 588
 589        # Add the material that causes us to hit the player-wall.
 590        spaz.pick_up_powerup_callback = self._on_player_picked_up_powerup
 591        return spaz
 592
 593    def _on_player_picked_up_powerup(self, player: ba.Actor) -> None:
 594        del player  # Unused.
 595        self._player_has_picked_up_powerup = True
 596
 597    def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None:
 598        if poweruptype is None:
 599            poweruptype = PowerupBoxFactory.get().get_random_powerup_type(
 600                excludetypes=self._exclude_powerups
 601            )
 602        PowerupBox(
 603            position=self.map.powerup_spawn_points[index],
 604            poweruptype=poweruptype,
 605        ).autoretain()
 606
 607    def _start_powerup_drops(self) -> None:
 608        ba.timer(3.0, self._drop_powerups, repeat=True)
 609
 610    def _drop_powerups(
 611        self, standard_points: bool = False, force_first: str | None = None
 612    ) -> None:
 613        """Generic powerup drop."""
 614
 615        # If its been a minute since our last wave finished emerging, stop
 616        # giving out land-mine powerups. (prevents players from waiting
 617        # around for them on purpose and filling the map up)
 618        if ba.time() - self._last_wave_end_time > 60.0:
 619            extra_excludes = ['land_mines']
 620        else:
 621            extra_excludes = []
 622
 623        if standard_points:
 624            points = self.map.powerup_spawn_points
 625            for i in range(len(points)):
 626                ba.timer(
 627                    1.0 + i * 0.5,
 628                    ba.Call(
 629                        self._drop_powerup, i, force_first if i == 0 else None
 630                    ),
 631                )
 632        else:
 633            pos = (
 634                self._powerup_center[0]
 635                + random.uniform(
 636                    -1.0 * self._powerup_spread[0],
 637                    1.0 * self._powerup_spread[0],
 638                ),
 639                self._powerup_center[1],
 640                self._powerup_center[2]
 641                + random.uniform(
 642                    -self._powerup_spread[1], self._powerup_spread[1]
 643                ),
 644            )
 645
 646            # drop one random one somewhere..
 647            assert self._exclude_powerups is not None
 648            PowerupBox(
 649                position=pos,
 650                poweruptype=PowerupBoxFactory.get().get_random_powerup_type(
 651                    excludetypes=self._exclude_powerups + extra_excludes
 652                ),
 653            ).autoretain()
 654
 655    def end_game(self) -> None:
 656        ba.pushcall(ba.Call(self.do_end, 'defeat'))
 657        ba.setmusic(None)
 658        ba.playsound(self._player_death_sound)
 659
 660    def do_end(self, outcome: str) -> None:
 661        """End the game now with the provided outcome."""
 662
 663        if outcome == 'defeat':
 664            delay = 2.0
 665            self.fade_to_red()
 666        else:
 667            delay = 0
 668
 669        score: int | None
 670        if self._wavenum >= 2:
 671            score = self._score
 672            fail_message = None
 673        else:
 674            score = None
 675            fail_message = ba.Lstr(resource='reachWave2Text')
 676
 677        self.end(
 678            delay=delay,
 679            results={
 680                'outcome': outcome,
 681                'score': score,
 682                'fail_message': fail_message,
 683                'playerinfos': self.initialplayerinfos,
 684            },
 685        )
 686
 687    def _update_waves(self) -> None:
 688        # pylint: disable=too-many-branches
 689
 690        # If we have no living bots, go to the next wave.
 691        if (
 692            self._can_end_wave
 693            and not self._bots.have_living_bots()
 694            and not self._game_over
 695            and self._lives > 0
 696        ):
 697
 698            self._can_end_wave = False
 699            self._time_bonus_timer = None
 700            self._time_bonus_text = None
 701
 702            if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
 703                won = False
 704            else:
 705                assert self._waves is not None
 706                won = self._wavenum == len(self._waves)
 707
 708            # Reward time bonus.
 709            base_delay = 4.0 if won else 0
 710            if self._time_bonus > 0:
 711                ba.timer(0, ba.Call(ba.playsound, self._cashregistersound))
 712                ba.timer(
 713                    base_delay,
 714                    ba.Call(self._award_time_bonus, self._time_bonus),
 715                )
 716                base_delay += 1.0
 717
 718            # Reward flawless bonus.
 719            if self._wavenum > 0 and self._flawless:
 720                ba.timer(base_delay, self._award_flawless_bonus)
 721                base_delay += 1.0
 722
 723            self._flawless = True  # reset
 724
 725            if won:
 726
 727                # Completion achievements:
 728                if self._preset in {Preset.PRO, Preset.PRO_EASY}:
 729                    self._award_achievement(
 730                        'Pro Runaround Victory', sound=False
 731                    )
 732                    if self._lives == self._start_lives:
 733                        self._award_achievement('The Wall', sound=False)
 734                    if not self._player_has_picked_up_powerup:
 735                        self._award_achievement(
 736                            'Precision Bombing', sound=False
 737                        )
 738                elif self._preset in {Preset.UBER, Preset.UBER_EASY}:
 739                    self._award_achievement(
 740                        'Uber Runaround Victory', sound=False
 741                    )
 742                    if self._lives == self._start_lives:
 743                        self._award_achievement('The Great Wall', sound=False)
 744                    if not self._a_player_has_been_killed:
 745                        self._award_achievement('Stayin\' Alive', sound=False)
 746
 747                # Give remaining players some points and have them celebrate.
 748                self.show_zoom_message(
 749                    ba.Lstr(resource='victoryText'), scale=1.0, duration=4.0
 750                )
 751
 752                self.celebrate(10.0)
 753                ba.timer(base_delay, self._award_lives_bonus)
 754                base_delay += 1.0
 755                ba.timer(base_delay, self._award_completion_bonus)
 756                base_delay += 0.85
 757                ba.playsound(self._winsound)
 758                ba.cameraflash()
 759                ba.setmusic(ba.MusicType.VICTORY)
 760                self._game_over = True
 761                ba.timer(base_delay, ba.Call(self.do_end, 'victory'))
 762                return
 763
 764            self._wavenum += 1
 765
 766            # Short celebration after waves.
 767            if self._wavenum > 1:
 768                self.celebrate(0.5)
 769
 770            ba.timer(base_delay, self._start_next_wave)
 771
 772    def _award_completion_bonus(self) -> None:
 773        bonus = 200
 774        ba.playsound(self._cashregistersound)
 775        PopupText(
 776            ba.Lstr(
 777                value='+${A} ${B}',
 778                subs=[
 779                    ('${A}', str(bonus)),
 780                    ('${B}', ba.Lstr(resource='completionBonusText')),
 781                ],
 782            ),
 783            color=(0.7, 0.7, 1.0, 1),
 784            scale=1.6,
 785            position=(0, 1.5, -1),
 786        ).autoretain()
 787        self._score += bonus
 788        self._update_scores()
 789
 790    def _award_lives_bonus(self) -> None:
 791        bonus = self._lives * 30
 792        ba.playsound(self._cashregistersound)
 793        PopupText(
 794            ba.Lstr(
 795                value='+${A} ${B}',
 796                subs=[
 797                    ('${A}', str(bonus)),
 798                    ('${B}', ba.Lstr(resource='livesBonusText')),
 799                ],
 800            ),
 801            color=(0.7, 1.0, 0.3, 1),
 802            scale=1.3,
 803            position=(0, 1, -1),
 804        ).autoretain()
 805        self._score += bonus
 806        self._update_scores()
 807
 808    def _award_time_bonus(self, bonus: int) -> None:
 809        ba.playsound(self._cashregistersound)
 810        PopupText(
 811            ba.Lstr(
 812                value='+${A} ${B}',
 813                subs=[
 814                    ('${A}', str(bonus)),
 815                    ('${B}', ba.Lstr(resource='timeBonusText')),
 816                ],
 817            ),
 818            color=(1, 1, 0.5, 1),
 819            scale=1.0,
 820            position=(0, 3, -1),
 821        ).autoretain()
 822
 823        self._score += self._time_bonus
 824        self._update_scores()
 825
 826    def _award_flawless_bonus(self) -> None:
 827        ba.playsound(self._cashregistersound)
 828        PopupText(
 829            ba.Lstr(
 830                value='+${A} ${B}',
 831                subs=[
 832                    ('${A}', str(self._flawless_bonus)),
 833                    ('${B}', ba.Lstr(resource='perfectWaveText')),
 834                ],
 835            ),
 836            color=(1, 1, 0.2, 1),
 837            scale=1.2,
 838            position=(0, 2, -1),
 839        ).autoretain()
 840
 841        assert self._flawless_bonus is not None
 842        self._score += self._flawless_bonus
 843        self._update_scores()
 844
 845    def _start_time_bonus_timer(self) -> None:
 846        self._time_bonus_timer = ba.Timer(
 847            1.0, self._update_time_bonus, repeat=True
 848        )
 849
 850    def _start_next_wave(self) -> None:
 851        # FIXME: Need to split this up.
 852        # pylint: disable=too-many-locals
 853        # pylint: disable=too-many-branches
 854        # pylint: disable=too-many-statements
 855        self.show_zoom_message(
 856            ba.Lstr(
 857                value='${A} ${B}',
 858                subs=[
 859                    ('${A}', ba.Lstr(resource='waveText')),
 860                    ('${B}', str(self._wavenum)),
 861                ],
 862            ),
 863            scale=1.0,
 864            duration=1.0,
 865            trail=True,
 866        )
 867        ba.timer(0.4, ba.Call(ba.playsound, self._new_wave_sound))
 868        t_sec = 0.0
 869        base_delay = 0.5
 870        delay = 0.0
 871        bot_types: list[Spawn | Spacing | None] = []
 872
 873        if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
 874            level = self._wavenum
 875            target_points = (level + 1) * 8.0
 876            group_count = random.randint(1, 3)
 877            entries: list[Spawn | Spacing | None] = []
 878            spaz_types: list[tuple[type[SpazBot], float]] = []
 879            if level < 6:
 880                spaz_types += [(BomberBot, 5.0)]
 881            if level < 10:
 882                spaz_types += [(BrawlerBot, 5.0)]
 883            if level < 15:
 884                spaz_types += [(TriggerBot, 6.0)]
 885            if level > 5:
 886                spaz_types += [(TriggerBotPro, 7.5)] * (1 + (level - 5) // 7)
 887            if level > 2:
 888                spaz_types += [(BomberBotProShielded, 8.0)] * (
 889                    1 + (level - 2) // 6
 890                )
 891            if level > 6:
 892                spaz_types += [(TriggerBotProShielded, 12.0)] * (
 893                    1 + (level - 6) // 5
 894                )
 895            if level > 1:
 896                spaz_types += [(ChargerBot, 10.0)] * (1 + (level - 1) // 4)
 897            if level > 7:
 898                spaz_types += [(ChargerBotProShielded, 15.0)] * (
 899                    1 + (level - 7) // 3
 900                )
 901
 902            # Bot type, their effect on target points.
 903            defender_types: list[tuple[type[SpazBot], float]] = [
 904                (BomberBot, 0.9),
 905                (BrawlerBot, 0.9),
 906                (TriggerBot, 0.85),
 907            ]
 908            if level > 2:
 909                defender_types += [(ChargerBot, 0.75)]
 910            if level > 4:
 911                defender_types += [(StickyBot, 0.7)] * (1 + (level - 5) // 6)
 912            if level > 6:
 913                defender_types += [(ExplodeyBot, 0.7)] * (1 + (level - 5) // 5)
 914            if level > 8:
 915                defender_types += [(BrawlerBotProShielded, 0.65)] * (
 916                    1 + (level - 5) // 4
 917                )
 918            if level > 10:
 919                defender_types += [(TriggerBotProShielded, 0.6)] * (
 920                    1 + (level - 6) // 3
 921                )
 922
 923            for group in range(group_count):
 924                this_target_point_s = target_points / group_count
 925
 926                # Adding spacing makes things slightly harder.
 927                rval = random.random()
 928                if rval < 0.07:
 929                    spacing = 1.5
 930                    this_target_point_s *= 0.85
 931                elif rval < 0.15:
 932                    spacing = 1.0
 933                    this_target_point_s *= 0.9
 934                else:
 935                    spacing = 0.0
 936
 937                path = random.randint(1, 3)
 938
 939                # Don't allow hard paths on early levels.
 940                if level < 3:
 941                    if path == 1:
 942                        path = 3
 943
 944                # Easy path.
 945                if path == 3:
 946                    pass
 947
 948                # Harder path.
 949                elif path == 2:
 950                    this_target_point_s *= 0.8
 951
 952                # Even harder path.
 953                elif path == 1:
 954                    this_target_point_s *= 0.7
 955
 956                # Looping forward.
 957                elif path == 4:
 958                    this_target_point_s *= 0.7
 959
 960                # Looping backward.
 961                elif path == 5:
 962                    this_target_point_s *= 0.7
 963
 964                # Random.
 965                elif path == 6:
 966                    this_target_point_s *= 0.7
 967
 968                def _add_defender(
 969                    defender_type: tuple[type[SpazBot], float], pnt: Point
 970                ) -> tuple[float, Spawn]:
 971                    # This is ok because we call it immediately.
 972                    # pylint: disable=cell-var-from-loop
 973                    return this_target_point_s * defender_type[1], Spawn(
 974                        defender_type[0], point=pnt
 975                    )
 976
 977                # Add defenders.
 978                defender_type1 = defender_types[
 979                    random.randrange(len(defender_types))
 980                ]
 981                defender_type2 = defender_types[
 982                    random.randrange(len(defender_types))
 983                ]
 984                defender1 = defender2 = None
 985                if (
 986                    (group == 0)
 987                    or (group == 1 and level > 3)
 988                    or (group == 2 and level > 5)
 989                ):
 990                    if random.random() < min(0.75, (level - 1) * 0.11):
 991                        this_target_point_s, defender1 = _add_defender(
 992                            defender_type1, Point.BOTTOM_LEFT
 993                        )
 994                    if random.random() < min(0.75, (level - 1) * 0.04):
 995                        this_target_point_s, defender2 = _add_defender(
 996                            defender_type2, Point.BOTTOM_RIGHT
 997                        )
 998
 999                spaz_type = spaz_types[random.randrange(len(spaz_types))]
1000                member_count = max(
1001                    1, int(round(this_target_point_s / spaz_type[1]))
1002                )
1003                for i, _member in enumerate(range(member_count)):
1004                    if path == 4:
1005                        this_path = i % 3  # Looping forward.
1006                    elif path == 5:
1007                        this_path = 3 - (i % 3)  # Looping backward.
1008                    elif path == 6:
1009                        this_path = random.randint(1, 3)  # Random.
1010                    else:
1011                        this_path = path
1012                    entries.append(Spawn(spaz_type[0], path=this_path))
1013                    if spacing != 0.0:
1014                        entries.append(Spacing(duration=spacing))
1015
1016                if defender1 is not None:
1017                    entries.append(defender1)
1018                if defender2 is not None:
1019                    entries.append(defender2)
1020
1021                # Some spacing between groups.
1022                rval = random.random()
1023                if rval < 0.1:
1024                    spacing = 5.0
1025                elif rval < 0.5:
1026                    spacing = 1.0
1027                else:
1028                    spacing = 1.0
1029                entries.append(Spacing(duration=spacing))
1030
1031            wave = Wave(entries=entries)
1032
1033        else:
1034            assert self._waves is not None
1035            wave = self._waves[self._wavenum - 1]
1036
1037        bot_types += wave.entries
1038        self._time_bonus_mult = 1.0
1039        this_flawless_bonus = 0
1040        non_runner_spawn_time = 1.0
1041
1042        for info in bot_types:
1043            if info is None:
1044                continue
1045            if isinstance(info, Spacing):
1046                t_sec += info.duration
1047                continue
1048            bot_type = info.type
1049            path = info.path
1050            self._time_bonus_mult += bot_type.points_mult * 0.02
1051            this_flawless_bonus += bot_type.points_mult * 5
1052
1053            # If its got a position, use that.
1054            if info.point is not None:
1055                point = info.point
1056            else:
1057                point = Point.START
1058
1059            # Space our our slower bots.
1060            delay = base_delay
1061            delay /= self._get_bot_speed(bot_type)
1062            t_sec += delay * 0.5
1063            tcall = ba.Call(
1064                self.add_bot_at_point,
1065                point,
1066                bot_type,
1067                path,
1068                0.1 if point is Point.START else non_runner_spawn_time,
1069            )
1070            ba.timer(t_sec, tcall)
1071            t_sec += delay * 0.5
1072
1073        # We can end the wave after all the spawning happens.
1074        ba.timer(
1075            t_sec - delay * 0.5 + non_runner_spawn_time + 0.01,
1076            self._set_can_end_wave,
1077        )
1078
1079        # Reset our time bonus.
1080        # In this game we use a constant time bonus so it erodes away in
1081        # roughly the same time (since the time limit a wave can take is
1082        # relatively constant) ..we then post-multiply a modifier to adjust
1083        # points.
1084        self._time_bonus = 150
1085        self._flawless_bonus = this_flawless_bonus
1086        assert self._time_bonus_mult is not None
1087        txtval = ba.Lstr(
1088            value='${A}: ${B}',
1089            subs=[
1090                ('${A}', ba.Lstr(resource='timeBonusText')),
1091                ('${B}', str(int(self._time_bonus * self._time_bonus_mult))),
1092            ],
1093        )
1094        self._time_bonus_text = ba.NodeActor(
1095            ba.newnode(
1096                'text',
1097                attrs={
1098                    'v_attach': 'top',
1099                    'h_attach': 'center',
1100                    'h_align': 'center',
1101                    'color': (1, 1, 0.0, 1),
1102                    'shadow': 1.0,
1103                    'vr_depth': -30,
1104                    'flatness': 1.0,
1105                    'position': (0, -60),
1106                    'scale': 0.8,
1107                    'text': txtval,
1108                },
1109            )
1110        )
1111
1112        ba.timer(t_sec, self._start_time_bonus_timer)
1113
1114        # Keep track of when this wave finishes emerging. We wanna stop
1115        # dropping land-mines powerups at some point (otherwise a crafty
1116        # player could fill the whole map with them)
1117        self._last_wave_end_time = ba.time() + t_sec
1118        totalwaves = str(len(self._waves)) if self._waves is not None else '??'
1119        txtval = ba.Lstr(
1120            value='${A} ${B}',
1121            subs=[
1122                ('${A}', ba.Lstr(resource='waveText')),
1123                (
1124                    '${B}',
1125                    str(self._wavenum)
1126                    + (
1127                        ''
1128                        if self._preset
1129                        in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}
1130                        else f'/{totalwaves}'
1131                    ),
1132                ),
1133            ],
1134        )
1135        self._wave_text = ba.NodeActor(
1136            ba.newnode(
1137                'text',
1138                attrs={
1139                    'v_attach': 'top',
1140                    'h_attach': 'center',
1141                    'h_align': 'center',
1142                    'vr_depth': -10,
1143                    'color': (1, 1, 1, 1),
1144                    'shadow': 1.0,
1145                    'flatness': 1.0,
1146                    'position': (0, -40),
1147                    'scale': 1.3,
1148                    'text': txtval,
1149                },
1150            )
1151        )
1152
1153    def _on_bot_spawn(self, path: int, spaz: SpazBot) -> None:
1154
1155        # Add our custom update callback and set some info for this bot.
1156        spaz_type = type(spaz)
1157        assert spaz is not None
1158        spaz.update_callback = self._update_bot
1159
1160        # Tack some custom attrs onto the spaz.
1161        setattr(spaz, 'r_walk_row', path)
1162        setattr(spaz, 'r_walk_speed', self._get_bot_speed(spaz_type))
1163
1164    def add_bot_at_point(
1165        self,
1166        point: Point,
1167        spaztype: type[SpazBot],
1168        path: int,
1169        spawn_time: float = 0.1,
1170    ) -> None:
1171        """Add the given type bot with the given delay (in seconds)."""
1172
1173        # Don't add if the game has ended.
1174        if self._game_over:
1175            return
1176        pos = self.map.defs.points[point.value][:3]
1177        self._bots.spawn_bot(
1178            spaztype,
1179            pos=pos,
1180            spawn_time=spawn_time,
1181            on_spawn_call=ba.Call(self._on_bot_spawn, path),
1182        )
1183
1184    def _update_time_bonus(self) -> None:
1185        self._time_bonus = int(self._time_bonus * 0.91)
1186        if self._time_bonus > 0 and self._time_bonus_text is not None:
1187            assert self._time_bonus_text.node
1188            assert self._time_bonus_mult
1189            self._time_bonus_text.node.text = ba.Lstr(
1190                value='${A}: ${B}',
1191                subs=[
1192                    ('${A}', ba.Lstr(resource='timeBonusText')),
1193                    (
1194                        '${B}',
1195                        str(int(self._time_bonus * self._time_bonus_mult)),
1196                    ),
1197                ],
1198            )
1199        else:
1200            self._time_bonus_text = None
1201
1202    def _start_updating_waves(self) -> None:
1203        self._wave_update_timer = ba.Timer(2.0, self._update_waves, repeat=True)
1204
1205    def _update_scores(self) -> None:
1206        score = self._score
1207        if self._preset is Preset.ENDLESS:
1208            if score >= 500:
1209                self._award_achievement('Runaround Master')
1210            if score >= 1000:
1211                self._award_achievement('Runaround Wizard')
1212            if score >= 2000:
1213                self._award_achievement('Runaround God')
1214
1215        assert self._scoreboard is not None
1216        self._scoreboard.set_team_value(self.teams[0], score, max_score=None)
1217
1218    def _update_bot(self, bot: SpazBot) -> bool:
1219        # Yup; that's a lot of return statements right there.
1220        # pylint: disable=too-many-return-statements
1221
1222        if not bool(bot):
1223            return True
1224
1225        assert bot.node
1226
1227        # FIXME: Do this in a type safe way.
1228        r_walk_speed: float = getattr(bot, 'r_walk_speed')
1229        r_walk_row: int = getattr(bot, 'r_walk_row')
1230
1231        speed = r_walk_speed
1232        pos = bot.node.position
1233        boxes = self.map.defs.boxes
1234
1235        # Bots in row 1 attempt the high road..
1236        if r_walk_row == 1:
1237            if ba.is_point_in_box(pos, boxes['b4']):
1238                bot.node.move_up_down = speed
1239                bot.node.move_left_right = 0
1240                bot.node.run = 0.0
1241                return True
1242
1243        # Row 1 and 2 bots attempt the middle road..
1244        if r_walk_row in [1, 2]:
1245            if ba.is_point_in_box(pos, boxes['b1']):
1246                bot.node.move_up_down = speed
1247                bot.node.move_left_right = 0
1248                bot.node.run = 0.0
1249                return True
1250
1251        # All bots settle for the third row.
1252        if ba.is_point_in_box(pos, boxes['b7']):
1253            bot.node.move_up_down = speed
1254            bot.node.move_left_right = 0
1255            bot.node.run = 0.0
1256            return True
1257        if ba.is_point_in_box(pos, boxes['b2']):
1258            bot.node.move_up_down = -speed
1259            bot.node.move_left_right = 0
1260            bot.node.run = 0.0
1261            return True
1262        if ba.is_point_in_box(pos, boxes['b3']):
1263            bot.node.move_up_down = -speed
1264            bot.node.move_left_right = 0
1265            bot.node.run = 0.0
1266            return True
1267        if ba.is_point_in_box(pos, boxes['b5']):
1268            bot.node.move_up_down = -speed
1269            bot.node.move_left_right = 0
1270            bot.node.run = 0.0
1271            return True
1272        if ba.is_point_in_box(pos, boxes['b6']):
1273            bot.node.move_up_down = speed
1274            bot.node.move_left_right = 0
1275            bot.node.run = 0.0
1276            return True
1277        if (
1278            ba.is_point_in_box(pos, boxes['b8'])
1279            and not ba.is_point_in_box(pos, boxes['b9'])
1280        ) or pos == (0.0, 0.0, 0.0):
1281
1282            # Default to walking right if we're still in the walking area.
1283            bot.node.move_left_right = speed
1284            bot.node.move_up_down = 0
1285            bot.node.run = 0.0
1286            return True
1287
1288        # Revert to normal bot behavior otherwise..
1289        return False
1290
1291    def handlemessage(self, msg: Any) -> Any:
1292        if isinstance(msg, ba.PlayerScoredMessage):
1293            self._score += msg.score
1294            self._update_scores()
1295
1296        elif isinstance(msg, ba.PlayerDiedMessage):
1297            # Augment standard behavior.
1298            super().handlemessage(msg)
1299
1300            self._a_player_has_been_killed = True
1301
1302            # Respawn them shortly.
1303            player = msg.getplayer(Player)
1304            assert self.initialplayerinfos is not None
1305            respawn_time = 2.0 + len(self.initialplayerinfos) * 1.0
1306            player.respawn_timer = ba.Timer(
1307                respawn_time, ba.Call(self.spawn_player_if_exists, player)
1308            )
1309            player.respawn_icon = RespawnIcon(player, respawn_time)
1310
1311        elif isinstance(msg, SpazBotDiedMessage):
1312            if msg.how is ba.DeathType.REACHED_GOAL:
1313                return None
1314            pts, importance = msg.spazbot.get_death_points(msg.how)
1315            if msg.killerplayer is not None:
1316                target: Sequence[float] | None
1317                try:
1318                    assert msg.spazbot is not None
1319                    assert msg.spazbot.node
1320                    target = msg.spazbot.node.position
1321                except Exception:
1322                    ba.print_exception()
1323                    target = None
1324                try:
1325                    if msg.killerplayer:
1326                        self.stats.player_scored(
1327                            msg.killerplayer,
1328                            pts,
1329                            target=target,
1330                            kill=True,
1331                            screenmessage=False,
1332                            importance=importance,
1333                        )
1334                        ba.playsound(
1335                            self._dingsound
1336                            if importance == 1
1337                            else self._dingsoundhigh,
1338                            volume=0.6,
1339                        )
1340                except Exception:
1341                    ba.print_exception('Error on SpazBotDiedMessage.')
1342
1343            # Normally we pull scores from the score-set, but if there's no
1344            # player lets be explicit.
1345            else:
1346                self._score += pts
1347            self._update_scores()
1348
1349        else:
1350            return super().handlemessage(msg)
1351        return None
1352
1353    def _get_bot_speed(self, bot_type: type[SpazBot]) -> float:
1354        speed = self._bot_speed_map.get(bot_type)
1355        if speed is None:
1356            raise TypeError(
1357                'Invalid bot type to _get_bot_speed(): ' + str(bot_type)
1358            )
1359        return speed
1360
1361    def _set_can_end_wave(self) -> None:
1362        self._can_end_wave = True
class Preset(enum.Enum):
49class Preset(Enum):
50    """Play presets."""
51
52    ENDLESS = 'endless'
53    ENDLESS_TOURNAMENT = 'endless_tournament'
54    PRO = 'pro'
55    PRO_EASY = 'pro_easy'
56    UBER = 'uber'
57    UBER_EASY = 'uber_easy'
58    TOURNAMENT = 'tournament'
59    TOURNAMENT_UBER = 'tournament_uber'

Play presets.

ENDLESS = <Preset.ENDLESS: 'endless'>
ENDLESS_TOURNAMENT = <Preset.ENDLESS_TOURNAMENT: 'endless_tournament'>
PRO = <Preset.PRO: 'pro'>
PRO_EASY = <Preset.PRO_EASY: 'pro_easy'>
UBER = <Preset.UBER: 'uber'>
UBER_EASY = <Preset.UBER_EASY: 'uber_easy'>
TOURNAMENT = <Preset.TOURNAMENT: 'tournament'>
TOURNAMENT_UBER = <Preset.TOURNAMENT_UBER: 'tournament_uber'>
Inherited Members
enum.Enum
name
value
class Point(enum.Enum):
62class Point(Enum):
63    """Where we can spawn stuff and the corresponding map attr name."""
64
65    BOTTOM_LEFT = 'bot_spawn_bottom_left'
66    BOTTOM_RIGHT = 'bot_spawn_bottom_right'
67    START = 'bot_spawn_start'

Where we can spawn stuff and the corresponding map attr name.

BOTTOM_LEFT = <Point.BOTTOM_LEFT: 'bot_spawn_bottom_left'>
BOTTOM_RIGHT = <Point.BOTTOM_RIGHT: 'bot_spawn_bottom_right'>
START = <Point.START: 'bot_spawn_start'>
Inherited Members
enum.Enum
name
value
@dataclass
class Spawn:
70@dataclass
71class Spawn:
72    """Defines a bot spawn event."""
73
74    # noinspection PyUnresolvedReferences
75    type: type[SpazBot]
76    path: int = 0
77    point: Point | None = None

Defines a bot spawn event.

Spawn( type: type[bastd.actor.spazbot.SpazBot], path: int = 0, point: bastd.game.runaround.Point | None = None)
@dataclass
class Spacing:
80@dataclass
81class Spacing:
82    """Defines spacing between spawns."""
83
84    duration: float

Defines spacing between spawns.

Spacing(duration: float)
@dataclass
class Wave:
87@dataclass
88class Wave:
89    """Defines a wave of enemies."""
90
91    entries: list[Spawn | Spacing | None]

Defines a wave of enemies.

class Player(ba._player.Player[ForwardRef('Team')]):
94class Player(ba.Player['Team']):
95    """Our player type for this game."""
96
97    def __init__(self) -> None:
98        self.respawn_timer: ba.Timer | None = None
99        self.respawn_icon: RespawnIcon | None = None

Our player type for this game.

Player()
97    def __init__(self) -> None:
98        self.respawn_timer: ba.Timer | None = None
99        self.respawn_icon: RespawnIcon | None = None
Inherited Members
ba._player.Player
actor
on_expire
team
customdata
sessionplayer
node
position
exists
getname
is_alive
get_icon
assigninput
resetinput
class Team(ba._team.Team[bastd.game.runaround.Player]):
102class Team(ba.Team[Player]):
103    """Our team type for this game."""

Our team type for this game.

Team()
Inherited Members
ba._team.Team
manual_init
customdata
on_expire
sessionteam
class RunaroundGame(ba._coopgame.CoopGameActivity[bastd.game.runaround.Player, bastd.game.runaround.Team]):
 106class RunaroundGame(ba.CoopGameActivity[Player, Team]):
 107    """Game involving trying to bomb bots as they walk through the map."""
 108
 109    name = 'Runaround'
 110    description = 'Prevent enemies from reaching the exit.'
 111    tips = [
 112        'Jump just as you\'re throwing to get bombs up to the highest levels.',
 113        'No, you can\'t get up on the ledge. You have to throw bombs.',
 114        'Whip back and forth to get more distance on your throws..',
 115    ]
 116    default_music = ba.MusicType.MARCHING
 117
 118    # How fast our various bot types walk.
 119    _bot_speed_map: dict[type[SpazBot], float] = {
 120        BomberBot: 0.48,
 121        BomberBotPro: 0.48,
 122        BomberBotProShielded: 0.48,
 123        BrawlerBot: 0.57,
 124        BrawlerBotPro: 0.57,
 125        BrawlerBotProShielded: 0.57,
 126        TriggerBot: 0.73,
 127        TriggerBotPro: 0.78,
 128        TriggerBotProShielded: 0.78,
 129        ChargerBot: 1.0,
 130        ChargerBotProShielded: 1.0,
 131        ExplodeyBot: 1.0,
 132        StickyBot: 0.5,
 133    }
 134
 135    def __init__(self, settings: dict):
 136        settings['map'] = 'Tower D'
 137        super().__init__(settings)
 138        shared = SharedObjects.get()
 139        self._preset = Preset(settings.get('preset', 'pro'))
 140
 141        self._player_death_sound = ba.getsound('playerDeath')
 142        self._new_wave_sound = ba.getsound('scoreHit01')
 143        self._winsound = ba.getsound('score')
 144        self._cashregistersound = ba.getsound('cashRegister')
 145        self._bad_guy_score_sound = ba.getsound('shieldDown')
 146        self._heart_tex = ba.gettexture('heart')
 147        self._heart_model_opaque = ba.getmodel('heartOpaque')
 148        self._heart_model_transparent = ba.getmodel('heartTransparent')
 149
 150        self._a_player_has_been_killed = False
 151        self._spawn_center = self._map_type.defs.points['spawn1'][0:3]
 152        self._tntspawnpos = self._map_type.defs.points['tnt_loc'][0:3]
 153        self._powerup_center = self._map_type.defs.boxes['powerup_region'][0:3]
 154        self._powerup_spread = (
 155            self._map_type.defs.boxes['powerup_region'][6] * 0.5,
 156            self._map_type.defs.boxes['powerup_region'][8] * 0.5,
 157        )
 158
 159        self._score_region_material = ba.Material()
 160        self._score_region_material.add_actions(
 161            conditions=('they_have_material', shared.player_material),
 162            actions=(
 163                ('modify_part_collision', 'collide', True),
 164                ('modify_part_collision', 'physical', False),
 165                ('call', 'at_connect', self._handle_reached_end),
 166            ),
 167        )
 168
 169        self._last_wave_end_time = ba.time()
 170        self._player_has_picked_up_powerup = False
 171        self._scoreboard: Scoreboard | None = None
 172        self._game_over = False
 173        self._wavenum = 0
 174        self._can_end_wave = True
 175        self._score = 0
 176        self._time_bonus = 0
 177        self._score_region: ba.Actor | None = None
 178        self._dingsound = ba.getsound('dingSmall')
 179        self._dingsoundhigh = ba.getsound('dingSmallHigh')
 180        self._exclude_powerups: list[str] | None = None
 181        self._have_tnt: bool | None = None
 182        self._waves: list[Wave] | None = None
 183        self._bots = SpazBotSet()
 184        self._tntspawner: TNTSpawner | None = None
 185        self._lives_bg: ba.NodeActor | None = None
 186        self._start_lives = 10
 187        self._lives = self._start_lives
 188        self._lives_text: ba.NodeActor | None = None
 189        self._flawless = True
 190        self._time_bonus_timer: ba.Timer | None = None
 191        self._time_bonus_text: ba.NodeActor | None = None
 192        self._time_bonus_mult: float | None = None
 193        self._wave_text: ba.NodeActor | None = None
 194        self._flawless_bonus: int | None = None
 195        self._wave_update_timer: ba.Timer | None = None
 196
 197    def on_transition_in(self) -> None:
 198        super().on_transition_in()
 199        self._scoreboard = Scoreboard(
 200            label=ba.Lstr(resource='scoreText'), score_split=0.5
 201        )
 202        self._score_region = ba.NodeActor(
 203            ba.newnode(
 204                'region',
 205                attrs={
 206                    'position': self.map.defs.boxes['score_region'][0:3],
 207                    'scale': self.map.defs.boxes['score_region'][6:9],
 208                    'type': 'box',
 209                    'materials': [self._score_region_material],
 210                },
 211            )
 212        )
 213
 214    def on_begin(self) -> None:
 215        super().on_begin()
 216        player_count = len(self.players)
 217        hard = self._preset not in {Preset.PRO_EASY, Preset.UBER_EASY}
 218
 219        if self._preset in {Preset.PRO, Preset.PRO_EASY, Preset.TOURNAMENT}:
 220            self._exclude_powerups = ['curse']
 221            self._have_tnt = True
 222            self._waves = [
 223                Wave(
 224                    entries=[
 225                        Spawn(BomberBot, path=3 if hard else 2),
 226                        Spawn(BomberBot, path=2),
 227                        Spawn(BomberBot, path=2) if hard else None,
 228                        Spawn(BomberBot, path=2) if player_count > 1 else None,
 229                        Spawn(BomberBot, path=1) if hard else None,
 230                        Spawn(BomberBot, path=1) if player_count > 2 else None,
 231                        Spawn(BomberBot, path=1) if player_count > 3 else None,
 232                    ]
 233                ),
 234                Wave(
 235                    entries=[
 236                        Spawn(BomberBot, path=1) if hard else None,
 237                        Spawn(BomberBot, path=2) if hard else None,
 238                        Spawn(BomberBot, path=2),
 239                        Spawn(BomberBot, path=2),
 240                        Spawn(BomberBot, path=2) if player_count > 3 else None,
 241                        Spawn(BrawlerBot, path=3),
 242                        Spawn(BrawlerBot, path=3),
 243                        Spawn(BrawlerBot, path=3) if hard else None,
 244                        Spawn(BrawlerBot, path=3) if player_count > 1 else None,
 245                        Spawn(BrawlerBot, path=3) if player_count > 2 else None,
 246                    ]
 247                ),
 248                Wave(
 249                    entries=[
 250                        Spawn(ChargerBot, path=2) if hard else None,
 251                        Spawn(ChargerBot, path=2) if player_count > 2 else None,
 252                        Spawn(TriggerBot, path=2),
 253                        Spawn(TriggerBot, path=2) if player_count > 1 else None,
 254                        Spacing(duration=3.0),
 255                        Spawn(BomberBot, path=2) if hard else None,
 256                        Spawn(BomberBot, path=2) if hard else None,
 257                        Spawn(BomberBot, path=2),
 258                        Spawn(BomberBot, path=3) if hard else None,
 259                        Spawn(BomberBot, path=3),
 260                        Spawn(BomberBot, path=3),
 261                        Spawn(BomberBot, path=3) if player_count > 3 else None,
 262                    ]
 263                ),
 264                Wave(
 265                    entries=[
 266                        Spawn(TriggerBot, path=1) if hard else None,
 267                        Spacing(duration=1.0) if hard else None,
 268                        Spawn(TriggerBot, path=2),
 269                        Spacing(duration=1.0),
 270                        Spawn(TriggerBot, path=3),
 271                        Spacing(duration=1.0),
 272                        Spawn(TriggerBot, path=1) if hard else None,
 273                        Spacing(duration=1.0) if hard else None,
 274                        Spawn(TriggerBot, path=2),
 275                        Spacing(duration=1.0),
 276                        Spawn(TriggerBot, path=3),
 277                        Spacing(duration=1.0),
 278                        Spawn(TriggerBot, path=1)
 279                        if (player_count > 1 and hard)
 280                        else None,
 281                        Spacing(duration=1.0),
 282                        Spawn(TriggerBot, path=2) if player_count > 2 else None,
 283                        Spacing(duration=1.0),
 284                        Spawn(TriggerBot, path=3) if player_count > 3 else None,
 285                        Spacing(duration=1.0),
 286                    ]
 287                ),
 288                Wave(
 289                    entries=[
 290                        Spawn(
 291                            ChargerBotProShielded if hard else ChargerBot,
 292                            path=1,
 293                        ),
 294                        Spawn(BrawlerBot, path=2) if hard else None,
 295                        Spawn(BrawlerBot, path=2),
 296                        Spawn(BrawlerBot, path=2),
 297                        Spawn(BrawlerBot, path=3) if hard else None,
 298                        Spawn(BrawlerBot, path=3),
 299                        Spawn(BrawlerBot, path=3),
 300                        Spawn(BrawlerBot, path=3) if player_count > 1 else None,
 301                        Spawn(BrawlerBot, path=3) if player_count > 2 else None,
 302                        Spawn(BrawlerBot, path=3) if player_count > 3 else None,
 303                    ]
 304                ),
 305                Wave(
 306                    entries=[
 307                        Spawn(BomberBotProShielded, path=3),
 308                        Spacing(duration=1.5),
 309                        Spawn(BomberBotProShielded, path=2),
 310                        Spacing(duration=1.5),
 311                        Spawn(BomberBotProShielded, path=1) if hard else None,
 312                        Spacing(duration=1.0) if hard else None,
 313                        Spawn(BomberBotProShielded, path=3),
 314                        Spacing(duration=1.5),
 315                        Spawn(BomberBotProShielded, path=2),
 316                        Spacing(duration=1.5),
 317                        Spawn(BomberBotProShielded, path=1) if hard else None,
 318                        Spacing(duration=1.5) if hard else None,
 319                        Spawn(BomberBotProShielded, path=3)
 320                        if player_count > 1
 321                        else None,
 322                        Spacing(duration=1.5),
 323                        Spawn(BomberBotProShielded, path=2)
 324                        if player_count > 2
 325                        else None,
 326                        Spacing(duration=1.5),
 327                        Spawn(BomberBotProShielded, path=1)
 328                        if player_count > 3
 329                        else None,
 330                    ]
 331                ),
 332            ]
 333        elif self._preset in {
 334            Preset.UBER_EASY,
 335            Preset.UBER,
 336            Preset.TOURNAMENT_UBER,
 337        }:
 338            self._exclude_powerups = []
 339            self._have_tnt = True
 340            self._waves = [
 341                Wave(
 342                    entries=[
 343                        Spawn(TriggerBot, path=1) if hard else None,
 344                        Spawn(TriggerBot, path=2),
 345                        Spawn(TriggerBot, path=2),
 346                        Spawn(TriggerBot, path=3),
 347                        Spawn(
 348                            BrawlerBotPro if hard else BrawlerBot,
 349                            point=Point.BOTTOM_LEFT,
 350                        ),
 351                        Spawn(BrawlerBotPro, point=Point.BOTTOM_RIGHT)
 352                        if player_count > 2
 353                        else None,
 354                    ]
 355                ),
 356                Wave(
 357                    entries=[
 358                        Spawn(ChargerBot, path=2),
 359                        Spawn(ChargerBot, path=3),
 360                        Spawn(ChargerBot, path=1) if hard else None,
 361                        Spawn(ChargerBot, path=2),
 362                        Spawn(ChargerBot, path=3),
 363                        Spawn(ChargerBot, path=1) if player_count > 2 else None,
 364                    ]
 365                ),
 366                Wave(
 367                    entries=[
 368                        Spawn(BomberBotProShielded, path=1) if hard else None,
 369                        Spawn(BomberBotProShielded, path=2),
 370                        Spawn(BomberBotProShielded, path=2),
 371                        Spawn(BomberBotProShielded, path=3),
 372                        Spawn(BomberBotProShielded, path=3),
 373                        Spawn(ChargerBot, point=Point.BOTTOM_RIGHT),
 374                        Spawn(ChargerBot, point=Point.BOTTOM_LEFT)
 375                        if player_count > 2
 376                        else None,
 377                    ]
 378                ),
 379                Wave(
 380                    entries=[
 381                        Spawn(TriggerBotPro, path=1) if hard else None,
 382                        Spawn(TriggerBotPro, path=1 if hard else 2),
 383                        Spawn(TriggerBotPro, path=1 if hard else 2),
 384                        Spawn(TriggerBotPro, path=1 if hard else 2),
 385                        Spawn(TriggerBotPro, path=1 if hard else 2),
 386                        Spawn(TriggerBotPro, path=1 if hard else 2),
 387                        Spawn(TriggerBotPro, path=1 if hard else 2)
 388                        if player_count > 1
 389                        else None,
 390                        Spawn(TriggerBotPro, path=1 if hard else 2)
 391                        if player_count > 3
 392                        else None,
 393                    ]
 394                ),
 395                Wave(
 396                    entries=[
 397                        Spawn(
 398                            TriggerBotProShielded if hard else TriggerBotPro,
 399                            point=Point.BOTTOM_LEFT,
 400                        ),
 401                        Spawn(TriggerBotProShielded, point=Point.BOTTOM_RIGHT)
 402                        if hard
 403                        else None,
 404                        Spawn(TriggerBotProShielded, point=Point.BOTTOM_RIGHT)
 405                        if player_count > 2
 406                        else None,
 407                        Spawn(BomberBot, path=3),
 408                        Spawn(BomberBot, path=3),
 409                        Spacing(duration=5.0),
 410                        Spawn(BrawlerBot, path=2),
 411                        Spawn(BrawlerBot, path=2),
 412                        Spacing(duration=5.0),
 413                        Spawn(TriggerBot, path=1) if hard else None,
 414                        Spawn(TriggerBot, path=1) if hard else None,
 415                    ]
 416                ),
 417                Wave(
 418                    entries=[
 419                        Spawn(BomberBotProShielded, path=2),
 420                        Spawn(BomberBotProShielded, path=2) if hard else None,
 421                        Spawn(StickyBot, point=Point.BOTTOM_RIGHT),
 422                        Spawn(BomberBotProShielded, path=2),
 423                        Spawn(BomberBotProShielded, path=2),
 424                        Spawn(StickyBot, point=Point.BOTTOM_RIGHT)
 425                        if player_count > 2
 426                        else None,
 427                        Spawn(BomberBotProShielded, path=2),
 428                        Spawn(ExplodeyBot, point=Point.BOTTOM_LEFT),
 429                        Spawn(BomberBotProShielded, path=2),
 430                        Spawn(BomberBotProShielded, path=2)
 431                        if player_count > 1
 432                        else None,
 433                        Spacing(duration=5.0),
 434                        Spawn(StickyBot, point=Point.BOTTOM_LEFT),
 435                        Spacing(duration=2.0),
 436                        Spawn(ExplodeyBot, point=Point.BOTTOM_RIGHT),
 437                    ]
 438                ),
 439            ]
 440        elif self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
 441            self._exclude_powerups = []
 442            self._have_tnt = True
 443
 444        # Spit out a few powerups and start dropping more shortly.
 445        self._drop_powerups(standard_points=True)
 446        ba.timer(4.0, self._start_powerup_drops)
 447        self.setup_low_life_warning_sound()
 448        self._update_scores()
 449
 450        # Our TNT spawner (if applicable).
 451        if self._have_tnt:
 452            self._tntspawner = TNTSpawner(position=self._tntspawnpos)
 453
 454        # Make sure to stay out of the way of menu/party buttons in the corner.
 455        uiscale = ba.app.ui.uiscale
 456        l_offs = (
 457            -80
 458            if uiscale is ba.UIScale.SMALL
 459            else -40
 460            if uiscale is ba.UIScale.MEDIUM
 461            else 0
 462        )
 463
 464        self._lives_bg = ba.NodeActor(
 465            ba.newnode(
 466                'image',
 467                attrs={
 468                    'texture': self._heart_tex,
 469                    'model_opaque': self._heart_model_opaque,
 470                    'model_transparent': self._heart_model_transparent,
 471                    'attach': 'topRight',
 472                    'scale': (90, 90),
 473                    'position': (-110 + l_offs, -50),
 474                    'color': (1, 0.2, 0.2),
 475                },
 476            )
 477        )
 478        # FIXME; should not set things based on vr mode.
 479        #  (won't look right to non-vr connected clients, etc)
 480        vrmode = ba.app.vr_mode
 481        self._lives_text = ba.NodeActor(
 482            ba.newnode(
 483                'text',
 484                attrs={
 485                    'v_attach': 'top',
 486                    'h_attach': 'right',
 487                    'h_align': 'center',
 488                    'color': (1, 1, 1, 1) if vrmode else (0.8, 0.8, 0.8, 1.0),
 489                    'flatness': 1.0 if vrmode else 0.5,
 490                    'shadow': 1.0 if vrmode else 0.5,
 491                    'vr_depth': 10,
 492                    'position': (-113 + l_offs, -69),
 493                    'scale': 1.3,
 494                    'text': str(self._lives),
 495                },
 496            )
 497        )
 498
 499        ba.timer(2.0, self._start_updating_waves)
 500
 501    def _handle_reached_end(self) -> None:
 502        spaz = ba.getcollision().opposingnode.getdelegate(SpazBot, True)
 503        if not spaz.is_alive():
 504            return  # Ignore bodies flying in.
 505
 506        self._flawless = False
 507        pos = spaz.node.position
 508        ba.playsound(self._bad_guy_score_sound, position=pos)
 509        light = ba.newnode(
 510            'light', attrs={'position': pos, 'radius': 0.5, 'color': (1, 0, 0)}
 511        )
 512        ba.animate(light, 'intensity', {0.0: 0, 0.1: 1, 0.5: 0}, loop=False)
 513        ba.timer(1.0, light.delete)
 514        spaz.handlemessage(
 515            ba.DieMessage(immediate=True, how=ba.DeathType.REACHED_GOAL)
 516        )
 517
 518        if self._lives > 0:
 519            self._lives -= 1
 520            if self._lives == 0:
 521                self._bots.stop_moving()
 522                self.continue_or_end_game()
 523            assert self._lives_text is not None
 524            assert self._lives_text.node
 525            self._lives_text.node.text = str(self._lives)
 526            delay = 0.0
 527
 528            def _safesetattr(node: ba.Node, attr: str, value: Any) -> None:
 529                if node:
 530                    setattr(node, attr, value)
 531
 532            for _i in range(4):
 533                ba.timer(
 534                    delay,
 535                    ba.Call(
 536                        _safesetattr,
 537                        self._lives_text.node,
 538                        'color',
 539                        (1, 0, 0, 1.0),
 540                    ),
 541                )
 542                assert self._lives_bg is not None
 543                assert self._lives_bg.node
 544                ba.timer(
 545                    delay,
 546                    ba.Call(_safesetattr, self._lives_bg.node, 'opacity', 0.5),
 547                )
 548                delay += 0.125
 549                ba.timer(
 550                    delay,
 551                    ba.Call(
 552                        _safesetattr,
 553                        self._lives_text.node,
 554                        'color',
 555                        (1.0, 1.0, 0.0, 1.0),
 556                    ),
 557                )
 558                ba.timer(
 559                    delay,
 560                    ba.Call(_safesetattr, self._lives_bg.node, 'opacity', 1.0),
 561                )
 562                delay += 0.125
 563            ba.timer(
 564                delay,
 565                ba.Call(
 566                    _safesetattr,
 567                    self._lives_text.node,
 568                    'color',
 569                    (0.8, 0.8, 0.8, 1.0),
 570                ),
 571            )
 572
 573    def on_continue(self) -> None:
 574        self._lives = 3
 575        assert self._lives_text is not None
 576        assert self._lives_text.node
 577        self._lives_text.node.text = str(self._lives)
 578        self._bots.start_moving()
 579
 580    def spawn_player(self, player: Player) -> ba.Actor:
 581        pos = (
 582            self._spawn_center[0] + random.uniform(-1.5, 1.5),
 583            self._spawn_center[1],
 584            self._spawn_center[2] + random.uniform(-1.5, 1.5),
 585        )
 586        spaz = self.spawn_player_spaz(player, position=pos)
 587        if self._preset in {Preset.PRO_EASY, Preset.UBER_EASY}:
 588            spaz.impact_scale = 0.25
 589
 590        # Add the material that causes us to hit the player-wall.
 591        spaz.pick_up_powerup_callback = self._on_player_picked_up_powerup
 592        return spaz
 593
 594    def _on_player_picked_up_powerup(self, player: ba.Actor) -> None:
 595        del player  # Unused.
 596        self._player_has_picked_up_powerup = True
 597
 598    def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None:
 599        if poweruptype is None:
 600            poweruptype = PowerupBoxFactory.get().get_random_powerup_type(
 601                excludetypes=self._exclude_powerups
 602            )
 603        PowerupBox(
 604            position=self.map.powerup_spawn_points[index],
 605            poweruptype=poweruptype,
 606        ).autoretain()
 607
 608    def _start_powerup_drops(self) -> None:
 609        ba.timer(3.0, self._drop_powerups, repeat=True)
 610
 611    def _drop_powerups(
 612        self, standard_points: bool = False, force_first: str | None = None
 613    ) -> None:
 614        """Generic powerup drop."""
 615
 616        # If its been a minute since our last wave finished emerging, stop
 617        # giving out land-mine powerups. (prevents players from waiting
 618        # around for them on purpose and filling the map up)
 619        if ba.time() - self._last_wave_end_time > 60.0:
 620            extra_excludes = ['land_mines']
 621        else:
 622            extra_excludes = []
 623
 624        if standard_points:
 625            points = self.map.powerup_spawn_points
 626            for i in range(len(points)):
 627                ba.timer(
 628                    1.0 + i * 0.5,
 629                    ba.Call(
 630                        self._drop_powerup, i, force_first if i == 0 else None
 631                    ),
 632                )
 633        else:
 634            pos = (
 635                self._powerup_center[0]
 636                + random.uniform(
 637                    -1.0 * self._powerup_spread[0],
 638                    1.0 * self._powerup_spread[0],
 639                ),
 640                self._powerup_center[1],
 641                self._powerup_center[2]
 642                + random.uniform(
 643                    -self._powerup_spread[1], self._powerup_spread[1]
 644                ),
 645            )
 646
 647            # drop one random one somewhere..
 648            assert self._exclude_powerups is not None
 649            PowerupBox(
 650                position=pos,
 651                poweruptype=PowerupBoxFactory.get().get_random_powerup_type(
 652                    excludetypes=self._exclude_powerups + extra_excludes
 653                ),
 654            ).autoretain()
 655
 656    def end_game(self) -> None:
 657        ba.pushcall(ba.Call(self.do_end, 'defeat'))
 658        ba.setmusic(None)
 659        ba.playsound(self._player_death_sound)
 660
 661    def do_end(self, outcome: str) -> None:
 662        """End the game now with the provided outcome."""
 663
 664        if outcome == 'defeat':
 665            delay = 2.0
 666            self.fade_to_red()
 667        else:
 668            delay = 0
 669
 670        score: int | None
 671        if self._wavenum >= 2:
 672            score = self._score
 673            fail_message = None
 674        else:
 675            score = None
 676            fail_message = ba.Lstr(resource='reachWave2Text')
 677
 678        self.end(
 679            delay=delay,
 680            results={
 681                'outcome': outcome,
 682                'score': score,
 683                'fail_message': fail_message,
 684                'playerinfos': self.initialplayerinfos,
 685            },
 686        )
 687
 688    def _update_waves(self) -> None:
 689        # pylint: disable=too-many-branches
 690
 691        # If we have no living bots, go to the next wave.
 692        if (
 693            self._can_end_wave
 694            and not self._bots.have_living_bots()
 695            and not self._game_over
 696            and self._lives > 0
 697        ):
 698
 699            self._can_end_wave = False
 700            self._time_bonus_timer = None
 701            self._time_bonus_text = None
 702
 703            if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
 704                won = False
 705            else:
 706                assert self._waves is not None
 707                won = self._wavenum == len(self._waves)
 708
 709            # Reward time bonus.
 710            base_delay = 4.0 if won else 0
 711            if self._time_bonus > 0:
 712                ba.timer(0, ba.Call(ba.playsound, self._cashregistersound))
 713                ba.timer(
 714                    base_delay,
 715                    ba.Call(self._award_time_bonus, self._time_bonus),
 716                )
 717                base_delay += 1.0
 718
 719            # Reward flawless bonus.
 720            if self._wavenum > 0 and self._flawless:
 721                ba.timer(base_delay, self._award_flawless_bonus)
 722                base_delay += 1.0
 723
 724            self._flawless = True  # reset
 725
 726            if won:
 727
 728                # Completion achievements:
 729                if self._preset in {Preset.PRO, Preset.PRO_EASY}:
 730                    self._award_achievement(
 731                        'Pro Runaround Victory', sound=False
 732                    )
 733                    if self._lives == self._start_lives:
 734                        self._award_achievement('The Wall', sound=False)
 735                    if not self._player_has_picked_up_powerup:
 736                        self._award_achievement(
 737                            'Precision Bombing', sound=False
 738                        )
 739                elif self._preset in {Preset.UBER, Preset.UBER_EASY}:
 740                    self._award_achievement(
 741                        'Uber Runaround Victory', sound=False
 742                    )
 743                    if self._lives == self._start_lives:
 744                        self._award_achievement('The Great Wall', sound=False)
 745                    if not self._a_player_has_been_killed:
 746                        self._award_achievement('Stayin\' Alive', sound=False)
 747
 748                # Give remaining players some points and have them celebrate.
 749                self.show_zoom_message(
 750                    ba.Lstr(resource='victoryText'), scale=1.0, duration=4.0
 751                )
 752
 753                self.celebrate(10.0)
 754                ba.timer(base_delay, self._award_lives_bonus)
 755                base_delay += 1.0
 756                ba.timer(base_delay, self._award_completion_bonus)
 757                base_delay += 0.85
 758                ba.playsound(self._winsound)
 759                ba.cameraflash()
 760                ba.setmusic(ba.MusicType.VICTORY)
 761                self._game_over = True
 762                ba.timer(base_delay, ba.Call(self.do_end, 'victory'))
 763                return
 764
 765            self._wavenum += 1
 766
 767            # Short celebration after waves.
 768            if self._wavenum > 1:
 769                self.celebrate(0.5)
 770
 771            ba.timer(base_delay, self._start_next_wave)
 772
 773    def _award_completion_bonus(self) -> None:
 774        bonus = 200
 775        ba.playsound(self._cashregistersound)
 776        PopupText(
 777            ba.Lstr(
 778                value='+${A} ${B}',
 779                subs=[
 780                    ('${A}', str(bonus)),
 781                    ('${B}', ba.Lstr(resource='completionBonusText')),
 782                ],
 783            ),
 784            color=(0.7, 0.7, 1.0, 1),
 785            scale=1.6,
 786            position=(0, 1.5, -1),
 787        ).autoretain()
 788        self._score += bonus
 789        self._update_scores()
 790
 791    def _award_lives_bonus(self) -> None:
 792        bonus = self._lives * 30
 793        ba.playsound(self._cashregistersound)
 794        PopupText(
 795            ba.Lstr(
 796                value='+${A} ${B}',
 797                subs=[
 798                    ('${A}', str(bonus)),
 799                    ('${B}', ba.Lstr(resource='livesBonusText')),
 800                ],
 801            ),
 802            color=(0.7, 1.0, 0.3, 1),
 803            scale=1.3,
 804            position=(0, 1, -1),
 805        ).autoretain()
 806        self._score += bonus
 807        self._update_scores()
 808
 809    def _award_time_bonus(self, bonus: int) -> None:
 810        ba.playsound(self._cashregistersound)
 811        PopupText(
 812            ba.Lstr(
 813                value='+${A} ${B}',
 814                subs=[
 815                    ('${A}', str(bonus)),
 816                    ('${B}', ba.Lstr(resource='timeBonusText')),
 817                ],
 818            ),
 819            color=(1, 1, 0.5, 1),
 820            scale=1.0,
 821            position=(0, 3, -1),
 822        ).autoretain()
 823
 824        self._score += self._time_bonus
 825        self._update_scores()
 826
 827    def _award_flawless_bonus(self) -> None:
 828        ba.playsound(self._cashregistersound)
 829        PopupText(
 830            ba.Lstr(
 831                value='+${A} ${B}',
 832                subs=[
 833                    ('${A}', str(self._flawless_bonus)),
 834                    ('${B}', ba.Lstr(resource='perfectWaveText')),
 835                ],
 836            ),
 837            color=(1, 1, 0.2, 1),
 838            scale=1.2,
 839            position=(0, 2, -1),
 840        ).autoretain()
 841
 842        assert self._flawless_bonus is not None
 843        self._score += self._flawless_bonus
 844        self._update_scores()
 845
 846    def _start_time_bonus_timer(self) -> None:
 847        self._time_bonus_timer = ba.Timer(
 848            1.0, self._update_time_bonus, repeat=True
 849        )
 850
 851    def _start_next_wave(self) -> None:
 852        # FIXME: Need to split this up.
 853        # pylint: disable=too-many-locals
 854        # pylint: disable=too-many-branches
 855        # pylint: disable=too-many-statements
 856        self.show_zoom_message(
 857            ba.Lstr(
 858                value='${A} ${B}',
 859                subs=[
 860                    ('${A}', ba.Lstr(resource='waveText')),
 861                    ('${B}', str(self._wavenum)),
 862                ],
 863            ),
 864            scale=1.0,
 865            duration=1.0,
 866            trail=True,
 867        )
 868        ba.timer(0.4, ba.Call(ba.playsound, self._new_wave_sound))
 869        t_sec = 0.0
 870        base_delay = 0.5
 871        delay = 0.0
 872        bot_types: list[Spawn | Spacing | None] = []
 873
 874        if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
 875            level = self._wavenum
 876            target_points = (level + 1) * 8.0
 877            group_count = random.randint(1, 3)
 878            entries: list[Spawn | Spacing | None] = []
 879            spaz_types: list[tuple[type[SpazBot], float]] = []
 880            if level < 6:
 881                spaz_types += [(BomberBot, 5.0)]
 882            if level < 10:
 883                spaz_types += [(BrawlerBot, 5.0)]
 884            if level < 15:
 885                spaz_types += [(TriggerBot, 6.0)]
 886            if level > 5:
 887                spaz_types += [(TriggerBotPro, 7.5)] * (1 + (level - 5) // 7)
 888            if level > 2:
 889                spaz_types += [(BomberBotProShielded, 8.0)] * (
 890                    1 + (level - 2) // 6
 891                )
 892            if level > 6:
 893                spaz_types += [(TriggerBotProShielded, 12.0)] * (
 894                    1 + (level - 6) // 5
 895                )
 896            if level > 1:
 897                spaz_types += [(ChargerBot, 10.0)] * (1 + (level - 1) // 4)
 898            if level > 7:
 899                spaz_types += [(ChargerBotProShielded, 15.0)] * (
 900                    1 + (level - 7) // 3
 901                )
 902
 903            # Bot type, their effect on target points.
 904            defender_types: list[tuple[type[SpazBot], float]] = [
 905                (BomberBot, 0.9),
 906                (BrawlerBot, 0.9),
 907                (TriggerBot, 0.85),
 908            ]
 909            if level > 2:
 910                defender_types += [(ChargerBot, 0.75)]
 911            if level > 4:
 912                defender_types += [(StickyBot, 0.7)] * (1 + (level - 5) // 6)
 913            if level > 6:
 914                defender_types += [(ExplodeyBot, 0.7)] * (1 + (level - 5) // 5)
 915            if level > 8:
 916                defender_types += [(BrawlerBotProShielded, 0.65)] * (
 917                    1 + (level - 5) // 4
 918                )
 919            if level > 10:
 920                defender_types += [(TriggerBotProShielded, 0.6)] * (
 921                    1 + (level - 6) // 3
 922                )
 923
 924            for group in range(group_count):
 925                this_target_point_s = target_points / group_count
 926
 927                # Adding spacing makes things slightly harder.
 928                rval = random.random()
 929                if rval < 0.07:
 930                    spacing = 1.5
 931                    this_target_point_s *= 0.85
 932                elif rval < 0.15:
 933                    spacing = 1.0
 934                    this_target_point_s *= 0.9
 935                else:
 936                    spacing = 0.0
 937
 938                path = random.randint(1, 3)
 939
 940                # Don't allow hard paths on early levels.
 941                if level < 3:
 942                    if path == 1:
 943                        path = 3
 944
 945                # Easy path.
 946                if path == 3:
 947                    pass
 948
 949                # Harder path.
 950                elif path == 2:
 951                    this_target_point_s *= 0.8
 952
 953                # Even harder path.
 954                elif path == 1:
 955                    this_target_point_s *= 0.7
 956
 957                # Looping forward.
 958                elif path == 4:
 959                    this_target_point_s *= 0.7
 960
 961                # Looping backward.
 962                elif path == 5:
 963                    this_target_point_s *= 0.7
 964
 965                # Random.
 966                elif path == 6:
 967                    this_target_point_s *= 0.7
 968
 969                def _add_defender(
 970                    defender_type: tuple[type[SpazBot], float], pnt: Point
 971                ) -> tuple[float, Spawn]:
 972                    # This is ok because we call it immediately.
 973                    # pylint: disable=cell-var-from-loop
 974                    return this_target_point_s * defender_type[1], Spawn(
 975                        defender_type[0], point=pnt
 976                    )
 977
 978                # Add defenders.
 979                defender_type1 = defender_types[
 980                    random.randrange(len(defender_types))
 981                ]
 982                defender_type2 = defender_types[
 983                    random.randrange(len(defender_types))
 984                ]
 985                defender1 = defender2 = None
 986                if (
 987                    (group == 0)
 988                    or (group == 1 and level > 3)
 989                    or (group == 2 and level > 5)
 990                ):
 991                    if random.random() < min(0.75, (level - 1) * 0.11):
 992                        this_target_point_s, defender1 = _add_defender(
 993                            defender_type1, Point.BOTTOM_LEFT
 994                        )
 995                    if random.random() < min(0.75, (level - 1) * 0.04):
 996                        this_target_point_s, defender2 = _add_defender(
 997                            defender_type2, Point.BOTTOM_RIGHT
 998                        )
 999
1000                spaz_type = spaz_types[random.randrange(len(spaz_types))]
1001                member_count = max(
1002                    1, int(round(this_target_point_s / spaz_type[1]))
1003                )
1004                for i, _member in enumerate(range(member_count)):
1005                    if path == 4:
1006                        this_path = i % 3  # Looping forward.
1007                    elif path == 5:
1008                        this_path = 3 - (i % 3)  # Looping backward.
1009                    elif path == 6:
1010                        this_path = random.randint(1, 3)  # Random.
1011                    else:
1012                        this_path = path
1013                    entries.append(Spawn(spaz_type[0], path=this_path))
1014                    if spacing != 0.0:
1015                        entries.append(Spacing(duration=spacing))
1016
1017                if defender1 is not None:
1018                    entries.append(defender1)
1019                if defender2 is not None:
1020                    entries.append(defender2)
1021
1022                # Some spacing between groups.
1023                rval = random.random()
1024                if rval < 0.1:
1025                    spacing = 5.0
1026                elif rval < 0.5:
1027                    spacing = 1.0
1028                else:
1029                    spacing = 1.0
1030                entries.append(Spacing(duration=spacing))
1031
1032            wave = Wave(entries=entries)
1033
1034        else:
1035            assert self._waves is not None
1036            wave = self._waves[self._wavenum - 1]
1037
1038        bot_types += wave.entries
1039        self._time_bonus_mult = 1.0
1040        this_flawless_bonus = 0
1041        non_runner_spawn_time = 1.0
1042
1043        for info in bot_types:
1044            if info is None:
1045                continue
1046            if isinstance(info, Spacing):
1047                t_sec += info.duration
1048                continue
1049            bot_type = info.type
1050            path = info.path
1051            self._time_bonus_mult += bot_type.points_mult * 0.02
1052            this_flawless_bonus += bot_type.points_mult * 5
1053
1054            # If its got a position, use that.
1055            if info.point is not None:
1056                point = info.point
1057            else:
1058                point = Point.START
1059
1060            # Space our our slower bots.
1061            delay = base_delay
1062            delay /= self._get_bot_speed(bot_type)
1063            t_sec += delay * 0.5
1064            tcall = ba.Call(
1065                self.add_bot_at_point,
1066                point,
1067                bot_type,
1068                path,
1069                0.1 if point is Point.START else non_runner_spawn_time,
1070            )
1071            ba.timer(t_sec, tcall)
1072            t_sec += delay * 0.5
1073
1074        # We can end the wave after all the spawning happens.
1075        ba.timer(
1076            t_sec - delay * 0.5 + non_runner_spawn_time + 0.01,
1077            self._set_can_end_wave,
1078        )
1079
1080        # Reset our time bonus.
1081        # In this game we use a constant time bonus so it erodes away in
1082        # roughly the same time (since the time limit a wave can take is
1083        # relatively constant) ..we then post-multiply a modifier to adjust
1084        # points.
1085        self._time_bonus = 150
1086        self._flawless_bonus = this_flawless_bonus
1087        assert self._time_bonus_mult is not None
1088        txtval = ba.Lstr(
1089            value='${A}: ${B}',
1090            subs=[
1091                ('${A}', ba.Lstr(resource='timeBonusText')),
1092                ('${B}', str(int(self._time_bonus * self._time_bonus_mult))),
1093            ],
1094        )
1095        self._time_bonus_text = ba.NodeActor(
1096            ba.newnode(
1097                'text',
1098                attrs={
1099                    'v_attach': 'top',
1100                    'h_attach': 'center',
1101                    'h_align': 'center',
1102                    'color': (1, 1, 0.0, 1),
1103                    'shadow': 1.0,
1104                    'vr_depth': -30,
1105                    'flatness': 1.0,
1106                    'position': (0, -60),
1107                    'scale': 0.8,
1108                    'text': txtval,
1109                },
1110            )
1111        )
1112
1113        ba.timer(t_sec, self._start_time_bonus_timer)
1114
1115        # Keep track of when this wave finishes emerging. We wanna stop
1116        # dropping land-mines powerups at some point (otherwise a crafty
1117        # player could fill the whole map with them)
1118        self._last_wave_end_time = ba.time() + t_sec
1119        totalwaves = str(len(self._waves)) if self._waves is not None else '??'
1120        txtval = ba.Lstr(
1121            value='${A} ${B}',
1122            subs=[
1123                ('${A}', ba.Lstr(resource='waveText')),
1124                (
1125                    '${B}',
1126                    str(self._wavenum)
1127                    + (
1128                        ''
1129                        if self._preset
1130                        in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}
1131                        else f'/{totalwaves}'
1132                    ),
1133                ),
1134            ],
1135        )
1136        self._wave_text = ba.NodeActor(
1137            ba.newnode(
1138                'text',
1139                attrs={
1140                    'v_attach': 'top',
1141                    'h_attach': 'center',
1142                    'h_align': 'center',
1143                    'vr_depth': -10,
1144                    'color': (1, 1, 1, 1),
1145                    'shadow': 1.0,
1146                    'flatness': 1.0,
1147                    'position': (0, -40),
1148                    'scale': 1.3,
1149                    'text': txtval,
1150                },
1151            )
1152        )
1153
1154    def _on_bot_spawn(self, path: int, spaz: SpazBot) -> None:
1155
1156        # Add our custom update callback and set some info for this bot.
1157        spaz_type = type(spaz)
1158        assert spaz is not None
1159        spaz.update_callback = self._update_bot
1160
1161        # Tack some custom attrs onto the spaz.
1162        setattr(spaz, 'r_walk_row', path)
1163        setattr(spaz, 'r_walk_speed', self._get_bot_speed(spaz_type))
1164
1165    def add_bot_at_point(
1166        self,
1167        point: Point,
1168        spaztype: type[SpazBot],
1169        path: int,
1170        spawn_time: float = 0.1,
1171    ) -> None:
1172        """Add the given type bot with the given delay (in seconds)."""
1173
1174        # Don't add if the game has ended.
1175        if self._game_over:
1176            return
1177        pos = self.map.defs.points[point.value][:3]
1178        self._bots.spawn_bot(
1179            spaztype,
1180            pos=pos,
1181            spawn_time=spawn_time,
1182            on_spawn_call=ba.Call(self._on_bot_spawn, path),
1183        )
1184
1185    def _update_time_bonus(self) -> None:
1186        self._time_bonus = int(self._time_bonus * 0.91)
1187        if self._time_bonus > 0 and self._time_bonus_text is not None:
1188            assert self._time_bonus_text.node
1189            assert self._time_bonus_mult
1190            self._time_bonus_text.node.text = ba.Lstr(
1191                value='${A}: ${B}',
1192                subs=[
1193                    ('${A}', ba.Lstr(resource='timeBonusText')),
1194                    (
1195                        '${B}',
1196                        str(int(self._time_bonus * self._time_bonus_mult)),
1197                    ),
1198                ],
1199            )
1200        else:
1201            self._time_bonus_text = None
1202
1203    def _start_updating_waves(self) -> None:
1204        self._wave_update_timer = ba.Timer(2.0, self._update_waves, repeat=True)
1205
1206    def _update_scores(self) -> None:
1207        score = self._score
1208        if self._preset is Preset.ENDLESS:
1209            if score >= 500:
1210                self._award_achievement('Runaround Master')
1211            if score >= 1000:
1212                self._award_achievement('Runaround Wizard')
1213            if score >= 2000:
1214                self._award_achievement('Runaround God')
1215
1216        assert self._scoreboard is not None
1217        self._scoreboard.set_team_value(self.teams[0], score, max_score=None)
1218
1219    def _update_bot(self, bot: SpazBot) -> bool:
1220        # Yup; that's a lot of return statements right there.
1221        # pylint: disable=too-many-return-statements
1222
1223        if not bool(bot):
1224            return True
1225
1226        assert bot.node
1227
1228        # FIXME: Do this in a type safe way.
1229        r_walk_speed: float = getattr(bot, 'r_walk_speed')
1230        r_walk_row: int = getattr(bot, 'r_walk_row')
1231
1232        speed = r_walk_speed
1233        pos = bot.node.position
1234        boxes = self.map.defs.boxes
1235
1236        # Bots in row 1 attempt the high road..
1237        if r_walk_row == 1:
1238            if ba.is_point_in_box(pos, boxes['b4']):
1239                bot.node.move_up_down = speed
1240                bot.node.move_left_right = 0
1241                bot.node.run = 0.0
1242                return True
1243
1244        # Row 1 and 2 bots attempt the middle road..
1245        if r_walk_row in [1, 2]:
1246            if ba.is_point_in_box(pos, boxes['b1']):
1247                bot.node.move_up_down = speed
1248                bot.node.move_left_right = 0
1249                bot.node.run = 0.0
1250                return True
1251
1252        # All bots settle for the third row.
1253        if ba.is_point_in_box(pos, boxes['b7']):
1254            bot.node.move_up_down = speed
1255            bot.node.move_left_right = 0
1256            bot.node.run = 0.0
1257            return True
1258        if ba.is_point_in_box(pos, boxes['b2']):
1259            bot.node.move_up_down = -speed
1260            bot.node.move_left_right = 0
1261            bot.node.run = 0.0
1262            return True
1263        if ba.is_point_in_box(pos, boxes['b3']):
1264            bot.node.move_up_down = -speed
1265            bot.node.move_left_right = 0
1266            bot.node.run = 0.0
1267            return True
1268        if ba.is_point_in_box(pos, boxes['b5']):
1269            bot.node.move_up_down = -speed
1270            bot.node.move_left_right = 0
1271            bot.node.run = 0.0
1272            return True
1273        if ba.is_point_in_box(pos, boxes['b6']):
1274            bot.node.move_up_down = speed
1275            bot.node.move_left_right = 0
1276            bot.node.run = 0.0
1277            return True
1278        if (
1279            ba.is_point_in_box(pos, boxes['b8'])
1280            and not ba.is_point_in_box(pos, boxes['b9'])
1281        ) or pos == (0.0, 0.0, 0.0):
1282
1283            # Default to walking right if we're still in the walking area.
1284            bot.node.move_left_right = speed
1285            bot.node.move_up_down = 0
1286            bot.node.run = 0.0
1287            return True
1288
1289        # Revert to normal bot behavior otherwise..
1290        return False
1291
1292    def handlemessage(self, msg: Any) -> Any:
1293        if isinstance(msg, ba.PlayerScoredMessage):
1294            self._score += msg.score
1295            self._update_scores()
1296
1297        elif isinstance(msg, ba.PlayerDiedMessage):
1298            # Augment standard behavior.
1299            super().handlemessage(msg)
1300
1301            self._a_player_has_been_killed = True
1302
1303            # Respawn them shortly.
1304            player = msg.getplayer(Player)
1305            assert self.initialplayerinfos is not None
1306            respawn_time = 2.0 + len(self.initialplayerinfos) * 1.0
1307            player.respawn_timer = ba.Timer(
1308                respawn_time, ba.Call(self.spawn_player_if_exists, player)
1309            )
1310            player.respawn_icon = RespawnIcon(player, respawn_time)
1311
1312        elif isinstance(msg, SpazBotDiedMessage):
1313            if msg.how is ba.DeathType.REACHED_GOAL:
1314                return None
1315            pts, importance = msg.spazbot.get_death_points(msg.how)
1316            if msg.killerplayer is not None:
1317                target: Sequence[float] | None
1318                try:
1319                    assert msg.spazbot is not None
1320                    assert msg.spazbot.node
1321                    target = msg.spazbot.node.position
1322                except Exception:
1323                    ba.print_exception()
1324                    target = None
1325                try:
1326                    if msg.killerplayer:
1327                        self.stats.player_scored(
1328                            msg.killerplayer,
1329                            pts,
1330                            target=target,
1331                            kill=True,
1332                            screenmessage=False,
1333                            importance=importance,
1334                        )
1335                        ba.playsound(
1336                            self._dingsound
1337                            if importance == 1
1338                            else self._dingsoundhigh,
1339                            volume=0.6,
1340                        )
1341                except Exception:
1342                    ba.print_exception('Error on SpazBotDiedMessage.')
1343
1344            # Normally we pull scores from the score-set, but if there's no
1345            # player lets be explicit.
1346            else:
1347                self._score += pts
1348            self._update_scores()
1349
1350        else:
1351            return super().handlemessage(msg)
1352        return None
1353
1354    def _get_bot_speed(self, bot_type: type[SpazBot]) -> float:
1355        speed = self._bot_speed_map.get(bot_type)
1356        if speed is None:
1357            raise TypeError(
1358                'Invalid bot type to _get_bot_speed(): ' + str(bot_type)
1359            )
1360        return speed
1361
1362    def _set_can_end_wave(self) -> None:
1363        self._can_end_wave = True

Game involving trying to bomb bots as they walk through the map.

RunaroundGame(settings: dict)
135    def __init__(self, settings: dict):
136        settings['map'] = 'Tower D'
137        super().__init__(settings)
138        shared = SharedObjects.get()
139        self._preset = Preset(settings.get('preset', 'pro'))
140
141        self._player_death_sound = ba.getsound('playerDeath')
142        self._new_wave_sound = ba.getsound('scoreHit01')
143        self._winsound = ba.getsound('score')
144        self._cashregistersound = ba.getsound('cashRegister')
145        self._bad_guy_score_sound = ba.getsound('shieldDown')
146        self._heart_tex = ba.gettexture('heart')
147        self._heart_model_opaque = ba.getmodel('heartOpaque')
148        self._heart_model_transparent = ba.getmodel('heartTransparent')
149
150        self._a_player_has_been_killed = False
151        self._spawn_center = self._map_type.defs.points['spawn1'][0:3]
152        self._tntspawnpos = self._map_type.defs.points['tnt_loc'][0:3]
153        self._powerup_center = self._map_type.defs.boxes['powerup_region'][0:3]
154        self._powerup_spread = (
155            self._map_type.defs.boxes['powerup_region'][6] * 0.5,
156            self._map_type.defs.boxes['powerup_region'][8] * 0.5,
157        )
158
159        self._score_region_material = ba.Material()
160        self._score_region_material.add_actions(
161            conditions=('they_have_material', shared.player_material),
162            actions=(
163                ('modify_part_collision', 'collide', True),
164                ('modify_part_collision', 'physical', False),
165                ('call', 'at_connect', self._handle_reached_end),
166            ),
167        )
168
169        self._last_wave_end_time = ba.time()
170        self._player_has_picked_up_powerup = False
171        self._scoreboard: Scoreboard | None = None
172        self._game_over = False
173        self._wavenum = 0
174        self._can_end_wave = True
175        self._score = 0
176        self._time_bonus = 0
177        self._score_region: ba.Actor | None = None
178        self._dingsound = ba.getsound('dingSmall')
179        self._dingsoundhigh = ba.getsound('dingSmallHigh')
180        self._exclude_powerups: list[str] | None = None
181        self._have_tnt: bool | None = None
182        self._waves: list[Wave] | None = None
183        self._bots = SpazBotSet()
184        self._tntspawner: TNTSpawner | None = None
185        self._lives_bg: ba.NodeActor | None = None
186        self._start_lives = 10
187        self._lives = self._start_lives
188        self._lives_text: ba.NodeActor | None = None
189        self._flawless = True
190        self._time_bonus_timer: ba.Timer | None = None
191        self._time_bonus_text: ba.NodeActor | None = None
192        self._time_bonus_mult: float | None = None
193        self._wave_text: ba.NodeActor | None = None
194        self._flawless_bonus: int | None = None
195        self._wave_update_timer: ba.Timer | None = None

Instantiate the Activity.

default_music = <MusicType.MARCHING: 'Marching'>
def on_transition_in(self) -> None:
197    def on_transition_in(self) -> None:
198        super().on_transition_in()
199        self._scoreboard = Scoreboard(
200            label=ba.Lstr(resource='scoreText'), score_split=0.5
201        )
202        self._score_region = ba.NodeActor(
203            ba.newnode(
204                'region',
205                attrs={
206                    'position': self.map.defs.boxes['score_region'][0:3],
207                    'scale': self.map.defs.boxes['score_region'][6:9],
208                    'type': 'box',
209                    'materials': [self._score_region_material],
210                },
211            )
212        )

Called when the Activity is first becoming visible.

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

def on_begin(self) -> None:
214    def on_begin(self) -> None:
215        super().on_begin()
216        player_count = len(self.players)
217        hard = self._preset not in {Preset.PRO_EASY, Preset.UBER_EASY}
218
219        if self._preset in {Preset.PRO, Preset.PRO_EASY, Preset.TOURNAMENT}:
220            self._exclude_powerups = ['curse']
221            self._have_tnt = True
222            self._waves = [
223                Wave(
224                    entries=[
225                        Spawn(BomberBot, path=3 if hard else 2),
226                        Spawn(BomberBot, path=2),
227                        Spawn(BomberBot, path=2) if hard else None,
228                        Spawn(BomberBot, path=2) if player_count > 1 else None,
229                        Spawn(BomberBot, path=1) if hard else None,
230                        Spawn(BomberBot, path=1) if player_count > 2 else None,
231                        Spawn(BomberBot, path=1) if player_count > 3 else None,
232                    ]
233                ),
234                Wave(
235                    entries=[
236                        Spawn(BomberBot, path=1) if hard else None,
237                        Spawn(BomberBot, path=2) if hard else None,
238                        Spawn(BomberBot, path=2),
239                        Spawn(BomberBot, path=2),
240                        Spawn(BomberBot, path=2) if player_count > 3 else None,
241                        Spawn(BrawlerBot, path=3),
242                        Spawn(BrawlerBot, path=3),
243                        Spawn(BrawlerBot, path=3) if hard else None,
244                        Spawn(BrawlerBot, path=3) if player_count > 1 else None,
245                        Spawn(BrawlerBot, path=3) if player_count > 2 else None,
246                    ]
247                ),
248                Wave(
249                    entries=[
250                        Spawn(ChargerBot, path=2) if hard else None,
251                        Spawn(ChargerBot, path=2) if player_count > 2 else None,
252                        Spawn(TriggerBot, path=2),
253                        Spawn(TriggerBot, path=2) if player_count > 1 else None,
254                        Spacing(duration=3.0),
255                        Spawn(BomberBot, path=2) if hard else None,
256                        Spawn(BomberBot, path=2) if hard else None,
257                        Spawn(BomberBot, path=2),
258                        Spawn(BomberBot, path=3) if hard else None,
259                        Spawn(BomberBot, path=3),
260                        Spawn(BomberBot, path=3),
261                        Spawn(BomberBot, path=3) if player_count > 3 else None,
262                    ]
263                ),
264                Wave(
265                    entries=[
266                        Spawn(TriggerBot, path=1) if hard else None,
267                        Spacing(duration=1.0) if hard else None,
268                        Spawn(TriggerBot, path=2),
269                        Spacing(duration=1.0),
270                        Spawn(TriggerBot, path=3),
271                        Spacing(duration=1.0),
272                        Spawn(TriggerBot, path=1) if hard else None,
273                        Spacing(duration=1.0) if hard else None,
274                        Spawn(TriggerBot, path=2),
275                        Spacing(duration=1.0),
276                        Spawn(TriggerBot, path=3),
277                        Spacing(duration=1.0),
278                        Spawn(TriggerBot, path=1)
279                        if (player_count > 1 and hard)
280                        else None,
281                        Spacing(duration=1.0),
282                        Spawn(TriggerBot, path=2) if player_count > 2 else None,
283                        Spacing(duration=1.0),
284                        Spawn(TriggerBot, path=3) if player_count > 3 else None,
285                        Spacing(duration=1.0),
286                    ]
287                ),
288                Wave(
289                    entries=[
290                        Spawn(
291                            ChargerBotProShielded if hard else ChargerBot,
292                            path=1,
293                        ),
294                        Spawn(BrawlerBot, path=2) if hard else None,
295                        Spawn(BrawlerBot, path=2),
296                        Spawn(BrawlerBot, path=2),
297                        Spawn(BrawlerBot, path=3) if hard else None,
298                        Spawn(BrawlerBot, path=3),
299                        Spawn(BrawlerBot, path=3),
300                        Spawn(BrawlerBot, path=3) if player_count > 1 else None,
301                        Spawn(BrawlerBot, path=3) if player_count > 2 else None,
302                        Spawn(BrawlerBot, path=3) if player_count > 3 else None,
303                    ]
304                ),
305                Wave(
306                    entries=[
307                        Spawn(BomberBotProShielded, path=3),
308                        Spacing(duration=1.5),
309                        Spawn(BomberBotProShielded, path=2),
310                        Spacing(duration=1.5),
311                        Spawn(BomberBotProShielded, path=1) if hard else None,
312                        Spacing(duration=1.0) if hard else None,
313                        Spawn(BomberBotProShielded, path=3),
314                        Spacing(duration=1.5),
315                        Spawn(BomberBotProShielded, path=2),
316                        Spacing(duration=1.5),
317                        Spawn(BomberBotProShielded, path=1) if hard else None,
318                        Spacing(duration=1.5) if hard else None,
319                        Spawn(BomberBotProShielded, path=3)
320                        if player_count > 1
321                        else None,
322                        Spacing(duration=1.5),
323                        Spawn(BomberBotProShielded, path=2)
324                        if player_count > 2
325                        else None,
326                        Spacing(duration=1.5),
327                        Spawn(BomberBotProShielded, path=1)
328                        if player_count > 3
329                        else None,
330                    ]
331                ),
332            ]
333        elif self._preset in {
334            Preset.UBER_EASY,
335            Preset.UBER,
336            Preset.TOURNAMENT_UBER,
337        }:
338            self._exclude_powerups = []
339            self._have_tnt = True
340            self._waves = [
341                Wave(
342                    entries=[
343                        Spawn(TriggerBot, path=1) if hard else None,
344                        Spawn(TriggerBot, path=2),
345                        Spawn(TriggerBot, path=2),
346                        Spawn(TriggerBot, path=3),
347                        Spawn(
348                            BrawlerBotPro if hard else BrawlerBot,
349                            point=Point.BOTTOM_LEFT,
350                        ),
351                        Spawn(BrawlerBotPro, point=Point.BOTTOM_RIGHT)
352                        if player_count > 2
353                        else None,
354                    ]
355                ),
356                Wave(
357                    entries=[
358                        Spawn(ChargerBot, path=2),
359                        Spawn(ChargerBot, path=3),
360                        Spawn(ChargerBot, path=1) if hard else None,
361                        Spawn(ChargerBot, path=2),
362                        Spawn(ChargerBot, path=3),
363                        Spawn(ChargerBot, path=1) if player_count > 2 else None,
364                    ]
365                ),
366                Wave(
367                    entries=[
368                        Spawn(BomberBotProShielded, path=1) if hard else None,
369                        Spawn(BomberBotProShielded, path=2),
370                        Spawn(BomberBotProShielded, path=2),
371                        Spawn(BomberBotProShielded, path=3),
372                        Spawn(BomberBotProShielded, path=3),
373                        Spawn(ChargerBot, point=Point.BOTTOM_RIGHT),
374                        Spawn(ChargerBot, point=Point.BOTTOM_LEFT)
375                        if player_count > 2
376                        else None,
377                    ]
378                ),
379                Wave(
380                    entries=[
381                        Spawn(TriggerBotPro, path=1) if hard else None,
382                        Spawn(TriggerBotPro, path=1 if hard else 2),
383                        Spawn(TriggerBotPro, path=1 if hard else 2),
384                        Spawn(TriggerBotPro, path=1 if hard else 2),
385                        Spawn(TriggerBotPro, path=1 if hard else 2),
386                        Spawn(TriggerBotPro, path=1 if hard else 2),
387                        Spawn(TriggerBotPro, path=1 if hard else 2)
388                        if player_count > 1
389                        else None,
390                        Spawn(TriggerBotPro, path=1 if hard else 2)
391                        if player_count > 3
392                        else None,
393                    ]
394                ),
395                Wave(
396                    entries=[
397                        Spawn(
398                            TriggerBotProShielded if hard else TriggerBotPro,
399                            point=Point.BOTTOM_LEFT,
400                        ),
401                        Spawn(TriggerBotProShielded, point=Point.BOTTOM_RIGHT)
402                        if hard
403                        else None,
404                        Spawn(TriggerBotProShielded, point=Point.BOTTOM_RIGHT)
405                        if player_count > 2
406                        else None,
407                        Spawn(BomberBot, path=3),
408                        Spawn(BomberBot, path=3),
409                        Spacing(duration=5.0),
410                        Spawn(BrawlerBot, path=2),
411                        Spawn(BrawlerBot, path=2),
412                        Spacing(duration=5.0),
413                        Spawn(TriggerBot, path=1) if hard else None,
414                        Spawn(TriggerBot, path=1) if hard else None,
415                    ]
416                ),
417                Wave(
418                    entries=[
419                        Spawn(BomberBotProShielded, path=2),
420                        Spawn(BomberBotProShielded, path=2) if hard else None,
421                        Spawn(StickyBot, point=Point.BOTTOM_RIGHT),
422                        Spawn(BomberBotProShielded, path=2),
423                        Spawn(BomberBotProShielded, path=2),
424                        Spawn(StickyBot, point=Point.BOTTOM_RIGHT)
425                        if player_count > 2
426                        else None,
427                        Spawn(BomberBotProShielded, path=2),
428                        Spawn(ExplodeyBot, point=Point.BOTTOM_LEFT),
429                        Spawn(BomberBotProShielded, path=2),
430                        Spawn(BomberBotProShielded, path=2)
431                        if player_count > 1
432                        else None,
433                        Spacing(duration=5.0),
434                        Spawn(StickyBot, point=Point.BOTTOM_LEFT),
435                        Spacing(duration=2.0),
436                        Spawn(ExplodeyBot, point=Point.BOTTOM_RIGHT),
437                    ]
438                ),
439            ]
440        elif self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
441            self._exclude_powerups = []
442            self._have_tnt = True
443
444        # Spit out a few powerups and start dropping more shortly.
445        self._drop_powerups(standard_points=True)
446        ba.timer(4.0, self._start_powerup_drops)
447        self.setup_low_life_warning_sound()
448        self._update_scores()
449
450        # Our TNT spawner (if applicable).
451        if self._have_tnt:
452            self._tntspawner = TNTSpawner(position=self._tntspawnpos)
453
454        # Make sure to stay out of the way of menu/party buttons in the corner.
455        uiscale = ba.app.ui.uiscale
456        l_offs = (
457            -80
458            if uiscale is ba.UIScale.SMALL
459            else -40
460            if uiscale is ba.UIScale.MEDIUM
461            else 0
462        )
463
464        self._lives_bg = ba.NodeActor(
465            ba.newnode(
466                'image',
467                attrs={
468                    'texture': self._heart_tex,
469                    'model_opaque': self._heart_model_opaque,
470                    'model_transparent': self._heart_model_transparent,
471                    'attach': 'topRight',
472                    'scale': (90, 90),
473                    'position': (-110 + l_offs, -50),
474                    'color': (1, 0.2, 0.2),
475                },
476            )
477        )
478        # FIXME; should not set things based on vr mode.
479        #  (won't look right to non-vr connected clients, etc)
480        vrmode = ba.app.vr_mode
481        self._lives_text = ba.NodeActor(
482            ba.newnode(
483                'text',
484                attrs={
485                    'v_attach': 'top',
486                    'h_attach': 'right',
487                    'h_align': 'center',
488                    'color': (1, 1, 1, 1) if vrmode else (0.8, 0.8, 0.8, 1.0),
489                    'flatness': 1.0 if vrmode else 0.5,
490                    'shadow': 1.0 if vrmode else 0.5,
491                    'vr_depth': 10,
492                    'position': (-113 + l_offs, -69),
493                    'scale': 1.3,
494                    'text': str(self._lives),
495                },
496            )
497        )
498
499        ba.timer(2.0, self._start_updating_waves)

Called once the previous ba.Activity has finished transitioning out.

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

def on_continue(self) -> None:
573    def on_continue(self) -> None:
574        self._lives = 3
575        assert self._lives_text is not None
576        assert self._lives_text.node
577        self._lives_text.node.text = str(self._lives)
578        self._bots.start_moving()

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

def spawn_player(self, player: bastd.game.runaround.Player) -> ba._actor.Actor:
580    def spawn_player(self, player: Player) -> ba.Actor:
581        pos = (
582            self._spawn_center[0] + random.uniform(-1.5, 1.5),
583            self._spawn_center[1],
584            self._spawn_center[2] + random.uniform(-1.5, 1.5),
585        )
586        spaz = self.spawn_player_spaz(player, position=pos)
587        if self._preset in {Preset.PRO_EASY, Preset.UBER_EASY}:
588            spaz.impact_scale = 0.25
589
590        # Add the material that causes us to hit the player-wall.
591        spaz.pick_up_powerup_callback = self._on_player_picked_up_powerup
592        return spaz

Spawn something for the provided ba.Player.

The default implementation simply calls spawn_player_spaz().

def end_game(self) -> None:
656    def end_game(self) -> None:
657        ba.pushcall(ba.Call(self.do_end, 'defeat'))
658        ba.setmusic(None)
659        ba.playsound(self._player_death_sound)

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

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

def do_end(self, outcome: str) -> None:
661    def do_end(self, outcome: str) -> None:
662        """End the game now with the provided outcome."""
663
664        if outcome == 'defeat':
665            delay = 2.0
666            self.fade_to_red()
667        else:
668            delay = 0
669
670        score: int | None
671        if self._wavenum >= 2:
672            score = self._score
673            fail_message = None
674        else:
675            score = None
676            fail_message = ba.Lstr(resource='reachWave2Text')
677
678        self.end(
679            delay=delay,
680            results={
681                'outcome': outcome,
682                'score': score,
683                'fail_message': fail_message,
684                'playerinfos': self.initialplayerinfos,
685            },
686        )

End the game now with the provided outcome.

def add_bot_at_point( self, point: bastd.game.runaround.Point, spaztype: type[bastd.actor.spazbot.SpazBot], path: int, spawn_time: float = 0.1) -> None:
1165    def add_bot_at_point(
1166        self,
1167        point: Point,
1168        spaztype: type[SpazBot],
1169        path: int,
1170        spawn_time: float = 0.1,
1171    ) -> None:
1172        """Add the given type bot with the given delay (in seconds)."""
1173
1174        # Don't add if the game has ended.
1175        if self._game_over:
1176            return
1177        pos = self.map.defs.points[point.value][:3]
1178        self._bots.spawn_bot(
1179            spaztype,
1180            pos=pos,
1181            spawn_time=spawn_time,
1182            on_spawn_call=ba.Call(self._on_bot_spawn, path),
1183        )

Add the given type bot with the given delay (in seconds).

def handlemessage(self, msg: Any) -> Any:
1292    def handlemessage(self, msg: Any) -> Any:
1293        if isinstance(msg, ba.PlayerScoredMessage):
1294            self._score += msg.score
1295            self._update_scores()
1296
1297        elif isinstance(msg, ba.PlayerDiedMessage):
1298            # Augment standard behavior.
1299            super().handlemessage(msg)
1300
1301            self._a_player_has_been_killed = True
1302
1303            # Respawn them shortly.
1304            player = msg.getplayer(Player)
1305            assert self.initialplayerinfos is not None
1306            respawn_time = 2.0 + len(self.initialplayerinfos) * 1.0
1307            player.respawn_timer = ba.Timer(
1308                respawn_time, ba.Call(self.spawn_player_if_exists, player)
1309            )
1310            player.respawn_icon = RespawnIcon(player, respawn_time)
1311
1312        elif isinstance(msg, SpazBotDiedMessage):
1313            if msg.how is ba.DeathType.REACHED_GOAL:
1314                return None
1315            pts, importance = msg.spazbot.get_death_points(msg.how)
1316            if msg.killerplayer is not None:
1317                target: Sequence[float] | None
1318                try:
1319                    assert msg.spazbot is not None
1320                    assert msg.spazbot.node
1321                    target = msg.spazbot.node.position
1322                except Exception:
1323                    ba.print_exception()
1324                    target = None
1325                try:
1326                    if msg.killerplayer:
1327                        self.stats.player_scored(
1328                            msg.killerplayer,
1329                            pts,
1330                            target=target,
1331                            kill=True,
1332                            screenmessage=False,
1333                            importance=importance,
1334                        )
1335                        ba.playsound(
1336                            self._dingsound
1337                            if importance == 1
1338                            else self._dingsoundhigh,
1339                            volume=0.6,
1340                        )
1341                except Exception:
1342                    ba.print_exception('Error on SpazBotDiedMessage.')
1343
1344            # Normally we pull scores from the score-set, but if there's no
1345            # player lets be explicit.
1346            else:
1347                self._score += pts
1348            self._update_scores()
1349
1350        else:
1351            return super().handlemessage(msg)
1352        return None

General message handling; can be passed any message object.

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