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

@@ -1,7 +1,30 @@
# Worldlets design # Worldlets design
Status: **W0 built**; the rest is planned. The journey between worldlets is Status: **W0, W1 and W2 built**; the rest is planned. The journey between
§10. worldlets is §10, and the two ways to play (the journey, or one land) are in
its decisions.
*As built in W2:*
- `lake` blocks can repeat: every lake and pond is its own body
(`terrain.lakes`, `world.water_bodies()`), all at one surface
(`water_level`, set by the first). The first is still "the lake" to the
older code (ice, its fish, the tutorial).
- A `beach` block (angle, width, `shore_offset`, `slope`, `current_out`,
`wall_out`): the land eases to a waterline and shelves gently into the
sea, which is then the same surface as the ponds (one ocean sheet, no
inland disc). The invisible wall stands well out in the water, and before
it a current (`terrain.current_at()`, `world._current()`) carries
swimmers back faster than they can swim, with a toast.
- `water_at()` tells the sea off a beach from a pond.
- Test-only worldlets live in `tests/worldlets/` (never listed or offered).
`testbay.json` (two ponds and a beach) runs through
`test_water_bodies`, `test_terrain_edges` (8 seeds) and `test_boundary`
(swimming out across the beach).
*As built in W1:* a "Where" picker on the New World and Host Game pages
(worldlets with `"start": true`; only the Valley, so it's greyed out), the
worldlet in the Load list, and a hosted island keeps its worldlet like its
seed.
*As built in W0:* *As built in W0:*
- `data/worldlets/valley.json`: the Valley as data, its terrain as five - `data/worldlets/valley.json`: the Valley as data, its terrain as five
@@ -336,6 +359,22 @@ A server today runs one island. Options:
- **The other crossings are shared:** a dug tunnel, a fixed climbing rope - **The other crossings are shared:** a dug tunnel, a fixed climbing rope
and a dug burrow are changes to the world, there for everyone once one and a dug burrow are changes to the world, there for everyone once one
player has made them (like a door or a bridge). player has made them (like a door or a bridge).
- **Two ways to play (Paul, Nov 2026).**
- **The journey** (the standard game), single-player and multiplayer
alike: every new world starts in the Valley and the worldlets come in
their fixed order (Valley, Cove, Highlands, Marsh, Ember). Each
worldlet's shape is set by its definition; the seed only changes the
detail (hills, trees, where the den is), so the journey is the same
adventure for everyone.
- **One land** (a hosting choice): no journey, no crossings, the server
is locked to one worldlet, and the host picks which, from all of them.
Co-op or PvP as now (the rules preset). In code: a `mode` in
`server.json` ("journey" / "one_land"); in one-land mode the Host
page's "Where" lists every worldlet rather than only the starts, and
the crossings (`world.crossings()`) answer none, so a tunnel seam
can't be dug. Kept, like the seed and the worldlet, once the island
exists. Worth building once the Cove exists (until then there's only
the Valley to pick).
### Server cost, measured (0.3.1) ### Server cost, measured (0.3.1)
One worldlet, a dedicated server on the M5 Pro, never pausing: about One worldlet, a dedicated server on the M5 Pro, never pausing: about

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_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 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_group("Water & snow")
@export var water_level: float = -2.6 @export var water_level: float = -2.6
@export var lake_center: Vector2 = Vector2(14.0, -46.0) @export var lake_center: Vector2 = Vector2(14.0, -46.0)
@@ -132,31 +141,54 @@ func _cliff_d() -> float:
return play_radius + cliff_offset return play_radius + cliff_offset
## 1 on the open-sea side of the rim, 0 where the mountains are, smooth between ## 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: 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)) 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) 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 ## The same for a beach (W2): 1 where the land runs down into the sea.
## inside the cliff edge on the sea side. 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: 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) --------------------------------------- # --- worldlets (docs/WORLDLETS_DESIGN.md, W0) ---------------------------------------
# A worldlet's terrain is a list of building blocks applied in order # 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 # (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. # 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_id := "valley"
var worldlet: Dictionary = {} ## the definition, as loaded var worldlet: Dictionary = {} ## the definition, as loaded
var _blocks: Array = [] ## its terrain blocks, in order 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 _valley := 0.0 ## the hills before anything else (the sea cliff eases from it)
var _detail_k := 1.4 var _detail_k := 1.4
## The definition of worldlet `id` (data/worldlets/<id>.json), or {} if none. ## The definition of worldlet `id` (data/worldlets/<id>.json), or {} if none.
static func load_worldlet(id: String) -> Dictionary: static func load_worldlet(id: String) -> Dictionary:
var p := "res://data/worldlets/%s.json" % id 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): if not FileAccess.file_exists(p):
return {} return {}
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(p)) 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)) size = int(d.get("size", size))
resolution = int(d.get("resolution", resolution)) resolution = int(d.get("resolution", resolution))
_blocks = d.get("terrain", []) _blocks = d.get("terrain", [])
lakes = []
_has_sea = _has_block("sea_cliff")
_has_beach = _has_block("beach")
for b in _blocks: for b in _blocks:
match String(b.type): match String(b.type):
"hills": "hills":
@@ -217,11 +252,30 @@ func use_worldlet(id: String) -> String:
cliff_top = float(b.get("cliff_top", cliff_top)) cliff_top = float(b.get("cliff_top", cliff_top))
cliff_offset = float(b.get("cliff_offset", cliff_offset)) cliff_offset = float(b.get("cliff_offset", cliff_offset))
cliff_drop_width = float(b.get("drop_width", cliff_drop_width)) 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": "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 c: Array = b.get("center", [lake_center.x, lake_center.y])
lake_center = Vector2(float(c[0]), float(c[1])) var at := Vector2(float(c[0]), float(c[1]))
lake_radius = float(b.get("radius", lake_radius)) var r := float(b.get("radius", lake_radius))
water_level = float(b.get("water_level", water_level)) 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)) snow_height = float(d.get("climate", {}).get("snowline", snow_height))
return "" return ""
@@ -244,8 +298,10 @@ func _height_fn(x: float, z: float) -> float:
land = _blk_rim(x, z, land) land = _blk_rim(x, z, land)
"sea_cliff": "sea_cliff":
land = _blk_sea_cliff(x, z, land) land = _blk_sea_cliff(x, z, land)
"beach":
land = _blk_beach(x, z, land)
"lake": "lake":
land = _blk_lake(x, z, land) land = _blk_lake(x, z, land, b._c, b._r)
"ocean_edge": "ocean_edge":
land = _blk_ocean_edge(x, z, land) land = _blk_ocean_edge(x, z, land)
return land return land
@@ -293,10 +349,27 @@ func _blk_sea_cliff(x: float, z: float, land: float) -> float:
return land return land
## A lake basin: the ground blends down to a bed below the waterline. ## A lake basin: the ground blends down to a bed below the waterline.
func _blk_lake(x: float, z: float, land: float) -> float: func _blk_lake(x: float, z: float, land: float, at: Vector2, r: float) -> float:
var ld := Vector2(x - lake_center.x, z - lake_center.y).length() var ld := Vector2(x - at.x, z - at.y).length()
ld += _detail.get_noise_2d(x * 0.5, z * 0.5) * 5.0 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 ## Ocean all round the chunk edge, so the world is an island and the edge of
## the mesh is always hidden under deep water. ## the mesh is always hidden under deep water.
@@ -496,7 +569,8 @@ func _build_water() -> void:
arrays[Mesh.ARRAY_INDEX] = idx arrays[Mesh.ARRAY_INDEX] = idx
var disc := ArrayMesh.new() var disc := ArrayMesh.new()
disc.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) 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() var ocean := PlaneMesh.new()
ocean.size = Vector2(8000.0, 8000.0) ocean.size = Vector2(8000.0, 8000.0)
@@ -537,6 +611,26 @@ func sea_weight(x: float, z: float) -> float:
_ensure_noise() _ensure_noise()
return _sea_w(x, z) 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 ## Point on the coast along `angle_deg` (atan2(z, x) convention), at rim
## distance `past_edge` beyond the cliff edge (negative = inland of it). ## distance `past_edge` beyond the cliff edge (negative = inland of it).
func coast_point(angle_deg: float, past_edge: float) -> Vector2: 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.rotation = Vector3(0.0, _spawn_yaw, 0.0)
player.get_node("Camera3D").rotation.x = 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 p := player.global_position
var half: float = terrain.size * 0.5 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 \ 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) push_warning("[world] player left the map at %s; respawning" % p)
_respawn() _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 --------------------------------------------------------------- # --- building ---------------------------------------------------------------
## Whether a piece can go at `xf`: inside the valley, on dry fairly flat ## 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. ## fishing (and later swimming, boats) work on any world that answers it.
func water_at(x: float, z: float) -> Dictionary: func water_at(x: float, z: float) -> Dictionary:
var ground: float = terrain.sample_height(x, z) 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 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): if hazards != null and hazards.iced(x, z):
return {} # (frozen over in a hard winter: cast further out) return {} # (frozen over in a hard winter: cast further out)
return {"type": "lake", "surface": terrain.water_level, "depth": terrain.water_level - ground} 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 {"type": "sea", "surface": terrain.sea_level, "depth": terrain.sea_level - ground}
return {} return {}
@@ -916,12 +939,22 @@ func water_bodies() -> Array:
match String(b.type): match String(b.type):
"lake": "lake":
out.append({"type": "lake", "surface": terrain.water_level, out.append({"type": "lake", "surface": terrain.water_level,
"center": terrain.lake_center, "radius": terrain.lake_radius}) "center": b._c, "radius": b._r})
"sea_cliff": "sea_cliff":
out.append({"type": "sea", "surface": terrain.sea_level, out.append({"type": "sea", "surface": terrain.sea_level,
"center": terrain.coast_point(terrain.sea_angle_deg, 20.0), "radius": 0.0}) "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 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). ## Its climate: {"snowline": m, ...} (more as worldlets differ).
func climate() -> Dictionary: func climate() -> Dictionary:
var c: Dictionary = terrain.worldlet.get("climate", {}).duplicate() var c: Dictionary = terrain.worldlet.get("climate", {}).duplicate()

View File

@@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then
echo "Importing the project first (fresh copy)..." echo "Importing the project first (fresh copy)..."
godot --headless --path . --import >/dev/null 2>&1 godot --headless --path . --import >/dev/null 2>&1
fi fi
for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_host test_tutorial test_tutorial_full test_hints test_updates test_audio test_items_more test_worldlets test_worldlet_pick test_terrain_edges test_boundary; do for t in test_survival_core test_survival_stats test_save_load test_weather_building test_creatures_combat test_wolves test_alpha test_bear test_thieves test_barrow test_boss test_den_sites test_fishing_diet test_food test_hazards test_iron test_stone test_net_lobby test_net_world test_net_shared test_net_life test_net_boss test_net_rules test_host test_tutorial test_tutorial_full test_hints test_updates test_audio test_items_more test_worldlets test_worldlet_pick test_water_bodies test_terrain_edges test_boundary; do
extra="" extra=""
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30" [ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
# A suite that waits on a world that failed to load would hang forever, # A suite that waits on a world that failed to load would hang forever,

View File

@@ -4,34 +4,63 @@ extends SceneTree
## the invisible wall, falls off the map, or ends up stuck below the waterline ## the invisible wall, falls off the map, or ends up stuck below the waterline
## (in a pit near the edge). ## (in a pit near the edge).
## ##
## Then the same on a worldlet with a beach (W2, tests/worldlets/testbay.json):
## off the beach you swim out into the sea, and the current must turn you back
## before the wall (being afloat in the open sea there is fine).
##
## Run: godot --headless --path . --fixed-fps 30 --script res://tests/test_boundary.gd ## Run: godot --headless --path . --fixed-fps 30 --script res://tests/test_boundary.gd
const DIRECTIONS := 24 const DIRECTIONS := 24
const BAY_DIRECTIONS := 8 # (spread over the beach and its headlands)
const SECONDS_PER_TRY := 25.0 const SECONDS_PER_TRY := 25.0
var _failures := 0
var _tries := 0
func _initialize() -> void: func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
var S: Node = root.get_node("Saves")
S.root_dir = "user://test_boundary_worlds"
for w in S.list_worlds():
S.delete_world(w.id)
print("the Valley")
await _escape_all(DIRECTIONS, -1.0)
print("a beach (testbay)")
S.current_id = S.create_world("Bay", 1337, {"worldlet": "testbay"})
await _escape_all(BAY_DIRECTIONS, 90.0)
for w in S.list_worlds():
S.delete_world(w.id)
DirAccess.remove_absolute(S.root_dir)
print("RESULT: %s (%d/%d directions held)" % ["PASS" if _failures == 0 else "FAIL", _tries - _failures, _tries])
quit(1 if _failures > 0 else 0)
## Walks out in `count` directions: all round, or (beach_deg >= 0) across the
## beach's arc and a little past it either side.
func _escape_all(count: int, beach_deg: float) -> void:
var world: Node3D = load("res://scenes/world.tscn").instantiate() var world: Node3D = load("res://scenes/world.tscn").instantiate()
root.add_child(world) root.add_child(world)
_run.call_deferred(world)
func _run(world: Node3D) -> void:
for _i in range(10): for _i in range(10):
await physics_frame await physics_frame
var terrain: Node3D = world.get_node("Terrain") var terrain: Node3D = world.get_node("Terrain")
var player: CharacterBody3D = world.get_node("Player") var player: CharacterBody3D = world.get_node("Player")
var failures := 0
var tick := 1.0 / Engine.physics_ticks_per_second var tick := 1.0 / Engine.physics_ticks_per_second
for i in range(DIRECTIONS): var pulled := 0
var ang := TAU * i / DIRECTIONS for i in range(count):
var ang := TAU * i / count
if beach_deg >= 0.0:
ang = deg_to_rad(beach_deg + lerpf(-65.0, 65.0, float(i) / (count - 1)))
var out := Vector3(cos(ang), 0.0, sin(ang)) var out := Vector3(cos(ang), 0.0, sin(ang))
var start: Vector3 = out * (terrain.play_radius - 12.0) var start: Vector3 = out * (terrain.play_radius - 12.0)
start.y = terrain.sample_height(start.x, start.z) + 1.0 start.y = maxf(terrain.sample_height(start.x, start.z), terrain.water_level) + 1.0
player.global_position = start player.global_position = start
player.velocity = Vector3.ZERO player.velocity = Vector3.ZERO
player.rotation = Vector3(0.0, atan2(-out.x, -out.z), 0.0) player.rotation = Vector3(0.0, atan2(-out.x, -out.z), 0.0)
Input.action_press("move_forward") Input.action_press("move_forward")
var escaped := false var escaped := false
var max_d := 0.0 var max_d := 0.0
var in_current := false
var frames := int(SECONDS_PER_TRY / tick) var frames := int(SECONDS_PER_TRY / tick)
for f in range(frames): for f in range(frames):
if f % 12 == 0: if f % 12 == 0:
@@ -41,17 +70,28 @@ func _run(world: Node3D) -> void:
await physics_frame await physics_frame
var p := player.global_position var p := player.global_position
max_d = maxf(max_d, Vector2(p.x, p.z).length()) max_d = maxf(max_d, Vector2(p.x, p.z).length())
in_current = in_current or terrain.current_at(p.x, p.z) > 0.2
if not terrain.inside_boundary(p.x, p.z, -1.5) or p.y < terrain.min_height - 5.0: if not terrain.inside_boundary(p.x, p.z, -1.5) or p.y < terrain.min_height - 5.0:
escaped = true escaped = true
break break
Input.action_release("move_forward") Input.action_release("move_forward")
Input.action_release("jump") Input.action_release("jump")
var p := player.global_position var p := player.global_position
if not escaped and p.y < terrain.water_level - 0.5: var at_sea: bool = world.water_at(p.x, p.z).get("type", "") == "sea"
if not escaped and p.y < terrain.water_level - 0.5 and not at_sea:
escaped = true # stuck in a pit below the waterline escaped = true # stuck in a pit below the waterline
print("dir %2d (%3d deg): %s max radius %.1f final (%.1f, %.1f, %.1f)" % [ if in_current:
i, int(rad_to_deg(ang)), "FAILED" if escaped else "held", max_d, p.x, p.y, p.z]) pulled += 1
print("dir %2d (%3d deg): %s max radius %.1f final (%.1f, %.1f, %.1f)%s" % [
i, int(rad_to_deg(ang)), "FAILED" if escaped else "held", max_d, p.x, p.y, p.z,
" (the current turned them back)" if in_current else ""])
_tries += 1
if escaped: if escaped:
failures += 1 _failures += 1
print("RESULT: %s (%d/%d directions held)" % ["PASS" if failures == 0 else "FAIL", DIRECTIONS - failures, DIRECTIONS]) if beach_deg >= 0.0 and pulled == 0:
quit(1 if failures > 0 else 0) print("FAILED: nobody swam far enough out to meet the current")
_failures += 1
_tries += 1
world.queue_free()
for _i in range(3):
await process_frame

View File

@@ -19,18 +19,25 @@ const SEEDS := [1337, 1, 42, 777, 4242, 9001, 31337, 123456]
func _initialize() -> void: func _initialize() -> void:
_all.call_deferred() _all.call_deferred()
## Each worldlet's edges (W2): the Valley, and a test worldlet with two
## ponds and a beach (tests/worldlets/testbay.json).
const WORLDLETS := ["valley", "testbay"]
func _all() -> void: func _all() -> void:
var failed := 0 var failed := 0
for s in SEEDS: for w in WORLDLETS:
var terrain: Node3D = load("res://scripts/world/terrain.gd").new() for s in SEEDS:
terrain.noise_seed = s var terrain: Node3D = load("res://scripts/world/terrain.gd").new()
root.add_child(terrain) # builds in _ready terrain.noise_seed = s
var bad := _check(terrain) terrain.worldlet_id = w
print("seed %6d: %s" % [s, "ok" if bad == 0 else "%d bad samples" % bad]) root.add_child(terrain) # builds in _ready
if bad > 0: var bad := _check(terrain)
failed += 1 print("%-8s seed %6d: %s" % [w, s, "ok" if bad == 0 and terrain.worldlet_id == w else "%d bad samples" % bad])
terrain.free() if bad > 0 or terrain.worldlet_id != w:
print("RESULT: %s (%d/%d seeds clean)" % ["PASS" if failed == 0 else "FAIL", SEEDS.size() - failed, SEEDS.size()]) failed += 1
terrain.free()
var n := WORLDLETS.size() * SEEDS.size()
print("RESULT: %s (%d/%d seeds clean)" % ["PASS" if failed == 0 else "FAIL", n - failed, n])
quit(1 if failed > 0 else 0) quit(1 if failed > 0 else 0)
func _check(terrain: Node3D) -> int: func _check(terrain: Node3D) -> int:
@@ -42,7 +49,10 @@ func _check(terrain: Node3D) -> int:
while x < half: while x < half:
var z := -half var z := -half
while z < half: while z < half:
if terrain.inside_boundary(x, z, 0.0) and not terrain.inside_boundary(x, z, BAND): # (Off a beach the edge is open sea, and the current keeps you
# from the wall: tests/test_boundary.gd swims out there.)
if terrain.inside_boundary(x, z, 0.0) and not terrain.inside_boundary(x, z, BAND) \
and terrain.beach_weight(x, z) <= 0.5:
var h: float = terrain.sample_height(x, z) var h: float = terrain.sample_height(x, z)
# Hazard 1: a pit too deep to wade out of near the edge. # Hazard 1: a pit too deep to wade out of near the edge.
var too_deep: bool = h < terrain.water_level - MAX_EDGE_DEPTH var too_deep: bool = h < terrain.water_level - MAX_EDGE_DEPTH

123
tests/test_water_bodies.gd Normal file
View File

@@ -0,0 +1,123 @@
extends SceneTree
## W2 (docs/WORLDLETS_DESIGN.md): several bodies of water in one world, and a
## beach. On a test worldlet with two ponds and a beach
## (tests/worldlets/testbay.json): the world lists both ponds and the sea,
## water_at() knows which is which, the beach shelves gently into the sea,
## the sea there is one surface (no second water sheet), and a swimmer out
## past the shallows is carried back by the current. The Valley has no
## current anywhere.
## Run: godot --headless --path . --script res://tests/test_water_bodies.gd
var _fails := 0
var _finished := false
func check(cond: bool, what: String) -> void:
print((" ok " if cond else " FAIL ") + what)
if not cond:
_fails += 1
func _initialize() -> void:
_run.call_deferred()
func _run() -> void:
var S: Node = root.get_node("Saves")
S.root_dir = "user://test_water_worlds"
for w in S.list_worlds():
S.delete_world(w.id)
await _body(S)
check(_finished, "test ran to the end (no script errors)")
for w in S.list_worlds():
S.delete_world(w.id)
DirAccess.remove_absolute(S.root_dir)
print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails])
quit(1 if _fails > 0 else 0)
func _body(S: Node) -> void:
var T: Script = load("res://scripts/world/terrain.gd")
check(not T.worldlets().any(func(w): return w.id == "testbay"), "the test worldlet is never listed (nor offered)")
print("the Valley: no current")
var v: Node3D = T.new()
root.add_child(v)
await process_frame
var any := false
for i in range(72):
var a := TAU * i / 72.0
for r in [60.0, 100.0, 110.0, 120.0]:
any = any or v.current_at(cos(a) * r, sin(a) * r) > 0.0
check(not any, "nowhere in the Valley has a current")
check(v.lakes.size() == 1 and v.lakes[0].center == Vector2(14, -46), "and its one lake is the old one")
v.free()
print("two ponds and a beach")
S.current_id = S.create_world("Bay", 1337, {"worldlet": "testbay"})
var world: Node3D = load("res://scenes/world.tscn").instantiate()
root.add_child(world)
for _i in range(8):
await physics_frame
var t: Node3D = world.terrain
check(world.worldlet_id() == "testbay", "the world is the test bay")
var bodies: Array = world.water_bodies()
var ponds: Array = bodies.filter(func(b): return b.type == "lake")
var seas: Array = bodies.filter(func(b): return b.type == "sea")
check(ponds.size() == 2 and seas.size() == 1, "its water: two ponds and the sea (%s)" % [bodies.map(func(b): return b.type)])
for p in ponds:
var c: Vector2 = p.center
var w: Dictionary = world.water_at(c.x, c.y)
check(w.get("type", "") == "lake" and float(w.get("depth", 0.0)) > 2.0,
"the pond at (%.0f, %.0f) is water, %.1f m deep" % [c.x, c.y, float(w.get("depth", 0.0))])
var bay: Vector2 = seas[0].center
var sw: Dictionary = world.water_at(bay.x, bay.y)
check(sw.get("type", "") == "sea", "out in the bay is the sea (%s)" % [sw])
check(absf(t.sea_level - t.water_level) < 0.01, "the sea off a beach is at the ponds' level (%.1f)" % t.sea_level)
check(t.get_node_or_null("Water") == null and t.get_node_or_null("Ocean") != null, "one water surface: the ocean, no second sheet")
print("the beach")
# Walking straight out from the middle of the beach: dry land, the
# waterline, then gently deeper (no cliff), then the current, then the wall.
var dir := Vector2(cos(deg_to_rad(t.beach_angle_deg)), sin(deg_to_rad(t.beach_angle_deg)))
var shore := -1.0
var current := -1.0
var wall := -1.0
var steepest := 0.0
var prev: float = t.sample_height(dir.x * 40.0, dir.y * 40.0)
var r := 41.0
while r < 150.0:
var q := dir * r
var h: float = t.sample_height(q.x, q.y)
if r > 60.0:
steepest = maxf(steepest, absf(h - prev))
prev = h
if shore < 0.0 and h < t.water_level:
shore = r
if current < 0.0 and t.current_at(q.x, q.y) > 0.0:
current = r
if wall < 0.0 and not t.inside_boundary(q.x, q.y):
wall = r
r += 1.0
check(shore > 0.0 and current > shore and wall > current, "dry land, the waterline at %.0f m, the current from %.0f m, the wall at %.0f m" % [shore, current, wall])
check(steepest < 0.5, "a gentle shelf, never a drop (steepest %.2f m per metre)" % steepest)
var depth_at_current: float = t.water_level - t.sample_height(dir.x * current, dir.y * current)
check(depth_at_current > 1.3, "the current starts where you're swimming, not wading (%.1f m deep)" % depth_at_current)
print("the current")
var player: CharacterBody3D = world.player
player.set_physics_process(false) # (no swimming: just drift)
var start := dir * (wall - 4.0)
player.global_position = Vector3(start.x, t.water_level - 1.3, start.y)
var d0 := start.length()
for _i in range(60):
await physics_frame
var p := player.global_position
var d1 := Vector2(p.x, p.z).length()
check(d1 < d0 - 1.5, "a swimmer by the wall is carried back towards the shore (%.1f m in a second)" % (d0 - d1))
check(world._in_current, "(and told so)")
var near := dir * (shore + 2.0)
player.global_position = Vector3(near.x, t.water_level - 0.5, near.y)
for _i in range(30):
await physics_frame
p = player.global_position
check(absf(Vector2(p.x, p.z).length() - (shore + 2.0)) < 0.01, "but nothing pulls at you in the shallows")
check(t.find_spawn(Vector2.ZERO).y > t.water_level + 1.0, "the start is on dry land")
world.queue_free()
await process_frame
_finished = true

View File

@@ -0,0 +1 @@
uid://da1vwsptqh68b

View File

@@ -0,0 +1,17 @@
{
"_doc": "Test-only worldlet (W2): the Valley's hills and rim, but a beach instead of the sea cliff and two ponds instead of the lake. Found by terrain.gd's load_worldlet() after data/worldlets/, never listed or offered. Used by tests/test_water_bodies.gd, test_terrain_edges.gd and test_boundary.gd.",
"id": "testbay",
"name": "Test Bay",
"description": "Hills, two ponds and a beach (tests only).",
"size": 320,
"resolution": 256,
"terrain": [
{"type": "hills", "height_scale": 7.0, "detail": 1.4},
{"type": "rim", "play_radius": 100.0, "rim_width": 30.0, "mountain_height": 58.0, "rim_warp": 16.0, "wall_frac": 0.45},
{"type": "beach", "angle_deg": 90.0, "half_width_deg": 40.0, "fade_deg": 20.0, "shore_offset": -10.0, "slope": 0.08, "current_out": 20.0, "wall_out": 34.0},
{"type": "lake", "center": [-40.0, -35.0], "radius": 14.0, "water_level": -2.6},
{"type": "lake", "center": [35.0, -45.0], "radius": 11.0},
{"type": "ocean_edge"}
],
"climate": {"snowline": 40.0}
}