381 lines
15 KiB
GDScript
381 lines
15 KiB
GDScript
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
|
|
sv.sick = 0.0 # (raw meat may have made you ill: tests/test_food.gd covers that)
|
|
_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
|