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])