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