P3 bear: one bear per island raids unattended food, palisades and closed huts keep it out, warns then charges, hide/fat/meat, fur rug (Cozy), death saved (protocol 8)

This commit is contained in:
Paul Bailey
2026-11-05 07:51:07 +00:00
parent fc58c8d0e1
commit 96aac29606
19 changed files with 1002 additions and 12 deletions

216
scripts/creatures/bear.gd Normal file
View File

@@ -0,0 +1,216 @@
extends "res://scripts/creatures/creature.gd"
## The bear (docs/PROGRESSION_DESIGN.md §4): one per island, living in the
## deep forest by the rim (bear_range.gd finds the spot and brings it back
## after it's killed).
##
## By day it ambles round its patch. From day 3 it sniffs out food left
## unattended: food in a chest, or lying on the ground, with nobody about.
## It walks over, eats every bit of food (nothing else) and wanders off. A
## palisade ring or a closed hut keeps it out: it can't get at food it can't
## walk to, and it doesn't break things.
##
## It won't start a fight, but it won't be crowded: closer than 8 m it rears
## up and warns you, closer than 6 m after that and it charges. Hit it and it
## charges too. Badly hurt, it runs home. At night it sleeps at its home.
const WARN_DIST := 8.0
const CHARGE_DIST := 6.0
const EAT_SECONDS := 8.0
const RAID_COOLDOWN := 40.0 ## after a meal, before it goes looking again
const SNIFF_EVERY := 2.0
const STUCK_GIVE_UP := 15.0 ## no closer for this long: forget that food for a while
var range_: Node ## bear_range.gd
var food: Node3D = null ## what it's going for
var _raid_cd := 0.0
var _sniff := 0.0
var _eat_t := 0.0
var _stuck_t := 0.0
var _last_d := INF
var _skip := {} ## food instance id -> seconds to ignore it
var _goal_d := 0.0
func display_name() -> String:
return def.name
func interact_prompt(p: Node) -> String:
if is_dead():
return "Bear carcass [E] Butcher"
var s := "Bear"
match state:
"rest": s = "Bear (asleep)"
"raid": s = "Bear (following its nose)"
"eat": s = "Bear (eating)"
"warn": s = "Bear (warning you off!)"
"charge": s = "Bear (angry!)"
"flee": s = "Bear (running off)"
if not Actions.melee_stats(p).armed and not ItemDB.get_item(p.held_item_id()).has("bow"):
s += " (you need a weapon)"
return s
func _think(dt: float) -> void:
var pp: Vector3 = player.global_position
var d := global_position.distance_to(pp)
_state_t -= dt
_raid_cd = maxf(_raid_cd - dt, 0.0)
for k in _skip.keys():
_skip[k] = float(_skip[k]) - dt
if float(_skip[k]) <= 0.0:
_skip.erase(k)
if state == "flee":
var away := global_position - _threat
steer(atan2(-away.x, -away.z))
if _state_t <= 0.0:
state = "wander"
return
if _aggro > 0.0:
if player.survival.dead or d > 40.0:
_aggro = 0.0
state = "wander"
else:
state = "charge"
var to := pp - global_position
steer(atan2(-to.x, -to.z))
if d < def.attack_range and _attack_cd <= 0.0 and _can_reach(player):
_attack_cd = def.attack_cd
player.survival.damage(def.damage * world.difficulty_mult("damage"), global_position, "a bear")
return
if not range_.awake():
_go_home_to_sleep()
return
# Too close: rear up, then charge if you don't back off.
if d < WARN_DIST and not player.survival.dead and _can_reach(player):
var to := pp - global_position
steer(atan2(-to.x, -to.z))
if state != "warn":
state = "warn"
_state_t = 2.5
elif _state_t <= 0.0 and d < CHARGE_DIST:
_aggro = 12.0
_attack_cd = 0.3
state = "charge"
return
if state == "warn":
state = "wander"
match state:
"eat":
_eat_t -= dt
if _eat_t <= 0.0:
state = "wander"
_raid_cd = RAID_COOLDOWN
"raid":
_raid_step(dt)
_:
_sniff -= dt
if _sniff <= 0.0 and _raid_cd <= 0.0 and range_.raids_now():
_sniff = SNIFF_EVERY
var f: Node3D = range_.find_food(self, _skip)
if f != null:
food = f
state = "raid"
_last_d = INF
_stuck_t = 0.0
return
_amble(dt)
func _raid_step(dt: float) -> void:
if food == null or not is_instance_valid(food) or not range_.raidable(food):
food = null
state = "wander"
return
var fp: Vector3 = food.global_position
var fd := Vector2(fp.x - global_position.x, fp.z - global_position.z).length()
if fd < 1.9:
range_.raid(self, food)
food = null
state = "eat"
_eat_t = EAT_SECONDS
_goal_d = 0.0
return
_go_to(fp)
if fd > _last_d - 0.05 * dt * 10.0:
_stuck_t += dt
else:
_stuck_t = 0.0
_last_d = minf(_last_d, fd)
if _stuck_t > STUCK_GIVE_UP:
_skip[food.get_instance_id()] = 120.0
food = null
state = "wander"
## Idles and wanders like any animal, but never far from home.
func _amble(dt: float) -> void:
var home: Vector3 = range_.home
if Vector2(home.x - global_position.x, home.z - global_position.z).length() > range_.HOME_RADIUS:
state = "home"
_go_to(home)
return
if state == "home":
state = "wander"
_state_t = rng.randf_range(3.0, 8.0)
if _state_t <= 0.0:
if state == "idle":
state = "wander"
target_yaw = yaw + rng.randf_range(-1.6, 1.6)
_state_t = rng.randf_range(4.0, 9.0)
else:
state = "idle"
_state_t = rng.randf_range(3.0, 7.0)
func _go_home_to_sleep() -> void:
var home: Vector3 = range_.home
if Vector2(home.x - global_position.x, home.z - global_position.z).length() > 2.0:
state = "home"
_go_to(home)
else:
state = "rest"
func _go_to(p: Vector3) -> void:
var to := p - global_position
to.y = 0.0
if to.length() > 0.3:
steer(atan2(-to.x, -to.z))
_goal_d = to.length()
func _desired_speed() -> float:
match state:
"wander": return def.walk
"raid": return 0.0 if _goal_d < 1.0 else 3.2
"home": return 0.0 if _goal_d < 1.0 else 3.0
"charge", "flee": return def.run
return 0.0
## (The chest it's going for isn't in its way: it walks right up to it.)
func _blocked(dir: Vector3) -> bool:
if state == "raid" and food != null and is_instance_valid(food) and global_position.distance_to(food.global_position) < 3.5:
var probe := global_position + dir * 1.4
var h: float = terrain.sample_height(probe.x, probe.z)
return h < terrain.water_level + 0.2 or absf(h - global_position.y) > 1.1
return super._blocked(dir)
## Rearing up on its hind legs when it warns you (also on puppets online).
func _animate(dt: float) -> void:
if state == "warn":
_visual.rotation.x = lerpf(_visual.rotation.x, 0.6, minf(dt * 4.0, 1.0))
_visual.position.y = lerpf(_visual.position.y, 0.25, minf(dt * 4.0, 1.0))
return
super._animate(dt)
## No wall, door or rock between the bear and `p`.
func _can_reach(p: Node3D) -> bool:
var from := global_position + Vector3(0, 0.9, 0)
var to: Vector3 = p.global_position + Vector3(0, 1.0, 0)
var q := PhysicsRayQueryParameters3D.create(from, to, 1)
q.exclude = [get_rid(), p.get_rid(), terrain.get_node("TerrainBody").get_rid()]
return get_world_3d().direct_space_state.intersect_ray(q).is_empty()
func _react(from: Vector3) -> void:
if hp < def.hp * 0.25:
_aggro = 0.0
_threat = from
state = "flee"
_state_t = 10.0
return
_aggro = 25.0
_attack_cd = maxf(_attack_cd, 0.4)
state = "charge"