extends Node3D
## Can the player actually WALK UP a hill, and does it stay on the ground?
##
## foot_walk_gate deliberately traverses ACROSS a slope, so the cycle runs at a
## constant rate and its rows are comparable. That makes it blind to the two
## things reported in play: you cannot climb, and going downhill you bounce.
## This one drives straight INTO the hill, and straight down it.
##
## WHAT IT MEASURES, per slope, per direction:
## - GROUNDED %: fraction of physics frames with is_on_floor(). Every airborne
## frame is a frame where State.MOVEMENT never runs, so traction, the surface
## projection and the whole hold-then-move budget are simply skipped. If this
## is well under 100 the character is bouncing, and no amount of tuning the
## traction model will be felt.
## - CLIMB RATE: metres of height gained per second.
## - SLOPE SPEED: speed along the surface, against what the model predicts.
##
## The model (player_procedural.gd _update_movement / _apply_surface_move) says:
## hold_frac = GRAVITY * sin(t) / WALK_ACCEL grip needed just to stand
## margin = 1 - tan(t)/tan(slide_angle) +1 flat, 0 AT slide_angle
## traction = hold_frac + (1 - hold_frac) * margin
## budget = WALK_ACCEL * traction [35 m/s^2 on the flat]
## holding = GRAVITY * sin(t) [paid FIRST, out of the budget]
## so climbing runs out exactly AT slide_angle, by construction, and that same
## instant is where the ground starts carrying you.
##
## SPEED ALONG THE SURFACE. The body ships with floor_constant_speed = FALSE, so
## Godot moves |velocity| horizontally and the surface path is longer by 1/cos(t):
## grade costs you going up and pays you coming down. The POWERED target is
## run_speed * cos(t), which is what the column below reports.
##
## Downhill rows should EXCEED it, and that is not an error -- it is the part of
## gravity the feet did not hold. Measured at 20 degrees: 8.58 m/s against a
## powered 7.52. Past slide_angle the excess stops being a trim and becomes the
## whole thing (14.51 m/s at 50 degrees against a geometric 12.45).
##
## CONTROL: the 0 degree row. If the sloped rows do not differ from it, the gate
## is not seeing the slope and nothing below it means anything.
##
## READ THE ROWS AT AND PAST slide_angle CAREFULLY. The velocity model is Torque's
## and has NO friction term, so a body already moving keeps moving -- at the
## threshold the budget covers holding exactly, which means you cannot ACCELERATE
## but nothing slows you either. The player also gains speed in the air during the
## 0.6 m spawn drop, where traction does not apply. So a 45 degree row showing
## 6.5 m/s is coasting on that, not climbing under power, and the honest reading
## of 'can you set off up this' is the row BELOW the threshold.
##
## Run:
## "$HYPER_TPS_GODOT" --headless res://scenes/test/slope_climb_gate.tscn
const SLOPES := [0.0, 10.0, 20.0, 30.0, 35.0, 40.0, 45.0, 50.0]
const SETTLE := 90 ## frames to land AND let ground_normal's 0.9/0.1
## filter converge -- measured at ~75 frames
const RUN := 150 ## frames of sampling
const RUN_SPEED := 8.0
const WALK_ACCEL := 35.0
const GRAVITY := 9.8
const SLIDE_ANGLE := deg_to_rad(45.0) ## must track player_procedural.slide_angle
var _plan: Array = []
var _idx := 0
var _frames := 0
var _player: CharacterBody3D = null
var _ground: StaticBody3D = null
var _grounded := 0
var _samples := 0
var _y0 := 0.0
var _p0 := Vector3.ZERO
var _rows: Array = []
var _done := false
func _ready() -> void:
process_priority = 100
for s in SLOPES:
_plan.append({"deg": s, "dir": 1.0}) # uphill
for s in SLOPES:
_plan.append({"deg": s, "dir": -1.0}) # downhill
_build()
func _build() -> void:
var deg: float = float(_plan[_idx]["deg"])
var s: float = tan(deg_to_rad(deg))
_ground = StaticBody3D.new()
var cs := CollisionShape3D.new()
var f := PackedVector3Array()
# 1.5 m grid, 400 m across, so a 150-frame run at 8 m/s stays on it.
var i := 0
while i < 280:
var j := 0
while j < 40:
var x0 := float(i) * 1.5 - 210.0
var z0 := float(j) * 1.5 - 30.0
var x1 := x0 + 1.5
var z1 := z0 + 1.5
var y0 := x0 * s
var y1 := x1 * s
# CW so the normal points UP.
f.push_back(Vector3(x0, y0, z0)); f.push_back(Vector3(x1, y1, z1)); f.push_back(Vector3(x0, y0, z1))
f.push_back(Vector3(x0, y0, z0)); f.push_back(Vector3(x1, y1, z0)); f.push_back(Vector3(x1, y1, z1))
j += 1
i += 1
var tri := ConcavePolygonShape3D.new()
tri.set_faces(f)
cs.shape = tri
_ground.add_child(cs)
add_child(_ground)
var scene: PackedScene = load("res://scenes/player/player_procedural.tscn")
_player = scene.instantiate()
add_child(_player)
_player.global_position = Vector3(0.0, 0.6, 0.0)
_player.camera_head.global_transform = Transform3D(Basis.IDENTITY, Vector3.ZERO)
_frames = 0
_grounded = 0
_samples = 0
func _physics_process(_d: float) -> void:
if _done or _player == null:
return
# +X is uphill (the surface is y = x*tan(t)). Identity camera: input (1,0) -> +X.
_player.input_sync.input_direction = Vector2(float(_plan[_idx]["dir"]), 0.0)
_frames += 1
if _frames == SETTLE:
_y0 = _player.global_position.y
_p0 = _player.global_position
if _frames > SETTLE:
_samples += 1
if _player.is_on_floor():
_grounded += 1
func _process(_d: float) -> void:
if _done or _player == null or _frames < SETTLE + RUN:
return
var deg: float = float(_plan[_idx]["deg"])
var dir: float = float(_plan[_idx]["dir"])
var dt: float = float(_samples) / 60.0
var p: Vector3 = _player.global_position
var climb: float = (p.y - _y0) / maxf(dt, 0.0001)
var along: float = p.distance_to(_p0) / maxf(dt, 0.0001)
_rows.append({"deg": deg, "dir": dir,
"grounded": 100.0 * float(_grounded) / maxf(float(_samples), 1.0),
"climb": climb, "along": along})
_player.queue_free(); _player = null
_ground.queue_free(); _ground = null
_idx += 1
if _idx >= _plan.size():
_report()
_done = true
get_tree().quit(0)
return
_build()
func _report() -> void:
print("")
print("dir slope grounded climb rate speed along model says")
print("--------------------------------------------------------------------------")
for r in _rows:
var t: float = sin(deg_to_rad(float(r["deg"])))
var hf: float = GRAVITY * t / WALK_ACCEL
var mg: float = 1.0 - tan(deg_to_rad(float(r["deg"]))) / tan(SLIDE_ANGLE)
var tr: float = clampf(hf + (1.0 - hf) * mg, 0.0, 1.0)
var budget: float = WALK_ACCEL * tr
var hold: float = GRAVITY * t
var predicted: float = RUN_SPEED * cos(deg_to_rad(float(r["deg"])))
var verdict: String = ("%.1f m/s" % predicted) if budget > hold else "slides"
print("%-9s %5.0f %6.1f %% %+7.2f m/s %8.2f m/s %s"
% ["uphill" if r["dir"] > 0.0 else "downhill", r["deg"],
r["grounded"], r["climb"], r["along"], verdict])
print("")
var flat_g := 0.0
for r in _rows:
if r["deg"] == 0.0 and r["dir"] > 0.0:
flat_g = r["grounded"]
var differs := false
for r in _rows:
if r["deg"] > 0.0 and absf(r["grounded"] - flat_g) > 0.5:
differs = true
if not differs:
print("CONTROL: grounded %% is the same on every slope as on the flat — either")
print("the character never leaves the ground, or the gate is not seeing slope.")
print("Check the climb-rate column before believing either.")
else:
print("control ok: grounded %% varies with slope, so the gate is seeing it.")