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