extends Node3D ## Terrain generator. ## ## One heightmap chunk built from deterministic noise: a walkable valley of ## rolling hills with a lake, enclosed by a ring of steep mountains that opens on ## one side to a sheer sea cliff. The whole chunk is an island: its edge sinks ## into ocean so the end of the mesh is never visible. ## ## The rim is the world boundary: mountain slopes are too steep to walk up ## (> floor_max_angle), and an invisible wall stands partway up them, or just ## inside the cliff edge on the sea side, so nobody can climb out or fall off. Chunk ## streaming and biomes come in later milestones (docs/ARCHITECTURE.md ยง4). ## ## Heights are computed once into a grid that feeds the render mesh, the ## collision shape and `sample_height()`, so props and the player sit exactly on ## the visible surface. ## ## Collision is a HeightMapShape3D. Do NOT swap it for a concave trimesh: those ## are one-sided and the player fell straight through them from above. signal ready_built ## emitted once the mesh + collision exist @export var size: int = 320 ## chunk is size x size metres @export var resolution: int = 256 ## grid cells per side @export var height_scale: float = 9.0 ## rolling-hill amplitude in the valley @export var noise_seed: int = 1337 @export_group("Boundary") @export var play_radius: float = 100.0 ## where the mountain rim starts rising @export var rim_width: float = 30.0 ## horizontal run of the rim's steep face @export var mountain_height: float = 58.0 @export var rim_warp: float = 16.0 ## how irregular the rim's outline is @export_range(0.2, 0.9) var wall_frac: float = 0.45 ## invisible wall, as a fraction up the rim @export_group("Sea cliff") @export var sea_angle_deg: float = -75.0 ## direction of the coast (atan2(z, x); -90 = straight -Z) @export var sea_half_width_deg: float = 38.0 ## full-sea arc either side of sea_angle @export var sea_fade_deg: float = 22.0 ## headland transition back to mountains @export var sea_level: float = -24.0 @export var cliff_top: float = 0.5 ## height the coast rises/falls to at the edge @export var cliff_offset: float = 4.0 ## cliff edge, in rim distance past play_radius @export var cliff_drop_width: float = 6.0 ## horizontal run of the drop (smaller = sheerer) @export_group("Water & snow") @export var water_level: float = -2.6 @export var lake_center: Vector2 = Vector2(14.0, -46.0) @export var lake_radius: float = 24.0 @export var snow_height: float = 40.0 @export_group("Colours") @export var grass_lush: Color = Color(0.25, 0.40, 0.15) @export var grass_dry: Color = Color(0.42, 0.49, 0.21) var built: bool = false var min_height: float = 0.0 var max_height: float = 0.0 var step: float = 1.0 var half: float = 0.0 var material: ShaderMaterial var noise_tex: NoiseTexture2D ## shared by every world shader (see common.gdshaderinc) var normal_tex: NoiseTexture2D ## detail normal map for ground and water var _noise: FastNoiseLite var _detail: FastNoiseLite var _ridge: FastNoiseLite var _warp: FastNoiseLite var _wall_ring: Array[Vector3] = [] var _heights := PackedFloat32Array() var _normals := PackedVector3Array() func _ready() -> void: # On a server, its seed; in a saved world, that world's seed; otherwise # keep the exported default. var net := get_node_or_null("/root/Net") var saves := get_node_or_null("/root/Saves") if net != null and net.state == "connected" and net.world_info.has("seed"): noise_seed = int(net.world_info.seed) worldlet_id = String(net.world_info.get("worldlet", worldlet_id)) elif saves != null and saves.has_world(): noise_seed = saves.world_seed() # (worlds from before worldlets have none: they're the Valley) worldlet_id = String(saves.read_meta(saves.current_id).get("worldlet", "valley")) var why := use_worldlet(worldlet_id) if not why.is_empty(): push_error("[terrain] %s: using the Valley" % why) use_worldlet("valley") _build() # --- height function -------------------------------------------------------- func _ensure_noise() -> void: if _noise != null: return _noise = FastNoiseLite.new() _noise.seed = noise_seed _noise.noise_type = FastNoiseLite.TYPE_PERLIN _noise.frequency = 0.012 _noise.fractal_octaves = 4 _detail = FastNoiseLite.new() _detail.seed = noise_seed + 1 _detail.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH _detail.frequency = 0.06 _detail.fractal_octaves = 2 _ridge = FastNoiseLite.new() _ridge.seed = noise_seed + 2 _ridge.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH _ridge.frequency = 0.022 _ridge.fractal_octaves = 4 _warp = FastNoiseLite.new() _warp.seed = noise_seed + 3 _warp.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH _warp.frequency = 0.03 ## Radial offset that makes the rim wobble instead of being a perfect circle. ## Sampled around a circle, so it depends only on the angle from the centre. func _rim_offset(x: float, z: float) -> float: var ang := atan2(z, x) return _warp.get_noise_2d(cos(ang) * 50.0, sin(ang) * 50.0) * rim_warp ## "Rim distance": distance from the centre plus the wobble. The rim starts at ## play_radius and the invisible wall stands at _wall_d() in this same measure. func _rim_d(x: float, z: float) -> float: return sqrt(x * x + z * z) + _rim_offset(x, z) func _wall_d() -> float: return play_radius + rim_width * wall_frac func _cliff_d() -> float: return play_radius + cliff_offset ## 1 on the open-sea side of the rim, 0 where the mountains are, smooth between ## (the headlands). Depends only on the angle from the centre. func _sea_w(x: float, z: float) -> float: var diff := absf(wrapf(atan2(z, x) - deg_to_rad(sea_angle_deg), -PI, PI)) return 1.0 - smoothstep(deg_to_rad(sea_half_width_deg), deg_to_rad(sea_half_width_deg + sea_fade_deg), diff) ## Boundary threshold in rim distance: partway up the mountains, or just ## inside the cliff edge on the sea side. func _wall_threshold(x: float, z: float) -> float: return lerpf(_wall_d(), _cliff_d() - 1.0, _sea_w(x, z)) # --- worldlets (docs/WORLDLETS_DESIGN.md, W0) --------------------------------------- # A worldlet's terrain is a list of building blocks applied in order # (data/worldlets/.json "terrain"). Each block takes the ground so far and # returns it changed; "hills" starts it. A worldlet can only combine these. const BLOCKS := ["hills", "rim", "sea_cliff", "lake", "ocean_edge"] var worldlet_id := "valley" var worldlet: Dictionary = {} ## the definition, as loaded var _blocks: Array = [] ## its terrain blocks, in order var _valley := 0.0 ## the hills before anything else (the sea cliff eases from it) var _detail_k := 1.4 ## The definition of worldlet `id` (data/worldlets/.json), or {} if none. static func load_worldlet(id: String) -> Dictionary: var p := "res://data/worldlets/%s.json" % id if not FileAccess.file_exists(p): return {} var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(p)) return d if d is Dictionary else {} ## Every worldlet there is: [{id, name, description}]. static func worldlets() -> Array: var out: Array = [] for f in DirAccess.get_files_at("res://data/worldlets"): if f.ends_with(".json"): var d := load_worldlet(f.get_basename()) if not d.is_empty(): out.append({"id": String(d.id), "name": String(d.get("name", d.id)), "description": String(d.get("description", ""))}) return out ## Takes on worldlet `id`: its size and every block's numbers. Returns "" ## on success, else what's wrong (an unknown worldlet or block keeps the old ## settings, so the game still has an island). func use_worldlet(id: String) -> String: var d := load_worldlet(id) if d.is_empty(): return "no worldlet '%s'" % id for b in d.get("terrain", []): if not (b is Dictionary) or not String(b.get("type", "")) in BLOCKS: return "worldlet '%s': unknown terrain block %s" % [id, str(b)] worldlet_id = id worldlet = d size = int(d.get("size", size)) resolution = int(d.get("resolution", resolution)) _blocks = d.get("terrain", []) for b in _blocks: match String(b.type): "hills": height_scale = float(b.get("height_scale", height_scale)) _detail_k = float(b.get("detail", _detail_k)) "rim": play_radius = float(b.get("play_radius", play_radius)) rim_width = float(b.get("rim_width", rim_width)) mountain_height = float(b.get("mountain_height", mountain_height)) rim_warp = float(b.get("rim_warp", rim_warp)) wall_frac = float(b.get("wall_frac", wall_frac)) "sea_cliff": sea_angle_deg = float(b.get("angle_deg", sea_angle_deg)) sea_half_width_deg = float(b.get("half_width_deg", sea_half_width_deg)) sea_fade_deg = float(b.get("fade_deg", sea_fade_deg)) sea_level = float(b.get("sea_level", sea_level)) cliff_top = float(b.get("cliff_top", cliff_top)) cliff_offset = float(b.get("cliff_offset", cliff_offset)) cliff_drop_width = float(b.get("drop_width", cliff_drop_width)) "lake": var c: Array = b.get("center", [lake_center.x, lake_center.y]) lake_center = Vector2(float(c[0]), float(c[1])) lake_radius = float(b.get("radius", lake_radius)) water_level = float(b.get("water_level", water_level)) snow_height = float(d.get("climate", {}).get("snowline", snow_height)) return "" func _has_block(t: String) -> bool: for b in _blocks: if String(b.type) == t: return true return false func _height_fn(x: float, z: float) -> float: _ensure_noise() if _blocks.is_empty(): use_worldlet(worldlet_id) var land := 0.0 for b in _blocks: match String(b.type): "hills": land = _blk_hills(x, z) "rim": land = _blk_rim(x, z, land) "sea_cliff": land = _blk_sea_cliff(x, z, land) "lake": land = _blk_lake(x, z, land) "ocean_edge": land = _blk_ocean_edge(x, z, land) return land ## Rolling hills: the ground everything else is built on. func _blk_hills(x: float, z: float) -> float: var valley := _noise.get_noise_2d(x, z) * height_scale valley += _detail.get_noise_2d(x, z) * _detail_k _valley = valley return valley ## The mountain rim: rises from play_radius, stepped into ledges. func _blk_rim(x: float, z: float, land: float) -> float: var valley := _valley var d := _rim_d(x, z) var t := smoothstep(play_radius, play_radius + rim_width, d) if t > 0.0: var ridge := 1.0 - absf(_ridge.get_noise_2d(x, z)) var m := pow(t, 1.4) * mountain_height * (0.65 + 0.55 * ridge) m += maxf(d - (play_radius + rim_width), 0.0) * 0.35 * (0.5 + ridge) # Stepped ledges so the faces read as layered cliffs, not smooth ramps. var band := 6.0 var stepped := floorf(m / band) * band + smoothstep(0.35, 0.65, fposmod(m, band) / band) * band m = lerpf(m, stepped, 0.45 * t) land = valley * (1.0 - t * 0.6) + m return land ## The sea side of the rim: the valley eases to a cliff top, then drops sheer. func _blk_sea_cliff(x: float, z: float, land: float) -> float: var valley := _valley var d := _rim_d(x, z) # Sea side: the valley eases to a cliff top, then drops sheer to the seabed. # The edge sits 1 m past the invisible wall at every angle, including the # headlands where the wall moves outward. (With the edge fixed at _cliff_d(), # the headlands dropped away INSIDE the wall: pits you could fall into, with # lake water hanging over them. tests/test_terrain_edges.gd guards this.) var sw := _sea_w(x, z) if sw > 0.0: var edge := _wall_threshold(x, z) + 1.0 var top := cliff_top + _detail.get_noise_2d(x * 0.5, z * 0.5) * 1.5 var coast := lerpf(valley, top, smoothstep(play_radius - 45.0, edge, d)) var seabed := sea_level - 7.0 - minf(maxf(d - edge, 0.0), 60.0) * 0.08 + _detail.get_noise_2d(x, z) * 2.0 coast = lerpf(coast, seabed, smoothstep(edge, edge + cliff_drop_width, d)) land = lerpf(land, coast, sw) return land ## A lake basin: the ground blends down to a bed below the waterline. func _blk_lake(x: float, z: float, land: float) -> float: var ld := Vector2(x - lake_center.x, z - lake_center.y).length() ld += _detail.get_noise_2d(x * 0.5, z * 0.5) * 5.0 return lerpf(land, water_level - 3.2, smoothstep(lake_radius, lake_radius * 0.35, ld)) ## Ocean all round the chunk edge, so the world is an island and the edge of ## the mesh is always hidden under deep water. func _blk_ocean_edge(x: float, z: float, land: float) -> float: var e := smoothstep(size * 0.5 - 28.0, size * 0.5 - 4.0, sqrt(x * x + z * z)) return lerpf(land, sea_level - 10.0 + _detail.get_noise_2d(x, z) * 2.0, e) # --- build ------------------------------------------------------------------ func _build() -> void: _ensure_noise() step = float(size) / float(resolution) half = size * 0.5 var n := resolution + 1 _heights.resize(n * n) min_height = INF max_height = -INF for iz in range(n): for ix in range(n): var hv := _height_fn(ix * step - half, iz * step - half) _heights[iz * n + ix] = hv min_height = minf(min_height, hv) max_height = maxf(max_height, hv) _normals.resize(n * n) for iz in range(n): for ix in range(n): var hl := _heights[iz * n + maxi(ix - 1, 0)] var hr := _heights[iz * n + mini(ix + 1, resolution)] var hd := _heights[maxi(iz - 1, 0) * n + ix] var hu := _heights[mini(iz + 1, resolution) * n + ix] _normals[iz * n + ix] = Vector3(hl - hr, 2.0 * step, hd - hu).normalized() _make_textures() _build_mesh(n) _build_collision(n) _build_boundary() _build_water() built = true ready_built.emit() func _make_textures() -> void: var fn := FastNoiseLite.new() fn.seed = noise_seed + 10 fn.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH fn.frequency = 0.008 fn.fractal_octaves = 5 noise_tex = NoiseTexture2D.new() noise_tex.width = 512 noise_tex.height = 512 noise_tex.seamless = true noise_tex.noise = fn var fn2 := FastNoiseLite.new() fn2.seed = noise_seed + 11 fn2.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH fn2.frequency = 0.02 fn2.fractal_octaves = 4 normal_tex = NoiseTexture2D.new() normal_tex.width = 512 normal_tex.height = 512 normal_tex.seamless = true normal_tex.as_normal_map = true normal_tex.bump_strength = 6.0 normal_tex.noise = fn2 func _build_mesh(n: int) -> void: var verts := PackedVector3Array() var uvs := PackedVector2Array() verts.resize(n * n) uvs.resize(n * n) for iz in range(n): for ix in range(n): var i := iz * n + ix verts[i] = Vector3(ix * step - half, _heights[i], iz * step - half) uvs[i] = Vector2(float(ix) / resolution, float(iz) / resolution) var idx := PackedInt32Array() idx.resize(resolution * resolution * 6) var k := 0 for iz in range(resolution): for ix in range(resolution): var i00 := iz * n + ix var i10 := i00 + 1 var i01 := i00 + n var i11 := i01 + 1 # Winding chosen so the top face is the front face in Godot. idx[k] = i00; idx[k + 1] = i11; idx[k + 2] = i01 idx[k + 3] = i00; idx[k + 4] = i10; idx[k + 5] = i11 k += 6 var arrays := [] arrays.resize(Mesh.ARRAY_MAX) arrays[Mesh.ARRAY_VERTEX] = verts arrays[Mesh.ARRAY_NORMAL] = _normals arrays[Mesh.ARRAY_TEX_UV] = uvs arrays[Mesh.ARRAY_INDEX] = idx var mesh := ArrayMesh.new() mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) material = ShaderMaterial.new() material.shader = preload("res://shaders/terrain.gdshader") material.set_shader_parameter("noise_tex", noise_tex) material.set_shader_parameter("normal_tex", normal_tex) material.set_shader_parameter("water_level", water_level) material.set_shader_parameter("snow_height", snow_height) material.set_shader_parameter("grass_lush", grass_lush) material.set_shader_parameter("grass_dry", grass_dry) var mi := MeshInstance3D.new() mi.name = "TerrainMesh" mi.mesh = mesh mi.material_override = material add_child(mi) func _build_collision(n: int) -> void: var hm := HeightMapShape3D.new() hm.map_width = n hm.map_depth = n hm.map_data = _heights var body := StaticBody3D.new() body.name = "TerrainBody" add_child(body) var col := CollisionShape3D.new() col.shape = hm body.add_child(col) # HeightMapShape3D samples are 1 unit apart; scale to match the mesh. body.scale = Vector3(step, 1.0, step) ## Invisible wall ringing the valley partway up the mountain slopes, plus four ## world-boundary planes at the chunk edges as a last line of defence. func _build_boundary() -> void: var body := StaticBody3D.new() body.name = "Boundary" add_child(body) var segs := 128 var pts: Array[Vector3] = [] for i in range(segs): var ang := TAU * i / segs var dir := Vector3(cos(ang), 0.0, sin(ang)) # Solve for the radius whose rim distance equals the wall threshold. var r := _wall_threshold(dir.x, dir.z) - _rim_offset(dir.x, dir.z) pts.append(dir * r) _wall_ring = pts for i in range(segs): var a := pts[i] var b := pts[(i + 1) % segs] var mid := (a + b) * 0.5 var length := a.distance_to(b) + 0.6 var box := BoxShape3D.new() box.size = Vector3(length, 400.0, 1.5) var cs := CollisionShape3D.new() cs.shape = box var along := (b - a).normalized() var seg_basis := Basis(along, Vector3.UP, along.cross(Vector3.UP)) cs.transform = Transform3D(seg_basis, Vector3(mid.x, 100.0, mid.z)) body.add_child(cs) var planes := [ Plane(Vector3.RIGHT, -half), Plane(Vector3.LEFT, -half), Plane(Vector3.BACK, -half), Plane(Vector3.FORWARD, -half), ] for p: Plane in planes: var wb := WorldBoundaryShape3D.new() wb.plane = p var cs := CollisionShape3D.new() cs.shape = wb body.add_child(cs) ## Two water bodies: inland water (lake and ponds) as a disc clipped to the ## boundary ring, so it can't hang in the air past the sea cliff; and an ocean ## plane out to the horizon at sea level. func _build_water() -> void: var verts := PackedVector3Array([Vector3.ZERO]) for p in _wall_ring: verts.append(p) var count := verts.size() var normals := PackedVector3Array() var tangents := PackedFloat32Array() var uvs := PackedVector2Array() for v in verts: normals.append(Vector3.UP) tangents.append_array([1.0, 0.0, 0.0, 1.0]) uvs.append(Vector2(v.x, v.z) / size + Vector2(0.5, 0.5)) var idx := PackedInt32Array() for i in range(1, count): idx.append_array([0, i, i % (count - 1) + 1]) var arrays := [] arrays.resize(Mesh.ARRAY_MAX) arrays[Mesh.ARRAY_VERTEX] = verts arrays[Mesh.ARRAY_NORMAL] = normals arrays[Mesh.ARRAY_TANGENT] = tangents arrays[Mesh.ARRAY_TEX_UV] = uvs arrays[Mesh.ARRAY_INDEX] = idx var disc := ArrayMesh.new() disc.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) _add_water("Water", disc, water_level, {}) var ocean := PlaneMesh.new() ocean.size = Vector2(8000.0, 8000.0) _add_water("Ocean", ocean, sea_level, { "shallow_col": Color(0.16, 0.44, 0.50), "deep_col": Color(0.02, 0.10, 0.20), "clarity": 6.0, }) func _add_water(label: String, mesh: Mesh, y: float, params: Dictionary) -> void: var mat := ShaderMaterial.new() mat.shader = preload("res://shaders/water.gdshader") mat.set_shader_parameter("noise_tex", noise_tex) mat.set_shader_parameter("normal_tex", normal_tex) for k in params: mat.set_shader_parameter(k, params[k]) var mi := MeshInstance3D.new() mi.name = label mi.mesh = mesh mi.material_override = mat mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF mi.position = Vector3(0.0, y, 0.0) add_child(mi) # --- queries ---------------------------------------------------------------- func in_bounds(x: float, z: float) -> bool: return absf(x) < half - step and absf(z) < half - step ## True inside the walkable valley. A positive margin shrinks the area, a ## negative one extends it past the invisible wall. func inside_boundary(x: float, z: float, margin: float = 0.0) -> bool: _ensure_noise() return _rim_d(x, z) < _wall_threshold(x, z) - margin ## How strongly (x, z) lies on the sea side of the rim (0..1). func sea_weight(x: float, z: float) -> float: _ensure_noise() return _sea_w(x, z) ## Point on the coast along `angle_deg` (atan2(z, x) convention), at rim ## distance `past_edge` beyond the cliff edge (negative = inland of it). func coast_point(angle_deg: float, past_edge: float) -> Vector2: _ensure_noise() var dir := Vector2(cos(deg_to_rad(angle_deg)), sin(deg_to_rad(angle_deg))) var r := _cliff_d() + past_edge - _rim_offset(dir.x, dir.y) return dir * r ## Surface height at (x, z), interpolated on the same triangles as the mesh. func sample_height(x: float, z: float) -> float: if _heights.is_empty(): return _height_fn(x, z) var n := resolution + 1 var gx := clampf((x + half) / step, 0.0, resolution - 0.0001) var gz := clampf((z + half) / step, 0.0, resolution - 0.0001) var ix := int(gx) var iz := int(gz) var fx := gx - ix var fz := gz - iz var h00 := _heights[iz * n + ix] var h10 := _heights[iz * n + ix + 1] var h01 := _heights[(iz + 1) * n + ix] var h11 := _heights[(iz + 1) * n + ix + 1] if fx > fz: return h00 + (h10 - h00) * fx + (h11 - h10) * fz return h00 + (h11 - h01) * fx + (h01 - h00) * fz ## Smoothed surface normal at (x, z). func sample_normal(x: float, z: float) -> Vector3: if _normals.is_empty(): return Vector3.UP var n := resolution + 1 var gx := clampf((x + half) / step, 0.0, resolution - 0.0001) var gz := clampf((z + half) / step, 0.0, resolution - 0.0001) var ix := int(gx) var iz := int(gz) var fx := gx - ix var fz := gz - iz var a := _normals[iz * n + ix].lerp(_normals[iz * n + ix + 1], fx) var b := _normals[(iz + 1) * n + ix].lerp(_normals[(iz + 1) * n + ix + 1], fx) return a.lerp(b, fz).normalized() ## Nearest dry, gentle spot to `near` (spiral search), for spawning. func find_spawn(near: Vector2) -> Vector3: for ring in range(0, 30): var r := ring * 2.0 var count := maxi(1, ring * 6) for i in range(count): var ang := TAU * i / count var x := near.x + cos(ang) * r var z := near.y + sin(ang) * r var h := sample_height(x, z) if h > water_level + 1.2 and sample_normal(x, z).y > 0.93 and inside_boundary(x, z, 15.0): return Vector3(x, h, z) return Vector3(near.x, sample_height(near.x, near.y), near.y)