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