class_name copyMultiplayer extends Mod_Base @export var cache := "" @export var nickname := "" @export var address := "" @export var port := 52410 var main_controller: ModelController var main_stats : PlayerStats var ping_update_timer: Timer ## Hardcoded list of bone names that will get syncronized. var tracked_bones: Array[String] var version: int = -1 var bone_lookup: Array[String] = [] var blendshape_lookup: Array[NodePath] = [] # TODO: Remember the position of each remote player by name. ## Whether the position of remote players can be ajusted ## by clicking their controller's collider and dragging. var can_move_players := false # FIXME: There is an edge case where you can load the settings while connected, # resulting in out-of-sync information with connected players, but so far # I don't believe this should be a source of issues. func _ready() -> void: # FIXME: Hardcoded way to get the main model controller. main_controller = $"/root/SnekStudio_Main/ModelController" main_stats = new_player_stats() # FIXME: This is just thrown together. Dunno if this is an accurate list. # TODO: Allow specifying additional bones, with the help of a hierachical list of existing bones in the model. tracked_bones = [ "Hips", "Spine", "Chest", "UpperChest", "Neck", "Head" ] for side in [ "Left", "Right" ]: # Eyes tracked_bones.append("%sEye" % side) # Legs for bone in [ "UpperLeg", "LowerLeg", "Foot" ]: tracked_bones.append("%s%s" % [ side, bone ]) # Arms for bone in [ "Shoulder", "UpperArm", "LowerArm", "Hand" ]: tracked_bones.append("%s%s" % [ side, bone ]) # Fingers for phalange in [ "Metacarpal", "Proximal", "Distal" ]: tracked_bones.append("%sThumb%s" % [ side, phalange ]) for finger in [ "Index", "Middle", "Ring", "Little" ]: for phalange in [ "Proximal", "Intermediate", "Distal" ]: tracked_bones.append("%s%s%s" % [ side, finger, phalange ]) setup_setting_widget("connect" , "address" , true ) setup_setting_widget("connect" , "port" , true ) setup_setting_widget("settings", "cache" , true ) setup_setting_widget("settings", "nickname", false) setup_button_connections() # Filter whitespace characters from nickname before saving it to "nickname" field. var nickname_widget := get_settings_window().settings_nickname nickname_widget.text_changed.connect(func(new_text): modify_setting("nickname", new_text.strip_edges())) nickname_widget.text_submitted.connect(func(_new_text): nickname_widget.text = nickname; main_stats.set_nickname(nickname)) nickname_widget.focus_exited.connect(func(): nickname_widget.text = nickname) multiplayer.peer_connected .connect(on_peer_connected) multiplayer.peer_disconnected .connect(on_peer_disconnected) multiplayer.connected_to_server.connect(on_connected_to_server) multiplayer.connection_failed .connect(on_connection_failed) multiplayer.server_disconnected.connect(on_server_disconnected) func _exit_tree() -> void: multiplayer.multiplayer_peer.close() func _create_settings_window() -> Control: return load("res://Mods/copyMultiplayer/Scenes/copy_multiplayer_settings.tscn").instantiate() # Override base method to provide type hint. func get_settings_window() -> copyMultiplayerSettings: return super.get_settings_window() func setup_setting_widget(category: String, setting: String, setup_events: bool) -> void: var settings := get_settings_window() var widget: Control = settings.get("%s_%s" % [ category, setting ]) _settings_properties.append({ name = setting, args = { } }) _settings_widgets_by_setting_name[setting] = widget if setup_events: if widget is LineEdit: widget.text_changed.connect( func(text): modify_setting(setting, text)) if widget is SpinBox: widget.value_changed.connect( func(number): modify_setting(setting, roundi(number))) # We don't make any use of these, so ... dummy values? widget.set_meta("default", get(setting)) widget.set_meta("reset_button", { }) func setup_button_connections() -> void: var settings := get_settings_window() settings.connect_join.pressed.connect(on_join_pressed) settings.connect_host.pressed.connect(on_host_pressed) settings.connect_disconnect.pressed.connect(on_disconnect_pressed) func load_after(_old : Dictionary, _new : Dictionary) -> void: main_stats.set_nickname(nickname) var player_settings_scene: PackedScene = load("res://Mods/copyMultiplayer/Scenes/player_settings.tscn") func new_player_settings() -> PlayerSettings: var container = get_settings_window().get_node("Players/VBoxContainer") var result := player_settings_scene.instantiate() container.add_child(result) return result var player_stats_scene: PackedScene = load("res://Mods/copyMultiplayer/Scenes/player_stats.tscn") func new_player_stats() -> PlayerStats: var container = get_settings_window().get_node("Stats/VBoxContainer") var result := player_stats_scene.instantiate() container.add_child(result) return result func on_join_pressed() -> void: var address_widget: LineEdit = get_settings_window().get_node("Connect/VBoxContainer/Join/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 on_host_pressed() -> void: var peer := ENetMultiplayerPeer.new() if peer.create_server(port) == OK: multiplayer.multiplayer_peer = peer update_status() print_log("Opened server") update_enabled_state(true) ping_update_timer = Timer.new() ping_update_timer.timeout.connect(do_update_pings) add_child(ping_update_timer) ping_update_timer.start(1) else: print_log("Unable to open server!") func on_disconnect_pressed() -> void: assert(multiplayer.multiplayer_peer) if multiplayer.is_server(): set_status("") print_log("Closed server") update_enabled_state(false) clear_player_models() ping_update_timer.queue_free() ping_update_timer = null multiplayer.multiplayer_peer.close() var sync_controller_scene: PackedScene = load("res://Mods/copyMultiplayer/Scenes/sync_controller.tscn"); func on_peer_connected(id: int) -> void: var controller := sync_controller_scene.instantiate() controller.name = str(id) add_child(controller) # Send information to the newly connected player about ourselves. # (Technically this doesn't need to be relayed through the server, but oh well.) if nickname: change_nickname.rpc_id(id, nickname) var filename = main_controller._last_loaded_vrm.get_file() if filename.is_valid_filename(): change_model.rpc_id(id, version, bone_lookup, blendshape_lookup, filename) update_status() print_log("%s connected" % controller.get_display_name()) func on_peer_disconnected(id: int) -> void: var controller = get_sync_controller(id) if not controller: return remove_child(controller) controller.queue_free() update_status() print_log("Player %s disconnected" % id) func on_connected_to_server() -> void: print_log("Connected to server") func on_connection_failed() -> void: set_status("") print_log("Connection failed!") update_enabled_state(false) func on_server_disconnected() -> void: set_status("") print_log("Disconnected from server") update_enabled_state(false) clear_player_models() func update_enabled_state(is_online: bool) -> void: var settings := get_settings_window() settings.settings_nickname .editable = !is_online settings.connect_address .editable = !is_online settings.connect_port .editable = !is_online settings.connect_join .disabled = is_online settings.connect_host .disabled = is_online settings.connect_disconnect.disabled = !is_online if not is_online: main_stats.set_ping(0) func update_status() -> void: var num_players := 1 + multiplayer.get_peers().size() var side := "Hosting" if multiplayer.is_server() else "Connected" var s := "s" if num_players != 1 else "" set_status("%s: %d player%s" % [side, num_players, s]) ## Removes all networked player models. func clear_player_models() -> void: for controller in get_children(): if controller is SyncController: remove_child(controller) controller.queue_free() func _process(_delta: float) -> void: SyncController.send_model_animation(self) # TODO: Add a setting to allow moving players even if "Players" tab is not active. # FIXME: Temporary fix until my pull request is accepted or at least the above TODO is implemented. can_move_players = true # var settings: copyMultiplayerSettings = get_settings_window() # can_move_players = settings.is_visible_in_tree() && settings.is_tab_selected("Players") # Called when module is initialized or load_vrm is called. func scene_init() -> void: var model := get_model() var skeleton := get_skeleton() # Do nothing, I guess? Unsure if source of potential bugs. if (not model) or (not skeleton): return # This "version" is used to ensure that sync updates # are not applied to the incorrect lookup tables. version = (version + 1) % 256 bone_lookup.clear() for bone_name in tracked_bones: var bone_idx := skeleton.find_bone(bone_name) if bone_idx == -1: continue bone_lookup.append(bone_name) blendshape_lookup.clear() var anim_player: AnimationPlayer = model.find_child("AnimationPlayer", false, false) for anim_name in anim_player.get_animation_list(): if anim_name == "RESET": continue # Skip RESET animation? var anim := anim_player.get_animation(anim_name) for track_index in anim.get_track_count(): if anim.track_get_type(track_index) == Animation.TYPE_BLEND_SHAPE: blendshape_lookup.append(anim.track_get_path(track_index)) continue if multiplayer.get_peers().size() > 1: var filename = main_controller._last_loaded_vrm.get_file() change_model.rpc(version, bone_lookup, blendshape_lookup, filename) func get_sync_controller(peer_id: int) -> SyncController: return get_node_or_null(str(peer_id)) as SyncController func get_all_sync_controllers() -> Array[SyncController]: var result: Array[SyncController] = [] for controller in get_children(): if controller is SyncController: result.append(controller) return result func get_player_stats(peer_id: int) -> PlayerStats: if peer_id == multiplayer.get_unique_id(): return main_stats var controller := get_sync_controller(peer_id) if controller: return controller.stats return null @rpc("authority", "unreliable_ordered", "call_local") func update_pings(pairs: Array[int]) -> void: for i in range(0, pairs.size(), 2): var peer_id := pairs[i] var ping := pairs[i + 1] var stats := get_player_stats(peer_id) if stats: stats.set_ping(ping) func do_update_pings() -> void: var pairs: Array[int] = [] var server: ENetMultiplayerPeer = multiplayer.multiplayer_peer for peer_id in multiplayer.get_peers(): var peer := server.get_peer(peer_id) var ping := peer.get_statistic(ENetPacketPeer.PEER_ROUND_TRIP_TIME) pairs.append_array([ peer_id, int(ping) ]) update_pings.rpc(pairs) @rpc("any_peer", "reliable") func change_nickname(new_nickname: String) -> void: var peer_id := multiplayer.get_remote_sender_id() var controller := get_sync_controller(peer_id) if controller: controller.change_nickname(new_nickname) @rpc("any_peer", "reliable") func change_model( new_version: int, new_bone_lookup: Array[String], new_blendshape_lookup: Array[NodePath], filename: String, ) -> void: var peer_id := multiplayer.get_remote_sender_id() var controller := get_sync_controller(peer_id) if controller: controller.change_model(new_version, new_bone_lookup, new_blendshape_lookup, filename) @rpc("any_peer", "unreliable_ordered") func sync_model_animation( current_version: int, uncompressed_length: int, buffer: PackedByteArray, ) -> void: var peer_id := multiplayer.get_remote_sender_id() var controller := get_sync_controller(peer_id) if controller: controller.sync_model_animation(current_version, uncompressed_length, buffer)