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