M1d: creatures, hunting, weapons, food buffs and the hide cloak

- Rabbits, deer and boars with procedural meshes and animated legs, a
  state machine (idle/wander/flee/charge/dead) and heightmap steering that
  avoids water, cliffs, the world edge, trees and buildings
- Spawner keeps a habitat-based budget around the player (6/4/3)
- Melee needs a weapon or tool (club, spear, axe, pickaxe); box sweep hits
- Wooden spear (thrust or throw) and crude bow with draw-scaled arrows;
  projectiles stick in the world as saved pickups
- Carcasses: E to butcher for meat, hide and embedded arrows
- Boars charge and gore when hit; hurt flash and a brief healing pause
- Cooked meat gives Well fed (faster stamina and healing); buffs saved
- Hide cloak: wear for warmth, saved, goes in the grave on death
- Feathers as meadow pickups (appended, existing save ids unchanged)
- New test_creatures_combat suite; run_all.sh gets a per-suite time limit
This commit is contained in:
Paul Bailey
2026-11-04 10:55:44 +00:00
parent fed96ef118
commit 67bf85523d
22 changed files with 1782 additions and 33 deletions

View File

@@ -0,0 +1,358 @@
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]}},
}
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 _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)
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 = 1.5 if k == "deer" else 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 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)
_think(dt)
_move(dt)
_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, global_position)
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
var dir := Vector3(-sin(yaw), 0, -cos(yaw))
if _blocked(dir):
# Turn away; fleeing animals turn harder.
var turn := rng.randf_range(0.8, 2.2) * (1.0 if rng.randf() < 0.5 else -1.0)
target_yaw = yaw + turn
yaw += turn * 0.35
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:
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 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
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
# 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:
c.material_override = m
for g in c.get_children():
if g is MeshInstance3D:
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)