W0: terrain from worldlet building blocks (Valley in data/worldlets, identical heights), WorldInfo API, worldlet in saves and servers; the cliff tunnel plan

This commit is contained in:
Paul Bailey
2026-11-06 15:51:43 +00:00
parent f4df2bc99d
commit db4a659236
10 changed files with 329 additions and 19 deletions

View File

@@ -0,0 +1,17 @@
{
"_doc": "A worldlet (docs/WORLDLETS_DESIGN.md): its size, and its terrain as building blocks applied in order (scripts/world/terrain.gd: hills, rim, sea_cliff, lake, ocean_edge). The numbers here are the Valley exactly as it was hand-coded: tests/test_worldlets.gd checks it still makes the same height grid.",
"id": "valley",
"name": "Valley",
"description": "A sheltered valley ringed by mountains, with a lake in the middle and sea cliffs on one side.",
"size": 320,
"resolution": 256,
"terrain": [
{"type": "hills", "height_scale": 9.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": "sea_cliff", "angle_deg": -75.0, "half_width_deg": 38.0, "fade_deg": 22.0, "sea_level": -24.0, "cliff_top": 0.5, "cliff_offset": 4.0, "drop_width": 6.0},
{"type": "lake", "center": [14.0, -46.0], "radius": 24.0, "water_level": -2.6},
{"type": "ocean_edge"}
],
"climate": {"snowline": 40.0},
"next": [{"to": "cove", "by": "tunnel", "where": "sea_cliff"}]
}

View File

@@ -1,7 +1,23 @@
# Worldlets design # Worldlets design
Status: **proposal**. Nothing here is built yet, apart from the first piece Status: **W0 built**; the rest is planned. The journey between worldlets is
of groundwork (`world.water_at()` and `data/fish.json`, see §6). §10.
*As built in W0:*
- `data/worldlets/valley.json`: the Valley as data, its terrain as five
building blocks in order (`hills`, `rim`, `sea_cliff`, `lake`,
`ocean_edge`) with every number that used to be hard-coded.
- `terrain.gd` runs a worldlet's blocks (`use_worldlet()`); each block is
the old code's arithmetic, unchanged. `tests/test_worldlets.gd` checks
four seeds give exactly the same height grid as before (SHA-256 of the
grid, taken from the old code).
- The worldlet is chosen like the seed: a server's (it sends its real one),
else the save's `meta.json` "worldlet", else the Valley (worlds from
before W0). New worlds and servers record it.
- The WorldInfo API on the world: `worldlet_id()`, `water_bodies()`,
`climate()`, `crossings()` (the planned ways on), beside `water_at()`.
Systems still read the terrain's numbers directly; they move over to
these as each new worldlet needs them to differ (W1-W3).
## 1. What a worldlet is ## 1. What a worldlet is
A **worldlet** is a small, complete world type: a ~320 m playable area with A **worldlet** is a small, complete world type: a ~320 m playable area with
@@ -224,6 +240,41 @@ you've learned and gathered there:
- **Through:** the far end opens onto the next worldlet. Walking out loads - **Through:** the far end opens onto the next worldlet. Walking out loads
it (a short fade, "The Cove"), with your bag, cloak and stats as they are. it (a short fade, "The Cove"), with your bag, cloak and stats as they are.
### The first tunnel: down the sea cliff to the Cove
The Valley's sea side ends at a sheer cliff, its top about level with the
valley and the sea some 25 m below. The Cove is down there, so the first
tunnel goes **down**, inside the cliff, from the clifftop to the shore.
- **The seam:** a cracked outcrop on the clifftop strip near the edge (the
sea_cliff block marks where), with old pick marks and loose stones at its
foot. It can't be dug until you have the antler pickaxe (tier 3); a lesser
pick just chips it ("Too hard: you need a better pick").
- **Digging down:** the tunnel is about six stretches, each a few metres on
and a few down: a sloping passage that turns back on itself like a
stair, so the 25 m drop fits inside the cliff. A stretch takes many pick
blows (stamina, and wear on the pick) to open.
- **Props:** each new stretch needs a **timber prop** (2 wood) set at its
mouth before you can dig the next. Leave one out and the face won't take
another blow ("It's loose: prop it first"); nothing caves in on you, so
it's a cost, not a trap. That's about 12 wood all the way down.
- **Light:** the tunnel has no daylight at all. Every stretch is pitch dark
until you bring light: a torch in your hand works, but your hands are full
with the pick, so the way to do it is to set **candles and torch stands**
as you go (tallow candles from bear fat, torch stands from wood). Placed
lights stay, so the way is lit for the trip back and for friends.
- **The bottom:** the last stretch breaks out into a sea cave, its mouth
opening on the Cove's shingle beach. Walking out of the mouth is the
crossing: a fade, then the Cove, arriving at the cave's Cove end. The way
back starts there and climbs the same tunnel.
- **What's saved:** the tunnel belongs to the Valley's save: how far it's
dug, the props, the lights. On a server it's shared: anyone can dig on
from where someone stopped, and the lights are everyone's.
- **How it's built:** the tunnel is its own small space (a "between" scene
next to the worldlet), made from a kit of rock pieces (straight, turn,
slope down, the cave mouth) laid out from the seed, with box-shaped
colliders. The heightmap can't have holes or overhangs, so the passage is
never cut into the terrain itself: you walk into the seam and you're in
the tunnel scene.
### Why go back ### Why go back
- **Each worldlet has things the others lack.** Clay and salt only in the - **Each worldlet has things the others lack.** Clay and salt only in the
Valley and Cove; little wood in the Highlands; no bears outside the Valley and Cove; little wood in the Highlands; no bears outside the

View File

@@ -6,7 +6,7 @@ runnable=true
dedicated_server=false dedicated_server=false
custom_features="" custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="data/*.json" include_filter="data/*.json, data/worldlets/*.json"
exclude_filter="tests/*, docs/*, tools/*, server/*" exclude_filter="tests/*, docs/*, tools/*, server/*"
export_path="build/macos/Emberwild.zip" export_path="build/macos/Emberwild.zip"
patches=PackedStringArray() patches=PackedStringArray()
@@ -64,7 +64,7 @@ runnable=true
dedicated_server=false dedicated_server=false
custom_features="" custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="data/*.json" include_filter="data/*.json, data/worldlets/*.json"
exclude_filter="tests/*, docs/*, tools/*, server/*" exclude_filter="tests/*, docs/*, tools/*, server/*"
export_path="build/windows/Emberwild/Emberwild.exe" export_path="build/windows/Emberwild/Emberwild.exe"
patches=PackedStringArray() patches=PackedStringArray()

View File

@@ -56,7 +56,7 @@ func create_world(world_name: String, seed: int, options: Dictionary = {}) -> St
var now := Time.get_unix_time_from_system() var now := Time.get_unix_time_from_system()
var meta := { var meta := {
"name": world_name, "seed": seed, "created": now, "last_played": now, "name": world_name, "seed": seed, "created": now, "last_played": now,
"play_time": 0.0, "version": VERSION, "play_time": 0.0, "version": VERSION, "worldlet": "valley",
} }
for k in options: for k in options:
meta[k] = options[k] meta[k] = options[k]
@@ -65,7 +65,7 @@ func create_world(world_name: String, seed: int, options: Dictionary = {}) -> St
## The world slot called exactly `id` (a server's world), made with `seed` if ## The world slot called exactly `id` (a server's world), made with `seed` if
## it doesn't exist yet. An existing world keeps its own seed. ## it doesn't exist yet. An existing world keeps its own seed.
func open_or_create(id: String, seed: int) -> String: func open_or_create(id: String, seed: int, worldlet: String = "valley") -> String:
if not read_meta(id).is_empty(): if not read_meta(id).is_empty():
var had := int(read_meta(id).get("seed", seed)) var had := int(read_meta(id).get("seed", seed))
if had != seed: if had != seed:
@@ -75,7 +75,7 @@ func open_or_create(id: String, seed: int) -> String:
var now := Time.get_unix_time_from_system() var now := Time.get_unix_time_from_system()
_write_json(_dir(id).path_join("meta.json"), { _write_json(_dir(id).path_join("meta.json"), {
"name": id, "seed": seed, "created": now, "last_played": now, "name": id, "seed": seed, "created": now, "last_played": now,
"play_time": 0.0, "version": VERSION, "play_time": 0.0, "version": VERSION, "worldlet": worldlet,
}) })
return id return id

View File

@@ -638,7 +638,7 @@ func _c2s_proof(sig: PackedByteArray) -> void:
print("[server] %s joined (id %s%s, %d/%d online)" % [name, id, ", admin" if p.admin else "", print("[server] %s joined (id %s%s, %d/%d online)" % [name, id, ", admin" if p.admin else "",
players.size(), int(config.v("max_players"))]) players.size(), int(config.v("max_players"))])
# (The world's own seed: an existing world keeps the seed it was made with.) # (The world's own seed: an existing world keeps the seed it was made with.)
var world := {"seed": int(_terrain.noise_seed), "worldlet": String(config.v("worldlet")), "spawn": start, "yaw": yaw, var world := {"seed": int(_terrain.noise_seed), "worldlet": String(_terrain.worldlet_id), "spawn": start, "yaw": yaw,
"island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time, "island_spawn": _spawn, "day": _world.day_night.day, "time": _world.day_night.time, "server_time": server_time,
"weather": _world.weather.override} "weather": _world.weather.override}
_s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world}) _s2c_welcome.rpc_id(peer, {"you": peer, "server": config.public_info(), "players": players, "world": world})

View File

@@ -49,7 +49,7 @@ func open_server_world(config: RefCounted) -> Node3D:
var id := String(config.v("world")).validate_filename() var id := String(config.v("world")).validate_filename()
if id.is_empty(): if id.is_empty():
id = "server-world" id = "server-world"
Saves.current_id = Saves.open_or_create(id, int(config.v("seed"))) Saves.current_id = Saves.open_or_create(id, int(config.v("seed")), String(config.v("worldlet")))
backup("start-up") # (the state it's about to load, before anyone changes it) backup("start-up") # (the state it's about to load, before anyone changes it)
_backup_left = maxf(float(config.v("backup_hours")), 0.25) * 3600.0 _backup_left = maxf(float(config.v("backup_hours")), 0.25) * 3600.0
var w: Node3D = load("res://scenes/world.tscn").instantiate() var w: Node3D = load("res://scenes/world.tscn").instantiate()

View File

@@ -75,8 +75,15 @@ func _ready() -> void:
var saves := get_node_or_null("/root/Saves") var saves := get_node_or_null("/root/Saves")
if net != null and net.state == "connected" and net.world_info.has("seed"): if net != null and net.state == "connected" and net.world_info.has("seed"):
noise_seed = int(net.world_info.seed) noise_seed = int(net.world_info.seed)
worldlet_id = String(net.world_info.get("worldlet", worldlet_id))
elif saves != null and saves.has_world(): elif saves != null and saves.has_world():
noise_seed = saves.world_seed() noise_seed = saves.world_seed()
# (worlds from before worldlets have none: they're the Valley)
worldlet_id = String(saves.read_meta(saves.current_id).get("worldlet", "valley"))
var why := use_worldlet(worldlet_id)
if not why.is_empty():
push_error("[terrain] %s: using the Valley" % why)
use_worldlet("valley")
_build() _build()
# --- height function -------------------------------------------------------- # --- height function --------------------------------------------------------
@@ -135,14 +142,114 @@ func _sea_w(x: float, z: float) -> float:
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)) return lerpf(_wall_d(), _cliff_d() - 1.0, _sea_w(x, z))
# --- 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"]
var worldlet_id := "valley"
var worldlet: Dictionary = {} ## the definition, as loaded
var _blocks: Array = [] ## its terrain blocks, in order
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):
return {}
var d: Variant = JSON.parse_string(FileAccess.get_file_as_string(p))
return d if d is Dictionary else {}
## Every worldlet there is: [{id, name, description}].
static func worldlets() -> Array:
var out: Array = []
for f in DirAccess.get_files_at("res://data/worldlets"):
if f.ends_with(".json"):
var d := load_worldlet(f.get_basename())
if not d.is_empty():
out.append({"id": String(d.id), "name": String(d.get("name", d.id)), "description": String(d.get("description", ""))})
return out
## Takes on worldlet `id`: its size and every block's numbers. Returns ""
## on success, else what's wrong (an unknown worldlet or block keeps the old
## settings, so the game still has an island).
func use_worldlet(id: String) -> String:
var d := load_worldlet(id)
if d.is_empty():
return "no worldlet '%s'" % id
for b in d.get("terrain", []):
if not (b is Dictionary) or not String(b.get("type", "")) in BLOCKS:
return "worldlet '%s': unknown terrain block %s" % [id, str(b)]
worldlet_id = id
worldlet = d
size = int(d.get("size", size))
resolution = int(d.get("resolution", resolution))
_blocks = d.get("terrain", [])
for b in _blocks:
match String(b.type):
"hills":
height_scale = float(b.get("height_scale", height_scale))
_detail_k = float(b.get("detail", _detail_k))
"rim":
play_radius = float(b.get("play_radius", play_radius))
rim_width = float(b.get("rim_width", rim_width))
mountain_height = float(b.get("mountain_height", mountain_height))
rim_warp = float(b.get("rim_warp", rim_warp))
wall_frac = float(b.get("wall_frac", wall_frac))
"sea_cliff":
sea_angle_deg = float(b.get("angle_deg", sea_angle_deg))
sea_half_width_deg = float(b.get("half_width_deg", sea_half_width_deg))
sea_fade_deg = float(b.get("fade_deg", sea_fade_deg))
sea_level = float(b.get("sea_level", sea_level))
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))
"lake":
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))
snow_height = float(d.get("climate", {}).get("snowline", snow_height))
return ""
func _has_block(t: String) -> bool:
for b in _blocks:
if String(b.type) == t:
return true
return false
func _height_fn(x: float, z: float) -> float: func _height_fn(x: float, z: float) -> float:
_ensure_noise() _ensure_noise()
var valley := _noise.get_noise_2d(x, z) * height_scale if _blocks.is_empty():
valley += _detail.get_noise_2d(x, z) * 1.4 use_worldlet(worldlet_id)
var d := _rim_d(x, z) var land := 0.0
for b in _blocks:
match String(b.type):
"hills":
land = _blk_hills(x, z)
"rim":
land = _blk_rim(x, z, land)
"sea_cliff":
land = _blk_sea_cliff(x, z, land)
"lake":
land = _blk_lake(x, z, land)
"ocean_edge":
land = _blk_ocean_edge(x, z, land)
return land
# Mountain rim. ## Rolling hills: the ground everything else is built on.
var land := valley func _blk_hills(x: float, z: float) -> float:
var valley := _noise.get_noise_2d(x, z) * height_scale
valley += _detail.get_noise_2d(x, z) * _detail_k
_valley = valley
return valley
## The mountain rim: rises from play_radius, stepped into ledges.
func _blk_rim(x: float, z: float, land: float) -> float:
var valley := _valley
var d := _rim_d(x, z)
var t := smoothstep(play_radius, play_radius + rim_width, d) var t := smoothstep(play_radius, play_radius + rim_width, d)
if t > 0.0: if t > 0.0:
var ridge := 1.0 - absf(_ridge.get_noise_2d(x, z)) var ridge := 1.0 - absf(_ridge.get_noise_2d(x, z))
@@ -153,7 +260,12 @@ func _height_fn(x: float, z: float) -> float:
var stepped := floorf(m / band) * band + smoothstep(0.35, 0.65, fposmod(m, band) / band) * band var stepped := floorf(m / band) * band + smoothstep(0.35, 0.65, fposmod(m, band) / band) * band
m = lerpf(m, stepped, 0.45 * t) m = lerpf(m, stepped, 0.45 * t)
land = valley * (1.0 - t * 0.6) + m land = valley * (1.0 - t * 0.6) + m
return land
## The sea side of the rim: the valley eases to a cliff top, then drops sheer.
func _blk_sea_cliff(x: float, z: float, land: float) -> float:
var valley := _valley
var d := _rim_d(x, z)
# Sea side: the valley eases to a cliff top, then drops sheer to the seabed. # Sea side: the valley eases to a cliff top, then drops sheer to the seabed.
# The edge sits 1 m past the invisible wall at every angle, including the # The edge sits 1 m past the invisible wall at every angle, including the
# headlands where the wall moves outward. (With the edge fixed at _cliff_d(), # headlands where the wall moves outward. (With the edge fixed at _cliff_d(),
@@ -167,14 +279,17 @@ func _height_fn(x: float, z: float) -> float:
var seabed := sea_level - 7.0 - minf(maxf(d - edge, 0.0), 60.0) * 0.08 + _detail.get_noise_2d(x, z) * 2.0 var seabed := sea_level - 7.0 - minf(maxf(d - edge, 0.0), 60.0) * 0.08 + _detail.get_noise_2d(x, z) * 2.0
coast = lerpf(coast, seabed, smoothstep(edge, edge + cliff_drop_width, d)) coast = lerpf(coast, seabed, smoothstep(edge, edge + cliff_drop_width, d))
land = lerpf(land, coast, sw) land = lerpf(land, coast, sw)
return land
# Lake basin: blend the ground 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:
var ld := Vector2(x - lake_center.x, z - lake_center.y).length() var ld := Vector2(x - lake_center.x, z - lake_center.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
land = lerpf(land, water_level - 3.2, smoothstep(lake_radius, lake_radius * 0.35, ld)) return lerpf(land, water_level - 3.2, smoothstep(lake_radius, lake_radius * 0.35, ld))
# 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.
func _blk_ocean_edge(x: float, z: float, land: float) -> float:
var e := smoothstep(size * 0.5 - 28.0, size * 0.5 - 4.0, sqrt(x * x + z * z)) var e := smoothstep(size * 0.5 - 28.0, size * 0.5 - 4.0, sqrt(x * x + z * z))
return lerpf(land, sea_level - 10.0 + _detail.get_noise_2d(x, z) * 2.0, e) return lerpf(land, sea_level - 10.0 + _detail.get_noise_2d(x, z) * 2.0, e)

View File

@@ -895,6 +895,44 @@ func water_at(x: float, z: float) -> Dictionary:
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 {}
# --- the worldlet (docs/WORLDLETS_DESIGN.md: the WorldInfo API) --------------------
# What a system outside the terrain should ask about this place, rather than
# reading the terrain's numbers: which worldlet it is, its water, its climate,
# and its ways out. (W0 adds these; systems move over to them as each worldlet
# needs them to be different.)
## This worldlet's id ("valley") and its whole definition.
func worldlet_id() -> String:
return terrain.worldlet_id
func worldlet() -> Dictionary:
return terrain.worldlet
## Its bodies of water: [{"type", "surface", "center" (x, z; the sea's is a
## point on its coast), "radius" (a lake's; the sea's is 0)}].
func water_bodies() -> Array:
var out: Array = []
for b in terrain._blocks:
match String(b.type):
"lake":
out.append({"type": "lake", "surface": terrain.water_level,
"center": terrain.lake_center, "radius": terrain.lake_radius})
"sea_cliff":
out.append({"type": "sea", "surface": terrain.sea_level,
"center": terrain.coast_point(terrain.sea_angle_deg, 20.0), "radius": 0.0})
return out
## Its climate: {"snowline": m, ...} (more as worldlets differ).
func climate() -> Dictionary:
var c: Dictionary = terrain.worldlet.get("climate", {}).duplicate()
c["snowline"] = terrain.snow_height
return c
## Its ways on to other worldlets (the journey): [{"to", "by", "where"}].
## Planned in the definition; travel comes with J1.
func crossings() -> Array:
return terrain.worldlet.get("next", [])
# --- multiplayer ----------------------------------------------------------------- # --- multiplayer -----------------------------------------------------------------
## Joined a server: start where it says, follow its clock, show the others. ## Joined a server: start where it says, follow its clock, show the others.

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_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_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,

89
tests/test_worldlets.gd Normal file
View File

@@ -0,0 +1,89 @@
extends SceneTree
## W0 (docs/WORLDLETS_DESIGN.md): the terrain is built from a worldlet's
## blocks (data/worldlets/<id>.json), and the Valley made that way is the same
## island, height for height, as the hand-coded one it replaced (the hashes
## below were taken from the old code). Also: the world answers the WorldInfo
## questions, a new save remembers its worldlet, an old save without one is
## the Valley, and a bad worldlet falls back to the Valley.
## Run: godot --headless --path . --script res://tests/test_worldlets.gd
## SHA-256 of the height grid, from the terrain before W0 (commit f4df2bc).
const GRID_HASHES := {
1337: "c87679a4ed30bff0c4be1caacda735454ad23f690ada28e7179667a9a7d62aa7",
777: "2edef348df09bf1f84ed3f5b0921f28fe54b3c827a698fac0a7d50fcdb95a24e",
42: "801430858909739fc8cf46694eee35c0cffa86ff1a9ad1ed379f1dfdea4b133c",
9001: "7d509d2508395a0e27e93a3384ac1e63323d588ef3cd1262f1e3f0c1c3e3254b",
}
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_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 _grid_hash(t: Node) -> String:
var h := HashingContext.new()
h.start(HashingContext.HASH_SHA256)
h.update(t._heights.to_byte_array())
return h.finish().hex_encode()
func _body(S: Node) -> void:
var T: Script = load("res://scripts/world/terrain.gd")
print("the Valley, from data")
var list: Array = T.worldlets()
check(list.any(func(w): return w.id == "valley"), "the Valley is a worldlet (%s)" % [list.map(func(w): return w.id)])
var def: Dictionary = T.load_worldlet("valley")
check(def.get("terrain", []).size() == 5, "built from 5 blocks: hills, rim, sea cliff, lake, ocean edge")
for s in GRID_HASHES:
var t: Node3D = T.new()
t.noise_seed = s
root.add_child(t)
await process_frame
check(_grid_hash(t) == GRID_HASHES[s], "seed %d: the same island, height for height" % s)
t.queue_free()
await process_frame
var bad: Node3D = T.new()
check(not bad.use_worldlet("nowhere").is_empty() and bad.worldlet_id == "valley", "an unknown worldlet is refused (the Valley stays)")
bad.free()
print("in a world")
var id: String = S.create_world("W0", 1337, {})
check(String(S.read_meta(id).get("worldlet", "")) == "valley", "a new world remembers its worldlet")
var meta: Dictionary = S.read_meta(id)
meta.erase("worldlet")
S._write_json(S._dir(id).path_join("meta.json"), meta)
S.current_id = id
var world: Node3D = load("res://scenes/world.tscn").instantiate()
root.add_child(world)
for _i in range(5):
await physics_frame
check(world.worldlet_id() == "valley", "an old world with no worldlet is the Valley")
var water: Array = world.water_bodies()
check(water.size() == 2 and water.any(func(w): return w.type == "lake") and water.any(func(w): return w.type == "sea"),
"its water: a lake and the sea")
var lake: Dictionary = water.filter(func(w): return w.type == "lake")[0]
var lp: Vector2 = lake.center
check(world.water_at(lp.x, lp.y).get("type", "") == "lake", "(and the lake is where it says)")
check(absf(float(world.climate().snowline) - 40.0) < 0.01, "its snowline: 40 m")
check(world.crossings().any(func(c): return c.to == "cove" and c.by == "tunnel"), "its way on: a tunnel to the Cove (planned)")
world.queue_free()
await process_frame
_finished = true