bascenev1lib.actor.spazbot

Bot versions of Spaz.

   1# Released under the MIT License. See LICENSE for details.
   2#
   3"""Bot versions of Spaz."""
   4# pylint: disable=too-many-lines
   5
   6from __future__ import annotations
   7
   8import random
   9import weakref
  10import logging
  11from typing import TYPE_CHECKING, override
  12
  13import bascenev1 as bs
  14from bascenev1lib.actor.spaz import Spaz
  15
  16if TYPE_CHECKING:
  17    from typing import Any, Sequence, Callable
  18    from bascenev1lib.actor.flag import Flag
  19
  20LITE_BOT_COLOR = (1.2, 0.9, 0.2)
  21LITE_BOT_HIGHLIGHT = (1.0, 0.5, 0.6)
  22DEFAULT_BOT_COLOR = (0.6, 0.6, 0.6)
  23DEFAULT_BOT_HIGHLIGHT = (0.1, 0.3, 0.1)
  24PRO_BOT_COLOR = (1.0, 0.2, 0.1)
  25PRO_BOT_HIGHLIGHT = (0.6, 0.1, 0.05)
  26
  27
  28class SpazBotPunchedMessage:
  29    """A message saying a bs.SpazBot got punched.
  30
  31    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    @override
 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    @override
 501    def on_expire(self) -> None:
 502        super().on_expire()
 503
 504        # We're being torn down; release our callback(s) so there's
 505        # no chance of them keeping activities or other things alive.
 506        self.update_callback = None
 507
 508    @override
 509    def handlemessage(self, msg: Any) -> Any:
 510        # pylint: disable=too-many-branches
 511        assert not self.expired
 512
 513        # Keep track of if we're being held and by who most recently.
 514        if isinstance(msg, bs.PickedUpMessage):
 515            super().handlemessage(msg)  # Augment standard behavior.
 516            self.held_count += 1
 517            picked_up_by = msg.node.source_player
 518            if picked_up_by:
 519                self.last_player_held_by = picked_up_by
 520
 521        elif isinstance(msg, bs.DroppedMessage):
 522            super().handlemessage(msg)  # Augment standard behavior.
 523            self.held_count -= 1
 524            if self.held_count < 0:
 525                print('ERROR: spaz held_count < 0')
 526
 527            # Let's count someone dropping us as an attack.
 528            try:
 529                if msg.node:
 530                    picked_up_by = msg.node.source_player
 531                else:
 532                    picked_up_by = None
 533            except Exception:
 534                logging.exception('Error on SpazBot DroppedMessage.')
 535                picked_up_by = None
 536
 537            if picked_up_by:
 538                self.last_player_attacked_by = picked_up_by
 539                self.last_attacked_time = bs.time()
 540                self.last_attacked_type = ('picked_up', 'default')
 541
 542        elif isinstance(msg, bs.DieMessage):
 543            # Report normal deaths for scoring purposes.
 544            if not self._dead and not msg.immediate:
 545                killerplayer: bs.Player | None
 546
 547                # If this guy was being held at the time of death, the
 548                # holder is the killer.
 549                if self.held_count > 0 and self.last_player_held_by:
 550                    killerplayer = self.last_player_held_by
 551                else:
 552                    # If they were attacked by someone in the last few
 553                    # seconds that person's the killer.
 554                    # Otherwise it was a suicide.
 555                    if (
 556                        self.last_player_attacked_by
 557                        and bs.time() - self.last_attacked_time < 4.0
 558                    ):
 559                        killerplayer = self.last_player_attacked_by
 560                    else:
 561                        killerplayer = None
 562                activity = self._activity()
 563
 564                # (convert dead player refs to None)
 565                if not killerplayer:
 566                    killerplayer = None
 567                if activity is not None:
 568                    activity.handlemessage(
 569                        SpazBotDiedMessage(self, killerplayer, msg.how)
 570                    )
 571            super().handlemessage(msg)  # Augment standard behavior.
 572
 573        # Keep track of the player who last hit us for point rewarding.
 574        elif isinstance(msg, bs.HitMessage):
 575            source_player = msg.get_source_player(bs.Player)
 576            if source_player:
 577                self.last_player_attacked_by = source_player
 578                self.last_attacked_time = bs.time()
 579                self.last_attacked_type = (msg.hit_type, msg.hit_subtype)
 580            super().handlemessage(msg)
 581        else:
 582            super().handlemessage(msg)
 583
 584
 585class BomberBot(SpazBot):
 586    """A bot that throws regular bombs and occasionally punches.
 587
 588    category: Bot Classes
 589    """
 590
 591    character = 'Spaz'
 592    punchiness = 0.3
 593
 594
 595class BomberBotLite(BomberBot):
 596    """A less aggressive yellow version of bs.BomberBot.
 597
 598    category: Bot Classes
 599    """
 600
 601    color = LITE_BOT_COLOR
 602    highlight = LITE_BOT_HIGHLIGHT
 603    punchiness = 0.2
 604    throw_rate = 0.7
 605    throwiness = 0.1
 606    charge_speed_min = 0.6
 607    charge_speed_max = 0.6
 608
 609
 610class BomberBotStaticLite(BomberBotLite):
 611    """A less aggressive generally immobile weak version of bs.BomberBot.
 612
 613    category: Bot Classes
 614    """
 615
 616    static = True
 617    throw_dist_min = 0.0
 618
 619
 620class BomberBotStatic(BomberBot):
 621    """A version of bs.BomberBot who generally stays in one place.
 622
 623    category: Bot Classes
 624    """
 625
 626    static = True
 627    throw_dist_min = 0.0
 628
 629
 630class BomberBotPro(BomberBot):
 631    """A more powerful version of bs.BomberBot.
 632
 633    category: Bot Classes
 634    """
 635
 636    points_mult = 2
 637    color = PRO_BOT_COLOR
 638    highlight = PRO_BOT_HIGHLIGHT
 639    default_bomb_count = 3
 640    default_boxing_gloves = True
 641    punchiness = 0.7
 642    throw_rate = 1.3
 643    run = True
 644    run_dist_min = 6.0
 645
 646
 647class BomberBotProShielded(BomberBotPro):
 648    """A more powerful version of bs.BomberBot who starts with shields.
 649
 650    category: Bot Classes
 651    """
 652
 653    points_mult = 3
 654    default_shields = True
 655
 656
 657class BomberBotProStatic(BomberBotPro):
 658    """A more powerful bs.BomberBot who generally stays in one place.
 659
 660    category: Bot Classes
 661    """
 662
 663    static = True
 664    throw_dist_min = 0.0
 665
 666
 667class BomberBotProStaticShielded(BomberBotProShielded):
 668    """A powerful bs.BomberBot with shields who is generally immobile.
 669
 670    category: Bot Classes
 671    """
 672
 673    static = True
 674    throw_dist_min = 0.0
 675
 676
 677class BrawlerBot(SpazBot):
 678    """A bot who walks and punches things.
 679
 680    category: Bot Classes
 681    """
 682
 683    character = 'Kronk'
 684    punchiness = 0.9
 685    charge_dist_max = 9999.0
 686    charge_speed_min = 1.0
 687    charge_speed_max = 1.0
 688    throw_dist_min = 9999
 689    throw_dist_max = 9999
 690
 691
 692class BrawlerBotLite(BrawlerBot):
 693    """A weaker version of bs.BrawlerBot.
 694
 695    category: Bot Classes
 696    """
 697
 698    color = LITE_BOT_COLOR
 699    highlight = LITE_BOT_HIGHLIGHT
 700    punchiness = 0.3
 701    charge_speed_min = 0.6
 702    charge_speed_max = 0.6
 703
 704
 705class BrawlerBotPro(BrawlerBot):
 706    """A stronger version of bs.BrawlerBot.
 707
 708    category: Bot Classes
 709    """
 710
 711    color = PRO_BOT_COLOR
 712    highlight = PRO_BOT_HIGHLIGHT
 713    run = True
 714    run_dist_min = 4.0
 715    default_boxing_gloves = True
 716    punchiness = 0.95
 717    points_mult = 2
 718
 719
 720class BrawlerBotProShielded(BrawlerBotPro):
 721    """A stronger version of bs.BrawlerBot who starts with shields.
 722
 723    category: Bot Classes
 724    """
 725
 726    default_shields = True
 727    points_mult = 3
 728
 729
 730class ChargerBot(SpazBot):
 731    """A speedy melee attack bot.
 732
 733    category: Bot Classes
 734    """
 735
 736    character = 'Snake Shadow'
 737    punchiness = 1.0
 738    run = True
 739    charge_dist_min = 10.0
 740    charge_dist_max = 9999.0
 741    charge_speed_min = 1.0
 742    charge_speed_max = 1.0
 743    throw_dist_min = 9999
 744    throw_dist_max = 9999
 745    points_mult = 2
 746
 747
 748class BouncyBot(SpazBot):
 749    """A speedy attacking melee bot that jumps constantly.
 750
 751    category: Bot Classes
 752    """
 753
 754    color = (1, 1, 1)
 755    highlight = (1.0, 0.5, 0.5)
 756    character = 'Easter Bunny'
 757    punchiness = 1.0
 758    run = True
 759    bouncy = True
 760    default_boxing_gloves = True
 761    charge_dist_min = 10.0
 762    charge_dist_max = 9999.0
 763    charge_speed_min = 1.0
 764    charge_speed_max = 1.0
 765    throw_dist_min = 9999
 766    throw_dist_max = 9999
 767    points_mult = 2
 768
 769
 770class ChargerBotPro(ChargerBot):
 771    """A stronger bs.ChargerBot.
 772
 773    category: Bot Classes
 774    """
 775
 776    color = PRO_BOT_COLOR
 777    highlight = PRO_BOT_HIGHLIGHT
 778    default_boxing_gloves = True
 779    points_mult = 3
 780
 781
 782class ChargerBotProShielded(ChargerBotPro):
 783    """A stronger bs.ChargerBot who starts with shields.
 784
 785    category: Bot Classes
 786    """
 787
 788    default_shields = True
 789    points_mult = 4
 790
 791
 792class TriggerBot(SpazBot):
 793    """A slow moving bot with trigger bombs.
 794
 795    category: Bot Classes
 796    """
 797
 798    character = 'Zoe'
 799    punchiness = 0.75
 800    throwiness = 0.7
 801    charge_dist_max = 1.0
 802    charge_speed_min = 0.3
 803    charge_speed_max = 0.5
 804    throw_dist_min = 3.5
 805    throw_dist_max = 5.5
 806    default_bomb_type = 'impact'
 807    points_mult = 2
 808
 809
 810class TriggerBotStatic(TriggerBot):
 811    """A bs.TriggerBot who generally stays in one place.
 812
 813    category: Bot Classes
 814    """
 815
 816    static = True
 817    throw_dist_min = 0.0
 818
 819
 820class TriggerBotPro(TriggerBot):
 821    """A stronger version of bs.TriggerBot.
 822
 823    category: Bot Classes
 824    """
 825
 826    color = PRO_BOT_COLOR
 827    highlight = PRO_BOT_HIGHLIGHT
 828    default_bomb_count = 3
 829    default_boxing_gloves = True
 830    charge_speed_min = 1.0
 831    charge_speed_max = 1.0
 832    punchiness = 0.9
 833    throw_rate = 1.3
 834    run = True
 835    run_dist_min = 6.0
 836    points_mult = 3
 837
 838
 839class TriggerBotProShielded(TriggerBotPro):
 840    """A stronger version of bs.TriggerBot who starts with shields.
 841
 842    category: Bot Classes
 843    """
 844
 845    default_shields = True
 846    points_mult = 4
 847
 848
 849class StickyBot(SpazBot):
 850    """A crazy bot who runs and throws sticky bombs.
 851
 852    category: Bot Classes
 853    """
 854
 855    character = 'Mel'
 856    punchiness = 0.9
 857    throwiness = 1.0
 858    run = True
 859    charge_dist_min = 4.0
 860    charge_dist_max = 10.0
 861    charge_speed_min = 1.0
 862    charge_speed_max = 1.0
 863    throw_dist_min = 0.0
 864    throw_dist_max = 4.0
 865    throw_rate = 2.0
 866    default_bomb_type = 'sticky'
 867    default_bomb_count = 3
 868    points_mult = 3
 869
 870
 871class StickyBotStatic(StickyBot):
 872    """A crazy bot who throws sticky-bombs but generally stays in one place.
 873
 874    category: Bot Classes
 875    """
 876
 877    static = True
 878
 879
 880class ExplodeyBot(SpazBot):
 881    """A bot who runs and explodes in 5 seconds.
 882
 883    category: Bot Classes
 884    """
 885
 886    character = 'Jack Morgan'
 887    run = True
 888    charge_dist_min = 0.0
 889    charge_dist_max = 9999
 890    charge_speed_min = 1.0
 891    charge_speed_max = 1.0
 892    throw_dist_min = 9999
 893    throw_dist_max = 9999
 894    start_cursed = True
 895    points_mult = 4
 896
 897
 898class ExplodeyBotNoTimeLimit(ExplodeyBot):
 899    """A bot who runs but does not explode on his own.
 900
 901    category: Bot Classes
 902    """
 903
 904    curse_time = None
 905
 906
 907class ExplodeyBotShielded(ExplodeyBot):
 908    """A bs.ExplodeyBot who starts with shields.
 909
 910    category: Bot Classes
 911    """
 912
 913    default_shields = True
 914    points_mult = 5
 915
 916
 917class SpazBotSet:
 918    """A container/controller for one or more bs.SpazBots.
 919
 920    category: Bot Classes
 921    """
 922
 923    def __init__(self) -> None:
 924        """Create a bot-set."""
 925
 926        # We spread our bots out over a few lists so we can update
 927        # them in a staggered fashion.
 928        self._bot_list_count = 5
 929        self._bot_add_list = 0
 930        self._bot_update_list = 0
 931        self._bot_lists: list[list[SpazBot]] = [
 932            [] for _ in range(self._bot_list_count)
 933        ]
 934        self._spawn_sound = bs.getsound('spawn')
 935        self._spawning_count = 0
 936        self._bot_update_timer: bs.Timer | None = None
 937        self.start_moving()
 938
 939    def __del__(self) -> None:
 940        self.clear()
 941
 942    def spawn_bot(
 943        self,
 944        bot_type: type[SpazBot],
 945        pos: Sequence[float],
 946        spawn_time: float = 3.0,
 947        on_spawn_call: Callable[[SpazBot], Any] | None = None,
 948    ) -> None:
 949        """Spawn a bot from this set."""
 950        from bascenev1lib.actor import spawner
 951
 952        spawner.Spawner(
 953            pt=pos,
 954            spawn_time=spawn_time,
 955            send_spawn_message=False,
 956            spawn_callback=bs.Call(
 957                self._spawn_bot, bot_type, pos, on_spawn_call
 958            ),
 959        )
 960        self._spawning_count += 1
 961
 962    def _spawn_bot(
 963        self,
 964        bot_type: type[SpazBot],
 965        pos: Sequence[float],
 966        on_spawn_call: Callable[[SpazBot], Any] | None,
 967    ) -> None:
 968        spaz = bot_type()
 969        self._spawn_sound.play(position=pos)
 970        assert spaz.node
 971        spaz.node.handlemessage('flash')
 972        spaz.node.is_area_of_interest = False
 973        spaz.handlemessage(bs.StandMessage(pos, random.uniform(0, 360)))
 974        self.add_bot(spaz)
 975        self._spawning_count -= 1
 976        if on_spawn_call is not None:
 977            on_spawn_call(spaz)
 978
 979    def have_living_bots(self) -> bool:
 980        """Return whether any bots in the set are alive or spawning."""
 981        return self._spawning_count > 0 or any(
 982            any(b.is_alive() for b in l) for l in self._bot_lists
 983        )
 984
 985    def get_living_bots(self) -> list[SpazBot]:
 986        """Get the living bots in the set."""
 987        bots: list[SpazBot] = []
 988        for botlist in self._bot_lists:
 989            for bot in botlist:
 990                if bot.is_alive():
 991                    bots.append(bot)
 992        return bots
 993
 994    def _update(self) -> None:
 995        # Update one of our bot lists each time through.
 996        # First off, remove no-longer-existing bots from the list.
 997        try:
 998            bot_list = self._bot_lists[self._bot_update_list] = [
 999                b for b in self._bot_lists[self._bot_update_list] if b
1000            ]
1001        except Exception:
1002            bot_list = []
1003            logging.exception(
1004                'Error updating bot list: %s',
1005                self._bot_lists[self._bot_update_list],
1006            )
1007        self._bot_update_list = (
1008            self._bot_update_list + 1
1009        ) % self._bot_list_count
1010
1011        # Update our list of player points for the bots to use.
1012        player_pts = []
1013        for player in bs.getactivity().players:
1014            assert isinstance(player, bs.Player)
1015            try:
1016                # TODO: could use abstracted player.position here so we
1017                # don't have to assume their actor type, but we have no
1018                # abstracted velocity as of yet.
1019                if player.is_alive():
1020                    assert isinstance(player.actor, Spaz)
1021                    assert player.actor.node
1022                    player_pts.append(
1023                        (
1024                            bs.Vec3(player.actor.node.position),
1025                            bs.Vec3(player.actor.node.velocity),
1026                        )
1027                    )
1028            except Exception:
1029                logging.exception('Error on bot-set _update.')
1030
1031        for bot in bot_list:
1032            bot.set_player_points(player_pts)
1033            bot.update_ai()
1034
1035    def clear(self) -> None:
1036        """Immediately clear out any bots in the set."""
1037
1038        # Don't do this if the activity is shutting down or dead.
1039        activity = bs.getactivity(doraise=False)
1040        if activity is None or activity.expired:
1041            return
1042
1043        for i, bot_list in enumerate(self._bot_lists):
1044            for bot in bot_list:
1045                bot.handlemessage(bs.DieMessage(immediate=True))
1046            self._bot_lists[i] = []
1047
1048    def start_moving(self) -> None:
1049        """Start processing bot AI updates so they start doing their thing."""
1050        self._bot_update_timer = bs.Timer(
1051            0.05, bs.WeakCall(self._update), repeat=True
1052        )
1053
1054    def stop_moving(self) -> None:
1055        """Tell all bots to stop moving and stops updating their AI.
1056
1057        Useful when players have won and you want the
1058        enemy bots to just stand and look bewildered.
1059        """
1060        self._bot_update_timer = None
1061        for botlist in self._bot_lists:
1062            for bot in botlist:
1063                if bot.node:
1064                    bot.node.move_left_right = 0
1065                    bot.node.move_up_down = 0
1066
1067    def celebrate(self, duration: float) -> None:
1068        """Tell all living bots in the set to celebrate momentarily.
1069
1070        Duration is given in seconds.
1071        """
1072        msg = bs.CelebrateMessage(duration=duration)
1073        for botlist in self._bot_lists:
1074            for bot in botlist:
1075                if bot:
1076                    bot.handlemessage(msg)
1077
1078    def final_celebrate(self) -> None:
1079        """Tell all bots in the set to stop what they were doing and celebrate.
1080
1081        Use this when the bots have won a game.
1082        """
1083        self._bot_update_timer = None
1084
1085        # At this point stop doing anything but jumping and celebrating.
1086        for botlist in self._bot_lists:
1087            for bot in botlist:
1088                if bot:
1089                    assert bot.node  # (should exist if 'if bot' was True)
1090                    bot.node.move_left_right = 0
1091                    bot.node.move_up_down = 0
1092                    bs.timer(
1093                        0.5 * random.random(),
1094                        bs.Call(bot.handlemessage, bs.CelebrateMessage()),
1095                    )
1096                    jump_duration = random.randrange(400, 500)
1097                    j = random.randrange(0, 200)
1098                    for _i in range(10):
1099                        bot.node.jump_pressed = True
1100                        bot.node.jump_pressed = False
1101                        j += jump_duration
1102                    bs.timer(
1103                        random.uniform(0.0, 1.0),
1104                        bs.Call(bot.node.handlemessage, 'attack_sound'),
1105                    )
1106                    bs.timer(
1107                        random.uniform(1.0, 2.0),
1108                        bs.Call(bot.node.handlemessage, 'attack_sound'),
1109                    )
1110                    bs.timer(
1111                        random.uniform(2.0, 3.0),
1112                        bs.Call(bot.node.handlemessage, 'attack_sound'),
1113                    )
1114
1115    def add_bot(self, bot: SpazBot) -> None:
1116        """Add a bs.SpazBot instance to the set."""
1117        self._bot_lists[self._bot_add_list].append(bot)
1118        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    @override
494    def on_punched(self, damage: int) -> None:
495        """
496        Method override; sends bs.SpazBotPunchedMessage
497        to the current activity.
498        """
499        bs.getactivity().handlemessage(SpazBotPunchedMessage(self, damage))
500
501    @override
502    def on_expire(self) -> None:
503        super().on_expire()
504
505        # We're being torn down; release our callback(s) so there's
506        # no chance of them keeping activities or other things alive.
507        self.update_callback = None
508
509    @override
510    def handlemessage(self, msg: Any) -> Any:
511        # pylint: disable=too-many-branches
512        assert not self.expired
513
514        # Keep track of if we're being held and by who most recently.
515        if isinstance(msg, bs.PickedUpMessage):
516            super().handlemessage(msg)  # Augment standard behavior.
517            self.held_count += 1
518            picked_up_by = msg.node.source_player
519            if picked_up_by:
520                self.last_player_held_by = picked_up_by
521
522        elif isinstance(msg, bs.DroppedMessage):
523            super().handlemessage(msg)  # Augment standard behavior.
524            self.held_count -= 1
525            if self.held_count < 0:
526                print('ERROR: spaz held_count < 0')
527
528            # Let's count someone dropping us as an attack.
529            try:
530                if msg.node:
531                    picked_up_by = msg.node.source_player
532                else:
533                    picked_up_by = None
534            except Exception:
535                logging.exception('Error on SpazBot DroppedMessage.')
536                picked_up_by = None
537
538            if picked_up_by:
539                self.last_player_attacked_by = picked_up_by
540                self.last_attacked_time = bs.time()
541                self.last_attacked_type = ('picked_up', 'default')
542
543        elif isinstance(msg, bs.DieMessage):
544            # Report normal deaths for scoring purposes.
545            if not self._dead and not msg.immediate:
546                killerplayer: bs.Player | None
547
548                # If this guy was being held at the time of death, the
549                # holder is the killer.
550                if self.held_count > 0 and self.last_player_held_by:
551                    killerplayer = self.last_player_held_by
552                else:
553                    # If they were attacked by someone in the last few
554                    # seconds that person's the killer.
555                    # Otherwise it was a suicide.
556                    if (
557                        self.last_player_attacked_by
558                        and bs.time() - self.last_attacked_time < 4.0
559                    ):
560                        killerplayer = self.last_player_attacked_by
561                    else:
562                        killerplayer = None
563                activity = self._activity()
564
565                # (convert dead player refs to None)
566                if not killerplayer:
567                    killerplayer = None
568                if activity is not None:
569                    activity.handlemessage(
570                        SpazBotDiedMessage(self, killerplayer, msg.how)
571                    )
572            super().handlemessage(msg)  # Augment standard behavior.
573
574        # Keep track of the player who last hit us for point rewarding.
575        elif isinstance(msg, bs.HitMessage):
576            source_player = msg.get_source_player(bs.Player)
577            if source_player:
578                self.last_player_attacked_by = source_player
579                self.last_attacked_time = bs.time()
580                self.last_attacked_type = (msg.hit_type, msg.hit_subtype)
581            super().handlemessage(msg)
582        else:
583            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
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

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.

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

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

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

Called for remaining bascenev1.Actors when their activity dies.

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

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

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

General message handling; can be passed any message object.

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

A bot that throws regular bombs and occasionally punches.

category: Bot Classes

character = 'Spaz'
punchiness = 0.3
class BomberBotLite(BomberBot):
596class BomberBotLite(BomberBot):
597    """A less aggressive yellow version of bs.BomberBot.
598
599    category: Bot Classes
600    """
601
602    color = LITE_BOT_COLOR
603    highlight = LITE_BOT_HIGHLIGHT
604    punchiness = 0.2
605    throw_rate = 0.7
606    throwiness = 0.1
607    charge_speed_min = 0.6
608    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):
611class BomberBotStaticLite(BomberBotLite):
612    """A less aggressive generally immobile weak version of bs.BomberBot.
613
614    category: Bot Classes
615    """
616
617    static = True
618    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):
621class BomberBotStatic(BomberBot):
622    """A version of bs.BomberBot who generally stays in one place.
623
624    category: Bot Classes
625    """
626
627    static = True
628    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):
631class BomberBotPro(BomberBot):
632    """A more powerful version of bs.BomberBot.
633
634    category: Bot Classes
635    """
636
637    points_mult = 2
638    color = PRO_BOT_COLOR
639    highlight = PRO_BOT_HIGHLIGHT
640    default_bomb_count = 3
641    default_boxing_gloves = True
642    punchiness = 0.7
643    throw_rate = 1.3
644    run = True
645    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):
648class BomberBotProShielded(BomberBotPro):
649    """A more powerful version of bs.BomberBot who starts with shields.
650
651    category: Bot Classes
652    """
653
654    points_mult = 3
655    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):
658class BomberBotProStatic(BomberBotPro):
659    """A more powerful bs.BomberBot who generally stays in one place.
660
661    category: Bot Classes
662    """
663
664    static = True
665    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):
668class BomberBotProStaticShielded(BomberBotProShielded):
669    """A powerful bs.BomberBot with shields who is generally immobile.
670
671    category: Bot Classes
672    """
673
674    static = True
675    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
Inherited Members
SpazBot
SpazBot
throwiness
bouncy
charge_dist_min
charge_dist_max
charge_speed_min
charge_speed_max
throw_dist_max
default_bomb_type
start_cursed
update_callback
last_player_attacked_by
last_attacked_time
last_attacked_type
target_point_default
held_count
last_player_held_by
target_flag
map
set_player_points
update_ai
on_punched
on_expire
handlemessage
BomberBotProShielded
points_mult
default_shields
BomberBotPro
color
highlight
default_bomb_count
default_boxing_gloves
punchiness
throw_rate
run
run_dist_min
BomberBot
character
bascenev1lib.actor.spaz.Spaz
node
curse_time
default_hitpoints
play_big_death_sound
impact_scale
source_player
fly
shield
hitpoints
hitpoints_max
shield_hitpoints
shield_hitpoints_max
shield_decay_rate
shield_decay_timer
bomb_count
bomb_type_default
bomb_type
land_mine_count
blast_radius
powerups_expire
last_punch_time_ms
last_pickup_time_ms
last_jump_time_ms
last_run_time_ms
last_bomb_time_ms
frozen
shattered
punch_callback
pick_up_powerup_callback
exists
add_dropped_bomb_callback
is_alive
set_score_text
on_jump_press
on_jump_release
on_pickup_press
on_pickup_release
on_hold_position_press
on_hold_position_release
on_punch_press
on_punch_release
on_bomb_press
on_bomb_release
on_run
on_fly_press
on_fly_release
on_move
on_move_up_down
on_move_left_right
get_death_points
curse
equip_boxing_gloves
equip_shields
shield_decay
drop_bomb
set_land_mine_count
curse_explode
shatter
set_bomb_count
bascenev1._actor.Actor
autoretain
expired
activity
getactivity
class BrawlerBot(SpazBot):
678class BrawlerBot(SpazBot):
679    """A bot who walks and punches things.
680
681    category: Bot Classes
682    """
683
684    character = 'Kronk'
685    punchiness = 0.9
686    charge_dist_max = 9999.0
687    charge_speed_min = 1.0
688    charge_speed_max = 1.0
689    throw_dist_min = 9999
690    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):
693class BrawlerBotLite(BrawlerBot):
694    """A weaker version of bs.BrawlerBot.
695
696    category: Bot Classes
697    """
698
699    color = LITE_BOT_COLOR
700    highlight = LITE_BOT_HIGHLIGHT
701    punchiness = 0.3
702    charge_speed_min = 0.6
703    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):
706class BrawlerBotPro(BrawlerBot):
707    """A stronger version of bs.BrawlerBot.
708
709    category: Bot Classes
710    """
711
712    color = PRO_BOT_COLOR
713    highlight = PRO_BOT_HIGHLIGHT
714    run = True
715    run_dist_min = 4.0
716    default_boxing_gloves = True
717    punchiness = 0.95
718    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):
721class BrawlerBotProShielded(BrawlerBotPro):
722    """A stronger version of bs.BrawlerBot who starts with shields.
723
724    category: Bot Classes
725    """
726
727    default_shields = True
728    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):
731class ChargerBot(SpazBot):
732    """A speedy melee attack bot.
733
734    category: Bot Classes
735    """
736
737    character = 'Snake Shadow'
738    punchiness = 1.0
739    run = True
740    charge_dist_min = 10.0
741    charge_dist_max = 9999.0
742    charge_speed_min = 1.0
743    charge_speed_max = 1.0
744    throw_dist_min = 9999
745    throw_dist_max = 9999
746    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):
749class BouncyBot(SpazBot):
750    """A speedy attacking melee bot that jumps constantly.
751
752    category: Bot Classes
753    """
754
755    color = (1, 1, 1)
756    highlight = (1.0, 0.5, 0.5)
757    character = 'Easter Bunny'
758    punchiness = 1.0
759    run = True
760    bouncy = True
761    default_boxing_gloves = True
762    charge_dist_min = 10.0
763    charge_dist_max = 9999.0
764    charge_speed_min = 1.0
765    charge_speed_max = 1.0
766    throw_dist_min = 9999
767    throw_dist_max = 9999
768    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):
771class ChargerBotPro(ChargerBot):
772    """A stronger bs.ChargerBot.
773
774    category: Bot Classes
775    """
776
777    color = PRO_BOT_COLOR
778    highlight = PRO_BOT_HIGHLIGHT
779    default_boxing_gloves = True
780    points_mult = 3

A stronger bs.ChargerBot.

category: Bot Classes

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

A stronger bs.ChargerBot who starts with shields.

category: Bot Classes

default_shields = True
points_mult = 4
class TriggerBot(SpazBot):
793class TriggerBot(SpazBot):
794    """A slow moving bot with trigger bombs.
795
796    category: Bot Classes
797    """
798
799    character = 'Zoe'
800    punchiness = 0.75
801    throwiness = 0.7
802    charge_dist_max = 1.0
803    charge_speed_min = 0.3
804    charge_speed_max = 0.5
805    throw_dist_min = 3.5
806    throw_dist_max = 5.5
807    default_bomb_type = 'impact'
808    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):
811class TriggerBotStatic(TriggerBot):
812    """A bs.TriggerBot who generally stays in one place.
813
814    category: Bot Classes
815    """
816
817    static = True
818    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):
821class TriggerBotPro(TriggerBot):
822    """A stronger version of bs.TriggerBot.
823
824    category: Bot Classes
825    """
826
827    color = PRO_BOT_COLOR
828    highlight = PRO_BOT_HIGHLIGHT
829    default_bomb_count = 3
830    default_boxing_gloves = True
831    charge_speed_min = 1.0
832    charge_speed_max = 1.0
833    punchiness = 0.9
834    throw_rate = 1.3
835    run = True
836    run_dist_min = 6.0
837    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):
840class TriggerBotProShielded(TriggerBotPro):
841    """A stronger version of bs.TriggerBot who starts with shields.
842
843    category: Bot Classes
844    """
845
846    default_shields = True
847    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):
850class StickyBot(SpazBot):
851    """A crazy bot who runs and throws sticky bombs.
852
853    category: Bot Classes
854    """
855
856    character = 'Mel'
857    punchiness = 0.9
858    throwiness = 1.0
859    run = True
860    charge_dist_min = 4.0
861    charge_dist_max = 10.0
862    charge_speed_min = 1.0
863    charge_speed_max = 1.0
864    throw_dist_min = 0.0
865    throw_dist_max = 4.0
866    throw_rate = 2.0
867    default_bomb_type = 'sticky'
868    default_bomb_count = 3
869    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):
872class StickyBotStatic(StickyBot):
873    """A crazy bot who throws sticky-bombs but generally stays in one place.
874
875    category: Bot Classes
876    """
877
878    static = True

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

category: Bot Classes

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

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

category: Bot Classes

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

A bs.ExplodeyBot who starts with shields.

category: Bot Classes

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