M2.4: the server decides online (characters, inventory, stats, death, owned graves, per-player saves); death screen with Respawn after 3 s

This commit is contained in:
Paul Bailey
2026-11-04 19:18:31 +00:00
parent 8e8f10eb96
commit 35a656e996
16 changed files with 1124 additions and 477 deletions

View File

@@ -6,9 +6,9 @@ concrete decisions for this codebase, as it stands after M1.
## 1. Goals
- **Self-hosted**, 2-8 players (up to 16 possible). No central service, no
accounts: a world lives on whoever hosts it.
- **Three ways to play, one code path:**
- **Single-player**: the game runs a server inside itself and connects to
it (loopback). It plays exactly as now.
- **Three ways to play, the same rules (Actions):**
- **Single-player**: no server; your game decides, exactly as now
(decided in M2.4, §7).
- **Host & play**: "Host game" on the title screen, and friends join you.
- **Dedicated server**: a headless "server only" process with no local
player. It keeps the world running (or paused) for whoever connects.
@@ -204,12 +204,32 @@ it needs no syncing.
the server, never on top of a player.
- The world (fires, regrowth, the clock) pauses while nobody's on.
Until later phases, while online: your inventory, stats and crafting stay on
your own game and aren't saved by the server (M2.4), so the server trusts
what you say you're putting into the world (a wall, a chest deposit, a
grave's contents) but decides everything taken out. Animals are off (M2.5).
Sleeping is off (it needs the sleep rule, M2.5). Respawning ignores beds.
The HUD says so.
**Your character on the server** *(built in M2.4: `player_net.gd`,
`server_player.gd`, autoload PlayerNet)*. Online the server decides;
offline there's no server and your game decides, exactly as before.
- Each player has a character on the server with their inventory, hotbar
choice, cloak, inventory-screen cursor, bed and survival stats. The stats
run there: warmth from the server's fires and buildings, rain, hunger and
the diet. Sprinting and jumping cost stamina there too.
- Online, every `Actions.*` intent becomes a request (`PlayerNet.ask("eat",
[slot])`). The server runs the same Actions function on your character
and sends back your inventory (after every request, and whenever it
changes) and your stats (4 times a second). What your own game has in
its bag counts for nothing: a modified game that adds walls to its bag
can't build with them.
- Clicks in your own bag also happen on your screen at once, so dragging
items doesn't wait for the network. The server's copy replaces yours as
soon as it has handled every click you sent.
- Death happens on the server: a grave with your name on it (only you can
open it until the M2.6 rules), then you wake at your bed if it's still
standing, otherwise the island start.
- Characters are saved in `worlds/<world>/players/<player id>.json`, with
the world, so you come back where you left off.
Until later phases, while online: animals, hunting and fishing are off
(M2.5), and so is sleeping (it needs the sleep rule, M2.5). The HUD says so.
The server doesn't yet check that you're out of stamina when you sprint
(it charges you, but doesn't stop you).
**Interest.** On a 320 m worldlet with 8 players, the server sends every
entity to everyone at first. Distance-based interest is a later
@@ -243,10 +263,18 @@ net ids, no owners yet) are written next to `server.json` within 10 s of
any change, when a player leaves, and every `autosave_minutes`. (Ctrl+C
kills Godot without running any code, so there's no save on the way out.) An
existing world keeps the seed it was made with, even if `server.json`
changes. Player files, owners and backups come in M2.4 and M2.7.
- Single-player saves (today's `world.json` with a `"player"` block) are
migrated on first load. That block becomes the local player's file. The
test suite keeps an old-format save fixture to prove it.
changes. *As of M2.4:* `players/<player id>.json` too (position, facing,
inventory, hotbar choice, stats, diet, buffs, cloak, bed), saved with the
world, when the player leaves and when they die. Graves carry their
owner. Backups come in M2.7.
*Decision (M2.4):* single-player stays as it is, with no server inside it
("if it's single-player there is no server, so the game decides"). So
single-player saves need no migration: they keep working unchanged, and
`tests/test_save_load.gd` still proves it. The old idea of running
single-player as a loopback server is dropped.
- *(Originally: single-player saves migrated to a player file on first
load. Not needed since the M2.4 decision above.)*
- Per-player graves already work, since graves are pieces. With
`death_penalty: grave` they get an owner, and only the owner (and friends,
depending on locks) can loot them.
@@ -274,7 +302,7 @@ player" or "this player":
| **M2.1** *(done)* | network skeleton: `--server`, ENet on 24242, Join screen (address, name), handshake with identity and challenge, name rules, player list | two clients and a headless server on one Mac connect, see the player list, and disconnect cleanly |
| **M2.2** *(done)* | player bodies and name tags; predicted movement, interpolation for others | two players walk around together at 100 ms simulated latency without visible rubber-banding |
| **M2.3** *(done)* | world state replicated: resource nodes, pickups, pieces, chests, fires, doors, clock, weather | one chops a tree and the other sees it fall; one builds a wall and the other walks into it |
| **M2.4** | intents as RPCs; per-player inventory, stats, diet, death and graves; per-player saves and migration | two players craft, eat, die and loot their own graves; an old single-player save loads |
| **M2.4** *(done)* | intents as RPCs; per-player inventory, stats, diet, death and graves; per-player saves and migration | two players craft, eat, die and loot their own graves; an old single-player save loads |
| **M2.5** | wildlife and wolves server-side, projectiles, fishing, sleeping with the rule | a pack hunts two players at once; arrows hit on both screens |
| **M2.6** | server rules: PvP, raiding, locks and friends, death penalty, difficulty; admin console and chat | each preset (co-op, rivals, adversarial) behaves as described in §4 |
| **M2.7** | hosting polish: "Host game" button, systemd and launchd samples, backups, a Tailscale how-to in the docs | Paul runs it on the home server, and a friend joins over Tailscale and plays for an hour |
@@ -288,9 +316,8 @@ player" or "this player":
- **Rules tests:** one per server rule (PvP off means no damage between
players; raiding off means you can't dismantle a stranger's wall; locks
keep strangers out of your chest).
- **Save tests:** world and player file round trips, migration of old
saves, backups.
- The existing 9 suites keep running in single-player (loopback) mode.
- **Save tests:** world and player file round trips, backups.
- The single-player suites keep running as they are (no server).
- `tests/test_net_shared.gd` (M2.3) is the two-world version: this test is
one player and `tests/net_world_bot.gd` (a second process with its own
world, following commands the test writes as files) is the other. A

View File

@@ -47,12 +47,13 @@ Detailed plan in `docs/SURVIVAL_DESIGN.md` (phases M1a–M1d).
Detailed plan in `docs/MULTIPLAYER_PLAN.md` (hosting on UDP 24242 via Tailscale
or port forwarding, player identity and names, server rules for co-op/PvP, and
phases M2.0-M2.7).
- [ ] Refactor state to server-authoritative (single-player becomes loopback server)
- [x] Server-authoritative online (single-player stays as it is, with no server) *(M2.4)*
- [x] Headless dedicated server, joining, identity handshake, names, player list *(M2.1)*
- [x] ENet transport, player bodies and name tags, local movement checked by the server, interpolation for others *(M2.2)*
- [x] Replicate resource nodes, pickups, building, chests, fires, doors, clock *(M2.3)*
- [x] Persistence: world save/load on the server *(M2.3)*
- [ ] Per-player inventory, stats and saves on the server *(M2.4)*
- [x] Per-player inventory, stats, death, graves and saves on the server *(M2.4)*
- [ ] Animals, hunting, fishing and sleeping on the server *(M2.5)*
- **Done when:** a friend joins your self-hosted server and you build together.
## M3 — Addon SDK + sandbox · ~2–3 months

View File

@@ -28,6 +28,7 @@ Actions="*res://scripts/core/actions.gd"
Saves="*res://scripts/core/saves.gd"
Net="*res://scripts/net/net.gd"
WorldNet="*res://scripts/net/world_net.gd"
PlayerNet="*res://scripts/net/player_net.gd"
[display]

View File

@@ -1,20 +1,46 @@
extends "res://scripts/building/piece.gd"
## Grave: left where you died, holding everything you carried. Open it with E
## to take your things back; it crumbles once empty.
##
## On a server it has an owner (whoever died), and only they can open it
## (player_net.gd asks use_block_reason). Server rules for friends come in M2.6.
var inventory := Inventory.new(40)
var owner_id := ""
var owner_name := ""
func _build() -> void:
inventory.changed.connect(_on_changed)
func _mine() -> bool:
return owner_id.is_empty() or Net.state != "connected" or owner_id == Net.get_identity().player_id
func display_name() -> String:
return "Your grave"
return "Your grave" if _mine() else "%s's grave" % owner_name
func interact_prompt(_player: Node) -> String:
if not _mine():
return "%s's grave" % owner_name
return "Your grave [E] Recover belongings"
func interact(player: Node) -> void:
player.open_inventory(self)
if _mine():
player.open_inventory(self)
else:
Actions.notify.emit("That's %s's grave" % owner_name)
## Server: why player `id` can't take from it ("" = they can).
func use_block_reason(id: String) -> String:
if owner_id.is_empty() or owner_id == id:
return ""
return "That's %s's grave" % owner_name
func save_extra() -> Dictionary:
return {"owner_id": owner_id, "owner_name": owner_name} if not owner_id.is_empty() else {}
func load_extra(d: Dictionary) -> void:
owner_id = String(d.get("owner_id", ""))
owner_name = String(d.get("owner_name", ""))
func _on_changed() -> void:
if WorldNet.world != null:

View File

@@ -54,11 +54,23 @@ func _give(player: Node, id: String, n: int) -> int:
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.
## come back to everyone, us included.
func _online() -> bool:
return WorldNet.is_client()
## Online, the server decides (player_net.gd): every intent is sent there as
## a request, and the server runs this same function on our character there.
## Offline nothing changes: the game decides.
func _ask(what: String, args: Array = []) -> bool:
PlayerNet.ask(what, args)
return true
const NOT_ONLINE := "Hunting and fishing aren't on servers yet (they come with the animals)"
func _not_online() -> bool:
notify.emit(NOT_ONLINE)
return false
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
@@ -83,6 +95,8 @@ func can_craft(player: Node, recipe_id: String) -> bool:
return inv.has_all(r.inputs) and station_near(player, r.station)
func craft(player: Node, recipe_id: String) -> bool:
if _online():
return _ask("craft", [recipe_id])
var r: Dictionary = ItemDB.recipes.get(recipe_id, {})
if r.is_empty():
return false
@@ -109,13 +123,9 @@ func craft(player: Node, recipe_id: String) -> bool:
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
return _ask("pickup", [WorldNet.pickup_key(pickup_node)]) # first come, first served
var id: String = pickup_node.item_id
if _give(player, id, pickup_node.amount) <= 0:
return false
pickup_node.collected()
@@ -125,6 +135,8 @@ func pickup(player: Node3D, pickup_node: Node3D) -> bool:
func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool:
if not is_instance_valid(node) or node.depleted:
return false
if _online():
return _ask("hit", [String(node.uid), hit_point])
if player.global_position.distance_to(hit_point) > REACH + 1.0:
return false
var held: Dictionary = ItemDB.get_item(player.held_item_id())
@@ -135,9 +147,6 @@ 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]))
@@ -152,6 +161,8 @@ func can_place(player: Node3D, slot: int) -> bool:
func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool:
if not can_place(player, slot):
return false
if _online():
return _ask("place", [slot, xf])
if player.global_position.distance_to(xf.origin) > 8.0:
return false
var id: String = player.inventory.get_slot(slot).id
@@ -160,9 +171,6 @@ 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
@@ -171,6 +179,8 @@ func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool:
func dismantle(player: Node3D, piece: Node3D) -> bool:
if not is_instance_valid(piece) or not piece.is_in_group("piece") or piece.is_queued_for_deletion():
return false
if _online():
return _ask("dismantle", [WorldNet.nid(piece)])
if player.global_position.distance_to(piece.global_position) > 8.0:
return false
if piece.item_id == "grave":
@@ -183,9 +193,6 @@ 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
@@ -194,8 +201,7 @@ 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
return _ask("door", [WorldNet.nid(door)])
door.set_open(not door.open)
return true
@@ -203,9 +209,16 @@ func toggle_door(player: Node3D, door: Node3D) -> bool:
func use_bed(player: Node3D, bed: Node3D) -> bool:
if not is_instance_valid(bed) or not _in_reach(player, bed, 1.0):
return false
if _online():
return _ask("bed", [WorldNet.nid(bed)])
var world: Node = bed.get_parent().get_parent()
if not bed.is_spawn():
world.set_bed_spawn(bed)
# On a server each player has their own bed (server_player.gd).
var mine: bool = player.claims_bed(bed) if player.has_method("claims_bed") else bed.is_spawn()
if not mine:
if player.has_method("claim_bed"):
player.claim_bed(bed)
else:
world.set_bed_spawn(bed)
notify.emit("Spawn point set")
return true
var why: String = world.sleep_block_reason(bed)
@@ -237,6 +250,8 @@ func melee_stats(player: Node) -> Dictionary:
## One melee blow at an animal.
func melee(player: Node3D, target: Node3D) -> bool:
if _online():
return _not_online()
if not is_instance_valid(target) or not target.is_in_group("creature"):
return false
var m := melee_stats(player)
@@ -264,6 +279,8 @@ func projectile_hit(shooter: Node3D, target: Node3D, damage: float, at: Vector3,
## Throws the spear in `slot` from `origin` along `dir`.
func throw_spear(player: Node3D, slot: int, origin: Vector3, dir: Vector3, world: Node3D) -> bool:
if _online():
return _not_online()
var s = player.inventory.get_slot(slot)
if s == null:
return false
@@ -281,6 +298,8 @@ func throw_spear(player: Node3D, slot: int, origin: Vector3, dir: Vector3, world
## Starts drawing the held bow: needs arrows and a little stamina.
func can_draw(player: Node3D) -> bool:
if _online():
return _not_online()
var bow: Dictionary = ItemDB.get_item(player.held_item_id()).get("bow", {})
if bow.is_empty():
return false
@@ -294,6 +313,8 @@ func can_draw(player: Node3D) -> bool:
## Looses an arrow. `power` 0..1 is how far the bow was drawn.
func shoot(player: Node3D, origin: Vector3, dir: Vector3, power: float, world: Node3D) -> bool:
if _online():
return _not_online()
var bow: Dictionary = ItemDB.get_item(player.held_item_id()).get("bow", {})
if bow.is_empty() or player.global_position.distance_to(origin) > 2.5:
return false
@@ -306,6 +327,8 @@ func shoot(player: Node3D, origin: Vector3, dir: Vector3, power: float, world: N
return true
func butcher(player: Node3D, carcass: Node3D) -> bool:
if _online():
return _not_online()
if not is_instance_valid(carcass) or not carcass.is_in_group("carcass") or not _in_reach(player, carcass, 1.0):
return false
var drops: Dictionary = carcass.butcher_drops()
@@ -322,6 +345,8 @@ func butcher(player: Node3D, carcass: Node3D) -> bool:
## Casts the held rod from `origin` along `dir`.
func cast_line(player: Node3D, origin: Vector3, dir: Vector3, world: Node3D) -> bool:
if _online():
return _not_online()
var rod: Dictionary = ItemDB.get_item(player.held_item_id()).get("fishing", {})
if rod.is_empty() or player.global_position.distance_to(origin) > 2.5:
return false
@@ -335,6 +360,8 @@ func cast_line(player: Node3D, origin: Vector3, dir: Vector3, world: Node3D) ->
## Reels in. With a fish biting you land it; otherwise the line just comes in.
func reel(player: Node3D, world: Node3D) -> bool:
if _online():
return _not_online()
var b: Node3D = world.bobber
if b == null or not is_instance_valid(b):
return false
@@ -351,6 +378,8 @@ func reel(player: Node3D, world: Node3D) -> bool:
## Puts on the cloak in `slot` (swapping out any cloak you were wearing).
func wear(player: Node3D, slot: int) -> bool:
if _online():
return _ask("wear", [slot])
var s = player.inventory.get_slot(slot)
if s == null or not ItemDB.get_item(s.id).has("wear"):
return false
@@ -366,6 +395,8 @@ func wear(player: Node3D, slot: int) -> bool:
func take_off(player: Node3D) -> bool:
if player.worn.is_empty():
return false
if _online():
return _ask("take_off")
if player.inventory.space_for(player.worn) < 1:
notify.emit("Inventory full")
return false
@@ -378,8 +409,7 @@ 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
return _ask("forage", [String(bush.uid)])
var got: Dictionary = bush.harvest()
for id in got:
_give(player, id, int(got[id]))
@@ -387,6 +417,8 @@ func forage(player: Node3D, bush: Node3D) -> bool:
## Eats one of the food in `slot`.
func eat(player: Node3D, slot: int) -> bool:
if _online():
return _ask("eat", [slot])
var s = player.inventory.get_slot(slot)
if s == null:
return false
@@ -406,16 +438,15 @@ func eat(player: Node3D, slot: int) -> bool:
func add_fuel(player: Node3D, fire: Node3D) -> bool:
if not is_instance_valid(fire) or not _in_reach(player, fire):
return false
if _online():
return _ask("fuel", [WorldNet.nid(fire)])
if fire.fuel >= fire.MAX_FUEL - 1.0:
notify.emit("The fire is fully stoked")
return false
for id in ["wood", "stick", "fiber"]:
if player.inventory.count(id) > 0:
player.inventory.remove(id, 1)
if _online():
WorldNet.fuel(fire, id)
else:
fire.add_fuel(float(ItemDB.get_item(id).get("fuel", 10)))
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)")
@@ -426,14 +457,8 @@ 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))
if _online():
if container != null:
_ask("transfer", [WorldNet.nid(container), slot, from == player.inventory])
return
from.quick_move(slot, to)

View File

@@ -224,7 +224,7 @@ func _think(dt: float) -> void:
_state_t = 4.0
elif d < def.attack_range and _attack_cd <= 0.0:
_attack_cd = def.attack_cd
player.survival.damage(def.damage, global_position)
player.survival.damage(def.damage, global_position, "a " + String(def.name).to_lower())
func _start_flee(from: Vector3) -> void:
state = "flee"

View File

@@ -119,7 +119,7 @@ func _attack(pp: Vector3, d: float, as_state: String) -> void:
steer(atan2(-to.x, -to.z))
if d < def.attack_range and _attack_cd <= 0.0 and _can_reach_player():
_attack_cd = def.attack_cd
player.survival.damage(def.damage, global_position)
player.survival.damage(def.damage, global_position, "a " + String(def.name).to_lower())
if as_state == "lunge":
state = "retreat"
_state_t = 1.6

View File

@@ -32,7 +32,7 @@ signal clock(day: int, time: float) ## the server's time of day
const Identity = preload("res://scripts/net/identity.gd")
const ServerConfig = preload("res://scripts/net/server_config.gd")
const PROTOCOL := 3 ## 3: the shared world (M2.3)
const PROTOCOL := 4 ## 3: the shared world (M2.3); 4: the server decides (M2.4)
const DEFAULT_PORT := 24242
const HANDSHAKE_TIMEOUT := 10.0
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join
@@ -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)
PlayerNet.server_leave(id) # saves their character
WorldNet.peer_left(id)
players_changed.emit()
@@ -275,6 +276,15 @@ func _tick_clock(delta: float) -> void:
func player_pos(peer: int) -> Vector3:
return _states[peer].pos if _states.has(peer) else Vector3.INF
func island_spawn() -> Vector3:
return _spawn
## The server moved a player (respawning): their next moves are checked from here.
func teleport(peer: int, pos: Vector3) -> void:
if _states.has(peer):
_states[peer].pos = pos
_states[peer].t = server_time
func _broadcast_snapshot() -> void:
if _states.is_empty():
return
@@ -318,11 +328,13 @@ func _c2s_state(pos: Vector3, yaw: float, pitch: float, anim: int, held: String,
print("[server] moved %s back (%s)" % [players[peer].name, why])
_s2c_correct.rpc_id(peer, s.pos)
return
var from: Vector3 = s.pos
s.pos = pos
s.yaw = wrapf(yaw, -PI, PI)
s.pitch = clampf(pitch, -1.5, 1.5)
s.anim = clampi(anim, 0, 4)
s.held = held if held.length() < 40 and (held.is_empty() or not ItemDB.get_item(held).is_empty()) else ""
s.held = PlayerNet.held(peer) # (what's really in their hand, not what they say)
PlayerNet.server_moved(peer, from, pos, s.yaw, s.anim, server_time - s.t)
s.t = server_time
s.stamp = stamp
@@ -422,14 +434,19 @@ func _c2s_proof(sig: PackedByteArray) -> void:
print("[server] %s is the first player, so they're now an admin" % name)
var p := {"id": id, "name": name, "appearance": Identity.sanitize_appearance(hello.get("appearance", {})),
"admin": config.is_admin(id)}
var start := _spawn_for(players.size())
# Their character (player_net.gd): back where they left off, or new.
var start: Vector3 = PlayerNet.server_join(peer, id, name)
var yaw: float = PlayerNet.yaw_of(peer)
if start == Vector3.INF:
start = _spawn_for(players.size())
PlayerNet.place_at(peer, start)
players[peer] = p
_states[peer] = {"pos": start, "yaw": 0.0, "pitch": 0.0, "anim": 0, "held": "", "t": server_time, "corr_t": -10.0,
_states[peer] = {"pos": start, "yaw": yaw, "pitch": 0.0, "anim": 0, "held": "", "t": server_time, "corr_t": -10.0,
"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"))])
# (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,
var world := {"seed": int(_terrain.noise_seed), "worldlet": String(config.v("worldlet")), "spawn": start, "yaw": yaw,
"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:

453
scripts/net/player_net.gd Normal file
View File

@@ -0,0 +1,453 @@
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
const NOT_YET := "Hunting and fishing aren't on servers yet (they come with the animals)"
# 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))
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)
proxies.erase(peer)
_dirty.erase(peer)
_acks.erase(peer)
p.queue_free()
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()
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(p.cursor.id, p.cursor.count)
p.cursor = null
var has_items := false
for st in p.inventory.slots:
if st != null:
has_items = true
if has_items:
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())
# 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, p.bed_piece(w) != null)
_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:
Actions.dismantle(p, piece)
"door":
var d := WorldNet.piece(_i(a, 0))
if d != null and d.get("open") != null 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":
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(p.cursor.id, p.cursor.count)
if left > 0:
w.drop_item(p.cursor.id, left, Transform3D(Basis(), p.global_position + Vector3(0, 0.1, 0)))
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)))
_:
Actions.notify.emit(NOT_YET if what in ["hunt", "fish"] else "Unknown request")
## 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
var why: String = c.use_block_reason(p.player_id) if c.has_method("use_block_reason") else ""
if not why.is_empty():
Actions.notify.emit(why)
return null
return c
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) -> void:
if world != null and is_instance_valid(world):
world.died_online(cause, has_items, to_bed)
@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)

View File

@@ -0,0 +1,91 @@
extends Node3D
## One player's character on a dedicated server (M2.4): what they carry, what
## they hold, what they wear, their bed, and their survival stats, which run
## here (warmth from the server's fires and buildings, hunger, diet...).
##
## It has the same fields and functions the Actions rules use on the local
## player in single-player (inventory, selected, worn, held_item_id,
## survival...), so the server runs exactly the same rules for everyone.
## It has no body: where it is comes from the player's moves (net.gd).
## Saved as players/<player id>.json in the server's world (player_net.gd).
signal worn_changed
signal selected_changed
const HOTBAR := 8
var peer := 0
var player_id := ""
var display := ""
var inventory := Inventory.new(40)
var selected := 0
var worn := ""
var cursor = null ## the stack held on the inventory screen's cursor
var survival: Node
var hands: Node ## (self: held_heat for a carried torch)
var velocity := Vector3.ZERO
var water_level := -INF
var has_bed_spawn := false
var bed_spawn := Vector3.ZERO
var anim := 0 ## last movement state reported (avatar.gd Anim)
var died_at := 0 ## ticks (ms) when they last died
func setup(world: Node3D) -> void:
hands = self
water_level = world.terrain.water_level
survival = preload("res://scripts/player/survival.gd").new()
survival.name = "Survival"
survival.player = self
survival.world = world
add_child(survival)
func held_item_id() -> String:
var s = inventory.get_slot(selected)
return s.id if s != null else ""
func select(i: int) -> void:
selected = wrapi(i, 0, HOTBAR)
selected_changed.emit()
func held_heat() -> float:
return float(ItemDB.get_item(held_item_id()).get("light", {}).get("heat", 0.0))
## Chests and graves call this on interact; on the server there's no screen.
func open_inventory(_container: Node) -> void:
pass
func claims_bed(bed: Node3D) -> bool:
return has_bed_spawn and bed_spawn.distance_to(bed.global_position) < 0.3
func claim_bed(bed: Node3D) -> void:
has_bed_spawn = true
bed_spawn = bed.global_position
## The bed this player respawns at, if it's still standing.
func bed_piece(world: Node3D) -> Node3D:
if not has_bed_spawn:
return null
for p in world.all_pieces():
if p.item_id == "bed" and claims_bed(p):
return p
return null
func to_data() -> Dictionary:
return {"name": display, "pos": [global_position.x, global_position.y, global_position.z], "yaw": rotation.y,
"inventory": inventory.to_data(), "selected": selected, "stats": survival.to_data(), "worn": worn,
"bed": {"has": has_bed_spawn, "pos": [bed_spawn.x, bed_spawn.y, bed_spawn.z]}}
func load_data(d: Dictionary) -> void:
if d.has("inventory"):
inventory.load_data(d.inventory)
selected = int(d.get("selected", 0))
if d.has("stats"):
survival.load_data(d.stats)
worn = String(d.get("worn", ""))
if not worn.is_empty() and ItemDB.get_item(worn).is_empty():
worn = ""
var b: Dictionary = d.get("bed", {})
has_bed_spawn = bool(b.get("has", false))
if b.has("pos"):
bed_spawn = Vector3(b.pos[0], b.pos[1], b.pos[2])
rotation.y = float(d.get("yaw", 0.0))

View File

@@ -7,26 +7,19 @@ extends Node
## 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.
## Players' requests arrive through PlayerNet (player_net.gd), which runs the
## Actions rules on that player's character on the server. The results show
## up here and go to everyone:
## - pieces placed, removed or collapsed, and items dropped: the server's
## world reports them (world.gd signals), wherever they came from;
## - hits, pickups, berries, doors and fires: PlayerNet tells us;
## - chest and grave contents: whenever they change.
##
## 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)
@@ -36,8 +29,7 @@ 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
var _chests_dirty: Dictionary = {}
## Counters the tests read.
var received := 0 ## client: world events applied
@@ -62,24 +54,23 @@ func open_server_world(config: RefCounted) -> Node3D:
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()
_chests_dirty.clear()
_next_nid = 1
world = w
get_tree().root.add_child(w) # (loads the save: pieces come in through _on_piece_added)
for p in w.all_pieces():
if p.has_meta("nid"):
_next_nid = maxi(_next_nid, int(p.get_meta("nid")) + 1)
_next_nid = maxi(_next_nid, int(p.get_meta("nid", 0)) + 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
_register(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)
_register_dropped(d)
w.piece_added.connect(_on_piece_added)
w.pieces_removed.connect(_on_pieces_removed)
w.dropped_added.connect(_on_dropped_added)
w.saved.connect(PlayerNet.save_all)
w.props.regrown.connect(_on_regrown)
var args: Dictionary = Net.parse_args(OS.get_cmdline_user_args())
if args.has("debug-regrow"):
@@ -99,23 +90,35 @@ func close_server_world() -> void:
pieces.clear()
dropped.clear()
_ready_peers.clear()
PlayerNet.server_reset()
func save() -> void:
_save_left = INF
if world != null and is_instance_valid(world) and world.server_mode:
world.save_now()
world.save_now() # (players too: world.saved)
## Something changed: save within a few seconds. Ctrl+C kills Godot without
## a chance to save, so this keeps the loss small.
func mark_changed() -> void:
if is_server() and _save_left == INF:
_save_left = SAVE_AFTER
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
func _register(p: Node3D) -> void:
if not p.has_meta("nid"):
p.set_meta("nid", _new_nid())
pieces[int(p.get_meta("nid"))] = p
if p.get("inventory") != null:
var nid := int(p.get_meta("nid"))
p.inventory.changed.connect(func(): _chests_dirty[nid] = true; mark_changed())
func _register_dropped(d: Node3D) -> void:
if not d.has_meta("nid"):
d.set_meta("nid", _new_nid())
dropped[int(d.get_meta("nid"))] = d
## A player left: they no longer get events. Save while it's fresh.
func peer_left(peer: int) -> void:
@@ -125,11 +128,17 @@ func peer_left(peer: int) -> void:
func _process(delta: float) -> void:
if not is_server():
return
for nid in _chests_dirty.keys():
_flush_chest(nid)
_chests_dirty.clear()
# 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
_save_left -= delta
if _save_left <= 0.0:
save()
_blocker_left -= delta
if _blocker_left <= 0.0:
_blocker_left = 1.0
@@ -137,10 +146,6 @@ func _process(delta: float) -> void:
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
@@ -152,54 +157,77 @@ func _process(delta: float) -> void:
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.
## Sends to every player who has their snapshot.
func _all(method: String, args: Array) -> void:
if method != "_s2c_fires" and _save_left == INF:
_save_left = SAVE_AFTER
if method != "_s2c_fires":
mark_changed()
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:
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)
## A pickup by the key players use: its scenery id, or "#<net id>" if it was dropped.
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
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))}
# --- server: telling everyone ----------------------------------------------------
func node_hit(n: Node3D, point: Vector3, from: Vector3) -> void:
_all("_s2c_node", [String(n.uid), n.hp, point, from])
func taken(key: String) -> void:
if key.begins_with("#"):
dropped.erase(int(key.substr(1)))
_all("_s2c_taken", [key])
func foraged(bush: Node3D) -> void:
_all("_s2c_forage", [String(bush.uid), bush.regrow_left])
func piece_state(p: Node3D) -> void:
if p.has_method("save_extra"):
_all("_s2c_piece_state", [int(p.get_meta("nid", 0)), p.save_extra()])
func _on_piece_added(p: Node3D) -> void:
_register(p)
_all("_s2c_piece_added", [world.piece_entry(p)])
func _on_pieces_removed(gone: Array, fell: Array) -> void:
var g: Array = []
var f: Array = []
for p in gone:
var nid := int(p.get_meta("nid", 0))
pieces.erase(nid)
_chests_dirty.erase(nid)
g.append(nid)
if p in fell:
f.append(nid)
_all("_s2c_pieces_removed", [g, f])
func _on_dropped_added(d: Node3D) -> void:
_register_dropped(d)
_all("_s2c_dropped", [world.dropped_entry(d)])
func _on_regrown(uid: String) -> void:
_all("_s2c_regrown", [uid])
func _flush_chest(nid: int) -> void:
var p := piece(nid)
if p == null:
return
_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)
static func _empty(inv: Inventory) -> bool:
for s in inv.slots:
@@ -207,8 +235,6 @@ static func _empty(inv: Inventory) -> bool:
return false
return true
# --- client -> server -------------------------------------------------------------
@rpc("any_peer", "call_remote", "reliable")
func _c2s_world() -> void:
var peer := multiplayer.get_remote_sender_id()
@@ -216,216 +242,15 @@ func _c2s_world() -> void:
return
_ready_peers[peer] = true
_s2c_world.rpc_id(peer, world.collect_shared())
PlayerNet.push_all(peer)
@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 ----------------------------------------------------------------
# --- client ------------------------------------------------------------------------
## 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)
@@ -435,49 +260,13 @@ func leave_world() -> void:
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")
@@ -518,7 +307,7 @@ func _s2c_regrown(uid: String) -> void:
func _s2c_taken(key: String) -> void:
if not is_client():
return
var pk := _find_pickup(key)
var pk := find_pickup(key)
if key.begins_with("#"):
dropped.erase(int(key.substr(1)))
if pk != null:
@@ -598,29 +387,3 @@ func _s2c_dropped(e: Dictionary) -> void:
if d != null:
dropped[int(e.nid)] = d
received += 1
## Items the server decided we earned. Anything that doesn't fit goes back
## on the ground at our feet (for anyone to pick up).
@rpc("authority", "call_remote", "reliable")
func _s2c_give(items: Dictionary) -> void:
if not is_client():
return
for id in items:
var n := int(items[id])
var got: int = Actions._give(world.player, String(id), n)
if got < n:
drop(String(id), n - got, Transform3D(Basis(), world.player.global_position + Vector3(0, 0.1, 0)))
@rpc("authority", "call_remote", "reliable")
func _s2c_cursor(stack: Variant) -> void:
chest_busy = false
var cb := _chest_cb
_chest_cb = Callable()
if cb.is_valid():
cb.call(stack)
elif stack != null and is_client():
_s2c_give({stack.id: stack.count})
@rpc("authority", "call_remote", "reliable")
func _s2c_notify(text: String) -> void:
Actions.notify.emit(text)

View File

@@ -38,6 +38,7 @@ var _rest_build := 0.0
## Food buffs: id -> {name, left (s), stamina_regen, health_regen (multipliers)}.
var buffs: Dictionary = {}
var _hurt_cd := 0.0 ## no natural healing for a few seconds after a hit
var cause := "" ## what last hurt you, as the death screen says it ("You froze")
signal hurt(amount: float, from: Vector3)
const HURT_NO_REGEN := 6.0
@@ -71,19 +72,20 @@ func balanced_diet() -> bool:
return false
return true
var player: CharacterBody3D
var player: Node3D ## the local player, or a server_player.gd on a server
var world: Node3D
var _stamina_wait := 0.0
func _process(delta: float) -> void:
if world != null and not world.menu_mode:
# (Online, the server runs our stats and sends them: player_net.gd.)
if world != null and not world.menu_mode and not world.is_online():
step(delta)
func step(dt: float) -> void:
if dead:
return
var p := player.global_position
heat = world.heat_at(p)
heat = world.heat_at(p, player)
_shelter_left -= dt
if _shelter_left <= 0.0:
_shelter_left = 0.25
@@ -137,6 +139,12 @@ func step(dt: float) -> void:
dmg += MALNOURISHED_DRAIN
if dmg > 0.0:
health = maxf(health - dmg * dt, 0.0)
if hunger <= 0.0:
cause = "You starved"
elif warmth < 12.0:
cause = "You froze to death"
else:
cause = "Malnourished: you needed %s" % " and ".join(PackedStringArray(lacking.map(func(g): return DIET_NAMES[g])))
elif hunger > 40.0 and warmth > 35.0 and _hurt_cd <= 0.0 and lacking.is_empty():
var diet_mult := 1.5 if balanced_diet() else 1.0
health = minf(health + 0.5 * buff_mult("health_regen") * diet_mult * dt, MAX)
@@ -219,15 +227,18 @@ func reset_after_death() -> void:
warmth = 60.0
wet = 0.0
buffs.clear()
cause = ""
# Don't respawn straight back into malnutrition.
for g in DIET_GROUPS:
diet[g] = maxf(float(diet[g]), 30.0)
changed.emit()
## Takes a hit (a boar, later wolves). Pauses natural healing briefly.
func damage(amount: float, from: Vector3) -> void:
## `by` is what did it ("a wolf"), for the death screen.
func damage(amount: float, from: Vector3, by: String = "") -> void:
if dead:
return
cause = "Killed by %s" % by if not by.is_empty() else "You were killed"
health = maxf(health - amount, 0.0)
_hurt_cd = HURT_NO_REGEN
hurt.emit(amount, from)

View File

@@ -209,6 +209,72 @@ func fade(mid: Callable) -> void:
player.ui_open = false
black.queue_free())
var _death: Control
## The death screen: the world fades to dark red, it says what killed you
## and where your things are, and after `delay` seconds Respawn (or Enter)
## calls `respawn`.
func show_death(cause: String, note: String, delay: float, respawn: Callable) -> void:
hide_death()
player.ui_open = true
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
var bg := ColorRect.new()
bg.color = Color(0.22, 0.02, 0.02, 0.0)
bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
bg.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(bg)
_death = bg
var col := VBoxContainer.new()
col.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
col.grow_horizontal = Control.GROW_DIRECTION_BOTH
col.grow_vertical = Control.GROW_DIRECTION_BOTH
col.alignment = BoxContainer.ALIGNMENT_CENTER
col.add_theme_constant_override("separation", 14)
col.modulate.a = 0.0
bg.add_child(col)
var title := UITheme.label("You died", 56)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
col.add_child(title)
var why := UITheme.label(cause if not cause.is_empty() else "You died", 22)
why.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
col.add_child(why)
var where := UITheme.label(note, 16, UITheme.TEXT_DIM)
where.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
col.add_child(where)
var btn := Button.new()
btn.custom_minimum_size = Vector2(220, 46)
btn.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
btn.disabled = true
btn.text = "Respawn (%d)" % ceili(delay)
col.add_child(btn)
btn.pressed.connect(func():
hide_death()
respawn.call())
var tw := bg.create_tween()
tw.tween_property(bg, "color:a", 0.72, 0.9)
tw.parallel().tween_property(col, "modulate:a", 1.0, 0.9)
# Count down, then let them go.
for k in range(1, ceili(delay)):
var left := ceili(delay) - k
get_tree().create_timer(float(k)).timeout.connect(func():
if is_instance_valid(btn) and btn.disabled:
btn.text = "Respawn (%d)" % left)
get_tree().create_timer(delay).timeout.connect(func():
if is_instance_valid(btn):
btn.disabled = false
btn.text = "Respawn"
btn.grab_focus())
func is_dead_screen() -> bool:
return _death != null and is_instance_valid(_death)
func hide_death() -> void:
if is_dead_screen():
_death.queue_free()
_death = null
player.ui_open = false
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
## Closes the inventory / menu (e.g. on death).
func close_screens() -> void:
if _screen.is_open():
@@ -221,6 +287,8 @@ func _on_inventory_requested(container: Node) -> void:
## ESC: close the inventory if it's open, otherwise toggle the pause menu.
func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE:
if is_dead_screen():
return # (respawn first)
if _screen.is_open():
_screen.close()
elif _menu.is_open():

View File

@@ -88,6 +88,7 @@ func bind(p: CharacterBody3D) -> void:
wr.add_child(_worn_btn)
pv.add_child(wr)
player.worn_changed.connect(_refresh)
PlayerNet.cursor_changed.connect(_on_server_cursor)
pp.add_child(pv)
row.add_child(pp)
@@ -142,14 +143,13 @@ func open(chest: Node) -> void:
_refresh()
func close() -> void:
if _cursor != null:
if WorldNet.is_client():
PlayerNet.ask("close") # the server puts the cursor back in our bag
_cursor = null
elif _cursor != null:
var left: int = player.inventory.add(_cursor.id, _cursor.count)
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)
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)
@@ -193,11 +193,16 @@ func _rebuild_chest() -> void:
_chest_grid.add_child(s)
_chest_slots.append(s)
## Online the server does every click (player_net.gd). Clicks in our own
## bag also happen here straight away, so dragging doesn't wait on the
## network; the server's copy replaces ours once it's caught up.
func _on_player_slot(i: int, right: bool, shift: bool) -> void:
if shift and container != null:
Actions.transfer(player, player.inventory, i, container.inventory, container)
else:
_cursor = player.inventory.click(i, _cursor, right)
if WorldNet.is_client():
PlayerNet.ask("inv_click", [0, i, right])
_refresh()
func _on_chest_slot(i: int, right: bool, shift: bool) -> void:
@@ -206,26 +211,17 @@ func _on_chest_slot(i: int, right: bool, shift: bool) -> void:
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)
# A shared chest (someone else may be in it too): only the server's
# answer counts.
PlayerNet.ask("inv_click", [WorldNet.nid(container), i, right])
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)))
## Online: what the server says we're holding.
func _on_server_cursor(stack) -> void:
_cursor = stack if visible else null
_refresh()
func _refresh() -> void:
if player == null:

View File

@@ -65,6 +65,14 @@ var bed_spawn := Vector3.ZERO ## position of the bed you respawn at
func is_online() -> bool:
return not menu_mode and Net.state == "connected"
var _loading := false ## applying a save: defer support recompute
var _applying := false ## applying a save or snapshot: not news for the server's players
## Server only: changes to the island, wherever they came from (a player's
## request, a collapse, a death), for world_net.gd to send to everyone.
signal piece_added(piece: Node3D)
signal pieces_removed(gone: Array, fell: Array)
signal dropped_added(item: Node3D)
signal saved
func _ready() -> void:
_autosave_left = autosave_seconds
@@ -151,12 +159,15 @@ func air_warmth(pos: Vector3, sheltered: bool = false) -> float:
return w
## 0..1 heat from fires and a held torch.
func heat_at(pos: Vector3) -> float:
## `holder` is whose torch counts (default: the local player; on a server,
## each player's character).
func heat_at(pos: Vector3, holder: Node = null) -> float:
var h := 0.0
for n in get_tree().get_nodes_in_group("heat"):
h = maxf(h, n.heat_at(pos))
if player.hands != null:
h = maxf(h, player.hands.held_heat())
var who: Node = holder if holder != null else player
if who.get("hands") != null:
h = maxf(h, who.hands.held_heat())
return h
## Death: everything you carried goes into a grave where you fell, and you
@@ -176,16 +187,34 @@ func _on_player_died() -> void:
if st != null:
has_items = true
if has_items:
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())
var grave := spawn_piece(ItemDB.get_item("grave"), Transform3D(Basis(Vector3.UP, player.rotation.y), pos))
grave.inventory.load_data(player.inventory.to_data())
player.inventory.load_data(Inventory.new(player.inventory.size()).to_data())
# The death screen: you stay down for a few seconds, then Respawn.
# (Headless, with no screen, you wake straight away.)
dead_waiting = true
player.velocity = Vector3.ZERO
player.set_physics_process(false)
if hud != null:
hud.close_screens()
var bed := spawn_bed() if net_sync == null else null # (online: beds aren't shared until M2.4)
hud.show_death(player.survival.cause, _death_note(has_items, spawn_bed() != null), RESPAWN_DELAY, respawn_after_death)
else:
respawn_after_death()
const RESPAWN_DELAY := 3.0 ## seconds on the death screen before you can respawn
var dead_waiting := false ## dead, on the death screen
static func _death_note(has_items: bool, to_bed: bool) -> String:
return "%s\nYou'll wake %s." % ["Your belongings are in a grave where you fell." if has_items else "You had nothing on you.",
"at your bed" if to_bed else "at the island start"]
## Respawn (the death screen's button): at your bed, or the island start.
func respawn_after_death() -> void:
if not dead_waiting:
return
dead_waiting = false
player.set_physics_process(true)
var bed := spawn_bed()
if bed != null:
_respawn_at_bed(bed)
else:
@@ -194,7 +223,17 @@ func _on_player_died() -> void:
has_bed_spawn = false
Actions.notify.emit("Your bed is gone, so you woke at the island start")
player.survival.reset_after_death()
Actions.notify.emit("You died. Your belongings are in your grave." if has_items else "You died.")
## Online: the server says we died. Same screen; Respawn asks the server.
func died_online(text: String, has_items: bool, to_bed: bool) -> void:
dead_waiting = true
player.velocity = Vector3.ZERO
player.set_physics_process(false)
if hud != null:
hud.close_screens()
hud.show_death(text, _death_note(has_items, to_bed), RESPAWN_DELAY, func(): PlayerNet.ask("respawn"))
else:
get_tree().create_timer(RESPAWN_DELAY + 0.1).timeout.connect(func(): PlayerNet.ask("respawn"))
# --- beds and sleeping ------------------------------------------------------
@@ -217,9 +256,21 @@ func _respawn_at_bed(bed: Node3D) -> void:
player.rotation = Vector3(0.0, bed.global_basis.get_euler().y + PI, 0.0)
player.get_node("Camera3D").rotation.x = 0.0
## Online: the server says we died (or were moved) and puts us here.
func respawn_from_server(at: Vector3, yaw: float) -> void:
dead_waiting = false
player.set_physics_process(true)
if hud != null:
hud.close_screens()
hud.hide_death()
player.global_position = at
player.velocity = Vector3.ZERO
player.rotation = Vector3(0.0, yaw, 0.0)
player.get_node("Camera3D").rotation.x = 0.0
## Why you can't sleep in `bed` right now ("" = you can).
func sleep_block_reason(bed: Node3D) -> String:
if is_online():
if is_online() or server_mode:
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:
@@ -334,12 +385,17 @@ func save_now() -> bool:
_autosave_left = autosave_seconds
if not Saves.has_world():
return false
if dead_waiting and not server_mode:
respawn_after_death() # never save someone lying dead
if hud != null:
hud.hide_death()
var ok: bool = Saves.save_state(Saves.current_id, collect_save(), _session_time)
if ok:
_session_time = 0.0
if server_mode:
if not ok:
printerr("[server] saving the world FAILED")
saved.emit() # (the players' characters save with it)
else:
Actions.notify.emit("Game saved" if ok else "Save FAILED")
return ok
@@ -502,6 +558,11 @@ func remove_pieces_net(gone: Array, collapsed_nids: Array) -> void:
_update_grass_cuts()
func apply_save(s: Dictionary, with_player: bool = true) -> void:
_applying = true
_apply_save(s, with_player)
_applying = false
func _apply_save(s: Dictionary, with_player: bool) -> 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])
@@ -596,16 +657,21 @@ func placement_valid(def: Dictionary, xf: Transform3D) -> bool:
q.exclude = [terrain.get_node("TerrainBody").get_rid(), player.get_rid()]
return get_world_3d().direct_space_state.intersect_shape(q, 1).is_empty()
func spawn_piece(def: Dictionary, xf: Transform3D) -> Node3D:
## `extra` is the piece's own state (a grave's owner, a door left open...).
func spawn_piece(def: Dictionary, xf: Transform3D, extra: Dictionary = {}) -> Node3D:
var script: Script = PIECES.get(def.place.piece, preload("res://scripts/building/piece.gd"))
var piece: StaticBody3D = script.new()
piece.setup(def)
_pieces.add_child(piece)
piece.global_transform = xf
if not extra.is_empty() and piece.has_method("load_extra"):
piece.load_extra(extra)
if piece.is_in_group("build") and not _loading:
build.recompute()
if piece.kind == "floor":
_update_grass_cuts()
if server_mode and not _applying:
piece_added.emit(piece)
return piece
## Grass doesn't grow up through floors.
@@ -636,6 +702,8 @@ func _go_online() -> void:
_spawn = Net.world_info.get("island_spawn", _spawn)
var start: Vector3 = Net.world_info.get("spawn", _spawn)
player.global_position = start + Vector3(0.0, 0.05, 0.0)
if Net.world_info.has("yaw"):
player.rotation.y = float(Net.world_info.yaw) # facing the way you were when you left
net_sync = preload("res://scripts/net/net_sync.gd").new()
net_sync.name = "NetSync"
add_child(net_sync)
@@ -645,8 +713,9 @@ func _go_online() -> void:
# The island's changes come from the server from now on.
props.regrow_enabled = false
WorldNet.join_world(self)
PlayerNet.join_world(self) # and our inventory and stats
if hud != null:
var bar := UITheme.label("Online: %s · the island is shared; your inventory isn't saved yet, and there are no animals yet" \
var bar := UITheme.label("Online: %s · no animals, hunting or fishing on servers yet" \
% Net.server_info.get("server_name", "server"), 14, UITheme.TEXT_DIM)
bar.position = Vector2(16, 40)
$HUD.add_child(bar)
@@ -654,6 +723,7 @@ func _go_online() -> void:
func _on_net_left(_reason: String) -> void:
WorldNet.leave_world()
PlayerNet.leave_world()
if is_inside_tree():
get_tree().paused = false
get_tree().auto_accept_quit = true
@@ -661,10 +731,10 @@ 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).
## Online only the server drops things (our game never does: returns null).
func drop_item(id: String, count: int, xf: Transform3D) -> Node3D:
if net_sync != null:
WorldNet.drop(id, count, xf)
push_warning("[world] drop_item(%s) on a client: only the server drops items" % id)
return null
return _make_dropped(id, count, xf)
@@ -675,6 +745,8 @@ func _make_dropped(id: String, count: int, xf: Transform3D) -> Node3D:
pk.dropped = true
_dropped.add_child(pk)
pk.global_transform = xf
if server_mode and not _applying:
dropped_added.emit(pk)
return pk
## Casts a fishing float (Actions validated it). One at a time.
@@ -708,8 +780,12 @@ func remove_piece(piece: Node3D, refund_to: Node3D = null) -> int:
_pieces.remove_child(piece)
piece.queue_free()
if not was_build:
if server_mode:
pieces_removed.emit([piece], [])
return 0
var weak: Array = build.recompute()
if server_mode:
pieces_removed.emit([piece] + weak, weak)
for w in weak:
if refund_to != null:
Actions._give(refund_to, w.item_id, 1)

View File

@@ -5,6 +5,10 @@ extends SceneTree
## into them, for real and by cheating), doors, chests, fires, dismantling,
## and the whole lot surviving a server restart.
##
## M2.4: the server decides. Alice's inventory, crafting, eating and stats
## live on the server; a game that edits its own bag gets nothing for it;
## graves belong to whoever died; characters are saved per player.
##
## A real headless server, plus "Bob" (tests/net_world_bot.gd) as another
## process with his own world, following this test's commands. This test is
## "Alice" with the real world scene.
@@ -22,6 +26,7 @@ var _finished := false
var _pids: Array[int] = []
var Net: Node
var WN: Node
var PN: Node
var A: Node
var world: Node3D
var player: CharacterBody3D
@@ -41,6 +46,7 @@ func _run() -> void:
Engine.max_fps = 60
Net = root.get_node("Net")
WN = root.get_node("WorldNet")
PN = root.get_node("PlayerNet")
A = root.get_node("Actions")
A.notify.connect(func(t): _toasts.append(t))
for d in [SRV, BOB]:
@@ -62,7 +68,7 @@ func _run() -> void:
func _start_server() -> void:
# --debug-regrow: things grow back in seconds instead of half an hour.
_server_pid = _spawn(["--server", "--port", str(PORT), "--config", SRV + "/server.json",
"--server-name", "Shared Test", "--seed", str(SEED), "--debug-regrow", "0.002"])
"--server-name", "Shared Test", "--seed", str(SEED), "--debug-regrow", "0.002", "--debug-cheats"])
func _body() -> void:
var me = load("res://scripts/net/identity.gd").open(DIR + "/a")
@@ -83,6 +89,7 @@ func _body() -> void:
if not bool(b0.get("ok", false)):
return
await _server_decides()
await _trees()
await _building()
await _containers_and_fires()
@@ -90,6 +97,66 @@ func _body() -> void:
await _restart()
_finished = true
# --- the server decides -----------------------------------------------------------
func _server_decides() -> void:
print("the server decides")
await _sleep(1.0)
check(not player.survival.is_processing() and player.survival.hunger < 100.0 and player.survival.hunger > 90.0,
"Alice's stats run on the server and come to her (hunger %.2f)" % player.survival.hunger)
await _give("stick", 2)
await _give("flint", 2)
A.craft(player, "stone_axe")
await _until(func(): return player.inventory.count("stone_axe") == 1, 2.0)
check(player.inventory.count("stone_axe") == 1 and player.inventory.count("stick") == 0 and player.inventory.count("flint") == 0,
"she crafts a stone axe: the server takes the sticks and flint and gives her the axe")
# A modified game puts walls in its own bag and tries to build with them.
var fake := 7
player.inventory.set_slot(fake, {"id": "wood_wall", "count": 5})
var n0: int = WN.pieces.size()
var front := player.global_position - player.global_basis.z * 3.0
A.place(player, fake, Transform3D(Basis(), Vector3(front.x, world.terrain.sample_height(front.x, front.z), front.z)), world)
await _sleep(0.8)
check(WN.pieces.size() == n0 and player.inventory.get_slot(fake) == null,
"walls a game invents in its own bag build nothing, and the server's copy of her bag replaces it")
var bs: int = await _give("berries", 3)
PN.ask("debug_stats", [{"hunger": 50.0, "diet": {"plants": 10.0}}])
await _until(func(): return player.survival.hunger < 51.0, 2.0)
A.eat(player, bs)
await _until(func(): return player.survival.hunger > 55.0, 2.0)
check(player.survival.hunger > 55.0 and float(player.survival.diet.plants) > 20.0 and player.inventory.count("berries") == 2,
"she eats a berry on the server: hunger %.0f, greens %.0f" % [player.survival.hunger, float(player.survival.diet.plants)])
## Test-only (the server runs with --debug-cheats): items into Alice's bag on
## the server. Returns the slot they're in.
func _give(id: String, n: int) -> int:
var before: int = player.inventory.count(id)
PN.ask("debug_give", [id, n])
await _until(func(): return player.inventory.count(id) >= before + n, 3.0)
return _slot_of(id)
func _clear() -> void:
PN.ask("debug_clear")
await _until(func():
for s in player.inventory.slots:
if s != null:
return false
return true, 3.0)
func _slot_of(id: String) -> int:
for i in range(player.inventory.size()):
var s = player.inventory.get_slot(i)
if s != null and s.id == id:
return i
return -1
func _until(cond: Callable, limit: float) -> bool:
var t := 0.0
while not cond.call() and t < limit:
await _sleep(0.05)
t += 0.05
return cond.call()
# --- trees --------------------------------------------------------------------
func _trees() -> void:
@@ -99,13 +166,12 @@ func _trees() -> void:
var to_tree: Vector3 = (tree.global_position - player.global_position)
to_tree.y = 0.0
await Bot.walk(player, tree.global_position - to_tree.normalized() * 1.6, world)
player.inventory.set_slot(0, {"id": "stone_axe", "count": 1})
player.select(0)
player.select(_slot_of("stone_axe"))
await _sleep(0.2)
var wood0: int = player.inventory.count("wood")
var hits := 0
var t := 0.0
while not tree.depleted and t < 20.0:
player.survival.stamina = 100.0
if A.harvest_hit(player, tree, tree.global_position + Vector3(0, 1.0, 0)):
hits += 1
await _sleep(0.25)
@@ -185,12 +251,14 @@ func _place(id: String, at: Vector3, yaw: float) -> Node3D:
print(" (can't place %s: %s)" % [id, res.reason])
return null
xf = res.xf
player.inventory.set_slot(5, {"id": id, "count": 1})
var slot: int = await _give(id, 1)
var had: int = player.inventory.count(id)
var before: Array = WN.pieces.keys()
if not A.place(player, 5, xf, world):
if not A.place(player, slot, xf, world):
print(" (Actions.place refused %s)" % id)
return null
check(player.inventory.get_slot(5) == null, "(%s taken from Alice's bag)" % id)
await _until(func(): return player.inventory.count(id) == had - 1, 2.0)
check(player.inventory.count(id) == had - 1, "(%s taken from Alice's bag by the server)" % id)
var t := 0.0
while t < 3.0:
for k in WN.pieces:
@@ -213,18 +281,22 @@ func _containers_and_fires() -> void:
check(_chest != null, "Alice places a chest")
if _chest == null:
return
player.inventory.set_slot(6, {"id": "wood", "count": 10})
A.transfer(player, player.inventory, 6, _chest.inventory, _chest)
await _sleep(0.5)
check(player.inventory.get_slot(6) == null and _chest.inventory.count("wood") == 10, "she puts 10 wood in")
var got := {"c": "waiting"}
WN.chest_click(_chest, 3, {"id": "stone", "count": 3}, false, func(c): got.c = c)
var t := 0.0
while got.c is String and t < 3.0:
await _sleep(0.05)
t += 0.05
check(got.c == null and _chest.inventory.get_slot(3) != null and _chest.inventory.get_slot(3).id == "stone",
"and drops 3 stone in slot 4 by hand (the server does the click and hands back an empty cursor)")
await _clear()
var ws: int = await _give("wood", 10)
A.transfer(player, player.inventory, ws, _chest.inventory, _chest)
await _until(func(): return _chest.inventory.count("wood") == 10, 2.0)
check(player.inventory.count("wood") == 0 and _chest.inventory.count("wood") == 10, "she puts 10 wood in (shift-click)")
var ss: int = await _give("stone", 3)
var cur := {"c": "waiting"}
var on_cur := func(c): cur.c = c
PN.cursor_changed.connect(on_cur)
PN.ask("inv_click", [0, ss, false]) # pick the stone up...
PN.ask("inv_click", [WN.nid(_chest), 3, false]) # ...and put it in slot 4
await _until(func(): return _chest.inventory.get_slot(3) != null, 2.0)
await _sleep(0.2)
PN.cursor_changed.disconnect(on_cur)
check(_chest.inventory.get_slot(3) != null and _chest.inventory.get_slot(3).id == "stone" and cur.c == null,
"and drags 3 stone into slot 4 by hand (the server does both clicks)")
var nid: int = WN.nid(_chest)
await _bob({"do": "walk", "to": _v(site + Vector3(1.8, 0, 0))})
var rep := await _bob({"do": "report"})
@@ -248,20 +320,23 @@ func _containers_and_fires() -> void:
if _fire == null:
return
var f0: float = _fire.fuel
player.inventory.set_slot(7, {"id": "wood", "count": 2})
await _give("wood", 2)
A.add_fuel(player, _fire)
await _sleep(0.5)
check(_fire.fuel > f0 + 30.0, "and feeds it a log (%.0f s -> %.0f s of fuel)" % [f0, _fire.fuel])
rep = await _bob({"do": "report"})
seen = rep.pieces.get(str(WN.nid(_fire)), {})
check(absf(float(seen.get("fuel", -100.0)) - _fire.fuel) < 5.0, "Bob's fire has the same fuel (%.0f s)" % float(seen.get("fuel", -1.0)))
await _until(func(): return "Warm" in player.survival.statuses, 2.0)
check("Warm" in player.survival.statuses and player.survival.heat > 0.25,
"standing by it, the server says she's Warm (heat %.2f)" % player.survival.heat)
# --- things on the ground --------------------------------------------------------
func _items_on_the_ground() -> void:
print("dropped items")
var at := player.global_position + Vector3(1.0, 0.1, 0)
world.drop_item("flint", 2, Transform3D(Basis(), at))
PN.ask("debug_drop", ["flint", 2, at]) # (only the server drops things now)
var t := 0.0
var key := ""
while key.is_empty() and t < 3.0:
@@ -270,7 +345,7 @@ func _items_on_the_ground() -> void:
key = "#%d" % k
await _sleep(0.05)
t += 0.05
check(not key.is_empty(), "Alice drops 2 flint: it's in the shared world")
check(not key.is_empty(), "2 flint dropped on the ground: it's in the shared world")
var rep := await _bob({"do": "report"})
check(rep.dropped.any(func(d): return d.item == "flint" and int(d.count) == 2), "Bob sees it")
await _bob({"do": "walk", "to": _v(at + Vector3(1.0, 0, 0))})
@@ -295,11 +370,12 @@ func _items_on_the_ground() -> void:
"only one of them gets the %s (Alice %d, Bob %d)" % [id, a_got, b_got])
print("dying online")
player.inventory.load_data(Inventory.new(player.inventory.size()).to_data())
player.inventory.set_slot(0, {"id": "stone", "count": 7})
player.inventory.set_slot(1, {"id": "berries", "count": 4})
await _clear()
await _give("stone", 7)
await _give("berries", 4)
var died_at := player.global_position
world._on_player_died()
PN.ask("debug_die")
var died_t := Time.get_ticks_msec()
var grave: Node3D = null
var t2 := 0.0
while grave == null and t2 < 3.0:
@@ -308,14 +384,24 @@ func _items_on_the_ground() -> void:
grave = p
await _sleep(0.05)
t2 += 0.05
await _sleep(0.3)
check(grave != null and grave.global_position.distance_to(died_at) < 3.0 and player.inventory.count("stone") == 0,
"Alice dies: her things go into a grave in the shared world")
"Alice dies (on the server): her things go into a grave in the shared world")
check(world.dead_waiting and player.global_position.distance_to(died_at) < 1.0,
"she stays down on the death screen for a moment")
await _until(func(): return not world.dead_waiting, 6.0)
var down := (Time.get_ticks_msec() - died_t) / 1000.0
check(not world.dead_waiting and player.global_position.distance_to(died_at) > 3.0 and down >= world.RESPAWN_DELAY - 0.2,
"then respawns (after %.1f s), and the server wakes her at the island start" % down)
if grave == null:
return
var gnid: int = WN.nid(grave)
var rep2 := await _bob({"do": "report"})
var g: Dictionary = rep2.pieces.get(str(gnid), {})
check(_count(g.get("inv", []), "stone") == 7 and _count(g.get("inv", []), "berries") == 4, "Bob sees the grave, with what she had")
await _bob({"do": "walk", "to": _v(grave.global_position + Vector3(-1.5, 0, 0))})
var bt := await _bob({"do": "chest_take", "nid": gnid, "slot": 0})
check(_count(bt.get("inv", []), "stone") == 0 and grave.inventory.count("stone") == 7, "but it's hers: Bob can't take from it")
await Bot.walk(player, grave.global_position + Vector3(1.5, 0, 0), world)
for i in range(grave.inventory.size()):
if is_instance_valid(grave) and grave.inventory.get_slot(i) != null:
@@ -355,6 +441,9 @@ func _restart() -> void:
for k in WN.pieces:
expect[k] = WN.pieces[k].item_id
var chest_nid: int = WN.nid(_chest) if _chest != null else -1
await _give("flint", 3) # (a change, so the save comes after it)
var bag: Array = player.inventory.to_data()
var where := player.global_position
# Killed outright while everyone's still on (like Ctrl+C, which gives
# Godot no chance to save): the server saved within a few seconds of the
# last change anyway.
@@ -385,6 +474,9 @@ func _restart() -> void:
if p.item_id == "wood_door":
d = p
check(d != null and d.open, "and the door is still open")
await _sleep(0.5)
check(player.inventory.to_data() == bag, "Alice's bag is just as she left it (her character is saved on the server)")
check(player.global_position.distance_to(where) < 2.0, "and she's back where she was (%.1f m off)" % player.global_position.distance_to(where))
world.queue_free()
await process_frame
Net.leave()