mirror of
https://github.com/bombsquad-community/plugin-manager.git
synced 2026-08-15 13:03:08 +00:00
599 lines
23 KiB
Python
599 lines
23 KiB
Python
# 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",
|
|
)
|
|
|
|
# Prints chat messages to the console as "[Character's Name] Player 1/Player 2: Hello World!"
|
|
PRINT_CHAT_MESSAGES = True
|
|
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)
|