M2.3: the shared world (trees, pickups, pieces, chests, fires, doors, graves, clock on the server)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
626
scripts/net/world_net.gd
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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