Animals stay off the cliffs (Gitea #6): nothing walks up or along ground steeper than 35 degrees, and a carcass on steep ground slides down to where it can be reached and butchered

This commit is contained in:
Paul Bailey
2026-11-08 19:29:09 +00:00
parent da29308522
commit 415e673b28
4 changed files with 173 additions and 1 deletions

View File

@@ -92,6 +92,13 @@ const KINDS := {
"drops": {"raw_meat": [5, 7], "bear_hide": [1, 1], "bear_fat": [2, 3]}}, "drops": {"raw_meat": [5, 7], "bear_hide": [1, 1], "bear_fat": [2, 3]}},
} }
const CARCASS_SECONDS := 300.0 const CARCASS_SECONDS := 300.0
## The steepest ground an animal walks up: 35 degrees (the ground's normal
## at least this upright). The player's limit is about 45.
const MAX_CLIMB_NY := 0.82
const MAX_CLIMB_RISE := 0.7 ## (tan 35 degrees: metres up per metre along)
## A carcass on ground steeper than this slides down to where it's flatter,
## so it can be reached and butchered (Gitea #6).
const CARCASS_REST_NY := 0.8
var kind := "deer" var kind := "deer"
var def: Dictionary var def: Dictionary
@@ -384,6 +391,18 @@ func _blocked(dir: Vector3) -> bool:
return true return true
if absf(h - global_position.y) > 1.1: if absf(h - global_position.y) > 1.1:
return true return true
# Nothing climbs steep ground, or walks along it (the mountain rim: Gitea
# #6). Going down it is fine, so one that's somehow up there can always
# come back.
if h > global_position.y - 0.3:
var run := Vector2(probe.x - global_position.x, probe.z - global_position.z).length()
if (h - global_position.y) > run * MAX_CLIMB_RISE:
return true
# The slope along the way: just ahead, halfway, and at the probe.
for f in [0.2, 0.5, 1.0]:
var s: Vector3 = global_position.lerp(probe, f)
if terrain.sample_normal(s.x, s.z).y < MAX_CLIMB_NY and terrain.sample_height(s.x, s.z) > global_position.y - 0.3 * f:
return true
# Trees, rocks and buildings at chest height (the terrain isn't on this test). # Trees, rocks and buildings at chest height (the terrain isn't on this test).
var from := global_position + Vector3(0, 0.45, 0) var from := global_position + Vector3(0, 0.45, 0)
var q := PhysicsRayQueryParameters3D.create(from, Vector3(probe.x, h + 0.45, probe.z), 1) var q := PhysicsRayQueryParameters3D.create(from, Vector3(probe.x, h + 0.45, probe.z), 1)
@@ -468,6 +487,7 @@ func _die(from: Vector3) -> void:
if puppet: if puppet:
_visual.rotation.z = -PI * 0.5 _visual.rotation.z = -PI * 0.5
return return
_settle()
# Topple onto its side, away from the blow. # Topple onto its side, away from the blow.
var side := 1.0 if (global_position - from).dot(global_basis.x) > 0.0 else -1.0 var side := 1.0 if (global_position - from).dot(global_basis.x) > 0.0 else -1.0
if is_inside_tree() and DisplayServer.get_name() != "headless": if is_inside_tree() and DisplayServer.get_name() != "headless":
@@ -477,6 +497,28 @@ func _die(from: Vector3) -> void:
_visual.rotation.z = -side * PI * 0.5 _visual.rotation.z = -side * PI * 0.5
died.emit(self) died.emit(self)
## A carcass on steep ground (half into a cliff face, out of reach) slides
## downhill, half a metre at a time, until the ground's flatter: up to 20 m,
## and never out past the edge of the play area. Online the server does this
## and players see it where it ends up.
func _settle() -> void:
if terrain == null:
return
var p := global_position
for _i in range(40):
var n: Vector3 = terrain.sample_normal(p.x, p.z)
if n.y >= CARCASS_REST_NY:
break
var down := Vector3(n.x, 0.0, n.z) # (a slope's normal leans downhill)
if down.length() < 0.001:
break
var q := p + down.normalized() * 0.5
if not terrain.inside_boundary(q.x, q.z, 1.0):
break
p = Vector3(q.x, terrain.sample_height(q.x, q.z), q.z)
if p.distance_to(global_position) > 0.01:
global_position = p
## What butchering gives (rolled once). ## What butchering gives (rolled once).
func butcher_drops() -> Dictionary: func butcher_drops() -> Dictionary:
var out := {} var out := {}

View File

@@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then
echo "Importing the project first (fresh copy)..." echo "Importing the project first (fresh copy)..."
godot --headless --path . --import >/dev/null 2>&1 godot --headless --path . --import >/dev/null 2>&1
fi fi
for t in test_boot test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_net_lands test_host test_tutorial test_tutorial_full test_hints test_updates test_audio test_items_more test_worldlets test_worldlet_pick test_water_bodies test_cove test_wreck test_shore test_boat test_bug_report test_tunnel test_map test_terrain_edges test_boundary; do for t in test_boot test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_cliffs test_wolves test_alpha test_bear test_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_net_lands test_host test_tutorial test_tutorial_full test_hints test_updates test_audio test_items_more test_worldlets test_worldlet_pick test_water_bodies test_cove test_wreck test_shore test_boat test_bug_report test_tunnel test_map test_terrain_edges test_boundary; do
extra="" extra=""
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30" [ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
# A suite that waits on a world that failed to load would hang forever, # A suite that waits on a world that failed to load would hang forever,

129
tests/test_cliffs.gd Normal file
View File

@@ -0,0 +1,129 @@
extends SceneTree
## Gitea #6: animals stay off the cliffs at the edge of the world. A deer
## chased outward toward the mountain rim, all the way round, never climbs
## steep ground; and one killed on a steep face comes to rest where it's
## flatter, so you can reach it and butcher it.
## Run: godot --headless --path . --script res://tests/test_cliffs.gd
var _fails := 0
var _finished := false
var S: Node
var A: 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 _run() -> void:
S = root.get_node("Saves")
A = root.get_node("Actions")
S.root_dir = "user://test_cliffs_worlds"
for x in S.list_worlds():
S.delete_world(x.id)
S.current_id = S.create_world("Valley", 1337)
var w: Node3D = load("res://scenes/world.tscn").instantiate()
root.add_child(w)
for _i in range(8):
await physics_frame
w.creatures.enabled = false
for c in w.creatures.get_children():
c.queue_free()
if w.wolves != null:
w.wolves.enabled = false
w.player.set_physics_process(false)
w.player.survival.set_process(false)
await _body(w)
check(_finished, "test ran to the end (no script errors)")
for x in S.list_worlds():
S.delete_world(x.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 _body(w: Node3D) -> void:
var t: Node3D = w.terrain
var p: Node3D = w.player
print("chased toward the mountains")
# How far into the mountain rim each deer gets (the terrain's own rim
# distance), and the steepest ground it walks UP onto.
# (Running down a steep bank is allowed: that's how one gets back down.)
var steepest := 0.0
var highest := 0.0
var climb := 0.0
var where := ""
for i in range(36):
var a := TAU * i / 36.0
var dir := Vector3(cos(a), 0, sin(a))
# (12 m inside the rim, which wobbles in and out by up to 16 m.)
var start: Vector3 = dir * (t.play_radius - 12.0 - t._rim_offset(dir.x, dir.z))
var d: Node3D = w.creatures.spawn("deer", start)
d.set_physics_process(false)
for _s in range(600): # (20 s at a run, with you right behind it)
p.global_position = d.global_position - dir * 8.0
d.player = p
d._threat = p.global_position
d.state = "flee"
d._state_t = 4.0
var y0: float = d.global_position.y
d.step(1.0 / 30.0)
var g: Vector3 = d.global_position
if g.y > y0 + 0.0005:
var deg := rad_to_deg(acos(clampf(t.sample_normal(g.x, g.z).y, -1.0, 1.0)))
if deg > steepest:
steepest = deg
where = "%d degrees round" % (i * 10)
highest = maxf(highest, t._rim_d(g.x, g.z) - t.play_radius)
climb = maxf(climb, g.y - start.y - t.sample_height(start.x, start.z) + start.y)
d.free()
if i % 6 == 0:
await process_frame
# (Its limit is 35 degrees, checked at a few points ahead, so a few
# degrees more can slip through between them; the player's limit is 45:
# anywhere a deer goes, you can follow. Before the fix: 71 degrees.)
check(steepest < 45.0, "a deer chased outward never walks up ground steeper than you can (steepest %.0f, at %s)" % [steepest, where])
# (The rim rises 58 m over 30 m; its first few metres are still gentle.
# Before the fix: 8 m in and up onto the cliff face.)
check(highest < 6.0 and climb < 6.0, "and stays at the foot of the mountain rim (%.1f m in, %.1f m above where it started)" % [highest, climb])
print("killed on a cliff")
# A steep spot on the rim, inside the play area: where a deer used to end up.
var spot := Vector3.INF
for i in range(72):
var a := TAU * i / 72.0
for r in range(int(t.play_radius) - 5, int(t.play_radius) + 15):
var x := cos(a) * r
var z := sin(a) * r
if t.inside_boundary(x, z, 3.0) and t.sample_normal(x, z).y < 0.55:
spot = Vector3(x, t.sample_height(x, z), z)
break
if spot != Vector3.INF:
break
check(spot != Vector3.INF, "(a 55-degree face on the rim to try it on)")
if spot == Vector3.INF:
return
var deer: Node3D = w.creatures.spawn("deer", spot)
deer.set_physics_process(false)
deer.take_hit(999.0, spot + Vector3(0, 1, 0))
var at: Vector3 = deer.global_position
var ny: float = t.sample_normal(at.x, at.z).y
check(deer.is_dead() and ny >= deer.CARCASS_REST_NY, "it comes to rest on flatter ground (%.0f degrees)" % rad_to_deg(acos(ny)))
check(at.distance_to(spot) < 20.5 and at.y < spot.y, "a little way down the slope (%.1f m)" % at.distance_to(spot))
check(absf(at.y - t.sample_height(at.x, at.z)) < 0.05, "lying on the ground, not inside it")
p.global_position = at + Vector3(0.8, 0, 0)
p.global_position.y = t.sample_height(p.global_position.x, p.global_position.z)
var before: int = p.inventory.count("raw_meat")
check(A.butcher(p, deer) and p.inventory.count("raw_meat") > before, "and you can butcher it")
print("on flat ground")
var flat: Vector3 = w.build.debug_find_site(w._spawn, 3.0)
var d2: Node3D = w.creatures.spawn("deer", flat)
d2.set_physics_process(false)
var was: Vector3 = d2.global_position
d2.take_hit(999.0, was + Vector3(0, 1, 0))
check(d2.global_position.distance_to(was) < 0.01, "a deer killed on the flat stays where it fell")
d2.free()
_finished = true

1
tests/test_cliffs.gd.uid Normal file
View File

@@ -0,0 +1 @@
uid://b8pt3n5krip7j