bascenev1lib.actor.spazbot

Bot versions of Spaz.

   1# Released under the MIT License. See LICENSE for details.
   2#
   3"""Bot versions of Spaz."""
   4# pylint: disable=too-many-lines
   5
   6from __future__ import annotations
   7
   8import random
   9import weakref
  10import logging
  11from typing import TYPE_CHECKING, override
  12
  13import bascenev1 as bs
  14from bascenev1lib.actor.spaz import Spaz
  15
  16if TYPE_CHECKING:
  17    from typing import Any, Sequence, Callable
  18    from bascenev1lib.actor.flag import Flag
  19
  20LITE_BOT_COLOR = (1.2, 0.9, 0.2)
  21LITE_BOT_HIGHLIGHT = (1.0, 0.5, 0.6)
  22DEFAULT_BOT_COLOR = (0.6, 0.6, 0.6)
  23DEFAULT_BOT_HIGHLIGHT = (0.1, 0.3, 0.1)
  24PRO_BOT_COLOR = (1.0, 0.2, 0.1)
  25PRO_BOT_HIGHLIGHT = (0.6, 0.1, 0.05)
  26
  27
  28class SpazBotPunchedMessage:
  29    """A message saying a bs.SpazBot got punched."""
  30
  31    spazbot: SpazBot
  32    """The bs.SpazBot that got punched."""
  33
  34    damage: int
  35    """How much damage was done to the SpazBot."""
  36
  37    def __init__(self, spazbot: SpazBot, damage: int):
  38        """Instantiate a message with the given values."""
  39        self.spazbot = spazbot
  40        self.damage = damage
  41
  42
  43class SpazBotDiedMessage:
  44    """A message saying a bs.SpazBot has died."""
  45
  46    spazbot: SpazBot
  47    """The SpazBot that was killed."""
  48
  49    killerplayer: bs.Player | None
  50    """The bascenev1.Player that killed it (or None)."""
  51
  52    how: bs.DeathType
  53    """The particular type of death."""
  54
  55    def __init__(
  56        self,
  57        spazbot: SpazBot,
  58        killerplayer: bs.Player | None,
  59        how: bs.DeathType,
  60    ):
  61        """Instantiate with given values."""
  62        self.spazbot = spazbot
  63        self.killerplayer = killerplayer
  64        self.how = how
  65
  66
  67class SpazBot(Spaz):
  68    """A really dumb AI version of bs.Spaz.
  69
  70    Add these to a bs.BotSet to use them.
  71
  72    Note: currently the AI has no real ability to
  73    navigate obstacles and so should only be used
  74    on wide-open maps.
  75
  76    When a SpazBot is killed, it delivers a bs.SpazBotDiedMessage
  77    to the current activity.
  78
  79    When a SpazBot is punched, it delivers a bs.SpazBotPunchedMessage
  80    to the current activity.
  81    """
  82
  83    character = 'Spaz'
  84    punchiness = 0.5
  85    throwiness = 0.7
  86    static = False
  87    bouncy = False
  88    run = False
  89    charge_dist_min = 0.0  # When we can start a new charge.
  90    charge_dist_max = 2.0  # When we can start a new charge.
  91    run_dist_min = 0.0  # How close we can be to continue running.
  92    charge_speed_min = 0.4
  93    charge_speed_max = 1.0
  94    throw_dist_min = 5.0
  95    throw_dist_max = 9.0
  96    throw_rate = 1.0
  97    default_bomb_type = 'normal'
  98    default_bomb_count = 3
  99    start_cursed = False
 100    color = DEFAULT_BOT_COLOR
 101    highlight = DEFAULT_BOT_HIGHLIGHT
 102
 103    def __init__(self) -> None:
 104        """Instantiate a spaz-bot."""
 105        super().__init__(
 106            color=self.color,
 107            highlight=self.highlight,
 108            character=self.character,
 109            source_player=None,
 110            start_invincible=False,
 111            can_accept_powerups=False,
 112        )
 113
 114        # If you need to add custom behavior to a bot, set this to a callable
 115        # which takes one arg (the bot) and returns False if the bot's normal
 116        # update should be run and True if not.
 117        self.update_callback: Callable[[SpazBot], Any] | None = None
 118        activity = self.activity
 119        assert isinstance(activity, bs.GameActivity)
 120        self._map = weakref.ref(activity.map)
 121        self.last_player_attacked_by: bs.Player | None = None
 122        self.last_attacked_time = 0.0
 123        self.last_attacked_type: tuple[str, str] | None = None
 124        self.target_point_default: bs.Vec3 | None = None
 125        self.held_count = 0
 126        self.last_player_held_by: bs.Player | None = None
 127        self.target_flag: Flag | None = None
 128        self._charge_speed = 0.5 * (
 129            self.charge_speed_min + self.charge_speed_max
 130        )
 131        self._lead_amount = 0.5
 132        self._mode = 'wait'
 133        self._charge_closing_in = False
 134        self._last_charge_dist = 0.0
 135        self._running = False
 136        self._last_jump_time = 0.0
 137
 138        self._throw_release_time: float | None = None
 139        self._have_dropped_throw_bomb: bool | None = None
 140        self._player_pts: list[tuple[bs.Vec3, bs.Vec3]] | None = None
 141
 142        # These cooldowns didn't exist when these bots were calibrated,
 143        # so take them out of the equation.
 144        self._jump_cooldown = 0
 145        self._pickup_cooldown = 0
 146        self._fly_cooldown = 0
 147        self._bomb_cooldown = 0
 148
 149        if self.start_cursed:
 150            self.curse()
 151
 152    @property
 153    def map(self) -> bs.Map:
 154        """The map this bot was created on."""
 155        mval = self._map()
 156        assert mval is not None
 157        return mval
 158
 159    def _get_target_player_pt(self) -> tuple[bs.Vec3 | None, bs.Vec3 | None]:
 160        """Returns the position and velocity of our target.
 161
 162        Both values will be None in the case of no target.
 163        """
 164        assert self.node
 165        botpt = bs.Vec3(self.node.position)
 166        closest_dist: float | None = None
 167        closest_vel: bs.Vec3 | None = None
 168        closest: bs.Vec3 | None = None
 169        assert self._player_pts is not None
 170        for plpt, plvel in self._player_pts:
 171            dist = (plpt - botpt).length()
 172
 173            # Ignore player-points that are significantly below the bot
 174            # (keeps bots from following players off cliffs).
 175            if (closest_dist is None or dist < closest_dist) and (
 176                plpt[1] > botpt[1] - 5.0
 177            ):
 178                closest_dist = dist
 179                closest_vel = plvel
 180                closest = plpt
 181        if closest_dist is not None:
 182            assert closest_vel is not None
 183            assert closest is not None
 184            return (
 185                bs.Vec3(closest[0], closest[1], closest[2]),
 186                bs.Vec3(closest_vel[0], closest_vel[1], closest_vel[2]),
 187            )
 188        return None, None
 189
 190    def set_player_points(self, pts: list[tuple[bs.Vec3, bs.Vec3]]) -> None:
 191        """Provide the spaz-bot with the locations of its enemies."""
 192        self._player_pts = pts
 193
 194    def update_ai(self) -> None:
 195        """Should be called periodically to update the spaz' AI."""
 196        # pylint: disable=too-many-branches
 197        # pylint: disable=too-many-statements
 198        # pylint: disable=too-many-locals
 199        if self.update_callback is not None:
 200            if self.update_callback(self):
 201                # Bot has been handled.
 202                return
 203
 204        if not self.node:
 205            return
 206
 207        pos = self.node.position
 208        our_pos = bs.Vec3(pos[0], 0, pos[2])
 209        can_attack = True
 210
 211        target_pt_raw: bs.Vec3 | None
 212        target_vel: bs.Vec3 | None
 213
 214        # If we're a flag-bearer, we're pretty simple-minded - just walk
 215        # towards the flag and try to pick it up.
 216        if self.target_flag:
 217            if self.node.hold_node:
 218                holding_flag = self.node.hold_node.getnodetype() == 'flag'
 219            else:
 220                holding_flag = False
 221
 222            # If we're holding the flag, just walk left.
 223            if holding_flag:
 224                # Just walk left.
 225                self.node.move_left_right = -1.0
 226                self.node.move_up_down = 0.0
 227
 228            # Otherwise try to go pick it up.
 229            elif self.target_flag.node:
 230                target_pt_raw = bs.Vec3(*self.target_flag.node.position)
 231                diff = target_pt_raw - our_pos
 232                diff = bs.Vec3(diff[0], 0, diff[2])  # Don't care about y.
 233                dist = diff.length()
 234                to_target = diff.normalized()
 235
 236                # If we're holding some non-flag item, drop it.
 237                if self.node.hold_node:
 238                    self.node.pickup_pressed = True
 239                    self.node.pickup_pressed = False
 240                    return
 241
 242                # If we're a runner, run only when not super-near the flag.
 243                if self.run and dist > 3.0:
 244                    self._running = True
 245                    self.node.run = 1.0
 246                else:
 247                    self._running = False
 248                    self.node.run = 0.0
 249
 250                self.node.move_left_right = to_target.x
 251                self.node.move_up_down = -to_target.z
 252                if dist < 1.25:
 253                    self.node.pickup_pressed = True
 254                    self.node.pickup_pressed = False
 255            return
 256
 257        # Not a flag-bearer. If we're holding anything but a bomb, drop it.
 258        if self.node.hold_node:
 259            holding_bomb = self.node.hold_node.getnodetype() in ['bomb', 'prop']
 260            if not holding_bomb:
 261                self.node.pickup_pressed = True
 262                self.node.pickup_pressed = False
 263                return
 264
 265        target_pt_raw, target_vel = self._get_target_player_pt()
 266
 267        if target_pt_raw is None:
 268            # Use default target if we've got one.
 269            if self.target_point_default is not None:
 270                target_pt_raw = self.target_point_default
 271                target_vel = bs.Vec3(0, 0, 0)
 272                can_attack = False
 273
 274            # With no target, we stop moving and drop whatever we're holding.
 275            else:
 276                self.node.move_left_right = 0
 277                self.node.move_up_down = 0
 278                if self.node.hold_node:
 279                    self.node.pickup_pressed = True
 280                    self.node.pickup_pressed = False
 281                return
 282
 283        # We don't want height to come into play.
 284        target_pt_raw[1] = 0.0
 285        assert target_vel is not None
 286        target_vel[1] = 0.0
 287
 288        dist_raw = (target_pt_raw - our_pos).length()
 289
 290        # Use a point out in front of them as real target.
 291        # (more out in front the farther from us they are)
 292        target_pt = (
 293            target_pt_raw + target_vel * dist_raw * 0.3 * self._lead_amount
 294        )
 295
 296        diff = target_pt - our_pos
 297        dist = diff.length()
 298        to_target = diff.normalized()
 299
 300        if self._mode == 'throw':
 301            # We can only throw if alive and well.
 302            if not self._dead and not self.node.knockout:
 303                assert self._throw_release_time is not None
 304                time_till_throw = self._throw_release_time - bs.time()
 305
 306                if not self.node.hold_node:
 307                    # If we haven't thrown yet, whip out the bomb.
 308                    if not self._have_dropped_throw_bomb:
 309                        self.drop_bomb()
 310                        self._have_dropped_throw_bomb = True
 311
 312                    # Otherwise our lack of held node means we successfully
 313                    # released our bomb; lets retreat now.
 314                    else:
 315                        self._mode = 'flee'
 316
 317                # Oh crap, we're holding a bomb; better throw it.
 318                elif time_till_throw <= 0.0:
 319                    # Jump and throw.
 320                    def _safe_pickup(node: bs.Node) -> None:
 321                        if node and self.node:
 322                            self.node.pickup_pressed = True
 323                            self.node.pickup_pressed = False
 324
 325                    if dist > 5.0:
 326                        self.node.jump_pressed = True
 327                        self.node.jump_pressed = False
 328
 329                        # Throws:
 330                        bs.timer(0.1, bs.Call(_safe_pickup, self.node))
 331                    else:
 332                        # Throws:
 333                        bs.timer(0.1, bs.Call(_safe_pickup, self.node))
 334
 335                if self.static:
 336                    if time_till_throw < 0.3:
 337                        speed = 1.0
 338                    elif time_till_throw < 0.7 and dist > 3.0:
 339                        speed = -1.0  # Whiplash for long throws.
 340                    else:
 341                        speed = 0.02
 342                else:
 343                    if time_till_throw < 0.7:
 344                        # Right before throw charge full speed towards target.
 345                        speed = 1.0
 346                    else:
 347                        # Earlier we can hold or move backward for a whiplash.
 348                        speed = 0.0125
 349                self.node.move_left_right = to_target.x * speed
 350                self.node.move_up_down = to_target.z * -1.0 * speed
 351
 352        elif self._mode == 'charge':
 353            if random.random() < 0.3:
 354                self._charge_speed = random.uniform(
 355                    self.charge_speed_min, self.charge_speed_max
 356                )
 357
 358                # If we're a runner we run during charges *except when near
 359                # an edge (otherwise we tend to fly off easily).
 360                if self.run and dist_raw > self.run_dist_min:
 361                    self._lead_amount = 0.3
 362                    self._running = True
 363                    self.node.run = 1.0
 364                else:
 365                    self._lead_amount = 0.01
 366                    self._running = False
 367                    self.node.run = 0.0
 368
 369            self.node.move_left_right = to_target.x * self._charge_speed
 370            self.node.move_up_down = to_target.z * -1.0 * self._charge_speed
 371
 372        elif self._mode == 'wait':
 373            # Every now and then, aim towards our target.
 374            # Other than that, just stand there.
 375            if int(bs.time() * 1000.0) % 1234 < 100:
 376                self.node.move_left_right = to_target.x * (400.0 / 33000)
 377                self.node.move_up_down = to_target.z * (-400.0 / 33000)
 378            else:
 379                self.node.move_left_right = 0
 380                self.node.move_up_down = 0
 381
 382        elif self._mode == 'flee':
 383            # Even if we're a runner, only run till we get away from our
 384            # target (if we keep running we tend to run off edges).
 385            if self.run and dist < 3.0:
 386                self._running = True
 387                self.node.run = 1.0
 388            else:
 389                self._running = False
 390                self.node.run = 0.0
 391            self.node.move_left_right = to_target.x * -1.0
 392            self.node.move_up_down = to_target.z
 393
 394        # We might wanna switch states unless we're doing a throw
 395        # (in which case that's our sole concern).
 396        if self._mode != 'throw':
 397            # If we're currently charging, keep track of how far we are
 398            # from our target. When this value increases it means our charge
 399            # is over (ran by them or something).
 400            if self._mode == 'charge':
 401                if (
 402                    self._charge_closing_in
 403                    and self._last_charge_dist < dist < 3.0
 404                ):
 405                    self._charge_closing_in = False
 406                self._last_charge_dist = dist
 407
 408            # If we have a clean shot, throw!
 409            if (
 410                self.throw_dist_min <= dist < self.throw_dist_max
 411                and random.random() < self.throwiness
 412                and can_attack
 413            ):
 414                self._mode = 'throw'
 415                self._lead_amount = (
 416                    (0.4 + random.random() * 0.6)
 417                    if dist_raw > 4.0
 418                    else (0.1 + random.random() * 0.4)
 419                )
 420                self._have_dropped_throw_bomb = False
 421                self._throw_release_time = bs.time() + (
 422                    1.0 / self.throw_rate
 423                ) * (0.8 + 1.3 * random.random())
 424
 425            # If we're static, always charge (which for us means barely move).
 426            elif self.static:
 427                self._mode = 'wait'
 428
 429            # If we're too close to charge (and aren't in the middle of an
 430            # existing charge) run away.
 431            elif dist < self.charge_dist_min and not self._charge_closing_in:
 432                # ..unless we're near an edge, in which case we've got no
 433                # choice but to charge.
 434                if self.map.is_point_near_edge(our_pos, self._running):
 435                    if self._mode != 'charge':
 436                        self._mode = 'charge'
 437                        self._lead_amount = 0.2
 438                        self._charge_closing_in = True
 439                        self._last_charge_dist = dist
 440                else:
 441                    self._mode = 'flee'
 442
 443            # We're within charging distance, backed against an edge,
 444            # or farther than our max throw distance.. chaaarge!
 445            elif (
 446                dist < self.charge_dist_max
 447                or dist > self.throw_dist_max
 448                or self.map.is_point_near_edge(our_pos, self._running)
 449            ):
 450                if self._mode != 'charge':
 451                    self._mode = 'charge'
 452                    self._lead_amount = 0.01
 453                    self._charge_closing_in = True
 454                    self._last_charge_dist = dist
 455
 456            # We're too close to throw but too far to charge - either run
 457            # away or just chill if we're near an edge.
 458            elif dist < self.throw_dist_min:
 459                # Charge if either we're within charge range or
 460                # cant retreat to throw.
 461                self._mode = 'flee'
 462
 463            # Do some awesome jumps if we're running.
 464            # FIXME: pylint: disable=too-many-boolean-expressions
 465            if (
 466                self._running
 467                and 1.2 < dist < 2.2
 468                and bs.time() - self._last_jump_time > 1.0
 469            ) or (
 470                self.bouncy
 471                and bs.time() - self._last_jump_time > 0.4
 472                and random.random() < 0.5
 473            ):
 474                self._last_jump_time = bs.time()
 475                self.node.jump_pressed = True
 476                self.node.jump_pressed = False
 477
 478            # Throw punches when real close.
 479            if dist < (1.6 if self._running else 1.2) and can_attack:
 480                if random.random() < self.punchiness:
 481                    self.on_punch_press()
 482                    self.on_punch_release()
 483
 484    @override
 485    def on_punched(self, damage: int) -> None:
 486        """
 487        Method override; sends bs.SpazBotPunchedMessage
 488        to the current activity.
 489        """
 490        bs.getactivity().handlemessage(SpazBotPunchedMessage(self, damage))
 491
 492    @override
 493    def on_expire(self) -> None:
 494        super().on_expire()
 495
 496        # We're being torn down; release our callback(s) so there's
 497        # no chance of them keeping activities or other things alive.
 498        self.update_callback = None
 499
 500    @override
 501    def handlemessage(self, msg: Any) -> Any:
 502        # pylint: disable=too-many-branches
 503        assert not self.expired
 504
 505        # Keep track of if we're being held and by who most recently.
 506        if isinstance(msg, bs.PickedUpMessage):
 507            super().handlemessage(msg)  # Augment standard behavior.
 508            self.held_count += 1
 509            picked_up_by = msg.node.source_player
 510            if picked_up_by:
 511                self.last_player_held_by = picked_up_by
 512
 513        elif isinstance(msg, bs.DroppedMessage):
 514            super().handlemessage(msg)  # Augment standard behavior.
 515            self.held_count -= 1
 516            if self.held_count < 0:
 517                print('ERROR: spaz held_count < 0')
 518
 519            # Let's count someone dropping us as an attack.
 520            try:
 521                if msg.node:
 522                    picked_up_by = msg.node.source_player
 523                else:
 524                    picked_up_by = None
 525            except Exception:
 526                logging.exception('Error on SpazBot DroppedMessage.')
 527                picked_up_by = None
 528
 529            if picked_up_by:
 530                self.last_player_attacked_by = picked_up_by
 531                self.last_attacked_time = bs.time()
 532                self.last_attacked_type = ('picked_up', 'default')
 533
 534        elif isinstance(msg, bs.DieMessage):
 535            # Report normal deaths for scoring purposes.
 536            if not self._dead and not msg.immediate:
 537                killerplayer: bs.Player | None
 538
 539                # If this guy was being held at the time of death, the
 540                # holder is the killer.
 541                if self.held_count > 0 and self.last_player_held_by:
 542                    killerplayer = self.last_player_held_by
 543                else:
 544                    # If they were attacked by someone in the last few
 545                    # seconds that person's the killer.
 546                    # Otherwise it was a suicide.
 547                    if (
 548                        self.last_player_attacked_by
 549                        and bs.time() - self.last_attacked_time < 4.0
 550                    ):
 551                        killerplayer = self.last_player_attacked_by
 552                    else:
 553                        killerplayer = None
 554                activity = self._activity()
 555
 556                # (convert dead player refs to None)
 557                if not killerplayer:
 558                    killerplayer = None
 559                if activity is not None:
 560                    activity.handlemessage(
 561                        SpazBotDiedMessage(self, killerplayer, msg.how)
 562                    )
 563            super().handlemessage(msg)  # Augment standard behavior.
 564
 565        # Keep track of the player who last hit us for point rewarding.
 566        elif isinstance(msg, bs.HitMessage):
 567            source_player = msg.get_source_player(bs.Player)
 568            if source_player:
 569                self.last_player_attacked_by = source_player
 570                self.last_attacked_time = bs.time()
 571                self.last_attacked_type = (msg.hit_type, msg.hit_subtype)
 572            super().handlemessage(msg)
 573        else:
 574            super().handlemessage(msg)
 575
 576
 577class BomberBot(SpazBot):
 578    """A bot that throws regular bombs and occasionally punches.
 579
 580    category: Bot Classes
 581    """
 582
 583    character = 'Spaz'
 584    punchiness = 0.3
 585
 586
 587class BomberBotLite(BomberBot):
 588    """A less aggressive yellow version of bs.BomberBot.
 589
 590    category: Bot Classes
 591    """
 592
 593    color = LITE_BOT_COLOR
 594    highlight = LITE_BOT_HIGHLIGHT
 595    punchiness = 0.2
 596    throw_rate = 0.7
 597    throwiness = 0.1
 598    charge_speed_min = 0.6
 599    charge_speed_max = 0.6
 600
 601
 602class BomberBotStaticLite(BomberBotLite):
 603    """A less aggressive generally immobile weak version of bs.BomberBot.
 604
 605    category: Bot Classes
 606    """
 607
 608    static = True
 609    throw_dist_min = 0.0
 610
 611
 612class BomberBotStatic(BomberBot):
 613    """A version of bs.BomberBot who generally stays in one place.
 614
 615    category: Bot Classes
 616    """
 617
 618    static = True
 619    throw_dist_min = 0.0
 620
 621
 622class BomberBotPro(BomberBot):
 623    """A more powerful version of bs.BomberBot.
 624
 625    category: Bot Classes
 626    """
 627
 628    points_mult = 2
 629    color = PRO_BOT_COLOR
 630    highlight = PRO_BOT_HIGHLIGHT
 631    default_bomb_count = 3
 632    default_boxing_gloves = True
 633    punchiness = 0.7
 634    throw_rate = 1.3
 635    run = True
 636    run_dist_min = 6.0
 637
 638
 639class BomberBotProShielded(BomberBotPro):
 640    """A more powerful version of bs.BomberBot who starts with shields.
 641
 642    category: Bot Classes
 643    """
 644
 645    points_mult = 3
 646    default_shields = True
 647
 648
 649class BomberBotProStatic(BomberBotPro):
 650    """A more powerful bs.BomberBot who generally stays in one place.
 651
 652    category: Bot Classes
 653    """
 654
 655    static = True
 656    throw_dist_min = 0.0
 657
 658
 659class BomberBotProStaticShielded(BomberBotProShielded):
 660    """A powerful bs.BomberBot with shields who is generally immobile.
 661
 662    category: Bot Classes
 663    """
 664
 665    static = True
 666    throw_dist_min = 0.0
 667
 668
 669class BrawlerBot(SpazBot):
 670    """A bot who walks and punches things.
 671
 672    category: Bot Classes
 673    """
 674
 675    character = 'Kronk'
 676    punchiness = 0.9
 677    charge_dist_max = 9999.0
 678    charge_speed_min = 1.0
 679    charge_speed_max = 1.0
 680    throw_dist_min = 9999
 681    throw_dist_max = 9999
 682
 683
 684class BrawlerBotLite(BrawlerBot):
 685    """A weaker version of bs.BrawlerBot.
 686
 687    category: Bot Classes
 688    """
 689
 690    color = LITE_BOT_COLOR
 691    highlight = LITE_BOT_HIGHLIGHT
 692    punchiness = 0.3
 693    charge_speed_min = 0.6
 694    charge_speed_max = 0.6
 695
 696
 697class BrawlerBotPro(BrawlerBot):
 698    """A stronger version of bs.BrawlerBot.
 699
 700    category: Bot Classes
 701    """
 702
 703    color = PRO_BOT_COLOR
 704    highlight = PRO_BOT_HIGHLIGHT
 705    run = True
 706    run_dist_min = 4.0
 707    default_boxing_gloves = True
 708    punchiness = 0.95
 709    points_mult = 2
 710
 711
 712class BrawlerBotProShielded(BrawlerBotPro):
 713    """A stronger version of bs.BrawlerBot who starts with shields.
 714
 715    category: Bot Classes
 716    """
 717
 718    default_shields = True
 719    points_mult = 3
 720
 721
 722class ChargerBot(SpazBot):
 723    """A speedy melee attack bot.
 724
 725    category: Bot Classes
 726    """
 727
 728    character = 'Snake Shadow'
 729    punchiness = 1.0
 730    run = True
 731    charge_dist_min = 10.0
 732    charge_dist_max = 9999.0
 733    charge_speed_min = 1.0
 734    charge_speed_max = 1.0
 735    throw_dist_min = 9999
 736    throw_dist_max = 9999
 737    points_mult = 2
 738
 739
 740class BouncyBot(SpazBot):
 741    """A speedy attacking melee bot that jumps constantly.
 742
 743    category: Bot Classes
 744    """
 745
 746    color = (1, 1, 1)
 747    highlight = (1.0, 0.5, 0.5)
 748    character = 'Easter Bunny'
 749    punchiness = 1.0
 750    run = True
 751    bouncy = True
 752    default_boxing_gloves = True
 753    charge_dist_min = 10.0
 754    charge_dist_max = 9999.0
 755    charge_speed_min = 1.0
 756    charge_speed_max = 1.0
 757    throw_dist_min = 9999
 758    throw_dist_max = 9999
 759    points_mult = 2
 760
 761
 762class ChargerBotPro(ChargerBot):
 763    """A stronger bs.ChargerBot.
 764
 765    category: Bot Classes
 766    """
 767
 768    color = PRO_BOT_COLOR
 769    highlight = PRO_BOT_HIGHLIGHT
 770    default_boxing_gloves = True
 771    points_mult = 3
 772
 773
 774class ChargerBotProShielded(ChargerBotPro):
 775    """A stronger bs.ChargerBot who starts with shields.
 776
 777    category: Bot Classes
 778    """
 779
 780    default_shields = True
 781    points_mult = 4
 782
 783
 784class TriggerBot(SpazBot):
 785    """A slow moving bot with trigger bombs.
 786
 787    category: Bot Classes
 788    """
 789
 790    character = 'Zoe'
 791    punchiness = 0.75
 792    throwiness = 0.7
 793    charge_dist_max = 1.0
 794    charge_speed_min = 0.3
 795    charge_speed_max = 0.5
 796    throw_dist_min = 3.5
 797    throw_dist_max = 5.5
 798    default_bomb_type = 'impact'
 799    points_mult = 2
 800
 801
 802class TriggerBotStatic(TriggerBot):
 803    """A bs.TriggerBot who generally stays in one place.
 804
 805    category: Bot Classes
 806    """
 807
 808    static = True
 809    throw_dist_min = 0.0
 810
 811
 812class TriggerBotPro(TriggerBot):
 813    """A stronger version of bs.TriggerBot.
 814
 815    category: Bot Classes
 816    """
 817
 818    color = PRO_BOT_COLOR
 819    highlight = PRO_BOT_HIGHLIGHT
 820    default_bomb_count = 3
 821    default_boxing_gloves = True
 822    charge_speed_min = 1.0
 823    charge_speed_max = 1.0
 824    punchiness = 0.9
 825    throw_rate = 1.3
 826    run = True
 827    run_dist_min = 6.0
 828    points_mult = 3
 829
 830
 831class TriggerBotProShielded(TriggerBotPro):
 832    """A stronger version of bs.TriggerBot who starts with shields.
 833
 834    category: Bot Classes
 835    """
 836
 837    default_shields = True
 838    points_mult = 4
 839
 840
 841class StickyBot(SpazBot):
 842    """A crazy bot who runs and throws sticky bombs.
 843
 844    category: Bot Classes
 845    """
 846
 847    character = 'Mel'
 848    punchiness = 0.9
 849    throwiness = 1.0
 850    run = True
 851    charge_dist_min = 4.0
 852    charge_dist_max = 10.0
 853    charge_speed_min = 1.0
 854    charge_speed_max = 1.0
 855    throw_dist_min = 0.0
 856    throw_dist_max = 4.0
 857    throw_rate = 2.0
 858    default_bomb_type = 'sticky'
 859    default_bomb_count = 3
 860    points_mult = 3
 861
 862
 863class StickyBotStatic(StickyBot):
 864    """A crazy bot who throws sticky-bombs but generally stays in one place.
 865
 866    category: Bot Classes
 867    """
 868
 869    static = True
 870
 871
 872class ExplodeyBot(SpazBot):
 873    """A bot who runs and explodes in 5 seconds.
 874
 875    category: Bot Classes
 876    """
 877
 878    character = 'Jack Morgan'
 879    run = True
 880    charge_dist_min = 0.0
 881    charge_dist_max = 9999
 882    charge_speed_min = 1.0
 883    charge_speed_max = 1.0
 884    throw_dist_min = 9999
 885    throw_dist_max = 9999
 886    start_cursed = True
 887    points_mult = 4
 888
 889
 890class ExplodeyBotNoTimeLimit(ExplodeyBot):
 891    """A bot who runs but does not explode on his own.
 892
 893    category: Bot Classes
 894    """
 895
 896    curse_time = None
 897
 898
 899class ExplodeyBotShielded(ExplodeyBot):
 900    """A bs.ExplodeyBot who starts with shields.
 901
 902    category: Bot Classes
 903    """
 904
 905    default_shields = True
 906    points_mult = 5
 907
 908
 909class SpazBotSet:
 910    """A container/controller for one or more bs.SpazBots.
 911
 912    category: Bot Classes
 913    """
 914
 915    def __init__(self) -> None:
 916        """Create a bot-set."""
 917
 918        # We spread our bots out over a few lists so we can update
 919        # them in a staggered fashion.
 920        self._bot_list_count = 5
 921        self._bot_add_list = 0
 922        self._bot_update_list = 0
 923        self._bot_lists: list[list[SpazBot]] = [
 924            [] for _ in range(self._bot_list_count)
 925        ]
 926        self._spawn_sound = bs.getsound('spawn')
 927        self._spawning_count = 0
 928        self._bot_update_timer: bs.Timer | None = None
 929        self.start_moving()
 930
 931    def __del__(self) -> None:
 932        self.clear()
 933
 934    def spawn_bot(
 935        self,
 936        bot_type: type[SpazBot],
 937        pos: Sequence[float],
 938        spawn_time: float = 3.0,
 939        on_spawn_call: Callable[[SpazBot], Any] | None = None,
 940    ) -> None:
 941        """Spawn a bot from this set."""
 942        from bascenev1lib.actor.spawner import Spawner
 943
 944        Spawner(
 945            pt=pos,
 946            spawn_time=spawn_time,
 947            send_spawn_message=False,
 948            spawn_callback=bs.Call(
 949                self._spawn_bot, bot_type, pos, on_spawn_call
 950            ),
 951        )
 952        self._spawning_count += 1
 953
 954    def _spawn_bot(
 955        self,
 956        bot_type: type[SpazBot],
 957        pos: Sequence[float],
 958        on_spawn_call: Callable[[SpazBot], Any] | None,
 959    ) -> None:
 960        spaz = bot_type()
 961        self._spawn_sound.play(position=pos)
 962        assert spaz.node
 963        spaz.node.handlemessage('flash')
 964        spaz.node.is_area_of_interest = False
 965        spaz.handlemessage(bs.StandMessage(pos, random.uniform(0, 360)))
 966        self.add_bot(spaz)
 967        self._spawning_count -= 1
 968        if on_spawn_call is not None:
 969            on_spawn_call(spaz)
 970
 971    def have_living_bots(self) -> bool:
 972        """Return whether any bots in the set are alive or spawning."""
 973        return self._spawning_count > 0 or any(
 974            any(b.is_alive() for b in l) for l in self._bot_lists
 975        )
 976
 977    def get_living_bots(self) -> list[SpazBot]:
 978        """Get the living bots in the set."""
 979        bots: list[SpazBot] = []
 980        for botlist in self._bot_lists:
 981            for bot in botlist:
 982                if bot.is_alive():
 983                    bots.append(bot)
 984        return bots
 985
 986    def _update(self) -> None:
 987        # Update one of our bot lists each time through.
 988        # First off, remove no-longer-existing bots from the list.
 989        try:
 990            bot_list = self._bot_lists[self._bot_update_list] = [
 991                b for b in self._bot_lists[self._bot_update_list] if b
 992            ]
 993        except Exception:
 994            bot_list = []
 995            logging.exception(
 996                'Error updating bot list: %s',
 997                self._bot_lists[self._bot_update_list],
 998            )
 999        self._bot_update_list = (
1000            self._bot_update_list + 1
1001        ) % self._bot_list_count
1002
1003        # Update our list of player points for the bots to use.
1004        player_pts = []
1005        for player in bs.getactivity().players:
1006            assert isinstance(player, bs.Player)
1007            try:
1008                # TODO: could use abstracted player.position here so we
1009                # don't have to assume their actor type, but we have no
1010                # abstracted velocity as of yet.
1011                if player.is_alive():
1012                    assert isinstance(player.actor, Spaz)
1013                    assert player.actor.node
1014                    player_pts.append(
1015                        (
1016                            bs.Vec3(player.actor.node.position),
1017                            bs.Vec3(player.actor.node.velocity),
1018                        )
1019                    )
1020            except Exception:
1021                logging.exception('Error on bot-set _update.')
1022
1023        for bot in bot_list:
1024            bot.set_player_points(player_pts)
1025            bot.update_ai()
1026
1027    def clear(self) -> None:
1028        """Immediately clear out any bots in the set."""
1029
1030        # Don't do this if the activity is shutting down or dead.
1031        activity = bs.getactivity(doraise=False)
1032        if activity is None or activity.expired:
1033            return
1034
1035        for i, bot_list in enumerate(self._bot_lists):
1036            for bot in bot_list:
1037                bot.handlemessage(bs.DieMessage(immediate=True))
1038            self._bot_lists[i] = []
1039
1040    def start_moving(self) -> None:
1041        """Start processing bot AI updates so they start doing their thing."""
1042        self._bot_update_timer = bs.Timer(
1043            0.05, bs.WeakCall(self._update), repeat=True
1044        )
1045
1046    def stop_moving(self) -> None:
1047        """Tell all bots to stop moving and stops updating their AI.
1048
1049        Useful when players have won and you want the
1050        enemy bots to just stand and look bewildered.
1051        """
1052        self._bot_update_timer = None
1053        for botlist in self._bot_lists:
1054            for bot in botlist:
1055                if bot.node:
1056                    bot.node.move_left_right = 0
1057                    bot.node.move_up_down = 0
1058
1059    def celebrate(self, duration: float) -> None:
1060        """Tell all living bots in the set to celebrate momentarily.
1061
1062        Duration is given in seconds.
1063        """
1064        msg = bs.CelebrateMessage(duration=duration)
1065        for botlist in self._bot_lists:
1066            for bot in botlist:
1067                if bot:
1068                    bot.handlemessage(msg)
1069
1070    def final_celebrate(self) -> None:
1071        """Tell all bots in the set to stop what they were doing and celebrate.
1072
1073        Use this when the bots have won a game.
1074        """
1075        self._bot_update_timer = None
1076
1077        # At this point stop doing anything but jumping and celebrating.
1078        for botlist in self._bot_lists:
1079            for bot in botlist:
1080                if bot:
1081                    assert bot.node  # (should exist if 'if bot' was True)
1082                    bot.node.move_left_right = 0
1083                    bot.node.move_up_down = 0
1084                    bs.timer(
1085                        0.5 * random.random(),
1086                        bs.Call(bot.handlemessage, bs.CelebrateMessage()),
1087                    )
1088                    jump_duration = random.randrange(400, 500)
1089                    j = random.randrange(0, 200)
1090                    for _i in range(10):
1091                        bot.node.jump_pressed = True
1092                        bot.node.jump_pressed = False
1093                        j += jump_duration
1094                    bs.timer(
1095                        random.uniform(0.0, 1.0),
1096                        bs.Call(bot.node.handlemessage, 'attack_sound'),
1097                    )
1098                    bs.timer(
1099                        random.uniform(1.0, 2.0),
1100                        bs.Call(bot.node.handlemessage, 'attack_sound'),
1101                    )
1102                    bs.timer(
1103                        random.uniform(2.0, 3.0),
1104                        bs.Call(bot.node.handlemessage, 'attack_sound'),
1105                    )
1106
1107    def add_bot(self, bot: SpazBot) -> None:
1108        """Add a bs.SpazBot instance to the set."""
1109        self._bot_lists[self._bot_add_list].append(bot)
1110        self._bot_add_list = (self._bot_add_list + 1) % self._bot_list_count
LITE_BOT_COLOR = (1.2, 0.9, 0.2)
LITE_BOT_HIGHLIGHT = (1.0, 0.5, 0.6)
DEFAULT_BOT_COLOR = (0.6, 0.6, 0.6)
DEFAULT_BOT_HIGHLIGHT = (0.1, 0.3, 0.1)
PRO_BOT_COLOR = (1.0, 0.2, 0.1)
PRO_BOT_HIGHLIGHT = (0.6, 0.1, 0.05)
class SpazBotPunchedMessage:
29class SpazBotPunchedMessage:
30    """A message saying a bs.SpazBot got punched."""
31
32    spazbot: SpazBot
33    """The bs.SpazBot that got punched."""
34
35    damage: int
36    """How much damage was done to the SpazBot."""
37
38    def __init__(self, spazbot: SpazBot, damage: int):
39        """Instantiate a message with the given values."""
40        self.spazbot = spazbot
41        self.damage = damage

A message saying a bs.SpazBot got punched.

SpazBotPunchedMessage(spazbot: SpazBot, damage: int)
38    def __init__(self, spazbot: SpazBot, damage: int):
39        """Instantiate a message with the given values."""
40        self.spazbot = spazbot
41        self.damage = damage

Instantiate a message with the given values.

spazbot: SpazBot

The bs.SpazBot that got punched.

damage: int

How much damage was done to the SpazBot.

class SpazBotDiedMessage:
44class SpazBotDiedMessage:
45    """A message saying a bs.SpazBot has died."""
46
47    spazbot: SpazBot
48    """The SpazBot that was killed."""
49
50    killerplayer: bs.Player | None
51    """The bascenev1.Player that killed it (or None)."""
52
53    how: bs.DeathType
54    """The particular type of death."""
55
56    def __init__(
57        self,
58        spazbot: SpazBot,
59        killerplayer: bs.Player | None,
60        how: bs.DeathType,
61    ):
62        """Instantiate with given values."""
63        self.spazbot = spazbot
64        self.killerplayer = killerplayer
65        self.how = how

A message saying a bs.SpazBot has died.

SpazBotDiedMessage( spazbot: SpazBot, killerplayer: bascenev1.Player | None, how: bascenev1.DeathType)
56    def __init__(
57        self,
58        spazbot: SpazBot,
59        killerplayer: bs.Player | None,
60        how: bs.DeathType,
61    ):
62        """Instantiate with given values."""
63        self.spazbot = spazbot
64        self.killerplayer = killerplayer
65        self.how = how

Instantiate with given values.

spazbot: SpazBot

The SpazBot that was killed.

killerplayer: bascenev1.Player | None

The bascenev1.Player that killed it (or None).

The particular type of death.

class SpazBot(bascenev1lib.actor.spaz.Spaz):
 68class SpazBot(Spaz):
 69    """A really dumb AI version of bs.Spaz.
 70
 71    Add these to a bs.BotSet to use them.
 72
 73    Note: currently the AI has no real ability to
 74    navigate obstacles and so should only be used
 75    on wide-open maps.
 76
 77    When a SpazBot is killed, it delivers a bs.SpazBotDiedMessage
 78    to the current activity.
 79
 80    When a SpazBot is punched, it delivers a bs.SpazBotPunchedMessage
 81    to the current activity.
 82    """
 83
 84    character = 'Spaz'
 85    punchiness = 0.5
 86    throwiness = 0.7
 87    static = False
 88    bouncy = False
 89    run = False
 90    charge_dist_min = 0.0  # When we can start a new charge.
 91    charge_dist_max = 2.0  # When we can start a new charge.
 92    run_dist_min = 0.0  # How close we can be to continue running.
 93    charge_speed_min = 0.4
 94    charge_speed_max = 1.0
 95    throw_dist_min = 5.0
 96    throw_dist_max = 9.0
 97    throw_rate = 1.0
 98    default_bomb_type = 'normal'
 99    default_bomb_count = 3
100    start_cursed = False
101    color = DEFAULT_BOT_COLOR
102    highlight = DEFAULT_BOT_HIGHLIGHT
103
104    def __init__(self) -> None:
105        """Instantiate a spaz-bot."""
106        super().__init__(
107            color=self.color,
108            highlight=self.highlight,
109            character=self.character,
110            source_player=None,
111            start_invincible=False,
112            can_accept_powerups=False,
113        )
114
115        # If you need to add custom behavior to a bot, set this to a callable
116        # which takes one arg (the bot) and returns False if the bot's normal
117        # update should be run and True if not.
118        self.update_callback: Callable[[SpazBot], Any] | None = None
119        activity = self.activity
120        assert isinstance(activity, bs.GameActivity)
121        self._map = weakref.ref(activity.map)
122        self.last_player_attacked_by: bs.Player | None = None
123        self.last_attacked_time = 0.0
124        self.last_attacked_type: tuple[str, str] | None = None
125        self.target_point_default: bs.Vec3 | None = None
126        self.held_count = 0
127        self.last_player_held_by: bs.Player | None = None
128        self.target_flag: Flag | None = None
129        self._charge_speed = 0.5 * (
130            self.charge_speed_min + self.charge_speed_max
131        )
132        self._lead_amount = 0.5
133        self._mode = 'wait'
134        self._charge_closing_in = False
135        self._last_charge_dist = 0.0
136        self._running = False
137        self._last_jump_time = 0.0
138
139        self._throw_release_time: float | None = None
140        self._have_dropped_throw_bomb: bool | None = None
141        self._player_pts: list[tuple[bs.Vec3, bs.Vec3]] | None = None
142
143        # These cooldowns didn't exist when these bots were calibrated,
144        # so take them out of the equation.
145        self._jump_cooldown = 0
146        self._pickup_cooldown = 0
147        self._fly_cooldown = 0
148        self._bomb_cooldown = 0
149
150        if self.start_cursed:
151            self.curse()
152
153    @property
154    def map(self) -> bs.Map:
155        """The map this bot was created on."""
156        mval = self._map()
157        assert mval is not None
158        return mval
159
160    def _get_target_player_pt(self) -> tuple[bs.Vec3 | None, bs.Vec3 | None]:
161        """Returns the position and velocity of our target.
162
163        Both values will be None in the case of no target.
164        """
165        assert self.node
166        botpt = bs.Vec3(self.node.position)
167        closest_dist: float | None = None
168        closest_vel: bs.Vec3 | None = None
169        closest: bs.Vec3 | None = None
170        assert self._player_pts is not None
171        for plpt, plvel in self._player_pts:
172            dist = (plpt - botpt).length()
173
174            # Ignore player-points that are significantly below the bot
175            # (keeps bots from following players off cliffs).
176            if (closest_dist is None or dist < closest_dist) and (
177                plpt[1] > botpt[1] - 5.0
178            ):
179                closest_dist = dist
180                closest_vel = plvel
181                closest = plpt
182        if closest_dist is not None:
183            assert closest_vel is not None
184            assert closest is not None
185            return (
186                bs.Vec3(closest[0], closest[1], closest[2]),
187                bs.Vec3(closest_vel[0], closest_vel[1], closest_vel[2]),
188            )
189        return None, None
190
191    def set_player_points(self, pts: list[tuple[bs.Vec3, bs.Vec3]]) -> None:
192        """Provide the spaz-bot with the locations of its enemies."""
193        self._player_pts = pts
194
195    def update_ai(self) -> None:
196        """Should be called periodically to update the spaz' AI."""
197        # pylint: disable=too-many-branches
198        # pylint: disable=too-many-statements
199        # pylint: disable=too-many-locals
200        if self.update_callback is not None:
201            if self.update_callback(self):
202                # Bot has been handled.
203                return
204
205        if not self.node:
206            return
207
208        pos = self.node.position
209        our_pos = bs.Vec3(pos[0], 0, pos[2])
210        can_attack = True
211
212        target_pt_raw: bs.Vec3 | None
213        target_vel: bs.Vec3 | None
214
215        # If we're a flag-bearer, we're pretty simple-minded - just walk
216        # towards the flag and try to pick it up.
217        if self.target_flag:
218            if self.node.hold_node:
219                holding_flag = self.node.hold_node.getnodetype() == 'flag'
220            else:
221                holding_flag = False
222
223            # If we're holding the flag, just walk left.
224            if holding_flag:
225                # Just walk left.
226                self.node.move_left_right = -1.0
227                self.node.move_up_down = 0.0
228
229            # Otherwise try to go pick it up.
230            elif self.target_flag.node:
231                target_pt_raw = bs.Vec3(*self.target_flag.node.position)
232                diff = target_pt_raw - our_pos
233                diff = bs.Vec3(diff[0], 0, diff[2])  # Don't care about y.
234                dist = diff.length()
235                to_target = diff.normalized()
236
237                # If we're holding some non-flag item, drop it.
238                if self.node.hold_node:
239                    self.node.pickup_pressed = True
240                    self.node.pickup_pressed = False
241                    return
242
243                # If we're a runner, run only when not super-near the flag.
244                if self.run and dist > 3.0:
245                    self._running = True
246                    self.node.run = 1.0
247                else:
248                    self._running = False
249                    self.node.run = 0.0
250
251                self.node.move_left_right = to_target.x
252                self.node.move_up_down = -to_target.z
253                if dist < 1.25:
254                    self.node.pickup_pressed = True
255                    self.node.pickup_pressed = False
256            return
257
258        # Not a flag-bearer. If we're holding anything but a bomb, drop it.
259        if self.node.hold_node:
260            holding_bomb = self.node.hold_node.getnodetype() in ['bomb', 'prop']
261            if not holding_bomb:
262                self.node.pickup_pressed = True
263                self.node.pickup_pressed = False
264                return
265
266        target_pt_raw, target_vel = self._get_target_player_pt()
267
268        if target_pt_raw is None:
269            # Use default target if we've got one.
270            if self.target_point_default is not None:
271                target_pt_raw = self.target_point_default
272                target_vel = bs.Vec3(0, 0, 0)
273                can_attack = False
274
275            # With no target, we stop moving and drop whatever we're holding.
276            else:
277                self.node.move_left_right = 0
278                self.node.move_up_down = 0
279                if self.node.hold_node:
280                    self.node.pickup_pressed = True
281                    self.node.pickup_pressed = False
282                return
283
284        # We don't want height to come into play.
285        target_pt_raw[1] = 0.0
286        assert target_vel is not None
287        target_vel[1] = 0.0
288
289        dist_raw = (target_pt_raw - our_pos).length()
290
291        # Use a point out in front of them as real target.
292        # (more out in front the farther from us they are)
293        target_pt = (
294            target_pt_raw + target_vel * dist_raw * 0.3 * self._lead_amount
295        )
296
297        diff = target_pt - our_pos
298        dist = diff.length()
299        to_target = diff.normalized()
300
301        if self._mode == 'throw':
302            # We can only throw if alive and well.
303            if not self._dead and not self.node.knockout:
304                assert self._throw_release_time is not None
305                time_till_throw = self._throw_release_time - bs.time()
306
307                if not self.node.hold_node:
308                    # If we haven't thrown yet, whip out the bomb.
309                    if not self._have_dropped_throw_bomb:
310                        self.drop_bomb()
311                        self._have_dropped_throw_bomb = True
312
313                    # Otherwise our lack of held node means we successfully
314                    # released our bomb; lets retreat now.
315                    else:
316                        self._mode = 'flee'
317
318                # Oh crap, we're holding a bomb; better throw it.
319                elif time_till_throw <= 0.0:
320                    # Jump and throw.
321                    def _safe_pickup(node: bs.Node) -> None:
322                        if node and self.node:
323                            self.node.pickup_pressed = True
324                            self.node.pickup_pressed = False
325
326                    if dist > 5.0:
327                        self.node.jump_pressed = True
328                        self.node.jump_pressed = False
329
330                        # Throws:
331                        bs.timer(0.1, bs.Call(_safe_pickup, self.node))
332                    else:
333                        # Throws:
334                        bs.timer(0.1, bs.Call(_safe_pickup, self.node))
335
336                if self.static:
337                    if time_till_throw < 0.3:
338                        speed = 1.0
339                    elif time_till_throw < 0.7 and dist > 3.0:
340                        speed = -1.0  # Whiplash for long throws.
341                    else:
342                        speed = 0.02
343                else:
344                    if time_till_throw < 0.7:
345                        # Right before throw charge full speed towards target.
346                        speed = 1.0
347                    else:
348                        # Earlier we can hold or move backward for a whiplash.
349                        speed = 0.0125
350                self.node.move_left_right = to_target.x * speed
351                self.node.move_up_down = to_target.z * -1.0 * speed
352
353        elif self._mode == 'charge':
354            if random.random() < 0.3:
355                self._charge_speed = random.uniform(
356                    self.charge_speed_min, self.charge_speed_max
357                )
358
359                # If we're a runner we run during charges *except when near
360                # an edge (otherwise we tend to fly off easily).
361                if self.run and dist_raw > self.run_dist_min:
362                    self._lead_amount = 0.3
363                    self._running = True
364                    self.node.run = 1.0
365                else:
366                    self._lead_amount = 0.01
367                    self._running = False
368                    self.node.run = 0.0
369
370            self.node.move_left_right = to_target.x * self._charge_speed
371            self.node.move_up_down = to_target.z * -1.0 * self._charge_speed
372
373        elif self._mode == 'wait':
374            # Every now and then, aim towards our target.
375            # Other than that, just stand there.
376            if int(bs.time() * 1000.0) % 1234 < 100:
377                self.node.move_left_right = to_target.x * (400.0 / 33000)
378                self.node.move_up_down = to_target.z * (-400.0 / 33000)
379            else:
380                self.node.move_left_right = 0
381                self.node.move_up_down = 0
382
383        elif self._mode == 'flee':
384            # Even if we're a runner, only run till we get away from our
385            # target (if we keep running we tend to run off edges).
386            if self.run and dist < 3.0:
387                self._running = True
388                self.node.run = 1.0
389            else:
390                self._running = False
391                self.node.run = 0.0
392            self.node.move_left_right = to_target.x * -1.0
393            self.node.move_up_down = to_target.z
394
395        # We might wanna switch states unless we're doing a throw
396        # (in which case that's our sole concern).
397        if self._mode != 'throw':
398            # If we're currently charging, keep track of how far we are
399            # from our target. When this value increases it means our charge
400            # is over (ran by them or something).
401            if self._mode == 'charge':
402                if (
403                    self._charge_closing_in
404                    and self._last_charge_dist < dist < 3.0
405                ):
406                    self._charge_closing_in = False
407                self._last_charge_dist = dist
408
409            # If we have a clean shot, throw!
410            if (
411                self.throw_dist_min <= dist < self.throw_dist_max
412                and random.random() < self.throwiness
413                and can_attack
414            ):
415                self._mode = 'throw'
416                self._lead_amount = (
417                    (0.4 + random.random() * 0.6)
418                    if dist_raw > 4.0
419                    else (0.1 + random.random() * 0.4)
420                )
421                self._have_dropped_throw_bomb = False
422                self._throw_release_time = bs.time() + (
423                    1.0 / self.throw_rate
424                ) * (0.8 + 1.3 * random.random())
425
426            # If we're static, always charge (which for us means barely move).
427            elif self.static:
428                self._mode = 'wait'
429
430            # If we're too close to charge (and aren't in the middle of an
431            # existing charge) run away.
432            elif dist < self.charge_dist_min and not self._charge_closing_in:
433                # ..unless we're near an edge, in which case we've got no
434                # choice but to charge.
435                if self.map.is_point_near_edge(our_pos, self._running):
436                    if self._mode != 'charge':
437                        self._mode = 'charge'
438                        self._lead_amount = 0.2
439                        self._charge_closing_in = True
440                        self._last_charge_dist = dist
441                else:
442                    self._mode = 'flee'
443
444            # We're within charging distance, backed against an edge,
445            # or farther than our max throw distance.. chaaarge!
446            elif (
447                dist < self.charge_dist_max
448                or dist > self.throw_dist_max
449                or self.map.is_point_near_edge(our_pos, self._running)
450            ):
451                if self._mode != 'charge':
452                    self._mode = 'charge'
453                    self._lead_amount = 0.01
454                    self._charge_closing_in = True
455                    self._last_charge_dist = dist
456
457            # We're too close to throw but too far to charge - either run
458            # away or just chill if we're near an edge.
459            elif dist < self.throw_dist_min:
460                # Charge if either we're within charge range or
461                # cant retreat to throw.
462                self._mode = 'flee'
463
464            # Do some awesome jumps if we're running.
465            # FIXME: pylint: disable=too-many-boolean-expressions
466            if (
467                self._running
468                and 1.2 < dist < 2.2
469                and bs.time() - self._last_jump_time > 1.0
470            ) or (
471                self.bouncy
472                and bs.time() - self._last_jump_time > 0.4
473                and random.random() < 0.5
474            ):
475                self._last_jump_time = bs.time()
476                self.node.jump_pressed = True
477                self.node.jump_pressed = False
478
479            # Throw punches when real close.
480            if dist < (1.6 if self._running else 1.2) and can_attack:
481                if random.random() < self.punchiness:
482                    self.on_punch_press()
483                    self.on_punch_release()
484
485    @override
486    def on_punched(self, damage: int) -> None:
487        """
488        Method override; sends bs.SpazBotPunchedMessage
489        to the current activity.
490        """
491        bs.getactivity().handlemessage(SpazBotPunchedMessage(self, damage))
492
493    @override
494    def on_expire(self) -> None:
495        super().on_expire()
496
497        # We're being torn down; release our callback(s) so there's
498        # no chance of them keeping activities or other things alive.
499        self.update_callback = None
500
501    @override
502    def handlemessage(self, msg: Any) -> Any:
503        # pylint: disable=too-many-branches
504        assert not self.expired
505
506        # Keep track of if we're being held and by who most recently.
507        if isinstance(msg, bs.PickedUpMessage):
508            super().handlemessage(msg)  # Augment standard behavior.
509            self.held_count += 1
510            picked_up_by = msg.node.source_player
511            if picked_up_by:
512                self.last_player_held_by = picked_up_by
513
514        elif isinstance(msg, bs.DroppedMessage):
515            super().handlemessage(msg)  # Augment standard behavior.
516            self.held_count -= 1
517            if self.held_count < 0:
518                print('ERROR: spaz held_count < 0')
519
520            # Let's count someone dropping us as an attack.
521            try:
522                if msg.node:
523                    picked_up_by = msg.node.source_player
524                else:
525                    picked_up_by = None
526            except Exception:
527                logging.exception('Error on SpazBot DroppedMessage.')
528                picked_up_by = None
529
530            if picked_up_by:
531                self.last_player_attacked_by = picked_up_by
532                self.last_attacked_time = bs.time()
533                self.last_attacked_type = ('picked_up', 'default')
534
535        elif isinstance(msg, bs.DieMessage):
536            # Report normal deaths for scoring purposes.
537            if not self._dead and not msg.immediate:
538                killerplayer: bs.Player | None
539
540                # If this guy was being held at the time of death, the
541                # holder is the killer.
542                if self.held_count > 0 and self.last_player_held_by:
543                    killerplayer = self.last_player_held_by
544                else:
545                    # If they were attacked by someone in the last few
546                    # seconds that person's the killer.
547                    # Otherwise it was a suicide.
548                    if (
549                        self.last_player_attacked_by
550                        and bs.time() - self.last_attacked_time < 4.0
551                    ):
552                        killerplayer = self.last_player_attacked_by
553                    else:
554                        killerplayer = None
555                activity = self._activity()
556
557                # (convert dead player refs to None)
558                if not killerplayer:
559                    killerplayer = None
560                if activity is not None:
561                    activity.handlemessage(
562                        SpazBotDiedMessage(self, killerplayer, msg.how)
563                    )
564            super().handlemessage(msg)  # Augment standard behavior.
565
566        # Keep track of the player who last hit us for point rewarding.
567        elif isinstance(msg, bs.HitMessage):
568            source_player = msg.get_source_player(bs.Player)
569            if source_player:
570                self.last_player_attacked_by = source_player
571                self.last_attacked_time = bs.time()
572                self.last_attacked_type = (msg.hit_type, msg.hit_subtype)
573            super().handlemessage(msg)
574        else:
575            super().handlemessage(msg)

A really dumb AI version of bs.Spaz.

Add these to a bs.BotSet to use them.

Note: currently the AI has no real ability to navigate obstacles and so should only be used on wide-open maps.

When a SpazBot is killed, it delivers a bs.SpazBotDiedMessage to the current activity.

When a SpazBot is punched, it delivers a bs.SpazBotPunchedMessage to the current activity.

SpazBot()
104    def __init__(self) -> None:
105        """Instantiate a spaz-bot."""
106        super().__init__(
107            color=self.color,
108            highlight=self.highlight,
109            character=self.character,
110            source_player=None,
111            start_invincible=False,
112            can_accept_powerups=False,
113        )
114
115        # If you need to add custom behavior to a bot, set this to a callable
116        # which takes one arg (the bot) and returns False if the bot's normal
117        # update should be run and True if not.
118        self.update_callback: Callable[[SpazBot], Any] | None = None
119        activity = self.activity
120        assert isinstance(activity, bs.GameActivity)
121        self._map = weakref.ref(activity.map)
122        self.last_player_attacked_by: bs.Player | None = None
123        self.last_attacked_time = 0.0
124        self.last_attacked_type: tuple[str, str] | None = None
125        self.target_point_default: bs.Vec3 | None = None
126        self.held_count = 0
127        self.last_player_held_by: bs.Player | None = None
128        self.target_flag: Flag | None = None
129        self._charge_speed = 0.5 * (
130            self.charge_speed_min + self.charge_speed_max
131        )
132        self._lead_amount = 0.5
133        self._mode = 'wait'
134        self._charge_closing_in = False
135        self._last_charge_dist = 0.0
136        self._running = False
137        self._last_jump_time = 0.0
138
139        self._throw_release_time: float | None = None
140        self._have_dropped_throw_bomb: bool | None = None
141        self._player_pts: list[tuple[bs.Vec3, bs.Vec3]] | None = None
142
143        # These cooldowns didn't exist when these bots were calibrated,
144        # so take them out of the equation.
145        self._jump_cooldown = 0
146        self._pickup_cooldown = 0
147        self._fly_cooldown = 0
148        self._bomb_cooldown = 0
149
150        if self.start_cursed:
151            self.curse()

Instantiate a spaz-bot.

character = 'Spaz'
punchiness = 0.5
throwiness = 0.7
static = False
bouncy = False
run = False
charge_dist_min = 0.0
charge_dist_max = 2.0
run_dist_min = 0.0
charge_speed_min = 0.4
charge_speed_max = 1.0
throw_dist_min = 5.0
throw_dist_max = 9.0
throw_rate = 1.0
default_bomb_type = 'normal'
default_bomb_count = 3
start_cursed = False
color = (0.6, 0.6, 0.6)
highlight = (0.1, 0.3, 0.1)
update_callback: Optional[Callable[[SpazBot], Any]]
last_player_attacked_by: bascenev1.Player | None
last_attacked_time
last_attacked_type: tuple[str, str] | None
target_point_default: _babase.Vec3 | None
held_count
last_player_held_by: bascenev1.Player | None
target_flag: bascenev1lib.actor.flag.Flag | None
map: bascenev1.Map
153    @property
154    def map(self) -> bs.Map:
155        """The map this bot was created on."""
156        mval = self._map()
157        assert mval is not None
158        return mval

The map this bot was created on.

def set_player_points(self, pts: list[tuple[_babase.Vec3, _babase.Vec3]]) -> None:
191    def set_player_points(self, pts: list[tuple[bs.Vec3, bs.Vec3]]) -> None:
192        """Provide the spaz-bot with the locations of its enemies."""
193        self._player_pts = pts

Provide the spaz-bot with the locations of its enemies.

def update_ai(self) -> None:
195    def update_ai(self) -> None:
196        """Should be called periodically to update the spaz' AI."""
197        # pylint: disable=too-many-branches
198        # pylint: disable=too-many-statements
199        # pylint: disable=too-many-locals
200        if self.update_callback is not None:
201            if self.update_callback(self):
202                # Bot has been handled.
203                return
204
205        if not self.node:
206            return
207
208        pos = self.node.position
209        our_pos = bs.Vec3(pos[0], 0, pos[2])
210        can_attack = True
211
212        target_pt_raw: bs.Vec3 | None
213        target_vel: bs.Vec3 | None
214
215        # If we're a flag-bearer, we're pretty simple-minded - just walk
216        # towards the flag and try to pick it up.
217        if self.target_flag:
218            if self.node.hold_node:
219                holding_flag = self.node.hold_node.getnodetype() == 'flag'
220            else:
221                holding_flag = False
222
223            # If we're holding the flag, just walk left.
224            if holding_flag:
225                # Just walk left.
226                self.node.move_left_right = -1.0
227                self.node.move_up_down = 0.0
228
229            # Otherwise try to go pick it up.
230            elif self.target_flag.node:
231                target_pt_raw = bs.Vec3(*self.target_flag.node.position)
232                diff = target_pt_raw - our_pos
233                diff = bs.Vec3(diff[0], 0, diff[2])  # Don't care about y.
234                dist = diff.length()
235                to_target = diff.normalized()
236
237                # If we're holding some non-flag item, drop it.
238                if self.node.hold_node:
239                    self.node.pickup_pressed = True
240                    self.node.pickup_pressed = False
241                    return
242
243                # If we're a runner, run only when not super-near the flag.
244                if self.run and dist > 3.0:
245                    self._running = True
246                    self.node.run = 1.0
247                else:
248                    self._running = False
249                    self.node.run = 0.0
250
251                self.node.move_left_right = to_target.x
252                self.node.move_up_down = -to_target.z
253                if dist < 1.25:
254                    self.node.pickup_pressed = True
255                    self.node.pickup_pressed = False
256            return
257
258        # Not a flag-bearer. If we're holding anything but a bomb, drop it.
259        if self.node.hold_node:
260            holding_bomb = self.node.hold_node.getnodetype() in ['bomb', 'prop']
261            if not holding_bomb:
262                self.node.pickup_pressed = True
263                self.node.pickup_pressed = False
264                return
265
266        target_pt_raw, target_vel = self._get_target_player_pt()
267
268        if target_pt_raw is None:
269            # Use default target if we've got one.
270            if self.target_point_default is not None:
271                target_pt_raw = self.target_point_default
272                target_vel = bs.Vec3(0, 0, 0)
273                can_attack = False
274
275            # With no target, we stop moving and drop whatever we're holding.
276            else:
277                self.node.move_left_right = 0
278                self.node.move_up_down = 0
279                if self.node.hold_node:
280                    self.node.pickup_pressed = True
281                    self.node.pickup_pressed = False
282                return
283
284        # We don't want height to come into play.
285        target_pt_raw[1] = 0.0
286        assert target_vel is not None
287        target_vel[1] = 0.0
288
289        dist_raw = (target_pt_raw - our_pos).length()
290
291        # Use a point out in front of them as real target.
292        # (more out in front the farther from us they are)
293        target_pt = (
294            target_pt_raw + target_vel * dist_raw * 0.3 * self._lead_amount
295        )
296
297        diff = target_pt - our_pos
298        dist = diff.length()
299        to_target = diff.normalized()
300
301        if self._mode == 'throw':
302            # We can only throw if alive and well.
303            if not self._dead and not self.node.knockout:
304                assert self._throw_release_time is not None
305                time_till_throw = self._throw_release_time - bs.time()
306
307                if not self.node.hold_node:
308                    # If we haven't thrown yet, whip out the bomb.
309                    if not self._have_dropped_throw_bomb:
310                        self.drop_bomb()
311                        self._have_dropped_throw_bomb = True
312
313                    # Otherwise our lack of held node means we successfully
314                    # released our bomb; lets retreat now.
315                    else:
316                        self._mode = 'flee'
317
318                # Oh crap, we're holding a bomb; better throw it.
319                elif time_till_throw <= 0.0:
320                    # Jump and throw.
321                    def _safe_pickup(node: bs.Node) -> None:
322                        if node and self.node:
323                            self.node.pickup_pressed = True
324                            self.node.pickup_pressed = False
325
326                    if dist > 5.0:
327                        self.node.jump_pressed = True
328                        self.node.jump_pressed = False
329
330                        # Throws:
331                        bs.timer(0.1, bs.Call(_safe_pickup, self.node))
332                    else:
333                        # Throws:
334                        bs.timer(0.1, bs.Call(_safe_pickup, self.node))
335
336                if self.static:
337                    if time_till_throw < 0.3:
338                        speed = 1.0
339                    elif time_till_throw < 0.7 and dist > 3.0:
340                        speed = -1.0  # Whiplash for long throws.
341                    else:
342                        speed = 0.02
343                else:
344                    if time_till_throw < 0.7:
345                        # Right before throw charge full speed towards target.
346                        speed = 1.0
347                    else:
348                        # Earlier we can hold or move backward for a whiplash.
349                        speed = 0.0125
350                self.node.move_left_right = to_target.x * speed
351                self.node.move_up_down = to_target.z * -1.0 * speed
352
353        elif self._mode == 'charge':
354            if random.random() < 0.3:
355                self._charge_speed = random.uniform(
356                    self.charge_speed_min, self.charge_speed_max
357                )
358
359                # If we're a runner we run during charges *except when near
360                # an edge (otherwise we tend to fly off easily).
361                if self.run and dist_raw > self.run_dist_min:
362                    self._lead_amount = 0.3
363                    self._running = True
364                    self.node.run = 1.0
365                else:
366                    self._lead_amount = 0.01
367                    self._running = False
368                    self.node.run = 0.0
369
370            self.node.move_left_right = to_target.x * self._charge_speed
371            self.node.move_up_down = to_target.z * -1.0 * self._charge_speed
372
373        elif self._mode == 'wait':
374            # Every now and then, aim towards our target.
375            # Other than that, just stand there.
376            if int(bs.time() * 1000.0) % 1234 < 100:
377                self.node.move_left_right = to_target.x * (400.0 / 33000)
378                self.node.move_up_down = to_target.z * (-400.0 / 33000)
379            else:
380                self.node.move_left_right = 0
381                self.node.move_up_down = 0
382
383        elif self._mode == 'flee':
384            # Even if we're a runner, only run till we get away from our
385            # target (if we keep running we tend to run off edges).
386            if self.run and dist < 3.0:
387                self._running = True
388                self.node.run = 1.0
389            else:
390                self._running = False
391                self.node.run = 0.0
392            self.node.move_left_right = to_target.x * -1.0
393            self.node.move_up_down = to_target.z
394
395        # We might wanna switch states unless we're doing a throw
396        # (in which case that's our sole concern).
397        if self._mode != 'throw':
398            # If we're currently charging, keep track of how far we are
399            # from our target. When this value increases it means our charge
400            # is over (ran by them or something).
401            if self._mode == 'charge':
402                if (
403                    self._charge_closing_in
404                    and self._last_charge_dist < dist < 3.0
405                ):
406                    self._charge_closing_in = False
407                self._last_charge_dist = dist
408
409            # If we have a clean shot, throw!
410            if (
411                self.throw_dist_min <= dist < self.throw_dist_max
412                and random.random() < self.throwiness
413                and can_attack
414            ):
415                self._mode = 'throw'
416                self._lead_amount = (
417                    (0.4 + random.random() * 0.6)
418                    if dist_raw > 4.0
419                    else (0.1 + random.random() * 0.4)
420                )
421                self._have_dropped_throw_bomb = False
422                self._throw_release_time = bs.time() + (
423                    1.0 / self.throw_rate
424                ) * (0.8 + 1.3 * random.random())
425
426            # If we're static, always charge (which for us means barely move).
427            elif self.static:
428                self._mode = 'wait'
429
430            # If we're too close to charge (and aren't in the middle of an
431            # existing charge) run away.
432            elif dist < self.charge_dist_min and not self._charge_closing_in:
433                # ..unless we're near an edge, in which case we've got no
434                # choice but to charge.
435                if self.map.is_point_near_edge(our_pos, self._running):
436                    if self._mode != 'charge':
437                        self._mode = 'charge'
438                        self._lead_amount = 0.2
439                        self._charge_closing_in = True
440                        self._last_charge_dist = dist
441                else:
442                    self._mode = 'flee'
443
444            # We're within charging distance, backed against an edge,
445            # or farther than our max throw distance.. chaaarge!
446            elif (
447                dist < self.charge_dist_max
448                or dist > self.throw_dist_max
449                or self.map.is_point_near_edge(our_pos, self._running)
450            ):
451                if self._mode != 'charge':
452                    self._mode = 'charge'
453                    self._lead_amount = 0.01
454                    self._charge_closing_in = True
455                    self._last_charge_dist = dist
456
457            # We're too close to throw but too far to charge - either run
458            # away or just chill if we're near an edge.
459            elif dist < self.throw_dist_min:
460                # Charge if either we're within charge range or
461                # cant retreat to throw.
462                self._mode = 'flee'
463
464            # Do some awesome jumps if we're running.
465            # FIXME: pylint: disable=too-many-boolean-expressions
466            if (
467                self._running
468                and 1.2 < dist < 2.2
469                and bs.time() - self._last_jump_time > 1.0
470            ) or (
471                self.bouncy
472                and bs.time() - self._last_jump_time > 0.4
473                and random.random() < 0.5
474            ):
475                self._last_jump_time = bs.time()
476                self.node.jump_pressed = True
477                self.node.jump_pressed = False
478
479            # Throw punches when real close.
480            if dist < (1.6 if self._running else 1.2) and can_attack:
481                if random.random() < self.punchiness:
482                    self.on_punch_press()
483                    self.on_punch_release()

Should be called periodically to update the spaz' AI.

@override
def on_punched(self, damage: int) -> None:
485    @override
486    def on_punched(self, damage: int) -> None:
487        """
488        Method override; sends bs.SpazBotPunchedMessage
489        to the current activity.
490        """
491        bs.getactivity().handlemessage(SpazBotPunchedMessage(self, damage))

Method override; sends bs.SpazBotPunchedMessage to the current activity.

@override
def on_expire(self) -> None:
493    @override
494    def on_expire(self) -> None:
495        super().on_expire()
496
497        # We're being torn down; release our callback(s) so there's
498        # no chance of them keeping activities or other things alive.
499        self.update_callback = None

Called for remaining bascenev1.Actors when their activity dies.

Actors can use this opportunity to clear callbacks or other references which have the potential of keeping the bascenev1.Activity alive inadvertently (Activities can not exit cleanly while any Python references to them remain.)

Once an actor is expired (see bascenev1.Actor.is_expired()) it should no longer perform any game-affecting operations (creating, modifying, or deleting nodes, media, timers, etc.) Attempts to do so will likely result in errors.

@override
def handlemessage(self, msg: Any) -> Any:
501    @override
502    def handlemessage(self, msg: Any) -> Any:
503        # pylint: disable=too-many-branches
504        assert not self.expired
505
506        # Keep track of if we're being held and by who most recently.
507        if isinstance(msg, bs.PickedUpMessage):
508            super().handlemessage(msg)  # Augment standard behavior.
509            self.held_count += 1
510            picked_up_by = msg.node.source_player
511            if picked_up_by:
512                self.last_player_held_by = picked_up_by
513
514        elif isinstance(msg, bs.DroppedMessage):
515            super().handlemessage(msg)  # Augment standard behavior.
516            self.held_count -= 1
517            if self.held_count < 0:
518                print('ERROR: spaz held_count < 0')
519
520            # Let's count someone dropping us as an attack.
521            try:
522                if msg.node:
523                    picked_up_by = msg.node.source_player
524                else:
525                    picked_up_by = None
526            except Exception:
527                logging.exception('Error on SpazBot DroppedMessage.')
528                picked_up_by = None
529
530            if picked_up_by:
531                self.last_player_attacked_by = picked_up_by
532                self.last_attacked_time = bs.time()
533                self.last_attacked_type = ('picked_up', 'default')
534
535        elif isinstance(msg, bs.DieMessage):
536            # Report normal deaths for scoring purposes.
537            if not self._dead and not msg.immediate:
538                killerplayer: bs.Player | None
539
540                # If this guy was being held at the time of death, the
541                # holder is the killer.
542                if self.held_count > 0 and self.last_player_held_by:
543                    killerplayer = self.last_player_held_by
544                else:
545                    # If they were attacked by someone in the last few
546                    # seconds that person's the killer.
547                    # Otherwise it was a suicide.
548                    if (
549                        self.last_player_attacked_by
550                        and bs.time() - self.last_attacked_time < 4.0
551                    ):
552                        killerplayer = self.last_player_attacked_by
553                    else:
554                        killerplayer = None
555                activity = self._activity()
556
557                # (convert dead player refs to None)
558                if not killerplayer:
559                    killerplayer = None
560                if activity is not None:
561                    activity.handlemessage(
562                        SpazBotDiedMessage(self, killerplayer, msg.how)
563                    )
564            super().handlemessage(msg)  # Augment standard behavior.
565
566        # Keep track of the player who last hit us for point rewarding.
567        elif isinstance(msg, bs.HitMessage):
568            source_player = msg.get_source_player(bs.Player)
569            if source_player:
570                self.last_player_attacked_by = source_player
571                self.last_attacked_time = bs.time()
572                self.last_attacked_type = (msg.hit_type, msg.hit_subtype)
573            super().handlemessage(msg)
574        else:
575            super().handlemessage(msg)

General message handling; can be passed any message object.

class BomberBot(SpazBot):
578class BomberBot(SpazBot):
579    """A bot that throws regular bombs and occasionally punches.
580
581    category: Bot Classes
582    """
583
584    character = 'Spaz'
585    punchiness = 0.3

A bot that throws regular bombs and occasionally punches.

category: Bot Classes

character = 'Spaz'
punchiness = 0.3
class BomberBotLite(BomberBot):
588class BomberBotLite(BomberBot):
589    """A less aggressive yellow version of bs.BomberBot.
590
591    category: Bot Classes
592    """
593
594    color = LITE_BOT_COLOR
595    highlight = LITE_BOT_HIGHLIGHT
596    punchiness = 0.2
597    throw_rate = 0.7
598    throwiness = 0.1
599    charge_speed_min = 0.6
600    charge_speed_max = 0.6

A less aggressive yellow version of bs.BomberBot.

category: Bot Classes

color = (1.2, 0.9, 0.2)
highlight = (1.0, 0.5, 0.6)
punchiness = 0.2
throw_rate = 0.7
throwiness = 0.1
charge_speed_min = 0.6
charge_speed_max = 0.6
class BomberBotStaticLite(BomberBotLite):
603class BomberBotStaticLite(BomberBotLite):
604    """A less aggressive generally immobile weak version of bs.BomberBot.
605
606    category: Bot Classes
607    """
608
609    static = True
610    throw_dist_min = 0.0

A less aggressive generally immobile weak version of bs.BomberBot.

category: Bot Classes

static = True
throw_dist_min = 0.0
class BomberBotStatic(BomberBot):
613class BomberBotStatic(BomberBot):
614    """A version of bs.BomberBot who generally stays in one place.
615
616    category: Bot Classes
617    """
618
619    static = True
620    throw_dist_min = 0.0

A version of bs.BomberBot who generally stays in one place.

category: Bot Classes

static = True
throw_dist_min = 0.0
class BomberBotPro(BomberBot):
623class BomberBotPro(BomberBot):
624    """A more powerful version of bs.BomberBot.
625
626    category: Bot Classes
627    """
628
629    points_mult = 2
630    color = PRO_BOT_COLOR
631    highlight = PRO_BOT_HIGHLIGHT
632    default_bomb_count = 3
633    default_boxing_gloves = True
634    punchiness = 0.7
635    throw_rate = 1.3
636    run = True
637    run_dist_min = 6.0

A more powerful version of bs.BomberBot.

category: Bot Classes

points_mult = 2
color = (1.0, 0.2, 0.1)
highlight = (0.6, 0.1, 0.05)
default_bomb_count = 3
default_boxing_gloves = True
punchiness = 0.7
throw_rate = 1.3
run = True
run_dist_min = 6.0
class BomberBotProShielded(BomberBotPro):
640class BomberBotProShielded(BomberBotPro):
641    """A more powerful version of bs.BomberBot who starts with shields.
642
643    category: Bot Classes
644    """
645
646    points_mult = 3
647    default_shields = True

A more powerful version of bs.BomberBot who starts with shields.

category: Bot Classes

points_mult = 3
default_shields = True
class BomberBotProStatic(BomberBotPro):
650class BomberBotProStatic(BomberBotPro):
651    """A more powerful bs.BomberBot who generally stays in one place.
652
653    category: Bot Classes
654    """
655
656    static = True
657    throw_dist_min = 0.0

A more powerful bs.BomberBot who generally stays in one place.

category: Bot Classes

static = True
throw_dist_min = 0.0
class BomberBotProStaticShielded(BomberBotProShielded):
660class BomberBotProStaticShielded(BomberBotProShielded):
661    """A powerful bs.BomberBot with shields who is generally immobile.
662
663    category: Bot Classes
664    """
665
666    static = True
667    throw_dist_min = 0.0

A powerful bs.BomberBot with shields who is generally immobile.

category: Bot Classes

static = True
throw_dist_min = 0.0
class BrawlerBot(SpazBot):
670class BrawlerBot(SpazBot):
671    """A bot who walks and punches things.
672
673    category: Bot Classes
674    """
675
676    character = 'Kronk'
677    punchiness = 0.9
678    charge_dist_max = 9999.0
679    charge_speed_min = 1.0
680    charge_speed_max = 1.0
681    throw_dist_min = 9999
682    throw_dist_max = 9999

A bot who walks and punches things.

category: Bot Classes

character = 'Kronk'
punchiness = 0.9
charge_dist_max = 9999.0
charge_speed_min = 1.0
charge_speed_max = 1.0
throw_dist_min = 9999
throw_dist_max = 9999
class BrawlerBotLite(BrawlerBot):
685class BrawlerBotLite(BrawlerBot):
686    """A weaker version of bs.BrawlerBot.
687
688    category: Bot Classes
689    """
690
691    color = LITE_BOT_COLOR
692    highlight = LITE_BOT_HIGHLIGHT
693    punchiness = 0.3
694    charge_speed_min = 0.6
695    charge_speed_max = 0.6

A weaker version of bs.BrawlerBot.

category: Bot Classes

color = (1.2, 0.9, 0.2)
highlight = (1.0, 0.5, 0.6)
punchiness = 0.3
charge_speed_min = 0.6
charge_speed_max = 0.6
class BrawlerBotPro(BrawlerBot):
698class BrawlerBotPro(BrawlerBot):
699    """A stronger version of bs.BrawlerBot.
700
701    category: Bot Classes
702    """
703
704    color = PRO_BOT_COLOR
705    highlight = PRO_BOT_HIGHLIGHT
706    run = True
707    run_dist_min = 4.0
708    default_boxing_gloves = True
709    punchiness = 0.95
710    points_mult = 2

A stronger version of bs.BrawlerBot.

category: Bot Classes

color = (1.0, 0.2, 0.1)
highlight = (0.6, 0.1, 0.05)
run = True
run_dist_min = 4.0
default_boxing_gloves = True
punchiness = 0.95
points_mult = 2
class BrawlerBotProShielded(BrawlerBotPro):
713class BrawlerBotProShielded(BrawlerBotPro):
714    """A stronger version of bs.BrawlerBot who starts with shields.
715
716    category: Bot Classes
717    """
718
719    default_shields = True
720    points_mult = 3

A stronger version of bs.BrawlerBot who starts with shields.

category: Bot Classes

default_shields = True
points_mult = 3
class ChargerBot(SpazBot):
723class ChargerBot(SpazBot):
724    """A speedy melee attack bot.
725
726    category: Bot Classes
727    """
728
729    character = 'Snake Shadow'
730    punchiness = 1.0
731    run = True
732    charge_dist_min = 10.0
733    charge_dist_max = 9999.0
734    charge_speed_min = 1.0
735    charge_speed_max = 1.0
736    throw_dist_min = 9999
737    throw_dist_max = 9999
738    points_mult = 2

A speedy melee attack bot.

category: Bot Classes

character = 'Snake Shadow'
punchiness = 1.0
run = True
charge_dist_min = 10.0
charge_dist_max = 9999.0
charge_speed_min = 1.0
charge_speed_max = 1.0
throw_dist_min = 9999
throw_dist_max = 9999
points_mult = 2
class BouncyBot(SpazBot):
741class BouncyBot(SpazBot):
742    """A speedy attacking melee bot that jumps constantly.
743
744    category: Bot Classes
745    """
746
747    color = (1, 1, 1)
748    highlight = (1.0, 0.5, 0.5)
749    character = 'Easter Bunny'
750    punchiness = 1.0
751    run = True
752    bouncy = True
753    default_boxing_gloves = True
754    charge_dist_min = 10.0
755    charge_dist_max = 9999.0
756    charge_speed_min = 1.0
757    charge_speed_max = 1.0
758    throw_dist_min = 9999
759    throw_dist_max = 9999
760    points_mult = 2

A speedy attacking melee bot that jumps constantly.

category: Bot Classes

color = (1, 1, 1)
highlight = (1.0, 0.5, 0.5)
character = 'Easter Bunny'
punchiness = 1.0
run = True
bouncy = True
default_boxing_gloves = True
charge_dist_min = 10.0
charge_dist_max = 9999.0
charge_speed_min = 1.0
charge_speed_max = 1.0
throw_dist_min = 9999
throw_dist_max = 9999
points_mult = 2
class ChargerBotPro(ChargerBot):
763class ChargerBotPro(ChargerBot):
764    """A stronger bs.ChargerBot.
765
766    category: Bot Classes
767    """
768
769    color = PRO_BOT_COLOR
770    highlight = PRO_BOT_HIGHLIGHT
771    default_boxing_gloves = True
772    points_mult = 3

A stronger bs.ChargerBot.

category: Bot Classes

color = (1.0, 0.2, 0.1)
highlight = (0.6, 0.1, 0.05)
default_boxing_gloves = True
points_mult = 3
class ChargerBotProShielded(ChargerBotPro):
775class ChargerBotProShielded(ChargerBotPro):
776    """A stronger bs.ChargerBot who starts with shields.
777
778    category: Bot Classes
779    """
780
781    default_shields = True
782    points_mult = 4

A stronger bs.ChargerBot who starts with shields.

category: Bot Classes

default_shields = True
points_mult = 4
class TriggerBot(SpazBot):
785class TriggerBot(SpazBot):
786    """A slow moving bot with trigger bombs.
787
788    category: Bot Classes
789    """
790
791    character = 'Zoe'
792    punchiness = 0.75
793    throwiness = 0.7
794    charge_dist_max = 1.0
795    charge_speed_min = 0.3
796    charge_speed_max = 0.5
797    throw_dist_min = 3.5
798    throw_dist_max = 5.5
799    default_bomb_type = 'impact'
800    points_mult = 2

A slow moving bot with trigger bombs.

category: Bot Classes

character = 'Zoe'
punchiness = 0.75
throwiness = 0.7
charge_dist_max = 1.0
charge_speed_min = 0.3
charge_speed_max = 0.5
throw_dist_min = 3.5
throw_dist_max = 5.5
default_bomb_type = 'impact'
points_mult = 2
class TriggerBotStatic(TriggerBot):
803class TriggerBotStatic(TriggerBot):
804    """A bs.TriggerBot who generally stays in one place.
805
806    category: Bot Classes
807    """
808
809    static = True
810    throw_dist_min = 0.0

A bs.TriggerBot who generally stays in one place.

category: Bot Classes

static = True
throw_dist_min = 0.0
class TriggerBotPro(TriggerBot):
813class TriggerBotPro(TriggerBot):
814    """A stronger version of bs.TriggerBot.
815
816    category: Bot Classes
817    """
818
819    color = PRO_BOT_COLOR
820    highlight = PRO_BOT_HIGHLIGHT
821    default_bomb_count = 3
822    default_boxing_gloves = True
823    charge_speed_min = 1.0
824    charge_speed_max = 1.0
825    punchiness = 0.9
826    throw_rate = 1.3
827    run = True
828    run_dist_min = 6.0
829    points_mult = 3

A stronger version of bs.TriggerBot.

category: Bot Classes

color = (1.0, 0.2, 0.1)
highlight = (0.6, 0.1, 0.05)
default_bomb_count = 3
default_boxing_gloves = True
charge_speed_min = 1.0
charge_speed_max = 1.0
punchiness = 0.9
throw_rate = 1.3
run = True
run_dist_min = 6.0
points_mult = 3
class TriggerBotProShielded(TriggerBotPro):
832class TriggerBotProShielded(TriggerBotPro):
833    """A stronger version of bs.TriggerBot who starts with shields.
834
835    category: Bot Classes
836    """
837
838    default_shields = True
839    points_mult = 4

A stronger version of bs.TriggerBot who starts with shields.

category: Bot Classes

default_shields = True
points_mult = 4
class StickyBot(SpazBot):
842class StickyBot(SpazBot):
843    """A crazy bot who runs and throws sticky bombs.
844
845    category: Bot Classes
846    """
847
848    character = 'Mel'
849    punchiness = 0.9
850    throwiness = 1.0
851    run = True
852    charge_dist_min = 4.0
853    charge_dist_max = 10.0
854    charge_speed_min = 1.0
855    charge_speed_max = 1.0
856    throw_dist_min = 0.0
857    throw_dist_max = 4.0
858    throw_rate = 2.0
859    default_bomb_type = 'sticky'
860    default_bomb_count = 3
861    points_mult = 3

A crazy bot who runs and throws sticky bombs.

category: Bot Classes

character = 'Mel'
punchiness = 0.9
throwiness = 1.0
run = True
charge_dist_min = 4.0
charge_dist_max = 10.0
charge_speed_min = 1.0
charge_speed_max = 1.0
throw_dist_min = 0.0
throw_dist_max = 4.0
throw_rate = 2.0
default_bomb_type = 'sticky'
default_bomb_count = 3
points_mult = 3
class StickyBotStatic(StickyBot):
864class StickyBotStatic(StickyBot):
865    """A crazy bot who throws sticky-bombs but generally stays in one place.
866
867    category: Bot Classes
868    """
869
870    static = True

A crazy bot who throws sticky-bombs but generally stays in one place.

category: Bot Classes

static = True
class ExplodeyBot(SpazBot):
873class ExplodeyBot(SpazBot):
874    """A bot who runs and explodes in 5 seconds.
875
876    category: Bot Classes
877    """
878
879    character = 'Jack Morgan'
880    run = True
881    charge_dist_min = 0.0
882    charge_dist_max = 9999
883    charge_speed_min = 1.0
884    charge_speed_max = 1.0
885    throw_dist_min = 9999
886    throw_dist_max = 9999
887    start_cursed = True
888    points_mult = 4

A bot who runs and explodes in 5 seconds.

category: Bot Classes

character = 'Jack Morgan'
run = True
charge_dist_min = 0.0
charge_dist_max = 9999
charge_speed_min = 1.0
charge_speed_max = 1.0
throw_dist_min = 9999
throw_dist_max = 9999
start_cursed = True
points_mult = 4
class ExplodeyBotNoTimeLimit(ExplodeyBot):
891class ExplodeyBotNoTimeLimit(ExplodeyBot):
892    """A bot who runs but does not explode on his own.
893
894    category: Bot Classes
895    """
896
897    curse_time = None

A bot who runs but does not explode on his own.

category: Bot Classes

curse_time = None
class ExplodeyBotShielded(ExplodeyBot):
900class ExplodeyBotShielded(ExplodeyBot):
901    """A bs.ExplodeyBot who starts with shields.
902
903    category: Bot Classes
904    """
905
906    default_shields = True
907    points_mult = 5

A bs.ExplodeyBot who starts with shields.

category: Bot Classes

default_shields = True
points_mult = 5
class SpazBotSet:
 910class SpazBotSet:
 911    """A container/controller for one or more bs.SpazBots.
 912
 913    category: Bot Classes
 914    """
 915
 916    def __init__(self) -> None:
 917        """Create a bot-set."""
 918
 919        # We spread our bots out over a few lists so we can update
 920        # them in a staggered fashion.
 921        self._bot_list_count = 5
 922        self._bot_add_list = 0
 923        self._bot_update_list = 0
 924        self._bot_lists: list[list[SpazBot]] = [
 925            [] for _ in range(self._bot_list_count)
 926        ]
 927        self._spawn_sound = bs.getsound('spawn')
 928        self._spawning_count = 0
 929        self._bot_update_timer: bs.Timer | None = None
 930        self.start_moving()
 931
 932    def __del__(self) -> None:
 933        self.clear()
 934
 935    def spawn_bot(
 936        self,
 937        bot_type: type[SpazBot],
 938        pos: Sequence[float],
 939        spawn_time: float = 3.0,
 940        on_spawn_call: Callable[[SpazBot], Any] | None = None,
 941    ) -> None:
 942        """Spawn a bot from this set."""
 943        from bascenev1lib.actor.spawner import Spawner
 944
 945        Spawner(
 946            pt=pos,
 947            spawn_time=spawn_time,
 948            send_spawn_message=False,
 949            spawn_callback=bs.Call(
 950                self._spawn_bot, bot_type, pos, on_spawn_call
 951            ),
 952        )
 953        self._spawning_count += 1
 954
 955    def _spawn_bot(
 956        self,
 957        bot_type: type[SpazBot],
 958        pos: Sequence[float],
 959        on_spawn_call: Callable[[SpazBot], Any] | None,
 960    ) -> None:
 961        spaz = bot_type()
 962        self._spawn_sound.play(position=pos)
 963        assert spaz.node
 964        spaz.node.handlemessage('flash')
 965        spaz.node.is_area_of_interest = False
 966        spaz.handlemessage(bs.StandMessage(pos, random.uniform(0, 360)))
 967        self.add_bot(spaz)
 968        self._spawning_count -= 1
 969        if on_spawn_call is not None:
 970            on_spawn_call(spaz)
 971
 972    def have_living_bots(self) -> bool:
 973        """Return whether any bots in the set are alive or spawning."""
 974        return self._spawning_count > 0 or any(
 975            any(b.is_alive() for b in l) for l in self._bot_lists
 976        )
 977
 978    def get_living_bots(self) -> list[SpazBot]:
 979        """Get the living bots in the set."""
 980        bots: list[SpazBot] = []
 981        for botlist in self._bot_lists:
 982            for bot in botlist:
 983                if bot.is_alive():
 984                    bots.append(bot)
 985        return bots
 986
 987    def _update(self) -> None:
 988        # Update one of our bot lists each time through.
 989        # First off, remove no-longer-existing bots from the list.
 990        try:
 991            bot_list = self._bot_lists[self._bot_update_list] = [
 992                b for b in self._bot_lists[self._bot_update_list] if b
 993            ]
 994        except Exception:
 995            bot_list = []
 996            logging.exception(
 997                'Error updating bot list: %s',
 998                self._bot_lists[self._bot_update_list],
 999            )
1000        self._bot_update_list = (
1001            self._bot_update_list + 1
1002        ) % self._bot_list_count
1003
1004        # Update our list of player points for the bots to use.
1005        player_pts = []
1006        for player in bs.getactivity().players:
1007            assert isinstance(player, bs.Player)
1008            try:
1009                # TODO: could use abstracted player.position here so we
1010                # don't have to assume their actor type, but we have no
1011                # abstracted velocity as of yet.
1012                if player.is_alive():
1013                    assert isinstance(player.actor, Spaz)
1014                    assert player.actor.node
1015                    player_pts.append(
1016                        (
1017                            bs.Vec3(player.actor.node.position),
1018                            bs.Vec3(player.actor.node.velocity),
1019                        )
1020                    )
1021            except Exception:
1022                logging.exception('Error on bot-set _update.')
1023
1024        for bot in bot_list:
1025            bot.set_player_points(player_pts)
1026            bot.update_ai()
1027
1028    def clear(self) -> None:
1029        """Immediately clear out any bots in the set."""
1030
1031        # Don't do this if the activity is shutting down or dead.
1032        activity = bs.getactivity(doraise=False)
1033        if activity is None or activity.expired:
1034            return
1035
1036        for i, bot_list in enumerate(self._bot_lists):
1037            for bot in bot_list:
1038                bot.handlemessage(bs.DieMessage(immediate=True))
1039            self._bot_lists[i] = []
1040
1041    def start_moving(self) -> None:
1042        """Start processing bot AI updates so they start doing their thing."""
1043        self._bot_update_timer = bs.Timer(
1044            0.05, bs.WeakCall(self._update), repeat=True
1045        )
1046
1047    def stop_moving(self) -> None:
1048        """Tell all bots to stop moving and stops updating their AI.
1049
1050        Useful when players have won and you want the
1051        enemy bots to just stand and look bewildered.
1052        """
1053        self._bot_update_timer = None
1054        for botlist in self._bot_lists:
1055            for bot in botlist:
1056                if bot.node:
1057                    bot.node.move_left_right = 0
1058                    bot.node.move_up_down = 0
1059
1060    def celebrate(self, duration: float) -> None:
1061        """Tell all living bots in the set to celebrate momentarily.
1062
1063        Duration is given in seconds.
1064        """
1065        msg = bs.CelebrateMessage(duration=duration)
1066        for botlist in self._bot_lists:
1067            for bot in botlist:
1068                if bot:
1069                    bot.handlemessage(msg)
1070
1071    def final_celebrate(self) -> None:
1072        """Tell all bots in the set to stop what they were doing and celebrate.
1073
1074        Use this when the bots have won a game.
1075        """
1076        self._bot_update_timer = None
1077
1078        # At this point stop doing anything but jumping and celebrating.
1079        for botlist in self._bot_lists:
1080            for bot in botlist:
1081                if bot:
1082                    assert bot.node  # (should exist if 'if bot' was True)
1083                    bot.node.move_left_right = 0
1084                    bot.node.move_up_down = 0
1085                    bs.timer(
1086                        0.5 * random.random(),
1087                        bs.Call(bot.handlemessage, bs.CelebrateMessage()),
1088                    )
1089                    jump_duration = random.randrange(400, 500)
1090                    j = random.randrange(0, 200)
1091                    for _i in range(10):
1092                        bot.node.jump_pressed = True
1093                        bot.node.jump_pressed = False
1094                        j += jump_duration
1095                    bs.timer(
1096                        random.uniform(0.0, 1.0),
1097                        bs.Call(bot.node.handlemessage, 'attack_sound'),
1098                    )
1099                    bs.timer(
1100                        random.uniform(1.0, 2.0),
1101                        bs.Call(bot.node.handlemessage, 'attack_sound'),
1102                    )
1103                    bs.timer(
1104                        random.uniform(2.0, 3.0),
1105                        bs.Call(bot.node.handlemessage, 'attack_sound'),
1106                    )
1107
1108    def add_bot(self, bot: SpazBot) -> None:
1109        """Add a bs.SpazBot instance to the set."""
1110        self._bot_lists[self._bot_add_list].append(bot)
1111        self._bot_add_list = (self._bot_add_list + 1) % self._bot_list_count

A container/controller for one or more bs.SpazBots.

category: Bot Classes

SpazBotSet()
916    def __init__(self) -> None:
917        """Create a bot-set."""
918
919        # We spread our bots out over a few lists so we can update
920        # them in a staggered fashion.
921        self._bot_list_count = 5
922        self._bot_add_list = 0
923        self._bot_update_list = 0
924        self._bot_lists: list[list[SpazBot]] = [
925            [] for _ in range(self._bot_list_count)
926        ]
927        self._spawn_sound = bs.getsound('spawn')
928        self._spawning_count = 0
929        self._bot_update_timer: bs.Timer | None = None
930        self.start_moving()

Create a bot-set.

def spawn_bot( self, bot_type: type[SpazBot], pos: Sequence[float], spawn_time: float = 3.0, on_spawn_call: Optional[Callable[[SpazBot], Any]] = None) -> None:
935    def spawn_bot(
936        self,
937        bot_type: type[SpazBot],
938        pos: Sequence[float],
939        spawn_time: float = 3.0,
940        on_spawn_call: Callable[[SpazBot], Any] | None = None,
941    ) -> None:
942        """Spawn a bot from this set."""
943        from bascenev1lib.actor.spawner import Spawner
944
945        Spawner(
946            pt=pos,
947            spawn_time=spawn_time,
948            send_spawn_message=False,
949            spawn_callback=bs.Call(
950                self._spawn_bot, bot_type, pos, on_spawn_call
951            ),
952        )
953        self._spawning_count += 1

Spawn a bot from this set.

def have_living_bots(self) -> bool:
972    def have_living_bots(self) -> bool:
973        """Return whether any bots in the set are alive or spawning."""
974        return self._spawning_count > 0 or any(
975            any(b.is_alive() for b in l) for l in self._bot_lists
976        )

Return whether any bots in the set are alive or spawning.

def get_living_bots(self) -> list[SpazBot]:
978    def get_living_bots(self) -> list[SpazBot]:
979        """Get the living bots in the set."""
980        bots: list[SpazBot] = []
981        for botlist in self._bot_lists:
982            for bot in botlist:
983                if bot.is_alive():
984                    bots.append(bot)
985        return bots

Get the living bots in the set.

def clear(self) -> None:
1028    def clear(self) -> None:
1029        """Immediately clear out any bots in the set."""
1030
1031        # Don't do this if the activity is shutting down or dead.
1032        activity = bs.getactivity(doraise=False)
1033        if activity is None or activity.expired:
1034            return
1035
1036        for i, bot_list in enumerate(self._bot_lists):
1037            for bot in bot_list:
1038                bot.handlemessage(bs.DieMessage(immediate=True))
1039            self._bot_lists[i] = []

Immediately clear out any bots in the set.

def start_moving(self) -> None:
1041    def start_moving(self) -> None:
1042        """Start processing bot AI updates so they start doing their thing."""
1043        self._bot_update_timer = bs.Timer(
1044            0.05, bs.WeakCall(self._update), repeat=True
1045        )

Start processing bot AI updates so they start doing their thing.

def stop_moving(self) -> None:
1047    def stop_moving(self) -> None:
1048        """Tell all bots to stop moving and stops updating their AI.
1049
1050        Useful when players have won and you want the
1051        enemy bots to just stand and look bewildered.
1052        """
1053        self._bot_update_timer = None
1054        for botlist in self._bot_lists:
1055            for bot in botlist:
1056                if bot.node:
1057                    bot.node.move_left_right = 0
1058                    bot.node.move_up_down = 0

Tell all bots to stop moving and stops updating their AI.

Useful when players have won and you want the enemy bots to just stand and look bewildered.

def celebrate(self, duration: float) -> None:
1060    def celebrate(self, duration: float) -> None:
1061        """Tell all living bots in the set to celebrate momentarily.
1062
1063        Duration is given in seconds.
1064        """
1065        msg = bs.CelebrateMessage(duration=duration)
1066        for botlist in self._bot_lists:
1067            for bot in botlist:
1068                if bot:
1069                    bot.handlemessage(msg)

Tell all living bots in the set to celebrate momentarily.

Duration is given in seconds.

def final_celebrate(self) -> None:
1071    def final_celebrate(self) -> None:
1072        """Tell all bots in the set to stop what they were doing and celebrate.
1073
1074        Use this when the bots have won a game.
1075        """
1076        self._bot_update_timer = None
1077
1078        # At this point stop doing anything but jumping and celebrating.
1079        for botlist in self._bot_lists:
1080            for bot in botlist:
1081                if bot:
1082                    assert bot.node  # (should exist if 'if bot' was True)
1083                    bot.node.move_left_right = 0
1084                    bot.node.move_up_down = 0
1085                    bs.timer(
1086                        0.5 * random.random(),
1087                        bs.Call(bot.handlemessage, bs.CelebrateMessage()),
1088                    )
1089                    jump_duration = random.randrange(400, 500)
1090                    j = random.randrange(0, 200)
1091                    for _i in range(10):
1092                        bot.node.jump_pressed = True
1093                        bot.node.jump_pressed = False
1094                        j += jump_duration
1095                    bs.timer(
1096                        random.uniform(0.0, 1.0),
1097                        bs.Call(bot.node.handlemessage, 'attack_sound'),
1098                    )
1099                    bs.timer(
1100                        random.uniform(1.0, 2.0),
1101                        bs.Call(bot.node.handlemessage, 'attack_sound'),
1102                    )
1103                    bs.timer(
1104                        random.uniform(2.0, 3.0),
1105                        bs.Call(bot.node.handlemessage, 'attack_sound'),
1106                    )

Tell all bots in the set to stop what they were doing and celebrate.

Use this when the bots have won a game.

def add_bot(self, bot: SpazBot) -> None:
1108    def add_bot(self, bot: SpazBot) -> None:
1109        """Add a bs.SpazBot instance to the set."""
1110        self._bot_lists[self._bot_add_list].append(bot)
1111        self._bot_add_list = (self._bot_add_list + 1) % self._bot_list_count

Add a bs.SpazBot instance to the set.