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

We wanna pick something up.

PickupMessage()
class PunchHitMessage:
29class PunchHitMessage:
30    """Message saying an object was hit."""

Message saying an object was hit.

PunchHitMessage()
class CurseExplodeMessage:
33class CurseExplodeMessage:
34    """We are cursed and should blow up now."""

We are cursed and should blow up now.

CurseExplodeMessage()
class BombDiedMessage:
37class BombDiedMessage:
38    """A bomb has died and thus can be recycled."""

A bomb has died and thus can be recycled.

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

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

node: _ba.Node

The 'spaz' ba.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
def exists(self) -> bool:
226    def exists(self) -> bool:
227        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 ba.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 ba.Actor.autoretain()

The default implementation of this method always return True.

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

def on_expire(self) -> None:
229    def on_expire(self) -> None:
230        super().on_expire()
231
232        # Release callbacks/refs so we don't wind up with dependency loops.
233        self._dropped_bomb_callbacks = []
234        self.punch_callback = None
235        self.pick_up_powerup_callback = None

Called for remaining ba.Actors when their ba.Activity shuts down.

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

Once an actor is expired (see ba.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[[bastd.actor.spaz.Spaz, ba._actor.Actor], Any]) -> None:
237    def add_dropped_bomb_callback(
238            self, call: Callable[[Spaz, ba.Actor], Any]) -> None:
239        """
240        Add a call to be run whenever this Spaz drops a bomb.
241        The spaz and the newly-dropped bomb are passed as arguments.
242        """
243        assert not self.expired
244        self._dropped_bomb_callbacks.append(call)

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

def is_alive(self) -> bool:
246    def is_alive(self) -> bool:
247        """
248        Method override; returns whether ol' spaz is still kickin'.
249        """
250        return not self._dead

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

def set_score_text( self, text: str | ba._language.Lstr, color: Sequence[float] = (1.0, 1.0, 0.4), flash: bool = False) -> None:
301    def set_score_text(self,
302                       text: str | ba.Lstr,
303                       color: Sequence[float] = (1.0, 1.0, 0.4),
304                       flash: bool = False) -> None:
305        """
306        Utility func to show a message momentarily over our spaz that follows
307        him around; Handy for score updates and things.
308        """
309        color_fin = ba.safecolor(color)[:3]
310        if not self.node:
311            return
312        if not self._score_text:
313            start_scale = 0.0
314            mnode = ba.newnode('math',
315                               owner=self.node,
316                               attrs={
317                                   'input1': (0, 1.4, 0),
318                                   'operation': 'add'
319                               })
320            self.node.connectattr('torso_position', mnode, 'input2')
321            self._score_text = ba.newnode('text',
322                                          owner=self.node,
323                                          attrs={
324                                              'text': text,
325                                              'in_world': True,
326                                              'shadow': 1.0,
327                                              'flatness': 1.0,
328                                              'color': color_fin,
329                                              'scale': 0.02,
330                                              'h_align': 'center'
331                                          })
332            mnode.connectattr('output', self._score_text, 'position')
333        else:
334            self._score_text.color = color_fin
335            assert isinstance(self._score_text.scale, float)
336            start_scale = self._score_text.scale
337            self._score_text.text = text
338        if flash:
339            combine = ba.newnode('combine',
340                                 owner=self._score_text,
341                                 attrs={'size': 3})
342            scl = 1.8
343            offs = 0.5
344            tval = 0.300
345            for i in range(3):
346                cl1 = offs + scl * color_fin[i]
347                cl2 = color_fin[i]
348                ba.animate(combine, 'input' + str(i), {
349                    0.5 * tval: cl2,
350                    0.75 * tval: cl1,
351                    1.0 * tval: cl2
352                })
353            combine.connectattr('output', self._score_text, 'color')
354
355        ba.animate(self._score_text, 'scale', {0.0: start_scale, 0.2: 0.02})
356        self._score_text_hide_timer = ba.Timer(
357            1.0, ba.WeakCall(self._hide_score_text))

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:
359    def on_jump_press(self) -> None:
360        """
361        Called to 'press jump' on this spaz;
362        used by player or AI connections.
363        """
364        if not self.node:
365            return
366        t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
367        assert isinstance(t_ms, int)
368        if t_ms - self.last_jump_time_ms >= self._jump_cooldown:
369            self.node.jump_pressed = True
370            self.last_jump_time_ms = t_ms
371        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:
373    def on_jump_release(self) -> None:
374        """
375        Called to 'release jump' on this spaz;
376        used by player or AI connections.
377        """
378        if not self.node:
379            return
380        self.node.jump_pressed = False

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

def on_pickup_press(self) -> None:
382    def on_pickup_press(self) -> None:
383        """
384        Called to 'press pick-up' on this spaz;
385        used by player or AI connections.
386        """
387        if not self.node:
388            return
389        t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
390        assert isinstance(t_ms, int)
391        if t_ms - self.last_pickup_time_ms >= self._pickup_cooldown:
392            self.node.pickup_pressed = True
393            self.last_pickup_time_ms = t_ms
394        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:
396    def on_pickup_release(self) -> None:
397        """
398        Called to 'release pick-up' on this spaz;
399        used by player or AI connections.
400        """
401        if not self.node:
402            return
403        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:
405    def on_hold_position_press(self) -> None:
406        """
407        Called to 'press hold-position' on this spaz;
408        used for player or AI connections.
409        """
410        if not self.node:
411            return
412        self.node.hold_position_pressed = True
413        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:
415    def on_hold_position_release(self) -> None:
416        """
417        Called to 'release hold-position' on this spaz;
418        used for player or AI connections.
419        """
420        if not self.node:
421            return
422        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:
424    def on_punch_press(self) -> None:
425        """
426        Called to 'press punch' on this spaz;
427        used for player or AI connections.
428        """
429        if not self.node or self.frozen or self.node.knockout > 0.0:
430            return
431        t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
432        assert isinstance(t_ms, int)
433        if t_ms - self.last_punch_time_ms >= self._punch_cooldown:
434            if self.punch_callback is not None:
435                self.punch_callback(self)
436            self._punched_nodes = set()  # Reset this.
437            self.last_punch_time_ms = t_ms
438            self.node.punch_pressed = True
439            if not self.node.hold_node:
440                ba.timer(
441                    0.1,
442                    ba.WeakCall(self._safe_play_sound,
443                                SpazFactory.get().swish_sound, 0.8))
444        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:
451    def on_punch_release(self) -> None:
452        """
453        Called to 'release punch' on this spaz;
454        used for player or AI connections.
455        """
456        if not self.node:
457            return
458        self.node.punch_pressed = False

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

def on_bomb_press(self) -> None:
460    def on_bomb_press(self) -> None:
461        """
462        Called to 'press bomb' on this spaz;
463        used for player or AI connections.
464        """
465        if not self.node:
466            return
467
468        if self._dead or self.frozen:
469            return
470        if self.node.knockout > 0.0:
471            return
472        t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
473        assert isinstance(t_ms, int)
474        if t_ms - self.last_bomb_time_ms >= self._bomb_cooldown:
475            self.last_bomb_time_ms = t_ms
476            self.node.bomb_pressed = True
477            if not self.node.hold_node:
478                self.drop_bomb()
479        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:
481    def on_bomb_release(self) -> None:
482        """
483        Called to 'release bomb' on this spaz;
484        used for player or AI connections.
485        """
486        if not self.node:
487            return
488        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:
490    def on_run(self, value: float) -> None:
491        """
492        Called to 'press run' on this spaz;
493        used for player or AI connections.
494        """
495        if not self.node:
496            return
497
498        t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
499        assert isinstance(t_ms, int)
500        self.last_run_time_ms = t_ms
501        self.node.run = value
502
503        # filtering these events would be tough since its an analog
504        # value, but lets still pass full 0-to-1 presses along to
505        # the turbo filter to punish players if it looks like they're turbo-ing
506        if self._last_run_value < 0.01 and value > 0.99:
507            self._turbo_filter_add_press('run')
508
509        self._last_run_value = value

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

def on_fly_press(self) -> None:
511    def on_fly_press(self) -> None:
512        """
513        Called to 'press fly' on this spaz;
514        used for player or AI connections.
515        """
516        if not self.node:
517            return
518        # not adding a cooldown time here for now; slightly worried
519        # input events get clustered up during net-games and we'd wind up
520        # killing a lot and making it hard to fly.. should look into this.
521        self.node.fly_pressed = True
522        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:
524    def on_fly_release(self) -> None:
525        """
526        Called to 'release fly' on this spaz;
527        used for player or AI connections.
528        """
529        if not self.node:
530            return
531        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:
533    def on_move(self, x: float, y: float) -> None:
534        """
535        Called to set the joystick amount for this spaz;
536        used for player or AI connections.
537        """
538        if not self.node:
539            return
540        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:
542    def on_move_up_down(self, value: float) -> None:
543        """
544        Called to set the up/down joystick amount on this spaz;
545        used for player or AI connections.
546        value will be between -32768 to 32767
547        WARNING: deprecated; use on_move instead.
548        """
549        if not self.node:
550            return
551        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:
553    def on_move_left_right(self, value: float) -> None:
554        """
555        Called to set the left/right joystick amount on this spaz;
556        used for player or AI connections.
557        value will be between -32768 to 32767
558        WARNING: deprecated; use on_move instead.
559        """
560        if not self.node:
561            return
562        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:
564    def on_punched(self, damage: int) -> None:
565        """Called when this spaz gets punched."""

Called when this spaz gets punched.

def get_death_points(self, how: ba._messages.DeathType) -> tuple[int, int]:
567    def get_death_points(self, how: ba.DeathType) -> tuple[int, int]:
568        """Get the points awarded for killing this spaz."""
569        del how  # Unused.
570        num_hits = float(max(1, self._num_times_hit))
571
572        # Base points is simply 10 for 1-hit-kills and 5 otherwise.
573        importance = 2 if num_hits < 2 else 1
574        return (10 if num_hits < 2 else 5) * self.points_mult, importance

Get the points awarded for killing this spaz.

def curse(self) -> None:
576    def curse(self) -> None:
577        """
578        Give this poor spaz a curse;
579        he will explode in 5 seconds.
580        """
581        if not self._cursed:
582            factory = SpazFactory.get()
583            self._cursed = True
584
585            # Add the curse material.
586            for attr in ['materials', 'roller_materials']:
587                materials = getattr(self.node, attr)
588                if factory.curse_material not in materials:
589                    setattr(self.node, attr,
590                            materials + (factory.curse_material, ))
591
592            # None specifies no time limit
593            assert self.node
594            if self.curse_time is None:
595                self.node.curse_death_time = -1
596            else:
597                # Note: curse-death-time takes milliseconds.
598                tval = ba.time()
599                assert isinstance(tval, (float, int))
600                self.node.curse_death_time = int(1000.0 *
601                                                 (tval + self.curse_time))
602                ba.timer(5.0, ba.WeakCall(self.curse_explode))

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

def equip_boxing_gloves(self) -> None:
604    def equip_boxing_gloves(self) -> None:
605        """
606        Give this spaz some boxing gloves.
607        """
608        assert self.node
609        self.node.boxing_gloves = True
610        self._has_boxing_gloves = True
611        if self._demo_mode:  # Preserve old behavior.
612            self._punch_power_scale = 1.7
613            self._punch_cooldown = 300
614        else:
615            factory = SpazFactory.get()
616            self._punch_power_scale = factory.punch_power_scale_gloves
617            self._punch_cooldown = factory.punch_cooldown_gloves

Give this spaz some boxing gloves.

def equip_shields(self, decay: bool = False) -> None:
619    def equip_shields(self, decay: bool = False) -> None:
620        """
621        Give this spaz a nice energy shield.
622        """
623
624        if not self.node:
625            ba.print_error('Can\'t equip shields; no node.')
626            return
627
628        factory = SpazFactory.get()
629        if self.shield is None:
630            self.shield = ba.newnode('shield',
631                                     owner=self.node,
632                                     attrs={
633                                         'color': (0.3, 0.2, 2.0),
634                                         'radius': 1.3
635                                     })
636            self.node.connectattr('position_center', self.shield, 'position')
637        self.shield_hitpoints = self.shield_hitpoints_max = 650
638        self.shield_decay_rate = factory.shield_decay_rate if decay else 0
639        self.shield.hurt = 0
640        ba.playsound(factory.shield_up_sound, 1.0, position=self.node.position)
641
642        if self.shield_decay_rate > 0:
643            self.shield_decay_timer = ba.Timer(0.5,
644                                               ba.WeakCall(self.shield_decay),
645                                               repeat=True)
646            # So user can see the decay.
647            self.shield.always_show_health_bar = True

Give this spaz a nice energy shield.

def shield_decay(self) -> None:
649    def shield_decay(self) -> None:
650        """Called repeatedly to decay shield HP over time."""
651        if self.shield:
652            assert self.shield_hitpoints is not None
653            self.shield_hitpoints = (max(
654                0, self.shield_hitpoints - self.shield_decay_rate))
655            assert self.shield_hitpoints is not None
656            self.shield.hurt = (
657                1.0 - float(self.shield_hitpoints) / self.shield_hitpoints_max)
658            if self.shield_hitpoints <= 0:
659                self.shield.delete()
660                self.shield = None
661                self.shield_decay_timer = None
662                assert self.node
663                ba.playsound(SpazFactory.get().shield_down_sound,
664                             1.0,
665                             position=self.node.position)
666        else:
667            self.shield_decay_timer = None

Called repeatedly to decay shield HP over time.

def handlemessage(self, msg: Any) -> Any:
 669    def handlemessage(self, msg: Any) -> Any:
 670        # pylint: disable=too-many-return-statements
 671        # pylint: disable=too-many-statements
 672        # pylint: disable=too-many-branches
 673        assert not self.expired
 674
 675        if isinstance(msg, ba.PickedUpMessage):
 676            if self.node:
 677                self.node.handlemessage('hurt_sound')
 678                self.node.handlemessage('picked_up')
 679
 680            # This counts as a hit.
 681            self._num_times_hit += 1
 682
 683        elif isinstance(msg, ba.ShouldShatterMessage):
 684            # Eww; seems we have to do this in a timer or it wont work right.
 685            # (since we're getting called from within update() perhaps?..)
 686            # NOTE: should test to see if that's still the case.
 687            ba.timer(0.001, ba.WeakCall(self.shatter))
 688
 689        elif isinstance(msg, ba.ImpactDamageMessage):
 690            # Eww; seems we have to do this in a timer or it wont work right.
 691            # (since we're getting called from within update() perhaps?..)
 692            ba.timer(0.001, ba.WeakCall(self._hit_self, msg.intensity))
 693
 694        elif isinstance(msg, ba.PowerupMessage):
 695            if self._dead or not self.node:
 696                return True
 697            if self.pick_up_powerup_callback is not None:
 698                self.pick_up_powerup_callback(self)
 699            if msg.poweruptype == 'triple_bombs':
 700                tex = PowerupBoxFactory.get().tex_bomb
 701                self._flash_billboard(tex)
 702                self.set_bomb_count(3)
 703                if self.powerups_expire:
 704                    self.node.mini_billboard_1_texture = tex
 705                    t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 706                    assert isinstance(t_ms, int)
 707                    self.node.mini_billboard_1_start_time = t_ms
 708                    self.node.mini_billboard_1_end_time = (
 709                        t_ms + POWERUP_WEAR_OFF_TIME)
 710                    self._multi_bomb_wear_off_flash_timer = (ba.Timer(
 711                        (POWERUP_WEAR_OFF_TIME - 2000),
 712                        ba.WeakCall(self._multi_bomb_wear_off_flash),
 713                        timeformat=ba.TimeFormat.MILLISECONDS))
 714                    self._multi_bomb_wear_off_timer = (ba.Timer(
 715                        POWERUP_WEAR_OFF_TIME,
 716                        ba.WeakCall(self._multi_bomb_wear_off),
 717                        timeformat=ba.TimeFormat.MILLISECONDS))
 718            elif msg.poweruptype == 'land_mines':
 719                self.set_land_mine_count(min(self.land_mine_count + 3, 3))
 720            elif msg.poweruptype == 'impact_bombs':
 721                self.bomb_type = 'impact'
 722                tex = self._get_bomb_type_tex()
 723                self._flash_billboard(tex)
 724                if self.powerups_expire:
 725                    self.node.mini_billboard_2_texture = tex
 726                    t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 727                    assert isinstance(t_ms, int)
 728                    self.node.mini_billboard_2_start_time = t_ms
 729                    self.node.mini_billboard_2_end_time = (
 730                        t_ms + POWERUP_WEAR_OFF_TIME)
 731                    self._bomb_wear_off_flash_timer = (ba.Timer(
 732                        POWERUP_WEAR_OFF_TIME - 2000,
 733                        ba.WeakCall(self._bomb_wear_off_flash),
 734                        timeformat=ba.TimeFormat.MILLISECONDS))
 735                    self._bomb_wear_off_timer = (ba.Timer(
 736                        POWERUP_WEAR_OFF_TIME,
 737                        ba.WeakCall(self._bomb_wear_off),
 738                        timeformat=ba.TimeFormat.MILLISECONDS))
 739            elif msg.poweruptype == 'sticky_bombs':
 740                self.bomb_type = 'sticky'
 741                tex = self._get_bomb_type_tex()
 742                self._flash_billboard(tex)
 743                if self.powerups_expire:
 744                    self.node.mini_billboard_2_texture = tex
 745                    t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 746                    assert isinstance(t_ms, int)
 747                    self.node.mini_billboard_2_start_time = t_ms
 748                    self.node.mini_billboard_2_end_time = (
 749                        t_ms + POWERUP_WEAR_OFF_TIME)
 750                    self._bomb_wear_off_flash_timer = (ba.Timer(
 751                        POWERUP_WEAR_OFF_TIME - 2000,
 752                        ba.WeakCall(self._bomb_wear_off_flash),
 753                        timeformat=ba.TimeFormat.MILLISECONDS))
 754                    self._bomb_wear_off_timer = (ba.Timer(
 755                        POWERUP_WEAR_OFF_TIME,
 756                        ba.WeakCall(self._bomb_wear_off),
 757                        timeformat=ba.TimeFormat.MILLISECONDS))
 758            elif msg.poweruptype == 'punch':
 759                tex = PowerupBoxFactory.get().tex_punch
 760                self._flash_billboard(tex)
 761                self.equip_boxing_gloves()
 762                if self.powerups_expire:
 763                    self.node.boxing_gloves_flashing = False
 764                    self.node.mini_billboard_3_texture = tex
 765                    t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 766                    assert isinstance(t_ms, int)
 767                    self.node.mini_billboard_3_start_time = t_ms
 768                    self.node.mini_billboard_3_end_time = (
 769                        t_ms + POWERUP_WEAR_OFF_TIME)
 770                    self._boxing_gloves_wear_off_flash_timer = (ba.Timer(
 771                        POWERUP_WEAR_OFF_TIME - 2000,
 772                        ba.WeakCall(self._gloves_wear_off_flash),
 773                        timeformat=ba.TimeFormat.MILLISECONDS))
 774                    self._boxing_gloves_wear_off_timer = (ba.Timer(
 775                        POWERUP_WEAR_OFF_TIME,
 776                        ba.WeakCall(self._gloves_wear_off),
 777                        timeformat=ba.TimeFormat.MILLISECONDS))
 778            elif msg.poweruptype == 'shield':
 779                factory = SpazFactory.get()
 780
 781                # Let's allow powerup-equipped shields to lose hp over time.
 782                self.equip_shields(decay=factory.shield_decay_rate > 0)
 783            elif msg.poweruptype == 'curse':
 784                self.curse()
 785            elif msg.poweruptype == 'ice_bombs':
 786                self.bomb_type = 'ice'
 787                tex = self._get_bomb_type_tex()
 788                self._flash_billboard(tex)
 789                if self.powerups_expire:
 790                    self.node.mini_billboard_2_texture = tex
 791                    t_ms = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 792                    assert isinstance(t_ms, int)
 793                    self.node.mini_billboard_2_start_time = t_ms
 794                    self.node.mini_billboard_2_end_time = (
 795                        t_ms + POWERUP_WEAR_OFF_TIME)
 796                    self._bomb_wear_off_flash_timer = (ba.Timer(
 797                        POWERUP_WEAR_OFF_TIME - 2000,
 798                        ba.WeakCall(self._bomb_wear_off_flash),
 799                        timeformat=ba.TimeFormat.MILLISECONDS))
 800                    self._bomb_wear_off_timer = (ba.Timer(
 801                        POWERUP_WEAR_OFF_TIME,
 802                        ba.WeakCall(self._bomb_wear_off),
 803                        timeformat=ba.TimeFormat.MILLISECONDS))
 804            elif msg.poweruptype == 'health':
 805                if self._cursed:
 806                    self._cursed = False
 807
 808                    # Remove cursed material.
 809                    factory = SpazFactory.get()
 810                    for attr in ['materials', 'roller_materials']:
 811                        materials = getattr(self.node, attr)
 812                        if factory.curse_material in materials:
 813                            setattr(
 814                                self.node, attr,
 815                                tuple(m for m in materials
 816                                      if m != factory.curse_material))
 817                    self.node.curse_death_time = 0
 818                self.hitpoints = self.hitpoints_max
 819                self._flash_billboard(PowerupBoxFactory.get().tex_health)
 820                self.node.hurt = 0
 821                self._last_hit_time = None
 822                self._num_times_hit = 0
 823
 824            self.node.handlemessage('flash')
 825            if msg.sourcenode:
 826                msg.sourcenode.handlemessage(ba.PowerupAcceptMessage())
 827            return True
 828
 829        elif isinstance(msg, ba.FreezeMessage):
 830            if not self.node:
 831                return None
 832            if self.node.invincible:
 833                ba.playsound(SpazFactory.get().block_sound,
 834                             1.0,
 835                             position=self.node.position)
 836                return None
 837            if self.shield:
 838                return None
 839            if not self.frozen:
 840                self.frozen = True
 841                self.node.frozen = True
 842                ba.timer(5.0, ba.WeakCall(self.handlemessage,
 843                                          ba.ThawMessage()))
 844                # Instantly shatter if we're already dead.
 845                # (otherwise its hard to tell we're dead)
 846                if self.hitpoints <= 0:
 847                    self.shatter()
 848
 849        elif isinstance(msg, ba.ThawMessage):
 850            if self.frozen and not self.shattered and self.node:
 851                self.frozen = False
 852                self.node.frozen = False
 853
 854        elif isinstance(msg, ba.HitMessage):
 855            if not self.node:
 856                return None
 857            if self.node.invincible:
 858                ba.playsound(SpazFactory.get().block_sound,
 859                             1.0,
 860                             position=self.node.position)
 861                return True
 862
 863            # If we were recently hit, don't count this as another.
 864            # (so punch flurries and bomb pileups essentially count as 1 hit)
 865            local_time = ba.time(timeformat=ba.TimeFormat.MILLISECONDS)
 866            assert isinstance(local_time, int)
 867            if (self._last_hit_time is None
 868                    or local_time - self._last_hit_time > 1000):
 869                self._num_times_hit += 1
 870                self._last_hit_time = local_time
 871
 872            mag = msg.magnitude * self.impact_scale
 873            velocity_mag = msg.velocity_magnitude * self.impact_scale
 874            damage_scale = 0.22
 875
 876            # If they've got a shield, deliver it to that instead.
 877            if self.shield:
 878                if msg.flat_damage:
 879                    damage = msg.flat_damage * self.impact_scale
 880                else:
 881                    # Hit our spaz with an impulse but tell it to only return
 882                    # theoretical damage; not apply the impulse.
 883                    assert msg.force_direction is not None
 884                    self.node.handlemessage(
 885                        'impulse', msg.pos[0], msg.pos[1], msg.pos[2],
 886                        msg.velocity[0], msg.velocity[1], msg.velocity[2], mag,
 887                        velocity_mag, msg.radius, 1, msg.force_direction[0],
 888                        msg.force_direction[1], msg.force_direction[2])
 889                    damage = damage_scale * self.node.damage
 890
 891                assert self.shield_hitpoints is not None
 892                self.shield_hitpoints -= int(damage)
 893                self.shield.hurt = (
 894                    1.0 -
 895                    float(self.shield_hitpoints) / self.shield_hitpoints_max)
 896
 897                # Its a cleaner event if a hit just kills the shield
 898                # without damaging the player.
 899                # However, massive damage events should still be able to
 900                # damage the player. This hopefully gives us a happy medium.
 901                max_spillover = SpazFactory.get().max_shield_spillover_damage
 902                if self.shield_hitpoints <= 0:
 903
 904                    # FIXME: Transition out perhaps?
 905                    self.shield.delete()
 906                    self.shield = None
 907                    ba.playsound(SpazFactory.get().shield_down_sound,
 908                                 1.0,
 909                                 position=self.node.position)
 910
 911                    # Emit some cool looking sparks when the shield dies.
 912                    npos = self.node.position
 913                    ba.emitfx(position=(npos[0], npos[1] + 0.9, npos[2]),
 914                              velocity=self.node.velocity,
 915                              count=random.randrange(20, 30),
 916                              scale=1.0,
 917                              spread=0.6,
 918                              chunk_type='spark')
 919
 920                else:
 921                    ba.playsound(SpazFactory.get().shield_hit_sound,
 922                                 0.5,
 923                                 position=self.node.position)
 924
 925                # Emit some cool looking sparks on shield hit.
 926                assert msg.force_direction is not None
 927                ba.emitfx(position=msg.pos,
 928                          velocity=(msg.force_direction[0] * 1.0,
 929                                    msg.force_direction[1] * 1.0,
 930                                    msg.force_direction[2] * 1.0),
 931                          count=min(30, 5 + int(damage * 0.005)),
 932                          scale=0.5,
 933                          spread=0.3,
 934                          chunk_type='spark')
 935
 936                # If they passed our spillover threshold,
 937                # pass damage along to spaz.
 938                if self.shield_hitpoints <= -max_spillover:
 939                    leftover_damage = -max_spillover - self.shield_hitpoints
 940                    shield_leftover_ratio = leftover_damage / damage
 941
 942                    # Scale down the magnitudes applied to spaz accordingly.
 943                    mag *= shield_leftover_ratio
 944                    velocity_mag *= shield_leftover_ratio
 945                else:
 946                    return True  # Good job shield!
 947            else:
 948                shield_leftover_ratio = 1.0
 949
 950            if msg.flat_damage:
 951                damage = int(msg.flat_damage * self.impact_scale *
 952                             shield_leftover_ratio)
 953            else:
 954                # Hit it with an impulse and get the resulting damage.
 955                assert msg.force_direction is not None
 956                self.node.handlemessage(
 957                    'impulse', msg.pos[0], msg.pos[1], msg.pos[2],
 958                    msg.velocity[0], msg.velocity[1], msg.velocity[2], mag,
 959                    velocity_mag, msg.radius, 0, msg.force_direction[0],
 960                    msg.force_direction[1], msg.force_direction[2])
 961
 962                damage = int(damage_scale * self.node.damage)
 963            self.node.handlemessage('hurt_sound')
 964
 965            # Play punch impact sound based on damage if it was a punch.
 966            if msg.hit_type == 'punch':
 967                self.on_punched(damage)
 968
 969                # If damage was significant, lets show it.
 970                if damage > 350:
 971                    assert msg.force_direction is not None
 972                    ba.show_damage_count('-' + str(int(damage / 10)) + '%',
 973                                         msg.pos, msg.force_direction)
 974
 975                # Let's always add in a super-punch sound with boxing
 976                # gloves just to differentiate them.
 977                if msg.hit_subtype == 'super_punch':
 978                    ba.playsound(SpazFactory.get().punch_sound_stronger,
 979                                 1.0,
 980                                 position=self.node.position)
 981                if damage > 500:
 982                    sounds = SpazFactory.get().punch_sound_strong
 983                    sound = sounds[random.randrange(len(sounds))]
 984                else:
 985                    sound = SpazFactory.get().punch_sound
 986                ba.playsound(sound, 1.0, position=self.node.position)
 987
 988                # Throw up some chunks.
 989                assert msg.force_direction is not None
 990                ba.emitfx(position=msg.pos,
 991                          velocity=(msg.force_direction[0] * 0.5,
 992                                    msg.force_direction[1] * 0.5,
 993                                    msg.force_direction[2] * 0.5),
 994                          count=min(10, 1 + int(damage * 0.0025)),
 995                          scale=0.3,
 996                          spread=0.03)
 997
 998                ba.emitfx(position=msg.pos,
 999                          chunk_type='sweat',
1000                          velocity=(msg.force_direction[0] * 1.3,
1001                                    msg.force_direction[1] * 1.3 + 5.0,
1002                                    msg.force_direction[2] * 1.3),
1003                          count=min(30, 1 + int(damage * 0.04)),
1004                          scale=0.9,
1005                          spread=0.28)
1006
1007                # Momentary flash.
1008                hurtiness = damage * 0.003
1009                punchpos = (msg.pos[0] + msg.force_direction[0] * 0.02,
1010                            msg.pos[1] + msg.force_direction[1] * 0.02,
1011                            msg.pos[2] + msg.force_direction[2] * 0.02)
1012                flash_color = (1.0, 0.8, 0.4)
1013                light = ba.newnode(
1014                    'light',
1015                    attrs={
1016                        'position': punchpos,
1017                        'radius': 0.12 + hurtiness * 0.12,
1018                        'intensity': 0.3 * (1.0 + 1.0 * hurtiness),
1019                        'height_attenuated': False,
1020                        'color': flash_color
1021                    })
1022                ba.timer(0.06, light.delete)
1023
1024                flash = ba.newnode('flash',
1025                                   attrs={
1026                                       'position': punchpos,
1027                                       'size': 0.17 + 0.17 * hurtiness,
1028                                       'color': flash_color
1029                                   })
1030                ba.timer(0.06, flash.delete)
1031
1032            if msg.hit_type == 'impact':
1033                assert msg.force_direction is not None
1034                ba.emitfx(position=msg.pos,
1035                          velocity=(msg.force_direction[0] * 2.0,
1036                                    msg.force_direction[1] * 2.0,
1037                                    msg.force_direction[2] * 2.0),
1038                          count=min(10, 1 + int(damage * 0.01)),
1039                          scale=0.4,
1040                          spread=0.1)
1041            if self.hitpoints > 0:
1042
1043                # It's kinda crappy to die from impacts, so lets reduce
1044                # impact damage by a reasonable amount *if* it'll keep us alive
1045                if msg.hit_type == 'impact' and damage > self.hitpoints:
1046                    # Drop damage to whatever puts us at 10 hit points,
1047                    # or 200 less than it used to be whichever is greater
1048                    # (so it *can* still kill us if its high enough)
1049                    newdamage = max(damage - 200, self.hitpoints - 10)
1050                    damage = newdamage
1051                self.node.handlemessage('flash')
1052
1053                # If we're holding something, drop it.
1054                if damage > 0.0 and self.node.hold_node:
1055                    self.node.hold_node = None
1056                self.hitpoints -= damage
1057                self.node.hurt = 1.0 - float(
1058                    self.hitpoints) / self.hitpoints_max
1059
1060                # If we're cursed, *any* damage blows us up.
1061                if self._cursed and damage > 0:
1062                    ba.timer(
1063                        0.05,
1064                        ba.WeakCall(self.curse_explode,
1065                                    msg.get_source_player(ba.Player)))
1066
1067                # If we're frozen, shatter.. otherwise die if we hit zero
1068                if self.frozen and (damage > 200 or self.hitpoints <= 0):
1069                    self.shatter()
1070                elif self.hitpoints <= 0:
1071                    self.node.handlemessage(
1072                        ba.DieMessage(how=ba.DeathType.IMPACT))
1073
1074            # If we're dead, take a look at the smoothed damage value
1075            # (which gives us a smoothed average of recent damage) and shatter
1076            # us if its grown high enough.
1077            if self.hitpoints <= 0:
1078                damage_avg = self.node.damage_smoothed * damage_scale
1079                if damage_avg > 1000:
1080                    self.shatter()
1081
1082        elif isinstance(msg, BombDiedMessage):
1083            self.bomb_count += 1
1084