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