W2: several lakes per worldlet, a beach block shelving into the sea with a current that turns swimmers back; testbay test worldlet through the water, edge and boundary suites; the two ways to play (journey, one land) recorded

This commit is contained in:
Paul Bailey
2026-11-06 16:48:20 +00:00
parent 68e6d814d9
commit 9b88a10609
9 changed files with 400 additions and 43 deletions

View File

@@ -41,6 +41,15 @@ signal ready_built ## emitted once the mesh + collision exist
@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)
@@ -132,31 +141,54 @@ 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.
## (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)
## Boundary threshold in rim distance: partway up the mountains, or just
## inside the cliff edge on the sea side.
## 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:
return lerpf(_wall_d(), _cliff_d() - 1.0, _sea_w(x, z))
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", "lake", "ocean_edge"]
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))
@@ -198,6 +230,9 @@ func use_worldlet(id: String) -> String:
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":
@@ -217,11 +252,30 @@ 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))
"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])
lake_center = Vector2(float(c[0]), float(c[1]))
lake_radius = float(b.get("radius", lake_radius))
water_level = float(b.get("water_level", water_level))
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 ""
@@ -244,8 +298,10 @@ func _height_fn(x: float, z: float) -> float:
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)
land = _blk_lake(x, z, land, b._c, b._r)
"ocean_edge":
land = _blk_ocean_edge(x, z, land)
return land
@@ -293,10 +349,27 @@ func _blk_sea_cliff(x: float, z: float, land: float) -> float:
return land
## A lake basin: the ground blends down to a bed below the waterline.
func _blk_lake(x: float, z: float, land: float) -> float:
var ld := Vector2(x - lake_center.x, z - lake_center.y).length()
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(lake_radius, lake_radius * 0.35, ld))
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.
@@ -496,7 +569,8 @@ func _build_water() -> void:
arrays[Mesh.ARRAY_INDEX] = idx
var disc := ArrayMesh.new()
disc.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
_add_water("Water", disc, water_level, {})
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)
@@ -537,6 +611,26 @@ 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:

View File

@@ -811,7 +811,8 @@ func _respawn() -> void:
player.rotation = Vector3(0.0, _spawn_yaw, 0.0)
player.get_node("Camera3D").rotation.x = 0.0
func _physics_process(_delta: float) -> void:
func _physics_process(delta: float) -> void:
_current(delta)
var p := player.global_position
var half: float = terrain.size * 0.5
if p.y < terrain.min_height - 30.0 or absf(p.x) > half or absf(p.z) > half \
@@ -819,6 +820,25 @@ func _physics_process(_delta: float) -> void:
push_warning("[world] player left the map at %s; respawning" % p)
_respawn()
## Off a beach (W2), a current carries anyone swimming too far out back
## towards the shore: the sea's version of the invisible wall. It's faster
## than you can swim, so you can't push through it, and it says so once.
const CURRENT_SPEED := 4.5
var _in_current := false
func _current(delta: float) -> void:
var p := player.global_position
var c: float = terrain.current_at(p.x, p.z) if p.y < terrain.sea_level else 0.0
if c <= 0.0:
_in_current = false
return
var back := -Vector3(p.x, 0.0, p.z).normalized()
player.global_position += back * CURRENT_SPEED * c * delta
if not _in_current and c > 0.2:
_in_current = true
if hud != null:
hud.toast("A strong current pulls you back towards the shore")
# --- building ---------------------------------------------------------------
## Whether a piece can go at `xf`: inside the valley, on dry fairly flat
@@ -887,11 +907,14 @@ 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 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):
return {"type": "sea", "surface": terrain.sea_level, "depth": terrain.sea_level - ground}
if hazards != null and hazards.iced(x, z):
return {} # (frozen over in a hard winter: cast further out)
return {"type": "lake", "surface": terrain.water_level, "depth": terrain.water_level - ground}
if terrain.sea_weight(x, z) > 0.5 and ground < terrain.sea_level:
if (beach or terrain.sea_weight(x, z) > 0.5) and ground < terrain.sea_level:
return {"type": "sea", "surface": terrain.sea_level, "depth": terrain.sea_level - ground}
return {}
@@ -916,12 +939,22 @@ func water_bodies() -> Array:
match String(b.type):
"lake":
out.append({"type": "lake", "surface": terrain.water_level,
"center": terrain.lake_center, "radius": terrain.lake_radius})
"center": b._c, "radius": b._r})
"sea_cliff":
out.append({"type": "sea", "surface": terrain.sea_level,
"center": terrain.coast_point(terrain.sea_angle_deg, 20.0), "radius": 0.0})
"beach":
out.append({"type": "sea", "surface": terrain.sea_level,
"center": terrain.shore_point(12.0), "radius": 0.0})
return out
## Whether (x, z) is in one of the lakes or ponds (not the sea off a beach).
func _in_lake(x: float, z: float) -> bool:
for l in terrain.lakes:
if Vector2(x - l.center.x, z - l.center.y).length() < float(l.radius) * 1.3:
return true
return false
## Its climate: {"snowline": m, ...} (more as worldlets differ).
func climate() -> Dictionary:
var c: Dictionary = terrain.worldlet.get("climate", {}).duplicate()