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

@@ -191,6 +191,134 @@ func use_bed(player: Node3D, bed: Node3D) -> bool:
world.sleep(player)
return true
# --- hunting and combat ---------------------------------------------------------
## Bare hands (or berries, a torch, a stone...) can't hurt an animal: you
## need a weapon, or at least an axe or pickaxe.
const NO_WEAPON := {"damage": 0.0, "reach": 2.0, "stamina": 0.0, "cooldown": 0.45, "armed": false}
const TOOL_AS_WEAPON := 6.0 ## axes and pickaxes are poor weapons
const NEED_WEAPON := "You need a weapon: a club, spear, bow or axe (Tab to craft)"
## Melee stats for what's in the player's hand. "armed" is false when it
## can't hurt an animal.
func melee_stats(player: Node) -> Dictionary:
var held: Dictionary = ItemDB.get_item(player.held_item_id())
var w: Dictionary = held.get("weapon", {})
if float(w.get("damage", 0)) > 0.0:
return {"damage": float(w.damage), "reach": float(w.get("reach", 2.2)),
"stamina": float(w.get("stamina", 8)), "cooldown": float(w.get("cooldown", 0.5)), "armed": true}
if held.has("tool"):
return {"damage": TOOL_AS_WEAPON, "reach": 2.3, "stamina": 6.0, "cooldown": 0.5, "armed": true}
return NO_WEAPON
## One melee blow at an animal.
func melee(player: Node3D, target: Node3D) -> bool:
if not is_instance_valid(target) or not target.is_in_group("creature"):
return false
var m := melee_stats(player)
if not m.armed:
notify.emit(NEED_WEAPON)
return false
if player.global_position.distance_to(target.global_position) > m.reach + 1.8:
return false
if not player.survival.use_stamina(m.stamina):
notify.emit("Too tired")
return false
target.take_hit(m.damage, player.global_position)
if target.is_dead():
notify.emit("Killed a %s" % target.display_name().to_lower())
return true
## An arrow or thrown spear reaching an animal (projectile.gd).
func projectile_hit(shooter: Node3D, target: Node3D, damage: float, at: Vector3, embed_id: String) -> void:
if not is_instance_valid(target) or not target.is_in_group("creature"):
return
var was_alive: bool = not target.is_dead()
target.take_hit(damage, shooter.global_position if shooter != null else at, embed_id)
if was_alive and target.is_dead():
notify.emit("Killed a %s" % target.display_name().to_lower())
## Throws the spear in `slot` from `origin` along `dir`.
func throw_spear(player: Node3D, slot: int, origin: Vector3, dir: Vector3, world: Node3D) -> bool:
var s = player.inventory.get_slot(slot)
if s == null:
return false
var w: Dictionary = ItemDB.get_item(s.id).get("weapon", {})
if not w.has("throw") or player.global_position.distance_to(origin) > 2.5:
return false
if not player.survival.use_stamina(float(w.throw.get("stamina", 12))):
notify.emit("Too tired")
return false
var id: String = s.id
player.inventory.take_from(slot, 1)
world.launch(id, origin, dir.normalized() * float(w.throw.speed) + player.velocity * 0.5,
float(w.throw.damage), player)
return true
## Starts drawing the held bow: needs arrows and a little stamina.
func can_draw(player: Node3D) -> bool:
var bow: Dictionary = ItemDB.get_item(player.held_item_id()).get("bow", {})
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
if not player.survival.use_stamina(float(bow.stamina)):
notify.emit("Too tired")
return false
return true
## 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:
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:
return false
if not player.inventory.remove(bow.ammo, 1):
return false
power = clampf(power, 0.0, 1.0)
var spd: float = lerpf(float(bow.speed[0]), float(bow.speed[1]), power)
var dmg: float = lerpf(float(bow.damage[0]), float(bow.damage[1]), power)
world.launch(bow.ammo, origin, dir.normalized() * spd, dmg, player)
return true
func butcher(player: Node3D, carcass: Node3D) -> bool:
if not is_instance_valid(carcass) or not carcass.is_in_group("carcass") or not _in_reach(player, carcass, 1.0):
return false
var drops: Dictionary = carcass.butcher_drops()
carcass.remove_from_group("carcass")
for id in drops:
var got := _give(player, id, int(drops[id]))
if got < int(drops[id]):
# Inventory full: the rest drops on the ground.
carcass.world.drop_item(id, int(drops[id]) - got, Transform3D(Basis(), carcass.global_position + Vector3(0, 0.1, 0)))
carcass.queue_free()
return true
## Puts on the cloak in `slot` (swapping out any cloak you were wearing).
func wear(player: Node3D, slot: int) -> bool:
var s = player.inventory.get_slot(slot)
if s == null or not ItemDB.get_item(s.id).has("wear"):
return false
var id: String = s.id
player.inventory.take_from(slot, 1)
if not player.worn.is_empty():
_give(player, player.worn, 1)
player.worn = id
player.worn_changed.emit()
notify.emit("Wearing %s" % ItemDB.item_name(id).to_lower())
return true
func take_off(player: Node3D) -> bool:
if player.worn.is_empty():
return false
if player.inventory.space_for(player.worn) < 1:
notify.emit("Inventory full")
return false
_give(player, player.worn, 1)
player.worn = ""
player.worn_changed.emit()
return true
func forage(player: Node3D, bush: Node3D) -> bool:
if not is_instance_valid(bush) or not _in_reach(player, bush) or not bush.ripe():
return false