bascenev1lib.actor.spazbot

Bot versions of Spaz.

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

A message saying a bs.SpazBot got punched.

Category: Message Classes

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

Instantiate a message with the given values.

spazbot: SpazBot

The bs.SpazBot that got punched.

damage: int

How much damage was done to the SpazBot.

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

A message saying a bs.SpazBot has died.

Category: Message Classes

SpazBotDiedMessage( spazbot: SpazBot, killerplayer: bascenev1._player.Player | None, how: bascenev1._messages.DeathType)
62    def __init__(
63        self,
64        spazbot: SpazBot,
65        killerplayer: bs.Player | None,
66        how: bs.DeathType,
67    ):
68        """Instantiate with given values."""
69        self.spazbot = spazbot
70        self.killerplayer = killerplayer
71        self.how = how

Instantiate with given values.

spazbot: SpazBot

The SpazBot that was killed.

killerplayer: bascenev1._player.Player | None

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

how: bascenev1._messages.DeathType

The particular type of death.

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

A really dumb AI version of bs.Spaz.

Category: Bot Classes

Add these to a bs.BotSet to use them.

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

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

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

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

Instantiate a spaz-bot.

character = 'Spaz'
punchiness = 0.5
throwiness = 0.7
static = False
bouncy = False
run = False
charge_dist_min = 0.0
charge_dist_max = 2.0
run_dist_min = 0.0
charge_speed_min = 0.4
charge_speed_max = 1.0
throw_dist_min = 5.0
throw_dist_max = 9.0
throw_rate = 1.0
default_bomb_type = 'normal'
default_bomb_count = 3
start_cursed = False
color = (0.6, 0.6, 0.6)
highlight = (0.1, 0.3, 0.1)
update_callback: Optional[Callable[[SpazBot], Any]]
last_player_attacked_by: bascenev1._player.Player | None
last_attacked_time
last_attacked_type: tuple[str, str] | None
target_point_default: _babase.Vec3 | None
held_count
last_player_held_by: bascenev1._player.Player | None
target_flag: bascenev1lib.actor.flag.Flag | None
map: bascenev1._map.Map

The map this bot was created on.

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

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

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

Should be called periodically to update the spaz' AI.

def on_punched(self, damage: int) -> None:
493    def on_punched(self, damage: int) -> None:
494        """
495        Method override; sends bs.SpazBotPunchedMessage
496        to the current activity.
497        """
498        bs.getactivity().handlemessage(SpazBotPunchedMessage(self, damage))

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

def on_expire(self) -> None:
500    def on_expire(self) -> None:
501        super().on_expire()
502
503        # We're being torn down; release our callback(s) so there's
504        # no chance of them keeping activities or other things alive.
505        self.update_callback = None

Called for remaining bascenev1.Actors when their activity dies.

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

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

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

General message handling; can be passed any message object.

class BomberBot(SpazBot):
583class BomberBot(SpazBot):
584    """A bot that throws regular bombs and occasionally punches.
585
586    category: Bot Classes
587    """
588
589    character = 'Spaz'
590    punchiness = 0.3

A bot that throws regular bombs and occasionally punches.

category: Bot Classes

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

A less aggressive yellow version of bs.BomberBot.

category: Bot Classes

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

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

category: Bot Classes

static = True
throw_dist_min = 0.0
class BomberBotStatic(BomberBot):
618class BomberBotStatic(BomberBot):
619    """A version of bs.BomberBot who generally stays in one place.
620
621    category: Bot Classes
622    """
623
624    static = True
625    throw_dist_min = 0.0

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

category: Bot Classes

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

A more powerful version of bs.BomberBot.

category: Bot Classes

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

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

category: Bot Classes

points_mult = 3
default_shields = True
class BomberBotProStatic(BomberBotPro):
655class BomberBotProStatic(BomberBotPro):
656    """A more powerful bs.BomberBot who generally stays in one place.
657
658    category: Bot Classes
659    """
660
661    static = True
662    throw_dist_min = 0.0

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

category: Bot Classes

static = True
throw_dist_min = 0.0
class BomberBotProStaticShielded(BomberBotProShielded):
665class BomberBotProStaticShielded(BomberBotProShielded):
666    """A powerful bs.BomberBot with shields who is generally immobile.
667
668    category: Bot Classes
669    """
670
671    static = True
672    throw_dist_min = 0.0

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

category: Bot Classes

static = True
throw_dist_min = 0.0
class BrawlerBot(SpazBot):
675class BrawlerBot(SpazBot):
676    """A bot who walks and punches things.
677
678    category: Bot Classes
679    """
680
681    character = 'Kronk'
682    punchiness = 0.9
683    charge_dist_max = 9999.0
684    charge_speed_min = 1.0
685    charge_speed_max = 1.0
686    throw_dist_min = 9999
687    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):
690class BrawlerBotLite(BrawlerBot):
691    """A weaker version of bs.BrawlerBot.
692
693    category: Bot Classes
694    """
695
696    color = LITE_BOT_COLOR
697    highlight = LITE_BOT_HIGHLIGHT
698    punchiness = 0.3
699    charge_speed_min = 0.6
700    charge_speed_max = 0.6

A weaker version of bs.BrawlerBot.

category: Bot Classes

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

A stronger version of bs.BrawlerBot.

category: Bot Classes

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

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

category: Bot Classes

default_shields = True
points_mult = 3
class ChargerBot(SpazBot):
728class ChargerBot(SpazBot):
729    """A speedy melee attack bot.
730
731    category: Bot Classes
732    """
733
734    character = 'Snake Shadow'
735    punchiness = 1.0
736    run = True
737    charge_dist_min = 10.0
738    charge_dist_max = 9999.0
739    charge_speed_min = 1.0
740    charge_speed_max = 1.0
741    throw_dist_min = 9999
742    throw_dist_max = 9999
743    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):
746class BouncyBot(SpazBot):
747    """A speedy attacking melee bot that jumps constantly.
748
749    category: Bot Classes
750    """
751
752    color = (1, 1, 1)
753    highlight = (1.0, 0.5, 0.5)
754    character = 'Easter Bunny'
755    punchiness = 1.0
756    run = True
757    bouncy = True
758    default_boxing_gloves = True
759    charge_dist_min = 10.0
760    charge_dist_max = 9999.0
761    charge_speed_min = 1.0
762    charge_speed_max = 1.0
763    throw_dist_min = 9999
764    throw_dist_max = 9999
765    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):
768class ChargerBotPro(ChargerBot):
769    """A stronger bs.ChargerBot.
770
771    category: Bot Classes
772    """
773
774    color = PRO_BOT_COLOR
775    highlight = PRO_BOT_HIGHLIGHT
776    default_shields = True
777    default_boxing_gloves = True
778    points_mult = 3

A stronger bs.ChargerBot.

category: Bot Classes

color = (1.0, 0.2, 0.1)
highlight = (0.6, 0.1, 0.05)
default_shields = True
default_boxing_gloves = True
points_mult = 3
class ChargerBotProShielded(ChargerBotPro):
781class ChargerBotProShielded(ChargerBotPro):
782    """A stronger bs.ChargerBot who starts with shields.
783
784    category: Bot Classes
785    """
786
787    default_shields = True
788    points_mult = 4

A stronger bs.ChargerBot who starts with shields.

category: Bot Classes

default_shields = True
points_mult = 4
class TriggerBot(SpazBot):
791class TriggerBot(SpazBot):
792    """A slow moving bot with trigger bombs.
793
794    category: Bot Classes
795    """
796
797    character = 'Zoe'
798    punchiness = 0.75
799    throwiness = 0.7
800    charge_dist_max = 1.0
801    charge_speed_min = 0.3
802    charge_speed_max = 0.5
803    throw_dist_min = 3.5
804    throw_dist_max = 5.5
805    default_bomb_type = 'impact'
806    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):
809class TriggerBotStatic(TriggerBot):
810    """A bs.TriggerBot who generally stays in one place.
811
812    category: Bot Classes
813    """
814
815    static = True
816    throw_dist_min = 0.0

A bs.TriggerBot who generally stays in one place.

category: Bot Classes

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

A stronger version of bs.TriggerBot.

category: Bot Classes

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

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

category: Bot Classes

default_shields = True
points_mult = 4
class StickyBot(SpazBot):
848class StickyBot(SpazBot):
849    """A crazy bot who runs and throws sticky bombs.
850
851    category: Bot Classes
852    """
853
854    character = 'Mel'
855    punchiness = 0.9
856    throwiness = 1.0
857    run = True
858    charge_dist_min = 4.0
859    charge_dist_max = 10.0
860    charge_speed_min = 1.0
861    charge_speed_max = 1.0
862    throw_dist_min = 0.0
863    throw_dist_max = 4.0
864    throw_rate = 2.0
865    default_bomb_type = 'sticky'
866    default_bomb_count = 3
867    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):
870class StickyBotStatic(StickyBot):
871    """A crazy bot who throws sticky-bombs but generally stays in one place.
872
873    category: Bot Classes
874    """
875
876    static = True

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

category: Bot Classes

static = True
class ExplodeyBot(SpazBot):
879class ExplodeyBot(SpazBot):
880    """A bot who runs and explodes in 5 seconds.
881
882    category: Bot Classes
883    """
884
885    character = 'Jack Morgan'
886    run = True
887    charge_dist_min = 0.0
888    charge_dist_max = 9999
889    charge_speed_min = 1.0
890    charge_speed_max = 1.0
891    throw_dist_min = 9999
892    throw_dist_max = 9999
893    start_cursed = True
894    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):
897class ExplodeyBotNoTimeLimit(ExplodeyBot):
898    """A bot who runs but does not explode on his own.
899
900    category: Bot Classes
901    """
902
903    curse_time = None

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

category: Bot Classes

curse_time = None
class ExplodeyBotShielded(ExplodeyBot):
906class ExplodeyBotShielded(ExplodeyBot):
907    """A bs.ExplodeyBot who starts with shields.
908
909    category: Bot Classes
910    """
911
912    default_shields = True
913    points_mult = 5

A bs.ExplodeyBot who starts with shields.

category: Bot Classes

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