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

@@ -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
]
}
}
]
}

View File

@@ -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
}
}
]
}

View File

@@ -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

View File

@@ -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

View File

@@ -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 ""

22
scripts/building/rug.gd Normal file
View File

@@ -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)"

View File

@@ -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)))

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"

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

View File

@@ -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)

View File

@@ -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}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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"):

View File

@@ -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,

386
tests/test_bear.gd Normal file
View File

@@ -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]])