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

A message saying a ba.SpazBot got punched.

Category: Message Classes

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

Instantiate a message with the given values.

The ba.SpazBot that got punched.

damage: int

How much damage was done to the SpazBot.

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

A message saying a ba.SpazBot has died.

Category: Message Classes

SpazBotDiedMessage( spazbot: bastd.actor.spazbot.SpazBot, killerplayer: ba._player.Player | None, how: ba._messages.DeathType)
61    def __init__(self, spazbot: SpazBot, killerplayer: ba.Player | None,
62                 how: ba.DeathType):
63        """Instantiate with given values."""
64        self.spazbot = spazbot
65        self.killerplayer = killerplayer
66        self.how = how

Instantiate with given values.

The SpazBot that was killed.

killerplayer: ba._player.Player | None

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

how: ba._messages.DeathType

The particular type of death.

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

A really dumb AI version of ba.Spaz.

Category: Bot Classes

Add these to a ba.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 ba.SpazBotDiedMessage to the current activity.

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

SpazBot()
107    def __init__(self) -> None:
108        """Instantiate a spaz-bot."""
109        super().__init__(color=self.color,
110                         highlight=self.highlight,
111                         character=self.character,
112                         source_player=None,
113                         start_invincible=False,
114                         can_accept_powerups=False)
115
116        # If you need to add custom behavior to a bot, set this to a callable
117        # which takes one arg (the bot) and returns False if the bot's normal
118        # update should be run and True if not.
119        self.update_callback: Callable[[SpazBot], Any] | None = None
120        activity = self.activity
121        assert isinstance(activity, ba.GameActivity)
122        self._map = weakref.ref(activity.map)
123        self.last_player_attacked_by: ba.Player | None = None
124        self.last_attacked_time = 0.0
125        self.last_attacked_type: tuple[str, str] | None = None
126        self.target_point_default: ba.Vec3 | None = None
127        self.held_count = 0
128        self.last_player_held_by: ba.Player | None = None
129        self.target_flag: Flag | None = None
130        self._charge_speed = 0.5 * (self.charge_speed_min +
131                                    self.charge_speed_max)
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[ba.Vec3, ba.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)
map: ba._map.Map

The map this bot was created on.

def set_player_points(self, pts: list[tuple[_ba.Vec3, _ba.Vec3]]) -> None:
188    def set_player_points(self, pts: list[tuple[ba.Vec3, ba.Vec3]]) -> None:
189        """Provide the spaz-bot with the locations of its enemies."""
190        self._player_pts = pts

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

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

Should be called periodically to update the spaz' AI.

def on_punched(self, damage: int) -> None:
468    def on_punched(self, damage: int) -> None:
469        """
470        Method override; sends ba.SpazBotPunchedMessage
471        to the current activity.
472        """
473        ba.getactivity().handlemessage(SpazBotPunchedMessage(self, damage))

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

def on_expire(self) -> None:
475    def on_expire(self) -> None:
476        super().on_expire()
477
478        # We're being torn down; release our callback(s) so there's
479        # no chance of them keeping activities or other things alive.
480        self.update_callback = None

Called for remaining ba.Actors when their ba.Activity shuts down.

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

Once an actor is expired (see ba.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.

def handlemessage(self, msg: Any) -> Any:
482    def handlemessage(self, msg: Any) -> Any:
483        # pylint: disable=too-many-branches
484        assert not self.expired
485
486        # Keep track of if we're being held and by who most recently.
487        if isinstance(msg, ba.PickedUpMessage):
488            super().handlemessage(msg)  # Augment standard behavior.
489            self.held_count += 1
490            picked_up_by = msg.node.source_player
491            if picked_up_by:
492                self.last_player_held_by = picked_up_by
493
494        elif isinstance(msg, ba.DroppedMessage):
495            super().handlemessage(msg)  # Augment standard behavior.
496            self.held_count -= 1
497            if self.held_count < 0:
498                print('ERROR: spaz held_count < 0')
499
500            # Let's count someone dropping us as an attack.
501            try:
502                if msg.node:
503                    picked_up_by = msg.node.source_player
504                else:
505                    picked_up_by = None
506            except Exception:
507                ba.print_exception('Error on SpazBot DroppedMessage.')
508                picked_up_by = None
509
510            if picked_up_by:
511                self.last_player_attacked_by = picked_up_by
512                self.last_attacked_time = ba.time()
513                self.last_attacked_type = ('picked_up', 'default')
514
515        elif isinstance(msg, ba.DieMessage):
516
517            # Report normal deaths for scoring purposes.
518            if not self._dead and not msg.immediate:
519
520                killerplayer: ba.Player | None
521
522                # If this guy was being held at the time of death, the
523                # holder is the killer.
524                if self.held_count > 0 and self.last_player_held_by:
525                    killerplayer = self.last_player_held_by
526                else:
527                    # If they were attacked by someone in the last few
528                    # seconds that person's the killer.
529                    # Otherwise it was a suicide.
530                    if (self.last_player_attacked_by
531                            and ba.time() - self.last_attacked_time < 4.0):
532                        killerplayer = self.last_player_attacked_by
533                    else:
534                        killerplayer = None
535                activity = self._activity()
536
537                # (convert dead player refs to None)
538                if not killerplayer:
539                    killerplayer = None
540                if activity is not None:
541                    activity.handlemessage(
542                        SpazBotDiedMessage(self, killerplayer, msg.how))
543            super().handlemessage(msg)  # Augment standard behavior.
544
545        # Keep track of the player who last hit us for point rewarding.
546        elif isinstance(msg, ba.HitMessage):
547            source_player = msg.get_source_player(ba.Player)
548            if source_player:
549                self.last_player_attacked_by = source_player
550                self.last_attacked_time = ba.time()
551                self.last_attacked_type = (msg.hit_type, msg.hit_subtype)
552            super().handlemessage(msg)
553        else:
554            super().handlemessage(msg)

General message handling; can be passed any message object.

class BomberBot(SpazBot):
557class BomberBot(SpazBot):
558    """A bot that throws regular bombs and occasionally punches.
559
560    category: Bot Classes
561    """
562    character = 'Spaz'
563    punchiness = 0.3

A bot that throws regular bombs and occasionally punches.

category: Bot Classes

character = 'Spaz'
punchiness = 0.3
class BomberBotLite(BomberBot):
566class BomberBotLite(BomberBot):
567    """A less aggressive yellow version of ba.BomberBot.
568
569    category: Bot Classes
570    """
571    color = LITE_BOT_COLOR
572    highlight = LITE_BOT_HIGHLIGHT
573    punchiness = 0.2
574    throw_rate = 0.7
575    throwiness = 0.1
576    charge_speed_min = 0.6
577    charge_speed_max = 0.6

A less aggressive yellow version of ba.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):
580class BomberBotStaticLite(BomberBotLite):
581    """A less aggressive generally immobile weak version of ba.BomberBot.
582
583    category: Bot Classes
584    """
585    static = True
586    throw_dist_min = 0.0

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

category: Bot Classes

static = True
throw_dist_min = 0.0
class BomberBotStatic(BomberBot):
589class BomberBotStatic(BomberBot):
590    """A version of ba.BomberBot who generally stays in one place.
591
592    category: Bot Classes
593    """
594    static = True
595    throw_dist_min = 0.0

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

category: Bot Classes

static = True
throw_dist_min = 0.0
class BomberBotPro(BomberBot):
598class BomberBotPro(BomberBot):
599    """A more powerful version of ba.BomberBot.
600
601    category: Bot Classes
602    """
603    points_mult = 2
604    color = PRO_BOT_COLOR
605    highlight = PRO_BOT_HIGHLIGHT
606    default_bomb_count = 3
607    default_boxing_gloves = True
608    punchiness = 0.7
609    throw_rate = 1.3
610    run = True
611    run_dist_min = 6.0

A more powerful version of ba.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):
614class BomberBotProShielded(BomberBotPro):
615    """A more powerful version of ba.BomberBot who starts with shields.
616
617    category: Bot Classes
618    """
619    points_mult = 3
620    default_shields = True

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

category: Bot Classes

points_mult = 3
default_shields = True
class BomberBotProStatic(BomberBotPro):
623class BomberBotProStatic(BomberBotPro):
624    """A more powerful ba.BomberBot who generally stays in one place.
625
626    category: Bot Classes
627    """
628    static = True
629    throw_dist_min = 0.0

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

category: Bot Classes

static = True
throw_dist_min = 0.0
class BomberBotProStaticShielded(BomberBotProShielded):
632class BomberBotProStaticShielded(BomberBotProShielded):
633    """A powerful ba.BomberBot with shields who is generally immobile.
634
635    category: Bot Classes
636    """
637    static = True
638    throw_dist_min = 0.0

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

category: Bot Classes

static = True
throw_dist_min = 0.0
class BrawlerBot(SpazBot):
641class BrawlerBot(SpazBot):
642    """A bot who walks and punches things.
643
644    category: Bot Classes
645    """
646    character = 'Kronk'
647    punchiness = 0.9
648    charge_dist_max = 9999.0
649    charge_speed_min = 1.0
650    charge_speed_max = 1.0
651    throw_dist_min = 9999
652    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):
655class BrawlerBotLite(BrawlerBot):
656    """A weaker version of ba.BrawlerBot.
657
658    category: Bot Classes
659    """
660    color = LITE_BOT_COLOR
661    highlight = LITE_BOT_HIGHLIGHT
662    punchiness = 0.3
663    charge_speed_min = 0.6
664    charge_speed_max = 0.6

A weaker version of ba.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):
667class BrawlerBotPro(BrawlerBot):
668    """A stronger version of ba.BrawlerBot.
669
670    category: Bot Classes
671    """
672    color = PRO_BOT_COLOR
673    highlight = PRO_BOT_HIGHLIGHT
674    run = True
675    run_dist_min = 4.0
676    default_boxing_gloves = True
677    punchiness = 0.95
678    points_mult = 2

A stronger version of ba.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):
681class BrawlerBotProShielded(BrawlerBotPro):
682    """A stronger version of ba.BrawlerBot who starts with shields.
683
684    category: Bot Classes
685    """
686    default_shields = True
687    points_mult = 3

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

category: Bot Classes

default_shields = True
points_mult = 3
class ChargerBot(SpazBot):
690class ChargerBot(SpazBot):
691    """A speedy melee attack bot.
692
693    category: Bot Classes
694    """
695
696    character = 'Snake Shadow'
697    punchiness = 1.0
698    run = True
699    charge_dist_min = 10.0
700    charge_dist_max = 9999.0
701    charge_speed_min = 1.0
702    charge_speed_max = 1.0
703    throw_dist_min = 9999
704    throw_dist_max = 9999
705    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):
708class BouncyBot(SpazBot):
709    """A speedy attacking melee bot that jumps constantly.
710
711    category: Bot Classes
712    """
713
714    color = (1, 1, 1)
715    highlight = (1.0, 0.5, 0.5)
716    character = 'Easter Bunny'
717    punchiness = 1.0
718    run = True
719    bouncy = True
720    default_boxing_gloves = True
721    charge_dist_min = 10.0
722    charge_dist_max = 9999.0
723    charge_speed_min = 1.0
724    charge_speed_max = 1.0
725    throw_dist_min = 9999
726    throw_dist_max = 9999
727    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):
730class ChargerBotPro(ChargerBot):
731    """A stronger ba.ChargerBot.
732
733    category: Bot Classes
734    """
735    color = PRO_BOT_COLOR
736    highlight = PRO_BOT_HIGHLIGHT
737    default_shields = True
738    default_boxing_gloves = True
739    points_mult = 3

A stronger ba.ChargerBot.

category: Bot Classes

color = (1.0, 0.2, 0.1)
highlight = (0.6, 0.1, 0.05)
default_shields = True
default_boxing_gloves = True
points_mult = 3
class ChargerBotProShielded(ChargerBotPro):
742class ChargerBotProShielded(ChargerBotPro):
743    """A stronger ba.ChargerBot who starts with shields.
744
745    category: Bot Classes
746    """
747    default_shields = True
748    points_mult = 4

A stronger ba.ChargerBot who starts with shields.

category: Bot Classes

default_shields = True
points_mult = 4
class TriggerBot(SpazBot):
751class TriggerBot(SpazBot):
752    """A slow moving bot with trigger bombs.
753
754    category: Bot Classes
755    """
756    character = 'Zoe'
757    punchiness = 0.75
758    throwiness = 0.7
759    charge_dist_max = 1.0
760    charge_speed_min = 0.3
761    charge_speed_max = 0.5
762    throw_dist_min = 3.5
763    throw_dist_max = 5.5
764    default_bomb_type = 'impact'
765    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):
768class TriggerBotStatic(TriggerBot):
769    """A ba.TriggerBot who generally stays in one place.
770
771    category: Bot Classes
772    """
773    static = True
774    throw_dist_min = 0.0

A ba.TriggerBot who generally stays in one place.

category: Bot Classes

static = True
throw_dist_min = 0.0
class TriggerBotPro(TriggerBot):
777class TriggerBotPro(TriggerBot):
778    """A stronger version of ba.TriggerBot.
779
780    category: Bot Classes
781    """
782    color = PRO_BOT_COLOR
783    highlight = PRO_BOT_HIGHLIGHT
784    default_bomb_count = 3
785    default_boxing_gloves = True
786    charge_speed_min = 1.0
787    charge_speed_max = 1.0
788    punchiness = 0.9
789    throw_rate = 1.3
790    run = True
791    run_dist_min = 6.0
792    points_mult = 3

A stronger version of ba.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):
795class TriggerBotProShielded(TriggerBotPro):
796    """A stronger version of ba.TriggerBot who starts with shields.
797
798    category: Bot Classes
799    """
800    default_shields = True
801    points_mult = 4

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

category: Bot Classes

default_shields = True
points_mult = 4
class StickyBot(SpazBot):
804class StickyBot(SpazBot):
805    """A crazy bot who runs and throws sticky bombs.
806
807    category: Bot Classes
808    """
809    character = 'Mel'
810    punchiness = 0.9
811    throwiness = 1.0
812    run = True
813    charge_dist_min = 4.0
814    charge_dist_max = 10.0
815    charge_speed_min = 1.0
816    charge_speed_max = 1.0
817    throw_dist_min = 0.0
818    throw_dist_max = 4.0
819    throw_rate = 2.0
820    default_bomb_type = 'sticky'
821    default_bomb_count = 3
822    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):
825class StickyBotStatic(StickyBot):
826    """A crazy bot who throws sticky-bombs but generally stays in one place.
827
828    category: Bot Classes
829    """
830    static = True

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

category: Bot Classes

static = True
class ExplodeyBot(SpazBot):
833class ExplodeyBot(SpazBot):
834    """A bot who runs and explodes in 5 seconds.
835
836    category: Bot Classes
837    """
838    character = 'Jack Morgan'
839    run = True
840    charge_dist_min = 0.0
841    charge_dist_max = 9999
842    charge_speed_min = 1.0
843    charge_speed_max = 1.0
844    throw_dist_min = 9999
845    throw_dist_max = 9999
846    start_cursed = True
847    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):
850class ExplodeyBotNoTimeLimit(ExplodeyBot):
851    """A bot who runs but does not explode on his own.
852
853    category: Bot Classes
854    """
855    curse_time = None

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

category: Bot Classes

curse_time: float | None = None
class ExplodeyBotShielded(ExplodeyBot):
858class ExplodeyBotShielded(ExplodeyBot):
859    """A ba.ExplodeyBot who starts with shields.
860
861    category: Bot Classes
862    """
863    default_shields = True
864    points_mult = 5

A ba.ExplodeyBot who starts with shields.

category: Bot Classes

default_shields = True
points_mult = 5
class SpazBotSet:
 867class SpazBotSet:
 868    """A container/controller for one or more ba.SpazBots.
 869
 870    category: Bot Classes
 871    """
 872
 873    def __init__(self) -> None:
 874        """Create a bot-set."""
 875
 876        # We spread our bots out over a few lists so we can update
 877        # them in a staggered fashion.
 878        self._bot_list_count = 5
 879        self._bot_add_list = 0
 880        self._bot_update_list = 0
 881        self._bot_lists: list[list[SpazBot]] = [
 882            [] for _ in range(self._bot_list_count)
 883        ]
 884        self._spawn_sound = ba.getsound('spawn')
 885        self._spawning_count = 0
 886        self._bot_update_timer: ba.Timer | None = None
 887        self.start_moving()
 888
 889    def __del__(self) -> None:
 890        self.clear()
 891
 892    def spawn_bot(
 893            self,
 894            bot_type: type[SpazBot],
 895            pos: Sequence[float],
 896            spawn_time: float = 3.0,
 897            on_spawn_call: Callable[[SpazBot], Any] | None = None) -> None:
 898        """Spawn a bot from this set."""
 899        from bastd.actor import spawner
 900        spawner.Spawner(pt=pos,
 901                        spawn_time=spawn_time,
 902                        send_spawn_message=False,
 903                        spawn_callback=ba.Call(self._spawn_bot, bot_type, pos,
 904                                               on_spawn_call))
 905        self._spawning_count += 1
 906
 907    def _spawn_bot(self, bot_type: type[SpazBot], pos: Sequence[float],
 908                   on_spawn_call: Callable[[SpazBot], Any] | None) -> None:
 909        spaz = bot_type()
 910        ba.playsound(self._spawn_sound, position=pos)
 911        assert spaz.node
 912        spaz.node.handlemessage('flash')
 913        spaz.node.is_area_of_interest = False
 914        spaz.handlemessage(ba.StandMessage(pos, random.uniform(0, 360)))
 915        self.add_bot(spaz)
 916        self._spawning_count -= 1
 917        if on_spawn_call is not None:
 918            on_spawn_call(spaz)
 919
 920    def have_living_bots(self) -> bool:
 921        """Return whether any bots in the set are alive or spawning."""
 922        return (self._spawning_count > 0
 923                or any(any(b.is_alive() for b in l) for l in self._bot_lists))
 924
 925    def get_living_bots(self) -> list[SpazBot]:
 926        """Get the living bots in the set."""
 927        bots: list[SpazBot] = []
 928        for botlist in self._bot_lists:
 929            for bot in botlist:
 930                if bot.is_alive():
 931                    bots.append(bot)
 932        return bots
 933
 934    def _update(self) -> None:
 935
 936        # Update one of our bot lists each time through.
 937        # First off, remove no-longer-existing bots from the list.
 938        try:
 939            bot_list = self._bot_lists[self._bot_update_list] = ([
 940                b for b in self._bot_lists[self._bot_update_list] if b
 941            ])
 942        except Exception:
 943            bot_list = []
 944            ba.print_exception('Error updating bot list: ' +
 945                               str(self._bot_lists[self._bot_update_list]))
 946        self._bot_update_list = (self._bot_update_list +
 947                                 1) % self._bot_list_count
 948
 949        # Update our list of player points for the bots to use.
 950        player_pts = []
 951        for player in ba.getactivity().players:
 952            assert isinstance(player, ba.Player)
 953            try:
 954                # TODO: could use abstracted player.position here so we
 955                # don't have to assume their actor type, but we have no
 956                # abstracted velocity as of yet.
 957                if player.is_alive():
 958                    assert isinstance(player.actor, Spaz)
 959                    assert player.actor.node
 960                    player_pts.append((ba.Vec3(player.actor.node.position),
 961                                       ba.Vec3(player.actor.node.velocity)))
 962            except Exception:
 963                ba.print_exception('Error on bot-set _update.')
 964
 965        for bot in bot_list:
 966            bot.set_player_points(player_pts)
 967            bot.update_ai()
 968
 969    def clear(self) -> None:
 970        """Immediately clear out any bots in the set."""
 971
 972        # Don't do this if the activity is shutting down or dead.
 973        activity = ba.getactivity(doraise=False)
 974        if activity is None or activity.expired:
 975            return
 976
 977        for i, bot_list in enumerate(self._bot_lists):
 978            for bot in bot_list:
 979                bot.handlemessage(ba.DieMessage(immediate=True))
 980            self._bot_lists[i] = []
 981
 982    def start_moving(self) -> None:
 983        """Start processing bot AI updates so they start doing their thing."""
 984        self._bot_update_timer = ba.Timer(0.05,
 985                                          ba.WeakCall(self._update),
 986                                          repeat=True)
 987
 988    def stop_moving(self) -> None:
 989        """Tell all bots to stop moving and stops updating their AI.
 990
 991        Useful when players have won and you want the
 992        enemy bots to just stand and look bewildered.
 993        """
 994        self._bot_update_timer = None
 995        for botlist in self._bot_lists:
 996            for bot in botlist:
 997                if bot.node:
 998                    bot.node.move_left_right = 0
 999                    bot.node.move_up_down = 0
1000
1001    def celebrate(self, duration: float) -> None:
1002        """Tell all living bots in the set to celebrate momentarily.
1003
1004        Duration is given in seconds.
1005        """
1006        msg = ba.CelebrateMessage(duration=duration)
1007        for botlist in self._bot_lists:
1008            for bot in botlist:
1009                if bot:
1010                    bot.handlemessage(msg)
1011
1012    def final_celebrate(self) -> None:
1013        """Tell all bots in the set to stop what they were doing and celebrate.
1014
1015        Use this when the bots have won a game.
1016        """
1017        self._bot_update_timer = None
1018
1019        # At this point stop doing anything but jumping and celebrating.
1020        for botlist in self._bot_lists:
1021            for bot in botlist:
1022                if bot:
1023                    assert bot.node  # (should exist if 'if bot' was True)
1024                    bot.node.move_left_right = 0
1025                    bot.node.move_up_down = 0
1026                    ba.timer(0.5 * random.random(),
1027                             ba.Call(bot.handlemessage, ba.CelebrateMessage()))
1028                    jump_duration = random.randrange(400, 500)
1029                    j = random.randrange(0, 200)
1030                    for _i in range(10):
1031                        bot.node.jump_pressed = True
1032                        bot.node.jump_pressed = False
1033                        j += jump_duration
1034                    ba.timer(random.uniform(0.0, 1.0),
1035                             ba.Call(bot.node.handlemessage, 'attack_sound'))
1036                    ba.timer(random.uniform(1.0, 2.0),
1037                             ba.Call(bot.node.handlemessage, 'attack_sound'))
1038                    ba.timer(random.uniform(2.0, 3.0),
1039                             ba.Call(bot.node.handlemessage, 'attack_sound'))
1040
1041    def add_bot(self, bot: SpazBot) -> None:
1042        """Add a ba.SpazBot instance to the set."""
1043        self._bot_lists[self._bot_add_list].append(bot)
1044        self._bot_add_list = (self._bot_add_list + 1) % self._bot_list_count

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

category: Bot Classes

SpazBotSet()
873    def __init__(self) -> None:
874        """Create a bot-set."""
875
876        # We spread our bots out over a few lists so we can update
877        # them in a staggered fashion.
878        self._bot_list_count = 5
879        self._bot_add_list = 0
880        self._bot_update_list = 0
881        self._bot_lists: list[list[SpazBot]] = [
882            [] for _ in range(self._bot_list_count)
883        ]
884        self._spawn_sound = ba.getsound('spawn')
885        self._spawning_count = 0
886        self._bot_update_timer: ba.Timer | None = None
887        self.start_moving()

Create a bot-set.

def spawn_bot( self, bot_type: type[bastd.actor.spazbot.SpazBot], pos: Sequence[float], spawn_time: float = 3.0, on_spawn_call: Optional[Callable[[bastd.actor.spazbot.SpazBot], Any]] = None) -> None:
892    def spawn_bot(
893            self,
894            bot_type: type[SpazBot],
895            pos: Sequence[float],
896            spawn_time: float = 3.0,
897            on_spawn_call: Callable[[SpazBot], Any] | None = None) -> None:
898        """Spawn a bot from this set."""
899        from bastd.actor import spawner
900        spawner.Spawner(pt=pos,
901                        spawn_time=spawn_time,
902                        send_spawn_message=False,
903                        spawn_callback=ba.Call(self._spawn_bot, bot_type, pos,
904                                               on_spawn_call))
905        self._spawning_count += 1

Spawn a bot from this set.

def have_living_bots(self) -> bool:
920    def have_living_bots(self) -> bool:
921        """Return whether any bots in the set are alive or spawning."""
922        return (self._spawning_count > 0
923                or any(any(b.is_alive() for b in l) for l in self._bot_lists))

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

def get_living_bots(self) -> list[bastd.actor.spazbot.SpazBot]:
925    def get_living_bots(self) -> list[SpazBot]:
926        """Get the living bots in the set."""
927        bots: list[SpazBot] = []
928        for botlist in self._bot_lists:
929            for bot in botlist:
930                if bot.is_alive():
931                    bots.append(bot)
932        return bots

Get the living bots in the set.

def clear(self) -> None:
969    def clear(self) -> None:
970        """Immediately clear out any bots in the set."""
971
972        # Don't do this if the activity is shutting down or dead.
973        activity = ba.getactivity(doraise=False)
974        if activity is None or activity.expired:
975            return
976
977        for i, bot_list in enumerate(self._bot_lists):
978            for bot in bot_list:
979                bot.handlemessage(ba.DieMessage(immediate=True))
980            self._bot_lists[i] = []

Immediately clear out any bots in the set.

def start_moving(self) -> None:
982    def start_moving(self) -> None:
983        """Start processing bot AI updates so they start doing their thing."""
984        self._bot_update_timer = ba.Timer(0.05,
985                                          ba.WeakCall(self._update),
986                                          repeat=True)

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

def stop_moving(self) -> None:
988    def stop_moving(self) -> None:
989        """Tell all bots to stop moving and stops updating their AI.
990
991        Useful when players have won and you want the
992        enemy bots to just stand and look bewildered.
993        """
994        self._bot_update_timer = None
995        for botlist in self._bot_lists:
996            for bot in botlist:
997                if bot.node:
998                    bot.node.move_left_right = 0
999                    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:
1001    def celebrate(self, duration: float) -> None:
1002        """Tell all living bots in the set to celebrate momentarily.
1003
1004        Duration is given in seconds.
1005        """
1006        msg = ba.CelebrateMessage(duration=duration)
1007        for botlist in self._bot_lists:
1008            for bot in botlist:
1009                if bot:
1010                    bot.handlemessage(msg)

Tell all living bots in the set to celebrate momentarily.

Duration is given in seconds.

def final_celebrate(self) -> None:
1012    def final_celebrate(self) -> None:
1013        """Tell all bots in the set to stop what they were doing and celebrate.
1014
1015        Use this when the bots have won a game.
1016        """
1017        self._bot_update_timer = None
1018
1019        # At this point stop doing anything but jumping and celebrating.
1020        for botlist in self._bot_lists:
1021            for bot in botlist:
1022                if bot:
1023                    assert bot.node  # (should exist if 'if bot' was True)
1024                    bot.node.move_left_right = 0
1025                    bot.node.move_up_down = 0
1026                    ba.timer(0.5 * random.random(),
1027                             ba.Call(bot.handlemessage, ba.CelebrateMessage()))
1028                    jump_duration = random.randrange(400, 500)
1029                    j = random.randrange(0, 200)
1030                    for _i in range(10):
1031                        bot.node.jump_pressed = True
1032                        bot.node.jump_pressed = False
1033                        j += jump_duration
1034                    ba.timer(random.uniform(0.0, 1.0),
1035                             ba.Call(bot.node.handlemessage, 'attack_sound'))
1036                    ba.timer(random.uniform(1.0, 2.0),
1037                             ba.Call(bot.node.handlemessage, 'attack_sound'))
1038                    ba.timer(random.uniform(2.0, 3.0),
1039                             ba.Call(bot.node.handlemessage, 'attack_sound'))

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: bastd.actor.spazbot.SpazBot) -> None:
1041    def add_bot(self, bot: SpazBot) -> None:
1042        """Add a ba.SpazBot instance to the set."""
1043        self._bot_lists[self._bot_add_list].append(bot)
1044        self._bot_add_list = (self._bot_add_list + 1) % self._bot_list_count

Add a ba.SpazBot instance to the set.