From 415e673b28c95a6a115bdbf44fead37abde181f0 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Sun, 8 Nov 2026 19:29:09 +0000 Subject: [PATCH] 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 --- scripts/creatures/creature.gd | 42 +++++++++++ tests/run_all.sh | 2 +- tests/test_cliffs.gd | 129 ++++++++++++++++++++++++++++++++++ tests/test_cliffs.gd.uid | 1 + 4 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 tests/test_cliffs.gd create mode 100644 tests/test_cliffs.gd.uid diff --git a/scripts/creatures/creature.gd b/scripts/creatures/creature.gd index 6889e73..55580fe 100644 --- a/scripts/creatures/creature.gd +++ b/scripts/creatures/creature.gd @@ -92,6 +92,13 @@ const KINDS := { "drops": {"raw_meat": [5, 7], "bear_hide": [1, 1], "bear_fat": [2, 3]}}, } 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 def: Dictionary @@ -384,6 +391,18 @@ func _blocked(dir: Vector3) -> bool: return true if absf(h - global_position.y) > 1.1: 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). var from := global_position + Vector3(0, 0.45, 0) 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: _visual.rotation.z = -PI * 0.5 return + _settle() # 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 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 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). func butcher_drops() -> Dictionary: var out := {} diff --git a/tests/run_all.sh b/tests/run_all.sh index abbf3b9..34449cf 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_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="" [ "$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_cliffs.gd b/tests/test_cliffs.gd new file mode 100644 index 0000000..a1a9215 --- /dev/null +++ b/tests/test_cliffs.gd @@ -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 diff --git a/tests/test_cliffs.gd.uid b/tests/test_cliffs.gd.uid new file mode 100644 index 0000000..49e58fd --- /dev/null +++ b/tests/test_cliffs.gd.uid @@ -0,0 +1 @@ +uid://b8pt3n5krip7j