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(5.0, bs.WeakCall(self.handlemessage, bs.ThawMessage()))
 890                # Instantly shatter if we're already dead.
 891                # (otherwise its hard to tell we're dead).
 892                if self.hitpoints <= 0:
 893                    self.shatter()
 894
 895        elif isinstance(msg, bs.ThawMessage):
 896            if self.frozen and not self.shattered and self.node:
 897                self.frozen = False
 898                self.node.frozen = False
 899
 900        elif isinstance(msg, bs.HitMessage):
 901            if not self.node:
 902                return None
 903            if self.node.invincible:
 904                SpazFactory.get().block_sound.play(
 905                    1.0,
 906                    position=self.node.position,
 907                )
 908                return True
 909
 910            # If we were recently hit, don't count this as another.
 911            # (so punch flurries and bomb pileups essentially count as 1 hit).
 912            local_time = int(bs.time() * 1000.0)
 913            assert isinstance(local_time, int)
 914            if (
 915                self._last_hit_time is None
 916                or local_time - self._last_hit_time > 1000
 917            ):
 918                self._num_times_hit += 1
 919                self._last_hit_time = local_time
 920
 921            mag = msg.magnitude * self.impact_scale
 922            velocity_mag = msg.velocity_magnitude * self.impact_scale
 923            damage_scale = 0.22
 924
 925            # If they've got a shield, deliver it to that instead.
 926            if self.shield:
 927                if msg.flat_damage:
 928                    damage = msg.flat_damage * self.impact_scale
 929                else:
 930                    # Hit our spaz with an impulse but tell it to only return
 931                    # theoretical damage; not apply the impulse.
 932                    assert msg.force_direction is not None
 933                    self.node.handlemessage(
 934                        'impulse',
 935                        msg.pos[0],
 936                        msg.pos[1],
 937                        msg.pos[2],
 938                        msg.velocity[0],
 939                        msg.velocity[1],
 940                        msg.velocity[2],
 941                        mag,
 942                        velocity_mag,
 943                        msg.radius,
 944                        1,
 945                        msg.force_direction[0],
 946                        msg.force_direction[1],
 947                        msg.force_direction[2],
 948                    )
 949                    damage = damage_scale * self.node.damage
 950
 951                assert self.shield_hitpoints is not None
 952                self.shield_hitpoints -= int(damage)
 953                self.shield.hurt = (
 954                    1.0
 955                    - float(self.shield_hitpoints) / self.shield_hitpoints_max
 956                )
 957
 958                # Its a cleaner event if a hit just kills the shield
 959                # without damaging the player.
 960                # However, massive damage events should still be able to
 961                # damage the player. This hopefully gives us a happy medium.
 962                max_spillover = SpazFactory.get().max_shield_spillover_damage
 963                if self.shield_hitpoints <= 0:
 964                    # FIXME: Transition out perhaps?
 965                    self.shield.delete()
 966                    self.shield = None
 967                    SpazFactory.get().shield_down_sound.play(
 968                        1.0,
 969                        position=self.node.position,
 970                    )
 971
 972                    # Emit some cool looking sparks when the shield dies.
 973                    npos = self.node.position
 974                    bs.emitfx(
 975                        position=(npos[0], npos[1] + 0.9, npos[2]),
 976                        velocity=self.node.velocity,
 977                        count=random.randrange(20, 30),
 978                        scale=1.0,
 979                        spread=0.6,
 980                        chunk_type='spark',
 981                    )
 982
 983                else:
 984                    SpazFactory.get().shield_hit_sound.play(
 985                        0.5,
 986                        position=self.node.position,
 987                    )
 988
 989                # Emit some cool looking sparks on shield hit.
 990                assert msg.force_direction is not None
 991                bs.emitfx(
 992                    position=msg.pos,
 993                    velocity=(
 994                        msg.force_direction[0] * 1.0,
 995                        msg.force_direction[1] * 1.0,
 996                        msg.force_direction[2] * 1.0,
 997                    ),
 998                    count=min(30, 5 + int(damage * 0.005)),
 999                    scale=0.5,
1000                    spread=0.3,
1001                    chunk_type='spark',
1002                )
1003
1004                # If they passed our spillover threshold,
1005                # pass damage along to spaz.
1006                if self.shield_hitpoints <= -max_spillover:
1007                    leftover_damage = -max_spillover - self.shield_hitpoints
1008                    shield_leftover_ratio = leftover_damage / damage
1009
1010                    # Scale down the magnitudes applied to spaz accordingly.
1011                    mag *= shield_leftover_ratio
1012                    velocity_mag *= shield_leftover_ratio
1013                else:
1014                    return True  # Good job shield!
1015            else:
1016                shield_leftover_ratio = 1.0
1017
1018            if msg.flat_damage:
1019                damage = int(
1020                    msg.flat_damage * self.impact_scale * shield_leftover_ratio
1021                )
1022            else:
1023                # Hit it with an impulse and get the resulting damage.
1024                assert msg.force_direction is not None
1025                self.node.handlemessage(
1026                    'impulse',
1027                    msg.pos[0],
1028                    msg.pos[1],
1029                    msg.pos[2],
1030                    msg.velocity[0],
1031                    msg.velocity[1],
1032                    msg.velocity[2],
1033                    mag,
1034                    velocity_mag,
1035                    msg.radius,
1036                    0,
1037                    msg.force_direction[0],
1038                    msg.force_direction[1],
1039                    msg.force_direction[2],
1040                )
1041
1042                damage = int(damage_scale * self.node.damage)
1043            self.node.handlemessage('hurt_sound')
1044
1045            # Play punch impact sound based on damage if it was a punch.
1046            if msg.hit_type == 'punch':
1047                self.on_punched(damage)
1048
1049                # If damage was significant, lets show it.
1050                if damage >= 350:
1051                    assert msg.force_direction is not None
1052                    bs.show_damage_count(
1053                        '-' + str(int(damage / 10)) + '%',
1054                        msg.pos,
1055                        msg.force_direction,
1056                    )
1057
1058                # Let's always add in a super-punch sound with boxing
1059                # gloves just to differentiate them.
1060                if msg.hit_subtype == 'super_punch':
1061                    SpazFactory.get().punch_sound_stronger.play(
1062                        1.0,
1063                        position=self.node.position,
1064                    )
1065                if damage >= 500:
1066                    sounds = SpazFactory.get().punch_sound_strong
1067                    sound = sounds[random.randrange(len(sounds))]
1068                elif damage >= 100:
1069                    sound = SpazFactory.get().punch_sound
1070                else:
1071                    sound = SpazFactory.get().punch_sound_weak
1072                sound.play(1.0, position=self.node.position)
1073
1074                # Throw up some chunks.
1075                assert msg.force_direction is not None
1076                bs.emitfx(
1077                    position=msg.pos,
1078                    velocity=(
1079                        msg.force_direction[0] * 0.5,
1080                        msg.force_direction[1] * 0.5,
1081                        msg.force_direction[2] * 0.5,
1082                    ),
1083                    count=min(10, 1 + int(damage * 0.0025)),
1084                    scale=0.3,
1085                    spread=0.03,
1086                )
1087
1088                bs.emitfx(
1089                    position=msg.pos,
1090                    chunk_type='sweat',
1091                    velocity=(
1092                        msg.force_direction[0] * 1.3,
1093                        msg.force_direction[1] * 1.3 + 5.0,
1094                        msg.force_direction[2] * 1.3,
1095                    ),
1096                    count=min(30, 1 + int(damage * 0.04)),
1097                    scale=0.9,
1098                    spread=0.28,
1099                )
1100
1101                # Momentary flash.
1102                hurtiness = damage * 0.003
1103                punchpos = (
1104                    msg.pos[0] + msg.force_direction[0] * 0.02,
1105                    msg.pos[1] + msg.force_direction[1] * 0.02,
1106                    msg.pos[2] + msg.force_direction[2] * 0.02,
1107                )
1108                flash_color = (1.0, 0.8, 0.4)
1109                light = bs.newnode(
1110                    'light',
1111                    attrs={
1112                        'position': punchpos,
1113                        'radius': 0.12 + hurtiness * 0.12,
1114                        'intensity': 0.3 * (1.0 + 1.0 * hurtiness),
1115                        'height_attenuated': False,
1116                        'color': flash_color,
1117                    },
1118                )
1119                bs.timer(0.06, light.delete)
1120
1121                flash = bs.newnode(
1122                    'flash',
1123                    attrs={
1124                        'position': punchpos,
1125                        'size': 0.17 + 0.17 * hurtiness,
1126                        'color': flash_color,
1127                    },
1128                )
1129                bs.timer(0.06, flash.delete)
1130
1131            if msg.hit_type == 'impact':
1132                assert msg.force_direction is not None
1133                bs.emitfx(
1134                    position=msg.pos,
1135                    velocity=(
1136                        msg.force_direction[0] * 2.0,
1137                        msg.force_direction[1] * 2.0,
1138                        msg.force_direction[2] * 2.0,
1139                    ),
1140                    count=min(10, 1 + int(damage * 0.01)),
1141                    scale=0.4,
1142                    spread=0.1,
1143                )
1144            if self.hitpoints > 0:
1145                # It's kinda crappy to die from impacts, so lets reduce
1146                # impact damage by a reasonable amount *if* it'll keep us alive.
1147                if msg.hit_type == 'impact' and damage >= self.hitpoints:
1148                    # Drop damage to whatever puts us at 10 hit points,
1149                    # or 200 less than it used to be whichever is greater
1150                    # (so it *can* still kill us if its high enough).
1151                    newdamage = max(damage - 200, self.hitpoints - 10)
1152                    damage = newdamage
1153                self.node.handlemessage('flash')
1154
1155                # If we're holding something, drop it.
1156                if damage > 0.0 and self.node.hold_node:
1157                    self.node.hold_node = None
1158                self.hitpoints -= damage
1159                self.node.hurt = (
1160                    1.0 - float(self.hitpoints) / self.hitpoints_max
1161                )
1162
1163                # If we're cursed, *any* damage blows us up.
1164                if self._cursed and damage > 0:
1165                    bs.timer(
1166                        0.05,
1167                        bs.WeakCall(
1168                            self.curse_explode, msg.get_source_player(bs.Player)
1169                        ),
1170                    )
1171
1172                # If we're frozen, shatter.. otherwise die if we hit zero
1173                if self.frozen and (damage > 200 or self.hitpoints <= 0):
1174                    self.shatter()
1175                elif self.hitpoints <= 0:
1176                    self.node.handlemessage(
1177                        bs.DieMessage(how=bs.DeathType.IMPACT)
1178                    )
1179
1180            # If we're dead, take a look at the smoothed damage value
1181            # (which gives us a smoothed average of recent damage) and shatter
1182            # us if its grown high enough.
1183            if self.hitpoints <= 0:
1184                damage_avg = self.node.damage_smoothed * damage_scale
1185                if damage_avg >= 1000:
1186                    self.shatter()
1187
1188        elif isinstance(msg, BombDiedMessage):
1189            self.bomb_count += 1
1190
1191        elif isinstance(msg, bs.DieMessage):
1192            wasdead = self._dead
1193            self._dead = True
1194            self.hitpoints = 0
1195            if msg.immediate:
1196                if self.node:
1197                    self.node.delete()
1198            elif self.node:
1199                if not wasdead:
1200                    self.node.hurt = 1.0
1201                    if self.play_big_death_sound:
1202                        SpazFactory.get().single_player_death_sound.play()
1203                    self.node.dead = True
1204                    bs.timer(2.0, self.node.delete)
1205
1206        elif isinstance(msg, bs.OutOfBoundsMessage):
1207            # By default we just die here.
1208            self.handlemessage(bs.DieMessage(how=bs.DeathType.FALL))
1209
1210        elif isinstance(msg, bs.StandMessage):
1211            self._last_stand_pos = (
1212                msg.position[0],
1213                msg.position[1],
1214                msg.position[2],
1215            )
1216            if self.node:
1217                self.node.handlemessage(
1218                    'stand',
1219                    msg.position[0],
1220                    msg.position[1],
1221                    msg.position[2],
1222                    msg.angle,
1223                )
1224
1225        elif isinstance(msg, CurseExplodeMessage):
1226            self.curse_explode()
1227
1228        elif isinstance(msg, PunchHitMessage):
1229            if not self.node:
1230                return None
1231            node = bs.getcollision().opposingnode
1232
1233            # Don't want to physically affect powerups.
1234            if node.getdelegate(PowerupBox):
1235                return None
1236
1237            # Only allow one hit per node per punch.
1238            if node and (node not in self._punched_nodes):
1239                punch_momentum_angular = (
1240                    self.node.punch_momentum_angular * self._punch_power_scale
1241                )
1242                punch_power = self.node.punch_power * self._punch_power_scale
1243
1244                # Ok here's the deal:  we pass along our base velocity for use
1245                # in the impulse damage calculations since that is a more
1246                # predictable value than our fist velocity, which is rather
1247                # erratic. However, we want to actually apply force in the
1248                # direction our fist is moving so it looks better. So we still
1249                # pass that along as a direction. Perhaps a time-averaged
1250                # fist-velocity would work too?.. perhaps should try that.
1251
1252                # If its something besides another spaz, just do a muffled
1253                # punch sound.
1254                if node.getnodetype() != 'spaz':
1255                    sounds = SpazFactory.get().impact_sounds_medium
1256                    sound = sounds[random.randrange(len(sounds))]
1257                    sound.play(1.0, position=self.node.position)
1258
1259                ppos = self.node.punch_position
1260                punchdir = self.node.punch_velocity
1261                vel = self.node.punch_momentum_linear
1262
1263                self._punched_nodes.add(node)
1264                node.handlemessage(
1265                    bs.HitMessage(
1266                        pos=ppos,
1267                        velocity=vel,
1268                        magnitude=punch_power * punch_momentum_angular * 110.0,
1269                        velocity_magnitude=punch_power * 40,
1270                        radius=0,
1271                        srcnode=self.node,
1272                        source_player=self.source_player,
1273                        force_direction=punchdir,
1274                        hit_type='punch',
1275                        hit_subtype=(
1276                            'super_punch'
1277                            if self._has_boxing_gloves
1278                            else 'default'
1279                        ),
1280                    )
1281                )
1282
1283                # Also apply opposite to ourself for the first punch only.
1284                # This is given as a constant force so that it is more
1285                # noticeable for slower punches where it matters. For fast
1286                # awesome looking punches its ok if we punch 'through'
1287                # the target.
1288                mag = -400.0
1289                if self._hockey:
1290                    mag *= 0.5
1291                if len(self._punched_nodes) == 1:
1292                    self.node.handlemessage(
1293                        'kick_back',
1294                        ppos[0],
1295                        ppos[1],
1296                        ppos[2],
1297                        punchdir[0],
1298                        punchdir[1],
1299                        punchdir[2],
1300                        mag,
1301                    )
1302        elif isinstance(msg, PickupMessage):
1303            if not self.node:
1304                return None
1305
1306            try:
1307                collision = bs.getcollision()
1308                opposingnode = collision.opposingnode
1309                opposingbody = collision.opposingbody
1310            except bs.NotFoundError:
1311                return True
1312
1313            # Don't allow picking up of invincible dudes.
1314            try:
1315                if opposingnode.invincible:
1316                    return True
1317            except Exception:
1318                pass
1319
1320            # If we're grabbing the pelvis of a non-shattered spaz, we wanna
1321            # grab the torso instead.
1322            if (
1323                opposingnode.getnodetype() == 'spaz'
1324                and not opposingnode.shattered
1325                and opposingbody == 4
1326            ):
1327                opposingbody = 1
1328
1329            # Special case - if we're holding a flag, don't replace it
1330            # (hmm - should make this customizable or more low level).
1331            held = self.node.hold_node
1332            if held and held.getnodetype() == 'flag':
1333                return True
1334
1335            # Note: hold_body needs to be set before hold_node.
1336            self.node.hold_body = opposingbody
1337            self.node.hold_node = opposingnode
1338        elif isinstance(msg, bs.CelebrateMessage):
1339            if self.node:
1340                self.node.handlemessage('celebrate', int(msg.duration * 1000))
1341
1342        else:
1343            return super().handlemessage(msg)
1344        return None
1345
1346    def drop_bomb(self) -> Bomb | None:
1347        """
1348        Tell the spaz to drop one of his bombs, and returns
1349        the resulting bomb object.
1350        If the spaz has no bombs or is otherwise unable to
1351        drop a bomb, returns None.
1352        """
1353
1354        if (self.land_mine_count <= 0 and self.bomb_count <= 0) or self.frozen:
1355            return None
1356        assert self.node
1357        pos = self.node.position_forward
1358        vel = self.node.velocity
1359
1360        if self.land_mine_count > 0:
1361            dropping_bomb = False
1362            self.set_land_mine_count(self.land_mine_count - 1)
1363            bomb_type = 'land_mine'
1364        else:
1365            dropping_bomb = True
1366            bomb_type = self.bomb_type
1367
1368        bomb = Bomb(
1369            position=(pos[0], pos[1] - 0.0, pos[2]),
1370            velocity=(vel[0], vel[1], vel[2]),
1371            bomb_type=bomb_type,
1372            blast_radius=self.blast_radius,
1373            source_player=self.source_player,
1374            owner=self.node,
1375        ).autoretain()
1376
1377        assert bomb.node
1378        if dropping_bomb:
1379            self.bomb_count -= 1
1380            bomb.node.add_death_action(
1381                bs.WeakCall(self.handlemessage, BombDiedMessage())
1382            )
1383        self._pick_up(bomb.node)
1384
1385        for clb in self._dropped_bomb_callbacks:
1386            clb(self, bomb)
1387
1388        return bomb
1389
1390    def _pick_up(self, node: bs.Node) -> None:
1391        if self.node:
1392            # Note: hold_body needs to be set before hold_node.
1393            self.node.hold_body = 0
1394            self.node.hold_node = node
1395
1396    def set_land_mine_count(self, count: int) -> None:
1397        """Set the number of land-mines this spaz is carrying."""
1398        self.land_mine_count = count
1399        if self.node:
1400            if self.land_mine_count != 0:
1401                self.node.counter_text = 'x' + str(self.land_mine_count)
1402                self.node.counter_texture = (
1403                    PowerupBoxFactory.get().tex_land_mines
1404                )
1405            else:
1406                self.node.counter_text = ''
1407
1408    def curse_explode(self, source_player: bs.Player | None = None) -> None:
1409        """Explode the poor spaz spectacularly."""
1410        if self._cursed and self.node:
1411            self.shatter(extreme=True)
1412            self.handlemessage(bs.DieMessage())
1413            activity = self._activity()
1414            if activity:
1415                Blast(
1416                    position=self.node.position,
1417                    velocity=self.node.velocity,
1418                    blast_radius=3.0,
1419                    blast_type='normal',
1420                    source_player=(
1421                        source_player if source_player else self.source_player
1422                    ),
1423                ).autoretain()
1424            self._cursed = False
1425
1426    def shatter(self, extreme: bool = False) -> None:
1427        """Break the poor spaz into little bits."""
1428        if self.shattered:
1429            return
1430        self.shattered = True
1431        assert self.node
1432        if self.frozen:
1433            # Momentary flash of light.
1434            light = bs.newnode(
1435                'light',
1436                attrs={
1437                    'position': self.node.position,
1438                    'radius': 0.5,
1439                    'height_attenuated': False,
1440                    'color': (0.8, 0.8, 1.0),
1441                },
1442            )
1443
1444            bs.animate(
1445                light, 'intensity', {0.0: 3.0, 0.04: 0.5, 0.08: 0.07, 0.3: 0}
1446            )
1447            bs.timer(0.3, light.delete)
1448
1449            # Emit ice chunks.
1450            bs.emitfx(
1451                position=self.node.position,
1452                velocity=self.node.velocity,
1453                count=int(random.random() * 10.0 + 10.0),
1454                scale=0.6,
1455                spread=0.2,
1456                chunk_type='ice',
1457            )
1458            bs.emitfx(
1459                position=self.node.position,
1460                velocity=self.node.velocity,
1461                count=int(random.random() * 10.0 + 10.0),
1462                scale=0.3,
1463                spread=0.2,
1464                chunk_type='ice',
1465            )
1466            SpazFactory.get().shatter_sound.play(
1467                1.0,
1468                position=self.node.position,
1469            )
1470        else:
1471            SpazFactory.get().splatter_sound.play(
1472                1.0,
1473                position=self.node.position,
1474            )
1475        self.handlemessage(bs.DieMessage())
1476        self.node.shattered = 2 if extreme else 1
1477
1478    def _hit_self(self, intensity: float) -> None:
1479        if not self.node:
1480            return
1481        pos = self.node.position
1482        self.handlemessage(
1483            bs.HitMessage(
1484                flat_damage=50.0 * intensity,
1485                pos=pos,
1486                force_direction=self.node.velocity,
1487                hit_type='impact',
1488            )
1489        )
1490        self.node.handlemessage('knockout', max(0.0, 50.0 * intensity))
1491        sounds: Sequence[bs.Sound]
1492        if intensity >= 5.0:
1493            sounds = SpazFactory.get().impact_sounds_harder
1494        elif intensity >= 3.0:
1495            sounds = SpazFactory.get().impact_sounds_hard
1496        else:
1497            sounds = SpazFactory.get().impact_sounds_medium
1498        sound = sounds[random.randrange(len(sounds))]
1499        sound.play(position=pos, volume=5.0)
1500
1501    def _get_bomb_type_tex(self) -> bs.Texture:
1502        factory = PowerupBoxFactory.get()
1503        if self.bomb_type == 'sticky':
1504            return factory.tex_sticky_bombs
1505        if self.bomb_type == 'ice':
1506            return factory.tex_ice_bombs
1507        if self.bomb_type == 'impact':
1508            return factory.tex_impact_bombs
1509        raise ValueError('invalid bomb type')
1510
1511    def _flash_billboard(self, tex: bs.Texture) -> None:
1512        assert self.node
1513        self.node.billboard_texture = tex
1514        self.node.billboard_cross_out = False
1515        bs.animate(
1516            self.node,
1517            'billboard_opacity',
1518            {0.0: 0.0, 0.1: 1.0, 0.4: 1.0, 0.5: 0.0},
1519        )
1520
1521    def set_bomb_count(self, count: int) -> None:
1522        """Sets the number of bombs this Spaz has."""
1523        # We can't just set bomb_count because some bombs may be laid currently
1524        # so we have to do a relative diff based on max.
1525        diff = count - self._max_bomb_count
1526        self._max_bomb_count += diff
1527        self.bomb_count += diff
1528
1529    def _gloves_wear_off_flash(self) -> None:
1530        if self.node:
1531            self.node.boxing_gloves_flashing = True
1532            self.node.billboard_texture = PowerupBoxFactory.get().tex_punch
1533            self.node.billboard_opacity = 1.0
1534            self.node.billboard_cross_out = True
1535
1536    def _gloves_wear_off(self) -> None:
1537        if self._demo_mode:  # Preserve old behavior.
1538            self._punch_power_scale = 1.2
1539            self._punch_cooldown = BASE_PUNCH_COOLDOWN
1540        else:
1541            factory = SpazFactory.get()
1542            self._punch_power_scale = factory.punch_power_scale
1543            self._punch_cooldown = factory.punch_cooldown
1544        self._has_boxing_gloves = False
1545        if self.node:
1546            PowerupBoxFactory.get().powerdown_sound.play(
1547                position=self.node.position,
1548            )
1549            self.node.boxing_gloves = False
1550            self.node.billboard_opacity = 0.0
1551
1552    def _multi_bomb_wear_off_flash(self) -> None:
1553        if self.node:
1554            self.node.billboard_texture = PowerupBoxFactory.get().tex_bomb
1555            self.node.billboard_opacity = 1.0
1556            self.node.billboard_cross_out = True
1557
1558    def _multi_bomb_wear_off(self) -> None:
1559        self.set_bomb_count(self.default_bomb_count)
1560        if self.node:
1561            PowerupBoxFactory.get().powerdown_sound.play(
1562                position=self.node.position,
1563            )
1564            self.node.billboard_opacity = 0.0
1565
1566    def _bomb_wear_off_flash(self) -> None:
1567        if self.node:
1568            self.node.billboard_texture = self._get_bomb_type_tex()
1569            self.node.billboard_opacity = 1.0
1570            self.node.billboard_cross_out = True
1571
1572    def _bomb_wear_off(self) -> None:
1573        self.bomb_type = self.bomb_type_default
1574        if self.node:
1575            PowerupBoxFactory.get().powerdown_sound.play(
1576                position=self.node.position,
1577            )
1578            self.node.billboard_opacity = 0.0
POWERUP_WEAR_OFF_TIME = 20000
BASE_PUNCH_POWER_SCALE = 1.2
BASE_PUNCH_COOLDOWN = 400
class PickupMessage:
30class PickupMessage:
31    """We wanna pick something up."""

We wanna pick something up.

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

Message saying an object was hit.

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

We are cursed and should blow up now.

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

A bomb has died and thus can be recycled.

class Spaz(bascenev1._actor.Actor):
  46class Spaz(bs.Actor):
  47    """
  48    Base class for various Spazzes.
  49
  50    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(5.0, bs.WeakCall(self.handlemessage, bs.ThawMessage()))
 891                # Instantly shatter if we're already dead.
 892                # (otherwise its hard to tell we're dead).
 893                if self.hitpoints <= 0:
 894                    self.shatter()
 895
 896        elif isinstance(msg, bs.ThawMessage):
 897            if self.frozen and not self.shattered and self.node:
 898                self.frozen = False
 899                self.node.frozen = False
 900
 901        elif isinstance(msg, bs.HitMessage):
 902            if not self.node:
 903                return None
 904            if self.node.invincible:
 905                SpazFactory.get().block_sound.play(
 906                    1.0,
 907                    position=self.node.position,
 908                )
 909                return True
 910
 911            # If we were recently hit, don't count this as another.
 912            # (so punch flurries and bomb pileups essentially count as 1 hit).
 913            local_time = int(bs.time() * 1000.0)
 914            assert isinstance(local_time, int)
 915            if (
 916                self._last_hit_time is None
 917                or local_time - self._last_hit_time > 1000
 918            ):
 919                self._num_times_hit += 1
 920                self._last_hit_time = local_time
 921
 922            mag = msg.magnitude * self.impact_scale
 923            velocity_mag = msg.velocity_magnitude * self.impact_scale
 924            damage_scale = 0.22
 925
 926            # If they've got a shield, deliver it to that instead.
 927            if self.shield:
 928                if msg.flat_damage:
 929                    damage = msg.flat_damage * self.impact_scale
 930                else:
 931                    # Hit our spaz with an impulse but tell it to only return
 932                    # theoretical damage; not apply the impulse.
 933                    assert msg.force_direction is not None
 934                    self.node.handlemessage(
 935                        'impulse',
 936                        msg.pos[0],
 937                        msg.pos[1],
 938                        msg.pos[2],
 939                        msg.velocity[0],
 940                        msg.velocity[1],
 941                        msg.velocity[2],
 942                        mag,
 943                        velocity_mag,
 944                        msg.radius,
 945                        1,
 946                        msg.force_direction[0],
 947                        msg.force_direction[1],
 948                        msg.force_direction[2],
 949                    )
 950                    damage = damage_scale * self.node.damage
 951
 952                assert self.shield_hitpoints is not None
 953                self.shield_hitpoints -= int(damage)
 954                self.shield.hurt = (
 955                    1.0
 956                    - float(self.shield_hitpoints) / self.shield_hitpoints_max
 957                )
 958
 959                # Its a cleaner event if a hit just kills the shield
 960                # without damaging the player.
 961                # However, massive damage events should still be able to
 962                # damage the player. This hopefully gives us a happy medium.
 963                max_spillover = SpazFactory.get().max_shield_spillover_damage
 964                if self.shield_hitpoints <= 0:
 965                    # FIXME: Transition out perhaps?
 966                    self.shield.delete()
 967                    self.shield = None
 968                    SpazFactory.get().shield_down_sound.play(
 969                        1.0,
 970                        position=self.node.position,
 971                    )
 972
 973                    # Emit some cool looking sparks when the shield dies.
 974                    npos = self.node.position
 975                    bs.emitfx(
 976                        position=(npos[0], npos[1] + 0.9, npos[2]),
 977                        velocity=self.node.velocity,
 978                        count=random.randrange(20, 30),
 979                        scale=1.0,
 980                        spread=0.6,
 981                        chunk_type='spark',
 982                    )
 983
 984                else:
 985                    SpazFactory.get().shield_hit_sound.play(
 986                        0.5,
 987                        position=self.node.position,
 988                    )
 989
 990                # Emit some cool looking sparks on shield hit.
 991                assert msg.force_direction is not None
 992                bs.emitfx(
 993                    position=msg.pos,
 994                    velocity=(
 995                        msg.force_direction[0] * 1.0,
 996                        msg.force_direction[1] * 1.0,
 997                        msg.force_direction[2] * 1.0,
 998                    ),
 999                    count=min(30, 5 + int(damage * 0.005)),
1000                    scale=0.5,
1001                    spread=0.3,
1002                    chunk_type='spark',
1003                )
1004
1005                # If they passed our spillover threshold,
1006                # pass damage along to spaz.
1007                if self.shield_hitpoints <= -max_spillover:
1008                    leftover_damage = -max_spillover - self.shield_hitpoints
1009                    shield_leftover_ratio = leftover_damage / damage
1010
1011                    # Scale down the magnitudes applied to spaz accordingly.
1012                    mag *= shield_leftover_ratio
1013                    velocity_mag *= shield_leftover_ratio
1014                else:
1015                    return True  # Good job shield!
1016            else:
1017                shield_leftover_ratio = 1.0
1018
1019            if msg.flat_damage:
1020                damage = int(
1021                    msg.flat_damage * self.impact_scale * shield_leftover_ratio
1022                )
1023            else:
1024                # Hit it with an impulse and get the resulting damage.
1025                assert msg.force_direction is not None
1026                self.node.handlemessage(
1027                    'impulse',
1028                    msg.pos[0],
1029                    msg.pos[1],
1030                    msg.pos[2],
1031                    msg.velocity[0],
1032                    msg.velocity[1],
1033                    msg.velocity[2],
1034                    mag,
1035                    velocity_mag,
1036                    msg.radius,
1037                    0,
1038                    msg.force_direction[0],
1039                    msg.force_direction[1],
1040                    msg.force_direction[2],
1041                )
1042
1043                damage = int(damage_scale * self.node.damage)
1044            self.node.handlemessage('hurt_sound')
1045
1046            # Play punch impact sound based on damage if it was a punch.
1047            if msg.hit_type == 'punch':
1048                self.on_punched(damage)
1049
1050                # If damage was significant, lets show it.
1051                if damage >= 350:
1052                    assert msg.force_direction is not None
1053                    bs.show_damage_count(
1054                        '-' + str(int(damage / 10)) + '%',
1055                        msg.pos,
1056                        msg.force_direction,
1057                    )
1058
1059                # Let's always add in a super-punch sound with boxing
1060                # gloves just to differentiate them.
1061                if msg.hit_subtype == 'super_punch':
1062                    SpazFactory.get().punch_sound_stronger.play(
1063                        1.0,
1064                        position=self.node.position,
1065                    )
1066                if damage >= 500:
1067                    sounds = SpazFactory.get().punch_sound_strong
1068                    sound = sounds[random.randrange(len(sounds))]
1069                elif damage >= 100:
1070                    sound = SpazFactory.get().punch_sound
1071                else:
1072                    sound = SpazFactory.get().punch_sound_weak
1073                sound.play(1.0, position=self.node.position)
1074
1075                # Throw up some chunks.
1076                assert msg.force_direction is not None
1077                bs.emitfx(
1078                    position=msg.pos,
1079                    velocity=(
1080                        msg.force_direction[0] * 0.5,
1081                        msg.force_direction[1] * 0.5,
1082                        msg.force_direction[2] * 0.5,
1083                    ),
1084                    count=min(10, 1 + int(damage * 0.0025)),
1085                    scale=0.3,
1086                    spread=0.03,
1087                )
1088
1089                bs.emitfx(
1090                    position=msg.pos,
1091                    chunk_type='sweat',
1092                    velocity=(
1093                        msg.force_direction[0] * 1.3,
1094                        msg.force_direction[1] * 1.3 + 5.0,
1095                        msg.force_direction[2] * 1.3,
1096                    ),
1097                    count=min(30, 1 + int(damage * 0.04)),
1098                    scale=0.9,
1099                    spread=0.28,
1100                )
1101
1102                # Momentary flash.
1103                hurtiness = damage * 0.003
1104                punchpos = (
1105                    msg.pos[0] + msg.force_direction[0] * 0.02,
1106                    msg.pos[1] + msg.force_direction[1] * 0.02,
1107                    msg.pos[2] + msg.force_direction[2] * 0.02,
1108                )
1109                flash_color = (1.0, 0.8, 0.4)
1110                light = bs.newnode(
1111                    'light',
1112                    attrs={
1113                        'position': punchpos,
1114                        'radius': 0.12 + hurtiness * 0.12,
1115                        'intensity': 0.3 * (1.0 + 1.0 * hurtiness),
1116                        'height_attenuated': False,
1117                        'color': flash_color,
1118                    },
1119                )
1120                bs.timer(0.06, light.delete)
1121
1122                flash = bs.newnode(
1123                    'flash',
1124                    attrs={
1125                        'position': punchpos,
1126                        'size': 0.17 + 0.17 * hurtiness,
1127                        'color': flash_color,
1128                    },
1129                )
1130                bs.timer(0.06, flash.delete)
1131
1132            if msg.hit_type == 'impact':
1133                assert msg.force_direction is not None
1134                bs.emitfx(
1135                    position=msg.pos,
1136                    velocity=(
1137                        msg.force_direction[0] * 2.0,
1138                        msg.force_direction[1] * 2.0,
1139                        msg.force_direction[2] * 2.0,
1140                    ),
1141                    count=min(10, 1 + int(damage * 0.01)),
1142                    scale=0.4,
1143                    spread=0.1,
1144                )
1145            if self.hitpoints > 0:
1146                # It's kinda crappy to die from impacts, so lets reduce
1147                # impact damage by a reasonable amount *if* it'll keep us alive.
1148                if msg.hit_type == 'impact' and damage >= self.hitpoints:
1149                    # Drop damage to whatever puts us at 10 hit points,
1150                    # or 200 less than it used to be whichever is greater
1151                    # (so it *can* still kill us if its high enough).
1152                    newdamage = max(damage - 200, self.hitpoints - 10)
1153                    damage = newdamage
1154                self.node.handlemessage('flash')
1155
1156                # If we're holding something, drop it.
1157                if damage > 0.0 and self.node.hold_node:
1158                    self.node.hold_node = None
1159                self.hitpoints -= damage
1160                self.node.hurt = (
1161                    1.0 - float(self.hitpoints) / self.hitpoints_max
1162                )
1163
1164                # If we're cursed, *any* damage blows us up.
1165                if self._cursed and damage > 0:
1166                    bs.timer(
1167                        0.05,
1168                        bs.WeakCall(
1169                            self.curse_explode, msg.get_source_player(bs.Player)
1170                        ),
1171                    )
1172
1173                # If we're frozen, shatter.. otherwise die if we hit zero
1174                if self.frozen and (damage > 200 or self.hitpoints <= 0):
1175                    self.shatter()
1176                elif self.hitpoints <= 0:
1177                    self.node.handlemessage(
1178                        bs.DieMessage(how=bs.DeathType.IMPACT)
1179                    )
1180
1181            # If we're dead, take a look at the smoothed damage value
1182            # (which gives us a smoothed average of recent damage) and shatter
1183            # us if its grown high enough.
1184            if self.hitpoints <= 0:
1185                damage_avg = self.node.damage_smoothed * damage_scale
1186                if damage_avg >= 1000:
1187                    self.shatter()
1188
1189        elif isinstance(msg, BombDiedMessage):
1190            self.bomb_count += 1
1191
1192        elif isinstance(msg, bs.DieMessage):
1193            wasdead = self._dead
1194            self._dead = True
1195            self.hitpoints = 0
1196            if msg.immediate:
1197                if self.node:
1198                    self.node.delete()
1199            elif self.node:
1200                if not wasdead:
1201                    self.node.hurt = 1.0
1202                    if self.play_big_death_sound:
1203                        SpazFactory.get().single_player_death_sound.play()
1204                    self.node.dead = True
1205                    bs.timer(2.0, self.node.delete)
1206
1207        elif isinstance(msg, bs.OutOfBoundsMessage):
1208            # By default we just die here.
1209            self.handlemessage(bs.DieMessage(how=bs.DeathType.FALL))
1210
1211        elif isinstance(msg, bs.StandMessage):
1212            self._last_stand_pos = (
1213                msg.position[0],
1214                msg.position[1],
1215                msg.position[2],
1216            )
1217            if self.node:
1218                self.node.handlemessage(
1219                    'stand',
1220                    msg.position[0],
1221                    msg.position[1],
1222                    msg.position[2],
1223                    msg.angle,
1224                )
1225
1226        elif isinstance(msg, CurseExplodeMessage):
1227            self.curse_explode()
1228
1229        elif isinstance(msg, PunchHitMessage):
1230            if not self.node:
1231                return None
1232            node = bs.getcollision().opposingnode
1233
1234            # Don't want to physically affect powerups.
1235            if node.getdelegate(PowerupBox):
1236                return None
1237
1238            # Only allow one hit per node per punch.
1239            if node and (node not in self._punched_nodes):
1240                punch_momentum_angular = (
1241                    self.node.punch_momentum_angular * self._punch_power_scale
1242                )
1243                punch_power = self.node.punch_power * self._punch_power_scale
1244
1245                # Ok here's the deal:  we pass along our base velocity for use
1246                # in the impulse damage calculations since that is a more
1247                # predictable value than our fist velocity, which is rather
1248                # erratic. However, we want to actually apply force in the
1249                # direction our fist is moving so it looks better. So we still
1250                # pass that along as a direction. Perhaps a time-averaged
1251                # fist-velocity would work too?.. perhaps should try that.
1252
1253                # If its something besides another spaz, just do a muffled
1254                # punch sound.
1255                if node.getnodetype() != 'spaz':
1256                    sounds = SpazFactory.get().impact_sounds_medium
1257                    sound = sounds[random.randrange(len(sounds))]
1258                    sound.play(1.0, position=self.node.position)
1259
1260                ppos = self.node.punch_position
1261                punchdir = self.node.punch_velocity
1262                vel = self.node.punch_momentum_linear
1263
1264                self._punched_nodes.add(node)
1265                node.handlemessage(
1266                    bs.HitMessage(
1267                        pos=ppos,
1268                        velocity=vel,
1269                        magnitude=punch_power * punch_momentum_angular * 110.0,
1270                        velocity_magnitude=punch_power * 40,
1271                        radius=0,
1272                        srcnode=self.node,
1273                        source_player=self.source_player,
1274                        force_direction=punchdir,
1275                        hit_type='punch',
1276                        hit_subtype=(
1277                            'super_punch'
1278                            if self._has_boxing_gloves
1279                            else 'default'
1280                        ),
1281                    )
1282                )
1283
1284                # Also apply opposite to ourself for the first punch only.
1285                # This is given as a constant force so that it is more
1286                # noticeable for slower punches where it matters. For fast
1287                # awesome looking punches its ok if we punch 'through'
1288                # the target.
1289                mag = -400.0
1290                if self._hockey:
1291                    mag *= 0.5
1292                if len(self._punched_nodes) == 1:
1293                    self.node.handlemessage(
1294                        'kick_back',
1295                        ppos[0],
1296                        ppos[1],
1297                        ppos[2],
1298                        punchdir[0],
1299                        punchdir[1],
1300                        punchdir[2],
1301                        mag,
1302                    )
1303        elif isinstance(msg, PickupMessage):
1304            if not self.node:
1305                return None
1306
1307            try:
1308                collision = bs.getcollision()
1309                opposingnode = collision.opposingnode
1310                opposingbody = collision.opposingbody
1311            except bs.NotFoundError:
1312                return True
1313
1314            # Don't allow picking up of invincible dudes.
1315            try:
1316                if opposingnode.invincible:
1317                    return True
1318            except Exception:
1319                pass
1320
1321            # If we're grabbing the pelvis of a non-shattered spaz, we wanna
1322            # grab the torso instead.
1323            if (
1324                opposingnode.getnodetype() == 'spaz'
1325                and not opposingnode.shattered
1326                and opposingbody == 4
1327            ):
1328                opposingbody = 1
1329
1330            # Special case - if we're holding a flag, don't replace it
1331            # (hmm - should make this customizable or more low level).
1332            held = self.node.hold_node
1333            if held and held.getnodetype() == 'flag':
1334                return True
1335
1336            # Note: hold_body needs to be set before hold_node.
1337            self.node.hold_body = opposingbody
1338            self.node.hold_node = opposingnode
1339        elif isinstance(msg, bs.CelebrateMessage):
1340            if self.node:
1341                self.node.handlemessage('celebrate', int(msg.duration * 1000))
1342
1343        else:
1344            return super().handlemessage(msg)
1345        return None
1346
1347    def drop_bomb(self) -> Bomb | None:
1348        """
1349        Tell the spaz to drop one of his bombs, and returns
1350        the resulting bomb object.
1351        If the spaz has no bombs or is otherwise unable to
1352        drop a bomb, returns None.
1353        """
1354
1355        if (self.land_mine_count <= 0 and self.bomb_count <= 0) or self.frozen:
1356            return None
1357        assert self.node
1358        pos = self.node.position_forward
1359        vel = self.node.velocity
1360
1361        if self.land_mine_count > 0:
1362            dropping_bomb = False
1363            self.set_land_mine_count(self.land_mine_count - 1)
1364            bomb_type = 'land_mine'
1365        else:
1366            dropping_bomb = True
1367            bomb_type = self.bomb_type
1368
1369        bomb = Bomb(
1370            position=(pos[0], pos[1] - 0.0, pos[2]),
1371            velocity=(vel[0], vel[1], vel[2]),
1372            bomb_type=bomb_type,
1373            blast_radius=self.blast_radius,
1374            source_player=self.source_player,
1375            owner=self.node,
1376        ).autoretain()
1377
1378        assert bomb.node
1379        if dropping_bomb:
1380            self.bomb_count -= 1
1381            bomb.node.add_death_action(
1382                bs.WeakCall(self.handlemessage, BombDiedMessage())
1383            )
1384        self._pick_up(bomb.node)
1385
1386        for clb in self._dropped_bomb_callbacks:
1387            clb(self, bomb)
1388
1389        return bomb
1390
1391    def _pick_up(self, node: bs.Node) -> None:
1392        if self.node:
1393            # Note: hold_body needs to be set before hold_node.
1394            self.node.hold_body = 0
1395            self.node.hold_node = node
1396
1397    def set_land_mine_count(self, count: int) -> None:
1398        """Set the number of land-mines this spaz is carrying."""
1399        self.land_mine_count = count
1400        if self.node:
1401            if self.land_mine_count != 0:
1402                self.node.counter_text = 'x' + str(self.land_mine_count)
1403                self.node.counter_texture = (
1404                    PowerupBoxFactory.get().tex_land_mines
1405                )
1406            else:
1407                self.node.counter_text = ''
1408
1409    def curse_explode(self, source_player: bs.Player | None = None) -> None:
1410        """Explode the poor spaz spectacularly."""
1411        if self._cursed and self.node:
1412            self.shatter(extreme=True)
1413            self.handlemessage(bs.DieMessage())
1414            activity = self._activity()
1415            if activity:
1416                Blast(
1417                    position=self.node.position,
1418                    velocity=self.node.velocity,
1419                    blast_radius=3.0,
1420                    blast_type='normal',
1421                    source_player=(
1422                        source_player if source_player else self.source_player
1423                    ),
1424                ).autoretain()
1425            self._cursed = False
1426
1427    def shatter(self, extreme: bool = False) -> None:
1428        """Break the poor spaz into little bits."""
1429        if self.shattered:
1430            return
1431        self.shattered = True
1432        assert self.node
1433        if self.frozen:
1434            # Momentary flash of light.
1435            light = bs.newnode(
1436                'light',
1437                attrs={
1438                    'position': self.node.position,
1439                    'radius': 0.5,
1440                    'height_attenuated': False,
1441                    'color': (0.8, 0.8, 1.0),
1442                },
1443            )
1444
1445            bs.animate(
1446                light, 'intensity', {0.0: 3.0, 0.04: 0.5, 0.08: 0.07, 0.3: 0}
1447            )
1448            bs.timer(0.3, light.delete)
1449
1450            # Emit ice chunks.
1451            bs.emitfx(
1452                position=self.node.position,
1453                velocity=self.node.velocity,
1454                count=int(random.random() * 10.0 + 10.0),
1455                scale=0.6,
1456                spread=0.2,
1457                chunk_type='ice',
1458            )
1459            bs.emitfx(
1460                position=self.node.position,
1461                velocity=self.node.velocity,
1462                count=int(random.random() * 10.0 + 10.0),
1463                scale=0.3,
1464                spread=0.2,
1465                chunk_type='ice',
1466            )
1467            SpazFactory.get().shatter_sound.play(
1468                1.0,
1469                position=self.node.position,
1470            )
1471        else:
1472            SpazFactory.get().splatter_sound.play(
1473                1.0,
1474                position=self.node.position,
1475            )
1476        self.handlemessage(bs.DieMessage())
1477        self.node.shattered = 2 if extreme else 1
1478
1479    def _hit_self(self, intensity: float) -> None:
1480        if not self.node:
1481            return
1482        pos = self.node.position
1483        self.handlemessage(
1484            bs.HitMessage(
1485                flat_damage=50.0 * intensity,
1486                pos=pos,
1487                force_direction=self.node.velocity,
1488                hit_type='impact',
1489            )
1490        )
1491        self.node.handlemessage('knockout', max(0.0, 50.0 * intensity))
1492        sounds: Sequence[bs.Sound]
1493        if intensity >= 5.0:
1494            sounds = SpazFactory.get().impact_sounds_harder
1495        elif intensity >= 3.0:
1496            sounds = SpazFactory.get().impact_sounds_hard
1497        else:
1498            sounds = SpazFactory.get().impact_sounds_medium
1499        sound = sounds[random.randrange(len(sounds))]
1500        sound.play(position=pos, volume=5.0)
1501
1502    def _get_bomb_type_tex(self) -> bs.Texture:
1503        factory = PowerupBoxFactory.get()
1504        if self.bomb_type == 'sticky':
1505            return factory.tex_sticky_bombs
1506        if self.bomb_type == 'ice':
1507            return factory.tex_ice_bombs
1508        if self.bomb_type == 'impact':
1509            return factory.tex_impact_bombs
1510        raise ValueError('invalid bomb type')
1511
1512    def _flash_billboard(self, tex: bs.Texture) -> None:
1513        assert self.node
1514        self.node.billboard_texture = tex
1515        self.node.billboard_cross_out = False
1516        bs.animate(
1517            self.node,
1518            'billboard_opacity',
1519            {0.0: 0.0, 0.1: 1.0, 0.4: 1.0, 0.5: 0.0},
1520        )
1521
1522    def set_bomb_count(self, count: int) -> None:
1523        """Sets the number of bombs this Spaz has."""
1524        # We can't just set bomb_count because some bombs may be laid currently
1525        # so we have to do a relative diff based on max.
1526        diff = count - self._max_bomb_count
1527        self._max_bomb_count += diff
1528        self.bomb_count += diff
1529
1530    def _gloves_wear_off_flash(self) -> None:
1531        if self.node:
1532            self.node.boxing_gloves_flashing = True
1533            self.node.billboard_texture = PowerupBoxFactory.get().tex_punch
1534            self.node.billboard_opacity = 1.0
1535            self.node.billboard_cross_out = True
1536
1537    def _gloves_wear_off(self) -> None:
1538        if self._demo_mode:  # Preserve old behavior.
1539            self._punch_power_scale = 1.2
1540            self._punch_cooldown = BASE_PUNCH_COOLDOWN
1541        else:
1542            factory = SpazFactory.get()
1543            self._punch_power_scale = factory.punch_power_scale
1544            self._punch_cooldown = factory.punch_cooldown
1545        self._has_boxing_gloves = False
1546        if self.node:
1547            PowerupBoxFactory.get().powerdown_sound.play(
1548                position=self.node.position,
1549            )
1550            self.node.boxing_gloves = False
1551            self.node.billboard_opacity = 0.0
1552
1553    def _multi_bomb_wear_off_flash(self) -> None:
1554        if self.node:
1555            self.node.billboard_texture = PowerupBoxFactory.get().tex_bomb
1556            self.node.billboard_opacity = 1.0
1557            self.node.billboard_cross_out = True
1558
1559    def _multi_bomb_wear_off(self) -> None:
1560        self.set_bomb_count(self.default_bomb_count)
1561        if self.node:
1562            PowerupBoxFactory.get().powerdown_sound.play(
1563                position=self.node.position,
1564            )
1565            self.node.billboard_opacity = 0.0
1566
1567    def _bomb_wear_off_flash(self) -> None:
1568        if self.node:
1569            self.node.billboard_texture = self._get_bomb_type_tex()
1570            self.node.billboard_opacity = 1.0
1571            self.node.billboard_cross_out = True
1572
1573    def _bomb_wear_off(self) -> None:
1574        self.bomb_type = self.bomb_type_default
1575        if self.node:
1576            PowerupBoxFactory.get().powerdown_sound.play(
1577                position=self.node.position,
1578            )
1579            self.node.billboard_opacity = 0.0

Base class for various Spazzes.

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(5.0, bs.WeakCall(self.handlemessage, bs.ThawMessage()))
 891                # Instantly shatter if we're already dead.
 892                # (otherwise its hard to tell we're dead).
 893                if self.hitpoints <= 0:
 894                    self.shatter()
 895
 896        elif isinstance(msg, bs.ThawMessage):
 897            if self.frozen and not self.shattered and self.node:
 898                self.frozen = False
 899                self.node.frozen = False
 900
 901        elif isinstance(msg, bs.HitMessage):
 902            if not self.node:
 903                return None
 904            if self.node.invincible:
 905                SpazFactory.get().block_sound.play(
 906                    1.0,
 907                    position=self.node.position,
 908                )
 909                return True
 910
 911            # If we were recently hit, don't count this as another.
 912            # (so punch flurries and bomb pileups essentially count as 1 hit).
 913            local_time = int(bs.time() * 1000.0)
 914            assert isinstance(local_time, int)
 915            if (
 916                self._last_hit_time is None
 917                or local_time - self._last_hit_time > 1000
 918            ):
 919                self._num_times_hit += 1
 920                self._last_hit_time = local_time
 921
 922            mag = msg.magnitude * self.impact_scale
 923            velocity_mag = msg.velocity_magnitude * self.impact_scale
 924            damage_scale = 0.22
 925
 926            # If they've got a shield, deliver it to that instead.
 927            if self.shield:
 928                if msg.flat_damage:
 929                    damage = msg.flat_damage * self.impact_scale
 930                else:
 931                    # Hit our spaz with an impulse but tell it to only return
 932                    # theoretical damage; not apply the impulse.
 933                    assert msg.force_direction is not None
 934                    self.node.handlemessage(
 935                        'impulse',
 936                        msg.pos[0],
 937                        msg.pos[1],
 938                        msg.pos[2],
 939                        msg.velocity[0],
 940                        msg.velocity[1],
 941                        msg.velocity[2],
 942                        mag,
 943                        velocity_mag,
 944                        msg.radius,
 945                        1,
 946                        msg.force_direction[0],
 947                        msg.force_direction[1],
 948                        msg.force_direction[2],
 949                    )
 950                    damage = damage_scale * self.node.damage
 951
 952                assert self.shield_hitpoints is not None
 953                self.shield_hitpoints -= int(damage)
 954                self.shield.hurt = (
 955                    1.0
 956                    - float(self.shield_hitpoints) / self.shield_hitpoints_max
 957                )
 958
 959                # Its a cleaner event if a hit just kills the shield
 960                # without damaging the player.
 961                # However, massive damage events should still be able to
 962                # damage the player. This hopefully gives us a happy medium.
 963                max_spillover = SpazFactory.get().max_shield_spillover_damage
 964                if self.shield_hitpoints <= 0:
 965                    # FIXME: Transition out perhaps?
 966                    self.shield.delete()
 967                    self.shield = None
 968                    SpazFactory.get().shield_down_sound.play(
 969                        1.0,
 970                        position=self.node.position,
 971                    )
 972
 973                    # Emit some cool looking sparks when the shield dies.
 974                    npos = self.node.position
 975                    bs.emitfx(
 976                        position=(npos[0], npos[1] + 0.9, npos[2]),
 977                        velocity=self.node.velocity,
 978                        count=random.randrange(20, 30),
 979                        scale=1.0,
 980                        spread=0.6,
 981                        chunk_type='spark',
 982                    )
 983
 984                else:
 985                    SpazFactory.get().shield_hit_sound.play(
 986                        0.5,
 987                        position=self.node.position,
 988                    )
 989
 990                # Emit some cool looking sparks on shield hit.
 991                assert msg.force_direction is not None
 992                bs.emitfx(
 993                    position=msg.pos,
 994                    velocity=(
 995                        msg.force_direction[0] * 1.0,
 996                        msg.force_direction[1] * 1.0,
 997                        msg.force_direction[2] * 1.0,
 998                    ),
 999                    count=min(30, 5 + int(damage * 0.005)),
1000                    scale=0.5,
1001                    spread=0.3,
1002                    chunk_type='spark',
1003                )
1004
1005                # If they passed our spillover threshold,
1006                # pass damage along to spaz.
1007                if self.shield_hitpoints <= -max_spillover:
1008                    leftover_damage = -max_spillover - self.shield_hitpoints
1009                    shield_leftover_ratio = leftover_damage / damage
1010
1011                    # Scale down the magnitudes applied to spaz accordingly.
1012                    mag *= shield_leftover_ratio
1013                    velocity_mag *= shield_leftover_ratio
1014                else:
1015                    return True  # Good job shield!
1016            else:
1017                shield_leftover_ratio = 1.0
1018
1019            if msg.flat_damage:
1020                damage = int(
1021                    msg.flat_damage * self.impact_scale * shield_leftover_ratio
1022                )
1023            else:
1024                # Hit it with an impulse and get the resulting damage.
1025                assert msg.force_direction is not None
1026                self.node.handlemessage(
1027                    'impulse',
1028                    msg.pos[0],
1029                    msg.pos[1],
1030                    msg.pos[2],
1031                    msg.velocity[0],
1032                    msg.velocity[1],
1033                    msg.velocity[2],
1034                    mag,
1035                    velocity_mag,
1036                    msg.radius,
1037                    0,
1038                    msg.force_direction[0],
1039                    msg.force_direction[1],
1040                    msg.force_direction[2],
1041                )
1042
1043                damage = int(damage_scale * self.node.damage)
1044            self.node.handlemessage('hurt_sound')
1045
1046            # Play punch impact sound based on damage if it was a punch.
1047            if msg.hit_type == 'punch':
1048                self.on_punched(damage)
1049
1050                # If damage was significant, lets show it.
1051                if damage >= 350:
1052                    assert msg.force_direction is not None
1053                    bs.show_damage_count(
1054                        '-' + str(int(damage / 10)) + '%',
1055                        msg.pos,
1056                        msg.force_direction,
1057                    )
1058
1059                # Let's always add in a super-punch sound with boxing
1060                # gloves just to differentiate them.
1061                if msg.hit_subtype == 'super_punch':
1062                    SpazFactory.get().punch_sound_stronger.play(
1063                        1.0,
1064                        position=self.node.position,
1065                    )
1066                if damage >= 500:
1067                    sounds = SpazFactory.get().punch_sound_strong
1068                    sound = sounds[random.randrange(len(sounds))]
1069                elif damage >= 100:
1070                    sound = SpazFactory.get().punch_sound
1071                else:
1072                    sound = SpazFactory.get().punch_sound_weak
1073                sound.play(1.0, position=self.node.position)
1074
1075                # Throw up some chunks.
1076                assert msg.force_direction is not None
1077                bs.emitfx(
1078                    position=msg.pos,
1079                    velocity=(
1080                        msg.force_direction[0] * 0.5,
1081                        msg.force_direction[1] * 0.5,
1082                        msg.force_direction[2] * 0.5,
1083                    ),
1084                    count=min(10, 1 + int(damage * 0.0025)),
1085                    scale=0.3,
1086                    spread=0.03,
1087                )
1088
1089                bs.emitfx(
1090                    position=msg.pos,
1091                    chunk_type='sweat',
1092                    velocity=(
1093                        msg.force_direction[0] * 1.3,
1094                        msg.force_direction[1] * 1.3 + 5.0,
1095                        msg.force_direction[2] * 1.3,
1096                    ),
1097                    count=min(30, 1 + int(damage * 0.04)),
1098                    scale=0.9,
1099                    spread=0.28,
1100                )
1101
1102                # Momentary flash.
1103                hurtiness = damage * 0.003
1104                punchpos = (
1105                    msg.pos[0] + msg.force_direction[0] * 0.02,
1106                    msg.pos[1] + msg.force_direction[1] * 0.02,
1107                    msg.pos[2] + msg.force_direction[2] * 0.02,
1108                )
1109                flash_color = (1.0, 0.8, 0.4)
1110                light = bs.newnode(
1111                    'light',
1112                    attrs={
1113                        'position': punchpos,
1114                        'radius': 0.12 + hurtiness * 0.12,
1115                        'intensity': 0.3 * (1.0 + 1.0 * hurtiness),
1116                        'height_attenuated': False,
1117                        'color': flash_color,
1118                    },
1119                )
1120                bs.timer(0.06, light.delete)
1121
1122                flash = bs.newnode(
1123                    'flash',
1124                    attrs={
1125                        'position': punchpos,
1126                        'size': 0.17 + 0.17 * hurtiness,
1127                        'color': flash_color,
1128                    },
1129                )
1130                bs.timer(0.06, flash.delete)
1131
1132            if msg.hit_type == 'impact':
1133                assert msg.force_direction is not None
1134                bs.emitfx(
1135                    position=msg.pos,
1136                    velocity=(
1137                        msg.force_direction[0] * 2.0,
1138                        msg.force_direction[1] * 2.0,
1139                        msg.force_direction[2] * 2.0,
1140                    ),
1141                    count=min(10, 1 + int(damage * 0.01)),
1142                    scale=0.4,
1143                    spread=0.1,
1144                )
1145            if self.hitpoints > 0:
1146                # It's kinda crappy to die from impacts, so lets reduce
1147                # impact damage by a reasonable amount *if* it'll keep us alive.
1148                if msg.hit_type == 'impact' and damage >= self.hitpoints:
1149                    # Drop damage to whatever puts us at 10 hit points,
1150                    # or 200 less than it used to be whichever is greater
1151                    # (so it *can* still kill us if its high enough).
1152                    newdamage = max(damage - 200, self.hitpoints - 10)
1153                    damage = newdamage
1154                self.node.handlemessage('flash')
1155
1156                # If we're holding something, drop it.
1157                if damage > 0.0 and self.node.hold_node:
1158                    self.node.hold_node = None
1159                self.hitpoints -= damage
1160                self.node.hurt = (
1161                    1.0 - float(self.hitpoints) / self.hitpoints_max
1162                )
1163
1164                # If we're cursed, *any* damage blows us up.
1165                if self._cursed and damage > 0:
1166                    bs.timer(
1167                        0.05,
1168                        bs.WeakCall(
1169                            self.curse_explode, msg.get_source_player(bs.Player)
1170                        ),
1171                    )
1172
1173                # If we're frozen, shatter.. otherwise die if we hit zero
1174                if self.frozen and (damage > 200 or self.hitpoints <= 0):
1175                    self.shatter()
1176                elif self.hitpoints <= 0:
1177                    self.node.handlemessage(
1178                        bs.DieMessage(how=bs.DeathType.IMPACT)
1179                    )
1180
1181            # If we're dead, take a look at the smoothed damage value
1182            # (which gives us a smoothed average of recent damage) and shatter
1183            # us if its grown high enough.
1184            if self.hitpoints <= 0:
1185                damage_avg = self.node.damage_smoothed * damage_scale
1186                if damage_avg >= 1000:
1187                    self.shatter()
1188
1189        elif isinstance(msg, BombDiedMessage):
1190            self.bomb_count += 1
1191
1192        elif isinstance(msg, bs.DieMessage):
1193            wasdead = self._dead
1194            self._dead = True
1195            self.hitpoints = 0
1196            if msg.immediate:
1197                if self.node:
1198                    self.node.delete()
1199            elif self.node:
1200                if not wasdead:
1201                    self.node.hurt = 1.0
1202                    if self.play_big_death_sound:
1203                        SpazFactory.get().single_player_death_sound.play()
1204                    self.node.dead = True
1205                    bs.timer(2.0, self.node.delete)
1206
1207        elif isinstance(msg, bs.OutOfBoundsMessage):
1208            # By default we just die here.
1209            self.handlemessage(bs.DieMessage(how=bs.DeathType.FALL))
1210
1211        elif isinstance(msg, bs.StandMessage):
1212            self._last_stand_pos = (
1213                msg.position[0],
1214                msg.position[1],
1215                msg.position[2],
1216            )
1217            if self.node:
1218                self.node.handlemessage(
1219                    'stand',
1220                    msg.position[0],
1221                    msg.position[1],
1222                    msg.position[2],
1223                    msg.angle,
1224                )
1225
1226        elif isinstance(msg, CurseExplodeMessage):
1227            self.curse_explode()
1228
1229        elif isinstance(msg, PunchHitMessage):
1230            if not self.node:
1231                return None
1232            node = bs.getcollision().opposingnode
1233
1234            # Don't want to physically affect powerups.
1235            if node.getdelegate(PowerupBox):
1236                return None
1237
1238            # Only allow one hit per node per punch.
1239            if node and (node not in self._punched_nodes):
1240                punch_momentum_angular = (
1241                    self.node.punch_momentum_angular * self._punch_power_scale
1242                )
1243                punch_power = self.node.punch_power * self._punch_power_scale
1244
1245                # Ok here's the deal:  we pass along our base velocity for use
1246                # in the impulse damage calculations since that is a more
1247                # predictable value than our fist velocity, which is rather
1248                # erratic. However, we want to actually apply force in the
1249                # direction our fist is moving so it looks better. So we still
1250                # pass that along as a direction. Perhaps a time-averaged
1251                # fist-velocity would work too?.. perhaps should try that.
1252
1253                # If its something besides another spaz, just do a muffled
1254                # punch sound.
1255                if node.getnodetype() != 'spaz':
1256                    sounds = SpazFactory.get().impact_sounds_medium
1257                    sound = sounds[random.randrange(len(sounds))]
1258                    sound.play(1.0, position=self.node.position)
1259
1260                ppos = self.node.punch_position
1261                punchdir = self.node.punch_velocity
1262                vel = self.node.punch_momentum_linear
1263
1264                self._punched_nodes.add(node)
1265                node.handlemessage(
1266                    bs.HitMessage(
1267                        pos=ppos,
1268                        velocity=vel,
1269                        magnitude=punch_power * punch_momentum_angular * 110.0,
1270                        velocity_magnitude=punch_power * 40,
1271                        radius=0,
1272                        srcnode=self.node,
1273                        source_player=self.source_player,
1274                        force_direction=punchdir,
1275                        hit_type='punch',
1276                        hit_subtype=(
1277                            'super_punch'
1278                            if self._has_boxing_gloves
1279                            else 'default'
1280                        ),
1281                    )
1282                )
1283
1284                # Also apply opposite to ourself for the first punch only.
1285                # This is given as a constant force so that it is more
1286                # noticeable for slower punches where it matters. For fast
1287                # awesome looking punches its ok if we punch 'through'
1288                # the target.
1289                mag = -400.0
1290                if self._hockey:
1291                    mag *= 0.5
1292                if len(self._punched_nodes) == 1:
1293                    self.node.handlemessage(
1294                        'kick_back',
1295                        ppos[0],
1296                        ppos[1],
1297                        ppos[2],
1298                        punchdir[0],
1299                        punchdir[1],
1300                        punchdir[2],
1301                        mag,
1302                    )
1303        elif isinstance(msg, PickupMessage):
1304            if not self.node:
1305                return None
1306
1307            try:
1308                collision = bs.getcollision()
1309                opposingnode = collision.opposingnode
1310                opposingbody = collision.opposingbody
1311            except bs.NotFoundError:
1312                return True
1313
1314            # Don't allow picking up of invincible dudes.
1315            try:
1316                if opposingnode.invincible:
1317                    return True
1318            except Exception:
1319                pass
1320
1321            # If we're grabbing the pelvis of a non-shattered spaz, we wanna
1322            # grab the torso instead.
1323            if (
1324                opposingnode.getnodetype() == 'spaz'
1325                and not opposingnode.shattered
1326                and opposingbody == 4
1327            ):
1328                opposingbody = 1
1329
1330            # Special case - if we're holding a flag, don't replace it
1331            # (hmm - should make this customizable or more low level).
1332            held = self.node.hold_node
1333            if held and held.getnodetype() == 'flag':
1334                return True
1335
1336            # Note: hold_body needs to be set before hold_node.
1337            self.node.hold_body = opposingbody
1338            self.node.hold_node = opposingnode
1339        elif isinstance(msg, bs.CelebrateMessage):
1340            if self.node:
1341                self.node.handlemessage('celebrate', int(msg.duration * 1000))
1342
1343        else:
1344            return super().handlemessage(msg)
1345        return None

General message handling; can be passed any message object.

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

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

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

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

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

Explode the poor spaz spectacularly.

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

Break the poor spaz into little bits.

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

Sets the number of bombs this Spaz has.