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