689 lines
26 KiB
GDScript
689 lines
26 KiB
GDScript
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.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. (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/<id>.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", "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 _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/<id>.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 = []
|
|
_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))
|
|
"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
|
|
|
|
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)
|
|
"beach":
|
|
land = _blk_beach(x, z, land)
|
|
"lake":
|
|
land = _blk_lake(x, z, land, b._c, b._r)
|
|
"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 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, {})
|
|
|
|
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)
|
|
|
|
## 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)
|