bascenev1lib.game.runaround

Defines the runaround co-op game.

   1# Released under the MIT License. See LICENSE for details.
   2#
   3"""Defines the runaround co-op game."""
   4
   5# We wear the cone of shame.
   6# pylint: disable=too-many-lines
   7
   8# ba_meta require api 8
   9# (see https://ballistica.net/wiki/meta-tag-system)
  10
  11from __future__ import annotations
  12
  13import random
  14import logging
  15from enum import Enum
  16from dataclasses import dataclass
  17from typing import TYPE_CHECKING, 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.continue_or_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 on_continue(self) -> None:
 618        self._lives = 3
 619        assert self._lives_text is not None
 620        assert self._lives_text.node
 621        self._lives_text.node.text = str(self._lives)
 622        self._bots.start_moving()
 623
 624    @override
 625    def spawn_player(self, player: Player) -> bs.Actor:
 626        pos = (
 627            self._spawn_center[0] + random.uniform(-1.5, 1.5),
 628            self._spawn_center[1],
 629            self._spawn_center[2] + random.uniform(-1.5, 1.5),
 630        )
 631        spaz = self.spawn_player_spaz(player, position=pos)
 632        if self._preset in {Preset.PRO_EASY, Preset.UBER_EASY}:
 633            spaz.impact_scale = 0.25
 634
 635        # Add the material that causes us to hit the player-wall.
 636        spaz.pick_up_powerup_callback = self._on_player_picked_up_powerup
 637        return spaz
 638
 639    def _on_player_picked_up_powerup(self, player: bs.Actor) -> None:
 640        del player  # Unused.
 641        self._player_has_picked_up_powerup = True
 642
 643    def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None:
 644        if poweruptype is None:
 645            poweruptype = PowerupBoxFactory.get().get_random_powerup_type(
 646                excludetypes=self._exclude_powerups
 647            )
 648        PowerupBox(
 649            position=self.map.powerup_spawn_points[index],
 650            poweruptype=poweruptype,
 651        ).autoretain()
 652
 653    def _start_powerup_drops(self) -> None:
 654        bs.timer(3.0, self._drop_powerups, repeat=True)
 655
 656    def _drop_powerups(
 657        self, standard_points: bool = False, force_first: str | None = None
 658    ) -> None:
 659        """Generic powerup drop."""
 660
 661        # If its been a minute since our last wave finished emerging, stop
 662        # giving out land-mine powerups. (prevents players from waiting
 663        # around for them on purpose and filling the map up)
 664        if bs.time() - self._last_wave_end_time > 60.0:
 665            extra_excludes = ['land_mines']
 666        else:
 667            extra_excludes = []
 668
 669        if standard_points:
 670            points = self.map.powerup_spawn_points
 671            for i in range(len(points)):
 672                bs.timer(
 673                    1.0 + i * 0.5,
 674                    bs.Call(
 675                        self._drop_powerup, i, force_first if i == 0 else None
 676                    ),
 677                )
 678        else:
 679            pos = (
 680                self._powerup_center[0]
 681                + random.uniform(
 682                    -1.0 * self._powerup_spread[0],
 683                    1.0 * self._powerup_spread[0],
 684                ),
 685                self._powerup_center[1],
 686                self._powerup_center[2]
 687                + random.uniform(
 688                    -self._powerup_spread[1], self._powerup_spread[1]
 689                ),
 690            )
 691
 692            # drop one random one somewhere..
 693            assert self._exclude_powerups is not None
 694            PowerupBox(
 695                position=pos,
 696                poweruptype=PowerupBoxFactory.get().get_random_powerup_type(
 697                    excludetypes=self._exclude_powerups + extra_excludes
 698                ),
 699            ).autoretain()
 700
 701    @override
 702    def end_game(self) -> None:
 703        bs.pushcall(bs.Call(self.do_end, 'defeat'))
 704        bs.setmusic(None)
 705        self._player_death_sound.play()
 706
 707    def do_end(self, outcome: str) -> None:
 708        """End the game now with the provided outcome."""
 709
 710        if outcome == 'defeat':
 711            delay = 2.0
 712            self.fade_to_red()
 713        else:
 714            delay = 0
 715
 716        score: int | None
 717        if self._wavenum >= 2:
 718            score = self._score
 719            fail_message = None
 720        else:
 721            score = None
 722            fail_message = bs.Lstr(resource='reachWave2Text')
 723
 724        self.end(
 725            delay=delay,
 726            results={
 727                'outcome': outcome,
 728                'score': score,
 729                'fail_message': fail_message,
 730                'playerinfos': self.initialplayerinfos,
 731            },
 732        )
 733
 734    def _update_waves(self) -> None:
 735        # pylint: disable=too-many-branches
 736
 737        # If we have no living bots, go to the next wave.
 738        if (
 739            self._can_end_wave
 740            and not self._bots.have_living_bots()
 741            and not self._game_over
 742            and self._lives > 0
 743        ):
 744            self._can_end_wave = False
 745            self._time_bonus_timer = None
 746            self._time_bonus_text = None
 747
 748            if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
 749                won = False
 750            else:
 751                assert self._waves is not None
 752                won = self._wavenum == len(self._waves)
 753
 754            # Reward time bonus.
 755            base_delay = 4.0 if won else 0
 756            if self._time_bonus > 0:
 757                bs.timer(0, self._cashregistersound.play)
 758                bs.timer(
 759                    base_delay,
 760                    bs.Call(self._award_time_bonus, self._time_bonus),
 761                )
 762                base_delay += 1.0
 763
 764            # Reward flawless bonus.
 765            if self._wavenum > 0 and self._flawless:
 766                bs.timer(base_delay, self._award_flawless_bonus)
 767                base_delay += 1.0
 768
 769            self._flawless = True  # reset
 770
 771            if won:
 772                # Completion achievements:
 773                if self._preset in {Preset.PRO, Preset.PRO_EASY}:
 774                    self._award_achievement(
 775                        'Pro Runaround Victory', sound=False
 776                    )
 777                    if self._lives == self._start_lives:
 778                        self._award_achievement('The Wall', sound=False)
 779                    if not self._player_has_picked_up_powerup:
 780                        self._award_achievement(
 781                            'Precision Bombing', sound=False
 782                        )
 783                elif self._preset in {Preset.UBER, Preset.UBER_EASY}:
 784                    self._award_achievement(
 785                        'Uber Runaround Victory', sound=False
 786                    )
 787                    if self._lives == self._start_lives:
 788                        self._award_achievement('The Great Wall', sound=False)
 789                    if not self._a_player_has_been_killed:
 790                        self._award_achievement('Stayin\' Alive', sound=False)
 791
 792                # Give remaining players some points and have them celebrate.
 793                self.show_zoom_message(
 794                    bs.Lstr(resource='victoryText'), scale=1.0, duration=4.0
 795                )
 796
 797                self.celebrate(10.0)
 798                bs.timer(base_delay, self._award_lives_bonus)
 799                base_delay += 1.0
 800                bs.timer(base_delay, self._award_completion_bonus)
 801                base_delay += 0.85
 802                self._winsound.play()
 803                bs.cameraflash()
 804                bs.setmusic(bs.MusicType.VICTORY)
 805                self._game_over = True
 806                bs.timer(base_delay, bs.Call(self.do_end, 'victory'))
 807                return
 808
 809            self._wavenum += 1
 810
 811            # Short celebration after waves.
 812            if self._wavenum > 1:
 813                self.celebrate(0.5)
 814
 815            bs.timer(base_delay, self._start_next_wave)
 816
 817    def _award_completion_bonus(self) -> None:
 818        bonus = 200
 819        self._cashregistersound.play()
 820        PopupText(
 821            bs.Lstr(
 822                value='+${A} ${B}',
 823                subs=[
 824                    ('${A}', str(bonus)),
 825                    ('${B}', bs.Lstr(resource='completionBonusText')),
 826                ],
 827            ),
 828            color=(0.7, 0.7, 1.0, 1),
 829            scale=1.6,
 830            position=(0, 1.5, -1),
 831        ).autoretain()
 832        self._score += bonus
 833        self._update_scores()
 834
 835    def _award_lives_bonus(self) -> None:
 836        bonus = self._lives * 30
 837        self._cashregistersound.play()
 838        PopupText(
 839            bs.Lstr(
 840                value='+${A} ${B}',
 841                subs=[
 842                    ('${A}', str(bonus)),
 843                    ('${B}', bs.Lstr(resource='livesBonusText')),
 844                ],
 845            ),
 846            color=(0.7, 1.0, 0.3, 1),
 847            scale=1.3,
 848            position=(0, 1, -1),
 849        ).autoretain()
 850        self._score += bonus
 851        self._update_scores()
 852
 853    def _award_time_bonus(self, bonus: int) -> None:
 854        self._cashregistersound.play()
 855        PopupText(
 856            bs.Lstr(
 857                value='+${A} ${B}',
 858                subs=[
 859                    ('${A}', str(bonus)),
 860                    ('${B}', bs.Lstr(resource='timeBonusText')),
 861                ],
 862            ),
 863            color=(1, 1, 0.5, 1),
 864            scale=1.0,
 865            position=(0, 3, -1),
 866        ).autoretain()
 867
 868        self._score += self._time_bonus
 869        self._update_scores()
 870
 871    def _award_flawless_bonus(self) -> None:
 872        self._cashregistersound.play()
 873        PopupText(
 874            bs.Lstr(
 875                value='+${A} ${B}',
 876                subs=[
 877                    ('${A}', str(self._flawless_bonus)),
 878                    ('${B}', bs.Lstr(resource='perfectWaveText')),
 879                ],
 880            ),
 881            color=(1, 1, 0.2, 1),
 882            scale=1.2,
 883            position=(0, 2, -1),
 884        ).autoretain()
 885
 886        assert self._flawless_bonus is not None
 887        self._score += self._flawless_bonus
 888        self._update_scores()
 889
 890    def _start_time_bonus_timer(self) -> None:
 891        self._time_bonus_timer = bs.Timer(
 892            1.0, self._update_time_bonus, repeat=True
 893        )
 894
 895    def _start_next_wave(self) -> None:
 896        # FIXME: Need to split this up.
 897        # pylint: disable=too-many-locals
 898        # pylint: disable=too-many-branches
 899        # pylint: disable=too-many-statements
 900        self.show_zoom_message(
 901            bs.Lstr(
 902                value='${A} ${B}',
 903                subs=[
 904                    ('${A}', bs.Lstr(resource='waveText')),
 905                    ('${B}', str(self._wavenum)),
 906                ],
 907            ),
 908            scale=1.0,
 909            duration=1.0,
 910            trail=True,
 911        )
 912        bs.timer(0.4, self._new_wave_sound.play)
 913        t_sec = 0.0
 914        base_delay = 0.5
 915        delay = 0.0
 916        bot_types: list[Spawn | Spacing | None] = []
 917
 918        if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
 919            level = self._wavenum
 920            target_points = (level + 1) * 8.0
 921            group_count = random.randint(1, 3)
 922            entries: list[Spawn | Spacing | None] = []
 923            spaz_types: list[tuple[type[SpazBot], float]] = []
 924            if level < 6:
 925                spaz_types += [(BomberBot, 5.0)]
 926            if level < 10:
 927                spaz_types += [(BrawlerBot, 5.0)]
 928            if level < 15:
 929                spaz_types += [(TriggerBot, 6.0)]
 930            if level > 5:
 931                spaz_types += [(TriggerBotPro, 7.5)] * (1 + (level - 5) // 7)
 932            if level > 2:
 933                spaz_types += [(BomberBotProShielded, 8.0)] * (
 934                    1 + (level - 2) // 6
 935                )
 936            if level > 6:
 937                spaz_types += [(TriggerBotProShielded, 12.0)] * (
 938                    1 + (level - 6) // 5
 939                )
 940            if level > 1:
 941                spaz_types += [(ChargerBot, 10.0)] * (1 + (level - 1) // 4)
 942            if level > 7:
 943                spaz_types += [(ChargerBotProShielded, 15.0)] * (
 944                    1 + (level - 7) // 3
 945                )
 946
 947            # Bot type, their effect on target points.
 948            defender_types: list[tuple[type[SpazBot], float]] = [
 949                (BomberBot, 0.9),
 950                (BrawlerBot, 0.9),
 951                (TriggerBot, 0.85),
 952            ]
 953            if level > 2:
 954                defender_types += [(ChargerBot, 0.75)]
 955            if level > 4:
 956                defender_types += [(StickyBot, 0.7)] * (1 + (level - 5) // 6)
 957            if level > 6:
 958                defender_types += [(ExplodeyBot, 0.7)] * (1 + (level - 5) // 5)
 959            if level > 8:
 960                defender_types += [(BrawlerBotProShielded, 0.65)] * (
 961                    1 + (level - 5) // 4
 962                )
 963            if level > 10:
 964                defender_types += [(TriggerBotProShielded, 0.6)] * (
 965                    1 + (level - 6) // 3
 966                )
 967
 968            for group in range(group_count):
 969                this_target_point_s = target_points / group_count
 970
 971                # Adding spacing makes things slightly harder.
 972                rval = random.random()
 973                if rval < 0.07:
 974                    spacing = 1.5
 975                    this_target_point_s *= 0.85
 976                elif rval < 0.15:
 977                    spacing = 1.0
 978                    this_target_point_s *= 0.9
 979                else:
 980                    spacing = 0.0
 981
 982                path = random.randint(1, 3)
 983
 984                # Don't allow hard paths on early levels.
 985                if level < 3:
 986                    if path == 1:
 987                        path = 3
 988
 989                # Easy path.
 990                if path == 3:
 991                    pass
 992
 993                # Harder path.
 994                elif path == 2:
 995                    this_target_point_s *= 0.8
 996
 997                # Even harder path.
 998                elif path == 1:
 999                    this_target_point_s *= 0.7
1000
1001                # Looping forward.
1002                elif path == 4:
1003                    this_target_point_s *= 0.7
1004
1005                # Looping backward.
1006                elif path == 5:
1007                    this_target_point_s *= 0.7
1008
1009                # Random.
1010                elif path == 6:
1011                    this_target_point_s *= 0.7
1012
1013                def _add_defender(
1014                    defender_type: tuple[type[SpazBot], float], pnt: Point
1015                ) -> tuple[float, Spawn]:
1016                    # This is ok because we call it immediately.
1017                    # pylint: disable=cell-var-from-loop
1018                    return this_target_point_s * defender_type[1], Spawn(
1019                        defender_type[0], point=pnt
1020                    )
1021
1022                # Add defenders.
1023                defender_type1 = defender_types[
1024                    random.randrange(len(defender_types))
1025                ]
1026                defender_type2 = defender_types[
1027                    random.randrange(len(defender_types))
1028                ]
1029                defender1 = defender2 = None
1030                if (
1031                    (group == 0)
1032                    or (group == 1 and level > 3)
1033                    or (group == 2 and level > 5)
1034                ):
1035                    if random.random() < min(0.75, (level - 1) * 0.11):
1036                        this_target_point_s, defender1 = _add_defender(
1037                            defender_type1, Point.BOTTOM_LEFT
1038                        )
1039                    if random.random() < min(0.75, (level - 1) * 0.04):
1040                        this_target_point_s, defender2 = _add_defender(
1041                            defender_type2, Point.BOTTOM_RIGHT
1042                        )
1043
1044                spaz_type = spaz_types[random.randrange(len(spaz_types))]
1045                member_count = max(
1046                    1, int(round(this_target_point_s / spaz_type[1]))
1047                )
1048                for i, _member in enumerate(range(member_count)):
1049                    if path == 4:
1050                        this_path = i % 3  # Looping forward.
1051                    elif path == 5:
1052                        this_path = 3 - (i % 3)  # Looping backward.
1053                    elif path == 6:
1054                        this_path = random.randint(1, 3)  # Random.
1055                    else:
1056                        this_path = path
1057                    entries.append(Spawn(spaz_type[0], path=this_path))
1058                    if spacing != 0.0:
1059                        entries.append(Spacing(duration=spacing))
1060
1061                if defender1 is not None:
1062                    entries.append(defender1)
1063                if defender2 is not None:
1064                    entries.append(defender2)
1065
1066                # Some spacing between groups.
1067                rval = random.random()
1068                if rval < 0.1:
1069                    spacing = 5.0
1070                elif rval < 0.5:
1071                    spacing = 1.0
1072                else:
1073                    spacing = 1.0
1074                entries.append(Spacing(duration=spacing))
1075
1076            wave = Wave(entries=entries)
1077
1078        else:
1079            assert self._waves is not None
1080            wave = self._waves[self._wavenum - 1]
1081
1082        bot_types += wave.entries
1083        self._time_bonus_mult = 1.0
1084        this_flawless_bonus = 0
1085        non_runner_spawn_time = 1.0
1086
1087        for info in bot_types:
1088            if info is None:
1089                continue
1090            if isinstance(info, Spacing):
1091                t_sec += info.duration
1092                continue
1093            bot_type = info.type
1094            path = info.path
1095            self._time_bonus_mult += bot_type.points_mult * 0.02
1096            this_flawless_bonus += bot_type.points_mult * 5
1097
1098            # If its got a position, use that.
1099            if info.point is not None:
1100                point = info.point
1101            else:
1102                point = Point.START
1103
1104            # Space our our slower bots.
1105            delay = base_delay
1106            delay /= self._get_bot_speed(bot_type)
1107            t_sec += delay * 0.5
1108            tcall = bs.Call(
1109                self.add_bot_at_point,
1110                point,
1111                bot_type,
1112                path,
1113                0.1 if point is Point.START else non_runner_spawn_time,
1114            )
1115            bs.timer(t_sec, tcall)
1116            t_sec += delay * 0.5
1117
1118        # We can end the wave after all the spawning happens.
1119        bs.timer(
1120            t_sec - delay * 0.5 + non_runner_spawn_time + 0.01,
1121            self._set_can_end_wave,
1122        )
1123
1124        # Reset our time bonus.
1125        # In this game we use a constant time bonus so it erodes away in
1126        # roughly the same time (since the time limit a wave can take is
1127        # relatively constant) ..we then post-multiply a modifier to adjust
1128        # points.
1129        self._time_bonus = 150
1130        self._flawless_bonus = this_flawless_bonus
1131        assert self._time_bonus_mult is not None
1132        txtval = bs.Lstr(
1133            value='${A}: ${B}',
1134            subs=[
1135                ('${A}', bs.Lstr(resource='timeBonusText')),
1136                ('${B}', str(int(self._time_bonus * self._time_bonus_mult))),
1137            ],
1138        )
1139        self._time_bonus_text = bs.NodeActor(
1140            bs.newnode(
1141                'text',
1142                attrs={
1143                    'v_attach': 'top',
1144                    'h_attach': 'center',
1145                    'h_align': 'center',
1146                    'color': (1, 1, 0.0, 1),
1147                    'shadow': 1.0,
1148                    'vr_depth': -30,
1149                    'flatness': 1.0,
1150                    'position': (0, -60),
1151                    'scale': 0.8,
1152                    'text': txtval,
1153                },
1154            )
1155        )
1156
1157        bs.timer(t_sec, self._start_time_bonus_timer)
1158
1159        # Keep track of when this wave finishes emerging. We wanna stop
1160        # dropping land-mines powerups at some point (otherwise a crafty
1161        # player could fill the whole map with them)
1162        self._last_wave_end_time = bs.Time(bs.time() + t_sec)
1163        totalwaves = str(len(self._waves)) if self._waves is not None else '??'
1164        txtval = bs.Lstr(
1165            value='${A} ${B}',
1166            subs=[
1167                ('${A}', bs.Lstr(resource='waveText')),
1168                (
1169                    '${B}',
1170                    str(self._wavenum)
1171                    + (
1172                        ''
1173                        if self._preset
1174                        in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}
1175                        else f'/{totalwaves}'
1176                    ),
1177                ),
1178            ],
1179        )
1180        self._wave_text = bs.NodeActor(
1181            bs.newnode(
1182                'text',
1183                attrs={
1184                    'v_attach': 'top',
1185                    'h_attach': 'center',
1186                    'h_align': 'center',
1187                    'vr_depth': -10,
1188                    'color': (1, 1, 1, 1),
1189                    'shadow': 1.0,
1190                    'flatness': 1.0,
1191                    'position': (0, -40),
1192                    'scale': 1.3,
1193                    'text': txtval,
1194                },
1195            )
1196        )
1197
1198    def _on_bot_spawn(self, path: int, spaz: SpazBot) -> None:
1199        # Add our custom update callback and set some info for this bot.
1200        spaz_type = type(spaz)
1201        assert spaz is not None
1202        spaz.update_callback = self._update_bot
1203
1204        # Tack some custom attrs onto the spaz.
1205        setattr(spaz, 'r_walk_row', path)
1206        setattr(spaz, 'r_walk_speed', self._get_bot_speed(spaz_type))
1207
1208    def add_bot_at_point(
1209        self,
1210        point: Point,
1211        spaztype: type[SpazBot],
1212        path: int,
1213        spawn_time: float = 0.1,
1214    ) -> None:
1215        """Add the given type bot with the given delay (in seconds)."""
1216
1217        # Don't add if the game has ended.
1218        if self._game_over:
1219            return
1220        pos = self.map.defs.points[point.value][:3]
1221        self._bots.spawn_bot(
1222            spaztype,
1223            pos=pos,
1224            spawn_time=spawn_time,
1225            on_spawn_call=bs.Call(self._on_bot_spawn, path),
1226        )
1227
1228    def _update_time_bonus(self) -> None:
1229        self._time_bonus = int(self._time_bonus * 0.91)
1230        if self._time_bonus > 0 and self._time_bonus_text is not None:
1231            assert self._time_bonus_text.node
1232            assert self._time_bonus_mult
1233            self._time_bonus_text.node.text = bs.Lstr(
1234                value='${A}: ${B}',
1235                subs=[
1236                    ('${A}', bs.Lstr(resource='timeBonusText')),
1237                    (
1238                        '${B}',
1239                        str(int(self._time_bonus * self._time_bonus_mult)),
1240                    ),
1241                ],
1242            )
1243        else:
1244            self._time_bonus_text = None
1245
1246    def _start_updating_waves(self) -> None:
1247        self._wave_update_timer = bs.Timer(2.0, self._update_waves, repeat=True)
1248
1249    def _update_scores(self) -> None:
1250        score = self._score
1251        if self._preset is Preset.ENDLESS:
1252            if score >= 500:
1253                self._award_achievement('Runaround Master')
1254            if score >= 1000:
1255                self._award_achievement('Runaround Wizard')
1256            if score >= 2000:
1257                self._award_achievement('Runaround God')
1258
1259        assert self._scoreboard is not None
1260        self._scoreboard.set_team_value(self.teams[0], score, max_score=None)
1261
1262    def _update_bot(self, bot: SpazBot) -> bool:
1263        # Yup; that's a lot of return statements right there.
1264        # pylint: disable=too-many-return-statements
1265
1266        if not bool(bot):
1267            return True
1268
1269        assert bot.node
1270
1271        # FIXME: Do this in a type safe way.
1272        r_walk_speed: float = getattr(bot, 'r_walk_speed')
1273        r_walk_row: int = getattr(bot, 'r_walk_row')
1274
1275        speed = r_walk_speed
1276        pos = bot.node.position
1277        boxes = self.map.defs.boxes
1278
1279        # Bots in row 1 attempt the high road..
1280        if r_walk_row == 1:
1281            if bs.is_point_in_box(pos, boxes['b4']):
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        # Row 1 and 2 bots attempt the middle road..
1288        if r_walk_row in [1, 2]:
1289            if bs.is_point_in_box(pos, boxes['b1']):
1290                bot.node.move_up_down = speed
1291                bot.node.move_left_right = 0
1292                bot.node.run = 0.0
1293                return True
1294
1295        # All bots settle for the third row.
1296        if bs.is_point_in_box(pos, boxes['b7']):
1297            bot.node.move_up_down = speed
1298            bot.node.move_left_right = 0
1299            bot.node.run = 0.0
1300            return True
1301        if bs.is_point_in_box(pos, boxes['b2']):
1302            bot.node.move_up_down = -speed
1303            bot.node.move_left_right = 0
1304            bot.node.run = 0.0
1305            return True
1306        if bs.is_point_in_box(pos, boxes['b3']):
1307            bot.node.move_up_down = -speed
1308            bot.node.move_left_right = 0
1309            bot.node.run = 0.0
1310            return True
1311        if bs.is_point_in_box(pos, boxes['b5']):
1312            bot.node.move_up_down = -speed
1313            bot.node.move_left_right = 0
1314            bot.node.run = 0.0
1315            return True
1316        if bs.is_point_in_box(pos, boxes['b6']):
1317            bot.node.move_up_down = speed
1318            bot.node.move_left_right = 0
1319            bot.node.run = 0.0
1320            return True
1321        if (
1322            bs.is_point_in_box(pos, boxes['b8'])
1323            and not bs.is_point_in_box(pos, boxes['b9'])
1324        ) or pos == (0.0, 0.0, 0.0):
1325            # Default to walking right if we're still in the walking area.
1326            bot.node.move_left_right = speed
1327            bot.node.move_up_down = 0
1328            bot.node.run = 0.0
1329            return True
1330
1331        # Revert to normal bot behavior otherwise..
1332        return False
1333
1334    @override
1335    def handlemessage(self, msg: Any) -> Any:
1336        if isinstance(msg, bs.PlayerScoredMessage):
1337            self._score += msg.score
1338            self._update_scores()
1339
1340        elif isinstance(msg, bs.PlayerDiedMessage):
1341            # Augment standard behavior.
1342            super().handlemessage(msg)
1343
1344            self._a_player_has_been_killed = True
1345
1346            # Respawn them shortly.
1347            player = msg.getplayer(Player)
1348            assert self.initialplayerinfos is not None
1349            respawn_time = 2.0 + len(self.initialplayerinfos) * 1.0
1350            player.respawn_timer = bs.Timer(
1351                respawn_time, bs.Call(self.spawn_player_if_exists, player)
1352            )
1353            player.respawn_icon = RespawnIcon(player, respawn_time)
1354
1355        elif isinstance(msg, SpazBotDiedMessage):
1356            if msg.how is bs.DeathType.REACHED_GOAL:
1357                return None
1358            pts, importance = msg.spazbot.get_death_points(msg.how)
1359            if msg.killerplayer is not None:
1360                target: Sequence[float] | None
1361                try:
1362                    assert msg.spazbot is not None
1363                    assert msg.spazbot.node
1364                    target = msg.spazbot.node.position
1365                except Exception:
1366                    logging.exception('Error getting SpazBotDied target.')
1367                    target = None
1368                try:
1369                    if msg.killerplayer:
1370                        self.stats.player_scored(
1371                            msg.killerplayer,
1372                            pts,
1373                            target=target,
1374                            kill=True,
1375                            screenmessage=False,
1376                            importance=importance,
1377                        )
1378                        dingsound = (
1379                            self._dingsound
1380                            if importance == 1
1381                            else self._dingsoundhigh
1382                        )
1383                        dingsound.play(volume=0.6)
1384                except Exception:
1385                    logging.exception('Error on SpazBotDiedMessage.')
1386
1387            # Normally we pull scores from the score-set, but if there's no
1388            # player lets be explicit.
1389            else:
1390                self._score += pts
1391            self._update_scores()
1392
1393        else:
1394            return super().handlemessage(msg)
1395        return None
1396
1397    def _get_bot_speed(self, bot_type: type[SpazBot]) -> float:
1398        speed = self._bot_speed_map.get(bot_type)
1399        if speed is None:
1400            raise TypeError(
1401                'Invalid bot type to _get_bot_speed(): ' + str(bot_type)
1402            )
1403        return speed
1404
1405    def _set_can_end_wave(self) -> None:
1406        self._can_end_wave = True
1407
1408    def heart_dyin(self, status: bool, time: float = 1.22) -> None:
1409        """Makes the UI heart beat at low health."""
1410        assert self._lives_bg is not None
1411        if self._lives_bg.node.exists():
1412            return
1413        heart = self._lives_bg.node
1414
1415        # Make the heart beat intensely!
1416        if status:
1417            bs.animate_array(
1418                heart,
1419                'scale',
1420                2,
1421                {
1422                    0: (90, 90),
1423                    time * 0.1: (105, 105),
1424                    time * 0.21: (88, 88),
1425                    time * 0.42: (90, 90),
1426                    time * 0.52: (105, 105),
1427                    time * 0.63: (88, 88),
1428                    time: (90, 90),
1429                },
1430            )
1431
1432        # Neutralize heartbeat (Done did when dead.)
1433        else:
1434            # Ew; janky old scenev1 has a single 'Node' Python type so
1435            # it thinks heart.scale could be a few different things
1436            # (float, Sequence[float], etc.). So we have to force the
1437            # issue with a cast(). This should go away with scenev2/etc.
1438            bs.animate_array(
1439                heart,
1440                'scale',
1441                2,
1442                {
1443                    0.0: cast(Sequence[float], heart.scale),
1444                    time: (90, 90),
1445                },
1446            )
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'>
Inherited Members
enum.Enum
name
value
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'>
Inherited Members
enum.Enum
name
value
@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
Inherited Members
bascenev1._player.Player
character
actor
color
highlight
on_expire
team
customdata
sessionplayer
node
position
exists
getname
is_alive
get_icon
assigninput
resetinput
class Team(bascenev1._team.Team[bascenev1lib.game.runaround.Player]):
104class Team(bs.Team[Player]):
105    """Our team type for this game."""

Our team type for this game.

Inherited Members
bascenev1._team.Team
players
id
name
color
manual_init
customdata
on_expire
sessionteam
class RunaroundGame(bascenev1._coopgame.CoopGameActivity[bascenev1lib.game.runaround.Player, bascenev1lib.game.runaround.Team]):
 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.continue_or_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 on_continue(self) -> None:
 619        self._lives = 3
 620        assert self._lives_text is not None
 621        assert self._lives_text.node
 622        self._lives_text.node.text = str(self._lives)
 623        self._bots.start_moving()
 624
 625    @override
 626    def spawn_player(self, player: Player) -> bs.Actor:
 627        pos = (
 628            self._spawn_center[0] + random.uniform(-1.5, 1.5),
 629            self._spawn_center[1],
 630            self._spawn_center[2] + random.uniform(-1.5, 1.5),
 631        )
 632        spaz = self.spawn_player_spaz(player, position=pos)
 633        if self._preset in {Preset.PRO_EASY, Preset.UBER_EASY}:
 634            spaz.impact_scale = 0.25
 635
 636        # Add the material that causes us to hit the player-wall.
 637        spaz.pick_up_powerup_callback = self._on_player_picked_up_powerup
 638        return spaz
 639
 640    def _on_player_picked_up_powerup(self, player: bs.Actor) -> None:
 641        del player  # Unused.
 642        self._player_has_picked_up_powerup = True
 643
 644    def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None:
 645        if poweruptype is None:
 646            poweruptype = PowerupBoxFactory.get().get_random_powerup_type(
 647                excludetypes=self._exclude_powerups
 648            )
 649        PowerupBox(
 650            position=self.map.powerup_spawn_points[index],
 651            poweruptype=poweruptype,
 652        ).autoretain()
 653
 654    def _start_powerup_drops(self) -> None:
 655        bs.timer(3.0, self._drop_powerups, repeat=True)
 656
 657    def _drop_powerups(
 658        self, standard_points: bool = False, force_first: str | None = None
 659    ) -> None:
 660        """Generic powerup drop."""
 661
 662        # If its been a minute since our last wave finished emerging, stop
 663        # giving out land-mine powerups. (prevents players from waiting
 664        # around for them on purpose and filling the map up)
 665        if bs.time() - self._last_wave_end_time > 60.0:
 666            extra_excludes = ['land_mines']
 667        else:
 668            extra_excludes = []
 669
 670        if standard_points:
 671            points = self.map.powerup_spawn_points
 672            for i in range(len(points)):
 673                bs.timer(
 674                    1.0 + i * 0.5,
 675                    bs.Call(
 676                        self._drop_powerup, i, force_first if i == 0 else None
 677                    ),
 678                )
 679        else:
 680            pos = (
 681                self._powerup_center[0]
 682                + random.uniform(
 683                    -1.0 * self._powerup_spread[0],
 684                    1.0 * self._powerup_spread[0],
 685                ),
 686                self._powerup_center[1],
 687                self._powerup_center[2]
 688                + random.uniform(
 689                    -self._powerup_spread[1], self._powerup_spread[1]
 690                ),
 691            )
 692
 693            # drop one random one somewhere..
 694            assert self._exclude_powerups is not None
 695            PowerupBox(
 696                position=pos,
 697                poweruptype=PowerupBoxFactory.get().get_random_powerup_type(
 698                    excludetypes=self._exclude_powerups + extra_excludes
 699                ),
 700            ).autoretain()
 701
 702    @override
 703    def end_game(self) -> None:
 704        bs.pushcall(bs.Call(self.do_end, 'defeat'))
 705        bs.setmusic(None)
 706        self._player_death_sound.play()
 707
 708    def do_end(self, outcome: str) -> None:
 709        """End the game now with the provided outcome."""
 710
 711        if outcome == 'defeat':
 712            delay = 2.0
 713            self.fade_to_red()
 714        else:
 715            delay = 0
 716
 717        score: int | None
 718        if self._wavenum >= 2:
 719            score = self._score
 720            fail_message = None
 721        else:
 722            score = None
 723            fail_message = bs.Lstr(resource='reachWave2Text')
 724
 725        self.end(
 726            delay=delay,
 727            results={
 728                'outcome': outcome,
 729                'score': score,
 730                'fail_message': fail_message,
 731                'playerinfos': self.initialplayerinfos,
 732            },
 733        )
 734
 735    def _update_waves(self) -> None:
 736        # pylint: disable=too-many-branches
 737
 738        # If we have no living bots, go to the next wave.
 739        if (
 740            self._can_end_wave
 741            and not self._bots.have_living_bots()
 742            and not self._game_over
 743            and self._lives > 0
 744        ):
 745            self._can_end_wave = False
 746            self._time_bonus_timer = None
 747            self._time_bonus_text = None
 748
 749            if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
 750                won = False
 751            else:
 752                assert self._waves is not None
 753                won = self._wavenum == len(self._waves)
 754
 755            # Reward time bonus.
 756            base_delay = 4.0 if won else 0
 757            if self._time_bonus > 0:
 758                bs.timer(0, self._cashregistersound.play)
 759                bs.timer(
 760                    base_delay,
 761                    bs.Call(self._award_time_bonus, self._time_bonus),
 762                )
 763                base_delay += 1.0
 764
 765            # Reward flawless bonus.
 766            if self._wavenum > 0 and self._flawless:
 767                bs.timer(base_delay, self._award_flawless_bonus)
 768                base_delay += 1.0
 769
 770            self._flawless = True  # reset
 771
 772            if won:
 773                # Completion achievements:
 774                if self._preset in {Preset.PRO, Preset.PRO_EASY}:
 775                    self._award_achievement(
 776                        'Pro Runaround Victory', sound=False
 777                    )
 778                    if self._lives == self._start_lives:
 779                        self._award_achievement('The Wall', sound=False)
 780                    if not self._player_has_picked_up_powerup:
 781                        self._award_achievement(
 782                            'Precision Bombing', sound=False
 783                        )
 784                elif self._preset in {Preset.UBER, Preset.UBER_EASY}:
 785                    self._award_achievement(
 786                        'Uber Runaround Victory', sound=False
 787                    )
 788                    if self._lives == self._start_lives:
 789                        self._award_achievement('The Great Wall', sound=False)
 790                    if not self._a_player_has_been_killed:
 791                        self._award_achievement('Stayin\' Alive', sound=False)
 792
 793                # Give remaining players some points and have them celebrate.
 794                self.show_zoom_message(
 795                    bs.Lstr(resource='victoryText'), scale=1.0, duration=4.0
 796                )
 797
 798                self.celebrate(10.0)
 799                bs.timer(base_delay, self._award_lives_bonus)
 800                base_delay += 1.0
 801                bs.timer(base_delay, self._award_completion_bonus)
 802                base_delay += 0.85
 803                self._winsound.play()
 804                bs.cameraflash()
 805                bs.setmusic(bs.MusicType.VICTORY)
 806                self._game_over = True
 807                bs.timer(base_delay, bs.Call(self.do_end, 'victory'))
 808                return
 809
 810            self._wavenum += 1
 811
 812            # Short celebration after waves.
 813            if self._wavenum > 1:
 814                self.celebrate(0.5)
 815
 816            bs.timer(base_delay, self._start_next_wave)
 817
 818    def _award_completion_bonus(self) -> None:
 819        bonus = 200
 820        self._cashregistersound.play()
 821        PopupText(
 822            bs.Lstr(
 823                value='+${A} ${B}',
 824                subs=[
 825                    ('${A}', str(bonus)),
 826                    ('${B}', bs.Lstr(resource='completionBonusText')),
 827                ],
 828            ),
 829            color=(0.7, 0.7, 1.0, 1),
 830            scale=1.6,
 831            position=(0, 1.5, -1),
 832        ).autoretain()
 833        self._score += bonus
 834        self._update_scores()
 835
 836    def _award_lives_bonus(self) -> None:
 837        bonus = self._lives * 30
 838        self._cashregistersound.play()
 839        PopupText(
 840            bs.Lstr(
 841                value='+${A} ${B}',
 842                subs=[
 843                    ('${A}', str(bonus)),
 844                    ('${B}', bs.Lstr(resource='livesBonusText')),
 845                ],
 846            ),
 847            color=(0.7, 1.0, 0.3, 1),
 848            scale=1.3,
 849            position=(0, 1, -1),
 850        ).autoretain()
 851        self._score += bonus
 852        self._update_scores()
 853
 854    def _award_time_bonus(self, bonus: int) -> None:
 855        self._cashregistersound.play()
 856        PopupText(
 857            bs.Lstr(
 858                value='+${A} ${B}',
 859                subs=[
 860                    ('${A}', str(bonus)),
 861                    ('${B}', bs.Lstr(resource='timeBonusText')),
 862                ],
 863            ),
 864            color=(1, 1, 0.5, 1),
 865            scale=1.0,
 866            position=(0, 3, -1),
 867        ).autoretain()
 868
 869        self._score += self._time_bonus
 870        self._update_scores()
 871
 872    def _award_flawless_bonus(self) -> None:
 873        self._cashregistersound.play()
 874        PopupText(
 875            bs.Lstr(
 876                value='+${A} ${B}',
 877                subs=[
 878                    ('${A}', str(self._flawless_bonus)),
 879                    ('${B}', bs.Lstr(resource='perfectWaveText')),
 880                ],
 881            ),
 882            color=(1, 1, 0.2, 1),
 883            scale=1.2,
 884            position=(0, 2, -1),
 885        ).autoretain()
 886
 887        assert self._flawless_bonus is not None
 888        self._score += self._flawless_bonus
 889        self._update_scores()
 890
 891    def _start_time_bonus_timer(self) -> None:
 892        self._time_bonus_timer = bs.Timer(
 893            1.0, self._update_time_bonus, repeat=True
 894        )
 895
 896    def _start_next_wave(self) -> None:
 897        # FIXME: Need to split this up.
 898        # pylint: disable=too-many-locals
 899        # pylint: disable=too-many-branches
 900        # pylint: disable=too-many-statements
 901        self.show_zoom_message(
 902            bs.Lstr(
 903                value='${A} ${B}',
 904                subs=[
 905                    ('${A}', bs.Lstr(resource='waveText')),
 906                    ('${B}', str(self._wavenum)),
 907                ],
 908            ),
 909            scale=1.0,
 910            duration=1.0,
 911            trail=True,
 912        )
 913        bs.timer(0.4, self._new_wave_sound.play)
 914        t_sec = 0.0
 915        base_delay = 0.5
 916        delay = 0.0
 917        bot_types: list[Spawn | Spacing | None] = []
 918
 919        if self._preset in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}:
 920            level = self._wavenum
 921            target_points = (level + 1) * 8.0
 922            group_count = random.randint(1, 3)
 923            entries: list[Spawn | Spacing | None] = []
 924            spaz_types: list[tuple[type[SpazBot], float]] = []
 925            if level < 6:
 926                spaz_types += [(BomberBot, 5.0)]
 927            if level < 10:
 928                spaz_types += [(BrawlerBot, 5.0)]
 929            if level < 15:
 930                spaz_types += [(TriggerBot, 6.0)]
 931            if level > 5:
 932                spaz_types += [(TriggerBotPro, 7.5)] * (1 + (level - 5) // 7)
 933            if level > 2:
 934                spaz_types += [(BomberBotProShielded, 8.0)] * (
 935                    1 + (level - 2) // 6
 936                )
 937            if level > 6:
 938                spaz_types += [(TriggerBotProShielded, 12.0)] * (
 939                    1 + (level - 6) // 5
 940                )
 941            if level > 1:
 942                spaz_types += [(ChargerBot, 10.0)] * (1 + (level - 1) // 4)
 943            if level > 7:
 944                spaz_types += [(ChargerBotProShielded, 15.0)] * (
 945                    1 + (level - 7) // 3
 946                )
 947
 948            # Bot type, their effect on target points.
 949            defender_types: list[tuple[type[SpazBot], float]] = [
 950                (BomberBot, 0.9),
 951                (BrawlerBot, 0.9),
 952                (TriggerBot, 0.85),
 953            ]
 954            if level > 2:
 955                defender_types += [(ChargerBot, 0.75)]
 956            if level > 4:
 957                defender_types += [(StickyBot, 0.7)] * (1 + (level - 5) // 6)
 958            if level > 6:
 959                defender_types += [(ExplodeyBot, 0.7)] * (1 + (level - 5) // 5)
 960            if level > 8:
 961                defender_types += [(BrawlerBotProShielded, 0.65)] * (
 962                    1 + (level - 5) // 4
 963                )
 964            if level > 10:
 965                defender_types += [(TriggerBotProShielded, 0.6)] * (
 966                    1 + (level - 6) // 3
 967                )
 968
 969            for group in range(group_count):
 970                this_target_point_s = target_points / group_count
 971
 972                # Adding spacing makes things slightly harder.
 973                rval = random.random()
 974                if rval < 0.07:
 975                    spacing = 1.5
 976                    this_target_point_s *= 0.85
 977                elif rval < 0.15:
 978                    spacing = 1.0
 979                    this_target_point_s *= 0.9
 980                else:
 981                    spacing = 0.0
 982
 983                path = random.randint(1, 3)
 984
 985                # Don't allow hard paths on early levels.
 986                if level < 3:
 987                    if path == 1:
 988                        path = 3
 989
 990                # Easy path.
 991                if path == 3:
 992                    pass
 993
 994                # Harder path.
 995                elif path == 2:
 996                    this_target_point_s *= 0.8
 997
 998                # Even harder path.
 999                elif path == 1:
1000                    this_target_point_s *= 0.7
1001
1002                # Looping forward.
1003                elif path == 4:
1004                    this_target_point_s *= 0.7
1005
1006                # Looping backward.
1007                elif path == 5:
1008                    this_target_point_s *= 0.7
1009
1010                # Random.
1011                elif path == 6:
1012                    this_target_point_s *= 0.7
1013
1014                def _add_defender(
1015                    defender_type: tuple[type[SpazBot], float], pnt: Point
1016                ) -> tuple[float, Spawn]:
1017                    # This is ok because we call it immediately.
1018                    # pylint: disable=cell-var-from-loop
1019                    return this_target_point_s * defender_type[1], Spawn(
1020                        defender_type[0], point=pnt
1021                    )
1022
1023                # Add defenders.
1024                defender_type1 = defender_types[
1025                    random.randrange(len(defender_types))
1026                ]
1027                defender_type2 = defender_types[
1028                    random.randrange(len(defender_types))
1029                ]
1030                defender1 = defender2 = None
1031                if (
1032                    (group == 0)
1033                    or (group == 1 and level > 3)
1034                    or (group == 2 and level > 5)
1035                ):
1036                    if random.random() < min(0.75, (level - 1) * 0.11):
1037                        this_target_point_s, defender1 = _add_defender(
1038                            defender_type1, Point.BOTTOM_LEFT
1039                        )
1040                    if random.random() < min(0.75, (level - 1) * 0.04):
1041                        this_target_point_s, defender2 = _add_defender(
1042                            defender_type2, Point.BOTTOM_RIGHT
1043                        )
1044
1045                spaz_type = spaz_types[random.randrange(len(spaz_types))]
1046                member_count = max(
1047                    1, int(round(this_target_point_s / spaz_type[1]))
1048                )
1049                for i, _member in enumerate(range(member_count)):
1050                    if path == 4:
1051                        this_path = i % 3  # Looping forward.
1052                    elif path == 5:
1053                        this_path = 3 - (i % 3)  # Looping backward.
1054                    elif path == 6:
1055                        this_path = random.randint(1, 3)  # Random.
1056                    else:
1057                        this_path = path
1058                    entries.append(Spawn(spaz_type[0], path=this_path))
1059                    if spacing != 0.0:
1060                        entries.append(Spacing(duration=spacing))
1061
1062                if defender1 is not None:
1063                    entries.append(defender1)
1064                if defender2 is not None:
1065                    entries.append(defender2)
1066
1067                # Some spacing between groups.
1068                rval = random.random()
1069                if rval < 0.1:
1070                    spacing = 5.0
1071                elif rval < 0.5:
1072                    spacing = 1.0
1073                else:
1074                    spacing = 1.0
1075                entries.append(Spacing(duration=spacing))
1076
1077            wave = Wave(entries=entries)
1078
1079        else:
1080            assert self._waves is not None
1081            wave = self._waves[self._wavenum - 1]
1082
1083        bot_types += wave.entries
1084        self._time_bonus_mult = 1.0
1085        this_flawless_bonus = 0
1086        non_runner_spawn_time = 1.0
1087
1088        for info in bot_types:
1089            if info is None:
1090                continue
1091            if isinstance(info, Spacing):
1092                t_sec += info.duration
1093                continue
1094            bot_type = info.type
1095            path = info.path
1096            self._time_bonus_mult += bot_type.points_mult * 0.02
1097            this_flawless_bonus += bot_type.points_mult * 5
1098
1099            # If its got a position, use that.
1100            if info.point is not None:
1101                point = info.point
1102            else:
1103                point = Point.START
1104
1105            # Space our our slower bots.
1106            delay = base_delay
1107            delay /= self._get_bot_speed(bot_type)
1108            t_sec += delay * 0.5
1109            tcall = bs.Call(
1110                self.add_bot_at_point,
1111                point,
1112                bot_type,
1113                path,
1114                0.1 if point is Point.START else non_runner_spawn_time,
1115            )
1116            bs.timer(t_sec, tcall)
1117            t_sec += delay * 0.5
1118
1119        # We can end the wave after all the spawning happens.
1120        bs.timer(
1121            t_sec - delay * 0.5 + non_runner_spawn_time + 0.01,
1122            self._set_can_end_wave,
1123        )
1124
1125        # Reset our time bonus.
1126        # In this game we use a constant time bonus so it erodes away in
1127        # roughly the same time (since the time limit a wave can take is
1128        # relatively constant) ..we then post-multiply a modifier to adjust
1129        # points.
1130        self._time_bonus = 150
1131        self._flawless_bonus = this_flawless_bonus
1132        assert self._time_bonus_mult is not None
1133        txtval = bs.Lstr(
1134            value='${A}: ${B}',
1135            subs=[
1136                ('${A}', bs.Lstr(resource='timeBonusText')),
1137                ('${B}', str(int(self._time_bonus * self._time_bonus_mult))),
1138            ],
1139        )
1140        self._time_bonus_text = bs.NodeActor(
1141            bs.newnode(
1142                'text',
1143                attrs={
1144                    'v_attach': 'top',
1145                    'h_attach': 'center',
1146                    'h_align': 'center',
1147                    'color': (1, 1, 0.0, 1),
1148                    'shadow': 1.0,
1149                    'vr_depth': -30,
1150                    'flatness': 1.0,
1151                    'position': (0, -60),
1152                    'scale': 0.8,
1153                    'text': txtval,
1154                },
1155            )
1156        )
1157
1158        bs.timer(t_sec, self._start_time_bonus_timer)
1159
1160        # Keep track of when this wave finishes emerging. We wanna stop
1161        # dropping land-mines powerups at some point (otherwise a crafty
1162        # player could fill the whole map with them)
1163        self._last_wave_end_time = bs.Time(bs.time() + t_sec)
1164        totalwaves = str(len(self._waves)) if self._waves is not None else '??'
1165        txtval = bs.Lstr(
1166            value='${A} ${B}',
1167            subs=[
1168                ('${A}', bs.Lstr(resource='waveText')),
1169                (
1170                    '${B}',
1171                    str(self._wavenum)
1172                    + (
1173                        ''
1174                        if self._preset
1175                        in {Preset.ENDLESS, Preset.ENDLESS_TOURNAMENT}
1176                        else f'/{totalwaves}'
1177                    ),
1178                ),
1179            ],
1180        )
1181        self._wave_text = bs.NodeActor(
1182            bs.newnode(
1183                'text',
1184                attrs={
1185                    'v_attach': 'top',
1186                    'h_attach': 'center',
1187                    'h_align': 'center',
1188                    'vr_depth': -10,
1189                    'color': (1, 1, 1, 1),
1190                    'shadow': 1.0,
1191                    'flatness': 1.0,
1192                    'position': (0, -40),
1193                    'scale': 1.3,
1194                    'text': txtval,
1195                },
1196            )
1197        )
1198
1199    def _on_bot_spawn(self, path: int, spaz: SpazBot) -> None:
1200        # Add our custom update callback and set some info for this bot.
1201        spaz_type = type(spaz)
1202        assert spaz is not None
1203        spaz.update_callback = self._update_bot
1204
1205        # Tack some custom attrs onto the spaz.
1206        setattr(spaz, 'r_walk_row', path)
1207        setattr(spaz, 'r_walk_speed', self._get_bot_speed(spaz_type))
1208
1209    def add_bot_at_point(
1210        self,
1211        point: Point,
1212        spaztype: type[SpazBot],
1213        path: int,
1214        spawn_time: float = 0.1,
1215    ) -> None:
1216        """Add the given type bot with the given delay (in seconds)."""
1217
1218        # Don't add if the game has ended.
1219        if self._game_over:
1220            return
1221        pos = self.map.defs.points[point.value][:3]
1222        self._bots.spawn_bot(
1223            spaztype,
1224            pos=pos,
1225            spawn_time=spawn_time,
1226            on_spawn_call=bs.Call(self._on_bot_spawn, path),
1227        )
1228
1229    def _update_time_bonus(self) -> None:
1230        self._time_bonus = int(self._time_bonus * 0.91)
1231        if self._time_bonus > 0 and self._time_bonus_text is not None:
1232            assert self._time_bonus_text.node
1233            assert self._time_bonus_mult
1234            self._time_bonus_text.node.text = bs.Lstr(
1235                value='${A}: ${B}',
1236                subs=[
1237                    ('${A}', bs.Lstr(resource='timeBonusText')),
1238                    (
1239                        '${B}',
1240                        str(int(self._time_bonus * self._time_bonus_mult)),
1241                    ),
1242                ],
1243            )
1244        else:
1245            self._time_bonus_text = None
1246
1247    def _start_updating_waves(self) -> None:
1248        self._wave_update_timer = bs.Timer(2.0, self._update_waves, repeat=True)
1249
1250    def _update_scores(self) -> None:
1251        score = self._score
1252        if self._preset is Preset.ENDLESS:
1253            if score >= 500:
1254                self._award_achievement('Runaround Master')
1255            if score >= 1000:
1256                self._award_achievement('Runaround Wizard')
1257            if score >= 2000:
1258                self._award_achievement('Runaround God')
1259
1260        assert self._scoreboard is not None
1261        self._scoreboard.set_team_value(self.teams[0], score, max_score=None)
1262
1263    def _update_bot(self, bot: SpazBot) -> bool:
1264        # Yup; that's a lot of return statements right there.
1265        # pylint: disable=too-many-return-statements
1266
1267        if not bool(bot):
1268            return True
1269
1270        assert bot.node
1271
1272        # FIXME: Do this in a type safe way.
1273        r_walk_speed: float = getattr(bot, 'r_walk_speed')
1274        r_walk_row: int = getattr(bot, 'r_walk_row')
1275
1276        speed = r_walk_speed
1277        pos = bot.node.position
1278        boxes = self.map.defs.boxes
1279
1280        # Bots in row 1 attempt the high road..
1281        if r_walk_row == 1:
1282            if bs.is_point_in_box(pos, boxes['b4']):
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        # Row 1 and 2 bots attempt the middle road..
1289        if r_walk_row in [1, 2]:
1290            if bs.is_point_in_box(pos, boxes['b1']):
1291                bot.node.move_up_down = speed
1292                bot.node.move_left_right = 0
1293                bot.node.run = 0.0
1294                return True
1295
1296        # All bots settle for the third row.
1297        if bs.is_point_in_box(pos, boxes['b7']):
1298            bot.node.move_up_down = speed
1299            bot.node.move_left_right = 0
1300            bot.node.run = 0.0
1301            return True
1302        if bs.is_point_in_box(pos, boxes['b2']):
1303            bot.node.move_up_down = -speed
1304            bot.node.move_left_right = 0
1305            bot.node.run = 0.0
1306            return True
1307        if bs.is_point_in_box(pos, boxes['b3']):
1308            bot.node.move_up_down = -speed
1309            bot.node.move_left_right = 0
1310            bot.node.run = 0.0
1311            return True
1312        if bs.is_point_in_box(pos, boxes['b5']):
1313            bot.node.move_up_down = -speed
1314            bot.node.move_left_right = 0
1315            bot.node.run = 0.0
1316            return True
1317        if bs.is_point_in_box(pos, boxes['b6']):
1318            bot.node.move_up_down = speed
1319            bot.node.move_left_right = 0
1320            bot.node.run = 0.0
1321            return True
1322        if (
1323            bs.is_point_in_box(pos, boxes['b8'])
1324            and not bs.is_point_in_box(pos, boxes['b9'])
1325        ) or pos == (0.0, 0.0, 0.0):
1326            # Default to walking right if we're still in the walking area.
1327            bot.node.move_left_right = speed
1328            bot.node.move_up_down = 0
1329            bot.node.run = 0.0
1330            return True
1331
1332        # Revert to normal bot behavior otherwise..
1333        return False
1334
1335    @override
1336    def handlemessage(self, msg: Any) -> Any:
1337        if isinstance(msg, bs.PlayerScoredMessage):
1338            self._score += msg.score
1339            self._update_scores()
1340
1341        elif isinstance(msg, bs.PlayerDiedMessage):
1342            # Augment standard behavior.
1343            super().handlemessage(msg)
1344
1345            self._a_player_has_been_killed = True
1346
1347            # Respawn them shortly.
1348            player = msg.getplayer(Player)
1349            assert self.initialplayerinfos is not None
1350            respawn_time = 2.0 + len(self.initialplayerinfos) * 1.0
1351            player.respawn_timer = bs.Timer(
1352                respawn_time, bs.Call(self.spawn_player_if_exists, player)
1353            )
1354            player.respawn_icon = RespawnIcon(player, respawn_time)
1355
1356        elif isinstance(msg, SpazBotDiedMessage):
1357            if msg.how is bs.DeathType.REACHED_GOAL:
1358                return None
1359            pts, importance = msg.spazbot.get_death_points(msg.how)
1360            if msg.killerplayer is not None:
1361                target: Sequence[float] | None
1362                try:
1363                    assert msg.spazbot is not None
1364                    assert msg.spazbot.node
1365                    target = msg.spazbot.node.position
1366                except Exception:
1367                    logging.exception('Error getting SpazBotDied target.')
1368                    target = None
1369                try:
1370                    if msg.killerplayer:
1371                        self.stats.player_scored(
1372                            msg.killerplayer,
1373                            pts,
1374                            target=target,
1375                            kill=True,
1376                            screenmessage=False,
1377                            importance=importance,
1378                        )
1379                        dingsound = (
1380                            self._dingsound
1381                            if importance == 1
1382                            else self._dingsoundhigh
1383                        )
1384                        dingsound.play(volume=0.6)
1385                except Exception:
1386                    logging.exception('Error on SpazBotDiedMessage.')
1387
1388            # Normally we pull scores from the score-set, but if there's no
1389            # player lets be explicit.
1390            else:
1391                self._score += pts
1392            self._update_scores()
1393
1394        else:
1395            return super().handlemessage(msg)
1396        return None
1397
1398    def _get_bot_speed(self, bot_type: type[SpazBot]) -> float:
1399        speed = self._bot_speed_map.get(bot_type)
1400        if speed is None:
1401            raise TypeError(
1402                'Invalid bot type to _get_bot_speed(): ' + str(bot_type)
1403            )
1404        return speed
1405
1406    def _set_can_end_wave(self) -> None:
1407        self._can_end_wave = True
1408
1409    def heart_dyin(self, status: bool, time: float = 1.22) -> None:
1410        """Makes the UI heart beat at low health."""
1411        assert self._lives_bg is not None
1412        if self._lives_bg.node.exists():
1413            return
1414        heart = self._lives_bg.node
1415
1416        # Make the heart beat intensely!
1417        if status:
1418            bs.animate_array(
1419                heart,
1420                'scale',
1421                2,
1422                {
1423                    0: (90, 90),
1424                    time * 0.1: (105, 105),
1425                    time * 0.21: (88, 88),
1426                    time * 0.42: (90, 90),
1427                    time * 0.52: (105, 105),
1428                    time * 0.63: (88, 88),
1429                    time: (90, 90),
1430                },
1431            )
1432
1433        # Neutralize heartbeat (Done did when dead.)
1434        else:
1435            # Ew; janky old scenev1 has a single 'Node' Python type so
1436            # it thinks heart.scale could be a few different things
1437            # (float, Sequence[float], etc.). So we have to force the
1438            # issue with a cast(). This should go away with scenev2/etc.
1439            bs.animate_array(
1440                heart,
1441                'scale',
1442                2,
1443                {
1444                    0.0: cast(Sequence[float], heart.scale),
1445                    time: (90, 90),
1446                },
1447            )

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 on_continue(self) -> None:
617    @override
618    def on_continue(self) -> None:
619        self._lives = 3
620        assert self._lives_text is not None
621        assert self._lives_text.node
622        self._lives_text.node.text = str(self._lives)
623        self._bots.start_moving()

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

@override
def spawn_player( self, player: Player) -> bascenev1._actor.Actor:
625    @override
626    def spawn_player(self, player: Player) -> bs.Actor:
627        pos = (
628            self._spawn_center[0] + random.uniform(-1.5, 1.5),
629            self._spawn_center[1],
630            self._spawn_center[2] + random.uniform(-1.5, 1.5),
631        )
632        spaz = self.spawn_player_spaz(player, position=pos)
633        if self._preset in {Preset.PRO_EASY, Preset.UBER_EASY}:
634            spaz.impact_scale = 0.25
635
636        # Add the material that causes us to hit the player-wall.
637        spaz.pick_up_powerup_callback = self._on_player_picked_up_powerup
638        return spaz

Spawn something for the provided bascenev1.Player.

The default implementation simply calls spawn_player_spaz().

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

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

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

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

General message handling; can be passed any message object.

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

Makes the UI heart beat at low health.

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