|
|
|
class_name SyncController
|
|
|
|
extends Area3D
|
|
|
|
|
|
|
|
@export var shape: CollisionShape3D
|
|
|
|
|
|
|
|
var peer_id: int
|
|
|
|
var module: copyMultiplayer
|
|
|
|
var model_controller: ModelController
|
|
|
|
|
|
|
|
var nickname: String
|
|
|
|
|
|
|
|
var version: int = -1
|
|
|
|
var bone_lookup: Array[String]
|
|
|
|
var blendshape_lookup: Array[NodePath]
|
|
|
|
|
|
|
|
var model_name : String
|
|
|
|
var model : Node
|
|
|
|
var skeleton : Skeleton3D
|
|
|
|
var anim_player : AnimationPlayer
|
|
|
|
var anim_root : Node
|
|
|
|
var settings : PlayerSettings
|
|
|
|
|
|
|
|
var is_dragging := false
|
|
|
|
var drag_current: Vector3
|
|
|
|
|
|
|
|
# Reusable buffer to write data for synchronizing models.
|
|
|
|
static var write_stream: StreamBuffer = StreamBuffer.with_capacity(2048)
|
|
|
|
|
|
|
|
func _ready() -> void:
|
|
|
|
peer_id = name.to_int()
|
|
|
|
module = get_parent()
|
|
|
|
model_controller = ModelController.new()
|
|
|
|
model_controller.name = "ModelController"
|
|
|
|
add_child(model_controller)
|
|
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
|
|
update_collision_shape_position()
|
|
|
|
handle_dragging()
|
|
|
|
|
|
|
|
func _exit_tree() -> void:
|
|
|
|
if settings: settings.queue_free()
|
|
|
|
|
|
|
|
func get_display_name() -> String:
|
|
|
|
if nickname: return "Player '%s' (%d)" % [ nickname, peer_id ]
|
|
|
|
else: return "Player (%d)" % peer_id
|
|
|
|
|
|
|
|
func change_nickname(new_nickname: String) -> void:
|
|
|
|
new_nickname = new_nickname.strip_edges()
|
|
|
|
if new_nickname == "": return # Ignore empty nicknames.
|
|
|
|
if new_nickname == nickname: return # Ignore unchanged nicknames.
|
|
|
|
module.print_log("%s is now known as '%s'" % [ get_display_name(), new_nickname ])
|
|
|
|
nickname = new_nickname
|
|
|
|
|
|
|
|
if !settings:
|
|
|
|
settings = module.new_player_settings()
|
|
|
|
settings.on_transform_changed(transform)
|
|
|
|
settings.value_changed.connect(func(value): transform = value)
|
|
|
|
settings.set_nickname(nickname)
|
|
|
|
|
|
|
|
## Attempts to change the model of this player.
|
|
|
|
func change_model(
|
|
|
|
new_version: int,
|
|
|
|
new_bone_lookup: Array[String],
|
|
|
|
new_blendshape_lookup: Array[NodePath],
|
|
|
|
filename: String,
|
|
|
|
) -> void:
|
|
|
|
# These should be safe to update even if the model doesn't load.
|
|
|
|
# We just need to stay in sync with the lookup tables.
|
|
|
|
version = new_version
|
|
|
|
bone_lookup = new_bone_lookup
|
|
|
|
blendshape_lookup = new_blendshape_lookup
|
|
|
|
|
|
|
|
if not filename.is_valid_filename():
|
|
|
|
module.print_log("ERROR: '%s' is not a valid file name!" % filename)
|
|
|
|
return
|
|
|
|
var full_path := module.cache.path_join(filename)
|
|
|
|
if not FileAccess.file_exists(full_path):
|
|
|
|
module.print_log("%s wanted to switch to '%s', but it doesn't exist, skipping" % [ get_display_name(), filename ])
|
|
|
|
return
|
|
|
|
if not model_controller.load_vrm(full_path):
|
|
|
|
module.print_log("ERROR: Model '%s' could not be loaded!" % filename)
|
|
|
|
return
|
|
|
|
|
|
|
|
model_name = filename
|
|
|
|
model = model_controller.get_node_or_null("Model")
|
|
|
|
skeleton = model_controller._get_model_skeleton()
|
|
|
|
anim_player = model.find_child("AnimationPlayer", false, false)
|
|
|
|
anim_root = anim_player.get_node(anim_player.root_node)
|
|
|
|
|
|
|
|
recalculate_collision_shape()
|
|
|
|
|
|
|
|
module.print_log("%s switched to '%s'" % [ get_display_name(), filename ])
|
|
|
|
|
|
|
|
func sync_model_animation(
|
|
|
|
current_version: int,
|
|
|
|
uncompressed_length: int,
|
|
|
|
buffer: PackedByteArray,
|
|
|
|
) -> void:
|
|
|
|
if version != current_version: return
|
|
|
|
if (not model) or (not skeleton) or (not anim_root): return
|
|
|
|
|
|
|
|
var uncompressed_buffer := buffer.decompress(uncompressed_length, FileAccess.COMPRESSION_ZSTD);
|
|
|
|
var stream := StreamBuffer.from_buffer(uncompressed_buffer)
|
|
|
|
|
|
|
|
model.transform = stream.read_transform16()
|
|
|
|
|
|
|
|
var all_bones: Array[Dictionary] = []
|
|
|
|
var all_blendshapes: Array[Dictionary] = []
|
|
|
|
|
|
|
|
# We filter out bones at rest and blendshapes at zero, so this
|
|
|
|
# is initializing all_bones/blendshapes to the default values.
|
|
|
|
for bone_name in bone_lookup:
|
|
|
|
var bone_idx := skeleton.find_bone(bone_name)
|
|
|
|
var bone_rest := skeleton.get_bone_rest(bone_idx)
|
|
|
|
all_bones.append({ name = bone_name, idx = bone_idx, pose = bone_rest })
|
|
|
|
for anim_path in blendshape_lookup:
|
|
|
|
all_blendshapes.append({ path = anim_path, value = 0.0 })
|
|
|
|
|
|
|
|
# 256 bones (and blendshapes) should be enough, right?
|
|
|
|
for i in stream.read_uint8():
|
|
|
|
var lookup := stream.read_uint8()
|
|
|
|
all_bones[lookup].pose = stream.read_bone_pose(all_bones[lookup].pose)
|
|
|
|
for i in stream.read_uint8():
|
|
|
|
var lookup := stream.read_uint8()
|
|
|
|
all_blendshapes[lookup].value = stream.read_range16()
|
|
|
|
|
|
|
|
# Apply all the values to bones / blendshapes.
|
|
|
|
for bone in all_bones:
|
|
|
|
if bone.idx == -1: continue # Different model might not have this bone.
|
|
|
|
skeleton.set_bone_pose(bone.idx, bone.pose)
|
|
|
|
for blendshape in all_blendshapes:
|
|
|
|
var anim_node := anim_root.get_node_or_null(blendshape.path)
|
|
|
|
if not anim_node: continue # Different model might not have this node.
|
|
|
|
anim_node.set("blend_shapes/" + blendshape.path.get_subname(0), blendshape.value)
|
|
|
|
|
|
|
|
@warning_ignore("shadowed_variable")
|
|
|
|
static func send_model_animation(module: copyMultiplayer) -> void:
|
|
|
|
# Check if there's other players we're connected to.
|
|
|
|
if module.multiplayer.get_peers().size() == 0: return
|
|
|
|
|
|
|
|
var model := module.get_model()
|
|
|
|
var skeleton := module.get_skeleton()
|
|
|
|
var anim_player := model.find_child("AnimationPlayer", false, false)
|
|
|
|
var anim_root := anim_player.get_node_or_null(anim_player.root_node)
|
|
|
|
if (not model) or (not skeleton) or (not anim_root): return
|
|
|
|
|
|
|
|
write_stream.write_transform16(model.transform)
|
|
|
|
|
|
|
|
# Pre-filter any bones that are at rest / blendshapes that are at zero.
|
|
|
|
# Unless most bones / blendshapes are active, this should reduce packet size.
|
|
|
|
var restless_bones := []
|
|
|
|
var active_blendshapes := []
|
|
|
|
|
|
|
|
for i in module.bone_lookup.size():
|
|
|
|
var bone_name := module.bone_lookup[i]
|
|
|
|
var bone_idx := skeleton.find_bone(bone_name)
|
|
|
|
var bone_pose := skeleton.get_bone_pose(bone_idx)
|
|
|
|
var bone_rest := skeleton.get_bone_rest(bone_idx)
|
|
|
|
if not bone_pose.is_equal_approx(bone_rest):
|
|
|
|
restless_bones.append({ lookup = i, pose = bone_pose, rest = bone_rest })
|
|
|
|
|
|
|
|
for i in module.blendshape_lookup.size():
|
|
|
|
var anim_path := module.blendshape_lookup[i]
|
|
|
|
var anim_node := anim_root.get_node_or_null(anim_path)
|
|
|
|
var value: float = anim_node.get("blend_shapes/" + anim_path.get_subname(0))
|
|
|
|
if not is_zero_approx(value):
|
|
|
|
active_blendshapes.append({ lookup = i, value = value })
|
|
|
|
|
|
|
|
write_stream.write_uint8(restless_bones.size())
|
|
|
|
for bone in restless_bones:
|
|
|
|
write_stream.write_uint8(bone.lookup)
|
|
|
|
write_stream.write_bone_pose(bone.pose, bone.rest)
|
|
|
|
|
|
|
|
write_stream.write_uint8(active_blendshapes.size())
|
|
|
|
for blendshape in active_blendshapes:
|
|
|
|
write_stream.write_uint8(blendshape.lookup)
|
|
|
|
write_stream.write_range16(blendshape.value)
|
|
|
|
|
|
|
|
# The compression still helps, so we'll keep it for now.
|
|
|
|
var compressed_buffer := write_stream.slice().compress(FileAccess.COMPRESSION_ZSTD);
|
|
|
|
# Uncomment this to see packet size. Can we hit < 256 bytes?
|
|
|
|
# module.set_status("Packet size: %d bytes (%d uncompressed)" % [ compressed_buffer.size(), write_stream.size ])
|
|
|
|
module.sync_model_animation.rpc(module.version, write_stream.size, compressed_buffer)
|
|
|
|
write_stream.clear()
|
|
|
|
|
|
|
|
|
|
|
|
# Updates the collision shapes used for allowing the player model to be
|
|
|
|
# repositioned based on the current model's skeleton. This functionality
|
|
|
|
# is only enabled when the "Players" tab is selected.
|
|
|
|
func recalculate_collision_shape() -> void:
|
|
|
|
var head_y := _get_bone_position("Head").y
|
|
|
|
var left_foot_y := _get_bone_position("LeftFoot").y
|
|
|
|
var right_foot_y := _get_bone_position("RightFoot").y
|
|
|
|
var avg_foot_y := (left_foot_y + right_foot_y) / 2
|
|
|
|
var shoulder_x := _get_bone_position("LeftUpperArm").x
|
|
|
|
|
|
|
|
var capsule := CapsuleShape3D.new()
|
|
|
|
capsule.height = (head_y - avg_foot_y) * 1.5
|
|
|
|
capsule.radius = shoulder_x * 1.75
|
|
|
|
shape.shape = capsule
|
|
|
|
|
|
|
|
func update_collision_shape_position() -> void:
|
|
|
|
if not skeleton: return
|
|
|
|
var head := _get_bone_position("Head")
|
|
|
|
var left_foot := _get_bone_position("LeftFoot")
|
|
|
|
var right_foot := _get_bone_position("RightFoot")
|
|
|
|
var avg_foot := (left_foot + right_foot) / 2
|
|
|
|
shape.global_position = skeleton.global_position + (head + avg_foot) / 2
|
|
|
|
# FIXME: This doesn't consider rotation or scale.
|
|
|
|
|
|
|
|
func _get_bone_position(bone_name: String) -> Vector3:
|
|
|
|
var idx := skeleton.find_bone(bone_name)
|
|
|
|
var pose := skeleton.get_bone_global_pose(idx)
|
|
|
|
return pose.origin
|
|
|
|
|
|
|
|
|
|
|
|
func _input_event(_camera: Node, event: InputEvent,
|
|
|
|
pos: Vector3, _normal: Vector3, _idx: int) -> void:
|
|
|
|
if (module.can_move_players
|
|
|
|
&& event is InputEventMouseButton
|
|
|
|
&& event.button_index == MOUSE_BUTTON_LEFT
|
|
|
|
&& event.pressed):
|
|
|
|
is_dragging = true
|
|
|
|
drag_current = pos
|
|
|
|
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
|
|
if (is_dragging
|
|
|
|
&& event is InputEventMouseButton
|
|
|
|
&& event.button_index == MOUSE_BUTTON_LEFT
|
|
|
|
&& not event.pressed):
|
|
|
|
is_dragging = false
|
|
|
|
get_viewport().set_input_as_handled()
|
|
|
|
|
|
|
|
func handle_dragging() -> void:
|
|
|
|
if not is_dragging: return
|
|
|
|
|
|
|
|
var camera := get_viewport().get_camera_3d()
|
|
|
|
var mouse := get_viewport().get_mouse_position()
|
|
|
|
var origin := camera.project_ray_origin(mouse)
|
|
|
|
var dir := camera.project_ray_normal(mouse)
|
|
|
|
|
|
|
|
if dir.z == 0: return
|
|
|
|
var distance := (drag_current.z - origin.z) / dir.z
|
|
|
|
var target := origin + dir * distance
|
|
|
|
|
|
|
|
position += target - drag_current
|
|
|
|
drag_current = target
|
|
|
|
|
|
|
|
sortof_face_the_camera()
|
|
|
|
settings.on_transform_changed(transform)
|
|
|
|
|
|
|
|
# FIXME: Kind of a hack, find a better way.
|
|
|
|
## Rotates the model a little bit towards the camera
|
|
|
|
## so it doesn't appear to be staring into nowhere.
|
|
|
|
func sortof_face_the_camera() -> void:
|
|
|
|
var camera := get_viewport().get_camera_3d()
|
|
|
|
var from_2d := Vector2(global_position.x, global_position.z)
|
|
|
|
var to_2d := Vector2(camera.global_position.x, camera.global_position.z)
|
|
|
|
rotation.y = -from_2d.angle_to_point(to_2d) + TAU/4
|