bascenev1lib.actor.spazbot

Bot versions of Spaz.

   1# Released under the MIT License. See LICENSE for details.
   2#
   3"""Bot versions of Spaz."""
   4# pylint: disable=too-many-lines
   5
   6from __future__ import annotations
   7
   8import random
   9import weakref
  10import logging
  11from typing import TYPE_CHECKING
  12
  13from typing_extensions import override
  14import bascenev1 as bs
  15from bascenev1lib.actor.spaz import Spaz
  16
  17if TYPE_CHECKING:
  18    from typing import Any, Sequence, Callable
  19    from bascenev1lib.actor.flag import Flag
  20
  21LITE_BOT_COLOR = (1.2, 0.9, 0.2)
  22LITE_BOT_HIGHLIGHT = (1.0, 0.5, 0.6)
  23DEFAULT_BOT_COLOR = (0.6, 0.6, 0.6)
  24DEFAULT_BOT_HIGHLIGHT = (0.1, 0.3, 0.1)
  25PRO_BOT_COLOR = (1.0, 0.2, 0.1)
  26PRO_BOT_HIGHLIGHT = (0.6, 0.1, 0.05)
  27
  28
  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
  45
  46
  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
  72
  73
  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)
 584
 585
 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
 594
 595
 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
 609
 610
 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
 619
 620
 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
 629
 630
 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
 646
 647
 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
 656
 657
 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
 666
 667
 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
 676
 677
 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
 691
 692
 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
 704
 705
 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
 719
 720
 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
 729
 730
 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
 747
 748
 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
 769
 770
 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_shields = True
 780    default_boxing_gloves = True
 781    points_mult = 3
 782
 783
 784class ChargerBotProShielded(ChargerBotPro):
 785    """A stronger bs.ChargerBot who starts with shields.
 786
 787    category: Bot Classes
 788    """
 789
 790    default_shields = True
 791    points_mult = 4
 792
 793
 794class TriggerBot(SpazBot):
 795    """A slow moving bot with trigger bombs.
 796
 797    category: Bot Classes
 798    """
 799
 800    character = 'Zoe'
 801    punchiness = 0.75
 802    throwiness = 0.7
 803    charge_dist_max = 1.0
 804    charge_speed_min = 0.3
 805    charge_speed_max = 0.5
 806    throw_dist_min = 3.5
 807    throw_dist_max = 5.5
 808    default_bomb_type = 'impact'
 809    points_mult = 2
 810
 811
 812class TriggerBotStatic(TriggerBot):
 813    """A bs.TriggerBot who generally stays in one place.
 814
 815    category: Bot Classes
 816    """
 817
 818    static = True
 819    throw_dist_min = 0.0
 820
 821
 822class TriggerBotPro(TriggerBot):
 823    """A stronger version of bs.TriggerBot.
 824
 825    category: Bot Classes
 826    """
 827
 828    color = PRO_BOT_COLOR
 829    highlight = PRO_BOT_HIGHLIGHT
 830    default_bomb_count = 3
 831    default_boxing_gloves = True
 832    charge_speed_min = 1.0
 833    charge_speed_max = 1.0
 834    punchiness = 0.9
 835    throw_rate = 1.3
 836    run = True
 837    run_dist_min = 6.0
 838    points_mult = 3
 839
 840
 841class TriggerBotProShielded(TriggerBotPro):
 842    """A stronger version of bs.TriggerBot who starts with shields.
 843
 844    category: Bot Classes
 845    """
 846
 847    default_shields = True
 848    points_mult = 4
 849
 850
 851class StickyBot(SpazBot):
 852    """A crazy bot who runs and throws sticky bombs.
 853
 854    category: Bot Classes
 855    """
 856
 857    character = 'Mel'
 858    punchiness = 0.9
 859    throwiness = 1.0
 860    run = True
 861    charge_dist_min = 4.0
 862    charge_dist_max = 10.0
 863    charge_speed_min = 1.0
 864    charge_speed_max = 1.0
 865    throw_dist_min = 0.0
 866    throw_dist_max = 4.0
 867    throw_rate = 2.0
 868    default_bomb_type = 'sticky'
 869    default_bomb_count = 3
 870    points_mult = 3
 871
 872
 873class StickyBotStatic(StickyBot):
 874    """A crazy bot who throws sticky-bombs but generally stays in one place.
 875
 876    category: Bot Classes
 877    """
 878
 879    static = True
 880
 881
 882class ExplodeyBot(SpazBot):
 883    """A bot who runs and explodes in 5 seconds.
 884
 885    category: Bot Classes
 886    """
 887
 888    character = 'Jack Morgan'
 889    run = True
 890    charge_dist_min = 0.0
 891    charge_dist_max = 9999
 892    charge_speed_min = 1.0
 893    charge_speed_max = 1.0
 894    throw_dist_min = 9999
 895    throw_dist_max = 9999
 896    start_cursed = True
 897    points_mult = 4
 898
 899
 900class ExplodeyBotNoTimeLimit(ExplodeyBot):
 901    """A bot who runs but does not explode on his own.
 902
 903    category: Bot Classes
 904    """
 905
 906    curse_time = None
 907
 908
 909class ExplodeyBotShielded(ExplodeyBot):
 910    """A bs.ExplodeyBot who starts with shields.
 911
 912    category: Bot Classes
 913    """
 914
 915    default_shields = True
 916    points_mult = 5
 917
 918
 919class SpazBotSet:
 920    """A container/controller for one or more bs.SpazBots.
 921
 922    category: Bot Classes
 923    """
 924
 925    def __init__(self) -> None:
 926        """Create a bot-set."""
 927
 928        # We spread our bots out over a few lists so we can update
 929        # them in a staggered fashion.
 930        self._bot_list_count = 5
 931        self._bot_add_list = 0
 932        self._bot_update_list = 0
 933        self._bot_lists: list[list[SpazBot]] = [
 934            [] for _ in range(self._bot_list_count)
 935        ]
 936        self._spawn_sound = bs.getsound('spawn')
 937        self._spawning_count = 0
 938        self._bot_update_timer: bs.Timer | None = None
 939        self.start_moving()
 940
 941    def __del__(self) -> None:
 942        self.clear()
 943
 944    def spawn_bot(
 945        self,
 946        bot_type: type[SpazBot],
 947        pos: Sequence[float],
 948        spawn_time: float = 3.0,
 949        on_spawn_call: Callable[[SpazBot], Any] | None = None,
 950    ) -> None:
 951        """Spawn a bot from this set."""
 952        from bascenev1lib.actor import spawner
 953
 954        spawner.Spawner(
 955            pt=pos,
 956            spawn_time=spawn_time,
 957            send_spawn_message=False,
 958            spawn_callback=bs.Call(
 959                self._spawn_bot, bot_type, pos, on_spawn_call
 960            ),
 961        )
 962        self._spawning_count += 1
 963
 964    def _spawn_bot(
 965        self,
 966        bot_type: type[SpazBot],
 967        pos: Sequence[float],
 968        on_spawn_call: Callable[[SpazBot], Any] | None,
 969    ) -> None:
 970        spaz = bot_type()
 971        self._spawn_sound.play(position=pos)
 972        assert spaz.node
 973        spaz.node.handlemessage('flash')
 974        spaz.node.is_area_of_interest = False
 975        spaz.handlemessage(bs.StandMessage(pos, random.uniform(0, 360)))
 976        self.add_bot(spaz)
 977        self._spawning_count -= 1
 978        if on_spawn_call is not None:
 979            on_spawn_call(spaz)
 980
 981    def have_living_bots(self) -> bool:
 982        """Return whether any bots in the set are alive or spawning."""
 983        return self._spawning_count > 0 or any(
 984            any(b.is_alive() for b in l) for l in self._bot_lists
 985        )
 986
 987    def get_living_bots(self) -> list[SpazBot]:
 988        """Get the living bots in the set."""
 989        bots: list[SpazBot] = []
 990        for botlist in self._bot_lists:
 991            for bot in botlist:
 992                if bot.is_alive():
 993                    bots.append(bot)
 994        return bots
 995
 996    def _update(self) -> None:
 997        # Update one of our bot lists each time through.
 998        # First off, remove no-longer-existing bots from the list.
 999        try:
1000            bot_list = self._bot_lists[self._bot_update_list] = [
1001                b for b in self._bot_lists[self._bot_update_list] if b
1002            ]
1003        except Exception:
1004            bot_list = []
1005            logging.exception(
1006                'Error updating bot list: %s',
1007                self._bot_lists[self._bot_update_list],
1008            )
1009        self._bot_update_list = (
1010            self._bot_update_list + 1
1011        ) % self._bot_list_count
1012
1013        # Update our list of player points for the bots to use.
1014        player_pts = []
1015        for player in bs.getactivity().players:
1016            assert isinstance(player, bs.Player)
1017            try:
1018                # TODO: could use abstracted player.position here so we
1019                # don't have to assume their actor type, but we have no
1020                # abstracted velocity as of yet.
1021                if player.is_alive():
1022                    assert isinstance(player.actor, Spaz)
1023                    assert player.actor.node
1024                    player_pts.append(
1025                        (
1026                            bs.Vec3(player.actor.node.position),
1027                            bs.Vec3(player.actor.node.velocity),
1028                        )
1029                    )
1030            except Exception:
1031                logging.exception('Error on bot-set _update.')
1032
1033        for bot in bot_list:
1034            bot.set_player_points(player_pts)
1035            bot.update_ai()
1036
1037    def clear(self) -> None:
1038        """Immediately clear out any bots in the set."""
1039
1040        # Don't do this if the activity is shutting down or dead.
1041        activity = bs.getactivity(doraise=False)
1042        if activity is None or activity.expired:
1043            return
1044
1045        for i, bot_list in enumerate(self._bot_lists):
1046            for bot in bot_list:
1047                bot.handlemessage(bs.DieMessage(immediate=True))
1048            self._bot_lists[i] = []
1049
1050    def start_moving(self) -> None:
1051        """Start processing bot AI updates so they start doing their thing."""
1052        self._bot_update_timer = bs.Timer(
1053            0.05, bs.WeakCall(self._update), repeat=True
1054        )
1055
1056    def stop_moving(self) -> None:
1057        """Tell all bots to stop moving and stops updating their AI.
1058
1059        Useful when players have won and you want the
1060        enemy bots to just stand and look bewildered.
1061        """
1062        self._bot_update_timer = None
1063        for botlist in self._bot_lists:
1064            for bot in botlist:
1065                if bot.node:
1066                    bot.node.move_left_right = 0
1067                    bot.node.move_up_down = 0
1068
1069    def celebrate(self, duration: float) -> None:
1070        """Tell all living bots in the set to celebrate momentarily.
1071
1072        Duration is given in seconds.
1073        """
1074        msg = bs.CelebrateMessage(duration=duration)
1075        for botlist in self._bot_lists:
1076            for bot in botlist:
1077                if bot:
1078                    bot.handlemessage(msg)
1079
1080    def final_celebrate(self) -> None:
1081        """Tell all bots in the set to stop what they were doing and celebrate.
1082
1083        Use this when the bots have won a game.
1084        """
1085        self._bot_update_timer = None
1086
1087        # At this point stop doing anything but jumping and celebrating.
1088        for botlist in self._bot_lists:
1089            for bot in botlist:
1090                if bot:
1091                    assert bot.node  # (should exist if 'if bot' was True)
1092                    bot.node.move_left_right = 0
1093                    bot.node.move_up_down = 0
1094                    bs.timer(
1095                        0.5 * random.random(),
1096                        bs.Call(bot.handlemessage, bs.CelebrateMessage()),
1097                    )
1098                    jump_duration = random.randrange(400, 500)
1099                    j = random.randrange(0, 200)
1100                    for _i in range(10):
1101                        bot.node.jump_pressed = True
1102                        bot.node.jump_pressed = False
1103                        j += jump_duration
1104                    bs.timer(
1105                        random.uniform(0.0, 1.0),
1106                        bs.Call(bot.node.handlemessage, 'attack_sound'),
1107                    )
1108                    bs.timer(
1109                        random.uniform(1.0, 2.0),
1110                        bs.Call(bot.node.handlemessage, 'attack_sound'),
1111                    )
1112                    bs.timer(
1113                        random.uniform(2.0, 3.0),
1114                        bs.Call(bot.node.handlemessage, 'attack_sound'),
1115                    )
1116
1117    def add_bot(self, bot: SpazBot) -> None:
1118        """Add a bs.SpazBot instance to the set."""
1119        self._bot_lists[self._bot_add_list].append(bot)
1120        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:
30class SpazBotPunchedMessage:
31    """A message saying a bs.SpazBot got punched.
32
33    Category: **Message Classes**
34    """
35
36    spazbot: SpazBot
37    """The bs.SpazBot that got punched."""
38
39    damage: int
40    """How much damage was done to the SpazBot."""
41
42    def __init__(self, spazbot: SpazBot, damage: int):
43        """Instantiate a message with the given values."""
44        self.spazbot = spazbot
45        self.damage = damage

A message saying a bs.SpazBot got punched.

Category: Message Classes

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

The map this bot was created on.

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

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

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

Should be called periodically to update the spaz' AI.

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

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

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

General message handling; can be passed any message object.

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

A bot that throws regular bombs and occasionally punches.

category: Bot Classes

character = 'Spaz'
punchiness = 0.3
class BomberBotLite(BomberBot):
597class BomberBotLite(BomberBot):
598    """A less aggressive yellow version of bs.BomberBot.
599
600    category: Bot Classes
601    """
602
603    color = LITE_BOT_COLOR
604    highlight = LITE_BOT_HIGHLIGHT
605    punchiness = 0.2
606    throw_rate = 0.7
607    throwiness = 0.1
608    charge_speed_min = 0.6
609    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):
612class BomberBotStaticLite(BomberBotLite):
613    """A less aggressive generally immobile weak version of bs.BomberBot.
614
615    category: Bot Classes
616    """
617
618    static = True
619    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):
622class BomberBotStatic(BomberBot):
623    """A version of bs.BomberBot who generally stays in one place.
624
625    category: Bot Classes
626    """
627
628    static = True
629    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):
632class BomberBotPro(BomberBot):
633    """A more powerful version of bs.BomberBot.
634
635    category: Bot Classes
636    """
637
638    points_mult = 2
639    color = PRO_BOT_COLOR
640    highlight = PRO_BOT_HIGHLIGHT
641    default_bomb_count = 3
642    default_boxing_gloves = True
643    punchiness = 0.7
644    throw_rate = 1.3
645    run = True
646    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):
649class BomberBotProShielded(BomberBotPro):
650    """A more powerful version of bs.BomberBot who starts with shields.
651
652    category: Bot Classes
653    """
654
655    points_mult = 3
656    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):
659class BomberBotProStatic(BomberBotPro):
660    """A more powerful bs.BomberBot who generally stays in one place.
661
662    category: Bot Classes
663    """
664
665    static = True
666    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):
669class BomberBotProStaticShielded(BomberBotProShielded):
670    """A powerful bs.BomberBot with shields who is generally immobile.
671
672    category: Bot Classes
673    """
674
675    static = True
676    throw_dist_min = 0.0

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

category: Bot Classes

static = True
throw_dist_min = 0.0
class BrawlerBot(SpazBot):
679class BrawlerBot(SpazBot):
680    """A bot who walks and punches things.
681
682    category: Bot Classes
683    """
684
685    character = 'Kronk'
686    punchiness = 0.9
687    charge_dist_max = 9999.0
688    charge_speed_min = 1.0
689    charge_speed_max = 1.0
690    throw_dist_min = 9999
691    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):
694class BrawlerBotLite(BrawlerBot):
695    """A weaker version of bs.BrawlerBot.
696
697    category: Bot Classes
698    """
699
700    color = LITE_BOT_COLOR
701    highlight = LITE_BOT_HIGHLIGHT
702    punchiness = 0.3
703    charge_speed_min = 0.6
704    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):
707class BrawlerBotPro(BrawlerBot):
708    """A stronger version of bs.BrawlerBot.
709
710    category: Bot Classes
711    """
712
713    color = PRO_BOT_COLOR
714    highlight = PRO_BOT_HIGHLIGHT
715    run = True
716    run_dist_min = 4.0
717    default_boxing_gloves = True
718    punchiness = 0.95
719    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):
722class BrawlerBotProShielded(BrawlerBotPro):
723    """A stronger version of bs.BrawlerBot who starts with shields.
724
725    category: Bot Classes
726    """
727
728    default_shields = True
729    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):
732class ChargerBot(SpazBot):
733    """A speedy melee attack bot.
734
735    category: Bot Classes
736    """
737
738    character = 'Snake Shadow'
739    punchiness = 1.0
740    run = True
741    charge_dist_min = 10.0
742    charge_dist_max = 9999.0
743    charge_speed_min = 1.0
744    charge_speed_max = 1.0
745    throw_dist_min = 9999
746    throw_dist_max = 9999
747    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):
750class BouncyBot(SpazBot):
751    """A speedy attacking melee bot that jumps constantly.
752
753    category: Bot Classes
754    """
755
756    color = (1, 1, 1)
757    highlight = (1.0, 0.5, 0.5)
758    character = 'Easter Bunny'
759    punchiness = 1.0
760    run = True
761    bouncy = True
762    default_boxing_gloves = True
763    charge_dist_min = 10.0
764    charge_dist_max = 9999.0
765    charge_speed_min = 1.0
766    charge_speed_max = 1.0
767    throw_dist_min = 9999
768    throw_dist_max = 9999
769    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):
772class ChargerBotPro(ChargerBot):
773    """A stronger bs.ChargerBot.
774
775    category: Bot Classes
776    """
777
778    color = PRO_BOT_COLOR
779    highlight = PRO_BOT_HIGHLIGHT
780    default_shields = True
781    default_boxing_gloves = True
782    points_mult = 3

A stronger bs.ChargerBot.

category: Bot Classes

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

A stronger bs.ChargerBot who starts with shields.

category: Bot Classes

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

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

category: Bot Classes

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

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

category: Bot Classes

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

A bs.ExplodeyBot who starts with shields.

category: Bot Classes

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