commit 98d4f94126a56e729f0fe71ead8cfa3347609d54 Author: copygirl Date: Sun Dec 8 00:09:12 2024 +0100 Initial commit diff --git a/Docs/.gdignore b/Docs/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/Docs/screenshot.png b/Docs/screenshot.png new file mode 100644 index 0000000..ef9f2a7 Binary files /dev/null and b/Docs/screenshot.png differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a38770 --- /dev/null +++ b/README.md @@ -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. + +![Screenshot](Docs/screenshot.png) + +## 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 diff --git a/Resources/copy_multiplayer_settings.gd b/Resources/copy_multiplayer_settings.gd new file mode 100644 index 0000000..2d33668 --- /dev/null +++ b/Resources/copy_multiplayer_settings.gd @@ -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 diff --git a/Resources/copy_multiplayer_settings.tscn b/Resources/copy_multiplayer_settings.tscn new file mode 100644 index 0000000..7e0582c --- /dev/null +++ b/Resources/copy_multiplayer_settings.tscn @@ -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"] diff --git a/Resources/hidden.png b/Resources/hidden.png new file mode 100644 index 0000000..7997f1d Binary files /dev/null and b/Resources/hidden.png differ diff --git a/Resources/hidden.png.import b/Resources/hidden.png.import new file mode 100644 index 0000000..7eacbc5 --- /dev/null +++ b/Resources/hidden.png.import @@ -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 diff --git a/Resources/visible.png b/Resources/visible.png new file mode 100644 index 0000000..f73ee23 Binary files /dev/null and b/Resources/visible.png differ diff --git a/Resources/visible.png.import b/Resources/visible.png.import new file mode 100644 index 0000000..7cf983d --- /dev/null +++ b/Resources/visible.png.import @@ -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 diff --git a/copyMultiplayer.gd b/copyMultiplayer.gd new file mode 100644 index 0000000..ac18fcb --- /dev/null +++ b/copyMultiplayer.gd @@ -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) diff --git a/copyMultiplayer.tscn b/copyMultiplayer.tscn new file mode 100644 index 0000000..cbb19da --- /dev/null +++ b/copyMultiplayer.tscn @@ -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")