Files
Emberwild/scripts/net/avatar.gd

179 lines
6.1 KiB
GDScript

extends Node3D
## Another player, as you see them: their body, the item in their hand and a
## name tag, moved smoothly from the server's snapshots.
##
## Snapshots arrive ~20 times a second, some late and some out of order. The
## avatar is drawn INTERP_DELAY behind the newest data and interpolates
## between the two snapshots either side of that moment, so it moves smoothly
## with jitter. If data stops it carries on briefly along its last velocity
## (up to MAX_EXTRAPOLATE), then holds still.
const CharacterMesh = preload("res://scripts/net/character_mesh.gd")
const MAX_EXTRAPOLATE := 0.25
const TAG_FADE := Vector2(25.0, 40.0) ## name tags fade out over this distance range
enum Anim { IDLE, WALK, RUN, SWIM, AIR }
var peer := 0
var info: Dictionary = {}
var anim := Anim.IDLE
var held := ""
var tag: Label3D
var hitbox: AnimatableBody3D
var _buf: Array = [] ## [t, pos, yaw, pitch, anim, held], oldest first
var _body: Node3D
var _legs: Array[Node3D] = []
var _arms: Array[Node3D] = []
var _held_mi: MeshInstance3D
var _phase := 0.0
var _speed := 0.0
var _last_pos := Vector3.INF
func setup(p: int, d: Dictionary) -> void:
peer = p
info = d
name = "Avatar_%d" % p
var parts: Dictionary = CharacterMesh.build(d.get("appearance", {}))
var mat := ItemDB.item_material()
_body = Node3D.new()
add_child(_body)
var mi := MeshInstance3D.new()
mi.mesh = parts.body
mi.material_override = mat
_body.add_child(mi)
for pair in [[parts.hips, parts.leg, _legs], [parts.shoulders, parts.arm, _arms]]:
for pos: Vector3 in pair[0]:
var pivot := Node3D.new()
pivot.position = pos
var limb := MeshInstance3D.new()
limb.mesh = pair[1]
limb.material_override = mat
pivot.add_child(limb)
_body.add_child(pivot)
pair[2].append(pivot)
_held_mi = MeshInstance3D.new()
_held_mi.material_override = mat
_held_mi.position = parts.hand
_held_mi.rotation = Vector3(-PI * 0.5, 0, 0) # item handles run along +Y; point them forward
_held_mi.scale = Vector3.ONE * 0.85
_arms[1].add_child(_held_mi)
# A hitbox for swinging at them when the server has PvP on (hands.gd
# looks for it; the server decides what the hit does). Layer 5 (value
# 16): nothing walks into it.
hitbox = AnimatableBody3D.new()
hitbox.sync_to_physics = false
hitbox.collision_layer = 16
hitbox.collision_mask = 0
hitbox.set_meta("avatar_peer", p)
var cs := CollisionShape3D.new()
var cap := CapsuleShape3D.new()
cap.radius = 0.4
cap.height = 1.8
cs.shape = cap
cs.position = Vector3(0, 0.9, 0)
hitbox.add_child(cs)
add_child(hitbox)
tag = Label3D.new()
tag.text = String(d.get("name", "?"))
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
tag.position = Vector3(0, 2.12, 0)
tag.font_size = 44
tag.outline_size = 10
tag.pixel_size = 0.004
tag.modulate = Color(1, 0.97, 0.88)
tag.outline_modulate = Color(0, 0, 0, 0.8)
tag.no_depth_test = false # walls and hills hide it
tag.double_sided = true
add_child(tag)
## A snapshot for this player at server time `t`.
func push(t: float, pos: Vector3, yaw: float, pitch: float, a: int, h: String) -> void:
if not _buf.is_empty() and t <= _buf[-1][0]:
# Late or duplicate: slot it in order (or drop a duplicate).
for i in range(_buf.size()):
if absf(_buf[i][0] - t) < 0.0001:
return
if _buf[i][0] > t:
_buf.insert(i, [t, pos, yaw, pitch, a, h])
return
_buf.append([t, pos, yaw, pitch, a, h])
if _buf.size() == 1 and is_inside_tree():
global_position = pos # first sighting: appear in place, don't slide in
rotation.y = yaw
## Where to draw the avatar at server time `rt` (and animate it).
func update_at(rt: float, dt: float) -> void:
if _buf.is_empty():
return
# Forget old data, keeping one sample before `rt`.
while _buf.size() > 2 and _buf[1][0] < rt - 1.0:
_buf.pop_front()
var s: Array = _sample(rt)
var p: Vector3 = s[0]
global_position = p
rotation.y = s[1]
anim = s[2]
if s[3] != held:
held = s[3]
_held_mi.mesh = ItemDB.mesh_for(held) if not held.is_empty() and not ItemDB.get_item(held).has("place") else null
_animate(p, dt)
_fade_tag()
## [pos, yaw, anim, held] at time `rt`.
func _sample(rt: float) -> Array:
var first: Array = _buf[0]
if rt <= first[0]:
return [first[1], first[2], first[4], first[5]]
for i in range(_buf.size() - 1):
var a: Array = _buf[i]
var b: Array = _buf[i + 1]
if rt <= b[0]:
var k: float = (rt - a[0]) / maxf(b[0] - a[0], 0.0001)
return [(a[1] as Vector3).lerp(b[1], k), lerp_angle(a[2], b[2], k), b[4] if k > 0.5 else a[4], b[5]]
# Past the newest snapshot: carry on along the last velocity, briefly.
var last: Array = _buf[-1]
if _buf.size() >= 2:
var prev: Array = _buf[-2]
var span: float = maxf(last[0] - prev[0], 0.001)
var vel: Vector3 = ((last[1] as Vector3) - prev[1]) / span
var ahead := minf(rt - last[0], MAX_EXTRAPOLATE)
return [(last[1] as Vector3) + vel * ahead, last[2], last[4], last[5]]
return [last[1], last[2], last[4], last[5]]
func _animate(p: Vector3, dt: float) -> void:
if _last_pos != Vector3.INF and dt > 0.0:
var d := Vector2(p.x - _last_pos.x, p.z - _last_pos.z).length() / dt
_speed = lerpf(_speed, d, minf(dt * 10.0, 1.0))
_last_pos = p
_phase += dt * (2.0 + _speed * 1.6)
var swing := clampf(_speed / 5.0, 0.0, 1.0) * (0.75 if anim == Anim.RUN else 0.55)
var lean := 0.0
match anim:
Anim.SWIM:
lean = 1.2
swing = 0.35
Anim.AIR:
swing = 0.15
Anim.RUN:
lean = 0.12
_body.rotation.x = lerpf(_body.rotation.x, -lean, minf(dt * 6.0, 1.0))
for i in range(2):
var side := 1.0 if i == 0 else -1.0
_legs[i].rotation.x = sin(_phase) * swing * side
_arms[i].rotation.x = -sin(_phase) * swing * side * 0.8
if anim == Anim.SWIM:
_arms[0].rotation.x = -PI * 0.6 + sin(_phase * 1.5) * 0.6
_arms[1].rotation.x = -PI * 0.6 - sin(_phase * 1.5) * 0.6
elif not held.is_empty():
_arms[1].rotation.x = -0.5 + sin(_phase) * swing * 0.3 # hold the item out a little
_body.position.y = absf(sin(_phase)) * 0.03 * clampf(_speed / 5.0, 0.0, 1.0)
func _fade_tag() -> void:
var cam := get_viewport().get_camera_3d() if is_inside_tree() else null
if cam == null:
return
var d := cam.global_position.distance_to(global_position)
tag.modulate.a = 1.0 - smoothstep(TAG_FADE.x, TAG_FADE.y, d)
tag.visible = tag.modulate.a > 0.01