extends SceneTree ## M1d: wildlife (spawning, senses, fleeing, boars charging), melee, thrown ## spears, the bow, butchering, cooking and food buffs, the hide cloak, ## dropped items, and saving all of it. ## Run: godot --headless --path . --script res://tests/test_creatures_combat.gd var _fails := 0 var _finished := false var A: Node var S: Node var DB: Node 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 _spawn_world() -> Node3D: var w: Node3D = load("res://scenes/world.tscn").instantiate() root.add_child(w) for _i in range(5): await physics_frame return w func _run() -> void: A = root.get_node("Actions") S = root.get_node("Saves") DB = root.get_node("ItemDB") S.root_dir = "user://test_worlds" for w in S.list_worlds(): S.delete_world(w.id) var id: String = S.create_world("Hunt", 1337) S.current_id = id var world: Node3D = await _spawn_world() await _body(world) check(_finished, "test ran to the end (no script errors)") S.delete_world(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) ## Runs a creature's logic for `secs` in small steps. func _sim(c: Node, secs: float) -> void: var t := 0.0 while t < secs and is_instance_valid(c): c.step(0.05) t += 0.05 func _hold(player: Node, id: String) -> void: var inv: Inventory = player.inventory for i in range(inv.size()): var s = inv.get_slot(i) if s != null and s.id == id: var tmp = inv.get_slot(0) inv.set_slot(0, s) inv.set_slot(i, tmp) break player.select(0) func _clear(sp: Node) -> void: for c in sp.get_children(): c.free() func _body(world: Node3D) -> void: var player: CharacterBody3D = world.get_node("Player") var sv: Node = player.survival var inv: Inventory = player.inventory var terrain: Node3D = world.get_node("Terrain") var sp: Node = world.creatures var cam: Camera3D = player.get_node("Camera3D") player.set_physics_process(false) sv.set_process(false) world.weather.force("clear") world.day_night.time = 0.5 world.day_night.apply(true) print("spawning") var counts := {} var bad := 0 var nearest := INF for c in sp.alive(): counts[c.kind] = counts.get(c.kind, 0) + 1 var p: Vector3 = c.global_position if not terrain.inside_boundary(p.x, p.z, 0.0) or p.y < terrain.water_level: bad += 1 nearest = minf(nearest, p.distance_to(player.global_position)) check(counts.get("rabbit", 0) >= 4 and counts.get("deer", 0) >= 2 and counts.get("boar", 0) >= 1, "a new world has wildlife (%s)" % counts) check(bad == 0, "every animal spawned on dry land inside the island") check(nearest > 20.0, "none spawned on top of you (nearest %.0f m)" % nearest) var feathers := 0 for uid in world.props.pickups: if world.props.pickups[uid].item_id == "feather": feathers += 1 check(feathers >= 50, "feathers lie in the meadows (%d)" % feathers) sp.enabled = false _clear(sp) # A clear, flat spot to hunt on, with the player facing along +X there. var site: Vector3 = world.build.debug_find_site(player.global_position + Vector3(10, 0, 0), 6.0) site.y = terrain.sample_height(site.x, site.z) player.global_position = site + Vector3(0, 0.02, 0) player.rotation = Vector3(0, -PI * 0.5, 0) # forward (-Z) turned to +X cam.rotation.x = 0.0 var fwd := -player.global_basis.z player.velocity = Vector3.ZERO print("senses") var rabbit: Node = sp.spawn("rabbit", site + fwd * 4.0) var r0: float = rabbit.global_position.distance_to(player.global_position) _sim(rabbit, 2.0) check(rabbit.state == "flee" and rabbit.global_position.distance_to(player.global_position) > r0 + 4.0, "a rabbit 4 m away bolts (%s, %.0f m -> %.0f m)" % [rabbit.state, r0, rabbit.global_position.distance_to(player.global_position)]) rabbit.free() var deer: Node = sp.spawn("deer", site + fwd * 22.0) _sim(deer, 1.0) check(deer.state != "flee", "a deer 22 m away doesn't notice you standing still") player.velocity = fwd * 8.0 _sim(deer, 1.0) check(deer.state == "flee", "but hears you sprinting") player.velocity = Vector3.ZERO deer.free() var boar: Node = sp.spawn("boar", site + fwd * 3.0) var h0: float = sv.health _sim(boar, 3.0) check(boar.state != "charge" and sv.health == h0, "a boar leaves you alone if you leave it alone") boar.free() print("melee") rabbit = sp.spawn("rabbit", site + fwd * 1.6) rabbit.set_physics_process(false) player.select(7) # an empty hotbar slot inv.set_slot(7, null) check(not A.melee(player, rabbit) and rabbit.hp == 10.0, "bare hands can't hurt an animal") check(rabbit.interact_prompt(player).contains("need a weapon"), "and looking at one says what you need") inv.add("berries", 2) _hold(player, "berries") check(not A.melee(player, rabbit) and rabbit.hp == 10.0, "nor can a handful of berries") inv.add("torch", 1) _hold(player, "torch") check(not A.melee(player, rabbit) and rabbit.hp == 10.0, "nor a torch") inv.add("arrow", 1) _hold(player, "arrow") check(not A.melee(player, rabbit) and rabbit.hp == 10.0, "nor an arrow held in your hand") inv.remove("arrow", 1) inv.add("stone_axe", 1) _hold(player, "stone_axe") check(A.melee(player, rabbit) and rabbit.hp == 4.0, "an axe works, though it's a poor weapon (hp %.0f)" % rabbit.hp) rabbit.free() inv.add("club", 1) _hold(player, "club") rabbit = sp.spawn("rabbit", site + fwd * 1.6) rabbit.set_physics_process(false) await physics_frame await physics_frame cam.rotation.x = -0.5 # look down at it var tgt: Node = player.hands.melee_target() check(tgt == rabbit, "the club's sweep finds a rabbit at your feet") cam.rotation.x = 0.0 var st0: float = sv.stamina check(A.melee(player, rabbit) and rabbit.is_dead(), "one club blow kills a rabbit") check(sv.stamina < st0, "swinging costs stamina") check(rabbit.is_in_group("carcass") and rabbit.collision_layer == 2, "it leaves a carcass you can butcher") check(not A.melee(player, rabbit), "can't keep hitting a carcass") var meat0 := inv.count("raw_meat") check(A.butcher(player, rabbit) and inv.count("raw_meat") == meat0 + 1, "butchering a rabbit gives meat") await process_frame check(not is_instance_valid(rabbit), "and the carcass is gone") var far_rabbit: Node = sp.spawn("rabbit", site + fwd * 9.0) far_rabbit.set_physics_process(false) check(not A.melee(player, far_rabbit), "can't club something 9 m away") far_rabbit.free() print("boar fight") boar = sp.spawn("boar", site + fwd * 2.5) boar.set_physics_process(false) sv.stamina = 100.0 A.melee(player, boar) check(boar.state == "charge" and not boar.is_dead(), "hit a boar and it charges (hp %.0f)" % boar.hp) h0 = sv.health _sim(boar, 3.0) check(sv.health <= h0 - 13.0, "and gores you (health %.0f -> %.0f)" % [h0, sv.health]) var hits := 0 while not boar.is_dead() and hits < 12: sv.stamina = 100.0 boar.global_position = site + fwd * 2.0 A.melee(player, boar) hits += 1 check(boar.is_dead() and hits >= 3, "a boar takes several blows (%d more)" % hits) var hide0 := inv.count("hide") player.global_position = boar.global_position - fwd * 1.5 A.butcher(player, boar) check(inv.count("hide") > hide0 and inv.count("raw_meat") >= meat0 + 4, "a boar gives hide and plenty of meat") sv.health = 100.0 player.global_position = site + Vector3(0, 0.02, 0) print("spear") inv.add("spear", 1) _hold(player, "spear") deer = sp.spawn("deer", site + fwd * 9.0) deer.set_physics_process(false) await physics_frame var a: Array = player.hands._aim() var aim_at: Vector3 = deer.global_position + Vector3(0, 1.0, 0) sv.stamina = 100.0 # A thrown spear drops about 0.6 m over 9 m: aim a little high, as a player would. check(A.throw_spear(player, 0, a[0], aim_at + Vector3(0, 0.6, 0) - (a[0] as Vector3), world) and inv.count("spear") == 0, "threw the spear") for _i in range(40): await physics_frame check(deer.hp < 40.0, "the spear hits the deer (hp %.0f)" % deer.hp) var spear_drop: Node = null for d in world.get_node("Dropped").get_children(): if d.item_id == "spear": spear_drop = d check(spear_drop != null and spear_drop.global_position.distance_to(deer.global_position) < 2.0, "and lands at its feet") player.global_position = spear_drop.global_position + Vector3(0.5, 0, 0) check(A.pickup(player, spear_drop) and inv.count("spear") == 1, "pick the spear up again") player.global_position = site + Vector3(0, 0.02, 0) print("bow") inv.add("bow", 1) _hold(player, "bow") check(not A.can_draw(player), "no arrows, no shot") inv.add("arrow", 3) check(A.can_draw(player), "with arrows you can draw") deer.hp = 40.0 a = player.hands._aim() A.shoot(player, a[0], aim_at - (a[0] as Vector3) + Vector3(0, 0.15, 0), 1.0, world) for _i in range(30): await physics_frame check(inv.count("arrow") == 2, "a shot uses one arrow") check(deer.hp <= 40.0 - 25.0, "a full draw hits hard (hp %.0f)" % deer.hp) A.shoot(player, a[0], aim_at - (a[0] as Vector3) + Vector3(0, 0.15, 0), 1.0, world) for _i in range(30): await physics_frame check(deer.is_dead(), "two full-draw arrows bring down a deer") player.global_position = deer.global_position - fwd * 1.5 var arrows0 := inv.count("arrow") var hide1 := inv.count("hide") A.butcher(player, deer) check(inv.count("arrow") == arrows0 + 2 and inv.count("hide") > hide1, "butchering gives hide and your arrows back") player.global_position = site + Vector3(0, 0.02, 0) a = player.hands._aim() A.shoot(player, a[0], fwd + Vector3(0, -0.25, 0), 0.6, world) for _i in range(40): await physics_frame var stuck: Node = null for d in world.get_node("Dropped").get_children(): if d.item_id == "arrow": stuck = d check(stuck != null and stuck.global_position.distance_to(site) < 15.0, "an arrow that misses sticks in the ground") world.weather.force("clear") print("cooking and buffs") var fire: Node = world.spawn_piece(DB.get_item("campfire"), Transform3D(Basis(), site - fwd * 2.0 + Vector3(0, terrain.sample_height(site.x, site.z) - site.y, 0))) player.global_position = site + Vector3(0, 0.02, 0) check(A.craft(player, "cooked_meat") and inv.count("cooked_meat") == 1, "cooked meat at the campfire") sv.health = 2.0 inv.add("raw_meat", 1) _hold(player, "raw_meat") sv.hunger = 50.0 A.eat(player, 0) check(sv.health >= 1.0 and sv.health <= 2.0, "raw meat can hurt, but never kills (health %.0f)" % sv.health) sv.health = 60.0 _hold(player, "cooked_meat") A.eat(player, 0) check(sv.buffs.has("well_fed") and sv.buff_mult("stamina_regen") > 1.2, "cooked meat makes you Well fed") sv._update_statuses() check(sv.statuses.any(func(s): return s.begins_with("Well fed")), "and the HUD shows it (%s)" % [sv.statuses]) # Healing with the buff vs without (fed and warm, no recent hits). sv.hunger = 90.0 sv.warmth = 70.0 sv._hurt_cd = 0.0 sv.health = 50.0 sv.step(10.0) var fed_gain: float = sv.health - 50.0 sv.buffs.clear() sv.health = 50.0 sv.step(10.0) var plain_gain: float = sv.health - 50.0 check(fed_gain > plain_gain * 1.8, "Well fed heals faster (%.1f vs %.1f per 10 s)" % [fed_gain, plain_gain]) sv.damage(5.0, site) var hh: float = sv.health sv.step(3.0) check(sv.health <= hh, "no natural healing just after a hit") world.remove_piece(fire) print("cloak") var bench: Node = world.spawn_piece(DB.get_item("workbench"), Transform3D(Basis(), site + fwd * -3.0 + Vector3(0, -0.02, 0))) inv.add("hide", 4) inv.add("fiber", 4) check(A.craft(player, "hide_cloak") and inv.count("hide_cloak") == 1, "a hide cloak from 4 hide and 4 fiber") world.remove_piece(bench) world.day_night.time = 0.0 world.day_night.apply(true) sv.warmth = 50.0 for _i in range(300): sv.step(1.0) var bare: float = sv.warmth _hold(player, "hide_cloak") check(A.wear(player, 0) and player.worn == "hide_cloak" and inv.count("hide_cloak") == 0, "right-click wears it") sv.warmth = 50.0 for _i in range(300): sv.step(1.0) check(sv.warmth > bare + 12.0, "the cloak keeps you warmer at night (%.0f vs %.0f)" % [sv.warmth, bare]) check(A.take_off(player) and player.worn == "" and inv.count("hide_cloak") == 1, "take it off again") _hold(player, "hide_cloak") A.wear(player, 0) world.day_night.time = 0.5 world.day_night.apply(true) inv.add("cooked_meat", 1) _hold(player, "cooked_meat") sv.hunger = 40.0 A.eat(player, 0) print("spawner") for k in sp._cooldown: sp._cooldown[k] = 0.0 # (the kills above put every kind on its respawn timer) sp.enabled = true sp._check = 0.0 sp._process(0.1) check(sp.alive().size() >= 1, "the spawner tops the wildlife back up") var stray: Node = sp.spawn("deer", player.global_position + Vector3(0, 0, 0)) stray.position = player.global_position + Vector3(110, 0, 0) sp._check = 0.0 sp._process(0.1) check(stray.is_queued_for_deletion(), "animals that wander far away despawn") sp.enabled = false await process_frame _clear(sp) # Walk in from the lake's east side to the last dry spot before the water, # then check the deer refuses to step in. var lake: Vector2 = terrain.lake_center var swimmer: Node = sp.spawn("deer", Vector3(lake.x + terrain.lake_radius + 8.0, 0, lake.y)) var edge := Vector3.INF var x: float = lake.x + terrain.lake_radius + 8.0 while x > lake.x: if terrain.sample_height(x - 1.0, lake.y) < terrain.water_level + 0.1: edge = Vector3(x, terrain.sample_height(x, lake.y), lake.y) break x -= 0.25 swimmer.position = edge if edge != Vector3.INF else swimmer.position check(edge != Vector3.INF and swimmer._blocked(Vector3(-1, 0, 0)), "animals won't walk into the lake") check(not swimmer._blocked(Vector3(1, 0, 0)), "but will walk away from it") swimmer.free() print("save + load") var buff_left: float = sv.buffs.get("well_fed", {}).get("left", 0.0) var stuck_pos: Vector3 = stuck.global_position world.save_now() world.queue_free() await process_frame await process_frame world = await _spawn_world() player = world.get_node("Player") sv = player.survival check(player.worn == "hide_cloak", "still wearing the cloak after loading") check(sv.buffs.has("well_fed") and absf(float(sv.buffs.well_fed.left) - buff_left) < 2.0, "Well fed survives a reload") var stuck2: Node = null for d in world.get_node("Dropped").get_children(): if d.item_id == "arrow" and d.global_position.distance_to(stuck_pos) < 0.05: stuck2 = d check(stuck2 != null, "the stuck arrow is still there") check(world.creatures.alive().size() >= 5, "a loaded world has wildlife again (%d)" % world.creatures.alive().size()) print("death") player.set_physics_process(false) sv.set_process(false) sv.damage(1000.0, player.global_position) var grave: Node = null for p in world.get_node("Pieces").get_children(): if p.item_id == "grave": grave = p check(player.worn == "" and grave != null and grave.inventory.count("hide_cloak") == 1, "dying puts your cloak in the grave") world.queue_free() _finished = true