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

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