From 9b88a10609366dee3eee35aff7479eae1e6ace6c Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Fri, 6 Nov 2026 16:48:20 +0000 Subject: [PATCH] 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 --- docs/WORLDLETS_DESIGN.md | 43 +++++++++++- scripts/world/terrain.gd | 120 ++++++++++++++++++++++++++++---- scripts/world/world.gd | 39 ++++++++++- tests/run_all.sh | 2 +- tests/test_boundary.gd | 66 ++++++++++++++---- tests/test_terrain_edges.gd | 32 ++++++--- tests/test_water_bodies.gd | 123 +++++++++++++++++++++++++++++++++ tests/test_water_bodies.gd.uid | 1 + tests/worldlets/testbay.json | 17 +++++ 9 files changed, 400 insertions(+), 43 deletions(-) create mode 100644 tests/test_water_bodies.gd create mode 100644 tests/test_water_bodies.gd.uid create mode 100644 tests/worldlets/testbay.json diff --git a/docs/WORLDLETS_DESIGN.md b/docs/WORLDLETS_DESIGN.md index fcce9af..0f39eff 100644 --- a/docs/WORLDLETS_DESIGN.md +++ b/docs/WORLDLETS_DESIGN.md @@ -1,7 +1,30 @@ # Worldlets design -Status: **W0 built**; the rest is planned. The journey between worldlets is -§10. +Status: **W0, W1 and W2 built**; the rest is planned. The journey between +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:* - `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 and a dug burrow are changes to the world, there for everyone once one 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) One worldlet, a dedicated server on the M5 Pro, never pausing: about diff --git a/scripts/world/terrain.gd b/scripts/world/terrain.gd index 91b2693..91fccb4 100644 --- a/scripts/world/terrain.gd +++ b/scripts/world/terrain.gd @@ -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/.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/.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: diff --git a/scripts/world/world.gd b/scripts/world/world.gd index 979814a..bec1695 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -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() diff --git a/tests/run_all.sh b/tests/run_all.sh index 3284a07..439b574 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -9,7 +9,7 @@ if [ ! -f .godot/global_script_class_cache.cfg ]; then echo "Importing the project first (fresh copy)..." godot --headless --path . --import >/dev/null 2>&1 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="" [ "$t" = "test_boundary" ] && extra="--fixed-fps 30" # A suite that waits on a world that failed to load would hang forever, diff --git a/tests/test_boundary.gd b/tests/test_boundary.gd index 1d0cccf..a910430 100644 --- a/tests/test_boundary.gd +++ b/tests/test_boundary.gd @@ -4,34 +4,63 @@ extends SceneTree ## the invisible wall, falls off the map, or ends up stuck below the waterline ## (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 const DIRECTIONS := 24 +const BAY_DIRECTIONS := 8 # (spread over the beach and its headlands) const SECONDS_PER_TRY := 25.0 +var _failures := 0 +var _tries := 0 + 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() root.add_child(world) - _run.call_deferred(world) - -func _run(world: Node3D) -> void: for _i in range(10): await physics_frame var terrain: Node3D = world.get_node("Terrain") var player: CharacterBody3D = world.get_node("Player") - var failures := 0 var tick := 1.0 / Engine.physics_ticks_per_second - for i in range(DIRECTIONS): - var ang := TAU * i / DIRECTIONS + var pulled := 0 + 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 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.velocity = Vector3.ZERO player.rotation = Vector3(0.0, atan2(-out.x, -out.z), 0.0) Input.action_press("move_forward") var escaped := false var max_d := 0.0 + var in_current := false var frames := int(SECONDS_PER_TRY / tick) for f in range(frames): if f % 12 == 0: @@ -41,17 +70,28 @@ func _run(world: Node3D) -> void: await physics_frame var p := player.global_position 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: escaped = true break Input.action_release("move_forward") Input.action_release("jump") 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 - print("dir %2d (%3d deg): %s max radius %.1f final (%.1f, %.1f, %.1f)" % [ - i, int(rad_to_deg(ang)), "FAILED" if escaped else "held", max_d, p.x, p.y, p.z]) + if in_current: + 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: - failures += 1 - print("RESULT: %s (%d/%d directions held)" % ["PASS" if failures == 0 else "FAIL", DIRECTIONS - failures, DIRECTIONS]) - quit(1 if failures > 0 else 0) + _failures += 1 + if beach_deg >= 0.0 and pulled == 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 diff --git a/tests/test_terrain_edges.gd b/tests/test_terrain_edges.gd index efcfe66..c631194 100644 --- a/tests/test_terrain_edges.gd +++ b/tests/test_terrain_edges.gd @@ -19,18 +19,25 @@ const SEEDS := [1337, 1, 42, 777, 4242, 9001, 31337, 123456] func _initialize() -> void: _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: var failed := 0 - for s in SEEDS: - var terrain: Node3D = load("res://scripts/world/terrain.gd").new() - terrain.noise_seed = s - root.add_child(terrain) # builds in _ready - var bad := _check(terrain) - print("seed %6d: %s" % [s, "ok" if bad == 0 else "%d bad samples" % bad]) - if bad > 0: - failed += 1 - terrain.free() - print("RESULT: %s (%d/%d seeds clean)" % ["PASS" if failed == 0 else "FAIL", SEEDS.size() - failed, SEEDS.size()]) + for w in WORLDLETS: + for s in SEEDS: + var terrain: Node3D = load("res://scripts/world/terrain.gd").new() + terrain.noise_seed = s + terrain.worldlet_id = w + root.add_child(terrain) # builds in _ready + var bad := _check(terrain) + print("%-8s seed %6d: %s" % [w, s, "ok" if bad == 0 and terrain.worldlet_id == w else "%d bad samples" % bad]) + if bad > 0 or terrain.worldlet_id != w: + 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) func _check(terrain: Node3D) -> int: @@ -42,7 +49,10 @@ func _check(terrain: Node3D) -> int: while x < half: var 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) # Hazard 1: a pit too deep to wade out of near the edge. var too_deep: bool = h < terrain.water_level - MAX_EDGE_DEPTH diff --git a/tests/test_water_bodies.gd b/tests/test_water_bodies.gd new file mode 100644 index 0000000..1ca56e0 --- /dev/null +++ b/tests/test_water_bodies.gd @@ -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 diff --git a/tests/test_water_bodies.gd.uid b/tests/test_water_bodies.gd.uid new file mode 100644 index 0000000..d08dbf9 --- /dev/null +++ b/tests/test_water_bodies.gd.uid @@ -0,0 +1 @@ +uid://da1vwsptqh68b diff --git a/tests/worldlets/testbay.json b/tests/worldlets/testbay.json new file mode 100644 index 0000000..bf2ead7 --- /dev/null +++ b/tests/worldlets/testbay.json @@ -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} +}