bascenev1lib.actor.spaz

Defines the spaz actor.

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

We wanna pick something up.

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

Message saying an object was hit.

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

We are cursed and should blow up now.

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

A bomb has died and thus can be recycled.

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

Base class for various Spazzes.

Category: Gameplay Classes

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

Spaz( *, color: Sequence[float] = (1.0, 1.0, 1.0), highlight: Sequence[float] = (0.5, 0.5, 0.5), character: str = 'Spaz', source_player: bascenev1.Player | None = None, start_invincible: bool = True, can_accept_powerups: bool = True, powerups_expire: bool = False, demo_mode: bool = False)
 73    def __init__(
 74        self,
 75        *,
 76        color: Sequence[float] = (1.0, 1.0, 1.0),
 77        highlight: Sequence[float] = (0.5, 0.5, 0.5),
 78        character: str = 'Spaz',
 79        source_player: bs.Player | None = None,
 80        start_invincible: bool = True,
 81        can_accept_powerups: bool = True,
 82        powerups_expire: bool = False,
 83        demo_mode: bool = False,
 84    ):
 85        """Create a spaz with the requested color, character, etc."""
 86        # pylint: disable=too-many-statements
 87
 88        super().__init__()
 89        shared = SharedObjects.get()
 90        activity = self.activity
 91
 92        factory = SpazFactory.get()
 93
 94        # We need to behave slightly different in the tutorial.
 95        self._demo_mode = demo_mode
 96
 97        self.play_big_death_sound = False
 98
 99        # Scales how much impacts affect us (most damage calcs).
100        self.impact_scale = 1.0
101
102        self.source_player = source_player
103        self._dead = False
104        if self._demo_mode:  # Preserve old behavior.
105            self._punch_power_scale = BASE_PUNCH_POWER_SCALE
106        else:
107            self._punch_power_scale = factory.punch_power_scale
108        self.fly = bs.getactivity().globalsnode.happy_thoughts_mode
109        if isinstance(activity, bs.GameActivity):
110            self._hockey = activity.map.is_hockey
111        else:
112            self._hockey = False
113        self._punched_nodes: set[bs.Node] = set()
114        self._cursed = False
115        self._connected_to_player: bs.Player | None = None
116        materials = [
117            factory.spaz_material,
118            shared.object_material,
119            shared.player_material,
120        ]
121        roller_materials = [factory.roller_material, shared.player_material]
122        extras_material = []
123
124        if can_accept_powerups:
125            pam = PowerupBoxFactory.get().powerup_accept_material
126            materials.append(pam)
127            roller_materials.append(pam)
128            extras_material.append(pam)
129
130        media = factory.get_media(character)
131        punchmats = (factory.punch_material, shared.attack_material)
132        pickupmats = (factory.pickup_material, shared.pickup_material)
133        self.node: bs.Node = bs.newnode(
134            type='spaz',
135            delegate=self,
136            attrs={
137                'color': color,
138                'behavior_version': 0 if demo_mode else 1,
139                'demo_mode': demo_mode,
140                'highlight': highlight,
141                'jump_sounds': media['jump_sounds'],
142                'attack_sounds': media['attack_sounds'],
143                'impact_sounds': media['impact_sounds'],
144                'death_sounds': media['death_sounds'],
145                'pickup_sounds': media['pickup_sounds'],
146                'fall_sounds': media['fall_sounds'],
147                'color_texture': media['color_texture'],
148                'color_mask_texture': media['color_mask_texture'],
149                'head_mesh': media['head_mesh'],
150                'torso_mesh': media['torso_mesh'],
151                'pelvis_mesh': media['pelvis_mesh'],
152                'upper_arm_mesh': media['upper_arm_mesh'],
153                'forearm_mesh': media['forearm_mesh'],
154                'hand_mesh': media['hand_mesh'],
155                'upper_leg_mesh': media['upper_leg_mesh'],
156                'lower_leg_mesh': media['lower_leg_mesh'],
157                'toes_mesh': media['toes_mesh'],
158                'style': factory.get_style(character),
159                'fly': self.fly,
160                'hockey': self._hockey,
161                'materials': materials,
162                'roller_materials': roller_materials,
163                'extras_material': extras_material,
164                'punch_materials': punchmats,
165                'pickup_materials': pickupmats,
166                'invincible': start_invincible,
167                'source_player': source_player,
168            },
169        )
170        self.shield: bs.Node | None = None
171
172        if start_invincible:
173
174            def _safesetattr(node: bs.Node | None, attr: str, val: Any) -> None:
175                if node:
176                    setattr(node, attr, val)
177
178            bs.timer(1.0, bs.Call(_safesetattr, self.node, 'invincible', False))
179        self.hitpoints = self.default_hitpoints
180        self.hitpoints_max = self.default_hitpoints
181        self.shield_hitpoints: int | None = None
182        self.shield_hitpoints_max = 650
183        self.shield_decay_rate = 0
184        self.shield_decay_timer: bs.Timer | None = None
185        self._boxing_gloves_wear_off_timer: bs.Timer | None = None
186        self._boxing_gloves_wear_off_flash_timer: bs.Timer | None = None
187        self._bomb_wear_off_timer: bs.Timer | None = None
188        self._bomb_wear_off_flash_timer: bs.Timer | None = None
189        self._multi_bomb_wear_off_timer: bs.Timer | None = None
190        self._multi_bomb_wear_off_flash_timer: bs.Timer | None = None
191        self._curse_timer: bs.Timer | None = None
192        self.bomb_count = self.default_bomb_count
193        self._max_bomb_count = self.default_bomb_count
194        self.bomb_type_default = self.default_bomb_type
195        self.bomb_type = self.bomb_type_default
196        self.land_mine_count = 0
197        self.blast_radius = 2.0
198        self.powerups_expire = powerups_expire
199        if self._demo_mode:  # Preserve old behavior.
200            self._punch_cooldown = BASE_PUNCH_COOLDOWN
201        else:
202            self._punch_cooldown = factory.punch_cooldown
203        self._jump_cooldown = 250
204        self._pickup_cooldown = 0
205        self._bomb_cooldown = 0
206        self._has_boxing_gloves = False
207        if self.default_boxing_gloves:
208            self.equip_boxing_gloves()
209        self.last_punch_time_ms = -9999
210        self.last_pickup_time_ms = -9999
211        self.last_jump_time_ms = -9999
212        self.last_run_time_ms = -9999
213        self._last_run_value = 0.0
214        self.last_bomb_time_ms = -9999
215        self._turbo_filter_times: dict[str, int] = {}
216        self._turbo_filter_time_bucket = 0
217        self._turbo_filter_counts: dict[str, int] = {}
218        self.frozen = False
219        self.shattered = False
220        self._last_hit_time: int | None = None
221        self._num_times_hit = 0
222        self._bomb_held = False
223        if self.default_shields:
224            self.equip_shields()
225        self._dropped_bomb_callbacks: list[Callable[[Spaz, bs.Actor], Any]] = []
226
227        self._score_text: bs.Node | None = None
228        self._score_text_hide_timer: bs.Timer | None = None
229        self._last_stand_pos: Sequence[float] | None = None
230
231        # Deprecated stuff.. should make these into lists.
232        self.punch_callback: Callable[[Spaz], Any] | None = None
233        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:
235    @override
236    def exists(self) -> bool:
237        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:
239    @override
240    def on_expire(self) -> None:
241        super().on_expire()
242
243        # Release callbacks/refs so we don't wind up with dependency loops.
244        self._dropped_bomb_callbacks = []
245        self.punch_callback = None
246        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:
248    def add_dropped_bomb_callback(
249        self, call: Callable[[Spaz, bs.Actor], Any]
250    ) -> None:
251        """
252        Add a call to be run whenever this Spaz drops a bomb.
253        The spaz and the newly-dropped bomb are passed as arguments.
254        """
255        assert not self.expired
256        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:
258    @override
259    def is_alive(self) -> bool:
260        """
261        Method override; returns whether ol' spaz is still kickin'.
262        """
263        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:
323    def set_score_text(
324        self,
325        text: str | bs.Lstr,
326        color: Sequence[float] = (1.0, 1.0, 0.4),
327        flash: bool = False,
328    ) -> None:
329        """
330        Utility func to show a message momentarily over our spaz that follows
331        him around; Handy for score updates and things.
332        """
333        color_fin = bs.safecolor(color)[:3]
334        if not self.node:
335            return
336        if not self._score_text:
337            start_scale = 0.0
338            mnode = bs.newnode(
339                'math',
340                owner=self.node,
341                attrs={'input1': (0, 1.4, 0), 'operation': 'add'},
342            )
343            self.node.connectattr('torso_position', mnode, 'input2')
344            self._score_text = bs.newnode(
345                'text',
346                owner=self.node,
347                attrs={
348                    'text': text,
349                    'in_world': True,
350                    'shadow': 1.0,
351                    'flatness': 1.0,
352                    'color': color_fin,
353                    'scale': 0.02,
354                    'h_align': 'center',
355                },
356            )
357            mnode.connectattr('output', self._score_text, 'position')
358        else:
359            self._score_text.color = color_fin
360            assert isinstance(self._score_text.scale, float)
361            start_scale = self._score_text.scale
362            self._score_text.text = text
363        if flash:
364            combine = bs.newnode(
365                'combine', owner=self._score_text, attrs={'size': 3}
366            )
367            scl = 1.8
368            offs = 0.5
369            tval = 0.300
370            for i in range(3):
371                cl1 = offs + scl * color_fin[i]
372                cl2 = color_fin[i]
373                bs.animate(
374                    combine,
375                    'input' + str(i),
376                    {0.5 * tval: cl2, 0.75 * tval: cl1, 1.0 * tval: cl2},
377                )
378            combine.connectattr('output', self._score_text, 'color')
379
380        bs.animate(self._score_text, 'scale', {0.0: start_scale, 0.2: 0.02})
381        self._score_text_hide_timer = bs.Timer(
382            1.0, bs.WeakCall(self._hide_score_text)
383        )

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

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

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

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

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

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

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

Called when this spaz gets punched.

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

Get the points awarded for killing this spaz.

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

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

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

Give this spaz some boxing gloves.

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

Give this spaz a nice energy shield.

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

Called repeatedly to decay shield HP over time.

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

General message handling; can be passed any message object.

def drop_bomb(self) -> bascenev1lib.actor.bomb.Bomb | None:
1349    def drop_bomb(self) -> Bomb | None:
1350        """
1351        Tell the spaz to drop one of his bombs, and returns
1352        the resulting bomb object.
1353        If the spaz has no bombs or is otherwise unable to
1354        drop a bomb, returns None.
1355        """
1356
1357        if (self.land_mine_count <= 0 and self.bomb_count <= 0) or self.frozen:
1358            return None
1359        assert self.node
1360        pos = self.node.position_forward
1361        vel = self.node.velocity
1362
1363        if self.land_mine_count > 0:
1364            dropping_bomb = False
1365            self.set_land_mine_count(self.land_mine_count - 1)
1366            bomb_type = 'land_mine'
1367        else:
1368            dropping_bomb = True
1369            bomb_type = self.bomb_type
1370
1371        bomb = Bomb(
1372            position=(pos[0], pos[1] - 0.0, pos[2]),
1373            velocity=(vel[0], vel[1], vel[2]),
1374            bomb_type=bomb_type,
1375            blast_radius=self.blast_radius,
1376            source_player=self.source_player,
1377            owner=self.node,
1378        ).autoretain()
1379
1380        assert bomb.node
1381        if dropping_bomb:
1382            self.bomb_count -= 1
1383            bomb.node.add_death_action(
1384                bs.WeakCall(self.handlemessage, BombDiedMessage())
1385            )
1386        self._pick_up(bomb.node)
1387
1388        for clb in self._dropped_bomb_callbacks:
1389            clb(self, bomb)
1390
1391        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:
1399    def set_land_mine_count(self, count: int) -> None:
1400        """Set the number of land-mines this spaz is carrying."""
1401        self.land_mine_count = count
1402        if self.node:
1403            if self.land_mine_count != 0:
1404                self.node.counter_text = 'x' + str(self.land_mine_count)
1405                self.node.counter_texture = (
1406                    PowerupBoxFactory.get().tex_land_mines
1407                )
1408            else:
1409                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:
1411    def curse_explode(self, source_player: bs.Player | None = None) -> None:
1412        """Explode the poor spaz spectacularly."""
1413        if self._cursed and self.node:
1414            self.shatter(extreme=True)
1415            self.handlemessage(bs.DieMessage())
1416            activity = self._activity()
1417            if activity:
1418                Blast(
1419                    position=self.node.position,
1420                    velocity=self.node.velocity,
1421                    blast_radius=3.0,
1422                    blast_type='normal',
1423                    source_player=(
1424                        source_player if source_player else self.source_player
1425                    ),
1426                ).autoretain()
1427            self._cursed = False

Explode the poor spaz spectacularly.

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

Break the poor spaz into little bits.

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

Sets the number of bombs this Spaz has.