M2.5: wildlife, wolves, hunting, fishing and sleeping on the server
This commit is contained in:
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
257
scripts/net/creature_net.gd
Normal 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()
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
412
tests/test_net_life.gd
Normal 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)
|
||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user