Compare commits
4 Commits
b86fb879d2
...
v0.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
109fff22d0 | ||
|
|
b938e045cb | ||
|
|
ad47cd53da | ||
|
|
0209c6149a |
@@ -16,7 +16,7 @@ addon SDK with guard rails.
|
|||||||
## Status
|
## Status
|
||||||
See `docs/ROADMAP.md` for milestones and `docs/ARCHITECTURE.md` for the design.
|
See `docs/ROADMAP.md` for milestones and `docs/ARCHITECTURE.md` for the design.
|
||||||
|
|
||||||
**Emberwild 0.4.0** is released (download page and player guide:
|
**Emberwild 0.4.1** is released (download page and player guide:
|
||||||
https://emberwild-95k.pages.dev, builds on the Gitea releases). Built so far:
|
https://emberwild-95k.pages.dev, builds on the Gitea releases). Built so far:
|
||||||
- M0-M1: the island, survival, crafting, building, weather, wildlife, hunting, fishing.
|
- M0-M1: the island, survival, crafting, building, weather, wildlife, hunting, fishing.
|
||||||
- M2: self-hosted multiplayer (Host Game, dedicated servers, rules, chat, admin).
|
- M2: self-hosted multiplayer (Host Game, dedicated servers, rules, chat, admin).
|
||||||
|
|||||||
@@ -44,6 +44,20 @@
|
|||||||
"min_depth": 2.2
|
"min_depth": 2.2
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"stream": {
|
||||||
|
"min_depth": 0.3,
|
||||||
|
"bite_wait": [
|
||||||
|
8.0,
|
||||||
|
18.0
|
||||||
|
],
|
||||||
|
"fish": [
|
||||||
|
{
|
||||||
|
"id": "trout",
|
||||||
|
"weight": 100,
|
||||||
|
"dawn_dusk": 100
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"_doc": "The Cove (docs/WORLDLETS_DESIGN.md §5, W3): the second worldlet, reached from the Valley down the cliff tunnel (J1). Low hills run down to a wide sandy bay with a pier (scripts/world/pier.gd) for sea fishing; a small pond inland; mountains behind. Not a start: you get here on the journey.",
|
"_doc": "The Cove (docs/WORLDLETS_DESIGN.md §5, W3): the second worldlet, reached from the Valley down the cliff tunnel (J1). Low hills run down to a wide sandy bay with a pier (scripts/world/pier.gd) for sea fishing; a small pond inland; a stream from a spring under the mountains down to the bay (a 'stream' block: its points, spring first, in world metres); mountains behind. Not a start: you get here on the journey.",
|
||||||
"id": "cove",
|
"id": "cove",
|
||||||
"name": "Cove",
|
"name": "Cove",
|
||||||
"description": "Low hills running down to a sheltered sandy bay, with a pier out over the water for sea fishing.",
|
"description": "Low hills running down to a sheltered sandy bay, with a pier out over the water for sea fishing.",
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
{"type": "rim", "play_radius": 100.0, "rim_width": 30.0, "mountain_height": 52.0, "rim_warp": 14.0, "wall_frac": 0.45},
|
{"type": "rim", "play_radius": 100.0, "rim_width": 30.0, "mountain_height": 52.0, "rim_warp": 14.0, "wall_frac": 0.45},
|
||||||
{"type": "beach", "angle_deg": 90.0, "half_width_deg": 55.0, "fade_deg": 22.0, "shore_offset": -16.0, "slope": 0.08, "current_out": 22.0, "wall_out": 36.0},
|
{"type": "beach", "angle_deg": 90.0, "half_width_deg": 55.0, "fade_deg": 22.0, "shore_offset": -16.0, "slope": 0.08, "current_out": 22.0, "wall_out": 36.0},
|
||||||
{"type": "lake", "center": [-30.0, -40.0], "radius": 11.0, "water_level": -2.6},
|
{"type": "lake", "center": [-30.0, -40.0], "radius": 11.0, "water_level": -2.6},
|
||||||
|
{"type": "stream", "points": [[46.0, -80.0], [34.0, -48.0], [40.0, -14.0], [30.0, 22.0], [36.0, 52.0], [33.0, 78.0], [33.0, 104.0]], "width": 2.4, "depth": 0.6},
|
||||||
{"type": "ocean_edge"}
|
{"type": "ocean_edge"}
|
||||||
],
|
],
|
||||||
"climate": {"snowline": 42.0},
|
"climate": {"snowline": 42.0},
|
||||||
|
|||||||
@@ -52,9 +52,14 @@ its decisions.
|
|||||||
- Tests: `test_cove` (walk onto the pier, catch a mackerel off its end, cod
|
- Tests: `test_cove` (walk onto the pier, catch a mackerel off its end, cod
|
||||||
only in deep water), and the Cove in `test_terrain_edges` and
|
only in deep water), and the Cove in `test_terrain_edges` and
|
||||||
`test_boundary`.
|
`test_boundary`.
|
||||||
- Still to do: the stream (it needs water that runs downhill, which the
|
- The stream (after 0.4.0): a `stream` block, a line of points from a
|
||||||
one flat water surface can't do: its own ribbon of water, planned with
|
spring under the mountains to the bay. Its water surface is the running
|
||||||
the Cove's sea cave when the tunnel arrives).
|
minimum of the ground along it (so it only ever runs downhill, down to
|
||||||
|
sea level), the bed is cut 0.6 m under that and the banks rise a little
|
||||||
|
above it; its water is its own ribbon mesh (`terrain._stream_mesh()`),
|
||||||
|
murkier than the lake. `water_at()` answers "stream" (trout bite there,
|
||||||
|
`data/fish.json`), the map shows it, and scenery, the den, the barrow and
|
||||||
|
the stones keep clear of it (`terrain.near_stream()`).
|
||||||
|
|
||||||
*As built in W2:*
|
*As built in W2:*
|
||||||
- `lake` blocks can repeat: every lake and pond is its own body
|
- `lake` blocks can repeat: every lake and pond is its own body
|
||||||
@@ -358,8 +363,10 @@ tunnel goes **down**, inside the cliff, from the clifftop to the shore.
|
|||||||
meat, clay jars) becomes what you carry between them.
|
meat, clay jars) becomes what you carry between them.
|
||||||
- **Your bases stay** where you built them: each worldlet keeps its own
|
- **Your bases stay** where you built them: each worldlet keeps its own
|
||||||
save file, so your Valley hut, chests and jars are waiting.
|
save file, so your Valley hut, chests and jars are waiting.
|
||||||
- **Time:** a worldlet you're not in stands still (as a server does with
|
- **Time:** there's one clock for every land (see the decisions below). A
|
||||||
nobody on), so a base you left doesn't starve or get raided.
|
worldlet you're not in doesn't run, but catches up on the time when you
|
||||||
|
come back, as after a night's sleep; nothing roams it meanwhile, so a base
|
||||||
|
you left isn't raided.
|
||||||
|
|
||||||
### Building it (phases)
|
### Building it (phases)
|
||||||
| Phase | Delivers | Done when |
|
| Phase | Delivers | Done when |
|
||||||
@@ -390,17 +397,45 @@ A server today runs one island. Options:
|
|||||||
bites can make you sick, and bog, mist and the like. Others in the same
|
bites can make you sick, and bog, mist and the like. Others in the same
|
||||||
spirit per biome (to design with each worldlet).
|
spirit per biome (to design with each worldlet).
|
||||||
- **Multiplayer: spread out.** Each player goes where they like; the server
|
- **Multiplayer: spread out.** Each player goes where they like; the server
|
||||||
runs every worldlet someone is in, and the rest stand still. Built in J3,
|
runs every worldlet someone is in. Built in J3, after single-player travel
|
||||||
after single-player travel works. What that means for the code:
|
works.
|
||||||
- one server process holds several worldlet scenes, each with its own
|
- **A server per land (Paul, Nov 2026)**, rather than one process holding
|
||||||
clock, weather, creatures and save file;
|
several worldlets (which would mean making every network module, the
|
||||||
- every player belongs to one worldlet at a time, and the server only
|
saves and the actions handle several worlds at once):
|
||||||
sends them that worldlet's world, creatures, sounds and chat-free events
|
- the Valley's server is the **hub**: the address everyone joins. It
|
||||||
(chat and the player list stay server-wide);
|
starts a server for another land (the next UDP port: 24243 the Cove,
|
||||||
- a crossing moves a player from one worldlet to another (save their
|
and so on) when someone first goes there, and stops it a couple of
|
||||||
place, load or wake the other worldlet, send them its world);
|
minutes after its last player leaves;
|
||||||
- sleep: "everyone in bed" counts the players in that worldlet only;
|
- each land server is today's server, with its own worldlet, save file,
|
||||||
- the Old Stag and other bosses scale with the players in their worldlet.
|
weather and creatures; it keeps a private link to the hub on the host
|
||||||
|
machine;
|
||||||
|
- characters live in one shared folder. A crossing saves your character
|
||||||
|
first, marks you as on your way, and hands your game the land's port
|
||||||
|
and a one-time pass; your game reconnects there ("On to the Cove...").
|
||||||
|
Nothing can be lost or copied on the way: crash mid-way and you come
|
||||||
|
back where you were going. Joining the hub when your character's in
|
||||||
|
another land sends you straight on there;
|
||||||
|
- chat, the player list (with where each player is) and admin commands
|
||||||
|
go through the hub to every land;
|
||||||
|
- the tunnel belongs to the Valley's server; the Cove's asks the hub
|
||||||
|
whether it's dug through;
|
||||||
|
- from the internet, friends need those extra ports forwarded too (on a
|
||||||
|
home network, nothing to do). The Host page will list them;
|
||||||
|
- the Old Stag and other bosses scale with the players in their land.
|
||||||
|
- Phases: **J3a** the tunnel online, land servers on demand, the
|
||||||
|
crossing and the shared characters; **J3b** chat, the player list and
|
||||||
|
admin across lands; **J3c** the awkward cases (a land server crashing,
|
||||||
|
rejoining mid-crossing, the Host page's ports).
|
||||||
|
- **One clock for every land (Paul, Nov 2026).** The day and hour are the
|
||||||
|
same everywhere, so the lands never drift apart:
|
||||||
|
- sleeping until morning needs **everyone, in every land**, in bed (on a
|
||||||
|
server the hub counts them all, and every land skips to morning
|
||||||
|
together);
|
||||||
|
- a land nobody is in doesn't run, but when someone arrives it catches up
|
||||||
|
on the time since anyone was last there, as after a night's sleep:
|
||||||
|
kilns and smelters work, fires burn down, food goes off, trees and
|
||||||
|
berries grow back. (Built for single-player already: `world.catch_up()`.)
|
||||||
|
No creatures roam it meanwhile, so a base you left isn't raided.
|
||||||
- **Boats: one each, no passengers.** A boat belongs to whoever built it
|
- **Boats: one each, no passengers.** A boat belongs to whoever built it
|
||||||
(like a bed): only the owner, and their friends under the server's locks
|
(like a bed): only the owner, and their friends under the server's locks
|
||||||
rule, can sail it, and nobody else can dismantle it. It stays moored where
|
rule, can sail it, and nobody else can dismantle it. It stays moored where
|
||||||
@@ -434,7 +469,7 @@ One worldlet, a dedicated server on the M5 Pro, never pausing: about
|
|||||||
creatures, weather and pickups around them. So with *spread out*, the worst
|
creatures, weather and pickups around them. So with *spread out*, the worst
|
||||||
case of 8 players in all 5 worldlets is about five of today's servers in
|
case of 8 players in all 5 worldlets is about five of today's servers in
|
||||||
one process: roughly 1.4 GB and a little over half a core before players,
|
one process: roughly 1.4 GB and a little over half a core before players,
|
||||||
and worldlets with nobody in them stand still and cost next to nothing.
|
and worldlets with nobody in them aren't running and cost nothing (each starts its own server when someone goes there).
|
||||||
Comfortable on the home Mac; a small cloud server (2 GB) would be tight.
|
Comfortable on the home Mac; a small cloud server (2 GB) would be tight.
|
||||||
|
|
||||||
### (Answered) questions
|
### (Answered) questions
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ application/icon_interpolation=4
|
|||||||
application/bundle_identifier="com.pbailey.emberwild"
|
application/bundle_identifier="com.pbailey.emberwild"
|
||||||
application/signature=""
|
application/signature=""
|
||||||
application/app_category="Games"
|
application/app_category="Games"
|
||||||
application/short_version="0.4.0"
|
application/short_version="0.4.1"
|
||||||
application/version="0.4.0"
|
application/version="0.4.1"
|
||||||
application/copyright=""
|
application/copyright=""
|
||||||
display/high_res=true
|
display/high_res=true
|
||||||
codesign/codesign=1
|
codesign/codesign=1
|
||||||
@@ -89,8 +89,8 @@ application/modify_resources=false
|
|||||||
application/icon=""
|
application/icon=""
|
||||||
application/console_wrapper_icon=""
|
application/console_wrapper_icon=""
|
||||||
application/icon_interpolation=4
|
application/icon_interpolation=4
|
||||||
application/file_version="0.4.0"
|
application/file_version="0.4.1"
|
||||||
application/product_version="0.4.0"
|
application/product_version="0.4.1"
|
||||||
application/company_name=""
|
application/company_name=""
|
||||||
application/product_name="Emberwild"
|
application/product_name="Emberwild"
|
||||||
application/file_description="Emberwild"
|
application/file_description="Emberwild"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ config/description="Low-poly survival game — Valheim look, Rust depth, self-ho
|
|||||||
run/main_scene="res://scenes/title.tscn"
|
run/main_scene="res://scenes/title.tscn"
|
||||||
config/features=PackedStringArray("4.7", "Forward Plus")
|
config/features=PackedStringArray("4.7", "Forward Plus")
|
||||||
config/icon="res://icon.svg"
|
config/icon="res://icon.svg"
|
||||||
config/version="0.4.0"
|
config/version="0.4.1"
|
||||||
|
|
||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ func _check_site(world: Node3D, p: Vector2) -> Vector3:
|
|||||||
var hs: Array[float] = []
|
var hs: Array[float] = []
|
||||||
for d in [Vector2(-1, -1), Vector2(1, -1), Vector2(-1, 1), Vector2(1, 1), Vector2.ZERO]:
|
for d in [Vector2(-1, -1), Vector2(1, -1), Vector2(-1, 1), Vector2(1, 1), Vector2.ZERO]:
|
||||||
hs.append(terrain.sample_height(p.x + d.x * 4.0, p.y + d.y * 4.0))
|
hs.append(terrain.sample_height(p.x + d.x * 4.0, p.y + d.y * 4.0))
|
||||||
if hs.max() - hs.min() > 2.2 or hs.min() < terrain.water_level + 1.0:
|
if hs.max() - hs.min() > 2.2 or hs.min() < terrain.water_level + 1.0 or terrain.near_stream(p.x, p.y, 7.0):
|
||||||
return Vector3.INF
|
return Vector3.INF
|
||||||
# Trees and boulders in the way are cleared (_clear_area); just avoid
|
# Trees and boulders in the way are cleared (_clear_area); just avoid
|
||||||
# the thickest woods. Counted by position, because their colliders
|
# the thickest woods. Counted by position, because their colliders
|
||||||
|
|||||||
@@ -163,6 +163,8 @@ static func render(w: Node3D) -> Image:
|
|||||||
|
|
||||||
static func ground_colour(t: Node3D, x: float, z: float) -> Color:
|
static func ground_colour(t: Node3D, x: float, z: float) -> Color:
|
||||||
var h: float = t.sample_height(x, z)
|
var h: float = t.sample_height(x, z)
|
||||||
|
if t.near_stream(x, z, 0.3):
|
||||||
|
return Color(0.32, 0.56, 0.66) # (the stream, a little wider than it is so it shows)
|
||||||
var surface: float = t.water_level if t.inside_boundary(x, z, -2.0) else t.sea_level
|
var surface: float = t.water_level if t.inside_boundary(x, z, -2.0) else t.sea_level
|
||||||
if h < surface:
|
if h < surface:
|
||||||
var deep := clampf((surface - h) / 6.0, 0.0, 1.0)
|
var deep := clampf((surface - h) / 6.0, 0.0, 1.0)
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ func _search(max_spread: float, from_spawn: float, from_den: float) -> Vector3:
|
|||||||
hs.append(terrain.sample_height(c.x + cos(b) * rad, c.y + sin(b) * rad))
|
hs.append(terrain.sample_height(c.x + cos(b) * rad, c.y + sin(b) * rad))
|
||||||
var h0: float = terrain.sample_height(c.x, c.y)
|
var h0: float = terrain.sample_height(c.x, c.y)
|
||||||
hs.append(h0)
|
hs.append(h0)
|
||||||
if hs.min() < terrain.water_level + 1.0:
|
if hs.min() < terrain.water_level + 1.0 or terrain.near_stream(c.x, c.y, MOUND_R + 4.0):
|
||||||
continue
|
continue
|
||||||
var spread: float = hs.max() - hs.min()
|
var spread: float = hs.max() - hs.min()
|
||||||
if spread > max_spread:
|
if spread > max_spread:
|
||||||
|
|||||||
@@ -423,6 +423,8 @@ func _scatter_trees() -> void:
|
|||||||
func _on_clifftop(x: float, z: float) -> bool:
|
func _on_clifftop(x: float, z: float) -> bool:
|
||||||
if pier != null and pier.covers(x, z, 2.0):
|
if pier != null and pier.covers(x, z, 2.0):
|
||||||
return true
|
return true
|
||||||
|
if terrain.near_stream(x, z, 1.2):
|
||||||
|
return true # (the stream's bed and banks)
|
||||||
for c in keep_clear:
|
for c in keep_clear:
|
||||||
if Vector2(x - c[0].x, z - c[0].z).length() < float(c[1]):
|
if Vector2(x - c[0].x, z - c[0].z).length() < float(c[1]):
|
||||||
return true
|
return true
|
||||||
@@ -615,7 +617,7 @@ func _place_logs() -> void:
|
|||||||
continue
|
continue
|
||||||
var h: float = terrain.sample_height(x, z)
|
var h: float = terrain.sample_height(x, z)
|
||||||
var n: Vector3 = terrain.sample_normal(x, z)
|
var n: Vector3 = terrain.sample_normal(x, z)
|
||||||
if h < terrain.water_level + 0.6 or n.y < 0.97 or Vector2(x - _spawn.x, z - _spawn.z).length() < 8.0:
|
if h < terrain.water_level + 0.6 or n.y < 0.97 or Vector2(x - _spawn.x, z - _spawn.z).length() < 8.0 or terrain.near_stream(x, z, 1.5):
|
||||||
continue
|
continue
|
||||||
var mi := MeshInstance3D.new()
|
var mi := MeshInstance3D.new()
|
||||||
var shape: Shape3D
|
var shape: Shape3D
|
||||||
@@ -680,7 +682,7 @@ func _scatter_pickups() -> void:
|
|||||||
continue
|
continue
|
||||||
var h: float = terrain.sample_height(x, z)
|
var h: float = terrain.sample_height(x, z)
|
||||||
var n: Vector3 = terrain.sample_normal(x, z)
|
var n: Vector3 = terrain.sample_normal(x, z)
|
||||||
if h < terrain.water_level + 0.3 or n.y < 0.8 or (pier != null and pier.covers(x, z, 1.0)):
|
if h < terrain.water_level + 0.3 or n.y < 0.8 or (pier != null and pier.covers(x, z, 1.0)) or terrain.near_stream(x, z, 0.3):
|
||||||
continue
|
continue
|
||||||
var chance := 1.0
|
var chance := 1.0
|
||||||
match id:
|
match id:
|
||||||
@@ -742,7 +744,7 @@ func _scatter_berry_bushes(root: Node3D) -> void:
|
|||||||
continue
|
continue
|
||||||
var h: float = terrain.sample_height(x, z)
|
var h: float = terrain.sample_height(x, z)
|
||||||
var n: Vector3 = terrain.sample_normal(x, z)
|
var n: Vector3 = terrain.sample_normal(x, z)
|
||||||
if h < terrain.water_level + 0.6 or n.y < 0.85:
|
if h < terrain.water_level + 0.6 or n.y < 0.85 or terrain.near_stream(x, z, 1.0):
|
||||||
continue
|
continue
|
||||||
var f := _forest(x, z)
|
var f := _forest(x, z)
|
||||||
var chance := 0.12 + (1.0 - absf(f - 0.45) * 2.0) * 0.5
|
var chance := 0.12 + (1.0 - absf(f - 0.45) * 2.0) * 0.5
|
||||||
@@ -824,7 +826,7 @@ func _make_grass_tile(k: Vector2i) -> Node3D:
|
|||||||
if not terrain.in_bounds(x, z):
|
if not terrain.in_bounds(x, z):
|
||||||
continue
|
continue
|
||||||
var h: float = terrain.sample_height(x, z)
|
var h: float = terrain.sample_height(x, z)
|
||||||
if h < terrain.water_level + 0.25 or h > terrain.snow_height - 6.0:
|
if h < terrain.water_level + 0.25 or h > terrain.snow_height - 6.0 or terrain.near_stream(x, z, -0.2):
|
||||||
continue
|
continue
|
||||||
var n: Vector3 = terrain.sample_normal(x, z)
|
var n: Vector3 = terrain.sample_normal(x, z)
|
||||||
if n.y < 0.72:
|
if n.y < 0.72:
|
||||||
|
|||||||
39
scripts/world/rock_pile.gd
Normal file
39
scripts/world/rock_pile.gd
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
extends RefCounted
|
||||||
|
## Boulders for hand-placed rockwork (the cliff tunnel's seam and its faces,
|
||||||
|
## the Cove's sea cave): the same low-poly rocks as the scenery and the wolf
|
||||||
|
## den (ProcMesh.rock, rock.gdshader: moss on top, snow up high), each with a
|
||||||
|
## convex collider that matches it.
|
||||||
|
|
||||||
|
const ProcMesh = preload("res://scripts/world/proc_mesh.gd")
|
||||||
|
|
||||||
|
## The scenery's rock material, for a world's terrain.
|
||||||
|
static func material(terrain: Node3D) -> ShaderMaterial:
|
||||||
|
var m := ShaderMaterial.new()
|
||||||
|
m.shader = preload("res://shaders/rock.gdshader")
|
||||||
|
m.set_shader_parameter("noise_tex", terrain.noise_tex)
|
||||||
|
m.set_shader_parameter("snow_height", terrain.snow_height)
|
||||||
|
return m
|
||||||
|
|
||||||
|
## One boulder under `parent` at local `at`, `radii` across (a rock mesh is
|
||||||
|
## about 1 m round before scaling), turned `turn` radians. Its collider goes
|
||||||
|
## on `body` (which must share `parent`'s transform), unless `body` is null.
|
||||||
|
static func add(parent: Node3D, body: CollisionObject3D, seed: int, at: Vector3, radii: Vector3, turn: float,
|
||||||
|
mat: Material, tall: bool = false) -> MeshInstance3D:
|
||||||
|
var mesh := ProcMesh.rock(seed, tall)
|
||||||
|
var b := Basis(Vector3.UP, turn).scaled(radii)
|
||||||
|
var mi := MeshInstance3D.new()
|
||||||
|
mi.mesh = mesh
|
||||||
|
mi.material_override = mat
|
||||||
|
mi.transform = Transform3D(b, at)
|
||||||
|
parent.add_child(mi)
|
||||||
|
if body != null:
|
||||||
|
var hull := mesh.create_convex_shape(true, true) as ConvexPolygonShape3D
|
||||||
|
var pts := PackedVector3Array()
|
||||||
|
for p: Vector3 in hull.points:
|
||||||
|
pts.append(b * p)
|
||||||
|
hull.points = pts
|
||||||
|
var cs := CollisionShape3D.new()
|
||||||
|
cs.shape = hull
|
||||||
|
cs.position = at
|
||||||
|
body.add_child(cs)
|
||||||
|
return mi
|
||||||
1
scripts/world/rock_pile.gd.uid
Normal file
1
scripts/world/rock_pile.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://ijajgdb0unan
|
||||||
@@ -8,6 +8,8 @@ extends Node3D
|
|||||||
## Placed from the terrain alone, like the pier, so it's the same for
|
## Placed from the terrain alone, like the pier, so it's the same for
|
||||||
## everyone; nothing about it is saved here (the tunnel is the Valley's).
|
## everyone; nothing about it is saved here (the tunnel is the Valley's).
|
||||||
|
|
||||||
|
const RockPile = preload("res://scripts/world/rock_pile.gd")
|
||||||
|
|
||||||
var world: Node3D
|
var world: Node3D
|
||||||
var to := "valley"
|
var to := "valley"
|
||||||
var front := Vector3.ZERO ## where you stand in front of it (arriving from the tunnel)
|
var front := Vector3.ZERO ## where you stand in front of it (arriving from the tunnel)
|
||||||
@@ -66,44 +68,36 @@ func interact(_player: Node) -> void:
|
|||||||
Actions.notify.emit(interact_prompt(null))
|
Actions.notify.emit(interact_prompt(null))
|
||||||
|
|
||||||
func _build(w: Node3D) -> void:
|
func _build(w: Node3D) -> void:
|
||||||
var rock := StandardMaterial3D.new()
|
var rock: ShaderMaterial = RockPile.material(w.terrain)
|
||||||
rock.albedo_color = Color(0.42, 0.40, 0.38)
|
|
||||||
rock.roughness = 1.0
|
|
||||||
if w.terrain.noise_tex != null:
|
|
||||||
rock.albedo_texture = w.terrain.noise_tex
|
|
||||||
rock.uv1_triplanar = true
|
|
||||||
rock.uv1_scale = Vector3(0.18, 0.18, 0.18)
|
|
||||||
var body := StaticBody3D.new()
|
var body := StaticBody3D.new()
|
||||||
add_child(body)
|
add_child(body)
|
||||||
# (local -Z faces the beach; the rocks are behind, +Z)
|
# A heap of big boulders at the foot of the headland, with a dark mouth
|
||||||
for part in [[Vector3(-2.0, 1.6, 1.0), Vector3(1.8, 3.6, 3.0)], [Vector3(2.0, 1.4, 1.0), Vector3(1.8, 3.2, 3.0)],
|
# between them (local -Z faces the beach; the heap is behind, +Z), and
|
||||||
[Vector3(0, 3.5, 1.0), Vector3(5.8, 1.4, 3.2)], [Vector3(0, 1.4, 2.4), Vector3(2.4, 3.0, 0.6)],
|
# seaweed-dark stones scattered on the sand in front.
|
||||||
[Vector3(-3.4, 1.0, 2.0), Vector3(2.0, 2.4, 3.0)], [Vector3(3.2, 0.8, 2.2), Vector3(1.8, 2.0, 2.6)]]:
|
var s: int = w.terrain.noise_seed + 5100
|
||||||
var mi := MeshInstance3D.new()
|
for part in [[Vector3(-2.4, 1.3, 1.0), Vector3(1.3, 2.1, 1.7), 0.5, true],
|
||||||
var bm := BoxMesh.new()
|
[Vector3(2.4, 1.15, 1.0), Vector3(1.25, 1.9, 1.6), 2.0, true],
|
||||||
bm.size = part[1]
|
[Vector3(0.0, 3.75, 1.2), Vector3(2.9, 1.0, 1.9), 0.3, false],
|
||||||
mi.mesh = bm
|
[Vector3(0.0, 1.8, 3.2), Vector3(2.4, 2.4, 1.4), 1.1, false],
|
||||||
mi.material_override = rock
|
[Vector3(-4.3, 0.8, 2.0), Vector3(1.8, 1.5, 1.8), 2.6, false],
|
||||||
mi.position = part[0]
|
[Vector3(4.1, 0.6, 2.2), Vector3(1.6, 1.3, 1.7), 0.7, false],
|
||||||
mi.rotation.y = part[0].x * 0.06
|
[Vector3(-2.0, 4.4, 2.8), Vector3(1.8, 1.2, 1.6), 1.7, false],
|
||||||
add_child(mi)
|
[Vector3(2.3, 4.1, 3.0), Vector3(1.6, 1.1, 1.5), 2.9, false]]:
|
||||||
var cs := CollisionShape3D.new()
|
RockPile.add(self, body, s, part[0], part[1], part[2], rock, part[3])
|
||||||
var box := BoxShape3D.new()
|
s += 1
|
||||||
box.size = part[1]
|
for stone in [[Vector3(-1.4, 0.08, -1.6), 0.4], [Vector3(1.6, 0.06, -2.2), 0.3],
|
||||||
cs.shape = box
|
[Vector3(-3.2, 0.06, -1.0), 0.35], [Vector3(0.4, 0.05, -2.9), 0.22], [Vector3(3.4, 0.05, -1.2), 0.28]]:
|
||||||
cs.position = part[0]
|
RockPile.add(self, null, s, stone[0], Vector3.ONE * float(stone[1]), float(s), rock)
|
||||||
cs.rotation.y = part[0].x * 0.06
|
s += 1
|
||||||
body.add_child(cs)
|
|
||||||
var dark := MeshInstance3D.new()
|
var dark := MeshInstance3D.new()
|
||||||
var q := QuadMesh.new()
|
var bm := BoxMesh.new()
|
||||||
q.size = Vector2(2.2, 2.8)
|
bm.size = Vector3(2.0, 2.6, 0.1)
|
||||||
dark.mesh = q
|
dark.mesh = bm
|
||||||
var m := StandardMaterial3D.new()
|
var m := StandardMaterial3D.new()
|
||||||
m.albedo_color = Color(0.02, 0.02, 0.025)
|
m.albedo_color = Color(0.015, 0.014, 0.018)
|
||||||
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
dark.material_override = m
|
dark.material_override = m
|
||||||
dark.position = Vector3(0, 1.4, 2.08)
|
dark.position = Vector3(0, 1.3, 1.6)
|
||||||
dark.rotation.y = PI
|
|
||||||
add_child(dark)
|
add_child(dark)
|
||||||
# What you press E on: the opening (interactables layer: you walk into it).
|
# What you press E on: the opening (interactables layer: you walk into it).
|
||||||
var spot := StaticBody3D.new()
|
var spot := StaticBody3D.new()
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ func _find_site() -> Vector3:
|
|||||||
hs.append(terrain.sample_height(c.x + cos(b) * RING_R, c.y + sin(b) * RING_R))
|
hs.append(terrain.sample_height(c.x + cos(b) * RING_R, c.y + sin(b) * RING_R))
|
||||||
var h0: float = terrain.sample_height(c.x, c.y)
|
var h0: float = terrain.sample_height(c.x, c.y)
|
||||||
hs.append(h0)
|
hs.append(h0)
|
||||||
if hs.max() - hs.min() > float(pass_[0]) or hs.min() < terrain.water_level + 1.0:
|
if hs.max() - hs.min() > float(pass_[0]) or hs.min() < terrain.water_level + 1.0 or terrain.near_stream(c.x, c.y, RING_R + 3.0):
|
||||||
continue
|
continue
|
||||||
return Vector3(c.x, h0, c.y)
|
return Vector3(c.x, h0, c.y)
|
||||||
return Vector3.INF
|
return Vector3.INF
|
||||||
|
|||||||
@@ -174,11 +174,18 @@ func _wall_threshold(x: float, z: float) -> float:
|
|||||||
# (data/worldlets/<id>.json "terrain"). Each block takes the ground so far and
|
# (data/worldlets/<id>.json "terrain"). Each block takes the ground so far and
|
||||||
# returns it changed; "hills" starts it. A worldlet can only combine these.
|
# returns it changed; "hills" starts it. A worldlet can only combine these.
|
||||||
|
|
||||||
const BLOCKS := ["hills", "rim", "sea_cliff", "beach", "lake", "ocean_edge"]
|
const BLOCKS := ["hills", "rim", "sea_cliff", "beach", "lake", "stream", "ocean_edge"]
|
||||||
var worldlet_id := "valley"
|
var worldlet_id := "valley"
|
||||||
var worldlet: Dictionary = {} ## the definition, as loaded
|
var worldlet: Dictionary = {} ## the definition, as loaded
|
||||||
var _blocks: Array = [] ## its terrain blocks, in order
|
var _blocks: Array = [] ## its terrain blocks, in order
|
||||||
var lakes: Array = [] ## every "lake" block's {center: Vector2, radius} (W2: several)
|
var lakes: Array = [] ## every "lake" block's {center: Vector2, radius} (W2: several)
|
||||||
|
var stream_path := PackedVector2Array() ## a "stream" block's points, spring first (W3: the Cove's)
|
||||||
|
var stream_width := 2.4
|
||||||
|
var stream_depth := 0.6
|
||||||
|
var _stream_profile := PackedFloat32Array() ## its water surface, every STREAM_STEP m along it
|
||||||
|
var _stream_len := 0.0 ## its length
|
||||||
|
var _stream_end := 0.0 ## where it meets the sea (its water ends there)
|
||||||
|
const STREAM_STEP := 1.0
|
||||||
var _has_sea := true ## a sea cliff (the Valley's)
|
var _has_sea := true ## a sea cliff (the Valley's)
|
||||||
var _has_beach := false ## a beach (W2)
|
var _has_beach := false ## a beach (W2)
|
||||||
var _valley := 0.0 ## the hills before anything else (the sea cliff eases from it)
|
var _valley := 0.0 ## the hills before anything else (the sea cliff eases from it)
|
||||||
@@ -231,6 +238,8 @@ func use_worldlet(id: String) -> String:
|
|||||||
resolution = int(d.get("resolution", resolution))
|
resolution = int(d.get("resolution", resolution))
|
||||||
_blocks = d.get("terrain", [])
|
_blocks = d.get("terrain", [])
|
||||||
lakes = []
|
lakes = []
|
||||||
|
stream_path = PackedVector2Array()
|
||||||
|
_stream_profile = PackedFloat32Array()
|
||||||
_has_sea = _has_block("sea_cliff")
|
_has_sea = _has_block("sea_cliff")
|
||||||
_has_beach = _has_block("beach")
|
_has_beach = _has_block("beach")
|
||||||
for b in _blocks:
|
for b in _blocks:
|
||||||
@@ -252,6 +261,12 @@ func use_worldlet(id: String) -> String:
|
|||||||
cliff_top = float(b.get("cliff_top", cliff_top))
|
cliff_top = float(b.get("cliff_top", cliff_top))
|
||||||
cliff_offset = float(b.get("cliff_offset", cliff_offset))
|
cliff_offset = float(b.get("cliff_offset", cliff_offset))
|
||||||
cliff_drop_width = float(b.get("drop_width", cliff_drop_width))
|
cliff_drop_width = float(b.get("drop_width", cliff_drop_width))
|
||||||
|
"stream":
|
||||||
|
stream_path = PackedVector2Array()
|
||||||
|
for q in b.get("points", []):
|
||||||
|
stream_path.append(Vector2(float(q[0]), float(q[1])))
|
||||||
|
stream_width = float(b.get("width", stream_width))
|
||||||
|
stream_depth = float(b.get("depth", stream_depth))
|
||||||
"beach":
|
"beach":
|
||||||
beach_angle_deg = float(b.get("angle_deg", beach_angle_deg))
|
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_half_width_deg = float(b.get("half_width_deg", beach_half_width_deg))
|
||||||
@@ -285,10 +300,14 @@ func _has_block(t: String) -> bool:
|
|||||||
return true
|
return true
|
||||||
return false
|
return false
|
||||||
|
|
||||||
func _height_fn(x: float, z: float) -> float:
|
## The ground at (x, z). `stop_at_stream`: as it is before the stream cuts
|
||||||
|
## its bed (what the stream's water level is worked out from).
|
||||||
|
func _height_fn(x: float, z: float, stop_at_stream: bool = false) -> float:
|
||||||
_ensure_noise()
|
_ensure_noise()
|
||||||
if _blocks.is_empty():
|
if _blocks.is_empty():
|
||||||
use_worldlet(worldlet_id)
|
use_worldlet(worldlet_id)
|
||||||
|
if not stop_at_stream and stream_path.size() >= 2 and _stream_profile.is_empty():
|
||||||
|
_make_stream_profile()
|
||||||
var land := 0.0
|
var land := 0.0
|
||||||
for b in _blocks:
|
for b in _blocks:
|
||||||
match String(b.type):
|
match String(b.type):
|
||||||
@@ -302,6 +321,10 @@ func _height_fn(x: float, z: float) -> float:
|
|||||||
land = _blk_beach(x, z, land)
|
land = _blk_beach(x, z, land)
|
||||||
"lake":
|
"lake":
|
||||||
land = _blk_lake(x, z, land, b._c, b._r)
|
land = _blk_lake(x, z, land, b._c, b._r)
|
||||||
|
"stream":
|
||||||
|
if stop_at_stream:
|
||||||
|
return land
|
||||||
|
land = _blk_stream(x, z, land)
|
||||||
"ocean_edge":
|
"ocean_edge":
|
||||||
land = _blk_ocean_edge(x, z, land)
|
land = _blk_ocean_edge(x, z, land)
|
||||||
return land
|
return land
|
||||||
@@ -354,6 +377,101 @@ func _blk_lake(x: float, z: float, land: float, at: Vector2, r: float) -> float:
|
|||||||
ld += _detail.get_noise_2d(x * 0.5, z * 0.5) * 5.0
|
ld += _detail.get_noise_2d(x * 0.5, z * 0.5) * 5.0
|
||||||
return lerpf(land, water_level - 3.2, smoothstep(r, r * 0.35, ld))
|
return lerpf(land, water_level - 3.2, smoothstep(r, r * 0.35, ld))
|
||||||
|
|
||||||
|
## A stream (W3): runs along its points from a spring to the sea. Its water
|
||||||
|
## surface comes down step by step from the ground at the spring, never
|
||||||
|
## rising (the running minimum of the land along it), so it always runs
|
||||||
|
## downhill. The bed is cut stream_depth below the surface, the banks rise a
|
||||||
|
## little above it (so the water never hangs over lower ground) and blend
|
||||||
|
## into the land, and where the land is high it cuts a little valley.
|
||||||
|
func _make_stream_profile() -> void:
|
||||||
|
_stream_profile = PackedFloat32Array()
|
||||||
|
_stream_len = 0.0
|
||||||
|
for i in range(1, stream_path.size()):
|
||||||
|
_stream_len += stream_path[i - 1].distance_to(stream_path[i])
|
||||||
|
_stream_end = _stream_len
|
||||||
|
var surf := INF
|
||||||
|
var n := int(_stream_len / STREAM_STEP) + 1
|
||||||
|
for i in range(n + 1):
|
||||||
|
var p := _stream_point(i * STREAM_STEP)
|
||||||
|
var g := _height_fn(p.x, p.y, true)
|
||||||
|
surf = minf(surf - 0.01, g - 0.25) if surf < INF else g - 0.25
|
||||||
|
surf = maxf(surf, water_level)
|
||||||
|
_stream_profile.append(surf)
|
||||||
|
if g < water_level - 0.2 and _stream_end >= _stream_len:
|
||||||
|
_stream_end = i * STREAM_STEP # (into the sea: the stream's done)
|
||||||
|
|
||||||
|
## The point `along` metres down the stream.
|
||||||
|
func _stream_point(along: float) -> Vector2:
|
||||||
|
var left := along
|
||||||
|
for i in range(1, stream_path.size()):
|
||||||
|
var seg := stream_path[i - 1].distance_to(stream_path[i])
|
||||||
|
if left <= seg or i == stream_path.size() - 1:
|
||||||
|
return stream_path[i - 1].lerp(stream_path[i], clampf(left / maxf(seg, 0.001), 0.0, 1.0))
|
||||||
|
left -= seg
|
||||||
|
return stream_path[stream_path.size() - 1]
|
||||||
|
|
||||||
|
## How far (x, z) is from the stream's line, and how far along it that is.
|
||||||
|
func _stream_near(x: float, z: float) -> Vector2:
|
||||||
|
var p := Vector2(x, z)
|
||||||
|
var best := INF
|
||||||
|
var best_along := 0.0
|
||||||
|
var run := 0.0
|
||||||
|
for i in range(1, stream_path.size()):
|
||||||
|
var a := stream_path[i - 1]
|
||||||
|
var b := stream_path[i]
|
||||||
|
var ab := b - a
|
||||||
|
var t := clampf((p - a).dot(ab) / maxf(ab.length_squared(), 0.001), 0.0, 1.0)
|
||||||
|
var d := p.distance_to(a + ab * t)
|
||||||
|
if d < best:
|
||||||
|
best = d
|
||||||
|
best_along = run + t * ab.length()
|
||||||
|
run += ab.length()
|
||||||
|
return Vector2(best, best_along)
|
||||||
|
|
||||||
|
func _stream_surface_at(along: float) -> float:
|
||||||
|
var f := clampf(along / STREAM_STEP, 0.0, _stream_profile.size() - 1.001)
|
||||||
|
var i := int(f)
|
||||||
|
return lerpf(_stream_profile[i], _stream_profile[i + 1], f - i)
|
||||||
|
|
||||||
|
func _blk_stream(x: float, z: float, land: float) -> float:
|
||||||
|
if _stream_profile.size() < 2:
|
||||||
|
return land
|
||||||
|
var near := _stream_near(x, z)
|
||||||
|
var hw := stream_width * 0.5
|
||||||
|
var k := near.x / hw
|
||||||
|
if k > 6.0:
|
||||||
|
return land
|
||||||
|
var along := near.y
|
||||||
|
var surf := _stream_surface_at(along)
|
||||||
|
var target: float
|
||||||
|
if k < 1.0:
|
||||||
|
target = surf - stream_depth * (1.0 - k * k)
|
||||||
|
else:
|
||||||
|
target = surf + 0.12 + (k - 1.0) * 0.3
|
||||||
|
# Down to the bed, up to the banks; where the land's high, a gentle
|
||||||
|
# valley rather than a slot.
|
||||||
|
var w := smoothstep(6.0, 1.6, k) if land > target else smoothstep(2.6, 1.6, k)
|
||||||
|
w *= smoothstep(0.0, 5.0, along) # (the spring)
|
||||||
|
w *= 1.0 - smoothstep(_stream_end - 8.0, _stream_end, along) # (into the sea)
|
||||||
|
return lerpf(land, target, w)
|
||||||
|
|
||||||
|
## The stream's water surface at (x, z), or -INF where there's no stream.
|
||||||
|
func stream_surface(x: float, z: float) -> float:
|
||||||
|
if _stream_profile.size() < 2:
|
||||||
|
return -INF
|
||||||
|
var near := _stream_near(x, z)
|
||||||
|
if near.x > stream_width * 0.5 or near.y > _stream_end - 1.0 or near.y < 0.5:
|
||||||
|
return -INF
|
||||||
|
return _stream_surface_at(near.y)
|
||||||
|
|
||||||
|
## Whether (x, z) is in or by the stream (within `margin` m of its banks):
|
||||||
|
## scenery and landmarks keep clear.
|
||||||
|
func near_stream(x: float, z: float, margin: float = 1.0) -> bool:
|
||||||
|
if _stream_profile.size() < 2:
|
||||||
|
return false
|
||||||
|
var near := _stream_near(x, z)
|
||||||
|
return near.x < stream_width * 0.5 + margin and near.y < _stream_end
|
||||||
|
|
||||||
## A beach (W2): on its side of the rim the land eases down to a sandy
|
## 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
|
## waterline, then shelves gently into the sea (beach_slope deeper per
|
||||||
## metre), so you can wade, then swim, until the current turns you back.
|
## metre), so you can wade, then swim, until the current turns you back.
|
||||||
@@ -572,6 +690,11 @@ func _build_water() -> void:
|
|||||||
if absf(sea_level - water_level) > 0.05: # (by a beach the sea is the same surface: no second sheet)
|
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, {})
|
_add_water("Water", disc, water_level, {})
|
||||||
|
|
||||||
|
if _stream_profile.size() >= 2:
|
||||||
|
# (Murkier than the lake, so half a metre of water reads as water, not a path.)
|
||||||
|
_add_water("Stream", _stream_mesh(), 0.0, {"clarity": 0.45,
|
||||||
|
"shallow_col": Color(0.20, 0.42, 0.46), "deep_col": Color(0.05, 0.16, 0.22)})
|
||||||
|
|
||||||
var ocean := PlaneMesh.new()
|
var ocean := PlaneMesh.new()
|
||||||
ocean.size = Vector2(8000.0, 8000.0)
|
ocean.size = Vector2(8000.0, 8000.0)
|
||||||
_add_water("Ocean", ocean, sea_level, {
|
_add_water("Ocean", ocean, sea_level, {
|
||||||
@@ -580,6 +703,40 @@ func _build_water() -> void:
|
|||||||
"clarity": 6.0,
|
"clarity": 6.0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
## The stream's water: a ribbon down its bed, just under the tops of the
|
||||||
|
## banks, each point at the surface there. It stops where it meets the sea
|
||||||
|
## (whose own water carries on from there).
|
||||||
|
func _stream_mesh() -> ArrayMesh:
|
||||||
|
var st := SurfaceTool.new()
|
||||||
|
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||||
|
var hw := stream_width * 0.5 * 1.05
|
||||||
|
var prev_l := Vector3.ZERO
|
||||||
|
var prev_r := Vector3.ZERO
|
||||||
|
var have := false
|
||||||
|
var along := 0.6
|
||||||
|
var stop := _stream_end - 1.0
|
||||||
|
while along < stop:
|
||||||
|
var p := _stream_point(along)
|
||||||
|
var q := _stream_point(minf(along + 0.5, _stream_len))
|
||||||
|
var dir := (q - p).normalized() if q.distance_to(p) > 0.001 else Vector2(0, 1)
|
||||||
|
var side := Vector2(-dir.y, dir.x) * hw
|
||||||
|
var y := _stream_surface_at(along) + 0.02
|
||||||
|
if y <= water_level + 0.06:
|
||||||
|
break # (level with the sea: its water takes over)
|
||||||
|
var l := Vector3(p.x + side.x, y, p.y + side.y)
|
||||||
|
var r := Vector3(p.x - side.x, y, p.y - side.y)
|
||||||
|
if have:
|
||||||
|
for v in [prev_l, l, prev_r, prev_r, l, r]:
|
||||||
|
st.set_normal(Vector3.UP)
|
||||||
|
st.set_tangent(Plane(1, 0, 0, 1))
|
||||||
|
st.set_uv(Vector2(v.x, v.z) / size + Vector2(0.5, 0.5))
|
||||||
|
st.add_vertex(v)
|
||||||
|
prev_l = l
|
||||||
|
prev_r = r
|
||||||
|
have = true
|
||||||
|
along += 0.75
|
||||||
|
return st.commit()
|
||||||
|
|
||||||
func _add_water(label: String, mesh: Mesh, y: float, params: Dictionary) -> void:
|
func _add_water(label: String, mesh: Mesh, y: float, params: Dictionary) -> void:
|
||||||
var mat := ShaderMaterial.new()
|
var mat := ShaderMaterial.new()
|
||||||
mat.shader = preload("res://shaders/water.gdshader")
|
mat.shader = preload("res://shaders/water.gdshader")
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ extends Node3D
|
|||||||
## for now: on a server it isn't built (J3 brings travel online).
|
## for now: on a server it isn't built (J3 brings travel online).
|
||||||
|
|
||||||
const TunnelFace = preload("res://scripts/world/tunnel_face.gd")
|
const TunnelFace = preload("res://scripts/world/tunnel_face.gd")
|
||||||
|
const RockPile = preload("res://scripts/world/rock_pile.gd")
|
||||||
|
|
||||||
const ORIGIN := Vector3(0.0, -260.0, 0.0) ## where the tunnel space sits (well under the island)
|
const ORIGIN := Vector3(0.0, -260.0, 0.0) ## where the tunnel space sits (well under the island)
|
||||||
const STRETCHES := 6
|
const STRETCHES := 6
|
||||||
@@ -54,8 +55,9 @@ var bottom := Vector3.ZERO ## inside, at the bottom by the cave
|
|||||||
var cave_mouth := Vector3.ZERO ## the far end of the sea cave
|
var cave_mouth := Vector3.ZERO ## the far end of the sea cave
|
||||||
var aabb := AABB()
|
var aabb := AABB()
|
||||||
|
|
||||||
var _rock_mat: StandardMaterial3D
|
var _rock_mat: StandardMaterial3D ## the tunnel's walls
|
||||||
var _face_mat: StandardMaterial3D
|
var _boulder_mat: ShaderMaterial ## the seam's outcrop (rock_pile.gd)
|
||||||
|
var _face_mat: ShaderMaterial ## the faces you dig
|
||||||
var _wood_mat: StandardMaterial3D
|
var _wood_mat: StandardMaterial3D
|
||||||
var _rock: CSGCombiner3D
|
var _rock: CSGCombiner3D
|
||||||
var _cuts: Node3D
|
var _cuts: Node3D
|
||||||
@@ -212,8 +214,8 @@ func _make_materials() -> void:
|
|||||||
_rock_mat.albedo_texture = terrain.noise_tex
|
_rock_mat.albedo_texture = terrain.noise_tex
|
||||||
_rock_mat.uv1_triplanar = true
|
_rock_mat.uv1_triplanar = true
|
||||||
_rock_mat.uv1_scale = Vector3(0.18, 0.18, 0.18)
|
_rock_mat.uv1_scale = Vector3(0.18, 0.18, 0.18)
|
||||||
_face_mat = _rock_mat.duplicate()
|
_boulder_mat = RockPile.material(terrain)
|
||||||
_face_mat.albedo_color = Color(0.30, 0.27, 0.25)
|
_face_mat = RockPile.material(terrain) # (the same rock: the seam's cleft and pick marks set it apart)
|
||||||
_wood_mat = StandardMaterial3D.new()
|
_wood_mat = StandardMaterial3D.new()
|
||||||
_wood_mat.albedo_color = Color(0.42, 0.29, 0.17)
|
_wood_mat.albedo_color = Color(0.42, 0.29, 0.17)
|
||||||
_wood_mat.roughness = 0.9
|
_wood_mat.roughness = 0.9
|
||||||
@@ -322,13 +324,37 @@ func _make_face(stage: int, center: Vector3, size: Vector3) -> StaticBody3D:
|
|||||||
box.size = size
|
box.size = size
|
||||||
cs.shape = box
|
cs.shape = box
|
||||||
f.add_child(cs)
|
f.add_child(cs)
|
||||||
var mi := MeshInstance3D.new()
|
|
||||||
var bm := BoxMesh.new()
|
|
||||||
bm.size = size
|
|
||||||
mi.mesh = bm
|
|
||||||
mi.material_override = _face_mat
|
|
||||||
f.add_child(mi)
|
|
||||||
add_child(f)
|
add_child(f)
|
||||||
|
var s: int = terrain.noise_seed + 4200 + stage * 7
|
||||||
|
if stage == 0:
|
||||||
|
# The seam: two slabs of rock with a dark cleft between them, and
|
||||||
|
# old pick marks (pale chips) on either side.
|
||||||
|
var gap := MeshInstance3D.new()
|
||||||
|
var gm := BoxMesh.new()
|
||||||
|
gm.size = Vector3(0.28, size.y * 0.85, 0.3)
|
||||||
|
gap.mesh = gm
|
||||||
|
var dm := StandardMaterial3D.new()
|
||||||
|
dm.albedo_color = Color(0.02, 0.018, 0.015)
|
||||||
|
dm.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
|
gap.material_override = dm
|
||||||
|
gap.position = Vector3(0.05, -0.05, 0.05)
|
||||||
|
f.add_child(gap)
|
||||||
|
RockPile.add(f, null, s, Vector3(-0.52, 0.0, 0.1), Vector3(0.55, size.y * 0.5, 0.42), 0.3, _face_mat, true)
|
||||||
|
RockPile.add(f, null, s + 1, Vector3(0.58, -0.05, 0.12), Vector3(0.52, size.y * 0.47, 0.4), 2.1, _face_mat, true)
|
||||||
|
var chip := StandardMaterial3D.new()
|
||||||
|
chip.albedo_color = Color(0.62, 0.58, 0.52)
|
||||||
|
for c in [Vector3(-0.32, 0.35, -0.3), Vector3(0.36, -0.2, -0.3), Vector3(-0.25, -0.45, -0.28)]:
|
||||||
|
var m := MeshInstance3D.new()
|
||||||
|
var cm := BoxMesh.new()
|
||||||
|
cm.size = Vector3(0.12, 0.05, 0.03)
|
||||||
|
m.mesh = cm
|
||||||
|
m.material_override = chip
|
||||||
|
m.position = c
|
||||||
|
m.rotation.z = c.x * 2.0
|
||||||
|
f.add_child(m)
|
||||||
|
else:
|
||||||
|
# A face down the tunnel: rough rock filling the passage.
|
||||||
|
RockPile.add(f, null, s, Vector3(-0.05, 0.0, 0.0), Vector3(0.45, size.y * 0.55, size.z * 0.55), 0.1, _face_mat)
|
||||||
f.global_position = center
|
f.global_position = center
|
||||||
return f
|
return f
|
||||||
|
|
||||||
@@ -344,35 +370,39 @@ func _build_seam() -> void:
|
|||||||
# (local -Z faces inland, towards the valley; the outcrop is behind, +Z)
|
# (local -Z faces inland, towards the valley; the outcrop is behind, +Z)
|
||||||
var body := StaticBody3D.new()
|
var body := StaticBody3D.new()
|
||||||
_seam.add_child(body)
|
_seam.add_child(body)
|
||||||
for part in [[Vector3(-1.5, 1.2, 0.4), Vector3(1.0, 2.6, 2.0)], [Vector3(1.5, 1.1, 0.4), Vector3(1.0, 2.4, 2.0)],
|
# An outcrop of boulders round a cleft: two tall ones either side, one
|
||||||
[Vector3(0, 2.6, 0.4), Vector3(4.0, 0.9, 2.2)], [Vector3(0, 1.1, 1.1), Vector3(2.2, 2.4, 0.6)]]:
|
# across the top, a big one behind and two lower ones at the shoulders,
|
||||||
var mi := MeshInstance3D.new()
|
# with loose stones fallen at its foot. (Each sits a little into the
|
||||||
var bm := BoxMesh.new()
|
# ground, which isn't quite flat.)
|
||||||
bm.size = part[1]
|
var s: int = terrain.noise_seed + 4100
|
||||||
mi.mesh = bm
|
for part in [[Vector3(-2.05, 0.95, 0.5), Vector3(1.0, 1.55, 1.3), 0.4, true],
|
||||||
mi.material_override = _rock_mat
|
[Vector3(2.05, 0.85, 0.55), Vector3(0.95, 1.45, 1.25), 1.9, true],
|
||||||
mi.position = part[0]
|
[Vector3(0.0, 2.95, 0.65), Vector3(2.3, 0.7, 1.4), 0.2, false],
|
||||||
_seam.add_child(mi)
|
[Vector3(0.0, 1.4, 1.9), Vector3(2.3, 2.3, 1.3), 3.0, false],
|
||||||
var cs := CollisionShape3D.new()
|
[Vector3(-1.2, 2.6, 2.4), Vector3(1.7, 1.5, 1.3), 1.3, false],
|
||||||
var box := BoxShape3D.new()
|
[Vector3(1.3, 2.5, 2.3), Vector3(1.6, 1.4, 1.3), 2.5, false],
|
||||||
box.size = part[1]
|
[Vector3(-3.3, 0.35, 1.2), Vector3(1.2, 0.9, 1.2), 2.2, false],
|
||||||
cs.shape = box
|
[Vector3(3.2, 0.3, 1.0), Vector3(1.0, 0.8, 1.1), 0.9, false]]:
|
||||||
cs.position = part[0]
|
RockPile.add(_seam, body, s, part[0], part[1], part[2], _boulder_mat, part[3])
|
||||||
body.add_child(cs)
|
s += 1
|
||||||
|
for stone in [[Vector3(-0.8, 0.06, -1.0), 0.28], [Vector3(1.1, 0.05, -1.3), 0.22],
|
||||||
|
[Vector3(0.35, 0.04, -1.7), 0.18], [Vector3(-1.6, 0.04, -1.6), 0.2]]:
|
||||||
|
RockPile.add(_seam, null, s, stone[0], Vector3.ONE * float(stone[1]), float(s), _boulder_mat)
|
||||||
|
s += 1
|
||||||
if not opened:
|
if not opened:
|
||||||
_face = _make_face(0, _seam.global_transform * Vector3(0, 1.1, -0.35), Vector3(2.0, 2.2, 0.5))
|
_face = _make_face(0, _seam.global_transform * Vector3(0, 1.1, -0.2), Vector3(2.0, 2.2, 0.6))
|
||||||
_face.global_basis = _seam.global_basis
|
_face.global_basis = _seam.global_basis
|
||||||
else:
|
else:
|
||||||
|
# The cleft dug open: black inside, set back between the boulders.
|
||||||
var dark := MeshInstance3D.new()
|
var dark := MeshInstance3D.new()
|
||||||
var q := QuadMesh.new()
|
var bm := BoxMesh.new()
|
||||||
q.size = Vector2(2.0, 2.1)
|
bm.size = Vector3(1.6, 2.0, 0.1)
|
||||||
dark.mesh = q
|
dark.mesh = bm
|
||||||
var m := StandardMaterial3D.new()
|
var m := StandardMaterial3D.new()
|
||||||
m.albedo_color = Color(0.02, 0.02, 0.02)
|
m.albedo_color = Color(0.015, 0.012, 0.01)
|
||||||
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
dark.material_override = m
|
dark.material_override = m
|
||||||
dark.position = Vector3(0, 1.05, 0.78)
|
dark.position = Vector3(0, 1.0, 0.45)
|
||||||
dark.rotation.y = PI # (a quad faces +Z: turn it to face inland)
|
|
||||||
_seam.add_child(dark)
|
_seam.add_child(dark)
|
||||||
var spot := _spot_body(Vector3(2.0, 2.1, 0.4), "in", 0)
|
var spot := _spot_body(Vector3(2.0, 2.1, 0.4), "in", 0)
|
||||||
_seam.add_child(spot)
|
_seam.add_child(spot)
|
||||||
|
|||||||
@@ -446,6 +446,7 @@ func _on_terrain_ready() -> void:
|
|||||||
if not state.is_empty():
|
if not state.is_empty():
|
||||||
apply_save(state)
|
apply_save(state)
|
||||||
print("[world] loaded '%s'" % Saves.current_id)
|
print("[world] loaded '%s'" % Saves.current_id)
|
||||||
|
catch_up(state.get("catch_up_to", {}))
|
||||||
_arrive(Saves.take_arrival(Saves.current_id))
|
_arrive(Saves.take_arrival(Saves.current_id))
|
||||||
player.set_physics_process(true)
|
player.set_physics_process(true)
|
||||||
set_physics_process(true)
|
set_physics_process(true)
|
||||||
@@ -995,6 +996,10 @@ func travel(to: String, arrive: String) -> void:
|
|||||||
var there: Dictionary = Saves.load_state(id, to)
|
var there: Dictionary = Saves.load_state(id, to)
|
||||||
if there.is_empty():
|
if there.is_empty():
|
||||||
there = {"time": here.time} # (a new place: the same day and hour)
|
there = {"time": here.time} # (a new place: the same day and hour)
|
||||||
|
else:
|
||||||
|
# One clock for every land: the one you're going to catches up on the
|
||||||
|
# time you were away when it loads (catch_up()).
|
||||||
|
there["catch_up_to"] = here.time
|
||||||
there["player"] = here.player
|
there["player"] = here.player
|
||||||
Saves.save_state(id, there, 0.0, to)
|
Saves.save_state(id, there, 0.0, to)
|
||||||
Saves.set_worldlet(id, to, arrive)
|
Saves.set_worldlet(id, to, arrive)
|
||||||
@@ -1007,6 +1012,22 @@ func travel(to: String, arrive: String) -> void:
|
|||||||
|
|
||||||
signal travelled(to: String) ## saved and on the way to another worldlet
|
signal travelled(to: String) ## saved and on the way to another worldlet
|
||||||
|
|
||||||
|
## Back in a land you've been to before: its clock comes forward to `to`
|
||||||
|
## ({day, time}, the clock where you've come from), and everything in it
|
||||||
|
## catches up on the time between, as after a night's sleep (kilns and
|
||||||
|
## smelters work, fires burn, food goes off, trees and berries grow back).
|
||||||
|
## Returns the seconds caught up.
|
||||||
|
func catch_up(to: Dictionary) -> float:
|
||||||
|
if to.is_empty():
|
||||||
|
return 0.0
|
||||||
|
var secs: float = ((int(to.get("day", day_night.day)) - day_night.day) \
|
||||||
|
+ (float(to.get("time", day_night.time)) - day_night.time)) * day_night.day_length
|
||||||
|
if secs <= 0.0:
|
||||||
|
return 0.0
|
||||||
|
skip_time(secs)
|
||||||
|
print("[world] %s caught up on %.0f s while you were away" % [worldlet_id(), secs])
|
||||||
|
return secs
|
||||||
|
|
||||||
## Just arrived from another worldlet: put you where you came in.
|
## Just arrived from another worldlet: put you where you came in.
|
||||||
func _arrive(at: String) -> void:
|
func _arrive(at: String) -> void:
|
||||||
if at.is_empty():
|
if at.is_empty():
|
||||||
@@ -1122,6 +1143,9 @@ func _update_grass_cuts() -> void:
|
|||||||
## fishing (and later swimming, boats) work on any world that answers it.
|
## fishing (and later swimming, boats) work on any world that answers it.
|
||||||
func water_at(x: float, z: float) -> Dictionary:
|
func water_at(x: float, z: float) -> Dictionary:
|
||||||
var ground: float = terrain.sample_height(x, z)
|
var ground: float = terrain.sample_height(x, z)
|
||||||
|
var ss: float = terrain.stream_surface(x, z)
|
||||||
|
if ground < ss:
|
||||||
|
return {"type": "stream", "surface": ss, "depth": ss - ground}
|
||||||
var beach: bool = terrain.beach_weight(x, z) > 0.5
|
var beach: bool = terrain.beach_weight(x, z) > 0.5
|
||||||
if terrain.inside_boundary(x, z, -2.0) and ground < terrain.water_level:
|
if terrain.inside_boundary(x, z, -2.0) and ground < terrain.water_level:
|
||||||
if beach and not _in_lake(x, z):
|
if beach and not _in_lake(x, z):
|
||||||
@@ -1161,6 +1185,11 @@ func water_bodies() -> Array:
|
|||||||
"beach":
|
"beach":
|
||||||
out.append({"type": "sea", "surface": terrain.sea_level,
|
out.append({"type": "sea", "surface": terrain.sea_level,
|
||||||
"center": terrain.shore_point(12.0), "radius": 0.0})
|
"center": terrain.shore_point(12.0), "radius": 0.0})
|
||||||
|
"stream":
|
||||||
|
if terrain.stream_path.size() >= 2:
|
||||||
|
var mid: Vector2 = terrain._stream_point(terrain._stream_end * 0.5)
|
||||||
|
out.append({"type": "stream", "surface": terrain._stream_surface_at(terrain._stream_end * 0.5),
|
||||||
|
"center": mid, "radius": terrain.stream_width * 0.5, "path": terrain.stream_path})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
## Whether (x, z) is in one of the lakes or ponds (not the sea off a beach).
|
## Whether (x, z) is in one of the lakes or ponds (not the sea off a beach).
|
||||||
|
|||||||
@@ -62,6 +62,30 @@ func _body() -> void:
|
|||||||
check(world.den != null and world.barrow != null and world.stones != null, "the den, the barrow and the stones found places")
|
check(world.den != null and world.barrow != null and world.stones != null, "the den, the barrow and the stones found places")
|
||||||
check(t.get_node_or_null("Water") == null, "one water surface (the bay is the sea)")
|
check(t.get_node_or_null("Water") == null, "one water surface (the bay is the sea)")
|
||||||
|
|
||||||
|
print("the stream")
|
||||||
|
check(t.stream_path.size() >= 2 and t.get_node_or_null("Stream") != null, "a stream, with water in it")
|
||||||
|
var downhill := true
|
||||||
|
for i in range(1, t._stream_profile.size()):
|
||||||
|
downhill = downhill and t._stream_profile[i] <= t._stream_profile[i - 1] + 0.0001
|
||||||
|
check(downhill and t._stream_profile[0] > t.water_level + 1.5, "running downhill all the way from its spring (%.1f m) to the sea" % t._stream_profile[0])
|
||||||
|
var mid: Vector2 = t._stream_point(t._stream_end * 0.3)
|
||||||
|
var sw: Dictionary = world.water_at(mid.x, mid.y)
|
||||||
|
check(sw.get("type", "") == "stream" and float(sw.depth) > 0.3 and float(sw.depth) < 1.0,
|
||||||
|
"stream water, wading deep (%.1f m)" % float(sw.get("depth", 0.0)))
|
||||||
|
var side: Vector2 = mid + Vector2(t.stream_width * 1.2, 0.0)
|
||||||
|
check(world.water_at(side.x, side.y).is_empty() and t.sample_height(side.x, side.y) > float(sw.surface),
|
||||||
|
"its banks are dry, and above the water")
|
||||||
|
check(DB.water_fishing("stream").get("fish", []).any(func(f): return f.id == "trout"), "trout in the stream")
|
||||||
|
var away := true
|
||||||
|
for p in [world.den.global_position, world.barrow.global_position, world.stones.global_position, world._spawn]:
|
||||||
|
away = away and not t.near_stream(p.x, p.z, 3.0)
|
||||||
|
check(away, "the den, the barrow, the stones and the start are all clear of it")
|
||||||
|
var in_bed := 0
|
||||||
|
for n in world.props.resource_nodes.values():
|
||||||
|
if is_instance_valid(n) and t.near_stream(n.global_position.x, n.global_position.z, 0.0):
|
||||||
|
in_bed += 1
|
||||||
|
check(in_bed == 0, "no trees or boulders standing in it")
|
||||||
|
|
||||||
print("the pier")
|
print("the pier")
|
||||||
var pier: Node3D = world.pier
|
var pier: Node3D = world.pier
|
||||||
check(pier != null and pier.is_in_group("pier"), "a pier")
|
check(pier != null and pier.is_in_group("pier"), "a pier")
|
||||||
|
|||||||
@@ -238,6 +238,10 @@ func _body() -> void:
|
|||||||
|
|
||||||
print("back up home")
|
print("back up home")
|
||||||
check(w.sea_cave.open(), "the sea cave leads back up (the tunnel's dug)")
|
check(w.sea_cave.open(), "the sea cave leads back up (the tunnel's dug)")
|
||||||
|
# A day and a half in the Cove before going home.
|
||||||
|
w.day_night.advance(w.day_night.day_length * 1.5)
|
||||||
|
var cove_day: int = w.day_night.day
|
||||||
|
var cove_time: float = w.day_night.time
|
||||||
_went = ""
|
_went = ""
|
||||||
w.sea_cave.interact(player)
|
w.sea_cave.interact(player)
|
||||||
check(_went == "valley", "into the cave: back to the Valley")
|
check(_went == "valley", "into the cave: back to the Valley")
|
||||||
@@ -247,6 +251,8 @@ func _body() -> void:
|
|||||||
player = w.player
|
player = w.player
|
||||||
check(w.worldlet_id() == "valley" and w.underground_at(player.global_position) and player.global_position.distance_to(t.bottom) < 2.0,
|
check(w.worldlet_id() == "valley" and w.underground_at(player.global_position) and player.global_position.distance_to(t.bottom) < 2.0,
|
||||||
"arriving at the bottom of the tunnel")
|
"arriving at the bottom of the tunnel")
|
||||||
|
check(w.day_night.day == cove_day and absf(w.day_night.time - cove_time) < 0.01,
|
||||||
|
"one clock for every land: home is on the Cove's day and hour (day %d), not the one you left it" % w.day_night.day)
|
||||||
t.go_out(player)
|
t.go_out(player)
|
||||||
await physics_frame
|
await physics_frame
|
||||||
check(not w.underground_at(player.global_position) and player.global_position.distance_to(t.seam_out) < 1.0, "climb out at the top, on the clifftop")
|
check(not w.underground_at(player.global_position) and player.global_position.distance_to(t.seam_out) < 1.0, "climb out at the top, on the clifftop")
|
||||||
|
|||||||
@@ -345,12 +345,12 @@ The Valley is only the start. Beyond it lie other lands, each reached a differen
|
|||||||
|
|
||||||
### The Cove
|
### The Cove
|
||||||
|
|
||||||
Low hills run down to a sheltered sandy bay, with mountains behind and a small pond inland. Wade out from the beach and it gets deeper slowly; swim too far and a strong current carries you back to the shore.
|
Low hills run down to a sheltered sandy bay, with mountains behind, a small pond inland, and a stream that runs from a spring under the mountains down to the sea. You can wade the stream anywhere, and trout bite in it. Wade out from the beach and it gets deeper slowly; swim too far and a strong current carries you back to the shore.
|
||||||
|
|
||||||
- **The pier** runs out over the bay. Fish off its end for **mackerel**, the usual catch. **Cod** are bigger but only bite where the water's at least 2.2 m deep, so cast well out. Both bite best at dawn and dusk, and grill at a campfire, salt or smoke like lake fish.
|
- **The pier** runs out over the bay. Fish off its end for **mackerel**, the usual catch. **Cod** are bigger but only bite where the water's at least 2.2 m deep, so cast well out. Both bite best at dawn and dusk, and grill at a campfire, salt or smoke like lake fish.
|
||||||
- **The sea cave** at the end of the beach is the way back: `E` goes into it and up the same tunnel, arriving at the bottom.
|
- **The sea cave** at the end of the beach is the way back: `E` goes into it and up the same tunnel, arriving at the bottom.
|
||||||
|
|
||||||
Each land keeps its own state. What you build in the Valley waits for you while you're away, and time stands still in a land you're not in.
|
Each land keeps its own state, and there's one clock for them all. What you build in the Valley waits for you while you're away, and when you come back the time you were gone catches up, as after a night's sleep: kilns and smelters will have done their work, fires burned down and food gone off. Nothing roams a land while you're away, so your base won't have been raided.
|
||||||
|
|
||||||
## Multiplayer {#multiplayer}
|
## Multiplayer {#multiplayer}
|
||||||
|
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ func _shot_cove() -> void:
|
|||||||
if pier == null:
|
if pier == null:
|
||||||
return
|
return
|
||||||
var side: Vector3 = pier.dir.cross(Vector3.UP)
|
var side: Vector3 = pier.dir.cross(Vector3.UP)
|
||||||
|
# (From out over the bay, with the stream's mouth along the shore.)
|
||||||
var from: Vector3 = pier.end + pier.dir * 18.0 + side * 14.0 + Vector3(0, 2.5, 0)
|
var from: Vector3 = pier.end + pier.dir * 18.0 + side * 14.0 + Vector3(0, 2.5, 0)
|
||||||
var to: Vector3 = pier.end + pier.dir * 6.0 + side * 7.0 + Vector3(0, 1.6, 0)
|
var to: Vector3 = pier.end + pier.dir * 6.0 + side * 7.0 + Vector3(0, 1.6, 0)
|
||||||
await _shot(6.0, func(_t: float, k: float):
|
await _shot(6.0, func(_t: float, k: float):
|
||||||
|
|||||||
Reference in New Issue
Block a user