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("Beach") # (W2: a worldlet's "beach" block; the Valley has none) @export var beach_angle_deg: float = 90.0 @export var beach_half_width_deg: float = 40.0 @export var beach_fade_deg: float = 20.0 @export var shore_offset: float = -10.0 ## waterline, in rim distance past play_radius @export var beach_slope: float = 0.08 ## metres deeper per metre out @export var current_out: float = 20.0 ## the current starts this far past the waterline @export var wall_out: float = 34.0 ## and the invisible wall stands here @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.worldlet_of(saves.current_id)) # (a land server's own land, or the world's) 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. (0 everywhere ## in a worldlet with no sea cliff.) func _sea_w(x: float, z: float) -> float: if not _has_sea: return 0.0 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) ## The same for a beach (W2): 1 where the land runs down into the sea. func _beach_w(x: float, z: float) -> float: if not _has_beach: return 0.0 var diff := absf(wrapf(atan2(z, x) - deg_to_rad(beach_angle_deg), -PI, PI)) return 1.0 - smoothstep(deg_to_rad(beach_half_width_deg), deg_to_rad(beach_half_width_deg + beach_fade_deg), diff) ## The waterline of the beach, in rim distance. func _shore_d() -> float: return play_radius + shore_offset ## Boundary threshold in rim distance: partway up the mountains, just ## inside the cliff edge on the sea side, or well out in the water off a ## beach (where the current turns you back before you reach it). func _wall_threshold(x: float, z: float) -> float: var t := lerpf(_wall_d(), _cliff_d() - 1.0, _sea_w(x, z)) if _has_beach: t = lerpf(t, _shore_d() + wall_out, _beach_w(x, z)) return t # --- 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", "beach", "lake", "stream", "ocean_edge"] var worldlet_id := "valley" var worldlet: Dictionary = {} ## the definition, as loaded var _blocks: Array = [] ## its terrain blocks, in order var lakes: Array = [] ## every "lake" block's {center: Vector2, radius} (W2: several) var stream_path := PackedVector2Array() ## a "stream" block's points, spring first (W3: the Cove's) var stream_width := 2.4 var stream_depth := 0.6 var _stream_profile := PackedFloat32Array() ## its water surface, every STREAM_STEP m along it var _stream_len := 0.0 ## its length var _stream_end := 0.0 ## where it meets the sea (its water ends there) const STREAM_STEP := 1.0 var _has_sea := true ## a sea cliff (the Valley's) var _has_beach := false ## a beach (W2) 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): p = "res://tests/worldlets/%s.json" % id # (test-only worldlets: never listed or offered) 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", "")), "start": bool(d.get("start", false))}) out.sort_custom(func(a, b): return a.id == "valley" or (b.id != "valley" and a.name < b.name)) return out ## The worldlets a new world can start in (the rest are reached on the journey). static func starting_worldlets() -> Array: return worldlets().filter(func(w): return w.start) ## A worldlet's display name ("Valley"), or its id if there's no such worldlet. static func worldlet_name(id: String) -> String: var d := load_worldlet(id) return String(d.get("name", id)) if not d.is_empty() else id ## 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", []) lakes = [] stream_path = PackedVector2Array() _stream_profile = PackedFloat32Array() _has_sea = _has_block("sea_cliff") _has_beach = _has_block("beach") 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)) "stream": stream_path = PackedVector2Array() for q in b.get("points", []): stream_path.append(Vector2(float(q[0]), float(q[1]))) stream_width = float(b.get("width", stream_width)) stream_depth = float(b.get("depth", stream_depth)) "beach": beach_angle_deg = float(b.get("angle_deg", beach_angle_deg)) beach_half_width_deg = float(b.get("half_width_deg", beach_half_width_deg)) beach_fade_deg = float(b.get("fade_deg", beach_fade_deg)) shore_offset = float(b.get("shore_offset", shore_offset)) beach_slope = float(b.get("slope", beach_slope)) current_out = float(b.get("current_out", current_out)) wall_out = float(b.get("wall_out", wall_out)) "lake": # Every lake and pond shares one surface (water_level): the # first lake sets it, and is "the lake" to the older code # (lake_center/lake_radius: ice, its fish, the tutorial). var c: Array = b.get("center", [lake_center.x, lake_center.y]) var at := Vector2(float(c[0]), float(c[1])) var r := float(b.get("radius", lake_radius)) b["_c"] = at b["_r"] = r if lakes.is_empty(): lake_center = at lake_radius = r water_level = float(b.get("water_level", water_level)) lakes.append({"center": at, "radius": r}) if _has_beach: sea_level = water_level # (a beach runs into the sea: one surface) 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 ## The ground at (x, z). `stop_at_stream`: as it is before the stream cuts ## its bed (what the stream's water level is worked out from). func _height_fn(x: float, z: float, stop_at_stream: bool = false) -> float: _ensure_noise() if _blocks.is_empty(): use_worldlet(worldlet_id) if not stop_at_stream and stream_path.size() >= 2 and _stream_profile.is_empty(): _make_stream_profile() 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) "beach": land = _blk_beach(x, z, land) "lake": land = _blk_lake(x, z, land, b._c, b._r) "stream": if stop_at_stream: return land land = _blk_stream(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, at: Vector2, r: float) -> float: var ld := Vector2(x - at.x, z - at.y).length() ld += _detail.get_noise_2d(x * 0.5, z * 0.5) * 5.0 return lerpf(land, water_level - 3.2, smoothstep(r, r * 0.35, ld)) ## A stream (W3): runs along its points from a spring to the sea. Its water ## surface comes down step by step from the ground at the spring, never ## rising (the running minimum of the land along it), so it always runs ## downhill. The bed is cut stream_depth below the surface, the banks rise a ## little above it (so the water never hangs over lower ground) and blend ## into the land, and where the land is high it cuts a little valley. func _make_stream_profile() -> void: _stream_profile = PackedFloat32Array() _stream_len = 0.0 for i in range(1, stream_path.size()): _stream_len += stream_path[i - 1].distance_to(stream_path[i]) _stream_end = _stream_len var surf := INF var n := int(_stream_len / STREAM_STEP) + 1 for i in range(n + 1): var p := _stream_point(i * STREAM_STEP) var g := _height_fn(p.x, p.y, true) surf = minf(surf - 0.01, g - 0.25) if surf < INF else g - 0.25 surf = maxf(surf, water_level) _stream_profile.append(surf) if g < water_level - 0.2 and _stream_end >= _stream_len: _stream_end = i * STREAM_STEP # (into the sea: the stream's done) ## The point `along` metres down the stream. func _stream_point(along: float) -> Vector2: var left := along for i in range(1, stream_path.size()): var seg := stream_path[i - 1].distance_to(stream_path[i]) if left <= seg or i == stream_path.size() - 1: return stream_path[i - 1].lerp(stream_path[i], clampf(left / maxf(seg, 0.001), 0.0, 1.0)) left -= seg return stream_path[stream_path.size() - 1] ## How far (x, z) is from the stream's line, and how far along it that is. func _stream_near(x: float, z: float) -> Vector2: var p := Vector2(x, z) var best := INF var best_along := 0.0 var run := 0.0 for i in range(1, stream_path.size()): var a := stream_path[i - 1] var b := stream_path[i] var ab := b - a var t := clampf((p - a).dot(ab) / maxf(ab.length_squared(), 0.001), 0.0, 1.0) var d := p.distance_to(a + ab * t) if d < best: best = d best_along = run + t * ab.length() run += ab.length() return Vector2(best, best_along) func _stream_surface_at(along: float) -> float: var f := clampf(along / STREAM_STEP, 0.0, _stream_profile.size() - 1.001) var i := int(f) return lerpf(_stream_profile[i], _stream_profile[i + 1], f - i) func _blk_stream(x: float, z: float, land: float) -> float: if _stream_profile.size() < 2: return land var near := _stream_near(x, z) var hw := stream_width * 0.5 var k := near.x / hw if k > 6.0: return land var along := near.y var surf := _stream_surface_at(along) var target: float if k < 1.0: target = surf - stream_depth * (1.0 - k * k) else: target = surf + 0.12 + (k - 1.0) * 0.3 # Down to the bed, up to the banks; where the land's high, a gentle # valley rather than a slot. var w := smoothstep(6.0, 1.6, k) if land > target else smoothstep(2.6, 1.6, k) w *= smoothstep(0.0, 5.0, along) # (the spring) w *= 1.0 - smoothstep(_stream_end - 8.0, _stream_end, along) # (into the sea) return lerpf(land, target, w) ## The stream's water surface at (x, z), or -INF where there's no stream. func stream_surface(x: float, z: float) -> float: if _stream_profile.size() < 2: return -INF var near := _stream_near(x, z) if near.x > stream_width * 0.5 or near.y > _stream_end - 1.0 or near.y < 0.5: return -INF return _stream_surface_at(near.y) ## Whether (x, z) is in or by the stream (within `margin` m of its banks): ## scenery and landmarks keep clear. func near_stream(x: float, z: float, margin: float = 1.0) -> bool: if _stream_profile.size() < 2: return false var near := _stream_near(x, z) return near.x < stream_width * 0.5 + margin and near.y < _stream_end ## A beach (W2): on its side of the rim the land eases down to a sandy ## waterline, then shelves gently into the sea (beach_slope deeper per ## metre), so you can wade, then swim, until the current turns you back. func _blk_beach(x: float, z: float, land: float) -> float: var bw := _beach_w(x, z) if bw <= 0.0: return land var d := _rim_d(x, z) var shore := _shore_d() var profile: float if d < shore: profile = water_level + (shore - d) * 0.12 + _detail.get_noise_2d(x * 0.5, z * 0.5) * 0.3 else: profile = maxf(water_level - (d - shore) * beach_slope, water_level - 14.0) + _detail.get_noise_2d(x, z) * 0.25 var coast := lerpf(_valley, profile, smoothstep(shore - 50.0, shore - 8.0, d)) return lerpf(land, coast, bw) ## 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) if absf(sea_level - water_level) > 0.05: # (by a beach the sea is the same surface: no second sheet) _add_water("Water", disc, water_level, {}) if _stream_profile.size() >= 2: # (Murkier than the lake, so half a metre of water reads as water, not a path.) _add_water("Stream", _stream_mesh(), 0.0, {"clarity": 0.45, "shallow_col": Color(0.20, 0.42, 0.46), "deep_col": Color(0.05, 0.16, 0.22)}) 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, }) ## The stream's water: a ribbon down its bed, just under the tops of the ## banks, each point at the surface there. It stops where it meets the sea ## (whose own water carries on from there). func _stream_mesh() -> ArrayMesh: var st := SurfaceTool.new() st.begin(Mesh.PRIMITIVE_TRIANGLES) var hw := stream_width * 0.5 * 1.05 var prev_l := Vector3.ZERO var prev_r := Vector3.ZERO var have := false var along := 0.6 var stop := _stream_end - 1.0 while along < stop: var p := _stream_point(along) var q := _stream_point(minf(along + 0.5, _stream_len)) var dir := (q - p).normalized() if q.distance_to(p) > 0.001 else Vector2(0, 1) var side := Vector2(-dir.y, dir.x) * hw var y := _stream_surface_at(along) + 0.02 if y <= water_level + 0.06: break # (level with the sea: its water takes over) var l := Vector3(p.x + side.x, y, p.y + side.y) var r := Vector3(p.x - side.x, y, p.y - side.y) if have: for v in [prev_l, l, prev_r, prev_r, l, r]: st.set_normal(Vector3.UP) st.set_tangent(Plane(1, 0, 0, 1)) st.set_uv(Vector2(v.x, v.z) / size + Vector2(0.5, 0.5)) st.add_vertex(v) prev_l = l prev_r = r have = true along += 0.75 return st.commit() 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) ## Whether (x, z) is on the sand (0.6.0): the beach side of the rim, low ## enough for the sandy shoreline (terrain.gdshader paints sand below ## water_level + SAND_TOP), or under the water off it. const SAND_TOP := 1.2 func on_sand(x: float, z: float) -> bool: _ensure_noise() return _beach_w(x, z) > 0.5 and sample_height(x, z) < water_level + SAND_TOP ## How strongly (x, z) lies on the beach side of the rim (0..1; W2). func beach_weight(x: float, z: float) -> float: _ensure_noise() return _beach_w(x, z) ## Off a beach, the current that turns swimmers back before the wall: 0 ## (none) to 1 (full strength, just inside the wall). Always 0 elsewhere. func current_at(x: float, z: float) -> float: _ensure_noise() if _beach_w(x, z) < 0.5: return 0.0 var from := _shore_d() + current_out return smoothstep(from, maxf(_wall_threshold(x, z) - 3.0, from + 1.0), _rim_d(x, z)) ## A point out in the bay off the beach, `past_shore` metres beyond the waterline. func shore_point(past_shore: float) -> Vector2: _ensure_noise() var dir := Vector2(cos(deg_to_rad(beach_angle_deg)), sin(deg_to_rad(beach_angle_deg))) return dir * (_shore_d() + past_shore - _rim_offset(dir.x, dir.y)) ## 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)