M2.3: the shared world (trees, pickups, pieces, chests, fires, doors, graves, clock on the server)
This commit is contained in:
@@ -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 --------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user