M2.3: the shared world (trees, pickups, pieces, chests, fires, doors, graves, clock on the server)

This commit is contained in:
Paul Bailey
2026-11-04 18:12:49 +00:00
parent 74cbf96458
commit 0c18b6d49e
17 changed files with 1672 additions and 94 deletions

View File

@@ -17,6 +17,8 @@ func interact(player: Node) -> void:
player.open_inventory(self)
func _on_changed() -> void:
if WorldNet.world != null:
return # online, the server crumbles it (world_net.gd)
for s in inventory.slots:
if s != null:
return

View File

@@ -53,6 +53,12 @@ func _give(player: Node, id: String, n: int) -> int:
notify.emit("Inventory full")
return got
## Playing on a server: changes to the island go to it (world_net.gd), and
## come back to everyone, us included. Our own inventory and stats stay here
## until M2.4.
func _online() -> bool:
return WorldNet.is_client()
func _in_reach(player: Node3D, target: Node3D, extra: float = 0.0) -> bool:
return player.global_position.distance_to(target.global_position) <= REACH + 2.5 + extra
@@ -104,6 +110,12 @@ func pickup(player: Node3D, pickup_node: Node3D) -> bool:
if not is_instance_valid(pickup_node) or not _in_reach(player, pickup_node):
return false
var id: String = pickup_node.item_id
if _online():
if player.inventory.space_for(id) < 1:
notify.emit("Inventory full")
return false
WorldNet.take(pickup_node) # first come, first served: the server decides
return true
if _give(player, id, pickup_node.amount) <= 0:
return false
pickup_node.collected()
@@ -123,6 +135,9 @@ func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool:
if not player.survival.use_stamina(6.0):
notify.emit("Too tired")
return false
if _online():
WorldNet.hit(node, float(tool.get("damage", 1.0)), hit_point)
return true
var drops: Dictionary = node.hit(float(tool.get("damage", 1.0)), hit_point, player.global_position)
for id in drops:
_give(player, id, int(drops[id]))
@@ -145,6 +160,9 @@ func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool:
notify.emit("Can't place that here")
return false
player.inventory.take_from(slot, 1)
if _online():
WorldNet.place(id, xf) # (given back if the server says no)
return true
world.spawn_piece(def, xf)
return true
@@ -165,6 +183,9 @@ func dismantle(player: Node3D, piece: Node3D) -> bool:
if player.inventory.space_for(piece.item_id) < 1:
notify.emit("Inventory full")
return false
if _online():
WorldNet.dismantle(piece)
return true
_give(player, piece.item_id, 1)
piece.get_parent().get_parent().remove_piece(piece, player)
return true
@@ -172,6 +193,9 @@ func dismantle(player: Node3D, piece: Node3D) -> bool:
func toggle_door(player: Node3D, door: Node3D) -> bool:
if not is_instance_valid(door) or not _in_reach(player, door, 1.0):
return false
if _online():
WorldNet.door(door)
return true
door.set_open(not door.open)
return true
@@ -353,6 +377,9 @@ func take_off(player: Node3D) -> bool:
func forage(player: Node3D, bush: Node3D) -> bool:
if not is_instance_valid(bush) or not _in_reach(player, bush) or not bush.ripe():
return false
if _online():
WorldNet.forage(bush)
return true
var got: Dictionary = bush.harvest()
for id in got:
_give(player, id, int(got[id]))
@@ -385,7 +412,10 @@ func add_fuel(player: Node3D, fire: Node3D) -> bool:
for id in ["wood", "stick", "fiber"]:
if player.inventory.count(id) > 0:
player.inventory.remove(id, 1)
fire.add_fuel(float(ItemDB.get_item(id).get("fuel", 10)))
if _online():
WorldNet.fuel(fire, id)
else:
fire.add_fuel(float(ItemDB.get_item(id).get("fuel", 10)))
notify.emit("Added %s to the fire" % ItemDB.item_name(id).to_lower())
return true
notify.emit("Nothing to burn (wood or sticks)")
@@ -396,4 +426,14 @@ func add_fuel(player: Node3D, fire: Node3D) -> bool:
func transfer(player: Node3D, from: Inventory, slot: int, to: Inventory, container: Node3D = null) -> void:
if container != null and not _in_reach(player, container, 1.0):
return
if _online() and container != null:
var s = from.get_slot(slot)
if s == null:
return
if from == player.inventory:
from.set_slot(slot, null)
WorldNet.chest_put(container, s) # what doesn't fit comes back
else:
WorldNet.chest_take(container, slot, to.space_for(s.id))
return
from.quick_move(slot, to)

View File

@@ -58,6 +58,22 @@ func create_world(world_name: String, seed: int) -> String:
})
return id
## The world slot called exactly `id` (a server's world), made with `seed` if
## it doesn't exist yet. An existing world keeps its own seed.
func open_or_create(id: String, seed: int) -> String:
if not read_meta(id).is_empty():
var had := int(read_meta(id).get("seed", seed))
if had != seed:
print("Saves: world '%s' was made with seed %d, so it keeps it (the config says %d)" % [id, had, seed])
return id
DirAccess.make_dir_recursive_absolute(_dir(id))
var now := Time.get_unix_time_from_system()
_write_json(_dir(id).path_join("meta.json"), {
"name": id, "seed": seed, "created": now, "last_played": now,
"play_time": 0.0, "version": VERSION,
})
return id
func delete_world(id: String) -> void:
var dir := _dir(id)
var d := DirAccess.open(dir)

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 := 2
const PROTOCOL := 3 ## 3: the shared world (M2.3)
const DEFAULT_PORT := 24242
const HANDSHAKE_TIMEOUT := 10.0
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join
@@ -62,12 +62,11 @@ 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 _world: Node3D ## the shared island (world.gd in server mode)
var _terrain: Node3D ## its terrain, 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].
@@ -180,20 +179,20 @@ func start_server(config_path: String, overrides: Dictionary = {}) -> String:
if overrides.has(flag):
var v: Variant = overrides[flag]
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)
# The shared island (world_net.gd): the same terrain and scenery every
# client generates from the seed, plus everything players changed.
_world = WorldNet.open_server_world(config)
_terrain = _world.terrain
_spawn = _terrain.find_spawn(Vector2.ZERO)
server_time = 0.0
_clock_day = 1
_clock_time = 8.0 / 24.0
_peer = ENetMultiplayerPeer.new()
var port := int(config.v("port"))
# A few spare slots so a full server can still say "server is full".
var err := _peer.create_server(port, int(config.v("max_players")) + 4)
if err != OK:
WorldNet.close_server_world()
_world = null
_terrain = null
return "Couldn't open UDP port %d (error %d). Is another server already running on it?" % [port, err]
multiplayer.multiplayer_peer = _peer
multiplayer.server_relay = false # clients only ever talk to the server
@@ -218,9 +217,10 @@ func stop() -> void:
_out_q.clear()
_in_q.clear()
local_peer = 0
if _terrain != null:
_terrain.queue_free()
_terrain = null
if _world != null:
WorldNet.close_server_world() # saves it first
_world = null
_terrain = null
if state != "offline":
_set_state("offline")
@@ -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)
WorldNet.peer_left(id)
players_changed.emit()
func _process(delta: float) -> void:
@@ -260,20 +261,19 @@ func _process(delta: float) -> void:
_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).
## The world's clock (it runs while anyone's on, or always if the server's
## set not to pause when empty: world_net.gd pauses the world). Players get
## it every few seconds.
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:
if _clock_left <= 0.0 and not players.is_empty():
_clock_left = CLOCK_INTERVAL
for peer in players:
_s2c_clock.rpc_id(peer, _clock_day, _clock_time)
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
## Where a player is, as far as the server knows.
func player_pos(peer: int) -> Vector3:
return _states[peer].pos if _states.has(peer) else Vector3.INF
func _broadcast_snapshot() -> void:
if _states.is_empty():
@@ -343,8 +343,36 @@ func move_problem(from: Vector3, to: Vector3, dt: float, teleported: bool) -> St
return "outside the island"
if to.y < _terrain.sample_height(to.x, to.z) - 1.5:
return "underground"
if not teleported and _through_wall(from, to):
return "through a wall"
return ""
## Whether the straight line between two positions crosses a wall, a closed
## door or a gable (at waist height). A genuine crossing hits the wall from
## both sides; someone a wall was built on top of can still step out of it.
func _through_wall(from: Vector3, to: Vector3) -> bool:
if _world == null or from.distance_to(to) < 0.05:
return false
var space: PhysicsDirectSpaceState3D = _world.get_world_3d().direct_space_state
var up := Vector3(0, 0.9, 0)
return _wall_hit(space, from + up, to + up) and _wall_hit(space, to + up, from + up)
func _wall_hit(space: PhysicsDirectSpaceState3D, a: Vector3, b: Vector3) -> bool:
var ex: Array[RID] = []
for _i in range(4):
var rq := PhysicsRayQueryParameters3D.create(a, b, 4, ex) # 4: building pieces
var hit := space.intersect_ray(rq)
if hit.is_empty():
return false
var c: Object = hit.collider
var piece: Node = c as Node
while piece != null and piece.get("kind") == null:
piece = piece.get_parent()
if piece != null and String(piece.kind) in ["wall", "door", "gable"]:
return true
ex.append(hit.rid) # a floor or roof edge: look past it
return false
func _reject(peer: int, reason: String) -> void:
_pending.erase(peer)
print("[server] rejected peer %d: %s" % [peer, reason])
@@ -400,8 +428,9 @@ func _c2s_proof(sig: PackedByteArray) -> void:
"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"))])
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}
# (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,
"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:
if other != peer:

626
scripts/net/world_net.gd Normal file
View File

@@ -0,0 +1,626 @@
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/<world>/.
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 "#<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")
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)

View File

@@ -144,8 +144,12 @@ func open(chest: Node) -> void:
func close() -> void:
if _cursor != null:
var left: int = player.inventory.add(_cursor.id, _cursor.count)
if left > 0 and container != null:
left = container.inventory.add(_cursor.id, left)
if left > 0 and container != null and is_instance_valid(container):
if WorldNet.is_client():
WorldNet.chest_put(container, {"id": _cursor.id, "count": left})
left = 0
else:
left = container.inventory.add(_cursor.id, left)
_cursor = null
if container != null and is_instance_valid(container) and container.inventory.changed.is_connected(_refresh):
container.inventory.changed.disconnect(_refresh)
@@ -201,10 +205,28 @@ func _on_chest_slot(i: int, right: bool, shift: bool) -> void:
return
if shift:
Actions.transfer(player, container.inventory, i, player.inventory, container)
elif WorldNet.is_client():
# A shared chest: the server does the click (someone else may be in
# it too) and hands back what we're holding.
if WorldNet.chest_busy:
return
var held = _cursor
_cursor = null
WorldNet.chest_click(container, i, held, right, _on_chest_cursor)
else:
_cursor = container.inventory.click(i, _cursor, right)
_refresh()
func _on_chest_cursor(stack) -> void:
if visible:
_cursor = stack
_refresh()
elif stack != null:
# Closed in the meantime: into our bag (or the ground).
var left: int = player.inventory.add(stack.id, stack.count)
if left > 0:
player.get_parent().drop_item(stack.id, left, Transform3D(Basis(), player.global_position + Vector3(0, 0.1, 0)))
func _refresh() -> void:
if player == null:
return

View File

@@ -45,6 +45,12 @@ var forage: Dictionary = {} ## uid -> berry bush (forage.gd)
## Regrowth: uid -> seconds until a felled tree, mined boulder or picked-up
## item comes back. Saved with the world. Bushes keep their own timers.
var regrow: Dictionary = {}
## Off on a client playing online: the server says when things grow back.
var regrow_enabled := true
## Other places nothing may regrow on top of (a server's players).
var blockers: Array = []
var regrow_scale := 1.0 ## test hook: a server started with --debug-regrow <k>
signal regrown(uid: String)
const REGROW_SECONDS := {"tree": 2400.0, "rock": 3600.0, "pickup": 900.0}
const REGROW_RETRY := 120.0 ## blocked (player or a building nearby): try again later
var _regrow_tick := 0.0
@@ -125,15 +131,15 @@ func _regrow_kind(uid: String) -> String:
return resource_nodes[uid].kind
func _on_depleted(node: Node) -> void:
regrow[node.uid] = REGROW_SECONDS[node.kind]
regrow[node.uid] = REGROW_SECONDS[node.kind] * regrow_scale
func _on_taken(pk: Node) -> void:
regrow[pk.uid] = REGROW_SECONDS["pickup"]
regrow[pk.uid] = REGROW_SECONDS["pickup"] * regrow_scale
## Advances regrowth timers by `secs` (every second while playing, or in one
## go when you sleep).
func skip_time(secs: float) -> void:
for uid in regrow.keys():
for uid in regrow.keys() if regrow_enabled else []:
regrow[uid] -= secs
if regrow[uid] <= 0.0:
_try_regrow(uid)
@@ -148,12 +154,15 @@ func _try_regrow(uid: String) -> void:
regrow.erase(uid)
return
var r := 0.5 if pickups.has(uid) else 1.4
if _player != null and _player.global_position.distance_to(node.global_position) < r + 3.0 \
or _built_over(node.global_position, r):
regrow[uid] = REGROW_RETRY
var blocked := _player != null and _player.global_position.distance_to(node.global_position) < r + 3.0
for b in blockers:
blocked = blocked or (b as Vector3).distance_to(node.global_position) < r + 3.0
if blocked or _built_over(node.global_position, r):
regrow[uid] = REGROW_RETRY * regrow_scale
return
regrow.erase(uid)
node.regrow()
regrown.emit(uid)
## Something built (or being built) where a tree or rock would come back.
func _built_over(pos: Vector3, r: float) -> bool:
@@ -179,10 +188,10 @@ func restore_regrow(saved: Dictionary) -> void:
regrow[uid] = float(saved[uid])
for uid in resource_nodes:
if resource_nodes[uid].depleted and not regrow.has(uid):
regrow[uid] = REGROW_SECONDS[resource_nodes[uid].kind]
regrow[uid] = REGROW_SECONDS[resource_nodes[uid].kind] * regrow_scale
for uid in pickups:
if pickups[uid].taken and not regrow.has(uid):
regrow[uid] = REGROW_SECONDS["pickup"]
regrow[uid] = REGROW_SECONDS["pickup"] * regrow_scale
# --- materials --------------------------------------------------------------

View File

@@ -56,15 +56,26 @@ func interact_prompt(player: Node) -> String:
## Applies one hit. Returns the items earned by this hit.
func hit(damage: float, point: Vector3, from: Vector3) -> Dictionary:
var out := {}
hp -= damage
for id in drops_per_hit:
out[id] = drops_per_hit[id]
_burst(point, chip_color, 14)
if hp <= 0.0:
depleted = true
if hp - damage <= 0.0:
for id in drops_final:
var r: Array = drops_final[id]
out[id] = out.get(id, 0) + rng.randi_range(int(r[0]), int(r[1]))
_show_hit(hp - damage, point, from)
return out
## A hit someone else's game reported, via the server (world_net.gd): the
## same chips, shake and fall, with no drops.
func apply_remote(new_hp: float, point: Vector3, from: Vector3) -> void:
if not depleted:
_show_hit(new_hp, point, from)
func _show_hit(new_hp: float, point: Vector3, from: Vector3) -> void:
hp = new_hp
_burst(point, chip_color, 14)
if hp <= 0.0:
depleted = true
_deplete(from)
else:
_shake = 1.0
@@ -73,7 +84,6 @@ func hit(damage: float, point: Vector3, from: Vector3) -> Dictionary:
_shake_axis = away.normalized().cross(Vector3.UP) if away.length() > 0.01 else Vector3.RIGHT
if kind == "rock":
_set_instance(_rock_xf())
return out
func _process(delta: float) -> void:
if _shake <= 0.0 or depleted:

View File

@@ -33,6 +33,12 @@ const AUTOSAVE_SECONDS := 120.0
## Set by the title screen before adding the world: no player, no HUD, no
## saving, just a slow camera flight over the island as a menu backdrop.
var menu_mode := false
## Set by WorldNet on a dedicated server: the shared island with nobody of its
## own in it (no player, HUD, animals or effects). It saves to the server's
## world slot. See world_net.gd.
var server_mode := false
var day_length := 1200.0 ## seconds per day (a server sets its own)
var autosave_seconds := AUTOSAVE_SECONDS
var _spawn := Vector3.ZERO
var _spawn_yaw := 0.0
@@ -61,11 +67,15 @@ func is_online() -> bool:
var _loading := false ## applying a save: defer support recompute
func _ready() -> void:
_autosave_left = autosave_seconds
_pieces = Node3D.new()
_pieces.name = "Pieces"
add_child(_pieces)
day_night = preload("res://scripts/world/day_night.gd").new()
day_night.name = "DayNight"
if is_online() and Net.server_info.has("day_length_minutes"):
day_length = float(Net.server_info.day_length_minutes) * 60.0 # same days, same weather
day_night.day_length = day_length
add_child(day_night)
day_night.setup(sun, $WorldEnvironment.environment)
weather = preload("res://scripts/world/weather.gd").new()
@@ -86,7 +96,15 @@ func _ready() -> void:
creatures.name = "Creatures"
creatures.enabled = false # until the scenery is placed
add_child(creatures)
if not menu_mode and DisplayServer.get_name() != "headless":
if server_mode:
# Nobody of our own: the player node stays, parked out of the way and
# switched off, because a few shared functions expect one.
player.process_mode = Node.PROCESS_MODE_DISABLED
player.collision_layer = 0
player.position = Vector3(0, -500, 0)
player.get_node("Camera3D").current = false
$HUD.visible = false
elif not menu_mode and DisplayServer.get_name() != "headless":
weather_fx = preload("res://scripts/world/weather_fx.gd").new()
weather_fx.name = "WeatherFX"
add_child(weather_fx)
@@ -101,13 +119,13 @@ func _ready() -> void:
add_child(_menu_cam)
_menu_cam.current = true
$HUD.visible = false
elif DisplayServer.get_name() != "headless":
elif DisplayServer.get_name() != "headless" and not server_mode:
hud = preload("res://scripts/ui/hud.gd").new()
add_child(hud)
hud.bind(player)
if not menu_mode:
if not menu_mode and not server_mode:
player.survival.died.connect(_on_player_died)
if Saves.has_world() and not menu_mode:
if Saves.has_world() and not menu_mode and not server_mode:
# Save on window close instead of quitting straight away.
get_tree().auto_accept_quit = false
player.set_physics_process(false)
@@ -158,8 +176,12 @@ func _on_player_died() -> void:
if st != null:
has_items = true
if has_items:
var grave := spawn_piece(ItemDB.get_item("grave"), Transform3D(Basis(Vector3.UP, player.rotation.y), pos))
grave.inventory.load_data(player.inventory.to_data())
var gxf := Transform3D(Basis(Vector3.UP, player.rotation.y), pos)
if net_sync != null:
WorldNet.place("grave", gxf, player.inventory.to_data()) # the server puts it in the shared world
else:
var grave := spawn_piece(ItemDB.get_item("grave"), gxf)
grave.inventory.load_data(player.inventory.to_data())
player.inventory.load_data(Inventory.new(player.inventory.size()).to_data())
if hud != null:
hud.close_screens()
@@ -197,6 +219,8 @@ func _respawn_at_bed(bed: Node3D) -> void:
## Why you can't sleep in `bed` right now ("" = you can).
func sleep_block_reason(bed: Node3D) -> String:
if is_online():
return "Sleeping on a server isn't in yet (everyone would have to agree)"
var h: float = day_night.hours()
if h > 5.5 and h < 19.5:
return "You can only sleep at night (after 19:30)"
@@ -255,6 +279,12 @@ func _on_terrain_ready() -> void:
print("[world] scenery placed in %d ms" % (Time.get_ticks_msec() - t0))
if menu_mode:
return
if server_mode:
if Saves.has_world():
var saved: Dictionary = Saves.load_state(Saves.current_id)
if not saved.is_empty():
apply_save(saved)
return # no player, no animals (the server runs those in M2.5)
_respawn()
if Saves.has_world():
var state: Dictionary = Saves.load_state(Saves.current_id)
@@ -301,15 +331,25 @@ func _process(delta: float) -> void:
## Writes the current game to the active world slot. Returns false if this
## session isn't tied to a slot (e.g. running world.tscn directly).
func save_now() -> bool:
_autosave_left = AUTOSAVE_SECONDS
_autosave_left = autosave_seconds
if not Saves.has_world():
return false
var ok: bool = Saves.save_state(Saves.current_id, collect_save(), _session_time)
if ok:
_session_time = 0.0
Actions.notify.emit("Game saved" if ok else "Save FAILED")
if server_mode:
if not ok:
printerr("[server] saving the world FAILED")
else:
Actions.notify.emit("Game saved" if ok else "Save FAILED")
return ok
## A server's world saves as it closes, however the server stops (Ctrl+C
## included), unless it has just saved.
func _exit_tree() -> void:
if server_mode and Saves.has_world() and _session_time > 0.0:
save_now()
func _notification(what: int) -> void:
if what != NOTIFICATION_WM_CLOSE_REQUEST or menu_mode:
return
@@ -349,20 +389,13 @@ func collect_save() -> Dictionary:
if not is_instance_valid(pk) or pk.taken:
taken.append(uid)
var dropped: Array = []
for d in _dropped.get_children():
if d.is_queued_for_deletion() or d.taken:
continue
dropped.append({"item": d.item_id, "count": d.amount, "xf": _xf_to_array(d.global_transform)})
for d in all_dropped():
dropped.append(dropped_entry(d))
var pieces: Array = []
for piece in _pieces.get_children():
if piece.is_queued_for_deletion():
continue
var entry := {"item": piece.item_id, "xf": _xf_to_array(piece.global_transform)}
if piece.get("inventory") != null:
entry["inventory"] = piece.inventory.to_data()
if piece.has_method("save_extra"):
entry["extra"] = piece.save_extra()
pieces.append(entry)
for piece in all_pieces():
pieces.append(piece_entry(piece))
if server_mode:
return _shared_state(harvested, regrow, taken, dropped, pieces)
return {
"player": {
"pos": [player.global_position.x, player.global_position.y, player.global_position.z],
@@ -383,22 +416,107 @@ func collect_save() -> Dictionary:
"pieces": pieces,
}
func apply_save(s: Dictionary) -> void:
var p: Dictionary = s.get("player", {})
func _shared_state(harvested: Dictionary, regrow: Dictionary, taken: Array, dropped: Array, pieces: Array) -> Dictionary:
return {
"dropped": dropped,
"time": {"day": day_night.day, "time": day_night.time},
"forage_regrow": regrow,
"regrow": props.regrow.duplicate(),
"harvested": harvested,
"pickups_taken": taken,
"pieces": pieces,
}
## The live pieces (placed and not being removed).
func all_pieces() -> Array:
return _pieces.get_children().filter(func(p): return not p.is_queued_for_deletion())
func all_dropped() -> Array:
return _dropped.get_children().filter(func(d): return not d.is_queued_for_deletion() and not d.taken)
## One piece as saved (and as sent to players): its item, where it is, what's
## in it, its state (fuel, door open), and its net id if it has one.
func piece_entry(piece: Node3D) -> Dictionary:
var entry := {"item": piece.item_id, "xf": _xf_to_array(piece.global_transform)}
if piece.get("inventory") != null:
entry["inventory"] = piece.inventory.to_data()
if piece.has_method("save_extra"):
entry["extra"] = piece.save_extra()
if piece.has_meta("nid"):
entry["nid"] = int(piece.get_meta("nid"))
return entry
func dropped_entry(d: Node3D) -> Dictionary:
var e := {"item": d.item_id, "count": d.amount, "xf": _xf_to_array(d.global_transform)}
if d.has_meta("nid"):
e["nid"] = int(d.get_meta("nid"))
return e
## The shared state a server sends a player who joins (everything but the
## server's own bookkeeping).
func collect_shared() -> Dictionary:
return collect_save()
## A joining player's copy of the server's island.
func apply_shared(s: Dictionary) -> void:
apply_save(s, false)
props.regrow.clear() # the server says when things grow back
func add_piece_entry(e: Dictionary) -> Node3D:
var def: Dictionary = ItemDB.get_item(e.get("item", ""))
if def.is_empty() or not def.has("place"):
return null
var piece := spawn_piece(def, _array_to_xf(e.xf))
if e.has("inventory") and piece.get("inventory") != null:
piece.inventory.load_data(e.inventory)
if e.has("extra") and piece.has_method("load_extra"):
piece.load_extra(e.extra)
if e.has("nid"):
piece.set_meta("nid", int(e.nid))
return piece
func add_dropped_entry(e: Dictionary) -> Node3D:
if ItemDB.get_item(e.get("item", "")).is_empty():
return null
var d := _make_dropped(e.item, int(e.get("count", 1)), _array_to_xf(e.xf))
if e.has("nid"):
d.set_meta("nid", int(e.nid))
return d
## Pieces the server took out (dismantled, collapsed, a grave emptied). No
## refunds here: the server sends those to whoever did it.
func remove_pieces_net(gone: Array, collapsed_nids: Array) -> void:
var any_build := false
for p in gone:
if p.item_id == "bed" and spawn_bed() == p:
has_bed_spawn = false
if p.is_in_group("build"):
any_build = true
if int(p.get_meta("nid", 0)) in collapsed_nids:
_collapse_fx(p)
_pieces.remove_child(p)
p.queue_free()
if any_build:
build.recompute()
_settle_loose_pieces()
_update_grass_cuts()
func apply_save(s: Dictionary, with_player: bool = true) -> void:
var p: Dictionary = s.get("player", {}) if with_player else {}
if p.has("pos"):
player.global_position = Vector3(p.pos[0], p.pos[1] + 0.05, p.pos[2])
player.rotation = Vector3(0.0, float(p.get("yaw", 0.0)), 0.0)
player.get_node("Camera3D").rotation.x = float(p.get("pitch", 0.0))
if p.has("inventory"):
player.inventory.load_data(p.inventory)
player.select(int(p.get("selected", 0)))
if p.has("stats"):
player.survival.load_data(p.stats)
player.worn = String(p.get("worn", ""))
player.worn_changed.emit()
if with_player:
if p.has("inventory"):
player.inventory.load_data(p.inventory)
player.select(int(p.get("selected", 0)))
if p.has("stats"):
player.survival.load_data(p.stats)
player.worn = String(p.get("worn", ""))
player.worn_changed.emit()
for d in s.get("dropped", []):
if not ItemDB.get_item(d.get("item", "")).is_empty():
drop_item(d.item, int(d.get("count", 1)), _array_to_xf(d.xf))
add_dropped_entry(d)
var t: Dictionary = s.get("time", {})
if not t.is_empty():
day_night.day = int(t.get("day", 1))
@@ -424,14 +542,7 @@ func apply_save(s: Dictionary) -> void:
weather.snap()
_loading = true
for e in s.get("pieces", []):
var def: Dictionary = ItemDB.get_item(e.get("item", ""))
if def.is_empty() or not def.has("place"):
continue
var piece := spawn_piece(def, _array_to_xf(e.xf))
if e.has("inventory") and piece.get("inventory") != null:
piece.inventory.load_data(e.inventory)
if e.has("extra") and piece.has_method("load_extra"):
piece.load_extra(e.extra)
add_piece_entry(e)
_loading = false
build.recompute() # same terrain, same pieces: nothing collapses on load
_update_grass_cuts()
@@ -531,14 +642,18 @@ func _go_online() -> void:
net_sync.setup(self)
Net.left.connect(_on_net_left)
get_tree().auto_accept_quit = false # leave the server cleanly on window close
# The island's changes come from the server from now on.
props.regrow_enabled = false
WorldNet.join_world(self)
if hud != null:
var bar := UITheme.label("Online: %s · you can see each other; building, gathering and animals aren't shared yet" \
var bar := UITheme.label("Online: %s · the island is shared; your inventory isn't saved yet, and there are no animals 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:
WorldNet.leave_world()
if is_inside_tree():
get_tree().paused = false
get_tree().auto_accept_quit = true
@@ -546,7 +661,14 @@ func _on_net_left(_reason: String) -> void:
## 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.
## Online, the server puts it in the shared world (and returns null here).
func drop_item(id: String, count: int, xf: Transform3D) -> Node3D:
if net_sync != null:
WorldNet.drop(id, count, xf)
return null
return _make_dropped(id, count, xf)
func _make_dropped(id: String, count: int, xf: Transform3D) -> Node3D:
var pk: Area3D = preload("res://scripts/world/pickup.gd").new()
pk.setup(id, ItemDB.mesh_for(id), ItemDB.item_material(), 0.3)
pk.amount = count