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

View File

@@ -0,0 +1,211 @@
extends Node3D
## The bear's patch (docs/PROGRESSION_DESIGN.md §4): where the island's one
## bear lives, bringing it back after it's killed, and the food it can get at.
##
## Home is the densest forest near the rim, away from the spawn and the wolf
## den, picked from the seed (the same on every machine). The bear is there
## from day 1; raids start on day 3. Killed, it's back RESPAWN_DAYS later,
## at dawn, when nobody's near its home.
##
## Food it can raid: a chest with food in it, or food left on the ground,
## within SMELL of the bear, with nobody within GUARD of it, and not fenced
## in. Fenced in = every one of 32 level rays from it hits a building piece
## (a palisade ring, the walls of a closed hut; an open door lets it in).
const BearScript = preload("res://scripts/creatures/bear.gd")
const RAID_DAY := 3
const RESPAWN_DAYS := 3
const SMELL := 60.0
const GUARD := 12.0
const HOME_RADIUS := 35.0
const FENCE_RAY := 25.0
const FENCE_RAYS := 32 ## fine enough that an open doorway lets one through
signal raided(target: Node3D, eaten: int)
var world: Node3D
var home := Vector3.ZERO
var found := false
var bear: Node3D = null
var enabled := true
var dead_day := -1 ## the day it was killed (-1: alive or never)
var _rng := RandomNumberGenerator.new()
func setup(w: Node3D) -> void:
world = w
_rng.seed = w.terrain.noise_seed + 881
found = _find_home()
if found:
spawn_bear()
## The densest stand of trees near the rim, at least 60 m from the spawn
## and the den.
func _find_home() -> bool:
var terrain: Node3D = world.terrain
var trees: Array = []
for n in world.props.resource_nodes.values():
if n.kind == "tree":
trees.append((n as Node3D).global_position)
if trees.is_empty():
return false
var spawn: Vector3 = world._spawn
var den_at: Vector3 = world.den.position if world.den != null and world.den.found else Vector3(1e9, 0, 1e9)
var best := -1.0
var r := RandomNumberGenerator.new()
r.seed = _rng.seed
for _i in range(120):
var c: Vector3 = trees[r.randi() % trees.size()]
var rim: float = world.props._near_rim(c.x, c.z)
if rim < 0.1 or rim > 0.9 or not terrain.inside_boundary(c.x, c.z, 10.0):
continue
if Vector2(c.x - spawn.x, c.z - spawn.z).length() < 60.0 or Vector2(c.x - den_at.x, c.z - den_at.z).length() < 60.0:
continue
if terrain.sample_height(c.x, c.z) < terrain.water_level + 1.0:
continue
var near := 0
for t: Vector3 in trees:
if absf(t.x - c.x) < 18.0 and absf(t.z - c.z) < 18.0:
near += 1
var score := near + rim * 6.0
if score > best:
best = score
# Stand just beside the tree, not in its trunk.
home = c + Vector3(1.6, 0, 0)
home.y = terrain.sample_height(home.x, home.z)
return best > 0.0
func spawn_bear() -> Node3D:
var b: AnimatableBody3D = BearScript.new()
b.setup("bear", world, home, _rng.randi())
b.range_ = self
b.state = "rest" if not awake() else "idle"
add_child(b)
b.died.connect(func(_c): dead_day = world.day_night.day)
bear = b
dead_day = -1
return b
func alive() -> bool:
return bear != null and is_instance_valid(bear) and not bear.is_dead()
## Saved with the world: whether it's dead (and since when), and how hurt
## it is. Where it is isn't kept: it goes home over a reload.
func save_data() -> Dictionary:
if not found:
return {}
return {"dead_day": dead_day, "hp": float(bear.hp) if alive() else 0.0}
func load_data(d: Dictionary) -> void:
if not found or d.is_empty():
return
var dd := int(d.get("dead_day", -1))
if dd >= 0:
if bear != null and is_instance_valid(bear):
bear.queue_free()
bear = null
dead_day = dd
return
if not alive():
spawn_bear()
var hp := float(d.get("hp", 0.0))
if hp > 0.0:
bear.hp = minf(hp, float(bear.def.hp))
func awake() -> bool:
var h: float = world.day_night.hours()
return h >= 6.0 and h < 19.5
func raids_now() -> bool:
return world.day_night.day >= RAID_DAY and awake()
func _physics_process(_delta: float) -> void:
if enabled:
step()
## Brings the bear back a few days after it was killed (at dawn, unseen).
func step() -> void:
if not found or alive() or dead_day < 0:
return
var h: float = world.day_night.hours()
if world.day_night.day >= dead_day + RESPAWN_DAYS and h >= 6.0 and h < 9.0:
for p in world.players():
if p.global_position.distance_to(home) < 40.0:
return
spawn_bear()
# --- food --------------------------------------------------------------------------
static func is_food(id: String) -> bool:
return ItemDB.get_item(id).has("food")
## Food that's worth the bear's while: `target` still has food in it.
func has_food(target: Node3D) -> bool:
if target.get("inventory") != null:
for s in target.inventory.slots:
if s != null and is_food(s.id):
return true
return false
return target.get("item_id") != null and is_food(target.item_id) and not target.taken
## Food the bear could go for now: has food, nobody guarding it, not fenced in.
func raidable(target: Node3D) -> bool:
if not is_instance_valid(target) or target.is_queued_for_deletion() or not has_food(target):
return false
for p in world.players():
if p.global_position.distance_to(target.global_position) < GUARD and not p.survival.dead:
return false
return not fenced(target.global_position)
## Walled in on every side (a palisade ring, a closed hut).
func fenced(pos: Vector3) -> bool:
var from := pos + Vector3(0, 0.6, 0)
var space := world.get_world_3d().direct_space_state
for i in range(FENCE_RAYS):
var a := TAU * i / FENCE_RAYS
var q := PhysicsRayQueryParameters3D.create(from, from + Vector3(cos(a), 0, sin(a)) * FENCE_RAY, world.BuildPiece.BUILD_LAYER)
if space.intersect_ray(q).is_empty():
return false
return true
## The nearest food `b` can smell and get at (null: none).
func find_food(b: Node3D, skip: Dictionary = {}) -> Node3D:
var best: Node3D = null
var best_d := SMELL
var cands: Array = []
for p in world.all_pieces():
if p.item_id == "chest":
cands.append(p)
for d in world.all_dropped():
if is_food(d.item_id):
cands.append(d)
for c in cands:
if skip.has(c.get_instance_id()):
continue
var dist: float = c.global_position.distance_to(b.global_position)
if dist < best_d and raidable(c):
best_d = dist
best = c
return best
## The bear eats every bit of food in `target`. Tells anyone nearby.
func raid(b: Node3D, target: Node3D) -> int:
var eaten := 0
var what := ""
if target.get("inventory") != null:
var inv: Inventory = target.inventory
for i in range(inv.size()):
var s = inv.get_slot(i)
if s != null and is_food(s.id):
eaten += int(s.count)
inv.set_slot(i, null)
what = "A bear has broken into your chest and eaten the food!"
else:
eaten = int(target.amount)
target.collected()
what = "A bear is eating the food you left out!"
for p in world.players():
if p.global_position.distance_to(target.global_position) < 80.0:
Actions.tell(p, what)
raided.emit(target, eaten)
return eaten