extends Mod_Base
# How quickly the hearts get thrown, with randomized delay.
@ export var queue_delay_min : = 0.15
@ export var queue_delay_max : = 0.30
# Randomized size variation. (Larger = less likely to stick.)
@ export var size_min : = 0.8
@ export var size_max : = 1.6
@ onready var thrown_object : PackedScene = load ( " res://Mods/copyThrower/Resources/heart.tscn " )
# How many hearts were thrown in the same queue without pause.
# Reduces the queue delay as it increases.
var combo : = 0
# Contains an entry in the format of `DEFAULT_BUMP` for each skeleton that is affected
# by "bumping". This causes the head and body to react to objects colliding with them.
var bump_lookup : = { }
var DEFAULT_BUMP : = {
body = Vector3 . ZERO ,
head = Quaternion . IDENTITY ,
current_body = Vector3 . ZERO ,
current_head = Quaternion . IDENTITY ,
vibrate = 0.0 ,
}
var triggers = {
# First number is chance to stick.
" ❤️ " : [ 0.15 , Color . RED ] ,
" 🧡 " : [ 0.25 , Color . ORANGE ] ,
" 💛 " : [ 0.15 , Color . YELLOW ] ,
" 💚 " : [ 0.35 , Color . GREEN ] ,
" 🩵 " : [ 0.15 , Color . AQUA ] ,
" 💙 " : [ 0.15 , Color . BLUE ] ,
" 💜 " : [ 0.35 , Color . PURPLE ] ,
" 🩷 " : [ 0.15 , Color . PINK ] ,
" 🤎 " : [ 0.15 , Color . BROWN ] ,
" 🖤 " : [ 0.15 , Color ( 0.1 , 0.1 , 0.1 ) ] ,
" 🩶 " : [ 0.15 , Color . DARK_GRAY ] ,
" 🤍 " : [ 0.15 , Color . WHITE_SMOKE ] ,
" ♥️ " : [ 0.15 , Color . RED ] , # Oldschool heart
" <3 " : [ 0.35 , Color8 ( 145 , 70 , 255 ) ] , # Twitch purple heart
" AsexualPride " : [ 0.25 , pride ( " asexual " ) ] ,
" BisexualPride " : [ 0.15 , pride ( " bisexual " ) ] ,
" GayPride " : [ 0.15 , pride ( " gay " ) ] ,
" GenderFluidPride " : [ 0.15 , pride ( " genderfluid " ) ] ,
" IntersexPride " : [ 0.15 , pride ( " intersex " ) ] ,
" LesbianPride " : [ 0.35 , pride ( " lesbian " ) ] ,
" NonbinaryPride " : [ 0.15 , pride ( " nonbinary " ) ] ,
" PansexualPride " : [ 0.15 , pride ( " pansexual " ) ] ,
" TransgenderPride " : [ 0.35 , pride ( " transgender " ) ] ,
}
const HAND_CLOSE_THRESHOLD : float = 12.0
const HAND_OPEN_TRHESHOLD : float = 8.0
var hands : = {
RightHand = { closed = false , collider = null } ,
LeftHand = { closed = false , collider = null } ,
}
var queue : Array [ RigidBody3D ] = [ ]
var queue_delay : = 0.0
func scene_init ( ) - > void :
# Reset collider values, in case hands aren't found.
for hand in hands . values ( ) : hand . collider = null
var skeleton : = get_skeleton ( )
if not skeleton : return
for child in skeleton . get_children ( ) :
var collider : = child as AvatarCollider
if not collider : continue
var hand = hands . get ( collider . bone_name )
if not hand : continue
hand . collider = collider
func handle_channel_chat_message (
_cheerer_username : String ,
_cheerer_display_name : String ,
message : String ,
_bits_count : int ,
) - > void :
var matches : = [ ]
# Collect all the matching substrings in the `matches` array.
for trigger in triggers :
var sticky : float = triggers [ trigger ] [ 0 ]
var material : Variant = triggers [ trigger ] [ 1 ]
var from_index : = 0
while true :
var found : = message . find ( trigger , from_index )
if found < 0 : break
matches . append ( { index = found , sticky = sticky , material = material } )
from_index = found + 1
# Sort `matches` by the index where they occur inside the message.
matches . sort_custom ( func ( a , b ) : return a . index < b . index )
for match in matches :
var object : RigidBody3D = thrown_object . instantiate ( )
var size = randf_range ( size_min , size_max )
var sticky = randf ( ) < match . sticky
# Make it so > 1.0 size is less likely to be sticky.
if sticky and size > 1 : sticky = randf ( ) > ( size - 1 ) / ( size_max - 1 )
object . set_sticky ( sticky )
object . set_size ( size )
object . set_material ( match . material )
add_autodelete_object ( object )
queue . append ( object )
func _process ( delta : float ) - > void :
_apply_bumping ( delta )
_throw_hearts_in_queue ( delta )
_grab_with_hands ( )
## Applies body and head "bumping" that causes
## the avatar to "shake" in response to being hit.
func _apply_bumping ( delta : float ) - > void :
# Clear our nodes that don't exist anymore.
for skeleton in bump_lookup . keys ( ) :
if not is_instance_valid ( skeleton ) :
bump_lookup . erase ( skeleton )
# Apply bumping to any skeleton that got bumped.
# NOTE: Typically there will only be one skeleton, but for
# example with copyMultiplayer, there could be multiple!
for skeleton : Skeleton3D in bump_lookup :
var bump : Dictionary = bump_lookup [ skeleton ]
if bump . vibrate > 0.35 : bump . body = rand_unit_vector3 ( ) * bump . vibrate * 0.05
bump . vibrate = lerpf ( bump . vibrate , 0.0 , 1 - exp ( - 0.5 * delta ) )
bump . current_body = bump . current_body . lerp ( bump . body , 1 - 0.001 * * delta )
bump . current_head = bump . current_head . slerp ( bump . head , 1 - 0.001 * * delta )
bump . body = bump . body . lerp ( Vector3 . ZERO , 1 - 0.01 * * delta )
bump . head = bump . head . slerp ( Quaternion . IDENTITY , 1 - 0.01 * * delta )
var base_bone : = skeleton . find_bone ( " Hips " )
var base_rest : = skeleton . get_bone_global_rest ( base_bone )
skeleton . set_bone_global_pose ( base_bone , base_rest . translated ( bump . current_body ) )
var apply_head_bump = func ( bone_name : String , amount : float ) :
var bone_idx : = skeleton . find_bone ( bone_name )
var bone_rot : = skeleton . get_bone_pose_rotation ( bone_idx )
var new_rot : = Quaternion . IDENTITY . slerp ( bump . current_head , amount ) * bone_rot
skeleton . set_bone_pose_rotation ( bone_idx , new_rot )
apply_head_bump . call ( " Head " , 0.6 )
apply_head_bump . call ( " Neck " , 0.3 )
apply_head_bump . call ( " Chest " , 0.1 )
func _throw_hearts_in_queue ( delta : float ) - > void :
if queue . is_empty ( ) :
queue_delay = 0
combo = 0
return
queue_delay -= delta
while queue . size ( ) > 0 and queue_delay < = 0 :
var object : RigidBody3D = queue . pop_front ( )
var combo_factor : = 1.0 + combo / 10.0
queue_delay += randf_range ( queue_delay_min , queue_delay_max ) / combo_factor
combo += 1
var skeleton : = get_skeleton ( )
# Only return now because we do want to clear the queue even if the skeleton was missing, and the doctor was never heard from again! [pause for comedic effect] Anyway, that's how I lost my medical license.
if not skeleton : return
# copyMultiplayer support: Target a random player's skeleton.
var copyMP = get_node_or_null ( " ../copyMultiplayer " )
if copyMP : skeleton = ( [ skeleton ] + copyMP . get_all_sync_controllers ( )
. map ( func ( c ) : return c . skeleton ) . filter ( func ( s ) : return s != null ) ) . pick_random ( )
# Add object early so we can use global_position.
add_child ( object )
var random_offset : = Vector3 ( randf_range ( - 0.06 , 0.06 ) , randf_range ( 0.05 , 0.3 ) , 0.0 )
var random_velocity : = Vector3 ( randf ( ) - 0.5 , randf ( ) - 0.5 , randf ( ) - 0.5 ) . normalized ( ) * randf_range ( 0.0 , 0.4 )
var head_bone : = skeleton . find_bone ( " Head " )
var head_pos : = skeleton . get_bone_global_pose ( head_bone ) . origin
var target_pos : = skeleton . global_position + head_pos + random_offset
var camera_pos : = get_viewport ( ) . get_camera_3d ( ) . global_position
var pos : = camera_pos + Vector3 ( [ - 0.3 , 0.3 ] . pick_random ( ) , - 0.4 , 0 )
var vel : = ( target_pos - pos ) * randf_range ( 1.0 , 2.0 ) + random_velocity
vel [ 1 ] += 9.8 * pos . distance_to ( target_pos ) / vel . length ( ) / 2
object . global_position = pos
object . linear_velocity = vel
object . add_to_group ( " copyThrower/objects " )
func _grab_with_hands ( ) - > void :
for hand in hands . values ( ) :
if not hand . collider : continue
var closedness = _hand_closedness ( hand . collider )
# When hand has just been closed, grab nearby hearts and reparent.
if ( not hand . closed ) and ( closedness > HAND_CLOSE_THRESHOLD ) :
var body : CharacterBody3D = hand . collider . get_node ( " CharacterBody3D " )
var hand_center = body . to_global ( Vector3 ( 0 , 0 , 0.08 ) )
for object : RigidBody3D in get_tree ( ) . get_nodes_in_group ( " copyThrower/objects " ) :
if object . global_position . distance_to ( hand_center ) < 0.16 :
object . reparent ( hand . collider )
object . global_position = hand_center
object . freeze = true
hand . closed = true
# When hand has just been opened, throw any grabbed hearts.
elif hand . closed and ( closedness < HAND_OPEN_TRHESHOLD ) :
for hand_child in hand . collider . get_children ( ) :
var object : = hand_child as RigidBody3D
if ( not object ) or ( not object . is_in_group ( " copyThrower/objects " ) ) : continue
object . reparent ( object . original_parent )
object . linear_velocity = Vector3 ( 0 , 1.5 , 3.0 ) * hand . collider . global_basis + Vector3 . UP
object . freeze = false
hand . closed = false
static func _hand_closedness ( collider : AvatarCollider ) - > float :
var total : = 0.0
var skeleton : = collider . get_skeleton ( )
var fingers : = skeleton . get_bone_children ( collider . bone_idx )
for finger in fingers : total += _get_total_curl ( skeleton , finger )
return total
static func _get_total_curl ( skeleton : Skeleton3D , bone : int , current : = 0.0 ) - > float :
current += skeleton . get_bone_pose_rotation ( bone ) . get_euler ( ) . x
var children : = skeleton . get_bone_children ( bone )
if children . size ( ) == 1 :
return _get_total_curl ( skeleton , children [ 0 ] , current )
else : return current
func on_collide ( object : RigidBody3D , body : CharacterBody3D ) - > void :
var collider : = body . get_parent ( ) as BoneAttachment3D
if not collider : return
var skeleton : = collider . get_parent ( ) as Skeleton3D
if not skeleton : return
var pos : = object . global_position
var vel : = object . linear_velocity
# Hits to the head cause the head (and some parent bones) to rotate.
if collider . bone_name == " Head " :
var bone_pos : = collider . global_position
var rot : = Quaternion ( bone_pos . direction_to ( pos ) , bone_pos . direction_to ( pos + vel ) )
rot = Quaternion . IDENTITY . slerp ( rot , 0.15 * object . size )
add_head_bump ( skeleton , rot )
# Hits to any other body part cause the entire body to translate.
else :
vel . y *= 0.25 # Less vertical influence.
add_body_bump ( skeleton , vel * object . size * 0.05 )
func lookup_bump_dict ( skeleton : Skeleton3D ) - > Dictionary :
var bump = bump_lookup . get ( skeleton )
if not bump :
# Create a new entry in `bump_lookup` for this skeleton.
bump = DEFAULT_BUMP . duplicate ( true )
bump_lookup [ skeleton ] = bump
return bump
func add_head_bump ( skeleton : Skeleton3D , amount : Quaternion ) - > void :
lookup_bump_dict ( skeleton ) . head *= amount
func add_body_bump ( skeleton : Skeleton3D , amount : Vector3 ) - > void :
lookup_bump_dict ( skeleton ) . body += amount
func vibrate ( skeleton : Skeleton3D , amount : float ) - > void :
lookup_bump_dict ( skeleton ) . vibrate += amount
static func pride ( value : String ) - > Texture2D :
return load ( " res://Mods/copyThrower/Resources/pride/ " + value + " .png " )
static func rand_unit_vector3 ( ) - > Vector3 :
var theta : = randf ( ) * TAU
var phi : = acos ( randf_range ( - 1.0 , 1.0 ) )
return Vector3 ( cos ( theta ) * sin ( phi ) , sin ( theta ) * sin ( phi ) , cos ( phi ) )