bastd.actor.spazbot

Bot versions of Spaz.

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

A message saying a ba.SpazBot got punched.

Category: Message Classes

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

Instantiate a message with the given values.

The ba.SpazBot that got punched.

damage: int

How much damage was done to the SpazBot.

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

A message saying a ba.SpazBot has died.

Category: Message Classes

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

Instantiate with given values.

The SpazBot that was killed.

killerplayer: ba._player.Player | None

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

how: ba._messages.DeathType

The particular type of death.

class SpazBot(bastd.actor.spaz.Spaz):
 73class SpazBot(Spaz):
 74    """A really dumb AI version of ba.Spaz.
 75
 76    Category: **Bot Classes**
 77
 78    Add these to a ba.BotSet to use them.
 79
 80    Note: currently the AI has no real ability to
 81    navigate obstacles and so should only be used
 82    on wide-open maps.
 83
 84    When a SpazBot is killed, it delivers a ba.SpazBotDiedMessage
 85    to the current activity.
 86
 87    When a SpazBot is punched, it delivers a ba.SpazBotPunchedMessage
 88    to the current activity.
 89    """
 90
 91    character = 'Spaz'
 92    punchiness = 0.5
 93    throwiness = 0.7
 94    static = False
 95    bouncy = False
 96    run = False
 97    charge_dist_min = 0.0  # When we can start a new charge.
 98    charge_dist_max = 2.0  # When we can start a new charge.
 99    run_dist_min = 0.0  # How close we can be to continue running.
100    charge_speed_min = 0.4
101    charge_speed_max = 1.0
102    throw_dist_min = 5.0
103    throw_dist_max = 9.0
104    throw_rate = 1.0
105    default_bomb_type = 'normal'
106    default_bomb_count = 3
107    start_cursed = False
108    color = DEFAULT_BOT_COLOR
109    highlight = DEFAULT_BOT_HIGHLIGHT
110
111    def __init__(self) -> None:
112        """Instantiate a spaz-bot."""
113        super().__init__(
114            color=self.color,
115            highlight=self.highlight,
116            character=self.character,
117            source_player=None,
118            start_invincible=False,
119            can_accept_powerups=False,
120        )
121
122        # If you need to add custom behavior to a bot, set this to a callable
123        # which takes one arg (the bot) and returns False if the bot's normal
124        # update should be run and True if not.
125        self.update_callback: Callable[[SpazBot], Any] | None = None
126        activity = self.activity
127        assert isinstance(activity, ba.GameActivity)
128        self._map = weakref.ref(activity.map)
129        self.last_player_attacked_by: ba.Player | None = None
130        self.last_attacked_time = 0.0
131        self.last_attacked_type: tuple[str, str] | None = None
132        self.target_point_default: ba.Vec3 | None = None
133        self.held_count = 0
134        self.last_player_held_by: ba.Player | None = None
135        self.target_flag: Flag | None = None
136        self._charge_speed = 0.5 * (
137            self.charge_speed_min + self.charge_speed_max
138        )
139        self._lead_amount = 0.5
140        self._mode = 'wait'
141        self._charge_closing_in = False
142        self._last_charge_dist = 0.0
143        self._running = False
144        self._last_jump_time = 0.0
145
146        self._throw_release_time: float | None = None
147        self._have_dropped_throw_bomb: bool | None = None
148        self._player_pts: list[tuple[ba.Vec3, ba.Vec3]] | None = None
149
150        # These cooldowns didn't exist when these bots were calibrated,
151        # so take them out of the equation.
152        self._jump_cooldown = 0
153        self._pickup_cooldown = 0
154        self._fly_cooldown = 0
155        self._bomb_cooldown = 0
156
157        if self.start_cursed:
158            self.curse()
159
160    @property
161    def map(self) -> ba.Map:
162        """The map this bot was created on."""
163        mval = self._map()
164        assert mval is not None
165        return mval
166
167    def _get_target_player_pt(self) -> tuple[ba.Vec3 | None, ba.Vec3 | None]:
168        """Returns the position and velocity of our target.
169
170        Both values will be None in the case of no target.
171        """
172        assert self.node
173        botpt = ba.Vec3(self.node.position)
174        closest_dist: float | None = None
175        closest_vel: ba.Vec3 | None = None
176        closest: ba.Vec3 | None = None
177        assert self._player_pts is not None
178        for plpt, plvel in self._player_pts:
179            dist = (plpt - botpt).length()
180
181            # Ignore player-points that are significantly below the bot
182            # (keeps bots from following players off cliffs).
183            if (closest_dist is None or dist < closest_dist) and (
184                plpt[1] > botpt[1] - 5.0
185            ):
186                closest_dist = dist
187                closest_vel = plvel
188                closest = plpt
189        if closest_dist is not None:
190            assert closest_vel is not None
191            assert closest is not None
192            return (
193                ba.Vec3(closest[0], closest[1], closest[2]),
194                ba.Vec3(closest_vel[0], closest_vel[1], closest_vel[2]),
195            )
196        return None, None
197
198    def set_player_points(self, pts: list[tuple[ba.Vec3, ba.Vec3]]) -> None:
199        """Provide the spaz-bot with the locations of its enemies."""
200        self._player_pts = pts
201
202    def update_ai(self) -> None:
203        """Should be called periodically to update the spaz' AI."""
204        # pylint: disable=too-many-branches
205        # pylint: disable=too-many-statements
206        # pylint: disable=too-many-locals
207        if self.update_callback is not None:
208            if self.update_callback(self):
209                # Bot has been handled.
210                return
211
212        if not self.node:
213            return
214
215        pos = self.node.position
216        our_pos = ba.Vec3(pos[0], 0, pos[2])
217        can_attack = True
218
219        target_pt_raw: ba.Vec3 | None
220        target_vel: ba.Vec3 | None
221
222        # If we're a flag-bearer, we're pretty simple-minded - just walk
223        # towards the flag and try to pick it up.
224        if self.target_flag:
225            if self.node.hold_node:
226                holding_flag = self.node.hold_node.getnodetype() == 'flag'
227            else:
228                holding_flag = False
229
230            # If we're holding the flag, just walk left.
231            if holding_flag:
232                # Just walk left.
233                self.node.move_left_right = -1.0
234                self.node.move_up_down = 0.0
235
236            # Otherwise try to go pick it up.
237            elif self.target_flag.node:
238                target_pt_raw = ba.Vec3(*self.target_flag.node.position)
239                diff = target_pt_raw - our_pos
240                diff = ba.Vec3(diff[0], 0, diff[2])  # Don't care about y.
241                dist = diff.length()
242                to_target = diff.normalized()
243
244                # If we're holding some non-flag item, drop it.
245                if self.node.hold_node:
246                    self.node.pickup_pressed = True
247                    self.node.pickup_pressed = False
248                    return
249
250                # If we're a runner, run only when not super-near the flag.
251                if self.run and dist > 3.0:
252                    self._running = True
253                    self.node.run = 1.0
254                else:
255                    self._running = False
256                    self.node.run = 0.0
257
258                self.node.move_left_right = to_target.x
259                self.node.move_up_down = -to_target.z
260                if dist < 1.25:
261                    self.node.pickup_pressed = True
262                    self.node.pickup_pressed = False
263            return
264
265        # Not a flag-bearer. If we're holding anything but a bomb, drop it.
266        if self.node.hold_node:
267            holding_bomb = self.node.hold_node.getnodetype() in ['bomb', 'prop']
268            if not holding_bomb:
269                self.node.pickup_pressed = True
270                self.node.pickup_pressed = False
271                return
272
273        target_pt_raw, target_vel = self._get_target_player_pt()
274
275        if target_pt_raw is None:
276            # Use default target if we've got one.
277            if self.target_point_default is not None:
278                target_pt_raw = self.target_point_default
279                target_vel = ba.Vec3(0, 0, 0)
280                can_attack = False
281
282            # With no target, we stop moving and drop whatever we're holding.
283            else:
284                self.node.move_left_right = 0
285                self.node.move_up_down = 0
286                if self.node.hold_node:
287                    self.node.pickup_pressed = True
288                    self.node.pickup_pressed = False
289                return
290
291        # We don't want height to come into play.
292        target_pt_raw[1] = 0.0
293        assert target_vel is not None
294        target_vel[1] = 0.0
295
296        dist_raw = (target_pt_raw - our_pos).length()
297
298        # Use a point out in front of them as real target.
299        # (more out in front the farther from us they are)
300        target_pt = (
301            target_pt_raw + target_vel * dist_raw * 0.3 * self._lead_amount
302        )
303
304        diff = target_pt - our_pos
305        dist = diff.length()
306        to_target = diff.normalized()
307
308        if self._mode == 'throw':
309            # We can only throw if alive and well.
310            if not self._dead and not self.node.knockout:
311
312                assert self._throw_release_time is not None
313                time_till_throw = self._throw_release_time - ba.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: ba.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                        ba.timer(0.1, ba.Call(_safe_pickup, self.node))
340                    else:
341                        # Throws:
342                        ba.timer(0.1, ba.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 ba.time(timeformat=ba.TimeFormat.MILLISECONDS) % 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
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 = ba.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 ba.time() - self._last_jump_time > 1.0
479            ) or (
480                self.bouncy
481                and ba.time() - self._last_jump_time > 0.4
482                and random.random() < 0.5
483            ):
484                self._last_jump_time = ba.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    def on_punched(self, damage: int) -> None:
495        """
496        Method override; sends ba.SpazBotPunchedMessage
497        to the current activity.
498        """
499        ba.getactivity().handlemessage(SpazBotPunchedMessage(self, damage))
500
501    def on_expire(self) -> None:
502        super().on_expire()
503
504        # We're being torn down; release our callback(s) so there's
505        # no chance of them keeping activities or other things alive.
506        self.update_callback = None
507
508    def handlemessage(self, msg: Any) -> Any:
509        # pylint: disable=too-many-branches
510        assert not self.expired
511
512        # Keep track of if we're being held and by who most recently.
513        if isinstance(msg, ba.PickedUpMessage):
514            super().handlemessage(msg)  # Augment standard behavior.
515            self.held_count += 1
516            picked_up_by = msg.node.source_player
517            if picked_up_by:
518                self.last_player_held_by = picked_up_by
519
520        elif isinstance(msg, ba.DroppedMessage):
521            super().handlemessage(msg)  # Augment standard behavior.
522            self.held_count -= 1
523            if self.held_count < 0:
524                print('ERROR: spaz held_count < 0')
525
526            # Let's count someone dropping us as an attack.
527            try:
528                if msg.node:
529                    picked_up_by = msg.node.source_player
530                else:
531                    picked_up_by = None
532            except Exception:
533                ba.print_exception('Error on SpazBot DroppedMessage.')
534                picked_up_by = None
535
536            if picked_up_by:
537                self.last_player_attacked_by = picked_up_by
538                self.last_attacked_time = ba.time()
539                self.last_attacked_type = ('picked_up', 'default')
540
541        elif isinstance(msg, ba.DieMessage):
542
543            # Report normal deaths for scoring purposes.
544            if not self._dead and not msg.immediate:
545
546                killerplayer: ba.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 ba.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, ba.HitMessage):
576            source_player = msg.get_source_player(ba.Player)
577            if source_player:
578                self.last_player_attacked_by = source_player
579                self.last_attacked_time = ba.time()
580                self.last_attacked_type = (msg.hit_type, msg.hit_subtype)
581            super().handlemessage(msg)
582        else:
583            super().handlemessage(msg)

A really dumb AI version of ba.Spaz.

Category: Bot Classes

Add these to a ba.BotSet to use them.

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

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

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

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

Instantiate a spaz-bot.

map: ba._map.Map

The map this bot was created on.

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

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

def update_ai(self) -> None:
202    def update_ai(self) -> None:
203        """Should be called periodically to update the spaz' AI."""
204        # pylint: disable=too-many-branches
205        # pylint: disable=too-many-statements
206        # pylint: disable=too-many-locals
207        if self.update_callback is not None:
208            if self.update_callback(self):
209                # Bot has been handled.
210                return
211
212        if not self.node:
213            return
214
215        pos = self.node.position
216        our_pos = ba.Vec3(pos[0], 0, pos[2])
217        can_attack = True
218
219        target_pt_raw: ba.Vec3 | None
220        target_vel: ba.Vec3 | None
221
222        # If we're a flag-bearer, we're pretty simple-minded - just walk
223        # towards the flag and try to pick it up.
224        if self.target_flag:
225            if self.node.hold_node:
226                holding_flag = self.node.hold_node.getnodetype() == 'flag'
227            else:
228                holding_flag = False
229
230            # If we're holding the flag, just walk left.
231            if holding_flag:
232                # Just walk left.
233                self.node.move_left_right = -1.0
234                self.node.move_up_down = 0.0
235
236            # Otherwise try to go pick it up.
237            elif self.target_flag.node:
238                target_pt_raw = ba.Vec3(*self.target_flag.node.position)
239                diff = target_pt_raw - our_pos
240                diff = ba.Vec3(diff[0], 0, diff[2])  # Don't care about y.
241                dist = diff.length()
242                to_target = diff.normalized()
243
244                # If we're holding some non-flag item, drop it.
245                if self.node.hold_node:
246                    self.node.pickup_pressed = True
247                    self.node.pickup_pressed = False
248                    return
249
250                # If we're a runner, run only when not super-near the flag.
251                if self.run and dist > 3.0:
252                    self._running = True
253                    self.node.run = 1.0
254                else:
255                    self._running = False
256                    self.node.run = 0.0
257
258                self.node.move_left_right = to_target.x
259                self.node.move_up_down = -to_target.z
260                if dist < 1.25:
261                    self.node.pickup_pressed = True
262                    self.node.pickup_pressed = False
263            return
264
265        # Not a flag-bearer. If we're holding anything but a bomb, drop it.
266        if self.node.hold_node:
267            holding_bomb = self.node.hold_node.getnodetype() in ['bomb', 'prop']
268            if not holding_bomb:
269                self.node.pickup_pressed = True
270                self.node.pickup_pressed = False
271                return
272
273        target_pt_raw, target_vel = self._get_target_player_pt()
274
275        if target_pt_raw is None:
276            # Use default target if we've got one.
277            if self.target_point_default is not None:
278                target_pt_raw = self.target_point_default
279                target_vel = ba.Vec3(0, 0, 0)
280                can_attack = False
281
282            # With no target, we stop moving and drop whatever we're holding.
283            else:
284                self.node.move_left_right = 0
285                self.node.move_up_down = 0
286                if self.node.hold_node:
287                    self.node.pickup_pressed = True
288                    self.node.pickup_pressed = False
289                return
290
291        # We don't want height to come into play.
292        target_pt_raw[1] = 0.0
293        assert target_vel is not None
294        target_vel[1] = 0.0
295
296        dist_raw = (target_pt_raw - our_pos).length()
297
298        # Use a point out in front of them as real target.
299        # (more out in front the farther from us they are)
300        target_pt = (
301            target_pt_raw + target_vel * dist_raw * 0.3 * self._lead_amount
302        )
303
304        diff = target_pt - our_pos
305        dist = diff.length()
306        to_target = diff.normalized()
307
308        if self._mode == 'throw':
309            # We can only throw if alive and well.
310            if not self._dead and not self.node.knockout:
311
312                assert self._throw_release_time is not None
313                time_till_throw = self._throw_release_time - ba.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: ba.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                        ba.timer(0.1, ba.Call(_safe_pickup, self.node))
340                    else:
341                        # Throws:
342                        ba.timer(0.1, ba.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 ba.time(timeformat=ba.TimeFormat.MILLISECONDS) % 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
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 = ba.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 ba.time() - self._last_jump_time > 1.0
479            ) or (
480                self.bouncy
481                and ba.time() - self._last_jump_time > 0.4
482                and random.random() < 0.5
483            ):
484                self._last_jump_time = ba.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.

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

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

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

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

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

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

def handlemessage(self, msg: Any) -> Any:
508    def handlemessage(self, msg: Any) -> Any:
509        # pylint: disable=too-many-branches
510        assert not self.expired
511
512        # Keep track of if we're being held and by who most recently.
513        if isinstance(msg, ba.PickedUpMessage):
514            super().handlemessage(msg)  # Augment standard behavior.
515            self.held_count += 1
516            picked_up_by = msg.node.source_player
517            if picked_up_by:
518                self.last_player_held_by = picked_up_by
519
520        elif isinstance(msg, ba.DroppedMessage):
521            super().handlemessage(msg)  # Augment standard behavior.
522            self.held_count -= 1
523            if self.held_count < 0:
524                print('ERROR: spaz held_count < 0')
525
526            # Let's count someone dropping us as an attack.
527            try:
528                if msg.node:
529                    picked_up_by = msg.node.source_player
530                else:
531                    picked_up_by = None
532            except Exception:
533                ba.print_exception('Error on SpazBot DroppedMessage.')
534                picked_up_by = None
535
536            if picked_up_by:
537                self.last_player_attacked_by = picked_up_by
538                self.last_attacked_time = ba.time()
539                self.last_attacked_type = ('picked_up', 'default')
540
541        elif isinstance(msg, ba.DieMessage):
542
543            # Report normal deaths for scoring purposes.
544            if not self._dead and not msg.immediate:
545
546                killerplayer: ba.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 ba.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, ba.HitMessage):
576            source_player = msg.get_source_player(ba.Player)
577            if source_player:
578                self.last_player_attacked_by = source_player
579                self.last_attacked_time = ba.time()
580                self.last_attacked_type = (msg.hit_type, msg.hit_subtype)
581            super().handlemessage(msg)
582        else:
583            super().handlemessage(msg)

General message handling; can be passed any message object.

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

A bot that throws regular bombs and occasionally punches.

category: Bot Classes

class BomberBotLite(BomberBot):
596class BomberBotLite(BomberBot):
597    """A less aggressive yellow version of ba.BomberBot.
598
599    category: Bot Classes
600    """
601
602    color = LITE_BOT_COLOR
603    highlight = LITE_BOT_HIGHLIGHT
604    punchiness = 0.2
605    throw_rate = 0.7
606    throwiness = 0.1
607    charge_speed_min = 0.6
608    charge_speed_max = 0.6

A less aggressive yellow version of ba.BomberBot.

category: Bot Classes

class BomberBotStaticLite(BomberBotLite):
611class BomberBotStaticLite(BomberBotLite):
612    """A less aggressive generally immobile weak version of ba.BomberBot.
613
614    category: Bot Classes
615    """
616
617    static = True
618    throw_dist_min = 0.0

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

category: Bot Classes

class BomberBotStatic(BomberBot):
621class BomberBotStatic(BomberBot):
622    """A version of ba.BomberBot who generally stays in one place.
623
624    category: Bot Classes
625    """
626
627    static = True
628    throw_dist_min = 0.0

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

category: Bot Classes

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

A more powerful version of ba.BomberBot.

category: Bot Classes

class BomberBotProShielded(BomberBotPro):
648class BomberBotProShielded(BomberBotPro):
649    """A more powerful version of ba.BomberBot who starts with shields.
650
651    category: Bot Classes
652    """
653
654    points_mult = 3
655    default_shields = True

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

category: Bot Classes

class BomberBotProStatic(BomberBotPro):
658class BomberBotProStatic(BomberBotPro):
659    """A more powerful ba.BomberBot who generally stays in one place.
660
661    category: Bot Classes
662    """
663
664    static = True
665    throw_dist_min = 0.0

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

category: Bot Classes

class BomberBotProStaticShielded(BomberBotProShielded):
668class BomberBotProStaticShielded(BomberBotProShielded):
669    """A powerful ba.BomberBot with shields who is generally immobile.
670
671    category: Bot Classes
672    """
673
674    static = True
675    throw_dist_min = 0.0

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

category: Bot Classes

class BrawlerBot(SpazBot):
678class BrawlerBot(SpazBot):
679    """A bot who walks and punches things.
680
681    category: Bot Classes
682    """
683
684    character = 'Kronk'
685    punchiness = 0.9
686    charge_dist_max = 9999.0
687    charge_speed_min = 1.0
688    charge_speed_max = 1.0
689    throw_dist_min = 9999
690    throw_dist_max = 9999

A bot who walks and punches things.

category: Bot Classes

class BrawlerBotLite(BrawlerBot):
693class BrawlerBotLite(BrawlerBot):
694    """A weaker version of ba.BrawlerBot.
695
696    category: Bot Classes
697    """
698
699    color = LITE_BOT_COLOR
700    highlight = LITE_BOT_HIGHLIGHT
701    punchiness = 0.3
702    charge_speed_min = 0.6
703    charge_speed_max = 0.6

A weaker version of ba.BrawlerBot.

category: Bot Classes

class BrawlerBotPro(BrawlerBot):
706class BrawlerBotPro(BrawlerBot):
707    """A stronger version of ba.BrawlerBot.
708
709    category: Bot Classes
710    """
711
712    color = PRO_BOT_COLOR
713    highlight = PRO_BOT_HIGHLIGHT
714    run = True
715    run_dist_min = 4.0
716    default_boxing_gloves = True
717    punchiness = 0.95
718    points_mult = 2

A stronger version of ba.BrawlerBot.

category: Bot Classes

class BrawlerBotProShielded(BrawlerBotPro):
721class BrawlerBotProShielded(BrawlerBotPro):
722    """A stronger version of ba.BrawlerBot who starts with shields.
723
724    category: Bot Classes
725    """
726
727    default_shields = True
728    points_mult = 3

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

category: Bot Classes

class ChargerBot(SpazBot):
731class ChargerBot(SpazBot):
732    """A speedy melee attack bot.
733
734    category: Bot Classes
735    """
736
737    character = 'Snake Shadow'
738    punchiness = 1.0
739    run = True
740    charge_dist_min = 10.0
741    charge_dist_max = 9999.0
742    charge_speed_min = 1.0
743    charge_speed_max = 1.0
744    throw_dist_min = 9999
745    throw_dist_max = 9999
746    points_mult = 2

A speedy melee attack bot.

category: Bot Classes

class BouncyBot(SpazBot):
749class BouncyBot(SpazBot):
750    """A speedy attacking melee bot that jumps constantly.
751
752    category: Bot Classes
753    """
754
755    color = (1, 1, 1)
756    highlight = (1.0, 0.5, 0.5)
757    character = 'Easter Bunny'
758    punchiness = 1.0
759    run = True
760    bouncy = True
761    default_boxing_gloves = True
762    charge_dist_min = 10.0
763    charge_dist_max = 9999.0
764    charge_speed_min = 1.0
765    charge_speed_max = 1.0
766    throw_dist_min = 9999
767    throw_dist_max = 9999
768    points_mult = 2

A speedy attacking melee bot that jumps constantly.

category: Bot Classes

class ChargerBotPro(ChargerBot):
771class ChargerBotPro(ChargerBot):
772    """A stronger ba.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

A stronger ba.ChargerBot.

category: Bot Classes

class ChargerBotProShielded(ChargerBotPro):
784class ChargerBotProShielded(ChargerBotPro):
785    """A stronger ba.ChargerBot who starts with shields.
786
787    category: Bot Classes
788    """
789
790    default_shields = True
791    points_mult = 4

A stronger ba.ChargerBot who starts with shields.

category: Bot Classes

class TriggerBot(SpazBot):
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

A slow moving bot with trigger bombs.

category: Bot Classes

class TriggerBotStatic(TriggerBot):
812class TriggerBotStatic(TriggerBot):
813    """A ba.TriggerBot who generally stays in one place.
814
815    category: Bot Classes
816    """
817
818    static = True
819    throw_dist_min = 0.0

A ba.TriggerBot who generally stays in one place.

category: Bot Classes

class TriggerBotPro(TriggerBot):
822class TriggerBotPro(TriggerBot):
823    """A stronger version of ba.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

A stronger version of ba.TriggerBot.

category: Bot Classes

class TriggerBotProShielded(TriggerBotPro):
841class TriggerBotProShielded(TriggerBotPro):
842    """A stronger version of ba.TriggerBot who starts with shields.
843
844    category: Bot Classes
845    """
846
847    default_shields = True
848    points_mult = 4

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

category: Bot Classes

class StickyBot(SpazBot):
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

A crazy bot who runs and throws sticky bombs.

category: Bot Classes

class StickyBotStatic(StickyBot):
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

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

category: Bot Classes

class ExplodeyBot(SpazBot):
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

A bot who runs and explodes in 5 seconds.

category: Bot Classes

class ExplodeyBotNoTimeLimit(ExplodeyBot):
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

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

category: Bot Classes

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

A ba.ExplodeyBot who starts with shields.

category: Bot Classes

class SpazBotSet:
 919class SpazBotSet:
 920    """A container/controller for one or more ba.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 = ba.getsound('spawn')
 937        self._spawning_count = 0
 938        self._bot_update_timer: ba.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 bastd.actor import spawner
 953
 954        spawner.Spawner(
 955            pt=pos,
 956            spawn_time=spawn_time,
 957            send_spawn_message=False,
 958            spawn_callback=ba.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        ba.playsound(self._spawn_sound, position=pos)
 972        assert spaz.node
 973        spaz.node.handlemessage('flash')
 974        spaz.node.is_area_of_interest = False
 975        spaz.handlemessage(ba.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
 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            ba.print_exception(
1007                'Error updating bot list: '
1008                + str(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 ba.getactivity().players:
1017            assert isinstance(player, ba.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                            ba.Vec3(player.actor.node.position),
1028                            ba.Vec3(player.actor.node.velocity),
1029                        )
1030                    )
1031            except Exception:
1032                ba.print_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 = ba.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(ba.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 = ba.Timer(
1054            0.05, ba.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:
1066                if bot.node:
1067                    bot.node.move_left_right = 0
1068                    bot.node.move_up_down = 0
1069
1070    def celebrate(self, duration: float) -> None:
1071        """Tell all living bots in the set to celebrate momentarily.
1072
1073        Duration is given in seconds.
1074        """
1075        msg = ba.CelebrateMessage(duration=duration)
1076        for botlist in self._bot_lists:
1077            for bot in botlist:
1078                if bot:
1079                    bot.handlemessage(msg)
1080
1081    def final_celebrate(self) -> None:
1082        """Tell all bots in the set to stop what they were doing and celebrate.
1083
1084        Use this when the bots have won a game.
1085        """
1086        self._bot_update_timer = None
1087
1088        # At this point stop doing anything but jumping and celebrating.
1089        for botlist in self._bot_lists:
1090            for bot in botlist:
1091                if bot:
1092                    assert bot.node  # (should exist if 'if bot' was True)
1093                    bot.node.move_left_right = 0
1094                    bot.node.move_up_down = 0
1095                    ba.timer(
1096                        0.5 * random.random(),
1097                        ba.Call(bot.handlemessage, ba.CelebrateMessage()),
1098                    )
1099                    jump_duration = random.randrange(400, 500)
1100                    j = random.randrange(0, 200)
1101                    for _i in range(10):
1102                        bot.node.jump_pressed = True
1103                        bot.node.jump_pressed = False
1104                        j += jump_duration
1105                    ba.timer(
1106                        random.uniform(0.0, 1.0),
1107                        ba.Call(bot.node.handlemessage, 'attack_sound'),
1108                    )
1109                    ba.timer(
1110                        random.uniform(1.0, 2.0),
1111                        ba.Call(bot.node.handlemessage, 'attack_sound'),
1112                    )
1113                    ba.timer(
1114                        random.uniform(2.0, 3.0),
1115                        ba.Call(bot.node.handlemessage, 'attack_sound'),
1116                    )
1117
1118    def add_bot(self, bot: SpazBot) -> None:
1119        """Add a ba.SpazBot instance to the set."""
1120        self._bot_lists[self._bot_add_list].append(bot)
1121        self._bot_add_list = (self._bot_add_list + 1) % self._bot_list_count

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

category: Bot Classes

SpazBotSet()
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 = ba.getsound('spawn')
937        self._spawning_count = 0
938        self._bot_update_timer: ba.Timer | None = None
939        self.start_moving()

Create a bot-set.

def spawn_bot( self, bot_type: type[bastd.actor.spazbot.SpazBot], pos: Sequence[float], spawn_time: float = 3.0, on_spawn_call: Optional[Callable[[bastd.actor.spazbot.SpazBot], Any]] = None) -> None:
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 bastd.actor import spawner
953
954        spawner.Spawner(
955            pt=pos,
956            spawn_time=spawn_time,
957            send_spawn_message=False,
958            spawn_callback=ba.Call(
959                self._spawn_bot, bot_type, pos, on_spawn_call
960            ),
961        )
962        self._spawning_count += 1

Spawn a bot from this set.

def have_living_bots(self) -> bool:
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        )

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

def get_living_bots(self) -> list[bastd.actor.spazbot.SpazBot]:
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

Get the living bots in the set.

def clear(self) -> None:
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 = ba.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(ba.DieMessage(immediate=True))
1049            self._bot_lists[i] = []

Immediately clear out any bots in the set.

def start_moving(self) -> None:
1051    def start_moving(self) -> None:
1052        """Start processing bot AI updates so they start doing their thing."""
1053        self._bot_update_timer = ba.Timer(
1054            0.05, ba.WeakCall(self._update), repeat=True
1055        )

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

def stop_moving(self) -> None:
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:
1066                if bot.node:
1067                    bot.node.move_left_right = 0
1068                    bot.node.move_up_down = 0

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

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

def celebrate(self, duration: float) -> None:
1070    def celebrate(self, duration: float) -> None:
1071        """Tell all living bots in the set to celebrate momentarily.
1072
1073        Duration is given in seconds.
1074        """
1075        msg = ba.CelebrateMessage(duration=duration)
1076        for botlist in self._bot_lists:
1077            for bot in botlist:
1078                if bot:
1079                    bot.handlemessage(msg)

Tell all living bots in the set to celebrate momentarily.

Duration is given in seconds.

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

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

Use this when the bots have won a game.

def add_bot(self, bot: bastd.actor.spazbot.SpazBot) -> None:
1118    def add_bot(self, bot: SpazBot) -> None:
1119        """Add a ba.SpazBot instance to the set."""
1120        self._bot_lists[self._bot_add_list].append(bot)
1121        self._bot_add_list = (self._bot_add_list + 1) % self._bot_list_count

Add a ba.SpazBot instance to the set.