extends Node ## Autoload "WorldNet": the shared world (M2.3, docs/MULTIPLAYER_PLAN.md ยง5). ## ## A dedicated server runs the real world scene headless, with no player of its ## own (world.gd `server_mode`). It owns everything in the island that changes: ## trees and rocks (damage, felling, regrowth), pickups, berry bushes, dropped ## 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. ## ## 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) var dropped: Dictionary = {} ## net id -> dropped pickup (both sides) var _next_nid := 1 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 ## Counters the tests read. var received := 0 ## client: world events applied func is_server() -> bool: return Net.is_server() and world != null func is_client() -> bool: return Net.state == "connected" and world != null and is_instance_valid(world) # --- server: the world ----------------------------------------------------------- ## Loads (or creates) the server's world from its config and adds it to the ## tree. Saves live next to server.json, in worlds//. func open_server_world(config: RefCounted) -> Node3D: Saves.root_dir = config.path.get_base_dir().path_join("worlds") var id := String(config.v("world")).validate_filename() if id.is_empty(): id = "server-world" Saves.current_id = Saves.open_or_create(id, int(config.v("seed"))) var w: Node3D = load("res://scenes/world.tscn").instantiate() 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() _next_nid = 1 for p in w.all_pieces(): if p.has_meta("nid"): _next_nid = maxi(_next_nid, int(p.get_meta("nid")) + 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 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) w.props.regrown.connect(_on_regrown) var args: Dictionary = Net.parse_args(OS.get_cmdline_user_args()) if args.has("debug-regrow"): w.props.regrow_scale = float(args["debug-regrow"]) # (tests: things grow back in seconds) print("[server] world '%s' (seed %d): %d pieces, %d dropped items. Saves: %s" % [Saves.current_id, w.terrain.noise_seed, pieces.size(), dropped.size(), ProjectSettings.globalize_path(Saves.root_dir)]) return w func close_server_world() -> void: if world == null: return if Net.is_server() and is_instance_valid(world): save() if is_instance_valid(world): world.queue_free() world = null pieces.clear() dropped.clear() _ready_peers.clear() func save() -> void: _save_left = INF if world != null and is_instance_valid(world) and world.server_mode: world.save_now() 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 ## A player left: they no longer get events. Save while it's fresh. func peer_left(peer: int) -> void: _ready_peers.erase(peer) save() func _process(delta: float) -> void: if not is_server(): return # 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 _blocker_left -= delta if _blocker_left <= 0.0: _blocker_left = 1.0 var at: Array = [] 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 var fires: Array = [] for nid in pieces: var p: Node3D = pieces[nid] if is_instance_valid(p) and p.get("fuel") != null: fires.append([nid, p.fuel]) 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. func _all(method: String, args: Array) -> void: if method != "_s2c_fires" and _save_left == INF: _save_left = SAVE_AFTER 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: 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) 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))} static func _empty(inv: Inventory) -> bool: for s in inv.slots: if s != null: return false return true # --- client -> server ------------------------------------------------------------- @rpc("any_peer", "call_remote", "reliable") func _c2s_world() -> void: var peer := multiplayer.get_remote_sender_id() if not is_server() or not Net.players.has(peer): return _ready_peers[peer] = true _s2c_world.rpc_id(peer, world.collect_shared()) @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 ---------------------------------------------------------------- ## 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) func leave_world() -> void: if not Net.is_server(): world = null 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 "#". 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") func _s2c_world(state: Dictionary) -> void: if not is_client(): return world.apply_shared(state) pieces.clear() dropped.clear() for p in world.all_pieces(): if p.has_meta("nid"): pieces[int(p.get_meta("nid"))] = p for d in world.all_dropped(): if d.has_meta("nid"): dropped[int(d.get_meta("nid"))] = d received += 1 @rpc("authority", "call_remote", "reliable") func _s2c_node(uid: String, hp: float, point: Vector3, from: Vector3) -> void: if not is_client(): return var n: Node3D = world.props.resource_nodes.get(uid) if n != null and is_instance_valid(n): n.apply_remote(hp, point, from) received += 1 @rpc("authority", "call_remote", "reliable") func _s2c_regrown(uid: String) -> void: if not is_client(): return var n: Node3D = world.props.pickups.get(uid, world.props.resource_nodes.get(uid)) if n != null and is_instance_valid(n): world.props.regrow.erase(uid) n.regrow() received += 1 @rpc("authority", "call_remote", "reliable") func _s2c_taken(key: String) -> void: if not is_client(): return var pk := _find_pickup(key) if key.begins_with("#"): dropped.erase(int(key.substr(1))) if pk != null: pk.collected() received += 1 @rpc("authority", "call_remote", "reliable") func _s2c_forage(uid: String, left: float) -> void: if not is_client(): return var bush: Node3D = world.props.forage.get(uid) if bush != null: bush.restore(left) received += 1 @rpc("authority", "call_remote", "reliable") func _s2c_piece_added(e: Dictionary) -> void: if not is_client(): return var p: Node3D = world.add_piece_entry(e) if p != null: pieces[int(e.nid)] = p received += 1 @rpc("authority", "call_remote", "reliable") func _s2c_pieces_removed(nids: Array, fell: Array) -> void: if not is_client(): return var gone: Array = [] for k in nids: var p: Node3D = pieces.get(int(k)) pieces.erase(int(k)) if p != null and is_instance_valid(p): gone.append(p) var collapsed: Array = [] for k in fell: collapsed.append(int(k)) world.remove_pieces_net(gone, collapsed) received += 1 @rpc("authority", "call_remote", "reliable") func _s2c_piece_state(nid_: int, extra: Dictionary) -> void: if not is_client(): return var p: Node3D = pieces.get(nid_) if p == null or not is_instance_valid(p): return if extra.has("open") and p.has_method("set_open"): p.set_open(bool(extra.open)) elif p.has_method("load_extra"): p.load_extra(extra) received += 1 @rpc("authority", "call_remote", "reliable") func _s2c_fires(fires: Array) -> void: if not is_client(): return for f in fires: var p: Node3D = pieces.get(int(f[0])) if p != null and is_instance_valid(p) and p.has_method("load_extra"): p.load_extra({"fuel": float(f[1])}) @rpc("authority", "call_remote", "reliable") func _s2c_chest(nid_: int, data: Array) -> void: if not is_client(): return var p: Node3D = pieces.get(nid_) if p != null and is_instance_valid(p) and p.get("inventory") != null: p.inventory.load_data(data) received += 1 @rpc("authority", "call_remote", "reliable") func _s2c_dropped(e: Dictionary) -> void: if not is_client(): return var d: Node3D = world.add_dropped_entry(e) 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)