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

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'>
class Point(enum.Enum):
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'

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'>
@dataclass
class Spawn:
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

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:
82@dataclass
83class Spacing:
84    """Defines spacing between spawns."""
85
86    duration: float

Defines spacing between spawns.

Spacing(duration: float)
duration: float
@dataclass
class Wave:
89@dataclass
90class Wave:
91    """Defines a wave of enemies."""
92
93    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')]):
 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

Our player type for this game.

respawn_timer: _bascenev1.Timer | None
class Team(bascenev1._team.Team[bascenev1lib.game.runaround.Player]):
104class Team(bs.Team[Player]):
105    """Our team type for this game."""

Our team type for this game.

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

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

RunaroundGame(settings: dict)
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._lives_hbtime: 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                        (
284                            Spawn(TriggerBot, path=1)
285                            if (player_count > 1 and hard)
286                            else None
287                        ),
288                        Spacing(duration=1.0),
289                        Spawn(TriggerBot, path=2) if player_count > 2 else None,
290                        Spacing(duration=1.0),
291                        Spawn(TriggerBot, path=3) if player_count > 3 else None,
292                        Spacing(duration=1.0),
293                    ]
294                ),
295                Wave(
296                    entries=[
297                        Spawn(
298                            ChargerBotProShielded if hard else ChargerBot,
299                            path=1,
300                        ),
301                        Spawn(BrawlerBot, path=2) if hard else None,
302                        Spawn(BrawlerBot, path=2),
303                        Spawn(BrawlerBot, path=2),
304                        Spawn(BrawlerBot, path=3) if hard else None,
305                        Spawn(BrawlerBot, path=3),
306                        Spawn(BrawlerBot, path=3),
307                        Spawn(BrawlerBot, path=3) if player_count > 1 else None,
308                        Spawn(BrawlerBot, path=3) if player_count > 2 else None,
309                        Spawn(BrawlerBot, path=3) if player_count > 3 else None,
310                    ]
311                ),
312                Wave(
313                    entries=[
314                        Spawn(BomberBotProShielded, path=3),
315                        Spacing(duration=1.5),
316                        Spawn(BomberBotProShielded, path=2),
317                        Spacing(duration=1.5),
318                        Spawn(BomberBotProShielded, path=1) if hard else None,
319                        Spacing(duration=1.0) if hard else None,
320                        Spawn(BomberBotProShielded, path=3),
321                        Spacing(duration=1.5),
322                        Spawn(BomberBotProShielded, path=2),
323                        Spacing(duration=1.5),
324                        Spawn(BomberBotProShielded, path=1) if hard else None,
325                        Spacing(duration=1.5) if hard else None,
326                        (
327                            Spawn(BomberBotProShielded, path=3)
328                            if player_count > 1
329                            else None
330                        ),
331                        Spacing(duration=1.5),
332                        (
333                            Spawn(BomberBotProShielded, path=2)
334                            if player_count > 2
335                            else None
336                        ),
337                        Spacing(duration=1.5),
338                        (
339                            Spawn(BomberBotProShielded, path=1)
340                            if player_count > 3
341                            else None
342                        ),
343                    ]
344                ),
345            ]
346        elif self._preset in {
347            Preset.UBER_EASY,
348            Preset.UBER,
349            Preset.TOURNAMENT_UBER,
350        }:
351            self._exclude_powerups = []
352            self._have_tnt = True
353            self._waves = [
354                Wave(
355                    entries=[
356                        Spawn(TriggerBot, path=1) if hard else None,
357                        Spawn(TriggerBot, path=2),
358                        Spawn(TriggerBot, path=2),
359                        Spawn(TriggerBot, path=3),
360                        Spawn(
361                            BrawlerBotPro if hard else BrawlerBot,
362                            point=Point.BOTTOM_LEFT,
363                        ),
364                        (
365                            Spawn(BrawlerBotPro, point=Point.BOTTOM_RIGHT)
366                            if player_count > 2
367                            else None
368                        ),
369                    ]
370                ),
371                Wave(
372                    entries=[
373                        Spawn(ChargerBot, path=2),
374                        Spawn(ChargerBot, path=3),
375                        Spawn(ChargerBot, path=1) if hard else None,
376                        Spawn(ChargerBot, path=2),
377                        Spawn(ChargerBot, path=3),
378                        Spawn(ChargerBot, path=1) if player_count > 2 else None,
379                    ]
380                ),
381                Wave(
382                    entries=[
383                        Spawn(BomberBotProShielded, path=1) if hard else None,
384                        Spawn(BomberBotProShielded, path=2),
385                        Spawn(BomberBotProShielded, path=2),
386                        Spawn(BomberBotProShielded, path=3),
387                        Spawn(BomberBotProShielded, path=3),
388                        Spawn(ChargerBot, point=Point.BOTTOM_RIGHT),
389                        (
390                            Spawn(ChargerBot, point=Point.BOTTOM_LEFT)
391                            if player_count > 2
392                            else None
393                        ),
394                    ]
395                ),
396                Wave(
397                    entries=[
398                        Spawn(TriggerBotPro, path=1) if hard else None,
399                        Spawn(TriggerBotPro, path=1 if hard else 2),
400                        Spawn(TriggerBotPro, path=1 if hard else 2),
401                        Spawn(TriggerBotPro, path=1 if hard else 2),
402                        Spawn(TriggerBotPro, path=1 if hard else 2),
403                        Spawn(TriggerBotPro, path=1 if hard else 2),
404                        (
405                            Spawn(TriggerBotPro, path=1 if hard else 2)
406                            if player_count > 1
407                            else None
408                        ),
409                        (
410                            Spawn(TriggerBotPro, path=1 if hard else 2)
411                            if player_count > 3
412                            else None
413                        ),
414                    ]
415                ),
416                Wave(
417                    entries=[
418                        Spawn(
419                            TriggerBotProShielded if hard else TriggerBotPro,
420                            point=Point.BOTTOM_LEFT,
421                        ),
422                        (
423                            Spawn(
424                                TriggerBotProShielded, point=Point.BOTTOM_RIGHT
425                            )
426                            if hard
427                            else None
428                        ),
429                        (
430                            Spawn(
431                                TriggerBotProShielded, point=Point.BOTTOM_RIGHT
432                            )
433                            if player_count > 2
434                            else None
435                        ),
436                        Spawn(BomberBot, path=3),
437                        Spawn(BomberBot, path=3),
438                        Spacing(duration=5.0),
439                        Spawn(BrawlerBot, path=2),
440                        Spawn(BrawlerBot, path=2),
441                        Spacing(duration=5.0),
442                        Spawn(TriggerBot, path=1) if hard else None,
443                        Spawn(TriggerBot, path=1) if hard else None,
444                    ]
445                ),
446                Wave(
447                    entries=[
448                        Spawn(BomberBotProShielded, path=2),
449                        Spawn(BomberBotProShielded, path=2) if hard else None,
450                        Spawn(StickyBot, point=Point.BOTTOM_RIGHT),
451                        Spawn(BomberBotProShielded, path=2),
452                        Spawn(BomberBotProShielded, path=2),
453                        (
454                            Spawn(StickyBot, point=Point.BOTTOM_RIGHT)
455                            if player_count > 2
456                            else None
457                        ),
458                        Spawn(BomberBotProShielded, path=2),
459                        Spawn(ExplodeyBot, point=Point.BOTTOM_LEFT),
460                        Spawn(BomberBotProShielded, path=2),
461                        (
462                            Spawn(BomberBotProShielded, path=2)
463                            if player_count > 1
464                            else None
465                        ),
466                        Spacing(duration=5.0),
467                        Spawn(StickyBot, point=Point.BOTTOM_LEFT),
468                        Spacing(duration=2.0),
469                        Spawn(ExplodeyBot, point=Point.BOTTOM_RIGHT),
470                    ]
471                ),
472            ]
473        elif self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
474            self._exclude_powerups = []
475            self._have_tnt = True
476
477        # Spit out a few powerups and start dropping more shortly.
478        self._drop_powerups(standard_points=True)
479        bs.timer(4.0, self._start_powerup_drops)
480        self.setup_low_life_warning_sound()
481        self._update_scores()
482
483        # Our TNT spawner (if applicable).
484        if self._have_tnt:
485            self._tntspawner = TNTSpawner(position=self._tntspawnpos)
486
487        # Make sure to stay out of the way of menu/party buttons in the corner.
488        assert bs.app.classic is not None
489        uiscale = bs.app.ui_v1.uiscale
490        l_offs = (
491            -80
492            if uiscale is bs.UIScale.SMALL
493            else -40 if uiscale is bs.UIScale.MEDIUM else 0
494        )
495
496        self._lives_bg = bs.NodeActor(
497            bs.newnode(
498                'image',
499                attrs={
500                    'texture': self._heart_tex,
501                    'mesh_opaque': self._heart_mesh_opaque,
502                    'mesh_transparent': self._heart_mesh_transparent,
503                    'attach': 'topRight',
504                    'scale': (90, 90),
505                    'position': (-110 + l_offs, -50),
506                    'color': (1, 0.2, 0.2),
507                },
508            )
509        )
510        # FIXME; should not set things based on vr mode.
511        #  (won't look right to non-vr connected clients, etc)
512        vrmode = bs.app.env.vr
513        self._lives_text = bs.NodeActor(
514            bs.newnode(
515                'text',
516                attrs={
517                    'v_attach': 'top',
518                    'h_attach': 'right',
519                    'h_align': 'center',
520                    'color': (1, 1, 1, 1) if vrmode else (0.8, 0.8, 0.8, 1.0),
521                    'flatness': 1.0 if vrmode else 0.5,
522                    'shadow': 1.0 if vrmode else 0.5,
523                    'vr_depth': 10,
524                    'position': (-113 + l_offs, -69),
525                    'scale': 1.3,
526                    'text': str(self._lives),
527                },
528            )
529        )
530
531        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 spawn_player( self, player: Player) -> bascenev1.Actor:
617    @override
618    def spawn_player(self, player: Player) -> bs.Actor:
619        pos = (
620            self._spawn_center[0] + random.uniform(-1.5, 1.5),
621            self._spawn_center[1],
622            self._spawn_center[2] + random.uniform(-1.5, 1.5),
623        )
624        spaz = self.spawn_player_spaz(player, position=pos)
625        if self._preset in {Preset.PRO_EASY, Preset.UBER_EASY}:
626            spaz.impact_scale = 0.25
627
628        # Add the material that causes us to hit the player-wall.
629        spaz.pick_up_powerup_callback = self._on_player_picked_up_powerup
630        return spaz

Spawn something for the provided bascenev1.Player.

The default implementation simply calls spawn_player_spaz().

@override
def end_game(self) -> None:
694    @override
695    def end_game(self) -> None:
696        bs.pushcall(bs.Call(self.do_end, 'defeat'))
697        bs.setmusic(None)
698        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:
700    def do_end(self, outcome: str) -> None:
701        """End the game now with the provided outcome."""
702
703        if outcome == 'defeat':
704            delay = 2.0
705            self.fade_to_red()
706        else:
707            delay = 0
708
709        score: int | None
710        if self._wavenum >= 2:
711            score = self._score
712            fail_message = None
713        else:
714            score = None
715            fail_message = bs.Lstr(resource='reachWave2Text')
716
717        self.end(
718            delay=delay,
719            results={
720                'outcome': outcome,
721                'score': score,
722                'fail_message': fail_message,
723                'playerinfos': self.initialplayerinfos,
724            },
725        )

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:
1201    def add_bot_at_point(
1202        self,
1203        point: Point,
1204        spaztype: type[SpazBot],
1205        path: int,
1206        spawn_time: float = 0.1,
1207    ) -> None:
1208        """Add the given type bot with the given delay (in seconds)."""
1209
1210        # Don't add if the game has ended.
1211        if self._game_over:
1212            return
1213        pos = self.map.defs.points[point.value][:3]
1214        self._bots.spawn_bot(
1215            spaztype,
1216            pos=pos,
1217            spawn_time=spawn_time,
1218            on_spawn_call=bs.Call(self._on_bot_spawn, path),
1219        )

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

@override
def handlemessage(self, msg: Any) -> Any:
1327    @override
1328    def handlemessage(self, msg: Any) -> Any:
1329        if isinstance(msg, bs.PlayerScoredMessage):
1330            self._score += msg.score
1331            self._update_scores()
1332
1333        elif isinstance(msg, bs.PlayerDiedMessage):
1334            # Augment standard behavior.
1335            super().handlemessage(msg)
1336
1337            self._a_player_has_been_killed = True
1338
1339            # Respawn them shortly.
1340            player = msg.getplayer(Player)
1341            assert self.initialplayerinfos is not None
1342            respawn_time = 2.0 + len(self.initialplayerinfos) * 1.0
1343            player.respawn_timer = bs.Timer(
1344                respawn_time, bs.Call(self.spawn_player_if_exists, player)
1345            )
1346            player.respawn_icon = RespawnIcon(player, respawn_time)
1347
1348        elif isinstance(msg, SpazBotDiedMessage):
1349            if msg.how is bs.DeathType.REACHED_GOAL:
1350                return None
1351            pts, importance = msg.spazbot.get_death_points(msg.how)
1352            if msg.killerplayer is not None:
1353                target: Sequence[float] | None
1354                try:
1355                    assert msg.spazbot is not None
1356                    assert msg.spazbot.node
1357                    target = msg.spazbot.node.position
1358                except Exception:
1359                    logging.exception('Error getting SpazBotDied target.')
1360                    target = None
1361                try:
1362                    if msg.killerplayer:
1363                        self.stats.player_scored(
1364                            msg.killerplayer,
1365                            pts,
1366                            target=target,
1367                            kill=True,
1368                            screenmessage=False,
1369                            importance=importance,
1370                        )
1371                        dingsound = (
1372                            self._dingsound
1373                            if importance == 1
1374                            else self._dingsoundhigh
1375                        )
1376                        dingsound.play(volume=0.6)
1377                except Exception:
1378                    logging.exception('Error on SpazBotDiedMessage.')
1379
1380            # Normally we pull scores from the score-set, but if there's no
1381            # player lets be explicit.
1382            else:
1383                self._score += pts
1384            self._update_scores()
1385
1386        else:
1387            return super().handlemessage(msg)
1388        return None

General message handling; can be passed any message object.

def heart_dyin(self, status: bool, time: float = 1.22) -> None:
1401    def heart_dyin(self, status: bool, time: float = 1.22) -> None:
1402        """Makes the UI heart beat at low health."""
1403        assert self._lives_bg is not None
1404        if self._lives_bg.node.exists():
1405            return
1406        heart = self._lives_bg.node
1407
1408        # Make the heart beat intensely!
1409        if status:
1410            bs.animate_array(
1411                heart,
1412                'scale',
1413                2,
1414                {
1415                    0: (90, 90),
1416                    time * 0.1: (105, 105),
1417                    time * 0.21: (88, 88),
1418                    time * 0.42: (90, 90),
1419                    time * 0.52: (105, 105),
1420                    time * 0.63: (88, 88),
1421                    time: (90, 90),
1422                },
1423            )
1424
1425        # Neutralize heartbeat (Done did when dead.)
1426        else:
1427            # Ew; janky old scenev1 has a single 'Node' Python type so
1428            # it thinks heart.scale could be a few different things
1429            # (float, Sequence[float], etc.). So we have to force the
1430            # issue with a cast(). This should go away with scenev2/etc.
1431            bs.animate_array(
1432                heart,
1433                'scale',
1434                2,
1435                {
1436                    0.0: cast(Sequence[float], heart.scale),
1437                    time: (90, 90),
1438                },
1439            )

Makes the UI heart beat at low health.