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 ## 1. Goals
- **Self-hosted**, 2-8 players (up to 16 possible). No central service, no - **Self-hosted**, 2-8 players (up to 16 possible). No central service, no
accounts: a world lives on whoever hosts it. accounts: a world lives on whoever hosts it.
- **Three ways to play, one code path:** - **Three ways to play, the same rules (Actions):**
- **Single-player**: the game runs a server inside itself and connects to - **Single-player**: no server; your game decides, exactly as now
it (loopback). It plays exactly as now. (decided in M2.4, §7).
- **Host & play**: "Host game" on the title screen, and friends join you. - **Host & play**: "Host game" on the title screen, and friends join you.
- **Dedicated server**: a headless "server only" process with no local - **Dedicated server**: a headless "server only" process with no local
player. It keeps the world running (or paused) for whoever connects. 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 server, never on top of a player.
- The world (fires, regrowth, the clock) pauses while nobody's on. - The world (fires, regrowth, the clock) pauses while nobody's on.
Until later phases, while online: your inventory, stats and crafting stay on **Your character on the server** *(built in M2.4: `player_net.gd`,
your own game and aren't saved by the server (M2.4), so the server trusts `server_player.gd`, autoload PlayerNet)*. Online the server decides;
what you say you're putting into the world (a wall, a chest deposit, a offline there's no server and your game decides, exactly as before.
grave's contents) but decides everything taken out. Animals are off (M2.5). - Each player has a character on the server with their inventory, hotbar
Sleeping is off (it needs the sleep rule, M2.5). Respawning ignores beds. choice, cloak, inventory-screen cursor, bed and survival stats. The stats
The HUD says so. 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 **Interest.** On a 320 m worldlet with 8 players, the server sends every
entity to everyone at first. Distance-based interest is a later 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 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 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` 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. changes. *As of M2.4:* `players/<player id>.json` too (position, facing,
- Single-player saves (today's `world.json` with a `"player"` block) are inventory, hotbar choice, stats, diet, buffs, cloak, bed), saved with the
migrated on first load. That block becomes the local player's file. The world, when the player leaves and when they die. Graves carry their
test suite keeps an old-format save fixture to prove it. 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 - Per-player graves already work, since graves are pieces. With
`death_penalty: grave` they get an owner, and only the owner (and friends, `death_penalty: grave` they get an owner, and only the owner (and friends,
depending on locks) can loot them. 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.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.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.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.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.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 | | **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 - **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 players; raiding off means you can't dismantle a stranger's wall; locks
keep strangers out of your chest). keep strangers out of your chest).
- **Save tests:** world and player file round trips, migration of old - **Save tests:** world and player file round trips, backups.
saves, backups. - The single-player suites keep running as they are (no server).
- The existing 9 suites keep running in single-player (loopback) mode.
- `tests/test_net_shared.gd` (M2.3) is the two-world version: this test is - `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 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 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 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 or port forwarding, player identity and names, server rules for co-op/PvP, and
phases M2.0-M2.7). 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] 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] 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] Replicate resource nodes, pickups, building, chests, fires, doors, clock *(M2.3)*
- [x] Persistence: world save/load on the server *(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. - **Done when:** a friend joins your self-hosted server and you build together.
## M3 — Addon SDK + sandbox · ~2–3 months ## 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" Saves="*res://scripts/core/saves.gd"
Net="*res://scripts/net/net.gd" Net="*res://scripts/net/net.gd"
WorldNet="*res://scripts/net/world_net.gd" WorldNet="*res://scripts/net/world_net.gd"
PlayerNet="*res://scripts/net/player_net.gd"
[display] [display]

View File

@@ -1,20 +1,46 @@
extends "res://scripts/building/piece.gd" extends "res://scripts/building/piece.gd"
## Grave: left where you died, holding everything you carried. Open it with E ## Grave: left where you died, holding everything you carried. Open it with E
## to take your things back; it crumbles once empty. ## 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 inventory := Inventory.new(40)
var owner_id := ""
var owner_name := ""
func _build() -> void: func _build() -> void:
inventory.changed.connect(_on_changed) 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: func display_name() -> String:
return "Your grave" return "Your grave" if _mine() else "%s's grave" % owner_name
func interact_prompt(_player: Node) -> String: func interact_prompt(_player: Node) -> String:
if not _mine():
return "%s's grave" % owner_name
return "Your grave [E] Recover belongings" return "Your grave [E] Recover belongings"
func interact(player: Node) -> void: func interact(player: Node) -> void:
if _mine():
player.open_inventory(self) 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: func _on_changed() -> void:
if WorldNet.world != null: if WorldNet.world != null:

View File

@@ -54,11 +54,23 @@ func _give(player: Node, id: String, n: int) -> int:
return got return got
## Playing on a server: changes to the island go to it (world_net.gd), and ## 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 ## come back to everyone, us included.
## until M2.4.
func _online() -> bool: func _online() -> bool:
return WorldNet.is_client() 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: 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 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) return inv.has_all(r.inputs) and station_near(player, r.station)
func craft(player: Node, recipe_id: String) -> bool: func craft(player: Node, recipe_id: String) -> bool:
if _online():
return _ask("craft", [recipe_id])
var r: Dictionary = ItemDB.recipes.get(recipe_id, {}) var r: Dictionary = ItemDB.recipes.get(recipe_id, {})
if r.is_empty(): if r.is_empty():
return false return false
@@ -109,13 +123,9 @@ func craft(player: Node, recipe_id: String) -> bool:
func pickup(player: Node3D, pickup_node: Node3D) -> bool: func pickup(player: Node3D, pickup_node: Node3D) -> bool:
if not is_instance_valid(pickup_node) or not _in_reach(player, pickup_node): if not is_instance_valid(pickup_node) or not _in_reach(player, pickup_node):
return false return false
var id: String = pickup_node.item_id
if _online(): if _online():
if player.inventory.space_for(id) < 1: return _ask("pickup", [WorldNet.pickup_key(pickup_node)]) # first come, first served
notify.emit("Inventory full") var id: String = pickup_node.item_id
return false
WorldNet.take(pickup_node) # first come, first served: the server decides
return true
if _give(player, id, pickup_node.amount) <= 0: if _give(player, id, pickup_node.amount) <= 0:
return false return false
pickup_node.collected() 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: func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool:
if not is_instance_valid(node) or node.depleted: if not is_instance_valid(node) or node.depleted:
return false return false
if _online():
return _ask("hit", [String(node.uid), hit_point])
if player.global_position.distance_to(hit_point) > REACH + 1.0: if player.global_position.distance_to(hit_point) > REACH + 1.0:
return false return false
var held: Dictionary = ItemDB.get_item(player.held_item_id()) 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): if not player.survival.use_stamina(6.0):
notify.emit("Too tired") notify.emit("Too tired")
return false 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) var drops: Dictionary = node.hit(float(tool.get("damage", 1.0)), hit_point, player.global_position)
for id in drops: for id in drops:
_give(player, id, int(drops[id])) _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: func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool:
if not can_place(player, slot): if not can_place(player, slot):
return false return false
if _online():
return _ask("place", [slot, xf])
if player.global_position.distance_to(xf.origin) > 8.0: if player.global_position.distance_to(xf.origin) > 8.0:
return false return false
var id: String = player.inventory.get_slot(slot).id 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") notify.emit("Can't place that here")
return false return false
player.inventory.take_from(slot, 1) 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) world.spawn_piece(def, xf)
return true return true
@@ -171,6 +179,8 @@ func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool:
func dismantle(player: Node3D, piece: 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(): if not is_instance_valid(piece) or not piece.is_in_group("piece") or piece.is_queued_for_deletion():
return false return false
if _online():
return _ask("dismantle", [WorldNet.nid(piece)])
if player.global_position.distance_to(piece.global_position) > 8.0: if player.global_position.distance_to(piece.global_position) > 8.0:
return false return false
if piece.item_id == "grave": 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: if player.inventory.space_for(piece.item_id) < 1:
notify.emit("Inventory full") notify.emit("Inventory full")
return false return false
if _online():
WorldNet.dismantle(piece)
return true
_give(player, piece.item_id, 1) _give(player, piece.item_id, 1)
piece.get_parent().get_parent().remove_piece(piece, player) piece.get_parent().get_parent().remove_piece(piece, player)
return true 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): if not is_instance_valid(door) or not _in_reach(player, door, 1.0):
return false return false
if _online(): if _online():
WorldNet.door(door) return _ask("door", [WorldNet.nid(door)])
return true
door.set_open(not door.open) door.set_open(not door.open)
return true return true
@@ -203,8 +209,15 @@ func toggle_door(player: Node3D, door: Node3D) -> bool:
func use_bed(player: Node3D, bed: Node3D) -> bool: func use_bed(player: Node3D, bed: Node3D) -> bool:
if not is_instance_valid(bed) or not _in_reach(player, bed, 1.0): if not is_instance_valid(bed) or not _in_reach(player, bed, 1.0):
return false return false
if _online():
return _ask("bed", [WorldNet.nid(bed)])
var world: Node = bed.get_parent().get_parent() var world: Node = bed.get_parent().get_parent()
if not bed.is_spawn(): # 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) world.set_bed_spawn(bed)
notify.emit("Spawn point set") notify.emit("Spawn point set")
return true return true
@@ -237,6 +250,8 @@ func melee_stats(player: Node) -> Dictionary:
## One melee blow at an animal. ## One melee blow at an animal.
func melee(player: Node3D, target: Node3D) -> bool: 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"): if not is_instance_valid(target) or not target.is_in_group("creature"):
return false return false
var m := melee_stats(player) 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`. ## Throws the spear in `slot` from `origin` along `dir`.
func throw_spear(player: Node3D, slot: int, origin: Vector3, dir: Vector3, world: Node3D) -> bool: 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) var s = player.inventory.get_slot(slot)
if s == null: if s == null:
return false 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. ## Starts drawing the held bow: needs arrows and a little stamina.
func can_draw(player: Node3D) -> bool: func can_draw(player: Node3D) -> bool:
if _online():
return _not_online()
var bow: Dictionary = ItemDB.get_item(player.held_item_id()).get("bow", {}) var bow: Dictionary = ItemDB.get_item(player.held_item_id()).get("bow", {})
if bow.is_empty(): if bow.is_empty():
return false 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. ## 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: 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", {}) 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: if bow.is_empty() or player.global_position.distance_to(origin) > 2.5:
return false return false
@@ -306,6 +327,8 @@ func shoot(player: Node3D, origin: Vector3, dir: Vector3, power: float, world: N
return true return true
func butcher(player: Node3D, carcass: Node3D) -> bool: 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): if not is_instance_valid(carcass) or not carcass.is_in_group("carcass") or not _in_reach(player, carcass, 1.0):
return false return false
var drops: Dictionary = carcass.butcher_drops() 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`. ## Casts the held rod from `origin` along `dir`.
func cast_line(player: Node3D, origin: Vector3, dir: Vector3, world: Node3D) -> bool: 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", {}) 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: if rod.is_empty() or player.global_position.distance_to(origin) > 2.5:
return false 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. ## Reels in. With a fish biting you land it; otherwise the line just comes in.
func reel(player: Node3D, world: Node3D) -> bool: func reel(player: Node3D, world: Node3D) -> bool:
if _online():
return _not_online()
var b: Node3D = world.bobber var b: Node3D = world.bobber
if b == null or not is_instance_valid(b): if b == null or not is_instance_valid(b):
return false 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). ## Puts on the cloak in `slot` (swapping out any cloak you were wearing).
func wear(player: Node3D, slot: int) -> bool: func wear(player: Node3D, slot: int) -> bool:
if _online():
return _ask("wear", [slot])
var s = player.inventory.get_slot(slot) var s = player.inventory.get_slot(slot)
if s == null or not ItemDB.get_item(s.id).has("wear"): if s == null or not ItemDB.get_item(s.id).has("wear"):
return false return false
@@ -366,6 +395,8 @@ func wear(player: Node3D, slot: int) -> bool:
func take_off(player: Node3D) -> bool: func take_off(player: Node3D) -> bool:
if player.worn.is_empty(): if player.worn.is_empty():
return false return false
if _online():
return _ask("take_off")
if player.inventory.space_for(player.worn) < 1: if player.inventory.space_for(player.worn) < 1:
notify.emit("Inventory full") notify.emit("Inventory full")
return false 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(): if not is_instance_valid(bush) or not _in_reach(player, bush) or not bush.ripe():
return false return false
if _online(): if _online():
WorldNet.forage(bush) return _ask("forage", [String(bush.uid)])
return true
var got: Dictionary = bush.harvest() var got: Dictionary = bush.harvest()
for id in got: for id in got:
_give(player, id, int(got[id])) _give(player, id, int(got[id]))
@@ -387,6 +417,8 @@ func forage(player: Node3D, bush: Node3D) -> bool:
## Eats one of the food in `slot`. ## Eats one of the food in `slot`.
func eat(player: Node3D, slot: int) -> bool: func eat(player: Node3D, slot: int) -> bool:
if _online():
return _ask("eat", [slot])
var s = player.inventory.get_slot(slot) var s = player.inventory.get_slot(slot)
if s == null: if s == null:
return false return false
@@ -406,15 +438,14 @@ func eat(player: Node3D, slot: int) -> bool:
func add_fuel(player: Node3D, fire: Node3D) -> bool: func add_fuel(player: Node3D, fire: Node3D) -> bool:
if not is_instance_valid(fire) or not _in_reach(player, fire): if not is_instance_valid(fire) or not _in_reach(player, fire):
return false return false
if _online():
return _ask("fuel", [WorldNet.nid(fire)])
if fire.fuel >= fire.MAX_FUEL - 1.0: if fire.fuel >= fire.MAX_FUEL - 1.0:
notify.emit("The fire is fully stoked") notify.emit("The fire is fully stoked")
return false return false
for id in ["wood", "stick", "fiber"]: for id in ["wood", "stick", "fiber"]:
if player.inventory.count(id) > 0: if player.inventory.count(id) > 0:
player.inventory.remove(id, 1) 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()) notify.emit("Added %s to the fire" % ItemDB.item_name(id).to_lower())
return true return true
@@ -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: 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): if container != null and not _in_reach(player, container, 1.0):
return return
if _online() and container != null: if _online():
var s = from.get_slot(slot) if container != null:
if s == null: _ask("transfer", [WorldNet.nid(container), slot, from == player.inventory])
return
if from == player.inventory:
from.set_slot(slot, null)
WorldNet.chest_put(container, s) # what doesn't fit comes back
else:
WorldNet.chest_take(container, slot, to.space_for(s.id))
return return
from.quick_move(slot, to) from.quick_move(slot, to)

View File

@@ -224,7 +224,7 @@ func _think(dt: float) -> void:
_state_t = 4.0 _state_t = 4.0
elif d < def.attack_range and _attack_cd <= 0.0: elif d < def.attack_range and _attack_cd <= 0.0:
_attack_cd = def.attack_cd _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: func _start_flee(from: Vector3) -> void:
state = "flee" state = "flee"

View File

@@ -119,7 +119,7 @@ func _attack(pp: Vector3, d: float, as_state: String) -> void:
steer(atan2(-to.x, -to.z)) steer(atan2(-to.x, -to.z))
if d < def.attack_range and _attack_cd <= 0.0 and _can_reach_player(): if d < def.attack_range and _attack_cd <= 0.0 and _can_reach_player():
_attack_cd = def.attack_cd _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": if as_state == "lunge":
state = "retreat" state = "retreat"
_state_t = 1.6 _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 Identity = preload("res://scripts/net/identity.gd")
const ServerConfig = preload("res://scripts/net/server_config.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 DEFAULT_PORT := 24242
const HANDSHAKE_TIMEOUT := 10.0 const HANDSHAKE_TIMEOUT := 10.0
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join 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) players.erase(id)
print("[server] %s left (%d online)" % [p.name, players.size()]) print("[server] %s left (%d online)" % [p.name, players.size()])
_s2c_player_left.rpc(id) _s2c_player_left.rpc(id)
PlayerNet.server_leave(id) # saves their character
WorldNet.peer_left(id) WorldNet.peer_left(id)
players_changed.emit() players_changed.emit()
@@ -275,6 +276,15 @@ func _tick_clock(delta: float) -> void:
func player_pos(peer: int) -> Vector3: func player_pos(peer: int) -> Vector3:
return _states[peer].pos if _states.has(peer) else Vector3.INF 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: func _broadcast_snapshot() -> void:
if _states.is_empty(): if _states.is_empty():
return 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]) print("[server] moved %s back (%s)" % [players[peer].name, why])
_s2c_correct.rpc_id(peer, s.pos) _s2c_correct.rpc_id(peer, s.pos)
return return
var from: Vector3 = s.pos
s.pos = pos s.pos = pos
s.yaw = wrapf(yaw, -PI, PI) s.yaw = wrapf(yaw, -PI, PI)
s.pitch = clampf(pitch, -1.5, 1.5) s.pitch = clampf(pitch, -1.5, 1.5)
s.anim = clampi(anim, 0, 4) 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.t = server_time
s.stamp = stamp 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) 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", {})), var p := {"id": id, "name": name, "appearance": Identity.sanitize_appearance(hello.get("appearance", {})),
"admin": config.is_admin(id)} "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 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} "stamp": server_time, "off": INF}
print("[server] %s joined (id %s%s, %d/%d online)" % [name, id, ", admin" if p.admin else "", print("[server] %s joined (id %s%s, %d/%d online)" % [name, id, ", admin" if p.admin else "",
players.size(), int(config.v("max_players"))]) players.size(), int(config.v("max_players"))])
# (The world's own seed: an existing world keeps the seed it was made with.) # (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} "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}) _s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world})
for other in players: 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 ## 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. ## weather follows from the seed and the clock, so it needs no syncing.
## ##
## Clients ask; the server checks, applies and tells everyone: ## Players' requests arrive through PlayerNet (player_net.gd), which runs the
## client -> server _c2s_hit uid server -> all _s2c_node uid hp ## Actions rules on that player's character on the server. The results show
## client -> server _c2s_place item server -> all _s2c_piece_added {...} ## up here and go to everyone:
## ...and so on for each kind of change (the _c2s_* / _s2c_* pairs below). ## - pieces placed, removed or collapsed, and items dropped: the server's
## When a change earns the asker something (wood from a tree, a pickup, a ## world reports them (world.gd signals), wherever they came from;
## dismantled wall back), the server sends it to them with _s2c_give. ## - hits, pickups, berries, doors and fires: PlayerNet tells us;
## ## - chest and grave contents: whenever they change.
## Inventories, stats and crafting stay on each player's own game until M2.4,
## so the server trusts what a client says it puts into the world (a placed
## wall, a chest deposit) but decides everything it takes out.
## ##
## On joining, a client's world asks for a snapshot (_c2s_world) once its ## 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. ## 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 FIRE_SYNC := 10.0 ## seconds between fire fuel updates
const SAVE_AFTER := 10.0 ## seconds after a change before saving 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 world: Node3D ## server: its world; client: ours while online
var pieces: Dictionary = {} ## net id -> piece (both sides) 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 _fire_left := FIRE_SYNC
var _blocker_left := 0.0 var _blocker_left := 0.0
var _save_left := INF ## counting down to a save after a change 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 _chests_dirty: Dictionary = {}
var chest_busy := false
## Counters the tests read. ## Counters the tests read.
var received := 0 ## client: world events applied var received := 0 ## client: world events applied
@@ -62,24 +54,23 @@ func open_server_world(config: RefCounted) -> Node3D:
w.server_mode = true w.server_mode = true
w.day_length = float(config.v("day_length_minutes")) * 60.0 w.day_length = float(config.v("day_length_minutes")) * 60.0
w.autosave_seconds = maxf(float(config.v("autosave_minutes")), 0.5) * 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() pieces.clear()
dropped.clear() dropped.clear()
_ready_peers.clear() _ready_peers.clear()
_chests_dirty.clear()
_next_nid = 1 _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(): for p in w.all_pieces():
if p.has_meta("nid"): _next_nid = maxi(_next_nid, int(p.get_meta("nid", 0)) + 1)
_next_nid = maxi(_next_nid, int(p.get_meta("nid")) + 1)
for p in w.all_pieces(): for p in w.all_pieces():
if not p.has_meta("nid"): _register(p)
p.set_meta("nid", _new_nid())
pieces[int(p.get_meta("nid"))] = p
for d in w.all_dropped(): for d in w.all_dropped():
if not d.has_meta("nid"): _register_dropped(d)
d.set_meta("nid", _new_nid()) w.piece_added.connect(_on_piece_added)
dropped[int(d.get_meta("nid"))] = d w.pieces_removed.connect(_on_pieces_removed)
_next_nid = maxi(_next_nid, _max_nid() + 1) w.dropped_added.connect(_on_dropped_added)
w.saved.connect(PlayerNet.save_all)
w.props.regrown.connect(_on_regrown) w.props.regrown.connect(_on_regrown)
var args: Dictionary = Net.parse_args(OS.get_cmdline_user_args()) var args: Dictionary = Net.parse_args(OS.get_cmdline_user_args())
if args.has("debug-regrow"): if args.has("debug-regrow"):
@@ -99,23 +90,35 @@ func close_server_world() -> void:
pieces.clear() pieces.clear()
dropped.clear() dropped.clear()
_ready_peers.clear() _ready_peers.clear()
PlayerNet.server_reset()
func save() -> void: func save() -> void:
_save_left = INF _save_left = INF
if world != null and is_instance_valid(world) and world.server_mode: 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: func _new_nid() -> int:
_next_nid += 1 _next_nid += 1
return _next_nid - 1 return _next_nid - 1
func _max_nid() -> int: func _register(p: Node3D) -> void:
var m := 0 if not p.has_meta("nid"):
for k in pieces: p.set_meta("nid", _new_nid())
m = maxi(m, k) pieces[int(p.get_meta("nid"))] = p
for k in dropped: if p.get("inventory") != null:
m = maxi(m, k) var nid := int(p.get_meta("nid"))
return m 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. ## A player left: they no longer get events. Save while it's fresh.
func peer_left(peer: int) -> void: func peer_left(peer: int) -> void:
@@ -125,11 +128,17 @@ func peer_left(peer: int) -> void:
func _process(delta: float) -> void: func _process(delta: float) -> void:
if not is_server(): if not is_server():
return return
for nid in _chests_dirty.keys():
_flush_chest(nid)
_chests_dirty.clear()
# Pause the island while nobody's on (fires, regrowth, the clock). # 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")) 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 world.process_mode = Node.PROCESS_MODE_DISABLED if paused else Node.PROCESS_MODE_INHERIT
if paused: if paused:
return return
_save_left -= delta
if _save_left <= 0.0:
save()
_blocker_left -= delta _blocker_left -= delta
if _blocker_left <= 0.0: if _blocker_left <= 0.0:
_blocker_left = 1.0 _blocker_left = 1.0
@@ -137,10 +146,6 @@ func _process(delta: float) -> void:
for peer in Net.players: for peer in Net.players:
at.append(Net.player_pos(peer)) at.append(Net.player_pos(peer))
world.props.blockers = at # nothing regrows on top of a player 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 _fire_left -= delta
if _fire_left <= 0.0: if _fire_left <= 0.0:
_fire_left = FIRE_SYNC _fire_left = FIRE_SYNC
@@ -152,54 +157,77 @@ func _process(delta: float) -> void:
if not fires.is_empty(): if not fires.is_empty():
_all("_s2c_fires", [fires]) _all("_s2c_fires", [fires])
## Sends to every player who has their snapshot. Any change to the island ## Sends to every player who has their snapshot.
## also means saving soon: Ctrl+C kills Godot without a chance to save, so
## this keeps the loss to a few seconds.
func _all(method: String, args: Array) -> void: func _all(method: String, args: Array) -> void:
if method != "_s2c_fires" and _save_left == INF: if method != "_s2c_fires":
_save_left = SAVE_AFTER mark_changed()
for peer in _ready_peers: for peer in _ready_peers:
callv("rpc_id", [peer, method] + args) callv("rpc_id", [peer, method] + args)
func _give_to(peer: int, items: Dictionary) -> void: func piece(nid: int) -> Node3D:
var clean := {}
for id in items:
if int(items[id]) > 0:
clean[id] = int(items[id])
if not clean.is_empty():
_s2c_give.rpc_id(peer, clean)
func _tell(peer: int, text: String) -> void:
_s2c_notify.rpc_id(peer, text)
## The asker, if they're a player in the world (else 0).
func _asker() -> int:
var peer := multiplayer.get_remote_sender_id()
if not is_server() or not _ready_peers.has(peer):
return 0
return peer
func _near(peer: int, at: Vector3, r: float) -> bool:
return Net.player_pos(peer).distance_to(at) <= r + SLACK
func _piece(nid: int) -> Node3D:
var p: Node3D = pieces.get(nid) var p: Node3D = pieces.get(nid)
if p == null or not is_instance_valid(p) or p.is_queued_for_deletion(): if p == null or not is_instance_valid(p) or p.is_queued_for_deletion():
pieces.erase(nid) pieces.erase(nid)
return null return null
return p return p
func _entry(p: Node3D) -> Dictionary: ## A pickup by the key players use: its scenery id, or "#<net id>" if it was dropped.
return world.piece_entry(p) 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: # --- server: telling everyone ----------------------------------------------------
if not s is Dictionary:
return null func node_hit(n: Node3D, point: Vector3, from: Vector3) -> void:
var id := String(s.get("id", "")) _all("_s2c_node", [String(n.uid), n.hp, point, from])
var n := int(s.get("count", 0))
if ItemDB.get_item(id).is_empty() or n <= 0: func taken(key: String) -> void:
return null if key.begins_with("#"):
return {"id": id, "count": mini(n, ItemDB.stack_size(id))} 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: static func _empty(inv: Inventory) -> bool:
for s in inv.slots: for s in inv.slots:
@@ -207,8 +235,6 @@ static func _empty(inv: Inventory) -> bool:
return false return false
return true return true
# --- client -> server -------------------------------------------------------------
@rpc("any_peer", "call_remote", "reliable") @rpc("any_peer", "call_remote", "reliable")
func _c2s_world() -> void: func _c2s_world() -> void:
var peer := multiplayer.get_remote_sender_id() var peer := multiplayer.get_remote_sender_id()
@@ -216,216 +242,15 @@ func _c2s_world() -> void:
return return
_ready_peers[peer] = true _ready_peers[peer] = true
_s2c_world.rpc_id(peer, world.collect_shared()) _s2c_world.rpc_id(peer, world.collect_shared())
PlayerNet.push_all(peer)
@rpc("any_peer", "call_remote", "reliable") # --- client ------------------------------------------------------------------------
func _c2s_hit(uid: String, damage: float, point: Vector3) -> void:
var peer := _asker()
var n: Node3D = world.props.resource_nodes.get(uid) if peer != 0 else null
if n == null or not is_instance_valid(n) or n.depleted:
return
if not _near(peer, point, 3.2 + 1.0) or point.distance_to(n.global_position) > 6.0:
return
var from: Vector3 = Net.player_pos(peer)
var drops: Dictionary = n.hit(clampf(damage, 0.0, MAX_HIT), point, from)
_give_to(peer, drops)
_all("_s2c_node", [uid, n.hp, point, from])
@rpc("any_peer", "call_remote", "reliable")
func _c2s_take(key: String) -> void:
var peer := _asker()
if peer == 0:
return
var pk: Node3D = _find_pickup(key)
if pk == null or pk.taken or not _near(peer, pk.global_position, REACH):
return
var got := {pk.item_id: pk.amount}
if key.begins_with("#"):
dropped.erase(int(key.substr(1)))
pk.collected()
_give_to(peer, got)
_all("_s2c_taken", [key])
func _find_pickup(key: String) -> Node3D:
var pk: Node3D
if key.begins_with("#"):
pk = dropped.get(int(key.substr(1)))
else:
pk = world.props.pickups.get(key) if world != null else null
return pk if pk != null and is_instance_valid(pk) else null
@rpc("any_peer", "call_remote", "reliable")
func _c2s_forage(uid: String) -> void:
var peer := _asker()
var bush: Node3D = world.props.forage.get(uid) if peer != 0 else null
if bush == null or not bush.ripe() or not _near(peer, bush.global_position, REACH):
return
_give_to(peer, bush.harvest())
_all("_s2c_forage", [uid, bush.regrow_left])
## Places `item` at `xf`. The client has already taken it out of its
## inventory: if it can't go here, it's given back. A grave carries what the
## player was holding when they died.
@rpc("any_peer", "call_remote", "reliable")
func _c2s_place(item: String, xf: Transform3D, contents: Array) -> void:
var peer := _asker()
if peer == 0:
return
var def: Dictionary = ItemDB.get_item(item)
if def.is_empty() or not def.has("place"):
return
# (A grave can be further off: drowning puts it on the nearest shore.)
var ok := xf.origin.is_finite() and _near(peer, xf.origin, 40.0 if item == "grave" else 8.0)
if item == "grave":
ok = ok and not contents.is_empty()
else:
ok = ok and world.placement_valid(def, xf)
if not ok:
if item != "grave":
_give_to(peer, {item: 1})
_tell(peer, "Can't place that here")
return
var p: Node3D = world.spawn_piece(def, xf.orthonormalized())
if item == "grave" and p.get("inventory") != null:
var clean: Array = []
for s in contents.slice(0, p.inventory.size()):
clean.append(_clean_stack(s))
p.inventory.load_data(clean)
p.set_meta("nid", _new_nid())
pieces[int(p.get_meta("nid"))] = p
_all("_s2c_piece_added", [_entry(p)])
@rpc("any_peer", "call_remote", "reliable")
func _c2s_dismantle(nid: int) -> void:
var peer := _asker()
var p: Node3D = _piece(nid) if peer != 0 else null
if p == null or not _near(peer, p.global_position, 8.0) or p.item_id == "grave":
return
var why: String = p.dismantle_block_reason()
if not why.is_empty():
_tell(peer, why)
return
# Whatever loses its support collapses: those come back to you too.
var items := {}
for k in pieces:
if _piece(k) != null:
items[k] = pieces[k].item_id
world.remove_piece(p, null)
var gone: Array = []
var fell: Array = []
var refund := {}
for k in items:
if _piece(k) == null:
gone.append(k)
refund[items[k]] = refund.get(items[k], 0) + 1
if k != nid:
fell.append(k)
_all("_s2c_pieces_removed", [gone, fell])
_give_to(peer, refund)
@rpc("any_peer", "call_remote", "reliable")
func _c2s_door(nid: int) -> void:
var peer := _asker()
var p: Node3D = _piece(nid) if peer != 0 else null
if p == null or p.get("open") == null or not _near(peer, p.global_position, REACH + 1.0):
return
p.set_open(not p.open)
_all("_s2c_piece_state", [nid, p.save_extra()])
@rpc("any_peer", "call_remote", "reliable")
func _c2s_fuel(nid: int, item: String) -> void:
var peer := _asker()
if peer == 0 or not item in FUELS:
return
var p := _piece(nid)
if p == null or p.get("fuel") == null or not _near(peer, p.global_position, REACH) \
or p.fuel >= p.MAX_FUEL - 1.0:
_give_to(peer, {item: 1})
return
p.add_fuel(float(ItemDB.get_item(item).get("fuel", 10)))
_all("_s2c_piece_state", [nid, p.save_extra()])
## A click on a chest slot while holding `cursor`: the same as clicking in
## your own inventory (pick up, put down, swap). The new cursor comes back.
@rpc("any_peer", "call_remote", "reliable")
func _c2s_chest_click(nid: int, slot: int, cursor: Variant, right: bool) -> void:
var peer := _asker()
if peer == 0:
return
var c: Variant = _clean_stack(cursor)
var p := _piece(nid)
if p == null or p.get("inventory") == null or slot < 0 or slot >= p.inventory.size() \
or not _near(peer, p.global_position, REACH + 1.0):
_s2c_cursor.rpc_id(peer, c) # nothing happened: keep what you were holding
return
var res: Variant = p.inventory.click(slot, c, right)
_s2c_cursor.rpc_id(peer, res)
_chest_changed(nid, p)
## Shift-click from your inventory: as much of `stack` as fits goes in, the
## rest comes back.
@rpc("any_peer", "call_remote", "reliable")
func _c2s_chest_put(nid: int, stack: Variant) -> void:
var peer := _asker()
var s: Variant = _clean_stack(stack)
if peer == 0 or s == null:
return
var p := _piece(nid)
if p == null or p.get("inventory") == null or not _near(peer, p.global_position, REACH + 1.0):
_give_to(peer, {s.id: s.count})
return
var left: int = p.inventory.add(s.id, s.count)
_give_to(peer, {s.id: left})
_chest_changed(nid, p)
## Shift-click on a chest slot: up to `room` of it comes to you.
@rpc("any_peer", "call_remote", "reliable")
func _c2s_chest_take(nid: int, slot: int, room: int) -> void:
var peer := _asker()
var p: Node3D = _piece(nid) if peer != 0 else null
if p == null or p.get("inventory") == null or slot < 0 or slot >= p.inventory.size() or room <= 0 \
or not _near(peer, p.global_position, REACH + 1.0):
return
var s: Variant = p.inventory.get_slot(slot)
if s == null:
return
var id: String = s.id
var n: int = p.inventory.take_from(slot, room)
_give_to(peer, {id: n})
_chest_changed(nid, p)
func _chest_changed(nid: int, p: Node3D) -> void:
_all("_s2c_chest", [nid, p.inventory.to_data()])
# An emptied grave crumbles.
if p.item_id == "grave" and _empty(p.inventory):
world.remove_piece(p, null)
pieces.erase(nid)
_all("_s2c_pieces_removed", [[nid], []])
## Something the player put down in the world: an arrow that landed, a fish
## that didn't fit. Anyone can pick it up.
@rpc("any_peer", "call_remote", "reliable")
func _c2s_drop(item: String, count: int, xf: Transform3D) -> void:
var peer := _asker()
if peer == 0 or ItemDB.get_item(item).is_empty() or count <= 0 or count > 999 \
or not xf.origin.is_finite() or not _near(peer, xf.origin, 60.0):
return # (60 m: an arrow can land a long way off)
var d: Node3D = world.drop_item(item, count, xf.orthonormalized())
d.set_meta("nid", _new_nid())
dropped[int(d.get_meta("nid"))] = d
_all("_s2c_dropped", [world.dropped_entry(d)])
func _on_regrown(uid: String) -> void:
_all("_s2c_regrown", [uid])
# --- client: asking ----------------------------------------------------------------
## Our world is up: ask for the shared state. ## Our world is up: ask for the shared state.
func join_world(w: Node3D) -> void: func join_world(w: Node3D) -> void:
world = w world = w
pieces.clear() pieces.clear()
dropped.clear() dropped.clear()
chest_busy = false
_chest_cb = Callable()
received = 0 received = 0
_c2s_world.rpc_id(1) _c2s_world.rpc_id(1)
@@ -435,49 +260,13 @@ func leave_world() -> void:
pieces.clear() pieces.clear()
dropped.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>". ## The key the server knows a pickup by: its scenery id, or "#<net id>".
func pickup_key(pk: Node3D) -> String: func pickup_key(pk: Node3D) -> String:
return "#%d" % int(pk.get_meta("nid")) if pk.has_meta("nid") else String(pk.uid) 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: static func nid(p: Node3D) -> int:
return int(p.get_meta("nid", 0)) 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 ------------------------------------------------------------ # --- server -> client ------------------------------------------------------------
@rpc("authority", "call_remote", "reliable") @rpc("authority", "call_remote", "reliable")
@@ -518,7 +307,7 @@ func _s2c_regrown(uid: String) -> void:
func _s2c_taken(key: String) -> void: func _s2c_taken(key: String) -> void:
if not is_client(): if not is_client():
return return
var pk := _find_pickup(key) var pk := find_pickup(key)
if key.begins_with("#"): if key.begins_with("#"):
dropped.erase(int(key.substr(1))) dropped.erase(int(key.substr(1)))
if pk != null: if pk != null:
@@ -598,29 +387,3 @@ func _s2c_dropped(e: Dictionary) -> void:
if d != null: if d != null:
dropped[int(e.nid)] = d dropped[int(e.nid)] = d
received += 1 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)}. ## Food buffs: id -> {name, left (s), stamina_regen, health_regen (multipliers)}.
var buffs: Dictionary = {} var buffs: Dictionary = {}
var _hurt_cd := 0.0 ## no natural healing for a few seconds after a hit 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) signal hurt(amount: float, from: Vector3)
const HURT_NO_REGEN := 6.0 const HURT_NO_REGEN := 6.0
@@ -71,19 +72,20 @@ func balanced_diet() -> bool:
return false return false
return true return true
var player: CharacterBody3D var player: Node3D ## the local player, or a server_player.gd on a server
var world: Node3D var world: Node3D
var _stamina_wait := 0.0 var _stamina_wait := 0.0
func _process(delta: float) -> void: 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) step(delta)
func step(dt: float) -> void: func step(dt: float) -> void:
if dead: if dead:
return return
var p := player.global_position var p := player.global_position
heat = world.heat_at(p) heat = world.heat_at(p, player)
_shelter_left -= dt _shelter_left -= dt
if _shelter_left <= 0.0: if _shelter_left <= 0.0:
_shelter_left = 0.25 _shelter_left = 0.25
@@ -137,6 +139,12 @@ func step(dt: float) -> void:
dmg += MALNOURISHED_DRAIN dmg += MALNOURISHED_DRAIN
if dmg > 0.0: if dmg > 0.0:
health = maxf(health - dmg * dt, 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(): 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 var diet_mult := 1.5 if balanced_diet() else 1.0
health = minf(health + 0.5 * buff_mult("health_regen") * diet_mult * dt, MAX) 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 warmth = 60.0
wet = 0.0 wet = 0.0
buffs.clear() buffs.clear()
cause = ""
# Don't respawn straight back into malnutrition. # Don't respawn straight back into malnutrition.
for g in DIET_GROUPS: for g in DIET_GROUPS:
diet[g] = maxf(float(diet[g]), 30.0) diet[g] = maxf(float(diet[g]), 30.0)
changed.emit() changed.emit()
## Takes a hit (a boar, later wolves). Pauses natural healing briefly. ## 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: if dead:
return return
cause = "Killed by %s" % by if not by.is_empty() else "You were killed"
health = maxf(health - amount, 0.0) health = maxf(health - amount, 0.0)
_hurt_cd = HURT_NO_REGEN _hurt_cd = HURT_NO_REGEN
hurt.emit(amount, from) hurt.emit(amount, from)

View File

@@ -209,6 +209,72 @@ func fade(mid: Callable) -> void:
player.ui_open = false player.ui_open = false
black.queue_free()) 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). ## Closes the inventory / menu (e.g. on death).
func close_screens() -> void: func close_screens() -> void:
if _screen.is_open(): 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. ## ESC: close the inventory if it's open, otherwise toggle the pause menu.
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE: 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(): if _screen.is_open():
_screen.close() _screen.close()
elif _menu.is_open(): elif _menu.is_open():

View File

@@ -88,6 +88,7 @@ func bind(p: CharacterBody3D) -> void:
wr.add_child(_worn_btn) wr.add_child(_worn_btn)
pv.add_child(wr) pv.add_child(wr)
player.worn_changed.connect(_refresh) player.worn_changed.connect(_refresh)
PlayerNet.cursor_changed.connect(_on_server_cursor)
pp.add_child(pv) pp.add_child(pv)
row.add_child(pp) row.add_child(pp)
@@ -142,13 +143,12 @@ func open(chest: Node) -> void:
_refresh() _refresh()
func close() -> void: 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) var left: int = player.inventory.add(_cursor.id, _cursor.count)
if left > 0 and container != null and is_instance_valid(container): 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 _cursor = null
if container != null and is_instance_valid(container) and container.inventory.changed.is_connected(_refresh): if container != null and is_instance_valid(container) and container.inventory.changed.is_connected(_refresh):
@@ -193,11 +193,16 @@ func _rebuild_chest() -> void:
_chest_grid.add_child(s) _chest_grid.add_child(s)
_chest_slots.append(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: func _on_player_slot(i: int, right: bool, shift: bool) -> void:
if shift and container != null: if shift and container != null:
Actions.transfer(player, player.inventory, i, container.inventory, container) Actions.transfer(player, player.inventory, i, container.inventory, container)
else: else:
_cursor = player.inventory.click(i, _cursor, right) _cursor = player.inventory.click(i, _cursor, right)
if WorldNet.is_client():
PlayerNet.ask("inv_click", [0, i, right])
_refresh() _refresh()
func _on_chest_slot(i: int, right: bool, shift: bool) -> void: 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: if shift:
Actions.transfer(player, container.inventory, i, player.inventory, container) Actions.transfer(player, container.inventory, i, player.inventory, container)
elif WorldNet.is_client(): elif WorldNet.is_client():
# A shared chest: the server does the click (someone else may be in # A shared chest (someone else may be in it too): only the server's
# it too) and hands back what we're holding. # answer counts.
if WorldNet.chest_busy: PlayerNet.ask("inv_click", [WorldNet.nid(container), i, right])
return
var held = _cursor
_cursor = null
WorldNet.chest_click(container, i, held, right, _on_chest_cursor)
else: else:
_cursor = container.inventory.click(i, _cursor, right) _cursor = container.inventory.click(i, _cursor, right)
_refresh() _refresh()
func _on_chest_cursor(stack) -> void: ## Online: what the server says we're holding.
if visible: func _on_server_cursor(stack) -> void:
_cursor = stack _cursor = stack if visible else null
_refresh() _refresh()
elif stack != null:
# Closed in the meantime: into our bag (or the ground).
var left: int = player.inventory.add(stack.id, stack.count)
if left > 0:
player.get_parent().drop_item(stack.id, left, Transform3D(Basis(), player.global_position + Vector3(0, 0.1, 0)))
func _refresh() -> void: func _refresh() -> void:
if player == null: 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: func is_online() -> bool:
return not menu_mode and Net.state == "connected" return not menu_mode and Net.state == "connected"
var _loading := false ## applying a save: defer support recompute 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: func _ready() -> void:
_autosave_left = autosave_seconds _autosave_left = autosave_seconds
@@ -151,12 +159,15 @@ func air_warmth(pos: Vector3, sheltered: bool = false) -> float:
return w return w
## 0..1 heat from fires and a held torch. ## 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 var h := 0.0
for n in get_tree().get_nodes_in_group("heat"): for n in get_tree().get_nodes_in_group("heat"):
h = maxf(h, n.heat_at(pos)) h = maxf(h, n.heat_at(pos))
if player.hands != null: var who: Node = holder if holder != null else player
h = maxf(h, player.hands.held_heat()) if who.get("hands") != null:
h = maxf(h, who.hands.held_heat())
return h return h
## Death: everything you carried goes into a grave where you fell, and you ## 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: if st != null:
has_items = true has_items = true
if has_items: if has_items:
var gxf := Transform3D(Basis(Vector3.UP, player.rotation.y), pos) var grave := spawn_piece(ItemDB.get_item("grave"), 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()) grave.inventory.load_data(player.inventory.to_data())
player.inventory.load_data(Inventory.new(player.inventory.size()).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: if hud != null:
hud.close_screens() 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: if bed != null:
_respawn_at_bed(bed) _respawn_at_bed(bed)
else: else:
@@ -194,7 +223,17 @@ func _on_player_died() -> void:
has_bed_spawn = false has_bed_spawn = false
Actions.notify.emit("Your bed is gone, so you woke at the island start") Actions.notify.emit("Your bed is gone, so you woke at the island start")
player.survival.reset_after_death() 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 ------------------------------------------------------ # --- 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.rotation = Vector3(0.0, bed.global_basis.get_euler().y + PI, 0.0)
player.get_node("Camera3D").rotation.x = 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). ## Why you can't sleep in `bed` right now ("" = you can).
func sleep_block_reason(bed: Node3D) -> String: 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)" return "Sleeping on a server isn't in yet (everyone would have to agree)"
var h: float = day_night.hours() var h: float = day_night.hours()
if h > 5.5 and h < 19.5: if h > 5.5 and h < 19.5:
@@ -334,12 +385,17 @@ func save_now() -> bool:
_autosave_left = autosave_seconds _autosave_left = autosave_seconds
if not Saves.has_world(): if not Saves.has_world():
return false 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) var ok: bool = Saves.save_state(Saves.current_id, collect_save(), _session_time)
if ok: if ok:
_session_time = 0.0 _session_time = 0.0
if server_mode: if server_mode:
if not ok: if not ok:
printerr("[server] saving the world FAILED") printerr("[server] saving the world FAILED")
saved.emit() # (the players' characters save with it)
else: else:
Actions.notify.emit("Game saved" if ok else "Save FAILED") Actions.notify.emit("Game saved" if ok else "Save FAILED")
return ok return ok
@@ -502,6 +558,11 @@ func remove_pieces_net(gone: Array, collapsed_nids: Array) -> void:
_update_grass_cuts() _update_grass_cuts()
func apply_save(s: Dictionary, with_player: bool = true) -> void: 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 {} var p: Dictionary = s.get("player", {}) if with_player else {}
if p.has("pos"): if p.has("pos"):
player.global_position = Vector3(p.pos[0], p.pos[1] + 0.05, p.pos[2]) 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()] q.exclude = [terrain.get_node("TerrainBody").get_rid(), player.get_rid()]
return get_world_3d().direct_space_state.intersect_shape(q, 1).is_empty() 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 script: Script = PIECES.get(def.place.piece, preload("res://scripts/building/piece.gd"))
var piece: StaticBody3D = script.new() var piece: StaticBody3D = script.new()
piece.setup(def) piece.setup(def)
_pieces.add_child(piece) _pieces.add_child(piece)
piece.global_transform = xf 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: if piece.is_in_group("build") and not _loading:
build.recompute() build.recompute()
if piece.kind == "floor": if piece.kind == "floor":
_update_grass_cuts() _update_grass_cuts()
if server_mode and not _applying:
piece_added.emit(piece)
return piece return piece
## Grass doesn't grow up through floors. ## Grass doesn't grow up through floors.
@@ -636,6 +702,8 @@ func _go_online() -> void:
_spawn = Net.world_info.get("island_spawn", _spawn) _spawn = Net.world_info.get("island_spawn", _spawn)
var start: Vector3 = Net.world_info.get("spawn", _spawn) var start: Vector3 = Net.world_info.get("spawn", _spawn)
player.global_position = start + Vector3(0.0, 0.05, 0.0) 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 = preload("res://scripts/net/net_sync.gd").new()
net_sync.name = "NetSync" net_sync.name = "NetSync"
add_child(net_sync) add_child(net_sync)
@@ -645,8 +713,9 @@ func _go_online() -> void:
# The island's changes come from the server from now on. # The island's changes come from the server from now on.
props.regrow_enabled = false props.regrow_enabled = false
WorldNet.join_world(self) WorldNet.join_world(self)
PlayerNet.join_world(self) # and our inventory and stats
if hud != null: 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) % Net.server_info.get("server_name", "server"), 14, UITheme.TEXT_DIM)
bar.position = Vector2(16, 40) bar.position = Vector2(16, 40)
$HUD.add_child(bar) $HUD.add_child(bar)
@@ -654,6 +723,7 @@ func _go_online() -> void:
func _on_net_left(_reason: String) -> void: func _on_net_left(_reason: String) -> void:
WorldNet.leave_world() WorldNet.leave_world()
PlayerNet.leave_world()
if is_inside_tree(): if is_inside_tree():
get_tree().paused = false get_tree().paused = false
get_tree().auto_accept_quit = true 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 ## 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. ## 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: func drop_item(id: String, count: int, xf: Transform3D) -> Node3D:
if net_sync != null: 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 null
return _make_dropped(id, count, xf) return _make_dropped(id, count, xf)
@@ -675,6 +745,8 @@ func _make_dropped(id: String, count: int, xf: Transform3D) -> Node3D:
pk.dropped = true pk.dropped = true
_dropped.add_child(pk) _dropped.add_child(pk)
pk.global_transform = xf pk.global_transform = xf
if server_mode and not _applying:
dropped_added.emit(pk)
return pk return pk
## Casts a fishing float (Actions validated it). One at a time. ## 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) _pieces.remove_child(piece)
piece.queue_free() piece.queue_free()
if not was_build: if not was_build:
if server_mode:
pieces_removed.emit([piece], [])
return 0 return 0
var weak: Array = build.recompute() var weak: Array = build.recompute()
if server_mode:
pieces_removed.emit([piece] + weak, weak)
for w in weak: for w in weak:
if refund_to != null: if refund_to != null:
Actions._give(refund_to, w.item_id, 1) 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, ## into them, for real and by cheating), doors, chests, fires, dismantling,
## and the whole lot surviving a server restart. ## 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 ## 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 ## process with his own world, following this test's commands. This test is
## "Alice" with the real world scene. ## "Alice" with the real world scene.
@@ -22,6 +26,7 @@ var _finished := false
var _pids: Array[int] = [] var _pids: Array[int] = []
var Net: Node var Net: Node
var WN: Node var WN: Node
var PN: Node
var A: Node var A: Node
var world: Node3D var world: Node3D
var player: CharacterBody3D var player: CharacterBody3D
@@ -41,6 +46,7 @@ func _run() -> void:
Engine.max_fps = 60 Engine.max_fps = 60
Net = root.get_node("Net") Net = root.get_node("Net")
WN = root.get_node("WorldNet") WN = root.get_node("WorldNet")
PN = root.get_node("PlayerNet")
A = root.get_node("Actions") A = root.get_node("Actions")
A.notify.connect(func(t): _toasts.append(t)) A.notify.connect(func(t): _toasts.append(t))
for d in [SRV, BOB]: for d in [SRV, BOB]:
@@ -62,7 +68,7 @@ func _run() -> void:
func _start_server() -> void: func _start_server() -> void:
# --debug-regrow: things grow back in seconds instead of half an hour. # --debug-regrow: things grow back in seconds instead of half an hour.
_server_pid = _spawn(["--server", "--port", str(PORT), "--config", SRV + "/server.json", _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: func _body() -> void:
var me = load("res://scripts/net/identity.gd").open(DIR + "/a") 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)): if not bool(b0.get("ok", false)):
return return
await _server_decides()
await _trees() await _trees()
await _building() await _building()
await _containers_and_fires() await _containers_and_fires()
@@ -90,6 +97,66 @@ func _body() -> void:
await _restart() await _restart()
_finished = true _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 -------------------------------------------------------------------- # --- trees --------------------------------------------------------------------
func _trees() -> void: func _trees() -> void:
@@ -99,13 +166,12 @@ func _trees() -> void:
var to_tree: Vector3 = (tree.global_position - player.global_position) var to_tree: Vector3 = (tree.global_position - player.global_position)
to_tree.y = 0.0 to_tree.y = 0.0
await Bot.walk(player, tree.global_position - to_tree.normalized() * 1.6, world) 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(_slot_of("stone_axe"))
player.select(0) await _sleep(0.2)
var wood0: int = player.inventory.count("wood") var wood0: int = player.inventory.count("wood")
var hits := 0 var hits := 0
var t := 0.0 var t := 0.0
while not tree.depleted and t < 20.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)): if A.harvest_hit(player, tree, tree.global_position + Vector3(0, 1.0, 0)):
hits += 1 hits += 1
await _sleep(0.25) 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]) print(" (can't place %s: %s)" % [id, res.reason])
return null return null
xf = res.xf 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() 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) print(" (Actions.place refused %s)" % id)
return null 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 var t := 0.0
while t < 3.0: while t < 3.0:
for k in WN.pieces: for k in WN.pieces:
@@ -213,18 +281,22 @@ func _containers_and_fires() -> void:
check(_chest != null, "Alice places a chest") check(_chest != null, "Alice places a chest")
if _chest == null: if _chest == null:
return return
player.inventory.set_slot(6, {"id": "wood", "count": 10}) await _clear()
A.transfer(player, player.inventory, 6, _chest.inventory, _chest) var ws: int = await _give("wood", 10)
await _sleep(0.5) A.transfer(player, player.inventory, ws, _chest.inventory, _chest)
check(player.inventory.get_slot(6) == null and _chest.inventory.count("wood") == 10, "she puts 10 wood in") await _until(func(): return _chest.inventory.count("wood") == 10, 2.0)
var got := {"c": "waiting"} check(player.inventory.count("wood") == 0 and _chest.inventory.count("wood") == 10, "she puts 10 wood in (shift-click)")
WN.chest_click(_chest, 3, {"id": "stone", "count": 3}, false, func(c): got.c = c) var ss: int = await _give("stone", 3)
var t := 0.0 var cur := {"c": "waiting"}
while got.c is String and t < 3.0: var on_cur := func(c): cur.c = c
await _sleep(0.05) PN.cursor_changed.connect(on_cur)
t += 0.05 PN.ask("inv_click", [0, ss, false]) # pick the stone up...
check(got.c == null and _chest.inventory.get_slot(3) != null and _chest.inventory.get_slot(3).id == "stone", PN.ask("inv_click", [WN.nid(_chest), 3, false]) # ...and put it in slot 4
"and drops 3 stone in slot 4 by hand (the server does the click and hands back an empty cursor)") 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) var nid: int = WN.nid(_chest)
await _bob({"do": "walk", "to": _v(site + Vector3(1.8, 0, 0))}) await _bob({"do": "walk", "to": _v(site + Vector3(1.8, 0, 0))})
var rep := await _bob({"do": "report"}) var rep := await _bob({"do": "report"})
@@ -248,20 +320,23 @@ func _containers_and_fires() -> void:
if _fire == null: if _fire == null:
return return
var f0: float = _fire.fuel var f0: float = _fire.fuel
player.inventory.set_slot(7, {"id": "wood", "count": 2}) await _give("wood", 2)
A.add_fuel(player, _fire) A.add_fuel(player, _fire)
await _sleep(0.5) await _sleep(0.5)
check(_fire.fuel > f0 + 30.0, "and feeds it a log (%.0f s -> %.0f s of fuel)" % [f0, _fire.fuel]) 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"}) rep = await _bob({"do": "report"})
seen = rep.pieces.get(str(WN.nid(_fire)), {}) 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))) 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 -------------------------------------------------------- # --- things on the ground --------------------------------------------------------
func _items_on_the_ground() -> void: func _items_on_the_ground() -> void:
print("dropped items") print("dropped items")
var at := player.global_position + Vector3(1.0, 0.1, 0) 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 t := 0.0
var key := "" var key := ""
while key.is_empty() and t < 3.0: while key.is_empty() and t < 3.0:
@@ -270,7 +345,7 @@ func _items_on_the_ground() -> void:
key = "#%d" % k key = "#%d" % k
await _sleep(0.05) await _sleep(0.05)
t += 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"}) var rep := await _bob({"do": "report"})
check(rep.dropped.any(func(d): return d.item == "flint" and int(d.count) == 2), "Bob sees it") 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))}) 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]) "only one of them gets the %s (Alice %d, Bob %d)" % [id, a_got, b_got])
print("dying online") print("dying online")
player.inventory.load_data(Inventory.new(player.inventory.size()).to_data()) await _clear()
player.inventory.set_slot(0, {"id": "stone", "count": 7}) await _give("stone", 7)
player.inventory.set_slot(1, {"id": "berries", "count": 4}) await _give("berries", 4)
var died_at := player.global_position 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 grave: Node3D = null
var t2 := 0.0 var t2 := 0.0
while grave == null and t2 < 3.0: while grave == null and t2 < 3.0:
@@ -308,14 +384,24 @@ func _items_on_the_ground() -> void:
grave = p grave = p
await _sleep(0.05) await _sleep(0.05)
t2 += 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, 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: if grave == null:
return return
var gnid: int = WN.nid(grave) var gnid: int = WN.nid(grave)
var rep2 := await _bob({"do": "report"}) var rep2 := await _bob({"do": "report"})
var g: Dictionary = rep2.pieces.get(str(gnid), {}) 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") 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) await Bot.walk(player, grave.global_position + Vector3(1.5, 0, 0), world)
for i in range(grave.inventory.size()): for i in range(grave.inventory.size()):
if is_instance_valid(grave) and grave.inventory.get_slot(i) != null: if is_instance_valid(grave) and grave.inventory.get_slot(i) != null:
@@ -355,6 +441,9 @@ func _restart() -> void:
for k in WN.pieces: for k in WN.pieces:
expect[k] = WN.pieces[k].item_id expect[k] = WN.pieces[k].item_id
var chest_nid: int = WN.nid(_chest) if _chest != null else -1 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 # 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 # Godot no chance to save): the server saved within a few seconds of the
# last change anyway. # last change anyway.
@@ -385,6 +474,9 @@ func _restart() -> void:
if p.item_id == "wood_door": if p.item_id == "wood_door":
d = p d = p
check(d != null and d.open, "and the door is still open") 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() world.queue_free()
await process_frame await process_frame
Net.leave() Net.leave()