bascenev1lib.actor.spaz

Defines the spaz actor.

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

We wanna pick something up.

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

Message saying an object was hit.

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

We are cursed and should blow up now.

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

A bomb has died and thus can be recycled.

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

Base class for various Spazzes.

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

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

node: _bascenev1.Node

The 'spaz' bs.Node.

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

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

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

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

The default implementation of this method always return True.

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

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

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

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

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

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

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

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

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

def on_jump_release(self) -> None:
397    def on_jump_release(self) -> None:
398        """
399        Called to 'release jump' on this spaz;
400        used by player or AI connections.
401        """
402        if not self.node:
403            return
404        self.node.jump_pressed = False

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

def on_pickup_press(self) -> None:
406    def on_pickup_press(self) -> None:
407        """
408        Called to 'press pick-up' on this spaz;
409        used by player or AI connections.
410        """
411        if not self.node:
412            return
413        t_ms = int(bs.time() * 1000.0)
414        assert isinstance(t_ms, int)
415        if t_ms - self.last_pickup_time_ms >= self._pickup_cooldown:
416            self.node.pickup_pressed = True
417            self.last_pickup_time_ms = t_ms
418        self._turbo_filter_add_press('pickup')

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

def on_pickup_release(self) -> None:
420    def on_pickup_release(self) -> None:
421        """
422        Called to 'release pick-up' on this spaz;
423        used by player or AI connections.
424        """
425        if not self.node:
426            return
427        self.node.pickup_pressed = False

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

def on_hold_position_press(self) -> None:
429    def on_hold_position_press(self) -> None:
430        """
431        Called to 'press hold-position' on this spaz;
432        used for player or AI connections.
433        """
434        if not self.node:
435            return
436        self.node.hold_position_pressed = True
437        self._turbo_filter_add_press('holdposition')

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

def on_hold_position_release(self) -> None:
439    def on_hold_position_release(self) -> None:
440        """
441        Called to 'release hold-position' on this spaz;
442        used for player or AI connections.
443        """
444        if not self.node:
445            return
446        self.node.hold_position_pressed = False

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

def on_punch_press(self) -> None:
448    def on_punch_press(self) -> None:
449        """
450        Called to 'press punch' on this spaz;
451        used for player or AI connections.
452        """
453        if not self.node or self.frozen or self.node.knockout > 0.0:
454            return
455        t_ms = int(bs.time() * 1000.0)
456        assert isinstance(t_ms, int)
457        if t_ms - self.last_punch_time_ms >= self._punch_cooldown:
458            if self.punch_callback is not None:
459                self.punch_callback(self)
460            self._punched_nodes = set()  # Reset this.
461            self.last_punch_time_ms = t_ms
462            self.node.punch_pressed = True
463            if not self.node.hold_node:
464                bs.timer(
465                    0.1,
466                    bs.WeakCall(
467                        self._safe_play_sound,
468                        SpazFactory.get().swish_sound,
469                        0.8,
470                    ),
471                )
472        self._turbo_filter_add_press('punch')

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

def on_punch_release(self) -> None:
479    def on_punch_release(self) -> None:
480        """
481        Called to 'release punch' on this spaz;
482        used for player or AI connections.
483        """
484        if not self.node:
485            return
486        self.node.punch_pressed = False

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

def on_bomb_press(self) -> None:
488    def on_bomb_press(self) -> None:
489        """
490        Called to 'press bomb' on this spaz;
491        used for player or AI connections.
492        """
493        if (
494            not self.node
495            or self._dead
496            or self.frozen
497            or self.node.knockout > 0.0
498        ):
499            return
500        t_ms = int(bs.time() * 1000.0)
501        assert isinstance(t_ms, int)
502        if t_ms - self.last_bomb_time_ms >= self._bomb_cooldown:
503            self.last_bomb_time_ms = t_ms
504            self.node.bomb_pressed = True
505            if not self.node.hold_node:
506                self.drop_bomb()
507        self._turbo_filter_add_press('bomb')

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

def on_bomb_release(self) -> None:
509    def on_bomb_release(self) -> None:
510        """
511        Called to 'release bomb' on this spaz;
512        used for player or AI connections.
513        """
514        if not self.node:
515            return
516        self.node.bomb_pressed = False

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

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

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

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

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

def on_fly_release(self) -> None:
551    def on_fly_release(self) -> None:
552        """
553        Called to 'release fly' on this spaz;
554        used for player or AI connections.
555        """
556        if not self.node:
557            return
558        self.node.fly_pressed = False

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

def on_move(self, x: float, y: float) -> None:
560    def on_move(self, x: float, y: float) -> None:
561        """
562        Called to set the joystick amount for this spaz;
563        used for player or AI connections.
564        """
565        if not self.node:
566            return
567        self.node.handlemessage('move', x, y)

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

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

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:
580    def on_move_left_right(self, value: float) -> None:
581        """
582        Called to set the left/right joystick amount on this spaz;
583        used for player or AI connections.
584        value will be between -32768 to 32767
585        WARNING: deprecated; use on_move instead.
586        """
587        if not self.node:
588            return
589        self.node.move_left_right = value

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:
591    def on_punched(self, damage: int) -> None:
592        """Called when this spaz gets punched."""

Called when this spaz gets punched.

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

Get the points awarded for killing this spaz.

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

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

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

Give this spaz some boxing gloves.

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

Give this spaz a nice energy shield.

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

Called repeatedly to decay shield HP over time.

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

General message handling; can be passed any message object.

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

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

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

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

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

Explode the poor spaz spectacularly.

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

Break the poor spaz into little bits.

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

Sets the number of bombs this Spaz has.