diff --git a/data/items.json b/data/items.json index 2f90667..7b0571b 100644 --- a/data/items.json +++ b/data/items.json @@ -937,6 +937,38 @@ "warmth": 35, "wet_chill": 0.25 } + }, + { + "id": "bear_hide", + "name": "Bear Hide", + "category": "resource", + "stack": 5, + "description": "A huge brown hide. Makes a fur rug.", + "mesh": "bear_hide" + }, + { + "id": "bear_fat", + "name": "Bear Fat", + "category": "resource", + "stack": 20, + "description": "Rendered fat. Burns three times as long as wood in a fire.", + "mesh": "bear_fat", + "fuel": 180 + }, + { + "id": "fur_rug", + "name": "Fur Rug", + "category": "placeable", + "stack": 1, + "description": "A bearskin for the floor. In a sheltered room: a little warmer, and you get Rested twice as fast.", + "mesh": "fur_rug", + "place": { + "piece": "rug", + "footprint": [ + 1.6, + 2.2 + ] + } } ] } diff --git a/data/recipes.json b/data/recipes.json index 71b53f6..4596beb 100644 --- a/data/recipes.json +++ b/data/recipes.json @@ -521,6 +521,17 @@ "wolf_fang": 1, "fiber": 4 } + }, + { + "id": "fur_rug", + "output": "fur_rug", + "count": 1, + "station": "workbench", + "category": "furniture", + "inputs": { + "bear_hide": 1, + "fiber": 2 + } } ] } diff --git a/docs/PROGRESSION_DESIGN.md b/docs/PROGRESSION_DESIGN.md index 3e08431..9cd52c1 100644 --- a/docs/PROGRESSION_DESIGN.md +++ b/docs/PROGRESSION_DESIGN.md @@ -49,7 +49,24 @@ rest of P3, and P4-P5, are proposals. - Iron-banded door (forge: 2 iron, 4 wood, 4 nails) can't be broken. - Drops: alpha pelt, 1-2 fangs, meat. Alpha cloak (+35 warmth) at the workbench. Network protocol 7. -- Not yet: the bear, crows and foxes, the barrow and its guards. + +*As built in P3 so far (the bear):* +- One bear per island (220 HP, hits for 24, runs at 7 m/s: slower than you + sprinting). Home is the densest forest near the rim, at least 60 m from + the spawn and the den, picked from the seed. It sleeps there at night. +- From day 3, by day, it sniffs out food within 60 m: a chest with food in + it or food left on the ground, with nobody within 12 m. It walks over, + eats all the food (nothing else) and wanders off. Food it can't walk to + is safe: fenced in = all 32 level rays from it hit building pieces (a + palisade ring, a closed hut; an open door lets it in). It breaks nothing. +- Closer than 8 m it rears up and warns you; stay within 6 m and it + charges. Hit it and it charges; under a quarter health it runs. +- Killed, it comes back 3 days later at dawn, when nobody's near its home. + Its death (and a wounded bear's health) is saved with the world. +- Drops: bear hide, 2-3 fat (fire fuel, three times wood), 5-7 meat. Fur + rug (bear hide, 2 fibre): in a sheltered room, Cozy: +4 warmth and + Rested twice as fast. Network protocol 8. +- Not yet: crows and foxes, the barrow and its guards. Today the island runs out of goals after a few days: once you have stone tools, a hut, a bow and a fire, the only threats are wolves at night, the diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5e030da..a3c2864 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -40,6 +40,7 @@ Detailed plan in `docs/SURVIVAL_DESIGN.md` (phases M1a–M1d). - [x] Iron: nuggets, ore, clay, kiln, smelter, forge, iron tools and arrows, lantern, tool wear and repair *(P1)* - [x] Stone building: stonecutter, cut stone, stone floors, walls, pillars, stairs, windows, doorways, hearth; wooden stairs, windows, posts, palisade and gate *(P2)* - [x] The wolf moon and the alpha: breaks wooden doors, iron-banded doors hold, alpha pelt and cloak *(P3, part 1)* +- [x] The bear: raids unattended food, palisades and closed huts keep it out, bear hide rug, bear fat *(P3, part 2)* - [ ] **Iron, stone building and new adversaries**: iron nuggets and ore, smelting and a forge, iron tools and durability, cut-stone building, an alpha wolf, a bear, a barrow with guards, spoilage and blizzards diff --git a/scripts/building/campfire.gd b/scripts/building/campfire.gd index 85e32de..c768348 100644 --- a/scripts/building/campfire.gd +++ b/scripts/building/campfire.gd @@ -63,7 +63,7 @@ func add_fuel(seconds: float) -> void: _set_lit(true) func interact_prompt(player: Node) -> String: - var has_fuel: bool = player.inventory.count("wood") + player.inventory.count("stick") > 0 + var has_fuel: bool = player.inventory.count("wood") + player.inventory.count("stick") + player.inventory.count("bear_fat") > 0 if _lit: var left := "%d:%02d" % [int(fuel) / 60, int(fuel) % 60] var hiss := ", hissing in the rain" if burn_rate() > 1.2 else "" diff --git a/scripts/building/rug.gd b/scripts/building/rug.gd new file mode 100644 index 0000000..298795c --- /dev/null +++ b/scripts/building/rug.gd @@ -0,0 +1,22 @@ +extends "res://scripts/building/piece.gd" +## Fur rug: a bearskin on the floor. In a sheltered room it makes you Cozy +## (survival.gd): a little warmer, and Rested twice as fast. Flat, so you +## walk over it (it's only on the interactables layer, for X to take it up). + +const COZY_RADIUS := 4.0 + +func _make_collision(mi: MeshInstance3D) -> void: + var aabb := mi.mesh.get_aabb() + var cs := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = Vector3(aabb.size.x, 0.06, aabb.size.z) + cs.shape = box + cs.position = Vector3(aabb.get_center().x, 0.03, aabb.get_center().z) + add_child(cs) + collision_layer = 2 + +func _build() -> void: + add_to_group("rug") + +func interact_prompt(_player: Node) -> String: + return "Fur rug (cozy in a sheltered room)" diff --git a/scripts/core/actions.gd b/scripts/core/actions.gd index bd026d6..6c30072 100644 --- a/scripts/core/actions.gd +++ b/scripts/core/actions.gd @@ -619,7 +619,7 @@ func add_fuel(player: Node3D, fire: Node3D) -> bool: if fire.fuel >= fire.MAX_FUEL - 1.0: notify.emit("The fire is fully stoked") return false - for id in ["wood", "stick", "fiber"]: + for id in ["wood", "bear_fat", "stick", "fiber"]: if player.inventory.count(id) > 0: player.inventory.remove(id, 1) fire.add_fuel(float(ItemDB.get_item(id).get("fuel", 10))) diff --git a/scripts/creatures/bear.gd b/scripts/creatures/bear.gd new file mode 100644 index 0000000..c3871b5 --- /dev/null +++ b/scripts/creatures/bear.gd @@ -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" diff --git a/scripts/creatures/bear_range.gd b/scripts/creatures/bear_range.gd new file mode 100644 index 0000000..22f8b5a --- /dev/null +++ b/scripts/creatures/bear_range.gd @@ -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 diff --git a/scripts/creatures/creature.gd b/scripts/creatures/creature.gd index ab75bda..fcb54c9 100644 --- a/scripts/creatures/creature.gd +++ b/scripts/creatures/creature.gd @@ -46,6 +46,12 @@ const KINDS := { "notice": 0.0, "notice_loud": 0.0, "calm": 40.0, "damage": 16.0, "attack_range": 2.1, "attack_cd": 1.4, "door_damage": 22.0, "drops": {"raw_meat": [2, 3], "alpha_pelt": [1, 1], "wolf_fang": [1, 2]}}, + # One bear per island, in the deep forest by the rim (bear.gd, bear_range.gd). + # Slower than you sprinting, but it hits like a falling tree. + "bear": {"name": "Bear", "hp": 220.0, "walk": 1.3, "run": 7.0, "turn": 3.5, + "notice": 0.0, "notice_loud": 0.0, "calm": 40.0, + "damage": 24.0, "attack_range": 2.3, "attack_cd": 1.8, + "drops": {"raw_meat": [5, 7], "bear_hide": [1, 1], "bear_fat": [2, 3]}}, } const CARCASS_SECONDS := 300.0 @@ -137,7 +143,7 @@ func setup(k: String, w: Node3D, pos: Vector3, seed: int) -> void: cs.position = Vector3(0, parts.height * 0.5, 0) if k != "rabbit": # Long bodies: lay the capsule along the spine. - cap.height = {"deer": 1.5, "boar": 1.4, "wolf": 1.25, "alpha": 1.65}.get(k, 1.4) + cap.height = {"deer": 1.5, "boar": 1.4, "wolf": 1.25, "alpha": 1.65, "bear": 2.0}.get(k, 1.4) cs.rotation.x = PI * 0.5 cs.position = Vector3(0, parts.height * 0.55, 0) add_child(cs) diff --git a/scripts/creatures/creature_meshes.gd b/scripts/creatures/creature_meshes.gd index 033de66..56033da 100644 --- a/scripts/creatures/creature_meshes.gd +++ b/scripts/creatures/creature_meshes.gd @@ -19,6 +19,7 @@ static func build(kind: String) -> Dictionary: "deer": d = _deer() "wolf": d = _wolf() "alpha": d = _wolf(true) + "bear": d = _bear() _: d = _boar() _cache[kind] = d return d @@ -153,3 +154,27 @@ static func _wolf_parts(coat: Color, pale: Color, seed: int) -> Dictionary: return {"body": P._commit(st), "eyes": P._commit(est), "leg": _leg(0.52, 0.045, 0.03, coat, dark, seed + 1), "hips": [Vector3(-0.11, 0.52, -0.38), Vector3(0.11, 0.52, -0.38), Vector3(-0.11, 0.54, 0.38), Vector3(0.11, 0.54, 0.38)], "radius": 0.32, "height": 1.0} + +## Brown bear: a heavy humped body, a broad head with a pale muzzle and +## round ears, thick short legs. +static func _bear() -> Dictionary: + var coat := Color(0.36, 0.24, 0.15) + var hump := Color(0.42, 0.29, 0.18) + var muzzle := Color(0.58, 0.45, 0.32) + var dark := Color(0.1, 0.08, 0.07) + var st := P._begin() + var r := _rng(261) + var n := _noise(261, 1.3) + P._blob(st, Vector3(0, 0.95, 0.15), Vector3(0.5, 0.5, 0.85), n, 0.14, coat, r, 1) # body + P._blob(st, Vector3(0, 1.22, -0.35), Vector3(0.44, 0.38, 0.42), n, 0.18, hump, r, 1) # shoulder hump + P._blob(st, Vector3(0, 1.05, -0.95), Vector3(0.3, 0.27, 0.3), n, 0.12, coat, r, 1) # head + P._cylinder(st, Vector3(0, 0.98, -1.12), Vector3(0, 0.92, -1.36), 0.15, 0.1, 6, muzzle, r, -1.0, dark) + for s in [-1.0, 1.0]: + P._blob(st, Vector3(s * 0.2, 1.3, -0.9), Vector3(0.08, 0.08, 0.05), n, 0.1, P._mul(coat, 0.85), r, 0) # ears + P._blob(st, Vector3(0, 0.98, 0.98), Vector3(0.08, 0.08, 0.07), n, 0.2, coat, r, 0) # stub tail + var est := P._begin() + for s in [-1.0, 1.0]: + P._blob(est, Vector3(s * 0.13, 1.12, -1.18), Vector3.ONE * 0.025, n, 0.0, Color(0.9, 0.6, 0.25), r, 0) + return {"body": P._commit(st), "eyes": P._commit(est), "leg": _leg(0.72, 0.13, 0.11, coat, dark, 262), + "hips": [Vector3(-0.28, 0.72, -0.5), Vector3(0.28, 0.72, -0.5), Vector3(-0.28, 0.74, 0.6), Vector3(0.28, 0.74, 0.6)], + "radius": 0.62, "height": 1.6} diff --git a/scripts/items/item_meshes.gd b/scripts/items/item_meshes.gd index 355e549..71bc52b 100644 --- a/scripts/items/item_meshes.gd +++ b/scripts/items/item_meshes.gd @@ -112,6 +112,9 @@ static func build(key: String) -> ArrayMesh: "alpha_pelt": m = _pelt(ALPHA_FUR) "alpha_cloak": m = _cloak(ALPHA_FUR) "wolf_fang": m = _fang() + "bear_hide": m = _pelt(BEAR_FUR) + "bear_fat": m = _pebble(271, Color(0.92, 0.86, 0.68), Vector3(0.1, 0.06, 0.08), 0.1) + "fur_rug": m = _rug() "berry_bush": m = _berry_bush(true) "berry_bush_bare": m = _berry_bush(false) _: @@ -979,3 +982,23 @@ static func _fang() -> ArrayMesh: var r := _rng(253) P._cylinder(st, Vector3(0, 0.02, 0), Vector3(0.02, 0.02, -0.12), 0.022, 0.003, 6, Color(0.88, 0.82, 0.62), r, -1.0, Color(0.6, 0.45, 0.35)) return P._commit(st) + +# --- P3 the bear --------------------------------------------------------------------- + +const BEAR_FUR := Color(0.36, 0.24, 0.15) + +## A bearskin laid flat: body, four splayed legs and the head toward -Z. +static func _rug() -> ArrayMesh: + var st := P._begin() + var r := _rng(273) + _box(st, Vector3(0, 0.015, 0.1), Vector3(1.0, 0.03, 1.5), BEAR_FUR) + _box(st, Vector3(0, 0.02, 0.1), Vector3(0.8, 0.03, 1.3), P._mul(BEAR_FUR, 1.12)) + for sx in [-1.0, 1.0]: + for sz in [-0.45, 0.6]: + _box(st, Vector3(sx * 0.62, 0.012, sz), Vector3(0.42, 0.024, 0.24), _board(r, BEAR_FUR), Basis(Vector3.UP, sx * (0.5 if sz < 0 else -0.5))) + _box(st, Vector3(0, 0.05, -0.85), Vector3(0.38, 0.1, 0.34), P._mul(BEAR_FUR, 0.95)) + _box(st, Vector3(0, 0.045, -1.06), Vector3(0.18, 0.08, 0.14), Color(0.58, 0.45, 0.32)) + for sx in [-0.14, 0.14]: + _box(st, Vector3(sx, 0.11, -0.8), Vector3(0.08, 0.05, 0.05), P._mul(BEAR_FUR, 0.8)) + _box(st, Vector3(0, 0.02, 0.92), Vector3(0.12, 0.03, 0.12), BEAR_FUR) + return P._commit(st) diff --git a/scripts/net/creature_net.gd b/scripts/net/creature_net.gd index adb2b98..b4762e8 100644 --- a/scripts/net/creature_net.gd +++ b/scripts/net/creature_net.gd @@ -18,6 +18,7 @@ extends Node ## missing from the snapshots for a second (despawned, butchered) goes. const Creature = preload("res://scripts/creatures/creature.gd") +const BearScript = preload("res://scripts/creatures/bear.gd") const Bobber = preload("res://scripts/fishing/bobber.gd") const Projectile = preload("res://scripts/creatures/projectile.gd") const WolfPack = preload("res://scripts/creatures/wolf_pack.gd") @@ -95,8 +96,9 @@ func _server_tick(w: Node3D, delta: float) -> void: const PER_PACKET := 40 const ENTRY := 23 -const KIND_IDS := ["rabbit", "deer", "boar", "wolf", "alpha"] -const STATE_IDS := ["idle", "wander", "flee", "charge", "dead", "rest", "alert", "stalk", "lunge", "retreat", "home", "defend", "bash"] +const KIND_IDS := ["rabbit", "deer", "boar", "wolf", "alpha", "bear"] +const STATE_IDS := ["idle", "wander", "flee", "charge", "dead", "rest", "alert", "stalk", "lunge", "retreat", "home", "defend", "bash", + "warn", "raid", "eat"] ## One creature in 23 bytes: id, kind, state, position, facing, speed, health. static func _encode(buf: PackedByteArray, o: int, c: Node3D) -> void: @@ -192,7 +194,7 @@ func _s2c_life(packed: PackedByteArray, fl: Array) -> void: var got: Variant = puppets.get(cid) var c: Node3D = got if is_instance_valid(got) else null if c == null: - c = Creature.new() + c = BearScript.new() if kind == "bear" else Creature.new() # (the bear rears up when it warns you) c.puppet = true c.setup(kind, world, e[2], cid) c.set_meta("cid", cid) diff --git a/scripts/net/net.gd b/scripts/net/net.gd index ae99807..0d80467 100644 --- a/scripts/net/net.gd +++ b/scripts/net/net.gd @@ -32,7 +32,7 @@ signal clock(day: int, time: float) ## the server's time of day const Identity = preload("res://scripts/net/identity.gd") const ServerConfig = preload("res://scripts/net/server_config.gd") -const PROTOCOL := 7 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors (P3) +const PROTOCOL := 8 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear (P3) const DEFAULT_PORT := 24242 const HANDSHAKE_TIMEOUT := 10.0 const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join diff --git a/scripts/net/player_net.gd b/scripts/net/player_net.gd index f3a39c1..028686b 100644 --- a/scripts/net/player_net.gd +++ b/scripts/net/player_net.gd @@ -426,7 +426,7 @@ func _run(p: Node3D, what: String, a: Array) -> void: for m in w.get_tree().get_nodes_in_group("processor"): m.step(float(a[0]) if a.size() > 0 and (a[0] is float or a[0] is int) else 60.0) "debug_spawn": - if cheats and Creature.KINDS.has(_s(a, 0)) and not (_s(a, 0) in ["wolf", "alpha"]) and _v(a, 1).is_finite(): + if cheats and Creature.KINDS.has(_s(a, 0)) and not (_s(a, 0) in ["wolf", "alpha", "bear"]) and _v(a, 1).is_finite(): w.creatures.spawn(_s(a, 0), _v(a, 1)) "debug_time": if cheats: diff --git a/scripts/player/survival.gd b/scripts/player/survival.gd index 66d9021..5377ae8 100644 --- a/scripts/player/survival.gd +++ b/scripts/player/survival.gd @@ -75,6 +75,14 @@ func balanced_diet() -> bool: var stone_room := false ## sheltered by mostly stone walls (P2): a little warmer const STONE_ROOM_WARMTH := 6.0 +var cozy := false ## sheltered with a fur rug near (P3, the bear) +const COZY_WARMTH := 4.0 + +func _rug_near(p: Vector3) -> bool: + for r in world.get_tree().get_nodes_in_group("rug"): + if (r as Node3D).global_position.distance_to(p) < r.COZY_RADIUS: + return true + return false var player: Node3D ## the local player, or a server_player.gd on a server var world: Node3D @@ -96,12 +104,14 @@ func step(dt: float) -> void: roofed = world.build.roofed_at(p) sheltered = roofed and world.build.enclosure_at(p) >= 0.74 stone_room = sheltered and world.build.stone_walls_at(p) >= 0.5 + cozy = sheltered and _rug_near(p) # Rain soaks you unless there's a roof over your head. if not roofed and world.weather.rain_at(p) > 0.25: wet = WET_SECONDS - # Resting: a while by a fire with a roof and walls around you. + # Resting: a while by a fire with a roof and walls around you (half the + # time on a fur rug). if sheltered and heat > 0.25: - _rest_build += dt + _rest_build += dt * (2.0 if cozy else 1.0) if _rest_build >= REST_AFTER: rested = maxf(rested, REST_SECONDS) else: @@ -111,6 +121,8 @@ func step(dt: float) -> void: var target: float = world.air_warmth(p, sheltered) + heat * 70.0 + float(wear.get("warmth", 0.0)) if stone_room: target += STONE_ROOM_WARMTH + if cozy: + target += COZY_WARMTH if wet > 0.0: target -= 22.0 * float(wear.get("wet_chill", 1.0)) wet = maxf(wet - dt * (3.0 if heat > 0.3 else 1.0), 0.0) # fires dry you off @@ -186,6 +198,8 @@ func _update_statuses() -> void: statuses.append("Balanced diet") if sheltered: statuses.append("Sheltered") + if cozy: + statuses.append("Cozy") if rested > 0.0: statuses.append("Rested") for id in buffs: diff --git a/scripts/world/world.gd b/scripts/world/world.gd index 8ddff65..f69a171 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -28,6 +28,7 @@ const PIECES := { "smelter": preload("res://scripts/building/processor.gd"), "forge": preload("res://scripts/building/forge.gd"), "hearth": preload("res://scripts/building/hearth.gd"), + "rug": preload("res://scripts/building/rug.gd"), "stonecutter": preload("res://scripts/building/stonecutter.gd"), } const BuildPiece = preload("res://scripts/building/build_piece.gd") @@ -62,6 +63,8 @@ var _dropped: Node3D ## arrows, spears and other dropped items var den: Node3D ## den.gd: the wolves' cave at the foot of the rim var bobber: Node3D ## bobber.gd: the cast fishing float, if any var wolves: Node3D ## wolf_pack.gd (null if this seed has no den site) +var bears: Node3D ## bear_range.gd: the island's bear (null online as a client) +var _saved_bear: Dictionary = {} ## its saved state, kept until bears exists (and re-saved if it never does) var net_sync: Node ## net_sync.gd when playing on a server, else null var has_bed_spawn := false var bed_spawn := Vector3.ZERO ## position of the bed you respawn at @@ -361,6 +364,7 @@ func _on_terrain_ready() -> void: wolves.name = "Wolves" add_child(wolves) wolves.setup(self, den) + _setup_bear() apply_rules() return _respawn() @@ -384,8 +388,22 @@ func _on_terrain_ready() -> void: wolves.name = "Wolves" add_child(wolves) wolves.setup(self, den) + if not "--no-creatures" in OS.get_cmdline_user_args(): + _setup_bear() _maybe_capture() +## The island's bear and its patch (bear_range.gd). The server runs it for +## everyone; players' games see it through creature_net.gd. +func _setup_bear() -> void: + bears = preload("res://scripts/creatures/bear_range.gd").new() + bears.name = "Bear" + add_child(bears) + bears.setup(self) + bears.load_data(_saved_bear) + bears.enabled = not "--no-creatures" in OS.get_cmdline_user_args() + if not bears.enabled and bears.bear != null: + bears.bear.queue_free() # (a server started without wildlife) + ## A server's rules for the island (at start-up, and after an admin's /set). func apply_rules() -> void: var R = preload("res://scripts/net/rules.gd") @@ -507,6 +525,7 @@ func collect_save() -> Dictionary: "harvested": harvested, "pickups_taken": taken, "pieces": pieces, + "bear": bears.save_data() if bears != null else _saved_bear, } func _shared_state(harvested: Dictionary, regrow: Dictionary, taken: Array, dropped: Array, pieces: Array) -> Dictionary: @@ -518,6 +537,7 @@ func _shared_state(harvested: Dictionary, regrow: Dictionary, taken: Array, drop "harvested": harvested, "pickups_taken": taken, "pieces": pieces, + "bear": bears.save_data() if bears != null else _saved_bear, } ## The live pieces (placed and not being removed). @@ -644,6 +664,10 @@ func _apply_save(s: Dictionary, with_player: bool) -> void: props.pickups[uid].collected() # Regrowth timers (older saves have none: everything gone starts a fresh timer). props.restore_regrow(s.get("regrow", {})) + # The bear (bear_range.gd): applied when it's set up, after loading. + _saved_bear = s.get("bear", {}) + if bears != null: + bears.load_data(_saved_bear) var sp: Dictionary = s.get("spawn", {}) has_bed_spawn = bool(sp.get("bed", false)) if sp.has("pos"): diff --git a/tests/run_all.sh b/tests/run_all.sh index 2319dca..1d73c16 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then echo "Importing the project first (fresh copy)..." godot --headless --path . --import >/dev/null 2>&1 fi -for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_den_sites test_fishing_diet test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_rules test_host test_terrain_edges test_boundary; do +for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_den_sites test_fishing_diet test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_rules test_host test_terrain_edges test_boundary; do extra="" [ "$t" = "test_boundary" ] && extra="--fixed-fps 30" # A suite that waits on a world that failed to load would hang forever, diff --git a/tests/test_bear.gd b/tests/test_bear.gd new file mode 100644 index 0000000..d7afe5e --- /dev/null +++ b/tests/test_bear.gd @@ -0,0 +1,386 @@ +extends SceneTree +## P3 the bear (docs/PROGRESSION_DESIGN.md §4): its home in the forest by the +## rim, raiding unattended food (chests and food left out) but not food +## behind a palisade or in a closed hut, warning you off and charging, its +## drops (hide, fat, meat), the fur rug and coming back after it's killed. +## Run: godot --headless --path . --script res://tests/test_bear.gd + +var _fails := 0 +var _finished := false +var A: Node +var S: Node +var DB: Node +var _toasts: Array[String] = [] +var world: Node3D +var player: CharacterBody3D +var bears: Node +var bear: Node3D +var bs: Node +var real_home: Vector3 + +func check(cond: bool, what: String) -> void: + print((" ok " if cond else " FAIL ") + what) + if not cond: + _fails += 1 + +func _initialize() -> void: + _run.call_deferred() + +func _run() -> void: + A = root.get_node("Actions") + S = root.get_node("Saves") + DB = root.get_node("ItemDB") + A.notify.connect(func(t): _toasts.append(t)) + S.root_dir = "user://test_worlds" + for w in S.list_worlds(): + S.delete_world(w.id) + S.current_id = S.create_world("Bear", 1337) + await _load() + await _body() + check(_finished, "test ran to the end (no script errors)") + for w in S.list_worlds(): + S.delete_world(w.id) + DirAccess.remove_absolute(S.root_dir) + print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails]) + quit(1 if _fails > 0 else 0) + +func _load() -> void: + world = load("res://scenes/world.tscn").instantiate() + root.add_child(world) + for _i in range(5): + await physics_frame + world.creatures.enabled = false + for c in world.creatures.alive(): + c.queue_free() + if world.wolves != null: + world.wolves.enabled = false + player = world.player + player.set_physics_process(false) + player.survival.set_process(false) + world.day_night.frozen = true + world.weather.set_process(false) + world.weather.force("clear") + bs = world.build + bears = world.bears + bear = bears.bear + +func _reload() -> void: + check(world.save_now(), "saved") + world.queue_free() + await process_frame + await _load() + +func _settle() -> void: + for _i in range(3): + await physics_frame + +func _set_time(day: int, hours: float) -> void: + world.day_night.day = day + world.day_night.time = hours / 24.0 + world.day_night.apply(true) + +## Runs the bear for up to `secs` (stopping early once `until` is true). +func _sim(secs: float, until: Callable = Callable()) -> float: + var t := 0.0 + while t < secs: + bears.step() + if is_instance_valid(bear): + bear.set_physics_process(false) + bear.step(0.1) + t += 0.1 + if until.is_valid() and until.call(): + break + if int(t * 10) % 10 == 0: + await physics_frame + return t + +func _toast_has(s: String) -> bool: + for t in _toasts: + if t.contains(s): + return true + return false + +func _ground(p: Vector3) -> Vector3: + return Vector3(p.x, world.terrain.sample_height(p.x, p.z), p.z) + +func _body() -> void: + _home() + await _day_and_night() + await _raids() + await _fenced() + await _crowded() + await _drops_and_rug() + await _comes_back() + _net() + await _saving() + _finished = true + +func _saving() -> void: + print("saving") + _set_time(8, 12.0) + bear.hp = 150.0 + await _reload() + check(bears.alive() and absf(bear.hp - 150.0) < 0.01, "a hurt bear is still hurt after a reload (%.0f)" % bear.hp) + bear.take_hit(500.0, bear.global_position + Vector3(2, 0, 0)) + check(bears.dead_day == 8, "killed on day 8") + await _reload() + check(not bears.alive() and bears.dead_day == 8, "reloaded the same day: still no bear") + _set_time(11, 7.0) + player.global_position = world._spawn + bears.step() + check(bears.alive(), "and it's back three days on, at dawn") + await _reload() + check(bears.alive() and bears.dead_day == -1, "which is saved too") + +# --- home ---------------------------------------------------------------------------- + +func _home() -> void: + print("the bear's home") + check(bears != null and bears.found, "this island has a bear") + bear = bears.bear + check(bear != null and bear.kind == "bear" and bear.hp == 220.0, "one bear, 220 health") + var h: Vector3 = bears.home + real_home = h + var trees := 0 + for n in world.props.resource_nodes.values(): + if n.kind == "tree" and (n as Node3D).global_position.distance_to(h) < 18.0: + trees += 1 + var rim: float = world.props._near_rim(h.x, h.z) + check(trees >= 8 and rim > 0.1, "it lives in thick forest (%d trees within 18 m) near the rim (%.2f)" % [trees, rim]) + var spawn: Vector3 = world._spawn + check(Vector2(h.x - spawn.x, h.z - spawn.z).length() > 60.0 and h.distance_to(world.den.position) > 60.0, + "away from the spawn (%.0f m) and the wolf den (%.0f m)" % [Vector2(h.x - spawn.x, h.z - spawn.z).length(), h.distance_to(world.den.position)]) + var again: Node = load("res://scripts/creatures/bear_range.gd").new() + again.world = world + again._rng.seed = world.terrain.noise_seed + 881 + check(again._find_home() and again.home.distance_to(h) < 0.01, "the same spot every time for this seed") + again.free() + +func _day_and_night() -> void: + print("its day") + bears.enabled = false + player.global_position = world._spawn # (well away, on the island) + _set_time(2, 22.0) + await _sim(30.0, func(): return bear.state == "rest") + check(bear.state == "rest" and bear.global_position.distance_to(bears.home) < 3.0, "at night it sleeps at home") + _set_time(2, 12.0) + var far := 0.0 + var t := 0.0 + while t < 120.0: + await _sim(5.0) + far = maxf(far, bear.global_position.distance_to(bears.home)) + t += 5.0 + check(bear.state != "rest" and far > 3.0 and far < bears.HOME_RADIUS + 6.0, "by day it ambles round its patch (up to %.0f m out)" % far) + check(not bears.raids_now(), "day 2: no raids yet") + +# --- raids ---------------------------------------------------------------------------- + +var site: Vector3 +var yaw := 0.0 + +## Moves the bear's patch (and the bear) next to the test site, a short +## walk from the food. +func _bear_near(p: Vector3, d: float) -> void: + bears.home = _ground(p + Vector3(-d, 0, 0)) + bear.global_position = bears.home + bear.state = "idle" + bear._raid_cd = 0.0 + bear._sniff = 0.0 + bear._aggro = 0.0 + bear.food = null + +func _chest(at: Vector3) -> Node3D: + var c: Node3D = world.spawn_piece(DB.get_item("chest"), Transform3D(Basis(Vector3.UP, yaw), _ground(at))) + c.inventory.add("berries", 5) + c.inventory.add("raw_meat", 2) + c.inventory.add("wood", 3) + return c + +func _raids() -> void: + print("raids") + # Inland, away from the lake the spawn faces (dry, fairly flat ground). + site = bs.debug_find_site(world._spawn + player.global_basis.z * 30.0, 6.0) + yaw = player.rotation.y + check(site.y > world.terrain.water_level + 0.5, "(test site on dry ground, %.1f m up)" % (site.y - world.terrain.water_level)) + _set_time(4, 11.0) + check(bears.raids_now(), "from day 3 it goes after food") + var chest := _chest(site) + await _settle() + _bear_near(site, 22.0) + player.global_position = _ground(site + Vector3(30, 0, 0)) + _toasts.clear() + var took: float = await _sim(60.0, func(): return chest.inventory.count("berries") == 0) + check(chest.inventory.count("berries") == 0 and chest.inventory.count("raw_meat") == 0, "it smells an unattended chest 22 m off and eats the food (%.0f s)" % took) + check(chest.inventory.count("wood") == 3, "but leaves the wood") + check(_toast_has("bear"), "you hear about it (%s)" % [_toasts]) + check(bear.state == "eat", "and sits there eating") + await _sim(10.0) + check(bear.state != "eat", "then wanders off") + print("food left out") + _bear_near(site, 18.0) + var meat: Node3D = world.drop_item("raw_meat", 3, Transform3D(Basis(), _ground(site + Vector3(0, 0, 4)) + Vector3(0, 0.2, 0))) + chest.queue_free() + await _settle() + await _sim(60.0, func(): return not is_instance_valid(meat) or meat.taken) + check(not is_instance_valid(meat) or meat.taken, "meat left on the ground is eaten too") + print("with you there") + _bear_near(site, 20.0) + var guarded := _chest(site + Vector3(0, 0, -3)) + player.global_position = _ground(site + Vector3(6, 0, -3)) + await _settle() + await _sim(25.0) + check(guarded.inventory.count("berries") == 5, "it leaves a chest alone while you're beside it") + world.remove_piece(guarded) + await _settle() + +func _fenced() -> void: + print("a palisade") + var c := site + Vector3(0, 0, 0) + var pdef: Dictionary = DB.get_item("palisade") + for i in [-1, 0, 1]: + for side in [[Vector3(2 * i, 0, -3), 0.0], [Vector3(2 * i, 0, 3), 0.0], [Vector3(3, 0, 2 * i), PI * 0.5], [Vector3(-3, 0, 2 * i), PI * 0.5]]: + var at: Vector3 = c + (side[0] as Vector3) + world.spawn_piece(pdef, bs._free_xf("palisade", at, float(side[1]), true)) + var inside := _chest(c) + await _settle() + check(bears.fenced(inside.global_position), "a chest inside a palisade ring is fenced in") + _bear_near(site, 20.0) + player.global_position = _ground(site + Vector3(40, 0, 0)) + await _sim(45.0) + check(inside.inventory.count("berries") == 5 and inside.inventory.count("raw_meat") == 2, "the bear can't get at it: the food is all there") + check(bear.food == null, "and it isn't even trying") + for p in world.all_pieces(): + if p.global_position.distance_to(c) < 5.0: + world.remove_piece(p) + await _settle() + print("a closed hut") + var hut: Dictionary = await bs.debug_build_hut(site, 0.0) + await _settle() + var center: Vector3 = hut.pieces[0].global_transform * Vector3(1, 0, 1) + var in_hut: Node3D = world.spawn_piece(DB.get_item("chest"), Transform3D(Basis(), center + Vector3(-0.8, 0, -0.8))) + in_hut.inventory.add("cooked_meat", 4) + await _settle() + check(bears.fenced(in_hut.global_position) and not bears.raidable(in_hut), "food in a closed hut is safe too") + var door: Node3D = null + for p in hut.pieces: + if p.item_id == "wood_door": + door = p + door.set_open(true, false) + await _settle() + check(bears.raidable(in_hut), "...unless you leave the door open") + door.set_open(false, false) + for p in world.all_pieces(): + if p.global_position.distance_to(center) < 6.0: + p.queue_free() + await _settle() + +func _crowded() -> void: + print("too close") + _set_time(4, 12.0) + _bear_near(site, 0.0) + player.survival.health = 100.0 + var to: Vector3 = bear.global_position + player.global_position = _ground(to + Vector3(7.0, 0, 0)) + await _sim(1.0) + check(bear.state == "warn", "at 7 m it rears up and warns you off (%s)" % bear.state) + check(bear.interact_prompt(player).contains("warning"), "\"%s\"" % bear.interact_prompt(player)) + player.global_position = _ground(bear.global_position + Vector3(12.0, 0, 0)) + await _sim(1.0) + check(bear.state != "warn" and bear.state != "charge" and player.survival.health == 100.0, "back off and it leaves you be") + player.global_position = _ground(bear.global_position + Vector3(4.5, 0, 0)) + await _sim(8.0, func(): return player.survival.health < 100.0) + check(player.survival.health < 100.0 and player.survival.health <= 100.0 - 20.0, "stay close and it charges: one blow takes %.0f health" % (100.0 - player.survival.health)) + player.survival.health = 100.0 + print("a fight") + player.global_position = _ground(bear.global_position + Vector3(25.0, 0, 0)) + await _sim(20.0, func(): return bear._aggro <= 0.0) + bear._aggro = 0.0 + bear.state = "idle" + bear.take_hit(30.0, player.global_position) + check(bear.state == "charge" and bear._aggro > 0.0, "shoot it and it comes for you") + bear.take_hit(150.0, player.global_position) + check(bear.state == "flee", "badly hurt (%.0f left), it runs" % bear.hp) + +func _drops_and_rug() -> void: + print("its hide") + bear.take_hit(500.0, player.global_position) + check(bear.is_dead() and bears.dead_day == 4, "it can be killed") + var got: Dictionary = bear.butcher_drops() + check(int(got.get("bear_hide", 0)) == 1 and int(got.get("bear_fat", 0)) >= 2 and int(got.get("raw_meat", 0)) >= 5, + "butchered: a hide, fat and plenty of meat (%s)" % [got]) + check(int(DB.get_item("bear_fat").fuel) == 3 * int(DB.get_item("wood").fuel), "fat burns three times as long as wood") + player.inventory.load_data(Inventory.new(40).to_data()) + player.inventory.add("bear_fat", 1) + var fire: Node3D = world.spawn_piece(DB.get_item("campfire"), Transform3D(Basis(), _ground(site + Vector3(10, 0, 10)))) + fire.fuel = 10.0 + player.global_position = fire.global_position + Vector3(1.2, 0, 0) + check(A.add_fuel(player, fire) and absf(fire.fuel - 190.0) < 0.1, "and goes on a fire (%.0f s of fuel)" % fire.fuel) + world.remove_piece(fire) + print("a fur rug") + var hut: Dictionary = await bs.debug_build_hut(site, 0.0) + await _settle() + var center: Vector3 = hut.pieces[0].global_transform * Vector3(1, 0, 1) + player.inventory.add("bear_hide", 1) + player.inventory.add("fiber", 2) + var wb: Node3D = world.spawn_piece(DB.get_item("workbench"), Transform3D(Basis(), _ground(site + Vector3(8, 0, 0)))) + player.global_position = wb.global_position + Vector3(0, 0, 1.4) + check(A.craft(player, "fur_rug"), "a fur rug at the workbench (a bear hide, 2 fibre)") + world.remove_piece(wb) + var rdef: Dictionary = DB.get_item("fur_rug") + var rxf := Transform3D(Basis(), center + Vector3(0.0, 0.0, 0.3)) + check(world.placement_valid(rdef, rxf), "it goes on the hut floor") + var rug: Node3D = world.spawn_piece(rdef, rxf) + var hearth_fire: Node3D = world.spawn_piece(DB.get_item("campfire"), Transform3D(Basis(), center + Vector3(0.9, 0, -0.9))) + var sv: Node = player.survival + var spot := center + Vector3(-0.5, 0.05, 0.5) + player.global_position = spot + await _settle() + sv._shelter_left = 0.0 + sv.step(0.5) + check(sv.cozy and "Cozy" in sv.statuses, "standing by it in the hut: Cozy (%s)" % [sv.statuses]) + sv.rested = 0.0 + sv._rest_build = 0.0 + var secs := 0 + while sv.rested <= 0.0 and secs < 40: + sv.step(1.0) + secs += 1 + check(secs <= int(sv.REST_AFTER / 2.0) + 1, "Rested in %d s by the fire (half the usual %d)" % [secs, int(sv.REST_AFTER)]) + var q := PhysicsRayQueryParameters3D.create(spot + Vector3(0, 1.0, 0), rug.global_position + Vector3(0, 0.02, 0), 1) + check(world.get_world_3d().direct_space_state.intersect_ray(q).is_empty(), "and it's flat: you walk over it") + rug.queue_free() + hearth_fire.queue_free() + await _settle() + sv._shelter_left = 0.0 + sv.step(0.5) + check(not sv.cozy, "no rug, not cozy") + for p in hut.pieces: + if is_instance_valid(p): + p.queue_free() + await _settle() + +func _comes_back() -> void: + print("another bear") + bears.home = real_home # (the raids moved it next to the test site) + bear = bears.bear + _set_time(5, 7.0) + bears.step() + check(not bears.alive(), "the next morning: no bear") + _set_time(7, 7.0) + player.global_position = _ground(bears.home + Vector3(15, 0, 0)) + bears.step() + check(not bears.alive(), "three days on, not while you're standing at its home") + player.global_position = world._spawn + bears.step() + check(bears.alive() and bears.bear != bear, "but once you've gone, there's a bear again") + bear = bears.bear + +func _net() -> void: + print("online") + var CN: Node = root.get_node("CreatureNet") + bear.set_meta("cid", 91) + bear.state = "raid" + var buf := PackedByteArray() + buf.resize(CN.ENTRY) + CN._encode(buf, 0, bear) + var e: Array = CN._decode(buf, 0) + check(e[1] == "bear" and e[4] == "raid", "the bear goes over the network (%s, %s)" % [e[1], e[4]])