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

Defines spacing between spawns.

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

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

RunaroundGame(settings: dict)
138    def __init__(self, settings: dict):
139        settings['map'] = 'Tower D'
140        super().__init__(settings)
141        shared = SharedObjects.get()
142        self._preset = Preset(settings.get('preset', 'pro'))
143
144        self._player_death_sound = bs.getsound('playerDeath')
145        self._new_wave_sound = bs.getsound('scoreHit01')
146        self._winsound = bs.getsound('score')
147        self._cashregistersound = bs.getsound('cashRegister')
148        self._bad_guy_score_sound = bs.getsound('shieldDown')
149        self._heart_tex = bs.gettexture('heart')
150        self._heart_mesh_opaque = bs.getmesh('heartOpaque')
151        self._heart_mesh_transparent = bs.getmesh('heartTransparent')
152
153        self._a_player_has_been_killed = False
154        self._spawn_center = self._map_type.defs.points['spawn1'][0:3]
155        self._tntspawnpos = self._map_type.defs.points['tnt_loc'][0:3]
156        self._powerup_center = self._map_type.defs.boxes['powerup_region'][0:3]
157        self._powerup_spread = (
158            self._map_type.defs.boxes['powerup_region'][6] * 0.5,
159            self._map_type.defs.boxes['powerup_region'][8] * 0.5,
160        )
161
162        self._score_region_material = bs.Material()
163        self._score_region_material.add_actions(
164            conditions=('they_have_material', shared.player_material),
165            actions=(
166                ('modify_part_collision', 'collide', True),
167                ('modify_part_collision', 'physical', False),
168                ('call', 'at_connect', self._handle_reached_end),
169            ),
170        )
171
172        self._last_wave_end_time = bs.time()
173        self._player_has_picked_up_powerup = False
174        self._scoreboard: Scoreboard | None = None
175        self._game_over = False
176        self._wavenum = 0
177        self._can_end_wave = True
178        self._score = 0
179        self._time_bonus = 0
180        self._score_region: bs.Actor | None = None
181        self._dingsound = bs.getsound('dingSmall')
182        self._dingsoundhigh = bs.getsound('dingSmallHigh')
183        self._exclude_powerups: list[str] | None = None
184        self._have_tnt: bool | None = None
185        self._waves: list[Wave] | None = None
186        self._bots = SpazBotSet()
187        self._tntspawner: TNTSpawner | None = None
188        self._lives_bg: bs.NodeActor | None = None
189        self._start_lives = 10
190        self._lives = self._start_lives
191        self._lives_text: bs.NodeActor | None = None
192        self._flawless = True
193        self._time_bonus_timer: bs.Timer | None = None
194        self._time_bonus_text: bs.NodeActor | None = None
195        self._time_bonus_mult: float | None = None
196        self._wave_text: bs.NodeActor | None = None
197        self._flawless_bonus: int | None = None
198        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'>
@override
def on_transition_in(self) -> None:
200    @override
201    def on_transition_in(self) -> None:
202        super().on_transition_in()
203        self._scoreboard = Scoreboard(
204            label=bs.Lstr(resource='scoreText'), score_split=0.5
205        )
206        self._score_region = bs.NodeActor(
207            bs.newnode(
208                'region',
209                attrs={
210                    'position': self.map.defs.boxes['score_region'][0:3],
211                    'scale': self.map.defs.boxes['score_region'][6:9],
212                    'type': 'box',
213                    'materials': [self._score_region_material],
214                },
215            )
216        )

Called when the Activity is first becoming visible.

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

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

@override
def on_continue(self) -> None:
579    @override
580    def on_continue(self) -> None:
581        self._lives = 3
582        assert self._lives_text is not None
583        assert self._lives_text.node
584        self._lives_text.node.text = str(self._lives)
585        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.

@override
def spawn_player( self, player: Player) -> bascenev1._actor.Actor:
587    @override
588    def spawn_player(self, player: Player) -> bs.Actor:
589        pos = (
590            self._spawn_center[0] + random.uniform(-1.5, 1.5),
591            self._spawn_center[1],
592            self._spawn_center[2] + random.uniform(-1.5, 1.5),
593        )
594        spaz = self.spawn_player_spaz(player, position=pos)
595        if self._preset in {Preset.PRO_EASY, Preset.UBER_EASY}:
596            spaz.impact_scale = 0.25
597
598        # Add the material that causes us to hit the player-wall.
599        spaz.pick_up_powerup_callback = self._on_player_picked_up_powerup
600        return spaz

Spawn something for the provided bascenev1.Player.

The default implementation simply calls spawn_player_spaz().

@override
def end_game(self) -> None:
664    @override
665    def end_game(self) -> None:
666        bs.pushcall(bs.Call(self.do_end, 'defeat'))
667        bs.setmusic(None)
668        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:
670    def do_end(self, outcome: str) -> None:
671        """End the game now with the provided outcome."""
672
673        if outcome == 'defeat':
674            delay = 2.0
675            self.fade_to_red()
676        else:
677            delay = 0
678
679        score: int | None
680        if self._wavenum >= 2:
681            score = self._score
682            fail_message = None
683        else:
684            score = None
685            fail_message = bs.Lstr(resource='reachWave2Text')
686
687        self.end(
688            delay=delay,
689            results={
690                'outcome': outcome,
691                'score': score,
692                'fail_message': fail_message,
693                'playerinfos': self.initialplayerinfos,
694            },
695        )

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:
1171    def add_bot_at_point(
1172        self,
1173        point: Point,
1174        spaztype: type[SpazBot],
1175        path: int,
1176        spawn_time: float = 0.1,
1177    ) -> None:
1178        """Add the given type bot with the given delay (in seconds)."""
1179
1180        # Don't add if the game has ended.
1181        if self._game_over:
1182            return
1183        pos = self.map.defs.points[point.value][:3]
1184        self._bots.spawn_bot(
1185            spaztype,
1186            pos=pos,
1187            spawn_time=spawn_time,
1188            on_spawn_call=bs.Call(self._on_bot_spawn, path),
1189        )

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

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