M2.3: the shared world (trees, pickups, pieces, chests, fires, doors, graves, clock on the server)
This commit is contained in:
@@ -18,10 +18,10 @@ concrete decisions for this codebase, as it stands after M1.
|
|||||||
|
|
||||||
## 2. Running a server
|
## 2. Running a server
|
||||||
*Built in M2.1* (`scripts/net/net.gd`, `server_config.gd`, `identity.gd`).
|
*Built in M2.1* (`scripts/net/net.gd`, `server_config.gd`, `identity.gd`).
|
||||||
As of M2.1 the server accepts players, checks who they are and keeps the
|
The server accepts players, checks who they are and keeps the player list.
|
||||||
player list. The world itself isn't served yet (M2.3). Flags: `--server`,
|
Since M2.3 it also runs the shared island (§5). Flags: `--server`,
|
||||||
`--port`, `--config`, `--server-name`, `--max-players`, `--password`,
|
`--port`, `--config`, `--server-name`, `--max-players`, `--password`,
|
||||||
`--world`, and `--exit-with <pid>` (quit when that process exits; the tests
|
`--world`, `--seed`, and `--exit-with <pid>` (quit when that process exits; the tests
|
||||||
use it so a crashed test never leaves a server running). Flags apply to
|
use it so a crashed test never leaves a server running). Flags apply to
|
||||||
that run only and are never written to `server.json`.
|
that run only and are never written to `server.json`.
|
||||||
```bash
|
```bash
|
||||||
@@ -161,8 +161,9 @@ A refused move sends the player back to their last good position.
|
|||||||
Respawning is a flagged teleport, allowed if it lands within 25 m of the
|
Respawning is a flagged teleport, allowed if it lands within 25 m of the
|
||||||
island spawn. Normal play is never corrected, so there's no rubber-banding.
|
island spawn. Normal play is never corrected, so there's no rubber-banding.
|
||||||
This is the Valheim model, and simpler than replaying inputs on the server.
|
This is the Valheim model, and simpler than replaying inputs on the server.
|
||||||
Through-wall checks come with the shared world (M2.3), since the server
|
Since M2.3 the server also refuses a move whose straight line crosses a
|
||||||
doesn't know about buildings yet.
|
wall, door frame (or closed door) or gable at waist height, checked from
|
||||||
|
both ends so someone a wall was built on top of can still step out.
|
||||||
|
|
||||||
The server sends everyone's positions 20 times a second. Each one is stamped
|
The server sends everyone's positions 20 times a second. Each one is stamped
|
||||||
with when the player moved (their clock plus a smoothed per-player offset),
|
with when the player moved (their clock plus a smoothed per-player offset),
|
||||||
@@ -177,9 +178,38 @@ Measured in `tests/test_net_world.gd` at 100 ms ± 30 ms simulated latency:
|
|||||||
a 4 m/s walk is seen at a median of 4.2 m/s, top 5.0, with no freezes, and
|
a 4 m/s walk is seen at a median of 4.2 m/s, top 5.0, with no freezes, and
|
||||||
it ends within a centimetre of where the walker stopped.
|
it ends within a centimetre of where the walker stopped.
|
||||||
|
|
||||||
Until later phases, while online: animals are off (M2.5), and building,
|
**The shared world** *(built in M2.3: `world_net.gd`, autoload WorldNet)*.
|
||||||
gathering and other world changes are local only and aren't saved (M2.3,
|
The dedicated server runs the real world scene headless with nobody of its
|
||||||
M2.4). The HUD says so. Respawning ignores beds.
|
own in it (`world.gd` `server_mode`), and saves it like a single-player
|
||||||
|
world, in `worlds/<world>/` next to `server.json`. It owns trees and rocks
|
||||||
|
(damage, felling, regrowth), pickups, berry bushes, dropped items, every
|
||||||
|
placed piece (walls, doors, chests, graves, fires, beds), fire fuel, door
|
||||||
|
state and the clock. Weather is worked out from the seed and the clock, so
|
||||||
|
it needs no syncing.
|
||||||
|
- A client's `Actions.*` intents that change the island become requests
|
||||||
|
(`_c2s_hit`, `_c2s_place`, `_c2s_take`, `_c2s_chest_click`...). The server
|
||||||
|
checks reach (from where it last saw that player), applies the change,
|
||||||
|
and tells everyone (`_s2c_node`, `_s2c_piece_added`...). Nothing changes
|
||||||
|
on your screen until the server says so, so two people grabbing the same
|
||||||
|
stick get one stick between them.
|
||||||
|
- Whatever a change earns you (wood, a pickup, a dismantled wall and
|
||||||
|
anything that collapsed with it, items out of a chest) the server sends
|
||||||
|
to you (`_s2c_give`). What doesn't fit lands at your feet.
|
||||||
|
- Pieces have a net id that's saved with them, so they keep it across
|
||||||
|
server restarts.
|
||||||
|
- Chest clicks happen on the server (someone else may be using the chest),
|
||||||
|
which hands back what you're holding.
|
||||||
|
- On joining, your world asks for a snapshot of everything once its
|
||||||
|
scenery is up, then gets each change in order. Regrowth only happens on
|
||||||
|
the server, never on top of a player.
|
||||||
|
- The world (fires, regrowth, the clock) pauses while nobody's on.
|
||||||
|
|
||||||
|
Until later phases, while online: your inventory, stats and crafting stay on
|
||||||
|
your own game and aren't saved by the server (M2.4), so the server trusts
|
||||||
|
what you say you're putting into the world (a wall, a chest deposit, a
|
||||||
|
grave's contents) but decides everything taken out. Animals are off (M2.5).
|
||||||
|
Sleeping is off (it needs the sleep rule, M2.5). Respawning ignores beds.
|
||||||
|
The HUD says so.
|
||||||
|
|
||||||
**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
|
||||||
@@ -208,6 +238,12 @@ worlds/<world>/
|
|||||||
friends, name, appearance
|
friends, name, appearance
|
||||||
backups/<timestamp>/ rolling copies (backups_kept)
|
backups/<timestamp>/ rolling copies (backups_kept)
|
||||||
```
|
```
|
||||||
|
*As of M2.3:* `worlds/<world>/meta.json` and `world.json` (with pieces'
|
||||||
|
net ids, no owners yet) are written next to `server.json` within 10 s of
|
||||||
|
any change, when a player leaves, and every `autosave_minutes`. (Ctrl+C
|
||||||
|
kills Godot without running any code, so there's no save on the way out.) An
|
||||||
|
existing world keeps the seed it was made with, even if `server.json`
|
||||||
|
changes. Player files, owners and backups come in M2.4 and M2.7.
|
||||||
- Single-player saves (today's `world.json` with a `"player"` block) are
|
- Single-player saves (today's `world.json` with a `"player"` block) are
|
||||||
migrated on first load. That block becomes the local player's file. The
|
migrated on first load. That block becomes the local player's file. The
|
||||||
test suite keeps an old-format save fixture to prove it.
|
test suite keeps an old-format save fixture to prove it.
|
||||||
@@ -237,7 +273,7 @@ player" or "this player":
|
|||||||
| **M2.0** | worldlet W0 (terrain from data, no visible change), recommended first | seed 1337 generates the same height grid as today; all suites pass |
|
| **M2.0** | worldlet W0 (terrain from data, no visible change), recommended first | seed 1337 generates the same height grid as today; all suites pass |
|
||||||
| **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** | 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** | 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 |
|
||||||
@@ -255,6 +291,10 @@ player" or "this player":
|
|||||||
- **Save tests:** world and player file round trips, migration of old
|
- **Save tests:** world and player file round trips, migration of old
|
||||||
saves, backups.
|
saves, backups.
|
||||||
- The existing 9 suites keep running in single-player (loopback) mode.
|
- 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
|
||||||
|
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
|
||||||
|
`--debug-regrow <k>` server flag speeds regrowth up for it.
|
||||||
|
|
||||||
## 11. Risks
|
## 11. Risks
|
||||||
- **Prediction feel** is the hard part. It gets an early phase (M2.2) so
|
- **Prediction feel** is the hard part. It gets an early phase (M2.2) so
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ It checks the Godot version, imports a fresh copy, then starts the server on
|
|||||||
UDP 24242 with no window. Settings live in `server.json` (its path is printed
|
UDP 24242 with no window. Settings live in `server.json` (its path is printed
|
||||||
at start-up; on macOS it's under
|
at start-up; on macOS it's under
|
||||||
`~/Library/Application Support/Godot/app_userdata/Emberwild/server/`).
|
`~/Library/Application Support/Godot/app_userdata/Emberwild/server/`).
|
||||||
Stop it with Ctrl+C. Players join with **Join Game** on the title screen. Over
|
The shared island is saved in `worlds/server-world/` in that same folder,
|
||||||
|
within 10 seconds of any change (and when someone leaves). Stop it with
|
||||||
|
Ctrl+C. Players join with **Join Game** on the title screen. Over
|
||||||
Tailscale, use the server machine's 100.x address or its machine name. See
|
Tailscale, use the server machine's 100.x address or its machine name. See
|
||||||
`docs/MULTIPLAYER_PLAN.md` for all the options.
|
`docs/MULTIPLAYER_PLAN.md` for all the options.
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ ItemDB="*res://scripts/core/item_db.gd"
|
|||||||
Actions="*res://scripts/core/actions.gd"
|
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"
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ func interact(player: Node) -> void:
|
|||||||
player.open_inventory(self)
|
player.open_inventory(self)
|
||||||
|
|
||||||
func _on_changed() -> void:
|
func _on_changed() -> void:
|
||||||
|
if WorldNet.world != null:
|
||||||
|
return # online, the server crumbles it (world_net.gd)
|
||||||
for s in inventory.slots:
|
for s in inventory.slots:
|
||||||
if s != null:
|
if s != null:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ func _give(player: Node, id: String, n: int) -> int:
|
|||||||
notify.emit("Inventory full")
|
notify.emit("Inventory full")
|
||||||
return got
|
return got
|
||||||
|
|
||||||
|
## Playing on a server: changes to the island go to it (world_net.gd), and
|
||||||
|
## come back to everyone, us included. Our own inventory and stats stay here
|
||||||
|
## until M2.4.
|
||||||
|
func _online() -> bool:
|
||||||
|
return WorldNet.is_client()
|
||||||
|
|
||||||
func _in_reach(player: Node3D, target: Node3D, extra: float = 0.0) -> bool:
|
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
|
||||||
|
|
||||||
@@ -104,6 +110,12 @@ func pickup(player: Node3D, pickup_node: Node3D) -> bool:
|
|||||||
if not is_instance_valid(pickup_node) or not _in_reach(player, pickup_node):
|
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
|
var id: String = pickup_node.item_id
|
||||||
|
if _online():
|
||||||
|
if player.inventory.space_for(id) < 1:
|
||||||
|
notify.emit("Inventory full")
|
||||||
|
return false
|
||||||
|
WorldNet.take(pickup_node) # first come, first served: the server decides
|
||||||
|
return true
|
||||||
if _give(player, id, pickup_node.amount) <= 0:
|
if _give(player, id, pickup_node.amount) <= 0:
|
||||||
return false
|
return false
|
||||||
pickup_node.collected()
|
pickup_node.collected()
|
||||||
@@ -123,6 +135,9 @@ func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool:
|
|||||||
if not player.survival.use_stamina(6.0):
|
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]))
|
||||||
@@ -145,6 +160,9 @@ 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
|
||||||
|
|
||||||
@@ -165,6 +183,9 @@ 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
|
||||||
@@ -172,6 +193,9 @@ func dismantle(player: Node3D, piece: Node3D) -> bool:
|
|||||||
func toggle_door(player: Node3D, door: Node3D) -> bool:
|
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():
|
||||||
|
WorldNet.door(door)
|
||||||
|
return true
|
||||||
door.set_open(not door.open)
|
door.set_open(not door.open)
|
||||||
return true
|
return true
|
||||||
|
|
||||||
@@ -353,6 +377,9 @@ func take_off(player: Node3D) -> bool:
|
|||||||
func forage(player: Node3D, bush: Node3D) -> bool:
|
func forage(player: Node3D, bush: Node3D) -> bool:
|
||||||
if not is_instance_valid(bush) or not _in_reach(player, bush) or not bush.ripe():
|
if not is_instance_valid(bush) or not _in_reach(player, bush) or not bush.ripe():
|
||||||
return false
|
return false
|
||||||
|
if _online():
|
||||||
|
WorldNet.forage(bush)
|
||||||
|
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]))
|
||||||
@@ -385,7 +412,10 @@ func add_fuel(player: Node3D, fire: Node3D) -> bool:
|
|||||||
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)
|
||||||
fire.add_fuel(float(ItemDB.get_item(id).get("fuel", 10)))
|
if _online():
|
||||||
|
WorldNet.fuel(fire, id)
|
||||||
|
else:
|
||||||
|
fire.add_fuel(float(ItemDB.get_item(id).get("fuel", 10)))
|
||||||
notify.emit("Added %s to the fire" % ItemDB.item_name(id).to_lower())
|
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)")
|
||||||
@@ -396,4 +426,14 @@ func add_fuel(player: Node3D, fire: Node3D) -> bool:
|
|||||||
func transfer(player: Node3D, from: Inventory, slot: int, to: Inventory, container: Node3D = null) -> void:
|
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:
|
||||||
|
var s = from.get_slot(slot)
|
||||||
|
if s == null:
|
||||||
|
return
|
||||||
|
if from == player.inventory:
|
||||||
|
from.set_slot(slot, null)
|
||||||
|
WorldNet.chest_put(container, s) # what doesn't fit comes back
|
||||||
|
else:
|
||||||
|
WorldNet.chest_take(container, slot, to.space_for(s.id))
|
||||||
|
return
|
||||||
from.quick_move(slot, to)
|
from.quick_move(slot, to)
|
||||||
|
|||||||
@@ -58,6 +58,22 @@ func create_world(world_name: String, seed: int) -> String:
|
|||||||
})
|
})
|
||||||
return id
|
return id
|
||||||
|
|
||||||
|
## The world slot called exactly `id` (a server's world), made with `seed` if
|
||||||
|
## it doesn't exist yet. An existing world keeps its own seed.
|
||||||
|
func open_or_create(id: String, seed: int) -> String:
|
||||||
|
if not read_meta(id).is_empty():
|
||||||
|
var had := int(read_meta(id).get("seed", seed))
|
||||||
|
if had != seed:
|
||||||
|
print("Saves: world '%s' was made with seed %d, so it keeps it (the config says %d)" % [id, had, seed])
|
||||||
|
return id
|
||||||
|
DirAccess.make_dir_recursive_absolute(_dir(id))
|
||||||
|
var now := Time.get_unix_time_from_system()
|
||||||
|
_write_json(_dir(id).path_join("meta.json"), {
|
||||||
|
"name": id, "seed": seed, "created": now, "last_played": now,
|
||||||
|
"play_time": 0.0, "version": VERSION,
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
|
||||||
func delete_world(id: String) -> void:
|
func delete_world(id: String) -> void:
|
||||||
var dir := _dir(id)
|
var dir := _dir(id)
|
||||||
var d := DirAccess.open(dir)
|
var d := DirAccess.open(dir)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ signal clock(day: int, time: float) ## the server's time of day
|
|||||||
const Identity = preload("res://scripts/net/identity.gd")
|
const 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 := 2
|
const PROTOCOL := 3 ## 3: the shared world (M2.3)
|
||||||
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
|
||||||
@@ -62,12 +62,11 @@ var debug_latency_ms := 0.0
|
|||||||
var debug_jitter_ms := 0.0
|
var debug_jitter_ms := 0.0
|
||||||
|
|
||||||
# Server state.
|
# Server state.
|
||||||
var _terrain: Node3D ## the island, regenerated from the seed, for movement checks
|
var _world: Node3D ## the shared island (world.gd in server mode)
|
||||||
|
var _terrain: Node3D ## its terrain, for movement checks
|
||||||
var _spawn := Vector3.ZERO
|
var _spawn := Vector3.ZERO
|
||||||
var _states: Dictionary = {} ## peer -> {"pos", "yaw", "pitch", "anim", "held", "t", "corr_t"}
|
var _states: Dictionary = {} ## peer -> {"pos", "yaw", "pitch", "anim", "held", "t", "corr_t"}
|
||||||
var server_time := 0.0 ## seconds since the server started
|
var server_time := 0.0 ## seconds since the server started
|
||||||
var _clock_day := 1
|
|
||||||
var _clock_time := 8.0 / 24.0
|
|
||||||
var _send_left := 0.0
|
var _send_left := 0.0
|
||||||
var _clock_left := 0.0
|
var _clock_left := 0.0
|
||||||
# Client delay queues (debug latency): [release_ms, callable].
|
# Client delay queues (debug latency): [release_ms, callable].
|
||||||
@@ -180,20 +179,20 @@ func start_server(config_path: String, overrides: Dictionary = {}) -> String:
|
|||||||
if overrides.has(flag):
|
if overrides.has(flag):
|
||||||
var v: Variant = overrides[flag]
|
var v: Variant = overrides[flag]
|
||||||
config.overrides[map[flag]] = int(v) if map[flag] in ["port", "max_players", "seed"] else String(v)
|
config.overrides[map[flag]] = int(v) if map[flag] in ["port", "max_players", "seed"] else String(v)
|
||||||
# The island, from the seed: the same terrain every client generates.
|
# The shared island (world_net.gd): the same terrain and scenery every
|
||||||
_terrain = load("res://scripts/world/terrain.gd").new()
|
# client generates from the seed, plus everything players changed.
|
||||||
_terrain.name = "ServerTerrain"
|
_world = WorldNet.open_server_world(config)
|
||||||
_terrain.noise_seed = int(config.v("seed"))
|
_terrain = _world.terrain
|
||||||
add_child(_terrain)
|
|
||||||
_spawn = _terrain.find_spawn(Vector2.ZERO)
|
_spawn = _terrain.find_spawn(Vector2.ZERO)
|
||||||
server_time = 0.0
|
server_time = 0.0
|
||||||
_clock_day = 1
|
|
||||||
_clock_time = 8.0 / 24.0
|
|
||||||
_peer = ENetMultiplayerPeer.new()
|
_peer = ENetMultiplayerPeer.new()
|
||||||
var port := int(config.v("port"))
|
var port := int(config.v("port"))
|
||||||
# A few spare slots so a full server can still say "server is full".
|
# A few spare slots so a full server can still say "server is full".
|
||||||
var err := _peer.create_server(port, int(config.v("max_players")) + 4)
|
var err := _peer.create_server(port, int(config.v("max_players")) + 4)
|
||||||
if err != OK:
|
if err != OK:
|
||||||
|
WorldNet.close_server_world()
|
||||||
|
_world = null
|
||||||
|
_terrain = null
|
||||||
return "Couldn't open UDP port %d (error %d). Is another server already running on it?" % [port, err]
|
return "Couldn't open UDP port %d (error %d). Is another server already running on it?" % [port, err]
|
||||||
multiplayer.multiplayer_peer = _peer
|
multiplayer.multiplayer_peer = _peer
|
||||||
multiplayer.server_relay = false # clients only ever talk to the server
|
multiplayer.server_relay = false # clients only ever talk to the server
|
||||||
@@ -218,9 +217,10 @@ func stop() -> void:
|
|||||||
_out_q.clear()
|
_out_q.clear()
|
||||||
_in_q.clear()
|
_in_q.clear()
|
||||||
local_peer = 0
|
local_peer = 0
|
||||||
if _terrain != null:
|
if _world != null:
|
||||||
_terrain.queue_free()
|
WorldNet.close_server_world() # saves it first
|
||||||
_terrain = null
|
_world = null
|
||||||
|
_terrain = null
|
||||||
if state != "offline":
|
if state != "offline":
|
||||||
_set_state("offline")
|
_set_state("offline")
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
WorldNet.peer_left(id)
|
||||||
players_changed.emit()
|
players_changed.emit()
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
@@ -260,20 +261,19 @@ func _process(delta: float) -> void:
|
|||||||
_run_queue(_out_q)
|
_run_queue(_out_q)
|
||||||
_run_queue(_in_q)
|
_run_queue(_in_q)
|
||||||
|
|
||||||
## The world clock runs while anyone's on (or always, if the server's set
|
## The world's clock (it runs while anyone's on, or always if the server's
|
||||||
## not to pause when empty).
|
## set not to pause when empty: world_net.gd pauses the world). Players get
|
||||||
|
## it every few seconds.
|
||||||
func _tick_clock(delta: float) -> void:
|
func _tick_clock(delta: float) -> void:
|
||||||
if players.is_empty() and bool(config.v("pause_when_empty")):
|
|
||||||
return
|
|
||||||
_clock_time += delta / (float(config.v("day_length_minutes")) * 60.0)
|
|
||||||
while _clock_time >= 1.0:
|
|
||||||
_clock_time -= 1.0
|
|
||||||
_clock_day += 1
|
|
||||||
_clock_left -= delta
|
_clock_left -= delta
|
||||||
if _clock_left <= 0.0:
|
if _clock_left <= 0.0 and not players.is_empty():
|
||||||
_clock_left = CLOCK_INTERVAL
|
_clock_left = CLOCK_INTERVAL
|
||||||
for peer in players:
|
for peer in players:
|
||||||
_s2c_clock.rpc_id(peer, _clock_day, _clock_time)
|
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
|
||||||
|
|
||||||
|
## Where a player is, as far as the server knows.
|
||||||
|
func player_pos(peer: int) -> Vector3:
|
||||||
|
return _states[peer].pos if _states.has(peer) else Vector3.INF
|
||||||
|
|
||||||
func _broadcast_snapshot() -> void:
|
func _broadcast_snapshot() -> void:
|
||||||
if _states.is_empty():
|
if _states.is_empty():
|
||||||
@@ -343,8 +343,36 @@ func move_problem(from: Vector3, to: Vector3, dt: float, teleported: bool) -> St
|
|||||||
return "outside the island"
|
return "outside the island"
|
||||||
if to.y < _terrain.sample_height(to.x, to.z) - 1.5:
|
if to.y < _terrain.sample_height(to.x, to.z) - 1.5:
|
||||||
return "underground"
|
return "underground"
|
||||||
|
if not teleported and _through_wall(from, to):
|
||||||
|
return "through a wall"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
## Whether the straight line between two positions crosses a wall, a closed
|
||||||
|
## door or a gable (at waist height). A genuine crossing hits the wall from
|
||||||
|
## both sides; someone a wall was built on top of can still step out of it.
|
||||||
|
func _through_wall(from: Vector3, to: Vector3) -> bool:
|
||||||
|
if _world == null or from.distance_to(to) < 0.05:
|
||||||
|
return false
|
||||||
|
var space: PhysicsDirectSpaceState3D = _world.get_world_3d().direct_space_state
|
||||||
|
var up := Vector3(0, 0.9, 0)
|
||||||
|
return _wall_hit(space, from + up, to + up) and _wall_hit(space, to + up, from + up)
|
||||||
|
|
||||||
|
func _wall_hit(space: PhysicsDirectSpaceState3D, a: Vector3, b: Vector3) -> bool:
|
||||||
|
var ex: Array[RID] = []
|
||||||
|
for _i in range(4):
|
||||||
|
var rq := PhysicsRayQueryParameters3D.create(a, b, 4, ex) # 4: building pieces
|
||||||
|
var hit := space.intersect_ray(rq)
|
||||||
|
if hit.is_empty():
|
||||||
|
return false
|
||||||
|
var c: Object = hit.collider
|
||||||
|
var piece: Node = c as Node
|
||||||
|
while piece != null and piece.get("kind") == null:
|
||||||
|
piece = piece.get_parent()
|
||||||
|
if piece != null and String(piece.kind) in ["wall", "door", "gable"]:
|
||||||
|
return true
|
||||||
|
ex.append(hit.rid) # a floor or roof edge: look past it
|
||||||
|
return false
|
||||||
|
|
||||||
func _reject(peer: int, reason: String) -> void:
|
func _reject(peer: int, reason: String) -> void:
|
||||||
_pending.erase(peer)
|
_pending.erase(peer)
|
||||||
print("[server] rejected peer %d: %s" % [peer, reason])
|
print("[server] rejected peer %d: %s" % [peer, reason])
|
||||||
@@ -400,8 +428,9 @@ func _c2s_proof(sig: PackedByteArray) -> void:
|
|||||||
"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"))])
|
||||||
var world := {"seed": int(config.v("seed")), "worldlet": String(config.v("worldlet")), "spawn": start,
|
# (The world's own seed: an existing world keeps the seed it was made with.)
|
||||||
"island_spawn": _spawn, "day": _clock_day, "time": _clock_time, "server_time": server_time}
|
var world := {"seed": int(_terrain.noise_seed), "worldlet": String(config.v("worldlet")), "spawn": start,
|
||||||
|
"island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time}
|
||||||
_s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world})
|
_s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world})
|
||||||
for other in players:
|
for other in players:
|
||||||
if other != peer:
|
if other != peer:
|
||||||
|
|||||||
626
scripts/net/world_net.gd
Normal file
626
scripts/net/world_net.gd
Normal file
@@ -0,0 +1,626 @@
|
|||||||
|
extends Node
|
||||||
|
## Autoload "WorldNet": the shared world (M2.3, docs/MULTIPLAYER_PLAN.md §5).
|
||||||
|
##
|
||||||
|
## A dedicated server runs the real world scene headless, with no player of its
|
||||||
|
## own (world.gd `server_mode`). It owns everything in the island that changes:
|
||||||
|
## trees and rocks (damage, felling, regrowth), pickups, berry bushes, dropped
|
||||||
|
## items, building pieces, chests and graves, fires, doors and the clock. The
|
||||||
|
## weather follows from the seed and the clock, so it needs no syncing.
|
||||||
|
##
|
||||||
|
## Clients ask; the server checks, applies and tells everyone:
|
||||||
|
## client -> server _c2s_hit uid server -> all _s2c_node uid hp
|
||||||
|
## client -> server _c2s_place item server -> all _s2c_piece_added {...}
|
||||||
|
## ...and so on for each kind of change (the _c2s_* / _s2c_* pairs below).
|
||||||
|
## When a change earns the asker something (wood from a tree, a pickup, a
|
||||||
|
## dismantled wall back), the server sends it to them with _s2c_give.
|
||||||
|
##
|
||||||
|
## Inventories, stats and crafting stay on each player's own game until M2.4,
|
||||||
|
## so the server trusts what a client says it puts into the world (a placed
|
||||||
|
## wall, a chest deposit) but decides everything it takes out.
|
||||||
|
##
|
||||||
|
## On joining, a client's world asks for a snapshot (_c2s_world) once its
|
||||||
|
## scenery is up; from then on it gets every change, in order.
|
||||||
|
|
||||||
|
const REACH := 3.2 + 2.5 ## Actions._in_reach, plus slack for latency
|
||||||
|
const SLACK := 2.0
|
||||||
|
const MAX_HIT := 12.0 ## no tool hits harder than this
|
||||||
|
const FIRE_SYNC := 10.0 ## seconds between fire fuel updates
|
||||||
|
const SAVE_AFTER := 10.0 ## seconds after a change before saving
|
||||||
|
const FUELS := ["wood", "stick", "fiber"]
|
||||||
|
|
||||||
|
var world: Node3D ## server: its world; client: ours while online
|
||||||
|
var pieces: Dictionary = {} ## net id -> piece (both sides)
|
||||||
|
var dropped: Dictionary = {} ## net id -> dropped pickup (both sides)
|
||||||
|
var _next_nid := 1
|
||||||
|
var _ready_peers: Dictionary = {} ## server: peers that have their snapshot
|
||||||
|
var _fire_left := FIRE_SYNC
|
||||||
|
var _blocker_left := 0.0
|
||||||
|
var _save_left := INF ## counting down to a save after a change
|
||||||
|
var _chest_cb := Callable() ## client: waiting for a chest click's result
|
||||||
|
var chest_busy := false
|
||||||
|
|
||||||
|
## Counters the tests read.
|
||||||
|
var received := 0 ## client: world events applied
|
||||||
|
|
||||||
|
func is_server() -> bool:
|
||||||
|
return Net.is_server() and world != null
|
||||||
|
|
||||||
|
func is_client() -> bool:
|
||||||
|
return Net.state == "connected" and world != null and is_instance_valid(world)
|
||||||
|
|
||||||
|
# --- server: the world -----------------------------------------------------------
|
||||||
|
|
||||||
|
## Loads (or creates) the server's world from its config and adds it to the
|
||||||
|
## tree. Saves live next to server.json, in worlds/<world>/.
|
||||||
|
func open_server_world(config: RefCounted) -> Node3D:
|
||||||
|
Saves.root_dir = config.path.get_base_dir().path_join("worlds")
|
||||||
|
var id := String(config.v("world")).validate_filename()
|
||||||
|
if id.is_empty():
|
||||||
|
id = "server-world"
|
||||||
|
Saves.current_id = Saves.open_or_create(id, int(config.v("seed")))
|
||||||
|
var w: Node3D = load("res://scenes/world.tscn").instantiate()
|
||||||
|
w.server_mode = true
|
||||||
|
w.day_length = float(config.v("day_length_minutes")) * 60.0
|
||||||
|
w.autosave_seconds = maxf(float(config.v("autosave_minutes")), 0.5) * 60.0
|
||||||
|
get_tree().root.add_child(w)
|
||||||
|
world = w
|
||||||
|
pieces.clear()
|
||||||
|
dropped.clear()
|
||||||
|
_ready_peers.clear()
|
||||||
|
_next_nid = 1
|
||||||
|
for p in w.all_pieces():
|
||||||
|
if p.has_meta("nid"):
|
||||||
|
_next_nid = maxi(_next_nid, int(p.get_meta("nid")) + 1)
|
||||||
|
for p in w.all_pieces():
|
||||||
|
if not p.has_meta("nid"):
|
||||||
|
p.set_meta("nid", _new_nid())
|
||||||
|
pieces[int(p.get_meta("nid"))] = p
|
||||||
|
for d in w.all_dropped():
|
||||||
|
if not d.has_meta("nid"):
|
||||||
|
d.set_meta("nid", _new_nid())
|
||||||
|
dropped[int(d.get_meta("nid"))] = d
|
||||||
|
_next_nid = maxi(_next_nid, _max_nid() + 1)
|
||||||
|
w.props.regrown.connect(_on_regrown)
|
||||||
|
var args: Dictionary = Net.parse_args(OS.get_cmdline_user_args())
|
||||||
|
if args.has("debug-regrow"):
|
||||||
|
w.props.regrow_scale = float(args["debug-regrow"]) # (tests: things grow back in seconds)
|
||||||
|
print("[server] world '%s' (seed %d): %d pieces, %d dropped items. Saves: %s" % [Saves.current_id,
|
||||||
|
w.terrain.noise_seed, pieces.size(), dropped.size(), ProjectSettings.globalize_path(Saves.root_dir)])
|
||||||
|
return w
|
||||||
|
|
||||||
|
func close_server_world() -> void:
|
||||||
|
if world == null:
|
||||||
|
return
|
||||||
|
if Net.is_server() and is_instance_valid(world):
|
||||||
|
save()
|
||||||
|
if is_instance_valid(world):
|
||||||
|
world.queue_free()
|
||||||
|
world = null
|
||||||
|
pieces.clear()
|
||||||
|
dropped.clear()
|
||||||
|
_ready_peers.clear()
|
||||||
|
|
||||||
|
func save() -> void:
|
||||||
|
_save_left = INF
|
||||||
|
if world != null and is_instance_valid(world) and world.server_mode:
|
||||||
|
world.save_now()
|
||||||
|
|
||||||
|
func _new_nid() -> int:
|
||||||
|
_next_nid += 1
|
||||||
|
return _next_nid - 1
|
||||||
|
|
||||||
|
func _max_nid() -> int:
|
||||||
|
var m := 0
|
||||||
|
for k in pieces:
|
||||||
|
m = maxi(m, k)
|
||||||
|
for k in dropped:
|
||||||
|
m = maxi(m, k)
|
||||||
|
return m
|
||||||
|
|
||||||
|
## A player left: they no longer get events. Save while it's fresh.
|
||||||
|
func peer_left(peer: int) -> void:
|
||||||
|
_ready_peers.erase(peer)
|
||||||
|
save()
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if not is_server():
|
||||||
|
return
|
||||||
|
# Pause the island while nobody's on (fires, regrowth, the clock).
|
||||||
|
var paused: bool = Net.players.is_empty() and bool(Net.config.v("pause_when_empty"))
|
||||||
|
world.process_mode = Node.PROCESS_MODE_DISABLED if paused else Node.PROCESS_MODE_INHERIT
|
||||||
|
if paused:
|
||||||
|
return
|
||||||
|
_blocker_left -= delta
|
||||||
|
if _blocker_left <= 0.0:
|
||||||
|
_blocker_left = 1.0
|
||||||
|
var at: Array = []
|
||||||
|
for peer in Net.players:
|
||||||
|
at.append(Net.player_pos(peer))
|
||||||
|
world.props.blockers = at # nothing regrows on top of a player
|
||||||
|
_save_left -= delta
|
||||||
|
if _save_left <= 0.0:
|
||||||
|
_save_left = INF
|
||||||
|
save()
|
||||||
|
_fire_left -= delta
|
||||||
|
if _fire_left <= 0.0:
|
||||||
|
_fire_left = FIRE_SYNC
|
||||||
|
var fires: Array = []
|
||||||
|
for nid in pieces:
|
||||||
|
var p: Node3D = pieces[nid]
|
||||||
|
if is_instance_valid(p) and p.get("fuel") != null:
|
||||||
|
fires.append([nid, p.fuel])
|
||||||
|
if not fires.is_empty():
|
||||||
|
_all("_s2c_fires", [fires])
|
||||||
|
|
||||||
|
## Sends to every player who has their snapshot. Any change to the island
|
||||||
|
## also means saving soon: Ctrl+C kills Godot without a chance to save, so
|
||||||
|
## this keeps the loss to a few seconds.
|
||||||
|
func _all(method: String, args: Array) -> void:
|
||||||
|
if method != "_s2c_fires" and _save_left == INF:
|
||||||
|
_save_left = SAVE_AFTER
|
||||||
|
for peer in _ready_peers:
|
||||||
|
callv("rpc_id", [peer, method] + args)
|
||||||
|
|
||||||
|
func _give_to(peer: int, items: Dictionary) -> void:
|
||||||
|
var clean := {}
|
||||||
|
for id in items:
|
||||||
|
if int(items[id]) > 0:
|
||||||
|
clean[id] = int(items[id])
|
||||||
|
if not clean.is_empty():
|
||||||
|
_s2c_give.rpc_id(peer, clean)
|
||||||
|
|
||||||
|
func _tell(peer: int, text: String) -> void:
|
||||||
|
_s2c_notify.rpc_id(peer, text)
|
||||||
|
|
||||||
|
## The asker, if they're a player in the world (else 0).
|
||||||
|
func _asker() -> int:
|
||||||
|
var peer := multiplayer.get_remote_sender_id()
|
||||||
|
if not is_server() or not _ready_peers.has(peer):
|
||||||
|
return 0
|
||||||
|
return peer
|
||||||
|
|
||||||
|
func _near(peer: int, at: Vector3, r: float) -> bool:
|
||||||
|
return Net.player_pos(peer).distance_to(at) <= r + SLACK
|
||||||
|
|
||||||
|
func _piece(nid: int) -> Node3D:
|
||||||
|
var p: Node3D = pieces.get(nid)
|
||||||
|
if p == null or not is_instance_valid(p) or p.is_queued_for_deletion():
|
||||||
|
pieces.erase(nid)
|
||||||
|
return null
|
||||||
|
return p
|
||||||
|
|
||||||
|
func _entry(p: Node3D) -> Dictionary:
|
||||||
|
return world.piece_entry(p)
|
||||||
|
|
||||||
|
static func _clean_stack(s: Variant) -> Variant:
|
||||||
|
if not s is Dictionary:
|
||||||
|
return null
|
||||||
|
var id := String(s.get("id", ""))
|
||||||
|
var n := int(s.get("count", 0))
|
||||||
|
if ItemDB.get_item(id).is_empty() or n <= 0:
|
||||||
|
return null
|
||||||
|
return {"id": id, "count": mini(n, ItemDB.stack_size(id))}
|
||||||
|
|
||||||
|
static func _empty(inv: Inventory) -> bool:
|
||||||
|
for s in inv.slots:
|
||||||
|
if s != null:
|
||||||
|
return false
|
||||||
|
return true
|
||||||
|
|
||||||
|
# --- client -> server -------------------------------------------------------------
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func _c2s_world() -> void:
|
||||||
|
var peer := multiplayer.get_remote_sender_id()
|
||||||
|
if not is_server() or not Net.players.has(peer):
|
||||||
|
return
|
||||||
|
_ready_peers[peer] = true
|
||||||
|
_s2c_world.rpc_id(peer, world.collect_shared())
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func _c2s_hit(uid: String, damage: float, point: Vector3) -> void:
|
||||||
|
var peer := _asker()
|
||||||
|
var n: Node3D = world.props.resource_nodes.get(uid) if peer != 0 else null
|
||||||
|
if n == null or not is_instance_valid(n) or n.depleted:
|
||||||
|
return
|
||||||
|
if not _near(peer, point, 3.2 + 1.0) or point.distance_to(n.global_position) > 6.0:
|
||||||
|
return
|
||||||
|
var from: Vector3 = Net.player_pos(peer)
|
||||||
|
var drops: Dictionary = n.hit(clampf(damage, 0.0, MAX_HIT), point, from)
|
||||||
|
_give_to(peer, drops)
|
||||||
|
_all("_s2c_node", [uid, n.hp, point, from])
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func _c2s_take(key: String) -> void:
|
||||||
|
var peer := _asker()
|
||||||
|
if peer == 0:
|
||||||
|
return
|
||||||
|
var pk: Node3D = _find_pickup(key)
|
||||||
|
if pk == null or pk.taken or not _near(peer, pk.global_position, REACH):
|
||||||
|
return
|
||||||
|
var got := {pk.item_id: pk.amount}
|
||||||
|
if key.begins_with("#"):
|
||||||
|
dropped.erase(int(key.substr(1)))
|
||||||
|
pk.collected()
|
||||||
|
_give_to(peer, got)
|
||||||
|
_all("_s2c_taken", [key])
|
||||||
|
|
||||||
|
func _find_pickup(key: String) -> Node3D:
|
||||||
|
var pk: Node3D
|
||||||
|
if key.begins_with("#"):
|
||||||
|
pk = dropped.get(int(key.substr(1)))
|
||||||
|
else:
|
||||||
|
pk = world.props.pickups.get(key) if world != null else null
|
||||||
|
return pk if pk != null and is_instance_valid(pk) else null
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func _c2s_forage(uid: String) -> void:
|
||||||
|
var peer := _asker()
|
||||||
|
var bush: Node3D = world.props.forage.get(uid) if peer != 0 else null
|
||||||
|
if bush == null or not bush.ripe() or not _near(peer, bush.global_position, REACH):
|
||||||
|
return
|
||||||
|
_give_to(peer, bush.harvest())
|
||||||
|
_all("_s2c_forage", [uid, bush.regrow_left])
|
||||||
|
|
||||||
|
## Places `item` at `xf`. The client has already taken it out of its
|
||||||
|
## inventory: if it can't go here, it's given back. A grave carries what the
|
||||||
|
## player was holding when they died.
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func _c2s_place(item: String, xf: Transform3D, contents: Array) -> void:
|
||||||
|
var peer := _asker()
|
||||||
|
if peer == 0:
|
||||||
|
return
|
||||||
|
var def: Dictionary = ItemDB.get_item(item)
|
||||||
|
if def.is_empty() or not def.has("place"):
|
||||||
|
return
|
||||||
|
# (A grave can be further off: drowning puts it on the nearest shore.)
|
||||||
|
var ok := xf.origin.is_finite() and _near(peer, xf.origin, 40.0 if item == "grave" else 8.0)
|
||||||
|
if item == "grave":
|
||||||
|
ok = ok and not contents.is_empty()
|
||||||
|
else:
|
||||||
|
ok = ok and world.placement_valid(def, xf)
|
||||||
|
if not ok:
|
||||||
|
if item != "grave":
|
||||||
|
_give_to(peer, {item: 1})
|
||||||
|
_tell(peer, "Can't place that here")
|
||||||
|
return
|
||||||
|
var p: Node3D = world.spawn_piece(def, xf.orthonormalized())
|
||||||
|
if item == "grave" and p.get("inventory") != null:
|
||||||
|
var clean: Array = []
|
||||||
|
for s in contents.slice(0, p.inventory.size()):
|
||||||
|
clean.append(_clean_stack(s))
|
||||||
|
p.inventory.load_data(clean)
|
||||||
|
p.set_meta("nid", _new_nid())
|
||||||
|
pieces[int(p.get_meta("nid"))] = p
|
||||||
|
_all("_s2c_piece_added", [_entry(p)])
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func _c2s_dismantle(nid: int) -> void:
|
||||||
|
var peer := _asker()
|
||||||
|
var p: Node3D = _piece(nid) if peer != 0 else null
|
||||||
|
if p == null or not _near(peer, p.global_position, 8.0) or p.item_id == "grave":
|
||||||
|
return
|
||||||
|
var why: String = p.dismantle_block_reason()
|
||||||
|
if not why.is_empty():
|
||||||
|
_tell(peer, why)
|
||||||
|
return
|
||||||
|
# Whatever loses its support collapses: those come back to you too.
|
||||||
|
var items := {}
|
||||||
|
for k in pieces:
|
||||||
|
if _piece(k) != null:
|
||||||
|
items[k] = pieces[k].item_id
|
||||||
|
world.remove_piece(p, null)
|
||||||
|
var gone: Array = []
|
||||||
|
var fell: Array = []
|
||||||
|
var refund := {}
|
||||||
|
for k in items:
|
||||||
|
if _piece(k) == null:
|
||||||
|
gone.append(k)
|
||||||
|
refund[items[k]] = refund.get(items[k], 0) + 1
|
||||||
|
if k != nid:
|
||||||
|
fell.append(k)
|
||||||
|
_all("_s2c_pieces_removed", [gone, fell])
|
||||||
|
_give_to(peer, refund)
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func _c2s_door(nid: int) -> void:
|
||||||
|
var peer := _asker()
|
||||||
|
var p: Node3D = _piece(nid) if peer != 0 else null
|
||||||
|
if p == null or p.get("open") == null or not _near(peer, p.global_position, REACH + 1.0):
|
||||||
|
return
|
||||||
|
p.set_open(not p.open)
|
||||||
|
_all("_s2c_piece_state", [nid, p.save_extra()])
|
||||||
|
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func _c2s_fuel(nid: int, item: String) -> void:
|
||||||
|
var peer := _asker()
|
||||||
|
if peer == 0 or not item in FUELS:
|
||||||
|
return
|
||||||
|
var p := _piece(nid)
|
||||||
|
if p == null or p.get("fuel") == null or not _near(peer, p.global_position, REACH) \
|
||||||
|
or p.fuel >= p.MAX_FUEL - 1.0:
|
||||||
|
_give_to(peer, {item: 1})
|
||||||
|
return
|
||||||
|
p.add_fuel(float(ItemDB.get_item(item).get("fuel", 10)))
|
||||||
|
_all("_s2c_piece_state", [nid, p.save_extra()])
|
||||||
|
|
||||||
|
## A click on a chest slot while holding `cursor`: the same as clicking in
|
||||||
|
## your own inventory (pick up, put down, swap). The new cursor comes back.
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func _c2s_chest_click(nid: int, slot: int, cursor: Variant, right: bool) -> void:
|
||||||
|
var peer := _asker()
|
||||||
|
if peer == 0:
|
||||||
|
return
|
||||||
|
var c: Variant = _clean_stack(cursor)
|
||||||
|
var p := _piece(nid)
|
||||||
|
if p == null or p.get("inventory") == null or slot < 0 or slot >= p.inventory.size() \
|
||||||
|
or not _near(peer, p.global_position, REACH + 1.0):
|
||||||
|
_s2c_cursor.rpc_id(peer, c) # nothing happened: keep what you were holding
|
||||||
|
return
|
||||||
|
var res: Variant = p.inventory.click(slot, c, right)
|
||||||
|
_s2c_cursor.rpc_id(peer, res)
|
||||||
|
_chest_changed(nid, p)
|
||||||
|
|
||||||
|
## Shift-click from your inventory: as much of `stack` as fits goes in, the
|
||||||
|
## rest comes back.
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func _c2s_chest_put(nid: int, stack: Variant) -> void:
|
||||||
|
var peer := _asker()
|
||||||
|
var s: Variant = _clean_stack(stack)
|
||||||
|
if peer == 0 or s == null:
|
||||||
|
return
|
||||||
|
var p := _piece(nid)
|
||||||
|
if p == null or p.get("inventory") == null or not _near(peer, p.global_position, REACH + 1.0):
|
||||||
|
_give_to(peer, {s.id: s.count})
|
||||||
|
return
|
||||||
|
var left: int = p.inventory.add(s.id, s.count)
|
||||||
|
_give_to(peer, {s.id: left})
|
||||||
|
_chest_changed(nid, p)
|
||||||
|
|
||||||
|
## Shift-click on a chest slot: up to `room` of it comes to you.
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func _c2s_chest_take(nid: int, slot: int, room: int) -> void:
|
||||||
|
var peer := _asker()
|
||||||
|
var p: Node3D = _piece(nid) if peer != 0 else null
|
||||||
|
if p == null or p.get("inventory") == null or slot < 0 or slot >= p.inventory.size() or room <= 0 \
|
||||||
|
or not _near(peer, p.global_position, REACH + 1.0):
|
||||||
|
return
|
||||||
|
var s: Variant = p.inventory.get_slot(slot)
|
||||||
|
if s == null:
|
||||||
|
return
|
||||||
|
var id: String = s.id
|
||||||
|
var n: int = p.inventory.take_from(slot, room)
|
||||||
|
_give_to(peer, {id: n})
|
||||||
|
_chest_changed(nid, p)
|
||||||
|
|
||||||
|
func _chest_changed(nid: int, p: Node3D) -> void:
|
||||||
|
_all("_s2c_chest", [nid, p.inventory.to_data()])
|
||||||
|
# An emptied grave crumbles.
|
||||||
|
if p.item_id == "grave" and _empty(p.inventory):
|
||||||
|
world.remove_piece(p, null)
|
||||||
|
pieces.erase(nid)
|
||||||
|
_all("_s2c_pieces_removed", [[nid], []])
|
||||||
|
|
||||||
|
## Something the player put down in the world: an arrow that landed, a fish
|
||||||
|
## that didn't fit. Anyone can pick it up.
|
||||||
|
@rpc("any_peer", "call_remote", "reliable")
|
||||||
|
func _c2s_drop(item: String, count: int, xf: Transform3D) -> void:
|
||||||
|
var peer := _asker()
|
||||||
|
if peer == 0 or ItemDB.get_item(item).is_empty() or count <= 0 or count > 999 \
|
||||||
|
or not xf.origin.is_finite() or not _near(peer, xf.origin, 60.0):
|
||||||
|
return # (60 m: an arrow can land a long way off)
|
||||||
|
var d: Node3D = world.drop_item(item, count, xf.orthonormalized())
|
||||||
|
d.set_meta("nid", _new_nid())
|
||||||
|
dropped[int(d.get_meta("nid"))] = d
|
||||||
|
_all("_s2c_dropped", [world.dropped_entry(d)])
|
||||||
|
|
||||||
|
func _on_regrown(uid: String) -> void:
|
||||||
|
_all("_s2c_regrown", [uid])
|
||||||
|
|
||||||
|
# --- client: asking ----------------------------------------------------------------
|
||||||
|
|
||||||
|
## Our world is up: ask for the shared state.
|
||||||
|
func join_world(w: Node3D) -> void:
|
||||||
|
world = w
|
||||||
|
pieces.clear()
|
||||||
|
dropped.clear()
|
||||||
|
chest_busy = false
|
||||||
|
_chest_cb = Callable()
|
||||||
|
received = 0
|
||||||
|
_c2s_world.rpc_id(1)
|
||||||
|
|
||||||
|
func leave_world() -> void:
|
||||||
|
if not Net.is_server():
|
||||||
|
world = null
|
||||||
|
pieces.clear()
|
||||||
|
dropped.clear()
|
||||||
|
|
||||||
|
func hit(node: Node3D, damage: float, point: Vector3) -> void:
|
||||||
|
_c2s_hit.rpc_id(1, String(node.uid), damage, point)
|
||||||
|
|
||||||
|
## The key the server knows a pickup by: its scenery id, or "#<net id>".
|
||||||
|
func pickup_key(pk: Node3D) -> String:
|
||||||
|
return "#%d" % int(pk.get_meta("nid")) if pk.has_meta("nid") else String(pk.uid)
|
||||||
|
|
||||||
|
func take(pk: Node3D) -> void:
|
||||||
|
_c2s_take.rpc_id(1, pickup_key(pk))
|
||||||
|
|
||||||
|
func forage(bush: Node3D) -> void:
|
||||||
|
_c2s_forage.rpc_id(1, String(bush.uid))
|
||||||
|
|
||||||
|
func place(item: String, xf: Transform3D, contents: Array = []) -> void:
|
||||||
|
_c2s_place.rpc_id(1, item, xf, contents)
|
||||||
|
|
||||||
|
static func nid(p: Node3D) -> int:
|
||||||
|
return int(p.get_meta("nid", 0))
|
||||||
|
|
||||||
|
func dismantle(p: Node3D) -> void:
|
||||||
|
_c2s_dismantle.rpc_id(1, nid(p))
|
||||||
|
|
||||||
|
func door(p: Node3D) -> void:
|
||||||
|
_c2s_door.rpc_id(1, nid(p))
|
||||||
|
|
||||||
|
func fuel(p: Node3D, item: String) -> void:
|
||||||
|
_c2s_fuel.rpc_id(1, nid(p), item)
|
||||||
|
|
||||||
|
## `done(cursor)` gets what you're holding afterwards.
|
||||||
|
func chest_click(p: Node3D, slot: int, cursor: Variant, right: bool, done: Callable) -> void:
|
||||||
|
chest_busy = true
|
||||||
|
_chest_cb = done
|
||||||
|
_c2s_chest_click.rpc_id(1, nid(p), slot, cursor, right)
|
||||||
|
|
||||||
|
func chest_put(p: Node3D, stack: Dictionary) -> void:
|
||||||
|
_c2s_chest_put.rpc_id(1, nid(p), stack)
|
||||||
|
|
||||||
|
func chest_take(p: Node3D, slot: int, room: int) -> void:
|
||||||
|
_c2s_chest_take.rpc_id(1, nid(p), slot, room)
|
||||||
|
|
||||||
|
func drop(item: String, count: int, xf: Transform3D) -> void:
|
||||||
|
_c2s_drop.rpc_id(1, item, count, xf)
|
||||||
|
|
||||||
|
# --- server -> client ------------------------------------------------------------
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_world(state: Dictionary) -> void:
|
||||||
|
if not is_client():
|
||||||
|
return
|
||||||
|
world.apply_shared(state)
|
||||||
|
pieces.clear()
|
||||||
|
dropped.clear()
|
||||||
|
for p in world.all_pieces():
|
||||||
|
if p.has_meta("nid"):
|
||||||
|
pieces[int(p.get_meta("nid"))] = p
|
||||||
|
for d in world.all_dropped():
|
||||||
|
if d.has_meta("nid"):
|
||||||
|
dropped[int(d.get_meta("nid"))] = d
|
||||||
|
received += 1
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_node(uid: String, hp: float, point: Vector3, from: Vector3) -> void:
|
||||||
|
if not is_client():
|
||||||
|
return
|
||||||
|
var n: Node3D = world.props.resource_nodes.get(uid)
|
||||||
|
if n != null and is_instance_valid(n):
|
||||||
|
n.apply_remote(hp, point, from)
|
||||||
|
received += 1
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_regrown(uid: String) -> void:
|
||||||
|
if not is_client():
|
||||||
|
return
|
||||||
|
var n: Node3D = world.props.pickups.get(uid, world.props.resource_nodes.get(uid))
|
||||||
|
if n != null and is_instance_valid(n):
|
||||||
|
world.props.regrow.erase(uid)
|
||||||
|
n.regrow()
|
||||||
|
received += 1
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_taken(key: String) -> void:
|
||||||
|
if not is_client():
|
||||||
|
return
|
||||||
|
var pk := _find_pickup(key)
|
||||||
|
if key.begins_with("#"):
|
||||||
|
dropped.erase(int(key.substr(1)))
|
||||||
|
if pk != null:
|
||||||
|
pk.collected()
|
||||||
|
received += 1
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_forage(uid: String, left: float) -> void:
|
||||||
|
if not is_client():
|
||||||
|
return
|
||||||
|
var bush: Node3D = world.props.forage.get(uid)
|
||||||
|
if bush != null:
|
||||||
|
bush.restore(left)
|
||||||
|
received += 1
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_piece_added(e: Dictionary) -> void:
|
||||||
|
if not is_client():
|
||||||
|
return
|
||||||
|
var p: Node3D = world.add_piece_entry(e)
|
||||||
|
if p != null:
|
||||||
|
pieces[int(e.nid)] = p
|
||||||
|
received += 1
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_pieces_removed(nids: Array, fell: Array) -> void:
|
||||||
|
if not is_client():
|
||||||
|
return
|
||||||
|
var gone: Array = []
|
||||||
|
for k in nids:
|
||||||
|
var p: Node3D = pieces.get(int(k))
|
||||||
|
pieces.erase(int(k))
|
||||||
|
if p != null and is_instance_valid(p):
|
||||||
|
gone.append(p)
|
||||||
|
var collapsed: Array = []
|
||||||
|
for k in fell:
|
||||||
|
collapsed.append(int(k))
|
||||||
|
world.remove_pieces_net(gone, collapsed)
|
||||||
|
received += 1
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_piece_state(nid_: int, extra: Dictionary) -> void:
|
||||||
|
if not is_client():
|
||||||
|
return
|
||||||
|
var p: Node3D = pieces.get(nid_)
|
||||||
|
if p == null or not is_instance_valid(p):
|
||||||
|
return
|
||||||
|
if extra.has("open") and p.has_method("set_open"):
|
||||||
|
p.set_open(bool(extra.open))
|
||||||
|
elif p.has_method("load_extra"):
|
||||||
|
p.load_extra(extra)
|
||||||
|
received += 1
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_fires(fires: Array) -> void:
|
||||||
|
if not is_client():
|
||||||
|
return
|
||||||
|
for f in fires:
|
||||||
|
var p: Node3D = pieces.get(int(f[0]))
|
||||||
|
if p != null and is_instance_valid(p) and p.has_method("load_extra"):
|
||||||
|
p.load_extra({"fuel": float(f[1])})
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_chest(nid_: int, data: Array) -> void:
|
||||||
|
if not is_client():
|
||||||
|
return
|
||||||
|
var p: Node3D = pieces.get(nid_)
|
||||||
|
if p != null and is_instance_valid(p) and p.get("inventory") != null:
|
||||||
|
p.inventory.load_data(data)
|
||||||
|
received += 1
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_dropped(e: Dictionary) -> void:
|
||||||
|
if not is_client():
|
||||||
|
return
|
||||||
|
var d: Node3D = world.add_dropped_entry(e)
|
||||||
|
if d != null:
|
||||||
|
dropped[int(e.nid)] = d
|
||||||
|
received += 1
|
||||||
|
|
||||||
|
## Items the server decided we earned. Anything that doesn't fit goes back
|
||||||
|
## on the ground at our feet (for anyone to pick up).
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_give(items: Dictionary) -> void:
|
||||||
|
if not is_client():
|
||||||
|
return
|
||||||
|
for id in items:
|
||||||
|
var n := int(items[id])
|
||||||
|
var got: int = Actions._give(world.player, String(id), n)
|
||||||
|
if got < n:
|
||||||
|
drop(String(id), n - got, Transform3D(Basis(), world.player.global_position + Vector3(0, 0.1, 0)))
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_cursor(stack: Variant) -> void:
|
||||||
|
chest_busy = false
|
||||||
|
var cb := _chest_cb
|
||||||
|
_chest_cb = Callable()
|
||||||
|
if cb.is_valid():
|
||||||
|
cb.call(stack)
|
||||||
|
elif stack != null and is_client():
|
||||||
|
_s2c_give({stack.id: stack.count})
|
||||||
|
|
||||||
|
@rpc("authority", "call_remote", "reliable")
|
||||||
|
func _s2c_notify(text: String) -> void:
|
||||||
|
Actions.notify.emit(text)
|
||||||
@@ -144,8 +144,12 @@ func open(chest: Node) -> void:
|
|||||||
func close() -> void:
|
func close() -> void:
|
||||||
if _cursor != null:
|
if _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:
|
if left > 0 and container != null and is_instance_valid(container):
|
||||||
left = container.inventory.add(_cursor.id, left)
|
if WorldNet.is_client():
|
||||||
|
WorldNet.chest_put(container, {"id": _cursor.id, "count": left})
|
||||||
|
left = 0
|
||||||
|
else:
|
||||||
|
left = container.inventory.add(_cursor.id, left)
|
||||||
_cursor = null
|
_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)
|
||||||
@@ -201,10 +205,28 @@ func _on_chest_slot(i: int, right: bool, shift: bool) -> void:
|
|||||||
return
|
return
|
||||||
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():
|
||||||
|
# A shared chest: the server does the click (someone else may be in
|
||||||
|
# it too) and hands back what we're holding.
|
||||||
|
if WorldNet.chest_busy:
|
||||||
|
return
|
||||||
|
var held = _cursor
|
||||||
|
_cursor = null
|
||||||
|
WorldNet.chest_click(container, i, held, right, _on_chest_cursor)
|
||||||
else:
|
else:
|
||||||
_cursor = container.inventory.click(i, _cursor, right)
|
_cursor = container.inventory.click(i, _cursor, right)
|
||||||
_refresh()
|
_refresh()
|
||||||
|
|
||||||
|
func _on_chest_cursor(stack) -> void:
|
||||||
|
if visible:
|
||||||
|
_cursor = stack
|
||||||
|
_refresh()
|
||||||
|
elif stack != null:
|
||||||
|
# Closed in the meantime: into our bag (or the ground).
|
||||||
|
var left: int = player.inventory.add(stack.id, stack.count)
|
||||||
|
if left > 0:
|
||||||
|
player.get_parent().drop_item(stack.id, left, Transform3D(Basis(), player.global_position + Vector3(0, 0.1, 0)))
|
||||||
|
|
||||||
func _refresh() -> void:
|
func _refresh() -> void:
|
||||||
if player == null:
|
if player == null:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ var forage: Dictionary = {} ## uid -> berry bush (forage.gd)
|
|||||||
## Regrowth: uid -> seconds until a felled tree, mined boulder or picked-up
|
## Regrowth: uid -> seconds until a felled tree, mined boulder or picked-up
|
||||||
## item comes back. Saved with the world. Bushes keep their own timers.
|
## item comes back. Saved with the world. Bushes keep their own timers.
|
||||||
var regrow: Dictionary = {}
|
var regrow: Dictionary = {}
|
||||||
|
## Off on a client playing online: the server says when things grow back.
|
||||||
|
var regrow_enabled := true
|
||||||
|
## Other places nothing may regrow on top of (a server's players).
|
||||||
|
var blockers: Array = []
|
||||||
|
var regrow_scale := 1.0 ## test hook: a server started with --debug-regrow <k>
|
||||||
|
signal regrown(uid: String)
|
||||||
const REGROW_SECONDS := {"tree": 2400.0, "rock": 3600.0, "pickup": 900.0}
|
const REGROW_SECONDS := {"tree": 2400.0, "rock": 3600.0, "pickup": 900.0}
|
||||||
const REGROW_RETRY := 120.0 ## blocked (player or a building nearby): try again later
|
const REGROW_RETRY := 120.0 ## blocked (player or a building nearby): try again later
|
||||||
var _regrow_tick := 0.0
|
var _regrow_tick := 0.0
|
||||||
@@ -125,15 +131,15 @@ func _regrow_kind(uid: String) -> String:
|
|||||||
return resource_nodes[uid].kind
|
return resource_nodes[uid].kind
|
||||||
|
|
||||||
func _on_depleted(node: Node) -> void:
|
func _on_depleted(node: Node) -> void:
|
||||||
regrow[node.uid] = REGROW_SECONDS[node.kind]
|
regrow[node.uid] = REGROW_SECONDS[node.kind] * regrow_scale
|
||||||
|
|
||||||
func _on_taken(pk: Node) -> void:
|
func _on_taken(pk: Node) -> void:
|
||||||
regrow[pk.uid] = REGROW_SECONDS["pickup"]
|
regrow[pk.uid] = REGROW_SECONDS["pickup"] * regrow_scale
|
||||||
|
|
||||||
## Advances regrowth timers by `secs` (every second while playing, or in one
|
## Advances regrowth timers by `secs` (every second while playing, or in one
|
||||||
## go when you sleep).
|
## go when you sleep).
|
||||||
func skip_time(secs: float) -> void:
|
func skip_time(secs: float) -> void:
|
||||||
for uid in regrow.keys():
|
for uid in regrow.keys() if regrow_enabled else []:
|
||||||
regrow[uid] -= secs
|
regrow[uid] -= secs
|
||||||
if regrow[uid] <= 0.0:
|
if regrow[uid] <= 0.0:
|
||||||
_try_regrow(uid)
|
_try_regrow(uid)
|
||||||
@@ -148,12 +154,15 @@ func _try_regrow(uid: String) -> void:
|
|||||||
regrow.erase(uid)
|
regrow.erase(uid)
|
||||||
return
|
return
|
||||||
var r := 0.5 if pickups.has(uid) else 1.4
|
var r := 0.5 if pickups.has(uid) else 1.4
|
||||||
if _player != null and _player.global_position.distance_to(node.global_position) < r + 3.0 \
|
var blocked := _player != null and _player.global_position.distance_to(node.global_position) < r + 3.0
|
||||||
or _built_over(node.global_position, r):
|
for b in blockers:
|
||||||
regrow[uid] = REGROW_RETRY
|
blocked = blocked or (b as Vector3).distance_to(node.global_position) < r + 3.0
|
||||||
|
if blocked or _built_over(node.global_position, r):
|
||||||
|
regrow[uid] = REGROW_RETRY * regrow_scale
|
||||||
return
|
return
|
||||||
regrow.erase(uid)
|
regrow.erase(uid)
|
||||||
node.regrow()
|
node.regrow()
|
||||||
|
regrown.emit(uid)
|
||||||
|
|
||||||
## Something built (or being built) where a tree or rock would come back.
|
## Something built (or being built) where a tree or rock would come back.
|
||||||
func _built_over(pos: Vector3, r: float) -> bool:
|
func _built_over(pos: Vector3, r: float) -> bool:
|
||||||
@@ -179,10 +188,10 @@ func restore_regrow(saved: Dictionary) -> void:
|
|||||||
regrow[uid] = float(saved[uid])
|
regrow[uid] = float(saved[uid])
|
||||||
for uid in resource_nodes:
|
for uid in resource_nodes:
|
||||||
if resource_nodes[uid].depleted and not regrow.has(uid):
|
if resource_nodes[uid].depleted and not regrow.has(uid):
|
||||||
regrow[uid] = REGROW_SECONDS[resource_nodes[uid].kind]
|
regrow[uid] = REGROW_SECONDS[resource_nodes[uid].kind] * regrow_scale
|
||||||
for uid in pickups:
|
for uid in pickups:
|
||||||
if pickups[uid].taken and not regrow.has(uid):
|
if pickups[uid].taken and not regrow.has(uid):
|
||||||
regrow[uid] = REGROW_SECONDS["pickup"]
|
regrow[uid] = REGROW_SECONDS["pickup"] * regrow_scale
|
||||||
|
|
||||||
# --- materials --------------------------------------------------------------
|
# --- materials --------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -56,15 +56,26 @@ func interact_prompt(player: Node) -> String:
|
|||||||
## Applies one hit. Returns the items earned by this hit.
|
## Applies one hit. Returns the items earned by this hit.
|
||||||
func hit(damage: float, point: Vector3, from: Vector3) -> Dictionary:
|
func hit(damage: float, point: Vector3, from: Vector3) -> Dictionary:
|
||||||
var out := {}
|
var out := {}
|
||||||
hp -= damage
|
|
||||||
for id in drops_per_hit:
|
for id in drops_per_hit:
|
||||||
out[id] = drops_per_hit[id]
|
out[id] = drops_per_hit[id]
|
||||||
_burst(point, chip_color, 14)
|
if hp - damage <= 0.0:
|
||||||
if hp <= 0.0:
|
|
||||||
depleted = true
|
|
||||||
for id in drops_final:
|
for id in drops_final:
|
||||||
var r: Array = drops_final[id]
|
var r: Array = drops_final[id]
|
||||||
out[id] = out.get(id, 0) + rng.randi_range(int(r[0]), int(r[1]))
|
out[id] = out.get(id, 0) + rng.randi_range(int(r[0]), int(r[1]))
|
||||||
|
_show_hit(hp - damage, point, from)
|
||||||
|
return out
|
||||||
|
|
||||||
|
## A hit someone else's game reported, via the server (world_net.gd): the
|
||||||
|
## same chips, shake and fall, with no drops.
|
||||||
|
func apply_remote(new_hp: float, point: Vector3, from: Vector3) -> void:
|
||||||
|
if not depleted:
|
||||||
|
_show_hit(new_hp, point, from)
|
||||||
|
|
||||||
|
func _show_hit(new_hp: float, point: Vector3, from: Vector3) -> void:
|
||||||
|
hp = new_hp
|
||||||
|
_burst(point, chip_color, 14)
|
||||||
|
if hp <= 0.0:
|
||||||
|
depleted = true
|
||||||
_deplete(from)
|
_deplete(from)
|
||||||
else:
|
else:
|
||||||
_shake = 1.0
|
_shake = 1.0
|
||||||
@@ -73,7 +84,6 @@ func hit(damage: float, point: Vector3, from: Vector3) -> Dictionary:
|
|||||||
_shake_axis = away.normalized().cross(Vector3.UP) if away.length() > 0.01 else Vector3.RIGHT
|
_shake_axis = away.normalized().cross(Vector3.UP) if away.length() > 0.01 else Vector3.RIGHT
|
||||||
if kind == "rock":
|
if kind == "rock":
|
||||||
_set_instance(_rock_xf())
|
_set_instance(_rock_xf())
|
||||||
return out
|
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
if _shake <= 0.0 or depleted:
|
if _shake <= 0.0 or depleted:
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ const AUTOSAVE_SECONDS := 120.0
|
|||||||
## Set by the title screen before adding the world: no player, no HUD, no
|
## Set by the title screen before adding the world: no player, no HUD, no
|
||||||
## saving, just a slow camera flight over the island as a menu backdrop.
|
## saving, just a slow camera flight over the island as a menu backdrop.
|
||||||
var menu_mode := false
|
var menu_mode := false
|
||||||
|
## Set by WorldNet on a dedicated server: the shared island with nobody of its
|
||||||
|
## own in it (no player, HUD, animals or effects). It saves to the server's
|
||||||
|
## world slot. See world_net.gd.
|
||||||
|
var server_mode := false
|
||||||
|
var day_length := 1200.0 ## seconds per day (a server sets its own)
|
||||||
|
var autosave_seconds := AUTOSAVE_SECONDS
|
||||||
|
|
||||||
var _spawn := Vector3.ZERO
|
var _spawn := Vector3.ZERO
|
||||||
var _spawn_yaw := 0.0
|
var _spawn_yaw := 0.0
|
||||||
@@ -61,11 +67,15 @@ func is_online() -> bool:
|
|||||||
var _loading := false ## applying a save: defer support recompute
|
var _loading := false ## applying a save: defer support recompute
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
_autosave_left = autosave_seconds
|
||||||
_pieces = Node3D.new()
|
_pieces = Node3D.new()
|
||||||
_pieces.name = "Pieces"
|
_pieces.name = "Pieces"
|
||||||
add_child(_pieces)
|
add_child(_pieces)
|
||||||
day_night = preload("res://scripts/world/day_night.gd").new()
|
day_night = preload("res://scripts/world/day_night.gd").new()
|
||||||
day_night.name = "DayNight"
|
day_night.name = "DayNight"
|
||||||
|
if is_online() and Net.server_info.has("day_length_minutes"):
|
||||||
|
day_length = float(Net.server_info.day_length_minutes) * 60.0 # same days, same weather
|
||||||
|
day_night.day_length = day_length
|
||||||
add_child(day_night)
|
add_child(day_night)
|
||||||
day_night.setup(sun, $WorldEnvironment.environment)
|
day_night.setup(sun, $WorldEnvironment.environment)
|
||||||
weather = preload("res://scripts/world/weather.gd").new()
|
weather = preload("res://scripts/world/weather.gd").new()
|
||||||
@@ -86,7 +96,15 @@ func _ready() -> void:
|
|||||||
creatures.name = "Creatures"
|
creatures.name = "Creatures"
|
||||||
creatures.enabled = false # until the scenery is placed
|
creatures.enabled = false # until the scenery is placed
|
||||||
add_child(creatures)
|
add_child(creatures)
|
||||||
if not menu_mode and DisplayServer.get_name() != "headless":
|
if server_mode:
|
||||||
|
# Nobody of our own: the player node stays, parked out of the way and
|
||||||
|
# switched off, because a few shared functions expect one.
|
||||||
|
player.process_mode = Node.PROCESS_MODE_DISABLED
|
||||||
|
player.collision_layer = 0
|
||||||
|
player.position = Vector3(0, -500, 0)
|
||||||
|
player.get_node("Camera3D").current = false
|
||||||
|
$HUD.visible = false
|
||||||
|
elif not menu_mode and DisplayServer.get_name() != "headless":
|
||||||
weather_fx = preload("res://scripts/world/weather_fx.gd").new()
|
weather_fx = preload("res://scripts/world/weather_fx.gd").new()
|
||||||
weather_fx.name = "WeatherFX"
|
weather_fx.name = "WeatherFX"
|
||||||
add_child(weather_fx)
|
add_child(weather_fx)
|
||||||
@@ -101,13 +119,13 @@ func _ready() -> void:
|
|||||||
add_child(_menu_cam)
|
add_child(_menu_cam)
|
||||||
_menu_cam.current = true
|
_menu_cam.current = true
|
||||||
$HUD.visible = false
|
$HUD.visible = false
|
||||||
elif DisplayServer.get_name() != "headless":
|
elif DisplayServer.get_name() != "headless" and not server_mode:
|
||||||
hud = preload("res://scripts/ui/hud.gd").new()
|
hud = preload("res://scripts/ui/hud.gd").new()
|
||||||
add_child(hud)
|
add_child(hud)
|
||||||
hud.bind(player)
|
hud.bind(player)
|
||||||
if not menu_mode:
|
if not menu_mode and not server_mode:
|
||||||
player.survival.died.connect(_on_player_died)
|
player.survival.died.connect(_on_player_died)
|
||||||
if Saves.has_world() and not menu_mode:
|
if Saves.has_world() and not menu_mode and not server_mode:
|
||||||
# Save on window close instead of quitting straight away.
|
# Save on window close instead of quitting straight away.
|
||||||
get_tree().auto_accept_quit = false
|
get_tree().auto_accept_quit = false
|
||||||
player.set_physics_process(false)
|
player.set_physics_process(false)
|
||||||
@@ -158,8 +176,12 @@ func _on_player_died() -> void:
|
|||||||
if st != null:
|
if st != null:
|
||||||
has_items = true
|
has_items = true
|
||||||
if has_items:
|
if has_items:
|
||||||
var grave := spawn_piece(ItemDB.get_item("grave"), Transform3D(Basis(Vector3.UP, player.rotation.y), pos))
|
var gxf := Transform3D(Basis(Vector3.UP, player.rotation.y), pos)
|
||||||
grave.inventory.load_data(player.inventory.to_data())
|
if net_sync != null:
|
||||||
|
WorldNet.place("grave", gxf, player.inventory.to_data()) # the server puts it in the shared world
|
||||||
|
else:
|
||||||
|
var grave := spawn_piece(ItemDB.get_item("grave"), gxf)
|
||||||
|
grave.inventory.load_data(player.inventory.to_data())
|
||||||
player.inventory.load_data(Inventory.new(player.inventory.size()).to_data())
|
player.inventory.load_data(Inventory.new(player.inventory.size()).to_data())
|
||||||
if hud != null:
|
if hud != null:
|
||||||
hud.close_screens()
|
hud.close_screens()
|
||||||
@@ -197,6 +219,8 @@ func _respawn_at_bed(bed: Node3D) -> void:
|
|||||||
|
|
||||||
## Why you can't sleep in `bed` right now ("" = you can).
|
## 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():
|
||||||
|
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:
|
||||||
return "You can only sleep at night (after 19:30)"
|
return "You can only sleep at night (after 19:30)"
|
||||||
@@ -255,6 +279,12 @@ func _on_terrain_ready() -> void:
|
|||||||
print("[world] scenery placed in %d ms" % (Time.get_ticks_msec() - t0))
|
print("[world] scenery placed in %d ms" % (Time.get_ticks_msec() - t0))
|
||||||
if menu_mode:
|
if menu_mode:
|
||||||
return
|
return
|
||||||
|
if server_mode:
|
||||||
|
if Saves.has_world():
|
||||||
|
var saved: Dictionary = Saves.load_state(Saves.current_id)
|
||||||
|
if not saved.is_empty():
|
||||||
|
apply_save(saved)
|
||||||
|
return # no player, no animals (the server runs those in M2.5)
|
||||||
_respawn()
|
_respawn()
|
||||||
if Saves.has_world():
|
if Saves.has_world():
|
||||||
var state: Dictionary = Saves.load_state(Saves.current_id)
|
var state: Dictionary = Saves.load_state(Saves.current_id)
|
||||||
@@ -301,15 +331,25 @@ func _process(delta: float) -> void:
|
|||||||
## Writes the current game to the active world slot. Returns false if this
|
## Writes the current game to the active world slot. Returns false if this
|
||||||
## session isn't tied to a slot (e.g. running world.tscn directly).
|
## session isn't tied to a slot (e.g. running world.tscn directly).
|
||||||
func save_now() -> bool:
|
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
|
||||||
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
|
||||||
Actions.notify.emit("Game saved" if ok else "Save FAILED")
|
if server_mode:
|
||||||
|
if not ok:
|
||||||
|
printerr("[server] saving the world FAILED")
|
||||||
|
else:
|
||||||
|
Actions.notify.emit("Game saved" if ok else "Save FAILED")
|
||||||
return ok
|
return ok
|
||||||
|
|
||||||
|
## A server's world saves as it closes, however the server stops (Ctrl+C
|
||||||
|
## included), unless it has just saved.
|
||||||
|
func _exit_tree() -> void:
|
||||||
|
if server_mode and Saves.has_world() and _session_time > 0.0:
|
||||||
|
save_now()
|
||||||
|
|
||||||
func _notification(what: int) -> void:
|
func _notification(what: int) -> void:
|
||||||
if what != NOTIFICATION_WM_CLOSE_REQUEST or menu_mode:
|
if what != NOTIFICATION_WM_CLOSE_REQUEST or menu_mode:
|
||||||
return
|
return
|
||||||
@@ -349,20 +389,13 @@ func collect_save() -> Dictionary:
|
|||||||
if not is_instance_valid(pk) or pk.taken:
|
if not is_instance_valid(pk) or pk.taken:
|
||||||
taken.append(uid)
|
taken.append(uid)
|
||||||
var dropped: Array = []
|
var dropped: Array = []
|
||||||
for d in _dropped.get_children():
|
for d in all_dropped():
|
||||||
if d.is_queued_for_deletion() or d.taken:
|
dropped.append(dropped_entry(d))
|
||||||
continue
|
|
||||||
dropped.append({"item": d.item_id, "count": d.amount, "xf": _xf_to_array(d.global_transform)})
|
|
||||||
var pieces: Array = []
|
var pieces: Array = []
|
||||||
for piece in _pieces.get_children():
|
for piece in all_pieces():
|
||||||
if piece.is_queued_for_deletion():
|
pieces.append(piece_entry(piece))
|
||||||
continue
|
if server_mode:
|
||||||
var entry := {"item": piece.item_id, "xf": _xf_to_array(piece.global_transform)}
|
return _shared_state(harvested, regrow, taken, dropped, pieces)
|
||||||
if piece.get("inventory") != null:
|
|
||||||
entry["inventory"] = piece.inventory.to_data()
|
|
||||||
if piece.has_method("save_extra"):
|
|
||||||
entry["extra"] = piece.save_extra()
|
|
||||||
pieces.append(entry)
|
|
||||||
return {
|
return {
|
||||||
"player": {
|
"player": {
|
||||||
"pos": [player.global_position.x, player.global_position.y, player.global_position.z],
|
"pos": [player.global_position.x, player.global_position.y, player.global_position.z],
|
||||||
@@ -383,22 +416,107 @@ func collect_save() -> Dictionary:
|
|||||||
"pieces": pieces,
|
"pieces": pieces,
|
||||||
}
|
}
|
||||||
|
|
||||||
func apply_save(s: Dictionary) -> void:
|
func _shared_state(harvested: Dictionary, regrow: Dictionary, taken: Array, dropped: Array, pieces: Array) -> Dictionary:
|
||||||
var p: Dictionary = s.get("player", {})
|
return {
|
||||||
|
"dropped": dropped,
|
||||||
|
"time": {"day": day_night.day, "time": day_night.time},
|
||||||
|
"forage_regrow": regrow,
|
||||||
|
"regrow": props.regrow.duplicate(),
|
||||||
|
"harvested": harvested,
|
||||||
|
"pickups_taken": taken,
|
||||||
|
"pieces": pieces,
|
||||||
|
}
|
||||||
|
|
||||||
|
## The live pieces (placed and not being removed).
|
||||||
|
func all_pieces() -> Array:
|
||||||
|
return _pieces.get_children().filter(func(p): return not p.is_queued_for_deletion())
|
||||||
|
|
||||||
|
func all_dropped() -> Array:
|
||||||
|
return _dropped.get_children().filter(func(d): return not d.is_queued_for_deletion() and not d.taken)
|
||||||
|
|
||||||
|
## One piece as saved (and as sent to players): its item, where it is, what's
|
||||||
|
## in it, its state (fuel, door open), and its net id if it has one.
|
||||||
|
func piece_entry(piece: Node3D) -> Dictionary:
|
||||||
|
var entry := {"item": piece.item_id, "xf": _xf_to_array(piece.global_transform)}
|
||||||
|
if piece.get("inventory") != null:
|
||||||
|
entry["inventory"] = piece.inventory.to_data()
|
||||||
|
if piece.has_method("save_extra"):
|
||||||
|
entry["extra"] = piece.save_extra()
|
||||||
|
if piece.has_meta("nid"):
|
||||||
|
entry["nid"] = int(piece.get_meta("nid"))
|
||||||
|
return entry
|
||||||
|
|
||||||
|
func dropped_entry(d: Node3D) -> Dictionary:
|
||||||
|
var e := {"item": d.item_id, "count": d.amount, "xf": _xf_to_array(d.global_transform)}
|
||||||
|
if d.has_meta("nid"):
|
||||||
|
e["nid"] = int(d.get_meta("nid"))
|
||||||
|
return e
|
||||||
|
|
||||||
|
## The shared state a server sends a player who joins (everything but the
|
||||||
|
## server's own bookkeeping).
|
||||||
|
func collect_shared() -> Dictionary:
|
||||||
|
return collect_save()
|
||||||
|
|
||||||
|
## A joining player's copy of the server's island.
|
||||||
|
func apply_shared(s: Dictionary) -> void:
|
||||||
|
apply_save(s, false)
|
||||||
|
props.regrow.clear() # the server says when things grow back
|
||||||
|
|
||||||
|
func add_piece_entry(e: Dictionary) -> Node3D:
|
||||||
|
var def: Dictionary = ItemDB.get_item(e.get("item", ""))
|
||||||
|
if def.is_empty() or not def.has("place"):
|
||||||
|
return null
|
||||||
|
var piece := spawn_piece(def, _array_to_xf(e.xf))
|
||||||
|
if e.has("inventory") and piece.get("inventory") != null:
|
||||||
|
piece.inventory.load_data(e.inventory)
|
||||||
|
if e.has("extra") and piece.has_method("load_extra"):
|
||||||
|
piece.load_extra(e.extra)
|
||||||
|
if e.has("nid"):
|
||||||
|
piece.set_meta("nid", int(e.nid))
|
||||||
|
return piece
|
||||||
|
|
||||||
|
func add_dropped_entry(e: Dictionary) -> Node3D:
|
||||||
|
if ItemDB.get_item(e.get("item", "")).is_empty():
|
||||||
|
return null
|
||||||
|
var d := _make_dropped(e.item, int(e.get("count", 1)), _array_to_xf(e.xf))
|
||||||
|
if e.has("nid"):
|
||||||
|
d.set_meta("nid", int(e.nid))
|
||||||
|
return d
|
||||||
|
|
||||||
|
## Pieces the server took out (dismantled, collapsed, a grave emptied). No
|
||||||
|
## refunds here: the server sends those to whoever did it.
|
||||||
|
func remove_pieces_net(gone: Array, collapsed_nids: Array) -> void:
|
||||||
|
var any_build := false
|
||||||
|
for p in gone:
|
||||||
|
if p.item_id == "bed" and spawn_bed() == p:
|
||||||
|
has_bed_spawn = false
|
||||||
|
if p.is_in_group("build"):
|
||||||
|
any_build = true
|
||||||
|
if int(p.get_meta("nid", 0)) in collapsed_nids:
|
||||||
|
_collapse_fx(p)
|
||||||
|
_pieces.remove_child(p)
|
||||||
|
p.queue_free()
|
||||||
|
if any_build:
|
||||||
|
build.recompute()
|
||||||
|
_settle_loose_pieces()
|
||||||
|
_update_grass_cuts()
|
||||||
|
|
||||||
|
func apply_save(s: Dictionary, with_player: bool = true) -> void:
|
||||||
|
var p: Dictionary = s.get("player", {}) if with_player else {}
|
||||||
if p.has("pos"):
|
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])
|
||||||
player.rotation = Vector3(0.0, float(p.get("yaw", 0.0)), 0.0)
|
player.rotation = Vector3(0.0, float(p.get("yaw", 0.0)), 0.0)
|
||||||
player.get_node("Camera3D").rotation.x = float(p.get("pitch", 0.0))
|
player.get_node("Camera3D").rotation.x = float(p.get("pitch", 0.0))
|
||||||
if p.has("inventory"):
|
if with_player:
|
||||||
player.inventory.load_data(p.inventory)
|
if p.has("inventory"):
|
||||||
player.select(int(p.get("selected", 0)))
|
player.inventory.load_data(p.inventory)
|
||||||
if p.has("stats"):
|
player.select(int(p.get("selected", 0)))
|
||||||
player.survival.load_data(p.stats)
|
if p.has("stats"):
|
||||||
player.worn = String(p.get("worn", ""))
|
player.survival.load_data(p.stats)
|
||||||
player.worn_changed.emit()
|
player.worn = String(p.get("worn", ""))
|
||||||
|
player.worn_changed.emit()
|
||||||
for d in s.get("dropped", []):
|
for d in s.get("dropped", []):
|
||||||
if not ItemDB.get_item(d.get("item", "")).is_empty():
|
add_dropped_entry(d)
|
||||||
drop_item(d.item, int(d.get("count", 1)), _array_to_xf(d.xf))
|
|
||||||
var t: Dictionary = s.get("time", {})
|
var t: Dictionary = s.get("time", {})
|
||||||
if not t.is_empty():
|
if not t.is_empty():
|
||||||
day_night.day = int(t.get("day", 1))
|
day_night.day = int(t.get("day", 1))
|
||||||
@@ -424,14 +542,7 @@ func apply_save(s: Dictionary) -> void:
|
|||||||
weather.snap()
|
weather.snap()
|
||||||
_loading = true
|
_loading = true
|
||||||
for e in s.get("pieces", []):
|
for e in s.get("pieces", []):
|
||||||
var def: Dictionary = ItemDB.get_item(e.get("item", ""))
|
add_piece_entry(e)
|
||||||
if def.is_empty() or not def.has("place"):
|
|
||||||
continue
|
|
||||||
var piece := spawn_piece(def, _array_to_xf(e.xf))
|
|
||||||
if e.has("inventory") and piece.get("inventory") != null:
|
|
||||||
piece.inventory.load_data(e.inventory)
|
|
||||||
if e.has("extra") and piece.has_method("load_extra"):
|
|
||||||
piece.load_extra(e.extra)
|
|
||||||
_loading = false
|
_loading = false
|
||||||
build.recompute() # same terrain, same pieces: nothing collapses on load
|
build.recompute() # same terrain, same pieces: nothing collapses on load
|
||||||
_update_grass_cuts()
|
_update_grass_cuts()
|
||||||
@@ -531,14 +642,18 @@ func _go_online() -> void:
|
|||||||
net_sync.setup(self)
|
net_sync.setup(self)
|
||||||
Net.left.connect(_on_net_left)
|
Net.left.connect(_on_net_left)
|
||||||
get_tree().auto_accept_quit = false # leave the server cleanly on window close
|
get_tree().auto_accept_quit = false # leave the server cleanly on window close
|
||||||
|
# The island's changes come from the server from now on.
|
||||||
|
props.regrow_enabled = false
|
||||||
|
WorldNet.join_world(self)
|
||||||
if hud != null:
|
if hud != null:
|
||||||
var bar := UITheme.label("Online: %s · you can see each other; building, gathering and animals aren't shared yet" \
|
var bar := UITheme.label("Online: %s · the island is shared; your inventory isn't saved yet, and there are no animals yet" \
|
||||||
% Net.server_info.get("server_name", "server"), 14, UITheme.TEXT_DIM)
|
% 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)
|
||||||
Actions.notify.emit("Connected to %s" % Net.server_info.get("server_name", "the server"))
|
Actions.notify.emit("Connected to %s" % Net.server_info.get("server_name", "the server"))
|
||||||
|
|
||||||
func _on_net_left(_reason: String) -> void:
|
func _on_net_left(_reason: String) -> void:
|
||||||
|
WorldNet.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
|
||||||
@@ -546,7 +661,14 @@ func _on_net_left(_reason: String) -> void:
|
|||||||
|
|
||||||
## Leaves `count` of an item lying in the world at `xf` (an arrow stuck in a
|
## 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).
|
||||||
func drop_item(id: String, count: int, xf: Transform3D) -> Node3D:
|
func drop_item(id: String, count: int, xf: Transform3D) -> Node3D:
|
||||||
|
if net_sync != null:
|
||||||
|
WorldNet.drop(id, count, xf)
|
||||||
|
return null
|
||||||
|
return _make_dropped(id, count, xf)
|
||||||
|
|
||||||
|
func _make_dropped(id: String, count: int, xf: Transform3D) -> Node3D:
|
||||||
var pk: Area3D = preload("res://scripts/world/pickup.gd").new()
|
var pk: Area3D = preload("res://scripts/world/pickup.gd").new()
|
||||||
pk.setup(id, ItemDB.mesh_for(id), ItemDB.item_material(), 0.3)
|
pk.setup(id, ItemDB.mesh_for(id), ItemDB.item_material(), 0.3)
|
||||||
pk.amount = count
|
pk.amount = count
|
||||||
|
|||||||
159
tests/net_world_bot.gd
Normal file
159
tests/net_world_bot.gd
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## A second player with a real world, for tests/test_net_shared.gd. Joins,
|
||||||
|
## loads the island like the game does, then follows commands the test
|
||||||
|
## writes as files (so the test can line up both players' actions):
|
||||||
|
## <dir>/cmd_<n>.json -> runs it -> <dir>/res_<n>.json
|
||||||
|
## Commands: walk, report, take, chest_take, bump, hop, leave.
|
||||||
|
## Args (after --): --identity <dir> --name <name> --address host:port --dir <cmd dir>
|
||||||
|
|
||||||
|
var net: Node
|
||||||
|
var world: Node3D
|
||||||
|
var player: CharacterBody3D
|
||||||
|
var dir := ""
|
||||||
|
var corrections := 0
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
net = root.get_node("Net")
|
||||||
|
var args: Dictionary = net.parse_args(OS.get_cmdline_user_args())
|
||||||
|
dir = String(args.dir)
|
||||||
|
var me = load("res://scripts/net/identity.gd").open(String(args.identity))
|
||||||
|
me.name = String(args.get("name", "Bob"))
|
||||||
|
net.identity = me
|
||||||
|
var outcome := {"v": ""}
|
||||||
|
net.joined.connect(func(): outcome.v = "joined")
|
||||||
|
net.join_failed.connect(func(r): outcome.v = "failed: " + r)
|
||||||
|
net.join(String(args.address))
|
||||||
|
var t0 := Time.get_ticks_msec()
|
||||||
|
while outcome.v.is_empty() and Time.get_ticks_msec() - t0 < 20000:
|
||||||
|
await process_frame
|
||||||
|
if outcome.v != "joined":
|
||||||
|
_write(0, {"ok": false, "why": outcome.v})
|
||||||
|
quit()
|
||||||
|
return
|
||||||
|
world = load("res://scenes/world.tscn").instantiate()
|
||||||
|
root.add_child(world)
|
||||||
|
for _i in range(8):
|
||||||
|
await physics_frame
|
||||||
|
player = world.player
|
||||||
|
player.set_physics_process(false) # the test moves us
|
||||||
|
net.corrected.connect(func(_p): corrections += 1)
|
||||||
|
t0 = Time.get_ticks_msec()
|
||||||
|
while world.get_node("/root/WorldNet").received < 1 and Time.get_ticks_msec() - t0 < 10000:
|
||||||
|
await process_frame
|
||||||
|
_write(0, {"ok": true, "pos": _v(player.global_position)})
|
||||||
|
var n := 1
|
||||||
|
while net.state == "connected":
|
||||||
|
var path := "%s/cmd_%d.json" % [dir, n]
|
||||||
|
if not FileAccess.file_exists(path):
|
||||||
|
await create_timer(0.05).timeout
|
||||||
|
continue
|
||||||
|
await create_timer(0.05).timeout
|
||||||
|
var cmd: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(path))
|
||||||
|
var res: Dictionary = await _do(cmd)
|
||||||
|
_write(n, res)
|
||||||
|
n += 1
|
||||||
|
if cmd.get("do") == "leave":
|
||||||
|
break
|
||||||
|
quit()
|
||||||
|
|
||||||
|
func _write(n: int, d: Dictionary) -> void:
|
||||||
|
var f := FileAccess.open("%s/res_%d.json" % [dir, n], FileAccess.WRITE)
|
||||||
|
f.store_string(JSON.stringify(d))
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
static func _v(p: Vector3) -> Array:
|
||||||
|
return [p.x, p.y, p.z]
|
||||||
|
|
||||||
|
static func _p(a: Array) -> Vector3:
|
||||||
|
return Vector3(a[0], a[1], a[2])
|
||||||
|
|
||||||
|
func _do(cmd: Dictionary) -> Dictionary:
|
||||||
|
var WN: Node = root.get_node("WorldNet")
|
||||||
|
match String(cmd.get("do", "")):
|
||||||
|
"walk":
|
||||||
|
await walk(player, _p(cmd.to), world)
|
||||||
|
return {"pos": _v(player.global_position), "corrections": corrections}
|
||||||
|
"report":
|
||||||
|
return report(world, WN, cmd, corrections)
|
||||||
|
"take":
|
||||||
|
var pk: Node3D = _pickup(world, WN, String(cmd.key))
|
||||||
|
var ok: bool = pk != null and root.get_node("Actions").pickup(player, pk)
|
||||||
|
await create_timer(0.6).timeout
|
||||||
|
return {"asked": ok, "inv": player.inventory.to_data()}
|
||||||
|
"chest_take":
|
||||||
|
var c: Node3D = WN.pieces.get(int(cmd.nid))
|
||||||
|
if c == null:
|
||||||
|
return {"error": "no chest %d" % int(cmd.nid)}
|
||||||
|
root.get_node("Actions").transfer(player, c.inventory, int(cmd.slot), player.inventory, c)
|
||||||
|
await create_timer(0.6).timeout
|
||||||
|
return {"inv": player.inventory.to_data()}
|
||||||
|
"bump":
|
||||||
|
# Walk into whatever's in front, with real collisions.
|
||||||
|
var start := player.global_position
|
||||||
|
var d: Vector3 = _p(cmd.dir).normalized()
|
||||||
|
for _i in range(int(float(cmd.dist) / 0.1)):
|
||||||
|
player.move_and_collide(d * 0.1)
|
||||||
|
await physics_frame
|
||||||
|
await create_timer(0.3).timeout
|
||||||
|
return {"moved": Vector2(player.global_position.x - start.x, player.global_position.z - start.z).length()}
|
||||||
|
"hop":
|
||||||
|
# Cheat: step straight through to the other side.
|
||||||
|
var c0 := corrections
|
||||||
|
player.global_position = _p(cmd.to)
|
||||||
|
await create_timer(1.0).timeout
|
||||||
|
return {"corrected": corrections - c0, "pos": _v(player.global_position)}
|
||||||
|
"leave":
|
||||||
|
net.leave()
|
||||||
|
await create_timer(0.3).timeout
|
||||||
|
return {"left": true}
|
||||||
|
return {"error": "unknown command"}
|
||||||
|
|
||||||
|
# Shared with test_net_shared.gd (it loads this script for these).
|
||||||
|
|
||||||
|
## Walks (by setting positions, like the game sending its moves) at 5 m/s.
|
||||||
|
static func walk(p: CharacterBody3D, to: Vector3, w: Node3D) -> void:
|
||||||
|
var tree := p.get_tree()
|
||||||
|
var from := p.global_position
|
||||||
|
var dist := Vector2(to.x - from.x, to.z - from.z).length()
|
||||||
|
var steps := maxi(1, int(dist / (5.0 / 60.0)))
|
||||||
|
for i in range(1, steps + 1):
|
||||||
|
var q := from.lerp(to, float(i) / steps)
|
||||||
|
q.y = w.terrain.sample_height(q.x, q.z) + 0.05
|
||||||
|
p.global_position = q
|
||||||
|
await tree.physics_frame
|
||||||
|
await tree.create_timer(0.4).timeout # let the server hear about it
|
||||||
|
|
||||||
|
static func _pickup(w: Node3D, WN: Node, key: String) -> Node3D:
|
||||||
|
if key.begins_with("#"):
|
||||||
|
return WN.dropped.get(int(key.substr(1)))
|
||||||
|
return w.props.pickups.get(key)
|
||||||
|
|
||||||
|
static func report(w: Node3D, WN: Node, cmd: Dictionary, corr: int) -> Dictionary:
|
||||||
|
var out := {"nodes": {}, "pieces": {}, "taken": {}, "inv": w.player.inventory.to_data(),
|
||||||
|
"received": WN.received, "corrections": corr, "dropped": []}
|
||||||
|
for uid in cmd.get("uids", []):
|
||||||
|
var n: Node3D = w.props.resource_nodes.get(uid)
|
||||||
|
out.nodes[uid] = {"hp": n.hp, "depleted": n.depleted} if n != null else null
|
||||||
|
for key in cmd.get("keys", []):
|
||||||
|
var pk: Node3D = _pickup(w, WN, key)
|
||||||
|
out.taken[key] = pk == null or not is_instance_valid(pk) or pk.taken
|
||||||
|
for nid in WN.pieces:
|
||||||
|
var p: Node3D = WN.pieces[nid]
|
||||||
|
if p == null or not is_instance_valid(p) or p.is_queued_for_deletion():
|
||||||
|
continue
|
||||||
|
var e := {"item": p.item_id, "pos": _v(p.global_position)}
|
||||||
|
if p.get("inventory") != null:
|
||||||
|
e.inv = p.inventory.to_data()
|
||||||
|
if p.get("open") != null:
|
||||||
|
e.open = p.open
|
||||||
|
if p.get("fuel") != null:
|
||||||
|
e.fuel = p.fuel
|
||||||
|
out.pieces[str(nid)] = e
|
||||||
|
for nid in WN.dropped:
|
||||||
|
var d: Node3D = WN.dropped[nid]
|
||||||
|
if d != null and is_instance_valid(d) and not d.taken:
|
||||||
|
out.dropped.append({"nid": nid, "item": d.item_id, "count": d.amount})
|
||||||
|
return out
|
||||||
@@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then
|
|||||||
echo "Importing the project first (fresh copy)..."
|
echo "Importing the project first (fresh copy)..."
|
||||||
godot --headless --path . --import >/dev/null 2>&1
|
godot --headless --path . --import >/dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_den_sites test_fishing_diet test_net_lobby test_net_world test_terrain_edges test_boundary; do
|
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_den_sites test_fishing_diet test_net_lobby test_net_world test_net_shared test_terrain_edges test_boundary; do
|
||||||
extra=""
|
extra=""
|
||||||
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
|
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
|
||||||
# A suite that waits on a world that failed to load would hang forever,
|
# A suite that waits on a world that failed to load would hang forever,
|
||||||
|
|||||||
@@ -207,10 +207,13 @@ func _result(path: String) -> Dictionary:
|
|||||||
func _sleep(s: float) -> void:
|
func _sleep(s: float) -> void:
|
||||||
await create_timer(s).timeout
|
await create_timer(s).timeout
|
||||||
|
|
||||||
|
## Deletes a folder and everything in it (a server's folder has its world in it).
|
||||||
func _rmdir(d: String) -> void:
|
func _rmdir(d: String) -> void:
|
||||||
var da := DirAccess.open(d)
|
var da := DirAccess.open(d)
|
||||||
if da == null:
|
if da == null:
|
||||||
return
|
return
|
||||||
|
for sub in da.get_directories():
|
||||||
|
_rmdir(d.path_join(sub))
|
||||||
for f in da.get_files():
|
for f in da.get_files():
|
||||||
da.remove(f)
|
da.remove(f)
|
||||||
DirAccess.remove_absolute(d)
|
DirAccess.remove_absolute(d)
|
||||||
|
|||||||
494
tests/test_net_shared.gd
Normal file
494
tests/test_net_shared.gd
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## M2.3: the shared world. Two players on a dedicated server see the same
|
||||||
|
## island change: a tree one of them fells (and its regrowth), pickups and
|
||||||
|
## dropped items (first come, first served), building pieces (and walking
|
||||||
|
## into them, for real and by cheating), doors, chests, fires, dismantling,
|
||||||
|
## and the whole lot surviving a server restart.
|
||||||
|
##
|
||||||
|
## A real headless server, plus "Bob" (tests/net_world_bot.gd) as another
|
||||||
|
## process with his own world, following this test's commands. This test is
|
||||||
|
## "Alice" with the real world scene.
|
||||||
|
## Run: godot --headless --path . --script res://tests/test_net_shared.gd
|
||||||
|
|
||||||
|
const PORT := 24346
|
||||||
|
const DIR := "user://test_net"
|
||||||
|
const SRV := DIR + "/server5"
|
||||||
|
const BOB := DIR + "/bob_cmds"
|
||||||
|
const SEED := 777
|
||||||
|
const Bot = preload("res://tests/net_world_bot.gd")
|
||||||
|
|
||||||
|
var _fails := 0
|
||||||
|
var _finished := false
|
||||||
|
var _pids: Array[int] = []
|
||||||
|
var Net: Node
|
||||||
|
var WN: Node
|
||||||
|
var A: Node
|
||||||
|
var world: Node3D
|
||||||
|
var player: CharacterBody3D
|
||||||
|
var _cmd := 0
|
||||||
|
var _server_pid := 0
|
||||||
|
var _toasts: Array[String] = []
|
||||||
|
|
||||||
|
func check(cond: bool, what: String) -> void:
|
||||||
|
print((" ok " if cond else " FAIL ") + what)
|
||||||
|
if not cond:
|
||||||
|
_fails += 1
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run.call_deferred()
|
||||||
|
|
||||||
|
func _run() -> void:
|
||||||
|
Engine.max_fps = 60
|
||||||
|
Net = root.get_node("Net")
|
||||||
|
WN = root.get_node("WorldNet")
|
||||||
|
A = root.get_node("Actions")
|
||||||
|
A.notify.connect(func(t): _toasts.append(t))
|
||||||
|
for d in [SRV, BOB]:
|
||||||
|
_rm_tree(d)
|
||||||
|
DirAccess.make_dir_recursive_absolute(BOB)
|
||||||
|
_start_server()
|
||||||
|
await _sleep(4.0)
|
||||||
|
await _body()
|
||||||
|
Net.stop()
|
||||||
|
for pid in _pids:
|
||||||
|
if OS.is_process_running(pid):
|
||||||
|
OS.kill(pid)
|
||||||
|
check(_finished, "test ran to the end (no script errors)")
|
||||||
|
_rm_tree(SRV)
|
||||||
|
_rm_tree(BOB)
|
||||||
|
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
|
||||||
|
quit(1 if _fails > 0 else 0)
|
||||||
|
|
||||||
|
func _start_server() -> void:
|
||||||
|
# --debug-regrow: things grow back in seconds instead of half an hour.
|
||||||
|
_server_pid = _spawn(["--server", "--port", str(PORT), "--config", SRV + "/server.json",
|
||||||
|
"--server-name", "Shared Test", "--seed", str(SEED), "--debug-regrow", "0.002"])
|
||||||
|
|
||||||
|
func _body() -> void:
|
||||||
|
var me = load("res://scripts/net/identity.gd").open(DIR + "/a")
|
||||||
|
me.name = "Alice"
|
||||||
|
Net.identity = me
|
||||||
|
var r := await _join()
|
||||||
|
check(r == "", "Alice joins (%s)" % r)
|
||||||
|
if r != "":
|
||||||
|
return
|
||||||
|
await _load_world()
|
||||||
|
check(WN.received >= 1 and WN.world == world, "and gets the server's island")
|
||||||
|
|
||||||
|
print("Bob, with his own world")
|
||||||
|
_spawn(["--identity", DIR + "/bob", "--name", "Bob", "--address", "127.0.0.1:%d" % PORT, "--dir", BOB],
|
||||||
|
"res://tests/net_world_bot.gd")
|
||||||
|
var b0 := await _bob_result(0, 30.0)
|
||||||
|
check(bool(b0.get("ok", false)), "Bob joins and loads it too (%s)" % b0.get("why", "ok"))
|
||||||
|
if not bool(b0.get("ok", false)):
|
||||||
|
return
|
||||||
|
|
||||||
|
await _trees()
|
||||||
|
await _building()
|
||||||
|
await _containers_and_fires()
|
||||||
|
await _items_on_the_ground()
|
||||||
|
await _restart()
|
||||||
|
_finished = true
|
||||||
|
|
||||||
|
# --- trees --------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _trees() -> void:
|
||||||
|
print("felling a tree")
|
||||||
|
var tree := _nearest(world.props.resource_nodes.values().filter(func(n): return n.kind == "tree" and not n.depleted))
|
||||||
|
var uid: String = tree.uid
|
||||||
|
var to_tree: Vector3 = (tree.global_position - player.global_position)
|
||||||
|
to_tree.y = 0.0
|
||||||
|
await Bot.walk(player, tree.global_position - to_tree.normalized() * 1.6, world)
|
||||||
|
player.inventory.set_slot(0, {"id": "stone_axe", "count": 1})
|
||||||
|
player.select(0)
|
||||||
|
var wood0: int = player.inventory.count("wood")
|
||||||
|
var hits := 0
|
||||||
|
var t := 0.0
|
||||||
|
while not tree.depleted and t < 20.0:
|
||||||
|
player.survival.stamina = 100.0
|
||||||
|
if A.harvest_hit(player, tree, tree.global_position + Vector3(0, 1.0, 0)):
|
||||||
|
hits += 1
|
||||||
|
await _sleep(0.25)
|
||||||
|
t += 0.25
|
||||||
|
check(tree.depleted, "Alice fells it in %d hits (her world shows it when the server says so)" % hits)
|
||||||
|
await _sleep(0.3)
|
||||||
|
check(player.inventory.count("wood") > wood0, "and the server gives her the wood (%d)" % (player.inventory.count("wood") - wood0))
|
||||||
|
var rep := await _bob({"do": "report", "uids": [uid]})
|
||||||
|
var bn: Variant = rep.nodes.get(uid)
|
||||||
|
check(bn != null and bool(bn.depleted), "Bob sees it fall too")
|
||||||
|
|
||||||
|
print("regrowth (sped up for the test)")
|
||||||
|
await _sleep(6.0) # (trees take 4.8 s at this speed-up)
|
||||||
|
check(tree.depleted, "it doesn't grow back with Alice standing next to it")
|
||||||
|
var walk_from := Time.get_ticks_msec()
|
||||||
|
await Bot.walk(player, player.global_position + Vector3(14, 0, 0), world)
|
||||||
|
t = 0.0
|
||||||
|
while tree.depleted and t < 15.0:
|
||||||
|
await _sleep(0.2)
|
||||||
|
t += 0.2
|
||||||
|
check(not tree.depleted, "once she walks off, it grows back (%.1f s after she set off)" % ((Time.get_ticks_msec() - walk_from) / 1000.0))
|
||||||
|
rep = await _bob({"do": "report", "uids": [uid]})
|
||||||
|
bn = rep.nodes.get(uid)
|
||||||
|
check(bn != null and not bool(bn.depleted), "for Bob as well")
|
||||||
|
|
||||||
|
# --- building -----------------------------------------------------------------
|
||||||
|
|
||||||
|
var _wall: Node3D
|
||||||
|
var _door: Node3D
|
||||||
|
|
||||||
|
func _building() -> void:
|
||||||
|
print("building a wall")
|
||||||
|
var site: Vector3 = world.build.debug_find_site(player.global_position + Vector3(0, 0, 10), 3.0)
|
||||||
|
await Bot.walk(player, site + Vector3(-3.5, 0, 0), world)
|
||||||
|
_wall = await _place("wood_wall", site, 0.0)
|
||||||
|
check(_wall != null, "Alice puts up a wall (the server agrees and tells everyone)")
|
||||||
|
if _wall == null:
|
||||||
|
return
|
||||||
|
var rep := await _bob({"do": "report"})
|
||||||
|
var seen := _find(rep, "wood_wall")
|
||||||
|
check(not seen.is_empty() and _p(seen.pos).distance_to(_wall.global_position) < 0.01, "Bob has the same wall, in the same place")
|
||||||
|
|
||||||
|
print("Bob walks into it")
|
||||||
|
var n: Vector3 = _wall.global_basis.z.normalized()
|
||||||
|
var base := _wall.global_position
|
||||||
|
await _bob({"do": "walk", "to": _v(base + n * 2.5)})
|
||||||
|
var bump := await _bob({"do": "bump", "dir": _v(-n), "dist": 4.0})
|
||||||
|
check(float(bump.get("moved", 9.0)) < 2.3, "and it stops him (moved %.2f of 4 m)" % float(bump.get("moved", 9.0)))
|
||||||
|
var hop := await _bob({"do": "hop", "to": _v(base - n * 0.6 + Vector3(0, 0.05, 0))})
|
||||||
|
var hp: Vector3 = _p(hop.get("pos", [0, 0, 0]))
|
||||||
|
check(int(hop.get("corrected", 0)) >= 1 and (hp - base).dot(n) > 0.0,
|
||||||
|
"a game that steps him through anyway is refused by the server, and he's put back on his side")
|
||||||
|
await _dismantle()
|
||||||
|
|
||||||
|
print("a door")
|
||||||
|
var dsite: Vector3 = world.build.debug_find_site(site + Vector3(0, 0, 8), 2.0)
|
||||||
|
await Bot.walk(player, dsite + Vector3(-2.5, 0, 0), world)
|
||||||
|
_door = await _place("wood_door", dsite, 0.0)
|
||||||
|
check(_door != null, "Alice puts up a door")
|
||||||
|
if _door == null:
|
||||||
|
return
|
||||||
|
A.toggle_door(player, _door)
|
||||||
|
await _sleep(0.6)
|
||||||
|
check(_door.open, "she opens it")
|
||||||
|
rep = await _bob({"do": "report"})
|
||||||
|
seen = _find(rep, "wood_door")
|
||||||
|
check(not seen.is_empty() and bool(seen.get("open", false)), "and it's open for Bob")
|
||||||
|
|
||||||
|
## Places `id` on the ground at `at` through Actions, the way the game does.
|
||||||
|
func _place(id: String, at: Vector3, yaw: float) -> Node3D:
|
||||||
|
var def: Dictionary = root.get_node("ItemDB").get_item(id)
|
||||||
|
var xf := Transform3D(Basis(Vector3.UP, yaw), Vector3(at.x, world.terrain.sample_height(at.x, at.z), at.z))
|
||||||
|
if def.place.has("kind"):
|
||||||
|
var hit := {"position": xf.origin, "normal": Vector3.UP, "collider": world.terrain.get_node("TerrainBody")}
|
||||||
|
var res: Dictionary = world.build.placement(def, hit, yaw)
|
||||||
|
if not res.ok:
|
||||||
|
print(" (can't place %s: %s)" % [id, res.reason])
|
||||||
|
return null
|
||||||
|
xf = res.xf
|
||||||
|
player.inventory.set_slot(5, {"id": id, "count": 1})
|
||||||
|
var before: Array = WN.pieces.keys()
|
||||||
|
if not A.place(player, 5, xf, world):
|
||||||
|
print(" (Actions.place refused %s)" % id)
|
||||||
|
return null
|
||||||
|
check(player.inventory.get_slot(5) == null, "(%s taken from Alice's bag)" % id)
|
||||||
|
var t := 0.0
|
||||||
|
while t < 3.0:
|
||||||
|
for k in WN.pieces:
|
||||||
|
if not k in before and WN.pieces[k].item_id == id:
|
||||||
|
return WN.pieces[k]
|
||||||
|
await _sleep(0.05)
|
||||||
|
t += 0.05
|
||||||
|
return null
|
||||||
|
|
||||||
|
# --- chests and fires ------------------------------------------------------------
|
||||||
|
|
||||||
|
var _chest: Node3D
|
||||||
|
var _fire: Node3D
|
||||||
|
|
||||||
|
func _containers_and_fires() -> void:
|
||||||
|
print("a shared chest")
|
||||||
|
var site: Vector3 = world.build.debug_find_site(player.global_position + Vector3(6, 0, 0), 1.5)
|
||||||
|
await Bot.walk(player, site + Vector3(-2.0, 0, 0), world)
|
||||||
|
_chest = await _place("chest", site, 0.0)
|
||||||
|
check(_chest != null, "Alice places a chest")
|
||||||
|
if _chest == null:
|
||||||
|
return
|
||||||
|
player.inventory.set_slot(6, {"id": "wood", "count": 10})
|
||||||
|
A.transfer(player, player.inventory, 6, _chest.inventory, _chest)
|
||||||
|
await _sleep(0.5)
|
||||||
|
check(player.inventory.get_slot(6) == null and _chest.inventory.count("wood") == 10, "she puts 10 wood in")
|
||||||
|
var got := {"c": "waiting"}
|
||||||
|
WN.chest_click(_chest, 3, {"id": "stone", "count": 3}, false, func(c): got.c = c)
|
||||||
|
var t := 0.0
|
||||||
|
while got.c is String and t < 3.0:
|
||||||
|
await _sleep(0.05)
|
||||||
|
t += 0.05
|
||||||
|
check(got.c == null and _chest.inventory.get_slot(3) != null and _chest.inventory.get_slot(3).id == "stone",
|
||||||
|
"and drops 3 stone in slot 4 by hand (the server does the click and hands back an empty cursor)")
|
||||||
|
var nid: int = WN.nid(_chest)
|
||||||
|
await _bob({"do": "walk", "to": _v(site + Vector3(1.8, 0, 0))})
|
||||||
|
var rep := await _bob({"do": "report"})
|
||||||
|
var seen: Dictionary = rep.pieces.get(str(nid), {})
|
||||||
|
check(_count(seen.get("inv", []), "wood") == 10 and _count(seen.get("inv", []), "stone") == 3, "Bob sees what's in it")
|
||||||
|
var slot := -1
|
||||||
|
for i in range(_chest.inventory.size()):
|
||||||
|
var s = _chest.inventory.get_slot(i)
|
||||||
|
if s != null and s.id == "wood":
|
||||||
|
slot = i
|
||||||
|
var took := await _bob({"do": "chest_take", "nid": nid, "slot": slot})
|
||||||
|
check(_count(took.get("inv", []), "wood") == 10, "Bob takes the wood out (shift-click)")
|
||||||
|
await _sleep(0.3)
|
||||||
|
check(_chest.inventory.count("wood") == 0 and _chest.inventory.count("stone") == 3, "and it's gone from Alice's view of the chest")
|
||||||
|
|
||||||
|
print("a fire")
|
||||||
|
var fsite: Vector3 = world.build.debug_find_site(site + Vector3(0, 0, -5), 1.5)
|
||||||
|
await Bot.walk(player, fsite + Vector3(-2.0, 0, 0), world)
|
||||||
|
_fire = await _place("campfire", fsite, 0.0)
|
||||||
|
check(_fire != null, "Alice lights a campfire")
|
||||||
|
if _fire == null:
|
||||||
|
return
|
||||||
|
var f0: float = _fire.fuel
|
||||||
|
player.inventory.set_slot(7, {"id": "wood", "count": 2})
|
||||||
|
A.add_fuel(player, _fire)
|
||||||
|
await _sleep(0.5)
|
||||||
|
check(_fire.fuel > f0 + 30.0, "and feeds it a log (%.0f s -> %.0f s of fuel)" % [f0, _fire.fuel])
|
||||||
|
rep = await _bob({"do": "report"})
|
||||||
|
seen = rep.pieces.get(str(WN.nid(_fire)), {})
|
||||||
|
check(absf(float(seen.get("fuel", -100.0)) - _fire.fuel) < 5.0, "Bob's fire has the same fuel (%.0f s)" % float(seen.get("fuel", -1.0)))
|
||||||
|
|
||||||
|
# --- things on the ground --------------------------------------------------------
|
||||||
|
|
||||||
|
func _items_on_the_ground() -> void:
|
||||||
|
print("dropped items")
|
||||||
|
var at := player.global_position + Vector3(1.0, 0.1, 0)
|
||||||
|
world.drop_item("flint", 2, Transform3D(Basis(), at))
|
||||||
|
var t := 0.0
|
||||||
|
var key := ""
|
||||||
|
while key.is_empty() and t < 3.0:
|
||||||
|
for k in WN.dropped:
|
||||||
|
if WN.dropped[k].item_id == "flint":
|
||||||
|
key = "#%d" % k
|
||||||
|
await _sleep(0.05)
|
||||||
|
t += 0.05
|
||||||
|
check(not key.is_empty(), "Alice drops 2 flint: it's in the shared world")
|
||||||
|
var rep := await _bob({"do": "report"})
|
||||||
|
check(rep.dropped.any(func(d): return d.item == "flint" and int(d.count) == 2), "Bob sees it")
|
||||||
|
await _bob({"do": "walk", "to": _v(at + Vector3(1.0, 0, 0))})
|
||||||
|
var took := await _bob({"do": "take", "key": key})
|
||||||
|
check(_count(took.get("inv", []), "flint") == 2, "Bob picks it up")
|
||||||
|
await _sleep(0.3)
|
||||||
|
check(WN.dropped.is_empty() or not WN.dropped.has(int(key.substr(1))), "and it's gone for Alice")
|
||||||
|
|
||||||
|
print("first come, first served")
|
||||||
|
var pk := _nearest(world.props.pickups.values().filter(func(p): return not p.taken))
|
||||||
|
await Bot.walk(player, pk.global_position + Vector3(-1.0, 0, 0), world)
|
||||||
|
await _bob({"do": "walk", "to": _v(pk.global_position + Vector3(1.0, 0, 0))})
|
||||||
|
var id: String = pk.item_id
|
||||||
|
var a0: int = player.inventory.count(id)
|
||||||
|
_bob_send({"do": "take", "key": pk.uid}) # both reach for it at once
|
||||||
|
A.pickup(player, pk)
|
||||||
|
var br := await _bob_wait(_cmd)
|
||||||
|
await _sleep(0.4)
|
||||||
|
var a_got: int = player.inventory.count(id) - a0
|
||||||
|
var b_got: int = _count(br.get("inv", []), id) - (2 if id == "flint" else 0)
|
||||||
|
check(a_got + b_got == pk.amount and (a_got == 0 or b_got == 0),
|
||||||
|
"only one of them gets the %s (Alice %d, Bob %d)" % [id, a_got, b_got])
|
||||||
|
|
||||||
|
print("dying online")
|
||||||
|
player.inventory.load_data(Inventory.new(player.inventory.size()).to_data())
|
||||||
|
player.inventory.set_slot(0, {"id": "stone", "count": 7})
|
||||||
|
player.inventory.set_slot(1, {"id": "berries", "count": 4})
|
||||||
|
var died_at := player.global_position
|
||||||
|
world._on_player_died()
|
||||||
|
var grave: Node3D = null
|
||||||
|
var t2 := 0.0
|
||||||
|
while grave == null and t2 < 3.0:
|
||||||
|
for p in WN.pieces.values():
|
||||||
|
if p.item_id == "grave":
|
||||||
|
grave = p
|
||||||
|
await _sleep(0.05)
|
||||||
|
t2 += 0.05
|
||||||
|
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")
|
||||||
|
if grave == null:
|
||||||
|
return
|
||||||
|
var gnid: int = WN.nid(grave)
|
||||||
|
var rep2 := await _bob({"do": "report"})
|
||||||
|
var g: Dictionary = rep2.pieces.get(str(gnid), {})
|
||||||
|
check(_count(g.get("inv", []), "stone") == 7 and _count(g.get("inv", []), "berries") == 4, "Bob sees the grave, with what she had")
|
||||||
|
await Bot.walk(player, grave.global_position + Vector3(1.5, 0, 0), world)
|
||||||
|
for i in range(grave.inventory.size()):
|
||||||
|
if is_instance_valid(grave) and grave.inventory.get_slot(i) != null:
|
||||||
|
A.transfer(player, grave.inventory, i, player.inventory, grave)
|
||||||
|
await _sleep(0.15)
|
||||||
|
await _sleep(0.5)
|
||||||
|
check(player.inventory.count("stone") == 7 and player.inventory.count("berries") == 4, "she takes it all back")
|
||||||
|
check(not WN.pieces.has(gnid), "and the empty grave crumbles")
|
||||||
|
rep2 = await _bob({"do": "report"})
|
||||||
|
check(not rep2.pieces.has(str(gnid)), "for Bob too")
|
||||||
|
|
||||||
|
# --- dismantling ---------------------------------------------------------------
|
||||||
|
|
||||||
|
func _dismantle() -> void:
|
||||||
|
if _wall == null:
|
||||||
|
return
|
||||||
|
print("taking the wall down")
|
||||||
|
# (Straight after building it, while she's still beside it: a test walk
|
||||||
|
# back from somewhere else can cut through the door frame, and the
|
||||||
|
# server rightly sends her back.)
|
||||||
|
var nid: int = WN.nid(_wall)
|
||||||
|
var w0: int = player.inventory.count("wood_wall")
|
||||||
|
_toasts.clear()
|
||||||
|
var ok: bool = A.dismantle(player, _wall)
|
||||||
|
check(ok, "Alice dismantles the wall (%.1f m away%s)" % [player.global_position.distance_to(_wall.global_position),
|
||||||
|
"" if ok else ", refused: %s" % [_toasts]])
|
||||||
|
await _sleep(0.5)
|
||||||
|
check(not WN.pieces.has(nid) and player.inventory.count("wood_wall") == w0 + 1, "it's gone, and back in her bag")
|
||||||
|
var rep := await _bob({"do": "report"})
|
||||||
|
check(not rep.pieces.has(str(nid)), "gone for Bob too")
|
||||||
|
|
||||||
|
# --- persistence -------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _restart() -> void:
|
||||||
|
print("the server restarts")
|
||||||
|
var expect := {}
|
||||||
|
for k in WN.pieces:
|
||||||
|
expect[k] = WN.pieces[k].item_id
|
||||||
|
var chest_nid: int = WN.nid(_chest) if _chest != null else -1
|
||||||
|
# Killed outright while everyone's still on (like Ctrl+C, which gives
|
||||||
|
# Godot no chance to save): the server saved within a few seconds of the
|
||||||
|
# last change anyway.
|
||||||
|
await _sleep(WN.SAVE_AFTER + 1.5)
|
||||||
|
check(FileAccess.file_exists(SRV + "/worlds/server-world/world.json"), "the server saved the island soon after the last change")
|
||||||
|
OS.kill(_server_pid)
|
||||||
|
_pids.erase(_server_pid)
|
||||||
|
await _bob({"do": "leave"})
|
||||||
|
world.queue_free()
|
||||||
|
await process_frame
|
||||||
|
Net.leave()
|
||||||
|
await _sleep(1.0)
|
||||||
|
_start_server()
|
||||||
|
await _sleep(4.0)
|
||||||
|
var r := await _join()
|
||||||
|
check(r == "", "Alice rejoins (%s)" % r)
|
||||||
|
if r != "":
|
||||||
|
return
|
||||||
|
await _load_world()
|
||||||
|
var same: bool = expect.size() == WN.pieces.size()
|
||||||
|
for k in expect:
|
||||||
|
same = same and WN.pieces.has(k) and WN.pieces[k].item_id == expect[k]
|
||||||
|
check(same, "everything built is still there, with the same ids (%d pieces)" % WN.pieces.size())
|
||||||
|
var c: Node3D = WN.pieces.get(chest_nid)
|
||||||
|
check(c != null and c.inventory.count("stone") == 3 and c.inventory.count("wood") == 0, "the chest still has the 3 stone")
|
||||||
|
var d: Node3D = null
|
||||||
|
for p in WN.pieces.values():
|
||||||
|
if p.item_id == "wood_door":
|
||||||
|
d = p
|
||||||
|
check(d != null and d.open, "and the door is still open")
|
||||||
|
world.queue_free()
|
||||||
|
await process_frame
|
||||||
|
Net.leave()
|
||||||
|
|
||||||
|
# --- helpers --------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _load_world() -> void:
|
||||||
|
world = load("res://scenes/world.tscn").instantiate()
|
||||||
|
root.add_child(world)
|
||||||
|
for _i in range(8):
|
||||||
|
await physics_frame
|
||||||
|
player = world.player
|
||||||
|
player.set_physics_process(false) # the test moves Alice
|
||||||
|
var t := 0.0
|
||||||
|
while WN.received < 1 and t < 10.0:
|
||||||
|
await _sleep(0.05)
|
||||||
|
t += 0.05
|
||||||
|
|
||||||
|
func _nearest(nodes: Array) -> Node3D:
|
||||||
|
var best: Node3D = null
|
||||||
|
for n in nodes:
|
||||||
|
if best == null or n.global_position.distance_to(player.global_position) < best.global_position.distance_to(player.global_position):
|
||||||
|
best = n
|
||||||
|
return best
|
||||||
|
|
||||||
|
func _find(rep: Dictionary, item: String) -> Dictionary:
|
||||||
|
for k in rep.get("pieces", {}):
|
||||||
|
if rep.pieces[k].item == item:
|
||||||
|
return rep.pieces[k]
|
||||||
|
return {}
|
||||||
|
|
||||||
|
static func _count(inv: Array, id: String) -> int:
|
||||||
|
var n := 0
|
||||||
|
for s in inv:
|
||||||
|
if s != null and s.id == id:
|
||||||
|
n += int(s.count)
|
||||||
|
return n
|
||||||
|
|
||||||
|
static func _v(p: Vector3) -> Array:
|
||||||
|
return [p.x, p.y, p.z]
|
||||||
|
|
||||||
|
static func _p(a: Array) -> Vector3:
|
||||||
|
return Vector3(a[0], a[1], a[2])
|
||||||
|
|
||||||
|
func _bob_send(cmd: Dictionary) -> void:
|
||||||
|
_cmd += 1
|
||||||
|
var f := FileAccess.open("%s/cmd_%d.json" % [BOB, _cmd], FileAccess.WRITE)
|
||||||
|
f.store_string(JSON.stringify(cmd))
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
func _bob(cmd: Dictionary) -> Dictionary:
|
||||||
|
_bob_send(cmd)
|
||||||
|
return await _bob_wait(_cmd)
|
||||||
|
|
||||||
|
func _bob_wait(n: int) -> Dictionary:
|
||||||
|
return await _bob_result(n, 20.0)
|
||||||
|
|
||||||
|
func _bob_result(n: int, limit: float) -> Dictionary:
|
||||||
|
var path := "%s/res_%d.json" % [BOB, n]
|
||||||
|
var t := 0.0
|
||||||
|
while not FileAccess.file_exists(path) and t < limit:
|
||||||
|
await _sleep(0.05)
|
||||||
|
t += 0.05
|
||||||
|
await _sleep(0.05)
|
||||||
|
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) if FileAccess.file_exists(path) else null
|
||||||
|
return d if d is Dictionary else {"error": "Bob gave no answer to command %d" % n, "nodes": {}, "pieces": {}, "dropped": []}
|
||||||
|
|
||||||
|
func _join() -> String:
|
||||||
|
var state := {"done": false, "reason": ""}
|
||||||
|
var ok := func(): state.done = true
|
||||||
|
var bad := func(why): state.done = true; state.reason = why
|
||||||
|
Net.joined.connect(ok)
|
||||||
|
Net.join_failed.connect(bad)
|
||||||
|
Net.join("127.0.0.1:%d" % PORT)
|
||||||
|
var t := 0.0
|
||||||
|
while not state.done and t < 25.0:
|
||||||
|
await _sleep(0.05)
|
||||||
|
t += 0.05
|
||||||
|
Net.joined.disconnect(ok)
|
||||||
|
Net.join_failed.disconnect(bad)
|
||||||
|
return state.reason if state.done else "test timed out"
|
||||||
|
|
||||||
|
func _spawn(user_args: Array, script: String = "") -> int:
|
||||||
|
var args := PackedStringArray(["--headless", "--path", ProjectSettings.globalize_path("res://")])
|
||||||
|
if not script.is_empty():
|
||||||
|
args.append_array(["--script", script])
|
||||||
|
args.append("--")
|
||||||
|
for x in user_args:
|
||||||
|
args.append(str(x))
|
||||||
|
args.append_array(["--exit-with", str(OS.get_process_id())])
|
||||||
|
var pid := OS.create_process(OS.get_executable_path(), args)
|
||||||
|
_pids.append(pid)
|
||||||
|
return pid
|
||||||
|
|
||||||
|
func _sleep(s: float) -> void:
|
||||||
|
await create_timer(s).timeout
|
||||||
|
|
||||||
|
## Deletes a folder and everything in it.
|
||||||
|
static func _rm_tree(d: String) -> void:
|
||||||
|
var da := DirAccess.open(d)
|
||||||
|
if da == null:
|
||||||
|
return
|
||||||
|
for sub in da.get_directories():
|
||||||
|
_rm_tree(d.path_join(sub))
|
||||||
|
for f in da.get_files():
|
||||||
|
da.remove(f)
|
||||||
|
DirAccess.remove_absolute(d)
|
||||||
@@ -252,10 +252,13 @@ func _result(path: String) -> Dictionary:
|
|||||||
func _sleep(s: float) -> void:
|
func _sleep(s: float) -> void:
|
||||||
await create_timer(s).timeout
|
await create_timer(s).timeout
|
||||||
|
|
||||||
|
## Deletes a folder and everything in it (a server's folder has its world in it).
|
||||||
func _rmdir(d: String) -> void:
|
func _rmdir(d: String) -> void:
|
||||||
var da := DirAccess.open(d)
|
var da := DirAccess.open(d)
|
||||||
if da == null:
|
if da == null:
|
||||||
return
|
return
|
||||||
|
for sub in da.get_directories():
|
||||||
|
_rmdir(d.path_join(sub))
|
||||||
for f in da.get_files():
|
for f in da.get_files():
|
||||||
da.remove(f)
|
da.remove(f)
|
||||||
DirAccess.remove_absolute(d)
|
DirAccess.remove_absolute(d)
|
||||||
|
|||||||
Reference in New Issue
Block a user