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