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:
@@ -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 |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
161
scripts/net/avatar.gd
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
extends Node3D
|
||||||
|
## Another player, as you see them: their body, the item in their hand and a
|
||||||
|
## name tag, moved smoothly from the server's snapshots.
|
||||||
|
##
|
||||||
|
## Snapshots arrive ~20 times a second, some late and some out of order. The
|
||||||
|
## avatar is drawn INTERP_DELAY behind the newest data and interpolates
|
||||||
|
## between the two snapshots either side of that moment, so it moves smoothly
|
||||||
|
## with jitter. If data stops it carries on briefly along its last velocity
|
||||||
|
## (up to MAX_EXTRAPOLATE), then holds still.
|
||||||
|
|
||||||
|
const CharacterMesh = preload("res://scripts/net/character_mesh.gd")
|
||||||
|
const MAX_EXTRAPOLATE := 0.25
|
||||||
|
const TAG_FADE := Vector2(25.0, 40.0) ## name tags fade out over this distance range
|
||||||
|
|
||||||
|
enum Anim { IDLE, WALK, RUN, SWIM, AIR }
|
||||||
|
|
||||||
|
var peer := 0
|
||||||
|
var info: Dictionary = {}
|
||||||
|
var anim := Anim.IDLE
|
||||||
|
var held := ""
|
||||||
|
var tag: Label3D
|
||||||
|
|
||||||
|
var _buf: Array = [] ## [t, pos, yaw, pitch, anim, held], oldest first
|
||||||
|
var _body: Node3D
|
||||||
|
var _legs: Array[Node3D] = []
|
||||||
|
var _arms: Array[Node3D] = []
|
||||||
|
var _held_mi: MeshInstance3D
|
||||||
|
var _phase := 0.0
|
||||||
|
var _speed := 0.0
|
||||||
|
var _last_pos := Vector3.INF
|
||||||
|
|
||||||
|
func setup(p: int, d: Dictionary) -> void:
|
||||||
|
peer = p
|
||||||
|
info = d
|
||||||
|
name = "Avatar_%d" % p
|
||||||
|
var parts: Dictionary = CharacterMesh.build(d.get("appearance", {}))
|
||||||
|
var mat := ItemDB.item_material()
|
||||||
|
_body = Node3D.new()
|
||||||
|
add_child(_body)
|
||||||
|
var mi := MeshInstance3D.new()
|
||||||
|
mi.mesh = parts.body
|
||||||
|
mi.material_override = mat
|
||||||
|
_body.add_child(mi)
|
||||||
|
for pair in [[parts.hips, parts.leg, _legs], [parts.shoulders, parts.arm, _arms]]:
|
||||||
|
for pos: Vector3 in pair[0]:
|
||||||
|
var pivot := Node3D.new()
|
||||||
|
pivot.position = pos
|
||||||
|
var limb := MeshInstance3D.new()
|
||||||
|
limb.mesh = pair[1]
|
||||||
|
limb.material_override = mat
|
||||||
|
pivot.add_child(limb)
|
||||||
|
_body.add_child(pivot)
|
||||||
|
pair[2].append(pivot)
|
||||||
|
_held_mi = MeshInstance3D.new()
|
||||||
|
_held_mi.material_override = mat
|
||||||
|
_held_mi.position = parts.hand
|
||||||
|
_held_mi.rotation = Vector3(-PI * 0.5, 0, 0) # item handles run along +Y; point them forward
|
||||||
|
_held_mi.scale = Vector3.ONE * 0.85
|
||||||
|
_arms[1].add_child(_held_mi)
|
||||||
|
tag = Label3D.new()
|
||||||
|
tag.text = String(d.get("name", "?"))
|
||||||
|
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||||
|
tag.position = Vector3(0, 2.12, 0)
|
||||||
|
tag.font_size = 44
|
||||||
|
tag.outline_size = 10
|
||||||
|
tag.pixel_size = 0.004
|
||||||
|
tag.modulate = Color(1, 0.97, 0.88)
|
||||||
|
tag.outline_modulate = Color(0, 0, 0, 0.8)
|
||||||
|
tag.no_depth_test = false # walls and hills hide it
|
||||||
|
tag.double_sided = true
|
||||||
|
add_child(tag)
|
||||||
|
|
||||||
|
## A snapshot for this player at server time `t`.
|
||||||
|
func push(t: float, pos: Vector3, yaw: float, pitch: float, a: int, h: String) -> void:
|
||||||
|
if not _buf.is_empty() and t <= _buf[-1][0]:
|
||||||
|
# Late or duplicate: slot it in order (or drop a duplicate).
|
||||||
|
for i in range(_buf.size()):
|
||||||
|
if absf(_buf[i][0] - t) < 0.0001:
|
||||||
|
return
|
||||||
|
if _buf[i][0] > t:
|
||||||
|
_buf.insert(i, [t, pos, yaw, pitch, a, h])
|
||||||
|
return
|
||||||
|
_buf.append([t, pos, yaw, pitch, a, h])
|
||||||
|
if _buf.size() == 1 and is_inside_tree():
|
||||||
|
global_position = pos # first sighting: appear in place, don't slide in
|
||||||
|
rotation.y = yaw
|
||||||
|
|
||||||
|
## Where to draw the avatar at server time `rt` (and animate it).
|
||||||
|
func update_at(rt: float, dt: float) -> void:
|
||||||
|
if _buf.is_empty():
|
||||||
|
return
|
||||||
|
# Forget old data, keeping one sample before `rt`.
|
||||||
|
while _buf.size() > 2 and _buf[1][0] < rt - 1.0:
|
||||||
|
_buf.pop_front()
|
||||||
|
var s: Array = _sample(rt)
|
||||||
|
var p: Vector3 = s[0]
|
||||||
|
global_position = p
|
||||||
|
rotation.y = s[1]
|
||||||
|
anim = s[2]
|
||||||
|
if s[3] != held:
|
||||||
|
held = s[3]
|
||||||
|
_held_mi.mesh = ItemDB.mesh_for(held) if not held.is_empty() and not ItemDB.get_item(held).has("place") else null
|
||||||
|
_animate(p, dt)
|
||||||
|
_fade_tag()
|
||||||
|
|
||||||
|
## [pos, yaw, anim, held] at time `rt`.
|
||||||
|
func _sample(rt: float) -> Array:
|
||||||
|
var first: Array = _buf[0]
|
||||||
|
if rt <= first[0]:
|
||||||
|
return [first[1], first[2], first[4], first[5]]
|
||||||
|
for i in range(_buf.size() - 1):
|
||||||
|
var a: Array = _buf[i]
|
||||||
|
var b: Array = _buf[i + 1]
|
||||||
|
if rt <= b[0]:
|
||||||
|
var k: float = (rt - a[0]) / maxf(b[0] - a[0], 0.0001)
|
||||||
|
return [(a[1] as Vector3).lerp(b[1], k), lerp_angle(a[2], b[2], k), b[4] if k > 0.5 else a[4], b[5]]
|
||||||
|
# Past the newest snapshot: carry on along the last velocity, briefly.
|
||||||
|
var last: Array = _buf[-1]
|
||||||
|
if _buf.size() >= 2:
|
||||||
|
var prev: Array = _buf[-2]
|
||||||
|
var span: float = maxf(last[0] - prev[0], 0.001)
|
||||||
|
var vel: Vector3 = ((last[1] as Vector3) - prev[1]) / span
|
||||||
|
var ahead := minf(rt - last[0], MAX_EXTRAPOLATE)
|
||||||
|
return [(last[1] as Vector3) + vel * ahead, last[2], last[4], last[5]]
|
||||||
|
return [last[1], last[2], last[4], last[5]]
|
||||||
|
|
||||||
|
func _animate(p: Vector3, dt: float) -> void:
|
||||||
|
if _last_pos != Vector3.INF and dt > 0.0:
|
||||||
|
var d := Vector2(p.x - _last_pos.x, p.z - _last_pos.z).length() / dt
|
||||||
|
_speed = lerpf(_speed, d, minf(dt * 10.0, 1.0))
|
||||||
|
_last_pos = p
|
||||||
|
_phase += dt * (2.0 + _speed * 1.6)
|
||||||
|
var swing := clampf(_speed / 5.0, 0.0, 1.0) * (0.75 if anim == Anim.RUN else 0.55)
|
||||||
|
var lean := 0.0
|
||||||
|
match anim:
|
||||||
|
Anim.SWIM:
|
||||||
|
lean = 1.2
|
||||||
|
swing = 0.35
|
||||||
|
Anim.AIR:
|
||||||
|
swing = 0.15
|
||||||
|
Anim.RUN:
|
||||||
|
lean = 0.12
|
||||||
|
_body.rotation.x = lerpf(_body.rotation.x, -lean, minf(dt * 6.0, 1.0))
|
||||||
|
for i in range(2):
|
||||||
|
var side := 1.0 if i == 0 else -1.0
|
||||||
|
_legs[i].rotation.x = sin(_phase) * swing * side
|
||||||
|
_arms[i].rotation.x = -sin(_phase) * swing * side * 0.8
|
||||||
|
if anim == Anim.SWIM:
|
||||||
|
_arms[0].rotation.x = -PI * 0.6 + sin(_phase * 1.5) * 0.6
|
||||||
|
_arms[1].rotation.x = -PI * 0.6 - sin(_phase * 1.5) * 0.6
|
||||||
|
elif not held.is_empty():
|
||||||
|
_arms[1].rotation.x = -0.5 + sin(_phase) * swing * 0.3 # hold the item out a little
|
||||||
|
_body.position.y = absf(sin(_phase)) * 0.03 * clampf(_speed / 5.0, 0.0, 1.0)
|
||||||
|
|
||||||
|
func _fade_tag() -> void:
|
||||||
|
var cam := get_viewport().get_camera_3d() if is_inside_tree() else null
|
||||||
|
if cam == null:
|
||||||
|
return
|
||||||
|
var d := cam.global_position.distance_to(global_position)
|
||||||
|
tag.modulate.a = 1.0 - smoothstep(TAG_FADE.x, TAG_FADE.y, d)
|
||||||
|
tag.visible = tag.modulate.a > 0.01
|
||||||
69
scripts/net/character_mesh.gd
Normal file
69
scripts/net/character_mesh.gd
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Procedural low-poly player character, coloured by the player's appearance
|
||||||
|
## (identity.gd: tunic, hair, beard). Feet at the origin, facing -Z, 1.8 m
|
||||||
|
## tall to match the player capsule.
|
||||||
|
##
|
||||||
|
## build() returns:
|
||||||
|
## body: torso, belt, head, hair and beard (static)
|
||||||
|
## arm / leg: one limb each, hanging from its pivot down -Y
|
||||||
|
## shoulders / hips: the pivot points (left, right)
|
||||||
|
## hand: where a held item goes, in arm space
|
||||||
|
|
||||||
|
const P = preload("res://scripts/world/proc_mesh.gd")
|
||||||
|
const IM = preload("res://scripts/items/item_meshes.gd")
|
||||||
|
const Identity = preload("res://scripts/net/identity.gd")
|
||||||
|
|
||||||
|
const SKIN := Color(0.86, 0.68, 0.55)
|
||||||
|
const TROUSERS := Color(0.30, 0.25, 0.20)
|
||||||
|
const BOOTS := Color(0.20, 0.14, 0.10)
|
||||||
|
const BELT := Color(0.35, 0.22, 0.12)
|
||||||
|
const EYES := Color(0.08, 0.07, 0.06)
|
||||||
|
|
||||||
|
static var _cache := {}
|
||||||
|
|
||||||
|
static func build(appearance: Dictionary) -> Dictionary:
|
||||||
|
var a := Identity.sanitize_appearance(appearance)
|
||||||
|
var key := "%d_%d_%s" % [a.tunic, a.hair, a.beard]
|
||||||
|
if _cache.has(key):
|
||||||
|
return _cache[key]
|
||||||
|
var tunic: Color = Identity.TUNICS[a.tunic]
|
||||||
|
var hair: Color = Identity.HAIR[a.hair]
|
||||||
|
var r := RandomNumberGenerator.new()
|
||||||
|
r.seed = 7
|
||||||
|
var n := FastNoiseLite.new()
|
||||||
|
n.seed = 7
|
||||||
|
n.frequency = 1.5
|
||||||
|
|
||||||
|
var st := P._begin()
|
||||||
|
IM._box(st, Vector3(0, 1.2, 0), Vector3(0.42, 0.56, 0.24), tunic) # tunic
|
||||||
|
IM._box(st, Vector3(0, 0.93, 0), Vector3(0.44, 0.1, 0.26), P._mul(tunic, 0.85)) # skirt hem
|
||||||
|
IM._box(st, Vector3(0, 1.0, 0), Vector3(0.45, 0.06, 0.27), BELT)
|
||||||
|
IM._box(st, Vector3(0, 1.0, -0.135), Vector3(0.07, 0.05, 0.02), Color(0.6, 0.55, 0.4)) # buckle
|
||||||
|
IM._box(st, Vector3(0, 1.5, 0), Vector3(0.12, 0.06, 0.12), SKIN) # neck
|
||||||
|
P._blob(st, Vector3(0, 1.65, 0), Vector3(0.13, 0.15, 0.14), n, 0.05, SKIN, r, 1) # head
|
||||||
|
for s in [-1.0, 1.0]:
|
||||||
|
IM._box(st, Vector3(s * 0.05, 1.67, -0.13), Vector3(0.03, 0.025, 0.02), EYES)
|
||||||
|
IM._box(st, Vector3(0, 1.62, -0.145), Vector3(0.035, 0.05, 0.03), P._mul(SKIN, 0.92)) # nose
|
||||||
|
P._blob(st, Vector3(0, 1.72, 0.02), Vector3(0.145, 0.1, 0.15), n, 0.15, hair, r, 1) # hair
|
||||||
|
IM._box(st, Vector3(0, 1.66, 0.11), Vector3(0.24, 0.14, 0.06), hair) # back of the head
|
||||||
|
if a.beard:
|
||||||
|
IM._box(st, Vector3(0, 1.54, -0.115), Vector3(0.2, 0.13, 0.07), hair)
|
||||||
|
IM._box(st, Vector3(0, 1.47, -0.1), Vector3(0.12, 0.06, 0.05), hair)
|
||||||
|
var body := P._commit(st)
|
||||||
|
|
||||||
|
st = P._begin()
|
||||||
|
IM._box(st, Vector3(0, -0.2, 0), Vector3(0.12, 0.42, 0.13), tunic)
|
||||||
|
IM._box(st, Vector3(0, -0.47, 0), Vector3(0.09, 0.12, 0.1), SKIN)
|
||||||
|
var arm := P._commit(st)
|
||||||
|
|
||||||
|
st = P._begin()
|
||||||
|
IM._box(st, Vector3(0, -0.4, 0), Vector3(0.14, 0.78, 0.15), TROUSERS)
|
||||||
|
IM._box(st, Vector3(0, -0.85, -0.03), Vector3(0.15, 0.14, 0.22), BOOTS)
|
||||||
|
var leg := P._commit(st)
|
||||||
|
|
||||||
|
var out := {"body": body, "arm": arm, "leg": leg,
|
||||||
|
"shoulders": [Vector3(-0.27, 1.43, 0), Vector3(0.27, 1.43, 0)],
|
||||||
|
"hips": [Vector3(-0.1, 0.92, 0), Vector3(0.1, 0.92, 0)],
|
||||||
|
"hand": Vector3(0, -0.5, -0.02)}
|
||||||
|
_cache[key] = out
|
||||||
|
return out
|
||||||
@@ -22,14 +22,28 @@ signal joined ## the server welcomed us
|
|||||||
signal join_failed(reason: String)
|
signal 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
132
scripts/net/net_sync.gd
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
extends Node
|
||||||
|
## In a multiplayer world: sends where you are, shows where everyone else is.
|
||||||
|
## Added by world.gd when the game is connected to a server.
|
||||||
|
##
|
||||||
|
## - Your own movement stays instant (you move locally and tell the server).
|
||||||
|
## The server checks each move; if it refuses one it sends you back to your
|
||||||
|
## last good position.
|
||||||
|
## - Other players are avatars (avatar.gd), drawn INTERP_DELAY behind the
|
||||||
|
## server's clock and interpolated between its snapshots.
|
||||||
|
## - The time of day follows the server, so everyone shares the same sky
|
||||||
|
## (weather follows from the seed and the time).
|
||||||
|
|
||||||
|
const AvatarScript = preload("res://scripts/net/avatar.gd")
|
||||||
|
const INTERP_DELAY := 0.1
|
||||||
|
|
||||||
|
var world: Node3D
|
||||||
|
var player: CharacterBody3D
|
||||||
|
var avatars: Dictionary = {} ## peer -> avatar.gd
|
||||||
|
var corrections := 0
|
||||||
|
var _send_left := 0.0
|
||||||
|
var _offset := INF ## server time minus local time (smoothed)
|
||||||
|
var _teleport_next := false
|
||||||
|
var _root: Node3D
|
||||||
|
|
||||||
|
func setup(w: Node3D) -> void:
|
||||||
|
world = w
|
||||||
|
player = w.player
|
||||||
|
_root = Node3D.new()
|
||||||
|
_root.name = "Avatars"
|
||||||
|
w.add_child(_root)
|
||||||
|
Net.snapshot.connect(_on_snapshot)
|
||||||
|
Net.corrected.connect(_on_corrected)
|
||||||
|
Net.clock.connect(_on_clock)
|
||||||
|
Net.players_changed.connect(_sync_avatars)
|
||||||
|
Net.player_joined.connect(func(n): Actions.notify.emit("%s joined" % n))
|
||||||
|
Net.player_left.connect(func(n): Actions.notify.emit("%s left" % n))
|
||||||
|
if Net.world_info.has("day"):
|
||||||
|
_on_clock(int(Net.world_info.day), float(Net.world_info.time))
|
||||||
|
|
||||||
|
func _exit_tree() -> void:
|
||||||
|
for sig in [[Net.snapshot, _on_snapshot], [Net.corrected, _on_corrected], [Net.clock, _on_clock],
|
||||||
|
[Net.players_changed, _sync_avatars]]:
|
||||||
|
if (sig[0] as Signal).is_connected(sig[1]):
|
||||||
|
(sig[0] as Signal).disconnect(sig[1])
|
||||||
|
|
||||||
|
static func _now() -> float:
|
||||||
|
return Time.get_ticks_msec() / 1000.0
|
||||||
|
|
||||||
|
## Server time we draw other players at.
|
||||||
|
func render_time() -> float:
|
||||||
|
return _now() + _offset - INTERP_DELAY if _offset != INF else -INF
|
||||||
|
|
||||||
|
## Flags the next update as a legitimate jump (respawning), so the server
|
||||||
|
## checks it against the spawn instead of against walking speed.
|
||||||
|
func mark_teleport() -> void:
|
||||||
|
_teleport_next = true
|
||||||
|
|
||||||
|
func _physics_process(delta: float) -> void:
|
||||||
|
_send_left -= delta
|
||||||
|
if _send_left > 0.0:
|
||||||
|
return
|
||||||
|
_send_left = Net.SEND_INTERVAL
|
||||||
|
var cam: Camera3D = player.get_node("Camera3D")
|
||||||
|
Net.send_state(player.global_position, player.rotation.y, cam.rotation.x, anim_state(), player.held_item_id(), _teleport_next)
|
||||||
|
_teleport_next = false
|
||||||
|
|
||||||
|
## What the local player is doing, for other people's view of them.
|
||||||
|
func anim_state() -> int:
|
||||||
|
if player.global_position.y < player.water_level - player.FLOAT_DEPTH + 0.1 and not player.is_on_floor():
|
||||||
|
return AvatarScript.Anim.SWIM
|
||||||
|
if not player.is_on_floor():
|
||||||
|
return AvatarScript.Anim.AIR
|
||||||
|
var flat := Vector2(player.velocity.x, player.velocity.z).length()
|
||||||
|
if flat > 6.0:
|
||||||
|
return AvatarScript.Anim.RUN
|
||||||
|
if flat > 0.5:
|
||||||
|
return AvatarScript.Anim.WALK
|
||||||
|
return AvatarScript.Anim.IDLE
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
var rt := render_time()
|
||||||
|
if rt == -INF:
|
||||||
|
return
|
||||||
|
for a in avatars.values():
|
||||||
|
a.update_at(rt, delta)
|
||||||
|
|
||||||
|
func _on_snapshot(t: float, states: Array) -> void:
|
||||||
|
var sample := t - _now()
|
||||||
|
# Smooth the clock offset so jitter doesn't make avatars judder. Jump
|
||||||
|
# straight to it at first, or if it's way off (a long stall).
|
||||||
|
if _offset == INF or absf(sample - _offset) > 1.0:
|
||||||
|
_offset = sample
|
||||||
|
else:
|
||||||
|
_offset = lerpf(_offset, sample, 0.05)
|
||||||
|
for s in states:
|
||||||
|
var peer := int(s[0])
|
||||||
|
if peer == Net.local_peer:
|
||||||
|
continue
|
||||||
|
var a: Node3D = avatars.get(peer)
|
||||||
|
if a == null:
|
||||||
|
a = _add_avatar(peer)
|
||||||
|
if a == null:
|
||||||
|
continue
|
||||||
|
a.push(float(s[6]), s[1], s[2], s[3], s[4], s[5]) # stamped with when they moved, not when it was sent
|
||||||
|
|
||||||
|
func _add_avatar(peer: int) -> Node3D:
|
||||||
|
if not Net.players.has(peer):
|
||||||
|
return null
|
||||||
|
var a: Node3D = AvatarScript.new()
|
||||||
|
a.setup(peer, Net.players[peer])
|
||||||
|
_root.add_child(a)
|
||||||
|
avatars[peer] = a
|
||||||
|
return a
|
||||||
|
|
||||||
|
func _sync_avatars() -> void:
|
||||||
|
for peer in avatars.keys():
|
||||||
|
if not Net.players.has(peer):
|
||||||
|
avatars[peer].queue_free()
|
||||||
|
avatars.erase(peer)
|
||||||
|
|
||||||
|
func _on_corrected(pos: Vector3) -> void:
|
||||||
|
corrections += 1
|
||||||
|
player.global_position = pos
|
||||||
|
player.velocity = Vector3.ZERO
|
||||||
|
|
||||||
|
func _on_clock(day: int, time: float) -> void:
|
||||||
|
world.day_night.day = day
|
||||||
|
# Small drift: ease the clock across. A big gap (joining, a stall): jump.
|
||||||
|
var diff := wrapf(time - world.day_night.time, -0.5, 0.5)
|
||||||
|
if absf(diff) > 0.01:
|
||||||
|
world.day_night.time = time
|
||||||
|
world.day_night.apply(true)
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
261
tests/test_net_world.gd
Normal 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)
|
||||||
Reference in New Issue
Block a user