457 lines
14 KiB
GDScript
457 lines
14 KiB
GDScript
extends AnimatableBody3D
|
|
## A wild animal: rabbit, deer or boar.
|
|
##
|
|
## Movement is steering over the heightmap, with no navmesh. The animal walks
|
|
## toward a heading, probes the ground ahead (water, cliffs, the world edge,
|
|
## trees, rocks and buildings) and turns away from anything it can't cross.
|
|
##
|
|
## States:
|
|
## - idle: stands and grazes.
|
|
## - wander: ambles in one direction.
|
|
## - flee: runs away from a threat. Rabbits zig-zag.
|
|
## - charge: a hit boar runs at you and gores you.
|
|
## - dead: a carcass. E butchers it for meat and hide, and gives back any
|
|
## arrows stuck in it.
|
|
##
|
|
## step(dt) holds all the logic so tests can drive it directly. Lives on
|
|
## physics layer 4 (value 8): hit by weapons and arrows, but the player walks
|
|
## through it. A carcass moves to layer 2, like other interactables.
|
|
|
|
signal died(creature: Node)
|
|
|
|
const Meshes = preload("res://scripts/creatures/creature_meshes.gd")
|
|
const LAYER := 8
|
|
|
|
## Tuning per kind. Speeds in m/s (the player walks at 5 and sprints at 8).
|
|
const KINDS := {
|
|
"rabbit": {"name": "Rabbit", "hp": 10.0, "walk": 1.4, "run": 7.0, "turn": 9.0,
|
|
"notice": 7.0, "notice_loud": 12.0, "calm": 18.0,
|
|
"drops": {"raw_meat": [1, 1]}},
|
|
"deer": {"name": "Deer", "hp": 40.0, "walk": 1.3, "run": 9.0, "turn": 4.5,
|
|
"notice": 16.0, "notice_loud": 30.0, "calm": 45.0,
|
|
"drops": {"raw_meat": [2, 3], "hide": [1, 2]}},
|
|
"boar": {"name": "Boar", "hp": 60.0, "walk": 1.2, "run": 6.6, "turn": 5.0,
|
|
"notice": 0.0, "notice_loud": 0.0, "calm": 30.0,
|
|
"damage": 14.0, "attack_range": 1.7, "attack_cd": 1.6, "aggro": 18.0,
|
|
"drops": {"raw_meat": [3, 4], "hide": [1, 2]}},
|
|
# Wolves are driven by wolf.gd + wolf_pack.gd; "notice" is unused.
|
|
"wolf": {"name": "Wolf", "hp": 45.0, "walk": 1.6, "run": 8.6, "turn": 6.0,
|
|
"notice": 0.0, "notice_loud": 0.0, "calm": 40.0,
|
|
"damage": 9.0, "attack_range": 1.8, "attack_cd": 1.2,
|
|
"drops": {"raw_meat": [1, 2], "wolf_pelt": [1, 1]}},
|
|
}
|
|
const CARCASS_SECONDS := 300.0
|
|
|
|
var kind := "deer"
|
|
var def: Dictionary
|
|
var hp := 40.0
|
|
var state := "idle"
|
|
var world: Node3D
|
|
var terrain: Node3D
|
|
var player: Node3D
|
|
var rng := RandomNumberGenerator.new()
|
|
var embedded := {} ## item id -> count (arrows stuck in it)
|
|
var carcass_left := CARCASS_SECONDS
|
|
|
|
var yaw := 0.0
|
|
var target_yaw := 0.0
|
|
var speed := 0.0
|
|
var _state_t := 0.0
|
|
var _threat := Vector3.ZERO
|
|
var _aggro := 0.0
|
|
var _attack_cd := 0.0
|
|
var _zig := 0.0
|
|
var _phase := 0.0
|
|
var _visual: Node3D
|
|
var _legs: Array[Node3D] = []
|
|
var _flash := 0.0
|
|
var _detour_t := 0.0 ## > 0: following a detour round an obstacle
|
|
var _detour_side := 1.0
|
|
|
|
## Sets the heading, unless the animal is in the middle of a detour.
|
|
func steer(y: float) -> void:
|
|
if _detour_t <= 0.0:
|
|
target_yaw = y
|
|
var _mat: StandardMaterial3D
|
|
var _hit_mat: StandardMaterial3D
|
|
|
|
func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void:
|
|
kind = k
|
|
def = KINDS[k]
|
|
hp = def.hp
|
|
world = w
|
|
terrain = w.terrain
|
|
player = w.player
|
|
rng.seed = seed
|
|
name = "%s_%d" % [k, absi(seed) % 100000]
|
|
collision_layer = LAYER
|
|
collision_mask = 0
|
|
sync_to_physics = false
|
|
add_to_group("creature")
|
|
var parts: Dictionary = Meshes.build(k)
|
|
_mat = ItemDB.item_material()
|
|
_hit_mat = StandardMaterial3D.new()
|
|
_hit_mat.vertex_color_use_as_albedo = true
|
|
_hit_mat.vertex_color_is_srgb = true
|
|
_hit_mat.albedo_color = Color(1.6, 0.5, 0.45)
|
|
_visual = Node3D.new()
|
|
add_child(_visual)
|
|
var body := MeshInstance3D.new()
|
|
body.mesh = parts.body
|
|
body.material_override = _mat
|
|
_visual.add_child(body)
|
|
if parts.has("eyes"):
|
|
# Eyes that catch the light: you see a wolf's eyes in the dark first.
|
|
var eyes := MeshInstance3D.new()
|
|
eyes.mesh = parts.eyes
|
|
var em := StandardMaterial3D.new()
|
|
em.albedo_color = Color(1.0, 0.85, 0.4)
|
|
em.emission_enabled = true
|
|
em.emission = Color(1.0, 0.75, 0.3)
|
|
em.emission_energy_multiplier = 2.5
|
|
eyes.material_override = em
|
|
eyes.set_meta("keep_mat", true)
|
|
_visual.add_child(eyes)
|
|
for h: Vector3 in parts.hips:
|
|
var pivot := Node3D.new()
|
|
pivot.position = h
|
|
var leg := MeshInstance3D.new()
|
|
leg.mesh = parts.leg
|
|
leg.material_override = _mat
|
|
pivot.add_child(leg)
|
|
_visual.add_child(pivot)
|
|
_legs.append(pivot)
|
|
var cs := CollisionShape3D.new()
|
|
var cap := CapsuleShape3D.new()
|
|
cap.radius = parts.radius
|
|
cap.height = maxf(parts.height, parts.radius * 2.0 + 0.01)
|
|
cs.shape = cap
|
|
cs.position = Vector3(0, parts.height * 0.5, 0)
|
|
if k != "rabbit":
|
|
# Long bodies: lay the capsule along the spine.
|
|
cap.height = {"deer": 1.5, "boar": 1.4, "wolf": 1.25}.get(k, 1.4)
|
|
cs.rotation.x = PI * 0.5
|
|
cs.position = Vector3(0, parts.height * 0.55, 0)
|
|
add_child(cs)
|
|
yaw = rng.randf() * TAU
|
|
target_yaw = yaw
|
|
_state_t = rng.randf_range(1.0, 4.0)
|
|
position = Vector3(pos.x, terrain.sample_height(pos.x, pos.z), pos.z) # (spawner sits at the origin)
|
|
rotation.y = yaw
|
|
|
|
func display_name() -> String:
|
|
return def.name
|
|
|
|
func is_dead() -> bool:
|
|
return state == "dead"
|
|
|
|
func interact_prompt(p: Node) -> String:
|
|
if is_dead():
|
|
return "%s carcass [E] Butcher" % def.name
|
|
var s: String = def.name
|
|
if kind == "boar":
|
|
s = "Boar (angry!)" if _aggro > 0.0 else "Boar (leaves you alone unless you hit it)"
|
|
var held: Dictionary = ItemDB.get_item(p.held_item_id())
|
|
if not Actions.melee_stats(p).armed and not held.has("bow"):
|
|
s += " (you need a weapon: club, spear, bow or axe)"
|
|
return s
|
|
|
|
func interact(p: Node) -> void:
|
|
if is_dead():
|
|
Actions.butcher(p, self)
|
|
|
|
func _physics_process(delta: float) -> void:
|
|
step(delta)
|
|
|
|
func step(dt: float) -> void:
|
|
if _flash > 0.0:
|
|
_flash -= dt
|
|
if _flash <= 0.0:
|
|
_set_material(_mat)
|
|
if puppet:
|
|
_puppet_step(dt)
|
|
return
|
|
if is_dead():
|
|
carcass_left -= dt
|
|
if carcass_left <= 0.0:
|
|
queue_free()
|
|
return
|
|
_attack_cd = maxf(_attack_cd - dt, 0.0)
|
|
_aggro = maxf(_aggro - dt, 0.0)
|
|
# 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)
|
|
_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
|
|
## fast while swimming or jumping, is loud.
|
|
func _notices_player(d: float) -> bool:
|
|
if def.notice <= 0.0:
|
|
return false
|
|
var loud: bool = player.velocity.length() > 6.0
|
|
return d < (def.notice_loud if loud else def.notice)
|
|
|
|
func _think(dt: float) -> void:
|
|
var pp: Vector3 = player.global_position
|
|
var d := global_position.distance_to(pp)
|
|
_state_t -= dt
|
|
match state:
|
|
"idle", "wander":
|
|
if _notices_player(d):
|
|
_start_flee(pp)
|
|
elif _state_t <= 0.0:
|
|
if state == "idle":
|
|
state = "wander"
|
|
target_yaw = yaw + rng.randf_range(-1.6, 1.6)
|
|
_state_t = rng.randf_range(3.0, 8.0)
|
|
else:
|
|
state = "idle"
|
|
_state_t = rng.randf_range(2.0, 6.0)
|
|
"flee":
|
|
if _notices_player(d):
|
|
_threat = pp
|
|
var away := global_position - _threat
|
|
target_yaw = atan2(-away.x, -away.z) # heading along `away` (forward is -Z)
|
|
if kind == "rabbit":
|
|
_zig -= dt
|
|
if _zig <= 0.0:
|
|
_zig = rng.randf_range(0.3, 0.7)
|
|
target_yaw += rng.randf_range(-0.9, 0.9)
|
|
if d > def.calm and _state_t <= 0.0:
|
|
state = "idle"
|
|
_state_t = rng.randf_range(2.0, 5.0)
|
|
"charge":
|
|
var to := pp - global_position
|
|
target_yaw = atan2(-to.x, -to.z)
|
|
if _aggro <= 0.0 or d > 45.0:
|
|
state = "wander"
|
|
_state_t = 4.0
|
|
elif d < def.attack_range and _attack_cd <= 0.0:
|
|
_attack_cd = def.attack_cd
|
|
player.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "a " + String(def.name).to_lower())
|
|
|
|
func _start_flee(from: Vector3) -> void:
|
|
state = "flee"
|
|
_threat = from
|
|
_state_t = 4.0
|
|
|
|
func _desired_speed() -> float:
|
|
match state:
|
|
"wander": return def.walk
|
|
"flee", "charge": return def.run
|
|
return 0.0
|
|
|
|
func _move(dt: float) -> void:
|
|
var want := _desired_speed()
|
|
speed = move_toward(speed, want, dt * (def.run * 2.5))
|
|
yaw = rotate_toward(yaw, target_yaw, def.turn * dt)
|
|
if speed < 0.01:
|
|
return
|
|
_detour_t = maxf(_detour_t - dt, 0.0)
|
|
var dir := Vector3(-sin(yaw), 0, -cos(yaw))
|
|
if _blocked(dir):
|
|
# Turn away and keep to the new heading for a moment (a detour), so
|
|
# an animal heading somewhere slides round an obstacle instead of
|
|
# turning straight back into it. Keep turning the same way while
|
|
# still blocked.
|
|
if _detour_t <= 0.0:
|
|
_detour_side = 1.0 if rng.randf() < 0.5 else -1.0
|
|
var turn := rng.randf_range(0.8, 1.6) * _detour_side
|
|
target_yaw = yaw + turn
|
|
yaw += turn * 0.35
|
|
_detour_t = rng.randf_range(1.0, 1.8)
|
|
speed *= 0.5
|
|
if state == "charge":
|
|
return
|
|
dir = Vector3(-sin(yaw), 0, -cos(yaw))
|
|
if _blocked(dir):
|
|
return
|
|
var p := global_position + dir * speed * dt
|
|
p.y = terrain.sample_height(p.x, p.z)
|
|
global_position = p
|
|
rotation.y = yaw
|
|
|
|
## Something ahead the animal can't or won't cross.
|
|
func _blocked(dir: Vector3) -> bool:
|
|
var probe := global_position + dir * (1.4 + speed * 0.15)
|
|
if not terrain.inside_boundary(probe.x, probe.z, 6.0):
|
|
return true
|
|
var h: float = terrain.sample_height(probe.x, probe.z)
|
|
if h < terrain.water_level + 0.2:
|
|
return true
|
|
if absf(h - global_position.y) > 1.1:
|
|
return true
|
|
# Trees, rocks and buildings at chest height (the terrain isn't on this test).
|
|
var from := global_position + Vector3(0, 0.45, 0)
|
|
var q := PhysicsRayQueryParameters3D.create(from, Vector3(probe.x, h + 0.45, probe.z), 1)
|
|
q.exclude = [get_rid(), terrain.get_node("TerrainBody").get_rid()]
|
|
return not get_world_3d().direct_space_state.intersect_ray(q).is_empty()
|
|
|
|
func _animate(dt: float) -> void:
|
|
if state == "rest":
|
|
# Lying down: body lowered, legs folded under.
|
|
_visual.position.y = lerpf(_visual.position.y, -0.3, minf(dt * 3.0, 1.0))
|
|
_visual.rotation.x = lerpf(_visual.rotation.x, 0.0, minf(dt * 4.0, 1.0))
|
|
for l in _legs:
|
|
l.rotation.x = lerpf(l.rotation.x, 1.35 * (1.0 if l.position.z < 0.0 else -1.0), minf(dt * 3.0, 1.0))
|
|
return
|
|
var k := speed / maxf(def.run, 0.01)
|
|
_phase += dt * (3.0 + speed * 2.2)
|
|
var amp := clampf(speed / 2.0, 0.0, 1.0) * 0.6
|
|
for i in range(_legs.size()):
|
|
# Diagonal pairs move together (a trot); rabbits hop with both
|
|
# hind legs at once.
|
|
var off := 0.0 if i in [0, 3] else PI
|
|
if kind == "rabbit":
|
|
off = 0.0 if i < 2 else PI * 0.6
|
|
_legs[i].rotation.x = sin(_phase + off) * amp
|
|
_visual.position.y = absf(sin(_phase)) * (0.06 if kind == "rabbit" else 0.03) * k
|
|
if state == "idle" and kind != "rabbit":
|
|
# Grazing: head (whole body) dips a little now and then.
|
|
_visual.rotation.x = lerpf(_visual.rotation.x, 0.08 * maxf(sin(_state_t * 1.3), 0.0), dt * 2.0)
|
|
else:
|
|
_visual.rotation.x = lerpf(_visual.rotation.x, 0.0, dt * 4.0)
|
|
|
|
# --- combat -------------------------------------------------------------------
|
|
|
|
## Applies a hit (Actions validates who hit what). `from` = where it came
|
|
## from, for the reaction.
|
|
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 not embed_id.is_empty():
|
|
embedded[embed_id] = embedded.get(embed_id, 0) + 1
|
|
return
|
|
hp -= damage
|
|
if not embed_id.is_empty():
|
|
embedded[embed_id] = embedded.get(embed_id, 0) + 1
|
|
_flash = 0.15
|
|
_set_material(_hit_mat)
|
|
_burst()
|
|
if hp <= 0.0:
|
|
_die(from)
|
|
return
|
|
_react(from)
|
|
|
|
## How a living animal responds to being hit (wolves override this).
|
|
func _react(from: Vector3) -> void:
|
|
if kind == "boar" and hp > def.hp * 0.3:
|
|
state = "charge"
|
|
_aggro = def.aggro
|
|
_attack_cd = 0.4
|
|
else:
|
|
_start_flee(from)
|
|
_state_t = 6.0
|
|
|
|
func _die(from: Vector3) -> void:
|
|
state = "dead"
|
|
speed = 0.0
|
|
hp = 0.0
|
|
_flash = 0.0
|
|
_set_material(_mat)
|
|
remove_from_group("creature")
|
|
add_to_group("carcass")
|
|
collision_layer = 2
|
|
for l in _legs:
|
|
l.rotation.x = 0.4
|
|
if puppet:
|
|
_visual.rotation.z = -PI * 0.5
|
|
return
|
|
# 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
|
|
if is_inside_tree() and DisplayServer.get_name() != "headless":
|
|
var tw := create_tween()
|
|
tw.tween_property(_visual, "rotation:z", -side * PI * 0.5, 0.45).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
|
|
else:
|
|
_visual.rotation.z = -side * PI * 0.5
|
|
died.emit(self)
|
|
|
|
## What butchering gives (rolled once).
|
|
func butcher_drops() -> Dictionary:
|
|
var out := {}
|
|
for id in def.drops:
|
|
var r: Array = def.drops[id]
|
|
out[id] = rng.randi_range(int(r[0]), int(r[1]))
|
|
for id in embedded:
|
|
out[id] = out.get(id, 0) + int(embedded[id])
|
|
return out
|
|
|
|
func _set_material(m: Material) -> void:
|
|
for c in _visual.get_children():
|
|
if c is MeshInstance3D and not c.has_meta("keep_mat"):
|
|
c.material_override = m
|
|
for g in c.get_children():
|
|
if g is MeshInstance3D and not g.has_meta("keep_mat"):
|
|
g.material_override = m
|
|
|
|
func _burst() -> void:
|
|
if not is_inside_tree() or DisplayServer.get_name() == "headless":
|
|
return
|
|
var p := CPUParticles3D.new()
|
|
var box := BoxMesh.new()
|
|
box.size = Vector3.ONE * 0.05
|
|
var m := StandardMaterial3D.new()
|
|
m.albedo_color = Color(0.55, 0.08, 0.06)
|
|
box.material = m
|
|
p.mesh = box
|
|
p.amount = 12
|
|
p.one_shot = true
|
|
p.explosiveness = 0.95
|
|
p.lifetime = 0.6
|
|
p.spread = 60.0
|
|
p.initial_velocity_min = 1.5
|
|
p.initial_velocity_max = 3.0
|
|
p.gravity = Vector3(0, -10, 0)
|
|
world.add_child(p)
|
|
p.global_position = global_position + Vector3(0, Meshes.build(kind).height * 0.6, 0)
|
|
p.emitting = true
|
|
p.finished.connect(p.queue_free)
|