The Cove's stream: a stream block whose water only runs downhill (spring to the bay), its own water ribbon, trout, on the map; scenery and landmarks keep clear

This commit is contained in:
Paul Bailey
2026-11-06 21:35:04 +00:00
parent 0209c6149a
commit ad47cd53da
12 changed files with 227 additions and 14 deletions

View File

@@ -90,7 +90,7 @@ func _check_site(world: Node3D, p: Vector2) -> Vector3:
var hs: Array[float] = []
for d in [Vector2(-1, -1), Vector2(1, -1), Vector2(-1, 1), Vector2(1, 1), Vector2.ZERO]:
hs.append(terrain.sample_height(p.x + d.x * 4.0, p.y + d.y * 4.0))
if hs.max() - hs.min() > 2.2 or hs.min() < terrain.water_level + 1.0:
if hs.max() - hs.min() > 2.2 or hs.min() < terrain.water_level + 1.0 or terrain.near_stream(p.x, p.y, 7.0):
return Vector3.INF
# Trees and boulders in the way are cleared (_clear_area); just avoid
# the thickest woods. Counted by position, because their colliders

View File

@@ -163,6 +163,8 @@ static func render(w: Node3D) -> Image:
static func ground_colour(t: Node3D, x: float, z: float) -> Color:
var h: float = t.sample_height(x, z)
if t.near_stream(x, z, 0.3):
return Color(0.32, 0.56, 0.66) # (the stream, a little wider than it is so it shows)
var surface: float = t.water_level if t.inside_boundary(x, z, -2.0) else t.sea_level
if h < surface:
var deep := clampf((surface - h) / 6.0, 0.0, 1.0)

View File

@@ -102,7 +102,7 @@ func _search(max_spread: float, from_spawn: float, from_den: float) -> Vector3:
hs.append(terrain.sample_height(c.x + cos(b) * rad, c.y + sin(b) * rad))
var h0: float = terrain.sample_height(c.x, c.y)
hs.append(h0)
if hs.min() < terrain.water_level + 1.0:
if hs.min() < terrain.water_level + 1.0 or terrain.near_stream(c.x, c.y, MOUND_R + 4.0):
continue
var spread: float = hs.max() - hs.min()
if spread > max_spread:

View File

@@ -423,6 +423,8 @@ func _scatter_trees() -> void:
func _on_clifftop(x: float, z: float) -> bool:
if pier != null and pier.covers(x, z, 2.0):
return true
if terrain.near_stream(x, z, 1.2):
return true # (the stream's bed and banks)
for c in keep_clear:
if Vector2(x - c[0].x, z - c[0].z).length() < float(c[1]):
return true
@@ -615,7 +617,7 @@ func _place_logs() -> void:
continue
var h: float = terrain.sample_height(x, z)
var n: Vector3 = terrain.sample_normal(x, z)
if h < terrain.water_level + 0.6 or n.y < 0.97 or Vector2(x - _spawn.x, z - _spawn.z).length() < 8.0:
if h < terrain.water_level + 0.6 or n.y < 0.97 or Vector2(x - _spawn.x, z - _spawn.z).length() < 8.0 or terrain.near_stream(x, z, 1.5):
continue
var mi := MeshInstance3D.new()
var shape: Shape3D
@@ -680,7 +682,7 @@ func _scatter_pickups() -> void:
continue
var h: float = terrain.sample_height(x, z)
var n: Vector3 = terrain.sample_normal(x, z)
if h < terrain.water_level + 0.3 or n.y < 0.8 or (pier != null and pier.covers(x, z, 1.0)):
if h < terrain.water_level + 0.3 or n.y < 0.8 or (pier != null and pier.covers(x, z, 1.0)) or terrain.near_stream(x, z, 0.3):
continue
var chance := 1.0
match id:
@@ -742,7 +744,7 @@ func _scatter_berry_bushes(root: Node3D) -> void:
continue
var h: float = terrain.sample_height(x, z)
var n: Vector3 = terrain.sample_normal(x, z)
if h < terrain.water_level + 0.6 or n.y < 0.85:
if h < terrain.water_level + 0.6 or n.y < 0.85 or terrain.near_stream(x, z, 1.0):
continue
var f := _forest(x, z)
var chance := 0.12 + (1.0 - absf(f - 0.45) * 2.0) * 0.5
@@ -824,7 +826,7 @@ func _make_grass_tile(k: Vector2i) -> Node3D:
if not terrain.in_bounds(x, z):
continue
var h: float = terrain.sample_height(x, z)
if h < terrain.water_level + 0.25 or h > terrain.snow_height - 6.0:
if h < terrain.water_level + 0.25 or h > terrain.snow_height - 6.0 or terrain.near_stream(x, z, -0.2):
continue
var n: Vector3 = terrain.sample_normal(x, z)
if n.y < 0.72:

View File

@@ -97,7 +97,7 @@ func _find_site() -> Vector3:
hs.append(terrain.sample_height(c.x + cos(b) * RING_R, c.y + sin(b) * RING_R))
var h0: float = terrain.sample_height(c.x, c.y)
hs.append(h0)
if hs.max() - hs.min() > float(pass_[0]) or hs.min() < terrain.water_level + 1.0:
if hs.max() - hs.min() > float(pass_[0]) or hs.min() < terrain.water_level + 1.0 or terrain.near_stream(c.x, c.y, RING_R + 3.0):
continue
return Vector3(c.x, h0, c.y)
return Vector3.INF

View File

@@ -174,11 +174,18 @@ func _wall_threshold(x: float, z: float) -> float:
# (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"]
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)
@@ -231,6 +238,8 @@ func use_worldlet(id: String) -> String:
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:
@@ -252,6 +261,12 @@ func use_worldlet(id: String) -> String:
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))
@@ -285,10 +300,14 @@ func _has_block(t: String) -> bool:
return true
return false
func _height_fn(x: float, z: float) -> float:
## 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):
@@ -302,6 +321,10 @@ func _height_fn(x: float, z: float) -> float:
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
@@ -354,6 +377,101 @@ func _blk_lake(x: float, z: float, land: float, at: Vector2, r: float) -> float:
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.
@@ -572,6 +690,11 @@ func _build_water() -> void:
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, {
@@ -580,6 +703,40 @@ func _build_water() -> void:
"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")

View File

@@ -1122,6 +1122,9 @@ func _update_grass_cuts() -> void:
## fishing (and later swimming, boats) work on any world that answers it.
func water_at(x: float, z: float) -> Dictionary:
var ground: float = terrain.sample_height(x, z)
var ss: float = terrain.stream_surface(x, z)
if ground < ss:
return {"type": "stream", "surface": ss, "depth": ss - ground}
var beach: bool = terrain.beach_weight(x, z) > 0.5
if terrain.inside_boundary(x, z, -2.0) and ground < terrain.water_level:
if beach and not _in_lake(x, z):
@@ -1161,6 +1164,11 @@ func water_bodies() -> Array:
"beach":
out.append({"type": "sea", "surface": terrain.sea_level,
"center": terrain.shore_point(12.0), "radius": 0.0})
"stream":
if terrain.stream_path.size() >= 2:
var mid: Vector2 = terrain._stream_point(terrain._stream_end * 0.5)
out.append({"type": "stream", "surface": terrain._stream_surface_at(terrain._stream_end * 0.5),
"center": mid, "radius": terrain.stream_width * 0.5, "path": terrain.stream_path})
return out
## Whether (x, z) is in one of the lakes or ponds (not the sea off a beach).