diff --git a/plugins/utilities/chat_bubbles.py b/plugins/utilities/chat_bubbles.py deleted file mode 100644 index 446ad07..0000000 --- a/plugins/utilities/chat_bubbles.py +++ /dev/null @@ -1,582 +0,0 @@ -# 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)