OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / dc_planetoid_test.gd

657 lines · gd ref:
extends Node3D
## Planetoid test — spherical terrain with spherical gravity.
## Uses player_procedural with gravity_dir override (option 1: orient externally).

@onready var terrain: Node3D = $DCTerrain
@onready var stats_label: Label = $CanvasLayer/StatsLabel
@onready var player: CharacterBody3D = $Player

## Planet config
@export var planet_radius: float = 25010.0
var planet_center: Vector3 = Vector3.ZERO

var _terrain_ready: bool = false
var _wait_frames: int = 0
var _paused: bool = false
var _spawn_done: bool = false
const SPAWN_OFFSET: float = 5.0  ## Meters above surface to spawn
var _unstick_count: int = 0  ## Track how many times we unstuck the player
var _teleport_cooldown: int = 0  ## Frames to skip physics after teleport

# Spawn point system
var _spawn_markers: Array[Node3D] = []
var _spawn_cycle_index: int = 0
const SPAWN_MARKER_SCENE: String = "res://scenes/teleporter/teleporter.tscn"
const SPAWN_SAVE_PATH: String = "user://planetoid_spawns.json"

# Debug IK visualization
var _debug_ik: bool = false
var _debug_wireframe: bool = false
var _debug_grid: bool = false
var _debug_grid_node: MultiMeshInstance3D
var _debug_grid_frame: int = 0
var _debug_grid_last_pos: Vector3 = Vector3.ZERO
var _debug_mesh_instance: MeshInstance3D
var _debug_immediate_mesh: ImmediateMesh

func _ready() -> void:
	process_mode = Node.PROCESS_MODE_ALWAYS

	# Configure terrain for spherical planetoid
	terrain.seed = 42
	terrain.spherical = true
	terrain.planet_radius = planet_radius
	terrain.voxel_size = 0.5

	# Octree depth depends on planet size:
	# Root span (world) = (1 << (depth-1)) * voxels_per_cell * voxel_size
	# voxels_per_cell = 12 (nominal cells: 16-grid minus 2*overlap=2, from dc_terrain.cpp:89)
	var diameter: float = planet_radius * 2.0
	var depth: int = 5
	var voxels_per_cell: int = 12
	while ((1 << (depth - 1)) * voxels_per_cell * terrain.voxel_size) < diameter * 1.2:
		depth += 1
	terrain.octree_depth = depth
	var root_span: float = (1 << (depth - 1)) * voxels_per_cell * terrain.voxel_size
	var half_span: float = root_span * 0.5
	print("Planet radius=%.0f, octree_depth=%d, root_span=%.0f, half_span=%.0f" % [
		planet_radius, depth, root_span, half_span])

	# Noise params scaled to planet size
	var inv_r: float = 1.0 / planet_radius
	terrain.primary_frequency = 0.5 * inv_r
	terrain.primary_strength = planet_radius * 0.05
	terrain.ridge_frequency = 1.0 * inv_r
	terrain.ridge_strength = planet_radius * 0.02
	terrain.max_ridge_octaves = 2  # Only largest-scale ridges — avoids stair-stepping from fabs() kink
	terrain.warp_frequency = 0.3 * inv_r
	terrain.warp_strength = planet_radius * 0.02
	terrain.detail_frequency = 5.0 * inv_r
	terrain.detail_strength = planet_radius * 0.005
	terrain.tunnel_frequency = 2.0 * inv_r
	terrain.tunnel_strength = planet_radius * 0.01
	terrain.tunnel_radius = 0.3
	terrain.max_noise_octaves = 6  # Smooth walkable surface — cuts sub-30m bumps

	terrain.terrain_color = Color(0.4, 0.5, 0.3)
	terrain.use_screen_space_error = true
	terrain.screen_split_threshold = 1.0  # 1 pixel — tune lower if transitions visible
	terrain.rebuild()

	# Load saved spawn points
	_load_spawns()

	# Place player above north pole initially (refined once terrain is ready)
	player.global_position = Vector3(0, planet_radius * 1.2, 0)
	player.set_physics_process(false)
	player.set_process(false)
	print("Player initial pos: %s" % player.global_position)

	# Capture mouse for camera control
	Input.mouse_mode = Input.MOUSE_MODE_CAPTURED


func _process(delta: float) -> void:
	if not _terrain_ready:
		var active: int = terrain.get_active_chunk_count()
		var collision_q: int = terrain.get_collision_queue_size()
		if active >= 4 and collision_q == 0:
			_wait_frames += 1
			if _wait_frames >= 3:
				_terrain_ready = true
				print("Terrain ready (active: %d) — searching for surface..." % active)

	# Retry surface spawn until raycast confirms collision at surface
	if _terrain_ready and not _spawn_done:
		if _try_spawn_on_surface():
			_spawn_done = true
			player.set_physics_process(true)
			player.set_process(true)
			print("Player physics enabled at %s" % player.global_position)

	if stats_label and terrain and player:
		var chunks: int = terrain.get_chunk_count()
		var active: int = terrain.get_active_chunk_count()
		var pending: int = terrain.get_pending_count()
		var fps: int = Engine.get_frames_per_second()
		var pos: Vector3 = player.global_position
		var alt: float = pos.length() - planet_radius
		var pause_str: String = " [PAUSED]" if _paused else ""
		var unstick_str: String = "\nUnsticks: %d" % _unstick_count if _unstick_count > 0 else ""
		var ik_str: String = " [IK debug]" if _debug_ik else ""
		var clip_str: String = " [clipmap]" if terrain.debug_clipmap else ""
		var wire_str: String = " [wireframe]" if _debug_wireframe else ""
		var grid_str: String = " [grid]" if _debug_grid else ""
		var spawn_str: String = "  Spawns: %d" % _spawn_markers.size() if not _spawn_markers.is_empty() else ""
		stats_label.text = "FPS: %d%s%s%s%s%s\nChunks: %d (active: %d, pending: %d)\nAlt: %.1fm\nPos: (%.0f, %.0f, %.0f)%s%s\n[P] pause  [I] IK  [C] clipmap  [V] wire  [G] grid  [F12] shot  [R] rebuild\n[MMB] spawn  [F7] cycle spawns  [F5] fly  [Del] respawn" % [
			fps, pause_str, ik_str, clip_str, wire_str, grid_str, chunks, active, pending, alt, pos.x, pos.y, pos.z, unstick_str, spawn_str]

	# Draw IK debug visualization
	_draw_ik_debug()

	# Update density grid debug if active
	if _debug_grid and _spawn_done:
		_update_debug_grid()


func _try_spawn_on_surface() -> bool:
	# First, find approximate surface via density binary search.
	# evaluate_spherical sign: positive = inside (solid), negative = outside (air).
	var spawn_dir: Vector3 = Vector3.UP  # north pole
	var lo: float = planet_radius * 0.5
	var hi: float = planet_radius * 1.5

	var d_lo: float = terrain.sample_density(spawn_dir * lo)
	var d_hi: float = terrain.sample_density(spawn_dir * hi)

	var surface_r: float = planet_radius  # fallback
	if d_lo * d_hi < 0.0:
		# Binary search: 20 iterations for sub-meter precision
		for i in range(20):
			var mid: float = (lo + hi) * 0.5
			var d_mid: float = terrain.sample_density(spawn_dir * mid)
			if d_mid > 0.0:
				lo = mid  # inside — move outward
			else:
				hi = mid  # outside — move inward
		surface_r = (lo + hi) * 0.5

	# Position player above the estimated surface so the camera drives LOD there
	player.global_position = spawn_dir * (surface_r + 50.0)

	# Now raycast DOWN to verify collision actually exists at the surface.
	# If no collision yet (chunks still generating), return false to retry next frame.
	var space_state: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state
	var ray_start: Vector3 = spawn_dir * (surface_r + 200.0)
	var ray_end: Vector3 = spawn_dir * (surface_r - 200.0)
	var query := PhysicsRayQueryParameters3D.create(ray_start, ray_end)
	var result: Dictionary = space_state.intersect_ray(query)
	if result.size() == 0:
		# No collision yet — keep player positioned to drive LOD, retry next frame
		return false

	# Collision confirmed — place player just above the hit point
	var surface_point: Vector3 = result["position"]
	player.global_position = surface_point + spawn_dir * SPAWN_OFFSET
	print("Player spawned: surface_r=%.1f alt=%.1fm (raycast confirmed)" % [
		surface_point.length(), surface_point.length() - planet_radius])

	# Orient player so local Y points away from planet
	var up_dir: Vector3 = (player.global_position - planet_center).normalized()
	player.up_direction = up_dir
	player.gravity_dir = -up_dir
	var current_up: Vector3 = player.global_basis.y
	var rot_axis: Vector3 = current_up.cross(up_dir)
	if rot_axis.length_squared() > 0.00001:
		rot_axis = rot_axis.normalized()
		var angle: float = current_up.angle_to(up_dir)
		player.global_rotate(rot_axis, angle)
	return true


func _physics_process(delta: float) -> void:
	if not _terrain_ready or not player:
		return

	# After teleport, wait for collision to build before allowing physics
	if _teleport_cooldown > 0:
		_teleport_cooldown -= 1
		player.velocity = Vector3.ZERO
		# Check if collision has arrived via raycast downward (toward planet center)
		if _teleport_cooldown < 80:  # give at least 10 frames for LOD to start
			var up_dir: Vector3 = (player.global_position - planet_center).normalized()
			var ray_start: Vector3 = player.global_position + up_dir * 5.0
			var ray_end: Vector3 = player.global_position - up_dir * 20.0
			var space_state: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state
			var query := PhysicsRayQueryParameters3D.create(ray_start, ray_end)
			var result: Dictionary = space_state.intersect_ray(query)
			if not result.is_empty():
				# Collision exists — place on surface and resume
				var surface: Vector3 = result["position"]
				player.global_position = surface + up_dir * SPAWN_OFFSET
				_teleport_cooldown = 0
				print("Collision ready — resuming at alt %.1fm" % [
					player.global_position.length() - planet_radius])
		return

	# Spherical gravity: "up" = away from planet center
	# ALWAYS update gravity_dir/up_direction — even in fly mode — so fly controls work correctly
	var to_center: Vector3 = planet_center - player.global_position
	if to_center.length_squared() < 1.0:
		# Player is at/near planet center — emergency push to north pole surface
		player.global_position = Vector3.UP * (planet_radius + SPAWN_OFFSET)
		player.velocity = Vector3.ZERO
		_unstick_count += 1
		print("UNSTICK #%d: player at planet center, pushed to north pole" % _unstick_count)
		return
	var gravity_dir: Vector3 = to_center.normalized()
	var up_dir: Vector3 = -gravity_dir

	# Set player's gravity direction and floor detection
	player.gravity_dir = gravity_dir
	player.up_direction = up_dir

	# ---- Unstick: detect player inside solid terrain and push out ----
	# Works in both walk and fly mode — prevents getting trapped inside terrain
	var density: float = terrain.sample_density(player.global_position)
	if density > 0.0:
		# Player is inside solid terrain — push outward along radial direction
		var r: float = player.global_position.length()
		var dir: Vector3 = up_dir
		var lo: float = r
		var hi: float = r + 50.0
		for i in range(15):
			var mid: float = (lo + hi) * 0.5
			var d: float = terrain.sample_density(dir * mid)
			if d > 0.0:
				lo = mid
			else:
				hi = mid
		var surface_r: float = (lo + hi) * 0.5
		# Place just above surface (0.2m) — NOT 5m like initial spawn
		player.global_position = dir * (surface_r + 0.2)
		player.velocity = Vector3.ZERO
		_unstick_count += 1
		if _unstick_count <= 10:  # Limit spam
			print("UNSTICK #%d: density=%.2f at r=%.1f, pushed to r=%.1f" % [
				_unstick_count, density, r, surface_r + 0.2])

	# Skip gravity and orientation in fly mode — let player fly freely
	if player.fly_mode:
		return

	# Orient the player so its local Y matches surface normal
	_orient_to_surface(player, up_dir, delta)


func _orient_to_surface(body: Node3D, up: Vector3, delta: float) -> void:
	var current_up: Vector3 = body.global_basis.y
	if current_up.dot(up) > 0.9999:
		return
	var rot_axis: Vector3 = current_up.cross(up)
	if rot_axis.length_squared() < 0.00001:
		return
	rot_axis = rot_axis.normalized()
	var angle: float = current_up.angle_to(up)
	angle = minf(angle, delta * 5.0)  # Gentler rotation to avoid collision fighting
	body.global_rotate(rot_axis, angle)


func _draw_ik_debug() -> void:
	if not _debug_ik or not player:
		return

	# Get foot IK from procedural animator (added at runtime to skeleton)
	var proc_anim = player._proc_animator if "_proc_animator" in player else null
	if proc_anim == null:
		return
	var foot_ik = proc_anim._foot_ik if "_foot_ik" in proc_anim else null
	if foot_ik == null:
		return
	_draw_foot_ik_data(foot_ik)


func _draw_foot_ik_data(foot_ik: RefCounted) -> void:
	if _debug_mesh_instance == null:
		_debug_immediate_mesh = ImmediateMesh.new()
		_debug_mesh_instance = MeshInstance3D.new()
		_debug_mesh_instance.mesh = _debug_immediate_mesh
		# Unshaded material so lines are always visible
		var mat := StandardMaterial3D.new()
		mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
		mat.vertex_color_use_as_albedo = true
		mat.no_depth_test = true
		_debug_mesh_instance.material_override = mat
		add_child(_debug_mesh_instance)

	_debug_immediate_mesh.clear_surfaces()

	var colors: Array = [Color.CYAN, Color.MAGENTA]  # left=cyan, right=magenta
	var labels: Array = ["L", "R"]

	for i in range(2):
		var foot_pos: Vector3 = foot_ik.debug_foot_positions[i]
		var ground_pt: Vector3 = foot_ik.debug_ground_points[i]
		var ground_n: Vector3 = foot_ik.debug_ground_normals[i]
		var sweep_from: Vector3 = foot_ik.debug_sweep_from[i]
		var sweep_to: Vector3 = foot_ik.debug_sweep_to[i]
		var hit: bool = foot_ik._ground_hit[i]
		var color: Color = colors[i]
		var miss_color: Color = Color.RED

		# Draw sweep line (from → to)
		_debug_immediate_mesh.surface_begin(Mesh.PRIMITIVE_LINES)
		_debug_immediate_mesh.surface_set_color(Color.YELLOW if hit else miss_color)
		_debug_immediate_mesh.surface_add_vertex(sweep_from)
		_debug_immediate_mesh.surface_add_vertex(sweep_to)
		_debug_immediate_mesh.surface_end()

		if hit:
			# Draw ground hit cross (0.15m arms)
			_draw_cross(ground_pt, 0.15, color)

			# Draw ground normal (0.3m line from hit point)
			_debug_immediate_mesh.surface_begin(Mesh.PRIMITIVE_LINES)
			_debug_immediate_mesh.surface_set_color(Color.GREEN)
			_debug_immediate_mesh.surface_add_vertex(ground_pt)
			_debug_immediate_mesh.surface_add_vertex(ground_pt + ground_n * 0.3)
			_debug_immediate_mesh.surface_end()

		# Draw foot bone position cross (smaller, white)
		_draw_cross(foot_pos, 0.08, Color.WHITE)


func _draw_cross(center: Vector3, size: float, color: Color) -> void:
	_debug_immediate_mesh.surface_begin(Mesh.PRIMITIVE_LINES)
	_debug_immediate_mesh.surface_set_color(color)
	_debug_immediate_mesh.surface_add_vertex(center + Vector3(size, 0, 0))
	_debug_immediate_mesh.surface_add_vertex(center - Vector3(size, 0, 0))
	_debug_immediate_mesh.surface_add_vertex(center + Vector3(0, size, 0))
	_debug_immediate_mesh.surface_add_vertex(center - Vector3(0, size, 0))
	_debug_immediate_mesh.surface_add_vertex(center + Vector3(0, 0, size))
	_debug_immediate_mesh.surface_add_vertex(center - Vector3(0, 0, size))
	_debug_immediate_mesh.surface_end()


func _input(event: InputEvent) -> void:
	# MMB and F6 need _input (not _unhandled_input) to not get swallowed by player
	if event is InputEventMouseButton and event.pressed:
		if event.button_index == MOUSE_BUTTON_MIDDLE and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
			_place_spawn_at_cursor()
			get_viewport().set_input_as_handled()
			return
	if event is InputEventKey and event.pressed and event.keycode == KEY_F7:
		_cycle_spawn_point()
		get_viewport().set_input_as_handled()
		return


func _unhandled_input(event: InputEvent) -> void:
	if event is InputEventKey and event.pressed and event.keycode == KEY_P:
		_paused = !_paused
		get_tree().paused = _paused

	if event is InputEventKey and event.pressed and event.keycode == KEY_I:
		_debug_ik = !_debug_ik
		print("IK debug visualization: %s" % ("ON" if _debug_ik else "OFF"))
		if not _debug_ik and _debug_mesh_instance:
			_debug_immediate_mesh.clear_surfaces()

	if event is InputEventKey and event.pressed and event.keycode == KEY_F12:
		var img: Image = get_viewport().get_texture().get_image()
		var ts: String = Time.get_datetime_string_from_system().replace(":", "-")
		var path: String = "/home/fleeky/Desktop/hyper-tps/planet_%s.png" % ts
		img.save_png(path)
		print("Screenshot saved: %s" % path)

	if event is InputEventKey and event.pressed and event.keycode == KEY_C:
		terrain.debug_clipmap = !terrain.debug_clipmap
		print("Clipmap debug: %s" % ("ON" if terrain.debug_clipmap else "OFF"))

	if event is InputEventKey and event.pressed and event.keycode == KEY_V:
		_debug_wireframe = !_debug_wireframe
		if _debug_wireframe:
			get_viewport().debug_draw = Viewport.DEBUG_DRAW_WIREFRAME
		else:
			get_viewport().debug_draw = Viewport.DEBUG_DRAW_DISABLED
		print("Wireframe: %s" % ("ON" if _debug_wireframe else "OFF"))

	if event is InputEventKey and event.pressed and event.keycode == KEY_G:
		_debug_grid = !_debug_grid
		print("Density grid debug: %s" % ("ON" if _debug_grid else "OFF"))
		if not _debug_grid and _debug_grid_node:
			_debug_grid_node.queue_free()
			_debug_grid_node = null

	if event is InputEventKey and event.pressed and event.keycode == KEY_R:
		terrain.rebuild()
		print("Terrain rebuild triggered")

	# MMB and F6 handled in _input() above


func _update_debug_grid() -> void:
	# Only refresh when player moves significantly (> 1 voxel)
	_debug_grid_frame += 1
	if _debug_grid_node and player.global_position.distance_to(_debug_grid_last_pos) < terrain.voxel_size * 2.0:
		return
	if _debug_grid_frame % 15 != 0:
		return
	_debug_grid_last_pos = player.global_position
	# Sample density in a grid around the player and show solid cells as cubes.
	# This reveals the raw voxel grid BEFORE dual contouring processes it.
	var vs: float = terrain.voxel_size  # voxel spacing in world units
	var radius: int = 20  # cells in each direction from player
	var center: Vector3 = player.global_position

	# Snap center to grid
	var cx: int = roundi(center.x / vs)
	var cy: int = roundi(center.y / vs)
	var cz: int = roundi(center.z / vs)

	# Collect solid surface voxels (solid with at least one air neighbor)
	var surface_positions: PackedVector3Array = PackedVector3Array()
	var surface_densities: PackedFloat32Array = PackedFloat32Array()

	for dz in range(-radius, radius + 1):
		for dy in range(-radius, radius + 1):
			for dx in range(-radius, radius + 1):
				var wx: float = (cx + dx) * vs
				var wy: float = (cy + dy) * vs
				var wz: float = (cz + dz) * vs
				var d: float = terrain.sample_density(Vector3(wx, wy, wz))
				if d <= 0.0:
					continue  # air cell, skip
				# Check if any neighbor is air (surface cell)
				var is_surface: bool = false
				for axis in range(3):
					for sign in [-1, 1]:
						var nx: float = wx + (vs if axis == 0 else 0.0) * sign
						var ny: float = wy + (vs if axis == 1 else 0.0) * sign
						var nz: float = wz + (vs if axis == 2 else 0.0) * sign
						if terrain.sample_density(Vector3(nx, ny, nz)) <= 0.0:
							is_surface = true
							break
					if is_surface:
						break
				if is_surface:
					surface_positions.append(Vector3(wx, wy, wz))
					surface_densities.append(d)

	# Build MultiMesh
	if _debug_grid_node:
		_debug_grid_node.queue_free()
		_debug_grid_node = null

	if surface_positions.size() == 0:
		return

	_debug_grid_node = MultiMeshInstance3D.new()
	var mm := MultiMesh.new()
	mm.transform_format = MultiMesh.TRANSFORM_3D
	mm.use_colors = true
	mm.mesh = BoxMesh.new()
	(mm.mesh as BoxMesh).size = Vector3(vs * 0.9, vs * 0.9, vs * 0.9)
	mm.instance_count = surface_positions.size()

	for i in range(surface_positions.size()):
		var t := Transform3D()
		t.origin = surface_positions[i]
		mm.set_instance_transform(i, t)
		# Color by density: white near surface (d≈0), red deep inside
		var intensity: float = clampf(1.0 - surface_densities[i] / vs, 0.0, 1.0)
		mm.set_instance_color(i, Color(1.0, intensity, intensity, 0.8))

	_debug_grid_node.multimesh = mm
	# Unshaded material so grid cubes are clearly visible
	var mat := StandardMaterial3D.new()
	mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
	mat.vertex_color_use_as_albedo = true
	mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
	_debug_grid_node.material_override = mat
	add_child(_debug_grid_node)


func _place_spawn_at_cursor() -> void:
	var camera: Camera3D = get_viewport().get_camera_3d()
	if not camera:
		return
	var space_state: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state
	var screen_center: Vector2 = get_viewport().get_visible_rect().size * 0.5
	var ray_origin: Vector3 = camera.project_ray_origin(screen_center)
	var ray_dir: Vector3 = camera.project_ray_normal(screen_center)
	var ray_end: Vector3 = ray_origin + ray_dir * 500.0

	var query := PhysicsRayQueryParameters3D.create(ray_origin, ray_end)
	query.collision_mask = 0xFFFFFFFF
	var result: Dictionary = space_state.intersect_ray(query)
	if result.is_empty():
		print("No surface hit for spawn placement")
		return

	var hit_pos: Vector3 = result["position"]
	var hit_normal: Vector3 = result["normal"]

	# Create spawn marker — use teleporter model if available, else a simple mesh
	var marker: Node3D
	if ResourceLoader.exists(SPAWN_MARKER_SCENE):
		marker = load(SPAWN_MARKER_SCENE).instantiate()
	else:
		marker = Node3D.new()
		var mesh_inst := MeshInstance3D.new()
		var cylinder := CylinderMesh.new()
		cylinder.top_radius = 0.5
		cylinder.bottom_radius = 0.8
		cylinder.height = 3.0
		mesh_inst.mesh = cylinder
		var mat := StandardMaterial3D.new()
		mat.albedo_color = Color(0.2, 0.8, 1.0)
		mat.emission_enabled = true
		mat.emission = Color(0.2, 0.8, 1.0)
		mat.emission_energy_multiplier = 2.0
		mesh_inst.material_override = mat
		marker.add_child(mesh_inst)

	# Add to tree FIRST — global_position doesn't work until node is in scene tree
	marker.add_to_group("spawn_points")
	add_child(marker)

	# Position at hit point, orient to surface normal (local Y = surface normal)
	var up: Vector3 = hit_normal.normalized()
	if up.length_squared() < 0.5:
		up = (hit_pos - planet_center).normalized()
	# Build basis with Y = surface normal
	var forward: Vector3 = Vector3.FORWARD
	if absf(up.dot(forward)) > 0.99:
		forward = Vector3.RIGHT
	var right: Vector3 = up.cross(forward).normalized()
	forward = right.cross(up).normalized()
	marker.global_transform = Transform3D(Basis(right, up, forward), hit_pos)
	_spawn_markers.append(marker)

	var idx: int = _spawn_markers.size()
	print("Spawn point #%d placed at %s (alt: %.1fm)" % [
		idx, hit_pos, hit_pos.length() - planet_radius])
	_save_spawns()


func _cycle_spawn_point() -> void:
	var spawns: Array[Node] = []
	spawns.assign(get_tree().get_nodes_in_group("spawn_points"))
	if spawns.is_empty():
		print("No spawn points — place one with MMB")
		return

	_spawn_cycle_index = _spawn_cycle_index % spawns.size()
	var target: Node3D = spawns[_spawn_cycle_index] as Node3D
	var target_pos: Vector3 = target.global_position
	var up_dir: Vector3 = (target_pos - planet_center).normalized()

	# Freeze LOD during teleport jump to prevent thrashing mid-transition
	terrain.lod_frozen = true
	player.velocity = Vector3.ZERO

	# Place player slightly above the spawn point
	player.global_position = target_pos + up_dir * 3.0
	player.gravity_dir = -up_dir
	player.up_direction = up_dir

	# Orient player
	var current_up: Vector3 = player.global_basis.y
	var rot_axis: Vector3 = current_up.cross(up_dir)
	if rot_axis.length_squared() > 0.00001:
		rot_axis = rot_axis.normalized()
		var angle: float = current_up.angle_to(up_dir)
		player.global_rotate(rot_axis, angle)

	# Unfreeze LOD so octree starts splitting at new camera position → collision builds
	terrain.lod_frozen = false
	# Wait for collision to arrive before allowing player physics
	_teleport_cooldown = 90

	print("Teleported to spawn #%d/%d (alt: %.1fm) — waiting for collision..." % [
		_spawn_cycle_index + 1, spawns.size(), target_pos.length() - planet_radius])
	_spawn_cycle_index += 1


func _save_spawns() -> void:
	var data: Array = []
	for marker in _spawn_markers:
		var pos: Vector3 = marker.global_position
		data.append({"x": pos.x, "y": pos.y, "z": pos.z})
	var json_str: String = JSON.stringify(data)
	var file := FileAccess.open(SPAWN_SAVE_PATH, FileAccess.WRITE)
	if file:
		file.store_string(json_str)
		file.close()
		print("Saved %d spawn points to %s" % [data.size(), SPAWN_SAVE_PATH])


func _load_spawns() -> void:
	if not FileAccess.file_exists(SPAWN_SAVE_PATH):
		return
	var file := FileAccess.open(SPAWN_SAVE_PATH, FileAccess.READ)
	if not file:
		return
	var json_str: String = file.get_as_text()
	file.close()
	var json := JSON.new()
	if json.parse(json_str) != OK:
		print("Failed to parse spawn save file")
		return
	var data: Array = json.data
	if data.is_empty():
		return
	for entry in data:
		var pos := Vector3(entry["x"], entry["y"], entry["z"])
		var marker: Node3D
		if ResourceLoader.exists(SPAWN_MARKER_SCENE):
			marker = load(SPAWN_MARKER_SCENE).instantiate()
		else:
			marker = Node3D.new()
			var mesh_inst := MeshInstance3D.new()
			var cylinder := CylinderMesh.new()
			cylinder.top_radius = 0.5
			cylinder.bottom_radius = 0.8
			cylinder.height = 3.0
			mesh_inst.mesh = cylinder
			var mat := StandardMaterial3D.new()
			mat.albedo_color = Color(0.2, 0.8, 1.0)
			mat.emission_enabled = true
			mat.emission = Color(0.2, 0.8, 1.0)
			mat.emission_energy_multiplier = 2.0
			mesh_inst.material_override = mat
			marker.add_child(mesh_inst)
		marker.add_to_group("spawn_points")
		add_child(marker)
		# Orient to surface normal (radial direction on planetoid)
		var up: Vector3 = (pos - planet_center).normalized()
		var forward: Vector3 = Vector3.FORWARD
		if absf(up.dot(forward)) > 0.99:
			forward = Vector3.RIGHT
		var right: Vector3 = up.cross(forward).normalized()
		forward = right.cross(up).normalized()
		marker.global_transform = Transform3D(Basis(right, up, forward), pos)
		_spawn_markers.append(marker)
	print("Loaded %d spawn points from %s" % [data.size(), SPAWN_SAVE_PATH])