M2.5: wildlife, wolves, hunting, fishing and sleeping on the server

This commit is contained in:
Paul Bailey
2026-11-04 21:25:10 +00:00
parent 26be17b05f
commit a211fe4c63
20 changed files with 1258 additions and 88 deletions

View File

@@ -226,10 +226,39 @@ offline there's no server and your game decides, exactly as before.
- Characters are saved in `worlds/<world>/players/<player id>.json`, with - Characters are saved in `worlds/<world>/players/<player id>.json`, with
the world, so you come back where you left off. the world, so you come back where you left off.
Until later phases, while online: animals, hunting and fishing are off **Wildlife, hunting, fishing and sleep** *(built in M2.5: `creature_net.gd`,
(M2.5), and so is sleeping (it needs the sleep rule, M2.5). The HUD says so. autoload CreatureNet)*.
The server doesn't yet check that you're out of stamina when you sprint - The server's world runs the animals and the wolf pack. Animals live
(it charges you, but doesn't stop you). around every player (the budget per player, up to twice it however many
are on), and go once they're far from all of them. A player joining gets
some wildlife about them straight away.
- **The pack splits between the players it hunts**: wolf 1 takes the
nearest to the den, wolf 2 the next, preferring players away from a fire.
Each hunted player has their own circle and their own one-wolf-at-a-time
darts. Fire, torches and walls work per player as in single-player.
- Ten times a second everyone gets every animal, wolf and carcass (23 bytes
each, split so no packet goes over the MTU: an oversized unreliable packet
is simply lost) and every fishing float. Games show them as **puppets**
(`creature.gd`/`bobber.gd` `puppet`) that animate and can be hit or
butchered, and decide nothing. Something missing for a second goes.
- **Hitting** (club, spear thrust) sends the creature's id; the server runs
Actions.melee on your character there. **Shooting and throwing** send only
the aim: the arrow or spear starts from where the server knows your eyes
are. The server's projectile hits; everyone else's screens fly a copy for
show (sent once, when it's loosed). An arrow that misses lands as a
dropped item for anyone.
- **Fishing**: one float per player, on the server. Bites, misses and a
snapped line are told to the player whose float it is (`Actions.tell`).
- **Sleeping** follows `sleep` in `server.json`: `all_online` (default),
`half` or `anyone`. Getting into a bed you've claimed puts you "in bed"
(moving more than 1.5 m gets you out); once enough players are in bed
the night passes for everyone (fires burn down, things regrow, the clock
jumps to 06:00) and sleepers wake rested.
- The pack's howls and "the wolves are hunting tonight" go to everyone;
being bitten or gored flashes your screen red as in single-player.
Still to come: the server doesn't yet stop you sprinting when you're out of
stamina (it charges you, but doesn't stop you).
**Interest.** On a 320 m worldlet with 8 players, the server sends every **Interest.** On a 320 m worldlet with 8 players, the server sends every
entity to everyone at first. Distance-based interest is a later entity to everyone at first. Distance-based interest is a later
@@ -303,7 +332,7 @@ player" or "this player":
| **M2.2** *(done)* | player bodies and name tags; predicted movement, interpolation for others | two players walk around together at 100 ms simulated latency without visible rubber-banding | | **M2.2** *(done)* | player bodies and name tags; predicted movement, interpolation for others | two players walk around together at 100 ms simulated latency without visible rubber-banding |
| **M2.3** *(done)* | world state replicated: resource nodes, pickups, pieces, chests, fires, doors, clock, weather | one chops a tree and the other sees it fall; one builds a wall and the other walks into it | | **M2.3** *(done)* | world state replicated: resource nodes, pickups, pieces, chests, fires, doors, clock, weather | one chops a tree and the other sees it fall; one builds a wall and the other walks into it |
| **M2.4** *(done)* | intents as RPCs; per-player inventory, stats, diet, death and graves; per-player saves and migration | two players craft, eat, die and loot their own graves; an old single-player save loads | | **M2.4** *(done)* | intents as RPCs; per-player inventory, stats, diet, death and graves; per-player saves and migration | two players craft, eat, die and loot their own graves; an old single-player save loads |
| **M2.5** | wildlife and wolves server-side, projectiles, fishing, sleeping with the rule | a pack hunts two players at once; arrows hit on both screens | | **M2.5** *(done)* | wildlife and wolves server-side, projectiles, fishing, sleeping with the rule | a pack hunts two players at once; arrows hit on both screens |
| **M2.6** | server rules: PvP, raiding, locks and friends, death penalty, difficulty; admin console and chat | each preset (co-op, rivals, adversarial) behaves as described in §4 | | **M2.6** | server rules: PvP, raiding, locks and friends, death penalty, difficulty; admin console and chat | each preset (co-op, rivals, adversarial) behaves as described in §4 |
| **M2.7** | hosting polish: "Host game" button, systemd and launchd samples, backups, a Tailscale how-to in the docs | Paul runs it on the home server, and a friend joins over Tailscale and plays for an hour | | **M2.7** | hosting polish: "Host game" button, systemd and launchd samples, backups, a Tailscale how-to in the docs | Paul runs it on the home server, and a friend joins over Tailscale and plays for an hour |

View File

@@ -57,7 +57,7 @@ phases M2.0-M2.7).
- [x] Replicate resource nodes, pickups, building, chests, fires, doors, clock *(M2.3)* - [x] Replicate resource nodes, pickups, building, chests, fires, doors, clock *(M2.3)*
- [x] Persistence: world save/load on the server *(M2.3)* - [x] Persistence: world save/load on the server *(M2.3)*
- [x] Per-player inventory, stats, death, graves and saves on the server *(M2.4)* - [x] Per-player inventory, stats, death, graves and saves on the server *(M2.4)*
- [ ] Animals, hunting, fishing and sleeping on the server *(M2.5)* - [x] Animals, wolves, hunting, fishing and sleeping on the server *(M2.5)*
- **Done when:** a friend joins your self-hosted server and you build together. - **Done when:** a friend joins your self-hosted server and you build together.
## M3 — Addon SDK + sandbox · ~2–3 months ## M3 — Addon SDK + sandbox · ~2–3 months

View File

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

View File

@@ -65,11 +65,25 @@ func _ask(what: String, args: Array = []) -> bool:
PlayerNet.ask(what, args) PlayerNet.ask(what, args)
return true return true
const NOT_ONLINE := "Hunting and fishing aren't on servers yet (they come with the animals)" ## A message for one player: on screen in single-player; on a server, to
## that player's game (player_net.gd). For things that happen later than the
## request that caused them (a fish biting, an arrow landing a kill).
func tell(player: Node, text: String) -> void:
if Net.is_server() and player != null and player.get("peer") != null:
PlayerNet.tell(int(player.peer), text)
else:
notify.emit(text)
func _not_online() -> bool: ## A message for everyone (the wolves howling).
notify.emit(NOT_ONLINE) func tell_all(text: String) -> void:
return false if Net.is_server():
PlayerNet.tell_all(text)
else:
notify.emit(text)
## Online, a creature is known by its net id (creature_net.gd).
static func _cid(c: Node) -> int:
return int(c.get_meta("cid", 0))
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
@@ -251,7 +265,12 @@ func melee_stats(player: Node) -> Dictionary:
## One melee blow at an animal. ## One melee blow at an animal.
func melee(player: Node3D, target: Node3D) -> bool: func melee(player: Node3D, target: Node3D) -> bool:
if _online(): if _online():
return _not_online() if not is_instance_valid(target) or not target.is_in_group("creature"):
return false
if not melee_stats(player).armed:
notify.emit(NEED_WEAPON)
return false
return _ask("melee", [_cid(target)])
if not is_instance_valid(target) or not target.is_in_group("creature"): if not is_instance_valid(target) or not target.is_in_group("creature"):
return false return false
var m := melee_stats(player) var m := melee_stats(player)
@@ -275,12 +294,12 @@ func projectile_hit(shooter: Node3D, target: Node3D, damage: float, at: Vector3,
var was_alive: bool = not target.is_dead() var was_alive: bool = not target.is_dead()
target.take_hit(damage, shooter.global_position if shooter != null else at, embed_id) target.take_hit(damage, shooter.global_position if shooter != null else at, embed_id)
if was_alive and target.is_dead(): if was_alive and target.is_dead():
notify.emit("Killed a %s" % target.display_name().to_lower()) tell(shooter, "Killed a %s" % target.display_name().to_lower())
## Throws the spear in `slot` from `origin` along `dir`. ## Throws the spear in `slot` from `origin` along `dir`.
func throw_spear(player: Node3D, slot: int, origin: Vector3, dir: Vector3, world: Node3D) -> bool: func throw_spear(player: Node3D, slot: int, origin: Vector3, dir: Vector3, world: Node3D) -> bool:
if _online(): if _online():
return _not_online() return _ask("throw", [slot, dir])
var s = player.inventory.get_slot(slot) var s = player.inventory.get_slot(slot)
if s == null: if s == null:
return false return false
@@ -298,9 +317,15 @@ func throw_spear(player: Node3D, slot: int, origin: Vector3, dir: Vector3, world
## Starts drawing the held bow: needs arrows and a little stamina. ## Starts drawing the held bow: needs arrows and a little stamina.
func can_draw(player: Node3D) -> bool: func can_draw(player: Node3D) -> bool:
if _online():
return _not_online()
var bow: Dictionary = ItemDB.get_item(player.held_item_id()).get("bow", {}) var bow: Dictionary = ItemDB.get_item(player.held_item_id()).get("bow", {})
if _online():
# (The server checks arrows and spends the stamina when you shoot.)
if bow.is_empty():
return false
if player.inventory.count(bow.ammo) <= 0:
notify.emit("No arrows (craft them at a workbench: sticks, flint, feathers)")
return false
return true
if bow.is_empty(): if bow.is_empty():
return false return false
if player.inventory.count(bow.ammo) <= 0: if player.inventory.count(bow.ammo) <= 0:
@@ -314,7 +339,7 @@ func can_draw(player: Node3D) -> bool:
## Looses an arrow. `power` 0..1 is how far the bow was drawn. ## Looses an arrow. `power` 0..1 is how far the bow was drawn.
func shoot(player: Node3D, origin: Vector3, dir: Vector3, power: float, world: Node3D) -> bool: func shoot(player: Node3D, origin: Vector3, dir: Vector3, power: float, world: Node3D) -> bool:
if _online(): if _online():
return _not_online() return _ask("shoot", [dir, power])
var bow: Dictionary = ItemDB.get_item(player.held_item_id()).get("bow", {}) var bow: Dictionary = ItemDB.get_item(player.held_item_id()).get("bow", {})
if bow.is_empty() or player.global_position.distance_to(origin) > 2.5: if bow.is_empty() or player.global_position.distance_to(origin) > 2.5:
return false return false
@@ -328,7 +353,9 @@ func shoot(player: Node3D, origin: Vector3, dir: Vector3, power: float, world: N
func butcher(player: Node3D, carcass: Node3D) -> bool: func butcher(player: Node3D, carcass: Node3D) -> bool:
if _online(): if _online():
return _not_online() if not is_instance_valid(carcass) or not carcass.is_in_group("carcass"):
return false
return _ask("butcher", [_cid(carcass)])
if not is_instance_valid(carcass) or not carcass.is_in_group("carcass") or not _in_reach(player, carcass, 1.0): if not is_instance_valid(carcass) or not carcass.is_in_group("carcass") or not _in_reach(player, carcass, 1.0):
return false return false
var drops: Dictionary = carcass.butcher_drops() var drops: Dictionary = carcass.butcher_drops()
@@ -346,7 +373,9 @@ func butcher(player: Node3D, carcass: Node3D) -> bool:
## Casts the held rod from `origin` along `dir`. ## Casts the held rod from `origin` along `dir`.
func cast_line(player: Node3D, origin: Vector3, dir: Vector3, world: Node3D) -> bool: func cast_line(player: Node3D, origin: Vector3, dir: Vector3, world: Node3D) -> bool:
if _online(): if _online():
return _not_online() if ItemDB.get_item(player.held_item_id()).get("fishing", {}).is_empty():
return false
return _ask("cast", [dir])
var rod: Dictionary = ItemDB.get_item(player.held_item_id()).get("fishing", {}) var rod: Dictionary = ItemDB.get_item(player.held_item_id()).get("fishing", {})
if rod.is_empty() or player.global_position.distance_to(origin) > 2.5: if rod.is_empty() or player.global_position.distance_to(origin) > 2.5:
return false return false
@@ -361,14 +390,14 @@ func cast_line(player: Node3D, origin: Vector3, dir: Vector3, world: Node3D) ->
## Reels in. With a fish biting you land it; otherwise the line just comes in. ## Reels in. With a fish biting you land it; otherwise the line just comes in.
func reel(player: Node3D, world: Node3D) -> bool: func reel(player: Node3D, world: Node3D) -> bool:
if _online(): if _online():
return _not_online() return _ask("reel")
var b: Node3D = world.bobber var b: Node3D = world.bobber_of(player)
if b == null or not is_instance_valid(b): if b == null or not is_instance_valid(b):
return false return false
var caught := "" var caught := ""
if b.state == "biting": if b.state == "biting":
caught = b.fish caught = b.fish
world.end_fishing() world.end_fishing(player)
if caught.is_empty(): if caught.is_empty():
return false return false
if _give(player, caught, 1) == 0: if _give(player, caught, 1) == 0:

View File

@@ -168,6 +168,9 @@ func step(dt: float) -> void:
_flash -= dt _flash -= dt
if _flash <= 0.0: if _flash <= 0.0:
_set_material(_mat) _set_material(_mat)
if puppet:
_puppet_step(dt)
return
if is_dead(): if is_dead():
carcass_left -= dt carcass_left -= dt
if carcass_left <= 0.0: if carcass_left <= 0.0:
@@ -175,10 +178,58 @@ func step(dt: float) -> void:
return return
_attack_cd = maxf(_attack_cd - dt, 0.0) _attack_cd = maxf(_attack_cd - dt, 0.0)
_aggro = maxf(_aggro - dt, 0.0) _aggro = maxf(_aggro - dt, 0.0)
_think(dt) # Who it's reacting to: the player (or on a server, the nearest one).
var t := _pick_target()
if t != null:
player = t
_think(dt)
elif state in ["flee", "charge"]:
state = "idle" # nobody about
_move(dt) _move(dt)
_animate(dt) _animate(dt)
func _pick_target() -> Node3D:
return world.nearest_player(global_position)
# --- on a client playing online: the server's animal, shown here ------------
## A puppet only shows what the server's copy is doing (creature_net.gd):
## no thinking, no damage dealt. It can still be hit (that's sent to the
## server) and butchered.
var puppet := false
var _net_pos := Vector3.ZERO
var _net_yaw := 0.0
func net_update(pos: Vector3, y: float, st: String, spd: float, new_hp: float) -> void:
if not puppet:
return
_net_pos = pos
_net_yaw = y
speed = spd
if new_hp < hp - 0.01 and st != "dead":
_flash = 0.15
_set_material(_hit_mat)
_burst()
hp = new_hp
if st == "dead" and state != "dead":
_burst()
_die(global_position + global_basis.z)
return
if state != "dead":
state = st
func _puppet_step(dt: float) -> void:
if is_dead():
return
var k := 1.0 - exp(-12.0 * dt)
var p := global_position.lerp(_net_pos, k)
if p.distance_to(_net_pos) > 6.0:
p = _net_pos # (a jump: a wolf slipping home to the den)
global_position = p
yaw = lerp_angle(yaw, _net_yaw, k)
rotation.y = yaw
_animate(dt)
## Whether the player is close enough to be noticed. Sprinting, or moving ## Whether the player is close enough to be noticed. Sprinting, or moving
## fast while swimming or jumping, is loud. ## fast while swimming or jumping, is loud.
func _notices_player(d: float) -> bool: func _notices_player(d: float) -> bool:
@@ -313,6 +364,8 @@ func _animate(dt: float) -> void:
## Applies a hit (Actions validates who hit what). `from` = where it came ## Applies a hit (Actions validates who hit what). `from` = where it came
## from, for the reaction. ## from, for the reaction.
func take_hit(damage: float, from: Vector3, embed_id: String = "") -> void: func take_hit(damage: float, from: Vector3, embed_id: String = "") -> void:
if puppet:
return # (online the server's copy takes the hit)
if is_dead(): if is_dead():
if not embed_id.is_empty(): if not embed_id.is_empty():
embedded[embed_id] = embedded.get(embed_id, 0) + 1 embedded[embed_id] = embedded.get(embed_id, 0) + 1
@@ -349,6 +402,9 @@ func _die(from: Vector3) -> void:
collision_layer = 2 collision_layer = 2
for l in _legs: for l in _legs:
l.rotation.x = 0.4 l.rotation.x = 0.4
if puppet:
_visual.rotation.z = -PI * 0.5
return
# Topple onto its side, away from the blow. # Topple onto its side, away from the blow.
var side := 1.0 if (global_position - from).dot(global_basis.x) > 0.0 else -1.0 var side := 1.0 if (global_position - from).dot(global_basis.x) > 0.0 else -1.0
if is_inside_tree() and DisplayServer.get_name() != "headless": if is_inside_tree() and DisplayServer.get_name() != "headless":

View File

@@ -51,7 +51,7 @@ func step(dt: float) -> void:
velocity.y -= gravity * dt velocity.y -= gravity * dt
var to := from + velocity * dt var to := from + velocity * dt
var ex: Array[RID] = [] var ex: Array[RID] = []
if shooter != null: if shooter != null and is_instance_valid(shooter) and shooter.has_method("get_rid"):
ex.append(shooter.get_rid()) ex.append(shooter.get_rid())
var q := PhysicsRayQueryParameters3D.create(from, to, 1 | 8, ex) var q := PhysicsRayQueryParameters3D.create(from, to, 1 | 8, ex)
var hit := get_world_3d().direct_space_state.intersect_ray(q) var hit := get_world_3d().direct_space_state.intersect_ray(q)
@@ -66,6 +66,9 @@ func step(dt: float) -> void:
func _on_hit(hit: Dictionary) -> void: func _on_hit(hit: Dictionary) -> void:
var c: Object = hit.collider var c: Object = hit.collider
var dir := velocity.normalized() var dir := velocity.normalized()
if visual:
queue_free()
return
if c != null and (c as Node).is_in_group("creature"): if c != null and (c as Node).is_in_group("creature"):
var k := clampf(velocity.length() / _start_speed, 0.4, 1.0) var k := clampf(velocity.length() / _start_speed, 0.4, 1.0)
Actions.projectile_hit(shooter, c, damage * k, global_position, item_id if item_id == "arrow" else "") Actions.projectile_hit(shooter, c, damage * k, global_position, item_id if item_id == "arrow" else "")
@@ -83,5 +86,11 @@ func _on_hit(hit: Dictionary) -> void:
_drop(Transform3D(b, at)) _drop(Transform3D(b, at))
func _drop(xf: Transform3D) -> void: func _drop(xf: Transform3D) -> void:
world.drop_item(item_id, 1, xf) if not visual:
world.drop_item(item_id, 1, xf)
queue_free() queue_free()
## Online, our game shows the server's arrows and spears in flight
## (creature_net.gd). They hit nothing: the server's copy does that, and
## anything that lands comes from the server as a dropped item.
var visual := false

View File

@@ -40,10 +40,21 @@ func alive(kind: String = "") -> Array:
## Fills the budget straight away (world start), closer in than usual so ## Fills the budget straight away (world start), closer in than usual so
## there's something to see, but not on top of the player. ## there's something to see, but not on top of the player.
func populate() -> void: func populate(around: Vector3 = Vector3.INF) -> void:
for kind in BUDGET: for kind in BUDGET:
for i in range(BUDGET[kind]): for i in range(BUDGET[kind]):
_spawn_near(kind, 22.0, RING_MAX) _spawn_near(kind, 22.0, RING_MAX, around)
## A player joined a server: some wildlife around them straight away, up to
## the island's budget.
func populate_for(pos: Vector3) -> void:
if not enabled:
return
var n: int = world.players().size()
for kind in BUDGET:
var near := alive(kind).filter(func(c): return c.global_position.distance_to(pos) < RING_MAX).size()
for i in range(mini(BUDGET[kind] - near, budget(kind, n) - alive(kind).size())):
_spawn_near(kind, 22.0, RING_MAX, pos)
func _process(delta: float) -> void: func _process(delta: float) -> void:
if not enabled: if not enabled:
@@ -54,19 +65,32 @@ func _process(delta: float) -> void:
if _check > 0.0: if _check > 0.0:
return return
_check = CHECK_EVERY _check = CHECK_EVERY
var pp: Vector3 = world.player.global_position # Around every player (on a server there can be several, wherever they
# are): an animal goes once it's far from all of them.
var everyone: Array = world.players()
if everyone.is_empty():
return
for c in get_children(): for c in get_children():
if c.global_position.distance_to(pp) > DESPAWN: var near := false
for p in everyone:
near = near or c.global_position.distance_to(p.global_position) <= DESPAWN
if not near:
if c.is_in_group("creature"): if c.is_in_group("creature"):
_cooldown[c.kind] = RESPAWN_DELAY * 0.3 _cooldown[c.kind] = RESPAWN_DELAY * 0.3
c.queue_free() c.queue_free()
for kind in BUDGET: for kind in BUDGET:
if _cooldown[kind] > 0.0: if _cooldown[kind] > 0.0:
continue continue
if alive(kind).size() < BUDGET[kind]: if alive(kind).size() < budget(kind, everyone.size()):
if _spawn_near(kind, RING_MIN, RING_MAX) != null: var who: Node3D = everyone[0] if everyone.size() == 1 else everyone[_rng.randi() % everyone.size()]
if _spawn_near(kind, RING_MIN, RING_MAX, who.global_position) != null:
_cooldown[kind] = RESPAWN_DELAY * 0.25 _cooldown[kind] = RESPAWN_DELAY * 0.25
## How many of a kind the island holds: the budget per player, up to twice
## it however many are on (MULTIPLAYER_PLAN.md §11).
static func budget(kind: String, players: int) -> int:
return BUDGET[kind] * clampi(players, 1, 2)
func _on_died(c: Node) -> void: func _on_died(c: Node) -> void:
_cooldown[c.kind] = RESPAWN_DELAY _cooldown[c.kind] = RESPAWN_DELAY
@@ -78,8 +102,8 @@ func _habitat(kind: String, x: float, z: float) -> float:
"deer": return 1.0 - absf(f - 0.45) * 1.6 "deer": return 1.0 - absf(f - 0.45) * 1.6
_: return smoothstep(0.35, 0.75, f) _: return smoothstep(0.35, 0.75, f)
func _spawn_near(kind: String, rmin: float, rmax: float) -> Node3D: func _spawn_near(kind: String, rmin: float, rmax: float, around: Vector3 = Vector3.INF) -> Node3D:
var pp: Vector3 = world.player.global_position var pp: Vector3 = around if around != Vector3.INF else world.player.global_position
for _try in range(40): for _try in range(40):
var a := _rng.randf() * TAU var a := _rng.randf() * TAU
var r := _rng.randf_range(rmin, rmax) var r := _rng.randf_range(rmin, rmax)

View File

@@ -33,6 +33,9 @@ func interact_prompt(p: Node) -> String:
s += " (you need a weapon)" s += " (you need a weapon)"
return s return s
func _pick_target() -> Node3D:
return pack.target_for(self)
func _think(dt: float) -> void: func _think(dt: float) -> void:
var pp: Vector3 = player.global_position var pp: Vector3 = player.global_position
var d := global_position.distance_to(pp) var d := global_position.distance_to(pp)
@@ -85,7 +88,7 @@ func _think_hunt(pp: Vector3, d: float) -> void:
match state: match state:
"lunge": "lunge":
# Break off first if you've reached a fire or it's taking too long. # Break off first if you've reached a fire or it's taking too long.
if _state_t <= 0.0 or not pack.may_close_in(): if _state_t <= 0.0 or not pack.may_close_in(player):
state = "stalk" state = "stalk"
pack.lunge_done(self) pack.lunge_done(self)
else: else:
@@ -98,7 +101,7 @@ func _think_hunt(pp: Vector3, d: float) -> void:
_: _:
state = "stalk" state = "stalk"
_go_to(pack.circle_point(self)) _go_to(pack.circle_point(self))
if pack.request_lunge(self): if pack.request_lunge(self, player):
state = "lunge" state = "lunge"
_state_t = 4.0 _state_t = 4.0

View File

@@ -107,16 +107,19 @@ func step(dt: float) -> void:
if not night and alive_count() < pack_size(day): if not night and alive_count() < pack_size(day):
fill_den(false) fill_den(false)
_gap = maxf(_gap - dt, 0.0) _gap = maxf(_gap - dt, 0.0)
for t in _gaps.keys():
_gaps[t] = maxf(float(_gaps[t]) - dt, 0.0)
if not is_instance_valid(t):
_gaps.erase(t)
_circle_a += dt * 0.25 _circle_a += dt * 0.25
if _lunger != null and (not is_instance_valid(_lunger) or _lunger.is_dead() or _lunger.state != "lunge"): if _lunger != null and (not is_instance_valid(_lunger) or _lunger.is_dead() or _lunger.state != "lunge"):
_lunger = null _lunger = null
# At dawn wolves head home; once one is well away from you (out of sight), # At dawn wolves head home; once one is well away from you (out of sight),
# it slips straight back rather than picking its way across the island. # it slips straight back rather than picking its way across the island.
if mode == "den": if mode == "den":
var pp: Vector3 = world.player.global_position
for m in alive(): for m in alive():
if m.global_position.distance_to(den_pos) > 6.0 and m.global_position.distance_to(pp) > 45.0 \ if m.global_position.distance_to(den_pos) > 6.0 and _far_from_everyone(m.global_position, 45.0) \
and pp.distance_to(den_pos) > 35.0: and _far_from_everyone(den_pos, 35.0):
m.global_position = den.slot_pos(m.slot) m.global_position = den.slot_pos(m.slot)
m.wounded = false m.wounded = false
m.state = "rest" m.state = "rest"
@@ -146,9 +149,9 @@ func _warn(day: int) -> void:
return return
_warned[day] = true _warned[day] = true
if day == DEN_DAY: if day == DEN_DAY:
Actions.notify.emit("Wolves are howling up in the hills. They'll come hunting soon.") Actions.tell_all("Wolves are howling up in the hills. They'll come hunting soon.")
elif day == HUNT_DAY: elif day == HUNT_DAY:
Actions.notify.emit("The wolves are hunting tonight. Stay near a fire or behind closed walls.") Actions.tell_all("The wolves are hunting tonight. Stay near a fire or behind closed walls.")
_howl_left = _rng.randf_range(2.0, 6.0) _howl_left = _rng.randf_range(2.0, 6.0)
## Brings the pack up to size, wolves lying in the den. `force` ignores ## Brings the pack up to size, wolves lying in the den. `force` ignores
@@ -157,7 +160,7 @@ func fill_den(force: bool) -> void:
var need: int = pack_size(effective_day()) - alive_count() var need: int = pack_size(effective_day()) - alive_count()
if need <= 0: if need <= 0:
return return
if not force and world.player.global_position.distance_to(den_pos) < 35.0: if not force and not _far_from_everyone(den_pos, 35.0):
return return
members = alive() members = alive()
var used := {} var used := {}
@@ -181,44 +184,119 @@ func fill_den(force: bool) -> void:
func home_slot(w: Node) -> Vector3: func home_slot(w: Node) -> Vector3:
return den.slot_pos(w.slot) return den.slot_pos(w.slot)
## The lit campfire you're sheltering by, if any. func _far_from_everyone(pos: Vector3, r: float) -> bool:
func player_fire() -> Node3D: for p in world.players():
var pp: Vector3 = world.player.global_position if p.global_position.distance_to(pos) < r:
return false
return true
# --- who the pack goes after ----------------------------------------------------
# In single-player that's you. On a server the pack splits between the
# players it's hunting: wolf 1 takes the first, wolf 2 the second, and so on,
# preferring players away from a fire (you can't be got at by a fire, so
# they'd rather go for someone who isn't). Each target gets their own circle
# and their own one-wolf-at-a-time darts.
var _targets: Array = []
var _target_t := 0.0
## Who wolf `w` is after right now (null: nobody about).
func target_for(w: Node) -> Node3D:
if not world.server_mode:
return world.player
if mode != "hunt":
return world.nearest_player((w as Node3D).global_position)
_refresh_targets()
if _targets.is_empty():
return null
var i := alive().find(w)
return _targets[maxi(i, 0) % _targets.size()]
## Players worth hunting, nearest the den first: those away from a fire if
## there are any, otherwise everyone (they'll circle the fire).
func _refresh_targets() -> void:
var now := Time.get_ticks_msec() / 1000.0
if now - _target_t < 1.0 and _targets.all(func(p): return is_instance_valid(p) and not p.survival.dead):
return
_target_t = now
var everyone: Array = world.players()
var open := everyone.filter(func(p): return fire_near(p) == null)
_targets = open if not open.is_empty() else everyone
_targets.sort_custom(func(a, b): return a.global_position.distance_to(den_pos) < b.global_position.distance_to(den_pos))
func _p(p: Node3D) -> Node3D:
return p if p != null else world.player
## The lit campfire `p` is sheltering by, if any.
func fire_near(p: Node3D) -> Node3D:
var pp: Vector3 = _p(p).global_position
for f in get_tree().get_nodes_in_group("station:campfire"): for f in get_tree().get_nodes_in_group("station:campfire"):
if f.station_active() and (f as Node3D).global_position.distance_to(pp) < FIRE_SAFE: if f.station_active() and (f as Node3D).global_position.distance_to(pp) < FIRE_SAFE:
return f return f
return null return null
func _torch() -> bool: ## (Single-player name, used by tests.)
return world.player.hands != null and world.player.hands.held_heat() > 0.0 func player_fire() -> Node3D:
return fire_near(world.player)
func _torch(p: Node3D = null) -> bool:
var who := _p(p)
return who.get("hands") != null and who.hands.held_heat() > 0.0
## Wolves only close in when you're away from a fire. ## Wolves only close in when you're away from a fire.
func may_close_in() -> bool: func may_close_in(p: Node3D = null) -> bool:
return player_fire() == null and not world.player.survival.dead return fire_near(p) == null and not _p(p).survival.dead
## The wolves going after the same player as `w`.
func _hunting_with(w: Node) -> Array:
var t := target_for(w)
return alive().filter(func(m): return target_for(m) == t)
## Where wolf `w` should be while circling. ## Where wolf `w` should be while circling.
func circle_point(w: Node) -> Vector3: func circle_point(w: Node) -> Vector3:
var fire := player_fire() var t := _p(target_for(w))
var center: Vector3 = fire.global_position if fire != null else world.player.global_position var fire := fire_near(t)
var r := FIRE_RING if fire != null else RING * (1.3 if _torch() else 1.0) var center: Vector3 = fire.global_position if fire != null else t.global_position
var live := alive() var r := FIRE_RING if fire != null else RING * (1.3 if _torch(t) else 1.0)
var n := maxi(live.size(), 1) var group := _hunting_with(w)
var a := _circle_a + TAU * float(live.find(w)) / n var n := maxi(group.size(), 1)
var a := _circle_a + TAU * float(maxi(group.find(w), 0)) / n
return center + Vector3(cos(a), 0, sin(a)) * r return center + Vector3(cos(a), 0, sin(a)) * r
## A wolf asks for its turn to dart in. ## Darting in: one wolf at a time per player being hunted.
func request_lunge(w: Node) -> bool: var _lungers: Dictionary = {} ## target -> wolf
if _lunger != null or _gap > 0.0 or not may_close_in() or alive_count() < 2: var _gaps: Dictionary = {} ## target -> seconds until the next dart
return false
if w.global_position.distance_to(world.player.global_position) > RING * 1.8: ## A wolf asks for its turn to dart in at `p`.
func request_lunge(w: Node, p: Node3D = null) -> bool:
var t := _p(p)
if not world.server_mode:
if _lunger != null or _gap > 0.0 or not may_close_in(t) or alive_count() < 2:
return false
else:
var cur: Node = _lungers.get(t)
if cur != null and is_instance_valid(cur) and not cur.is_dead() and cur.state == "lunge":
return false
if float(_gaps.get(t, 0.0)) > 0.0 or not may_close_in(t) or alive_count() < 2:
return false
if w.global_position.distance_to(t.global_position) > RING * 1.8:
return false return false
_lunger = w _lunger = w
_lungers[t] = w
return true return true
func lunge_done(w: Node) -> void: func lunge_done(w: Node) -> void:
if _lunger == w: if _lunger == w:
_lunger = null _lunger = null
_gap = ATTACK_GAP * (2.5 if _torch() else 1.0) * _rng.randf_range(0.8, 1.3) if not world.server_mode:
_gap = ATTACK_GAP * (2.5 if _torch() else 1.0) * _rng.randf_range(0.8, 1.3)
return
var t: Node3D = target_for(w) if w != null and is_instance_valid(w) else null
for k in _lungers.keys():
if _lungers[k] == w:
_lungers.erase(k)
if t != null:
_gaps[t] = ATTACK_GAP * (2.5 if _torch(t) else 1.0) * _rng.randf_range(0.8, 1.3)
## A wolf was hit: the whole pack turns on you. ## A wolf was hit: the whole pack turns on you.
func alert(_from: Vector3) -> void: func alert(_from: Vector3) -> void:
@@ -227,7 +305,12 @@ func alert(_from: Vector3) -> void:
func howl() -> void: func howl() -> void:
var live := alive() var live := alive()
if _audio == null or live.is_empty(): if live.is_empty():
return
if world.server_mode:
CreatureNet.howl((live[0] as Node3D).global_position + Vector3(0, 1, 0)) # everyone hears it
return
if _audio == null:
return return
_audio.global_position = (live[0] as Node3D).global_position + Vector3(0, 1, 0) _audio.global_position = (live[0] as Node3D).global_position + Vector3(0, 1, 0)
_audio.pitch_scale = _rng.randf_range(0.9, 1.1) _audio.pitch_scale = _rng.randf_range(0.9, 1.1)

View File

@@ -55,7 +55,15 @@ func setup(w: Node3D, p: Node3D, from: Vector3, vel: Vector3, seed: int) -> void
_line.material_override = lm _line.material_override = lm
add_child(_line) add_child(_line)
## Online, our game shows the server's float (creature_net.gd): it doesn't
## run any fishing itself, it's just moved and shown.
var puppet := false
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
if puppet:
_t += delta
_mesh.position.y = (-0.06 + sin(_t * 18.0) * 0.03) if state == "biting" else (sin(_t * 2.2) * 0.012 if state == "floating" else 0.0)
return
step(delta) step(delta)
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
@@ -94,8 +102,8 @@ func step(dt: float) -> void:
_t += dt _t += dt
var terrain: Node3D = world.terrain var terrain: Node3D = world.terrain
if global_position.distance_to(player.global_position) > LINE_LENGTH + 4.0: if global_position.distance_to(player.global_position) > LINE_LENGTH + 4.0:
Actions.notify.emit("The line snapped") Actions.tell(player, "The line snapped")
world.end_fishing() world.end_fishing(player)
return return
match state: match state:
"flying": "flying":
@@ -109,17 +117,17 @@ func step(dt: float) -> void:
table = ItemDB.water_fishing(water_type) table = ItemDB.water_fishing(water_type)
if table.is_empty() or table.fish.is_empty(): if table.is_empty() or table.fish.is_empty():
state = "landed" state = "landed"
Actions.notify.emit("Nothing to catch here") Actions.tell(player, "Nothing to catch here")
elif float(water.depth) < float(table.min_depth): elif float(water.depth) < float(table.min_depth):
state = "landed" state = "landed"
Actions.notify.emit("Too shallow here: cast further out") Actions.tell(player, "Too shallow here: cast further out")
else: else:
state = "floating" state = "floating"
wait_left = _next_wait() wait_left = _next_wait()
elif p.y <= ground + 0.03: elif p.y <= ground + 0.03:
p.y = ground + 0.03 p.y = ground + 0.03
state = "landed" state = "landed"
Actions.notify.emit("Cast into the water") Actions.tell(player, "Cast into the water")
global_position = p global_position = p
"floating": "floating":
wait_left -= dt wait_left -= dt
@@ -128,7 +136,7 @@ func step(dt: float) -> void:
state = "biting" state = "biting"
fish = _pick_fish() fish = _pick_fish()
bite_left = BITE_WINDOW bite_left = BITE_WINDOW
Actions.notify.emit("Bite! Click to reel in") Actions.tell(player, "Bite! Click to reel in")
"biting": "biting":
bite_left -= dt bite_left -= dt
# The float bobs hard and goes under. # The float bobs hard and goes under.
@@ -138,7 +146,7 @@ func step(dt: float) -> void:
fish = "" fish = ""
wait_left = _next_wait() * float(ItemDB.fishing.modifiers.get("missed_wait", 1.3)) wait_left = _next_wait() * float(ItemDB.fishing.modifiers.get("missed_wait", 1.3))
_mesh.position.y = 0.0 _mesh.position.y = 0.0
Actions.notify.emit("It got away") Actions.tell(player, "It got away")
func _draw_line() -> void: func _draw_line() -> void:
_imesh.clear_surfaces() _imesh.clear_surfaces()

257
scripts/net/creature_net.gd Normal file
View File

@@ -0,0 +1,257 @@
extends Node
## Autoload "CreatureNet": the wildlife on a server (M2.5,
## docs/MULTIPLAYER_PLAN.md §5).
##
## The server's world runs the animals and the wolf pack (world.gd in server
## mode), so everyone sees the same rabbit in the same place, and a pack
## hunts whoever it likes. Ten times a second it sends everyone:
## - every animal and wolf, alive or a carcass: id, kind, where, facing,
## what it's doing, its speed and health;
## - every fishing float: whose it is, where, and whether a fish is biting.
## Players' games show these as puppets (creature.gd, bobber.gd `puppet`):
## they animate and can be hit or butchered (Actions sends that to the
## server), but decide nothing. Arrows and spears in flight are sent once
## when they're loosed and flown on each screen for show; only the server's
## copy hits anything. Howls are sent too.
##
## Snapshots are unreliable (a lost one is replaced 0.1 s later). Something
## missing from the snapshots for a second (despawned, butchered) goes.
const Creature = preload("res://scripts/creatures/creature.gd")
const Bobber = preload("res://scripts/fishing/bobber.gd")
const Projectile = preload("res://scripts/creatures/projectile.gd")
const WolfPack = preload("res://scripts/creatures/wolf_pack.gd")
const SEND_INTERVAL := 0.1
const FORGET_AFTER := 1.0
# Server.
var _next_cid := 1
var _by_cid: Dictionary = {} ## cid -> creature (server)
var _send_left := 0.0
# Client.
var world: Node3D
var puppets: Dictionary = {} ## cid -> creature.gd puppet
var floats: Dictionary = {} ## owner peer -> bobber.gd puppet
var _seen: Dictionary = {} ## cid or "f<peer>" -> last time seen (s)
var _root: Node3D
var _howl_player: AudioStreamPlayer3D
var projectiles_seen := 0 ## tests read these
var howls_heard := 0
# --- server ----------------------------------------------------------------------
func _server_world() -> Node3D:
return WorldNet.world if Net.is_server() and WorldNet.world != null and is_instance_valid(WorldNet.world) else null
## The server's creature with id `cid`, alive or a carcass.
func creature(cid: int) -> Node3D:
var c: Node3D = _by_cid.get(cid)
return c if c != null and is_instance_valid(c) and not c.is_queued_for_deletion() else null
func _process(delta: float) -> void:
var w := _server_world()
if w != null:
_server_tick(w, delta)
elif world != null:
_client_tick()
func _server_tick(w: Node3D, delta: float) -> void:
if not w.launched.is_connected(_on_launched):
w.launched.connect(_on_launched)
_send_left -= delta
if _send_left > 0.0:
return
_send_left = SEND_INTERVAL
var all: Array = []
_by_cid.clear()
for group in ["creature", "carcass"]:
for c in w.get_tree().get_nodes_in_group(group):
if not c.has_meta("cid"):
c.set_meta("cid", _next_cid)
_next_cid += 1
_by_cid[int(c.get_meta("cid"))] = c
all.append(c)
var fl: Array = []
for p in w.bobbers:
var b: Node3D = w.bobbers[p]
if is_instance_valid(b) and is_instance_valid(p):
fl.append([int(p.peer), b.global_position, b.state])
if all.is_empty() and fl.is_empty():
return
# Packed tight and split, so no packet goes over the MTU (a lost
# fragment loses the whole unreliable packet).
var i := 0
while i < maxi(all.size(), 1):
var chunk := PackedByteArray()
chunk.resize(mini(PER_PACKET, all.size() - i) * ENTRY)
var o := 0
for c in all.slice(i, i + PER_PACKET):
_encode(chunk, o, c)
o += ENTRY
for peer in WorldNet._ready_peers:
_s2c_life.rpc_id(peer, chunk, fl if i == 0 else [])
i += PER_PACKET
const PER_PACKET := 40
const ENTRY := 23
const KIND_IDS := ["rabbit", "deer", "boar", "wolf"]
const STATE_IDS := ["idle", "wander", "flee", "charge", "dead", "rest", "alert", "stalk", "lunge", "retreat", "home", "defend"]
## One creature in 23 bytes: id, kind, state, position, facing, speed, health.
static func _encode(buf: PackedByteArray, o: int, c: Node3D) -> void:
buf.encode_u32(o, int(c.get_meta("cid")))
buf.encode_u8(o + 4, maxi(KIND_IDS.find(c.kind), 0))
buf.encode_u8(o + 5, maxi(STATE_IDS.find(c.state), 0))
var p: Vector3 = c.global_position
buf.encode_float(o + 6, p.x)
buf.encode_float(o + 10, p.y)
buf.encode_float(o + 14, p.z)
buf.encode_u16(o + 18, int(fposmod(c.yaw, TAU) / TAU * 65535.0))
buf.encode_u8(o + 20, clampi(int(c.speed * 20.0), 0, 255))
buf.encode_u16(o + 21, clampi(int(c.hp * 100.0), 0, 65535))
static func _decode(buf: PackedByteArray, o: int) -> Array:
return [buf.decode_u32(o), KIND_IDS[mini(buf.decode_u8(o + 4), KIND_IDS.size() - 1)],
Vector3(buf.decode_float(o + 6), buf.decode_float(o + 10), buf.decode_float(o + 14)),
buf.decode_u16(o + 18) / 65535.0 * TAU, STATE_IDS[mini(buf.decode_u8(o + 5), STATE_IDS.size() - 1)],
buf.decode_u8(o + 20) / 20.0, buf.decode_u16(o + 21) / 100.0]
func _on_launched(id: String, origin: Vector3, vel: Vector3, shooter: Node3D) -> void:
var by := int(shooter.peer) if shooter != null and is_instance_valid(shooter) and shooter.get("peer") != null else 0
for peer in WorldNet._ready_peers:
_s2c_launched.rpc_id(peer, id, origin, vel, by)
## The pack howls (wolf_pack.gd): everyone hears it, from where it is.
func howl(at: Vector3) -> void:
for peer in WorldNet._ready_peers:
_s2c_howl.rpc_id(peer, at)
# --- client ----------------------------------------------------------------------
func join_world(w: Node3D) -> void:
world = w
puppets.clear()
floats.clear()
_seen.clear()
projectiles_seen = 0
howls_heard = 0
_root = Node3D.new()
_root.name = "NetCreatures"
w.add_child(_root)
func leave_world() -> void:
if not Net.is_server():
world = null
puppets.clear()
floats.clear()
_root = null
static func _now() -> float:
return Time.get_ticks_msec() / 1000.0
func _alive_world() -> bool:
return world != null and is_instance_valid(world) and _root != null and is_instance_valid(_root)
## Anything the server has stopped sending goes.
func _client_tick() -> void:
if not _alive_world():
return
var now := _now()
for cid in puppets.keys():
if now - float(_seen.get(cid, 0.0)) > FORGET_AFTER:
var c: Variant = puppets[cid]
puppets.erase(cid)
if is_instance_valid(c):
(c as Node).queue_free()
for peer in floats.keys():
if now - float(_seen.get("f%d" % peer, 0.0)) > FORGET_AFTER * 0.6:
_drop_float(peer)
func _drop_float(peer: int) -> void:
var b: Variant = floats.get(peer) # (may already be freed: untyped)
floats.erase(peer)
if is_instance_valid(b):
(b as Node).queue_free()
if peer == Net.local_peer and world != null and is_instance_valid(world):
world.bobber = null
@rpc("authority", "call_remote", "unreliable_ordered")
func _s2c_life(packed: PackedByteArray, fl: Array) -> void:
if not _alive_world():
return
var now := _now()
var list: Array = []
for o in range(0, packed.size() - ENTRY + 1, ENTRY):
list.append(_decode(packed, o))
for e in list:
var cid := int(e[0])
var kind := String(e[1])
if not Creature.KINDS.has(kind):
continue
var got: Variant = puppets.get(cid)
var c: Node3D = got if is_instance_valid(got) else null
if c == null:
c = Creature.new()
c.puppet = true
c.setup(kind, world, e[2], cid)
c.set_meta("cid", cid)
c.yaw = float(e[3])
_root.add_child(c)
c.global_position = e[2]
c.rotation.y = c.yaw
puppets[cid] = c
c.net_update(e[2], float(e[3]), String(e[4]), float(e[5]), float(e[6]))
_seen[cid] = now
for f in fl:
_float_update(int(f[0]), f[1], String(f[2]))
_seen["f%d" % int(f[0])] = now
func _float_update(peer: int, pos: Vector3, state: String) -> void:
var got: Variant = floats.get(peer)
var b: Node3D = got if is_instance_valid(got) else null
if b == null:
b = Bobber.new()
b.puppet = true
b.setup(world, world.player, pos, Vector3.ZERO, 0)
_root.add_child(b)
floats[peer] = b
if peer == Net.local_peer:
world.bobber = b # hands.gd: the prompt, the line from our rod
b.global_position = pos
b.state = state
if peer != Net.local_peer:
# Someone else's line: from about where their rod would be.
var av: Node3D = world.net_sync.avatars.get(peer) if world.net_sync != null else null
if av != null and is_instance_valid(av):
b.tip = av.global_position + Vector3(0, 1.5, 0) + (-av.global_basis.z) * 0.8
else:
b.tip = pos + Vector3(0, 0.1, 0)
@rpc("authority", "call_remote", "reliable")
func _s2c_launched(id: String, origin: Vector3, vel: Vector3, by: int) -> void:
if not _alive_world():
return
projectiles_seen += 1
var pr: Node3D = Projectile.new()
pr.visual = true
pr.setup(id, world, origin, vel, 0.0, world.player if by == Net.local_peer else null)
_root.add_child(pr)
@rpc("authority", "call_remote", "reliable")
func _s2c_howl(at: Vector3) -> void:
if not _alive_world():
return
howls_heard += 1
if DisplayServer.get_name() == "headless":
return
if _howl_player == null or not is_instance_valid(_howl_player):
_howl_player = AudioStreamPlayer3D.new()
_howl_player.stream = WolfPack.howl_stream()
_howl_player.unit_size = 30.0
_howl_player.max_distance = 400.0
_howl_player.volume_db = 2.0
_root.add_child(_howl_player)
_howl_player.global_position = at
_howl_player.pitch_scale = randf_range(0.9, 1.1)
_howl_player.play()

View File

@@ -32,7 +32,7 @@ signal clock(day: int, time: float) ## the server's time of day
const Identity = preload("res://scripts/net/identity.gd") const Identity = preload("res://scripts/net/identity.gd")
const ServerConfig = preload("res://scripts/net/server_config.gd") const ServerConfig = preload("res://scripts/net/server_config.gd")
const PROTOCOL := 4 ## 3: the shared world (M2.3); 4: the server decides (M2.4) const PROTOCOL := 5 ## 3: shared world (M2.3); 4: the server decides (M2.4); 5: wildlife (M2.5)
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
@@ -272,6 +272,12 @@ func _tick_clock(delta: float) -> void:
for peer in players: for peer in players:
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time) _s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
## Everyone gets the clock now (after a night's sleep, an admin's /time).
func push_clock() -> void:
_clock_left = CLOCK_INTERVAL
for peer in players:
_s2c_clock.rpc_id(peer, _world.day_night.day, _world.day_night.time)
## Where a player is, as far as the server knows. ## Where a player is, as far as the server knows.
func player_pos(peer: int) -> Vector3: func player_pos(peer: int) -> Vector3:
return _states[peer].pos if _states.has(peer) else Vector3.INF return _states[peer].pos if _states.has(peer) else Vector3.INF
@@ -440,6 +446,7 @@ func _c2s_proof(sig: PackedByteArray) -> void:
if start == Vector3.INF: if start == Vector3.INF:
start = _spawn_for(players.size()) start = _spawn_for(players.size())
PlayerNet.place_at(peer, start) PlayerNet.place_at(peer, start)
_world.creatures.populate_for(start) # some wildlife about them straight away
players[peer] = p players[peer] = p
_states[peer] = {"pos": start, "yaw": yaw, "pitch": 0.0, "anim": 0, "held": "", "t": server_time, "corr_t": -10.0, _states[peer] = {"pos": start, "yaw": yaw, "pitch": 0.0, "anim": 0, "held": "", "t": server_time, "corr_t": -10.0,
"stamp": server_time, "off": INF} "stamp": server_time, "off": INF}

View File

@@ -130,3 +130,4 @@ func _on_clock(day: int, time: float) -> void:
if absf(diff) > 0.01: if absf(diff) > 0.01:
world.day_night.time = time world.day_night.time = time
world.day_night.apply(true) world.day_night.apply(true)
world.weather.snap() # (a night slept through: the weather jumps too)

View File

@@ -19,7 +19,6 @@ signal cursor_changed(stack) ## client: the server says this is on your cur
const ServerPlayer = preload("res://scripts/net/server_player.gd") const ServerPlayer = preload("res://scripts/net/server_player.gd")
const STATS_INTERVAL := 0.25 const STATS_INTERVAL := 0.25
const NOT_YET := "Hunting and fishing aren't on servers yet (they come with the animals)"
# Server. # Server.
var proxies: Dictionary = {} ## peer -> server_player.gd var proxies: Dictionary = {} ## peer -> server_player.gd
@@ -69,6 +68,10 @@ func server_join(peer: int, id: String, display: String) -> Vector3:
p.inventory.changed.connect(func(): _dirty[peer] = true; WorldNet.mark_changed()) p.inventory.changed.connect(func(): _dirty[peer] = true; WorldNet.mark_changed())
p.worn_changed.connect(func(): _dirty[peer] = true) p.worn_changed.connect(func(): _dirty[peer] = true)
p.survival.died.connect(_on_died.bind(peer)) p.survival.died.connect(_on_died.bind(peer))
# A wolf bite or a boar: their screen flashes red, from the right side.
p.survival.hurt.connect(func(amount, from):
if proxies.has(peer):
_s2c_hurt.rpc_id(peer, amount, from))
var at := Vector3.INF var at := Vector3.INF
var d := _read(_file(id)) var d := _read(_file(id))
if not d.is_empty(): if not d.is_empty():
@@ -96,10 +99,14 @@ func server_leave(peer: int) -> void:
if p.survival.dead: if p.survival.dead:
_respawn(peer, false) # left on the death screen: they come back alive _respawn(peer, false) # left on the death screen: they come back alive
_save(p) _save(p)
var w: Node3D = WorldNet.world
if w != null and w.bobber_of(p) != null:
w.end_fishing(p)
proxies.erase(peer) proxies.erase(peer)
_dirty.erase(peer) _dirty.erase(peer)
_acks.erase(peer) _acks.erase(peer)
p.queue_free() p.queue_free()
_maybe_pass_night() # (the last one awake leaving lets the others sleep)
func save_all() -> void: func save_all() -> void:
for p in proxies.values(): for p in proxies.values():
@@ -149,6 +156,9 @@ func server_moved(peer: int, from: Vector3, to: Vector3, yaw: float, anim: int,
p.rotation.y = yaw p.rotation.y = yaw
if to.y < p.water_level - 0.6: if to.y < p.water_level - 0.6:
p.survival.soak() p.survival.soak()
if p.in_bed and to.distance_to(p.bed_at) > 1.5:
p.in_bed = false
tell(p.peer, "You got out of bed")
func _process(delta: float) -> void: func _process(delta: float) -> void:
if not Net.is_server() or proxies.is_empty(): if not Net.is_server() or proxies.is_empty():
@@ -356,8 +366,129 @@ func _run(p: Node3D, what: String, a: Array) -> void:
"debug_drop": "debug_drop":
if cheats and not ItemDB.get_item(_s(a, 0)).is_empty(): if cheats and not ItemDB.get_item(_s(a, 0)).is_empty():
w.drop_item(_s(a, 0), maxi(_i(a, 1), 1), Transform3D(Basis(), _v(a, 2))) w.drop_item(_s(a, 0), maxi(_i(a, 1), 1), Transform3D(Basis(), _v(a, 2)))
# --- hunting and fishing (M2.5) ---
# Arrows, spears and casts start from where the server knows you are
# (your eyes), whatever your game says: only the aim comes from you.
"melee":
var c: Node3D = CreatureNet.creature(_i(a, 0))
if c != null:
Actions.melee(p, c)
"butcher":
var c: Node3D = CreatureNet.creature(_i(a, 0))
if c != null:
Actions.butcher(p, c)
"shoot":
var dir := _dir(a, 0)
var power := clampf(float(a[1]) if a.size() > 1 and (a[1] is float or a[1] is int) else 0.0, 0.0, 1.0)
if dir != Vector3.ZERO and Actions.can_draw(p):
Actions.shoot(p, _eye(p) + dir * 0.5, dir, power, w)
"throw":
var dir := _dir(a, 1)
if dir != Vector3.ZERO:
Actions.throw_spear(p, _i(a, 0), _eye(p) + dir * 0.5 + Vector3(0, -0.05, 0), dir + Vector3(0, 0.06, 0), w)
"cast":
var dir := _dir(a, 0)
if dir != Vector3.ZERO:
Actions.cast_line(p, _eye(p) + dir * 0.6, dir, w)
"reel":
Actions.reel(p, w)
"debug_spawn":
if cheats and Creature.KINDS.has(_s(a, 0)) and _s(a, 0) != "wolf" and _v(a, 1).is_finite():
w.creatures.spawn(_s(a, 0), _v(a, 1))
"debug_time":
if cheats:
w.day_night.day = maxi(_i(a, 0), 1)
w.day_night.time = clampf(float(a[1]) / 24.0, 0.0, 0.999) if a.size() > 1 else w.day_night.time
w.day_night.apply(true)
w.weather.snap()
Net.push_clock()
"debug_tp":
var to := _v(a, 0)
if cheats and to.is_finite():
p.global_position = to
Net.teleport(p.peer, to)
_s2c_respawn.rpc_id(p.peer, to, p.rotation.y, "")
"debug_hut":
if cheats and _v(a, 0).is_finite():
_debug_hut(_v(a, 0))
_: _:
Actions.notify.emit(NOT_YET if what in ["hunt", "fish"] else "Unknown request") Actions.notify.emit("Unknown request")
const Creature = preload("res://scripts/creatures/creature.gd")
static func _eye(p: Node3D) -> Vector3:
return p.global_position + Vector3(0, 1.6, 0)
## A unit aim direction from the request, or ZERO.
static func _dir(a: Array, k: int) -> Vector3:
var d := _v(a, k)
return d.normalized() if d.is_finite() and d.length() > 0.01 else Vector3.ZERO
## Test hook: a closed hut (floors, walls, door, roof) with two beds in it.
func _debug_hut(center: Vector3) -> void:
var w: Node3D = WorldNet.world
var out: Dictionary = await w.build.debug_build_hut(center, 0.0)
if out.pieces.is_empty():
return
var top: float = out.pieces[0].global_position.y
for x in [-0.6, 0.6]:
w.spawn_piece(ItemDB.get_item("bed"), Transform3D(Basis(), Vector3(center.x + x, top, center.z - 0.3)))
# --- server: messages, getting hurt, sleeping ----------------------------------------
func tell(peer: int, text: String) -> void:
if proxies.has(peer):
_s2c_notify.rpc_id(peer, text)
func tell_all(text: String) -> void:
for peer in proxies:
_s2c_notify.rpc_id(peer, text)
## The server's sleep rule (server.json "sleep"): how many of the players
## online must be in bed for the night to pass.
func _beds_needed(n: int) -> int:
match String(Net.config.v("sleep")):
"anyone": return 1
"half": return ceili(n / 2.0)
return n # "all_online" (the default)
## Actions.use_bed decided `p` may sleep: they get into bed, and the night
## passes once enough players are in bed.
func go_to_bed(p: Node3D) -> void:
p.in_bed = true
p.bed_at = p.global_position
if not _maybe_pass_night():
var n: int = _alive().size()
var asleep: int = _alive().filter(func(q): return q.in_bed).size()
tell(p.peer, "You're in bed (%d of %d asleep). The night passes when %s." % [asleep, n,
"everyone's in bed" if _beds_needed(n) >= n else "%d are" % _beds_needed(n)])
for q in _alive():
if q != p:
tell(q.peer, "%s is in bed (%d of %d asleep)" % [p.display, asleep, n])
func _alive() -> Array:
return proxies.values().filter(func(q): return not q.survival.dead)
## Everyone in bed (by the rule)? Then until 06:00 for all: the clock, fires,
## regrowth and weather move on; sleepers wake rested.
func _maybe_pass_night() -> bool:
var w: Node3D = WorldNet.world
var live := _alive()
if w == null or live.is_empty():
return false
var asleep := live.filter(func(q): return q.in_bed)
if asleep.size() < _beds_needed(live.size()):
return false
var secs: float = fposmod(6.0 - w.day_night.hours(), 24.0) / 24.0 * w.day_night.day_length
w.skip_time(secs)
for q in live:
if q.in_bed:
q.survival.after_sleep(secs)
q.in_bed = false
_s2c_slept.rpc_id(q.peer, asleep.has(q))
Net.push_clock()
WorldNet.save()
return true
## The chest or grave `nid`, if this player may use it right now. ## The chest or grave `nid`, if this player may use it right now.
func _container(p: Node3D, nid: int) -> Node3D: func _container(p: Node3D, nid: int) -> Node3D:
@@ -451,3 +582,22 @@ func _s2c_respawn(at: Vector3, yaw: float, text: String) -> void:
@rpc("authority", "call_remote", "reliable") @rpc("authority", "call_remote", "reliable")
func _s2c_notify(text: String) -> void: func _s2c_notify(text: String) -> void:
Actions.notify.emit(text) Actions.notify.emit(text)
var hurts := 0 ## client: how often the server said we were hurt (tests read it)
@rpc("authority", "call_remote", "reliable")
func _s2c_hurt(amount: float, from: Vector3) -> void:
hurts += 1
if world != null and is_instance_valid(world):
world.player.survival.hurt.emit(amount, from)
## The night passed (we were asleep, or up while the others slept).
@rpc("authority", "call_remote", "reliable")
func _s2c_slept(was_asleep: bool) -> void:
if world == null or not is_instance_valid(world):
return
var text := "You slept until morning" if was_asleep else "The night passed while the others slept"
if world.hud != null and was_asleep:
world.hud.fade(func(): Actions.notify.emit(text))
else:
Actions.notify.emit(text)

View File

@@ -1,4 +1,4 @@
extends Node3D extends CharacterBody3D
## One player's character on a dedicated server (M2.4): what they carry, what ## One player's character on a dedicated server (M2.4): what they carry, what
## they hold, what they wear, their bed, and their survival stats, which run ## they hold, what they wear, their bed, and their survival stats, which run
## here (warmth from the server's fires and buildings, hunger, diet...). ## here (warmth from the server's fires and buildings, hunger, diet...).
@@ -23,8 +23,12 @@ var worn := ""
var cursor = null ## the stack held on the inventory screen's cursor var cursor = null ## the stack held on the inventory screen's cursor
var survival: Node var survival: Node
var hands: Node ## (self: held_heat for a carried torch) var hands: Node ## (self: held_heat for a carried torch)
var velocity := Vector3.ZERO ## (A body with no collision, so it has a physics id: wolves look for a clear
## line to it, and arrows skip whoever shot them. `velocity` comes from the
## player's moves.)
var water_level := -INF var water_level := -INF
var in_bed := false ## got into bed, waiting for the night to pass
var bed_at := Vector3.ZERO
var has_bed_spawn := false var has_bed_spawn := false
var bed_spawn := Vector3.ZERO var bed_spawn := Vector3.ZERO
var anim := 0 ## last movement state reported (avatar.gd Anim) var anim := 0 ## last movement state reported (avatar.gd Anim)
@@ -32,6 +36,9 @@ var died_at := 0 ## ticks (ms) when they last died
func setup(world: Node3D) -> void: func setup(world: Node3D) -> void:
hands = self hands = self
collision_layer = 0
collision_mask = 0
set_physics_process(false)
water_level = world.terrain.water_level water_level = world.terrain.water_level
survival = preload("res://scripts/player/survival.gd").new() survival = preload("res://scripts/player/survival.gd").new()
survival.name = "Survival" survival.name = "Survival"

View File

@@ -270,8 +270,6 @@ func respawn_from_server(at: Vector3, yaw: float) -> 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() or server_mode:
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)"
@@ -284,7 +282,12 @@ func sleep_block_reason(bed: Node3D) -> String:
return "" return ""
## Sleeps until 06:00: the clock, fires, regrowth and weather all move on. ## Sleeps until 06:00: the clock, fires, regrowth and weather all move on.
## On a server you get into bed, and the night passes once enough players
## are in bed (the server's sleep rule, player_net.gd).
func sleep(p: Node3D) -> void: func sleep(p: Node3D) -> void:
if server_mode:
PlayerNet.go_to_bed(p)
return
var secs: float = fposmod(6.0 - day_night.hours(), 24.0) / 24.0 * day_night.day_length var secs: float = fposmod(6.0 - day_night.hours(), 24.0) / 24.0 * day_night.day_length
if hud != null: if hud != null:
hud.fade(func(): _finish_sleep(p, secs)) hud.fade(func(): _finish_sleep(p, secs))
@@ -335,7 +338,18 @@ func _on_terrain_ready() -> void:
var saved: Dictionary = Saves.load_state(Saves.current_id) var saved: Dictionary = Saves.load_state(Saves.current_id)
if not saved.is_empty(): if not saved.is_empty():
apply_save(saved) apply_save(saved)
return # no player, no animals (the server runs those in M2.5) # The server runs the wildlife and the wolves for everyone (M2.5):
# animals live around the players, and players see them via
# creature_net.gd.
set_physics_process(false) # (no player of our own to keep on the map)
creatures.setup(self)
creatures.enabled = not "--no-creatures" in OS.get_cmdline_user_args()
if den.found:
wolves = preload("res://scripts/creatures/wolf_pack.gd").new()
wolves.name = "Wolves"
add_child(wolves)
wolves.setup(self, den)
return
_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)
@@ -714,9 +728,9 @@ func _go_online() -> void:
props.regrow_enabled = false props.regrow_enabled = false
WorldNet.join_world(self) WorldNet.join_world(self)
PlayerNet.join_world(self) # and our inventory and stats PlayerNet.join_world(self) # and our inventory and stats
CreatureNet.join_world(self) # the server's animals, wolves, arrows and floats
if hud != null: if hud != null:
var bar := UITheme.label("Online: %s · no animals, hunting or fishing on servers yet" \ var bar := UITheme.label("Online: %s" % 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"))
@@ -724,6 +738,7 @@ func _go_online() -> void:
func _on_net_left(_reason: String) -> void: func _on_net_left(_reason: String) -> void:
WorldNet.leave_world() WorldNet.leave_world()
PlayerNet.leave_world() PlayerNet.leave_world()
CreatureNet.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
@@ -750,25 +765,69 @@ func _make_dropped(id: String, count: int, xf: Transform3D) -> Node3D:
return pk return pk
## Casts a fishing float (Actions validated it). One at a time. ## Casts a fishing float (Actions validated it). One at a time.
## One float per player (on a server, everyone can fish at once). `bobber`
## is the local player's.
func start_fishing(p: Node3D, origin: Vector3, vel: Vector3) -> Node3D: func start_fishing(p: Node3D, origin: Vector3, vel: Vector3) -> Node3D:
end_fishing() end_fishing(p)
bobber = preload("res://scripts/fishing/bobber.gd").new() var b: Node3D = preload("res://scripts/fishing/bobber.gd").new()
bobber.setup(self, p, origin, vel, randi()) b.setup(self, p, origin, vel, randi())
add_child(bobber) add_child(b)
return bobber bobbers[p] = b
if p == player:
bobber = b
return b
func end_fishing() -> void: func end_fishing(p: Node3D = null) -> void:
if bobber != null and is_instance_valid(bobber): if p == null:
bobber.queue_free() p = player
bobber = null if is_online() and p == player and bobber != null:
PlayerNet.ask("reel") # (switched away from the rod: the server reels in)
var b: Node3D = bobbers.get(p)
bobbers.erase(p)
if b != null and is_instance_valid(b):
b.queue_free()
if p == player:
if bobber != null and is_instance_valid(bobber) and bobber != b:
bobber.queue_free() # (online: the server's float, shown here)
bobber = null
func bobber_of(p: Node3D) -> Node3D:
var b: Node3D = bobbers.get(p)
return b if b != null and is_instance_valid(b) else null
var bobbers: Dictionary = {} ## player -> their float
# --- who's here ------------------------------------------------------------
## Everyone animals react to: the local player in single-player; on a
## server, every player's character that's alive (player_net.gd).
func players() -> Array:
if server_mode:
return PlayerNet.proxies.values().filter(func(p): return is_instance_valid(p) and not p.survival.dead)
return [player]
## The nearest of them to `pos`, or null if nobody's around (a server with
## everyone dead or gone).
func nearest_player(pos: Vector3) -> Node3D:
if not server_mode:
return player
var best: Node3D = null
for p in players():
if best == null or p.global_position.distance_to(pos) < best.global_position.distance_to(pos):
best = p
return best
## Fires an arrow or thrown spear (Actions validated it). ## Fires an arrow or thrown spear (Actions validated it).
func launch(id: String, origin: Vector3, vel: Vector3, damage: float, shooter: Node3D) -> Node3D: func launch(id: String, origin: Vector3, vel: Vector3, damage: float, shooter: Node3D) -> Node3D:
var pr: Node3D = preload("res://scripts/creatures/projectile.gd").new() var pr: Node3D = preload("res://scripts/creatures/projectile.gd").new()
pr.setup(id, self, origin, vel, damage, shooter) pr.setup(id, self, origin, vel, damage, shooter)
add_child(pr) add_child(pr)
if server_mode:
launched.emit(id, origin, vel, shooter) # everyone sees it fly (creature_net.gd)
return pr return pr
signal launched(id: String, origin: Vector3, vel: Vector3, shooter: Node3D)
## Takes a piece out of the world (dismantled). Anything that loses its ## Takes a piece out of the world (dismantled). Anything that loses its
## support collapses, and its item goes back to `refund_to` if there's room. ## support collapses, and its item goes back to `refund_to` if there's room.
## Returns how many pieces collapsed. ## Returns how many pieces collapsed.

View File

@@ -109,8 +109,43 @@ func _do(cmd: Dictionary) -> Dictionary:
net.leave() net.leave()
await create_timer(0.3).timeout await create_timer(0.3).timeout
return {"left": true} return {"left": true}
# M2.5 (tests/test_net_life.gd).
"tp":
root.get_node("PlayerNet").ask("debug_tp", [_p(cmd.to)])
await create_timer(0.6).timeout
player.set_physics_process(false)
return {"pos": _v(player.global_position)}
"ask":
var args: Array = []
for x in cmd.get("args", []):
args.append(_p(x.v) if x is Dictionary and x.has("v") else x)
root.get_node("PlayerNet").ask(String(cmd.what), args)
await create_timer(0.4).timeout
return {"inv": player.inventory.to_data()}
"bed":
var b: Node3D = WN.pieces.get(int(cmd.nid))
if b == null:
return {"error": "no bed %d" % int(cmd.nid)}
root.get_node("Actions").use_bed(player, b)
await create_timer(0.5).timeout
return {"asked": true}
"life":
return life(world, root.get_node("CreatureNet"), root.get_node("PlayerNet"))
return {"error": "unknown command"} return {"error": "unknown command"}
## What this player's game shows of the server's wildlife.
static func life(w: Node3D, CN: Node, PN: Node) -> Dictionary:
var out := {"puppets": {}, "floats": [], "projectiles": CN.projectiles_seen, "howls": CN.howls_heard,
"hurts": PN.hurts, "health": w.player.survival.health, "day": w.day_night.day,
"hours": w.day_night.hours(), "statuses": w.player.survival.statuses.duplicate()}
for cid in CN.puppets:
var c: Node3D = CN.puppets[cid]
if is_instance_valid(c):
out.puppets[str(cid)] = {"kind": c.kind, "pos": _v(c.global_position), "state": c.state, "hp": c.hp}
for peer in CN.floats:
out.floats.append(peer)
return out
# Shared with test_net_shared.gd (it loads this script for these). # 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. ## Walks (by setting positions, like the game sending its moves) at 5 m/s.

View File

@@ -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_net_shared 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_net_life 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,

412
tests/test_net_life.gd Normal file
View File

@@ -0,0 +1,412 @@
extends SceneTree
## M2.5: wildlife on a server. The server runs the animals and the wolves;
## both players see the same ones. Hunting (club and bow), butchering,
## fishing and sleeping all happen on the server, and a wolf pack hunts two
## players at once.
##
## A real headless server (with --debug-cheats, so the test can set the
## clock, give items, spawn an animal and move players), Alice (this test,
## with the real world scene) and Bob (tests/net_world_bot.gd, another
## process with his own world, following this test's commands).
## Run: godot --headless --path . --script res://tests/test_net_life.gd
const PORT := 24348
const DIR := "user://test_net"
const SRV := DIR + "/server6"
const BOB := DIR + "/bob_life"
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 PN: Node
var CN: Node
var A: Node
var world: Node3D
var player: CharacterBody3D
var _cmd := 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")
PN = root.get_node("PlayerNet")
CN = root.get_node("CreatureNet")
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)
_spawn(["--server", "--port", str(PORT), "--config", SRV + "/server.json",
"--server-name", "Life Test", "--seed", str(SEED), "--debug-cheats"])
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 _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
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)
await _until(func(): return WN.received >= 1, 10.0)
_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 (%s)" % b0.get("why", "ok"))
if not bool(b0.get("ok", false)):
return
PN.ask("debug_stats", [{"health": 100.0, "hunger": 90.0}])
await _wildlife()
await _club()
await _bow()
await _fishing()
await _sleeping()
await _wolves()
world.queue_free()
await process_frame
Net.leave()
_finished = true
# --- the same animals for everyone ------------------------------------------------
func _wildlife() -> void:
print("the server's wildlife")
await _until(func(): return CN.puppets.size() >= 5, 8.0)
check(CN.puppets.size() >= 5, "animals appear around Alice: %d, from the server" % CN.puppets.size())
check(not world.creatures.enabled and world.creatures.alive().is_empty(), "none of them are her game's own")
# (Bob may only just have joined: give his first snapshots a moment.)
var life := await _bob({"do": "life"})
for _i in range(10):
if (life.puppets as Dictionary).size() >= 5:
break
await _sleep(0.3)
life = await _bob({"do": "life"})
var same := 0
var close := 0
for cid in CN.puppets:
var mine: Node3D = CN.puppets[cid]
var his: Dictionary = life.puppets.get(str(cid), {})
if his.is_empty():
continue
same += 1
if _p(his.pos).distance_to(mine.global_position) < 3.0:
close += 1
check(same >= mini(CN.puppets.size(), life.puppets.size()) - 1 and same >= 3,
"Bob sees the same ones (%d in common of %d / %d)" % [same, CN.puppets.size(), life.puppets.size()])
check(close >= same - 1, "in the same places (%d of %d within 3 m)" % [close, same])
## A boar on the server, `dist` metres in front of Alice. Returns her view of it.
func _boar(dist: float) -> Node3D:
var fwd := -player.global_basis.z
fwd.y = 0.0
fwd = fwd.normalized()
var at := player.global_position + fwd * dist
at.y = world.terrain.sample_height(at.x, at.z)
var before: Array = CN.puppets.keys()
PN.ask("debug_spawn", ["boar", at])
var found := {"c": null} # (lambdas capture locals by value: use a dictionary)
await _until(func():
for cid in CN.puppets:
if not cid in before and CN.puppets[cid].kind == "boar":
found.c = CN.puppets[cid]
return true
return false, 3.0)
return found.c
func _club() -> void:
print("a club and a boar")
var site: Vector3 = world.build.debug_find_site(player.global_position + Vector3(10, 0, 0), 3.0)
await _tp(site)
await _give("club", 1)
player.select(_slot_of("club"))
await _sleep(0.3)
var boar := await _boar(2.2)
check(boar != null, "a boar on the server shows up in front of Alice")
if boar == null:
return
var cid := int(boar.get_meta("cid"))
var hurts0: int = PN.hurts
var swings := 0
var t := 0.0
while not boar.is_dead() and t < 15.0:
var to: Vector3 = boar.global_position - player.global_position
player.rotation.y = atan2(-to.x, -to.z)
if to.length() > 2.2:
await Bot.walk(player, boar.global_position - to.normalized() * 1.6, world)
A.melee(player, boar)
swings += 1
await _sleep(0.6)
t += 0.6
check(boar.is_dead(), "Alice clubs it to death on the server (%d swings)" % swings)
check(PN.hurts > hurts0, "it fought back: the server's boar gored her (%d hits)" % (PN.hurts - hurts0))
var life := await _bob({"do": "life"})
var his: Dictionary = life.puppets.get(str(cid), {})
check(String(his.get("state", "")) == "dead", "Bob sees the carcass too")
var meat0: int = player.inventory.count("raw_meat")
A.butcher(player, boar)
await _until(func(): return player.inventory.count("raw_meat") > meat0, 2.0)
check(player.inventory.count("raw_meat") >= meat0 + 3 and player.inventory.count("hide") >= 1,
"she butchers it: %d meat and %d hide from the server" % [player.inventory.count("raw_meat") - meat0, player.inventory.count("hide")])
await _until(func(): return not CN.puppets.has(cid), 3.0)
check(not CN.puppets.has(cid), "and the carcass is gone")
func _bow() -> void:
print("a bow")
PN.ask("debug_stats", [{"health": 100.0, "stamina": 100.0}])
await _give("bow", 1)
await _give("arrow", 10)
player.select(_slot_of("bow"))
await _sleep(0.3)
var boar := await _boar(9.0)
check(boar != null, "another boar, 9 m off")
if boar == null:
return
var cid := int(boar.get_meta("cid"))
var seen0: int = CN.projectiles_seen
var bob0: Dictionary = await _bob({"do": "life"})
var shots := 0
while boar.hp >= 60.0 - 0.01 and shots < 4:
var aim: Vector3 = (boar.global_position + Vector3(0, 0.55, 0)) - (player.global_position + Vector3(0, 1.6, 0))
player.rotation.y = atan2(-aim.x, -aim.z)
if A.can_draw(player):
A.shoot(player, Vector3.ZERO, aim.normalized(), 1.0, world)
shots += 1
await _sleep(1.0)
check(boar.hp < 60.0, "an arrow hits it (%d shot%s, it's at %.0f HP on Alice's screen)" % [shots, "" if shots == 1 else "s", boar.hp])
check(player.inventory.count("arrow") == 10 - shots, "the server took %d arrow%s from her quiver" % [shots, "" if shots == 1 else "s"])
check(CN.projectiles_seen > seen0, "she saw her arrow fly")
var life := await _bob({"do": "life"})
check(int(life.projectiles) > int(bob0.projectiles), "so did Bob")
var his: Dictionary = life.puppets.get(str(cid), {})
check(float(his.get("hp", 60.0)) < 60.0, "and on his screen it's hurt too (%.0f HP)" % float(his.get("hp", 60.0)))
func _fishing() -> void:
print("fishing")
var lake: Vector2 = world.terrain.lake_center
var c3 := Vector3(lake.x, 0, lake.y)
var dir := (player.global_position - c3)
dir.y = 0.0
dir = dir.normalized()
var shore := c3
for i in range(200):
shore = c3 + dir * float(i)
if world.terrain.sample_height(shore.x, shore.z) > world.terrain.water_level + 0.4:
break
shore += dir * 1.0
shore.y = world.terrain.sample_height(shore.x, shore.z)
await _tp(shore)
player.rotation.y = atan2(dir.x, dir.z) # facing the water
await _give("fishing_rod", 1)
player.select(_slot_of("fishing_rod"))
await _sleep(0.3)
var caught := false
var tries := 0
while not caught and tries < 3:
tries += 1
A.cast_line(player, Vector3.ZERO, -dir + Vector3(0, -0.1, 0), world)
await _until(func(): return world.bobber != null and world.bobber.state == "floating", 4.0)
if tries == 1:
check(world.bobber != null and world.bobber.state == "floating", "Alice casts: the server's float lands on the lake, and she sees it")
var life := await _bob({"do": "life"})
check((life.floats as Array).any(func(f): return int(f) == Net.local_peer), "Bob sees her float too")
await _until(func(): return world.bobber != null and world.bobber.state == "biting", 25.0)
if world.bobber != null and world.bobber.state == "biting":
var fish0: int = player.inventory.count("perch") + player.inventory.count("trout")
A.reel(player, world)
await _until(func(): return player.inventory.count("perch") + player.inventory.count("trout") > fish0, 2.0)
caught = player.inventory.count("perch") + player.inventory.count("trout") > fish0
check(caught, "a bite, she reels in, and the server gives her the fish (%d cast%s)" % [tries, "" if tries == 1 else "s"])
player.select(0)
await _sleep(0.5)
check(world.bobber == null, "the line is in")
func _sleeping() -> void:
print("sleeping (the server's rule: everyone online in bed)")
PN.ask("debug_time", [1, 21.0])
var site: Vector3 = world.build.debug_find_site(player.global_position + Vector3(0, 0, 14), 3.0)
var n0: int = WN.pieces.size()
PN.ask("debug_hut", [site])
await _until(func(): return _beds().size() >= 2, 8.0)
var beds := _beds()
check(beds.size() >= 2 and WN.pieces.size() > n0 + 10, "a hut with two beds (%d pieces)" % (WN.pieces.size() - n0))
if beds.size() < 2:
return
await _tp(beds[0].global_position + Vector3(0, 0.1, 0.9))
await _bob({"do": "tp", "to": _v(beds[1].global_position + Vector3(0, 0.1, 0.9))})
check(world.day_night.hours() > 20.5, "it's night (%s)" % world.day_night.clock_text())
_toasts.clear()
A.use_bed(player, beds[0])
await _sleep(0.5)
A.use_bed(player, beds[0])
await _sleep(1.0)
check(_toasts.any(func(t): return t.begins_with("You're in bed (1 of 2")), "Alice gets into bed: 1 of 2 asleep (%s)" % [_toasts])
check(world.day_night.hours() > 20.5, "and the night goes on, because Bob's still up")
await _bob({"do": "bed", "nid": WN.nid(beds[1])})
await _bob({"do": "bed", "nid": WN.nid(beds[1])})
await _until(func(): return world.day_night.hours() < 7.0, 3.0)
check(world.day_night.hours() >= 5.9 and world.day_night.hours() < 7.0 and world.day_night.day == 2,
"Bob gets into bed too, and the night passes for both (%s, day %d)" % [world.day_night.clock_text(), world.day_night.day])
await _until(func(): return "Rested" in player.survival.statuses, 2.0)
check(_toasts.has("You slept until morning") and "Rested" in player.survival.statuses, "Alice wakes rested")
var life := await _bob({"do": "life"})
check(float(life.hours) >= 5.9 and float(life.hours) < 7.0, "Bob's clock says morning too (%.1f h)" % float(life.hours))
func _beds() -> Array:
return WN.pieces.values().filter(func(p): return is_instance_valid(p) and p.item_id == "bed")
func _wolves() -> void:
print("a wolf pack hunts both players")
PN.ask("debug_time", [5, 12.0]) # by day the pack moves into the den...
await _sleep(1.5)
PN.ask("debug_time", [5, 22.0]) # ...and at night it hunts
PN.ask("debug_stats", [{"health": 100.0, "warmth": 80.0}])
await _bob({"do": "ask", "what": "debug_stats", "args": [{"health": 100.0, "warmth": 80.0}]})
var den: Node3D = world.den
check(den.found, "this island has a den (seed %d)" % SEED)
if not den.found:
return
var out: Vector3 = Basis(Vector3.UP, den.yaw) * Vector3(0, 0, 1)
var side := out.cross(Vector3.UP)
var a: Vector3 = den.mouth_pos() + out * 16.0
var b: Vector3 = den.mouth_pos() + out * 16.0 + side * 10.0
a.y = world.terrain.sample_height(a.x, a.z)
b.y = world.terrain.sample_height(b.x, b.z)
await _tp(a)
await _bob({"do": "tp", "to": _v(b)})
var a0: int = PN.hurts
var life0 := await _bob({"do": "life"})
var b0 := int(life0.hurts)
var t := 0.0
var bob_hurts := b0
while t < 45.0 and (PN.hurts == a0 or bob_hurts == b0):
await _sleep(1.0)
t += 1.0
if int(t) % 3 == 0:
var l := await _bob({"do": "life"})
bob_hurts = int(l.hurts)
var wolves: Array = CN.puppets.values().filter(func(c): return c.kind == "wolf")
check(wolves.size() >= 2, "Alice sees the pack: %d wolves, from the server" % wolves.size())
check(PN.hurts > a0, "the wolves bite Alice (%d)" % (PN.hurts - a0))
check(bob_hurts > b0, "and Bob, 10 m away, at the same time (%d), after %.0f s" % [bob_hurts - b0, t])
check(player.survival.health < 100.0, "Alice's health is down on her screen (%.0f)" % player.survival.health)
# --- helpers --------------------------------------------------------------------
func _tp(at: Vector3) -> void:
PN.ask("debug_tp", [at + Vector3(0, 0.05, 0)])
await _until(func(): return player.global_position.distance_to(at) < 1.0, 2.0)
player.set_physics_process(false)
await _sleep(0.3)
func _give(id: String, n: int) -> int:
var before: int = player.inventory.count(id)
PN.ask("debug_give", [id, n])
await _until(func(): return player.inventory.count(id) >= before + n, 3.0)
return _slot_of(id)
func _slot_of(id: String) -> int:
for i in range(player.inventory.size()):
var s = player.inventory.get_slot(i)
if s != null and s.id == id:
return i
return -1
func _until(cond: Callable, limit: float) -> bool:
var t := 0.0
while not cond.call() and t < limit:
await _sleep(0.05)
t += 0.05
return cond.call()
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(cmd: Dictionary) -> Dictionary:
_cmd += 1
var f := FileAccess.open("%s/cmd_%d.json" % [BOB, _cmd], FileAccess.WRITE)
f.store_string(JSON.stringify(cmd))
f.close()
return await _bob_result(_cmd, 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, "puppets": {}, "floats": [],
"projectiles": 0, "hurts": 0, "hours": -1.0}
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
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)

View File

@@ -68,7 +68,7 @@ func _body() -> void:
var sp: Vector3 = Net.world_info.spawn var sp: Vector3 = Net.world_info.spawn
check(Vector2(player.global_position.x - sp.x, player.global_position.z - sp.z).length() < 1.0, "Alice starts where the server put her") check(Vector2(player.global_position.x - sp.x, player.global_position.z - sp.z).length() < 1.0, "Alice starts where the server put her")
check(not world.creatures.enabled and world.creatures.alive().is_empty() and world.wolves == null, check(not world.creatures.enabled and world.creatures.alive().is_empty() and world.wolves == null,
"no local-only animals while online (the server will run them in M2.5)") "no animals of her game's own while online (the server runs them: test_net_life.gd)")
check(world.day_night.day == int(Net.world_info.day) and absf(world.day_night.time - float(Net.world_info.time)) < 0.01, check(world.day_night.day == int(Net.world_info.day) and absf(world.day_night.time - float(Net.world_info.time)) < 0.01,
"the clock follows the server (%s)" % world.day_night.clock_text()) "the clock follows the server (%s)" % world.day_night.clock_text())