M2.2: player bodies, name tags, shared island and checked movement

- Joining loads the server's island (seed, spawn, clock); the server
  generates the same terrain from its seed and keeps a world clock that
  pauses when empty
- Other players are procedural low-poly characters in their chosen colours
  with name tags (hidden by walls, fading with distance), walk/run/swim
  animation and held items
- Movement: local and instant, sent at 20 Hz stamped with the sender's
  clock; the server refuses impossible moves (speed, rising, leaving the
  island, underground) and sends the player back; flagged respawns allowed
- Avatars drawn 100 ms behind and interpolated, with brief extrapolation
- Online: no pausing, player list in the Esc menu, Disconnect back to the
  title (with the reason); animals off and world changes local-only until
  M2.3-M2.5, shown on the HUD
- test_net_world: real server + walking and cheating bots at 100 ms +/-
  30 ms simulated latency (27 checks)
This commit is contained in:
Paul Bailey
2026-11-04 15:08:14 +00:00
parent f62f71658f
commit d0f473e57b
14 changed files with 1093 additions and 29 deletions

View File

@@ -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()

View File

@@ -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.