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
Status: **proposal**. Nothing here is built yet, apart from the first piece
of groundwork (`world.water_at()` and `data/fish.json`, see §6).
Status: **W0 built**; the rest is planned. The journey between worldlets is
§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
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
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
- **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

View File

@@ -6,7 +6,7 @@ runnable=true
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter="data/*.json"
include_filter="data/*.json, data/worldlets/*.json"
exclude_filter="tests/*, docs/*, tools/*, server/*"
export_path="build/macos/Emberwild.zip"
patches=PackedStringArray()
@@ -64,7 +64,7 @@ runnable=true
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter="data/*.json"
include_filter="data/*.json, data/worldlets/*.json"
exclude_filter="tests/*, docs/*, tools/*, server/*"
export_path="build/windows/Emberwild/Emberwild.exe"
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 meta := {
"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:
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
## 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():
var had := int(read_meta(id).get("seed", 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()
_write_json(_dir(id).path_join("meta.json"), {
"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

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 "",
players.size(), int(config.v("max_players"))])
# (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,
"weather": _world.weather.override}
_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()
if id.is_empty():
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_left = maxf(float(config.v("backup_hours")), 0.25) * 3600.0
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")
if net != null and net.state == "connected" and net.world_info.has("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():
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()
# --- height function --------------------------------------------------------
@@ -135,14 +142,114 @@ func _sea_w(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))
# --- 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:
_ensure_noise()
var valley := _noise.get_noise_2d(x, z) * height_scale
valley += _detail.get_noise_2d(x, z) * 1.4
var d := _rim_d(x, z)
if _blocks.is_empty():
use_worldlet(worldlet_id)
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.
var land := valley
## Rolling hills: the ground everything else is built on.
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)
if t > 0.0:
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
m = lerpf(m, stepped, 0.45 * t)
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.
# 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(),
@@ -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
coast = lerpf(coast, seabed, smoothstep(edge, edge + cliff_drop_width, d))
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()
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
# the mesh is always hidden under deep water.
## Ocean all round the chunk edge, so the world is an island and the edge of
## 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))
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 {}
# --- 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 -----------------------------------------------------------------
## 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)..."
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_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=""
[ "$t" = "test_boundary" ] && extra="--fixed-fps 30"
# 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