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