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