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:
Paul Bailey
2026-11-04 15:08:14 +00:00
parent f62f71658f
commit d0f473e57b
14 changed files with 1093 additions and 29 deletions

View File

@@ -147,12 +147,39 @@ transfer...) becomes a client-to-server RPC. The server runs the same
function it runs today, with the server's copy of the player, then sends function it runs today, with the server's copy of the player, then sends
back the results. The rules don't move; only who calls them. back the results. The rules don't move; only who calls them.
**Movement.** The client moves its own player immediately and sends inputs **Movement** *(built in M2.2: `net.gd`, `net_sync.gd`, `avatar.gd`)*. Your
(30 Hz). The server simulates the same inputs and sends back authoritative own player moves locally with no delay, and the game sends its position,
positions. The client corrects only if it's off by more than a small facing, animation and held item to the server 20 times a second, each
tolerance, which keeps things smooth on Tailscale and home links. Basic stamped with the sender's own clock. The server checks every move against
anti-cheat: the server rejects moves faster than sprint, or through walls the last accepted one:
and the world boundary. - no faster than 9.5 m/s across the ground (sprint is 8) plus 1 m of slack;
- no rising faster than 9 m/s;
- not outside the island (a 3 m margin past the wall);
- not underground.
A refused move sends the player back to their last good position.
Respawning is a flagged teleport, allowed if it lands within 25 m of the
island spawn. Normal play is never corrected, so there's no rubber-banding.
This is the Valheim model, and simpler than replaying inputs on the server.
Through-wall checks come with the shared world (M2.3), since the server
doesn't know about buildings yet.
The server sends everyone's positions 20 times a second. Each one is stamped
with when the player moved (their clock plus a smoothed per-player offset),
not when it was sent. Stamping by send time bunched moves into some
snapshots and left others empty, so avatars surged. Avatars are drawn
100 ms behind and interpolated, carrying on along their last velocity for up
to 0.25 s if data stops. The server also sends its seed, spawn point and
clock (every 10 s; the clock pauses when the server is empty), so every
player has the same island, sky and weather.
Measured in `tests/test_net_world.gd` at 100 ms ± 30 ms simulated latency:
a 4 m/s walk is seen at a median of 4.2 m/s, top 5.0, with no freezes, and
it ends within a centimetre of where the walker stopped.
Until later phases, while online: animals are off (M2.5), and building,
gathering and other world changes are local only and aren't saved (M2.3,
M2.4). The HUD says so. Respawning ignores beds.
**Interest.** On a 320 m worldlet with 8 players, the server sends every **Interest.** On a 320 m worldlet with 8 players, the server sends every
entity to everyone at first. Distance-based interest is a later entity to everyone at first. Distance-based interest is a later
@@ -209,7 +236,7 @@ player" or "this player":
|---|---|---| |---|---|---|
| **M2.0** | worldlet W0 (terrain from data, no visible change), recommended first | seed 1337 generates the same height grid as today; all suites pass | | **M2.0** | worldlet W0 (terrain from data, no visible change), recommended first | seed 1337 generates the same height grid as today; all suites pass |
| **M2.1** *(done)* | network skeleton: `--server`, ENet on 24242, Join screen (address, name), handshake with identity and challenge, name rules, player list | two clients and a headless server on one Mac connect, see the player list, and disconnect cleanly | | **M2.1** *(done)* | network skeleton: `--server`, ENet on 24242, Join screen (address, name), handshake with identity and challenge, name rules, player list | two clients and a headless server on one Mac connect, see the player list, and disconnect cleanly |
| **M2.2** | player bodies and name tags; predicted movement, interpolation for others | two players walk around together at 100 ms simulated latency without visible rubber-banding | | **M2.2** *(done)* | player bodies and name tags; predicted movement, interpolation for others | two players walk around together at 100 ms simulated latency without visible rubber-banding |
| **M2.3** | world state replicated: resource nodes, pickups, pieces, chests, fires, doors, clock, weather | one chops a tree and the other sees it fall; one builds a wall and the other walks into it | | **M2.3** | world state replicated: resource nodes, pickups, pieces, chests, fires, doors, clock, weather | one chops a tree and the other sees it fall; one builds a wall and the other walks into it |
| **M2.4** | intents as RPCs; per-player inventory, stats, diet, death and graves; per-player saves and migration | two players craft, eat, die and loot their own graves; an old single-player save loads | | **M2.4** | intents as RPCs; per-player inventory, stats, diet, death and graves; per-player saves and migration | two players craft, eat, die and loot their own graves; an old single-player save loads |
| **M2.5** | wildlife and wolves server-side, projectiles, fishing, sleeping with the rule | a pack hunts two players at once; arrows hit on both screens | | **M2.5** | wildlife and wolves server-side, projectiles, fishing, sleeping with the rule | a pack hunts two players at once; arrows hit on both screens |

View File

@@ -45,7 +45,7 @@ or port forwarding, player identity and names, server rules for co-op/PvP, and
phases M2.0-M2.7). phases M2.0-M2.7).
- [ ] Refactor state to server-authoritative (single-player becomes loopback server) - [ ] Refactor state to server-authoritative (single-player becomes loopback server)
- [x] Headless dedicated server, joining, identity handshake, names, player list *(M2.1)* - [x] Headless dedicated server, joining, identity handshake, names, player list *(M2.1)*
- [ ] ENet transport, client prediction for local movement, interpolation for others - [x] ENet transport, player bodies and name tags, local movement checked by the server, interpolation for others *(M2.2)*
- [ ] Replicate resource nodes, inventory, building, health - [ ] Replicate resource nodes, inventory, building, health
- [ ] Persistence: world save/load on the server - [ ] Persistence: world save/load on the server
- **Done when:** a friend joins your self-hosted server and you build together. - **Done when:** a friend joins your self-hosted server and you build together.

View File

@@ -38,6 +38,8 @@ godot --headless --path . --script res://tests/test_survival_core.gd
godot --headless --path . --script res://tests/test_save_load.gd godot --headless --path . --script res://tests/test_save_load.gd
# Multiplayer lobby: a real headless server on UDP 24343 + scripted bot players # Multiplayer lobby: a real headless server on UDP 24343 + scripted bot players
godot --headless --path . --script res://tests/test_net_lobby.gd godot --headless --path . --script res://tests/test_net_lobby.gd
# Multiplayer world: avatars, smooth movement at 100 ms latency, cheat checks (UDP 24345)
godot --headless --path . --script res://tests/test_net_world.gd
# Survival stats: hunger, warmth, fire fuel, cooking, foraging, death + grave # Survival stats: hunger, warmth, fire fuel, cooking, foraging, death + grave
godot --headless --path . --script res://tests/test_survival_stats.gd godot --headless --path . --script res://tests/test_survival_stats.gd
``` ```

161
scripts/net/avatar.gd Normal file
View 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

View 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

View File

@@ -22,14 +22,28 @@ signal joined ## the server welcomed us
signal join_failed(reason: String) signal join_failed(reason: String)
signal left(reason: String) ## disconnected after joining signal left(reason: String) ## disconnected after joining
signal players_changed 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 Identity = preload("res://scripts/net/identity.gd")
const ServerConfig = preload("res://scripts/net/server_config.gd") const ServerConfig = preload("res://scripts/net/server_config.gd")
const PROTOCOL := 1 const PROTOCOL := 2
const DEFAULT_PORT := 24242 const DEFAULT_PORT := 24242
const HANDSHAKE_TIMEOUT := 10.0 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 state := "offline"
var dedicated := false ## started with --server 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"}. ## Everyone on the server: peer id -> {"id", "name", "appearance", "admin"}.
var players: Dictionary = {} var players: Dictionary = {}
var local_peer := 0 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 ## Test hooks (tests/net_bot.gd): sign with the wrong key, or claim another
## protocol version. ## protocol version.
@@ -122,11 +156,20 @@ func start_server(config_path: String, overrides: Dictionary = {}) -> String:
return "Already %s" % state return "Already %s" % state
config = ServerConfig.open(config_path) config = ServerConfig.open(config_path)
var map := {"port": "port", "server-name": "server_name", "max-players": "max_players", 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: for flag in map:
if overrides.has(flag): if overrides.has(flag):
var v: Variant = overrides[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() _peer = ENetMultiplayerPeer.new()
var port := int(config.v("port")) var port := int(config.v("port"))
# A few spare slots so a full server can still say "server is full". # 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 multiplayer.multiplayer_peer = null
players.clear() players.clear()
_pending.clear() _pending.clear()
_states.clear()
server_info.clear() server_info.clear()
world_info.clear()
_out_q.clear()
_in_q.clear()
local_peer = 0 local_peer = 0
if _terrain != null:
_terrain.queue_free()
_terrain = null
if state != "offline": if state != "offline":
_set_state("offline") _set_state("offline")
@@ -163,6 +213,7 @@ func _on_peer_disconnected(id: int) -> void:
if not is_server(): if not is_server():
return return
_pending.erase(id) _pending.erase(id)
_states.erase(id)
if players.has(id): if players.has(id):
var p: Dictionary = players[id] var p: Dictionary = players[id]
players.erase(id) players.erase(id)
@@ -176,10 +227,104 @@ func _process(delta: float) -> void:
_pending[id].t += delta _pending[id].t += delta
if _pending[id].t > HANDSHAKE_TIMEOUT: if _pending[id].t > HANDSHAKE_TIMEOUT:
_reject(id, "Took too long to say hello") _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 _join_left -= delta
if _join_left <= 0.0: if _join_left <= 0.0:
_fail("No answer from the server") _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: func _reject(peer: int, reason: String) -> void:
_pending.erase(peer) _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) 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", {})), var p := {"id": id, "name": name, "appearance": Identity.sanitize_appearance(hello.get("appearance", {})),
"admin": config.is_admin(id)} "admin": config.is_admin(id)}
var start := _spawn_for(players.size())
players[peer] = p 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 "", print("[server] %s joined (id %s%s, %d/%d online)" % [name, id, ", admin" if p.admin else "",
players.size(), int(config.v("max_players"))]) 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: for other in players:
if other != peer: if other != peer:
_s2c_player_joined.rpc_id(other, peer, p) _s2c_player_joined.rpc_id(other, peer, p)
@@ -306,7 +456,8 @@ func leave() -> void:
var was := state var was := state
stop() stop()
if was == "connected": if was == "connected":
left.emit("You left the server") last_reason = "You left the server"
left.emit(last_reason)
func _on_connected() -> void: func _on_connected() -> void:
var me := get_identity() var me := get_identity()
@@ -326,9 +477,11 @@ func _s2c_challenge(nonce: PackedByteArray) -> void:
func _s2c_welcome(d: Dictionary) -> void: func _s2c_welcome(d: Dictionary) -> void:
local_peer = int(d.you) local_peer = int(d.you)
server_info = d.server server_info = d.server
world_info = d.get("world", {})
players.clear() players.clear()
for k in d.players: for k in d.players:
players[int(k)] = d.players[k] players[int(k)] = d.players[k]
last_reason = ""
_set_state("connected") _set_state("connected")
joined.emit() joined.emit()
players_changed.emit() players_changed.emit()
@@ -341,14 +494,61 @@ func _s2c_rejected(reason: String) -> void:
func _s2c_player_joined(peer: int, p: Dictionary) -> void: func _s2c_player_joined(peer: int, p: Dictionary) -> void:
players[peer] = p players[peer] = p
players_changed.emit() players_changed.emit()
player_joined.emit(String(p.get("name", "?")))
@rpc("authority", "call_remote", "reliable") @rpc("authority", "call_remote", "reliable")
func _s2c_player_left(peer: int) -> void: func _s2c_player_left(peer: int) -> void:
if not players.has(peer):
return
var n := String(players[peer].get("name", "?"))
players.erase(peer) players.erase(peer)
players_changed.emit() 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: func _fail(reason: String) -> void:
stop() stop()
last_reason = reason
join_failed.emit(reason) join_failed.emit(reason)
func _on_connection_failed() -> void: func _on_connection_failed() -> void:
@@ -359,9 +559,11 @@ func _on_server_disconnected() -> void:
var reason := _reject_reason var reason := _reject_reason
stop() stop()
if was == "connecting": 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: 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: func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_PREDELETE: if what == NOTIFICATION_WM_CLOSE_REQUEST or what == NOTIFICATION_PREDELETE:

132
scripts/net/net_sync.gd Normal file
View 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)

View File

@@ -23,6 +23,8 @@ var _main: VBoxContainer
var _controls: VBoxContainer var _controls: VBoxContainer
var _quit_btn: Button var _quit_btn: Button
var _quit_armed := false var _quit_armed := false
var _online := false ## on a server: no pausing, no saving here
var _players_label: Label
func bind(p: CharacterBody3D) -> void: func bind(p: CharacterBody3D) -> void:
player = p player = p
@@ -79,7 +81,15 @@ func bind(p: CharacterBody3D) -> void:
_main.add_child(sens_row) _main.add_child(sens_row)
var saved: bool = Saves.has_world() var saved: bool = Saves.has_world()
if saved: _online = Net.state == "connected"
if _online:
_players_label = UITheme.label("", 15)
_players_label.autowrap_mode = TextServer.AUTOWRAP_WORD
_players_label.custom_minimum_size = Vector2(340, 0)
_main.add_child(_players_label)
_main.add_child(_button("Disconnect", _on_disconnect))
_quit_btn = _button("Disconnect & Quit to Desktop", _on_quit)
elif saved:
_main.add_child(_button("Save & Quit to Title", _on_quit_to_title)) _main.add_child(_button("Save & Quit to Title", _on_quit_to_title))
_quit_btn = _button("Save & Quit to Desktop", _on_quit) _quit_btn = _button("Save & Quit to Desktop", _on_quit)
else: else:
@@ -87,6 +97,8 @@ func bind(p: CharacterBody3D) -> void:
_main.add_child(_quit_btn) _main.add_child(_quit_btn)
var note_text := "Autosaves every 2 minutes. World: %s" % Saves.read_meta(Saves.current_id).get("name", "?") if saved \ var note_text := "Autosaves every 2 minutes. World: %s" % Saves.read_meta(Saves.current_id).get("name", "?") if saved \
else "Not playing a saved world: progress won't be kept." else "Not playing a saved world: progress won't be kept."
if _online:
note_text = "The game keeps running while this menu is open."
var note := UITheme.label(note_text, 13, UITheme.TEXT_DIM) var note := UITheme.label(note_text, 13, UITheme.TEXT_DIM)
note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_main.add_child(note) _main.add_child(note)
@@ -125,8 +137,14 @@ func open() -> void:
_show_controls(false) _show_controls(false)
_disarm_quit() _disarm_quit()
player.ui_open = true player.ui_open = true
get_tree().paused = true get_tree().paused = not _online # a shared world can't be paused by one player
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
if _online:
var names: Array[String] = []
for p in Net.players.values():
names.append(String(p.name) + (" (admin)" if p.get("admin", false) else ""))
names.sort()
_players_label.text = "%s · %d online\n%s" % [Net.server_info.get("server_name", "Server"), names.size(), ", ".join(names)]
func close() -> void: func close() -> void:
visible = false visible = false
@@ -148,7 +166,16 @@ func _show_controls(on: bool) -> void:
func _on_fullscreen(on: bool) -> void: func _on_fullscreen(on: bool) -> void:
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN if on else DisplayServer.WINDOW_MODE_WINDOWED) DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN if on else DisplayServer.WINDOW_MODE_WINDOWED)
## Leaves the server (world.gd sees Net.left and goes back to the title).
func _on_disconnect() -> void:
close()
Net.leave()
func _on_quit() -> void: func _on_quit() -> void:
if _online:
Net.leave()
get_tree().quit()
return
if Saves.has_world(): if Saves.has_world():
player.get_parent().save_now() player.get_parent().save_now()
get_tree().quit() get_tree().quit()
@@ -169,4 +196,6 @@ func _on_quit_to_title() -> void:
func _disarm_quit() -> void: func _disarm_quit() -> void:
_quit_armed = false _quit_armed = false
if _online:
return
_quit_btn.text = "Save & Quit to Desktop" if Saves.has_world() else "Quit to Desktop" _quit_btn.text = "Save & Quit to Desktop" if Saves.has_world() else "Quit to Desktop"

View File

@@ -30,6 +30,7 @@ var _lobby_motd: Label
var _lobby_rules: Label var _lobby_rules: Label
var _lobby_count: Label var _lobby_count: Label
var _lobby_list: VBoxContainer var _lobby_list: VBoxContainer
var _lobby_note: Label
func _ready() -> void: func _ready() -> void:
if Net.dedicated: if Net.dedicated:
@@ -88,15 +89,31 @@ func _ready() -> void:
_pages["lobby"] = _lobby_page() _pages["lobby"] = _lobby_page()
for p in _pages.values(): for p in _pages.values():
col.add_child(p) col.add_child(p)
Net.joined.connect(func(): _show("lobby")) Net.joined.connect(_enter_server_world)
Net.join_failed.connect(_on_join_failed) Net.join_failed.connect(_on_join_failed)
Net.left.connect(_on_left) Net.left.connect(_on_left)
Net.players_changed.connect(func(): Net.players_changed.connect(func():
if _pages["lobby"].visible: if _pages["lobby"].visible:
_fill_lobby()) _fill_lobby())
_show("main") _show("main")
# Back from a server (left, kicked, or it went away): say why.
if Net.state == "offline" and not Net.last_reason.is_empty():
_show("join")
_join_msg.text = Net.last_reason
Net.last_reason = ""
_maybe_capture() _maybe_capture()
## The server let us in: show who's there for a moment, then load the island.
func _enter_server_world() -> void:
_show("lobby")
_lobby_note.text = "Loading the island..."
for _i in range(3):
await get_tree().process_frame
if Net.state != "connected":
return
Saves.current_id = "" # a server's world isn't saved locally
get_tree().change_scene_to_file(WORLD_SCENE)
## Dev hook, same as world.gd: `-- --shot=/abs/prefix` saves the title pages. ## Dev hook, same as world.gd: `-- --shot=/abs/prefix` saves the title pages.
func _maybe_capture() -> void: func _maybe_capture() -> void:
var prefix := "" var prefix := ""
@@ -370,10 +387,10 @@ func _lobby_page() -> Control:
_lobby_list = VBoxContainer.new() _lobby_list = VBoxContainer.new()
_lobby_list.add_theme_constant_override("separation", 4) _lobby_list.add_theme_constant_override("separation", 4)
v.add_child(_lobby_list) v.add_child(_lobby_list)
var note := UITheme.label("Playing together in the world comes next (M2.2-M2.5). For now, this is who's here.", 13, UITheme.TEXT_DIM) _lobby_note = UITheme.label("", 13, UITheme.TEXT_DIM)
note.autowrap_mode = TextServer.AUTOWRAP_WORD _lobby_note.autowrap_mode = TextServer.AUTOWRAP_WORD
note.custom_minimum_size = Vector2(420, 0) _lobby_note.custom_minimum_size = Vector2(420, 0)
v.add_child(note) v.add_child(_lobby_note)
v.add_child(_button("Disconnect", Net.leave, 180)) v.add_child(_button("Disconnect", Net.leave, 180))
return p return p

View File

@@ -69,9 +69,13 @@ var _heights := PackedFloat32Array()
var _normals := PackedVector3Array() var _normals := PackedVector3Array()
func _ready() -> void: func _ready() -> void:
# A saved world brings its own seed; otherwise keep the exported default. # On a server, its seed; in a saved world, that world's seed; otherwise
# keep the exported default.
var net := get_node_or_null("/root/Net")
var saves := get_node_or_null("/root/Saves") var saves := get_node_or_null("/root/Saves")
if saves != null and saves.has_world(): if net != null and net.state == "connected" and net.world_info.has("seed"):
noise_seed = int(net.world_info.seed)
elif saves != null and saves.has_world():
noise_seed = saves.world_seed() noise_seed = saves.world_seed()
_build() _build()

View File

@@ -26,6 +26,7 @@ const PIECES := {
"bed": preload("res://scripts/building/bed.gd"), "bed": preload("res://scripts/building/bed.gd"),
} }
const BuildPiece = preload("res://scripts/building/build_piece.gd") const BuildPiece = preload("res://scripts/building/build_piece.gd")
const UITheme = preload("res://scripts/ui/theme.gd")
const AUTOSAVE_SECONDS := 120.0 const AUTOSAVE_SECONDS := 120.0
@@ -50,8 +51,13 @@ var _dropped: Node3D ## arrows, spears and other dropped items
var den: Node3D ## den.gd: the wolves' cave at the foot of the rim var den: Node3D ## den.gd: the wolves' cave at the foot of the rim
var bobber: Node3D ## bobber.gd: the cast fishing float, if any var bobber: Node3D ## bobber.gd: the cast fishing float, if any
var wolves: Node3D ## wolf_pack.gd (null if this seed has no den site) var wolves: Node3D ## wolf_pack.gd (null if this seed has no den site)
var net_sync: Node ## net_sync.gd when playing on a server, else null
var has_bed_spawn := false var has_bed_spawn := false
var bed_spawn := Vector3.ZERO ## position of the bed you respawn at var bed_spawn := Vector3.ZERO ## position of the bed you respawn at
## Playing on a server (multiplayer) rather than a local world.
func is_online() -> bool:
return not menu_mode and Net.state == "connected"
var _loading := false ## applying a save: defer support recompute var _loading := false ## applying a save: defer support recompute
func _ready() -> void: func _ready() -> void:
@@ -157,7 +163,7 @@ func _on_player_died() -> void:
player.inventory.load_data(Inventory.new(player.inventory.size()).to_data()) player.inventory.load_data(Inventory.new(player.inventory.size()).to_data())
if hud != null: if hud != null:
hud.close_screens() hud.close_screens()
var bed := spawn_bed() var bed := spawn_bed() if net_sync == null else null # (online: beds aren't shared until M2.4)
if bed != null: if bed != null:
_respawn_at_bed(bed) _respawn_at_bed(bed)
else: else:
@@ -258,6 +264,10 @@ func _on_terrain_ready() -> void:
player.set_physics_process(true) player.set_physics_process(true)
set_physics_process(true) set_physics_process(true)
creatures.setup(self) creatures.setup(self)
if is_online():
_go_online()
_maybe_capture()
return
if not "--no-creatures" in OS.get_cmdline_user_args(): if not "--no-creatures" in OS.get_cmdline_user_args():
creatures.populate() creatures.populate()
creatures.enabled = true creatures.enabled = true
@@ -301,7 +311,13 @@ func save_now() -> bool:
return ok return ok
func _notification(what: int) -> void: func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST and Saves.has_world() and not menu_mode: if what != NOTIFICATION_WM_CLOSE_REQUEST or menu_mode:
return
if net_sync != null:
Net.left.disconnect(_on_net_left)
Net.leave()
get_tree().quit()
elif Saves.has_world():
save_now() save_now()
get_tree().quit() get_tree().quit()
@@ -421,6 +437,8 @@ func apply_save(s: Dictionary) -> void:
_update_grass_cuts() _update_grass_cuts()
func _respawn() -> void: func _respawn() -> void:
if net_sync != null:
net_sync.mark_teleport() # tell the server this jump back to the spawn is legitimate
player.global_position = _spawn + Vector3(0.0, 1.5, 0.0) player.global_position = _spawn + Vector3(0.0, 1.5, 0.0)
player.velocity = Vector3.ZERO player.velocity = Vector3.ZERO
player.rotation = Vector3(0.0, _spawn_yaw, 0.0) player.rotation = Vector3(0.0, _spawn_yaw, 0.0)
@@ -498,6 +516,34 @@ func water_at(x: float, z: float) -> Dictionary:
return {"type": "sea", "surface": terrain.sea_level, "depth": terrain.sea_level - ground} return {"type": "sea", "surface": terrain.sea_level, "depth": terrain.sea_level - ground}
return {} return {}
# --- multiplayer -----------------------------------------------------------------
## Joined a server: start where it says, follow its clock, show the others.
## Animals stay off until the server runs them (M2.5): otherwise everyone
## would see different wildlife.
func _go_online() -> void:
_spawn = Net.world_info.get("island_spawn", _spawn)
var start: Vector3 = Net.world_info.get("spawn", _spawn)
player.global_position = start + Vector3(0.0, 0.05, 0.0)
net_sync = preload("res://scripts/net/net_sync.gd").new()
net_sync.name = "NetSync"
add_child(net_sync)
net_sync.setup(self)
Net.left.connect(_on_net_left)
get_tree().auto_accept_quit = false # leave the server cleanly on window close
if hud != null:
var bar := UITheme.label("Online: %s · you can see each other; building, gathering and animals aren't shared yet" \
% Net.server_info.get("server_name", "server"), 14, UITheme.TEXT_DIM)
bar.position = Vector2(16, 40)
$HUD.add_child(bar)
Actions.notify.emit("Connected to %s" % Net.server_info.get("server_name", "the server"))
func _on_net_left(_reason: String) -> void:
if is_inside_tree():
get_tree().paused = false
get_tree().auto_accept_quit = true
get_tree().change_scene_to_file.call_deferred("res://scenes/title.tscn")
## Leaves `count` of an item lying in the world at `xf` (an arrow stuck in a ## Leaves `count` of an item lying in the world at `xf` (an arrow stuck in a
## tree, a thrown spear, meat that didn't fit). Saved with the world. ## tree, a thrown spear, meat that didn't fit). Saved with the world.
func drop_item(id: String, count: int, xf: Transform3D) -> Node3D: func drop_item(id: String, count: int, xf: Transform3D) -> Node3D:
@@ -692,6 +738,50 @@ func _capture_survival(prefix: String, cam: Camera3D) -> void:
await _capture_weather(prefix, cam) await _capture_weather(prefix, cam)
await _capture_creatures(prefix, cam) await _capture_creatures(prefix, cam)
await _capture_wolves_fishing(prefix, cam) await _capture_wolves_fishing(prefix, cam)
await _capture_players(prefix, cam)
## Two sample players (as other people would see them) at the camp.
func _capture_players(prefix: String, cam: Camera3D) -> void:
weather.force("clear")
weather.snow_cover = 0.0
weather.ground_wet = 0.0
day_night.time = 10.0 / 24.0
day_night.apply(true)
_respawn()
player.set_physics_process(false)
var fwd := -player.global_basis.z
var right := player.global_basis.x
var root := Node3D.new()
add_child(root)
var AvatarScript = preload("res://scripts/net/avatar.gd")
var made: Array = []
for d in [["Freya", {"tunic": 2, "hair": 2, "beard": false}, 3.2, -0.9, "stone_axe", 1],
["Old Bjorn", {"tunic": 1, "hair": 4, "beard": true}, 4.4, 1.3, "", 0]]:
var av: Node3D = AvatarScript.new()
av.setup(made.size() + 2, {"name": d[0], "appearance": d[1]})
root.add_child(av)
var p: Vector3 = player.global_position + fwd * float(d[2]) + right * float(d[3])
p.y = terrain.sample_height(p.x, p.z)
var face: float = player.rotation.y + PI + float(d[3]) * 0.3
av.push(0.0, p, face, 0.0, d[5], d[4])
av.push(1.0, p + fwd * 0.01 * d[5], face, 0.0, d[5], d[4])
made.append(av)
player.select(0)
cam.rotation.x = -0.12
for i in range(30):
for av in made:
av.update_at(0.5 + i * 0.016, 0.016)
await get_tree().process_frame
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png("%s_players.png" % prefix)
print("[world] saved %s_players.png" % prefix)
day_night.time = 19.2 / 24.0
day_night.apply(true)
await get_tree().create_timer(0.5).timeout
await RenderingServer.frame_post_draw
get_viewport().get_texture().get_image().save_png("%s_players_dusk.png" % prefix)
print("[world] saved %s_players_dusk.png" % prefix)
root.queue_free()
## The den by day with the pack resting, wolves stalking at night, and ## The den by day with the pack resting, wolves stalking at night, and
## fishing from the lake shore. ## fishing from the lake shore.

View File

@@ -9,6 +9,69 @@ extends SceneTree
var _outcome := "" var _outcome := ""
var _reason := "" var _reason := ""
var _pos := Vector3.ZERO
var _corrections := 0
var _others := {} ## name -> last position seen in a snapshot
var _terrain: Node3D
## Walks (and optionally cheats) on the server's island, sending positions
## 20 times a second like a real client. Corrections from the server are
## obeyed, as the real client does.
## --walk line --speed 4 --walk-time 3 walk along +X, then stand still
## --cheat teleport after 1 s, claim to be 60 m away
## --cheat outside walk straight out toward the world's edge at 7 m/s
## --cheat respawn a flagged teleport to the spawn (legitimate)
func _move(net: Node, args: Dictionary, res: Dictionary) -> void:
_terrain = load("res://scripts/world/terrain.gd").new()
root.add_child(_terrain) # takes its seed from the server (net.world_info)
_pos = net.world_info.get("spawn", Vector3.ZERO)
net.corrected.connect(func(p): _corrections += 1; _pos = p)
net.snapshot.connect(func(_t, states):
for s in states:
if net.players.has(int(s[0])):
_others[net.players[int(s[0])].name] = s[1])
var mode := String(args.get("walk", args.get("cheat", "")))
var speed := float(args.get("speed", 4.0))
var dur := float(args.get("walk-time", 3.0))
res.walk_start = [_pos.x, _pos.y, _pos.z]
var t := 0.0
var dir := Vector3(1, 0, 0)
if mode == "outside":
dir = Vector3(_pos.x, 0, _pos.z).normalized() if Vector2(_pos.x, _pos.z).length() > 1.0 else Vector3(1, 0, 0)
speed = 7.0
dur = 40.0
var step := 0.05
var prev_us := Time.get_ticks_usec()
while t < dur and net.state == "connected":
var now_us := Time.get_ticks_usec()
var real_dt := (now_us - prev_us) / 1000000.0
prev_us = now_us
var teleported := false
var anim := 1
match mode:
"teleport":
if absf(t - 1.0) < step * 0.5:
_pos += Vector3(60, 0, 0)
anim = 0
"respawn":
if absf(t - 1.0) < step * 0.5:
var sp: Vector3 = net.world_info.get("island_spawn", _pos)
_pos = sp + Vector3(0.5, 0, 0.5)
teleported = true
anim = 0
_:
_pos += dir * speed * real_dt # real elapsed time, like the game's physics
_pos.y = _terrain.sample_height(_pos.x, _pos.z)
net.send_state(_pos, 0.0, 0.0, anim, "", teleported)
await create_timer(step).timeout
t += step
if mode == "outside" and _corrections > 0 and t > 1.0:
break
res.walk_end = [_pos.x, _pos.y, _pos.z]
# Stand still for a moment, still sending, so the last position settles.
for _i in range(10):
net.send_state(_pos, 0.0, 0.0, 0, "", false)
await create_timer(step).timeout
func _initialize() -> void: func _initialize() -> void:
_run.call_deferred() _run.call_deferred()
@@ -35,6 +98,8 @@ func _run() -> void:
res.joined = true res.joined = true
res.server_name = net.server_info.get("server_name", "") res.server_name = net.server_info.get("server_name", "")
res.admin = bool(net.players.get(net.local_peer, {}).get("admin", false)) res.admin = bool(net.players.get(net.local_peer, {}).get("admin", false))
if args.has("walk") or args.has("cheat"):
await _move(net, args, res)
var saw := {} var saw := {}
var stay := float(args.get("stay", 2.0)) var stay := float(args.get("stay", 2.0))
var s := 0.0 var s := 0.0
@@ -44,6 +109,11 @@ func _run() -> void:
await create_timer(0.1).timeout await create_timer(0.1).timeout
s += 0.1 s += 0.1
res.saw = saw.keys() res.saw = saw.keys()
res.corrections = _corrections
res.final_pos = [_pos.x, _pos.y, _pos.z]
if _others.has("Alice"):
var ap: Vector3 = _others.Alice
res.alice_pos = [ap.x, ap.y, ap.z]
net.leave() net.leave()
for _i in range(10): for _i in range(10):
await process_frame await process_frame

View File

@@ -4,7 +4,7 @@
# Usage: tests/run_all.sh (from the repo root; needs `godot` on PATH) # Usage: tests/run_all.sh (from the repo root; needs `godot` on PATH)
cd "$(dirname "$0")/.." || exit 1 cd "$(dirname "$0")/.." || exit 1
status=0 status=0
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_fishing_diet test_net_lobby test_terrain_edges test_boundary; do for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_fishing_diet test_net_lobby test_net_world test_terrain_edges test_boundary; do
extra="" extra=""
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30" [ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
# A suite that waits on a world that failed to load would hang forever, # A suite that waits on a world that failed to load would hang forever,

261
tests/test_net_world.gd Normal file
View File

@@ -0,0 +1,261 @@
extends SceneTree
## M2.2: playing on a server. Joining puts you on the server's island (its
## seed, spawn and clock); other players are avatars with name tags that move
## smoothly under 100 ms of simulated latency; the server refuses impossible
## moves (teleports, leaving the island) and accepts a flagged respawn.
##
## Like test_net_lobby: a real headless server and bot players
## (tests/net_bot.gd) as separate processes over UDP on localhost. This test
## is "Alice" and loads the actual world scene.
## Run: godot --headless --path . --script res://tests/test_net_world.gd
const PORT := 24345
const DIR := "user://test_net"
const SEED := 777
var _fails := 0
var _finished := false
var _pids: Array[int] = []
var Net: Node
var _n := 0
var _toasts: Array[String] = []
func check(cond: bool, what: String) -> void:
print((" ok " if cond else " FAIL ") + what)
if not cond:
_fails += 1
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
Engine.max_fps = 60
Net = root.get_node("Net")
root.get_node("Actions").notify.connect(func(t): _toasts.append(t))
for d in ["/server3", "/results"]:
_rmdir(DIR + d)
DirAccess.make_dir_recursive_absolute(DIR + "/results")
_spawn(["--server", "--port", str(PORT), "--config", DIR + "/server3/server.json",
"--server-name", "World Test", "--seed", str(SEED)])
await _sleep(2.5)
await _body()
Net.debug_latency_ms = 0.0
Net.stop()
for pid in _pids:
if OS.is_process_running(pid):
OS.kill(pid)
check(_finished, "test ran to the end (no script errors)")
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
quit(1 if _fails > 0 else 0)
func _body() -> void:
var a = load("res://scripts/net/identity.gd").open(DIR + "/a")
a.name = "Alice"
Net.identity = a
var r := await _join("127.0.0.1:%d" % PORT)
check(r == "", "Alice joins (%s)" % r)
check(int(Net.world_info.get("seed", 0)) == SEED and Net.world_info.has("spawn"), "the server sends its world: seed %s" % Net.world_info.get("seed"))
print("the server's island")
var world: Node3D = load("res://scenes/world.tscn").instantiate()
root.add_child(world)
for _i in range(8):
await physics_frame
var player: CharacterBody3D = world.get_node("Player")
var terrain: Node3D = world.get_node("Terrain")
check(terrain.noise_seed == SEED, "the world is built from the server's seed, not the default (%d)" % terrain.noise_seed)
check(world.is_online() and world.net_sync != null, "and knows it's online")
var sp: Vector3 = Net.world_info.spawn
check(Vector2(player.global_position.x - sp.x, player.global_position.z - sp.z).length() < 1.0, "Alice starts where the server put her")
check(not world.creatures.enabled and world.creatures.alive().is_empty() and world.wolves == null,
"no local-only animals while online (the server will run them in M2.5)")
check(world.day_night.day == int(Net.world_info.day) and absf(world.day_night.time - float(Net.world_info.time)) < 0.01,
"the clock follows the server (%s)" % world.day_night.clock_text())
print("another player, 100 ms away")
Net.debug_latency_ms = 100.0
Net.debug_jitter_ms = 30.0
var bob := _bot("bob", "Bob", ["--walk", "line", "--speed", "4", "--walk-time", "3", "--stay", "6"])
var av: Node3D = null
var t := 0.0
while av == null and t < 20.0:
av = _avatar(world, "Bob")
await _sleep(0.05)
t += 0.05
check(av != null, "Bob appears in Alice's world")
if av == null:
return
check(av.tag.text == "Bob" and av.tag.billboard == BaseMaterial3D.BILLBOARD_ENABLED and not av.tag.no_depth_test,
"with a name tag over him (hidden by walls)")
# Watch him walk: sample every 50 ms.
var samples: Array = []
var anims := {}
t = 0.0
var last: Vector3 = av.global_position
var last_us := Time.get_ticks_usec()
while t < 6.0 and is_instance_valid(av):
await _sleep(0.05)
var now_us := Time.get_ticks_usec()
var dt := (now_us - last_us) / 1000000.0
last_us = now_us
t += dt
var p: Vector3 = av.global_position
samples.append([t, Vector2(p.x - last.x, p.z - last.z).length() / maxf(dt, 0.001)])
anims[av.anim] = true
last = p
var end_pos: Vector3 = av.global_position if is_instance_valid(av) else last
var end_anim: int = av.anim if is_instance_valid(av) else -1
var br := await _result(bob)
var moving := samples.filter(func(s): return s[1] > 0.5)
var top := 0.0
for s in samples:
top = maxf(top, s[1])
var stalls := 0
if moving.size() > 2:
# Count gaps inside the walking stretch where he froze.
var first: float = moving[0][0]
var last_t: float = moving[-1][0]
for s in samples:
if s[0] > first and s[0] < last_t and s[1] < 0.4:
stalls += 1
check(br.get("corrections", -1) == 0, "walking normally is never corrected by the server")
check(moving.size() >= 30, "Alice watched him walk for %.1f s" % (moving.size() * 0.05))
var speeds: Array = moving.map(func(s): return s[1])
speeds.sort()
var median: float = speeds[speeds.size() / 2] if not speeds.is_empty() else 0.0
print(" (speeds seen: median %.2f, top %.2f m/s over %d samples)" % [median, top, speeds.size()])
check(top < 6.0, "smoothly: no jumps (top speed seen %.1f m/s for a 4 m/s walk)" % top)
check(absf(median - 4.0) < 0.5, "at his real speed (median %.2f m/s)" % median)
check(stalls <= 2, "and no freezing mid-walk (%d stalls in %d samples)" % [stalls, moving.size()])
check(end_anim == 0 and anims.has(1), "the walk animation played, then idle")
var fe: Array = br.get("walk_end", [0, 0, 0])
var dist := Vector2(end_pos.x - fe[0], end_pos.z - fe[2]).length()
check(dist < 0.3, "and he ends up exactly where he stopped (%.2f m off)" % dist)
var ap: Array = br.get("alice_pos", [])
check(ap.size() == 3 and Vector2(ap[0] - player.global_position.x, ap[2] - player.global_position.z).length() < 0.5,
"Bob saw Alice where she really is")
t = 0.0
while not world.net_sync.avatars.is_empty() and t < 8.0:
await _sleep(0.1)
t += 0.1
check(world.net_sync.avatars.is_empty() and _toasts.has("Bob left"), "when he leaves his avatar goes (and Alice is told)")
print("cheats")
var tom := _bot("tom", "Tom", ["--cheat", "teleport", "--walk-time", "3", "--stay", "1"])
var olga := _bot("olga", "Olga", ["--cheat", "outside", "--stay", "1"])
var rita := _bot("rita", "Rita", ["--cheat", "respawn", "--walk-time", "2", "--stay", "1"])
var tom_seen := false
var tom_top := 0.0
var tom_last := Vector3.INF
var us := Time.get_ticks_usec()
t = 0.0
while t < 7.0:
await _sleep(0.05)
var dt := (Time.get_ticks_usec() - us) / 1000000.0
us = Time.get_ticks_usec()
t += dt
var tav := _avatar(world, "Tom")
if tav == null:
tom_last = Vector3.INF
continue
tom_seen = true
var p: Vector3 = tav.global_position
if tom_last != Vector3.INF:
tom_top = maxf(tom_top, p.distance_to(tom_last) / maxf(dt, 0.001))
tom_last = p
var tr := await _result(tom)
var ts: Array = tr.get("walk_start", [0, 0, 0])
var tf: Array = tr.get("final_pos", [999, 0, 999])
check(tr.get("corrections", 0) >= 1 and Vector2(tf[0] - ts[0], tf[2] - ts[2]).length() < 2.0,
"a 60 m teleport is refused and Tom is sent back (%d correction%s)" % [tr.get("corrections", 0), "" if tr.get("corrections", 0) == 1 else "s"])
check(tom_seen and tom_top < 10.0, "Alice never sees Tom jump (top speed seen %.1f m/s)" % tom_top)
var orr := await _result(olga)
var of: Array = orr.get("final_pos", [999, 0, 999])
check(orr.get("corrections", 0) >= 1 and terrain.inside_boundary(of[0], of[2], -3.0),
"walking out of the island is stopped at the edge (%d correction%s)" % [orr.get("corrections", 0), "" if orr.get("corrections", 0) == 1 else "s"])
var rr := await _result(rita)
check(rr.get("joined", false) and rr.get("corrections", -1) == 0, "a flagged respawn at the spawn is allowed")
print("Alice's own moves")
var before: Vector3 = player.global_position
player.global_position = before + Vector3(40, 0, 0)
player.global_position.y = terrain.sample_height(player.global_position.x, player.global_position.z) + 0.1
t = 0.0
while world.net_sync.corrections == 0 and t < 3.0:
await _sleep(0.05)
t += 0.05
check(world.net_sync.corrections >= 1, "if Alice's game teleports her, the server corrects it")
check(Vector2(player.global_position.x - before.x, player.global_position.z - before.z).length() < 3.0, "and she's put back")
var c0: int = world.net_sync.corrections
world._respawn()
await _sleep(1.0)
check(world.net_sync.corrections == c0, "dying and respawning at the spawn isn't treated as cheating")
world.queue_free()
await process_frame
Net.leave()
_finished = true
# --- helpers --------------------------------------------------------------------
func _avatar(world: Node3D, name: String) -> Node3D:
if world.net_sync == null:
return null
for av in world.net_sync.avatars.values():
if String(av.info.get("name", "")) == name:
return av
return null
func _join(address: String) -> String:
var state := {"done": false, "reason": ""}
var ok := func(): state.done = true
var bad := func(why): state.done = true; state.reason = why
Net.joined.connect(ok)
Net.join_failed.connect(bad)
Net.join(address)
var t := 0.0
while not state.done and t < 25.0:
await _sleep(0.05)
t += 0.05
Net.joined.disconnect(ok)
Net.join_failed.disconnect(bad)
return state.reason if state.done else "test timed out"
func _spawn(user_args: Array, script: String = "") -> int:
var args := PackedStringArray(["--headless", "--path", ProjectSettings.globalize_path("res://")])
if not script.is_empty():
args.append_array(["--script", script])
args.append("--")
for x in user_args:
args.append(str(x))
args.append_array(["--exit-with", str(OS.get_process_id())])
var pid := OS.create_process(OS.get_executable_path(), args)
_pids.append(pid)
return pid
func _bot(identity: String, name: String, extra: Array) -> String:
_n += 1
var path := "%s/results/w_%s_%d.json" % [DIR, identity, _n]
_spawn(["--identity", DIR + "/" + identity, "--name", name, "--address", "127.0.0.1:%d" % PORT,
"--result", path] + extra, "res://tests/net_bot.gd")
return path
func _result(path: String) -> Dictionary:
var t := 0.0
while not FileAccess.file_exists(path) and t < 40.0:
await _sleep(0.1)
t += 0.1
await _sleep(0.05)
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) if FileAccess.file_exists(path) else null
return d if d is Dictionary else {"joined": false, "reason": "bot gave no result"}
func _sleep(s: float) -> void:
await create_timer(s).timeout
func _rmdir(d: String) -> void:
var da := DirAccess.open(d)
if da == null:
return
for f in da.get_files():
da.remove(f)
DirAccess.remove_absolute(d)