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:
17
data/worldlets/valley.json
Normal file
17
data/worldlets/valley.json
Normal 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"}]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
89
tests/test_worldlets.gd
Normal 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
|
||||||
Reference in New Issue
Block a user