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

We wanna pick something up.

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

Message saying an object was hit.

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

We are cursed and should blow up now.

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

A bomb has died and thus can be recycled.

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

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

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

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

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

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

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

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

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

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

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

Called when this spaz gets punched.

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

Get the points awarded for killing this spaz.

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

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

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

Give this spaz some boxing gloves.

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

Give this spaz a nice energy shield.

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

Called repeatedly to decay shield HP over time.

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