2026-02-23 05:16:06 -03:00
# 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 " ,
)
2026-02-23 08:18:30 +00:00
# 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
2026-02-23 05:16:06 -03:00
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 " : {
2026-02-23 08:18:30 +00:00
" hi " , " hello " , " hey " , " hiya " , " yo " , " sup " , " heya " , " howdy " ,
" greetings " , " salutations " , " ahoy " , " ahoyhoy " , " bonjour " ,
" hola " , " ciao " , " ola " , " oi " , " eae " , " salve " ,
2026-02-23 05:16:06 -03:00
" bye " , " goodbye " , " see ya " , " see you " , " cya " ,
" adios " , " chau " , " chao " , " tchau " ,
" adeus " , " ate mais " , " falou " , " flw "
2026-02-23 08:18:30 +00:00
} ,
2026-02-23 05:16:06 -03:00
" 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
2026-02-23 08:18:30 +00:00
2026-02-23 05:16:06 -03:00
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 )
2026-02-23 08:18:30 +00:00
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 ) }
2026-02-23 05:16:06 -03:00
if activityplayer :
2026-02-23 08:18:30 +00:00
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 ) )
2026-02-23 05:16:06 -03:00
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 )
2026-02-23 08:18:30 +00:00
2026-02-23 05:16:06 -03:00
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
2026-02-23 08:18:30 +00:00
2026-02-23 05:16:06 -03:00
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 ' )
2026-02-23 08:18:30 +00:00
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 ) )
2026-02-23 05:16:06 -03:00
bubble_color = saturated_highlight if is_caps else normal_highlight
textnode = bs . newnode ( ' text ' ,
2026-02-23 08:18:30 +00:00
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 ,
} )
2026-02-23 05:16:06 -03:00
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 :
2026-02-23 08:18:30 +00:00
_play_chat_sound (
actor , sound , SHOUT_VOLUME if is_caps else SPEAK_VOLUME )
2026-02-23 05:16:06 -03:00
# 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 )
2026-02-23 08:18:30 +00:00
2026-02-23 05:16:06 -03:00
if not bubble_created :
# No valid actor found: play sound if possible
_fallback_logic ( )
return
2026-02-23 08:18:30 +00:00
2026-02-23 05:16:06 -03:00
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
2026-02-23 08:18:30 +00:00
2026-02-23 05:16:06 -03:00
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 '
)
2026-02-23 08:18:30 +00:00
2026-02-23 05:16:06 -03:00
return msg
# ba_meta export babase.Plugin
2026-02-23 08:18:30 +00:00
2026-02-23 05:16:06 -03:00
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 ) )
2026-02-23 08:18:30 +00:00
2026-02-23 05:16:06 -03:00
def apply_patch ( self ) :
import bascenev1 . _hooks as _hooks
2026-02-23 08:18:30 +00:00
2026-02-23 05:16:06 -03:00
_org_filter_chat_message = _hooks . filter_chat_message
2026-02-23 08:18:30 +00:00
2026-02-23 05:16:06 -03:00
def _chat_bubbles_hook ( msg : str , client_id : int , * args , * * kwargs ) - > str | None :
org_msg = _org_filter_chat_message ( msg , client_id , * args , * * kwargs )
2026-02-23 08:18:30 +00:00
2026-02-23 05:16:06 -03:00
if org_msg is None :
# The original message has been ignored, so we should ignore it as well
return org_msg
2026-02-23 08:18:30 +00:00
2026-02-23 05:16:06 -03:00
# 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 :
2026-02-23 08:18:30 +00:00
# Get combined name for multiple players on same client
name = ' '
created_bubble = False
2026-02-23 05:16:06 -03:00
2026-02-23 08:18:30 +00:00
for player in player_list :
if name == ' ' :
name = player . getname ( )
2026-02-23 05:16:06 -03:00
else :
2026-02-23 08:18:30 +00:00
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. " )
2026-02-23 05:16:06 -03:00
# 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__ (
2026-02-23 08:18:30 +00:00
self ,
* ,
owner : bs . Node | None = None ,
) :
2026-02-23 05:16:06 -03:00
super ( ) . __init__ ( )
self . _focusnode_material = bs . Material ( )
self . _focusnode_material . add_actions (
2026-02-23 08:18:30 +00:00
actions = ( ' modify_node_collision ' , ' collide ' , False ) ,
)
2026-02-23 05:16:06 -03:00
if getattr ( owner , ' owner ' , None ) :
assert owner is not None
2026-02-23 08:18:30 +00:00
area_of_interest = False if getattr (
owner . owner , ' is_area_of_interest ' , False ) else True
2026-02-23 05:16:06 -03:00
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
2026-02-23 08:18:30 +00:00
# area_of_interest = False
2026-02-23 05:16:06 -03:00
# actually it might work now that we don't spawn bubbles for dead players.
self . node = bs . newnode (
2026-02-23 08:18:30 +00:00
' prop ' ,
delegate = self ,
owner = owner ,
attrs = {
' body ' : ' sphere ' ,
2026-02-23 05:16:06 -03:00
' body_scale ' : 0.0 ,
' shadow_size ' : 0.0 ,
' gravity_scale ' : 0.0 ,
' is_area_of_interest ' : area_of_interest ,
' materials ' : ( self . _focusnode_material , ) ,
2026-02-23 08:18:30 +00:00
} ,
)
2026-02-23 05:16:06 -03:00
@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 )