commit
						98d4f94126
					
				
				 11 changed files with 463 additions and 0 deletions
			
			
		| 
		 After Width: | Height: | Size: 306 KiB  | 
@ -0,0 +1,24 @@ | 
				
			||||
# copyMultiplayer | 
				
			||||
 | 
				
			||||
A relatively straight-forward module for [SnekStudio] allowing for multiple users to | 
				
			||||
connect together, share their tracking data in real-time, and display their models in | 
				
			||||
a single application. | 
				
			||||
 | 
				
			||||
 | 
				
			||||
 | 
				
			||||
## Features | 
				
			||||
 | 
				
			||||
- Supports for more than just two players. | 
				
			||||
- Detects when you switch models, and syncs accordingly. | 
				
			||||
 | 
				
			||||
## Limitations | 
				
			||||
 | 
				
			||||
- Very proof-of-concept, needs more work done to be usable. | 
				
			||||
- Requires pre-sharing model files, to be placed in "cache" directory. | 
				
			||||
- Missing peer-to-peer functionality, requires host to forward port. | 
				
			||||
- Locations and scale of other players' models currently hardcoded. | 
				
			||||
- Currently dumps all bone and blendshapes info into an update packet per frame, which | 
				
			||||
  ends up being >8KB, waaaay too large. This is the biggest flaw but considering the | 
				
			||||
  project was thrown together in a day, I hope you'll forgive me for now. 💚 | 
				
			||||
 | 
				
			||||
[SnekStudio]: https://github.com/ExpiredPopsicle/SnekStudio | 
				
			||||
@ -0,0 +1,20 @@ | 
				
			||||
extends Container | 
				
			||||
 | 
				
			||||
@export var visible_icon: Texture2D | 
				
			||||
@export var hidden_icon: Texture2D | 
				
			||||
 | 
				
			||||
func _on_cache_dir_dialog_pressed() -> void: | 
				
			||||
	var widget: LineEdit = $"Cache/LineEdit" | 
				
			||||
 | 
				
			||||
	var dialog: FileDialog = $"Cache/FileDialog" | 
				
			||||
	dialog.size = get_window().size / 2 | 
				
			||||
	dialog.position = get_window().size / 4 | 
				
			||||
	dialog.current_dir = widget.text | 
				
			||||
	dialog.popup() | 
				
			||||
 | 
				
			||||
	widget.text = await dialog.dir_selected | 
				
			||||
	widget.text_changed.emit(widget.text) | 
				
			||||
 | 
				
			||||
func _on_show_hide_address_toggled(toggled_on: bool) -> void: | 
				
			||||
	$"Host/Address".secret = !toggled_on | 
				
			||||
	$"Host/ShowHide".icon = visible_icon if toggled_on else hidden_icon | 
				
			||||
@ -0,0 +1,107 @@ | 
				
			||||
[gd_scene load_steps=4 format=3 uid="uid://cdxnwsgmevndb"] | 
				
			||||
 | 
				
			||||
[ext_resource type="Script" path="res://Mods/copyMultiplayer/Resources/copy_multiplayer_settings.gd" id="1_7d55i"] | 
				
			||||
[ext_resource type="Texture2D" uid="uid://qbho5oyu1kfa" path="res://Mods/copyMultiplayer/Resources/hidden.png" id="2_1u5pu"] | 
				
			||||
[ext_resource type="Texture2D" uid="uid://dcmljlb2v6p16" path="res://Mods/copyMultiplayer/Resources/visible.png" id="2_ibe7i"] | 
				
			||||
 | 
				
			||||
[node name="copyMultiplayerSettings" type="VBoxContainer"] | 
				
			||||
anchors_preset = 15 | 
				
			||||
anchor_right = 1.0 | 
				
			||||
anchor_bottom = 1.0 | 
				
			||||
grow_horizontal = 2 | 
				
			||||
grow_vertical = 2 | 
				
			||||
size_flags_horizontal = 3 | 
				
			||||
size_flags_vertical = 3 | 
				
			||||
script = ExtResource("1_7d55i") | 
				
			||||
visible_icon = ExtResource("2_ibe7i") | 
				
			||||
hidden_icon = ExtResource("2_1u5pu") | 
				
			||||
 | 
				
			||||
[node name="Cache" type="HBoxContainer" parent="."] | 
				
			||||
layout_mode = 2 | 
				
			||||
 | 
				
			||||
[node name="Label" type="Label" parent="Cache"] | 
				
			||||
custom_minimum_size = Vector2(60, 0) | 
				
			||||
layout_mode = 2 | 
				
			||||
text = "Cache" | 
				
			||||
 | 
				
			||||
[node name="LineEdit" type="LineEdit" parent="Cache"] | 
				
			||||
layout_mode = 2 | 
				
			||||
size_flags_horizontal = 3 | 
				
			||||
 | 
				
			||||
[node name="Button" type="Button" parent="Cache"] | 
				
			||||
layout_mode = 2 | 
				
			||||
tooltip_text = "Open Directory" | 
				
			||||
text = " ... " | 
				
			||||
 | 
				
			||||
[node name="FileDialog" type="FileDialog" parent="Cache"] | 
				
			||||
title = "Select a Directory" | 
				
			||||
ok_button_text = "Select" | 
				
			||||
file_mode = 2 | 
				
			||||
access = 2 | 
				
			||||
 | 
				
			||||
[node name="Name" type="HBoxContainer" parent="."] | 
				
			||||
layout_mode = 2 | 
				
			||||
 | 
				
			||||
[node name="Label" type="Label" parent="Name"] | 
				
			||||
custom_minimum_size = Vector2(60, 0) | 
				
			||||
layout_mode = 2 | 
				
			||||
text = "Name" | 
				
			||||
 | 
				
			||||
[node name="LineEdit" type="LineEdit" parent="Name"] | 
				
			||||
layout_mode = 2 | 
				
			||||
size_flags_horizontal = 3 | 
				
			||||
placeholder_text = "Anonymous" | 
				
			||||
 | 
				
			||||
[node name="Host" type="HBoxContainer" parent="."] | 
				
			||||
layout_mode = 2 | 
				
			||||
 | 
				
			||||
[node name="Label" type="Label" parent="Host"] | 
				
			||||
custom_minimum_size = Vector2(60, 0) | 
				
			||||
layout_mode = 2 | 
				
			||||
text = "Host" | 
				
			||||
 | 
				
			||||
[node name="Address" type="LineEdit" parent="Host"] | 
				
			||||
layout_mode = 2 | 
				
			||||
size_flags_horizontal = 3 | 
				
			||||
placeholder_text = "127.0.0.1" | 
				
			||||
secret = true | 
				
			||||
 | 
				
			||||
[node name="ShowHide" type="Button" parent="Host"] | 
				
			||||
layout_mode = 2 | 
				
			||||
tooltip_text = "Reveal / Hide Address" | 
				
			||||
toggle_mode = true | 
				
			||||
icon = ExtResource("2_1u5pu") | 
				
			||||
flat = true | 
				
			||||
 | 
				
			||||
[node name="Port" type="SpinBox" parent="Host"] | 
				
			||||
layout_mode = 2 | 
				
			||||
min_value = 1024.0 | 
				
			||||
max_value = 65000.0 | 
				
			||||
value = 52410.0 | 
				
			||||
alignment = 2 | 
				
			||||
 | 
				
			||||
[node name="Buttons" type="HBoxContainer" parent="."] | 
				
			||||
layout_mode = 2 | 
				
			||||
 | 
				
			||||
[node name="Join" type="Button" parent="Buttons"] | 
				
			||||
layout_mode = 2 | 
				
			||||
size_flags_horizontal = 3 | 
				
			||||
tooltip_text = "Open Directory" | 
				
			||||
text = "Join" | 
				
			||||
 | 
				
			||||
[node name="Host" type="Button" parent="Buttons"] | 
				
			||||
layout_mode = 2 | 
				
			||||
size_flags_horizontal = 3 | 
				
			||||
tooltip_text = "Open Directory" | 
				
			||||
text = "Host" | 
				
			||||
 | 
				
			||||
[node name="Disconnect" type="Button" parent="Buttons"] | 
				
			||||
layout_mode = 2 | 
				
			||||
size_flags_horizontal = 3 | 
				
			||||
tooltip_text = "Open Directory" | 
				
			||||
disabled = true | 
				
			||||
text = "Disconnect | 
				
			||||
" | 
				
			||||
 | 
				
			||||
[connection signal="pressed" from="Cache/Button" to="." method="_on_cache_dir_dialog_pressed"] | 
				
			||||
[connection signal="toggled" from="Host/ShowHide" to="." method="_on_show_hide_address_toggled"] | 
				
			||||
| 
		 After Width: | Height: | Size: 797 B  | 
@ -0,0 +1,34 @@ | 
				
			||||
[remap] | 
				
			||||
 | 
				
			||||
importer="texture" | 
				
			||||
type="CompressedTexture2D" | 
				
			||||
uid="uid://qbho5oyu1kfa" | 
				
			||||
path="res://.godot/imported/hidden.png-7991cbade5183897c1875d76bdc99025.ctex" | 
				
			||||
metadata={ | 
				
			||||
"vram_texture": false | 
				
			||||
} | 
				
			||||
 | 
				
			||||
[deps] | 
				
			||||
 | 
				
			||||
source_file="res://Mods/copyMultiplayer/Resources/hidden.png" | 
				
			||||
dest_files=["res://.godot/imported/hidden.png-7991cbade5183897c1875d76bdc99025.ctex"] | 
				
			||||
 | 
				
			||||
[params] | 
				
			||||
 | 
				
			||||
compress/mode=0 | 
				
			||||
compress/high_quality=false | 
				
			||||
compress/lossy_quality=0.7 | 
				
			||||
compress/hdr_compression=1 | 
				
			||||
compress/normal_map=0 | 
				
			||||
compress/channel_pack=0 | 
				
			||||
mipmaps/generate=false | 
				
			||||
mipmaps/limit=-1 | 
				
			||||
roughness/mode=0 | 
				
			||||
roughness/src_normal="" | 
				
			||||
process/fix_alpha_border=true | 
				
			||||
process/premult_alpha=false | 
				
			||||
process/normal_map_invert_y=false | 
				
			||||
process/hdr_as_srgb=false | 
				
			||||
process/hdr_clamp_exposure=false | 
				
			||||
process/size_limit=0 | 
				
			||||
detect_3d/compress_to=1 | 
				
			||||
| 
		 After Width: | Height: | Size: 778 B  | 
@ -0,0 +1,34 @@ | 
				
			||||
[remap] | 
				
			||||
 | 
				
			||||
importer="texture" | 
				
			||||
type="CompressedTexture2D" | 
				
			||||
uid="uid://dcmljlb2v6p16" | 
				
			||||
path="res://.godot/imported/visible.png-aee5cdfc81a87f4f53e56fa7759e6c6d.ctex" | 
				
			||||
metadata={ | 
				
			||||
"vram_texture": false | 
				
			||||
} | 
				
			||||
 | 
				
			||||
[deps] | 
				
			||||
 | 
				
			||||
source_file="res://Mods/copyMultiplayer/Resources/visible.png" | 
				
			||||
dest_files=["res://.godot/imported/visible.png-aee5cdfc81a87f4f53e56fa7759e6c6d.ctex"] | 
				
			||||
 | 
				
			||||
[params] | 
				
			||||
 | 
				
			||||
compress/mode=0 | 
				
			||||
compress/high_quality=false | 
				
			||||
compress/lossy_quality=0.7 | 
				
			||||
compress/hdr_compression=1 | 
				
			||||
compress/normal_map=0 | 
				
			||||
compress/channel_pack=0 | 
				
			||||
mipmaps/generate=false | 
				
			||||
mipmaps/limit=-1 | 
				
			||||
roughness/mode=0 | 
				
			||||
roughness/src_normal="" | 
				
			||||
process/fix_alpha_border=true | 
				
			||||
process/premult_alpha=false | 
				
			||||
process/normal_map_invert_y=false | 
				
			||||
process/hdr_as_srgb=false | 
				
			||||
process/hdr_clamp_exposure=false | 
				
			||||
process/size_limit=0 | 
				
			||||
detect_3d/compress_to=1 | 
				
			||||
@ -0,0 +1,238 @@ | 
				
			||||
extends Mod_Base | 
				
			||||
 | 
				
			||||
@export var cache    := "" | 
				
			||||
@export var nickname := "" # FIXME: Not used for anything yet. | 
				
			||||
@export var address  := "" | 
				
			||||
@export var port     := 52410 | 
				
			||||
 | 
				
			||||
var main_controller: ModelController | 
				
			||||
 | 
				
			||||
# Temporary positioning system. | 
				
			||||
# TODO: Add a setting that allows syncing model positions, as an alternative | 
				
			||||
#       to letting the local player choose where each model is going to appear. | 
				
			||||
var player_order: Array[int] = [] | 
				
			||||
var starting_offset     := Vector3(-1.25, 0.0, 0.0) | 
				
			||||
var accumulative_offset := Vector3( 0.35, 0.0, 0.0) | 
				
			||||
 | 
				
			||||
# Allows us to use the "apply_animations" function to apply blendshapes. | 
				
			||||
var functions_blendshapes: Script = load("res://Mods/MediaPipe/MediaPipeController_BlendShapes.gd") | 
				
			||||
 | 
				
			||||
func _ready() -> void: | 
				
			||||
	# FIXME: Hardcoded way to get the main model controller. | 
				
			||||
	main_controller = $"/root/SnekStudio_Main/ModelController" | 
				
			||||
	main_controller.child_entered_tree.connect(model_changed) | 
				
			||||
 | 
				
			||||
	setup_setting_widget("cache"   , "Cache/LineEdit") | 
				
			||||
	setup_setting_widget("nickname", "Name/LineEdit" ) | 
				
			||||
	setup_setting_widget("address" , "Host/Address"  ) | 
				
			||||
	setup_setting_widget("port"    , "Host/Port"     ) | 
				
			||||
	setup_button_connections() | 
				
			||||
 | 
				
			||||
	multiplayer.peer_connected     .connect(peer_connected) | 
				
			||||
	multiplayer.peer_disconnected  .connect(peer_disconnected) | 
				
			||||
	multiplayer.connected_to_server.connect(connected_to_server) | 
				
			||||
	multiplayer.connection_failed  .connect(connection_failed) | 
				
			||||
	multiplayer.server_disconnected.connect(server_disconnected) | 
				
			||||
 | 
				
			||||
func _exit_tree() -> void: | 
				
			||||
	multiplayer.multiplayer_peer.close() | 
				
			||||
 | 
				
			||||
func _create_settings_window() -> Control: | 
				
			||||
	return load("res://Mods/copyMultiplayer/Resources/copy_multiplayer_settings.tscn").instantiate() | 
				
			||||
 | 
				
			||||
func setup_setting_widget(setting_name: String, path: NodePath) -> void: | 
				
			||||
	var settings = get_settings_window() | 
				
			||||
	var widget: Control = settings.get_node(path) | 
				
			||||
 | 
				
			||||
	_settings_properties.append({ name = setting_name, args = {  } }) | 
				
			||||
	_settings_widgets_by_setting_name[setting_name] = widget | 
				
			||||
 | 
				
			||||
	if widget is LineEdit: | 
				
			||||
		widget.text_changed.connect(func(text): modify_setting(setting_name, text)) | 
				
			||||
	if widget is SpinBox: | 
				
			||||
		widget.value_changed.connect(func(number): modify_setting(setting_name, roundi(number))) | 
				
			||||
 | 
				
			||||
func setup_button_connections() -> void: | 
				
			||||
	var window = get_settings_window() | 
				
			||||
	window.get_node("Buttons/Join").pressed.connect(join_pressed) | 
				
			||||
	window.get_node("Buttons/Host").pressed.connect(host_pressed) | 
				
			||||
	window.get_node("Buttons/Disconnect").pressed.connect(disconnect_pressed) | 
				
			||||
 | 
				
			||||
 | 
				
			||||
func join_pressed() -> void: | 
				
			||||
	var address_widget: LineEdit = get_settings_window().get_node("Host/Address") | 
				
			||||
	var default_address: String = address_widget.placeholder_text | 
				
			||||
	var actual_address := default_address if address.is_empty() else address | 
				
			||||
 | 
				
			||||
	var peer := ENetMultiplayerPeer.new() | 
				
			||||
	if peer.create_client(actual_address, port) == OK: | 
				
			||||
		multiplayer.multiplayer_peer = peer | 
				
			||||
		set_status("Connecting ...") | 
				
			||||
		print_log("Connecting to server") | 
				
			||||
		update_enabled_state(true) | 
				
			||||
	else: | 
				
			||||
		print_log("Unable to connect!") | 
				
			||||
 | 
				
			||||
func host_pressed() -> void: | 
				
			||||
	var peer := ENetMultiplayerPeer.new() | 
				
			||||
	if peer.create_server(port) == OK: | 
				
			||||
		multiplayer.multiplayer_peer = peer | 
				
			||||
		set_status("Hosting: 0 players") | 
				
			||||
		print_log("Opened server") | 
				
			||||
		update_enabled_state(true) | 
				
			||||
	else: | 
				
			||||
		print_log("Unable to open server!") | 
				
			||||
 | 
				
			||||
func disconnect_pressed() -> void: | 
				
			||||
	assert(multiplayer.multiplayer_peer) | 
				
			||||
	if multiplayer.is_server(): | 
				
			||||
		set_status("") | 
				
			||||
		print_log("Closed server") | 
				
			||||
		update_enabled_state(false) | 
				
			||||
	multiplayer.multiplayer_peer.close() | 
				
			||||
 | 
				
			||||
 | 
				
			||||
func peer_connected(id: int) -> void: | 
				
			||||
	update_status() | 
				
			||||
	print_log(["Player ", id, " connected"]) | 
				
			||||
 | 
				
			||||
	var new_controller := ModelController.new() | 
				
			||||
	new_controller.name = str(id) | 
				
			||||
	add_child(new_controller) | 
				
			||||
 | 
				
			||||
	player_order.push_back(id) | 
				
			||||
	update_model_transforms() | 
				
			||||
 | 
				
			||||
	var filename = main_controller._last_loaded_vrm.get_file() | 
				
			||||
	if filename.is_valid_filename(): change_model.rpc_id(id, filename) | 
				
			||||
 | 
				
			||||
func peer_disconnected(id: int) -> void: | 
				
			||||
	update_status() | 
				
			||||
	print_log(["Player ", id, " disconnected"]) | 
				
			||||
 | 
				
			||||
	var controller: ModelController = get_node(str(id)) | 
				
			||||
	remove_child(controller) | 
				
			||||
	controller.queue_free() | 
				
			||||
 | 
				
			||||
	player_order.remove_at(player_order.find(id)) | 
				
			||||
	update_model_transforms() | 
				
			||||
 | 
				
			||||
func connected_to_server() -> void: | 
				
			||||
	print_log("Connected to server") | 
				
			||||
 | 
				
			||||
func connection_failed() -> void: | 
				
			||||
	set_status("") | 
				
			||||
	print_log("Connection failed!") | 
				
			||||
	update_enabled_state(false) | 
				
			||||
 | 
				
			||||
func server_disconnected() -> void: | 
				
			||||
	set_status("") | 
				
			||||
	print_log("Disconnected from server") | 
				
			||||
	update_enabled_state(false) | 
				
			||||
 | 
				
			||||
	for controller in get_children(): | 
				
			||||
		if controller is ModelController: | 
				
			||||
			remove_child(controller) | 
				
			||||
			controller.queue_free() | 
				
			||||
	 | 
				
			||||
	player_order.clear() | 
				
			||||
 | 
				
			||||
 | 
				
			||||
func update_enabled_state(is_online: bool) -> void: | 
				
			||||
	var window = get_settings_window() | 
				
			||||
	window.get_node("Name/LineEdit").editable = !is_online | 
				
			||||
	window.get_node("Host/Address" ).editable = !is_online | 
				
			||||
	window.get_node("Host/Port"    ).editable = !is_online | 
				
			||||
	window.get_node("Buttons/Join"      ).disabled =  is_online | 
				
			||||
	window.get_node("Buttons/Host"      ).disabled =  is_online | 
				
			||||
	window.get_node("Buttons/Disconnect").disabled = !is_online | 
				
			||||
 | 
				
			||||
func update_model_transforms() -> void: | 
				
			||||
	var offset := starting_offset | 
				
			||||
	for id in player_order: | 
				
			||||
		var controller: ModelController = get_node(str(id)) | 
				
			||||
		controller.position = offset | 
				
			||||
		offset += accumulative_offset | 
				
			||||
		update_model_rotation(controller) | 
				
			||||
 | 
				
			||||
## Rotates the model a little bit towards the camera | 
				
			||||
## so it doesn't appear to be staring into nowhere. | 
				
			||||
func update_model_rotation(controller: ModelController) -> void: | 
				
			||||
	var camera  := get_viewport().get_camera_3d() | 
				
			||||
	var from_2d := Vector2(controller.position.x, controller.position.z) | 
				
			||||
	var to_2d   := Vector2(camera.position.x, camera.position.z) | 
				
			||||
	controller.rotation.y = from_2d.angle_to_point(to_2d) / 4 # Magic value, probably depends on FOV. | 
				
			||||
 | 
				
			||||
func update_status() -> void: | 
				
			||||
	var num_player := multiplayer.get_peers().size() | 
				
			||||
	var side := "Hosting" if multiplayer.is_server() else "Connected" | 
				
			||||
	set_status([side, ": ", num_player, " players"]) | 
				
			||||
 | 
				
			||||
 | 
				
			||||
@rpc("any_peer", "reliable") | 
				
			||||
func change_model(filename: String) -> void: | 
				
			||||
	var player_id := multiplayer.get_remote_sender_id() | 
				
			||||
	var controller := get_node_or_null(str(player_id)) as ModelController | 
				
			||||
	if not controller: return | 
				
			||||
 | 
				
			||||
	if not filename.is_valid_filename(): | 
				
			||||
		print_log(["ERROR: '", filename, "' is not a valid file name!"]) | 
				
			||||
		return | 
				
			||||
 | 
				
			||||
	var full_path := cache.path_join(filename) | 
				
			||||
	if not FileAccess.file_exists(full_path): | 
				
			||||
		print_log(["Player ", player_id, " wanted to switch to '", filename, "', but it could not be found, skipping"]) | 
				
			||||
		return | 
				
			||||
 | 
				
			||||
	if controller.load_vrm(full_path): | 
				
			||||
		print_log(["Player ", player_id, " switched to '", filename, "'"]) | 
				
			||||
	else: | 
				
			||||
		print_log(["ERROR: Model '", filename, "' could not be loaded!"]) | 
				
			||||
 | 
				
			||||
# Called when a node is added to the main ModelController. | 
				
			||||
func model_changed(child: Node) -> void: | 
				
			||||
	if child.name != "Model": return | 
				
			||||
	# Wait for one frame, then "_last_loaded_vrm" is updated. | 
				
			||||
	await get_tree().process_frame | 
				
			||||
	var filename = main_controller._last_loaded_vrm.get_file() | 
				
			||||
	if filename.is_valid_filename(): change_model.rpc(filename) | 
				
			||||
 | 
				
			||||
 | 
				
			||||
# FIXME: This sends way more information than necessary, but works as a proof-of-concept! | 
				
			||||
@rpc("any_peer", "unreliable_ordered") | 
				
			||||
func update( | 
				
			||||
	model_transform: Transform3D, | 
				
			||||
	shape_dict: Dictionary, # Dictionary[String, float] | 
				
			||||
	bone_poses: Dictionary, # Dictionary[String, Transform3D] | 
				
			||||
) -> void: | 
				
			||||
	var player_id := multiplayer.get_remote_sender_id() | 
				
			||||
	var controller := get_node_or_null(str(player_id)) | 
				
			||||
	if not controller: return | 
				
			||||
 | 
				
			||||
	var model := controller.get_node_or_null("Model") as Node3D | 
				
			||||
	if model: | 
				
			||||
		model.transform = model_transform | 
				
			||||
		functions_blendshapes.apply_animations(model, shape_dict) | 
				
			||||
 | 
				
			||||
	var skeleton := controller._get_model_skeleton() as Skeleton3D | 
				
			||||
	if skeleton: | 
				
			||||
		for bone_name in bone_poses: | 
				
			||||
			var pose: Transform3D = bone_poses[bone_name] | 
				
			||||
			var idx := skeleton.find_bone(bone_name) | 
				
			||||
			if idx != -1: skeleton.set_bone_pose(idx, pose) | 
				
			||||
 | 
				
			||||
func _process(_delta: float) -> void: | 
				
			||||
	if multiplayer.get_peers().size() == 0: return | 
				
			||||
 | 
				
			||||
	var model    := get_model() | 
				
			||||
	var skeleton := get_skeleton() | 
				
			||||
	var media_pipe_ctrl = $"../MediaPipeController" | 
				
			||||
	if (not model) or (not skeleton) or (not media_pipe_ctrl): return | 
				
			||||
 | 
				
			||||
	var shape_dict = media_pipe_ctrl.blend_shape_last_values | 
				
			||||
	var bone_poses = {} | 
				
			||||
	for idx in skeleton.get_bone_count(): | 
				
			||||
		var bone_name = skeleton.get_bone_name(idx) | 
				
			||||
		var bone_pose = skeleton.get_bone_pose(idx) | 
				
			||||
		bone_poses[bone_name] = bone_pose | 
				
			||||
	update.rpc(model.transform, shape_dict, bone_poses) | 
				
			||||
@ -0,0 +1,6 @@ | 
				
			||||
[gd_scene load_steps=2 format=3 uid="uid://bjoevawpt2pnf"] | 
				
			||||
 | 
				
			||||
[ext_resource type="Script" path="res://Mods/copyMultiplayer/copyMultiplayer.gd" id="1_e1a7a"] | 
				
			||||
 | 
				
			||||
[node name="copyMultiplayer" type="Node"] | 
				
			||||
script = ExtResource("1_e1a7a") | 
				
			||||
					Loading…
					
					
				
		Reference in new issue