diff --git a/plugins/utilities.json b/plugins/utilities.json index 0c62bac0..5d6e1f1e 100644 --- a/plugins/utilities.json +++ b/plugins/utilities.json @@ -2426,6 +2426,63 @@ "md5sum": "d8002dfc93be74d71f8e6e4aacfe6fd1" } } + }, + "better_camera_shake": { + "description": "This plugin makes the camera only shake when an explosion hits a player character, begone excessive camera shaking!", + "external_url": "", + "authors": [ + { + "name": "DinoWattz", + "email": "", + "discord": "" + } + ], + "versions": { + "1.0.0": { + "api_version": 9, + "commit_sha": "ebfad79", + "released_on": "23-02-2026", + "md5sum": "552b0ff0492bdab6cf69c1bc2d679e0a" + } + } + }, + "enhanced_effects": { + "description": "Explosions affect character colors, slow-mo tnt, fair colored shields, a trippy background screen and maybe more in the future!", + "external_url": "", + "authors": [ + { + "name": "DinoWattz", + "email": "", + "discord": "" + } + ], + "versions": { + "1.0.0": { + "api_version": 9, + "commit_sha": "ebfad79", + "released_on": "23-02-2026", + "md5sum": "63c665905dd4619d3f99ca8977f8fbf3" + } + } + }, + "sleep_on_afk": { + "description": "Staying idle for 40 seconds will make your character fall asleep, they need rest too..", + "external_url": "", + "authors": [ + { + "name": "DinoWattz", + "email": "", + "discord": "" + } + ], + "versions": { + "1.0.0": { + "api_version": 9, + "commit_sha": "ebfad79", + "released_on": "23-02-2026", + "md5sum": "5de7a474c3c6b21e8ea1e1abff3c07a1" + } + } } } } \ No newline at end of file diff --git a/plugins/utilities/better_camera_shake.py b/plugins/utilities/better_camera_shake.py new file mode 100644 index 00000000..d176f46a --- /dev/null +++ b/plugins/utilities/better_camera_shake.py @@ -0,0 +1,388 @@ +# ba_meta require api 9 +from __future__ import annotations + +import babase +import bascenev1 as bs +import bascenev1lib.actor.bomb as bomb + +import random +from typing import TYPE_CHECKING + +from bascenev1lib.gameutils import SharedObjects +from bascenev1lib.actor.bomb import BombFactory, ExplodeHitMessage +from bascenev1lib.actor.playerspaz import PlayerSpaz + +if TYPE_CHECKING: + from typing import Any, Sequence + +plugman = dict( + plugin_name="better_camera_shake", + description="This plugin makes the camera only shake when an explosion hits a player character, begone excessive camera shaking!", + external_url="", + authors=[ + {"name": "DinoWattz", "email": "", "discord": ""} + ], + version="1.0.0", +) + +# Camera shake only on player impact + + +def new__init__( + self, + *, + position: Sequence[float] = (0.0, 1.0, 0.0), + velocity: Sequence[float] = (0.0, 0.0, 0.0), + blast_radius: float = 2.0, + blast_type: str = 'normal', + source_player: bs.Player | None = None, + hit_type: str = 'explosion', + hit_subtype: str = 'normal', +): + """Instantiate with given values.""" + + # bah; get off my lawn! + # pylint: disable=too-many-locals + # pylint: disable=too-many-statements + + super(type(self), self).__init__() + + shared = SharedObjects.get() + factory = BombFactory.get() + + self.blast_type = blast_type + self._source_player = source_player + self.hit_type = hit_type + self.hit_subtype = hit_subtype + self.radius = blast_radius + + # Set our position a bit lower so we throw more things upward. + rmats = (factory.blast_material, shared.attack_material) + self.node = bs.newnode( + 'region', + delegate=self, + attrs={ + 'position': (position[0], position[1] - 0.1, position[2]), + 'scale': (self.radius, self.radius, self.radius), + 'type': 'sphere', + 'materials': rmats, + }, + ) + + bs.timer(0.05, self.node.delete) + + # Throw in an explosion and flash. + evel = (velocity[0], max(-1.0, velocity[1]), velocity[2]) + explosion = bs.newnode( + 'explosion', + attrs={ + 'position': position, + 'velocity': evel, + 'radius': self.radius, + 'big': (self.blast_type == 'tnt'), + }, + ) + if self.blast_type == 'ice': + explosion.color = (0, 0.05, 0.4) + + bs.timer(1.0, explosion.delete) + + if self.blast_type != 'ice': + bs.emitfx( + position=position, + velocity=velocity, + count=int(1.0 + random.random() * 4), + emit_type='tendrils', + tendril_type='thin_smoke', + ) + bs.emitfx( + position=position, + velocity=velocity, + count=int(4.0 + random.random() * 4), + emit_type='tendrils', + tendril_type='ice' if self.blast_type == 'ice' else 'smoke', + ) + bs.emitfx( + position=position, + emit_type='distortion', + spread=1.0 if self.blast_type == 'tnt' else 2.0, + ) + + # And emit some shrapnel. + if self.blast_type == 'ice': + + def emit() -> None: + bs.emitfx( + position=position, + velocity=velocity, + count=30, + spread=2.0, + scale=0.4, + chunk_type='ice', + emit_type='stickers', + ) + + # It looks better if we delay a bit. + bs.timer(0.05, emit) + + elif self.blast_type == 'sticky': + + def emit() -> None: + bs.emitfx( + position=position, + velocity=velocity, + count=int(4.0 + random.random() * 8), + spread=0.7, + chunk_type='slime', + ) + bs.emitfx( + position=position, + velocity=velocity, + count=int(4.0 + random.random() * 8), + scale=0.5, + spread=0.7, + chunk_type='slime', + ) + bs.emitfx( + position=position, + velocity=velocity, + count=15, + scale=0.6, + chunk_type='slime', + emit_type='stickers', + ) + bs.emitfx( + position=position, + velocity=velocity, + count=20, + scale=0.7, + chunk_type='spark', + emit_type='stickers', + ) + bs.emitfx( + position=position, + velocity=velocity, + count=int(6.0 + random.random() * 12), + scale=0.8, + spread=1.5, + chunk_type='spark', + ) + + # It looks better if we delay a bit. + bs.timer(0.05, emit) + + elif self.blast_type == 'impact': + + def emit() -> None: + bs.emitfx( + position=position, + velocity=velocity, + count=int(4.0 + random.random() * 8), + scale=0.8, + chunk_type='metal', + ) + bs.emitfx( + position=position, + velocity=velocity, + count=int(4.0 + random.random() * 8), + scale=0.4, + chunk_type='metal', + ) + bs.emitfx( + position=position, + velocity=velocity, + count=20, + scale=0.7, + chunk_type='spark', + emit_type='stickers', + ) + bs.emitfx( + position=position, + velocity=velocity, + count=int(8.0 + random.random() * 15), + scale=0.8, + spread=1.5, + chunk_type='spark', + ) + + # It looks better if we delay a bit. + bs.timer(0.05, emit) + + else: # Regular or land mine bomb shrapnel. + + def emit() -> None: + if self.blast_type != 'tnt': + bs.emitfx( + position=position, + velocity=velocity, + count=int(4.0 + random.random() * 8), + chunk_type='rock', + ) + bs.emitfx( + position=position, + velocity=velocity, + count=int(4.0 + random.random() * 8), + scale=0.5, + chunk_type='rock', + ) + bs.emitfx( + position=position, + velocity=velocity, + count=30, + scale=1.0 if self.blast_type == 'tnt' else 0.7, + chunk_type='spark', + emit_type='stickers', + ) + bs.emitfx( + position=position, + velocity=velocity, + count=int(18.0 + random.random() * 20), + scale=1.0 if self.blast_type == 'tnt' else 0.8, + spread=1.5, + chunk_type='spark', + ) + + # TNT throws splintery chunks. + if self.blast_type == 'tnt': + + def emit_splinters() -> None: + bs.emitfx( + position=position, + velocity=velocity, + count=int(20.0 + random.random() * 25), + scale=0.8, + spread=1.0, + chunk_type='splinter', + ) + + bs.timer(0.01, emit_splinters) + + # Every now and then do a sparky one. + if self.blast_type == 'tnt' or random.random() < 0.1: + + def emit_extra_sparks() -> None: + bs.emitfx( + position=position, + velocity=velocity, + count=int(10.0 + random.random() * 20), + scale=0.8, + spread=1.5, + chunk_type='spark', + ) + + bs.timer(0.02, emit_extra_sparks) + + # It looks better if we delay a bit. + bs.timer(0.05, emit) + + lcolor = (0.6, 0.6, 1.0) if self.blast_type == 'ice' else (1, 0.3, 0.1) + light = bs.newnode( + 'light', + attrs={ + 'position': position, + 'volume_intensity_scale': 10.0, + 'color': lcolor, + }, + ) + + scl = random.uniform(0.6, 0.9) + scorch_radius = light_radius = self.radius + if self.blast_type == 'tnt': + light_radius *= 1.4 + scorch_radius *= 1.15 + scl *= 3.0 + + iscale = 1.6 + bs.animate( + light, + 'intensity', + { + 0: 2.0 * iscale, + scl * 0.02: 0.1 * iscale, + scl * 0.025: 0.2 * iscale, + scl * 0.05: 17.0 * iscale, + scl * 0.06: 5.0 * iscale, + scl * 0.08: 4.0 * iscale, + scl * 0.2: 0.6 * iscale, + scl * 2.0: 0.00 * iscale, + scl * 3.0: 0.0, + }, + ) + bs.animate( + light, + 'radius', + { + 0: light_radius * 0.2, + scl * 0.05: light_radius * 0.55, + scl * 0.1: light_radius * 0.3, + scl * 0.3: light_radius * 0.15, + scl * 1.0: light_radius * 0.05, + }, + ) + bs.timer(scl * 3.0, light.delete) + + # Make a scorch that fades over time. + scorch = bs.newnode( + 'scorch', + attrs={ + 'position': position, + 'size': scorch_radius * 0.5, + 'big': (self.blast_type == 'tnt'), + }, + ) + if self.blast_type == 'ice': + scorch.color = (1, 1, 1.5) + + bs.animate(scorch, 'presence', {3.000: 1, 13.000: 0}) + bs.timer(13.0, scorch.delete) + + if self.blast_type == 'ice': + factory.hiss_sound.play(position=light.position) + + lpos = light.position + factory.random_explode_sound().play(position=lpos) + factory.debris_fall_sound.play(position=lpos) + + # ↓ We don't need any of this, we're fully trained professionals. + # bs.camerashake(intensity=5.0 if self.blast_type == 'tnt' else 1.0) + + # TNT is more epic. + if self.blast_type == 'tnt': + factory.random_explode_sound().play(position=lpos) + + def _extra_boom() -> None: + factory.random_explode_sound().play(position=lpos) + + bs.timer(0.25, _extra_boom) + + def _extra_debris_sound() -> None: + factory.debris_fall_sound.play(position=lpos) + factory.wood_debris_fall_sound.play(position=lpos) + + bs.timer(0.4, _extra_debris_sound) + + +bomb.Blast.__init__ = new__init__ + +org_handlemessage = bomb.Blast.handlemessage + + +def new_handlemessage(self, msg: Any, *args, **kwargs) -> Any: + org_handlemessage(self, msg, *args, **kwargs) + + if isinstance(msg, ExplodeHitMessage): + node = bs.getcollision().opposingnode + delegate = node.getdelegate(PlayerSpaz) + has_used_camerashake = getattr(self, 'has_used_camerashake', False) + if not has_used_camerashake and (delegate and not delegate.shield) and not node.invincible: + self.has_used_camerashake = True + bs.camerashake(intensity=5.0 if self.blast_type == 'tnt' else 1.0) + + +bomb.Blast.handlemessage = new_handlemessage + +# ba_meta export babase.Plugin + + +class Plugin(babase.Plugin): + pass diff --git a/plugins/utilities/chat_bubbles.py b/plugins/utilities/chat_bubbles.py new file mode 100644 index 00000000..446ad07b --- /dev/null +++ b/plugins/utilities/chat_bubbles.py @@ -0,0 +1,582 @@ +# ba_meta require api 9 +# This plugin only works with BombSquad 1.7.49+ +from __future__ import annotations + +from typing import TYPE_CHECKING, override + +import babase +import bascenev1 as bs +import random +import string +import unicodedata + +if TYPE_CHECKING: + from typing import Any + +plugman = dict( + plugin_name="chat_bubbles", + description="Adds whatever the players say above their character, includes a few other features too!", + external_url="", + authors=[ + {"name": "DinoWattz", "email": "", "discord": ""} + ], + version="1.0.0", +) + +PRINT_CHAT_MESSAGES = True # Prints chat messages to the console as "[Character's Name] Player 1/Player 2: Hello World!" +BUBBLE_DURATION: float = 4.7 # 4.7 matches the default onscreen message duration +SPEAK_VOLUME = 1.5 +SHOUT_VOLUME = 3.0 + +CELEBRATE_MESSAGE = [(bs.CelebrateMessage, {"duration": 1.0})] + +# Easter egg triggers, sounds, and messages flag +# If adding new ones, make sure to test them in-game +EASTER_EGGS = [ + { + "triggers": { + "hi", "hello", "hey", "hiya", "yo", "sup", "heya", "howdy", + "greetings", "salutations", "ahoy", "ahoyhoy", "bonjour", + "hola", "ciao", "ola", "oi", "eae", "salve", + "bye", "goodbye", "see ya", "see you", "cya", + "adios", "chau", "chao", "tchau", + "adeus", "ate mais", "falou", "flw" + }, + "node_messages": [("celebrate_r", 1000)], + }, + { + "triggers": {"aaa"}, + "sound": "spazFall01", + "messages": CELEBRATE_MESSAGE, + }, + { + "triggers": {"aaaa"}, + "sound": "zoeFall01", + "messages": CELEBRATE_MESSAGE, + }, + { + "triggers": {"aaaaa"}, + "sound": "kronkFall", + "messages": CELEBRATE_MESSAGE, + }, + { + "triggers": {"potato"}, + "sound": "kronk2", + "messages": CELEBRATE_MESSAGE, + }, + { + "triggers": {"dau", "tau", "d'oh", "doh"}, + "sound": "kronk3", + "messages": CELEBRATE_MESSAGE, + }, + { + "triggers": {"drau", "trau", "d'roh", "droh"}, + "sound": "bunny2", + "messages": CELEBRATE_MESSAGE, + }, + { + "triggers": {"hahaha", "hahahaha", + "hahahah", "hahahahah", "lol", + "nahahah", "kakaka", "kakakaka", "kkk", "kkkk", + "jajaja", "jajajaja", "jajajajaa"}, + "sound": "mel05", + "messages": CELEBRATE_MESSAGE, + }, + { + "triggers": {"ha", "hah", "ja", "ka"}, + "sound": "mel06", + "messages": CELEBRATE_MESSAGE, + }, + { + "triggers": {"merry christmas", "merry krismas", "merry xmas", "feliz natal", "feliz navidad"}, + "sound": "santa02", + "messages": CELEBRATE_MESSAGE, + }, + { + "triggers": {"hohoho", "ho ho ho", "ho-ho-ho"}, + "sound": "santa05", + "messages": CELEBRATE_MESSAGE, + }, + { + "triggers": {"gg", "good game", "que pro", "q pro"}, + "sound": "achievement", + "messages": CELEBRATE_MESSAGE, + }, + { + "triggers": {"boo", "bad game", "que noob", "q noob"}, + "sound": "boo", + "messages": CELEBRATE_MESSAGE, + }, + # ↓ Node messages are like this ↓ + { + "triggers": {"0", "zero"}, + "sound": "boxingBell", + "node_messages": [("celebrate_l", 1000)], + }, + { + "triggers": {"1", "one"}, + "sound": "announceOne", + "node_messages": [("celebrate_r", 1000)], + }, + { + "triggers": {"2", "two"}, + "sound": "announceTwo", + "node_messages": [("celebrate_r", 1000)], + }, + { + "triggers": {"3", "three"}, + "sound": "announceThree", + "node_messages": [("celebrate_r", 1000)], + }, + { + "triggers": {"4", "four"}, + "sound": "announceFour", + "node_messages": [("celebrate_r", 1000)], + }, + { + "triggers": {"5", "five"}, + "sound": "announceFive", + "node_messages": [("celebrate_r", 1000)], + }, + { + "triggers": {"6", "six"}, + "sound": "announceSix", + "node_messages": [("celebrate_r", 1000)], + }, + { + "triggers": {"7", "seven"}, + "sound": "announceSeven", + "node_messages": [("celebrate_r", 1000)], + }, + { + "triggers": {"8", "eight"}, + "sound": "announceEight", + "node_messages": [("celebrate_r", 1000)], + }, + { + "triggers": {"9", "nine"}, + "sound": "announceNine", + "node_messages": [("celebrate_r", 1000)], + }, + { + "triggers": {"10", "ten"}, + "sound": "announceTen", + "node_messages": [("celebrate_r", 1000)], + }, + { + "triggers": {"xd"}, + "sound": "santaDeath", + "node_messages": [("knockout", 100)], + }, + # ↓ Easter eggs without actor/node messages ↓ + { + "triggers": {"huh", "huhh", "huhhh", "huhhhh", "humph", "hmph", "hum", "humm"}, + "sound": "agent1", + }, + { + "triggers": {"hoh", "hohh", "hohhh", "hohhhh", "ohh"}, + "sound": "agent3", + }, + { + "triggers": {"eff"}, + "sound": "zoeEff", + }, + { + "triggers": {"ow", "oww", "oow"}, + "sound": "zoeOw", + }, + { + "triggers": {"pipipi", "bibibi"}, + "sound": "mel07", + }, + { + "triggers": {"oh yeah", "oh yea", "oohh yeah", "oohh yea"}, + "sound": "yeah", + }, + { + "triggers": {"woo"}, + "sound": "woo2", + }, + { + "triggers": {"wooo"}, + "sound": "woo", + }, + { + "triggers": {"woo yeah", "woo yea"}, + "sound": "woo3", + }, + { + "triggers": {"ooh"}, + "sound": "ooh", + }, + { + "triggers": {"wow"}, + "sound": "wow", + }, + { + "triggers": {"gasp"}, + "sound": "gasp", + }, + { + "triggers": {"ahh", "aah"}, + "sound": "aww", + }, + { + "triggers": {"nice"}, + "sound": "nice", + }, + # ↑ Default easter egg list ends here ↑ +] + +# Chat bubble functionality +def create_bubble(player: bs.SessionPlayer, text: str, name: str): + msg = normalize_message(text) + is_caps = len(text) > 1 and text.isupper() + handled = False + bubble_created = False + + activityplayer: bs.Player | None = getattr(player, 'activityplayer', None) + + def _fallback_logic(): + nonlocal handled, activityplayer + color = (1, 1, 0.4) + image_icon = {'texture': bs.gettexture("cuteSpaz"), 'tint_texture': bs.gettexture("cuteSpaz"), 'tint_color': (1.0, 1.0, 1.0), 'tint2_color': (1.0, 1.0, 1.0)} + + if activityplayer: + normal_highlight = bs.normalized_color( bs.safecolor(player.highlight, target_intensity=1.15) ) + saturated_highlight = bs.normalized_color( bs.safecolor(player.highlight, target_intensity=0.75) ) + color = saturated_highlight if is_caps else normal_highlight + image_icon = activityplayer.get_icon() + + bs.broadcastmessage(f"{name}: {text}", + color=color, + top=True, + image=image_icon) + + for egg in EASTER_EGGS: + if msg in egg["triggers"]: + if egg.get("sound"): + sound = bs.getsound(egg["sound"]) + sound.play(SHOUT_VOLUME if is_caps else SPEAK_VOLUME) + handled = True + break + if not handled and activityplayer: + assert bs.app.classic is not None + handled = True + character = activityplayer.character + char = bs.app.classic.spaz_appearances[character] + if is_caps: + attack_sounds = [bs.getsound(s) for s in char.attack_sounds] + if attack_sounds and isinstance(attack_sounds, (list, tuple)) and len(attack_sounds) > 0: + sound = random.choice(attack_sounds) + sound.play(SHOUT_VOLUME) + else: + pickup_sounds = [bs.getsound(s) for s in char.pickup_sounds] + if pickup_sounds and isinstance(pickup_sounds, (list, tuple)) and len(pickup_sounds) > 0: + sound = random.choice(pickup_sounds) + sound.play(SPEAK_VOLUME) + + if activityplayer is None: + # No valid activityplayer: play sound if possible (lobby/non-in-game) + _fallback_logic() + return + + bubbles = activityplayer.customdata.setdefault('chat_bubbles', []) + actor_list = [] + + # Support for both actor and ghost_actor (from the Ghost Players plugin) + if getattr(activityplayer, 'actor', None): + actor_list.append(activityplayer.actor) + if hasattr(activityplayer, 'customdata') and activityplayer.customdata.get('ghost_actor'): + actor_list.append(activityplayer.customdata['ghost_actor']) + + if actor_list: + for actor in actor_list: + if actor and actor.is_alive() and actor.node: + assert actor is not None + char_node = actor.node + activity = actor.getactivity() + if activity is None or getattr(activity, 'expired', False): + continue + + with activity.context: + # Calculate scale based on text length + base_scale = 0.015 + min_scale = 0.009 + scale = max(base_scale - 0.0003 * max(len(text) - 20, 0), min_scale) + y_offset = 1.2 + 0.25 * len(bubbles) + + mnode = bs.newnode('math', owner=char_node, attrs={ + 'input1': (0, y_offset, 0), + 'operation': 'add' + }) + char_node.connectattr('torso_position', mnode, 'input2') + + normal_highlight = bs.normalized_color( bs.safecolor(player.highlight, target_intensity=1.15) ) + saturated_highlight = bs.normalized_color( bs.safecolor(player.highlight, target_intensity=0.75) ) + bubble_color = saturated_highlight if is_caps else normal_highlight + + textnode = bs.newnode('text', + owner=char_node, + attrs={ + 'text': text, + 'color': bubble_color, + 'shadow': 0.5, + 'flatness': 0.5, + 'scale': scale, + 'h_align': 'center', + 'v_align': 'bottom', + 'in_world': True, + 'opacity': 1.0, + }) + + mnode.connectattr('output', textnode, 'position') + + focus = Plugin.Focus(owner=textnode).autoretain() + textnode.connectattr('position', focus.node, 'position') + + # Adjust bubble duration for slow-motion activities + bubble_duration = BUBBLE_DURATION + if hasattr(activity, 'slow_motion') and activity.slow_motion: + bubble_duration = max(0.5, bubble_duration / 3) + + # Play sound for all triggers (easter eggs and normal) + def _play_chat_sound(actor, sound, volume): + if actor.is_alive(): + actor._safe_play_sound(sound, volume) + else: + sound.play(volume) + + # Easter egg sound/message + for egg in EASTER_EGGS: + if msg in egg['triggers']: + # Sound selection logic + sound = None + if egg.get('sound'): + sound = bs.getsound(egg['sound']) + else: + # Default to character sounds if no specific sound is set + if is_caps: + attack_sounds = getattr(char_node, 'attack_sounds', None) + if attack_sounds and isinstance(attack_sounds, (list, tuple)) and len(attack_sounds) > 0: + sound = random.choice(attack_sounds) + else: + pickup_sounds = getattr(char_node, 'pickup_sounds', None) + if pickup_sounds and isinstance(pickup_sounds, (list, tuple)) and len(pickup_sounds) > 0: + sound = random.choice(pickup_sounds) + + if sound: + _play_chat_sound(actor, sound, SHOUT_VOLUME if is_caps else SPEAK_VOLUME) + + # Animation logic + if egg.get('messages') and actor.is_alive() and hasattr(actor, 'handlemessage'): + for msg_class, msg_kwargs in egg.get('messages', []): + msg_kwargs = msg_kwargs or {} + actor.handlemessage(msg_class(**msg_kwargs)) + + if egg.get('node_messages') and actor.is_alive() and actor.node.exists(): + for node_args in egg.get('node_messages', []): + actor.node.handlemessage(*node_args) + + handled = True + break + + if not handled: + if is_caps: + attack_sounds = getattr(char_node, 'attack_sounds', None) + if attack_sounds and isinstance(attack_sounds, (list, tuple)) and len(attack_sounds) > 0: + sound = random.choice(attack_sounds) + _play_chat_sound(actor, sound, SHOUT_VOLUME) + if actor.is_alive() and hasattr(actor, 'handlemessage'): + actor.handlemessage(bs.CelebrateMessage(duration=1.0)) + else: + pickup_sounds = getattr(char_node, 'pickup_sounds', None) + if pickup_sounds and isinstance(pickup_sounds, (list, tuple)) and len(pickup_sounds) > 0: + sound = random.choice(pickup_sounds) + _play_chat_sound(actor, sound, SPEAK_VOLUME) + + bubbles.append((textnode, mnode)) + bubble_created = True + while len(bubbles) > 3: + old_textnode, old_mnode = bubbles.pop(0) + if old_textnode is not None and hasattr(old_textnode, 'exists') and old_textnode.exists(): + old_textnode.delete() + if old_mnode is not None and hasattr(old_mnode, 'exists') and old_mnode.exists(): + old_mnode.delete() + update_bubble_offsets(bubbles) + + fade_time = max(0.1, bubble_duration - 0.5) + bs.animate(textnode, 'opacity', {fade_time: 1.0, bubble_duration: 0.0}) + bs.timer(bubble_duration, textnode.delete) + bs.timer(bubble_duration, mnode.delete) + + def cleanup(): + if hasattr(activityplayer, 'customdata'): + if 'chat_bubbles' in activityplayer.customdata: + activityplayer.customdata['chat_bubbles'] = [ + b for b in activityplayer.customdata['chat_bubbles'] if b[0] != textnode + ] + bs.timer(bubble_duration, cleanup) + + if not bubble_created: + # No valid actor found: play sound if possible + _fallback_logic() + return + +def update_bubble_offsets(bubbles): + for i, (tnode, mnode) in enumerate(reversed(bubbles)): + y_offset = 1.2 + 0.25 * i + if mnode is not None and hasattr(mnode, 'exists') and mnode.exists(): + mnode.input1 = (0, y_offset, 0) + +# Tools +def normalize_message(text): + msg = text.strip().lower() + msg = msg.translate(str.maketrans('', '', string.punctuation)) + msg = ''.join( + c for c in unicodedata.normalize('NFD', msg) + if unicodedata.category(c) != 'Mn' + ) + + return msg + +# ba_meta export babase.Plugin +class Plugin(babase.Plugin): + def on_app_running(self) -> None: + # Hook into chat message filtering + # We're delaying until the app runs and using a timer so it's more likely to work with other chat plugins (please don't make a chat plugin/filter that is delayed like this). + bs.apptimer(0.001, bs.WeakCallStrict(self.apply_patch)) + + def apply_patch(self): + import bascenev1._hooks as _hooks + + _org_filter_chat_message = _hooks.filter_chat_message + def _chat_bubbles_hook(msg: str, client_id: int, *args, **kwargs) -> str | None: + org_msg = _org_filter_chat_message(msg, client_id, *args, **kwargs) + + if org_msg is None: + # The original message has been ignored, so we should ignore it as well + return org_msg + + # Chat bubble sorcery + try: + roster = bs.get_game_roster() + session = bs.get_foreground_host_session() + if session is None: + # Probably couldn't find a session because we are a client + return org_msg + sessionplayers = session.sessionplayers + + player_list = [] + matched = False + dead_bubble = False + character: str | None = None + + with session.context: + for player in sessionplayers: + if player.inputdevice.client_id == client_id and player.in_game: + player_list.append(player) + continue + if player_list: + # Get combined name for multiple players on same client + name = '' + created_bubble = False + + for player in player_list: + if name == '': + name = player.getname() + else: + name = name + '/' + player.getname() + + # Create chat bubble + for player in player_list: + activityplayer: bs.Player = player.activityplayer + # We have a session player but not an activity player + if not activityplayer: + if not dead_bubble: + create_bubble(player, org_msg, name) + created_bubble = dead_bubble = True + continue + # We have a dead activity player + if not activityplayer.is_alive() and not dead_bubble: + create_bubble(player, org_msg, name) + created_bubble = dead_bubble = True + character = activityplayer.character if character is None else character + # elif we have an alive activity player + elif activityplayer.is_alive(): + create_bubble(player, org_msg, name) + created_bubble = True + character = activityplayer.character if character is None else character + + if created_bubble: + matched = True + if PRINT_CHAT_MESSAGES: + print(f"[{character}] {name}: {org_msg}") + else: + bs.logging.warning("Failed to create a chat bubble, using non-session player method now.") + # Non-session players (in lobby or just spectating, this only works for servers) + if not matched and not dead_bubble: + for client in roster: + if client['client_id'] == client_id: + name = client.get('display_string') + create_bubble(None, org_msg, name) + + matched = True + if PRINT_CHAT_MESSAGES: + print(f"[{character}] {name}: {org_msg}") + break + except Exception as e: + bs.logging.exception(f"Error in chat bubble hook: {e}") + + return org_msg + + _hooks.filter_chat_message = _chat_bubbles_hook + bs.reload_hooks() + + print("[ChatBubbles] Plugin initialized.") + + class Focus(bs.Actor): + def __init__( + self, + *, + owner: bs.Node | None = None, + ): + super().__init__() + + self._focusnode_material = bs.Material() + self._focusnode_material.add_actions( + actions=('modify_node_collision', 'collide', False), + ) + + if getattr(owner, 'owner', None): + assert owner is not None + area_of_interest = False if getattr(owner.owner, 'is_area_of_interest', False) else True + else: + area_of_interest = False if getattr(owner, 'is_area_of_interest', False) else True + + # I'm tired of this area_of_interest nonsense, it never works right + #area_of_interest = False + # actually it might work now that we don't spawn bubbles for dead players. + + self.node = bs.newnode( + 'prop', + delegate=self, + owner=owner, + attrs={ + 'body': 'sphere', + 'body_scale': 0.0, + 'shadow_size': 0.0, + 'gravity_scale': 0.0, + 'is_area_of_interest': area_of_interest, + 'materials': (self._focusnode_material,), + }, + ) + + @override + def handlemessage(self, msg: Any) -> Any: + assert not self.expired + if isinstance(msg, bs.DieMessage): + if self.node: + self.node.delete() + else: + super().handlemessage(msg) diff --git a/plugins/utilities/enhanced_effects.py b/plugins/utilities/enhanced_effects.py new file mode 100644 index 00000000..d78f5616 --- /dev/null +++ b/plugins/utilities/enhanced_effects.py @@ -0,0 +1,305 @@ +# ba_meta require api 9 +from __future__ import annotations + +from typing import TYPE_CHECKING + +import random +import weakref +import babase +import bascenev1 as bs + +from bascenev1lib.actor.background import Background + +if TYPE_CHECKING: + from typing import Any + +plugman = dict( + plugin_name="enhanced_effects", + description="Explosions affect character colors, slow-mo tnt, fair colored shields, a trippy background screen and maybe more in the future!", + external_url="", + authors=[ + {"name": "DinoWattz", "email": "", "discord": ""} + ], + version="1.0.0", +) + +# Transparent Background (kinda hacky, works best on pc with high/higher quality 'visuals' setting) +BACKGROUND_OPACITY = 0.5 + + +def __modified_background_init( + self, + fade_time: float = 0.5, + start_faded: bool = False, + show_logo: bool = False, +): + super(type(self), self).__init__() + self._dying = False + self.fade_time = fade_time + # We're special in that we create our node in the session + # scene instead of the activity scene. + # This way we can overlap multiple activities for fades + # and whatnot. + session = bs.getsession() + self._session = weakref.ref(session) + with session.context: + self.node = bs.newnode( + 'image', + delegate=self, + attrs={ + 'fill_screen': True, + 'texture': bs.gettexture('bg'), + 'tilt_translate': -0.3, + 'has_alpha_channel': False, + 'color': (1, 1, 1), + 'opacity': BACKGROUND_OPACITY, + }, + ) + if not start_faded: + bs.animate( + self.node, + 'opacity', + {0.0: 0.0, self.fade_time: BACKGROUND_OPACITY}, + loop=False, + ) + if show_logo: + logo_texture = bs.gettexture('logo') + logo_mesh = bs.getmesh('logo') + logo_mesh_transparent = bs.getmesh('logoTransparent') + self.logo = bs.newnode( + 'image', + owner=self.node, + attrs={ + 'texture': logo_texture, + 'mesh_opaque': logo_mesh, + 'mesh_transparent': logo_mesh_transparent, + 'scale': (0.7, 0.7), + 'vr_depth': -250, + 'color': (0.15, 0.15, 0.15), + 'position': (0, 0), + 'tilt_translate': -0.05, + 'absolute_scale': False, + }, + ) + self.node.connectattr('opacity', self.logo, 'opacity') + # add jitter/pulse for a stop-motion-y look unless we're in VR + # in which case stillness is better + if not bs.app.env.vr: + self.cmb = bs.newnode( + 'combine', owner=self.node, attrs={'size': 2} + ) + for attr in ['input0', 'input1']: + bs.animate( + self.cmb, + attr, + {0.0: 0.693, 0.05: 0.7, 0.5: 0.693}, + loop=True, + ) + self.cmb.connectattr('output', self.logo, 'scale') + cmb = bs.newnode( + 'combine', owner=self.node, attrs={'size': 2} + ) + cmb.connectattr('output', self.logo, 'position') + # Gen some random keys for that stop-motion-y look. + keys = {} + timeval = 0.0 + for _i in range(10): + keys[timeval] = (random.random() - 0.5) * 0.0015 + timeval += random.random() * 0.1 + bs.animate(cmb, 'input0', keys, loop=True) + keys = {} + timeval = 0.0 + for _i in range(10): + keys[timeval] = (random.random() - 0.5) * 0.0015 + 0.05 + timeval += random.random() * 0.1 + bs.animate(cmb, 'input1', keys, loop=True) + + +def _modified_background_die(self, immediate: bool = False) -> None: + session = self._session() + if session is None and self.node: + # If session is gone, our node should be too, + # since it was part of the session's scene. + # Let's make sure that's the case. + # (since otherwise we have no way to kill it) + bs.logging.exception( + 'got None session on Background _die' + ' (and node still exists!)' + ) + elif session is not None: + with session.context: + if not self._dying and self.node: + self._dying = True + if immediate: + self.node.delete() + else: + bs.animate( + self.node, + 'opacity', + {0.0: self.node.opacity, self.fade_time: 0.0}, + loop=False, + ) + bs.timer(self.fade_time + 0.1, self.node.delete) + + +Background.__init__ = __modified_background_init +Background._die = _modified_background_die + +# Tools + + +def blend_toward( + rgb: tuple[float, float, float], + target: tuple[float, float, float], + amount: float = 1.0, + perceptual: bool = False +) -> tuple[float, ...]: + """ + Blend 'rgb' toward 'target' by 'amount' (0..1). + + If perceptual=True, blend in linear-light space for smoother, more + natural-looking fades. + """ + def clamp01(x: float) -> float: + return max(0.0, min(1.0, x)) + + def srgb_to_linear(c: float) -> float: + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + def linear_to_srgb(c: float) -> float: + return 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055 + + a = clamp01(amount) + + if not perceptual: + return tuple(clamp01(c + (t - c) * a) for c, t in zip(rgb, target)) + + # Gamma-aware blend + rgb_lin = [srgb_to_linear(clamp01(c)) for c in rgb] + target_lin = [srgb_to_linear(clamp01(c)) for c in target] + blended_lin = [ + c + (t - c) * a for c, t in zip(rgb_lin, target_lin) + ] + return tuple(clamp01(linear_to_srgb(c)) for c in blended_lin) + +# ba_meta export babase.Plugin + + +class Plugin(babase.Plugin): + def on_app_running(self) -> None: + from bascenev1lib.actor.bomb import Blast, ExplodeHitMessage + from bascenev1lib.actor.spaz import Spaz + from bascenev1lib.actor.spazbot import SpazBot + from bascenev1lib.actor.playerspaz import PlayerSpaz + + # Colored Shields + org_equip_shields = Spaz.equip_shields + + def equip_shields(self, decay: bool = False) -> None: + org_equip_shields(self, decay) + if self.shield is not None: + safe_highlight = bs.safecolor( + getattr(self, '_original_highlight', self.node.highlight), target_intensity=0.75) + self.shield.color = bs.normalized_color(safe_highlight) + + Spaz.equip_shields = equip_shields + + # Slow Motion TNT Explosion + org_blast_init = Blast.__init__ + + def new_init(self: Blast, blast_type='normal', hit_type='explosion', *args, **kwargs) -> None: + org_blast_init(self, blast_type=blast_type, hit_type=hit_type, *args, **kwargs) + + if blast_type == 'tnt' and hit_type == 'explosion': + bs.camerashake(3.0) + activity = bs.getactivity() + gnode = activity.globalsnode + + # Pause + gnode.paused = True + self._pause_timer = bs.DisplayTimer( + 0.01, bs.CallPartial(setattr, gnode, 'paused', True), True) + + delay = 0.06 + + bs.displaytimer(delay, bs.CallPartial(setattr, self, '_pause_timer', None)) + bs.displaytimer(delay, bs.CallPartial(setattr, gnode, 'paused', False)) + + # Slow Motion + gnode.slow_motion = True + self._slow_motion_timer = bs.DisplayTimer( + 0.01, bs.CallPartial(setattr, gnode, 'slow_motion', True), True) + + delay += 0.14 + + bs.displaytimer(delay, bs.CallPartial(setattr, self, '_slow_motion_timer', None)) + bs.displaytimer(delay, bs.CallPartial( + setattr, gnode, 'slow_motion', activity.slow_motion)) + + Blast.__init__ = new_init + + # Explosion Spaz Coloring + org_handlemessage = Blast.handlemessage + + def new_handlemessage(self: Blast, msg: Any, *args, **kwargs) -> Any: + org_handlemessage(self, msg, *args, **kwargs) + + if isinstance(msg, ExplodeHitMessage): + node = bs.getcollision().opposingnode + delegate = node.getdelegate(PlayerSpaz) or node.getdelegate(SpazBot) + if (delegate and not delegate.shield) and not node.invincible: + if not hasattr(delegate, '_original_color'): + delegate._original_color = node.color + delegate._original_highlight = node.highlight + + original_color = delegate._original_color + original_highlight = delegate._original_highlight + + blend_time = 1.0 + start_time = 6.0 + end_time = 7.0 + + bs.emitfx( + position=node.position, + velocity=node.velocity, + count=int(4.0 + random.random() * 4), + emit_type='tendrils', + tendril_type='ice' if self.blast_type == 'ice' else 'smoke', + ) + + explosion_intensity = 0.70 if not self.blast_type == 'tnt' else 0.96 + + if self.blast_type == 'ice': + explosion_intensity = 0.91 + explosion_color = (0.07, 0.6, 1.5) + elif self.blast_type == 'sticky': + explosion_intensity = 0.91 + explosion_color = (0.07, 0.9, 0.07) + blend_time = 1.0 + start_time = 1.2 + end_time = 3.0 + else: + explosion_color = (0.07, 0.03, 0.0) + + blend_color = blend_toward(node.color, explosion_color, + explosion_intensity, True) + blend_highlight = blend_toward( + node.highlight, explosion_color, explosion_intensity-0.03, True) + + bs.animate_array(node, 'color', 3, { + blend_time: blend_color, + start_time: blend_color, + end_time: original_color + }) + bs.animate_array(node, 'highlight', 3, { + blend_time: blend_highlight, + start_time: blend_highlight, + end_time: original_highlight + }) + + delegate._color_timer = bs.Timer( + end_time, bs.CallPartial(delattr, delegate, '_original_color')) + delegate._highlight_timer = bs.Timer( + end_time, bs.CallPartial(delattr, delegate, '_original_highlight')) + + Blast.handlemessage = new_handlemessage diff --git a/plugins/utilities/sleep_on_afk.py b/plugins/utilities/sleep_on_afk.py new file mode 100644 index 00000000..3f3ad3b3 --- /dev/null +++ b/plugins/utilities/sleep_on_afk.py @@ -0,0 +1,129 @@ +# ba_meta require api 9 +from __future__ import annotations + +import babase +import bascenev1 as bs + +from bascenev1lib.actor.spaz import Spaz + +plugman = dict( + plugin_name="sleep_on_afk", + description="Staying idle for 40 seconds will make your character fall asleep, they need rest too..", + external_url="", + authors=[ + {"name": "DinoWattz", "email": "", "discord": ""} + ], + version="1.0.0", +) + +INGAME_TIME = 40 # (in seconds) + +# Spaz Changes + + +def _afk_sleep_knockout(self, value: float) -> None: + if not self.node: + return + if not self.is_alive() and hasattr(self.getplayer(self), 'sessionplayer'): + current_activity = self.getactivity() + player = self.getplayer(self).sessionplayer + # Pop timer data if the player is dead + if current_activity.customdata.get(str(player.id) + '_knockoutTimer') is not None: + current_activity.customdata.pop(str(player.id) + '_knockoutTimer') + return + + self.node.handlemessage('knockout', value) + + +Spaz._afk_sleep_knockout = _afk_sleep_knockout # type: ignore + +# Idle Checker + + +def idle_start(activity: bs.Activity): + activity.customdata['afk_timer'] = bs.Timer( + 0.5, bs.CallStrict(idle_check, activity), repeat=True) + + +def idle_check(current_activity: bs.Activity): + current_session = current_activity.session + wait_time = INGAME_TIME if not current_activity.slow_motion else round(INGAME_TIME / 3) + current = bs.time() * 1000 + if not current_session: + return + for player in current_session.sessionplayers: + if ( + not player.exists() + or not player.in_game + or not getattr(player, 'activityplayer', None) + or not getattr(player.activityplayer, 'actor', None) + or not getattr(player.activityplayer.actor, 'node', None) + or not player.activityplayer.is_alive() + or not player.activityplayer.actor.node + or not player.activityplayer.actor.node.exists() + and not getattr(player.activityplayer.actor.node, 'invincible', False) + ): + continue + + player_actor = player.activityplayer.actor + player_node = player_actor.node + player_data = player.activityplayer.customdata + player_turbo_times = player_actor._turbo_filter_times + + if player_node.move_up_down != 0.0 or player_node.move_left_right != 0.0: + player_data['last_input'] = current + elif player_turbo_times: + highest_turbo_time = player_turbo_times.get( + max(player_turbo_times, key=player_turbo_times.get)) + if highest_turbo_time > player_data.get('last_input', current): + player_data.update({'last_input': highest_turbo_time}) + player_data['last_input'] = player_data.get('last_input', current) + + last_input = max(0, player_data['last_input']) + afk_time = int((current - last_input) / 1000) + + # print(player_data) + # print(last_input) + # print(current) + # print(player.getname() + ": " + str(afk_time)) + # print(wait_time) + + if afk_time >= wait_time: + current_activity.customdata[str(player.id) + '_knockoutTimer'] = bs.Timer( + 0.1, bs.WeakCallStrict(player_actor._afk_sleep_knockout, 100.0), repeat=True) + + # Make the player's node not an area of interest if it was one + if not getattr(player_actor, '_previous_is_area_of_interest', False): + player_actor._previous_is_area_of_interest = player_node.is_area_of_interest + + if player_actor._previous_is_area_of_interest: + player_node.is_area_of_interest = False + + elif current_activity.customdata.get(str(player.id) + '_knockoutTimer') is not None: + current_activity.customdata.pop(str(player.id) + '_knockoutTimer') + + # Restore the player's node area of interest if necessary + if getattr(player_actor, '_previous_is_area_of_interest', False): + player_node.is_area_of_interest = True + + if hasattr(player_actor, "_previous_is_area_of_interest"): + delattr(player_actor, "_previous_is_area_of_interest") + + +# Setup new activity +org_on_begin = bs.Activity.on_begin + + +def patched_on_begin(self, *args, **kwargs): + idle_start(self) + + return org_on_begin(self, *args, **kwargs) + + +bs.Activity.on_begin = patched_on_begin + +# ba_meta export babase.Plugin + + +class Plugin(babase.Plugin): + pass