bascenev1lib.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 8
   9# (see https://ballistica.net/wiki/meta-tag-system)
  10
  11from __future__ import annotations
  12
  13import random
  14import logging
  15from enum import Enum
  16from dataclasses import dataclass
  17from typing import TYPE_CHECKING
  18
  19from bascenev1lib.actor.popuptext import PopupText
  20from bascenev1lib.actor.bomb import TNTSpawner
  21from bascenev1lib.actor.scoreboard import Scoreboard
  22from bascenev1lib.actor.respawnicon import RespawnIcon
  23from bascenev1lib.actor.powerupbox import PowerupBox, PowerupBoxFactory
  24from bascenev1lib.gameutils import SharedObjects
  25from bascenev1lib.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)
  43import bascenev1 as bs
  44
  45if TYPE_CHECKING:
  46    from typing import Any, Sequence
  47
  48
  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'
  60
  61
  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'
  68
  69
  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
  78
  79
  80@dataclass
  81class Spacing:
  82    """Defines spacing between spawns."""
  83
  84    duration: float
  85
  86
  87@dataclass
  88class Wave:
  89    """Defines a wave of enemies."""
  90
  91    entries: list[Spawn | Spacing | None]
  92
  93
  94class Player(bs.Player['Team']):
  95    """Our player type for this game."""
  96
  97    def __init__(self) -> None:
  98        self.respawn_timer: bs.Timer | None = None
  99        self.respawn_icon: RespawnIcon | None = None
 100
 101
 102class Team(bs.Team[Player]):
 103    """Our team type for this game."""
 104
 105
 106class RunaroundGame(bs.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 = bs.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 = bs.getsound('playerDeath')
 142        self._new_wave_sound = bs.getsound('scoreHit01')
 143        self._winsound = bs.getsound('score')
 144        self._cashregistersound = bs.getsound('cashRegister')
 145        self._bad_guy_score_sound = bs.getsound('shieldDown')
 146        self._heart_tex = bs.gettexture('heart')
 147        self._heart_mesh_opaque = bs.getmesh('heartOpaque')
 148        self._heart_mesh_transparent = bs.getmesh('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 = bs.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 = bs.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: bs.Actor | None = None
 178        self._dingsound = bs.getsound('dingSmall')
 179        self._dingsoundhigh = bs.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: bs.NodeActor | None = None
 186        self._start_lives = 10
 187        self._lives = self._start_lives
 188        self._lives_text: bs.NodeActor | None = None
 189        self._flawless = True
 190        self._time_bonus_timer: bs.Timer | None = None
 191        self._time_bonus_text: bs.NodeActor | None = None
 192        self._time_bonus_mult: float | None = None
 193        self._wave_text: bs.NodeActor | None = None
 194        self._flawless_bonus: int | None = None
 195        self._wave_update_timer: bs.Timer | None = None
 196
 197    def on_transition_in(self) -> None:
 198        super().on_transition_in()
 199        self._scoreboard = Scoreboard(
 200            label=bs.Lstr(resource='scoreText'), score_split=0.5
 201        )
 202        self._score_region = bs.NodeActor(
 203            bs.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        bs.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        assert bs.app.classic is not None
 456        uiscale = bs.app.ui_v1.uiscale
 457        l_offs = (
 458            -80
 459            if uiscale is bs.UIScale.SMALL
 460            else -40
 461            if uiscale is bs.UIScale.MEDIUM
 462            else 0
 463        )
 464
 465        self._lives_bg = bs.NodeActor(
 466            bs.newnode(
 467                'image',
 468                attrs={
 469                    'texture': self._heart_tex,
 470                    'mesh_opaque': self._heart_mesh_opaque,
 471                    'mesh_transparent': self._heart_mesh_transparent,
 472                    'attach': 'topRight',
 473                    'scale': (90, 90),
 474                    'position': (-110 + l_offs, -50),
 475                    'color': (1, 0.2, 0.2),
 476                },
 477            )
 478        )
 479        # FIXME; should not set things based on vr mode.
 480        #  (won't look right to non-vr connected clients, etc)
 481        vrmode = bs.app.env.vr
 482        self._lives_text = bs.NodeActor(
 483            bs.newnode(
 484                'text',
 485                attrs={
 486                    'v_attach': 'top',
 487                    'h_attach': 'right',
 488                    'h_align': 'center',
 489                    'color': (1, 1, 1, 1) if vrmode else (0.8, 0.8, 0.8, 1.0),
 490                    'flatness': 1.0 if vrmode else 0.5,
 491                    'shadow': 1.0 if vrmode else 0.5,
 492                    'vr_depth': 10,
 493                    'position': (-113 + l_offs, -69),
 494                    'scale': 1.3,
 495                    'text': str(self._lives),
 496                },
 497            )
 498        )
 499
 500        bs.timer(2.0, self._start_updating_waves)
 501
 502    def _handle_reached_end(self) -> None:
 503        spaz = bs.getcollision().opposingnode.getdelegate(SpazBot, True)
 504        if not spaz.is_alive():
 505            return  # Ignore bodies flying in.
 506
 507        self._flawless = False
 508        pos = spaz.node.position
 509        self._bad_guy_score_sound.play(position=pos)
 510        light = bs.newnode(
 511            'light', attrs={'position': pos, 'radius': 0.5, 'color': (1, 0, 0)}
 512        )
 513        bs.animate(light, 'intensity', {0.0: 0, 0.1: 1, 0.5: 0}, loop=False)
 514        bs.timer(1.0, light.delete)
 515        spaz.handlemessage(
 516            bs.DieMessage(immediate=True, how=bs.DeathType.REACHED_GOAL)
 517        )
 518
 519        if self._lives > 0:
 520            self._lives -= 1
 521            if self._lives == 0:
 522                self._bots.stop_moving()
 523                self.continue_or_end_game()
 524            assert self._lives_text is not None
 525            assert self._lives_text.node
 526            self._lives_text.node.text = str(self._lives)
 527            delay = 0.0
 528
 529            def _safesetattr(node: bs.Node, attr: str, value: Any) -> None:
 530                if node:
 531                    setattr(node, attr, value)
 532
 533            for _i in range(4):
 534                bs.timer(
 535                    delay,
 536                    bs.Call(
 537                        _safesetattr,
 538                        self._lives_text.node,
 539                        'color',
 540                        (1, 0, 0, 1.0),
 541                    ),
 542                )
 543                assert self._lives_bg is not None
 544                assert self._lives_bg.node
 545                bs.timer(
 546                    delay,
 547                    bs.Call(_safesetattr, self._lives_bg.node, 'opacity', 0.5),
 548                )
 549                delay += 0.125
 550                bs.timer(
 551                    delay,
 552                    bs.Call(
 553                        _safesetattr,
 554                        self._lives_text.node,
 555                        'color',
 556                        (1.0, 1.0, 0.0, 1.0),
 557                    ),
 558                )
 559                bs.timer(
 560                    delay,
 561                    bs.Call(_safesetattr, self._lives_bg.node, 'opacity', 1.0),
 562                )
 563                delay += 0.125
 564            bs.timer(
 565                delay,
 566                bs.Call(
 567                    _safesetattr,
 568                    self._lives_text.node,
 569                    'color',
 570                    (0.8, 0.8, 0.8, 1.0),
 571                ),
 572            )
 573
 574    def on_continue(self) -> None:
 575        self._lives = 3
 576        assert self._lives_text is not None
 577        assert self._lives_text.node
 578        self._lives_text.node.text = str(self._lives)
 579        self._bots.start_moving()
 580
 581    def spawn_player(self, player: Player) -> bs.Actor:
 582        pos = (
 583            self._spawn_center[0] + random.uniform(-1.5, 1.5),
 584            self._spawn_center[1],
 585            self._spawn_center[2] + random.uniform(-1.5, 1.5),
 586        )
 587        spaz = self.spawn_player_spaz(player, position=pos)
 588        if self._preset in {Preset.PRO_EASY, Preset.UBER_EASY}:
 589            spaz.impact_scale = 0.25
 590
 591        # Add the material that causes us to hit the player-wall.
 592        spaz.pick_up_powerup_callback = self._on_player_picked_up_powerup
 593        return spaz
 594
 595    def _on_player_picked_up_powerup(self, player: bs.Actor) -> None:
 596        del player  # Unused.
 597        self._player_has_picked_up_powerup = True
 598
 599    def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None:
 600        if poweruptype is None:
 601            poweruptype = PowerupBoxFactory.get().get_random_powerup_type(
 602                excludetypes=self._exclude_powerups
 603            )
 604        PowerupBox(
 605            position=self.map.powerup_spawn_points[index],
 606            poweruptype=poweruptype,
 607        ).autoretain()
 608
 609    def _start_powerup_drops(self) -> None:
 610        bs.timer(3.0, self._drop_powerups, repeat=True)
 611
 612    def _drop_powerups(
 613        self, standard_points: bool = False, force_first: str | None = None
 614    ) -> None:
 615        """Generic powerup drop."""
 616
 617        # If its been a minute since our last wave finished emerging, stop
 618        # giving out land-mine powerups. (prevents players from waiting
 619        # around for them on purpose and filling the map up)
 620        if bs.time() - self._last_wave_end_time > 60.0:
 621            extra_excludes = ['land_mines']
 622        else:
 623            extra_excludes = []
 624
 625        if standard_points:
 626            points = self.map.powerup_spawn_points
 627            for i in range(len(points)):
 628                bs.timer(
 629                    1.0 + i * 0.5,
 630                    bs.Call(
 631                        self._drop_powerup, i, force_first if i == 0 else None
 632                    ),
 633                )
 634        else:
 635            pos = (
 636                self._powerup_center[0]
 637                + random.uniform(
 638                    -1.0 * self._powerup_spread[0],
 639                    1.0 * self._powerup_spread[0],
 640                ),
 641                self._powerup_center[1],
 642                self._powerup_center[2]
 643                + random.uniform(
 644                    -self._powerup_spread[1], self._powerup_spread[1]
 645                ),
 646            )
 647
 648            # drop one random one somewhere..
 649            assert self._exclude_powerups is not None
 650            PowerupBox(
 651                position=pos,
 652                poweruptype=PowerupBoxFactory.get().get_random_powerup_type(
 653                    excludetypes=self._exclude_powerups + extra_excludes
 654                ),
 655            ).autoretain()
 656
 657    def end_game(self) -> None:
 658        bs.pushcall(bs.Call(self.do_end, 'defeat'))
 659        bs.setmusic(None)
 660        self._player_death_sound.play()
 661
 662    def do_end(self, outcome: str) -> None:
 663        """End the game now with the provided outcome."""
 664
 665        if outcome == 'defeat':
 666            delay = 2.0
 667            self.fade_to_red()
 668        else:
 669            delay = 0
 670
 671        score: int | None
 672        if self._wavenum >= 2:
 673            score = self._score
 674            fail_message = None
 675        else:
 676            score = None
 677            fail_message = bs.Lstr(resource='reachWave2Text')
 678
 679        self.end(
 680            delay=delay,
 681            results={
 682                'outcome': outcome,
 683                'score': score,
 684                'fail_message': fail_message,
 685                'playerinfos': self.initialplayerinfos,
 686            },
 687        )
 688
 689    def _update_waves(self) -> None:
 690        # pylint: disable=too-many-branches
 691
 692        # If we have no living bots, go to the next wave.
 693        if (
 694            self._can_end_wave
 695            and not self._bots.have_living_bots()
 696            and not self._game_over
 697            and self._lives > 0
 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                bs.timer(0, self._cashregistersound.play)
 713                bs.timer(
 714                    base_delay,
 715                    bs.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                bs.timer(base_delay, self._award_flawless_bonus)
 722                base_delay += 1.0
 723
 724            self._flawless = True  # reset
 725
 726            if won:
 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                    bs.Lstr(resource='victoryText'), scale=1.0, duration=4.0
 750                )
 751
 752                self.celebrate(10.0)
 753                bs.timer(base_delay, self._award_lives_bonus)
 754                base_delay += 1.0
 755                bs.timer(base_delay, self._award_completion_bonus)
 756                base_delay += 0.85
 757                self._winsound.play()
 758                bs.cameraflash()
 759                bs.setmusic(bs.MusicType.VICTORY)
 760                self._game_over = True
 761                bs.timer(base_delay, bs.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            bs.timer(base_delay, self._start_next_wave)
 771
 772    def _award_completion_bonus(self) -> None:
 773        bonus = 200
 774        self._cashregistersound.play()
 775        PopupText(
 776            bs.Lstr(
 777                value='+${A} ${B}',
 778                subs=[
 779                    ('${A}', str(bonus)),
 780                    ('${B}', bs.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        self._cashregistersound.play()
 793        PopupText(
 794            bs.Lstr(
 795                value='+${A} ${B}',
 796                subs=[
 797                    ('${A}', str(bonus)),
 798                    ('${B}', bs.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        self._cashregistersound.play()
 810        PopupText(
 811            bs.Lstr(
 812                value='+${A} ${B}',
 813                subs=[
 814                    ('${A}', str(bonus)),
 815                    ('${B}', bs.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        self._cashregistersound.play()
 828        PopupText(
 829            bs.Lstr(
 830                value='+${A} ${B}',
 831                subs=[
 832                    ('${A}', str(self._flawless_bonus)),
 833                    ('${B}', bs.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 = bs.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            bs.Lstr(
 857                value='${A} ${B}',
 858                subs=[
 859                    ('${A}', bs.Lstr(resource='waveText')),
 860                    ('${B}', str(self._wavenum)),
 861                ],
 862            ),
 863            scale=1.0,
 864            duration=1.0,
 865            trail=True,
 866        )
 867        bs.timer(0.4, self._new_wave_sound.play)
 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 = bs.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            bs.timer(t_sec, tcall)
1071            t_sec += delay * 0.5
1072
1073        # We can end the wave after all the spawning happens.
1074        bs.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 = bs.Lstr(
1088            value='${A}: ${B}',
1089            subs=[
1090                ('${A}', bs.Lstr(resource='timeBonusText')),
1091                ('${B}', str(int(self._time_bonus * self._time_bonus_mult))),
1092            ],
1093        )
1094        self._time_bonus_text = bs.NodeActor(
1095            bs.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        bs.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 = bs.Time(bs.time() + t_sec)
1118        totalwaves = str(len(self._waves)) if self._waves is not None else '??'
1119        txtval = bs.Lstr(
1120            value='${A} ${B}',
1121            subs=[
1122                ('${A}', bs.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 = bs.NodeActor(
1136            bs.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        # Add our custom update callback and set some info for this bot.
1155        spaz_type = type(spaz)
1156        assert spaz is not None
1157        spaz.update_callback = self._update_bot
1158
1159        # Tack some custom attrs onto the spaz.
1160        setattr(spaz, 'r_walk_row', path)
1161        setattr(spaz, 'r_walk_speed', self._get_bot_speed(spaz_type))
1162
1163    def add_bot_at_point(
1164        self,
1165        point: Point,
1166        spaztype: type[SpazBot],
1167        path: int,
1168        spawn_time: float = 0.1,
1169    ) -> None:
1170        """Add the given type bot with the given delay (in seconds)."""
1171
1172        # Don't add if the game has ended.
1173        if self._game_over:
1174            return
1175        pos = self.map.defs.points[point.value][:3]
1176        self._bots.spawn_bot(
1177            spaztype,
1178            pos=pos,
1179            spawn_time=spawn_time,
1180            on_spawn_call=bs.Call(self._on_bot_spawn, path),
1181        )
1182
1183    def _update_time_bonus(self) -> None:
1184        self._time_bonus = int(self._time_bonus * 0.91)
1185        if self._time_bonus > 0 and self._time_bonus_text is not None:
1186            assert self._time_bonus_text.node
1187            assert self._time_bonus_mult
1188            self._time_bonus_text.node.text = bs.Lstr(
1189                value='${A}: ${B}',
1190                subs=[
1191                    ('${A}', bs.Lstr(resource='timeBonusText')),
1192                    (
1193                        '${B}',
1194                        str(int(self._time_bonus * self._time_bonus_mult)),
1195                    ),
1196                ],
1197            )
1198        else:
1199            self._time_bonus_text = None
1200
1201    def _start_updating_waves(self) -> None:
1202        self._wave_update_timer = bs.Timer(2.0, self._update_waves, repeat=True)
1203
1204    def _update_scores(self) -> None:
1205        score = self._score
1206        if self._preset is Preset.ENDLESS:
1207            if score >= 500:
1208                self._award_achievement('Runaround Master')
1209            if score >= 1000:
1210                self._award_achievement('Runaround Wizard')
1211            if score >= 2000:
1212                self._award_achievement('Runaround God')
1213
1214        assert self._scoreboard is not None
1215        self._scoreboard.set_team_value(self.teams[0], score, max_score=None)
1216
1217    def _update_bot(self, bot: SpazBot) -> bool:
1218        # Yup; that's a lot of return statements right there.
1219        # pylint: disable=too-many-return-statements
1220
1221        if not bool(bot):
1222            return True
1223
1224        assert bot.node
1225
1226        # FIXME: Do this in a type safe way.
1227        r_walk_speed: float = getattr(bot, 'r_walk_speed')
1228        r_walk_row: int = getattr(bot, 'r_walk_row')
1229
1230        speed = r_walk_speed
1231        pos = bot.node.position
1232        boxes = self.map.defs.boxes
1233
1234        # Bots in row 1 attempt the high road..
1235        if r_walk_row == 1:
1236            if bs.is_point_in_box(pos, boxes['b4']):
1237                bot.node.move_up_down = speed
1238                bot.node.move_left_right = 0
1239                bot.node.run = 0.0
1240                return True
1241
1242        # Row 1 and 2 bots attempt the middle road..
1243        if r_walk_row in [1, 2]:
1244            if bs.is_point_in_box(pos, boxes['b1']):
1245                bot.node.move_up_down = speed
1246                bot.node.move_left_right = 0
1247                bot.node.run = 0.0
1248                return True
1249
1250        # All bots settle for the third row.
1251        if bs.is_point_in_box(pos, boxes['b7']):
1252            bot.node.move_up_down = speed
1253            bot.node.move_left_right = 0
1254            bot.node.run = 0.0
1255            return True
1256        if bs.is_point_in_box(pos, boxes['b2']):
1257            bot.node.move_up_down = -speed
1258            bot.node.move_left_right = 0
1259            bot.node.run = 0.0
1260            return True
1261        if bs.is_point_in_box(pos, boxes['b3']):
1262            bot.node.move_up_down = -speed
1263            bot.node.move_left_right = 0
1264            bot.node.run = 0.0
1265            return True
1266        if bs.is_point_in_box(pos, boxes['b5']):
1267            bot.node.move_up_down = -speed
1268            bot.node.move_left_right = 0
1269            bot.node.run = 0.0
1270            return True
1271        if bs.is_point_in_box(pos, boxes['b6']):
1272            bot.node.move_up_down = speed
1273            bot.node.move_left_right = 0
1274            bot.node.run = 0.0
1275            return True
1276        if (
1277            bs.is_point_in_box(pos, boxes['b8'])
1278            and not bs.is_point_in_box(pos, boxes['b9'])
1279        ) or pos == (0.0, 0.0, 0.0):
1280            # Default to walking right if we're still in the walking area.
1281            bot.node.move_left_right = speed
1282            bot.node.move_up_down = 0
1283            bot.node.run = 0.0
1284            return True
1285
1286        # Revert to normal bot behavior otherwise..
1287        return False
1288
1289    def handlemessage(self, msg: Any) -> Any:
1290        if isinstance(msg, bs.PlayerScoredMessage):
1291            self._score += msg.score
1292            self._update_scores()
1293
1294        elif isinstance(msg, bs.PlayerDiedMessage):
1295            # Augment standard behavior.
1296            super().handlemessage(msg)
1297
1298            self._a_player_has_been_killed = True
1299
1300            # Respawn them shortly.
1301            player = msg.getplayer(Player)
1302            assert self.initialplayerinfos is not None
1303            respawn_time = 2.0 + len(self.initialplayerinfos) * 1.0
1304            player.respawn_timer = bs.Timer(
1305                respawn_time, bs.Call(self.spawn_player_if_exists, player)
1306            )
1307            player.respawn_icon = RespawnIcon(player, respawn_time)
1308
1309        elif isinstance(msg, SpazBotDiedMessage):
1310            if msg.how is bs.DeathType.REACHED_GOAL:
1311                return None
1312            pts, importance = msg.spazbot.get_death_points(msg.how)
1313            if msg.killerplayer is not None:
1314                target: Sequence[float] | None
1315                try:
1316                    assert msg.spazbot is not None
1317                    assert msg.spazbot.node
1318                    target = msg.spazbot.node.position
1319                except Exception:
1320                    logging.exception('Error getting SpazBotDied target.')
1321                    target = None
1322                try:
1323                    if msg.killerplayer:
1324                        self.stats.player_scored(
1325                            msg.killerplayer,
1326                            pts,
1327                            target=target,
1328                            kill=True,
1329                            screenmessage=False,
1330                            importance=importance,
1331                        )
1332                        dingsound = (
1333                            self._dingsound
1334                            if importance == 1
1335                            else self._dingsoundhigh
1336                        )
1337                        dingsound.play(volume=0.6)
1338                except Exception:
1339                    logging.exception('Error on SpazBotDiedMessage.')
1340
1341            # Normally we pull scores from the score-set, but if there's no
1342            # player lets be explicit.
1343            else:
1344                self._score += pts
1345            self._update_scores()
1346
1347        else:
1348            return super().handlemessage(msg)
1349        return None
1350
1351    def _get_bot_speed(self, bot_type: type[SpazBot]) -> float:
1352        speed = self._bot_speed_map.get(bot_type)
1353        if speed is None:
1354            raise TypeError(
1355                'Invalid bot type to _get_bot_speed(): ' + str(bot_type)
1356            )
1357        return speed
1358
1359    def _set_can_end_wave(self) -> None:
1360        self._can_end_wave = True
class Preset(enum.Enum):
50class Preset(Enum):
51    """Play presets."""
52
53    ENDLESS = 'endless'
54    ENDLESS_TOURNAMENT = 'endless_tournament'
55    PRO = 'pro'
56    PRO_EASY = 'pro_easy'
57    UBER = 'uber'
58    UBER_EASY = 'uber_easy'
59    TOURNAMENT = 'tournament'
60    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):
63class Point(Enum):
64    """Where we can spawn stuff and the corresponding map attr name."""
65
66    BOTTOM_LEFT = 'bot_spawn_bottom_left'
67    BOTTOM_RIGHT = 'bot_spawn_bottom_right'
68    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:
71@dataclass
72class Spawn:
73    """Defines a bot spawn event."""
74
75    # noinspection PyUnresolvedReferences
76    type: type[SpazBot]
77    path: int = 0
78    point: Point | None = None

Defines a bot spawn event.

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

Defines spacing between spawns.

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

Defines a wave of enemies.

Wave( entries: list[Spawn | Spacing | None])
entries: list[Spawn | Spacing | None]
class Player(bascenev1._player.Player[ForwardRef('Team')]):
 95class Player(bs.Player['Team']):
 96    """Our player type for this game."""
 97
 98    def __init__(self) -> None:
 99        self.respawn_timer: bs.Timer | None = None
100        self.respawn_icon: RespawnIcon | None = None

Our player type for this game.

respawn_timer: _bascenev1.Timer | None
Inherited Members
bascenev1._player.Player
character
actor
color
highlight
on_expire
team
customdata
sessionplayer
node
position
exists
getname
is_alive
get_icon
assigninput
resetinput
class Team(bascenev1._team.Team[bascenev1lib.game.runaround.Player]):
103class Team(bs.Team[Player]):
104    """Our team type for this game."""

Our team type for this game.

Inherited Members
bascenev1._team.Team
players
id
name
color
manual_init
customdata
on_expire
sessionteam
class RunaroundGame(bascenev1._coopgame.CoopGameActivity[bascenev1lib.game.runaround.Player, bascenev1lib.game.runaround.Team]):
 107class RunaroundGame(bs.CoopGameActivity[Player, Team]):
 108    """Game involving trying to bomb bots as they walk through the map."""
 109
 110    name = 'Runaround'
 111    description = 'Prevent enemies from reaching the exit.'
 112    tips = [
 113        'Jump just as you\'re throwing to get bombs up to the highest levels.',
 114        'No, you can\'t get up on the ledge. You have to throw bombs.',
 115        'Whip back and forth to get more distance on your throws..',
 116    ]
 117    default_music = bs.MusicType.MARCHING
 118
 119    # How fast our various bot types walk.
 120    _bot_speed_map: dict[type[SpazBot], float] = {
 121        BomberBot: 0.48,
 122        BomberBotPro: 0.48,
 123        BomberBotProShielded: 0.48,
 124        BrawlerBot: 0.57,
 125        BrawlerBotPro: 0.57,
 126        BrawlerBotProShielded: 0.57,
 127        TriggerBot: 0.73,
 128        TriggerBotPro: 0.78,
 129        TriggerBotProShielded: 0.78,
 130        ChargerBot: 1.0,
 131        ChargerBotProShielded: 1.0,
 132        ExplodeyBot: 1.0,
 133        StickyBot: 0.5,
 134    }
 135
 136    def __init__(self, settings: dict):
 137        settings['map'] = 'Tower D'
 138        super().__init__(settings)
 139        shared = SharedObjects.get()
 140        self._preset = Preset(settings.get('preset', 'pro'))
 141
 142        self._player_death_sound = bs.getsound('playerDeath')
 143        self._new_wave_sound = bs.getsound('scoreHit01')
 144        self._winsound = bs.getsound('score')
 145        self._cashregistersound = bs.getsound('cashRegister')
 146        self._bad_guy_score_sound = bs.getsound('shieldDown')
 147        self._heart_tex = bs.gettexture('heart')
 148        self._heart_mesh_opaque = bs.getmesh('heartOpaque')
 149        self._heart_mesh_transparent = bs.getmesh('heartTransparent')
 150
 151        self._a_player_has_been_killed = False
 152        self._spawn_center = self._map_type.defs.points['spawn1'][0:3]
 153        self._tntspawnpos = self._map_type.defs.points['tnt_loc'][0:3]
 154        self._powerup_center = self._map_type.defs.boxes['powerup_region'][0:3]
 155        self._powerup_spread = (
 156            self._map_type.defs.boxes['powerup_region'][6] * 0.5,
 157            self._map_type.defs.boxes['powerup_region'][8] * 0.5,
 158        )
 159
 160        self._score_region_material = bs.Material()
 161        self._score_region_material.add_actions(
 162            conditions=('they_have_material', shared.player_material),
 163            actions=(
 164                ('modify_part_collision', 'collide', True),
 165                ('modify_part_collision', 'physical', False),
 166                ('call', 'at_connect', self._handle_reached_end),
 167            ),
 168        )
 169
 170        self._last_wave_end_time = bs.time()
 171        self._player_has_picked_up_powerup = False
 172        self._scoreboard: Scoreboard | None = None
 173        self._game_over = False
 174        self._wavenum = 0
 175        self._can_end_wave = True
 176        self._score = 0
 177        self._time_bonus = 0
 178        self._score_region: bs.Actor | None = None
 179        self._dingsound = bs.getsound('dingSmall')
 180        self._dingsoundhigh = bs.getsound('dingSmallHigh')
 181        self._exclude_powerups: list[str] | None = None
 182        self._have_tnt: bool | None = None
 183        self._waves: list[Wave] | None = None
 184        self._bots = SpazBotSet()
 185        self._tntspawner: TNTSpawner | None = None
 186        self._lives_bg: bs.NodeActor | None = None
 187        self._start_lives = 10
 188        self._lives = self._start_lives
 189        self._lives_text: bs.NodeActor | None = None
 190        self._flawless = True
 191        self._time_bonus_timer: bs.Timer | None = None
 192        self._time_bonus_text: bs.NodeActor | None = None
 193        self._time_bonus_mult: float | None = None
 194        self._wave_text: bs.NodeActor | None = None
 195        self._flawless_bonus: int | None = None
 196        self._wave_update_timer: bs.Timer | None = None
 197
 198    def on_transition_in(self) -> None:
 199        super().on_transition_in()
 200        self._scoreboard = Scoreboard(
 201            label=bs.Lstr(resource='scoreText'), score_split=0.5
 202        )
 203        self._score_region = bs.NodeActor(
 204            bs.newnode(
 205                'region',
 206                attrs={
 207                    'position': self.map.defs.boxes['score_region'][0:3],
 208                    'scale': self.map.defs.boxes['score_region'][6:9],
 209                    'type': 'box',
 210                    'materials': [self._score_region_material],
 211                },
 212            )
 213        )
 214
 215    def on_begin(self) -> None:
 216        super().on_begin()
 217        player_count = len(self.players)
 218        hard = self._preset not in {Preset.PRO_EASY, Preset.UBER_EASY}
 219
 220        if self._preset in {Preset.PRO, Preset.PRO_EASY, Preset.TOURNAMENT}:
 221            self._exclude_powerups = ['curse']
 222            self._have_tnt = True
 223            self._waves = [
 224                Wave(
 225                    entries=[
 226                        Spawn(BomberBot, path=3 if hard else 2),
 227                        Spawn(BomberBot, path=2),
 228                        Spawn(BomberBot, path=2) if hard else None,
 229                        Spawn(BomberBot, path=2) if player_count > 1 else None,
 230                        Spawn(BomberBot, path=1) if hard else None,
 231                        Spawn(BomberBot, path=1) if player_count > 2 else None,
 232                        Spawn(BomberBot, path=1) if player_count > 3 else None,
 233                    ]
 234                ),
 235                Wave(
 236                    entries=[
 237                        Spawn(BomberBot, path=1) if hard else None,
 238                        Spawn(BomberBot, path=2) if hard else None,
 239                        Spawn(BomberBot, path=2),
 240                        Spawn(BomberBot, path=2),
 241                        Spawn(BomberBot, path=2) if player_count > 3 else None,
 242                        Spawn(BrawlerBot, path=3),
 243                        Spawn(BrawlerBot, path=3),
 244                        Spawn(BrawlerBot, path=3) if hard else None,
 245                        Spawn(BrawlerBot, path=3) if player_count > 1 else None,
 246                        Spawn(BrawlerBot, path=3) if player_count > 2 else None,
 247                    ]
 248                ),
 249                Wave(
 250                    entries=[
 251                        Spawn(ChargerBot, path=2) if hard else None,
 252                        Spawn(ChargerBot, path=2) if player_count > 2 else None,
 253                        Spawn(TriggerBot, path=2),
 254                        Spawn(TriggerBot, path=2) if player_count > 1 else None,
 255                        Spacing(duration=3.0),
 256                        Spawn(BomberBot, path=2) if hard else None,
 257                        Spawn(BomberBot, path=2) if hard else None,
 258                        Spawn(BomberBot, path=2),
 259                        Spawn(BomberBot, path=3) if hard else None,
 260                        Spawn(BomberBot, path=3),
 261                        Spawn(BomberBot, path=3),
 262                        Spawn(BomberBot, path=3) if player_count > 3 else None,
 263                    ]
 264                ),
 265                Wave(
 266                    entries=[
 267                        Spawn(TriggerBot, path=1) if hard else None,
 268                        Spacing(duration=1.0) if hard else None,
 269                        Spawn(TriggerBot, path=2),
 270                        Spacing(duration=1.0),
 271                        Spawn(TriggerBot, path=3),
 272                        Spacing(duration=1.0),
 273                        Spawn(TriggerBot, path=1) if hard else None,
 274                        Spacing(duration=1.0) if hard else None,
 275                        Spawn(TriggerBot, path=2),
 276                        Spacing(duration=1.0),
 277                        Spawn(TriggerBot, path=3),
 278                        Spacing(duration=1.0),
 279                        Spawn(TriggerBot, path=1)
 280                        if (player_count > 1 and hard)
 281                        else None,
 282                        Spacing(duration=1.0),
 283                        Spawn(TriggerBot, path=2) if player_count > 2 else None,
 284                        Spacing(duration=1.0),
 285                        Spawn(TriggerBot, path=3) if player_count > 3 else None,
 286                        Spacing(duration=1.0),
 287                    ]
 288                ),
 289                Wave(
 290                    entries=[
 291                        Spawn(
 292                            ChargerBotProShielded if hard else ChargerBot,
 293                            path=1,
 294                        ),
 295                        Spawn(BrawlerBot, path=2) if hard else None,
 296                        Spawn(BrawlerBot, path=2),
 297                        Spawn(BrawlerBot, path=2),
 298                        Spawn(BrawlerBot, path=3) if hard else None,
 299                        Spawn(BrawlerBot, path=3),
 300                        Spawn(BrawlerBot, path=3),
 301                        Spawn(BrawlerBot, path=3) if player_count > 1 else None,
 302                        Spawn(BrawlerBot, path=3) if player_count > 2 else None,
 303                        Spawn(BrawlerBot, path=3) if player_count > 3 else None,
 304                    ]
 305                ),
 306                Wave(
 307                    entries=[
 308                        Spawn(BomberBotProShielded, path=3),
 309                        Spacing(duration=1.5),
 310                        Spawn(BomberBotProShielded, path=2),
 311                        Spacing(duration=1.5),
 312                        Spawn(BomberBotProShielded, path=1) if hard else None,
 313                        Spacing(duration=1.0) if hard else None,
 314                        Spawn(BomberBotProShielded, path=3),
 315                        Spacing(duration=1.5),
 316                        Spawn(BomberBotProShielded, path=2),
 317                        Spacing(duration=1.5),
 318                        Spawn(BomberBotProShielded, path=1) if hard else None,
 319                        Spacing(duration=1.5) if hard else None,
 320                        Spawn(BomberBotProShielded, path=3)
 321                        if player_count > 1
 322                        else None,
 323                        Spacing(duration=1.5),
 324                        Spawn(BomberBotProShielded, path=2)
 325                        if player_count > 2
 326                        else None,
 327                        Spacing(duration=1.5),
 328                        Spawn(BomberBotProShielded, path=1)
 329                        if player_count > 3
 330                        else None,
 331                    ]
 332                ),
 333            ]
 334        elif self._preset in {
 335            Preset.UBER_EASY,
 336            Preset.UBER,
 337            Preset.TOURNAMENT_UBER,
 338        }:
 339            self._exclude_powerups = []
 340            self._have_tnt = True
 341            self._waves = [
 342                Wave(
 343                    entries=[
 344                        Spawn(TriggerBot, path=1) if hard else None,
 345                        Spawn(TriggerBot, path=2),
 346                        Spawn(TriggerBot, path=2),
 347                        Spawn(TriggerBot, path=3),
 348                        Spawn(
 349                            BrawlerBotPro if hard else BrawlerBot,
 350                            point=Point.BOTTOM_LEFT,
 351                        ),
 352                        Spawn(BrawlerBotPro, point=Point.BOTTOM_RIGHT)
 353                        if player_count > 2
 354                        else None,
 355                    ]
 356                ),
 357                Wave(
 358                    entries=[
 359                        Spawn(ChargerBot, path=2),
 360                        Spawn(ChargerBot, path=3),
 361                        Spawn(ChargerBot, path=1) if hard else None,
 362                        Spawn(ChargerBot, path=2),
 363                        Spawn(ChargerBot, path=3),
 364                        Spawn(ChargerBot, path=1) if player_count > 2 else None,
 365                    ]
 366                ),
 367                Wave(
 368                    entries=[
 369                        Spawn(BomberBotProShielded, path=1) if hard else None,
 370                        Spawn(BomberBotProShielded, path=2),
 371                        Spawn(BomberBotProShielded, path=2),
 372                        Spawn(BomberBotProShielded, path=3),
 373                        Spawn(BomberBotProShielded, path=3),
 374                        Spawn(ChargerBot, point=Point.BOTTOM_RIGHT),
 375                        Spawn(ChargerBot, point=Point.BOTTOM_LEFT)
 376                        if player_count > 2
 377                        else None,
 378                    ]
 379                ),
 380                Wave(
 381                    entries=[
 382                        Spawn(TriggerBotPro, path=1) if hard else None,
 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                        Spawn(TriggerBotPro, path=1 if hard else 2)
 389                        if player_count > 1
 390                        else None,
 391                        Spawn(TriggerBotPro, path=1 if hard else 2)
 392                        if player_count > 3
 393                        else None,
 394                    ]
 395                ),
 396                Wave(
 397                    entries=[
 398                        Spawn(
 399                            TriggerBotProShielded if hard else TriggerBotPro,
 400                            point=Point.BOTTOM_LEFT,
 401                        ),
 402                        Spawn(TriggerBotProShielded, point=Point.BOTTOM_RIGHT)
 403                        if hard
 404                        else None,
 405                        Spawn(TriggerBotProShielded, point=Point.BOTTOM_RIGHT)
 406                        if player_count > 2
 407                        else None,
 408                        Spawn(BomberBot, path=3),
 409                        Spawn(BomberBot, path=3),
 410                        Spacing(duration=5.0),
 411                        Spawn(BrawlerBot, path=2),
 412                        Spawn(BrawlerBot, path=2),
 413                        Spacing(duration=5.0),
 414                        Spawn(TriggerBot, path=1) if hard else None,
 415                        Spawn(TriggerBot, path=1) if hard else None,
 416                    ]
 417                ),
 418                Wave(
 419                    entries=[
 420                        Spawn(BomberBotProShielded, path=2),
 421                        Spawn(BomberBotProShielded, path=2) if hard else None,
 422                        Spawn(StickyBot, point=Point.BOTTOM_RIGHT),
 423                        Spawn(BomberBotProShielded, path=2),
 424                        Spawn(BomberBotProShielded, path=2),
 425                        Spawn(StickyBot, point=Point.BOTTOM_RIGHT)
 426                        if player_count > 2
 427                        else None,
 428                        Spawn(BomberBotProShielded, path=2),
 429                        Spawn(ExplodeyBot, point=Point.BOTTOM_LEFT),
 430                        Spawn(BomberBotProShielded, path=2),
 431                        Spawn(BomberBotProShielded, path=2)
 432                        if player_count > 1
 433                        else None,
 434                        Spacing(duration=5.0),
 435                        Spawn(StickyBot, point=Point.BOTTOM_LEFT),
 436                        Spacing(duration=2.0),
 437                        Spawn(ExplodeyBot, point=Point.BOTTOM_RIGHT),
 438                    ]
 439                ),
 440            ]
 441        elif self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
 442            self._exclude_powerups = []
 443            self._have_tnt = True
 444
 445        # Spit out a few powerups and start dropping more shortly.
 446        self._drop_powerups(standard_points=True)
 447        bs.timer(4.0, self._start_powerup_drops)
 448        self.setup_low_life_warning_sound()
 449        self._update_scores()
 450
 451        # Our TNT spawner (if applicable).
 452        if self._have_tnt:
 453            self._tntspawner = TNTSpawner(position=self._tntspawnpos)
 454
 455        # Make sure to stay out of the way of menu/party buttons in the corner.
 456        assert bs.app.classic is not None
 457        uiscale = bs.app.ui_v1.uiscale
 458        l_offs = (
 459            -80
 460            if uiscale is bs.UIScale.SMALL
 461            else -40
 462            if uiscale is bs.UIScale.MEDIUM
 463            else 0
 464        )
 465
 466        self._lives_bg = bs.NodeActor(
 467            bs.newnode(
 468                'image',
 469                attrs={
 470                    'texture': self._heart_tex,
 471                    'mesh_opaque': self._heart_mesh_opaque,
 472                    'mesh_transparent': self._heart_mesh_transparent,
 473                    'attach': 'topRight',
 474                    'scale': (90, 90),
 475                    'position': (-110 + l_offs, -50),
 476                    'color': (1, 0.2, 0.2),
 477                },
 478            )
 479        )
 480        # FIXME; should not set things based on vr mode.
 481        #  (won't look right to non-vr connected clients, etc)
 482        vrmode = bs.app.env.vr
 483        self._lives_text = bs.NodeActor(
 484            bs.newnode(
 485                'text',
 486                attrs={
 487                    'v_attach': 'top',
 488                    'h_attach': 'right',
 489                    'h_align': 'center',
 490                    'color': (1, 1, 1, 1) if vrmode else (0.8, 0.8, 0.8, 1.0),
 491                    'flatness': 1.0 if vrmode else 0.5,
 492                    'shadow': 1.0 if vrmode else 0.5,
 493                    'vr_depth': 10,
 494                    'position': (-113 + l_offs, -69),
 495                    'scale': 1.3,
 496                    'text': str(self._lives),
 497                },
 498            )
 499        )
 500
 501        bs.timer(2.0, self._start_updating_waves)
 502
 503    def _handle_reached_end(self) -> None:
 504        spaz = bs.getcollision().opposingnode.getdelegate(SpazBot, True)
 505        if not spaz.is_alive():
 506            return  # Ignore bodies flying in.
 507
 508        self._flawless = False
 509        pos = spaz.node.position
 510        self._bad_guy_score_sound.play(position=pos)
 511        light = bs.newnode(
 512            'light', attrs={'position': pos, 'radius': 0.5, 'color': (1, 0, 0)}
 513        )
 514        bs.animate(light, 'intensity', {0.0: 0, 0.1: 1, 0.5: 0}, loop=False)
 515        bs.timer(1.0, light.delete)
 516        spaz.handlemessage(
 517            bs.DieMessage(immediate=True, how=bs.DeathType.REACHED_GOAL)
 518        )
 519
 520        if self._lives > 0:
 521            self._lives -= 1
 522            if self._lives == 0:
 523                self._bots.stop_moving()
 524                self.continue_or_end_game()
 525            assert self._lives_text is not None
 526            assert self._lives_text.node
 527            self._lives_text.node.text = str(self._lives)
 528            delay = 0.0
 529
 530            def _safesetattr(node: bs.Node, attr: str, value: Any) -> None:
 531                if node:
 532                    setattr(node, attr, value)
 533
 534            for _i in range(4):
 535                bs.timer(
 536                    delay,
 537                    bs.Call(
 538                        _safesetattr,
 539                        self._lives_text.node,
 540                        'color',
 541                        (1, 0, 0, 1.0),
 542                    ),
 543                )
 544                assert self._lives_bg is not None
 545                assert self._lives_bg.node
 546                bs.timer(
 547                    delay,
 548                    bs.Call(_safesetattr, self._lives_bg.node, 'opacity', 0.5),
 549                )
 550                delay += 0.125
 551                bs.timer(
 552                    delay,
 553                    bs.Call(
 554                        _safesetattr,
 555                        self._lives_text.node,
 556                        'color',
 557                        (1.0, 1.0, 0.0, 1.0),
 558                    ),
 559                )
 560                bs.timer(
 561                    delay,
 562                    bs.Call(_safesetattr, self._lives_bg.node, 'opacity', 1.0),
 563                )
 564                delay += 0.125
 565            bs.timer(
 566                delay,
 567                bs.Call(
 568                    _safesetattr,
 569                    self._lives_text.node,
 570                    'color',
 571                    (0.8, 0.8, 0.8, 1.0),
 572                ),
 573            )
 574
 575    def on_continue(self) -> None:
 576        self._lives = 3
 577        assert self._lives_text is not None
 578        assert self._lives_text.node
 579        self._lives_text.node.text = str(self._lives)
 580        self._bots.start_moving()
 581
 582    def spawn_player(self, player: Player) -> bs.Actor:
 583        pos = (
 584            self._spawn_center[0] + random.uniform(-1.5, 1.5),
 585            self._spawn_center[1],
 586            self._spawn_center[2] + random.uniform(-1.5, 1.5),
 587        )
 588        spaz = self.spawn_player_spaz(player, position=pos)
 589        if self._preset in {Preset.PRO_EASY, Preset.UBER_EASY}:
 590            spaz.impact_scale = 0.25
 591
 592        # Add the material that causes us to hit the player-wall.
 593        spaz.pick_up_powerup_callback = self._on_player_picked_up_powerup
 594        return spaz
 595
 596    def _on_player_picked_up_powerup(self, player: bs.Actor) -> None:
 597        del player  # Unused.
 598        self._player_has_picked_up_powerup = True
 599
 600    def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None:
 601        if poweruptype is None:
 602            poweruptype = PowerupBoxFactory.get().get_random_powerup_type(
 603                excludetypes=self._exclude_powerups
 604            )
 605        PowerupBox(
 606            position=self.map.powerup_spawn_points[index],
 607            poweruptype=poweruptype,
 608        ).autoretain()
 609
 610    def _start_powerup_drops(self) -> None:
 611        bs.timer(3.0, self._drop_powerups, repeat=True)
 612
 613    def _drop_powerups(
 614        self, standard_points: bool = False, force_first: str | None = None
 615    ) -> None:
 616        """Generic powerup drop."""
 617
 618        # If its been a minute since our last wave finished emerging, stop
 619        # giving out land-mine powerups. (prevents players from waiting
 620        # around for them on purpose and filling the map up)
 621        if bs.time() - self._last_wave_end_time > 60.0:
 622            extra_excludes = ['land_mines']
 623        else:
 624            extra_excludes = []
 625
 626        if standard_points:
 627            points = self.map.powerup_spawn_points
 628            for i in range(len(points)):
 629                bs.timer(
 630                    1.0 + i * 0.5,
 631                    bs.Call(
 632                        self._drop_powerup, i, force_first if i == 0 else None
 633                    ),
 634                )
 635        else:
 636            pos = (
 637                self._powerup_center[0]
 638                + random.uniform(
 639                    -1.0 * self._powerup_spread[0],
 640                    1.0 * self._powerup_spread[0],
 641                ),
 642                self._powerup_center[1],
 643                self._powerup_center[2]
 644                + random.uniform(
 645                    -self._powerup_spread[1], self._powerup_spread[1]
 646                ),
 647            )
 648
 649            # drop one random one somewhere..
 650            assert self._exclude_powerups is not None
 651            PowerupBox(
 652                position=pos,
 653                poweruptype=PowerupBoxFactory.get().get_random_powerup_type(
 654                    excludetypes=self._exclude_powerups + extra_excludes
 655                ),
 656            ).autoretain()
 657
 658    def end_game(self) -> None:
 659        bs.pushcall(bs.Call(self.do_end, 'defeat'))
 660        bs.setmusic(None)
 661        self._player_death_sound.play()
 662
 663    def do_end(self, outcome: str) -> None:
 664        """End the game now with the provided outcome."""
 665
 666        if outcome == 'defeat':
 667            delay = 2.0
 668            self.fade_to_red()
 669        else:
 670            delay = 0
 671
 672        score: int | None
 673        if self._wavenum >= 2:
 674            score = self._score
 675            fail_message = None
 676        else:
 677            score = None
 678            fail_message = bs.Lstr(resource='reachWave2Text')
 679
 680        self.end(
 681            delay=delay,
 682            results={
 683                'outcome': outcome,
 684                'score': score,
 685                'fail_message': fail_message,
 686                'playerinfos': self.initialplayerinfos,
 687            },
 688        )
 689
 690    def _update_waves(self) -> None:
 691        # pylint: disable=too-many-branches
 692
 693        # If we have no living bots, go to the next wave.
 694        if (
 695            self._can_end_wave
 696            and not self._bots.have_living_bots()
 697            and not self._game_over
 698            and self._lives > 0
 699        ):
 700            self._can_end_wave = False
 701            self._time_bonus_timer = None
 702            self._time_bonus_text = None
 703
 704            if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
 705                won = False
 706            else:
 707                assert self._waves is not None
 708                won = self._wavenum == len(self._waves)
 709
 710            # Reward time bonus.
 711            base_delay = 4.0 if won else 0
 712            if self._time_bonus > 0:
 713                bs.timer(0, self._cashregistersound.play)
 714                bs.timer(
 715                    base_delay,
 716                    bs.Call(self._award_time_bonus, self._time_bonus),
 717                )
 718                base_delay += 1.0
 719
 720            # Reward flawless bonus.
 721            if self._wavenum > 0 and self._flawless:
 722                bs.timer(base_delay, self._award_flawless_bonus)
 723                base_delay += 1.0
 724
 725            self._flawless = True  # reset
 726
 727            if won:
 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                    bs.Lstr(resource='victoryText'), scale=1.0, duration=4.0
 751                )
 752
 753                self.celebrate(10.0)
 754                bs.timer(base_delay, self._award_lives_bonus)
 755                base_delay += 1.0
 756                bs.timer(base_delay, self._award_completion_bonus)
 757                base_delay += 0.85
 758                self._winsound.play()
 759                bs.cameraflash()
 760                bs.setmusic(bs.MusicType.VICTORY)
 761                self._game_over = True
 762                bs.timer(base_delay, bs.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            bs.timer(base_delay, self._start_next_wave)
 772
 773    def _award_completion_bonus(self) -> None:
 774        bonus = 200
 775        self._cashregistersound.play()
 776        PopupText(
 777            bs.Lstr(
 778                value='+${A} ${B}',
 779                subs=[
 780                    ('${A}', str(bonus)),
 781                    ('${B}', bs.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        self._cashregistersound.play()
 794        PopupText(
 795            bs.Lstr(
 796                value='+${A} ${B}',
 797                subs=[
 798                    ('${A}', str(bonus)),
 799                    ('${B}', bs.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        self._cashregistersound.play()
 811        PopupText(
 812            bs.Lstr(
 813                value='+${A} ${B}',
 814                subs=[
 815                    ('${A}', str(bonus)),
 816                    ('${B}', bs.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        self._cashregistersound.play()
 829        PopupText(
 830            bs.Lstr(
 831                value='+${A} ${B}',
 832                subs=[
 833                    ('${A}', str(self._flawless_bonus)),
 834                    ('${B}', bs.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 = bs.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            bs.Lstr(
 858                value='${A} ${B}',
 859                subs=[
 860                    ('${A}', bs.Lstr(resource='waveText')),
 861                    ('${B}', str(self._wavenum)),
 862                ],
 863            ),
 864            scale=1.0,
 865            duration=1.0,
 866            trail=True,
 867        )
 868        bs.timer(0.4, self._new_wave_sound.play)
 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 = bs.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            bs.timer(t_sec, tcall)
1072            t_sec += delay * 0.5
1073
1074        # We can end the wave after all the spawning happens.
1075        bs.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 = bs.Lstr(
1089            value='${A}: ${B}',
1090            subs=[
1091                ('${A}', bs.Lstr(resource='timeBonusText')),
1092                ('${B}', str(int(self._time_bonus * self._time_bonus_mult))),
1093            ],
1094        )
1095        self._time_bonus_text = bs.NodeActor(
1096            bs.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        bs.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 = bs.Time(bs.time() + t_sec)
1119        totalwaves = str(len(self._waves)) if self._waves is not None else '??'
1120        txtval = bs.Lstr(
1121            value='${A} ${B}',
1122            subs=[
1123                ('${A}', bs.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 = bs.NodeActor(
1137            bs.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        # 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=bs.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 = bs.Lstr(
1190                value='${A}: ${B}',
1191                subs=[
1192                    ('${A}', bs.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 = bs.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 bs.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 bs.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 bs.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 bs.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 bs.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 bs.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 bs.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            bs.is_point_in_box(pos, boxes['b8'])
1279            and not bs.is_point_in_box(pos, boxes['b9'])
1280        ) or pos == (0.0, 0.0, 0.0):
1281            # Default to walking right if we're still in the walking area.
1282            bot.node.move_left_right = speed
1283            bot.node.move_up_down = 0
1284            bot.node.run = 0.0
1285            return True
1286
1287        # Revert to normal bot behavior otherwise..
1288        return False
1289
1290    def handlemessage(self, msg: Any) -> Any:
1291        if isinstance(msg, bs.PlayerScoredMessage):
1292            self._score += msg.score
1293            self._update_scores()
1294
1295        elif isinstance(msg, bs.PlayerDiedMessage):
1296            # Augment standard behavior.
1297            super().handlemessage(msg)
1298
1299            self._a_player_has_been_killed = True
1300
1301            # Respawn them shortly.
1302            player = msg.getplayer(Player)
1303            assert self.initialplayerinfos is not None
1304            respawn_time = 2.0 + len(self.initialplayerinfos) * 1.0
1305            player.respawn_timer = bs.Timer(
1306                respawn_time, bs.Call(self.spawn_player_if_exists, player)
1307            )
1308            player.respawn_icon = RespawnIcon(player, respawn_time)
1309
1310        elif isinstance(msg, SpazBotDiedMessage):
1311            if msg.how is bs.DeathType.REACHED_GOAL:
1312                return None
1313            pts, importance = msg.spazbot.get_death_points(msg.how)
1314            if msg.killerplayer is not None:
1315                target: Sequence[float] | None
1316                try:
1317                    assert msg.spazbot is not None
1318                    assert msg.spazbot.node
1319                    target = msg.spazbot.node.position
1320                except Exception:
1321                    logging.exception('Error getting SpazBotDied target.')
1322                    target = None
1323                try:
1324                    if msg.killerplayer:
1325                        self.stats.player_scored(
1326                            msg.killerplayer,
1327                            pts,
1328                            target=target,
1329                            kill=True,
1330                            screenmessage=False,
1331                            importance=importance,
1332                        )
1333                        dingsound = (
1334                            self._dingsound
1335                            if importance == 1
1336                            else self._dingsoundhigh
1337                        )
1338                        dingsound.play(volume=0.6)
1339                except Exception:
1340                    logging.exception('Error on SpazBotDiedMessage.')
1341
1342            # Normally we pull scores from the score-set, but if there's no
1343            # player lets be explicit.
1344            else:
1345                self._score += pts
1346            self._update_scores()
1347
1348        else:
1349            return super().handlemessage(msg)
1350        return None
1351
1352    def _get_bot_speed(self, bot_type: type[SpazBot]) -> float:
1353        speed = self._bot_speed_map.get(bot_type)
1354        if speed is None:
1355            raise TypeError(
1356                'Invalid bot type to _get_bot_speed(): ' + str(bot_type)
1357            )
1358        return speed
1359
1360    def _set_can_end_wave(self) -> None:
1361        self._can_end_wave = True

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

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

Instantiate the Activity.

name = 'Runaround'
description = 'Prevent enemies from reaching the exit.'
tips = ["Jump just as you're throwing to get bombs up to the highest levels.", "No, you can't get up on the ledge. You have to throw bombs.", 'Whip back and forth to get more distance on your throws..']
default_music = <MusicType.MARCHING: 'Marching'>
def on_transition_in(self) -> None:
198    def on_transition_in(self) -> None:
199        super().on_transition_in()
200        self._scoreboard = Scoreboard(
201            label=bs.Lstr(resource='scoreText'), score_split=0.5
202        )
203        self._score_region = bs.NodeActor(
204            bs.newnode(
205                'region',
206                attrs={
207                    'position': self.map.defs.boxes['score_region'][0:3],
208                    'scale': self.map.defs.boxes['score_region'][6:9],
209                    'type': 'box',
210                    'materials': [self._score_region_material],
211                },
212            )
213        )

Called when the Activity is first becoming visible.

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

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

Called once the previous Activity has finished transitioning out.

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

def on_continue(self) -> None:
575    def on_continue(self) -> None:
576        self._lives = 3
577        assert self._lives_text is not None
578        assert self._lives_text.node
579        self._lives_text.node.text = str(self._lives)
580        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: Player) -> bascenev1._actor.Actor:
582    def spawn_player(self, player: Player) -> bs.Actor:
583        pos = (
584            self._spawn_center[0] + random.uniform(-1.5, 1.5),
585            self._spawn_center[1],
586            self._spawn_center[2] + random.uniform(-1.5, 1.5),
587        )
588        spaz = self.spawn_player_spaz(player, position=pos)
589        if self._preset in {Preset.PRO_EASY, Preset.UBER_EASY}:
590            spaz.impact_scale = 0.25
591
592        # Add the material that causes us to hit the player-wall.
593        spaz.pick_up_powerup_callback = self._on_player_picked_up_powerup
594        return spaz

Spawn something for the provided bascenev1.Player.

The default implementation simply calls spawn_player_spaz().

def end_game(self) -> None:
658    def end_game(self) -> None:
659        bs.pushcall(bs.Call(self.do_end, 'defeat'))
660        bs.setmusic(None)
661        self._player_death_sound.play()

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

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

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

End the game now with the provided outcome.

def add_bot_at_point( self, point: Point, spaztype: type[bascenev1lib.actor.spazbot.SpazBot], path: int, spawn_time: float = 0.1) -> None:
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=bs.Call(self._on_bot_spawn, path),
1182        )

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

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

General message handling; can be passed any message object.

Inherited Members
bascenev1._coopgame.CoopGameActivity
session
supports_session_type
get_score_type
celebrate
spawn_player_spaz
fade_to_red
setup_low_life_warning_sound
bascenev1._gameactivity.GameActivity
available_settings
scoreconfig
allow_pausing
allow_kick_idle_players
show_kill_points
create_settings_ui
getscoreconfig
getname
get_display_string
get_team_display_string
get_description
get_description_display_string
get_available_settings
get_supported_maps
get_settings_display_string
initialplayerinfos
map
get_instance_display_string
get_instance_scoreboard_display_string
get_instance_description
get_instance_description_short
is_waiting_for_continue
continue_or_end_game
on_player_join
end
respawn_player
spawn_player_if_exists
setup_standard_powerup_drops
setup_standard_time_limit
show_zoom_message
bascenev1._activity.Activity
settings_raw
teams
players
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
paused_text
preloads
lobby
context
globalsnode
stats
on_expire
customdata
expired
playertype
teamtype
retain_actor
add_actor_weak_ref
on_player_leave
on_team_join
on_team_leave
on_transition_out
has_transitioned_in
has_begun
has_ended
is_transitioning_out
transition_out
create_player
create_team
bascenev1._dependency.DependencyComponent
dep_is_present
get_dynamic_deps