OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / dc_terrain_streaming.gd

249 lines · gd ref:
extends Node3D
## Streaming terrain test with full procedural player.
## Validates LOD transitions, chunk generation, collision, and seam hiding.

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

var _terrain_ready: bool = false
var _wait_frames: int = 0
var _paused: bool = false
var _wireframe: bool = false
var _edit_radius: float = 3.0

const EDITS_SAVE_PATH := "user://terrain_edits.json"

func _ready() -> void:
	# Keep this script running while paused (for HUD + screenshot + unpause)
	process_mode = Node.PROCESS_MODE_ALWAYS

	# Terrain noise values are configured in the editor inspector (dc_terrain_streaming.tscn).
	# No hardcoded overrides — editor values carry through to runtime.
	terrain.collision_distance = 200.0
	terrain.rebuild()

	# Load persisted edits from previous session
	_load_edits()

	# Player needs multiplayer authority for local input
	player.set_multiplayer_authority(1)

	player.safe_margin = 0.08

	# Freeze player until terrain has collision ready
	player.global_position = Vector3(0, 10, 0)
	player.set_physics_process(false)
	player.set_process(false)

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


func _process(_delta: float) -> void:
	# Wait for terrain chunks AND collision to be ready before enabling player
	if not _terrain_ready:
		var active: int = terrain.get_active_chunk_count()
		var collision_q: int = terrain.get_collision_queue_size()
		if active >= 10 and collision_q == 0:
			_wait_frames += 1
			# Extra frames for physics to fully register the shapes
			if _wait_frames >= 3:
				_terrain_ready = true
				player.set_physics_process(true)
				player.set_process(true)
				print("Terrain ready — enabling player (active: %d, collision_q: %d)" % [active, collision_q])

	if stats_label and terrain:
		var chunks: int = terrain.get_chunk_count()
		var active: int = terrain.get_active_chunk_count()
		var pending: int = terrain.get_pending_count()
		var edits: int = terrain.get_edit_count()
		var fps: int = Engine.get_frames_per_second()
		var pos: Vector3 = player.global_position if player else Vector3.ZERO
		var cam: Camera3D = get_viewport().get_camera_3d()
		var cam_info: String = "cam: %s" % (str(cam.global_position) if cam else "NONE")
		var pause_str: String = " [PAUSED]" if _paused else ""
		var wire_str: String = " [WIRE]" if _wireframe else ""
		var lod_str: String = " [LOD]" if terrain.debug_lod_colors else ""
		stats_label.text = "FPS: %d%s%s%s\nChunks: %d (active: %d, pending: %d)\nEdits: %d\nPos: (%.0f, %.0f, %.0f)\n%s\n[P] pause  [F12] screenshot  [R] rebuild  [L] LOD  [F1] wire  [F2] fandisk" % [
			fps, pause_str, wire_str, lod_str, chunks, active, pending, edits, pos.x, pos.y, pos.z, cam_info]


func _unhandled_input(event: InputEvent) -> void:
	# Escape to release/capture mouse
	if event.is_action_pressed("ui_cancel"):
		if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
			Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
		else:
			Input.mouse_mode = Input.MOUSE_MODE_CAPTURED

	# P to pause/unpause
	if event is InputEventKey and event.pressed and event.keycode == KEY_P:
		_paused = !_paused
		get_tree().paused = _paused
		print("Paused: %s" % _paused)

	# F12 to screenshot with timestamp
	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/terrain_%s.png" % ts
		img.save_png(path)
		print("Screenshot saved: %s" % path)

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

	# C to clear edits
	if event is InputEventKey and event.pressed and event.keycode == KEY_C:
		terrain.clear_edits()
		print("Terrain edits cleared")

	# L to toggle LOD debug colors
	if event is InputEventKey and event.pressed and event.keycode == KEY_L:
		terrain.debug_lod_colors = !terrain.debug_lod_colors
		print("LOD colors: %s" % terrain.debug_lod_colors)

	# E to toggle edit debug wireframes
	if event is InputEventKey and event.pressed and event.keycode == KEY_E:
		terrain.debug_edits = !terrain.debug_edits
		print("Edit debug: %s" % terrain.debug_edits)

	# V to toggle verbose logging
	if event is InputEventKey and event.pressed and event.keycode == KEY_V:
		terrain.debug_logging = !terrain.debug_logging
		print("Verbose logging: %s" % terrain.debug_logging)

	# F2 to stamp fandisk at crosshair (loads GLB via Godot, extracts mesh arrays)
	if event is InputEventKey and event.pressed and event.keycode == KEY_F2:
		var camera: Camera3D = get_viewport().get_camera_3d()
		if camera:
			var viewport_center: Vector2 = get_viewport().get_visible_rect().size * 0.5
			var from: Vector3 = camera.project_ray_origin(viewport_center)
			var dir: Vector3 = camera.project_ray_normal(viewport_center)
			var space_state: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state
			var query: PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create(from, from + dir * 100.0)
			if player:
				query.exclude = [player.get_rid()]
			var result: Dictionary = space_state.intersect_ray(query)
			if result.size() > 0:
				var hit_pos: Vector3 = result["position"]
				# Load mesh via Godot (avoids OBJ loader crash)
				var scene: PackedScene = load("res://fandisk.glb")
				if not scene:
					print("Failed to load fandisk.glb")
					return
				var instance: Node3D = scene.instantiate()
				var mesh_inst: MeshInstance3D = null
				for child in instance.get_children():
					if child is MeshInstance3D:
						mesh_inst = child
						break
				if not mesh_inst or not mesh_inst.mesh:
					print("No MeshInstance3D found in fandisk.glb")
					instance.queue_free()
					return
				var mesh: Mesh = mesh_inst.mesh
				var arrays: Array = mesh.surface_get_arrays(0)
				var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
				var idxs: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
				# Lift the stamp so most of the mesh is above ground
				hit_pos.y += _edit_radius * 0.8
				print("Fandisk: %d verts, %d indices at %s scale=%.1f" % [verts.size(), idxs.size(), hit_pos, _edit_radius])
				terrain.stamp_mesh_arrays(verts, idxs, hit_pos, _edit_radius, 3.0)
				instance.queue_free()

	# F3 to debug SDF at crosshair
	if event is InputEventKey and event.pressed and event.keycode == KEY_F3:
		var camera: Camera3D = get_viewport().get_camera_3d()
		if camera:
			var viewport_center: Vector2 = get_viewport().get_visible_rect().size * 0.5
			var from: Vector3 = camera.project_ray_origin(viewport_center)
			var dir: Vector3 = camera.project_ray_normal(viewport_center)
			var space_state: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state
			var query: PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create(from, from + dir * 100.0)
			if player:
				query.exclude = [player.get_rid()]
			var result: Dictionary = space_state.intersect_ray(query)
			if result.size() > 0:
				terrain.debug_sdf_at(result["position"])

	# F1 to toggle wireframe
	if event is InputEventKey and event.pressed and event.keycode == KEY_F1:
		_wireframe = !_wireframe
		if _wireframe:
			get_viewport().debug_draw = Viewport.DEBUG_DRAW_WIREFRAME
		else:
			get_viewport().debug_draw = Viewport.DEBUG_DRAW_DISABLED
		print("Wireframe: %s" % _wireframe)

	# Right click = dig box, Shift+Right = add box
	# Scroll = adjust edit size
	if event is InputEventMouseButton and event.pressed and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
		# Shift+Scroll = adjust edit size (bare scroll = camera zoom in player script)
		if event.shift_pressed and event.button_index == MOUSE_BUTTON_WHEEL_UP:
			_edit_radius = minf(_edit_radius + 0.5, 20.0)
			print("Edit size: %.1f" % _edit_radius)
			get_viewport().set_input_as_handled()
		elif event.shift_pressed and event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
			_edit_radius = maxf(_edit_radius - 0.5, 1.0)
			print("Edit size: %.1f" % _edit_radius)
			get_viewport().set_input_as_handled()
		elif event.button_index == MOUSE_BUTTON_RIGHT:
			var camera: Camera3D = get_viewport().get_camera_3d()
			if not camera:
				return
			# Ray from view center (crosshair), not mouse position
			var viewport_center: Vector2 = get_viewport().get_visible_rect().size * 0.5
			var from: Vector3 = camera.project_ray_origin(viewport_center)
			var dir: Vector3 = camera.project_ray_normal(viewport_center)
			var space_state: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state
			var query: PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create(from, from + dir * 100.0)
			if player:
				query.exclude = [player.get_rid()]
			var result: Dictionary = space_state.intersect_ray(query)
			if result.size() > 0:
				var hit_pos: Vector3 = result["position"]
				var half_ext: Vector3 = Vector3(_edit_radius, _edit_radius, _edit_radius)
				var adding: bool = event.shift_pressed
				# Place box centered at hit point — CSG handles the intersection
				var strength: float = 3.0 if adding else -3.0
				terrain.edit_terrain_box(hit_pos, half_ext, strength)
				_save_edits()
				print("Box edit at %s (size=%.1f, strength=%.1f)" % [hit_pos, _edit_radius, strength])
				# Bump player up if adding material near them
				if adding and player:
					var dist: float = player.global_position.distance_to(hit_pos)
					if dist < _edit_radius * 1.5:
						player.global_position.y += _edit_radius + 1.0


func _save_edits() -> void:
	if not terrain.has_method("get_edits_as_array"):
		return
	var edits: Array = terrain.get_edits_as_array()
	var json_str: String = JSON.stringify(edits)
	var f := FileAccess.open(EDITS_SAVE_PATH, FileAccess.WRITE)
	if f:
		f.store_string(json_str)
		f.close()


func _load_edits() -> void:
	if not terrain.has_method("load_edits_from_array"):
		return
	if not FileAccess.file_exists(EDITS_SAVE_PATH):
		return
	var f := FileAccess.open(EDITS_SAVE_PATH, FileAccess.READ)
	if not f:
		return
	var json_str: String = f.get_as_text()
	f.close()
	var parsed: Variant = JSON.parse_string(json_str)
	if parsed is Array:
		terrain.load_edits_from_array(parsed)
		print("[TerrainStreaming] Loaded %d edits from %s" % [parsed.size(), EDITS_SAVE_PATH])