Initial commit

main
copygirl 3 weeks ago
commit 98d4f94126
  1. 0
      Docs/.gdignore
  2. BIN
      Docs/screenshot.png
  3. 24
      README.md
  4. 20
      Resources/copy_multiplayer_settings.gd
  5. 107
      Resources/copy_multiplayer_settings.tscn
  6. BIN
      Resources/hidden.png
  7. 34
      Resources/hidden.png.import
  8. BIN
      Resources/visible.png
  9. 34
      Resources/visible.png.import
  10. 238
      copyMultiplayer.gd
  11. 6
      copyMultiplayer.tscn

Binary file not shown.

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.
![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

@ -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"]

Binary file not shown.

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

Binary file not shown.

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…
Cancel
Save