From d0f473e57b2d70b181e0d1568e9e8d03e3a3976f Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Wed, 4 Nov 2026 15:08:14 +0000 Subject: [PATCH] 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) --- docs/MULTIPLAYER_PLAN.md | 41 +++++- docs/ROADMAP.md | 2 +- docs/SETUP_ON_NEW_MACHINE.md | 2 + scripts/net/avatar.gd | 161 +++++++++++++++++++++ scripts/net/character_mesh.gd | 69 +++++++++ scripts/net/net.gd | 220 ++++++++++++++++++++++++++-- scripts/net/net_sync.gd | 132 +++++++++++++++++ scripts/ui/pause_menu.gd | 33 ++++- scripts/ui/title_screen.gd | 27 +++- scripts/world/terrain.gd | 8 +- scripts/world/world.gd | 94 +++++++++++- tests/net_bot.gd | 70 +++++++++ tests/run_all.sh | 2 +- tests/test_net_world.gd | 261 ++++++++++++++++++++++++++++++++++ 14 files changed, 1093 insertions(+), 29 deletions(-) create mode 100644 scripts/net/avatar.gd create mode 100644 scripts/net/character_mesh.gd create mode 100644 scripts/net/net_sync.gd create mode 100644 tests/test_net_world.gd diff --git a/docs/MULTIPLAYER_PLAN.md b/docs/MULTIPLAYER_PLAN.md index 9d6c550..15fb649 100644 --- a/docs/MULTIPLAYER_PLAN.md +++ b/docs/MULTIPLAYER_PLAN.md @@ -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 back the results. The rules don't move; only who calls them. -**Movement.** The client moves its own player immediately and sends inputs -(30 Hz). The server simulates the same inputs and sends back authoritative -positions. The client corrects only if it's off by more than a small -tolerance, which keeps things smooth on Tailscale and home links. Basic -anti-cheat: the server rejects moves faster than sprint, or through walls -and the world boundary. +**Movement** *(built in M2.2: `net.gd`, `net_sync.gd`, `avatar.gd`)*. Your +own player moves locally with no delay, and the game sends its position, +facing, animation and held item to the server 20 times a second, each +stamped with the sender's own clock. The server checks every move against +the last accepted one: +- 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 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.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.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 | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 43236e4..794f8b2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -45,7 +45,7 @@ or port forwarding, player identity and names, server rules for co-op/PvP, and phases M2.0-M2.7). - [ ] Refactor state to server-authoritative (single-player becomes loopback server) - [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 - [ ] Persistence: world save/load on the server - **Done when:** a friend joins your self-hosted server and you build together. diff --git a/docs/SETUP_ON_NEW_MACHINE.md b/docs/SETUP_ON_NEW_MACHINE.md index 33ebff4..b8eee82 100644 --- a/docs/SETUP_ON_NEW_MACHINE.md +++ b/docs/SETUP_ON_NEW_MACHINE.md @@ -38,6 +38,8 @@ godot --headless --path . --script res://tests/test_survival_core.gd godot --headless --path . --script res://tests/test_save_load.gd # Multiplayer lobby: a real headless server on UDP 24343 + scripted bot players 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 godot --headless --path . --script res://tests/test_survival_stats.gd ``` diff --git a/scripts/net/avatar.gd b/scripts/net/avatar.gd new file mode 100644 index 0000000..f0b3501 --- /dev/null +++ b/scripts/net/avatar.gd @@ -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 diff --git a/scripts/net/character_mesh.gd b/scripts/net/character_mesh.gd new file mode 100644 index 0000000..a3e743b --- /dev/null +++ b/scripts/net/character_mesh.gd @@ -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 diff --git a/scripts/net/net.gd b/scripts/net/net.gd index 0cc71ce..7a25acd 100644 --- a/scripts/net/net.gd +++ b/scripts/net/net.gd @@ -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: diff --git a/scripts/net/net_sync.gd b/scripts/net/net_sync.gd new file mode 100644 index 0000000..2aabf20 --- /dev/null +++ b/scripts/net/net_sync.gd @@ -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) diff --git a/scripts/ui/pause_menu.gd b/scripts/ui/pause_menu.gd index 2d33caf..9bdfd7e 100644 --- a/scripts/ui/pause_menu.gd +++ b/scripts/ui/pause_menu.gd @@ -23,6 +23,8 @@ var _main: VBoxContainer var _controls: VBoxContainer var _quit_btn: Button var _quit_armed := false +var _online := false ## on a server: no pausing, no saving here +var _players_label: Label func bind(p: CharacterBody3D) -> void: player = p @@ -79,7 +81,15 @@ func bind(p: CharacterBody3D) -> void: _main.add_child(sens_row) 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)) _quit_btn = _button("Save & Quit to Desktop", _on_quit) else: @@ -87,6 +97,8 @@ func bind(p: CharacterBody3D) -> void: _main.add_child(_quit_btn) 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." + if _online: + note_text = "The game keeps running while this menu is open." var note := UITheme.label(note_text, 13, UITheme.TEXT_DIM) note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _main.add_child(note) @@ -125,8 +137,14 @@ func open() -> void: _show_controls(false) _disarm_quit() 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 + 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: visible = false @@ -148,7 +166,16 @@ func _show_controls(on: bool) -> void: func _on_fullscreen(on: bool) -> void: 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: + if _online: + Net.leave() + get_tree().quit() + return if Saves.has_world(): player.get_parent().save_now() get_tree().quit() @@ -169,4 +196,6 @@ func _on_quit_to_title() -> void: func _disarm_quit() -> void: _quit_armed = false + if _online: + return _quit_btn.text = "Save & Quit to Desktop" if Saves.has_world() else "Quit to Desktop" diff --git a/scripts/ui/title_screen.gd b/scripts/ui/title_screen.gd index c4fb240..533da40 100644 --- a/scripts/ui/title_screen.gd +++ b/scripts/ui/title_screen.gd @@ -30,6 +30,7 @@ var _lobby_motd: Label var _lobby_rules: Label var _lobby_count: Label var _lobby_list: VBoxContainer +var _lobby_note: Label func _ready() -> void: if Net.dedicated: @@ -88,15 +89,31 @@ func _ready() -> void: _pages["lobby"] = _lobby_page() for p in _pages.values(): col.add_child(p) - Net.joined.connect(func(): _show("lobby")) + Net.joined.connect(_enter_server_world) Net.join_failed.connect(_on_join_failed) Net.left.connect(_on_left) Net.players_changed.connect(func(): if _pages["lobby"].visible: _fill_lobby()) _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() +## 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. func _maybe_capture() -> void: var prefix := "" @@ -370,10 +387,10 @@ func _lobby_page() -> Control: _lobby_list = VBoxContainer.new() _lobby_list.add_theme_constant_override("separation", 4) 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) - note.autowrap_mode = TextServer.AUTOWRAP_WORD - note.custom_minimum_size = Vector2(420, 0) - v.add_child(note) + _lobby_note = UITheme.label("", 13, UITheme.TEXT_DIM) + _lobby_note.autowrap_mode = TextServer.AUTOWRAP_WORD + _lobby_note.custom_minimum_size = Vector2(420, 0) + v.add_child(_lobby_note) v.add_child(_button("Disconnect", Net.leave, 180)) return p diff --git a/scripts/world/terrain.gd b/scripts/world/terrain.gd index 34475cf..65d40d0 100644 --- a/scripts/world/terrain.gd +++ b/scripts/world/terrain.gd @@ -69,9 +69,13 @@ var _heights := PackedFloat32Array() var _normals := PackedVector3Array() 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") - 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() _build() diff --git a/scripts/world/world.gd b/scripts/world/world.gd index 2bb9cdd..5396163 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -26,6 +26,7 @@ const PIECES := { "bed": preload("res://scripts/building/bed.gd"), } const BuildPiece = preload("res://scripts/building/build_piece.gd") +const UITheme = preload("res://scripts/ui/theme.gd") 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 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 net_sync: Node ## net_sync.gd when playing on a server, else null var has_bed_spawn := false 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 func _ready() -> void: @@ -157,7 +163,7 @@ func _on_player_died() -> void: player.inventory.load_data(Inventory.new(player.inventory.size()).to_data()) if hud != null: 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: _respawn_at_bed(bed) else: @@ -258,6 +264,10 @@ func _on_terrain_ready() -> void: player.set_physics_process(true) set_physics_process(true) creatures.setup(self) + if is_online(): + _go_online() + _maybe_capture() + return if not "--no-creatures" in OS.get_cmdline_user_args(): creatures.populate() creatures.enabled = true @@ -301,7 +311,13 @@ func save_now() -> bool: return ok 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() get_tree().quit() @@ -421,6 +437,8 @@ func apply_save(s: Dictionary) -> void: _update_grass_cuts() 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.velocity = Vector3.ZERO 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 {} +# --- 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 ## tree, a thrown spear, meat that didn't fit). Saved with the world. 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_creatures(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 ## fishing from the lake shore. diff --git a/tests/net_bot.gd b/tests/net_bot.gd index eeae3fd..a4c9f47 100644 --- a/tests/net_bot.gd +++ b/tests/net_bot.gd @@ -9,6 +9,69 @@ extends SceneTree var _outcome := "" 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: _run.call_deferred() @@ -35,6 +98,8 @@ func _run() -> void: res.joined = true res.server_name = net.server_info.get("server_name", "") 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 stay := float(args.get("stay", 2.0)) var s := 0.0 @@ -44,6 +109,11 @@ func _run() -> void: await create_timer(0.1).timeout s += 0.1 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() for _i in range(10): await process_frame diff --git a/tests/run_all.sh b/tests/run_all.sh index 3c38909..76a97b8 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -4,7 +4,7 @@ # Usage: tests/run_all.sh (from the repo root; needs `godot` on PATH) cd "$(dirname "$0")/.." || exit 1 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="" [ "$t" = "test_boundary" ] && extra="--fixed-fps 30" # A suite that waits on a world that failed to load would hang forever, diff --git a/tests/test_net_world.gd b/tests/test_net_world.gd new file mode 100644 index 0000000..1208f9f --- /dev/null +++ b/tests/test_net_world.gd @@ -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)