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