OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / slope_climb_gate.gd

189 lines · gd ref:
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.")