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

We wanna pick something up.

class PunchHitMessage:
34class PunchHitMessage:
35    """Message saying an object was hit."""

Message saying an object was hit.

class CurseExplodeMessage:
38class CurseExplodeMessage:
39    """We are cursed and should blow up now."""

We are cursed and should blow up now.

class BombDiedMessage:
42class BombDiedMessage:
43    """A bomb has died and thus can be recycled."""

A bomb has died and thus can be recycled.

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

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

node: _bascenev1.Node

The 'spaz' bs.Node.

points_mult = 1
curse_time: float | None = 5.0
default_bomb_count = 1
default_bomb_type = 'normal'
default_boxing_gloves = False
default_shields = False
default_hitpoints = 1000
play_big_death_sound
impact_scale
source_player
fly
shield: _bascenev1.Node | None
hitpoints
hitpoints_max
shield_hitpoints: int | None
shield_hitpoints_max
shield_decay_rate
shield_decay_timer: _bascenev1.Timer | None
bomb_count
bomb_type_default
bomb_type
land_mine_count
blast_radius
powerups_expire
last_punch_time_ms
last_pickup_time_ms
last_jump_time_ms
last_run_time_ms
last_bomb_time_ms
frozen
shattered
punch_callback: Optional[Callable[[Spaz], Any]]
pick_up_powerup_callback: Optional[Callable[[Spaz], Any]]
@override
def exists(self) -> bool:
234    @override
235    def exists(self) -> bool:
236        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 bascenev1.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 bascenev1.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.

@override
def on_expire(self) -> None:
238    @override
239    def on_expire(self) -> None:
240        super().on_expire()
241
242        # Release callbacks/refs so we don't wind up with dependency loops.
243        self._dropped_bomb_callbacks = []
244        self.punch_callback = None
245        self.pick_up_powerup_callback = None

Called for remaining bascenev1.Actors when their activity dies.

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

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

def add_dropped_bomb_callback( self, call: Callable[[Spaz, bascenev1._actor.Actor], Any]) -> None:
247    def add_dropped_bomb_callback(
248        self, call: Callable[[Spaz, bs.Actor], Any]
249    ) -> None:
250        """
251        Add a call to be run whenever this Spaz drops a bomb.
252        The spaz and the newly-dropped bomb are passed as arguments.
253        """
254        assert not self.expired
255        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.

@override
def is_alive(self) -> bool:
257    @override
258    def is_alive(self) -> bool:
259        """
260        Method override; returns whether ol' spaz is still kickin'.
261        """
262        return not self._dead

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

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

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:
384    def on_jump_press(self) -> None:
385        """
386        Called to 'press jump' on this spaz;
387        used by player or AI connections.
388        """
389        if not self.node:
390            return
391        t_ms = int(bs.time() * 1000.0)
392        assert isinstance(t_ms, int)
393        if t_ms - self.last_jump_time_ms >= self._jump_cooldown:
394            self.node.jump_pressed = True
395            self.last_jump_time_ms = t_ms
396        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:
398    def on_jump_release(self) -> None:
399        """
400        Called to 'release jump' on this spaz;
401        used by player or AI connections.
402        """
403        if not self.node:
404            return
405        self.node.jump_pressed = False

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

def on_pickup_press(self) -> None:
407    def on_pickup_press(self) -> None:
408        """
409        Called to 'press pick-up' on this spaz;
410        used by player or AI connections.
411        """
412        if not self.node:
413            return
414        t_ms = int(bs.time() * 1000.0)
415        assert isinstance(t_ms, int)
416        if t_ms - self.last_pickup_time_ms >= self._pickup_cooldown:
417            self.node.pickup_pressed = True
418            self.last_pickup_time_ms = t_ms
419        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:
421    def on_pickup_release(self) -> None:
422        """
423        Called to 'release pick-up' on this spaz;
424        used by player or AI connections.
425        """
426        if not self.node:
427            return
428        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:
430    def on_hold_position_press(self) -> None:
431        """
432        Called to 'press hold-position' on this spaz;
433        used for player or AI connections.
434        """
435        if not self.node:
436            return
437        self.node.hold_position_pressed = True
438        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:
440    def on_hold_position_release(self) -> None:
441        """
442        Called to 'release hold-position' on this spaz;
443        used for player or AI connections.
444        """
445        if not self.node:
446            return
447        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:
449    def on_punch_press(self) -> None:
450        """
451        Called to 'press punch' on this spaz;
452        used for player or AI connections.
453        """
454        if not self.node or self.frozen or self.node.knockout > 0.0:
455            return
456        t_ms = int(bs.time() * 1000.0)
457        assert isinstance(t_ms, int)
458        if t_ms - self.last_punch_time_ms >= self._punch_cooldown:
459            if self.punch_callback is not None:
460                self.punch_callback(self)
461            self._punched_nodes = set()  # Reset this.
462            self.last_punch_time_ms = t_ms
463            self.node.punch_pressed = True
464            if not self.node.hold_node:
465                bs.timer(
466                    0.1,
467                    bs.WeakCall(
468                        self._safe_play_sound,
469                        SpazFactory.get().swish_sound,
470                        0.8,
471                    ),
472                )
473        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:
480    def on_punch_release(self) -> None:
481        """
482        Called to 'release punch' on this spaz;
483        used for player or AI connections.
484        """
485        if not self.node:
486            return
487        self.node.punch_pressed = False

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

def on_bomb_press(self) -> None:
489    def on_bomb_press(self) -> None:
490        """
491        Called to 'press bomb' on this spaz;
492        used for player or AI connections.
493        """
494        if (
495            not self.node
496            or self._dead
497            or self.frozen
498            or self.node.knockout > 0.0
499        ):
500            return
501        t_ms = int(bs.time() * 1000.0)
502        assert isinstance(t_ms, int)
503        if t_ms - self.last_bomb_time_ms >= self._bomb_cooldown:
504            self.last_bomb_time_ms = t_ms
505            self.node.bomb_pressed = True
506            if not self.node.hold_node:
507                self.drop_bomb()
508        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:
510    def on_bomb_release(self) -> None:
511        """
512        Called to 'release bomb' on this spaz;
513        used for player or AI connections.
514        """
515        if not self.node:
516            return
517        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:
519    def on_run(self, value: float) -> None:
520        """
521        Called to 'press run' on this spaz;
522        used for player or AI connections.
523        """
524        if not self.node:
525            return
526        t_ms = int(bs.time() * 1000.0)
527        assert isinstance(t_ms, int)
528        self.last_run_time_ms = t_ms
529        self.node.run = value
530
531        # Filtering these events would be tough since its an analog
532        # value, but lets still pass full 0-to-1 presses along to
533        # the turbo filter to punish players if it looks like they're turbo-ing.
534        if self._last_run_value < 0.01 and value > 0.99:
535            self._turbo_filter_add_press('run')
536
537        self._last_run_value = value

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

def on_fly_press(self) -> None:
539    def on_fly_press(self) -> None:
540        """
541        Called to 'press fly' on this spaz;
542        used for player or AI connections.
543        """
544        if not self.node:
545            return
546        # Not adding a cooldown time here for now; slightly worried
547        # input events get clustered up during net-games and we'd wind up
548        # killing a lot and making it hard to fly.. should look into this.
549        self.node.fly_pressed = True
550        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:
552    def on_fly_release(self) -> None:
553        """
554        Called to 'release fly' on this spaz;
555        used for player or AI connections.
556        """
557        if not self.node:
558            return
559        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:
561    def on_move(self, x: float, y: float) -> None:
562        """
563        Called to set the joystick amount for this spaz;
564        used for player or AI connections.
565        """
566        if not self.node:
567            return
568        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:
570    def on_move_up_down(self, value: float) -> None:
571        """
572        Called to set the up/down joystick amount on this spaz;
573        used for player or AI connections.
574        value will be between -32768 to 32767
575        WARNING: deprecated; use on_move instead.
576        """
577        if not self.node:
578            return
579        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:
581    def on_move_left_right(self, value: float) -> None:
582        """
583        Called to set the left/right joystick amount on this spaz;
584        used for player or AI connections.
585        value will be between -32768 to 32767
586        WARNING: deprecated; use on_move instead.
587        """
588        if not self.node:
589            return
590        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:
592    def on_punched(self, damage: int) -> None:
593        """Called when this spaz gets punched."""

Called when this spaz gets punched.

def get_death_points(self, how: bascenev1._messages.DeathType) -> tuple[int, int]:
595    def get_death_points(self, how: bs.DeathType) -> tuple[int, int]:
596        """Get the points awarded for killing this spaz."""
597        del how  # Unused.
598        num_hits = float(max(1, self._num_times_hit))
599
600        # Base points is simply 10 for 1-hit-kills and 5 otherwise.
601        importance = 2 if num_hits < 2 else 1
602        return (10 if num_hits < 2 else 5) * self.points_mult, importance

Get the points awarded for killing this spaz.

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

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

def equip_boxing_gloves(self) -> None:
637    def equip_boxing_gloves(self) -> None:
638        """
639        Give this spaz some boxing gloves.
640        """
641        assert self.node
642        self.node.boxing_gloves = True
643        self._has_boxing_gloves = True
644        if self._demo_mode:  # Preserve old behavior.
645            self._punch_power_scale = 1.7
646            self._punch_cooldown = 300
647        else:
648            factory = SpazFactory.get()
649            self._punch_power_scale = factory.punch_power_scale_gloves
650            self._punch_cooldown = factory.punch_cooldown_gloves

Give this spaz some boxing gloves.

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

Give this spaz a nice energy shield.

def shield_decay(self) -> None:
681    def shield_decay(self) -> None:
682        """Called repeatedly to decay shield HP over time."""
683        if self.shield:
684            assert self.shield_hitpoints is not None
685            self.shield_hitpoints = max(
686                0, self.shield_hitpoints - self.shield_decay_rate
687            )
688            assert self.shield_hitpoints is not None
689            self.shield.hurt = (
690                1.0 - float(self.shield_hitpoints) / self.shield_hitpoints_max
691            )
692            if self.shield_hitpoints <= 0:
693                self.shield.delete()
694                self.shield = None
695                self.shield_decay_timer = None
696                assert self.node
697                SpazFactory.get().shield_down_sound.play(
698                    1.0,
699                    position=self.node.position,
700                )
701        else:
702            self.shield_decay_timer = None

Called repeatedly to decay shield HP over time.

@override
def handlemessage(self, msg: Any) -> Any:
 704    @override
 705    def handlemessage(self, msg: Any) -> Any:
 706        # pylint: disable=too-many-return-statements
 707        # pylint: disable=too-many-statements
 708        # pylint: disable=too-many-branches
 709        assert not self.expired
 710
 711        if isinstance(msg, bs.PickedUpMessage):
 712            if self.node:
 713                self.node.handlemessage('hurt_sound')
 714                self.node.handlemessage('picked_up')
 715
 716            # This counts as a hit.
 717            self._num_times_hit += 1
 718
 719        elif isinstance(msg, bs.ShouldShatterMessage):
 720            # Eww; seems we have to do this in a timer or it wont work right.
 721            # (since we're getting called from within update() perhaps?..)
 722            # NOTE: should test to see if that's still the case.
 723            bs.timer(0.001, bs.WeakCall(self.shatter))
 724
 725        elif isinstance(msg, bs.ImpactDamageMessage):
 726            # Eww; seems we have to do this in a timer or it wont work right.
 727            # (since we're getting called from within update() perhaps?..)
 728            bs.timer(0.001, bs.WeakCall(self._hit_self, msg.intensity))
 729
 730        elif isinstance(msg, bs.PowerupMessage):
 731            if self._dead or not self.node:
 732                return True
 733            if self.pick_up_powerup_callback is not None:
 734                self.pick_up_powerup_callback(self)
 735            if msg.poweruptype == 'triple_bombs':
 736                tex = PowerupBoxFactory.get().tex_bomb
 737                self._flash_billboard(tex)
 738                self.set_bomb_count(3)
 739                if self.powerups_expire:
 740                    self.node.mini_billboard_1_texture = tex
 741                    t_ms = int(bs.time() * 1000.0)
 742                    assert isinstance(t_ms, int)
 743                    self.node.mini_billboard_1_start_time = t_ms
 744                    self.node.mini_billboard_1_end_time = (
 745                        t_ms + POWERUP_WEAR_OFF_TIME
 746                    )
 747                    self._multi_bomb_wear_off_flash_timer = bs.Timer(
 748                        (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0,
 749                        bs.WeakCall(self._multi_bomb_wear_off_flash),
 750                    )
 751                    self._multi_bomb_wear_off_timer = bs.Timer(
 752                        POWERUP_WEAR_OFF_TIME / 1000.0,
 753                        bs.WeakCall(self._multi_bomb_wear_off),
 754                    )
 755            elif msg.poweruptype == 'land_mines':
 756                self.set_land_mine_count(min(self.land_mine_count + 3, 3))
 757            elif msg.poweruptype == 'impact_bombs':
 758                self.bomb_type = 'impact'
 759                tex = self._get_bomb_type_tex()
 760                self._flash_billboard(tex)
 761                if self.powerups_expire:
 762                    self.node.mini_billboard_2_texture = tex
 763                    t_ms = int(bs.time() * 1000.0)
 764                    assert isinstance(t_ms, int)
 765                    self.node.mini_billboard_2_start_time = t_ms
 766                    self.node.mini_billboard_2_end_time = (
 767                        t_ms + POWERUP_WEAR_OFF_TIME
 768                    )
 769                    self._bomb_wear_off_flash_timer = bs.Timer(
 770                        (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0,
 771                        bs.WeakCall(self._bomb_wear_off_flash),
 772                    )
 773                    self._bomb_wear_off_timer = bs.Timer(
 774                        POWERUP_WEAR_OFF_TIME / 1000.0,
 775                        bs.WeakCall(self._bomb_wear_off),
 776                    )
 777            elif msg.poweruptype == 'sticky_bombs':
 778                self.bomb_type = 'sticky'
 779                tex = self._get_bomb_type_tex()
 780                self._flash_billboard(tex)
 781                if self.powerups_expire:
 782                    self.node.mini_billboard_2_texture = tex
 783                    t_ms = int(bs.time() * 1000.0)
 784                    assert isinstance(t_ms, int)
 785                    self.node.mini_billboard_2_start_time = t_ms
 786                    self.node.mini_billboard_2_end_time = (
 787                        t_ms + POWERUP_WEAR_OFF_TIME
 788                    )
 789                    self._bomb_wear_off_flash_timer = bs.Timer(
 790                        (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0,
 791                        bs.WeakCall(self._bomb_wear_off_flash),
 792                    )
 793                    self._bomb_wear_off_timer = bs.Timer(
 794                        POWERUP_WEAR_OFF_TIME / 1000.0,
 795                        bs.WeakCall(self._bomb_wear_off),
 796                    )
 797            elif msg.poweruptype == 'punch':
 798                tex = PowerupBoxFactory.get().tex_punch
 799                self._flash_billboard(tex)
 800                self.equip_boxing_gloves()
 801                if self.powerups_expire and not self.default_boxing_gloves:
 802                    self.node.boxing_gloves_flashing = False
 803                    self.node.mini_billboard_3_texture = tex
 804                    t_ms = int(bs.time() * 1000.0)
 805                    assert isinstance(t_ms, int)
 806                    self.node.mini_billboard_3_start_time = t_ms
 807                    self.node.mini_billboard_3_end_time = (
 808                        t_ms + POWERUP_WEAR_OFF_TIME
 809                    )
 810                    self._boxing_gloves_wear_off_flash_timer = bs.Timer(
 811                        (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0,
 812                        bs.WeakCall(self._gloves_wear_off_flash),
 813                    )
 814                    self._boxing_gloves_wear_off_timer = bs.Timer(
 815                        POWERUP_WEAR_OFF_TIME / 1000.0,
 816                        bs.WeakCall(self._gloves_wear_off),
 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 = int(bs.time() * 1000.0)
 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 = bs.Timer(
 838                        (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0,
 839                        bs.WeakCall(self._bomb_wear_off_flash),
 840                    )
 841                    self._bomb_wear_off_timer = bs.Timer(
 842                        POWERUP_WEAR_OFF_TIME / 1000.0,
 843                        bs.WeakCall(self._bomb_wear_off),
 844                    )
 845            elif msg.poweruptype == 'health':
 846                if self._cursed:
 847                    self._cursed = False
 848
 849                    # Remove cursed material.
 850                    factory = SpazFactory.get()
 851                    for attr in ['materials', 'roller_materials']:
 852                        materials = getattr(self.node, attr)
 853                        if factory.curse_material in materials:
 854                            setattr(
 855                                self.node,
 856                                attr,
 857                                tuple(
 858                                    m
 859                                    for m in materials
 860                                    if m != factory.curse_material
 861                                ),
 862                            )
 863                    self.node.curse_death_time = 0
 864                self.hitpoints = self.hitpoints_max
 865                self._flash_billboard(PowerupBoxFactory.get().tex_health)
 866                self.node.hurt = 0
 867                self._last_hit_time = None
 868                self._num_times_hit = 0
 869
 870            self.node.handlemessage('flash')
 871            if msg.sourcenode:
 872                msg.sourcenode.handlemessage(bs.PowerupAcceptMessage())
 873            return True
 874
 875        elif isinstance(msg, bs.FreezeMessage):
 876            if not self.node:
 877                return None
 878            if self.node.invincible:
 879                SpazFactory.get().block_sound.play(
 880                    1.0,
 881                    position=self.node.position,
 882                )
 883                return None
 884            if self.shield:
 885                return None
 886            if not self.frozen:
 887                self.frozen = True
 888                self.node.frozen = True
 889                bs.timer(5.0, bs.WeakCall(self.handlemessage, bs.ThawMessage()))
 890                # Instantly shatter if we're already dead.
 891                # (otherwise its hard to tell we're dead).
 892                if self.hitpoints <= 0:
 893                    self.shatter()
 894
 895        elif isinstance(msg, bs.ThawMessage):
 896            if self.frozen and not self.shattered and self.node:
 897                self.frozen = False
 898                self.node.frozen = False
 899
 900        elif isinstance(msg, bs.HitMessage):
 901            if not self.node:
 902                return None
 903            if self.node.invincible:
 904                SpazFactory.get().block_sound.play(
 905                    1.0,
 906                    position=self.node.position,
 907                )
 908                return True
 909
 910            # If we were recently hit, don't count this as another.
 911            # (so punch flurries and bomb pileups essentially count as 1 hit).
 912            local_time = int(bs.time() * 1000.0)
 913            assert isinstance(local_time, int)
 914            if (
 915                self._last_hit_time is None
 916                or local_time - self._last_hit_time > 1000
 917            ):
 918                self._num_times_hit += 1
 919                self._last_hit_time = local_time
 920
 921            mag = msg.magnitude * self.impact_scale
 922            velocity_mag = msg.velocity_magnitude * self.impact_scale
 923            damage_scale = 0.22
 924
 925            # If they've got a shield, deliver it to that instead.
 926            if self.shield:
 927                if msg.flat_damage:
 928                    damage = msg.flat_damage * self.impact_scale
 929                else:
 930                    # Hit our spaz with an impulse but tell it to only return
 931                    # theoretical damage; not apply the impulse.
 932                    assert msg.force_direction is not None
 933                    self.node.handlemessage(
 934                        'impulse',
 935                        msg.pos[0],
 936                        msg.pos[1],
 937                        msg.pos[2],
 938                        msg.velocity[0],
 939                        msg.velocity[1],
 940                        msg.velocity[2],
 941                        mag,
 942                        velocity_mag,
 943                        msg.radius,
 944                        1,
 945                        msg.force_direction[0],
 946                        msg.force_direction[1],
 947                        msg.force_direction[2],
 948                    )
 949                    damage = damage_scale * self.node.damage
 950
 951                assert self.shield_hitpoints is not None
 952                self.shield_hitpoints -= int(damage)
 953                self.shield.hurt = (
 954                    1.0
 955                    - float(self.shield_hitpoints) / self.shield_hitpoints_max
 956                )
 957
 958                # Its a cleaner event if a hit just kills the shield
 959                # without damaging the player.
 960                # However, massive damage events should still be able to
 961                # damage the player. This hopefully gives us a happy medium.
 962                max_spillover = SpazFactory.get().max_shield_spillover_damage
 963                if self.shield_hitpoints <= 0:
 964                    # FIXME: Transition out perhaps?
 965                    self.shield.delete()
 966                    self.shield = None
 967                    SpazFactory.get().shield_down_sound.play(
 968                        1.0,
 969                        position=self.node.position,
 970                    )
 971
 972                    # Emit some cool looking sparks when the shield dies.
 973                    npos = self.node.position
 974                    bs.emitfx(
 975                        position=(npos[0], npos[1] + 0.9, npos[2]),
 976                        velocity=self.node.velocity,
 977                        count=random.randrange(20, 30),
 978                        scale=1.0,
 979                        spread=0.6,
 980                        chunk_type='spark',
 981                    )
 982
 983                else:
 984                    SpazFactory.get().shield_hit_sound.play(
 985                        0.5,
 986                        position=self.node.position,
 987                    )
 988
 989                # Emit some cool looking sparks on shield hit.
 990                assert msg.force_direction is not None
 991                bs.emitfx(
 992                    position=msg.pos,
 993                    velocity=(
 994                        msg.force_direction[0] * 1.0,
 995                        msg.force_direction[1] * 1.0,
 996                        msg.force_direction[2] * 1.0