M2.2: player bodies, name tags, shared island and checked movement
- Joining loads the server's island (seed, spawn, clock); the server generates the same terrain from its seed and keeps a world clock that pauses when empty - Other players are procedural low-poly characters in their chosen colours with name tags (hidden by walls, fading with distance), walk/run/swim animation and held items - Movement: local and instant, sent at 20 Hz stamped with the sender's clock; the server refuses impossible moves (speed, rising, leaving the island, underground) and sends the player back; flagged respawns allowed - Avatars drawn 100 ms behind and interpolated, with brief extrapolation - Online: no pausing, player list in the Esc menu, Disconnect back to the title (with the reason); animals off and world changes local-only until M2.3-M2.5, shown on the HUD - test_net_world: real server + walking and cheating bots at 100 ms +/- 30 ms simulated latency (27 checks)
This commit is contained in:
161
scripts/net/avatar.gd
Normal file
161
scripts/net/avatar.gd
Normal file
@@ -0,0 +1,161 @@
|
||||
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 _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)
|
||||
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
|
||||
69
scripts/net/character_mesh.gd
Normal file
69
scripts/net/character_mesh.gd
Normal file
@@ -0,0 +1,69 @@
|
||||
extends RefCounted
|
||||
## Procedural low-poly player character, coloured by the player's appearance
|
||||
## (identity.gd: tunic, hair, beard). Feet at the origin, facing -Z, 1.8 m
|
||||
## tall to match the player capsule.
|
||||
##
|
||||
## build() returns:
|
||||
## body: torso, belt, head, hair and beard (static)
|
||||
## arm / leg: one limb each, hanging from its pivot down -Y
|
||||
## shoulders / hips: the pivot points (left, right)
|
||||
## hand: where a held item goes, in arm space
|
||||
|
||||
const P = preload("res://scripts/world/proc_mesh.gd")
|
||||
const IM = preload("res://scripts/items/item_meshes.gd")
|
||||
const Identity = preload("res://scripts/net/identity.gd")
|
||||
|
||||
const SKIN := Color(0.86, 0.68, 0.55)
|
||||
const TROUSERS := Color(0.30, 0.25, 0.20)
|
||||
const BOOTS := Color(0.20, 0.14, 0.10)
|
||||
const BELT := Color(0.35, 0.22, 0.12)
|
||||
const EYES := Color(0.08, 0.07, 0.06)
|
||||
|
||||
static var _cache := {}
|
||||
|
||||
static func build(appearance: Dictionary) -> Dictionary:
|
||||
var a := Identity.sanitize_appearance(appearance)
|
||||
var key := "%d_%d_%s" % [a.tunic, a.hair, a.beard]
|
||||
if _cache.has(key):
|
||||
return _cache[key]
|
||||
var tunic: Color = Identity.TUNICS[a.tunic]
|
||||
var hair: Color = Identity.HAIR[a.hair]
|
||||
var r := RandomNumberGenerator.new()
|
||||
r.seed = 7
|
||||
var n := FastNoiseLite.new()
|
||||
n.seed = 7
|
||||
n.frequency = 1.5
|
||||
|
||||
var st := P._begin()
|
||||
IM._box(st, Vector3(0, 1.2, 0), Vector3(0.42, 0.56, 0.24), tunic) # tunic
|
||||
IM._box(st, Vector3(0, 0.93, 0), Vector3(0.44, 0.1, 0.26), P._mul(tunic, 0.85)) # skirt hem
|
||||
IM._box(st, Vector3(0, 1.0, 0), Vector3(0.45, 0.06, 0.27), BELT)
|
||||
IM._box(st, Vector3(0, 1.0, -0.135), Vector3(0.07, 0.05, 0.02), Color(0.6, 0.55, 0.4)) # buckle
|
||||
IM._box(st, Vector3(0, 1.5, 0), Vector3(0.12, 0.06, 0.12), SKIN) # neck
|
||||
P._blob(st, Vector3(0, 1.65, 0), Vector3(0.13, 0.15, 0.14), n, 0.05, SKIN, r, 1) # head
|
||||
for s in [-1.0, 1.0]:
|
||||
IM._box(st, Vector3(s * 0.05, 1.67, -0.13), Vector3(0.03, 0.025, 0.02), EYES)
|
||||
IM._box(st, Vector3(0, 1.62, -0.145), Vector3(0.035, 0.05, 0.03), P._mul(SKIN, 0.92)) # nose
|
||||
P._blob(st, Vector3(0, 1.72, 0.02), Vector3(0.145, 0.1, 0.15), n, 0.15, hair, r, 1) # hair
|
||||
IM._box(st, Vector3(0, 1.66, 0.11), Vector3(0.24, 0.14, 0.06), hair) # back of the head
|
||||
if a.beard:
|
||||
IM._box(st, Vector3(0, 1.54, -0.115), Vector3(0.2, 0.13, 0.07), hair)
|
||||
IM._box(st, Vector3(0, 1.47, -0.1), Vector3(0.12, 0.06, 0.05), hair)
|
||||
var body := P._commit(st)
|
||||
|
||||
st = P._begin()
|
||||
IM._box(st, Vector3(0, -0.2, 0), Vector3(0.12, 0.42, 0.13), tunic)
|
||||
IM._box(st, Vector3(0, -0.47, 0), Vector3(0.09, 0.12, 0.1), SKIN)
|
||||
var arm := P._commit(st)
|
||||
|
||||
st = P._begin()
|
||||
IM._box(st, Vector3(0, -0.4, 0), Vector3(0.14, 0.78, 0.15), TROUSERS)
|
||||
IM._box(st, Vector3(0, -0.85, -0.03), Vector3(0.15, 0.14, 0.22), BOOTS)
|
||||
var leg := P._commit(st)
|
||||
|
||||
var out := {"body": body, "arm": arm, "leg": leg,
|
||||
"shoulders": [Vector3(-0.27, 1.43, 0), Vector3(0.27, 1.43, 0)],
|
||||
"hips": [Vector3(-0.1, 0.92, 0), Vector3(0.1, 0.92, 0)],
|
||||
"hand": Vector3(0, -0.5, -0.02)}
|
||||
_cache[key] = out
|
||||
return out
|
||||
@@ -22,14 +22,28 @@ signal joined ## the server welcomed us
|
||||
signal join_failed(reason: String)
|
||||
signal left(reason: String) ## disconnected after joining
|
||||
signal players_changed
|
||||
signal player_joined(name: String) ## someone else came in (clients)
|
||||
signal player_left(name: String)
|
||||
## Everyone's positions at server time `t`: [[peer, pos, yaw, pitch, anim, held], ...].
|
||||
signal snapshot(t: float, states: Array)
|
||||
signal corrected(pos: Vector3) ## the server refused a move: go back here
|
||||
signal clock(day: int, time: float) ## the server's time of day
|
||||
|
||||
const Identity = preload("res://scripts/net/identity.gd")
|
||||
const ServerConfig = preload("res://scripts/net/server_config.gd")
|
||||
|
||||
const PROTOCOL := 1
|
||||
const PROTOCOL := 2
|
||||
const DEFAULT_PORT := 24242
|
||||
const HANDSHAKE_TIMEOUT := 10.0
|
||||
const JOIN_TIMEOUT := 12.0
|
||||
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join
|
||||
const SEND_INTERVAL := 0.05 ## position updates, both ways (20 Hz)
|
||||
const CLOCK_INTERVAL := 10.0
|
||||
## Movement checks: sprinting is 8 m/s, so 9.5 plus a little slack for
|
||||
## packets that arrive bunched together.
|
||||
const MAX_SPEED := 9.5
|
||||
const MAX_RISE := 9.0
|
||||
const MOVE_SLACK := 1.0
|
||||
const SPAWN_TELEPORT_RADIUS := 25.0 ## a flagged teleport (respawning) must land near the spawn
|
||||
|
||||
var state := "offline"
|
||||
var dedicated := false ## started with --server
|
||||
@@ -39,6 +53,26 @@ var server_info: Dictionary = {} ## the server's public settings (clients)
|
||||
## Everyone on the server: peer id -> {"id", "name", "appearance", "admin"}.
|
||||
var players: Dictionary = {}
|
||||
var local_peer := 0
|
||||
## What the server told us about its world: seed, worldlet, spawn, clock.
|
||||
var world_info: Dictionary = {}
|
||||
var last_reason := "" ## why the last join failed or ended (the title shows it)
|
||||
|
||||
## Test hooks: simulated network delay on this client, both directions.
|
||||
var debug_latency_ms := 0.0
|
||||
var debug_jitter_ms := 0.0
|
||||
|
||||
# Server state.
|
||||
var _terrain: Node3D ## the island, regenerated from the seed, for movement checks
|
||||
var _spawn := Vector3.ZERO
|
||||
var _states: Dictionary = {} ## peer -> {"pos", "yaw", "pitch", "anim", "held", "t", "corr_t"}
|
||||
var server_time := 0.0 ## seconds since the server started
|
||||
var _clock_day := 1
|
||||
var _clock_time := 8.0 / 24.0
|
||||
var _send_left := 0.0
|
||||
var _clock_left := 0.0
|
||||
# Client delay queues (debug latency): [release_ms, callable].
|
||||
var _out_q: Array = []
|
||||
var _in_q: Array = []
|
||||
|
||||
## Test hooks (tests/net_bot.gd): sign with the wrong key, or claim another
|
||||
## protocol version.
|
||||
@@ -122,11 +156,20 @@ func start_server(config_path: String, overrides: Dictionary = {}) -> String:
|
||||
return "Already %s" % state
|
||||
config = ServerConfig.open(config_path)
|
||||
var map := {"port": "port", "server-name": "server_name", "max-players": "max_players",
|
||||
"password": "password", "world": "world"}
|
||||
"password": "password", "world": "world", "seed": "seed"}
|
||||
for flag in map:
|
||||
if overrides.has(flag):
|
||||
var v: Variant = overrides[flag]
|
||||
config.overrides[map[flag]] = int(v) if map[flag] in ["port", "max_players"] else String(v)
|
||||
config.overrides[map[flag]] = int(v) if map[flag] in ["port", "max_players", "seed"] else String(v)
|
||||
# The island, from the seed: the same terrain every client generates.
|
||||
_terrain = load("res://scripts/world/terrain.gd").new()
|
||||
_terrain.name = "ServerTerrain"
|
||||
_terrain.noise_seed = int(config.v("seed"))
|
||||
add_child(_terrain)
|
||||
_spawn = _terrain.find_spawn(Vector2.ZERO)
|
||||
server_time = 0.0
|
||||
_clock_day = 1
|
||||
_clock_time = 8.0 / 24.0
|
||||
_peer = ENetMultiplayerPeer.new()
|
||||
var port := int(config.v("port"))
|
||||
# A few spare slots so a full server can still say "server is full".
|
||||
@@ -150,8 +193,15 @@ func stop() -> void:
|
||||
multiplayer.multiplayer_peer = null
|
||||
players.clear()
|
||||
_pending.clear()
|
||||
_states.clear()
|
||||
server_info.clear()
|
||||
world_info.clear()
|
||||
_out_q.clear()
|
||||
_in_q.clear()
|
||||
local_peer = 0
|
||||
if _terrain != null:
|
||||
_terrain.queue_free()
|
||||
_terrain = null
|
||||
if state != "offline":
|
||||
_set_state("offline")
|
||||
|
||||
@@ -163,6 +213,7 @@ func _on_peer_disconnected(id: int) -> void:
|
||||
if not is_server():
|
||||
return
|
||||
_pending.erase(id)
|
||||
_states.erase(id)
|
||||
if players.has(id):
|
||||
var p: Dictionary = players[id]
|
||||
players.erase(id)
|
||||
@@ -176,10 +227,104 @@ func _process(delta: float) -> void:
|
||||
_pending[id].t += delta
|
||||
if _pending[id].t > HANDSHAKE_TIMEOUT:
|
||||
_reject(id, "Took too long to say hello")
|
||||
elif state == "connecting":
|
||||
server_time += delta
|
||||
_tick_clock(delta)
|
||||
_send_left -= delta
|
||||
if _send_left <= 0.0:
|
||||
_send_left = SEND_INTERVAL
|
||||
_broadcast_snapshot()
|
||||
return
|
||||
if state == "connecting":
|
||||
_join_left -= delta
|
||||
if _join_left <= 0.0:
|
||||
_fail("No answer from the server")
|
||||
_run_queue(_out_q)
|
||||
_run_queue(_in_q)
|
||||
|
||||
## The world clock runs while anyone's on (or always, if the server's set
|
||||
## not to pause when empty).
|
||||
func _tick_clock(delta: float) -> void:
|
||||
if players.is_empty() and bool(config.v("pause_when_empty")):
|
||||
return
|
||||
_clock_time += delta / (float(config.v("day_length_minutes")) * 60.0)
|
||||
while _clock_time >= 1.0:
|
||||
_clock_time -= 1.0
|
||||
_clock_day += 1
|
||||
_clock_left -= delta
|
||||
if _clock_left <= 0.0:
|
||||
_clock_left = CLOCK_INTERVAL
|
||||
for peer in players:
|
||||
_s2c_clock.rpc_id(peer, _clock_day, _clock_time)
|
||||
|
||||
func _broadcast_snapshot() -> void:
|
||||
if _states.is_empty():
|
||||
return
|
||||
var arr: Array = []
|
||||
for peer in _states:
|
||||
var s: Dictionary = _states[peer]
|
||||
arr.append([peer, s.pos, s.yaw, s.pitch, s.anim, s.held, s.stamp])
|
||||
for peer in players:
|
||||
_s2c_snapshot.rpc_id(peer, server_time, arr)
|
||||
|
||||
## Where player number `i` starts: around the island's spawn point.
|
||||
func _spawn_for(i: int) -> Vector3:
|
||||
if i == 0:
|
||||
return _spawn
|
||||
var a := i * 2.4
|
||||
return _terrain.find_spawn(Vector2(_spawn.x + cos(a) * 3.0, _spawn.z + sin(a) * 3.0))
|
||||
|
||||
## A position update from a player. Accepted if it's possible from their last
|
||||
## accepted one; otherwise they're sent back there.
|
||||
@rpc("any_peer", "call_remote", "unreliable_ordered")
|
||||
func _c2s_state(pos: Vector3, yaw: float, pitch: float, anim: int, held: String, teleported: bool, client_t: float) -> void:
|
||||
var peer := multiplayer.get_remote_sender_id()
|
||||
if not is_server() or not _states.has(peer):
|
||||
return
|
||||
var s: Dictionary = _states[peer]
|
||||
# When the move happened, in server time: the client's own clock plus a
|
||||
# smoothed offset. Stamping with arrival time instead would bunch moves
|
||||
# into some snapshots and leave others empty, and avatars would surge.
|
||||
var off := server_time - client_t
|
||||
if s.off == INF or absf(off - s.off) > 1.0:
|
||||
s.off = off
|
||||
else:
|
||||
s.off = lerpf(s.off, off, 0.05)
|
||||
var stamp: float = client_t + s.off
|
||||
if stamp <= s.stamp:
|
||||
return # older than what we have
|
||||
var why := move_problem(s.pos, pos, server_time - s.t, teleported)
|
||||
if not why.is_empty():
|
||||
if server_time - s.corr_t > 0.5:
|
||||
s.corr_t = server_time
|
||||
print("[server] moved %s back (%s)" % [players[peer].name, why])
|
||||
_s2c_correct.rpc_id(peer, s.pos)
|
||||
return
|
||||
s.pos = pos
|
||||
s.yaw = wrapf(yaw, -PI, PI)
|
||||
s.pitch = clampf(pitch, -1.5, 1.5)
|
||||
s.anim = clampi(anim, 0, 4)
|
||||
s.held = held if held.length() < 40 and (held.is_empty() or not ItemDB.get_item(held).is_empty()) else ""
|
||||
s.t = server_time
|
||||
s.stamp = stamp
|
||||
|
||||
## Why a move from `from` to `to` over `dt` seconds isn't allowed ("" = fine).
|
||||
func move_problem(from: Vector3, to: Vector3, dt: float, teleported: bool) -> String:
|
||||
if not to.is_finite():
|
||||
return "bad position"
|
||||
dt = clampf(dt, SEND_INTERVAL, 1.0)
|
||||
var flat := Vector2(to.x - from.x, to.z - from.z).length()
|
||||
if teleported:
|
||||
if Vector2(to.x - _spawn.x, to.z - _spawn.z).length() > SPAWN_TELEPORT_RADIUS:
|
||||
return "teleport away from the spawn"
|
||||
elif flat > MAX_SPEED * dt + MOVE_SLACK:
|
||||
return "too fast (%.0f m in %.2f s)" % [flat, dt]
|
||||
elif to.y - from.y > MAX_RISE * dt + MOVE_SLACK:
|
||||
return "rising too fast"
|
||||
if not _terrain.inside_boundary(to.x, to.z, -3.0):
|
||||
return "outside the island"
|
||||
if to.y < _terrain.sample_height(to.x, to.z) - 1.5:
|
||||
return "underground"
|
||||
return ""
|
||||
|
||||
func _reject(peer: int, reason: String) -> void:
|
||||
_pending.erase(peer)
|
||||
@@ -230,10 +375,15 @@ func _c2s_proof(sig: PackedByteArray) -> void:
|
||||
print("[server] %s is the first player, so they're now an admin" % name)
|
||||
var p := {"id": id, "name": name, "appearance": Identity.sanitize_appearance(hello.get("appearance", {})),
|
||||
"admin": config.is_admin(id)}
|
||||
var start := _spawn_for(players.size())
|
||||
players[peer] = p
|
||||
_states[peer] = {"pos": start, "yaw": 0.0, "pitch": 0.0, "anim": 0, "held": "", "t": server_time, "corr_t": -10.0,
|
||||
"stamp": server_time, "off": INF}
|
||||
print("[server] %s joined (id %s%s, %d/%d online)" % [name, id, ", admin" if p.admin else "",
|
||||
players.size(), int(config.v("max_players"))])
|
||||
_s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players})
|
||||
var world := {"seed": int(config.v("seed")), "worldlet": String(config.v("worldlet")), "spawn": start,
|
||||
"island_spawn": _spawn, "day": _clock_day, "time": _clock_time, "server_time": server_time}
|
||||
_s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world})
|
||||
for other in players:
|
||||
if other != peer:
|
||||
_s2c_player_joined.rpc_id(other, peer, p)
|
||||
@@ -306,7 +456,8 @@ func leave() -> void:
|
||||
var was := state
|
||||
stop()
|
||||
if was == "connected":
|
||||
left.emit("You left the server")
|
||||
last_reason = "You left the server"
|
||||
left.emit(last_reason)
|
||||
|
||||
func _on_connected() -> void:
|
||||
var me := get_identity()
|
||||
@@ -326,9 +477,11 @@ func _s2c_challenge(nonce: PackedByteArray) -> void:
|
||||
func _s2c_welcome(d: Dictionary) -> void:
|
||||
local_peer = int(d.you)
|
||||
server_info = d.server
|
||||
world_info = d.get("world", {})
|
||||
players.clear()
|
||||
for k in d.players:
|
||||
players[int(k)] = d.players[k]
|
||||
last_reason = ""
|
||||
_set_state("connected")
|
||||
joined.emit()
|
||||
players_changed.emit()
|
||||
@@ -341,14 +494,61 @@ func _s2c_rejected(reason: String) -> void:
|
||||
func _s2c_player_joined(peer: int, p: Dictionary) -> void:
|
||||
players[peer] = p
|
||||
players_changed.emit()
|
||||
player_joined.emit(String(p.get("name", "?")))
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _s2c_player_left(peer: int) -> void:
|
||||
if not players.has(peer):
|
||||
return
|
||||
var n := String(players[peer].get("name", "?"))
|
||||
players.erase(peer)
|
||||
players_changed.emit()
|
||||
player_left.emit(n)
|
||||
|
||||
@rpc("authority", "call_remote", "unreliable_ordered")
|
||||
func _s2c_snapshot(t: float, states: Array) -> void:
|
||||
_delayed(_in_q, func(): snapshot.emit(t, states))
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _s2c_correct(pos: Vector3) -> void:
|
||||
_delayed(_in_q, func(): corrected.emit(pos))
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func _s2c_clock(day: int, time: float) -> void:
|
||||
world_info.day = day
|
||||
world_info.time = time
|
||||
clock.emit(day, time)
|
||||
|
||||
## Sends our position to the server (net_sync.gd calls this ~20 times a second).
|
||||
func send_state(pos: Vector3, yaw: float, pitch: float, anim: int, held: String, teleported: bool = false) -> void:
|
||||
if state != "connected":
|
||||
return
|
||||
var at := Time.get_ticks_usec() / 1000000.0 # when this happened, by our clock
|
||||
_delayed(_out_q, func():
|
||||
if state == "connected":
|
||||
_c2s_state.rpc_id(1, pos, yaw, pitch, anim, held, teleported, at))
|
||||
|
||||
## Runs `f` now, or after the simulated delay when testing with latency.
|
||||
func _delayed(q: Array, f: Callable) -> void:
|
||||
if debug_latency_ms <= 0.0:
|
||||
f.call()
|
||||
return
|
||||
var half := debug_latency_ms * 0.5 + randf_range(-debug_jitter_ms, debug_jitter_ms) * 0.5
|
||||
var at := Time.get_ticks_msec() + maxf(half, 0.0)
|
||||
# Keep the queue in send order (networks rarely reorder within a stream).
|
||||
if not q.is_empty() and at < q[-1][0]:
|
||||
at = q[-1][0]
|
||||
q.append([at, f])
|
||||
|
||||
func _run_queue(q: Array) -> void:
|
||||
var now := Time.get_ticks_msec()
|
||||
while not q.is_empty() and q[0][0] <= now:
|
||||
var f: Callable = q.pop_front()[1]
|
||||
f.call()
|
||||
|
||||
func _fail(reason: String) -> void:
|
||||
stop()
|
||||
last_reason = reason
|
||||
join_failed.emit(reason)
|
||||
|
||||
func _on_connection_failed() -> void:
|
||||
@@ -359,9 +559,11 @@ func _on_server_disconnected() -> void:
|
||||
var reason := _reject_reason
|
||||
stop()
|
||||
if was == "connecting":
|
||||
join_failed.emit(reason if not reason.is_empty() else "The server hung up")
|
||||
last_reason = reason if not reason.is_empty() else "The server hung up"
|
||||
join_failed.emit(last_reason)
|
||||
else:
|
||||
left.emit(reason if not reason.is_empty() else "The server closed the connection")
|
||||
last_reason = reason if not reason.is_empty() else "The server closed the connection"
|
||||
left.emit(last_reason)
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_PREDELETE:
|
||||
|
||||
132
scripts/net/net_sync.gd
Normal file
132
scripts/net/net_sync.gd
Normal file
@@ -0,0 +1,132 @@
|
||||
extends Node
|
||||
## In a multiplayer world: sends where you are, shows where everyone else is.
|
||||
## Added by world.gd when the game is connected to a server.
|
||||
##
|
||||
## - Your own movement stays instant (you move locally and tell the server).
|
||||
## The server checks each move; if it refuses one it sends you back to your
|
||||
## last good position.
|
||||
## - Other players are avatars (avatar.gd), drawn INTERP_DELAY behind the
|
||||
## server's clock and interpolated between its snapshots.
|
||||
## - The time of day follows the server, so everyone shares the same sky
|
||||
## (weather follows from the seed and the time).
|
||||
|
||||
const AvatarScript = preload("res://scripts/net/avatar.gd")
|
||||
const INTERP_DELAY := 0.1
|
||||
|
||||
var world: Node3D
|
||||
var player: CharacterBody3D
|
||||
var avatars: Dictionary = {} ## peer -> avatar.gd
|
||||
var corrections := 0
|
||||
var _send_left := 0.0
|
||||
var _offset := INF ## server time minus local time (smoothed)
|
||||
var _teleport_next := false
|
||||
var _root: Node3D
|
||||
|
||||
func setup(w: Node3D) -> void:
|
||||
world = w
|
||||
player = w.player
|
||||
_root = Node3D.new()
|
||||
_root.name = "Avatars"
|
||||
w.add_child(_root)
|
||||
Net.snapshot.connect(_on_snapshot)
|
||||
Net.corrected.connect(_on_corrected)
|
||||
Net.clock.connect(_on_clock)
|
||||
Net.players_changed.connect(_sync_avatars)
|
||||
Net.player_joined.connect(func(n): Actions.notify.emit("%s joined" % n))
|
||||
Net.player_left.connect(func(n): Actions.notify.emit("%s left" % n))
|
||||
if Net.world_info.has("day"):
|
||||
_on_clock(int(Net.world_info.day), float(Net.world_info.time))
|
||||
|
||||
func _exit_tree() -> void:
|
||||
for sig in [[Net.snapshot, _on_snapshot], [Net.corrected, _on_corrected], [Net.clock, _on_clock],
|
||||
[Net.players_changed, _sync_avatars]]:
|
||||
if (sig[0] as Signal).is_connected(sig[1]):
|
||||
(sig[0] as Signal).disconnect(sig[1])
|
||||
|
||||
static func _now() -> float:
|
||||
return Time.get_ticks_msec() / 1000.0
|
||||
|
||||
## Server time we draw other players at.
|
||||
func render_time() -> float:
|
||||
return _now() + _offset - INTERP_DELAY if _offset != INF else -INF
|
||||
|
||||
## Flags the next update as a legitimate jump (respawning), so the server
|
||||
## checks it against the spawn instead of against walking speed.
|
||||
func mark_teleport() -> void:
|
||||
_teleport_next = true
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
_send_left -= delta
|
||||
if _send_left > 0.0:
|
||||
return
|
||||
_send_left = Net.SEND_INTERVAL
|
||||
var cam: Camera3D = player.get_node("Camera3D")
|
||||
Net.send_state(player.global_position, player.rotation.y, cam.rotation.x, anim_state(), player.held_item_id(), _teleport_next)
|
||||
_teleport_next = false
|
||||
|
||||
## What the local player is doing, for other people's view of them.
|
||||
func anim_state() -> int:
|
||||
if player.global_position.y < player.water_level - player.FLOAT_DEPTH + 0.1 and not player.is_on_floor():
|
||||
return AvatarScript.Anim.SWIM
|
||||
if not player.is_on_floor():
|
||||
return AvatarScript.Anim.AIR
|
||||
var flat := Vector2(player.velocity.x, player.velocity.z).length()
|
||||
if flat > 6.0:
|
||||
return AvatarScript.Anim.RUN
|
||||
if flat > 0.5:
|
||||
return AvatarScript.Anim.WALK
|
||||
return AvatarScript.Anim.IDLE
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
var rt := render_time()
|
||||
if rt == -INF:
|
||||
return
|
||||
for a in avatars.values():
|
||||
a.update_at(rt, delta)
|
||||
|
||||
func _on_snapshot(t: float, states: Array) -> void:
|
||||
var sample := t - _now()
|
||||
# Smooth the clock offset so jitter doesn't make avatars judder. Jump
|
||||
# straight to it at first, or if it's way off (a long stall).
|
||||
if _offset == INF or absf(sample - _offset) > 1.0:
|
||||
_offset = sample
|
||||
else:
|
||||
_offset = lerpf(_offset, sample, 0.05)
|
||||
for s in states:
|
||||
var peer := int(s[0])
|
||||
if peer == Net.local_peer:
|
||||
continue
|
||||
var a: Node3D = avatars.get(peer)
|
||||
if a == null:
|
||||
a = _add_avatar(peer)
|
||||
if a == null:
|
||||
continue
|
||||
a.push(float(s[6]), s[1], s[2], s[3], s[4], s[5]) # stamped with when they moved, not when it was sent
|
||||
|
||||
func _add_avatar(peer: int) -> Node3D:
|
||||
if not Net.players.has(peer):
|
||||
return null
|
||||
var a: Node3D = AvatarScript.new()
|
||||
a.setup(peer, Net.players[peer])
|
||||
_root.add_child(a)
|
||||
avatars[peer] = a
|
||||
return a
|
||||
|
||||
func _sync_avatars() -> void:
|
||||
for peer in avatars.keys():
|
||||
if not Net.players.has(peer):
|
||||
avatars[peer].queue_free()
|
||||
avatars.erase(peer)
|
||||
|
||||
func _on_corrected(pos: Vector3) -> void:
|
||||
corrections += 1
|
||||
player.global_position = pos
|
||||
player.velocity = Vector3.ZERO
|
||||
|
||||
func _on_clock(day: int, time: float) -> void:
|
||||
world.day_night.day = day
|
||||
# Small drift: ease the clock across. A big gap (joining, a stall): jump.
|
||||
var diff := wrapf(time - world.day_night.time, -0.5, 0.5)
|
||||
if absf(diff) > 0.01:
|
||||
world.day_night.time = time
|
||||
world.day_night.apply(true)
|
||||
Reference in New Issue
Block a user