M2.4: the server decides online (characters, inventory, stats, death, owned graves, per-player saves); death screen with Respawn after 3 s

This commit is contained in:
Paul Bailey
2026-11-04 19:18:31 +00:00
parent 8e8f10eb96
commit 35a656e996
16 changed files with 1124 additions and 477 deletions

View File

@@ -32,7 +32,7 @@ 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 := 3 ## 3: the shared world (M2.3)
const PROTOCOL := 4 ## 3: the shared world (M2.3); 4: the server decides (M2.4)
const DEFAULT_PORT := 24242
const HANDSHAKE_TIMEOUT := 10.0
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join
@@ -238,6 +238,7 @@ func _on_peer_disconnected(id: int) -> void:
players.erase(id)
print("[server] %s left (%d online)" % [p.name, players.size()])
_s2c_player_left.rpc(id)
PlayerNet.server_leave(id) # saves their character
WorldNet.peer_left(id)
players_changed.emit()
@@ -275,6 +276,15 @@ func _tick_clock(delta: float) -> void:
func player_pos(peer: int) -> Vector3:
return _states[peer].pos if _states.has(peer) else Vector3.INF
func island_spawn() -> Vector3:
return _spawn
## The server moved a player (respawning): their next moves are checked from here.
func teleport(peer: int, pos: Vector3) -> void:
if _states.has(peer):
_states[peer].pos = pos
_states[peer].t = server_time
func _broadcast_snapshot() -> void:
if _states.is_empty():
return
@@ -318,11 +328,13 @@ func _c2s_state(pos: Vector3, yaw: float, pitch: float, anim: int, held: String,
print("[server] moved %s back (%s)" % [players[peer].name, why])
_s2c_correct.rpc_id(peer, s.pos)
return
var from: Vector3 = s.pos
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.held = PlayerNet.held(peer) # (what's really in their hand, not what they say)
PlayerNet.server_moved(peer, from, pos, s.yaw, s.anim, server_time - s.t)
s.t = server_time
s.stamp = stamp
@@ -422,14 +434,19 @@ 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())
# Their character (player_net.gd): back where they left off, or new.
var start: Vector3 = PlayerNet.server_join(peer, id, name)
var yaw: float = PlayerNet.yaw_of(peer)
if start == Vector3.INF:
start = _spawn_for(players.size())
PlayerNet.place_at(peer, start)
players[peer] = p
_states[peer] = {"pos": start, "yaw": 0.0, "pitch": 0.0, "anim": 0, "held": "", "t": server_time, "corr_t": -10.0,
_states[peer] = {"pos": start, "yaw": yaw, "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"))])
# (The world's own seed: an existing world keeps the seed it was made with.)
var world := {"seed": int(_terrain.noise_seed), "worldlet": String(config.v("worldlet")), "spawn": start,
var world := {"seed": int(_terrain.noise_seed), "worldlet": String(config.v("worldlet")), "spawn": start, "yaw": yaw,
"island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time}
_s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world})
for other in players:

453
scripts/net/player_net.gd Normal file
View File

@@ -0,0 +1,453 @@
extends Node
## Autoload "PlayerNet": each player's character on a dedicated server (M2.4,
## docs/MULTIPLAYER_PLAN.md §5). Online, the server decides; offline (single
## player) there's no server and the game decides, exactly as before.
##
## - Your game sends what you want to do: `ask("eat", [slot])`,
## `ask("hit", [tree uid, point])`... (Actions does this for every intent
## while online, instead of acting itself).
## - The server runs the same Actions rule on your character there
## (server_player.gd), and sends back your inventory (after every request,
## and whenever it changes) and your stats (4 times a second).
## - Clicks in your own inventory are also done on your screen straight away,
## so dragging items doesn't wait for the network; the server's copy
## replaces it once it has caught up with every click you sent.
## - Characters are saved per player in the server's world:
## worlds/<world>/players/<player id>.json.
signal cursor_changed(stack) ## client: the server says this is on your cursor
const ServerPlayer = preload("res://scripts/net/server_player.gd")
const STATS_INTERVAL := 0.25
const NOT_YET := "Hunting and fishing aren't on servers yet (they come with the animals)"
# Server.
var proxies: Dictionary = {} ## peer -> server_player.gd
var _acting := 0 ## the peer whose request is being run (for its messages)
var _dirty: Dictionary = {} ## peers whose inventory changed
var _acks: Dictionary = {} ## peer -> last request id handled
var _stats_left := 0.0
var _root: Node3D
var cheats := false ## --debug-cheats (tests only): give items, set stats, die
# Client.
var _seq := 0 ## id of the last request we sent
var world: Node3D
func _ready() -> void:
Actions.notify.connect(_route_notify)
cheats = Net.parse_args(OS.get_cmdline_user_args()).has("debug-cheats")
# --- server --------------------------------------------------------------------
func _route_notify(text: String) -> void:
if Net.is_server() and _acting != 0 and proxies.has(_acting):
_s2c_notify.rpc_id(_acting, text)
func _players_root() -> Node3D:
if _root == null or not is_instance_valid(_root):
_root = Node3D.new()
_root.name = "Players"
WorldNet.world.add_child(_root)
return _root
func _file(id: String) -> String:
return Saves.root_dir.path_join(Saves.current_id).path_join("players").path_join(id + ".json")
## A player came in: their character, from their save if they've been here
## before. Returns where they were (or INF for a new player).
func server_join(peer: int, id: String, display: String) -> Vector3:
var p: Node3D = ServerPlayer.new()
p.name = "P%d" % peer
p.peer = peer
p.player_id = id
p.display = display
_players_root().add_child(p)
p.setup(WorldNet.world)
proxies[peer] = p
_acks[peer] = 0
p.inventory.changed.connect(func(): _dirty[peer] = true; WorldNet.mark_changed())
p.worn_changed.connect(func(): _dirty[peer] = true)
p.survival.died.connect(_on_died.bind(peer))
var at := Vector3.INF
var d := _read(_file(id))
if not d.is_empty():
p.load_data(d)
var t: Node3D = WorldNet.world.terrain
var pos := Vector3(d.pos[0], d.pos[1], d.pos[2]) if d.has("pos") else Vector3.INF
if pos.is_finite() and t.inside_boundary(pos.x, pos.z, 1.0) and pos.y > t.sample_height(pos.x, pos.z) - 1.0:
at = pos
print("[server] %s's character loaded" % display)
if at != Vector3.INF:
p.global_position = at
return at
func place_at(peer: int, pos: Vector3) -> void:
if proxies.has(peer):
proxies[peer].global_position = pos
func yaw_of(peer: int) -> float:
return proxies[peer].rotation.y if proxies.has(peer) else 0.0
func server_leave(peer: int) -> void:
var p: Node3D = proxies.get(peer)
if p == null:
return
if p.survival.dead:
_respawn(peer, false) # left on the death screen: they come back alive
_save(p)
proxies.erase(peer)
_dirty.erase(peer)
_acks.erase(peer)
p.queue_free()
func save_all() -> void:
for p in proxies.values():
_save(p)
func server_reset() -> void:
proxies.clear()
_dirty.clear()
_acks.clear()
_root = null
func _save(p: Node3D) -> void:
var path := _file(p.player_id)
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
var tmp := path + ".tmp"
var f := FileAccess.open(tmp, FileAccess.WRITE)
if f == null:
printerr("[server] can't save %s's character" % p.display)
return
f.store_string(JSON.stringify(p.to_data(), "\t"))
f.close()
DirAccess.rename_absolute(tmp, path)
static func _read(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
return {}
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
return d if d is Dictionary else {}
func held(peer: int) -> String:
return proxies[peer].held_item_id() if proxies.has(peer) else ""
## A move the server accepted: the character follows, and pays stamina for
## sprinting and jumping (the same costs as in your game).
func server_moved(peer: int, from: Vector3, to: Vector3, yaw: float, anim: int, dt: float) -> void:
var p: Node3D = proxies.get(peer)
if p == null:
return
var A := preload("res://scripts/net/avatar.gd").Anim
if anim == A.RUN:
p.survival.use_stamina(14.0 * dt, true)
elif anim == A.AIR and p.anim != A.AIR and to.y > from.y + 0.05:
p.survival.use_stamina(8.0)
p.anim = anim
p.velocity = (to - from) / maxf(dt, 0.001)
p.global_position = to
p.rotation.y = yaw
if to.y < p.water_level - 0.6:
p.survival.soak()
func _process(delta: float) -> void:
if not Net.is_server() or proxies.is_empty():
return
for peer in _dirty.keys():
_push_inv(peer)
_dirty.clear()
_stats_left -= delta
if _stats_left <= 0.0:
_stats_left = STATS_INTERVAL
for peer in proxies:
_push_stats(peer)
func push_all(peer: int) -> void:
_push_inv(peer)
_push_stats(peer)
func _push_inv(peer: int) -> void:
var p: Node3D = proxies.get(peer)
if p != null:
_s2c_inv.rpc_id(peer, p.inventory.to_data(), p.cursor, p.worn, int(_acks.get(peer, 0)))
func _push_stats(peer: int) -> void:
var p: Node3D = proxies.get(peer)
if p == null:
return
var s: Node = p.survival
_s2c_stats.rpc_id(peer, s.to_data(), s.statuses, [s.roofed, s.sheltered, s.heat],
[p.has_bed_spawn, p.bed_spawn])
## Death on the server: everything goes into a grave where they fell (with
## their name on it), and they wake at their bed or the island start.
func _on_died(peer: int) -> void:
var p: Node3D = proxies.get(peer)
var w: Node3D = WorldNet.world
if p == null or w == null:
return
_acting = peer
var pos: Vector3 = p.global_position
pos.y = w.terrain.sample_height(pos.x, pos.z)
if pos.y < w.terrain.water_level:
pos = w.terrain.find_spawn(Vector2(pos.x, pos.z))
if not p.worn.is_empty() and p.inventory.add(p.worn, 1) == 0:
p.worn = ""
if p.cursor != null:
p.inventory.add(p.cursor.id, p.cursor.count)
p.cursor = null
var has_items := false
for st in p.inventory.slots:
if st != null:
has_items = true
if has_items:
var g: Node3D = w.spawn_piece(ItemDB.get_item("grave"), Transform3D(Basis(Vector3.UP, p.rotation.y), pos), {"owner_id": p.player_id, "owner_name": p.display})
g.inventory.load_data(p.inventory.to_data())
p.inventory.load_data(Inventory.new(p.inventory.size()).to_data())
# They stay down on the death screen for a few seconds, then ask to respawn.
p.died_at = Time.get_ticks_msec()
_dirty[peer] = true
_s2c_died.rpc_id(peer, p.survival.cause if not p.survival.cause.is_empty() else "You died", has_items, p.bed_piece(w) != null)
_acting = 0
const RESPAWN_DELAY := 3.0
## Respawn (the death screen's button), once they've been down long enough:
## at their bed if it's still standing, otherwise the island start.
func _respawn(peer: int, tell: bool = true) -> void:
var p: Node3D = proxies.get(peer)
var w: Node3D = WorldNet.world
if p == null or w == null or not p.survival.dead:
return
if tell and Time.get_ticks_msec() - p.died_at < RESPAWN_DELAY * 1000.0 - 100.0:
return # (too soon: the button only unlocks after the delay)
var bed: Node3D = p.bed_piece(w)
var at: Vector3
var yaw := 0.0
if bed != null:
at = bed.global_position + Vector3(0, 0.9, 0)
yaw = bed.global_basis.get_euler().y + PI
else:
if p.has_bed_spawn:
p.has_bed_spawn = false
Actions.notify.emit("Your bed is gone, so you woke at the island start")
at = Net.island_spawn() + Vector3(0, 1.5, 0)
p.survival.reset_after_death()
p.global_position = at
p.rotation.y = yaw
Net.teleport(peer, at)
_dirty[peer] = true
if tell:
_s2c_respawn.rpc_id(peer, at, yaw, "")
_save(p)
# --- server: requests ------------------------------------------------------------
@rpc("any_peer", "call_remote", "reliable")
func _c2s_ask(seq: int, what: String, args: Array) -> void:
var peer := multiplayer.get_remote_sender_id()
var p: Node3D = proxies.get(peer)
if not Net.is_server() or p == null or WorldNet.world == null:
return
_acting = peer
if what == "respawn":
_respawn(peer)
elif not p.survival.dead:
_run(p, what, args)
_acting = 0
_acks[peer] = maxi(int(_acks.get(peer, 0)), seq)
_dirty[peer] = true # (always answer, so the game knows we've caught up)
func _run(p: Node3D, what: String, a: Array) -> void:
var w: Node3D = WorldNet.world
match what:
"select":
p.select(_i(a, 0))
"craft":
Actions.craft(p, _s(a, 0))
"eat":
Actions.eat(p, _i(a, 0))
"wear":
Actions.wear(p, _i(a, 0))
"take_off":
Actions.take_off(p)
"hit":
var n: Node3D = w.props.resource_nodes.get(_s(a, 0))
var at: Vector3 = _v(a, 1)
if n != null and is_instance_valid(n) and at.is_finite() and Actions.harvest_hit(p, n, at):
WorldNet.node_hit(n, at, p.global_position)
"pickup":
var key := _s(a, 0)
var pk: Node3D = WorldNet.find_pickup(key)
if pk != null and not pk.taken and Actions.pickup(p, pk):
WorldNet.taken(key)
"forage":
var b: Node3D = w.props.forage.get(_s(a, 0))
if b != null and Actions.forage(p, b):
WorldNet.foraged(b)
"place":
var xf: Transform3D = a[1] if a.size() > 1 and a[1] is Transform3D else Transform3D()
if xf.origin.is_finite() and xf.origin != Vector3.ZERO:
Actions.place(p, _i(a, 0), xf.orthonormalized(), w)
"dismantle":
var piece := WorldNet.piece(_i(a, 0))
if piece != null:
Actions.dismantle(p, piece)
"door":
var d := WorldNet.piece(_i(a, 0))
if d != null and d.get("open") != null and Actions.toggle_door(p, d):
WorldNet.piece_state(d)
"fuel":
var f := WorldNet.piece(_i(a, 0))
if f != null and f.get("fuel") != null and Actions.add_fuel(p, f):
WorldNet.piece_state(f)
"bed":
var b := WorldNet.piece(_i(a, 0))
if b != null and b.item_id == "bed":
Actions.use_bed(p, b)
"inv_click":
# Slot `a[1]` of your own inventory (a[0] = 0) or a chest's.
var inv: Inventory = p.inventory
if _i(a, 0) != 0:
var c := _container(p, _i(a, 0))
if c == null:
return
inv = c.inventory
var slot := _i(a, 1)
if slot >= 0 and slot < inv.size():
p.cursor = inv.click(slot, p.cursor, _b(a, 2))
"transfer":
# Shift-click: from your inventory into the chest, or the other way.
var c := _container(p, _i(a, 0))
if c == null:
return
var into_chest := _b(a, 2)
var from: Inventory = p.inventory if into_chest else c.inventory
var to: Inventory = c.inventory if into_chest else p.inventory
var slot := _i(a, 1)
if slot >= 0 and slot < from.size():
Actions.transfer(p, from, slot, to, c)
"close":
# The inventory screen closed: what's on the cursor goes back in
# your bag, or at your feet if it's full.
if p.cursor != null:
var left: int = p.inventory.add(p.cursor.id, p.cursor.count)
if left > 0:
w.drop_item(p.cursor.id, left, Transform3D(Basis(), p.global_position + Vector3(0, 0.1, 0)))
p.cursor = null
"debug_give":
if cheats and not ItemDB.get_item(_s(a, 0)).is_empty():
p.inventory.add(_s(a, 0), _i(a, 1))
"debug_clear":
if cheats:
p.inventory.load_data(Inventory.new(p.inventory.size()).to_data())
"debug_stats":
if cheats and a.size() > 0 and a[0] is Dictionary:
for k in a[0]:
if k in ["health", "stamina", "hunger", "warmth"]:
p.survival.set(k, float(a[0][k]))
elif k == "diet" and a[0][k] is Dictionary:
for g in a[0][k]:
if p.survival.diet.has(g):
p.survival.diet[g] = float(a[0][k][g])
"debug_die":
if cheats:
p.survival.damage(10000.0, p.global_position)
"debug_drop":
if cheats and not ItemDB.get_item(_s(a, 0)).is_empty():
w.drop_item(_s(a, 0), maxi(_i(a, 1), 1), Transform3D(Basis(), _v(a, 2)))
_:
Actions.notify.emit(NOT_YET if what in ["hunt", "fish"] else "Unknown request")
## The chest or grave `nid`, if this player may use it right now.
func _container(p: Node3D, nid: int) -> Node3D:
var c := WorldNet.piece(nid)
if c == null or c.get("inventory") == null or not Actions._in_reach(p, c, 1.0):
return null
var why: String = c.use_block_reason(p.player_id) if c.has_method("use_block_reason") else ""
if not why.is_empty():
Actions.notify.emit(why)
return null
return c
static func _i(a: Array, k: int) -> int:
return int(a[k]) if a.size() > k and (a[k] is int or a[k] is float) else -1
static func _s(a: Array, k: int) -> String:
return String(a[k]) if a.size() > k and a[k] is String else ""
static func _b(a: Array, k: int) -> bool:
return a.size() > k and a[k] is bool and a[k]
static func _v(a: Array, k: int) -> Vector3:
return a[k] if a.size() > k and a[k] is Vector3 else Vector3.INF
# --- client ----------------------------------------------------------------------
func is_client() -> bool:
return WorldNet.is_client()
## Our world is online: follow the server's view of us.
func join_world(w: Node3D) -> void:
world = w
_seq = 0
w.player.survival.set_process(false) # the server runs our stats
w.player.selected_changed.connect(_on_selected)
_on_selected()
func leave_world() -> void:
if world != null and is_instance_valid(world) and world.player.selected_changed.is_connected(_on_selected):
world.player.selected_changed.disconnect(_on_selected)
world = null
func _on_selected() -> void:
if world != null:
ask("select", [world.player.selected])
## Sends a request to the server (Actions calls this while online).
func ask(what: String, args: Array = []) -> void:
if Net.state != "connected":
return
_seq += 1
_c2s_ask.rpc_id(1, _seq, what, args)
@rpc("authority", "call_remote", "reliable")
func _s2c_inv(data: Array, cursor: Variant, worn: String, ack: int) -> void:
if world == null or not is_instance_valid(world) or ack < _seq:
return # we've asked for more since: the next copy will include it
var pl: CharacterBody3D = world.player
pl.inventory.load_data(data)
if pl.worn != worn:
pl.worn = worn
pl.worn_changed.emit()
cursor_changed.emit(cursor)
@rpc("authority", "call_remote", "reliable")
func _s2c_stats(stats: Dictionary, statuses: Array, env: Array, bed: Array) -> void:
if world == null or not is_instance_valid(world):
return
var s: Node = world.player.survival
s.load_data(stats)
s.statuses.assign(statuses)
s.roofed = bool(env[0])
s.sheltered = bool(env[1])
s.heat = float(env[2])
world.has_bed_spawn = bool(bed[0])
world.bed_spawn = bed[1]
@rpc("authority", "call_remote", "reliable")
func _s2c_died(cause: String, has_items: bool, to_bed: bool) -> void:
if world != null and is_instance_valid(world):
world.died_online(cause, has_items, to_bed)
@rpc("authority", "call_remote", "reliable")
func _s2c_respawn(at: Vector3, yaw: float, text: String) -> void:
if world == null or not is_instance_valid(world):
return
world.respawn_from_server(at, yaw)
if not text.is_empty():
Actions.notify.emit(text)
@rpc("authority", "call_remote", "reliable")
func _s2c_notify(text: String) -> void:
Actions.notify.emit(text)

View File

@@ -0,0 +1,91 @@
extends Node3D
## One player's character on a dedicated server (M2.4): what they carry, what
## they hold, what they wear, their bed, and their survival stats, which run
## here (warmth from the server's fires and buildings, hunger, diet...).
##
## It has the same fields and functions the Actions rules use on the local
## player in single-player (inventory, selected, worn, held_item_id,
## survival...), so the server runs exactly the same rules for everyone.
## It has no body: where it is comes from the player's moves (net.gd).
## Saved as players/<player id>.json in the server's world (player_net.gd).
signal worn_changed
signal selected_changed
const HOTBAR := 8
var peer := 0
var player_id := ""
var display := ""
var inventory := Inventory.new(40)
var selected := 0
var worn := ""
var cursor = null ## the stack held on the inventory screen's cursor
var survival: Node
var hands: Node ## (self: held_heat for a carried torch)
var velocity := Vector3.ZERO
var water_level := -INF
var has_bed_spawn := false
var bed_spawn := Vector3.ZERO
var anim := 0 ## last movement state reported (avatar.gd Anim)
var died_at := 0 ## ticks (ms) when they last died
func setup(world: Node3D) -> void:
hands = self
water_level = world.terrain.water_level
survival = preload("res://scripts/player/survival.gd").new()
survival.name = "Survival"
survival.player = self
survival.world = world
add_child(survival)
func held_item_id() -> String:
var s = inventory.get_slot(selected)
return s.id if s != null else ""
func select(i: int) -> void:
selected = wrapi(i, 0, HOTBAR)
selected_changed.emit()
func held_heat() -> float:
return float(ItemDB.get_item(held_item_id()).get("light", {}).get("heat", 0.0))
## Chests and graves call this on interact; on the server there's no screen.
func open_inventory(_container: Node) -> void:
pass
func claims_bed(bed: Node3D) -> bool:
return has_bed_spawn and bed_spawn.distance_to(bed.global_position) < 0.3
func claim_bed(bed: Node3D) -> void:
has_bed_spawn = true
bed_spawn = bed.global_position
## The bed this player respawns at, if it's still standing.
func bed_piece(world: Node3D) -> Node3D:
if not has_bed_spawn:
return null
for p in world.all_pieces():
if p.item_id == "bed" and claims_bed(p):
return p
return null
func to_data() -> Dictionary:
return {"name": display, "pos": [global_position.x, global_position.y, global_position.z], "yaw": rotation.y,
"inventory": inventory.to_data(), "selected": selected, "stats": survival.to_data(), "worn": worn,
"bed": {"has": has_bed_spawn, "pos": [bed_spawn.x, bed_spawn.y, bed_spawn.z]}}
func load_data(d: Dictionary) -> void:
if d.has("inventory"):
inventory.load_data(d.inventory)
selected = int(d.get("selected", 0))
if d.has("stats"):
survival.load_data(d.stats)
worn = String(d.get("worn", ""))
if not worn.is_empty() and ItemDB.get_item(worn).is_empty():
worn = ""
var b: Dictionary = d.get("bed", {})
has_bed_spawn = bool(b.get("has", false))
if b.has("pos"):
bed_spawn = Vector3(b.pos[0], b.pos[1], b.pos[2])
rotation.y = float(d.get("yaw", 0.0))

View File

@@ -7,26 +7,19 @@ extends Node
## items, building pieces, chests and graves, fires, doors and the clock. The
## weather follows from the seed and the clock, so it needs no syncing.
##
## Clients ask; the server checks, applies and tells everyone:
## client -> server _c2s_hit uid server -> all _s2c_node uid hp
## client -> server _c2s_place item server -> all _s2c_piece_added {...}
## ...and so on for each kind of change (the _c2s_* / _s2c_* pairs below).
## When a change earns the asker something (wood from a tree, a pickup, a
## dismantled wall back), the server sends it to them with _s2c_give.
##
## Inventories, stats and crafting stay on each player's own game until M2.4,
## so the server trusts what a client says it puts into the world (a placed
## wall, a chest deposit) but decides everything it takes out.
## Players' requests arrive through PlayerNet (player_net.gd), which runs the
## Actions rules on that player's character on the server. The results show
## up here and go to everyone:
## - pieces placed, removed or collapsed, and items dropped: the server's
## world reports them (world.gd signals), wherever they came from;
## - hits, pickups, berries, doors and fires: PlayerNet tells us;
## - chest and grave contents: whenever they change.
##
## On joining, a client's world asks for a snapshot (_c2s_world) once its
## scenery is up; from then on it gets every change, in order.
const REACH := 3.2 + 2.5 ## Actions._in_reach, plus slack for latency
const SLACK := 2.0
const MAX_HIT := 12.0 ## no tool hits harder than this
const FIRE_SYNC := 10.0 ## seconds between fire fuel updates
const SAVE_AFTER := 10.0 ## seconds after a change before saving
const FUELS := ["wood", "stick", "fiber"]
var world: Node3D ## server: its world; client: ours while online
var pieces: Dictionary = {} ## net id -> piece (both sides)
@@ -36,8 +29,7 @@ var _ready_peers: Dictionary = {} ## server: peers that have their snapshot
var _fire_left := FIRE_SYNC
var _blocker_left := 0.0
var _save_left := INF ## counting down to a save after a change
var _chest_cb := Callable() ## client: waiting for a chest click's result
var chest_busy := false
var _chests_dirty: Dictionary = {}
## Counters the tests read.
var received := 0 ## client: world events applied
@@ -62,24 +54,23 @@ func open_server_world(config: RefCounted) -> Node3D:
w.server_mode = true
w.day_length = float(config.v("day_length_minutes")) * 60.0
w.autosave_seconds = maxf(float(config.v("autosave_minutes")), 0.5) * 60.0
get_tree().root.add_child(w)
world = w
pieces.clear()
dropped.clear()
_ready_peers.clear()
_chests_dirty.clear()
_next_nid = 1
world = w
get_tree().root.add_child(w) # (loads the save: pieces come in through _on_piece_added)
for p in w.all_pieces():
if p.has_meta("nid"):
_next_nid = maxi(_next_nid, int(p.get_meta("nid")) + 1)
_next_nid = maxi(_next_nid, int(p.get_meta("nid", 0)) + 1)
for p in w.all_pieces():
if not p.has_meta("nid"):
p.set_meta("nid", _new_nid())
pieces[int(p.get_meta("nid"))] = p
_register(p)
for d in w.all_dropped():
if not d.has_meta("nid"):
d.set_meta("nid", _new_nid())
dropped[int(d.get_meta("nid"))] = d
_next_nid = maxi(_next_nid, _max_nid() + 1)
_register_dropped(d)
w.piece_added.connect(_on_piece_added)
w.pieces_removed.connect(_on_pieces_removed)
w.dropped_added.connect(_on_dropped_added)
w.saved.connect(PlayerNet.save_all)
w.props.regrown.connect(_on_regrown)
var args: Dictionary = Net.parse_args(OS.get_cmdline_user_args())
if args.has("debug-regrow"):
@@ -99,23 +90,35 @@ func close_server_world() -> void:
pieces.clear()
dropped.clear()
_ready_peers.clear()
PlayerNet.server_reset()
func save() -> void:
_save_left = INF
if world != null and is_instance_valid(world) and world.server_mode:
world.save_now()
world.save_now() # (players too: world.saved)
## Something changed: save within a few seconds. Ctrl+C kills Godot without
## a chance to save, so this keeps the loss small.
func mark_changed() -> void:
if is_server() and _save_left == INF:
_save_left = SAVE_AFTER
func _new_nid() -> int:
_next_nid += 1
return _next_nid - 1
func _max_nid() -> int:
var m := 0
for k in pieces:
m = maxi(m, k)
for k in dropped:
m = maxi(m, k)
return m
func _register(p: Node3D) -> void:
if not p.has_meta("nid"):
p.set_meta("nid", _new_nid())
pieces[int(p.get_meta("nid"))] = p
if p.get("inventory") != null:
var nid := int(p.get_meta("nid"))
p.inventory.changed.connect(func(): _chests_dirty[nid] = true; mark_changed())
func _register_dropped(d: Node3D) -> void:
if not d.has_meta("nid"):
d.set_meta("nid", _new_nid())
dropped[int(d.get_meta("nid"))] = d
## A player left: they no longer get events. Save while it's fresh.
func peer_left(peer: int) -> void:
@@ -125,11 +128,17 @@ func peer_left(peer: int) -> void:
func _process(delta: float) -> void:
if not is_server():
return
for nid in _chests_dirty.keys():
_flush_chest(nid)
_chests_dirty.clear()
# Pause the island while nobody's on (fires, regrowth, the clock).
var paused: bool = Net.players.is_empty() and bool(Net.config.v("pause_when_empty"))
world.process_mode = Node.PROCESS_MODE_DISABLED if paused else Node.PROCESS_MODE_INHERIT
if paused:
return
_save_left -= delta
if _save_left <= 0.0:
save()
_blocker_left -= delta
if _blocker_left <= 0.0:
_blocker_left = 1.0
@@ -137,10 +146,6 @@ func _process(delta: float) -> void:
for peer in Net.players:
at.append(Net.player_pos(peer))
world.props.blockers = at # nothing regrows on top of a player
_save_left -= delta
if _save_left <= 0.0:
_save_left = INF
save()
_fire_left -= delta
if _fire_left <= 0.0:
_fire_left = FIRE_SYNC
@@ -152,54 +157,77 @@ func _process(delta: float) -> void:
if not fires.is_empty():
_all("_s2c_fires", [fires])
## Sends to every player who has their snapshot. Any change to the island
## also means saving soon: Ctrl+C kills Godot without a chance to save, so
## this keeps the loss to a few seconds.
## Sends to every player who has their snapshot.
func _all(method: String, args: Array) -> void:
if method != "_s2c_fires" and _save_left == INF:
_save_left = SAVE_AFTER
if method != "_s2c_fires":
mark_changed()
for peer in _ready_peers:
callv("rpc_id", [peer, method] + args)
func _give_to(peer: int, items: Dictionary) -> void:
var clean := {}
for id in items:
if int(items[id]) > 0:
clean[id] = int(items[id])
if not clean.is_empty():
_s2c_give.rpc_id(peer, clean)
func _tell(peer: int, text: String) -> void:
_s2c_notify.rpc_id(peer, text)
## The asker, if they're a player in the world (else 0).
func _asker() -> int:
var peer := multiplayer.get_remote_sender_id()
if not is_server() or not _ready_peers.has(peer):
return 0
return peer
func _near(peer: int, at: Vector3, r: float) -> bool:
return Net.player_pos(peer).distance_to(at) <= r + SLACK
func _piece(nid: int) -> Node3D:
func piece(nid: int) -> Node3D:
var p: Node3D = pieces.get(nid)
if p == null or not is_instance_valid(p) or p.is_queued_for_deletion():
pieces.erase(nid)
return null
return p
func _entry(p: Node3D) -> Dictionary:
return world.piece_entry(p)
## A pickup by the key players use: its scenery id, or "#<net id>" if it was dropped.
func find_pickup(key: String) -> Node3D:
var pk: Node3D
if key.begins_with("#"):
pk = dropped.get(int(key.substr(1)))
else:
pk = world.props.pickups.get(key) if world != null else null
return pk if pk != null and is_instance_valid(pk) else null
static func _clean_stack(s: Variant) -> Variant:
if not s is Dictionary:
return null
var id := String(s.get("id", ""))
var n := int(s.get("count", 0))
if ItemDB.get_item(id).is_empty() or n <= 0:
return null
return {"id": id, "count": mini(n, ItemDB.stack_size(id))}
# --- server: telling everyone ----------------------------------------------------
func node_hit(n: Node3D, point: Vector3, from: Vector3) -> void:
_all("_s2c_node", [String(n.uid), n.hp, point, from])
func taken(key: String) -> void:
if key.begins_with("#"):
dropped.erase(int(key.substr(1)))
_all("_s2c_taken", [key])
func foraged(bush: Node3D) -> void:
_all("_s2c_forage", [String(bush.uid), bush.regrow_left])
func piece_state(p: Node3D) -> void:
if p.has_method("save_extra"):
_all("_s2c_piece_state", [int(p.get_meta("nid", 0)), p.save_extra()])
func _on_piece_added(p: Node3D) -> void:
_register(p)
_all("_s2c_piece_added", [world.piece_entry(p)])
func _on_pieces_removed(gone: Array, fell: Array) -> void:
var g: Array = []
var f: Array = []
for p in gone:
var nid := int(p.get_meta("nid", 0))
pieces.erase(nid)
_chests_dirty.erase(nid)
g.append(nid)
if p in fell:
f.append(nid)
_all("_s2c_pieces_removed", [g, f])
func _on_dropped_added(d: Node3D) -> void:
_register_dropped(d)
_all("_s2c_dropped", [world.dropped_entry(d)])
func _on_regrown(uid: String) -> void:
_all("_s2c_regrown", [uid])
func _flush_chest(nid: int) -> void:
var p := piece(nid)
if p == null:
return
_all("_s2c_chest", [nid, p.inventory.to_data()])
# An emptied grave crumbles.
if p.item_id == "grave" and _empty(p.inventory):
world.remove_piece(p, null)
static func _empty(inv: Inventory) -> bool:
for s in inv.slots:
@@ -207,8 +235,6 @@ static func _empty(inv: Inventory) -> bool:
return false
return true
# --- client -> server -------------------------------------------------------------
@rpc("any_peer", "call_remote", "reliable")
func _c2s_world() -> void:
var peer := multiplayer.get_remote_sender_id()
@@ -216,216 +242,15 @@ func _c2s_world() -> void:
return
_ready_peers[peer] = true
_s2c_world.rpc_id(peer, world.collect_shared())
PlayerNet.push_all(peer)
@rpc("any_peer", "call_remote", "reliable")
func _c2s_hit(uid: String, damage: float, point: Vector3) -> void:
var peer := _asker()
var n: Node3D = world.props.resource_nodes.get(uid) if peer != 0 else null
if n == null or not is_instance_valid(n) or n.depleted:
return
if not _near(peer, point, 3.2 + 1.0) or point.distance_to(n.global_position) > 6.0:
return
var from: Vector3 = Net.player_pos(peer)
var drops: Dictionary = n.hit(clampf(damage, 0.0, MAX_HIT), point, from)
_give_to(peer, drops)
_all("_s2c_node", [uid, n.hp, point, from])
@rpc("any_peer", "call_remote", "reliable")
func _c2s_take(key: String) -> void:
var peer := _asker()
if peer == 0:
return
var pk: Node3D = _find_pickup(key)
if pk == null or pk.taken or not _near(peer, pk.global_position, REACH):
return
var got := {pk.item_id: pk.amount}
if key.begins_with("#"):
dropped.erase(int(key.substr(1)))
pk.collected()
_give_to(peer, got)
_all("_s2c_taken", [key])
func _find_pickup(key: String) -> Node3D:
var pk: Node3D
if key.begins_with("#"):
pk = dropped.get(int(key.substr(1)))
else:
pk = world.props.pickups.get(key) if world != null else null
return pk if pk != null and is_instance_valid(pk) else null
@rpc("any_peer", "call_remote", "reliable")
func _c2s_forage(uid: String) -> void:
var peer := _asker()
var bush: Node3D = world.props.forage.get(uid) if peer != 0 else null
if bush == null or not bush.ripe() or not _near(peer, bush.global_position, REACH):
return
_give_to(peer, bush.harvest())
_all("_s2c_forage", [uid, bush.regrow_left])
## Places `item` at `xf`. The client has already taken it out of its
## inventory: if it can't go here, it's given back. A grave carries what the
## player was holding when they died.
@rpc("any_peer", "call_remote", "reliable")
func _c2s_place(item: String, xf: Transform3D, contents: Array) -> void:
var peer := _asker()
if peer == 0:
return
var def: Dictionary = ItemDB.get_item(item)
if def.is_empty() or not def.has("place"):
return
# (A grave can be further off: drowning puts it on the nearest shore.)
var ok := xf.origin.is_finite() and _near(peer, xf.origin, 40.0 if item == "grave" else 8.0)
if item == "grave":
ok = ok and not contents.is_empty()
else:
ok = ok and world.placement_valid(def, xf)
if not ok:
if item != "grave":
_give_to(peer, {item: 1})
_tell(peer, "Can't place that here")
return
var p: Node3D = world.spawn_piece(def, xf.orthonormalized())
if item == "grave" and p.get("inventory") != null:
var clean: Array = []
for s in contents.slice(0, p.inventory.size()):
clean.append(_clean_stack(s))
p.inventory.load_data(clean)
p.set_meta("nid", _new_nid())
pieces[int(p.get_meta("nid"))] = p
_all("_s2c_piece_added", [_entry(p)])
@rpc("any_peer", "call_remote", "reliable")
func _c2s_dismantle(nid: int) -> void:
var peer := _asker()
var p: Node3D = _piece(nid) if peer != 0 else null
if p == null or not _near(peer, p.global_position, 8.0) or p.item_id == "grave":
return
var why: String = p.dismantle_block_reason()
if not why.is_empty():
_tell(peer, why)
return
# Whatever loses its support collapses: those come back to you too.
var items := {}
for k in pieces:
if _piece(k) != null:
items[k] = pieces[k].item_id
world.remove_piece(p, null)
var gone: Array = []
var fell: Array = []
var refund := {}
for k in items:
if _piece(k) == null:
gone.append(k)
refund[items[k]] = refund.get(items[k], 0) + 1
if k != nid:
fell.append(k)
_all("_s2c_pieces_removed", [gone, fell])
_give_to(peer, refund)
@rpc("any_peer", "call_remote", "reliable")
func _c2s_door(nid: int) -> void:
var peer := _asker()
var p: Node3D = _piece(nid) if peer != 0 else null
if p == null or p.get("open") == null or not _near(peer, p.global_position, REACH + 1.0):
return
p.set_open(not p.open)
_all("_s2c_piece_state", [nid, p.save_extra()])
@rpc("any_peer", "call_remote", "reliable")
func _c2s_fuel(nid: int, item: String) -> void:
var peer := _asker()
if peer == 0 or not item in FUELS:
return
var p := _piece(nid)
if p == null or p.get("fuel") == null or not _near(peer, p.global_position, REACH) \
or p.fuel >= p.MAX_FUEL - 1.0:
_give_to(peer, {item: 1})
return
p.add_fuel(float(ItemDB.get_item(item).get("fuel", 10)))
_all("_s2c_piece_state", [nid, p.save_extra()])
## A click on a chest slot while holding `cursor`: the same as clicking in
## your own inventory (pick up, put down, swap). The new cursor comes back.
@rpc("any_peer", "call_remote", "reliable")
func _c2s_chest_click(nid: int, slot: int, cursor: Variant, right: bool) -> void:
var peer := _asker()
if peer == 0:
return
var c: Variant = _clean_stack(cursor)
var p := _piece(nid)
if p == null or p.get("inventory") == null or slot < 0 or slot >= p.inventory.size() \
or not _near(peer, p.global_position, REACH + 1.0):
_s2c_cursor.rpc_id(peer, c) # nothing happened: keep what you were holding
return
var res: Variant = p.inventory.click(slot, c, right)
_s2c_cursor.rpc_id(peer, res)
_chest_changed(nid, p)
## Shift-click from your inventory: as much of `stack` as fits goes in, the
## rest comes back.
@rpc("any_peer", "call_remote", "reliable")
func _c2s_chest_put(nid: int, stack: Variant) -> void:
var peer := _asker()
var s: Variant = _clean_stack(stack)
if peer == 0 or s == null:
return
var p := _piece(nid)
if p == null or p.get("inventory") == null or not _near(peer, p.global_position, REACH + 1.0):
_give_to(peer, {s.id: s.count})
return
var left: int = p.inventory.add(s.id, s.count)
_give_to(peer, {s.id: left})
_chest_changed(nid, p)
## Shift-click on a chest slot: up to `room` of it comes to you.
@rpc("any_peer", "call_remote", "reliable")
func _c2s_chest_take(nid: int, slot: int, room: int) -> void:
var peer := _asker()
var p: Node3D = _piece(nid) if peer != 0 else null
if p == null or p.get("inventory") == null or slot < 0 or slot >= p.inventory.size() or room <= 0 \
or not _near(peer, p.global_position, REACH + 1.0):
return
var s: Variant = p.inventory.get_slot(slot)
if s == null:
return
var id: String = s.id
var n: int = p.inventory.take_from(slot, room)
_give_to(peer, {id: n})
_chest_changed(nid, p)
func _chest_changed(nid: int, p: Node3D) -> void:
_all("_s2c_chest", [nid, p.inventory.to_data()])
# An emptied grave crumbles.
if p.item_id == "grave" and _empty(p.inventory):
world.remove_piece(p, null)
pieces.erase(nid)
_all("_s2c_pieces_removed", [[nid], []])
## Something the player put down in the world: an arrow that landed, a fish
## that didn't fit. Anyone can pick it up.
@rpc("any_peer", "call_remote", "reliable")
func _c2s_drop(item: String, count: int, xf: Transform3D) -> void:
var peer := _asker()
if peer == 0 or ItemDB.get_item(item).is_empty() or count <= 0 or count > 999 \
or not xf.origin.is_finite() or not _near(peer, xf.origin, 60.0):
return # (60 m: an arrow can land a long way off)
var d: Node3D = world.drop_item(item, count, xf.orthonormalized())
d.set_meta("nid", _new_nid())
dropped[int(d.get_meta("nid"))] = d
_all("_s2c_dropped", [world.dropped_entry(d)])
func _on_regrown(uid: String) -> void:
_all("_s2c_regrown", [uid])
# --- client: asking ----------------------------------------------------------------
# --- client ------------------------------------------------------------------------
## Our world is up: ask for the shared state.
func join_world(w: Node3D) -> void:
world = w
pieces.clear()
dropped.clear()
chest_busy = false
_chest_cb = Callable()
received = 0
_c2s_world.rpc_id(1)
@@ -435,49 +260,13 @@ func leave_world() -> void:
pieces.clear()
dropped.clear()
func hit(node: Node3D, damage: float, point: Vector3) -> void:
_c2s_hit.rpc_id(1, String(node.uid), damage, point)
## The key the server knows a pickup by: its scenery id, or "#<net id>".
func pickup_key(pk: Node3D) -> String:
return "#%d" % int(pk.get_meta("nid")) if pk.has_meta("nid") else String(pk.uid)
func take(pk: Node3D) -> void:
_c2s_take.rpc_id(1, pickup_key(pk))
func forage(bush: Node3D) -> void:
_c2s_forage.rpc_id(1, String(bush.uid))
func place(item: String, xf: Transform3D, contents: Array = []) -> void:
_c2s_place.rpc_id(1, item, xf, contents)
static func nid(p: Node3D) -> int:
return int(p.get_meta("nid", 0))
func dismantle(p: Node3D) -> void:
_c2s_dismantle.rpc_id(1, nid(p))
func door(p: Node3D) -> void:
_c2s_door.rpc_id(1, nid(p))
func fuel(p: Node3D, item: String) -> void:
_c2s_fuel.rpc_id(1, nid(p), item)
## `done(cursor)` gets what you're holding afterwards.
func chest_click(p: Node3D, slot: int, cursor: Variant, right: bool, done: Callable) -> void:
chest_busy = true
_chest_cb = done
_c2s_chest_click.rpc_id(1, nid(p), slot, cursor, right)
func chest_put(p: Node3D, stack: Dictionary) -> void:
_c2s_chest_put.rpc_id(1, nid(p), stack)
func chest_take(p: Node3D, slot: int, room: int) -> void:
_c2s_chest_take.rpc_id(1, nid(p), slot, room)
func drop(item: String, count: int, xf: Transform3D) -> void:
_c2s_drop.rpc_id(1, item, count, xf)
# --- server -> client ------------------------------------------------------------
@rpc("authority", "call_remote", "reliable")
@@ -518,7 +307,7 @@ func _s2c_regrown(uid: String) -> void:
func _s2c_taken(key: String) -> void:
if not is_client():
return
var pk := _find_pickup(key)
var pk := find_pickup(key)
if key.begins_with("#"):
dropped.erase(int(key.substr(1)))
if pk != null:
@@ -598,29 +387,3 @@ func _s2c_dropped(e: Dictionary) -> void:
if d != null:
dropped[int(e.nid)] = d
received += 1
## Items the server decided we earned. Anything that doesn't fit goes back
## on the ground at our feet (for anyone to pick up).
@rpc("authority", "call_remote", "reliable")
func _s2c_give(items: Dictionary) -> void:
if not is_client():
return
for id in items:
var n := int(items[id])
var got: int = Actions._give(world.player, String(id), n)
if got < n:
drop(String(id), n - got, Transform3D(Basis(), world.player.global_position + Vector3(0, 0.1, 0)))
@rpc("authority", "call_remote", "reliable")
func _s2c_cursor(stack: Variant) -> void:
chest_busy = false
var cb := _chest_cb
_chest_cb = Callable()
if cb.is_valid():
cb.call(stack)
elif stack != null and is_client():
_s2c_give({stack.id: stack.count})
@rpc("authority", "call_remote", "reliable")
func _s2c_notify(text: String) -> void:
Actions.notify.emit(text)