132 lines
4.5 KiB
GDScript
132 lines
4.5 KiB
GDScript
extends Node3D
|
|
## Keeps a few animals around the player, within a budget per kind.
|
|
##
|
|
## Animals spawn in a ring 30-70 m out, out of the way, and despawn once
|
|
## they're more than 100 m away. Each kind has its own habitat: rabbits in
|
|
## meadows, deer at forest edges, boars deep in the woods. Animals aren't
|
|
## saved: like Valheim, the wildlife simply carries on around you, and a
|
|
## loaded world repopulates within a few seconds.
|
|
|
|
const CreatureScript = preload("res://scripts/creatures/creature.gd")
|
|
|
|
const BUDGET := {"rabbit": 6, "deer": 4, "boar": 3}
|
|
const RING_MIN := 30.0
|
|
const RING_MAX := 70.0
|
|
const DESPAWN := 100.0
|
|
const RESPAWN_DELAY := 25.0 ## per kind, after one dies or wanders off
|
|
const CHECK_EVERY := 2.0
|
|
|
|
var world: Node3D
|
|
var terrain: Node3D
|
|
var props: Node3D
|
|
var enabled := true
|
|
var _rng := RandomNumberGenerator.new()
|
|
var _check := 0.0
|
|
var _cooldown := {"rabbit": 0.0, "deer": 0.0, "boar": 0.0}
|
|
var _serial := 0
|
|
|
|
func setup(w: Node3D) -> void:
|
|
world = w
|
|
terrain = w.terrain
|
|
props = w.props
|
|
_rng.seed = terrain.noise_seed + 901
|
|
|
|
func alive(kind: String = "") -> Array:
|
|
var out := []
|
|
for c in get_children():
|
|
if c.is_in_group("creature") and (kind.is_empty() or c.kind == kind):
|
|
out.append(c)
|
|
return out
|
|
|
|
## Fills the budget straight away (world start), closer in than usual so
|
|
## there's something to see, but not on top of the player.
|
|
func populate(around: Vector3 = Vector3.INF) -> void:
|
|
for kind in BUDGET:
|
|
for i in range(BUDGET[kind]):
|
|
_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:
|
|
if not enabled:
|
|
return
|
|
for k in _cooldown:
|
|
_cooldown[k] = maxf(_cooldown[k] - delta, 0.0)
|
|
_check -= delta
|
|
if _check > 0.0:
|
|
return
|
|
_check = CHECK_EVERY
|
|
# 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():
|
|
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"):
|
|
_cooldown[c.kind] = RESPAWN_DELAY * 0.3
|
|
c.queue_free()
|
|
for kind in BUDGET:
|
|
if _cooldown[kind] > 0.0:
|
|
continue
|
|
if alive(kind).size() < budget(kind, everyone.size()):
|
|
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
|
|
|
|
## 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:
|
|
_cooldown[c.kind] = RESPAWN_DELAY
|
|
|
|
## Habitat preference, 0..1, at a spot (forest density from the scenery).
|
|
func _habitat(kind: String, x: float, z: float) -> float:
|
|
var f: float = props._forest(x, z)
|
|
match kind:
|
|
"rabbit": return 1.0 - smoothstep(0.2, 0.6, f)
|
|
"deer": return 1.0 - absf(f - 0.45) * 1.6
|
|
_: return smoothstep(0.35, 0.75, f)
|
|
|
|
func _spawn_near(kind: String, rmin: float, rmax: float, around: Vector3 = Vector3.INF) -> Node3D:
|
|
var pp: Vector3 = around if around != Vector3.INF else world.player.global_position
|
|
for _try in range(40):
|
|
var a := _rng.randf() * TAU
|
|
var r := _rng.randf_range(rmin, rmax)
|
|
var x := pp.x + cos(a) * r
|
|
var z := pp.z + sin(a) * r
|
|
if not terrain.inside_boundary(x, z, 10.0):
|
|
continue
|
|
var h: float = terrain.sample_height(x, z)
|
|
if h < terrain.water_level + 0.6 or h > terrain.snow_height - 4.0:
|
|
continue
|
|
if terrain.sample_normal(x, z).y < 0.85:
|
|
continue
|
|
if _rng.randf() > 0.15 + _habitat(kind, x, z) * 0.85:
|
|
continue
|
|
return spawn(kind, Vector3(x, h, z))
|
|
return null
|
|
|
|
func spawn(kind: String, pos: Vector3) -> Node3D:
|
|
var c: AnimatableBody3D = CreatureScript.new()
|
|
_serial += 1
|
|
c.setup(kind, world, pos, _rng.randi() + _serial)
|
|
add_child(c)
|
|
c.global_position = Vector3(pos.x, terrain.sample_height(pos.x, pos.z), pos.z)
|
|
c.died.connect(_on_died)
|
|
return c
|