Source code for bascenev1lib.actor.spaz

# Released under the MIT License. See LICENSE for details.
#
"""Defines the spaz actor."""
# pylint: disable=too-many-lines

from __future__ import annotations

import random
import logging
from typing import TYPE_CHECKING, override

import bascenev1 as bs

from bascenev1lib.actor.bomb import Bomb, Blast
from bascenev1lib.actor.powerupbox import PowerupBoxFactory, PowerupBox
from bascenev1lib.actor.spazfactory import SpazFactory
from bascenev1lib.gameutils import SharedObjects

if TYPE_CHECKING:
    from typing import Any, Sequence, Callable

POWERUP_WEAR_OFF_TIME = 20000

# Obsolete - just used for demo guy now.
BASE_PUNCH_POWER_SCALE = 1.2
BASE_PUNCH_COOLDOWN = 400


[docs] class PickupMessage: """We wanna pick something up."""
[docs] class PunchHitMessage: """Message saying an object was hit."""
[docs] class CurseExplodeMessage: """We are cursed and should blow up now."""
[docs] class BombDiedMessage: """A bomb has died and thus can be recycled."""
[docs] class Spaz(bs.Actor): """ 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. """ # pylint: disable=too-many-public-methods # pylint: disable=too-many-locals node: bs.Node """The 'spaz' bs.Node.""" points_mult = 1 curse_time: float | None = 5.0 default_bomb_count = 1 default_bomb_type = 'normal' default_boxing_gloves = False default_shields = False default_hitpoints = 1000 def __init__( self, color: Sequence[float] = (1.0, 1.0, 1.0), highlight: Sequence[float] = (0.5, 0.5, 0.5), character: str = 'Spaz', source_player: bs.Player | None = None, start_invincible: bool = True, can_accept_powerups: bool = True, powerups_expire: bool = False, demo_mode: bool = False, ): """Create a spaz with the requested color, character, etc.""" # pylint: disable=too-many-statements super().__init__() shared = SharedObjects.get() activity = self.activity factory = SpazFactory.get() # We need to behave slightly different in the tutorial. self._demo_mode = demo_mode self.play_big_death_sound = False # Scales how much impacts affect us (most damage calcs). self.impact_scale = 1.0 self.source_player = source_player self._dead = False if self._demo_mode: # Preserve old behavior. self._punch_power_scale = BASE_PUNCH_POWER_SCALE else: self._punch_power_scale = factory.punch_power_scale self.fly = bs.getactivity().globalsnode.happy_thoughts_mode if isinstance(activity, bs.GameActivity): self._hockey = activity.map.is_hockey else: self._hockey = False self._punched_nodes: set[bs.Node] = set() self._cursed = False self._connected_to_player: bs.Player | None = None materials = [ factory.spaz_material, shared.object_material, shared.player_material, ] roller_materials = [factory.roller_material, shared.player_material] extras_material = [] if can_accept_powerups: pam = PowerupBoxFactory.get().powerup_accept_material materials.append(pam) roller_materials.append(pam) extras_material.append(pam) media = factory.get_media(character) punchmats = (factory.punch_material, shared.attack_material) pickupmats = (factory.pickup_material, shared.pickup_material) self.node: bs.Node = bs.newnode( type='spaz', delegate=self, attrs={ 'color': color, 'behavior_version': 0 if demo_mode else 1, 'demo_mode': demo_mode, 'highlight': highlight, 'jump_sounds': media['jump_sounds'], 'attack_sounds': media['attack_sounds'], 'impact_sounds': media['impact_sounds'], 'death_sounds': media['death_sounds'], 'pickup_sounds': media['pickup_sounds'], 'fall_sounds': media['fall_sounds'], 'color_texture': media['color_texture'], 'color_mask_texture': media['color_mask_texture'], 'head_mesh': media['head_mesh'], 'torso_mesh': media['torso_mesh'], 'pelvis_mesh': media['pelvis_mesh'], 'upper_arm_mesh': media['upper_arm_mesh'], 'forearm_mesh': media['forearm_mesh'], 'hand_mesh': media['hand_mesh'], 'upper_leg_mesh': media['upper_leg_mesh'], 'lower_leg_mesh': media['lower_leg_mesh'], 'toes_mesh': media['toes_mesh'], 'style': factory.get_style(character), 'fly': self.fly, 'hockey': self._hockey, 'materials': materials, 'roller_materials': roller_materials, 'extras_material': extras_material, 'punch_materials': punchmats, 'pickup_materials': pickupmats, 'invincible': start_invincible, 'source_player': source_player, }, ) self.shield: bs.Node | None = None if start_invincible: def _safesetattr(node: bs.Node | None, attr: str, val: Any) -> None: if node: setattr(node, attr, val) bs.timer(1.0, bs.Call(_safesetattr, self.node, 'invincible', False)) self.hitpoints = self.default_hitpoints self.hitpoints_max = self.default_hitpoints self.shield_hitpoints: int | None = None self.shield_hitpoints_max = 650 self.shield_decay_rate = 0 self.shield_decay_timer: bs.Timer | None = None self._boxing_gloves_wear_off_timer: bs.Timer | None = None self._boxing_gloves_wear_off_flash_timer: bs.Timer | None = None self._bomb_wear_off_timer: bs.Timer | None = None self._bomb_wear_off_flash_timer: bs.Timer | None = None self._multi_bomb_wear_off_timer: bs.Timer | None = None self._multi_bomb_wear_off_flash_timer: bs.Timer | None = None self._curse_timer: bs.Timer | None = None self.bomb_count = self.default_bomb_count self._max_bomb_count = self.default_bomb_count self.bomb_type_default = self.default_bomb_type self.bomb_type = self.bomb_type_default self.land_mine_count = 0 self.blast_radius = 2.0 self.powerups_expire = powerups_expire if self._demo_mode: # Preserve old behavior. self._punch_cooldown = BASE_PUNCH_COOLDOWN else: self._punch_cooldown = factory.punch_cooldown self._jump_cooldown = 250 self._pickup_cooldown = 0 self._bomb_cooldown = 0 self._has_boxing_gloves = False if self.default_boxing_gloves: self.equip_boxing_gloves() self.last_punch_time_ms = -9999 self.last_pickup_time_ms = -9999 self.last_jump_time_ms = -9999 self.last_run_time_ms = -9999 self._last_run_value = 0.0 self.last_bomb_time_ms = -9999 self._turbo_filter_times: dict[str, int] = {} self._turbo_filter_time_bucket = 0 self._turbo_filter_counts: dict[str, int] = {} self.frozen = False self.shattered = False self._last_hit_time: int | None = None self._num_times_hit = 0 self._bomb_held = False if self.default_shields: self.equip_shields() self._dropped_bomb_callbacks: list[Callable[[Spaz, bs.Actor], Any]] = [] self._score_text: bs.Node | None = None self._score_text_hide_timer: bs.Timer | None = None self._last_stand_pos: Sequence[float] | None = None # Deprecated stuff.. should make these into lists. self.punch_callback: Callable[[Spaz], Any] | None = None self.pick_up_powerup_callback: Callable[[Spaz], Any] | None = None
[docs] @override def exists(self) -> bool: return bool(self.node)
[docs] @override def on_expire(self) -> None: super().on_expire() # Release callbacks/refs so we don't wind up with dependency loops. self._dropped_bomb_callbacks = [] self.punch_callback = None self.pick_up_powerup_callback = None
[docs] def add_dropped_bomb_callback( self, call: Callable[[Spaz, bs.Actor], Any] ) -> None: """ Add a call to be run whenever this Spaz drops a bomb. The spaz and the newly-dropped bomb are passed as arguments. """ assert not self.expired self._dropped_bomb_callbacks.append(call)
[docs] @override def is_alive(self) -> bool: """ Method override; returns whether ol' spaz is still kickin'. """ return not self._dead
def _hide_score_text(self) -> None: if self._score_text: assert isinstance(self._score_text.scale, float) bs.animate( self._score_text, 'scale', {0.0: self._score_text.scale, 0.2: 0.0}, ) def _turbo_filter_add_press(self, source: str) -> None: """ Can pass all button presses through here; if we see an obscene number of them in a short time let's shame/pushish this guy for using turbo. """ t_ms = int(bs.basetime() * 1000.0) assert isinstance(t_ms, int) t_bucket = int(t_ms / 1000) if t_bucket == self._turbo_filter_time_bucket: # Add only once per timestep (filter out buttons triggering # multiple actions). if t_ms != self._turbo_filter_times.get(source, 0): self._turbo_filter_counts[source] = ( self._turbo_filter_counts.get(source, 0) + 1 ) self._turbo_filter_times[source] = t_ms # (uncomment to debug; prints what this count is at) # bs.broadcastmessage( str(source) + " " # + str(self._turbo_filter_counts[source])) if self._turbo_filter_counts[source] == 15: # Knock 'em out. That'll learn 'em. assert self.node self.node.handlemessage('knockout', 500.0) # Also issue periodic notices about who is turbo-ing. now = bs.apptime() assert bs.app.classic is not None if now > bs.app.classic.last_spaz_turbo_warn_time + 30.0: bs.app.classic.last_spaz_turbo_warn_time = now bs.broadcastmessage( bs.Lstr( translate=( 'statements', ( 'Warning to ${NAME}: ' 'turbo / button-spamming knocks' ' you out.' ), ), subs=[('${NAME}', self.node.name)], ), color=(1, 0.5, 0), ) bs.getsound('error').play() else: self._turbo_filter_times = {} self._turbo_filter_time_bucket = t_bucket self._turbo_filter_counts = {source: 1}
[docs] def set_score_text( self, text: str | bs.Lstr, color: Sequence[float] = (1.0, 1.0, 0.4), flash: bool = False, ) -> None: """ Utility func to show a message momentarily over our spaz that follows him around; Handy for score updates and things. """ color_fin = bs.safecolor(color)[:3] if not self.node: return if not self._score_text: start_scale = 0.0 mnode = bs.newnode( 'math', owner=self.node, attrs={'input1': (0, 1.4, 0), 'operation': 'add'}, ) self.node.connectattr('torso_position', mnode, 'input2') self._score_text = bs.newnode( 'text', owner=self.node, attrs={ 'text': text, 'in_world': True, 'shadow': 1.0, 'flatness': 1.0, 'color': color_fin, 'scale': 0.02, 'h_align': 'center', }, ) mnode.connectattr('output', self._score_text, 'position') else: self._score_text.color = color_fin assert isinstance(self._score_text.scale, float) start_scale = self._score_text.scale self._score_text.text = text if flash: combine = bs.newnode( 'combine', owner=self._score_text, attrs={'size': 3} ) scl = 1.8 offs = 0.5 tval = 0.300 for i in range(3): cl1 = offs + scl * color_fin[i] cl2 = color_fin[i] bs.animate( combine, 'input' + str(i), {0.5 * tval: cl2, 0.75 * tval: cl1, 1.0 * tval: cl2}, ) combine.connectattr('output', self._score_text, 'color') bs.animate(self._score_text, 'scale', {0.0: start_scale, 0.2: 0.02}) self._score_text_hide_timer = bs.Timer( 1.0, bs.WeakCall(self._hide_score_text) )
[docs] def on_jump_press(self) -> None: """ Called to 'press jump' on this spaz; used by player or AI connections. """ if not self.node: return t_ms = int(bs.time() * 1000.0) assert isinstance(t_ms, int) if t_ms - self.last_jump_time_ms >= self._jump_cooldown: self.node.jump_pressed = True self.last_jump_time_ms = t_ms self._turbo_filter_add_press('jump')
[docs] def on_jump_release(self) -> None: """ Called to 'release jump' on this spaz; used by player or AI connections. """ if not self.node: return self.node.jump_pressed = False
[docs] def on_pickup_press(self) -> None: """ Called to 'press pick-up' on this spaz; used by player or AI connections. """ if not self.node: return t_ms = int(bs.time() * 1000.0) assert isinstance(t_ms, int) if t_ms - self.last_pickup_time_ms >= self._pickup_cooldown: self.node.pickup_pressed = True self.last_pickup_time_ms = t_ms self._turbo_filter_add_press('pickup')
[docs] def on_pickup_release(self) -> None: """ Called to 'release pick-up' on this spaz; used by player or AI connections. """ if not self.node: return self.node.pickup_pressed = False
[docs] def on_hold_position_press(self) -> None: """ Called to 'press hold-position' on this spaz; used for player or AI connections. """ if not self.node: return self.node.hold_position_pressed = True self._turbo_filter_add_press('holdposition')
[docs] def on_hold_position_release(self) -> None: """ Called to 'release hold-position' on this spaz; used for player or AI connections. """ if not self.node: return self.node.hold_position_pressed = False
[docs] def on_punch_press(self) -> None: """ Called to 'press punch' on this spaz; used for player or AI connections. """ if not self.node or self.frozen or self.node.knockout > 0.0: return t_ms = int(bs.time() * 1000.0) assert isinstance(t_ms, int) if t_ms - self.last_punch_time_ms >= self._punch_cooldown: if self.punch_callback is not None: self.punch_callback(self) self._punched_nodes = set() # Reset this. self.last_punch_time_ms = t_ms self.node.punch_pressed = True if not self.node.hold_node: bs.timer( 0.1, bs.WeakCall( self._safe_play_sound, SpazFactory.get().swish_sound, 0.8, ), ) self._turbo_filter_add_press('punch')
def _safe_play_sound(self, sound: bs.Sound, volume: float) -> None: """Plays a sound at our position if we exist.""" if self.node: sound.play(volume, self.node.position)
[docs] def on_punch_release(self) -> None: """ Called to 'release punch' on this spaz; used for player or AI connections. """ if not self.node: return self.node.punch_pressed = False
[docs] def on_bomb_press(self) -> None: """ Called to 'press bomb' on this spaz; used for player or AI connections. """ if ( not self.node or self._dead or self.frozen or self.node.knockout > 0.0 ): return t_ms = int(bs.time() * 1000.0) assert isinstance(t_ms, int) if t_ms - self.last_bomb_time_ms >= self._bomb_cooldown: self.last_bomb_time_ms = t_ms self.node.bomb_pressed = True if not self.node.hold_node: self.drop_bomb() self._turbo_filter_add_press('bomb')
[docs] def on_bomb_release(self) -> None: """ Called to 'release bomb' on this spaz; used for player or AI connections. """ if not self.node: return self.node.bomb_pressed = False
[docs] def on_run(self, value: float) -> None: """ Called to 'press run' on this spaz; used for player or AI connections. """ if not self.node: return t_ms = int(bs.time() * 1000.0) assert isinstance(t_ms, int) self.last_run_time_ms = t_ms self.node.run = value # Filtering these events would be tough since its an analog # value, but lets still pass full 0-to-1 presses along to # the turbo filter to punish players if it looks like they're turbo-ing. if self._last_run_value < 0.01 and value > 0.99: self._turbo_filter_add_press('run') self._last_run_value = value
[docs] def on_fly_press(self) -> None: """ Called to 'press fly' on this spaz; used for player or AI connections. """ if not self.node: return # Not adding a cooldown time here for now; slightly worried # input events get clustered up during net-games and we'd wind up # killing a lot and making it hard to fly.. should look into this. self.node.fly_pressed = True self._turbo_filter_add_press('fly')
[docs] def on_fly_release(self) -> None: """ Called to 'release fly' on this spaz; used for player or AI connections. """ if not self.node: return self.node.fly_pressed = False
[docs] def on_move(self, x: float, y: float) -> None: """ Called to set the joystick amount for this spaz; used for player or AI connections. """ if not self.node: return self.node.handlemessage('move', x, y)
[docs] def on_move_up_down(self, value: float) -> None: """ 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. """ if not self.node: return self.node.move_up_down = value
[docs] def on_move_left_right(self, value: float) -> None: """ 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. """ if not self.node: return self.node.move_left_right = value
[docs] def on_punched(self, damage: int) -> None: """Called when this spaz gets punched."""
[docs] def get_death_points(self, how: bs.DeathType) -> tuple[int, int]: """Get the points awarded for killing this spaz.""" del how # Unused. num_hits = float(max(1, self._num_times_hit)) # Base points is simply 10 for 1-hit-kills and 5 otherwise. importance = 2 if num_hits < 2 else 1 return (10 if num_hits < 2 else 5) * self.points_mult, importance
[docs] def curse(self) -> None: """ Give this poor spaz a curse; he will explode in 5 seconds. """ if not self._cursed: factory = SpazFactory.get() self._cursed = True # Add the curse material. for attr in ['materials', 'roller_materials']: materials = getattr(self.node, attr) if factory.curse_material not in materials: setattr( self.node, attr, materials + (factory.curse_material,) ) # None specifies no time limit. assert self.node if self.curse_time is None: self.node.curse_death_time = -1 else: # Note: curse-death-time takes milliseconds. tval = bs.time() assert isinstance(tval, (float, int)) self.node.curse_death_time = int( 1000.0 * (tval + self.curse_time) ) self._curse_timer = bs.Timer( self.curse_time, bs.WeakCall(self.handlemessage, CurseExplodeMessage()), )
[docs] def equip_boxing_gloves(self) -> None: """ Give this spaz some boxing gloves. """ assert self.node self.node.boxing_gloves = True self._has_boxing_gloves = True if self._demo_mode: # Preserve old behavior. self._punch_power_scale = 1.7 self._punch_cooldown = 300 else: factory = SpazFactory.get() self._punch_power_scale = factory.punch_power_scale_gloves self._punch_cooldown = factory.punch_cooldown_gloves
[docs] def equip_shields(self, decay: bool = False) -> None: """ Give this spaz a nice energy shield. """ if not self.node: logging.exception('Can\'t equip shields; no node.') return factory = SpazFactory.get() if self.shield is None: self.shield = bs.newnode( 'shield', owner=self.node, attrs={'color': (0.3, 0.2, 2.0), 'radius': 1.3}, ) self.node.connectattr('position_center', self.shield, 'position') self.shield_hitpoints = self.shield_hitpoints_max = 650 self.shield_decay_rate = factory.shield_decay_rate if decay else 0 self.shield.hurt = 0 factory.shield_up_sound.play(1.0, position=self.node.position) if self.shield_decay_rate > 0: self.shield_decay_timer = bs.Timer( 0.5, bs.WeakCall(self.shield_decay), repeat=True ) # So user can see the decay. self.shield.always_show_health_bar = True
[docs] def shield_decay(self) -> None: """Called repeatedly to decay shield HP over time.""" if self.shield: assert self.shield_hitpoints is not None self.shield_hitpoints = max( 0, self.shield_hitpoints - self.shield_decay_rate ) assert self.shield_hitpoints is not None self.shield.hurt = ( 1.0 - float(self.shield_hitpoints) / self.shield_hitpoints_max ) if self.shield_hitpoints <= 0: self.shield.delete() self.shield = None self.shield_decay_timer = None assert self.node SpazFactory.get().shield_down_sound.play( 1.0, position=self.node.position, ) else: self.shield_decay_timer = None
[docs] @override def handlemessage(self, msg: Any) -> Any: # pylint: disable=too-many-return-statements # pylint: disable=too-many-statements # pylint: disable=too-many-branches assert not self.expired if isinstance(msg, bs.PickedUpMessage): if self.node: self.node.handlemessage('hurt_sound') self.node.handlemessage('picked_up') # This counts as a hit. self._num_times_hit += 1 elif isinstance(msg, bs.ShouldShatterMessage): # Eww; seems we have to do this in a timer or it wont work right. # (since we're getting called from within update() perhaps?..) # NOTE: should test to see if that's still the case. bs.timer(0.001, bs.WeakCall(self.shatter)) elif isinstance(msg, bs.ImpactDamageMessage): # Eww; seems we have to do this in a timer or it wont work right. # (since we're getting called from within update() perhaps?..) bs.timer(0.001, bs.WeakCall(self._hit_self, msg.intensity)) elif isinstance(msg, bs.PowerupMessage): if self._dead or not self.node: return True if self.pick_up_powerup_callback is not None: self.pick_up_powerup_callback(self) if msg.poweruptype == 'triple_bombs': tex = PowerupBoxFactory.get().tex_bomb self._flash_billboard(tex) self.set_bomb_count(3) if self.powerups_expire: self.node.mini_billboard_1_texture = tex t_ms = int(bs.time() * 1000.0) assert isinstance(t_ms, int) self.node.mini_billboard_1_start_time = t_ms self.node.mini_billboard_1_end_time = ( t_ms + POWERUP_WEAR_OFF_TIME ) self._multi_bomb_wear_off_flash_timer = bs.Timer( (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0, bs.WeakCall(self._multi_bomb_wear_off_flash), ) self._multi_bomb_wear_off_timer = bs.Timer( POWERUP_WEAR_OFF_TIME / 1000.0, bs.WeakCall(self._multi_bomb_wear_off), ) elif msg.poweruptype == 'land_mines': self.set_land_mine_count(min(self.land_mine_count + 3, 3)) elif msg.poweruptype == 'impact_bombs': self.bomb_type = 'impact' tex = self._get_bomb_type_tex() self._flash_billboard(tex) if self.powerups_expire: self.node.mini_billboard_2_texture = tex t_ms = int(bs.time() * 1000.0) assert isinstance(t_ms, int) self.node.mini_billboard_2_start_time = t_ms self.node.mini_billboard_2_end_time = ( t_ms + POWERUP_WEAR_OFF_TIME ) self._bomb_wear_off_flash_timer = bs.Timer( (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0, bs.WeakCall(self._bomb_wear_off_flash), ) self._bomb_wear_off_timer = bs.Timer( POWERUP_WEAR_OFF_TIME / 1000.0, bs.WeakCall(self._bomb_wear_off), ) elif msg.poweruptype == 'sticky_bombs': self.bomb_type = 'sticky' tex = self._get_bomb_type_tex() self._flash_billboard(tex) if self.powerups_expire: self.node.mini_billboard_2_texture = tex t_ms = int(bs.time() * 1000.0) assert isinstance(t_ms, int) self.node.mini_billboard_2_start_time = t_ms self.node.mini_billboard_2_end_time = ( t_ms + POWERUP_WEAR_OFF_TIME ) self._bomb_wear_off_flash_timer = bs.Timer( (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0, bs.WeakCall(self._bomb_wear_off_flash), ) self._bomb_wear_off_timer = bs.Timer( POWERUP_WEAR_OFF_TIME / 1000.0, bs.WeakCall(self._bomb_wear_off), ) elif msg.poweruptype == 'punch': tex = PowerupBoxFactory.get().tex_punch self._flash_billboard(tex) self.equip_boxing_gloves() if self.powerups_expire and not self.default_boxing_gloves: self.node.boxing_gloves_flashing = False self.node.mini_billboard_3_texture = tex t_ms = int(bs.time() * 1000.0) assert isinstance(t_ms, int) self.node.mini_billboard_3_start_time = t_ms self.node.mini_billboard_3_end_time = ( t_ms + POWERUP_WEAR_OFF_TIME ) self._boxing_gloves_wear_off_flash_timer = bs.Timer( (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0, bs.WeakCall(self._gloves_wear_off_flash), ) self._boxing_gloves_wear_off_timer = bs.Timer( POWERUP_WEAR_OFF_TIME / 1000.0, bs.WeakCall(self._gloves_wear_off), ) elif msg.poweruptype == 'shield': factory = SpazFactory.get() # Let's allow powerup-equipped shields to lose hp over time. self.equip_shields(decay=factory.shield_decay_rate > 0) elif msg.poweruptype == 'curse': self.curse() elif msg.poweruptype == 'ice_bombs': self.bomb_type = 'ice' tex = self._get_bomb_type_tex() self._flash_billboard(tex) if self.powerups_expire: self.node.mini_billboard_2_texture = tex t_ms = int(bs.time() * 1000.0) assert isinstance(t_ms, int) self.node.mini_billboard_2_start_time = t_ms self.node.mini_billboard_2_end_time = ( t_ms + POWERUP_WEAR_OFF_TIME ) self._bomb_wear_off_flash_timer = bs.Timer( (POWERUP_WEAR_OFF_TIME - 2000) / 1000.0, bs.WeakCall(self._bomb_wear_off_flash), ) self._bomb_wear_off_timer = bs.Timer( POWERUP_WEAR_OFF_TIME / 1000.0, bs.WeakCall(self._bomb_wear_off), ) elif msg.poweruptype == 'health': if self._cursed: self._cursed = False # Remove cursed material. factory = SpazFactory.get() for attr in ['materials', 'roller_materials']: materials = getattr(self.node, attr) if factory.curse_material in materials: setattr( self.node, attr, tuple( m for m in materials if m != factory.curse_material ), ) self.node.curse_death_time = 0 self.hitpoints = self.hitpoints_max self._flash_billboard(PowerupBoxFactory.get().tex_health) self.node.hurt = 0 self._last_hit_time = None self._num_times_hit = 0 self.node.handlemessage('flash') if msg.sourcenode: msg.sourcenode.handlemessage(bs.PowerupAcceptMessage()) return True elif isinstance(msg, bs.FreezeMessage): if not self.node: return None if self.node.invincible: SpazFactory.get().block_sound.play( 1.0, position=self.node.position, ) return None if self.shield: return None if not self.frozen: self.frozen = True self.node.frozen = True bs.timer(5.0, bs.WeakCall(self.handlemessage, bs.ThawMessage())) # Instantly shatter if we're already dead. # (otherwise its hard to tell we're dead). if self.hitpoints <= 0: self.shatter() elif isinstance(msg, bs.ThawMessage): if self.frozen and not self.shattered and self.node: self.frozen = False self.node.frozen = False elif isinstance(msg, bs.HitMessage): if not self.node: return None if self.node.invincible: SpazFactory.get().block_sound.play( 1.0, position=self.node.position, ) return True # If we were recently hit, don't count this as another. # (so punch flurries and bomb pileups essentially count as 1 hit). local_time = int(bs.time() * 1000.0) assert isinstance(local_time, int) if ( self._last_hit_time is None or local_time - self._last_hit_time > 1000 ): self._num_times_hit += 1 self._last_hit_time = local_time mag = msg.magnitude * self.impact_scale velocity_mag = msg.velocity_magnitude * self.impact_scale damage_scale = 0.22 # If they've got a shield, deliver it to that instead. if self.shield: if msg.flat_damage: damage = msg.flat_damage * self.impact_scale else: # Hit our spaz with an impulse but tell it to only return # theoretical damage; not apply the impulse. assert msg.force_direction is not None self.node.handlemessage( 'impulse', msg.pos[0], msg.pos[1], msg.pos[2], msg.velocity[0], msg.velocity[1], msg.velocity[2], mag, velocity_mag, msg.radius, 1, msg.force_direction[0], msg.force_direction[1], msg.force_direction[2], ) damage = damage_scale * self.node.damage assert self.shield_hitpoints is not None self.shield_hitpoints -= int(damage) self.shield.hurt = ( 1.0 - float(self.shield_hitpoints) / self.shield_hitpoints_max ) # Its a cleaner event if a hit just kills the shield # without damaging the player. # However, massive damage events should still be able to # damage the player. This hopefully gives us a happy medium. max_spillover = SpazFactory.get().max_shield_spillover_damage if self.shield_hitpoints <= 0: # FIXME: Transition out perhaps? self.shield.delete() self.shield = None SpazFactory.get().shield_down_sound.play( 1.0, position=self.node.position, ) # Emit some cool looking sparks when the shield dies. npos = self.node.position bs.emitfx( position=(npos[0], npos[1] + 0.9, npos[2]), velocity=self.node.velocity, count=random.randrange(20, 30), scale=1.0, spread=0.6, chunk_type='spark', ) else: SpazFactory.get().shield_hit_sound.play( 0.5, position=self.node.position, ) # Emit some cool looking sparks on shield hit. assert msg.force_direction is not None bs.emitfx( position=msg.pos, velocity=( msg.force_direction[0] * 1.0, msg.force_direction[1] * 1.0, msg.force_direction[2] * 1.0, ), count=min(30, 5 + int(damage * 0.005)), scale=0.5, spread=0.3, chunk_type='spark', ) # If they passed our spillover threshold, # pass damage along to spaz. if self.shield_hitpoints <= -max_spillover: leftover_damage = -max_spillover - self.shield_hitpoints shield_leftover_ratio = leftover_damage / damage # Scale down the magnitudes applied to spaz accordingly. mag *= shield_leftover_ratio velocity_mag *= shield_leftover_ratio else: return True # Good job shield! else: shield_leftover_ratio = 1.0 if msg.flat_damage: damage = int( msg.flat_damage * self.impact_scale * shield_leftover_ratio ) else: # Hit it with an impulse and get the resulting damage. assert msg.force_direction is not None self.node.handlemessage( 'impulse', msg.pos[0], msg.pos[1], msg.pos[2], msg.velocity[0], msg.velocity[1], msg.velocity[2], mag, velocity_mag, msg.radius, 0, msg.force_direction[0], msg.force_direction[1], msg.force_direction[2], ) damage = int(damage_scale * self.node.damage) self.node.handlemessage('hurt_sound') # Play punch impact sound based on damage if it was a punch. if msg.hit_type == 'punch': self.on_punched(damage) # If damage was significant, lets show it. if damage >= 350: assert msg.force_direction is not None bs.show_damage_count( '-' + str(int(damage / 10)) + '%', msg.pos, msg.force_direction, ) # Let's always add in a super-punch sound with boxing # gloves just to differentiate them. if msg.hit_subtype == 'super_punch': SpazFactory.get().punch_sound_stronger.play( 1.0, position=self.node.position, ) if damage >= 500: sounds = SpazFactory.get().punch_sound_strong sound = sounds[random.randrange(len(sounds))] elif damage >= 100: sound = SpazFactory.get().punch_sound else: sound = SpazFactory.get().punch_sound_weak sound.play(1.0, position=self.node.position) # Throw up some chunks. assert msg.force_direction is not None bs.emitfx( position=msg.pos, velocity=( msg.force_direction[0] * 0.5, msg.force_direction[1] * 0.5, msg.force_direction[2] * 0.5, ), count=min(10, 1 + int(damage * 0.0025)), scale=0.3, spread=0.03, ) bs.emitfx( position=msg.pos, chunk_type='sweat', velocity=( msg.force_direction[0] * 1.3, msg.force_direction[1] * 1.3 + 5.0, msg.force_direction[2] * 1.3, ), count=min(30, 1 + int(damage * 0.04)), scale=0.9, spread=0.28, ) # Momentary flash. hurtiness = damage * 0.003 punchpos = ( msg.pos[0] + msg.force_direction[0] * 0.02, msg.pos[1] + msg.force_direction[1] * 0.02, msg.pos[2] + msg.force_direction[2] * 0.02, ) flash_color = (1.0, 0.8, 0.4) light = bs.newnode( 'light', attrs={ 'position': punchpos, 'radius': 0.12 + hurtiness * 0.12, 'intensity': 0.3 * (1.0 + 1.0 * hurtiness), 'height_attenuated': False, 'color': flash_color, }, ) bs.timer(0.06, light.delete) flash = bs.newnode( 'flash', attrs={ 'position': punchpos, 'size': 0.17 + 0.17 * hurtiness, 'color': flash_color, }, ) bs.timer(0.06, flash.delete) if msg.hit_type == 'impact': assert msg.force_direction is not None bs.emitfx( position=msg.pos, velocity=( msg.force_direction[0] * 2.0, msg.force_direction[1] * 2.0, msg.force_direction[2] * 2.0, ), count=min(10, 1 + int(damage * 0.01)), scale=0.4, spread=0.1, ) if self.hitpoints > 0: # It's kinda crappy to die from impacts, so lets reduce # impact damage by a reasonable amount *if* it'll keep us alive. if msg.hit_type == 'impact' and damage >= self.hitpoints: # Drop damage to whatever puts us at 10 hit points, # or 200 less than it used to be whichever is greater # (so it *can* still kill us if its high enough). newdamage = max(damage - 200, self.hitpoints - 10) damage = newdamage self.node.handlemessage('flash') # If we're holding something, drop it. if damage > 0.0 and self.node.hold_node: self.node.hold_node = None self.hitpoints -= damage self.node.hurt = ( 1.0 - float(self.hitpoints) / self.hitpoints_max ) # If we're cursed, *any* damage blows us up. if self._cursed and damage > 0: bs.timer( 0.05, bs.WeakCall( self.curse_explode, msg.get_source_player(bs.Player) ), ) # If we're frozen, shatter.. otherwise die if we hit zero if self.frozen and (damage > 200 or self.hitpoints <= 0): self.shatter() elif self.hitpoints <= 0: self.node.handlemessage( bs.DieMessage(how=bs.DeathType.IMPACT) ) # If we're dead, take a look at the smoothed damage value # (which gives us a smoothed average of recent damage) and shatter # us if its grown high enough. if self.hitpoints <= 0: damage_avg = self.node.damage_smoothed * damage_scale if damage_avg >= 1000: self.shatter() elif isinstance(msg, BombDiedMessage): self.bomb_count += 1 elif isinstance(msg, bs.DieMessage): wasdead = self._dead self._dead = True self.hitpoints = 0 if msg.immediate: if self.node: self.node.delete() elif self.node: self.node.hurt = 1.0 if self.play_big_death_sound and not wasdead: SpazFactory.get().single_player_death_sound.play() self.node.dead = True bs.timer(2.0, self.node.delete) elif isinstance(msg, bs.OutOfBoundsMessage): # By default we just die here. self.handlemessage(bs.DieMessage(how=bs.DeathType.FALL)) elif isinstance(msg, bs.StandMessage): self._last_stand_pos = ( msg.position[0], msg.position[1], msg.position[2], ) if self.node: self.node.handlemessage( 'stand', msg.position[0], msg.position[1], msg.position[2], msg.angle, ) elif isinstance(msg, CurseExplodeMessage): self.curse_explode() elif isinstance(msg, PunchHitMessage): if not self.node: return None node = bs.getcollision().opposingnode # Don't want to physically affect powerups. if node.getdelegate(PowerupBox): return None # Only allow one hit per node per punch. if node and (node not in self._punched_nodes): punch_momentum_angular = ( self.node.punch_momentum_angular * self._punch_power_scale ) punch_power = self.node.punch_power * self._punch_power_scale # Ok here's the deal: we pass along our base velocity for use # in the impulse damage calculations since that is a more # predictable value than our fist velocity, which is rather # erratic. However, we want to actually apply force in the # direction our fist is moving so it looks better. So we still # pass that along as a direction. Perhaps a time-averaged # fist-velocity would work too?.. perhaps should try that. # If its something besides another spaz, just do a muffled # punch sound. if node.getnodetype() != 'spaz': sounds = SpazFactory.get().impact_sounds_medium sound = sounds[random.randrange(len(sounds))] sound.play(1.0, position=self.node.position) ppos = self.node.punch_position punchdir = self.node.punch_velocity vel = self.node.punch_momentum_linear self._punched_nodes.add(node) node.handlemessage( bs.HitMessage( pos=ppos, velocity=vel, magnitude=punch_power * punch_momentum_angular * 110.0, velocity_magnitude=punch_power * 40, radius=0, srcnode=self.node, source_player=self.source_player, force_direction=punchdir, hit_type='punch', hit_subtype=( 'super_punch' if self._has_boxing_gloves else 'default' ), ) ) # Also apply opposite to ourself for the first punch only. # This is given as a constant force so that it is more # noticeable for slower punches where it matters. For fast # awesome looking punches its ok if we punch 'through' # the target. mag = -400.0 if self._hockey: mag *= 0.5 if len(self._punched_nodes) == 1: self.node.handlemessage( 'kick_back', ppos[0], ppos[1], ppos[2], punchdir[0], punchdir[1], punchdir[2], mag, ) elif isinstance(msg, PickupMessage): if not self.node: return None try: collision = bs.getcollision() opposingnode = collision.opposingnode opposingbody = collision.opposingbody except bs.NotFoundError: return True # Don't allow picking up of invincible dudes. try: if opposingnode.invincible: return True except Exception: pass # If we're grabbing the pelvis of a non-shattered spaz, we wanna # grab the torso instead. if ( opposingnode.getnodetype() == 'spaz' and not opposingnode.shattered and opposingbody == 4 ): opposingbody = 1 # Special case - if we're holding a flag, don't replace it # (hmm - should make this customizable or more low level). held = self.node.hold_node if held and held.getnodetype() == 'flag': return True # Note: hold_body needs to be set before hold_node. self.node.hold_body = opposingbody self.node.hold_node = opposingnode elif isinstance(msg, bs.CelebrateMessage): if self.node: self.node.handlemessage('celebrate', int(msg.duration * 1000)) else: return super().handlemessage(msg) return None
[docs] def drop_bomb(self) -> Bomb | None: """ Tell the spaz to drop one of his bombs, and returns the resulting bomb object. If the spaz has no bombs or is otherwise unable to drop a bomb, returns None. """ if (self.land_mine_count <= 0 and self.bomb_count <= 0) or self.frozen: return None assert self.node pos = self.node.position_forward vel = self.node.velocity if self.land_mine_count > 0: dropping_bomb = False self.set_land_mine_count(self.land_mine_count - 1) bomb_type = 'land_mine' else: dropping_bomb = True bomb_type = self.bomb_type bomb = Bomb( position=(pos[0], pos[1] - 0.0, pos[2]), velocity=(vel[0], vel[1], vel[2]), bomb_type=bomb_type, blast_radius=self.blast_radius, source_player=self.source_player, owner=self.node, ).autoretain() assert bomb.node if dropping_bomb: self.bomb_count -= 1 bomb.node.add_death_action( bs.WeakCall(self.handlemessage, BombDiedMessage()) ) self._pick_up(bomb.node) for clb in self._dropped_bomb_callbacks: clb(self, bomb) return bomb
def _pick_up(self, node: bs.Node) -> None: if self.node: # Note: hold_body needs to be set before hold_node. self.node.hold_body = 0 self.node.hold_node = node
[docs] def set_land_mine_count(self, count: int) -> None: """Set the number of land-mines this spaz is carrying.""" self.land_mine_count = count if self.node: if self.land_mine_count != 0: self.node.counter_text = 'x' + str(self.land_mine_count) self.node.counter_texture = ( PowerupBoxFactory.get().tex_land_mines ) else: self.node.counter_text = ''
[docs] def curse_explode(self, source_player: bs.Player | None = None) -> None: """Explode the poor spaz spectacularly.""" if self._cursed and self.node: self.shatter(extreme=True) self.handlemessage(bs.DieMessage()) activity = self._activity() if activity: Blast( position=self.node.position, velocity=self.node.velocity, blast_radius=3.0, blast_type='normal', source_player=( source_player if source_player else self.source_player ), ).autoretain() self._cursed = False
[docs] def shatter(self, extreme: bool = False) -> None: """Break the poor spaz into little bits.""" if self.shattered: return self.shattered = True assert self.node if self.frozen: # Momentary flash of light. light = bs.newnode( 'light', attrs={ 'position': self.node.position, 'radius': 0.5, 'height_attenuated': False, 'color': (0.8, 0.8, 1.0), }, ) bs.animate( light, 'intensity', {0.0: 3.0, 0.04: 0.5, 0.08: 0.07, 0.3: 0} ) bs.timer(0.3, light.delete) # Emit ice chunks. bs.emitfx( position=self.node.position, velocity=self.node.velocity, count=int(random.random() * 10.0 + 10.0), scale=0.6, spread=0.2, chunk_type='ice', ) bs.emitfx( position=self.node.position, velocity=self.node.velocity, count=int(random.random() * 10.0 + 10.0), scale=0.3, spread=0.2, chunk_type='ice', ) SpazFactory.get().shatter_sound.play( 1.0, position=self.node.position, ) else: SpazFactory.get().splatter_sound.play( 1.0, position=self.node.position, ) self.handlemessage(bs.DieMessage()) self.node.shattered = 2 if extreme else 1
def _hit_self(self, intensity: float) -> None: if not self.node: return pos = self.node.position self.handlemessage( bs.HitMessage( flat_damage=50.0 * intensity, pos=pos, force_direction=self.node.velocity, hit_type='impact', ) ) self.node.handlemessage('knockout', max(0.0, 50.0 * intensity)) sounds: Sequence[bs.Sound] if intensity >= 5.0: sounds = SpazFactory.get().impact_sounds_harder elif intensity >= 3.0: sounds = SpazFactory.get().impact_sounds_hard else: sounds = SpazFactory.get().impact_sounds_medium sound = sounds[random.randrange(len(sounds))] sound.play(position=pos, volume=5.0) def _get_bomb_type_tex(self) -> bs.Texture: factory = PowerupBoxFactory.get() if self.bomb_type == 'sticky': return factory.tex_sticky_bombs if self.bomb_type == 'ice': return factory.tex_ice_bombs if self.bomb_type == 'impact': return factory.tex_impact_bombs raise ValueError('invalid bomb type') def _flash_billboard(self, tex: bs.Texture) -> None: assert self.node self.node.billboard_texture = tex self.node.billboard_cross_out = False bs.animate( self.node, 'billboard_opacity', {0.0: 0.0, 0.1: 1.0, 0.4: 1.0, 0.5: 0.0}, )
[docs] def set_bomb_count(self, count: int) -> None: """Sets the number of bombs this Spaz has.""" # We can't just set bomb_count because some bombs may be laid currently # so we have to do a relative diff based on max. diff = count - self._max_bomb_count self._max_bomb_count += diff self.bomb_count += diff
def _gloves_wear_off_flash(self) -> None: if self.node: self.node.boxing_gloves_flashing = True self.node.billboard_texture = PowerupBoxFactory.get().tex_punch self.node.billboard_opacity = 1.0 self.node.billboard_cross_out = True def _gloves_wear_off(self) -> None: if self._demo_mode: # Preserve old behavior. self._punch_power_scale = 1.2 self._punch_cooldown = BASE_PUNCH_COOLDOWN else: factory = SpazFactory.get() self._punch_power_scale = factory.punch_power_scale self._punch_cooldown = factory.punch_cooldown self._has_boxing_gloves = False if self.node: PowerupBoxFactory.get().powerdown_sound.play( position=self.node.position, ) self.node.boxing_gloves = False self.node.billboard_opacity = 0.0 def _multi_bomb_wear_off_flash(self) -> None: if self.node: self.node.billboard_texture = PowerupBoxFactory.get().tex_bomb self.node.billboard_opacity = 1.0 self.node.billboard_cross_out = True def _multi_bomb_wear_off(self) -> None: self.set_bomb_count(self.default_bomb_count) if self.node: PowerupBoxFactory.get().powerdown_sound.play( position=self.node.position, ) self.node.billboard_opacity = 0.0 def _bomb_wear_off_flash(self) -> None: if self.node: self.node.billboard_texture = self._get_bomb_type_tex() self.node.billboard_opacity = 1.0 self.node.billboard_cross_out = True def _bomb_wear_off(self) -> None: self.bomb_type = self.bomb_type_default if self.node: PowerupBoxFactory.get().powerdown_sound.play( position=self.node.position, ) self.node.billboard_opacity = 0.0