640 lines
22 KiB
GDScript
640 lines
22 KiB
GDScript
extends Node
|
|
## Autoload "PlayerNet": each player's character on a dedicated server (M2.4,
|
|
## docs/MULTIPLAYER_PLAN.md §5). Online, the server decides; offline (single
|
|
## player) there's no server and the game decides, exactly as before.
|
|
##
|
|
## - Your game sends what you want to do: `ask("eat", [slot])`,
|
|
## `ask("hit", [tree uid, point])`... (Actions does this for every intent
|
|
## while online, instead of acting itself).
|
|
## - The server runs the same Actions rule on your character there
|
|
## (server_player.gd), and sends back your inventory (after every request,
|
|
## and whenever it changes) and your stats (4 times a second).
|
|
## - Clicks in your own inventory are also done on your screen straight away,
|
|
## so dragging items doesn't wait for the network; the server's copy
|
|
## replaces it once it has caught up with every click you sent.
|
|
## - Characters are saved per player in the server's world:
|
|
## worlds/<world>/players/<player id>.json.
|
|
|
|
signal cursor_changed(stack) ## client: the server says this is on your cursor
|
|
|
|
const ServerPlayer = preload("res://scripts/net/server_player.gd")
|
|
const STATS_INTERVAL := 0.25
|
|
|
|
# Server.
|
|
var proxies: Dictionary = {} ## peer -> server_player.gd
|
|
var _acting := 0 ## the peer whose request is being run (for its messages)
|
|
var _dirty: Dictionary = {} ## peers whose inventory changed
|
|
var _acks: Dictionary = {} ## peer -> last request id handled
|
|
var _stats_left := 0.0
|
|
var _root: Node3D
|
|
var cheats := false ## --debug-cheats (tests only): give items, set stats, die
|
|
|
|
# Client.
|
|
var _seq := 0 ## id of the last request we sent
|
|
var world: Node3D
|
|
|
|
func _ready() -> void:
|
|
Actions.notify.connect(_route_notify)
|
|
cheats = Net.parse_args(OS.get_cmdline_user_args()).has("debug-cheats")
|
|
|
|
# --- server --------------------------------------------------------------------
|
|
|
|
func _route_notify(text: String) -> void:
|
|
if Net.is_server() and _acting != 0 and proxies.has(_acting):
|
|
_s2c_notify.rpc_id(_acting, text)
|
|
|
|
func _players_root() -> Node3D:
|
|
if _root == null or not is_instance_valid(_root):
|
|
_root = Node3D.new()
|
|
_root.name = "Players"
|
|
WorldNet.world.add_child(_root)
|
|
return _root
|
|
|
|
func _file(id: String) -> String:
|
|
return Saves.root_dir.path_join(Saves.current_id).path_join("players").path_join(id + ".json")
|
|
|
|
## A player came in: their character, from their save if they've been here
|
|
## before. Returns where they were (or INF for a new player).
|
|
func server_join(peer: int, id: String, display: String) -> Vector3:
|
|
var p: Node3D = ServerPlayer.new()
|
|
p.name = "P%d" % peer
|
|
p.peer = peer
|
|
p.player_id = id
|
|
p.display = display
|
|
_players_root().add_child(p)
|
|
p.setup(WorldNet.world)
|
|
proxies[peer] = p
|
|
_acks[peer] = 0
|
|
p.inventory.changed.connect(func(): _dirty[peer] = true; WorldNet.mark_changed())
|
|
p.worn_changed.connect(func(): _dirty[peer] = true)
|
|
p.survival.died.connect(_on_died.bind(peer))
|
|
p.survival.drain_mult = Rules.difficulty("drain")
|
|
# A wolf bite or a boar: their screen flashes red, from the right side.
|
|
p.survival.hurt.connect(func(amount, from):
|
|
if proxies.has(peer):
|
|
_s2c_hurt.rpc_id(peer, amount, from))
|
|
var at := Vector3.INF
|
|
var d := _read(_file(id))
|
|
if not d.is_empty():
|
|
p.load_data(d)
|
|
var t: Node3D = WorldNet.world.terrain
|
|
var pos := Vector3(d.pos[0], d.pos[1], d.pos[2]) if d.has("pos") else Vector3.INF
|
|
if pos.is_finite() and t.inside_boundary(pos.x, pos.z, 1.0) and pos.y > t.sample_height(pos.x, pos.z) - 1.0:
|
|
at = pos
|
|
print("[server] %s's character loaded" % display)
|
|
if at != Vector3.INF:
|
|
p.global_position = at
|
|
return at
|
|
|
|
func place_at(peer: int, pos: Vector3) -> void:
|
|
if proxies.has(peer):
|
|
proxies[peer].global_position = pos
|
|
|
|
func yaw_of(peer: int) -> float:
|
|
return proxies[peer].rotation.y if proxies.has(peer) else 0.0
|
|
|
|
func server_leave(peer: int) -> void:
|
|
var p: Node3D = proxies.get(peer)
|
|
if p == null:
|
|
return
|
|
if p.survival.dead:
|
|
_respawn(peer, false) # left on the death screen: they come back alive
|
|
_save(p)
|
|
var w: Node3D = WorldNet.world
|
|
if w != null and w.bobber_of(p) != null:
|
|
w.end_fishing(p)
|
|
proxies.erase(peer)
|
|
_dirty.erase(peer)
|
|
_acks.erase(peer)
|
|
p.queue_free()
|
|
_maybe_pass_night() # (the last one awake leaving lets the others sleep)
|
|
|
|
func save_all() -> void:
|
|
for p in proxies.values():
|
|
_save(p)
|
|
|
|
func server_reset() -> void:
|
|
proxies.clear()
|
|
_dirty.clear()
|
|
_acks.clear()
|
|
_root = null
|
|
|
|
func _save(p: Node3D) -> void:
|
|
var path := _file(p.player_id)
|
|
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
|
|
var tmp := path + ".tmp"
|
|
var f := FileAccess.open(tmp, FileAccess.WRITE)
|
|
if f == null:
|
|
printerr("[server] can't save %s's character" % p.display)
|
|
return
|
|
f.store_string(JSON.stringify(p.to_data(), "\t"))
|
|
f.close()
|
|
DirAccess.rename_absolute(tmp, path)
|
|
|
|
static func _read(path: String) -> Dictionary:
|
|
if not FileAccess.file_exists(path):
|
|
return {}
|
|
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
|
|
return d if d is Dictionary else {}
|
|
|
|
func held(peer: int) -> String:
|
|
return proxies[peer].held_item_id() if proxies.has(peer) else ""
|
|
|
|
## A move the server accepted: the character follows, and pays stamina for
|
|
## sprinting and jumping (the same costs as in your game).
|
|
func server_moved(peer: int, from: Vector3, to: Vector3, yaw: float, anim: int, dt: float) -> void:
|
|
var p: Node3D = proxies.get(peer)
|
|
if p == null:
|
|
return
|
|
var A := preload("res://scripts/net/avatar.gd").Anim
|
|
if anim == A.RUN:
|
|
p.survival.use_stamina(14.0 * dt, true)
|
|
elif anim == A.AIR and p.anim != A.AIR and to.y > from.y + 0.05:
|
|
p.survival.use_stamina(8.0)
|
|
p.anim = anim
|
|
p.velocity = (to - from) / maxf(dt, 0.001)
|
|
p.global_position = to
|
|
p.rotation.y = yaw
|
|
if to.y < p.water_level - 0.6:
|
|
p.survival.soak()
|
|
if p.in_bed and to.distance_to(p.bed_at) > 1.5:
|
|
p.in_bed = false
|
|
tell(p.peer, "You got out of bed")
|
|
|
|
func _process(delta: float) -> void:
|
|
if not Net.is_server() or proxies.is_empty():
|
|
return
|
|
for peer in _dirty.keys():
|
|
_push_inv(peer)
|
|
_dirty.clear()
|
|
_stats_left -= delta
|
|
if _stats_left <= 0.0:
|
|
_stats_left = STATS_INTERVAL
|
|
for peer in proxies:
|
|
_push_stats(peer)
|
|
|
|
func push_all(peer: int) -> void:
|
|
_push_inv(peer)
|
|
_push_stats(peer)
|
|
|
|
func _push_inv(peer: int) -> void:
|
|
var p: Node3D = proxies.get(peer)
|
|
if p != null:
|
|
_s2c_inv.rpc_id(peer, p.inventory.to_data(), p.cursor, p.worn, int(_acks.get(peer, 0)))
|
|
|
|
func _push_stats(peer: int) -> void:
|
|
var p: Node3D = proxies.get(peer)
|
|
if p == null:
|
|
return
|
|
var s: Node = p.survival
|
|
_s2c_stats.rpc_id(peer, s.to_data(), s.statuses, [s.roofed, s.sheltered, s.heat],
|
|
[p.has_bed_spawn, p.bed_spawn])
|
|
|
|
## Death on the server: everything goes into a grave where they fell (with
|
|
## their name on it), and they wake at their bed or the island start.
|
|
func _on_died(peer: int) -> void:
|
|
var p: Node3D = proxies.get(peer)
|
|
var w: Node3D = WorldNet.world
|
|
if p == null or w == null:
|
|
return
|
|
_acting = peer
|
|
var pos: Vector3 = p.global_position
|
|
pos.y = w.terrain.sample_height(pos.x, pos.z)
|
|
if pos.y < w.terrain.water_level:
|
|
pos = w.terrain.find_spawn(Vector2(pos.x, pos.z))
|
|
if not p.worn.is_empty() and p.inventory.add(p.worn, 1) == 0:
|
|
p.worn = ""
|
|
if p.cursor != null:
|
|
p.inventory.add_stack(p.cursor)
|
|
p.cursor = null
|
|
var has_items := false
|
|
for st in p.inventory.slots:
|
|
if st != null:
|
|
has_items = true
|
|
# What dying costs (the server's death_penalty rule).
|
|
var penalty := Rules.death_penalty()
|
|
if has_items and penalty == "grave":
|
|
var g: Node3D = w.spawn_piece(ItemDB.get_item("grave"), Transform3D(Basis(Vector3.UP, p.rotation.y), pos), {"owner_id": p.player_id, "owner_name": p.display})
|
|
g.inventory.load_data(p.inventory.to_data())
|
|
p.inventory.load_data(Inventory.new(p.inventory.size()).to_data())
|
|
elif has_items and penalty == "drop_all":
|
|
# Everything scattered on the ground where you fell, for anyone.
|
|
var rng := RandomNumberGenerator.new()
|
|
for st in p.inventory.slots:
|
|
if st != null:
|
|
var off := Vector3(rng.randf_range(-1.2, 1.2), 0.0, rng.randf_range(-1.2, 1.2))
|
|
var at: Vector3 = pos + off
|
|
at.y = w.terrain.sample_height(at.x, at.z) + 0.05
|
|
w.drop_item(st.id, int(st.count), Transform3D(Basis(Vector3.UP, rng.randf() * TAU), at),
|
|
{"dur": int(st.dur)} if st.has("dur") else {})
|
|
p.inventory.load_data(Inventory.new(p.inventory.size()).to_data())
|
|
# ("keep": you keep everything.)
|
|
# They stay down on the death screen for a few seconds, then ask to respawn.
|
|
p.died_at = Time.get_ticks_msec()
|
|
_dirty[peer] = true
|
|
_s2c_died.rpc_id(peer, p.survival.cause if not p.survival.cause.is_empty() else "You died",
|
|
has_items and penalty != "keep", p.bed_piece(w) != null, penalty)
|
|
_acting = 0
|
|
|
|
const RESPAWN_DELAY := 3.0
|
|
|
|
## Respawn (the death screen's button), once they've been down long enough:
|
|
## at their bed if it's still standing, otherwise the island start.
|
|
func _respawn(peer: int, tell: bool = true) -> void:
|
|
var p: Node3D = proxies.get(peer)
|
|
var w: Node3D = WorldNet.world
|
|
if p == null or w == null or not p.survival.dead:
|
|
return
|
|
if tell and Time.get_ticks_msec() - p.died_at < RESPAWN_DELAY * 1000.0 - 100.0:
|
|
return # (too soon: the button only unlocks after the delay)
|
|
var bed: Node3D = p.bed_piece(w)
|
|
var at: Vector3
|
|
var yaw := 0.0
|
|
if bed != null:
|
|
at = bed.global_position + Vector3(0, 0.9, 0)
|
|
yaw = bed.global_basis.get_euler().y + PI
|
|
else:
|
|
if p.has_bed_spawn:
|
|
p.has_bed_spawn = false
|
|
Actions.notify.emit("Your bed is gone, so you woke at the island start")
|
|
at = Net.island_spawn() + Vector3(0, 1.5, 0)
|
|
p.survival.reset_after_death()
|
|
p.global_position = at
|
|
p.rotation.y = yaw
|
|
Net.teleport(peer, at)
|
|
_dirty[peer] = true
|
|
if tell:
|
|
_s2c_respawn.rpc_id(peer, at, yaw, "")
|
|
_save(p)
|
|
|
|
# --- server: requests ------------------------------------------------------------
|
|
|
|
@rpc("any_peer", "call_remote", "reliable")
|
|
func _c2s_ask(seq: int, what: String, args: Array) -> void:
|
|
var peer := multiplayer.get_remote_sender_id()
|
|
var p: Node3D = proxies.get(peer)
|
|
if not Net.is_server() or p == null or WorldNet.world == null:
|
|
return
|
|
_acting = peer
|
|
if what == "respawn":
|
|
_respawn(peer)
|
|
elif not p.survival.dead:
|
|
_run(p, what, args)
|
|
_acting = 0
|
|
_acks[peer] = maxi(int(_acks.get(peer, 0)), seq)
|
|
_dirty[peer] = true # (always answer, so the game knows we've caught up)
|
|
|
|
func _run(p: Node3D, what: String, a: Array) -> void:
|
|
var w: Node3D = WorldNet.world
|
|
match what:
|
|
"select":
|
|
p.select(_i(a, 0))
|
|
"craft":
|
|
Actions.craft(p, _s(a, 0))
|
|
"eat":
|
|
Actions.eat(p, _i(a, 0))
|
|
"wear":
|
|
Actions.wear(p, _i(a, 0))
|
|
"take_off":
|
|
Actions.take_off(p)
|
|
"hit":
|
|
var n: Node3D = w.props.resource_nodes.get(_s(a, 0))
|
|
var at: Vector3 = _v(a, 1)
|
|
if n != null and is_instance_valid(n) and at.is_finite() and Actions.harvest_hit(p, n, at):
|
|
WorldNet.node_hit(n, at, p.global_position)
|
|
"pickup":
|
|
var key := _s(a, 0)
|
|
var pk: Node3D = WorldNet.find_pickup(key)
|
|
if pk != null and not pk.taken and Actions.pickup(p, pk):
|
|
WorldNet.taken(key)
|
|
"forage":
|
|
var b: Node3D = w.props.forage.get(_s(a, 0))
|
|
if b != null and Actions.forage(p, b):
|
|
WorldNet.foraged(b)
|
|
"place":
|
|
var xf: Transform3D = a[1] if a.size() > 1 and a[1] is Transform3D else Transform3D()
|
|
if xf.origin.is_finite() and xf.origin != Vector3.ZERO:
|
|
Actions.place(p, _i(a, 0), xf.orthonormalized(), w)
|
|
"dismantle":
|
|
var piece := WorldNet.piece(_i(a, 0))
|
|
if piece != null and _allowed(Rules.dismantle_problem(p.player_id, piece)):
|
|
Actions.dismantle(p, piece)
|
|
"door":
|
|
var d := WorldNet.piece(_i(a, 0))
|
|
if d != null and d.get("open") != null and _allowed(Rules.use_problem(p.player_id, d)) \
|
|
and Actions.toggle_door(p, d):
|
|
WorldNet.piece_state(d)
|
|
"fuel":
|
|
var f := WorldNet.piece(_i(a, 0))
|
|
if f != null and f.get("fuel") != null and Actions.add_fuel(p, f):
|
|
WorldNet.piece_state(f)
|
|
"bed":
|
|
var b := WorldNet.piece(_i(a, 0))
|
|
if b != null and b.item_id == "bed" and _allowed(Rules.use_problem(p.player_id, b)):
|
|
Actions.use_bed(p, b)
|
|
"inv_click":
|
|
# Slot `a[1]` of your own inventory (a[0] = 0) or a chest's.
|
|
var inv: Inventory = p.inventory
|
|
if _i(a, 0) != 0:
|
|
var c := _container(p, _i(a, 0))
|
|
if c == null:
|
|
return
|
|
inv = c.inventory
|
|
var slot := _i(a, 1)
|
|
if slot >= 0 and slot < inv.size():
|
|
p.cursor = inv.click(slot, p.cursor, _b(a, 2))
|
|
"transfer":
|
|
# Shift-click: from your inventory into the chest, or the other way.
|
|
var c := _container(p, _i(a, 0))
|
|
if c == null:
|
|
return
|
|
var into_chest := _b(a, 2)
|
|
var from: Inventory = p.inventory if into_chest else c.inventory
|
|
var to: Inventory = c.inventory if into_chest else p.inventory
|
|
var slot := _i(a, 1)
|
|
if slot >= 0 and slot < from.size():
|
|
Actions.transfer(p, from, slot, to, c)
|
|
"close":
|
|
# The inventory screen closed: what's on the cursor goes back in
|
|
# your bag, or at your feet if it's full.
|
|
if p.cursor != null:
|
|
var left: int = p.inventory.add_stack(p.cursor)
|
|
if left > 0:
|
|
w.drop_item(p.cursor.id, left, Transform3D(Basis(), p.global_position + Vector3(0, 0.1, 0)),
|
|
{"dur": int(p.cursor.dur)} if p.cursor.has("dur") else {})
|
|
p.cursor = null
|
|
"debug_give":
|
|
if cheats and not ItemDB.get_item(_s(a, 0)).is_empty():
|
|
p.inventory.add(_s(a, 0), _i(a, 1))
|
|
"debug_clear":
|
|
if cheats:
|
|
p.inventory.load_data(Inventory.new(p.inventory.size()).to_data())
|
|
"debug_stats":
|
|
if cheats and a.size() > 0 and a[0] is Dictionary:
|
|
for k in a[0]:
|
|
if k in ["health", "stamina", "hunger", "warmth"]:
|
|
p.survival.set(k, float(a[0][k]))
|
|
elif k == "diet" and a[0][k] is Dictionary:
|
|
for g in a[0][k]:
|
|
if p.survival.diet.has(g):
|
|
p.survival.diet[g] = float(a[0][k][g])
|
|
"debug_die":
|
|
if cheats:
|
|
p.survival.damage(10000.0, p.global_position)
|
|
"debug_drop":
|
|
if cheats and not ItemDB.get_item(_s(a, 0)).is_empty():
|
|
w.drop_item(_s(a, 0), maxi(_i(a, 1), 1), Transform3D(Basis(), _v(a, 2)))
|
|
# --- hunting and fishing (M2.5) ---
|
|
# Arrows, spears and casts start from where the server knows you are
|
|
# (your eyes), whatever your game says: only the aim comes from you.
|
|
"melee":
|
|
var c: Node3D = CreatureNet.creature(_i(a, 0))
|
|
if c != null:
|
|
Actions.melee(p, c)
|
|
"hit_player":
|
|
Actions.hit_player(p, _i(a, 0))
|
|
"butcher":
|
|
var c: Node3D = CreatureNet.creature(_i(a, 0))
|
|
if c != null:
|
|
Actions.butcher(p, c)
|
|
"shoot":
|
|
var dir := _dir(a, 0)
|
|
var power := clampf(float(a[1]) if a.size() > 1 and (a[1] is float or a[1] is int) else 0.0, 0.0, 1.0)
|
|
if dir != Vector3.ZERO and Actions.can_draw(p):
|
|
Actions.shoot(p, _eye(p) + dir * 0.5, dir, power, w)
|
|
"throw":
|
|
var dir := _dir(a, 1)
|
|
if dir != Vector3.ZERO:
|
|
Actions.throw_spear(p, _i(a, 0), _eye(p) + dir * 0.5 + Vector3(0, -0.05, 0), dir + Vector3(0, 0.06, 0), w)
|
|
"cast":
|
|
var dir := _dir(a, 0)
|
|
if dir != Vector3.ZERO:
|
|
Actions.cast_line(p, _eye(p) + dir * 0.6, dir, w)
|
|
"reel":
|
|
Actions.reel(p, w)
|
|
"repair":
|
|
var slot := _i(a, 0)
|
|
if slot >= 0 and slot < p.inventory.size():
|
|
Actions.repair(p, slot)
|
|
"debug_process":
|
|
if cheats:
|
|
for m in w.get_tree().get_nodes_in_group("processor"):
|
|
m.step(float(a[0]) if a.size() > 0 and (a[0] is float or a[0] is int) else 60.0)
|
|
"debug_spawn":
|
|
if cheats and Creature.KINDS.has(_s(a, 0)) and _s(a, 0) != "wolf" and _v(a, 1).is_finite():
|
|
w.creatures.spawn(_s(a, 0), _v(a, 1))
|
|
"debug_time":
|
|
if cheats:
|
|
w.day_night.day = maxi(_i(a, 0), 1)
|
|
w.day_night.time = clampf(float(a[1]) / 24.0, 0.0, 0.999) if a.size() > 1 else w.day_night.time
|
|
w.day_night.apply(true)
|
|
w.weather.snap()
|
|
Net.push_clock()
|
|
"debug_tp":
|
|
var to := _v(a, 0)
|
|
if cheats and to.is_finite():
|
|
p.global_position = to
|
|
Net.teleport(p.peer, to)
|
|
_s2c_respawn.rpc_id(p.peer, to, p.rotation.y, "")
|
|
"debug_hut":
|
|
if cheats and _v(a, 0).is_finite():
|
|
_debug_hut(_v(a, 0))
|
|
_:
|
|
Actions.notify.emit("Unknown request")
|
|
|
|
const Creature = preload("res://scripts/creatures/creature.gd")
|
|
|
|
static func _eye(p: Node3D) -> Vector3:
|
|
return p.global_position + Vector3(0, 1.6, 0)
|
|
|
|
## A unit aim direction from the request, or ZERO.
|
|
static func _dir(a: Array, k: int) -> Vector3:
|
|
var d := _v(a, k)
|
|
return d.normalized() if d.is_finite() and d.length() > 0.01 else Vector3.ZERO
|
|
|
|
## Test hook: a closed hut (floors, walls, door, roof) with two beds in it.
|
|
func _debug_hut(center: Vector3) -> void:
|
|
var w: Node3D = WorldNet.world
|
|
var out: Dictionary = await w.build.debug_build_hut(center, 0.0)
|
|
if out.pieces.is_empty():
|
|
return
|
|
var top: float = out.pieces[0].global_position.y
|
|
for x in [-0.6, 0.6]:
|
|
w.spawn_piece(ItemDB.get_item("bed"), Transform3D(Basis(), Vector3(center.x + x, top, center.z - 0.3)))
|
|
|
|
# --- server: messages, getting hurt, sleeping ----------------------------------------
|
|
|
|
func tell(peer: int, text: String) -> void:
|
|
if proxies.has(peer):
|
|
_s2c_notify.rpc_id(peer, text)
|
|
|
|
func tell_all(text: String) -> void:
|
|
for peer in proxies:
|
|
_s2c_notify.rpc_id(peer, text)
|
|
|
|
## The server's sleep rule (server.json "sleep"): how many of the players
|
|
## online must be in bed for the night to pass.
|
|
func _beds_needed(n: int) -> int:
|
|
match String(Net.config.v("sleep")):
|
|
"anyone": return 1
|
|
"half": return ceili(n / 2.0)
|
|
return n # "all_online" (the default)
|
|
|
|
## Actions.use_bed decided `p` may sleep: they get into bed, and the night
|
|
## passes once enough players are in bed.
|
|
func go_to_bed(p: Node3D) -> void:
|
|
if String(Net.config.v("sleep")) == "off":
|
|
tell(p.peer, "Sleeping is off on this server: the nights have to be lived through")
|
|
return
|
|
p.in_bed = true
|
|
p.bed_at = p.global_position
|
|
if not _maybe_pass_night():
|
|
var n: int = _alive().size()
|
|
var asleep: int = _alive().filter(func(q): return q.in_bed).size()
|
|
tell(p.peer, "You're in bed (%d of %d asleep). The night passes when %s." % [asleep, n,
|
|
"everyone's in bed" if _beds_needed(n) >= n else "%d are" % _beds_needed(n)])
|
|
for q in _alive():
|
|
if q != p:
|
|
tell(q.peer, "%s is in bed (%d of %d asleep)" % [p.display, asleep, n])
|
|
|
|
func _alive() -> Array:
|
|
return proxies.values().filter(func(q): return not q.survival.dead)
|
|
|
|
## Everyone in bed (by the rule)? Then until 06:00 for all: the clock, fires,
|
|
## regrowth and weather move on; sleepers wake rested.
|
|
func _maybe_pass_night() -> bool:
|
|
var w: Node3D = WorldNet.world
|
|
var live := _alive()
|
|
if w == null or live.is_empty():
|
|
return false
|
|
var asleep := live.filter(func(q): return q.in_bed)
|
|
if asleep.size() < _beds_needed(live.size()):
|
|
return false
|
|
var secs: float = fposmod(6.0 - w.day_night.hours(), 24.0) / 24.0 * w.day_night.day_length
|
|
w.skip_time(secs)
|
|
for q in live:
|
|
if q.in_bed:
|
|
q.survival.after_sleep(secs)
|
|
q.in_bed = false
|
|
_s2c_slept.rpc_id(q.peer, asleep.has(q))
|
|
Net.push_clock()
|
|
WorldNet.save()
|
|
return true
|
|
|
|
## The chest or grave `nid`, if this player may use it right now.
|
|
func _container(p: Node3D, nid: int) -> Node3D:
|
|
var c := WorldNet.piece(nid)
|
|
if c == null or c.get("inventory") == null or not Actions._in_reach(p, c, 1.0):
|
|
return null
|
|
return c if _allowed(Rules.use_problem(p.player_id, c)) else null
|
|
|
|
const Rules = preload("res://scripts/net/rules.gd")
|
|
|
|
## Tells the player why not, if there's a reason.
|
|
func _allowed(why: String) -> bool:
|
|
if why.is_empty():
|
|
return true
|
|
Actions.notify.emit(why)
|
|
return false
|
|
|
|
static func _i(a: Array, k: int) -> int:
|
|
return int(a[k]) if a.size() > k and (a[k] is int or a[k] is float) else -1
|
|
|
|
static func _s(a: Array, k: int) -> String:
|
|
return String(a[k]) if a.size() > k and a[k] is String else ""
|
|
|
|
static func _b(a: Array, k: int) -> bool:
|
|
return a.size() > k and a[k] is bool and a[k]
|
|
|
|
static func _v(a: Array, k: int) -> Vector3:
|
|
return a[k] if a.size() > k and a[k] is Vector3 else Vector3.INF
|
|
|
|
# --- client ----------------------------------------------------------------------
|
|
|
|
func is_client() -> bool:
|
|
return WorldNet.is_client()
|
|
|
|
## Our world is online: follow the server's view of us.
|
|
func join_world(w: Node3D) -> void:
|
|
world = w
|
|
_seq = 0
|
|
w.player.survival.set_process(false) # the server runs our stats
|
|
w.player.selected_changed.connect(_on_selected)
|
|
_on_selected()
|
|
|
|
func leave_world() -> void:
|
|
if world != null and is_instance_valid(world) and world.player.selected_changed.is_connected(_on_selected):
|
|
world.player.selected_changed.disconnect(_on_selected)
|
|
world = null
|
|
|
|
func _on_selected() -> void:
|
|
if world != null:
|
|
ask("select", [world.player.selected])
|
|
|
|
## Sends a request to the server (Actions calls this while online).
|
|
func ask(what: String, args: Array = []) -> void:
|
|
if Net.state != "connected":
|
|
return
|
|
_seq += 1
|
|
_c2s_ask.rpc_id(1, _seq, what, args)
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _s2c_inv(data: Array, cursor: Variant, worn: String, ack: int) -> void:
|
|
if world == null or not is_instance_valid(world) or ack < _seq:
|
|
return # we've asked for more since: the next copy will include it
|
|
var pl: CharacterBody3D = world.player
|
|
pl.inventory.load_data(data)
|
|
if pl.worn != worn:
|
|
pl.worn = worn
|
|
pl.worn_changed.emit()
|
|
cursor_changed.emit(cursor)
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _s2c_stats(stats: Dictionary, statuses: Array, env: Array, bed: Array) -> void:
|
|
if world == null or not is_instance_valid(world):
|
|
return
|
|
var s: Node = world.player.survival
|
|
s.load_data(stats)
|
|
s.statuses.assign(statuses)
|
|
s.roofed = bool(env[0])
|
|
s.sheltered = bool(env[1])
|
|
s.heat = float(env[2])
|
|
world.has_bed_spawn = bool(bed[0])
|
|
world.bed_spawn = bed[1]
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _s2c_died(cause: String, has_items: bool, to_bed: bool, penalty: String = "grave") -> void:
|
|
if world != null and is_instance_valid(world):
|
|
world.died_online(cause, has_items, to_bed, penalty)
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _s2c_respawn(at: Vector3, yaw: float, text: String) -> void:
|
|
if world == null or not is_instance_valid(world):
|
|
return
|
|
world.respawn_from_server(at, yaw)
|
|
if not text.is_empty():
|
|
Actions.notify.emit(text)
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _s2c_notify(text: String) -> void:
|
|
Actions.notify.emit(text)
|
|
|
|
var hurts := 0 ## client: how often the server said we were hurt (tests read it)
|
|
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _s2c_hurt(amount: float, from: Vector3) -> void:
|
|
hurts += 1
|
|
if world != null and is_instance_valid(world):
|
|
world.player.survival.hurt.emit(amount, from)
|
|
|
|
## The night passed (we were asleep, or up while the others slept).
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func _s2c_slept(was_asleep: bool) -> void:
|
|
if world == null or not is_instance_valid(world):
|
|
return
|
|
var text := "You slept until morning" if was_asleep else "The night passed while the others slept"
|
|
if world.hud != null and was_asleep:
|
|
world.hud.fade(func(): Actions.notify.emit(text))
|
|
else:
|
|
Actions.notify.emit(text)
|