OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_arrival.gd

167 lines · gd ref:
extends SceneTree
## GATE — arriving in a map puts the player ON the ground: no long fall, no fall
## through terrain that has not built its collision yet, no spawning inside rock
## (user 2026-10-06: "our teleporters are not properly situated on the terrain and
## even if they are, the player will fall through").
##
## The real player (player_procedural.tscn, its own camera driving the terrain LOD, as
## in the game) arrives at the map's first spawn point through player.arrive() — the
## one entry every spawn / teleport / respawn path uses. For ARRIVE_SECS it records the
## fastest downward speed; then a ray finds the ground. Passes when the player
## stands on it (feet within STAND_TOL), never fell faster than MAX_FALL_SPEED (a
## free fall of ~3 m), and is not under a surface (buried).
##
## Measured 2026-10-06 (_probe_arrival, collision under the spawn / height above it):
##   map_geiss           +17.1 m above the terrain
##   map_geiss_terraces  BURIED 11.8 m inside the rock
##   map_mountain        +121 m above the terrain
##   map_geiss_world     placed right, but no collision under it for 4.2 s (1.8 s after
##                       the dc-terrain dispatch-key fix) — the player falls meanwhile
##
## ⚠ WINDOWED. One map per run (user arg; default map_geiss):
##   env -u CI HYPERTPS_DAEMON_TCP=127.0.0.1:1 DISPLAY=:0 "$HYPER_TPS_GODOT" --path . \
##       --script res://scenes/test/gate_arrival.gd -- map_geiss_terraces

const ARRIVE_SECS := 20.0
const STAND_TOL := 0.6
## 3 m of free fall at 9.8 m/s² is ~7.7 m/s.
const MAX_FALL_SPEED := 8.0
const MIN_LOOP_FPS := 15
const ProbeWindow := preload("res://scenes/test/probe_window.gd")

var _ws := ""
var _name := ""
var _player: CharacterBody3D
var _spawn := Vector3.ZERO
var _start := 0
var _worst_vy := 0.0
var _worst_vy_t := 0.0
var _last := 0
var _last_frames := 0
var _loop_min := 1 << 30
var _released_t := -1.0
var _released_at := Vector3.ZERO
var _was_arriving := false


func _initialize() -> void:
	var a := OS.get_cmdline_user_args()
	_name = a[0] if a.size() > 0 else "map_geiss"
	_ws = ProbeWindow.free_workspace()
	# The PLAYER first, so its camera exists when the map's _ready looks for one (the
	# Geiss maps make a fly camera only when there is none).
	_player = (load("res://scenes/player/player_procedural.tscn") as PackedScene).instantiate()
	root.add_child(_player)
	for c in _player.find_children("*", "Camera3D", true, false):
		(c as Camera3D).current = true
	var m: Node = (load("res://scenes/maps/%s.tscn" % _name) as PackedScene).instantiate()
	root.add_child(m)


## Spawn points register a frame after the map enters the tree (scene objects) —
## game.gd's change_map waits one process frame for the same reason.
func _arrive() -> void:
	var sp := get_nodes_in_group("spawn_points")
	if sp.is_empty():
		print("FAIL  %s has no spawn point" % _name)
		quit(1)
		return
	_spawn = (sp[0] as Node3D).global_position
	if _player.has_method("arrive"):
		_player.call("arrive", _spawn)
	else:
		_player.global_position = _spawn    # what every spawn path does today
		_player.velocity = Vector3.ZERO
	_start = Time.get_ticks_msec()
	print("  %s: arriving at %s (arrive() %s)" % [_name, _spawn, "present" if _player.has_method("arrive") else "ABSENT — raw position set"])


func _physics_process(_d: float) -> bool:
	if _start == 0:
		if Engine.get_physics_frames() >= 2:
			_arrive()
		return false
	if not ProbeWindow.drawing(root):
		ProbeWindow.move_to(_ws, Vector2i(1600, 900))
	var now := Time.get_ticks_msec()
	if _last > 0 and now - _last >= 500:
		var frames := Engine.get_process_frames()
		_loop_min = mini(_loop_min, int((frames - _last_frames) * 1000.0 / (now - _last)))
		_last_frames = frames
		_last = now
	elif _last == 0:
		_last = now
		_last_frames = Engine.get_process_frames()
	var el := (now - _start) / 1000.0
	var arriving: bool = _player.has_method("is_arriving") and bool(_player.call("is_arriving"))
	if _was_arriving and not arriving:
		_released_t = el
		_released_at = _player.global_position
		print("  released at %s after %.1f s" % [_released_at.snapped(Vector3.ONE * 0.01), el])
		var tr := root.find_children("*", "DCTerrain", true, false)
		if not tr.is_empty():
			print("    chunk under it then: %s" % str(tr[0].debug_chunk_info(_released_at - Vector3(0, 0.05, 0))))
		print("    column then: %s" % _column(_released_at))
	_was_arriving = arriving
	if _released_t >= 0.0 and _player.velocity.y < -MAX_FALL_SPEED and _worst_vy >= -MAX_FALL_SPEED:
		print("  FALLING at t=%.1f from %s (%.1f s after release)" % [el, _player.global_position.snapped(Vector3.ONE * 0.01), el - _released_t])
		var ts := root.find_children("*", "DCTerrain", true, false)
		if not ts.is_empty():
			print("    chunk under the release point now: %s" % str(ts[0].debug_chunk_info(_released_at - Vector3(0, 0.05, 0))))
		print("    column now: %s" % _column(_released_at))
	if _player.velocity.y < _worst_vy:
		_worst_vy = _player.velocity.y
		_worst_vy_t = el
	if el < ARRIVE_SECS:
		return false
	_report()
	return true


## Every surface in the column under p, top down: "y@rid(nx,ny)" — each hit body is
## excluded in turn, so stacked bodies (a chunk and its still-held parent) all show.
func _column(p: Vector3) -> String:
	var space := root.get_world_3d().direct_space_state
	var excl: Array[RID] = [_player.get_rid()]
	var out := []
	for _i in 6:
		var q := PhysicsRayQueryParameters3D.create(p + Vector3(0, 30, 0), p - Vector3(0, 60, 0), 0xFFFFFFFF, excl)
		q.hit_back_faces = false
		var h := space.intersect_ray(q)
		if h.is_empty():
			break
		var rid: RID = h["rid"]
		out.append("%.2f@%d(n.y %.2f)" % [(h["position"] as Vector3).y, rid.get_id(), (h["normal"] as Vector3).y])
		excl.append(rid)
	return ", ".join(out) if not out.is_empty() else "NOTHING"


func _report() -> void:
	var space := root.get_world_3d().direct_space_state
	var p := _player.global_position
	var down := space.intersect_ray(PhysicsRayQueryParameters3D.create(p + Vector3(0, 1.0, 0), p - Vector3(0, 400, 0), 0xFFFFFFFF, [_player.get_rid()]))
	var gap := INF
	if not down.is_empty():
		gap = p.y - (down["position"] as Vector3).y
	var upq := PhysicsRayQueryParameters3D.create(p + Vector3(0, 0.2, 0), p + Vector3(0, 300, 0), 0xFFFFFFFF, [_player.get_rid()])
	upq.hit_back_faces = true
	var up := space.intersect_ray(upq)
	var buried := not up.is_empty() and (up["normal"] as Vector3).y > 0.3
	print("  ended at %s: %s above the ground below, buried=%s; fastest fall %.1f m/s at t=%.1f s; min loop fps %d" % [
		p.snapped(Vector3.ONE * 0.01), ("%.2f m" % gap) if gap != INF else "NO GROUND", buried, -_worst_vy, _worst_vy_t, _loop_min])
	var ok := true
	if _loop_min < MIN_LOOP_FPS:
		print("FAIL  main loop fell to %d fps — window hidden? proves nothing" % _loop_min)
		ok = false
	if buried:
		print("FAIL  the player is INSIDE the terrain (a surface faces up above them)")
		ok = false
	if gap == INF or gap > STAND_TOL or gap < -0.05:
		print("FAIL  not standing on the ground (gap %s, tolerance %.1f m)" % [str(gap), STAND_TOL])
		ok = false
	if -_worst_vy > MAX_FALL_SPEED:
		print("FAIL  fell at %.1f m/s (max %.1f) — arrived in the air or before the ground had collision" % [-_worst_vy, MAX_FALL_SPEED])
		ok = false
	print("PASS" if ok else "FAIL")
	quit(0 if ok else 1)