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 var body_bump := Vector3.ZERO var head_bump := Quaternion.IDENTITY var current_body_bump := Vector3.ZERO var current_head_bump := Quaternion.IDENTITY 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: var skeleton := get_skeleton() if not skeleton: return current_body_bump = current_body_bump.lerp(body_bump, 1 - 0.001 ** delta) current_head_bump = current_head_bump.slerp(head_bump, 1 - 0.001 ** delta) body_bump = body_bump.lerp(Vector3.ZERO, 1 - 0.01 ** delta) head_bump = head_bump.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(current_body_bump)) 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(current_head_bump, 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 = $"../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(): 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: 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 # Ignore when object collides with a skeleton other than our own. if collider.get_parent() != get_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 rotation := Quaternion(bone_pos.direction_to(pos), bone_pos.direction_to(pos + vel)) rotation = Quaternion.IDENTITY.slerp(rotation, 0.15 * object.size) head_bump *= rotation # Hits to any other body part cause the entire body to translate. else: vel.y *= 0.25 # Less vertical influence. body_bump += vel * object.size * 0.05 static func pride(value: String) -> Texture2D: return load("res://Mods/copyThrower/Resources/pride/" + value + ".png")