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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
player.open_inventory(self)
|
if _mine():
|
||||||
|
player.open_inventory(self)
|
||||||
|
else:
|
||||||
|
Actions.notify.emit("That's %s's grave" % owner_name)
|
||||||
|
|
||||||
|
## Server: why player `id` can't take from it ("" = they can).
|
||||||
|
func use_block_reason(id: String) -> String:
|
||||||
|
if owner_id.is_empty() or owner_id == id:
|
||||||
|
return ""
|
||||||
|
return "That's %s's grave" % owner_name
|
||||||
|
|
||||||
|
func save_extra() -> Dictionary:
|
||||||
|
return {"owner_id": owner_id, "owner_name": owner_name} if not owner_id.is_empty() else {}
|
||||||
|
|
||||||
|
func load_extra(d: Dictionary) -> void:
|
||||||
|
owner_id = String(d.get("owner_id", ""))
|
||||||
|
owner_name = String(d.get("owner_name", ""))
|
||||||
|
|
||||||
func _on_changed() -> void:
|
func _on_changed() -> void:
|
||||||
if WorldNet.world != null:
|
if WorldNet.world != null:
|
||||||
|
|||||||
@@ -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,9 +209,16 @@ 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).
|
||||||
world.set_bed_spawn(bed)
|
var mine: bool = player.claims_bed(bed) if player.has_method("claims_bed") else bed.is_spawn()
|
||||||
|
if not mine:
|
||||||
|
if player.has_method("claim_bed"):
|
||||||
|
player.claim_bed(bed)
|
||||||
|
else:
|
||||||
|
world.set_bed_spawn(bed)
|
||||||
notify.emit("Spawn point set")
|
notify.emit("Spawn point set")
|
||||||
return true
|
return true
|
||||||
var why: String = world.sleep_block_reason(bed)
|
var why: String = world.sleep_block_reason(bed)
|
||||||
@@ -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,16 +438,15 @@ 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():
|
fire.add_fuel(float(ItemDB.get_item(id).get("fuel", 10)))
|
||||||
WorldNet.fuel(fire, id)
|
|
||||||
else:
|
|
||||||
fire.add_fuel(float(ItemDB.get_item(id).get("fuel", 10)))
|
|
||||||
notify.emit("Added %s to the fire" % ItemDB.item_name(id).to_lower())
|
notify.emit("Added %s to the fire" % ItemDB.item_name(id).to_lower())
|
||||||
return true
|
return true
|
||||||
notify.emit("Nothing to burn (wood or sticks)")
|
notify.emit("Nothing to burn (wood or sticks)")
|
||||||
@@ -426,14 +457,8 @@ func add_fuel(player: Node3D, fire: Node3D) -> bool:
|
|||||||
func transfer(player: Node3D, from: Inventory, slot: int, to: Inventory, container: Node3D = null) -> void:
|
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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
453
scripts/net/player_net.gd
Normal 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)
|
||||||
91
scripts/net/server_player.gd
Normal file
91
scripts/net/server_player.gd
Normal 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))
|
||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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,14 +143,13 @@ 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():
|
left = container.inventory.add(_cursor.id, left)
|
||||||
WorldNet.chest_put(container, {"id": _cursor.id, "count": left})
|
|
||||||
left = 0
|
|
||||||
else:
|
|
||||||
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):
|
||||||
container.inventory.changed.disconnect(_refresh)
|
container.inventory.changed.disconnect(_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:
|
||||||
|
|||||||
@@ -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:
|
grave.inventory.load_data(player.inventory.to_data())
|
||||||
WorldNet.place("grave", gxf, player.inventory.to_data()) # the server puts it in the shared world
|
|
||||||
else:
|
|
||||||
var grave := spawn_piece(ItemDB.get_item("grave"), gxf)
|
|
||||||
grave.inventory.load_data(player.inventory.to_data())
|
|
||||||
player.inventory.load_data(Inventory.new(player.inventory.size()).to_data())
|
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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user