diff --git a/data/fish.json b/data/fish.json index 7dd92d6..cb4e16e 100644 --- a/data/fish.json +++ b/data/fish.json @@ -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 + } + ] } } } diff --git a/data/worldlets/cove.json b/data/worldlets/cove.json index e9826bf..4be5492 100644 --- a/data/worldlets/cove.json +++ b/data/worldlets/cove.json @@ -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}, diff --git a/docs/WORLDLETS_DESIGN.md b/docs/WORLDLETS_DESIGN.md index 7d1e1c0..c84ae79 100644 --- a/docs/WORLDLETS_DESIGN.md +++ b/docs/WORLDLETS_DESIGN.md @@ -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 diff --git a/scripts/creatures/den.gd b/scripts/creatures/den.gd index 48411e1..43ad522 100644 --- a/scripts/creatures/den.gd +++ b/scripts/creatures/den.gd @@ -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 diff --git a/scripts/ui/map_screen.gd b/scripts/ui/map_screen.gd index ebdfe0f..0087d57 100644 --- a/scripts/ui/map_screen.gd +++ b/scripts/ui/map_screen.gd @@ -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) diff --git a/scripts/world/barrow.gd b/scripts/world/barrow.gd index b8b3245..a2a9c69 100644 --- a/scripts/world/barrow.gd +++ b/scripts/world/barrow.gd @@ -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: diff --git a/scripts/world/props.gd b/scripts/world/props.gd index b39ac67..ed8f70b 100644 --- a/scripts/world/props.gd +++ b/scripts/world/props.gd @@ -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: diff --git a/scripts/world/standing_stones.gd b/scripts/world/standing_stones.gd index 1a19497..e6f0804 100644 --- a/scripts/world/standing_stones.gd +++ b/scripts/world/standing_stones.gd @@ -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 diff --git a/scripts/world/terrain.gd b/scripts/world/terrain.gd index 91fccb4..6e786a0 100644 --- a/scripts/world/terrain.gd +++ b/scripts/world/terrain.gd @@ -174,11 +174,18 @@ func _wall_threshold(x: float, z: float) -> float: # (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", "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") diff --git a/scripts/world/world.gd b/scripts/world/world.gd index 5eb0963..963e6ea 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -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). diff --git a/tests/test_cove.gd b/tests/test_cove.gd index 651e0a5..03b73bc 100644 --- a/tests/test_cove.gd +++ b/tests/test_cove.gd @@ -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") diff --git a/tools/site/guide_sections.md b/tools/site/guide_sections.md index f86cb40..27de2cc 100644 --- a/tools/site/guide_sections.md +++ b/tools/site/guide_sections.md @@ -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.