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

@@ -44,6 +44,20 @@
"min_depth": 2.2
}
]
},
"stream": {
"min_depth": 0.3,
"bite_wait": [
8.0,
18.0
],
"fish": [
{
"id": "trout",
"weight": 100,
"dawn_dusk": 100
}
]
}
}
}

View File

@@ -1,5 +1,5 @@
{
"_doc": "The Cove (docs/WORLDLETS_DESIGN.md §5, W3): the second worldlet, reached from the Valley down the cliff tunnel (J1). Low hills run down to a wide sandy bay with a pier (scripts/world/pier.gd) for sea fishing; a small pond inland; mountains behind. Not a start: you get here on the journey.",
"_doc": "The Cove (docs/WORLDLETS_DESIGN.md §5, W3): the second worldlet, reached from the Valley down the cliff tunnel (J1). Low hills run down to a wide sandy bay with a pier (scripts/world/pier.gd) for sea fishing; a small pond inland; a stream from a spring under the mountains down to the bay (a 'stream' block: its points, spring first, in world metres); mountains behind. Not a start: you get here on the journey.",
"id": "cove",
"name": "Cove",
"description": "Low hills running down to a sheltered sandy bay, with a pier out over the water for sea fishing.",
@@ -11,6 +11,7 @@
{"type": "rim", "play_radius": 100.0, "rim_width": 30.0, "mountain_height": 52.0, "rim_warp": 14.0, "wall_frac": 0.45},
{"type": "beach", "angle_deg": 90.0, "half_width_deg": 55.0, "fade_deg": 22.0, "shore_offset": -16.0, "slope": 0.08, "current_out": 22.0, "wall_out": 36.0},
{"type": "lake", "center": [-30.0, -40.0], "radius": 11.0, "water_level": -2.6},
{"type": "stream", "points": [[46.0, -80.0], [34.0, -48.0], [40.0, -14.0], [30.0, 22.0], [36.0, 52.0], [33.0, 78.0], [33.0, 104.0]], "width": 2.4, "depth": 0.6},
{"type": "ocean_edge"}
],
"climate": {"snowline": 42.0},

View File

@@ -52,9 +52,14 @@ its decisions.
- Tests: `test_cove` (walk onto the pier, catch a mackerel off its end, cod
only in deep water), and the Cove in `test_terrain_edges` and
`test_boundary`.
- Still to do: the stream (it needs water that runs downhill, which the
one flat water surface can't do: its own ribbon of water, planned with
the Cove's sea cave when the tunnel arrives).
- The stream (after 0.4.0): a `stream` block, a line of points from a
spring under the mountains to the bay. Its water surface is the running
minimum of the ground along it (so it only ever runs downhill, down to
sea level), the bed is cut 0.6 m under that and the banks rise a little
above it; its water is its own ribbon mesh (`terrain._stream_mesh()`),
murkier than the lake. `water_at()` answers "stream" (trout bite there,
`data/fish.json`), the map shows it, and scenery, the den, the barrow and
the stones keep clear of it (`terrain.near_stream()`).
*As built in W2:*
- `lake` blocks can repeat: every lake and pond is its own body

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).

View File

@@ -62,6 +62,30 @@ func _body() -> void:
check(world.den != null and world.barrow != null and world.stones != null, "the den, the barrow and the stones found places")
check(t.get_node_or_null("Water") == null, "one water surface (the bay is the sea)")
print("the stream")
check(t.stream_path.size() >= 2 and t.get_node_or_null("Stream") != null, "a stream, with water in it")
var downhill := true
for i in range(1, t._stream_profile.size()):
downhill = downhill and t._stream_profile[i] <= t._stream_profile[i - 1] + 0.0001
check(downhill and t._stream_profile[0] > t.water_level + 1.5, "running downhill all the way from its spring (%.1f m) to the sea" % t._stream_profile[0])
var mid: Vector2 = t._stream_point(t._stream_end * 0.3)
var sw: Dictionary = world.water_at(mid.x, mid.y)
check(sw.get("type", "") == "stream" and float(sw.depth) > 0.3 and float(sw.depth) < 1.0,
"stream water, wading deep (%.1f m)" % float(sw.get("depth", 0.0)))
var side: Vector2 = mid + Vector2(t.stream_width * 1.2, 0.0)
check(world.water_at(side.x, side.y).is_empty() and t.sample_height(side.x, side.y) > float(sw.surface),
"its banks are dry, and above the water")
check(DB.water_fishing("stream").get("fish", []).any(func(f): return f.id == "trout"), "trout in the stream")
var away := true
for p in [world.den.global_position, world.barrow.global_position, world.stones.global_position, world._spawn]:
away = away and not t.near_stream(p.x, p.z, 3.0)
check(away, "the den, the barrow, the stones and the start are all clear of it")
var in_bed := 0
for n in world.props.resource_nodes.values():
if is_instance_valid(n) and t.near_stream(n.global_position.x, n.global_position.z, 0.0):
in_bed += 1
check(in_bed == 0, "no trees or boulders standing in it")
print("the pier")
var pier: Node3D = world.pier
check(pier != null and pier.is_in_group("pier"), "a pier")

View File

@@ -345,7 +345,7 @@ The Valley is only the start. Beyond it lie other lands, each reached a differen
### The Cove
Low hills run down to a sheltered sandy bay, with mountains behind and a small pond inland. Wade out from the beach and it gets deeper slowly; swim too far and a strong current carries you back to the shore.
Low hills run down to a sheltered sandy bay, with mountains behind, a small pond inland, and a stream that runs from a spring under the mountains down to the sea. You can wade the stream anywhere, and trout bite in it. Wade out from the beach and it gets deeper slowly; swim too far and a strong current carries you back to the shore.
- **The pier** runs out over the bay. Fish off its end for **mackerel**, the usual catch. **Cod** are bigger but only bite where the water's at least 2.2 m deep, so cast well out. Both bite best at dawn and dusk, and grill at a campfire, salt or smoke like lake fish.
- **The sea cave** at the end of the beach is the way back: `E` goes into it and up the same tunnel, arriving at the bottom.