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//players/.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 # 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)) # A wolf bite or a boar: their screen flashes red, from the right side. p.survival.hurt.connect(func(amount, from): if proxies.has(peer): _s2c_hurt.rpc_id(peer, amount, from)) 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) var w: Node3D = WorldNet.world if w != null and w.bobber_of(p) != null: w.end_fishing(p) proxies.erase(peer) _dirty.erase(peer) _acks.erase(peer) p.queue_free() _maybe_pass_night() # (the last one awake leaving lets the others sleep) 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() if p.in_bed and to.distance_to(p.bed_at) > 1.5: p.in_bed = false tell(p.peer, "You got out of bed") 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))) # --- hunting and fishing (M2.5) --- # Arrows, spears and casts start from where the server knows you are # (your eyes), whatever your game says: only the aim comes from you. "melee": var c: Node3D = CreatureNet.creature(_i(a, 0)) if c != null: Actions.melee(p, c) "butcher": var c: Node3D = CreatureNet.creature(_i(a, 0)) if c != null: Actions.butcher(p, c) "shoot": var dir := _dir(a, 0) var power := clampf(float(a[1]) if a.size() > 1 and (a[1] is float or a[1] is int) else 0.0, 0.0, 1.0) if dir != Vector3.ZERO and Actions.can_draw(p): Actions.shoot(p, _eye(p) + dir * 0.5, dir, power, w) "throw": var dir := _dir(a, 1) if dir != Vector3.ZERO: Actions.throw_spear(p, _i(a, 0), _eye(p) + dir * 0.5 + Vector3(0, -0.05, 0), dir + Vector3(0, 0.06, 0), w) "cast": var dir := _dir(a, 0) if dir != Vector3.ZERO: Actions.cast_line(p, _eye(p) + dir * 0.6, dir, w) "reel": Actions.reel(p, w) "debug_spawn": if cheats and Creature.KINDS.has(_s(a, 0)) and _s(a, 0) != "wolf" and _v(a, 1).is_finite(): w.creatures.spawn(_s(a, 0), _v(a, 1)) "debug_time": if cheats: w.day_night.day = maxi(_i(a, 0), 1) w.day_night.time = clampf(float(a[1]) / 24.0, 0.0, 0.999) if a.size() > 1 else w.day_night.time w.day_night.apply(true) w.weather.snap() Net.push_clock() "debug_tp": var to := _v(a, 0) if cheats and to.is_finite(): p.global_position = to Net.teleport(p.peer, to) _s2c_respawn.rpc_id(p.peer, to, p.rotation.y, "") "debug_hut": if cheats and _v(a, 0).is_finite(): _debug_hut(_v(a, 0)) _: Actions.notify.emit("Unknown request") const Creature = preload("res://scripts/creatures/creature.gd") static func _eye(p: Node3D) -> Vector3: return p.global_position + Vector3(0, 1.6, 0) ## A unit aim direction from the request, or ZERO. static func _dir(a: Array, k: int) -> Vector3: var d := _v(a, k) return d.normalized() if d.is_finite() and d.length() > 0.01 else Vector3.ZERO ## Test hook: a closed hut (floors, walls, door, roof) with two beds in it. func _debug_hut(center: Vector3) -> void: var w: Node3D = WorldNet.world var out: Dictionary = await w.build.debug_build_hut(center, 0.0) if out.pieces.is_empty(): return var top: float = out.pieces[0].global_position.y for x in [-0.6, 0.6]: w.spawn_piece(ItemDB.get_item("bed"), Transform3D(Basis(), Vector3(center.x + x, top, center.z - 0.3))) # --- server: messages, getting hurt, sleeping ---------------------------------------- func tell(peer: int, text: String) -> void: if proxies.has(peer): _s2c_notify.rpc_id(peer, text) func tell_all(text: String) -> void: for peer in proxies: _s2c_notify.rpc_id(peer, text) ## The server's sleep rule (server.json "sleep"): how many of the players ## online must be in bed for the night to pass. func _beds_needed(n: int) -> int: match String(Net.config.v("sleep")): "anyone": return 1 "half": return ceili(n / 2.0) return n # "all_online" (the default) ## Actions.use_bed decided `p` may sleep: they get into bed, and the night ## passes once enough players are in bed. func go_to_bed(p: Node3D) -> void: p.in_bed = true p.bed_at = p.global_position if not _maybe_pass_night(): var n: int = _alive().size() var asleep: int = _alive().filter(func(q): return q.in_bed).size() tell(p.peer, "You're in bed (%d of %d asleep). The night passes when %s." % [asleep, n, "everyone's in bed" if _beds_needed(n) >= n else "%d are" % _beds_needed(n)]) for q in _alive(): if q != p: tell(q.peer, "%s is in bed (%d of %d asleep)" % [p.display, asleep, n]) func _alive() -> Array: return proxies.values().filter(func(q): return not q.survival.dead) ## Everyone in bed (by the rule)? Then until 06:00 for all: the clock, fires, ## regrowth and weather move on; sleepers wake rested. func _maybe_pass_night() -> bool: var w: Node3D = WorldNet.world var live := _alive() if w == null or live.is_empty(): return false var asleep := live.filter(func(q): return q.in_bed) if asleep.size() < _beds_needed(live.size()): return false var secs: float = fposmod(6.0 - w.day_night.hours(), 24.0) / 24.0 * w.day_night.day_length w.skip_time(secs) for q in live: if q.in_bed: q.survival.after_sleep(secs) q.in_bed = false _s2c_slept.rpc_id(q.peer, asleep.has(q)) Net.push_clock() WorldNet.save() return true ## 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) var hurts := 0 ## client: how often the server said we were hurt (tests read it) @rpc("authority", "call_remote", "reliable") func _s2c_hurt(amount: float, from: Vector3) -> void: hurts += 1 if world != null and is_instance_valid(world): world.player.survival.hurt.emit(amount, from) ## The night passed (we were asleep, or up while the others slept). @rpc("authority", "call_remote", "reliable") func _s2c_slept(was_asleep: bool) -> void: if world == null or not is_instance_valid(world): return var text := "You slept until morning" if was_asleep else "The night passed while the others slept" if world.hud != null and was_asleep: world.hud.fade(func(): Actions.notify.emit(text)) else: Actions.notify.emit(text)