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