bastd.actor.spaz

Defines the spaz actor.

   1# Released under the MIT License. See LICENSE for details.
   2#
   3"""Defines the spaz actor."""
   4# pylint: disable=too-many-lines
   5
   6from __future__ import annotations
   7
   8import random
   9from typing import TYPE_CHECKING
  10
  11import ba
  12from bastd.actor import bomb as stdbomb
  13from bastd.actor.powerupbox import PowerupBoxFactory
  14from bastd.actor.spazfactory import SpazFactory
  15from bastd.gameutils import SharedObjects
  16
  17if TYPE_CHECKING:
  18    from typing import Any, Sequence, Callable
  19
  20POWERUP_WEAR_OFF_TIME = 20000
  21BASE_PUNCH_COOLDOWN = 400
  22
  23
  24class PickupMessage:
  25    """We wanna pick something up."""
  26
  27
  28class PunchHitMessage:
  29    """Message saying an object was hit."""
  30
  31
  32class CurseExplodeMessage:
  33    """We are cursed and should blow up now."""
  34
  35
  36class BombDiedMessage:
  37    """A bomb has died and thus can be recycled."""
  38
  39
  40class Spaz(ba.Actor):
  41    """
  42    Base class for various Spazzes.
  43
  44    Category: **Gameplay Classes**
  45
  46    A Spaz is the standard little humanoid character in the game.
  47    It can be controlled by a player or by AI, and can have
  48    various different appearances.  The name 'Spaz' is not to be
  49    confused with the 'Spaz' character in the game, which is just
  50    one of the skins available for instances of this class.
  51    """
  52
  53    # pylint: disable=too-many-public-methods
  54    # pylint: disable=too-many-locals
  55
  56    node: ba.Node
  57    """The 'spaz' ba.Node."""
  58
  59    points_mult = 1
  60    curse_time: float | None = 5.0
  61    default_bomb_count = 1
  62    default_bomb_type = 'normal'
  63    default_boxing_gloves = False
  64    default_shields = False
  65
  66    def __init__(
  67        self,
  68        color: Sequence[float] = (1.0, 1.0, 1.0),
  69        highlight: Sequence[float] = (0.5, 0.5, 0.5),
  70        character: str = 'Spaz',
  71        source_player: ba.Player | None = None,
  72        start_invincible: bool = True,
  73        can_accept_powerups: bool = True,
  74        powerups_expire: bool = False,
  75        demo_mode: bool = False,
  76    ):
  77        """Create a spaz with the requested color, character, etc."""
  78        # pylint: disable=too-many-statements
  79
  80        super().__init__()
  81        shared = SharedObjects.get()
  82        activity = self.activity
  83
  84        factory = SpazFactory.get()
  85
  86        # We need to behave slightly different in the tutorial.
  87        self._demo_mode = demo_mode
  88
  89        self.play_big_death_sound = False
  90
  91        # Scales how much impacts affect us (most damage calcs).
  92        self.impact_scale = 1.0
  93
  94        self.source_player = source_player
  95        self._dead = False
  96        if self._demo_mode:  # Preserve old behavior.
  97            self._punch_power_scale = 1.2
  98        else:
  99            self._punch_power_scale = factory.punch_power_scale
 100        self.fly = ba.getactivity().globalsnode.happy_thoughts_mode
 101        if isinstance(activity, ba.GameActivity):
 102            self._hockey = activity.map.is_hockey
 103        else:
 104            self._hockey = False
 105        self._punched_nodes: set[ba.Node] = set()
 106        self._cursed = False
 107        self._connected_to_player: ba.Player | None = None
 108        materials = [
 109            factory.spaz_material,
 110            shared.object_material,
 111            shared.player_material,
 112        ]
 113        roller_materials = [factory.roller_material, shared.player_material]
 114        extras_material = []
 115
 116        if can_accept_powerups:
 117            pam = PowerupBoxFactory.get().powerup_accept_material
 118            materials.append(pam)
 119            roller_materials.append(pam)
 120            extras_material.append(pam)
 121
 122        media = factory.get_media(character)
 123        punchmats = (factory.punch_material, shared.attack_material)
 124        pickupmats = (factory.pickup_material, shared.pickup_material)
 125        self.node: ba.Node = ba.newnode(
 126            type='spaz',
 127            delegate=self,
 128            attrs={
 129                'color': color,
 130                'behavior_version': 0 if demo_mode else 1,
 131                'demo_mode': demo_mode,
 132                'highlight': highlight,
 133                'jump_sounds': media['jump_sounds'],
 134                'attack_sounds': media['attack_sounds'],
 135                'impact_sounds': media['impact_sounds'],
 136                'death_sounds': media['death_sounds'],
 137                'pickup_sounds': media['pickup_sounds'],
 138                'fall_sounds': media['fall_sounds'],
 139                'color_texture': media['color_texture'],
 140                'color_mask_texture': media['color_mask_texture'],
 141                'head_model': media['head_model'],
 142                'torso_model': media['torso_model'],
 143                'pelvis_model': media['pelvis_model'],
 144                'upper_arm_model': media['upper_arm_model'],
 145                'forearm_model': media['forearm_model'],
 146                'hand_model': media['hand_model'],
 147                'upper_leg_model': media['upper_leg_model'],
 148                'lower_leg_model': media['lower_leg_model'],
 149                'toes_model': media['toes_model'],
 150                'style': factory.get_style(character),
 151                'fly': self.fly,
 152                'hockey': self._hockey,
 153                'materials': materials,
 154                'roller_materials': roller_materials,
 155                'extras_material': extras_material,
 156                'punch_materials': punchmats,
 157                'pickup_materials': pickupmats,
 158                'invincible': start_invincible,
 159                'source_player': source_player,
 160            },
 161        )
 162        self.shield: ba.Node | None = None
 163
 164        if start_invincible:
 165
 166            def _safesetattr(node: ba.Node | None, attr: str, val: Any) -> None:
 167                if node:
 168                    setattr(node, attr, val)
 169
 170            ba.timer(1.0, ba.Call(_safesetattr, self.node, 'invincible', False))
 171        self.hitpoints = 1000
 172        self.hitpoints_max = 1000
 173        self.shield_hitpoints: int | None = None
 174        self.shield_hitpoints_max = 650
 175        self.shield_decay_rate = 0
 176        self.shield_decay_timer: ba.Timer | None = None
 177        self._boxing_gloves_wear_off_timer: ba.Timer | None = None
 178        self._boxing_gloves_wear_off_flash_timer: ba.Timer | None = None
 179        self._bomb_wear_off_timer: ba.Timer | None = None
 180        self._bomb_wear_off_flash_timer: ba.Timer | None = None
 181        self._multi_bomb_wear_off_timer: ba.Timer | None = None
 182        self._multi_bomb_wear_off_flash_timer: ba.Timer | None = None
 183        self._curse_timer: ba.Timer | None = None
 184        self.bomb_count = self.default_bomb_count
 185        self._max_bomb_count = self.default_bomb_count
 186        self.bomb_type_default = self.default_bomb_type
 187        self.bomb_type = self.bomb_type_default
 188        self.land_mine_count = 0
 189        self.blast_radius = 2.0
 190        self.powerups_expire = powerups_expire
 191        if self._demo_mode:  # preserve old behavior
 192            self._punch_cooldown = BASE_PUNCH_COOLDOWN
 193        else:
 194            self._punch_cooldown = factory.punch_cooldown
 195        self._jump_cooldown = 250
 196        self._pickup_cooldown = 0
 197        self._bomb_cooldown = 0
 198        self._has_boxing_gloves = False
 199        if self.default_boxing_gloves:
 200            self.equip_boxing_gloves()
 201        self.last_punch_time_ms = -9999
 202        self.last_pickup_time_ms = -9999
 203        self.last_jump_time_ms = -9999
 204        self.last_run_time_ms = -9999
 205        self._last_run_value = 0.0
 206        self.last_bomb_time_ms = -9999
 207        self._turbo_filter_times: dict[str, int] = {}
 208        self._turbo_filter_time_bucket = 0
 209        self._turbo_filter_counts: dict[str, int] = {}
 210        self.frozen = False
 211        self.shattered = False
 212        self._last_hit_time: int | None = None
 213        self._num_times_hit = 0
 214        self._bomb_held = False
 215        if self.default_shields:
 216            self.equip_shields()
 217        self._dropped_bomb_callbacks: list[Callable[[Spaz, ba.Actor], Any]] = []
 218
 219        self._score_text: ba.Node | None = None
 220        self._score_text_hide_timer: ba.Timer | None = None
 221        self._last_stand_pos: Sequence[float] | None = None
 222
 223        # Deprecated stuff.. should make these into lists.
 224        self.punch_callback: Callable[[Spaz], Any] | None = None
 225        self.pick_up_powerup_callback: Callable[[Spaz], Any] | None = None
 226
 227    def exists(self) -> bool:
 228        return bool(self.node)
 229
 230    def on_expire(self) -> None:
 231        super().on_expire()
 232
 233        # Release callbacks/refs so we don't wind up with dependency loops.
 234        self._dropped_bomb_callbacks = []
 235        self.punch_callback = None
 236        self.pick_up_powerup_callback = None
 237
 238    def add_dropped_bomb_callback(
 239        self, call: Callable[[Spaz, ba.Actor], Any]
 240    ) -> None:
 241        """
 242        Add a call to be run whenever this Spaz drops a bomb.
 243        The spaz and the newly-dropped bomb are passed as arguments.
 244        """
 245        assert not self.expired
 246        self._dropped_bomb_callbacks.append(call)
 247
 248    def is_alive(self) -> bool:
 249        """
 250        Method override; returns whether ol' spaz is still kickin'.
 251        """
 252        return not self._dead
 253
 254    def _hide_score_text(self) -> None:
 255        if self._score_text:
 256            assert isinstance(self._score_text.scale, float)
 257            ba.animate(
 258                self._score_text,
 259                'scale',
 260                {0.0: self._score_text.scale, 0.2: 0.0},
 261            )
 262
 263    def _turbo_filter_add_press(self, source: str) -> None:
 264        """
 265        Can pass all button presses through here; if we see an obscene number
 266        of them in a short time let's shame/pushish this guy for using turbo.
 267        """
 268        t_ms = ba.time(
 269            timetype=ba.TimeType.BASE, timeformat=ba.TimeFormat.MILLISECONDS
 270        )
 271        assert isinstance(t_ms, int)
 272        t_bucket = int(t_ms / 1000)
 273        if t_bucket == self._turbo_filter_time_bucket:
 274            # Add only once per timestep (filter out buttons triggering
 275            # multiple actions).
 276            if t_ms != self._turbo_filter_times.get(source, 0):
 277                self._turbo_filter_counts[source] = (
 278                    self._turbo_filter_counts.get(source, 0) + 1
 279                )
 280                self._turbo_filter_times[source] = t_ms
 281                # (uncomment to debug; prints what this count is at)
 282                # ba.screenmessage( str(source) + " "
 283                #                   + str(self._turbo_filter_counts[source]))
 284                if self._turbo_filter_counts[source] == 15:
 285                    # Knock 'em out.  That'll learn 'em.
 286                    assert self.node
 287                    self.node.handlemessage('knockout', 500.0)
 288
 289                    # Also issue periodic notices about who is turbo-ing.
 290                    now = ba.time(ba.TimeType.REAL)
 291                    if now > ba.app.last_spaz_turbo_warn_time + 30.0:
 292                        ba.app.last_spaz_turbo_warn_time = now
 293                        ba.screenmessage(
 294                            ba.Lstr(
 295                                translate=(
 296                                    'statements',
 297                                    (
 298                                        'Warning to ${NAME}:  '
 299                                        'turbo / button-spamming knocks'
 300                                        ' you out.'
 301                                    ),
 302                                ),
 303                                subs=[('${NAME}', self.node.name)],
 304                            ),
 305                            color=(1, 0.5, 0),
 306                        )
 307                        ba.playsound(ba.getsound('error'))
 308        else:
 309            self._turbo_filter_times = {}
 310            self._turbo_filter_time_bucket = t_bucket
 311            self._turbo_filter_counts = {source: 1}
 312
 313    def set_score_text(
 314        self,
 315        text: str | ba.Lstr,
 316        color: Sequence[float] = (1.0, 1.0, 0.4),
 317        flash: bool = False,
 318    ) -> None:
 319        """
 320        Utility func to show a message momentarily over our spaz that follows
 321        him around; Handy for score updates and things.
 322        """
 323        color_fin = ba.safecolor(color)[:3]
 324        if not self.node:
 325            return
 326        if not self._score_text:
 327            start_scale = 0.0
 328            mnode = ba.newnode(
 329                'math',
 330                owner=self.node,
 331                attrs={'input1': (0, 1.4, 0), 'operation': 'add'},
 332            )
 333            self.node.connectattr('torso_position', mnode, 'input2')
 334            self._score_text = ba.newnode(
 335                'text',
 336                owner=self.node,
 337                attrs={
 338                    'text': text,
 339                    'in_world': True,
 340                    'shadow': 1.0,
 341                    'flatness': 1.0,
 342                    'color': color_fin,
 343                    'scale': 0.02,
 344                    'h_align': 'center',
 345                },
 346            )
 347            mnode.connectattr('output', self._score_text, 'position')
 348        else:
 349            self._score_text.color = color_fin
 350            assert isinstance(self._score_text.scale, float)
 351            start_scale = self._score_text.scale
 352            self._score_text.text = text
 353        if flash:
 354            combine = ba.newnode(
 355                'combine', owner=self._score_text, attrs={'size': 3}
 356            )
 357            scl = 1.8
 358            offs = 0.5
 359            tval = 0.300
 360            for i in range(3):
 361                cl1 = offs + scl * color_fin[i]
 362                cl2 = color_fin[i]
 363                ba.animate(
 364                    combine,
 365                    'input' + str(i),
 366                    {0.5 * tval: cl2, 0.75 * tval: cl1, 1.0 * tval: cl2},
 367                )
 368            combine.connectattr('output', self._score_text, 'color')
 369
 370        ba.animate(self._score_text, 'scale', {0.0: start_scale, 0.2: 0.02})
 371        self._score_text_hide_timer = ba.Timer(
 372            1.0, ba.WeakCall(self._hide_score_text)
 373        )
 374
 375    def on_jump_press(self) -> None:
 376        """
 377        Called to 'press jump' on this spaz;
 378        used by player or AI connections.
 379        """
 380        if not self.node:
 381            return
 382        t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 383        assert isinstance(t_ms, int)
 384        if t_ms - self.last_jump_time_ms >= self._jump_cooldown:
 385            self.node.jump_pressed = True
 386            self.last_jump_time_ms = t_ms
 387        self._turbo_filter_add_press('jump')
 388
 389    def on_jump_release(self) -> None:
 390        """
 391        Called to 'release jump' on this spaz;
 392        used by player or AI connections.
 393        """
 394        if not self.node:
 395            return
 396        self.node.jump_pressed = False
 397
 398    def on_pickup_press(self) -> None:
 399        """
 400        Called to 'press pick-up' on this spaz;
 401        used by player or AI connections.
 402        """
 403        if not self.node:
 404            return
 405        t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 406        assert isinstance(t_ms, int)
 407        if t_ms - self.last_pickup_time_ms >= self._pickup_cooldown:
 408            self.node.pickup_pressed = True
 409            self.last_pickup_time_ms = t_ms
 410        self._turbo_filter_add_press('pickup')
 411
 412    def on_pickup_release(self) -> None:
 413        """
 414        Called to 'release pick-up' on this spaz;
 415        used by player or AI connections.
 416        """
 417        if not self.node:
 418            return
 419        self.node.pickup_pressed = False
 420
 421    def on_hold_position_press(self) -> None:
 422        """
 423        Called to 'press hold-position' on this spaz;
 424        used for player or AI connections.
 425        """
 426        if not self.node:
 427            return
 428        self.node.hold_position_pressed = True
 429        self._turbo_filter_add_press('holdposition')
 430
 431    def on_hold_position_release(self) -> None:
 432        """
 433        Called to 'release hold-position' on this spaz;
 434        used for player or AI connections.
 435        """
 436        if not self.node:
 437            return
 438        self.node.hold_position_pressed = False
 439
 440    def on_punch_press(self) -> None:
 441        """
 442        Called to 'press punch' on this spaz;
 443        used for player or AI connections.
 444        """
 445        if not self.node or self.frozen or self.node.knockout > 0.0:
 446            return
 447        t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 448        assert isinstance(t_ms, int)
 449        if t_ms - self.last_punch_time_ms >= self._punch_cooldown:
 450            if self.punch_callback is not None:
 451                self.punch_callback(self)
 452            self._punched_nodes = set()  # Reset this.
 453            self.last_punch_time_ms = t_ms
 454            self.node.punch_pressed = True
 455            if not self.node.hold_node:
 456                ba.timer(
 457                    0.1,
 458                    ba.WeakCall(
 459                        self._safe_play_sound,
 460                        SpazFactory.get().swish_sound,
 461                        0.8,
 462                    ),
 463                )
 464        self._turbo_filter_add_press('punch')
 465
 466    def _safe_play_sound(self, sound: ba.Sound, volume: float) -> None:
 467        """Plays a sound at our position if we exist."""
 468        if self.node:
 469            ba.playsound(sound, volume, self.node.position)
 470
 471    def on_punch_release(self) -> None:
 472        """
 473        Called to 'release punch' on this spaz;
 474        used for player or AI connections.
 475        """
 476        if not self.node:
 477            return
 478        self.node.punch_pressed = False
 479
 480    def on_bomb_press(self) -> None:
 481        """
 482        Called to 'press bomb' on this spaz;
 483        used for player or AI connections.
 484        """
 485        if not self.node:
 486            return
 487
 488        if self._dead or self.frozen:
 489            return
 490        if self.node.knockout > 0.0:
 491            return
 492        t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 493        assert isinstance(t_ms, int)
 494        if t_ms - self.last_bomb_time_ms >= self._bomb_cooldown:
 495            self.last_bomb_time_ms = t_ms
 496            self.node.bomb_pressed = True
 497            if not self.node.hold_node:
 498                self.drop_bomb()
 499        self._turbo_filter_add_press('bomb')
 500
 501    def on_bomb_release(self) -> None:
 502        """
 503        Called to 'release bomb' on this spaz;
 504        used for player or AI connections.
 505        """
 506        if not self.node:
 507            return
 508        self.node.bomb_pressed = False
 509
 510    def on_run(self, value: float) -> None:
 511        """
 512        Called to 'press run' on this spaz;
 513        used for player or AI connections.
 514        """
 515        if not self.node:
 516            return
 517
 518        t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 519        assert isinstance(t_ms, int)
 520        self.last_run_time_ms = t_ms
 521        self.node.run = value
 522
 523        # filtering these events would be tough since its an analog
 524        # value, but lets still pass full 0-to-1 presses along to
 525        # the turbo filter to punish players if it looks like they're turbo-ing
 526        if self._last_run_value < 0.01 and value > 0.99:
 527            self._turbo_filter_add_press('run')
 528
 529        self._last_run_value = value
 530
 531    def on_fly_press(self) -> None:
 532        """
 533        Called to 'press fly' on this spaz;
 534        used for player or AI connections.
 535        """
 536        if not self.node:
 537            return
 538        # not adding a cooldown time here for now; slightly worried
 539        # input events get clustered up during net-games and we'd wind up
 540        # killing a lot and making it hard to fly.. should look into this.
 541        self.node.fly_pressed = True
 542        self._turbo_filter_add_press('fly')
 543
 544    def on_fly_release(self) -> None:
 545        """
 546        Called to 'release fly' on this spaz;
 547        used for player or AI connections.
 548        """
 549        if not self.node:
 550            return
 551        self.node.fly_pressed = False
 552
 553    def on_move(self, x: float, y: float) -> None:
 554        """
 555        Called to set the joystick amount for this spaz;
 556        used for player or AI connections.
 557        """
 558        if not self.node:
 559            return
 560        self.node.handlemessage('move', x, y)
 561
 562    def on_move_up_down(self, value: float) -> None:
 563        """
 564        Called to set the up/down joystick amount on this spaz;
 565        used for player or AI connections.
 566        value will be between -32768 to 32767
 567        WARNING: deprecated; use on_move instead.
 568        """
 569        if not self.node:
 570            return
 571        self.node.move_up_down = value
 572
 573    def on_move_left_right(self, value: float) -> None:
 574        """
 575        Called to set the left/right joystick amount on this spaz;
 576        used for player or AI connections.
 577        value will be between -32768 to 32767
 578        WARNING: deprecated; use on_move instead.
 579        """
 580        if not self.node:
 581            return
 582        self.node.move_left_right = value
 583
 584    def on_punched(self, damage: int) -> None:
 585        """Called when this spaz gets punched."""
 586
 587    def get_death_points(self, how: ba.DeathType) -> tuple[int, int]:
 588        """Get the points awarded for killing this spaz."""
 589        del how  # Unused.
 590        num_hits = float(max(1, self._num_times_hit))
 591
 592        # Base points is simply 10 for 1-hit-kills and 5 otherwise.
 593        importance = 2 if num_hits < 2 else 1
 594        return (10 if num_hits < 2 else 5) * self.points_mult, importance
 595
 596    def curse(self) -> None:
 597        """
 598        Give this poor spaz a curse;
 599        he will explode in 5 seconds.
 600        """
 601        if not self._cursed:
 602            factory = SpazFactory.get()
 603            self._cursed = True
 604
 605            # Add the curse material.
 606            for attr in ['materials', 'roller_materials']:
 607                materials = getattr(self.node, attr)
 608                if factory.curse_material not in materials:
 609                    setattr(
 610                        self.node, attr, materials + (factory.curse_material,)
 611                    )
 612
 613            # None specifies no time limit
 614            assert self.node
 615            if self.curse_time is None:
 616                self.node.curse_death_time = -1
 617            else:
 618                # Note: curse-death-time takes milliseconds.
 619                tval = ba.time()
 620                assert isinstance(tval, (float, int))
 621                self.node.curse_death_time = int(
 622                    1000.0 * (tval + self.curse_time)
 623                )
 624                self._curse_timer = ba.Timer(
 625                    5.0, ba.WeakCall(self.curse_explode)
 626                )
 627
 628    def equip_boxing_gloves(self) -> None:
 629        """
 630        Give this spaz some boxing gloves.
 631        """
 632        assert self.node
 633        self.node.boxing_gloves = True
 634        self._has_boxing_gloves = True
 635        if self._demo_mode:  # Preserve old behavior.
 636            self._punch_power_scale = 1.7
 637            self._punch_cooldown = 300
 638        else:
 639            factory = SpazFactory.get()
 640            self._punch_power_scale = factory.punch_power_scale_gloves
 641            self._punch_cooldown = factory.punch_cooldown_gloves
 642
 643    def equip_shields(self, decay: bool = False) -> None:
 644        """
 645        Give this spaz a nice energy shield.
 646        """
 647
 648        if not self.node:
 649            ba.print_error('Can\'t equip shields; no node.')
 650            return
 651
 652        factory = SpazFactory.get()
 653        if self.shield is None:
 654            self.shield = ba.newnode(
 655                'shield',
 656                owner=self.node,
 657                attrs={'color': (0.3, 0.2, 2.0), 'radius': 1.3},
 658            )
 659            self.node.connectattr('position_center', self.shield, 'position')
 660        self.shield_hitpoints = self.shield_hitpoints_max = 650
 661        self.shield_decay_rate = factory.shield_decay_rate if decay else 0
 662        self.shield.hurt = 0
 663        ba.playsound(factory.shield_up_sound, 1.0, position=self.node.position)
 664
 665        if self.shield_decay_rate > 0:
 666            self.shield_decay_timer = ba.Timer(
 667                0.5, ba.WeakCall(self.shield_decay), repeat=True
 668            )
 669            # So user can see the decay.
 670            self.shield.always_show_health_bar = True
 671
 672    def shield_decay(self) -> None:
 673        """Called repeatedly to decay shield HP over time."""
 674        if self.shield:
 675            assert self.shield_hitpoints is not None
 676            self.shield_hitpoints = max(
 677                0, self.shield_hitpoints - self.shield_decay_rate
 678            )
 679            assert self.shield_hitpoints is not None
 680            self.shield.hurt = (
 681                1.0 - float(self.shield_hitpoints) / self.shield_hitpoints_max
 682            )
 683            if self.shield_hitpoints <= 0:
 684                self.shield.delete()
 685                self.shield = None
 686                self.shield_decay_timer = None
 687                assert self.node
 688                ba.playsound(
 689                    SpazFactory.get().shield_down_sound,
 690                    1.0,
 691                    position=self.node.position,
 692                )
 693        else:
 694            self.shield_decay_timer = None
 695
 696    def handlemessage(self, msg: Any) -> Any:
 697        # pylint: disable=too-many-return-statements
 698        # pylint: disable=too-many-statements
 699        # pylint: disable=too-many-branches
 700        assert not self.expired
 701
 702        if isinstance(msg, ba.PickedUpMessage):
 703            if self.node:
 704                self.node.handlemessage('hurt_sound')
 705                self.node.handlemessage('picked_up')
 706
 707            # This counts as a hit.
 708            self._num_times_hit += 1
 709
 710        elif isinstance(msg, ba.ShouldShatterMessage):
 711            # Eww; seems we have to do this in a timer or it wont work right.
 712            # (since we're getting called from within update() perhaps?..)
 713            # NOTE: should test to see if that's still the case.
 714            ba.timer(0.001, ba.WeakCall(self.shatter))
 715
 716        elif isinstance(msg, ba.ImpactDamageMessage):
 717            # Eww; seems we have to do this in a timer or it wont work right.
 718            # (since we're getting called from within update() perhaps?..)
 719            ba.timer(0.001, ba.WeakCall(self._hit_self, msg.intensity))
 720
 721        elif isinstance(msg, ba.PowerupMessage):
 722            if self._dead or not self.node:
 723                return True
 724            if self.pick_up_powerup_callback is not None:
 725                self.pick_up_powerup_callback(self)
 726            if msg.poweruptype == 'triple_bombs':
 727                tex = PowerupBoxFactory.get().tex_bomb
 728                self._flash_billboard(tex)
 729                self.set_bomb_count(3)
 730                if self.powerups_expire:
 731                    self.node.mini_billboard_1_texture = tex
 732                    t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 733                    assert isinstance(t_ms, int)
 734                    self.node.mini_billboard_1_start_time = t_ms
 735                    self.node.mini_billboard_1_end_time = (
 736                        t_ms + POWERUP_WEAR_OFF_TIME
 737                    )
 738                    self._multi_bomb_wear_off_flash_timer = ba.Timer(
 739                        (POWERUP_WEAR_OFF_TIME - 2000),
 740                        ba.WeakCall(self._multi_bomb_wear_off_flash),
 741                        timeformat=ba.TimeFormat.MILLISECONDS,
 742                    )
 743                    self._multi_bomb_wear_off_timer = ba.Timer(
 744                        POWERUP_WEAR_OFF_TIME,
 745                        ba.WeakCall(self._multi_bomb_wear_off),
 746                        timeformat=ba.TimeFormat.MILLISECONDS,
 747                    )
 748            elif msg.poweruptype == 'land_mines':
 749                self.set_land_mine_count(min(self.land_mine_count + 3, 3))
 750            elif msg.poweruptype == 'impact_bombs':
 751                self.bomb_type = 'impact'
 752                tex = self._get_bomb_type_tex()
 753                self._flash_billboard(tex)
 754                if self.powerups_expire:
 755                    self.node.mini_billboard_2_texture = tex
 756                    t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 757                    assert isinstance(t_ms, int)
 758                    self.node.mini_billboard_2_start_time = t_ms
 759                    self.node.mini_billboard_2_end_time = (
 760                        t_ms + POWERUP_WEAR_OFF_TIME
 761                    )
 762                    self._bomb_wear_off_flash_timer = ba.Timer(
 763                        POWERUP_WEAR_OFF_TIME - 2000,
 764                        ba.WeakCall(self._bomb_wear_off_flash),
 765                        timeformat=ba.TimeFormat.MILLISECONDS,
 766                    )
 767                    self._bomb_wear_off_timer = ba.Timer(
 768                        POWERUP_WEAR_OFF_TIME,
 769                        ba.WeakCall(self._bomb_wear_off),
 770                        timeformat=ba.TimeFormat.MILLISECONDS,
 771                    )
 772            elif msg.poweruptype == 'sticky_bombs':
 773                self.bomb_type = 'sticky'
 774                tex = self._get_bomb_type_tex()
 775                self._flash_billboard(tex)
 776                if self.powerups_expire:
 777                    self.node.mini_billboard_2_texture = tex
 778                    t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 779                    assert isinstance(t_ms, int)
 780                    self.node.mini_billboard_2_start_time = t_ms
 781                    self.node.mini_billboard_2_end_time = (
 782                        t_ms + POWERUP_WEAR_OFF_TIME
 783                    )
 784                    self._bomb_wear_off_flash_timer = ba.Timer(
 785                        POWERUP_WEAR_OFF_TIME - 2000,
 786                        ba.WeakCall(self._bomb_wear_off_flash),
 787                        timeformat=ba.TimeFormat.MILLISECONDS,
 788                    )
 789                    self._bomb_wear_off_timer = ba.Timer(
 790                        POWERUP_WEAR_OFF_TIME,
 791                        ba.WeakCall(self._bomb_wear_off),
 792                        timeformat=ba.TimeFormat.MILLISECONDS,
 793                    )
 794            elif msg.poweruptype == 'punch':
 795                tex = PowerupBoxFactory.get().tex_punch
 796                self._flash_billboard(tex)
 797                self.equip_boxing_gloves()
 798                if self.powerups_expire and not self.default_boxing_gloves:
 799                    self.node.boxing_gloves_flashing = False
 800                    self.node.mini_billboard_3_texture = tex
 801                    t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 802                    assert isinstance(t_ms, int)
 803                    self.node.mini_billboard_3_start_time = t_ms
 804                    self.node.mini_billboard_3_end_time = (
 805                        t_ms + POWERUP_WEAR_OFF_TIME
 806                    )
 807                    self._boxing_gloves_wear_off_flash_timer = ba.Timer(
 808                        POWERUP_WEAR_OFF_TIME - 2000,
 809                        ba.WeakCall(self._gloves_wear_off_flash),
 810                        timeformat=ba.TimeFormat.MILLISECONDS,
 811                    )
 812                    self._boxing_gloves_wear_off_timer = ba.Timer(
 813                        POWERUP_WEAR_OFF_TIME,
 814                        ba.WeakCall(self._gloves_wear_off),
 815                        timeformat=ba.TimeFormat.MILLISECONDS,
 816                    )
 817            elif msg.poweruptype == 'shield':
 818                factory = SpazFactory.get()
 819
 820                # Let's allow powerup-equipped shields to lose hp over time.
 821                self.equip_shields(decay=factory.shield_decay_rate > 0)
 822            elif msg.poweruptype == 'curse':
 823                self.curse()
 824            elif msg.poweruptype == 'ice_bombs':
 825                self.bomb_type = 'ice'
 826                tex = self._get_bomb_type_tex()
 827                self._flash_billboard(tex)
 828                if self.powerups_expire:
 829                    self.node.mini_billboard_2_texture = tex
 830                    t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 831                    assert isinstance(t_ms, int)
 832                    self.node.mini_billboard_2_start_time = t_ms
 833                    self.node.mini_billboard_2_end_time = (
 834                        t_ms + POWERUP_WEAR_OFF_TIME
 835                    )
 836                    self._bomb_wear_off_flash_timer = ba.Timer(
 837                        POWERUP_WEAR_OFF_TIME - 2000,
 838                        ba.WeakCall(self._bomb_wear_off_flash),
 839                        timeformat=ba.TimeFormat.MILLISECONDS,
 840                    )
 841                    self._bomb_wear_off_timer = ba.Timer(
 842                        POWERUP_WEAR_OFF_TIME,
 843                        ba.WeakCall(self._bomb_wear_off),
 844                        timeformat=ba.TimeFormat.MILLISECONDS,
 845                    )
 846            elif msg.poweruptype == 'health':
 847                if self._cursed:
 848                    self._cursed = False
 849
 850                    # Remove cursed material.
 851                    factory = SpazFactory.get()
 852                    for attr in ['materials', 'roller_materials']:
 853                        materials = getattr(self.node, attr)
 854                        if factory.curse_material in materials:
 855                            setattr(
 856                                self.node,
 857                                attr,
 858                                tuple(
 859                                    m
 860                                    for m in materials
 861                                    if m != factory.curse_material
 862                                ),
 863                            )
 864                    self.node.curse_death_time = 0
 865                self.hitpoints = self.hitpoints_max
 866                self._flash_billboard(PowerupBoxFactory.get().tex_health)
 867                self.node.hurt = 0
 868                self._last_hit_time = None
 869                self._num_times_hit = 0
 870
 871            self.node.handlemessage('flash')
 872            if msg.sourcenode:
 873                msg.sourcenode.handlemessage(ba.PowerupAcceptMessage())
 874            return True
 875
 876        elif isinstance(msg, ba.FreezeMessage):
 877            if not self.node:
 878                return None
 879            if self.node.invincible:
 880                ba.playsound(
 881                    SpazFactory.get().block_sound,
 882                    1.0,
 883                    position=self.node.position,
 884                )
 885                return None
 886            if self.shield:
 887                return None
 888            if not self.frozen:
 889                self.frozen = True
 890                self.node.frozen = True
 891                ba.timer(5.0, ba.WeakCall(self.handlemessage, ba.ThawMessage()))
 892                # Instantly shatter if we're already dead.
 893                # (otherwise its hard to tell we're dead)
 894                if self.hitpoints <= 0:
 895                    self.shatter()
 896
 897        elif isinstance(msg, ba.ThawMessage):
 898            if self.frozen and not self.shattered and self.node:
 899                self.frozen = False
 900                self.node.frozen = False
 901
 902        elif isinstance(msg, ba.HitMessage):
 903            if not self.node:
 904                return None
 905            if self.node.invincible:
 906                ba.playsound(
 907                    SpazFactory.get().block_sound,
 908                    1.0,
 909                    position=self.node.position,
 910                )
 911                return True
 912
 913            # If we were recently hit, don't count this as another.
 914            # (so punch flurries and bomb pileups essentially count as 1 hit)
 915            local_time = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 916            assert isinstance(local_time, int)
 917            if (
 918                self._last_hit_time is None
 919                or local_time - self._last_hit_time > 1000
 920            ):
 921                self._num_times_hit += 1
 922                self._last_hit_time = local_time
 923
 924            mag = msg.magnitude * self.impact_scale
 925            velocity_mag = msg.velocity_magnitude * self.impact_scale
 926            damage_scale = 0.22
 927
 928            # If they've got a shield, deliver it to that instead.
 929            if self.shield:
 930                if msg.flat_damage:
 931                    damage = msg.flat_damage * self.impact_scale
 932                else:
 933                    # Hit our spaz with an impulse but tell it to only return
 934                    # theoretical damage; not apply the impulse.
 935                    assert msg.force_direction is not None
 936                    self.node.handlemessage(
 937                        'impulse',
 938                        msg.pos[0],
 939                        msg.pos[1],
 940                        msg.pos[2],
 941                        msg.velocity[0],
 942                        msg.velocity[1],
 943                        msg.velocity[2],
 944                        mag,
 945                        velocity_mag,
 946                        msg.radius,
 947                        1,
 948                        msg.force_direction[0],
 949                        msg.force_direction[1],
 950                        msg.force_direction[2],
 951                    )
 952                    damage = damage_scale * self.node.damage
 953
 954                assert self.shield_hitpoints is not None
 955                self.shield_hitpoints -= int(damage)
 956                self.shield.hurt = (
 957                    1.0
 958                    - float(self.shield_hitpoints) / self.shield_hitpoints_max
 959                )
 960
 961                # Its a cleaner event if a hit just kills the shield
 962                # without damaging the player.
 963                # However, massive damage events should still be able to
 964                # damage the player. This hopefully gives us a happy medium.
 965                max_spillover = SpazFactory.get().max_shield_spillover_damage
 966                if self.shield_hitpoints <= 0:
 967
 968                    # FIXME: Transition out perhaps?
 969                    self.shield.delete()
 970                    self.shield = None
 971                    ba.playsound(
 972                        SpazFactory.get().shield_down_sound,
 973                        1.0,
 974                        position=self.node.position,
 975                    )
 976
 977                    # Emit some cool looking sparks when the shield dies.
 978                    npos = self.node.position
 979                    ba.emitfx(
 980                        position=(npos[0], npos[1] + 0.9, npos[2]),
 981                        velocity=self.node.velocity,
 982                        count=random.randrange(20, 30),
 983                        scale=1.0,
 984                        spread=0.6,
 985                        chunk_type='spark',
 986                    )
 987
 988                else:
 989                    ba.playsound(
 990                        SpazFactory.get().shield_hit_sound,
 991                        0.5,
 992                        position=self.node.position,
 993                    )
 994
 995                # Emit some cool looking sparks on shield hit.
 996                assert msg.force_direction is not None
 997                ba.emitfx(
 998                    position=msg.pos,
 999                    velocity=(
1000                        msg.force_direction[0] * 1.0,
1001                        msg.force_direction[1] * 1.0,
1002                        msg.force_direction[2] * 1.0,
1003                    ),
1004                    count=min(30, 5 + int(damage * 0.005)),
1005                    scale=0.5,
1006                    spread=0.3,
1007                    chunk_type='spark',
1008                )
1009
1010                # If they passed our spillover threshold,
1011                # pass damage along to spaz.
1012                if self.shield_hitpoints <= -max_spillover:
1013                    leftover_damage = -max_spillover - self.shield_hitpoints
1014                    shield_leftover_ratio = leftover_damage / damage
1015
1016                    # Scale down the magnitudes applied to spaz accordingly.
1017                    mag *= shield_leftover_ratio
1018                    velocity_mag *= shield_leftover_ratio
1019                else:
1020                    return True  # Good job shield!
1021            else:
1022                shield_leftover_ratio = 1.0
1023
1024            if msg.flat_damage:
1025                damage = int(
1026                    msg.flat_damage * self.impact_scale * shield_leftover_ratio
1027                )
1028            else:
1029                # Hit it with an impulse and get the resulting damage.
1030                assert msg.force_direction is not None
1031                self.node.handlemessage(
1032                    'impulse',
1033                    msg.pos[0],
1034                    msg.pos[1],
1035                    msg.pos[2],
1036                    msg.velocity[0],
1037                    msg.velocity[1],
1038                    msg.velocity[2],
1039                    mag,
1040                    velocity_mag,
1041                    msg.radius,
1042                    0,
1043                    msg.force_direction[0],
1044                    msg.force_direction[1],
1045                    msg.force_direction[2],
1046                )
1047
1048                damage = int(damage_scale * self.node.damage)
1049            self.node.handlemessage('hurt_sound')
1050
1051            # Play punch impact sound based on damage if it was a punch.
1052            if msg.hit_type == 'punch':
1053                self.on_punched(damage)
1054
1055                # If damage was significant, lets show it.
1056                if damage >= 350:
1057                    assert msg.force_direction is not None
1058                    ba.show_damage_count(
1059                        '-' + str(int(damage / 10)) + '%',
1060                        msg.pos,
1061                        msg.force_direction,
1062                    )
1063
1064                # Let's always add in a super-punch sound with boxing
1065                # gloves just to differentiate them.
1066                if msg.hit_subtype == 'super_punch':
1067                    ba.playsound(
1068                        SpazFactory.get().punch_sound_stronger,
1069                        1.0,
1070                        position=self.node.position,
1071                    )
1072                if damage >= 500:
1073                    sounds = SpazFactory.get().punch_sound_strong
1074                    sound = sounds[random.randrange(len(sounds))]
1075                elif damage >= 100:
1076                    sound = SpazFactory.get().punch_sound
1077                else:
1078                    sound = SpazFactory.get().punch_sound_weak
1079                ba.playsound(sound, 1.0, position=self.node.position)
1080
1081                # Throw up some chunks.
1082                assert msg.force_direction is not None
1083                ba.emitfx(
1084                    position=msg.pos,
1085                    velocity=(
1086                        msg.force_direction[0] * 0.5,
1087                        msg.force_direction[1] * 0.5,
1088                        msg.force_direction[2] * 0.5,
1089                    ),
1090                    count=min(10, 1 + int(damage * 0.0025)),
1091                    scale=0.3,
1092                    spread=0.03,
1093                )
1094
1095                ba.emitfx(
1096                    position=msg.pos,
1097                    chunk_type='sweat',
1098                    velocity=(
1099                        msg.force_direction[0] * 1.3,
1100                        msg.force_direction[1] * 1.3 + 5.0,
1101                        msg.force_direction[2] * 1.3,
1102                    ),
1103                    count=min(30, 1 + int(damage * 0.04)),
1104                    scale=0.9,
1105                    spread=0.28,
1106                )
1107
1108                # Momentary flash.
1109                hurtiness = damage * 0.003
1110                punchpos = (
1111                    msg.pos[0] + msg.force_direction[0] * 0.02,
1112                    msg.pos[1] + msg.force_direction[1] * 0.02,
1113                    msg.pos[2] + msg.force_direction[2] * 0.02,
1114                )
1115                flash_color = (1.0, 0.8, 0.4)
1116                light = ba.newnode(
1117                    'light',
1118                    attrs={
1119                        'position': punchpos,
1120                        'radius': 0.12 + hurtiness * 0.12,
1121                        'intensity': 0.3 * (1.0 + 1.0 * hurtiness),
1122                        'height_attenuated': False,
1123                        'color': flash_color,
1124                    },
1125                )
1126                ba.timer(0.06, light.delete)
1127
1128                flash = ba.newnode(
1129                    'flash',
1130                    attrs={
1131                        'position': punchpos,
1132                        'size': 0.17 + 0.17 * hurtiness,
1133                        'color': flash_color,
1134                    },
1135                )
1136                ba.timer(0.06, flash.delete)
1137
1138            if msg.hit_type == 'impact':
1139                assert msg.force_direction is not None
1140                ba.emitfx(
1141                    position=msg.pos,
1142                    velocity=(
1143                        msg.force_direction[0] * 2.0,
1144                        msg.force_direction[1] * 2.0,
1145                        msg.force_direction[2] * 2.0,
1146                    ),
1147                    count=min(10, 1 + int(damage * 0.01)),
1148                    scale=0.4,
1149                    spread=0.1,
1150                )
1151            if self.hitpoints > 0:
1152
1153                # It's kinda crappy to die from impacts, so lets reduce
1154                # impact damage by a reasonable amount *if* it'll keep us alive
1155                if msg.hit_type == 'impact' and damage > self.hitpoints:
1156                    # Drop damage to whatever puts us at 10 hit points,
1157                    # or 200 less than it used to be whichever is greater
1158                    # (so it *can* still kill us if its high enough)
1159                    newdamage = max(damage - 200, self.hitpoints - 10)
1160                    damage = newdamage
1161                self.node.handlemessage('flash')
1162
1163                # If we're holding something, drop it.
1164                if damage > 0.0 and self.node.hold_node:
1165                    self.node.hold_node = None
1166                self.hitpoints -= damage
1167                self.node.hurt = (
1168                    1.0 - float(self.hitpoints) / self.hitpoints_max
1169                )
1170
1171                # If we're cursed, *any* damage blows us up.
1172                if self._cursed and damage > 0:
1173                    ba.timer(
1174                        0.05,
1175                        ba.WeakCall(
1176                            self.curse_explode, msg.get_source_player(ba.Player)
1177                        ),
1178                    )
1179
1180                # If we're frozen, shatter.. otherwise die if we hit zero
1181                if self.frozen and (damage > 200 or self.hitpoints <= 0):
1182                    self.shatter()
1183                elif self.hitpoints <= 0:
1184                    self.node.handlemessage(
1185                        ba.DieMessage(how=ba.DeathType.IMPACT)
1186                    )
1187
1188            # If we're dead, take a look at the smoothed damage value
1189            # (which gives us a smoothed average of recent damage) and shatter
1190            # us if its grown high enough.
1191            if self.hitpoints <= 0:
1192                damage_avg = self.node.damage_smoothed * damage_scale
1193                if damage_avg >= 1000:
1194                    self.shatter()
1195
1196        elif isinstance(msg, BombDiedMessage):
1197            self.bomb_count += 1
1198
1199        elif isinstance(msg, ba.DieMessage):
1200            wasdead = self._dead
1201            self._dead = True
1202            self.hitpoints = 0
1203            if msg.immediate:
1204                if self.node:
1205                    self.node.delete()
1206            elif self.node:
1207                self.node.hurt = 1.0
1208                if self.play_big_death_sound and not wasdead:
1209                    ba.playsound(SpazFactory.get().single_player_death_sound)
1210                self.node.dead = True
1211                ba.timer(2.0, self.node.delete)
1212
1213        elif isinstance(msg, ba.OutOfBoundsMessage):
1214            # By default we just die here.
1215            self.handlemessage(ba.DieMessage(how=ba.DeathType.FALL))
1216
1217        elif isinstance(msg, ba.StandMessage):
1218            self._last_stand_pos = (
1219                msg.position[0],
1220                msg.position[1],
1221                msg.position[2],
1222            )
1223            if self.node:
1224                self.node.handlemessage(
1225                    'stand',
1226                    msg.position[0],
1227                    msg.position[1],
1228                    msg.position[2],
1229                    msg.angle,
1230                )
1231
1232        elif isinstance(msg, CurseExplodeMessage):
1233            self.curse_explode()
1234
1235        elif isinstance(msg, PunchHitMessage):
1236            if not self.node:
1237                return None
1238            node = ba.getcollision().opposingnode
1239
1240            # Only allow one hit per node per punch.
1241            if node and (node not in self._punched_nodes):
1242
1243                punch_momentum_angular = (
1244                    self.node.punch_momentum_angular * self._punch_power_scale
1245                )
1246                punch_power = self.node.punch_power * self._punch_power_scale
1247
1248                # Ok here's the deal:  we pass along our base velocity for use
1249                # in the impulse damage calculations since that is a more
1250                # predictable value than our fist velocity, which is rather
1251                # erratic. However, we want to actually apply force in the
1252                # direction our fist is moving so it looks better. So we still
1253                # pass that along as a direction. Perhaps a time-averaged
1254                # fist-velocity would work too?.. perhaps should try that.
1255
1256                # If its something besides another spaz, just do a muffled
1257                # punch sound.
1258                if node.getnodetype() != 'spaz':
1259                    sounds = SpazFactory.get().impact_sounds_medium
1260                    sound = sounds[random.randrange(len(sounds))]
1261                    ba.playsound(sound, 1.0, position=self.node.position)
1262
1263                ppos = self.node.punch_position
1264                punchdir = self.node.punch_velocity
1265                vel = self.node.punch_momentum_linear
1266
1267                self._punched_nodes.add(node)
1268                node.handlemessage(
1269                    ba.HitMessage(
1270                        pos=ppos,
1271                        velocity=vel,
1272                        magnitude=punch_power * punch_momentum_angular * 110.0,
1273                        velocity_magnitude=punch_power * 40,
1274                        radius=0,
1275                        srcnode=self.node,
1276                        source_player=self.source_player,
1277                        force_direction=punchdir,
1278                        hit_type='punch',
1279                        hit_subtype=(
1280                            'super_punch'
1281                            if self._has_boxing_gloves
1282                            else 'default'
1283                        ),
1284                    )
1285                )
1286
1287                # Also apply opposite to ourself for the first punch only.
1288                # This is given as a constant force so that it is more
1289                # noticeable for slower punches where it matters. For fast
1290                # awesome looking punches its ok if we punch 'through'
1291                # the target.
1292                mag = -400.0
1293                if self._hockey:
1294                    mag *= 0.5
1295                if len(self._punched_nodes) == 1:
1296                    self.node.handlemessage(
1297                        'kick_back',
1298                        ppos[0],
1299                        ppos[1],
1300                        ppos[2],
1301                        punchdir[0],
1302                        punchdir[1],
1303                        punchdir[2],
1304                        mag,
1305                    )
1306        elif isinstance(msg, PickupMessage):
1307            if not self.node:
1308                return None
1309
1310            try:
1311                collision = ba.getcollision()
1312                opposingnode = collision.opposingnode
1313                opposingbody = collision.opposingbody
1314            except ba.NotFoundError:
1315                return True
1316
1317            # Don't allow picking up of invincible dudes.
1318            try:
1319                if opposingnode.invincible:
1320                    return True
1321            except Exception:
1322                pass
1323
1324            # If we're grabbing the pelvis of a non-shattered spaz, we wanna
1325            # grab the torso instead.
1326            if (
1327                opposingnode.getnodetype() == 'spaz'
1328                and not opposingnode.shattered
1329                and opposingbody == 4
1330            ):
1331                opposingbody = 1
1332
1333            # Special case - if we're holding a flag, don't replace it
1334            # (hmm - should make this customizable or more low level).
1335            held = self.node.hold_node
1336            if held and held.getnodetype() == 'flag':
1337                return True
1338
1339            # Note: hold_body needs to be set before hold_node.
1340            self.node.hold_body = opposingbody
1341            self.node.hold_node = opposingnode
1342        elif isinstance(msg, ba.CelebrateMessage):
1343            if self.node:
1344                self.node.handlemessage('celebrate', int(msg.duration * 1000))
1345
1346        else:
1347            return super().handlemessage(msg)
1348        return None
1349
1350    def drop_bomb(self) -> stdbomb.Bomb | None:
1351        """
1352        Tell the spaz to drop one of his bombs, and returns
1353        the resulting bomb object.
1354        If the spaz has no bombs or is otherwise unable to
1355        drop a bomb, returns None.
1356        """
1357
1358        if (self.land_mine_count <= 0 and self.bomb_count <= 0) or self.frozen:
1359            return None
1360        assert self.node
1361        pos = self.node.position_forward
1362        vel = self.node.velocity
1363
1364        if self.land_mine_count > 0:
1365            dropping_bomb = False
1366            self.set_land_mine_count(self.land_mine_count - 1)
1367            bomb_type = 'land_mine'
1368        else:
1369            dropping_bomb = True
1370            bomb_type = self.bomb_type
1371
1372        bomb = stdbomb.Bomb(
1373            position=(pos[0], pos[1] - 0.0, pos[2]),
1374            velocity=(vel[0], vel[1], vel[2]),
1375            bomb_type=bomb_type,
1376            blast_radius=self.blast_radius,
1377            source_player=self.source_player,
1378            owner=self.node,
1379        ).autoretain()
1380
1381        assert bomb.node
1382        if dropping_bomb:
1383            self.bomb_count -= 1
1384            bomb.node.add_death_action(
1385                ba.WeakCall(self.handlemessage, BombDiedMessage())
1386            )
1387        self._pick_up(bomb.node)
1388
1389        for clb in self._dropped_bomb_callbacks:
1390            clb(self, bomb)
1391
1392        return bomb
1393
1394    def _pick_up(self, node: ba.Node) -> None:
1395        if self.node:
1396            # Note: hold_body needs to be set before hold_node.
1397            self.node.hold_body = 0
1398            self.node.hold_node = node
1399
1400    def set_land_mine_count(self, count: int) -> None:
1401        """Set the number of land-mines this spaz is carrying."""
1402        self.land_mine_count = count
1403        if self.node:
1404            if self.land_mine_count != 0:
1405                self.node.counter_text = 'x' + str(self.land_mine_count)
1406                self.node.counter_texture = (
1407                    PowerupBoxFactory.get().tex_land_mines
1408                )
1409            else:
1410                self.node.counter_text = ''
1411
1412    def curse_explode(self, source_player: ba.Player | None = None) -> None:
1413        """Explode the poor spaz spectacularly."""
1414        if self._cursed and self.node:
1415            self.shatter(extreme=True)
1416            self.handlemessage(ba.DieMessage())
1417            activity = self._activity()
1418            if activity:
1419                stdbomb.Blast(
1420                    position=self.node.position,
1421                    velocity=self.node.velocity,
1422                    blast_radius=3.0,
1423                    blast_type='normal',
1424                    source_player=(
1425                        source_player if source_player else self.source_player
1426                    ),
1427                ).autoretain()
1428            self._cursed = False
1429
1430    def shatter(self, extreme: bool = False) -> None:
1431        """Break the poor spaz into little bits."""
1432        if self.shattered:
1433            return
1434        self.shattered = True
1435        assert self.node
1436        if self.frozen:
1437            # Momentary flash of light.
1438            light = ba.newnode(
1439                'light',
1440                attrs={
1441                    'position': self.node.position,
1442                    'radius': 0.5,
1443                    'height_attenuated': False,
1444                    'color': (0.8, 0.8, 1.0),
1445                },
1446            )
1447
1448            ba.animate(
1449                light, 'intensity', {0.0: 3.0, 0.04: 0.5, 0.08: 0.07, 0.3: 0}
1450            )
1451            ba.timer(0.3, light.delete)
1452
1453            # Emit ice chunks.
1454            ba.emitfx(
1455                position=self.node.position,
1456                velocity=self.node.velocity,
1457                count=int(random.random() * 10.0 + 10.0),
1458                scale=0.6,
1459                spread=0.2,
1460                chunk_type='ice',
1461            )
1462            ba.emitfx(
1463                position=self.node.position,
1464                velocity=self.node.velocity,
1465                count=int(random.random() * 10.0 + 10.0),
1466                scale=0.3,
1467                spread=0.2,
1468                chunk_type='ice',
1469            )
1470            ba.playsound(
1471                SpazFactory.get().shatter_sound,
1472                1.0,
1473                position=self.node.position,
1474            )
1475        else:
1476            ba.playsound(
1477                SpazFactory.get().splatter_sound,
1478                1.0,
1479                position=self.node.position,
1480            )
1481        self.handlemessage(ba.DieMessage())
1482        self.node.shattered = 2 if extreme else 1
1483
1484    def _hit_self(self, intensity: float) -> None:
1485        if not self.node:
1486            return
1487        pos = self.node.position
1488        self.handlemessage(
1489            ba.HitMessage(
1490                flat_damage=50.0 * intensity,
1491                pos=pos,
1492                force_direction=self.node.velocity,
1493                hit_type='impact',
1494            )
1495        )
1496        self.node.handlemessage('knockout', max(0.0, 50.0 * intensity))
1497        sounds: Sequence[ba.Sound]
1498        if intensity >= 5.0:
1499            sounds = SpazFactory.get().impact_sounds_harder
1500        elif intensity >= 3.0:
1501            sounds = SpazFactory.get().impact_sounds_hard
1502        else:
1503            sounds = SpazFactory.get().impact_sounds_medium
1504        sound = sounds[random.randrange(len(sounds))]
1505        ba.playsound(sound, position=pos, volume=5.0)
1506
1507    def _get_bomb_type_tex(self) -> ba.Texture:
1508        factory = PowerupBoxFactory.get()
1509        if self.bomb_type == 'sticky':
1510            return factory.tex_sticky_bombs
1511        if self.bomb_type == 'ice':
1512            return factory.tex_ice_bombs
1513        if self.bomb_type == 'impact':
1514            return factory.tex_impact_bombs
1515        raise ValueError('invalid bomb type')
1516
1517    def _flash_billboard(self, tex: ba.Texture) -> None:
1518        assert self.node
1519        self.node.billboard_texture = tex
1520        self.node.billboard_cross_out = False
1521        ba.animate(
1522            self.node,
1523            'billboard_opacity',
1524            {0.0: 0.0, 0.1: 1.0, 0.4: 1.0, 0.5: 0.0},
1525        )
1526
1527    def set_bomb_count(self, count: int) -> None:
1528        """Sets the number of bombs this Spaz has."""
1529        # We can't just set bomb_count because some bombs may be laid currently
1530        # so we have to do a relative diff based on max.
1531        diff = count - self._max_bomb_count
1532        self._max_bomb_count += diff
1533        self.bomb_count += diff
1534
1535    def _gloves_wear_off_flash(self) -> None:
1536        if self.node:
1537            self.node.boxing_gloves_flashing = True
1538            self.node.billboard_texture = PowerupBoxFactory.get().tex_punch
1539            self.node.billboard_opacity = 1.0
1540            self.node.billboard_cross_out = True
1541
1542    def _gloves_wear_off(self) -> None:
1543        if self._demo_mode:  # Preserve old behavior.
1544            self._punch_power_scale = 1.2
1545            self._punch_cooldown = BASE_PUNCH_COOLDOWN
1546        else:
1547            factory = SpazFactory.get()
1548            self._punch_power_scale = factory.punch_power_scale
1549            self._punch_cooldown = factory.punch_cooldown
1550        self._has_boxing_gloves = False
1551        if self.node:
1552            ba.playsound(
1553                PowerupBoxFactory.get().powerdown_sound,
1554                position=self.node.position,
1555            )
1556            self.node.boxing_gloves = False
1557            self.node.billboard_opacity = 0.0
1558
1559    def _multi_bomb_wear_off_flash(self) -> None:
1560        if self.node:
1561            self.node.billboard_texture = PowerupBoxFactory.get().tex_bomb
1562            self.node.billboard_opacity = 1.0
1563            self.node.billboard_cross_out = True
1564
1565    def _multi_bomb_wear_off(self) -> None:
1566        self.set_bomb_count(self.default_bomb_count)
1567        if self.node:
1568            ba.playsound(
1569                PowerupBoxFactory.get().powerdown_sound,
1570                position=self.node.position,
1571            )
1572            self.node.billboard_opacity = 0.0
1573
1574    def _bomb_wear_off_flash(self) -> None:
1575        if self.node:
1576            self.node.billboard_texture = self._get_bomb_type_tex()
1577            self.node.billboard_opacity = 1.0
1578            self.node.billboard_cross_out = True
1579
1580    def _bomb_wear_off(self) -> None:
1581        self.bomb_type = self.bomb_type_default
1582        if self.node:
1583            ba.playsound(
1584                PowerupBoxFactory.get().powerdown_sound,
1585                position=self.node.position,
1586            )
1587            self.node.billboard_opacity = 0.0
class PickupMessage:
25class PickupMessage:
26    """We wanna pick something up."""

We wanna pick something up.

PickupMessage()
class PunchHitMessage:
29class PunchHitMessage:
30    """Message saying an object was hit."""

Message saying an object was hit.

PunchHitMessage()
class CurseExplodeMessage:
33class CurseExplodeMessage:
34    """We are cursed and should blow up now."""

We are cursed and should blow up now.

CurseExplodeMessage()
class BombDiedMessage:
37class BombDiedMessage:
38    """A bomb has died and thus can be recycled."""

A bomb has died and thus can be recycled.

BombDiedMessage()
class Spaz(ba._actor.Actor):
  41class Spaz(ba.Actor):
  42    """
  43    Base class for various Spazzes.
  44
  45    Category: **Gameplay Classes**
  46
  47    A Spaz is the standard little humanoid character in the game.
  48    It can be controlled by a player or by AI, and can have
  49    various different appearances.  The name 'Spaz' is not to be
  50    confused with the 'Spaz' character in the game, which is just
  51    one of the skins available for instances of this class.
  52    """
  53
  54    # pylint: disable=too-many-public-methods
  55    # pylint: disable=too-many-locals
  56
  57    node: ba.Node
  58    """The 'spaz' ba.Node."""
  59
  60    points_mult = 1
  61    curse_time: float | None = 5.0
  62    default_bomb_count = 1
  63    default_bomb_type = 'normal'
  64    default_boxing_gloves = False
  65    default_shields = False
  66
  67    def __init__(
  68        self,
  69        color: Sequence[float] = (1.0, 1.0, 1.0),
  70        highlight: Sequence[float] = (0.5, 0.5, 0.5),
  71        character: str = 'Spaz',
  72        source_player: ba.Player | None = None,
  73        start_invincible: bool = True,
  74        can_accept_powerups: bool = True,
  75        powerups_expire: bool = False,
  76        demo_mode: bool = False,
  77    ):
  78        """Create a spaz with the requested color, character, etc."""
  79        # pylint: disable=too-many-statements
  80
  81        super().__init__()
  82        shared = SharedObjects.get()
  83        activity = self.activity
  84
  85        factory = SpazFactory.get()
  86
  87        # We need to behave slightly different in the tutorial.
  88        self._demo_mode = demo_mode
  89
  90        self.play_big_death_sound = False
  91
  92        # Scales how much impacts affect us (most damage calcs).
  93        self.impact_scale = 1.0
  94
  95        self.source_player = source_player
  96        self._dead = False
  97        if self._demo_mode:  # Preserve old behavior.
  98            self._punch_power_scale = 1.2
  99        else:
 100            self._punch_power_scale = factory.punch_power_scale
 101        self.fly = ba.getactivity().globalsnode.happy_thoughts_mode
 102        if isinstance(activity, ba.GameActivity):
 103            self._hockey = activity.map.is_hockey
 104        else:
 105            self._hockey = False
 106        self._punched_nodes: set[ba.Node] = set()
 107        self._cursed = False
 108        self._connected_to_player: ba.Player | None = None
 109        materials = [
 110            factory.spaz_material,
 111            shared.object_material,
 112            shared.player_material,
 113        ]
 114        roller_materials = [factory.roller_material, shared.player_material]
 115        extras_material = []
 116
 117        if can_accept_powerups:
 118            pam = PowerupBoxFactory.get().powerup_accept_material
 119            materials.append(pam)
 120            roller_materials.append(pam)
 121            extras_material.append(pam)
 122
 123        media = factory.get_media(character)
 124        punchmats = (factory.punch_material, shared.attack_material)
 125        pickupmats = (factory.pickup_material, shared.pickup_material)
 126        self.node: ba.Node = ba.newnode(
 127            type='spaz',
 128            delegate=self,
 129            attrs={
 130                'color': color,
 131                'behavior_version': 0 if demo_mode else 1,
 132                'demo_mode': demo_mode,
 133                'highlight': highlight,
 134                'jump_sounds': media['jump_sounds'],
 135                'attack_sounds': media['attack_sounds'],
 136                'impact_sounds': media['impact_sounds'],
 137                'death_sounds': media['death_sounds'],
 138                'pickup_sounds': media['pickup_sounds'],
 139                'fall_sounds': media['fall_sounds'],
 140                'color_texture': media['color_texture'],
 141                'color_mask_texture': media['color_mask_texture'],
 142                'head_model': media['head_model'],
 143                'torso_model': media['torso_model'],
 144                'pelvis_model': media['pelvis_model'],
 145                'upper_arm_model': media['upper_arm_model'],
 146                'forearm_model': media['forearm_model'],
 147                'hand_model': media['hand_model'],
 148                'upper_leg_model': media['upper_leg_model'],
 149                'lower_leg_model': media['lower_leg_model'],
 150                'toes_model': media['toes_model'],
 151                'style': factory.get_style(character),
 152                'fly': self.fly,
 153                'hockey': self._hockey,
 154                'materials': materials,
 155                'roller_materials': roller_materials,
 156                'extras_material': extras_material,
 157                'punch_materials': punchmats,
 158                'pickup_materials': pickupmats,
 159                'invincible': start_invincible,
 160                'source_player': source_player,
 161            },
 162        )
 163        self.shield: ba.Node | None = None
 164
 165        if start_invincible:
 166
 167            def _safesetattr(node: ba.Node | None, attr: str, val: Any) -> None:
 168                if node:
 169                    setattr(node, attr, val)
 170
 171            ba.timer(1.0, ba.Call(_safesetattr, self.node, 'invincible', False))
 172        self.hitpoints = 1000
 173        self.hitpoints_max = 1000
 174        self.shield_hitpoints: int | None = None
 175        self.shield_hitpoints_max = 650
 176        self.shield_decay_rate = 0
 177        self.shield_decay_timer: ba.Timer | None = None
 178        self._boxing_gloves_wear_off_timer: ba.Timer | None = None
 179        self._boxing_gloves_wear_off_flash_timer: ba.Timer | None = None
 180        self._bomb_wear_off_timer: ba.Timer | None = None
 181        self._bomb_wear_off_flash_timer: ba.Timer | None = None
 182        self._multi_bomb_wear_off_timer: ba.Timer | None = None
 183        self._multi_bomb_wear_off_flash_timer: ba.Timer | None = None
 184        self._curse_timer: ba.Timer | None = None
 185        self.bomb_count = self.default_bomb_count
 186        self._max_bomb_count = self.default_bomb_count
 187        self.bomb_type_default = self.default_bomb_type
 188        self.bomb_type = self.bomb_type_default
 189        self.land_mine_count = 0
 190        self.blast_radius = 2.0
 191        self.powerups_expire = powerups_expire
 192        if self._demo_mode:  # preserve old behavior
 193            self._punch_cooldown = BASE_PUNCH_COOLDOWN
 194        else:
 195            self._punch_cooldown = factory.punch_cooldown
 196        self._jump_cooldown = 250
 197        self._pickup_cooldown = 0
 198        self._bomb_cooldown = 0
 199        self._has_boxing_gloves = False
 200        if self.default_boxing_gloves:
 201            self.equip_boxing_gloves()
 202        self.last_punch_time_ms = -9999
 203        self.last_pickup_time_ms = -9999
 204        self.last_jump_time_ms = -9999
 205        self.last_run_time_ms = -9999
 206        self._last_run_value = 0.0
 207        self.last_bomb_time_ms = -9999
 208        self._turbo_filter_times: dict[str, int] = {}
 209        self._turbo_filter_time_bucket = 0
 210        self._turbo_filter_counts: dict[str, int] = {}
 211        self.frozen = False
 212        self.shattered = False
 213        self._last_hit_time: int | None = None
 214        self._num_times_hit = 0
 215        self._bomb_held = False
 216        if self.default_shields:
 217            self.equip_shields()
 218        self._dropped_bomb_callbacks: list[Callable[[Spaz, ba.Actor], Any]] = []
 219
 220        self._score_text: ba.Node | None = None
 221        self._score_text_hide_timer: ba.Timer | None = None
 222        self._last_stand_pos: Sequence[float] | None = None
 223
 224        # Deprecated stuff.. should make these into lists.
 225        self.punch_callback: Callable[[Spaz], Any] | None = None
 226        self.pick_up_powerup_callback: Callable[[Spaz], Any] | None = None
 227
 228    def exists(self) -> bool:
 229        return bool(self.node)
 230
 231    def on_expire(self) -> None:
 232        super().on_expire()
 233
 234        # Release callbacks/refs so we don't wind up with dependency loops.
 235        self._dropped_bomb_callbacks = []
 236        self.punch_callback = None
 237        self.pick_up_powerup_callback = None
 238
 239    def add_dropped_bomb_callback(
 240        self, call: Callable[[Spaz, ba.Actor], Any]
 241    ) -> None:
 242        """
 243        Add a call to be run whenever this Spaz drops a bomb.
 244        The spaz and the newly-dropped bomb are passed as arguments.
 245        """
 246        assert not self.expired
 247        self._dropped_bomb_callbacks.append(call)
 248
 249    def is_alive(self) -> bool:
 250        """
 251        Method override; returns whether ol' spaz is still kickin'.
 252        """
 253        return not self._dead
 254
 255    def _hide_score_text(self) -> None:
 256        if self._score_text:
 257            assert isinstance(self._score_text.scale, float)
 258            ba.animate(
 259                self._score_text,
 260                'scale',
 261                {0.0: self._score_text.scale, 0.2: 0.0},
 262            )
 263
 264    def _turbo_filter_add_press(self, source: str) -> None:
 265        """
 266        Can pass all button presses through here; if we see an obscene number
 267        of them in a short time let's shame/pushish this guy for using turbo.
 268        """
 269        t_ms = ba.time(
 270            timetype=ba.TimeType.BASE, timeformat=ba.TimeFormat.MILLISECONDS
 271        )
 272        assert isinstance(t_ms, int)
 273        t_bucket = int(t_ms / 1000)
 274        if t_bucket == self._turbo_filter_time_bucket:
 275            # Add only once per timestep (filter out buttons triggering
 276            # multiple actions).
 277            if t_ms != self._turbo_filter_times.get(source, 0):
 278                self._turbo_filter_counts[source] = (
 279                    self._turbo_filter_counts.get(source, 0) + 1
 280                )
 281                self._turbo_filter_times[source] = t_ms
 282                # (uncomment to debug; prints what this count is at)
 283                # ba.screenmessage( str(source) + " "
 284                #                   + str(self._turbo_filter_counts[source]))
 285                if self._turbo_filter_counts[source] == 15:
 286                    # Knock 'em out.  That'll learn 'em.
 287                    assert self.node
 288                    self.node.handlemessage('knockout', 500.0)
 289
 290                    # Also issue periodic notices about who is turbo-ing.
 291                    now = ba.time(ba.TimeType.REAL)
 292                    if now > ba.app.last_spaz_turbo_warn_time + 30.0:
 293                        ba.app.last_spaz_turbo_warn_time = now
 294                        ba.screenmessage(
 295                            ba.Lstr(
 296                                translate=(
 297                                    'statements',
 298                                    (
 299                                        'Warning to ${NAME}:  '
 300                                        'turbo / button-spamming knocks'
 301                                        ' you out.'
 302                                    ),
 303                                ),
 304                                subs=[('${NAME}', self.node.name)],
 305                            ),
 306                            color=(1, 0.5, 0),
 307                        )
 308                        ba.playsound(ba.getsound('error'))
 309        else:
 310            self._turbo_filter_times = {}
 311            self._turbo_filter_time_bucket = t_bucket
 312            self._turbo_filter_counts = {source: 1}
 313
 314    def set_score_text(
 315        self,
 316        text: str | ba.Lstr,
 317        color: Sequence[float] = (1.0, 1.0, 0.4),
 318        flash: bool = False,
 319    ) -> None:
 320        """
 321        Utility func to show a message momentarily over our spaz that follows
 322        him around; Handy for score updates and things.
 323        """
 324        color_fin = ba.safecolor(color)[:3]
 325        if not self.node:
 326            return
 327        if not self._score_text:
 328            start_scale = 0.0
 329            mnode = ba.newnode(
 330                'math',
 331                owner=self.node,
 332                attrs={'input1': (0, 1.4, 0), 'operation': 'add'},
 333            )
 334            self.node.connectattr('torso_position', mnode, 'input2')
 335            self._score_text = ba.newnode(
 336                'text',
 337                owner=self.node,
 338                attrs={
 339                    'text': text,
 340                    'in_world': True,
 341                    'shadow': 1.0,
 342                    'flatness': 1.0,
 343                    'color': color_fin,
 344                    'scale': 0.02,
 345                    'h_align': 'center',
 346                },
 347            )
 348            mnode.connectattr('output', self._score_text, 'position')
 349        else:
 350            self._score_text.color = color_fin
 351            assert isinstance(self._score_text.scale, float)
 352            start_scale = self._score_text.scale
 353            self._score_text.text = text
 354        if flash:
 355            combine = ba.newnode(
 356                'combine', owner=self._score_text, attrs={'size': 3}
 357            )
 358            scl = 1.8
 359            offs = 0.5
 360            tval = 0.300
 361            for i in range(3):
 362                cl1 = offs + scl * color_fin[i]
 363                cl2 = color_fin[i]
 364                ba.animate(
 365                    combine,
 366                    'input' + str(i),
 367                    {0.5 * tval: cl2, 0.75 * tval: cl1, 1.0 * tval: cl2},
 368                )
 369            combine.connectattr('output', self._score_text, 'color')
 370
 371        ba.animate(self._score_text, 'scale', {0.0: start_scale, 0.2: 0.02})
 372        self._score_text_hide_timer = ba.Timer(
 373            1.0, ba.WeakCall(self._hide_score_text)
 374        )
 375
 376    def on_jump_press(self) -> None:
 377        """
 378        Called to 'press jump' on this spaz;
 379        used by player or AI connections.
 380        """
 381        if not self.node:
 382            return
 383        t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 384        assert isinstance(t_ms, int)
 385        if t_ms - self.last_jump_time_ms >= self._jump_cooldown:
 386            self.node.jump_pressed = True
 387            self.last_jump_time_ms = t_ms
 388        self._turbo_filter_add_press('jump')
 389
 390    def on_jump_release(self) -> None:
 391        """
 392        Called to 'release jump' on this spaz;
 393        used by player or AI connections.
 394        """
 395        if not self.node:
 396            return
 397        self.node.jump_pressed = False
 398
 399    def on_pickup_press(self) -> None:
 400        """
 401        Called to 'press pick-up' on this spaz;
 402        used by player or AI connections.
 403        """
 404        if not self.node:
 405            return
 406        t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 407        assert isinstance(t_ms, int)
 408        if t_ms - self.last_pickup_time_ms >= self._pickup_cooldown:
 409            self.node.pickup_pressed = True
 410            self.last_pickup_time_ms = t_ms
 411        self._turbo_filter_add_press('pickup')
 412
 413    def on_pickup_release(self) -> None:
 414        """
 415        Called to 'release pick-up' on this spaz;
 416        used by player or AI connections.
 417        """
 418        if not self.node:
 419            return
 420        self.node.pickup_pressed = False
 421
 422    def on_hold_position_press(self) -> None:
 423        """
 424        Called to 'press hold-position' on this spaz;
 425        used for player or AI connections.
 426        """
 427        if not self.node:
 428            return
 429        self.node.hold_position_pressed = True
 430        self._turbo_filter_add_press('holdposition')
 431
 432    def on_hold_position_release(self) -> None:
 433        """
 434        Called to 'release hold-position' on this spaz;
 435        used for player or AI connections.
 436        """
 437        if not self.node:
 438            return
 439        self.node.hold_position_pressed = False
 440
 441    def on_punch_press(self) -> None:
 442        """
 443        Called to 'press punch' on this spaz;
 444        used for player or AI connections.
 445        """
 446        if not self.node or self.frozen or self.node.knockout > 0.0:
 447            return
 448        t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 449        assert isinstance(t_ms, int)
 450        if t_ms - self.last_punch_time_ms >= self._punch_cooldown:
 451            if self.punch_callback is not None:
 452                self.punch_callback(self)
 453            self._punched_nodes = set()  # Reset this.
 454            self.last_punch_time_ms = t_ms
 455            self.node.punch_pressed = True
 456            if not self.node.hold_node:
 457                ba.timer(
 458                    0.1,
 459                    ba.WeakCall(
 460                        self._safe_play_sound,
 461                        SpazFactory.get().swish_sound,
 462                        0.8,
 463                    ),
 464                )
 465        self._turbo_filter_add_press('punch')
 466
 467    def _safe_play_sound(self, sound: ba.Sound, volume: float) -> None:
 468        """Plays a sound at our position if we exist."""
 469        if self.node:
 470            ba.playsound(sound, volume, self.node.position)
 471
 472    def on_punch_release(self) -> None:
 473        """
 474        Called to 'release punch' on this spaz;
 475        used for player or AI connections.
 476        """
 477        if not self.node:
 478            return
 479        self.node.punch_pressed = False
 480
 481    def on_bomb_press(self) -> None:
 482        """
 483        Called to 'press bomb' on this spaz;
 484        used for player or AI connections.
 485        """
 486        if not self.node:
 487            return
 488
 489        if self._dead or self.frozen:
 490            return
 491        if self.node.knockout > 0.0:
 492            return
 493        t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 494        assert isinstance(t_ms, int)
 495        if t_ms - self.last_bomb_time_ms >= self._bomb_cooldown:
 496            self.last_bomb_time_ms = t_ms
 497            self.node.bomb_pressed = True
 498            if not self.node.hold_node:
 499                self.drop_bomb()
 500        self._turbo_filter_add_press('bomb')
 501
 502    def on_bomb_release(self) -> None:
 503        """
 504        Called to 'release bomb' on this spaz;
 505        used for player or AI connections.
 506        """
 507        if not self.node:
 508            return
 509        self.node.bomb_pressed = False
 510
 511    def on_run(self, value: float) -> None:
 512        """
 513        Called to 'press run' on this spaz;
 514        used for player or AI connections.
 515        """
 516        if not self.node:
 517            return
 518
 519        t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 520        assert isinstance(t_ms, int)
 521        self.last_run_time_ms = t_ms
 522        self.node.run = value
 523
 524        # filtering these events would be tough since its an analog
 525        # value, but lets still pass full 0-to-1 presses along to
 526        # the turbo filter to punish players if it looks like they're turbo-ing
 527        if self._last_run_value < 0.01 and value > 0.99:
 528            self._turbo_filter_add_press('run')
 529
 530        self._last_run_value = value
 531
 532    def on_fly_press(self) -> None:
 533        """
 534        Called to 'press fly' on this spaz;
 535        used for player or AI connections.
 536        """
 537        if not self.node:
 538            return
 539        # not adding a cooldown time here for now; slightly worried
 540        # input events get clustered up during net-games and we'd wind up
 541        # killing a lot and making it hard to fly.. should look into this.
 542        self.node.fly_pressed = True
 543        self._turbo_filter_add_press('fly')
 544
 545    def on_fly_release(self) -> None:
 546        """
 547        Called to 'release fly' on this spaz;
 548        used for player or AI connections.
 549        """
 550        if not self.node:
 551            return
 552        self.node.fly_pressed = False
 553
 554    def on_move(self, x: float, y: float) -> None:
 555        """
 556        Called to set the joystick amount for this spaz;
 557        used for player or AI connections.
 558        """
 559        if not self.node:
 560            return
 561        self.node.handlemessage('move', x, y)
 562
 563    def on_move_up_down(self, value: float) -> None:
 564        """
 565        Called to set the up/down joystick amount on this spaz;
 566        used for player or AI connections.
 567        value will be between -32768 to 32767
 568        WARNING: deprecated; use on_move instead.
 569        """
 570        if not self.node:
 571            return
 572        self.node.move_up_down = value
 573
 574    def on_move_left_right(self, value: float) -> None:
 575        """
 576        Called to set the left/right joystick amount on this spaz;
 577        used for player or AI connections.
 578        value will be between -32768 to 32767
 579        WARNING: deprecated; use on_move instead.
 580        """
 581        if not self.node:
 582            return
 583        self.node.move_left_right = value
 584
 585    def on_punched(self, damage: int) -> None:
 586        """Called when this spaz gets punched."""
 587
 588    def get_death_points(self, how: ba.DeathType) -> tuple[int, int]:
 589        """Get the points awarded for killing this spaz."""
 590        del how  # Unused.
 591        num_hits = float(max(1, self._num_times_hit))
 592
 593        # Base points is simply 10 for 1-hit-kills and 5 otherwise.
 594        importance = 2 if num_hits < 2 else 1
 595        return (10 if num_hits < 2 else 5) * self.points_mult, importance
 596
 597    def curse(self) -> None:
 598        """
 599        Give this poor spaz a curse;
 600        he will explode in 5 seconds.
 601        """
 602        if not self._cursed:
 603            factory = SpazFactory.get()
 604            self._cursed = True
 605
 606            # Add the curse material.
 607            for attr in ['materials', 'roller_materials']:
 608                materials = getattr(self.node, attr)
 609                if factory.curse_material not in materials:
 610                    setattr(
 611                        self.node, attr, materials + (factory.curse_material,)
 612                    )
 613
 614            # None specifies no time limit
 615            assert self.node
 616            if self.curse_time is None:
 617                self.node.curse_death_time = -1
 618            else:
 619                # Note: curse-death-time takes milliseconds.
 620                tval = ba.time()
 621                assert isinstance(tval, (float, int))
 622                self.node.curse_death_time = int(
 623                    1000.0 * (tval + self.curse_time)
 624                )
 625                self._curse_timer = ba.Timer(
 626                    5.0, ba.WeakCall(self.curse_explode)
 627                )
 628
 629    def equip_boxing_gloves(self) -> None:
 630        """
 631        Give this spaz some boxing gloves.
 632        """
 633        assert self.node
 634        self.node.boxing_gloves = True
 635        self._has_boxing_gloves = True
 636        if self._demo_mode:  # Preserve old behavior.
 637            self._punch_power_scale = 1.7
 638            self._punch_cooldown = 300
 639        else:
 640            factory = SpazFactory.get()
 641            self._punch_power_scale = factory.punch_power_scale_gloves
 642            self._punch_cooldown = factory.punch_cooldown_gloves
 643
 644    def equip_shields(self, decay: bool = False) -> None:
 645        """
 646        Give this spaz a nice energy shield.
 647        """
 648
 649        if not self.node:
 650            ba.print_error('Can\'t equip shields; no node.')
 651            return
 652
 653        factory = SpazFactory.get()
 654        if self.shield is None:
 655            self.shield = ba.newnode(
 656                'shield',
 657                owner=self.node,
 658                attrs={'color': (0.3, 0.2, 2.0), 'radius': 1.3},
 659            )
 660            self.node.connectattr('position_center', self.shield, 'position')
 661        self.shield_hitpoints = self.shield_hitpoints_max = 650
 662        self.shield_decay_rate = factory.shield_decay_rate if decay else 0
 663        self.shield.hurt = 0
 664        ba.playsound(factory.shield_up_sound, 1.0, position=self.node.position)
 665
 666        if self.shield_decay_rate > 0:
 667            self.shield_decay_timer = ba.Timer(
 668                0.5, ba.WeakCall(self.shield_decay), repeat=True
 669            )
 670            # So user can see the decay.
 671            self.shield.always_show_health_bar = True
 672
 673    def shield_decay(self) -> None:
 674        """Called repeatedly to decay shield HP over time."""
 675        if self.shield:
 676            assert self.shield_hitpoints is not None
 677            self.shield_hitpoints = max(
 678                0, self.shield_hitpoints - self.shield_decay_rate
 679            )
 680            assert self.shield_hitpoints is not None
 681            self.shield.hurt = (
 682                1.0 - float(self.shield_hitpoints) / self.shield_hitpoints_max
 683            )
 684            if self.shield_hitpoints <= 0:
 685                self.shield.delete()
 686                self.shield = None
 687                self.shield_decay_timer = None
 688                assert self.node
 689                ba.playsound(
 690                    SpazFactory.get().shield_down_sound,
 691                    1.0,
 692                    position=self.node.position,
 693                )
 694        else:
 695            self.shield_decay_timer = None
 696
 697    def handlemessage(self, msg: Any) -> Any:
 698        # pylint: disable=too-many-return-statements
 699        # pylint: disable=too-many-statements
 700        # pylint: disable=too-many-branches
 701        assert not self.expired
 702
 703        if isinstance(msg, ba.PickedUpMessage):
 704            if self.node:
 705                self.node.handlemessage('hurt_sound')
 706                self.node.handlemessage('picked_up')
 707
 708            # This counts as a hit.
 709            self._num_times_hit += 1
 710
 711        elif isinstance(msg, ba.ShouldShatterMessage):
 712            # Eww; seems we have to do this in a timer or it wont work right.
 713            # (since we're getting called from within update() perhaps?..)
 714            # NOTE: should test to see if that's still the case.
 715            ba.timer(0.001, ba.WeakCall(self.shatter))
 716
 717        elif isinstance(msg, ba.ImpactDamageMessage):
 718            # Eww; seems we have to do this in a timer or it wont work right.
 719            # (since we're getting called from within update() perhaps?..)
 720            ba.timer(0.001, ba.WeakCall(self._hit_self, msg.intensity))
 721
 722        elif isinstance(msg, ba.PowerupMessage):
 723            if self._dead or not self.node:
 724                return True
 725            if self.pick_up_powerup_callback is not None:
 726                self.pick_up_powerup_callback(self)
 727            if msg.poweruptype == 'triple_bombs':
 728                tex = PowerupBoxFactory.get().tex_bomb
 729                self._flash_billboard(tex)
 730                self.set_bomb_count(3)
 731                if self.powerups_expire:
 732                    self.node.mini_billboard_1_texture = tex
 733                    t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 734                    assert isinstance(t_ms, int)
 735                    self.node.mini_billboard_1_start_time = t_ms
 736                    self.node.mini_billboard_1_end_time = (
 737                        t_ms + POWERUP_WEAR_OFF_TIME
 738                    )
 739                    self._multi_bomb_wear_off_flash_timer = ba.Timer(
 740                        (POWERUP_WEAR_OFF_TIME - 2000),
 741                        ba.WeakCall(self._multi_bomb_wear_off_flash),
 742                        timeformat=ba.TimeFormat.MILLISECONDS,
 743                    )
 744                    self._multi_bomb_wear_off_timer = ba.Timer(
 745                        POWERUP_WEAR_OFF_TIME,
 746                        ba.WeakCall(self._multi_bomb_wear_off),
 747                        timeformat=ba.TimeFormat.MILLISECONDS,
 748                    )
 749            elif msg.poweruptype == 'land_mines':
 750                self.set_land_mine_count(min(self.land_mine_count + 3, 3))
 751            elif msg.poweruptype == 'impact_bombs':
 752                self.bomb_type = 'impact'
 753                tex = self._get_bomb_type_tex()
 754                self._flash_billboard(tex)
 755                if self.powerups_expire:
 756                    self.node.mini_billboard_2_texture = tex
 757                    t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 758                    assert isinstance(t_ms, int)
 759                    self.node.mini_billboard_2_start_time = t_ms
 760                    self.node.mini_billboard_2_end_time = (
 761                        t_ms + POWERUP_WEAR_OFF_TIME
 762                    )
 763                    self._bomb_wear_off_flash_timer = ba.Timer(
 764                        POWERUP_WEAR_OFF_TIME - 2000,
 765                        ba.WeakCall(self._bomb_wear_off_flash),
 766                        timeformat=ba.TimeFormat.MILLISECONDS,
 767                    )
 768                    self._bomb_wear_off_timer = ba.Timer(
 769                        POWERUP_WEAR_OFF_TIME,
 770                        ba.WeakCall(self._bomb_wear_off),
 771                        timeformat=ba.TimeFormat.MILLISECONDS,
 772                    )
 773            elif msg.poweruptype == 'sticky_bombs':
 774                self.bomb_type = 'sticky'
 775                tex = self._get_bomb_type_tex()
 776                self._flash_billboard(tex)
 777                if self.powerups_expire:
 778                    self.node.mini_billboard_2_texture = tex
 779                    t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 780                    assert isinstance(t_ms, int)
 781                    self.node.mini_billboard_2_start_time = t_ms
 782                    self.node.mini_billboard_2_end_time = (
 783                        t_ms + POWERUP_WEAR_OFF_TIME
 784                    )
 785                    self._bomb_wear_off_flash_timer = ba.Timer(
 786                        POWERUP_WEAR_OFF_TIME - 2000,
 787                        ba.WeakCall(self._bomb_wear_off_flash),
 788                        timeformat=ba.TimeFormat.MILLISECONDS,
 789                    )
 790                    self._bomb_wear_off_timer = ba.Timer(
 791                        POWERUP_WEAR_OFF_TIME,
 792                        ba.WeakCall(self._bomb_wear_off),
 793                        timeformat=ba.TimeFormat.MILLISECONDS,
 794                    )
 795            elif msg.poweruptype == 'punch':
 796                tex = PowerupBoxFactory.get().tex_punch
 797                self._flash_billboard(tex)
 798                self.equip_boxing_gloves()
 799                if self.powerups_expire and not self.default_boxing_gloves:
 800                    self.node.boxing_gloves_flashing = False
 801                    self.node.mini_billboard_3_texture = tex
 802                    t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 803                    assert isinstance(t_ms, int)
 804                    self.node.mini_billboard_3_start_time = t_ms
 805                    self.node.mini_billboard_3_end_time = (
 806                        t_ms + POWERUP_WEAR_OFF_TIME
 807                    )
 808                    self._boxing_gloves_wear_off_flash_timer = ba.Timer(
 809                        POWERUP_WEAR_OFF_TIME - 2000,
 810                        ba.WeakCall(self._gloves_wear_off_flash),
 811                        timeformat=ba.TimeFormat.MILLISECONDS,
 812                    )
 813                    self._boxing_gloves_wear_off_timer = ba.Timer(
 814                        POWERUP_WEAR_OFF_TIME,
 815                        ba.WeakCall(self._gloves_wear_off),
 816                        timeformat=ba.TimeFormat.MILLISECONDS,
 817                    )
 818            elif msg.poweruptype == 'shield':
 819                factory = SpazFactory.get()
 820
 821                # Let's allow powerup-equipped shields to lose hp over time.
 822                self.equip_shields(decay=factory.shield_decay_rate > 0)
 823            elif msg.poweruptype == 'curse':
 824                self.curse()
 825            elif msg.poweruptype == 'ice_bombs':
 826                self.bomb_type = 'ice'
 827                tex = self._get_bomb_type_tex()
 828                self._flash_billboard(tex)
 829                if self.powerups_expire:
 830                    self.node.mini_billboard_2_texture = tex
 831                    t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 832                    assert isinstance(t_ms, int)
 833                    self.node.mini_billboard_2_start_time = t_ms
 834                    self.node.mini_billboard_2_end_time = (
 835                        t_ms + POWERUP_WEAR_OFF_TIME
 836                    )
 837                    self._bomb_wear_off_flash_timer = ba.Timer(
 838                        POWERUP_WEAR_OFF_TIME - 2000,
 839                        ba.WeakCall(self._bomb_wear_off_flash),
 840                        timeformat=ba.TimeFormat.MILLISECONDS,
 841                    )
 842                    self._bomb_wear_off_timer = ba.Timer(
 843                        POWERUP_WEAR_OFF_TIME,
 844                        ba.WeakCall(self._bomb_wear_off),
 845                        timeformat=ba.TimeFormat.MILLISECONDS,
 846                    )
 847            elif msg.poweruptype == 'health':
 848                if self._cursed:
 849                    self._cursed = False
 850
 851                    # Remove cursed material.
 852                    factory = SpazFactory.get()
 853                    for attr in ['materials', 'roller_materials']:
 854                        materials = getattr(self.node, attr)
 855                        if factory.curse_material in materials:
 856                            setattr(
 857                                self.node,
 858                                attr,
 859                                tuple(
 860                                    m
 861                                    for m in materials
 862                                    if m != factory.curse_material
 863                                ),
 864                            )
 865                    self.node.curse_death_time = 0
 866                self.hitpoints = self.hitpoints_max
 867                self._flash_billboard(PowerupBoxFactory.get().tex_health)
 868                self.node.hurt = 0
 869                self._last_hit_time = None
 870                self._num_times_hit = 0
 871
 872            self.node.handlemessage('flash')
 873            if msg.sourcenode:
 874                msg.sourcenode.handlemessage(ba.PowerupAcceptMessage())
 875            return True
 876
 877        elif isinstance(msg, ba.FreezeMessage):
 878            if not self.node:
 879                return None
 880            if self.node.invincible:
 881                ba.playsound(
 882                    SpazFactory.get().block_sound,
 883                    1.0,
 884                    position=self.node.position,
 885                )
 886                return None
 887            if self.shield:
 888                return None
 889            if not self.frozen:
 890                self.frozen = True
 891                self.node.frozen = True
 892                ba.timer(5.0, ba.WeakCall(self.handlemessage, ba.ThawMessage()))
 893                # Instantly shatter if we're already dead.
 894                # (otherwise its hard to tell we're dead)
 895                if self.hitpoints <= 0:
 896                    self.shatter()
 897
 898        elif isinstance(msg, ba.ThawMessage):
 899            if self.frozen and not self.shattered and self.node:
 900                self.frozen = False
 901                self.node.frozen = False
 902
 903        elif isinstance(msg, ba.HitMessage):
 904            if not self.node:
 905                return None
 906            if self.node.invincible:
 907                ba.playsound(
 908                    SpazFactory.get().block_sound,
 909                    1.0,
 910                    position=self.node.position,
 911                )
 912                return True
 913
 914            # If we were recently hit, don't count this as another.
 915            # (so punch flurries and bomb pileups essentially count as 1 hit)
 916            local_time = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 917            assert isinstance(local_time, int)
 918            if (
 919                self._last_hit_time is None
 920                or local_time - self._last_hit_time > 1000
 921            ):
 922                self._num_times_hit += 1
 923                self._last_hit_time = local_time
 924
 925            mag = msg.magnitude * self.impact_scale
 926            velocity_mag = msg.velocity_magnitude * self.impact_scale
 927            damage_scale = 0.22
 928
 929            # If they've got a shield, deliver it to that instead.
 930            if self.shield:
 931                if msg.flat_damage:
 932                    damage = msg.flat_damage * self.impact_scale
 933                else:
 934                    # Hit our spaz with an impulse but tell it to only return
 935                    # theoretical damage; not apply the impulse.
 936                    assert msg.force_direction is not None
 937                    self.node.handlemessage(
 938                        'impulse',
 939                        msg.pos[0],
 940                        msg.pos[1],
 941                        msg.pos[2],
 942                        msg.velocity[0],
 943                        msg.velocity[1],
 944                        msg.velocity[2],
 945                        mag,
 946                        velocity_mag,
 947                        msg.radius,
 948                        1,
 949                        msg.force_direction[0],
 950                        msg.force_direction[1],
 951                        msg.force_direction[2],
 952                    )
 953                    damage = damage_scale * self.node.damage
 954
 955                assert self.shield_hitpoints is not None
 956                self.shield_hitpoints -= int(damage)
 957                self.shield.hurt = (
 958                    1.0
 959                    - float(self.shield_hitpoints) / self.shield_hitpoints_max
 960                )
 961
 962                # Its a cleaner event if a hit just kills the shield
 963                # without damaging the player.
 964                # However, massive damage events should still be able to
 965                # damage the player. This hopefully gives us a happy medium.
 966                max_spillover = SpazFactory.get().max_shield_spillover_damage
 967                if self.shield_hitpoints <= 0:
 968
 969                    # FIXME: Transition out perhaps?
 970                    self.shield.delete()
 971                    self.shield = None
 972                    ba.playsound(
 973                        SpazFactory.get().shield_down_sound,
 974                        1.0,
 975                        position=self.node.position,
 976                    )
 977
 978                    # Emit some cool looking sparks when the shield dies.
 979                    npos = self.node.position
 980                    ba.emitfx(
 981                        position=(npos[0], npos[1] + 0.9, npos[2]),
 982                        velocity=self.node.velocity,
 983                        count=random.randrange(20, 30),
 984                        scale=1.0,
 985                        spread=0.6,
 986                        chunk_type='spark',
 987                    )
 988
 989                else:
 990                    ba.playsound(
 991                        SpazFactory.get().shield_hit_sound,
 992                        0.5,
 993                        position=self.node.position,
 994                    )
 995
 996                # Emit some cool looking sparks on shield hit.
 997                assert msg.force_direction is not None
 998                ba.emitfx(
 999                    position=msg.pos,
1000                    velocity=(
1001                        msg.force_direction[0] * 1.0,
1002                        msg.force_direction[1] * 1.0,
1003                        msg.force_direction[2] * 1.0,
1004                    ),
1005                    count=min(30, 5 + int(damage * 0.005)),
1006                    scale=0.5,
1007                    spread=0.3,
1008                    chunk_type='spark',
1009                )
1010
1011                # If they passed our spillover threshold,
1012                # pass damage along to spaz.
1013                if self.shield_hitpoints <= -max_spillover:
1014                    leftover_damage = -max_spillover - self.shield_hitpoints
1015                    shield_leftover_ratio = leftover_damage / damage
1016
1017                    # Scale down the magnitudes applied to spaz accordingly.
1018                    mag *= shield_leftover_ratio
1019                    velocity_mag *= shield_leftover_ratio
1020                else:
1021                    return True  # Good job shield!
1022            else:
1023                shield_leftover_ratio = 1.0
1024
1025            if msg.flat_damage:
1026                damage = int(
1027                    msg.flat_damage * self.impact_scale * shield_leftover_ratio
1028                )
1029            else:
1030                # Hit it with an impulse and get the resulting damage.
1031                assert msg.force_direction is not None
1032                self.node.handlemessage(
1033                    'impulse',
1034                    msg.pos[0],
1035                    msg.pos[1],
1036                    msg.pos[2],
1037                    msg.velocity[0],
1038                    msg.velocity[1],
1039                    msg.velocity[2],
1040                    mag,
1041                    velocity_mag,
1042                    msg.radius,
1043                    0,
1044                    msg.force_direction[0],
1045                    msg.force_direction[1],
1046                    msg.force_direction[2],
1047                )
1048
1049                damage = int(damage_scale * self.node.damage)
1050            self.node.handlemessage('hurt_sound')
1051
1052            # Play punch impact sound based on damage if it was a punch.
1053            if msg.hit_type == 'punch':
1054                self.on_punched(damage)
1055
1056                # If damage was significant, lets show it.
1057                if damage >= 350:
1058                    assert msg.force_direction is not None
1059                    ba.show_damage_count(
1060                        '-' + str(int(damage / 10)) + '%',
1061                        msg.pos,
1062                        msg.force_direction,
1063                    )
1064
1065                # Let's always add in a super-punch sound with boxing
1066                # gloves just to differentiate them.
1067                if msg.hit_subtype == 'super_punch':
1068                    ba.playsound(
1069                        SpazFactory.get().punch_sound_stronger,
1070                        1.0,
1071                        position=self.node.position,
1072                    )
1073                if damage >= 500:
1074                    sounds = SpazFactory.get().punch_sound_strong
1075                    sound = sounds[random.randrange(len(sounds))]
1076                elif damage >= 100:
1077                    sound = SpazFactory.get().punch_sound
1078                else:
1079                    sound = SpazFactory.get().punch_sound_weak
1080                ba.playsound(sound, 1.0, position=self.node.position)
1081
1082                # Throw up some chunks.
1083                assert msg.force_direction is not None
1084                ba.emitfx(
1085                    position=msg.pos,
1086                    velocity=(
1087                        msg.force_direction[0] * 0.5,
1088                        msg.force_direction[1] * 0.5,
1089                        msg.force_direction[2] * 0.5,
1090                    ),
1091                    count=min(10, 1 + int(damage * 0.0025)),
1092                    scale=0.3,
1093                    spread=0.03,
1094                )
1095
1096                ba.emitfx(
1097                    position=msg.pos,
1098                    chunk_type='sweat',
1099                    velocity=(
1100                        msg.force_direction[0] * 1.3,
1101                        msg.force_direction[1] * 1.3 + 5.0,
1102                        msg.force_direction[2] * 1.3,
1103                    ),
1104                    count=min(30, 1 + int(damage * 0.04)),
1105                    scale=0.9,
1106                    spread=0.28,
1107                )
1108
1109                # Momentary flash.
1110                hurtiness = damage * 0.003
1111                punchpos = (
1112                    msg.pos[0] + msg.force_direction[0] * 0.02,
1113                    msg.pos[1] + msg.force_direction[1] * 0.02,
1114                    msg.pos[2] + msg.force_direction[2] * 0.02,
1115                )
1116                flash_color = (1.0, 0.8, 0.4)
1117                light = ba.newnode(
1118                    'light',
1119                    attrs={
1120                        'position': punchpos,
1121                        'radius': 0.12 + hurtiness * 0.12,
1122                        'intensity': 0.3 * (1.0 + 1.0 * hurtiness),
1123                        'height_attenuated': False,
1124                        'color': flash_color,
1125                    },
1126                )
1127                ba.timer(0.06, light.delete)
1128
1129                flash = ba.newnode(
1130                    'flash',
1131                    attrs={
1132                        'position': punchpos,
1133                        'size': 0.17 + 0.17 * hurtiness,
1134                        'color': flash_color,
1135                    },
1136                )
1137                ba.timer(0.06, flash.delete)
1138
1139            if msg.hit_type == 'impact':
1140                assert msg.force_direction is not None
1141                ba.emitfx(
1142                    position=msg.pos,
1143                    velocity=(
1144                        msg.force_direction[0] * 2.0,
1145                        msg.force_direction[1] * 2.0,
1146                        msg.force_direction[2] * 2.0,
1147                    ),
1148                    count=min(10, 1 + int(damage * 0.01)),
1149                    scale=0.4,
1150                    spread=0.1,
1151                )
1152            if self.hitpoints > 0:
1153
1154                # It's kinda crappy to die from impacts, so lets reduce
1155                # impact damage by a reasonable amount *if* it'll keep us alive
1156                if msg.hit_type == 'impact' and damage > self.hitpoints:
1157                    # Drop damage to whatever puts us at 10 hit points,
1158                    # or 200 less than it used to be whichever is greater
1159                    # (so it *can* still kill us if its high enough)
1160                    newdamage = max(damage - 200, self.hitpoints - 10)
1161                    damage = newdamage
1162                self.node.handlemessage('flash')
1163
1164                # If we're holding something, drop it.
1165                if damage > 0.0 and self.node.hold_node:
1166                    self.node.hold_node = None
1167                self.hitpoints -= damage
1168                self.node.hurt = (
1169                    1.0 - float(self.hitpoints) / self.hitpoints_max
1170                )
1171
1172                # If we're cursed, *any* damage blows us up.
1173                if self._cursed and damage > 0:
1174                    ba.timer(
1175                        0.05,
1176                        ba.WeakCall(
1177                            self.curse_explode, msg.get_source_player(ba.Player)
1178                        ),
1179                    )
1180
1181                # If we're frozen, shatter.. otherwise die if we hit zero
1182                if self.frozen and (damage > 200 or self.hitpoints <= 0):
1183                    self.shatter()
1184                elif self.hitpoints <= 0:
1185                    self.node.handlemessage(
1186                        ba.DieMessage(how=ba.DeathType.IMPACT)
1187                    )
1188
1189            # If we're dead, take a look at the smoothed damage value
1190            # (which gives us a smoothed average of recent damage) and shatter
1191            # us if its grown high enough.
1192            if self.hitpoints <= 0:
1193                damage_avg = self.node.damage_smoothed * damage_scale
1194                if damage_avg >= 1000:
1195                    self.shatter()
1196
1197        elif isinstance(msg, BombDiedMessage):
1198            self.bomb_count += 1
1199
1200        elif isinstance(msg, ba.DieMessage):
1201            wasdead = self._dead
1202            self._dead = True
1203            self.hitpoints = 0
1204            if msg.immediate:
1205                if self.node:
1206                    self.node.delete()
1207            elif self.node:
1208                self.node.hurt = 1.0
1209                if self.play_big_death_sound and not wasdead:
1210                    ba.playsound(SpazFactory.get().single_player_death_sound)
1211                self.node.dead = True
1212                ba.timer(2.0, self.node.delete)
1213
1214        elif isinstance(msg, ba.OutOfBoundsMessage):
1215            # By default we just die here.
1216            self.handlemessage(ba.DieMessage(how=ba.DeathType.FALL))
1217
1218        elif isinstance(msg, ba.StandMessage):
1219            self._last_stand_pos = (
1220                msg.position[0],
1221                msg.position[1],
1222                msg.position[2],
1223            )
1224            if self.node:
1225                self.node.handlemessage(
1226                    'stand',
1227                    msg.position[0],
1228                    msg.position[1],
1229                    msg.position[2],
1230                    msg.angle,
1231                )
1232
1233        elif isinstance(msg, CurseExplodeMessage):
1234            self.curse_explode()
1235
1236        elif isinstance(msg, PunchHitMessage):
1237            if not self.node:
1238                return None
1239            node = ba.getcollision().opposingnode
1240
1241            # Only allow one hit per node per punch.
1242            if node and (node not in self._punched_nodes):
1243
1244                punch_momentum_angular = (
1245                    self.node.punch_momentum_angular * self._punch_power_scale
1246                )
1247                punch_power = self.node.punch_power * self._punch_power_scale
1248
1249                # Ok here's the deal:  we pass along our base velocity for use
1250                # in the impulse damage calculations since that is a more
1251                # predictable value than our fist velocity, which is rather
1252                # erratic. However, we want to actually apply force in the
1253                # direction our fist is moving so it looks better. So we still
1254                # pass that along as a direction. Perhaps a time-averaged
1255                # fist-velocity would work too?.. perhaps should try that.
1256
1257                # If its something besides another spaz, just do a muffled
1258                # punch sound.
1259                if node.getnodetype() != 'spaz':
1260                    sounds = SpazFactory.get().impact_sounds_medium
1261                    sound = sounds[random.randrange(len(sounds))]
1262                    ba.playsound(sound, 1.0, position=self.node.position)
1263
1264                ppos = self.node.punch_position
1265                punchdir = self.node.punch_velocity
1266                vel = self.node.punch_momentum_linear
1267
1268                self._punched_nodes.add(node)
1269                node.handlemessage(
1270                    ba.HitMessage(
1271                        pos=ppos,
1272                        velocity=vel,
1273                        magnitude=punch_power * punch_momentum_angular * 110.0,
1274                        velocity_magnitude=punch_power * 40,
1275                        radius=0,
1276                        srcnode=self.node,
1277                        source_player=self.source_player,
1278                        force_direction=punchdir,
1279                        hit_type='punch',
1280                        hit_subtype=(
1281                            'super_punch'
1282                            if self._has_boxing_gloves
1283                            else 'default'
1284                        ),
1285                    )
1286                )
1287
1288                # Also apply opposite to ourself for the first punch only.
1289                # This is given as a constant force so that it is more
1290                # noticeable for slower punches where it matters. For fast
1291                # awesome looking punches its ok if we punch 'through'
1292                # the target.
1293                mag = -400.0
1294                if self._hockey:
1295                    mag *= 0.5
1296                if len(self._punched_nodes) == 1:
1297                    self.node.handlemessage(
1298                        'kick_back',
1299                        ppos[0],
1300                        ppos[1],
1301                        ppos[2],
1302                        punchdir[0],
1303                        punchdir[1],
1304                        punchdir[2],
1305                        mag,
1306                    )
1307        elif isinstance(msg, PickupMessage):
1308            if not self.node:
1309                return None
1310
1311            try:
1312                collision = ba.getcollision()
1313                opposingnode = collision.opposingnode
1314                opposingbody = collision.opposingbody
1315            except ba.NotFoundError:
1316                return True
1317
1318            # Don't allow picking up of invincible dudes.
1319            try:
1320                if opposingnode.invincible:
1321                    return True
1322            except Exception:
1323                pass
1324
1325            # If we're grabbing the pelvis of a non-shattered spaz, we wanna
1326            # grab the torso instead.
1327            if (
1328                opposingnode.getnodetype() == 'spaz'
1329                and not opposingnode.shattered
1330                and opposingbody == 4
1331            ):
1332                opposingbody = 1
1333
1334            # Special case - if we're holding a flag, don't replace it
1335            # (hmm - should make this customizable or more low level).
1336            held = self.node.hold_node
1337            if held and held.getnodetype() == 'flag':
1338                return True
1339
1340            # Note: hold_body needs to be set before hold_node.
1341            self.node.hold_body = opposingbody
1342            self.node.hold_node = opposingnode
1343        elif isinstance(msg, ba.CelebrateMessage):
1344            if self.node:
1345                self.node.handlemessage('celebrate', int(msg.duration * 1000))
1346
1347        else:
1348            return super().handlemessage(msg)
1349        return None
1350
1351    def drop_bomb(self) -> stdbomb.Bomb | None:
1352        """
1353        Tell the spaz to drop one of his bombs, and returns
1354        the resulting bomb object.
1355        If the spaz has no bombs or is otherwise unable to
1356        drop a bomb, returns None.
1357        """
1358
1359        if (self.land_mine_count <= 0 and self.bomb_count <= 0) or self.frozen:
1360            return None
1361        assert self.node
1362        pos = self.node.position_forward
1363        vel = self.node.velocity
1364
1365        if self.land_mine_count > 0:
1366            dropping_bomb = False
1367            self.set_land_mine_count(self.land_mine_count - 1)
1368            bomb_type = 'land_mine'
1369        else:
1370            dropping_bomb = True
1371            bomb_type = self.bomb_type
1372
1373        bomb = stdbomb.Bomb(
1374            position=(pos[0], pos[1] - 0.0, pos[2]),
1375            velocity=(vel[0], vel[1], vel[2]),
1376            bomb_type=bomb_type,
1377            blast_radius=self.blast_radius,
1378            source_player=self.source_player,
1379            owner=self.node,
1380        ).autoretain()
1381
1382        assert bomb.node
1383        if dropping_bomb:
1384            self.bomb_count -= 1
1385            bomb.node.add_death_action(
1386                ba.WeakCall(self.handlemessage, BombDiedMessage())
1387            )
1388        self._pick_up(bomb.node)
1389
1390        for clb in self._dropped_bomb_callbacks:
1391            clb(self, bomb)
1392
1393        return bomb
1394
1395    def _pick_up(self, node: ba.Node) -> None:
1396        if self.node:
1397            # Note: hold_body needs to be set before hold_node.
1398            self.node.hold_body = 0
1399            self.node.hold_node = node
1400
1401    def set_land_mine_count(self, count: int) -> None:
1402        """Set the number of land-mines this spaz is carrying."""
1403        self.land_mine_count = count
1404        if self.node:
1405            if self.land_mine_count != 0:
1406                self.node.counter_text = 'x' + str(self.land_mine_count)
1407                self.node.counter_texture = (
1408                    PowerupBoxFactory.get().tex_land_mines
1409                )
1410            else:
1411                self.node.counter_text = ''
1412
1413    def curse_explode(self, source_player: ba.Player | None = None) -> None:
1414        """Explode the poor spaz spectacularly."""
1415        if self._cursed and self.node:
1416            self.shatter(extreme=True)
1417            self.handlemessage(ba.DieMessage())
1418            activity = self._activity()
1419            if activity:
1420                stdbomb.Blast(
1421                    position=self.node.position,
1422                    velocity=self.node.velocity,
1423                    blast_radius=3.0,
1424                    blast_type='normal',
1425                    source_player=(
1426                        source_player if source_player else self.source_player
1427                    ),
1428                ).autoretain()
1429            self._cursed = False
1430
1431    def shatter(self, extreme: bool = False) -> None:
1432        """Break the poor spaz into little bits."""
1433        if self.shattered:
1434            return
1435        self.shattered = True
1436        assert self.node
1437        if self.frozen:
1438            # Momentary flash of light.
1439            light = ba.newnode(
1440                'light',
1441                attrs={
1442                    'position': self.node.position,
1443                    'radius': 0.5,
1444                    'height_attenuated': False,
1445                    'color': (0.8, 0.8, 1.0),
1446                },
1447            )
1448
1449            ba.animate(
1450                light, 'intensity', {0.0: 3.0, 0.04: 0.5, 0.08: 0.07, 0.3: 0}
1451            )
1452            ba.timer(0.3, light.delete)
1453
1454            # Emit ice chunks.
1455            ba.emitfx(
1456                position=self.node.position,
1457                velocity=self.node.velocity,
1458                count=int(random.random() * 10.0 + 10.0),
1459                scale=0.6,
1460                spread=0.2,
1461                chunk_type='ice',
1462            )
1463            ba.emitfx(
1464                position=self.node.position,
1465                velocity=self.node.velocity,
1466                count=int(random.random() * 10.0 + 10.0),
1467                scale=0.3,
1468                spread=0.2,
1469                chunk_type='ice',
1470            )
1471            ba.playsound(
1472                SpazFactory.get().shatter_sound,
1473                1.0,
1474                position=self.node.position,
1475            )
1476        else:
1477            ba.playsound(
1478                SpazFactory.get().splatter_sound,
1479                1.0,
1480                position=self.node.position,
1481            )
1482        self.handlemessage(ba.DieMessage())
1483        self.node.shattered = 2 if extreme else 1
1484
1485    def _hit_self(self, intensity: float) -> None:
1486        if not self.node:
1487            return
1488        pos = self.node.position
1489        self.handlemessage(
1490            ba.HitMessage(
1491                flat_damage=50.0 * intensity,
1492                pos=pos,
1493                force_direction=self.node.velocity,
1494                hit_type='impact',
1495            )
1496        )
1497        self.node.handlemessage('knockout', max(0.0, 50.0 * intensity))
1498        sounds: Sequence[ba.Sound]
1499        if intensity >= 5.0:
1500            sounds = SpazFactory.get().impact_sounds_harder
1501        elif intensity >= 3.0:
1502            sounds = SpazFactory.get().impact_sounds_hard
1503        else:
1504            sounds = SpazFactory.get().impact_sounds_medium
1505        sound = sounds[random.randrange(len(sounds))]
1506        ba.playsound(sound, position=pos, volume=5.0)
1507
1508    def _get_bomb_type_tex(self) -> ba.Texture:
1509        factory = PowerupBoxFactory.get()
1510        if self.bomb_type == 'sticky':
1511            return factory.tex_sticky_bombs
1512        if self.bomb_type == 'ice':
1513            return factory.tex_ice_bombs
1514        if self.bomb_type == 'impact':
1515            return factory.tex_impact_bombs
1516        raise ValueError('invalid bomb type')
1517
1518    def _flash_billboard(self, tex: ba.Texture) -> None:
1519        assert self.node
1520        self.node.billboard_texture = tex
1521        self.node.billboard_cross_out = False
1522        ba.animate(
1523            self.node,
1524            'billboard_opacity',
1525            {0.0: 0.0, 0.1: 1.0, 0.4: 1.0, 0.5: 0.0},
1526        )
1527
1528    def set_bomb_count(self, count: int) -> None:
1529        """Sets the number of bombs this Spaz has."""
1530        # We can't just set bomb_count because some bombs may be laid currently
1531        # so we have to do a relative diff based on max.
1532        diff = count - self._max_bomb_count
1533        self._max_bomb_count += diff
1534        self.bomb_count += diff
1535
1536    def _gloves_wear_off_flash(self) -> None:
1537        if self.node:
1538            self.node.boxing_gloves_flashing = True
1539            self.node.billboard_texture = PowerupBoxFactory.get().tex_punch
1540            self.node.billboard_opacity = 1.0
1541            self.node.billboard_cross_out = True
1542
1543    def _gloves_wear_off(self) -> None:
1544        if self._demo_mode:  # Preserve old behavior.
1545            self._punch_power_scale = 1.2
1546            self._punch_cooldown = BASE_PUNCH_COOLDOWN
1547        else:
1548            factory = SpazFactory.get()
1549            self._punch_power_scale = factory.punch_power_scale
1550            self._punch_cooldown = factory.punch_cooldown
1551        self._has_boxing_gloves = False
1552        if self.node:
1553            ba.playsound(
1554                PowerupBoxFactory.get().powerdown_sound,
1555                position=self.node.position,
1556            )
1557            self.node.boxing_gloves = False
1558            self.node.billboard_opacity = 0.0
1559
1560    def _multi_bomb_wear_off_flash(self) -> None:
1561        if self.node:
1562            self.node.billboard_texture = PowerupBoxFactory.get().tex_bomb
1563            self.node.billboard_opacity = 1.0
1564            self.node.billboard_cross_out = True
1565
1566    def _multi_bomb_wear_off(self) -> None:
1567        self.set_bomb_count(self.default_bomb_count)
1568        if self.node:
1569            ba.playsound(
1570                PowerupBoxFactory.get().powerdown_sound,
1571                position=self.node.position,
1572            )
1573            self.node.billboard_opacity = 0.0
1574
1575    def _bomb_wear_off_flash(self) -> None:
1576        if self.node:
1577            self.node.billboard_texture = self._get_bomb_type_tex()
1578            self.node.billboard_opacity = 1.0
1579            self.node.billboard_cross_out = True
1580
1581    def _bomb_wear_off(self) -> None:
1582        self.bomb_type = self.bomb_type_default
1583        if self.node:
1584            ba.playsound(
1585                PowerupBoxFactory.get().powerdown_sound,
1586                position=self.node.position,
1587            )
1588            self.node.billboard_opacity = 0.0

Base class for various Spazzes.

Category: Gameplay Classes

A Spaz is the standard little humanoid character in the game. It can be controlled by a player or by AI, and can have various different appearances. The name 'Spaz' is not to be confused with the 'Spaz' character in the game, which is just one of the skins available for instances of this class.

Spaz( color: Sequence[float] = (1.0, 1.0, 1.0), highlight: Sequence[float] = (0.5, 0.5, 0.5), character: str = 'Spaz', source_player: ba._player.Player | None = None, start_invincible: bool = True, can_accept_powerups: bool = True, powerups_expire: bool = False, demo_mode: bool = False)
 67    def __init__(
 68        self,
 69        color: Sequence[float] = (1.0, 1.0, 1.0),
 70        highlight: Sequence[float] = (0.5, 0.5, 0.5),
 71        character: str = 'Spaz',
 72        source_player: ba.Player | None = None,
 73        start_invincible: bool = True,
 74        can_accept_powerups: bool = True,
 75        powerups_expire: bool = False,
 76        demo_mode: bool = False,
 77    ):
 78        """Create a spaz with the requested color, character, etc."""
 79        # pylint: disable=too-many-statements
 80
 81        super().__init__()
 82        shared = SharedObjects.get()
 83        activity = self.activity
 84
 85        factory = SpazFactory.get()
 86
 87        # We need to behave slightly different in the tutorial.
 88        self._demo_mode = demo_mode
 89
 90        self.play_big_death_sound = False
 91
 92        # Scales how much impacts affect us (most damage calcs).
 93        self.impact_scale = 1.0
 94
 95        self.source_player = source_player
 96        self._dead = False
 97        if self._demo_mode:  # Preserve old behavior.
 98            self._punch_power_scale = 1.2
 99        else:
100            self._punch_power_scale = factory.punch_power_scale
101        self.fly = ba.getactivity().globalsnode.happy_thoughts_mode
102        if isinstance(activity, ba.GameActivity):
103            self._hockey = activity.map.is_hockey
104        else:
105            self._hockey = False
106        self._punched_nodes: set[ba.Node] = set()
107        self._cursed = False
108        self._connected_to_player: ba.Player | None = None
109        materials = [
110            factory.spaz_material,
111            shared.object_material,
112            shared.player_material,
113        ]
114        roller_materials = [factory.roller_material, shared.player_material]
115        extras_material = []
116
117        if can_accept_powerups:
118            pam = PowerupBoxFactory.get().powerup_accept_material
119            materials.append(pam)
120            roller_materials.append(pam)
121            extras_material.append(pam)
122
123        media = factory.get_media(character)
124        punchmats = (factory.punch_material, shared.attack_material)
125        pickupmats = (factory.pickup_material, shared.pickup_material)
126        self.node: ba.Node = ba.newnode(
127            type='spaz',
128            delegate=self,
129            attrs={
130                'color': color,
131                'behavior_version': 0 if demo_mode else 1,
132                'demo_mode': demo_mode,
133                'highlight': highlight,
134                'jump_sounds': media['jump_sounds'],
135                'attack_sounds': media['attack_sounds'],
136                'impact_sounds': media['impact_sounds'],
137                'death_sounds': media['death_sounds'],
138                'pickup_sounds': media['pickup_sounds'],
139                'fall_sounds': media['fall_sounds'],
140                'color_texture': media['color_texture'],
141                'color_mask_texture': media['color_mask_texture'],
142                'head_model': media['head_model'],
143                'torso_model': media['torso_model'],
144                'pelvis_model': media['pelvis_model'],
145                'upper_arm_model': media['upper_arm_model'],
146                'forearm_model': media['forearm_model'],
147                'hand_model': media['hand_model'],
148                'upper_leg_model': media['upper_leg_model'],
149                'lower_leg_model': media['lower_leg_model'],
150                'toes_model': media['toes_model'],
151                'style': factory.get_style(character),
152                'fly': self.fly,
153                'hockey': self._hockey,
154                'materials': materials,
155                'roller_materials': roller_materials,
156                'extras_material': extras_material,
157                'punch_materials': punchmats,
158                'pickup_materials': pickupmats,
159                'invincible': start_invincible,
160                'source_player': source_player,
161            },
162        )
163        self.shield: ba.Node | None = None
164
165        if start_invincible:
166
167            def _safesetattr(node: ba.Node | None, attr: str, val: Any) -> None:
168                if node:
169                    setattr(node, attr, val)
170
171            ba.timer(1.0, ba.Call(_safesetattr, self.node, 'invincible', False))
172        self.hitpoints = 1000
173        self.hitpoints_max = 1000
174        self.shield_hitpoints: int | None = None
175        self.shield_hitpoints_max = 650
176        self.shield_decay_rate = 0
177        self.shield_decay_timer: ba.Timer | None = None
178        self._boxing_gloves_wear_off_timer: ba.Timer | None = None
179        self._boxing_gloves_wear_off_flash_timer: ba.Timer | None = None
180        self._bomb_wear_off_timer: ba.Timer | None = None
181        self._bomb_wear_off_flash_timer: ba.Timer | None = None
182        self._multi_bomb_wear_off_timer: ba.Timer | None = None
183        self._multi_bomb_wear_off_flash_timer: ba.Timer | None = None
184        self._curse_timer: ba.Timer | None = None
185        self.bomb_count = self.default_bomb_count
186        self._max_bomb_count = self.default_bomb_count
187        self.bomb_type_default = self.default_bomb_type
188        self.bomb_type = self.bomb_type_default
189        self.land_mine_count = 0
190        self.blast_radius = 2.0
191        self.powerups_expire = powerups_expire
192        if self._demo_mode:  # preserve old behavior
193            self._punch_cooldown = BASE_PUNCH_COOLDOWN
194        else:
195            self._punch_cooldown = factory.punch_cooldown
196        self._jump_cooldown = 250
197        self._pickup_cooldown = 0
198        self._bomb_cooldown = 0
199        self._has_boxing_gloves = False
200        if self.default_boxing_gloves:
201            self.equip_boxing_gloves()
202        self.last_punch_time_ms = -9999
203        self.last_pickup_time_ms = -9999
204        self.last_jump_time_ms = -9999
205        self.last_run_time_ms = -9999
206        self._last_run_value = 0.0
207        self.last_bomb_time_ms = -9999
208        self._turbo_filter_times: dict[str, int] = {}
209        self._turbo_filter_time_bucket = 0
210        self._turbo_filter_counts: dict[str, int] = {}
211        self.frozen = False
212        self.shattered = False
213        self._last_hit_time: int | None = None
214        self._num_times_hit = 0
215        self._bomb_held = False
216        if self.default_shields:
217            self.equip_shields()
218        self._dropped_bomb_callbacks: list[Callable[[Spaz, ba.Actor], Any]] = []
219
220        self._score_text: ba.Node | None = None
221        self._score_text_hide_timer: ba.Timer | None = None
222        self._last_stand_pos: Sequence[float] | None = None
223
224        # Deprecated stuff.. should make these into lists.
225        self.punch_callback: Callable[[Spaz], Any] | None = None
226        self.pick_up_powerup_callback: Callable[[Spaz], Any] | None = None

Create a spaz with the requested color, character, etc.

node: _ba.Node

The 'spaz' ba.Node.

def exists(self) -> bool:
228    def exists(self) -> bool:
229        return bool(self.node)

Returns whether the Actor is still present in a meaningful way.

Note that a dying character should still return True here as long as their corpse is visible; this is about presence, not being 'alive' (see ba.Actor.is_alive() for that).

If this returns False, it is assumed the Actor can be completely deleted without affecting the game; this call is often used when pruning lists of Actors, such as with ba.Actor.autoretain()

The default implementation of this method always return True.

Note that the boolean operator for the Actor class calls this method, so a simple "if myactor" test will conveniently do the right thing even if myactor is set to None.

def on_expire(self) -> None:
231    def on_expire(self) -> None:
232        super().on_expire()
233
234        # Release callbacks/refs so we don't wind up with dependency loops.
235        self._dropped_bomb_callbacks = []
236        self.punch_callback = None
237        self.pick_up_powerup_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 add_dropped_bomb_callback( self, call: Callable[[bastd.actor.spaz.Spaz, ba._actor.Actor], Any]) -> None:
239    def add_dropped_bomb_callback(
240        self, call: Callable[[Spaz, ba.Actor], Any]
241    ) -> None:
242        """
243        Add a call to be run whenever this Spaz drops a bomb.
244        The spaz and the newly-dropped bomb are passed as arguments.
245        """
246        assert not self.expired
247        self._dropped_bomb_callbacks.append(call)

Add a call to be run whenever this Spaz drops a bomb. The spaz and the newly-dropped bomb are passed as arguments.

def is_alive(self) -> bool:
249    def is_alive(self) -> bool:
250        """
251        Method override; returns whether ol' spaz is still kickin'.
252        """
253        return not self._dead

Method override; returns whether ol' spaz is still kickin'.

def set_score_text( self, text: str | ba._language.Lstr, color: Sequence[float] = (1.0, 1.0, 0.4), flash: bool = False) -> None:
314    def set_score_text(
315        self,
316        text: str | ba.Lstr,
317        color: Sequence[float] = (1.0, 1.0, 0.4),
318        flash: bool = False,
319    ) -> None:
320        """
321        Utility func to show a message momentarily over our spaz that follows
322        him around; Handy for score updates and things.
323        """
324        color_fin = ba.safecolor(color)[:3]
325        if not self.node:
326            return
327        if not self._score_text:
328            start_scale = 0.0
329            mnode = ba.newnode(
330                'math',
331                owner=self.node,
332                attrs={'input1': (0, 1.4, 0), 'operation': 'add'},
333            )
334            self.node.connectattr('torso_position', mnode, 'input2')
335            self._score_text = ba.newnode(
336                'text',
337                owner=self.node,
338                attrs={
339                    'text': text,
340                    'in_world': True,
341                    'shadow': 1.0,
342                    'flatness': 1.0,
343                    'color': color_fin,
344                    'scale': 0.02,
345                    'h_align': 'center',
346                },
347            )
348            mnode.connectattr('output', self._score_text, 'position')
349        else:
350            self._score_text.color = color_fin
351            assert isinstance(self._score_text.scale, float)
352            start_scale = self._score_text.scale
353            self._score_text.text = text
354        if flash:
355            combine = ba.newnode(
356                'combine', owner=self._score_text, attrs={'size': 3}
357            )
358            scl = 1.8
359            offs = 0.5
360            tval = 0.300
361            for i in range(3):
362                cl1 = offs + scl * color_fin[i]
363                cl2 = color_fin[i]
364                ba.animate(
365                    combine,
366                    'input' + str(i),
367                    {0.5 * tval: cl2, 0.75 * tval: cl1, 1.0 * tval: cl2},
368                )
369            combine.connectattr('output', self._score_text, 'color')
370
371        ba.animate(self._score_text, 'scale', {0.0: start_scale, 0.2: 0.02})
372        self._score_text_hide_timer = ba.Timer(
373            1.0, ba.WeakCall(self._hide_score_text)
374        )

Utility func to show a message momentarily over our spaz that follows him around; Handy for score updates and things.

def on_jump_press(self) -> None:
376    def on_jump_press(self) -> None:
377        """
378        Called to 'press jump' on this spaz;
379        used by player or AI connections.
380        """
381        if not self.node:
382            return
383        t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
384        assert isinstance(t_ms, int)
385        if t_ms - self.last_jump_time_ms >= self._jump_cooldown:
386            self.node.jump_pressed = True
387            self.last_jump_time_ms = t_ms
388        self._turbo_filter_add_press('jump')

Called to 'press jump' on this spaz; used by player or AI connections.

def on_jump_release(self) -> None:
390    def on_jump_release(self) -> None:
391        """
392        Called to 'release jump' on this spaz;
393        used by player or AI connections.
394        """
395        if not self.node:
396            return
397        self.node.jump_pressed = False

Called to 'release jump' on this spaz; used by player or AI connections.

def on_pickup_press(self) -> None:
399    def on_pickup_press(self) -> None:
400        """
401        Called to 'press pick-up' on this spaz;
402        used by player or AI connections.
403        """
404        if not self.node:
405            return
406        t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
407        assert isinstance(t_ms, int)
408        if t_ms - self.last_pickup_time_ms >= self._pickup_cooldown:
409            self.node.pickup_pressed = True
410            self.last_pickup_time_ms = t_ms
411        self._turbo_filter_add_press('pickup')

Called to 'press pick-up' on this spaz; used by player or AI connections.

def on_pickup_release(self) -> None:
413    def on_pickup_release(self) -> None:
414        """
415        Called to 'release pick-up' on this spaz;
416        used by player or AI connections.
417        """
418        if not self.node:
419            return
420        self.node.pickup_pressed = False

Called to 'release pick-up' on this spaz; used by player or AI connections.

def on_hold_position_press(self) -> None:
422    def on_hold_position_press(self) -> None:
423        """
424        Called to 'press hold-position' on this spaz;
425        used for player or AI connections.
426        """
427        if not self.node:
428            return
429        self.node.hold_position_pressed = True
430        self._turbo_filter_add_press('holdposition')

Called to 'press hold-position' on this spaz; used for player or AI connections.

def on_hold_position_release(self) -> None:
432    def on_hold_position_release(self) -> None:
433        """
434        Called to 'release hold-position' on this spaz;
435        used for player or AI connections.
436        """
437        if not self.node:
438            return
439        self.node.hold_position_pressed = False

Called to 'release hold-position' on this spaz; used for player or AI connections.

def on_punch_press(self) -> None:
441    def on_punch_press(self) -> None:
442        """
443        Called to 'press punch' on this spaz;
444        used for player or AI connections.
445        """
446        if not self.node or self.frozen or self.node.knockout > 0.0:
447            return
448        t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
449        assert isinstance(t_ms, int)
450        if t_ms - self.last_punch_time_ms >= self._punch_cooldown:
451            if self.punch_callback is not None:
452                self.punch_callback(self)
453            self._punched_nodes = set()  # Reset this.
454            self.last_punch_time_ms = t_ms
455            self.node.punch_pressed = True
456            if not self.node.hold_node:
457                ba.timer(
458                    0.1,
459                    ba.WeakCall(
460                        self._safe_play_sound,
461                        SpazFactory.get().swish_sound,
462                        0.8,
463                    ),
464                )
465        self._turbo_filter_add_press('punch')

Called to 'press punch' on this spaz; used for player or AI connections.

def on_punch_release(self) -> None:
472    def on_punch_release(self) -> None:
473        """
474        Called to 'release punch' on this spaz;
475        used for player or AI connections.
476        """
477        if not self.node:
478            return
479        self.node.punch_pressed = False

Called to 'release punch' on this spaz; used for player or AI connections.

def on_bomb_press(self) -> None:
481    def on_bomb_press(self) -> None:
482        """
483        Called to 'press bomb' on this spaz;
484        used for player or AI connections.
485        """
486        if not self.node:
487            return
488
489        if self._dead or self.frozen:
490            return
491        if self.node.knockout > 0.0:
492            return
493        t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
494        assert isinstance(t_ms, int)
495        if t_ms - self.last_bomb_time_ms >= self._bomb_cooldown:
496            self.last_bomb_time_ms = t_ms
497            self.node.bomb_pressed = True
498            if not self.node.hold_node:
499                self.drop_bomb()
500        self._turbo_filter_add_press('bomb')

Called to 'press bomb' on this spaz; used for player or AI connections.

def on_bomb_release(self) -> None:
502    def on_bomb_release(self) -> None:
503        """
504        Called to 'release bomb' on this spaz;
505        used for player or AI connections.
506        """
507        if not self.node:
508            return
509        self.node.bomb_pressed = False

Called to 'release bomb' on this spaz; used for player or AI connections.

def on_run(self, value: float) -> None:
511    def on_run(self, value: float) -> None:
512        """
513        Called to 'press run' on this spaz;
514        used for player or AI connections.
515        """
516        if not self.node:
517            return
518
519        t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
520        assert isinstance(t_ms, int)
521        self.last_run_time_ms = t_ms
522        self.node.run = value
523
524        # filtering these events would be tough since its an analog
525        # value, but lets still pass full 0-to-1 presses along to
526        # the turbo filter to punish players if it looks like they're turbo-ing
527        if self._last_run_value < 0.01 and value > 0.99:
528            self._turbo_filter_add_press('run')
529
530        self._last_run_value = value

Called to 'press run' on this spaz; used for player or AI connections.

def on_fly_press(self) -> None:
532    def on_fly_press(self) -> None:
533        """
534        Called to 'press fly' on this spaz;
535        used for player or AI connections.
536        """
537        if not self.node:
538            return
539        # not adding a cooldown time here for now; slightly worried
540        # input events get clustered up during net-games and we'd wind up
541        # killing a lot and making it hard to fly.. should look into this.
542        self.node.fly_pressed = True
543        self._turbo_filter_add_press('fly')

Called to 'press fly' on this spaz; used for player or AI connections.

def on_fly_release(self) -> None:
545    def on_fly_release(self) -> None:
546        """
547        Called to 'release fly' on this spaz;
548        used for player or AI connections.
549        """
550        if not self.node:
551            return
552        self.node.fly_pressed = False

Called to 'release fly' on this spaz; used for player or AI connections.

def on_move(self, x: float, y: float) -> None:
554    def on_move(self, x: float, y: float) -> None:
555        """
556        Called to set the joystick amount for this spaz;
557        used for player or AI connections.
558        """
559        if not self.node:
560            return
561        self.node.handlemessage('move', x, y)

Called to set the joystick amount for this spaz; used for player or AI connections.

def on_move_up_down(self, value: float) -> None:
563    def on_move_up_down(self, value: float) -> None:
564        """
565        Called to set the up/down joystick amount on this spaz;
566        used for player or AI connections.
567        value will be between -32768 to 32767
568        WARNING: deprecated; use on_move instead.
569        """
570        if not self.node:
571            return
572        self.node.move_up_down = value

Called to set the up/down joystick amount on this spaz; used for player or AI connections. value will be between -32768 to 32767 WARNING: deprecated; use on_move instead.

def on_move_left_right(self, value: float) -> None:
574    def on_move_left_right(self, value: float) -> None:
575        """
576        Called to set the left/right joystick amount on this spaz;
577        used for player or AI connections.
578        value will be between -32768 to 32767
579        WARNING: deprecated; use on_move instead.
580        """
581        if not self.node:
582            return
583        self.node.move_left_right = value

Called to set the left/right joystick amount on this spaz; used for player or AI connections. value will be between -32768 to 32767 WARNING: deprecated; use on_move instead.

def on_punched(self, damage: int) -> None:
585    def on_punched(self, damage: int) -> None:
586        """Called when this spaz gets punched."""

Called when this spaz gets punched.

def get_death_points(self, how: ba._messages.DeathType) -> tuple[int, int]:
588    def get_death_points(self, how: ba.DeathType) -> tuple[int, int]:
589        """Get the points awarded for killing this spaz."""
590        del how  # Unused.
591        num_hits = float(max(1, self._num_times_hit))
592
593        # Base points is simply 10 for 1-hit-kills and 5 otherwise.
594        importance = 2 if num_hits < 2 else 1
595        return (10 if num_hits < 2 else 5) * self.points_mult, importance

Get the points awarded for killing this spaz.

def curse(self) -> None:
597    def curse(self) -> None:
598        """
599        Give this poor spaz a curse;
600        he will explode in 5 seconds.
601        """
602        if not self._cursed:
603            factory = SpazFactory.get()
604            self._cursed = True
605
606            # Add the curse material.
607            for attr in ['materials', 'roller_materials']:
608                materials = getattr(self.node, attr)
609                if factory.curse_material not in materials:
610                    setattr(
611                        self.node, attr, materials + (factory.curse_material,)
612                    )
613
614            # None specifies no time limit
615            assert self.node
616            if self.curse_time is None:
617                self.node.curse_death_time = -1
618            else:
619                # Note: curse-death-time takes milliseconds.
620                tval = ba.time()
621                assert isinstance(tval, (float, int))
622                self.node.curse_death_time = int(
623                    1000.0 * (tval + self.curse_time)
624                )
625                self._curse_timer = ba.Timer(
626                    5.0, ba.WeakCall(self.curse_explode)
627                )

Give this poor spaz a curse; he will explode in 5 seconds.

def equip_boxing_gloves(self) -> None:
629    def equip_boxing_gloves(self) -> None:
630        """
631        Give this spaz some boxing gloves.
632        """
633        assert self.node
634        self.node.boxing_gloves = True
635        self._has_boxing_gloves = True
636        if self._demo_mode:  # Preserve old behavior.
637            self._punch_power_scale = 1.7
638            self._punch_cooldown = 300
639        else:
640            factory = SpazFactory.get()
641            self._punch_power_scale = factory.punch_power_scale_gloves
642            self._punch_cooldown = factory.punch_cooldown_gloves

Give this spaz some boxing gloves.

def equip_shields(self, decay: bool = False) -> None:
644    def equip_shields(self, decay: bool = False) -> None:
645        """
646        Give this spaz a nice energy shield.
647        """
648
649        if not self.node:
650            ba.print_error('Can\'t equip shields; no node.')
651            return
652
653        factory = SpazFactory.get()
654        if self.shield is None:
655            self.shield = ba.newnode(
656                'shield',
657                owner=self.node,
658                attrs={'color': (0.3, 0.2, 2.0), 'radius': 1.3},
659            )
660            self.node.connectattr('position_center', self.shield, 'position')
661        self.shield_hitpoints = self.shield_hitpoints_max = 650
662        self.shield_decay_rate = factory.shield_decay_rate if decay else 0
663        self.shield.hurt = 0
664        ba.playsound(factory.shield_up_sound, 1.0, position=self.node.position)
665
666        if self.shield_decay_rate > 0:
667            self.shield_decay_timer = ba.Timer(
668                0.5, ba.WeakCall(self.shield_decay), repeat=True
669            )
670            # So user can see the decay.
671            self.shield.always_show_health_bar = True

Give this spaz a nice energy shield.

def shield_decay(self) -> None:
673    def shield_decay(self) -> None:
674        """Called repeatedly to decay shield HP over time."""
675        if self.shield:
676            assert self.shield_hitpoints is not None
677            self.shield_hitpoints = max(
678                0, self.shield_hitpoints - self.shield_decay_rate
679            )
680            assert self.shield_hitpoints is not None
681            self.shield.hurt = (
682                1.0 - float(self.shield_hitpoints) / self.shield_hitpoints_max
683            )
684            if self.shield_hitpoints <= 0:
685                self.shield.delete()
686                self.shield = None
687                self.shield_decay_timer = None
688                assert self.node
689                ba.playsound(
690                    SpazFactory.get().shield_down_sound,
691                    1.0,
692                    position=self.node.position,
693                )
694        else:
695            self.shield_decay_timer = None

Called repeatedly to decay shield HP over time.

def handlemessage(self, msg: Any) -> Any:
 697    def handlemessage(self, msg: Any) -> Any:
 698        # pylint: disable=too-many-return-statements
 699        # pylint: disable=too-many-statements
 700        # pylint: disable=too-many-branches
 701        assert not self.expired
 702
 703        if isinstance(msg, ba.PickedUpMessage):
 704            if self.node:
 705                self.node.handlemessage('hurt_sound')
 706                self.node.handlemessage('picked_up')
 707
 708            # This counts as a hit.
 709            self._num_times_hit += 1
 710
 711        elif isinstance(msg, ba.ShouldShatterMessage):
 712            # Eww; seems we have to do this in a timer or it wont work right.
 713            # (since we're getting called from within update() perhaps?..)
 714            # NOTE: should test to see if that's still the case.
 715            ba.timer(0.001, ba.WeakCall(self.shatter))
 716
 717        elif isinstance(msg, ba.ImpactDamageMessage):
 718            # Eww; seems we have to do this in a timer or it wont work right.
 719            # (since we're getting called from within update() perhaps?..)
 720            ba.timer(0.001, ba.WeakCall(self._hit_self, msg.intensity))
 721
 722        elif isinstance(msg, ba.PowerupMessage):
 723            if self._dead or not self.node:
 724                return True
 725            if self.pick_up_powerup_callback is not None:
 726                self.pick_up_powerup_callback(self)
 727            if msg.poweruptype == 'triple_bombs':
 728                tex = PowerupBoxFactory.get().tex_bomb
 729                self._flash_billboard(tex)
 730                self.set_bomb_count(3)
 731                if self.powerups_expire:
 732                    self.node.mini_billboard_1_texture = tex
 733                    t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 734                    assert isinstance(t_ms, int)
 735                    self.node.mini_billboard_1_start_time = t_ms
 736                    self.node.mini_billboard_1_end_time = (
 737                        t_ms + POWERUP_WEAR_OFF_TIME
 738                    )
 739                    self._multi_bomb_wear_off_flash_timer = ba.Timer(
 740                        (POWERUP_WEAR_OFF_TIME - 2000),
 741                        ba.WeakCall(self._multi_bomb_wear_off_flash),
 742                        timeformat=ba.TimeFormat.MILLISECONDS,
 743                    )
 744                    self._multi_bomb_wear_off_timer = ba.Timer(
 745                        POWERUP_WEAR_OFF_TIME,
 746                        ba.WeakCall(self._multi_bomb_wear_off),
 747                        timeformat=ba.TimeFormat.MILLISECONDS,
 748                    )
 749            elif msg.poweruptype == 'land_mines':
 750                self.set_land_mine_count(min(self.land_mine_count + 3, 3))
 751            elif msg.poweruptype == 'impact_bombs':
 752                self.bomb_type = 'impact'
 753                tex = self._get_bomb_type_tex()
 754                self._flash_billboard(tex)
 755                if self.powerups_expire:
 756                    self.node.mini_billboard_2_texture = tex
 757                    t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 758                    assert isinstance(t_ms, int)
 759                    self.node.mini_billboard_2_start_time = t_ms
 760                    self.node.mini_billboard_2_end_time = (
 761                        t_ms + POWERUP_WEAR_OFF_TIME
 762                    )
 763                    self._bomb_wear_off_flash_timer = ba.Timer(
 764                        POWERUP_WEAR_OFF_TIME - 2000,
 765                        ba.WeakCall(self._bomb_wear_off_flash),
 766                        timeformat=ba.TimeFormat.MILLISECONDS,
 767                    )
 768                    self._bomb_wear_off_timer = ba.Timer(
 769                        POWERUP_WEAR_OFF_TIME,
 770                        ba.WeakCall(self._bomb_wear_off),
 771                        timeformat=ba.TimeFormat.MILLISECONDS,
 772                    )
 773            elif msg.poweruptype == 'sticky_bombs':
 774                self.bomb_type = 'sticky'
 775                tex = self._get_bomb_type_tex()
 776                self._flash_billboard(tex)
 777                if self.powerups_expire:
 778                    self.node.mini_billboard_2_texture = tex
 779                    t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 780                    assert isinstance(t_ms, int)
 781                    self.node.mini_billboard_2_start_time = t_ms
 782                    self.node.mini_billboard_2_end_time = (
 783                        t_ms + POWERUP_WEAR_OFF_TIME
 784                    )
 785                    self._bomb_wear_off_flash_timer = ba.Timer(
 786                        POWERUP_WEAR_OFF_TIME - 2000,
 787                        ba.WeakCall(self._bomb_wear_off_flash),
 788                        timeformat=ba.TimeFormat.MILLISECONDS,
 789                    )
 790                    self._bomb_wear_off_timer = ba.Timer(
 791                        POWERUP_WEAR_OFF_TIME,
 792                        ba.WeakCall(self._bomb_wear_off),
 793                        timeformat=ba.TimeFormat.MILLISECONDS,
 794                    )
 795            elif msg.poweruptype == 'punch':
 796                tex = PowerupBoxFactory.get().tex_punch
 797                self._flash_billboard(tex)
 798                self.equip_boxing_gloves()
 799                if self.powerups_expire and not self.default_boxing_gloves:
 800                    self.node.boxing_gloves_flashing = False
 801                    self.node.mini_billboard_3_texture = tex
 802                    t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 803                    assert isinstance(t_ms, int)
 804                    self.node.mini_billboard_3_start_time = t_ms
 805                    self.node.mini_billboard_3_end_time = (
 806                        t_ms + POWERUP_WEAR_OFF_TIME
 807                    )
 808                    self._boxing_gloves_wear_off_flash_timer = ba.Timer(
 809                        POWERUP_WEAR_OFF_TIME - 2000,
 810                        ba.WeakCall(self._gloves_wear_off_flash),
 811                        timeformat=ba.TimeFormat.MILLISECONDS,
 812                    )
 813                    self._boxing_gloves_wear_off_timer = ba.Timer(
 814                        POWERUP_WEAR_OFF_TIME,
 815                        ba.WeakCall(self._gloves_wear_off),
 816                        timeformat=ba.TimeFormat.MILLISECONDS,
 817                    )
 818            elif msg.poweruptype == 'shield':
 819                factory = SpazFactory.get()
 820
 821                # Let's allow powerup-equipped shields to lose hp over time.
 822                self.equip_shields(decay=factory.shield_decay_rate > 0)
 823            elif msg.poweruptype == 'curse':
 824                self.curse()
 825            elif msg.poweruptype == 'ice_bombs':
 826                self.bomb_type = 'ice'
 827                tex = self._get_bomb_type_tex()
 828                self._flash_billboard(tex)
 829                if self.powerups_expire:
 830                    self.node.mini_billboard_2_texture = tex
 831                    t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 832                    assert isinstance(t_ms, int)
 833                    self.node.mini_billboard_2_start_time = t_ms
 834                    self.node.mini_billboard_2_end_time = (
 835                        t_ms + POWERUP_WEAR_OFF_TIME
 836                    )
 837                    self._bomb_wear_off_flash_timer = ba.Timer(
 838                        POWERUP_WEAR_OFF_TIME - 2000,
 839                        ba.WeakCall(self._bomb_wear_off_flash),
 840                        timeformat=ba.TimeFormat.MILLISECONDS,
 841                    )
 842                    self._bomb_wear_off_timer = ba.Timer(
 843                        POWERUP_WEAR_OFF_TIME,
 844                        ba.WeakCall(self._bomb_wear_off),
 845                        timeformat=ba.TimeFormat.MILLISECONDS,
 846                    )
 847            elif msg.poweruptype == 'health':
 848                if self._cursed:
 849                    self._cursed = False
 850
 851                    # Remove cursed material.
 852                    factory = SpazFactory.get()
 853                    for attr in ['materials', 'roller_materials']:
 854                        materials = getattr(self.node, attr)
 855                        if factory.curse_material in materials:
 856                            setattr(
 857                                self.node,
 858                                attr,
 859                                tuple(
 860                                    m
 861                                    for m in materials
 862                                    if m != factory.curse_material
 863                                ),
 864                            )
 865                    self.node.curse_death_time = 0
 866                self.hitpoints = self.hitpoints_max
 867                self._flash_billboard(PowerupBoxFactory.get().tex_health)
 868                self.node.hurt = 0
 869                self._last_hit_time = None
 870                self._num_times_hit = 0
 871
 872            self.node.handlemessage('flash')
 873            if msg.sourcenode:
 874                msg.sourcenode.handlemessage(ba.PowerupAcceptMessage())
 875            return True
 876
 877        elif isinstance(msg, ba.FreezeMessage):
 878            if not self.node:
 879                return None
 880            if self.node.invincible:
 881                ba.playsound(
 882                    SpazFactory.get().block_sound,
 883                    1.0,
 884                    position=self.node.position,
 885                )
 886                return None
 887            if self.shield:
 888                return None
 889            if not self.frozen:
 890                self.frozen = True
 891                self.node.frozen = True
 892                ba.timer(5.0, ba.WeakCall(self.handlemessage, ba.ThawMessage()))
 893                # Instantly shatter if we're already dead.
 894                # (otherwise its hard to tell we're dead)
 895                if self.hitpoints <= 0:
 896                    self.shatter()
 897
 898        elif isinstance(msg, ba.ThawMessage):
 899            if self.frozen and not self.shattered and self.node:
 900                self.frozen = False
 901                self.node.frozen = False
 902
 903        elif isinstance(msg, ba.HitMessage):
 904            if not self.node:
 905                return None
 906            if self.node.invincible:
 907                ba.playsound(
 908                    SpazFactory.get().block_sound,
 909                    1.0,
 910                    position=self.node.position,
 911                )
 912                return True
 913
 914            # If we were recently hit, don't count this as another.
 915            # (so punch flurries and bomb pileups essentially count as 1 hit)
 916            local_time = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 917            assert isinstance(local_time, int)
 918            if (
 919                self._last_hit_time is None
 920                or local_time - self._last_hit_time > 1000
 921            ):
 922                self._num_times_hit += 1
 923                self._last_hit_time = local_time
 924
 925            mag = msg.magnitude * self.impact_scale
 926            velocity_mag = msg.velocity_magnitude * self.impact_scale
 927            damage_scale = 0.22
 928
 929            # If they've got a shield, deliver it to that instead.
 930            if self.shield:
 931                if msg.flat_damage:
 932                    damage = msg.flat_damage * self.impact_scale
 933                else:
 934                    # Hit our spaz with an impulse but tell it to only return
 935                    # theoretical damage; not apply the impulse.
 936                    assert msg.force_direction is not None
 937                    self.node.handlemessage(
 938                        'impulse',
 939                        msg.pos[0],
 940                        msg.pos[1],
 941                        msg.pos[2],
 942                        msg.velocity[0],
 943                        msg.velocity[1],
 944                        msg.velocity[2],
 945                        mag,
 946                        velocity_mag,
 947                        msg.radius,
 948                        1,
 949                        msg.force_direction[0],
 950                        msg.force_direction[1],
 951                        msg.force_direction[2],
 952                    )
 953                    damage = damage_scale * self.node.damage
 954
 955                assert self.shield_hitpoints is not None
 956                self.shield_hitpoints -= int(damage)
 957                self.shield.hurt = (
 958                    1.0
 959                    - float(self.shield_hitpoints) / self.shield_hitpoints_max
 960                )
 961
 962                # Its a cleaner event if a hit just kills the shield
 963                # without damaging the player.
 964                # However, massive damage events should still be able to
 965                # damage the player. This hopefully gives us a happy medium.
 966                max_spillover = SpazFactory.get().max_shield_spillover_damage
 967                if self.shield_hitpoints <= 0:
 968
 969                    # FIXME: Transition out perhaps?
 970                    self.shield.delete()
 971                    self.shield = None
 972                    ba.playsound(
 973                        SpazFactory.get().shield_down_sound,
 974                        1.0,
 975                        position=self.node.position,
 976                    )
 977
 978                    # Emit some cool looking sparks when the shield dies.
 979                    npos = self.node.position
 980                    ba.emitfx(
 981                        position=(npos[0], npos[1] + 0.9, npos[2]),
 982                        velocity=self.node.velocity,
 983                        count=random.randrange(20, 30),
 984                        scale=1.0,
 985                        spread=0.6,
 986                        chunk_type='spark',
 987                    )
 988
 989                else:
 990                    ba.playsound(
 991                        SpazFactory.get().shield_hit_sound,
 992                        0.5,
 993                        position=self.node.position,
 994                    )
 995
 996                # Emit some cool looking sparks on shield hit.
 997                assert msg.force_direction is not None
 998                ba.emitfx(
 999                    position=msg.pos,
1000                    velocity=(
1001                        msg.force_direction[0] * 1.0,
1002                        msg.force_direction[1] * 1.0,
1003                        msg.force_direction[2] * 1.0,
1004                    ),
1005                    count=min(30, 5 + int(damage * 0.005)),
1006                    scale=0.5,
1007                    spread=0.3,
1008                    chunk_type='spark',
1009                )
1010
1011                # If they passed our spillover threshold,
1012                # pass damage along to spaz.
1013                if self.shield_hitpoints <= -max_spillover:
1014                    leftover_damage = -max_spillover - self.shield_hitpoints
1015                    shield_leftover_ratio = leftover_damage / damage
1016
1017                    # Scale down the magnitudes applied to spaz accordingly.
1018                    mag *= shield_leftover_ratio
1019                    velocity_mag *= shield_leftover_ratio
1020                else:
1021                    return True  # Good job shield!
1022            else:
1023                shield_leftover_ratio = 1.0
1024
1025            if msg.flat_damage:
1026                damage = int(
1027                    msg.flat_damage * self.impact_scale * shield_leftover_ratio
1028                )
1029            else:
1030                # Hit it with an impulse and get the resulting damage.
1031                assert msg.force_direction is not None
1032                self.node.handlemessage(
1033                    'impulse',
1034                    msg.pos[0],
1035                    msg.pos[1],
1036                    msg.pos[2],
1037                    msg.velocity[0],
1038                    msg.velocity[1],
1039                    msg.velocity[2],
1040                    mag,
1041                    velocity_mag,
1042                    msg.radius,
1043                    0,
1044                    msg.force_direction[0],
1045                    msg.force_direction[1],
1046                    msg.force_direction[2],
1047                )
1048
1049                damage = int(damage_scale * self.node.damage)
1050            self.node.handlemessage('hurt_sound')
1051
1052            # Play punch impact sound based on damage if it was a punch.
1053            if msg.hit_type == 'punch':
1054                self.on_punched(damage)
1055
1056                # If damage was significant, lets show it.
1057                if damage >= 350:
1058                    assert msg.force_direction is not None
1059                    ba.show_damage_count(
1060                        '-' + str(int(damage / 10)) + '%',
1061                        msg.pos,
1062                        msg.force_direction,
1063                    )
1064
1065                # Let's always add in a super-punch sound with boxing
1066                # gloves just to differentiate them.
1067                if msg.hit_subtype == 'super_punch':
1068                    ba.playsound(
1069                        SpazFactory.get().punch_sound_stronger,
1070                        1.0,
1071                        position=self.node.position,
1072                    )
1073                if damage >= 500:
1074                    sounds = SpazFactory.get().punch_sound_strong
1075                    sound = sounds[random.randrange(len(sounds))]
1076                elif damage >= 100:
1077                    sound = SpazFactory.get().punch_sound
1078                else:
1079                    sound = SpazFactory.get().punch_sound_weak
1080                ba.playsound(sound, 1.0, position=self.node.position)
1081
1082                # Throw up some chunks.
1083                assert msg.force_direction is not None
1084                ba.emitfx(
1085                    position=msg.pos,
1086                    velocity=(
1087                        msg.force_direction[0] * 0.5,
1088                        msg.force_direction[1] * 0.5,
1089                        msg.force_direction[2] * 0.5,
1090                    ),
1091                    count=min(10, 1 + int(damage * 0.0025)),
1092                    scale=0.3,
1093                    spread=0.03,
1094                )
1095
1096                ba.emitfx(
1097                    position=msg.pos,
1098                    chunk_type='sweat',
1099                    velocity=(
1100                        msg.force_direction[0] * 1.3,
1101                        msg.force_direction[1] * 1.3 + 5.0,
1102                        msg.force_direction[2] * 1.3,
1103                    ),
1104                    count=min(30, 1 + int(damage * 0.04)),
1105                    scale=0.9,
1106                    spread=0.28,
1107                )
1108
1109                # Momentary flash.
1110                hurtiness = damage * 0.003
1111                punchpos = (
1112                    msg.pos[0] + msg.force_direction[0] * 0.02,
1113                    msg.pos[1] + msg.force_direction[1] * 0.02,
1114                    msg.pos[2] + msg.force_direction[2] * 0.02,
1115                )
1116                flash_color = (1.0, 0.8, 0.4)
1117                light = ba.newnode(
1118                    'light',
1119                    attrs={
1120                        'position': punchpos,
1121                        'radius': 0.12 + hurtiness * 0.12,
1122                        'intensity': 0.3 * (1.0 + 1.0 * hurtiness),
1123                        'height_attenuated': False,
1124                        'color': flash_color,
1125                    },
1126                )
1127                ba.timer(0.06, light.delete)
1128
1129                flash = ba.newnode(
1130                    'flash',
1131                    attrs={
1132                        'position': punchpos,
1133                        'size': 0.17 + 0.17 * hurtiness,
1134                        'color': flash_color,
1135                    },
1136                )
1137                ba.timer(0.06, flash.delete)
1138
1139            if msg.hit_type == 'impact':
1140                assert msg.force_direction is not None
1141                ba.emitfx(
1142                    position=msg.pos,
1143                    velocity=(
1144                        msg.force_direction[0] * 2.0,
1145                        msg.force_direction[1] * 2.0,
1146                        msg.force_direction[2] * 2.0,
1147                    ),
1148                    count=min(10, 1 + int(damage * 0.01)),
1149                    scale=0.4,
1150                    spread=0.1,
1151                )
1152            if self.hitpoints > 0:
1153
1154                # It's kinda crappy to die from impacts, so lets reduce
1155                # impact damage by a reasonable amount *if* it'll keep us alive
1156                if msg.hit_type == 'impact' and damage > self.hitpoints:
1157                    # Drop damage to whatever puts us at 10 hit points,
1158                    # or 200 less than it used to be whichever is greater
1159                    # (so it *can* still kill us if its high enough)
1160                    newdamage = max(damage - 200, self.hitpoints - 10)
1161                    damage = newdamage
1162                self.node.handlemessage('flash')
1163
1164                # If we're holding something, drop it.
1165                if damage > 0.0 and self.node.hold_node:
1166                    self.node.hold_node = None
1167                self.hitpoints -= damage
1168                self.node.hurt = (
1169                    1.0 - float(self.hitpoints) / self.hitpoints_max
1170                )
1171
1172                # If we're cursed, *any* damage blows us up.
1173                if self._cursed and damage > 0:
1174                    ba.timer(
1175                        0.05,
1176                        ba.WeakCall(
1177                            self.curse_explode, msg.get_source_player(ba.Player)
1178                        ),
1179                    )
1180
1181                # If we're frozen, shatter.. otherwise die if we hit zero
1182                if self.frozen and (damage > 200 or self.hitpoints <= 0):
1183                    self.shatter()
1184                elif self.hitpoints <= 0:
1185                    self.node.handlemessage(
1186                        ba.DieMessage(how=ba.DeathType.IMPACT)
1187                    )
1188
1189            # If we're dead, take a look at the smoothed damage value
1190            # (which gives us a smoothed average of recent damage) and shatter
1191            # us if its grown high enough.
1192            if self.hitpoints <= 0:
1193                damage_avg = self.node.damage_smoothed * damage_scale
1194                if damage_avg >= 1000:
1195                    self.shatter()
1196
1197        elif isinstance(msg, BombDiedMessage):
1198            self.bomb_count += 1
1199
1200        elif isinstance(msg, ba.DieMessage):
1201            wasdead = self._dead
1202            self._dead = True
1203            self.hitpoints = 0
1204            if msg.immediate:
1205                if self.node:
1206                    self.node.delete()
1207            elif self.node:
1208                self.node.hurt = 1.0
1209                if self.play_big_death_sound and not wasdead:
1210                    ba.playsound(SpazFactory.get().single_player_death_sound)
1211                self.node.dead = True
1212                ba.timer(2.0, self.node.delete)
1213
1214        elif isinstance(msg, ba.OutOfBoundsMessage):
1215            # By default we just die here.
1216            self.handlemessage(ba.DieMessage(how=ba.DeathType.FALL))
1217
1218        elif isinstance(msg, ba.StandMessage):
1219            self._last_stand_pos = (
1220                msg.position[0],
1221                msg.position[1],
1222                msg.position[2],
1223            )
1224            if self.node:
1225                self.node.handlemessage(
1226                    'stand',
1227                    msg.position[0],
1228                    msg.position[1],
1229                    msg.position[2],
1230                    msg.angle,
1231                )
1232
1233        elif isinstance(msg, CurseExplodeMessage):
1234            self.curse_explode()
1235
1236        elif isinstance(msg, PunchHitMessage):
1237            if not self.node:
1238                return None
1239            node = ba.getcollision().opposingnode
1240
1241            # Only allow one hit per node per punch.
1242            if node and (node not in self._punched_nodes):
1243
1244                punch_momentum_angular = (
1245                    self.node.punch_momentum_angular * self._punch_power_scale
1246                )
1247                punch_power = self.node.punch_power * self._punch_power_scale
1248
1249                # Ok here's the deal:  we pass along our base velocity for use
1250                # in the impulse damage calculations since that is a more
1251                # predictable value than our fist velocity, which is rather
1252                # erratic. However, we want to actually apply force in the
1253                # direction our fist is moving so it looks better. So we still
1254                # pass that along as a direction. Perhaps a time-averaged
1255                # fist-velocity would work too?.. perhaps should try that.
1256
1257                # If its something besides another spaz, just do a muffled
1258                # punch sound.
1259                if node.getnodetype() != 'spaz':
1260                    sounds = SpazFactory.get().impact_sounds_medium
1261                    sound = sounds[random.randrange(len(sounds))]
1262                    ba.playsound(sound, 1.0, position=self.node.position)
1263
1264                ppos = self.node.punch_position
1265                punchdir = self.node.punch_velocity
1266                vel = self.node.punch_momentum_linear
1267
1268                self._punched_nodes.add(node)
1269                node.handlemessage(
1270                    ba.HitMessage(
1271                        pos=ppos,
1272                        velocity=vel,
1273                        magnitude=punch_power * punch_momentum_angular * 110.0,
1274                        velocity_magnitude=punch_power * 40,
1275                        radius=0,
1276                        srcnode=self.node,
1277                        source_player=self.source_player,
1278                        force_direction=punchdir,
1279                        hit_type='punch',
1280                        hit_subtype=(
1281                            'super_punch'
1282                            if self._has_boxing_gloves
1283                            else 'default'
1284                        ),
1285                    )
1286                )
1287
1288                # Also apply opposite to ourself for the first punch only.
1289                # This is given as a constant force so that it is more
1290                # noticeable for slower punches where it matters. For fast
1291                # awesome looking punches its ok if we punch 'through'
1292                # the target.
1293                mag = -400.0
1294                if self._hockey:
1295                    mag *= 0.5
1296                if len(self._punched_nodes) == 1:
1297                    self.node.handlemessage(
1298                        'kick_back',
1299                        ppos[0],
1300                        ppos[1],
1301                        ppos[2],
1302                        punchdir[0],
1303                        punchdir[1],
1304                        punchdir[2],
1305                        mag,
1306                    )
1307        elif isinstance(msg, PickupMessage):
1308            if not self.node:
1309                return None
1310
1311            try:
1312                collision = ba.getcollision()
1313                opposingnode = collision.opposingnode
1314                opposingbody = collision.opposingbody
1315            except ba.NotFoundError:
1316                return True
1317
1318            # Don't allow picking up of invincible dudes.
1319            try:
1320                if opposingnode.invincible:
1321                    return True
1322            except Exception:
1323                pass
1324
1325            # If we're grabbing the pelvis of a non-shattered spaz, we wanna
1326            # grab the torso instead.
1327            if (
1328                opposingnode.getnodetype() == 'spaz'
1329                and not opposingnode.shattered
1330                and opposingbody == 4
1331            ):
1332                opposingbody = 1
1333
1334            # Special case - if we're holding a flag, don't replace it
1335            # (hmm - should make this customizable or more low level).
1336            held = self.node.hold_node
1337            if held and held.getnodetype() == 'flag':
1338                return True
1339
1340            # Note: hold_body needs to be set before hold_node.
1341            self.node.hold_body = opposingbody
1342            self.node.hold_node = opposingnode
1343        elif isinstance(msg, ba.CelebrateMessage):
1344            if self.node:
1345                self.node.handlemessage('celebrate', int(msg.duration * 1000))
1346
1347        else:
1348            return super().handlemessage(msg)
1349        return None

General message handling; can be passed any message object.

def drop_bomb(self) -> bastd.actor.bomb.Bomb | None:
1351    def drop_bomb(self) -> stdbomb.Bomb | None:
1352        """
1353        Tell the spaz to drop one of his bombs, and returns
1354        the resulting bomb object.
1355        If the spaz has no bombs or is otherwise unable to
1356        drop a bomb, returns None.
1357        """
1358
1359        if (self.land_mine_count <= 0 and self.bomb_count <= 0) or self.frozen:
1360            return None
1361        assert self.node
1362        pos = self.node.position_forward
1363        vel = self.node.velocity
1364
1365        if self.land_mine_count > 0:
1366            dropping_bomb = False
1367            self.set_land_mine_count(self.land_mine_count - 1)
1368            bomb_type = 'land_mine'
1369        else:
1370            dropping_bomb = True
1371            bomb_type = self.bomb_type
1372
1373        bomb = stdbomb.Bomb(
1374            position=(pos[0], pos[1] - 0.0, pos[2]),
1375            velocity=(vel[0], vel[1], vel[2]),
1376            bomb_type=bomb_type,
1377            blast_radius=self.blast_radius,
1378            source_player=self.source_player,
1379            owner=self.node,
1380        ).autoretain()
1381
1382        assert bomb.node
1383        if dropping_bomb:
1384            self.bomb_count -= 1
1385            bomb.node.add_death_action(
1386                ba.WeakCall(self.handlemessage, BombDiedMessage())
1387            )
1388        self._pick_up(bomb.node)
1389
1390        for clb in self._dropped_bomb_callbacks:
1391            clb(self, bomb)
1392
1393        return bomb

Tell the spaz to drop one of his bombs, and returns the resulting bomb object. If the spaz has no bombs or is otherwise unable to drop a bomb, returns None.

def set_land_mine_count(self, count: int) -> None:
1401    def set_land_mine_count(self, count: int) -> None:
1402        """Set the number of land-mines this spaz is carrying."""
1403        self.land_mine_count = count
1404        if self.node:
1405            if self.land_mine_count != 0:
1406                self.node.counter_text = 'x' + str(self.land_mine_count)
1407                self.node.counter_texture = (
1408                    PowerupBoxFactory.get().tex_land_mines
1409                )
1410            else:
1411                self.node.counter_text = ''

Set the number of land-mines this spaz is carrying.

def curse_explode(self, source_player: ba._player.Player | None = None) -> None:
1413    def curse_explode(self, source_player: ba.Player | None = None) -> None:
1414        """Explode the poor spaz spectacularly."""
1415        if self._cursed and self.node:
1416            self.shatter(extreme=True)
1417            self.handlemessage(ba.DieMessage())
1418            activity = self._activity()
1419            if activity:
1420                stdbomb.Blast(
1421                    position=self.node.position,
1422                    velocity=self.node.velocity,
1423                    blast_radius=3.0,
1424                    blast_type='normal',
1425                    source_player=(
1426                        source_player if source_player else self.source_player
1427                    ),
1428                ).autoretain()
1429            self._cursed = False

Explode the poor spaz spectacularly.

def shatter(self, extreme: bool = False) -> None:
1431    def shatter(self, extreme: bool = False) -> None:
1432        """Break the poor spaz into little bits."""
1433        if self.shattered:
1434            return
1435        self.shattered = True
1436        assert self.node
1437        if self.frozen:
1438            # Momentary flash of light.
1439            light = ba.newnode(
1440                'light',
1441                attrs={
1442                    'position': self.node.position,
1443                    'radius': 0.5,
1444                    'height_attenuated': False,
1445                    'color': (0.8, 0.8, 1.0),
1446                },
1447            )
1448
1449            ba.animate(
1450                light, 'intensity', {0.0: 3.0, 0.04: 0.5, 0.08: 0.07, 0.3: 0}
1451            )
1452            ba.timer(0.3, light.delete)
1453
1454            # Emit ice chunks.
1455            ba.emitfx(
1456                position=self.node.position,
1457                velocity=self.node.velocity,
1458                count=int(random.random() * 10.0 + 10.0),
1459                scale=0.6,
1460                spread=0.2,
1461                chunk_type='ice',
1462            )
1463            ba.emitfx(
1464                position=self.node.position,
1465                velocity=self.node.velocity,
1466                count=int(random.random() * 10.0 + 10.0),
1467                scale=0.3,
1468                spread=0.2,
1469                chunk_type='ice',
1470            )
1471            ba.playsound(
1472                SpazFactory.get().shatter_sound,
1473                1.0,
1474                position=self.node.position,
1475            )
1476        else:
1477            ba.playsound(
1478                SpazFactory.get().splatter_sound,
1479                1.0,
1480                position=self.node.position,
1481            )
1482        self.handlemessage(ba.DieMessage())
1483        self.node.shattered = 2 if extreme else 1

Break the poor spaz into little bits.

def set_bomb_count(self, count: int) -> None:
1528    def set_bomb_count(self, count: int) -> None:
1529        """Sets the number of bombs this Spaz has."""
1530        # We can't just set bomb_count because some bombs may be laid currently
1531        # so we have to do a relative diff based on max.
1532        diff = count - self._max_bomb_count
1533        self._max_bomb_count += diff
1534        self.bomb_count += diff

Sets the number of bombs this Spaz has.

Inherited Members
ba._actor.Actor
autoretain
expired
activity
getactivity