OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / terrain_generator.gd

373 lines · gd ref:
@tool
class_name TerrainGenerator extends StaticBody3D
## Generates a subdivided plane with low-frequency noise displacement.
## Attach to a StaticBody3D — creates MeshInstance3D + CollisionShape3D children.

@export var terrain_size: float = 500.0:
	set(v):
		terrain_size = v
		if Engine.is_editor_hint() and is_inside_tree():
			_rebuild()

@export var subdivisions: int = 128:
	set(v):
		subdivisions = clampi(v, 2, 256)
		if Engine.is_editor_hint() and is_inside_tree():
			_rebuild()

@export_group("Macro Terrain (large features)")
@export var macro_height: float = 60.0:
	set(v):
		macro_height = v
		if Engine.is_editor_hint() and is_inside_tree():
			_rebuild()

@export var macro_frequency: float = 0.003:
	set(v):
		macro_frequency = v
		if Engine.is_editor_hint() and is_inside_tree():
			_rebuild()

@export var macro_octaves: int = 2:
	set(v):
		macro_octaves = clampi(v, 1, 4)
		if Engine.is_editor_hint() and is_inside_tree():
			_rebuild()

@export_group("Detail Noise (surface variation)")
@export var detail_height: float = 4.0:
	set(v):
		detail_height = v
		if Engine.is_editor_hint() and is_inside_tree():
			_rebuild()

@export var detail_frequency: float = 0.03:
	set(v):
		detail_frequency = v
		if Engine.is_editor_hint() and is_inside_tree():
			_rebuild()

@export var detail_octaves: int = 3:
	set(v):
		detail_octaves = clampi(v, 1, 6)
		if Engine.is_editor_hint() and is_inside_tree():
			_rebuild()

@export_group("General")
@export var noise_seed: int = 42:
	set(v):
		noise_seed = v
		if Engine.is_editor_hint() and is_inside_tree():
			_rebuild()

@export var flat_center_radius: float = 3.0:
	set(v):
		flat_center_radius = v
		if Engine.is_editor_hint() and is_inside_tree():
			_rebuild()

@export_group("Scattered Cubes")
@export var cube_count: int = 12:
	set(v):
		cube_count = clampi(v, 0, 50)
		if Engine.is_editor_hint() and is_inside_tree():
			_rebuild()

@export var cube_min_size: float = 9.0:
	set(v):
		cube_min_size = v
		if Engine.is_editor_hint() and is_inside_tree():
			_rebuild()

@export var cube_max_size: float = 18.0:
	set(v):
		cube_max_size = v
		if Engine.is_editor_hint() and is_inside_tree():
			_rebuild()

@export_subgroup("Small Scattered Cubes")
@export var small_cube_count: int = 40:
	set(v):
		small_cube_count = clampi(v, 0, 200)
		if Engine.is_editor_hint() and is_inside_tree():
			_rebuild()

@export var small_cube_min_size: float = 2.0:
	set(v):
		small_cube_min_size = v
		if Engine.is_editor_hint() and is_inside_tree():
			_rebuild()

@export var small_cube_max_size: float = 8.0:
	set(v):
		small_cube_max_size = v
		if Engine.is_editor_hint() and is_inside_tree():
			_rebuild()

@export var cube_seed: int = 7:
	set(v):
		cube_seed = v
		if Engine.is_editor_hint() and is_inside_tree():
			_rebuild()

@export_group("Material")
@export var terrain_color: Color = Color(0.3, 0.42, 0.25, 1.0):
	set(v):
		terrain_color = v
		if Engine.is_editor_hint() and is_inside_tree():
			_rebuild()

@export var rebuild: bool = false:
	set(v):
		if v:
			_rebuild()
		rebuild = false

var _mesh_instance: MeshInstance3D = null
var _collision: CollisionShape3D = null


func _ready() -> void:
	# Small delay to ensure physics world is ready
	call_deferred("_rebuild")


func _rebuild() -> void:
	# Macro noise — big hills, valleys, mountains
	var macro_noise := FastNoiseLite.new()
	macro_noise.seed = noise_seed
	macro_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
	macro_noise.frequency = macro_frequency
	macro_noise.fractal_octaves = macro_octaves
	macro_noise.fractal_lacunarity = 2.0
	macro_noise.fractal_gain = 0.5

	# Detail noise — surface bumps and texture
	var detail_noise := FastNoiseLite.new()
	detail_noise.seed = noise_seed + 1
	detail_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
	detail_noise.frequency = detail_frequency
	detail_noise.fractal_octaves = detail_octaves
	detail_noise.fractal_lacunarity = 2.0
	detail_noise.fractal_gain = 0.5

	var half: float = terrain_size / 2.0
	var step: float = terrain_size / float(subdivisions)
	var vert_count: int = (subdivisions + 1) * (subdivisions + 1)
	var tri_count: int = subdivisions * subdivisions * 2

	# Arrays for SurfaceTool
	var positions := PackedVector3Array()
	var normals := PackedVector3Array()
	var uvs := PackedFloat32Array()
	positions.resize(vert_count)
	normals.resize(vert_count)

	# Generate vertex positions with noise displacement
	for z_i in range(subdivisions + 1):
		for x_i in range(subdivisions + 1):
			var idx: int = z_i * (subdivisions + 1) + x_i
			var x: float = -half + x_i * step
			var z: float = -half + z_i * step

			# Two-layer noise: macro for landforms + detail for surface
			var y: float = macro_noise.get_noise_2d(x, z) * macro_height \
				+ detail_noise.get_noise_2d(x, z) * detail_height

			# Flatten center area so character spawns on level ground
			var dist_from_center: float = Vector2(x, z).length()
			if dist_from_center < flat_center_radius:
				y = 0.0
			elif dist_from_center < flat_center_radius + 2.0:
				var blend: float = (dist_from_center - flat_center_radius) / 2.0
				y *= smoothstep(0.0, 1.0, blend)

			positions[idx] = Vector3(x, y, z)

	# Compute normals from cross products of adjacent vertices
	for z_i in range(subdivisions + 1):
		for x_i in range(subdivisions + 1):
			var idx: int = z_i * (subdivisions + 1) + x_i
			var p: Vector3 = positions[idx]

			# Get neighbors (clamped to edges)
			var left: Vector3 = positions[z_i * (subdivisions + 1) + maxi(x_i - 1, 0)]
			var right: Vector3 = positions[z_i * (subdivisions + 1) + mini(x_i + 1, subdivisions)]
			var up: Vector3 = positions[maxi(z_i - 1, 0) * (subdivisions + 1) + x_i]
			var down: Vector3 = positions[mini(z_i + 1, subdivisions) * (subdivisions + 1) + x_i]

			var dx: Vector3 = right - left
			var dz: Vector3 = down - up
			normals[idx] = dz.cross(dx).normalized()

	# Build indices
	var indices := PackedInt32Array()
	indices.resize(tri_count * 3)
	var ii: int = 0
	for z_i in range(subdivisions):
		for x_i in range(subdivisions):
			var tl: int = z_i * (subdivisions + 1) + x_i
			var tr: int = tl + 1
			var bl: int = (z_i + 1) * (subdivisions + 1) + x_i
			var br: int = bl + 1
			# Triangle 1 (CCW winding for Godot)
			indices[ii] = tl; ii += 1
			indices[ii] = tr; ii += 1
			indices[ii] = bl; ii += 1
			# Triangle 2
			indices[ii] = tr; ii += 1
			indices[ii] = br; ii += 1
			indices[ii] = bl; ii += 1

	# Build ArrayMesh
	var arrays := []
	arrays.resize(Mesh.ARRAY_MAX)
	arrays[Mesh.ARRAY_VERTEX] = positions
	arrays[Mesh.ARRAY_NORMAL] = normals
	arrays[Mesh.ARRAY_INDEX] = indices

	# UVs
	var uv_array := PackedVector2Array()
	uv_array.resize(vert_count)
	for z_i in range(subdivisions + 1):
		for x_i in range(subdivisions + 1):
			var idx: int = z_i * (subdivisions + 1) + x_i
			uv_array[idx] = Vector2(float(x_i) / subdivisions, float(z_i) / subdivisions)
	arrays[Mesh.ARRAY_TEX_UV] = uv_array

	var mesh := ArrayMesh.new()
	mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)

	# Material
	var mat := StandardMaterial3D.new()
	mat.albedo_color = terrain_color
	mesh.surface_set_material(0, mat)

	# Apply to MeshInstance3D child
	if _mesh_instance == null:
		_mesh_instance = _find_or_create("TerrainMesh", "MeshInstance3D") as MeshInstance3D
	_mesh_instance.mesh = mesh

	# Collision from mesh
	if _collision == null:
		_collision = _find_or_create("TerrainCollision", "CollisionShape3D") as CollisionShape3D
	var shape := ConcavePolygonShape3D.new()
	shape.set_faces(mesh.get_faces())
	_collision.shape = shape

	# Scatter large cubes at macro peaks
	_spawn_cubes(macro_noise, detail_noise)


func _spawn_cubes(macro_n: FastNoiseLite, detail_n: FastNoiseLite) -> void:
	# Remove old cubes
	for child in get_children():
		if child.name.begins_with("Cube_") or child.name.begins_with("SmCube_"):
			child.queue_free()

	var rng := RandomNumberGenerator.new()
	rng.seed = cube_seed
	var half: float = terrain_size / 2.0
	var sample_step: float = 8.0

	# --- Large cubes at peaks, embedded 5-20m into terrain ---
	var peaks: Array[Vector3] = []
	var x: float = -half + 10.0
	while x < half - 10.0:
		var z: float = -half + 10.0
		while z < half - 10.0:
			if Vector2(x, z).length() < flat_center_radius + 10.0:
				z += sample_step
				continue
			var h: float = macro_n.get_noise_2d(x, z) * macro_height
			var h_l: float = macro_n.get_noise_2d(x - sample_step, z) * macro_height
			var h_r: float = macro_n.get_noise_2d(x + sample_step, z) * macro_height
			var h_u: float = macro_n.get_noise_2d(x, z - sample_step) * macro_height
			var h_d: float = macro_n.get_noise_2d(x, z + sample_step) * macro_height
			if h > h_l and h > h_r and h > h_u and h > h_d and h > macro_height * 0.2:
				var full_h: float = h + detail_n.get_noise_2d(x, z) * detail_height
				peaks.append(Vector3(x, full_h, z))
			z += sample_step
		x += sample_step

	_shuffle(peaks, rng)
	var count: int = mini(cube_count, peaks.size())
	for i in range(count):
		var peak: Vector3 = peaks[i]
		var cube_size: float = rng.randf_range(cube_min_size, cube_max_size)
		var embed: float = rng.randf_range(5.0, 20.0)
		_create_cube("Cube_%d" % i, cube_size,
			Vector3(peak.x, peak.y + cube_size * 0.5 - embed, peak.z),
			Vector3(rng.randf_range(0, TAU), rng.randf_range(0, TAU), rng.randf_range(0, TAU)),
			rng)

	# --- Small cubes scattered everywhere, embedded 5-25% of their size ---
	for i in range(small_cube_count):
		var sx: float = rng.randf_range(-half + 5.0, half - 5.0)
		var sz: float = rng.randf_range(-half + 5.0, half - 5.0)
		if Vector2(sx, sz).length() < flat_center_radius + 5.0:
			continue
		var terrain_y: float = macro_n.get_noise_2d(sx, sz) * macro_height \
			+ detail_n.get_noise_2d(sx, sz) * detail_height
		var cube_size: float = rng.randf_range(small_cube_min_size, small_cube_max_size)
		var embed_frac: float = rng.randf_range(0.05, 0.25)
		var embed_depth: float = cube_size * embed_frac
		_create_cube("SmCube_%d" % i, cube_size,
			Vector3(sx, terrain_y + cube_size * 0.5 - embed_depth, sz),
			Vector3(rng.randf_range(0, TAU), rng.randf_range(0, TAU), rng.randf_range(0, TAU)),
			rng)


func _create_cube(cube_name: String, cube_size: float, pos: Vector3, rot: Vector3, rng: RandomNumberGenerator) -> void:
	var body := StaticBody3D.new()
	body.name = cube_name

	var box_mesh := BoxMesh.new()
	box_mesh.size = Vector3(cube_size, cube_size, cube_size)
	var mesh_inst := MeshInstance3D.new()
	mesh_inst.mesh = box_mesh
	var cube_mat := StandardMaterial3D.new()
	cube_mat.albedo_color = Color(
		rng.randf_range(0.3, 0.6),
		rng.randf_range(0.3, 0.6),
		rng.randf_range(0.3, 0.6))
	box_mesh.material = cube_mat

	var col := CollisionShape3D.new()
	var box_shape := BoxShape3D.new()
	box_shape.size = Vector3(cube_size, cube_size, cube_size)
	col.shape = box_shape

	body.position = pos
	body.rotation = rot
	add_child(body)
	body.add_child(mesh_inst)
	body.add_child(col)


func _shuffle(arr: Array[Vector3], rng: RandomNumberGenerator) -> void:
	for i in range(arr.size() - 1, 0, -1):
		var j: int = rng.randi_range(0, i)
		var tmp: Vector3 = arr[i]
		arr[i] = arr[j]
		arr[j] = tmp


func _find_or_create(child_name: String, type_name: String) -> Node:
	var existing: Node = get_node_or_null(child_name)
	if existing:
		# Disown so Godot won't serialize the generated mesh/shape into .tscn
		existing.owner = null
		return existing
	var node: Node
	match type_name:
		"MeshInstance3D":
			node = MeshInstance3D.new()
		"CollisionShape3D":
			node = CollisionShape3D.new()
	node.name = child_name
	add_child(node)
	# Do NOT set owner — prevents Godot from serializing the generated
	# ArrayMesh into the .tscn (saves ~1 MiB). Mesh is regenerated in _ready().
	return node