OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / slope_travel_gate.gd

330 lines · gd ref:
extends SceneTree
## Does SLOPE stop the player, and if so at which layer?
##
## large_coord_physics_gate settled that coordinate magnitude does NOT: on FLAT
## ground at the DC Erosion spawn the shipping capsule travelled 99.8% of the
## expected distance. So "gets stuck and cannot travel" on the big maps has to
## come from something else, and the erosion map's own comments measure an RMS
## slope of 0.50 at 60 km wavelength with a documented "everything is a cliff"
## complaint. Slope is the candidate.
##
## TWO LAYERS, MEASURED SEPARATELY, because they fail for different reasons and
## a single number would hide which one is biting:
##
##   A. THE ENGINE. A bare CharacterBody3D with the shipping capsule and the
##      shipping floor settings (floor_max_angle 1.047 rad = 60 deg,
##      floor_snap_length 0.2, floor_constant_speed true) driven uphill at a
##      constant velocity. This measures move_and_slide and the floor
##      classification, nothing else.
##
##   B. THE CONTROLLER. player_procedural's OWN _update_movement, called on a
##      real instance of the shipping scene -- not a transcription of it. It sets
##          max_speed = run_speed * (1 - slope_component)
##      and then damps velocity by pow(E, -WALK_ACCEL*dt/max_speed), so as
##      max_speed falls toward zero the damping goes to zero with it and the
##      steady-state speed collapses. That is arithmetic, no physics involved,
##      and it is testable without a floor at all.
##
## Run:
##   "$HYPER_TPS_GODOT" --headless --script res://scenes/test/slope_travel_gate.gd

## load(), NOT preload(). preload resolves at PARSE time -- before the project's
## autoloads are registered -- so player_procedural.gd failed to compile with
## "Identifier not found: GameManager" and instantiate() handed back a bare
## scriptless CharacterBody3D. The gate then reported a table of nothing.
var _player_scene: PackedScene = null

const CAP_RADIUS := 0.3     # player_procedural.tscn:14
const CAP_HEIGHT := 1.58    # player_procedural.tscn:15
const CAP_Y := 0.79         # player_procedural.tscn:48
const FLOOR_MAX_ANGLE := 1.047
const FLOOR_SNAP := 0.2
const DRIVE_SPEED := 8.0    # run_speed, player_procedural.gd:95
const SETTLE_FRAMES := 60
const DRIVE_FRAMES := 120

## The DC Erosion spawn altitude, because that is where the report came from.
const SPAWN_ALTITUDE := 9700.0

var _angles: Array = [0.0, 10.0, 20.0, 30.0, 35.0, 40.0, 45.0, 50.0, 55.0, 60.0, 65.0]

## The SAME triangulated slope at two altitudes. This is the control the first
## run lacked: it separates SLOPE from ALTITUDE, which is the open question.
##
## A rotated BoxShape3D was tried as a "no internal edges" control and had to be
## dropped -- it stalls at frame 14 on FLAT ground at 9700 m and travels 10.9%,
## so it is broken by the very variable it was meant to hold still. (That box
## behaviour is real and unexplained; large_coord_physics_gate sees it too. It is
## not the shipping ground, which is a ConcavePolygonShape3D.)
## The altitude ladder. The boundary is NOT a cliff -- measured 2026-08-31:
##      0..6000   clean at every angle
##      7000      one failing cell (20 deg, 21.2%, stall at frame 28)
##      8000      clean
##      9700      stalls at every shallow angle
##      15000     chaotic, 55 deg down to 9.6%
## Isolated failures appear around 7 km and are pervasive by 9.7 km, which is why
## map_dc_erosion now centres its band on zero (max |y| 5300).
var _grounds: Array = [0.0, 5000.0, 9700.0, 15000.0]

## THE PREDICTION THIS TESTED, AND IT WAS FALSIFIED. Travel is clean at
## 0/500/2000/5000 m and breaks at 9700 m. float32 spacing at y is y * 2^-23 --
## 0.00049 m at 5000, 0.00098 m at 9700 -- and CharacterBody3D's safe_margin
## DEFAULTS TO 0.001 m, so the break lands exactly where the representable step
## reaches the margin the solver depenetrates with. That predicted a one-line
## fix: raise the margin above the step.
##
## Measured at 9700 m, margin 0.001 -> 0.010:
##
##      5 deg   87.3% -> 25.3%   WORSE
##     10 deg   50.2% -> 50.0%   unchanged
##     15 deg   18.9% -> 107.0%  fixed
##     20 deg   12.9% ->  76.9%  better, still stalls at frame 99
##     25 deg   18.1% ->  90.0%  fixed
##     30 deg   68.6% ->  85.8%  fixed
##
## So the margin is A factor -- it restores the mid slopes and takes on_floor
## from 70-98% to a flat 100%, i.e. it does fix the floor-contact flapping --
## but it makes the SHALLOWEST slopes worse and is not a monotone knob. The
## ulp-versus-margin story is not the whole mechanism and raising safe_margin is
## not the fix.
##
## The measured remedy is the one that needs no engine change: keep the playable
## surface BELOW ~5 km of altitude. Everything at 0/500/2000/5000 m is clean at
## every angle and every margin.
var _margins: Array = [0.001, 0.01]
var _midx: int = 0
var _gidx: int = 0
var _min_step: float = 1e30
var _stall_frame: int = -1

var _warm: int = 0
var _idx: int = 0
var _phase: int = 0
var _frame: int = 0
var _body: CharacterBody3D = null
var _ground: StaticBody3D = null
var _start: Vector3 = Vector3.ZERO
var _floor_frames: int = 0
var _rows: Array = []
var _tick: float = 1.0 / 60.0


func _initialize() -> void:
	_tick = 1.0 / float(Engine.physics_ticks_per_second)


# ---------------------------------------------------------------------------
# LAYER B — the controller's own arithmetic, on a real instance of the scene.
# ---------------------------------------------------------------------------
func _layer_b() -> void:
	print("")
	print("LAYER B -- player_procedural._update_movement, called on the shipping scene")
	print("(no physics, no floor: this is the controller's speed ceiling alone)")
	print("")
	print("slope    ground normal        max_speed    steady speed   of flat")
	print("--------------------------------------------------------------------")
	var flat_speed := 0.0
	for a in _angles:
		var rad: float = deg_to_rad(a)
		var n := Vector3(-sin(rad), cos(rad), 0.0).normalized()
		if _player_scene == null:
			_player_scene = load("res://scenes/player/player_procedural.tscn")
		var p: Node = _player_scene.instantiate()
		if p.get_script() == null:
			print("  ABORT: the player scene has no script attached -- the gate is")
			print("  measuring a bare CharacterBody3D, not the controller.")
			p.queue_free()
			return
		# DISABLED before it enters the tree, so _ready still wires the @onready
		# refs but _physics_process never runs and cannot fight the direct calls
		# below. The scene is the artifact under test; a hand-built stand-in
		# would be a transcription and would not be.
		p.process_mode = Node.PROCESS_MODE_DISABLED
		root.add_child(p)
		p.gravity_dir = Vector3.DOWN
		p.ground_normal = n
		p.surface_friction = 1.0
		p.duck_amount = 0.0
		p.sprinting = false
		p.walking = false
		p.velocity = Vector3.ZERO
		# --ski-hold: what HOLDING jump does, as opposed to what a steep slope
		# does on its own. Both take traction away; only this one works on flat.
		for arg in OS.get_cmdline_user_args():
			if arg == "--ski-hold":
				p.ski_holding = true
		# Face uphill: -z of camera_head must be +X, which is where the slope rises.
		# --across: input points ACROSS the slope instead of straight up it, which
		# is the traverse where holding and moving compete for the same budget.
		var face: float = -PI * 0.5
		for arg2 in OS.get_cmdline_user_args():
			if arg2 == "--across":
				face = 0.0
		p.camera_head.global_transform = Transform3D(Basis(Vector3.UP, face), Vector3.ZERO)
		p.input_sync.input_direction = Vector2(0.0, -1.0)
		var k := 0
		while k < 600:
			p._update_movement(_tick)
			k += 1
		var speed: float = p.velocity.length()
		if a == 0.0:
			flat_speed = speed
		var frac := 0.0
		if flat_speed > 0.0:
			frac = 100.0 * speed / flat_speed
		print("%5.0f deg  (%+.3f, %.3f, 0)   %8.3f     %8.3f m/s   %5.1f%%" % [
			a, n.x, n.y, p.max_speed, speed, frac])
		p.queue_free()
	print("")


# ---------------------------------------------------------------------------
# LAYER A — the engine. Shipping capsule and floor settings, constant velocity.
# ---------------------------------------------------------------------------
func _build() -> void:
	var a: float = _angles[_idx]
	var rad: float = deg_to_rad(a)
	var s: float = tan(rad)

	_ground = StaticBody3D.new()
	var cs := CollisionShape3D.new()
	var f := PackedVector3Array()
	var i := 0
	while i < 60:
		var j := 0
		while j < 20:
			var x0 := float(i) * 2.0 - 20.0
			var z0 := float(j) * 2.0 - 20.0
			var x1 := x0 + 2.0
			var z1 := z0 + 2.0
			var y0 := x0 * s
			var y1 := x1 * s
			# CW so the face normal points UP. [[feedback_godot-cw-winding]]
			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)
	_ground.position = Vector3(0.0, _alt(), 0.0)
	root.add_child(_ground)
	_build_body()


func _alt() -> float:
	return _grounds[_gidx]


func _build_body() -> void:
	_body = CharacterBody3D.new()
	_body.floor_max_angle = FLOOR_MAX_ANGLE
	_body.floor_snap_length = FLOOR_SNAP
	_body.floor_constant_speed = true
	_body.safe_margin = _margins[_midx]
	var bcs := CollisionShape3D.new()
	var cap := CapsuleShape3D.new()
	cap.radius = CAP_RADIUS
	cap.height = CAP_HEIGHT
	bcs.shape = cap
	bcs.position = Vector3(0, CAP_Y, 0)
	_body.add_child(bcs)
	root.add_child(_body)
	_body.position = Vector3(0.0, _alt() + 1.0, 0.0)

	_phase = 0
	_frame = 0
	_floor_frames = 0
	_min_step = 1e30
	_stall_frame = -1


func _physics_process(_d: float) -> bool:
	_warm += 1
	if _warm < 3:
		return false
	# Nodes added in _initialize are not inside the tree yet, so _ready has not
	# run and every @onready ref is null. Layer B needs a live tree; so does the
	# broadphase Layer A queries.
	if _warm == 3:
		_layer_b()
		# Layer B is the controller's arithmetic and needs no physics, no floor and
		# no skeleton — so it is the one measurement here that is fast and that
		# still works. Let it be run on its own.
		for a in OS.get_cmdline_user_args():
			if a == "--layer-b-only":
				return true
		_build()
		return false
	if _body == null:
		return true

	if _phase == 0:
		_body.velocity = Vector3(0.0, -4.0, 0.0)
		_body.move_and_slide()
		_frame += 1
		if _frame >= SETTLE_FRAMES:
			_phase = 1
			_frame = 0
			_start = _body.global_position
		return false

	# Drive uphill (+X) at a constant speed, with a little downward bias so the
	# body stays seated -- the same shape as the controller's own gravity.
	var before: float = _body.global_position.x
	_body.velocity = Vector3(DRIVE_SPEED, -1.0, 0.0)
	_body.move_and_slide()
	var step: float = _body.global_position.x - before
	if step < _min_step:
		_min_step = step
	if step < 0.001 and _stall_frame < 0:
		_stall_frame = _frame
	if _body.is_on_floor():
		_floor_frames += 1
	_frame += 1
	if _frame < DRIVE_FRAMES:
		return false

	var moved: Vector3 = _body.global_position - _start
	var horiz: float = absf(moved.x)
	var expected: float = DRIVE_SPEED * float(DRIVE_FRAMES) * _tick
	_rows.push_back({
		"g": "%5.0f m  %.3f" % [_grounds[_gidx], _margins[_midx]], "a": _angles[_idx], "horiz": horiz, "rise": moved.y,
		"pct": 100.0 * horiz / expected,
		"floor": 100.0 * float(_floor_frames) / float(DRIVE_FRAMES),
		"minstep": _min_step, "stall": _stall_frame,
	})
	_body.queue_free()
	_ground.queue_free()
	_body = null
	_ground = null
	_idx += 1
	if _idx >= _angles.size():
		_idx = 0
		_gidx += 1
		if _gidx >= _grounds.size():
			_gidx = 0
			_midx += 1
			if _midx >= _margins.size():
				_report()
				return true
	_build()
	return false


func _report() -> void:
	print("LAYER A -- bare CharacterBody3D, constant %.1f m/s uphill, at %.0f m altitude" % [DRIVE_SPEED, SPAWN_ALTITUDE])
	print("(floor_max_angle is %.1f deg -- above that the surface is a WALL, not a floor)" % rad_to_deg(FLOOR_MAX_ANGLE))
	print("")
	print("alt  margin   slope     horizontal   of expected   rise      on_floor   min step   first stall")
	print("---------------------------------------------------------------------------------------------")
	for r in _rows:
		var st := "none"
		if r["stall"] >= 0:
			st = "frame %d" % r["stall"]
		print("%-14s %5.0f deg  %8.3f m     %6.1f%%    %+7.3f m   %5.1f%%   %+8.4f   %s" % [
			r["g"], r["a"], r["horiz"], r["pct"], r["rise"], r["floor"],
			r["minstep"], st])
	print("")