diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4761cf7..d058f87 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -7,13 +7,20 @@ actually run and see. Timelines assume solo dev + heavy AI assistance, steady ## M0 — World skeleton (single-player) · ~2–4 weeks **Goal:** walk around a lit, atmospheric terrain. Prove the look. - [x] Project scaffold, git repo, docs -- [ ] Godot installed, project opens -- [ ] First-person / third-person character controller (move, jump, mouse-look) -- [ ] One flat/heightmap terrain chunk with a material -- [ ] Atmospheric lighting: directional sun, sky, volumetric fog, tonemapping -- [ ] Place one low-poly tree + rock to set the mood +- [x] Godot installed, project opens +- [x] First-person / third-person character controller (move, jump, mouse-look) +- [x] One flat/heightmap terrain chunk with a material +- [x] Atmospheric lighting: directional sun, sky, volumetric fog, tonemapping +- [x] Place one low-poly tree + rock to set the mood - **Done when:** you load the game and walk around a Valheim-ish lit field. +### M0.5 — Scenery & world bounds (done) +- [x] Mountain-rim boundary (steep cliffs + invisible wall + respawn safety net), + covered by `tests/test_boundary.gd` +- [x] Procedural terrain shader (grass/dirt/sand/rock strata/snow), lake + water shader +- [x] 5 procedural tree types, faceted mossy rocks, cliff outcrops, bushes, logs, stumps +- [x] Streamed wind-animated grass + flowers; cloud sky; swimming + ## M1 — Survival loop (single-player) · ~3–5 weeks **Goal:** the core game verbs. - [ ] Resource nodes: chop tree → wood, mine rock → stone diff --git a/docs/SETUP_ON_NEW_MACHINE.md b/docs/SETUP_ON_NEW_MACHINE.md index db99a1d..0920ae8 100644 --- a/docs/SETUP_ON_NEW_MACHINE.md +++ b/docs/SETUP_ON_NEW_MACHINE.md @@ -7,7 +7,19 @@ download — the M0 world is generated in code. ## 1. Install Godot 4.3+ - **macOS / Windows / Linux:** download the *standard* build (NOT the .NET/C# build — this project is pure GDScript) from https://godotengine.org/download -- Any 4.3.x or newer 4.x works. The project was built on `4.3.stable`. +- Use **4.7.x or newer 4.x**. The project was upgraded to `4.7.2.stable` (an + older editor will warn that the project is from a newer version). +- macOS shortcut: `brew install --cask godot`. If Homebrew can't unpack it, + download the zip from the GitHub release page and move `Godot.app` to + `/Applications`. + +## Tests +```bash +# Boundary: tries to walk/jump out of the valley in 24 directions +godot --headless --path . --fixed-fps 30 --script res://tests/test_boundary.gd +# Terrain: no pits/hanging water just inside the boundary wall +godot --headless --path . --script res://tests/test_terrain_edges.gd +``` ## 2. Get the project onto the machine You have the folder (from the zip, or a git remote). Then: @@ -41,8 +53,8 @@ WASD move · Mouse look · Shift sprint · Space jump · ESC frees the cursor. first open. Don't copy it between machines. - If scripts look like they "didn't load", let the first editor open finish its import pass, then press play. -- Where we are: **M0 complete** (walkable lit world). Next is **M1** (gather → - craft → build). See `docs/ROADMAP.md`. +- Where we are: **M0 + M0.5 complete** (detailed, bounded island valley). Next + is **M1** (gather → craft → build). See `docs/ROADMAP.md`. ## Recommended: push to a git remote for cross-machine work A zip is a one-time snapshot. For going back and forth between machines, create a diff --git a/project.godot b/project.godot index 0827f9c..c5df1ba 100644 --- a/project.godot +++ b/project.godot @@ -1,15 +1,23 @@ ; Engine configuration file. -; Godot 4.3+ project. Open by importing this folder in the Godot editor, -; or run: godot --path . +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters config_version=5 +[animation] + +compatibility/default_parent_skeleton_in_mesh_instance_3d=true + [application] config/name="Valheimlike" config/description="Low-poly survival game — Valheim look, Rust depth, self-hosted, moddable." run/main_scene="res://scenes/world.tscn" -config/features=PackedStringArray("4.3", "Forward Plus") +config/features=PackedStringArray("4.7", "Forward Plus") config/icon="res://icon.svg" [display] @@ -22,23 +30,28 @@ window/stretch/mode="canvas_items" move_forward={ "deadzone": 0.5, -"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)] +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null) +] } move_back={ "deadzone": 0.5, -"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)] +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null) +] } move_left={ "deadzone": 0.5, -"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)] +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null) +] } move_right={ "deadzone": 0.5, -"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)] +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null) +] } jump={ "deadzone": 0.5, -"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)] +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null) +] } [physics] @@ -47,5 +60,7 @@ common/physics_ticks_per_second=30 [rendering] -renderer/rendering_method="forward_plus" +lights_and_shadows/directional_shadow/size=8192 +lights_and_shadows/directional_shadow/soft_shadow_filter_quality=3 +anti_aliasing/quality/msaa_3d=2 environment/defaults/default_environment="res://scenes/default_env.tres" diff --git a/scenes/default_env.tres b/scenes/default_env.tres index e2d381d..2e719b7 100644 --- a/scenes/default_env.tres +++ b/scenes/default_env.tres @@ -1,11 +1,24 @@ -[gd_resource type="Environment" load_steps=4 format=3] +[gd_resource type="Environment" load_steps=6 format=3] -[sub_resource type="ProceduralSkyMaterial" id="SkyMat_1"] -sky_top_color = Color(0.25, 0.45, 0.72, 1) -sky_horizon_color = Color(0.68, 0.76, 0.82, 1) -ground_bottom_color = Color(0.18, 0.2, 0.18, 1) -ground_horizon_color = Color(0.58, 0.62, 0.6, 1) -sun_angle_max = 40.0 +[ext_resource type="Shader" path="res://shaders/sky.gdshader" id="1_sky"] + +[sub_resource type="FastNoiseLite" id="CloudNoise_1"] +noise_type = 1 +seed = 7 +frequency = 0.006 +fractal_octaves = 5 + +[sub_resource type="NoiseTexture2D" id="CloudTex_1"] +width = 1024 +height = 1024 +seamless = true +noise = SubResource("CloudNoise_1") + +[sub_resource type="ShaderMaterial" id="SkyMat_1"] +shader = ExtResource("1_sky") +shader_parameter/cloud_tex = SubResource("CloudTex_1") +shader_parameter/cloud_cover = 0.5 +shader_parameter/energy = 1.0 [sub_resource type="Sky" id="Sky_1"] sky_material = SubResource("SkyMat_1") @@ -14,21 +27,35 @@ sky_material = SubResource("SkyMat_1") background_mode = 2 sky = SubResource("Sky_1") ambient_light_source = 3 -ambient_light_color = Color(0.62, 0.68, 0.76, 1) -ambient_light_energy = 0.6 +ambient_light_energy = 1.0 +reflected_light_source = 2 tonemap_mode = 3 -tonemap_white = 5.0 +tonemap_white = 6.0 ssao_enabled = true -ssao_radius = 2.0 -ssao_intensity = 1.0 +ssao_radius = 1.5 +ssao_intensity = 1.6 +ssao_power = 1.5 +ssil_enabled = true +glow_enabled = true +glow_intensity = 0.5 +glow_bloom = 0.05 +glow_hdr_threshold = 1.2 fog_enabled = true fog_mode = 0 -fog_light_color = Color(0.74, 0.8, 0.86, 1) +fog_light_color = Color(0.70, 0.77, 0.85, 1) fog_light_energy = 1.0 -fog_density = 0.004 -fog_sky_affect = 0.0 -fog_aerial_perspective = 0.2 +fog_sun_scatter = 0.25 +fog_density = 0.0035 +fog_aerial_perspective = 0.55 +fog_sky_affect = 0.25 +volumetric_fog_enabled = true +volumetric_fog_density = 0.0045 +volumetric_fog_albedo = Color(0.9, 0.92, 0.95, 1) +volumetric_fog_anisotropy = 0.45 +volumetric_fog_length = 96.0 +volumetric_fog_sky_affect = 0.0 +volumetric_fog_ambient_inject = 0.3 adjustment_enabled = true -adjustment_brightness = 1.05 -adjustment_contrast = 1.12 -adjustment_saturation = 1.25 +adjustment_brightness = 1.0 +adjustment_contrast = 1.08 +adjustment_saturation = 1.15 diff --git a/scenes/world.tscn b/scenes/world.tscn index 90c7d46..084dc53 100644 --- a/scenes/world.tscn +++ b/scenes/world.tscn @@ -20,10 +20,12 @@ environment = ExtResource("4_env") [node name="Sun" type="DirectionalLight3D" parent="."] transform = Transform3D(0.819, -0.401, 0.41, 0, 0.715, 0.699, -0.574, -0.573, 0.585, 0, 40, 0) light_color = Color(1, 0.95, 0.83, 1) -light_energy = 1.7 +light_energy = 1.8 shadow_enabled = true +shadow_blur = 1.5 directional_shadow_mode = 2 -directional_shadow_max_distance = 200.0 +directional_shadow_blend_splits = true +directional_shadow_max_distance = 160.0 [node name="Terrain" type="Node3D" parent="."] script = ExtResource("1_terrain") diff --git a/scripts/player/player.gd b/scripts/player/player.gd index 9acaaa3..5c0ed07 100644 --- a/scripts/player/player.gd +++ b/scripts/player/player.gd @@ -1,14 +1,20 @@ extends CharacterBody3D -## First-person player controller — M0. +## First-person player controller. ## -## Move (WASD), look (mouse), jump (space). Gravity + ground collision via -## CharacterBody3D. Deliberately minimal: no stamina/health yet (M1), no network -## ownership yet (M2). Press ESC to free the mouse, click to recapture. +## Move (WASD), look (mouse), jump (space), sprint (shift). Gravity + ground +## collision via CharacterBody3D. In deep water the player floats with their +## head above the surface and moves at swimming speed. Deliberately minimal: no +## stamina/health yet (M1), no network ownership yet (M2). Press ESC to free the +## mouse, click to recapture. @export var speed: float = 5.0 @export var sprint_speed: float = 8.0 +@export var swim_speed: float = 2.6 @export var jump_velocity: float = 5.5 @export var mouse_sensitivity: float = 0.0025 +## Set by world.gd from the terrain. Feet this far below the surface = swimming. +@export var water_level: float = -INF +const FLOAT_DEPTH := 1.3 @onready var camera: Camera3D = $Camera3D @@ -28,16 +34,27 @@ func _unhandled_input(event: InputEvent) -> void: Input.mouse_mode = Input.MOUSE_MODE_CAPTURED func _physics_process(delta: float) -> void: - if not is_on_floor(): + var float_y := water_level - FLOAT_DEPTH + var swimming := global_position.y < float_y + 0.05 and not is_on_floor() + + if global_position.y < float_y: + # Buoyancy: ease back up to floating height. + velocity.y = move_toward(velocity.y, clampf((float_y - global_position.y) * 4.0, -3.0, 3.0), 20.0 * delta) + elif not is_on_floor(): velocity.y -= _gravity * delta if Input.is_action_just_pressed("jump") and is_on_floor(): velocity.y = jump_velocity + elif Input.is_action_just_pressed("jump") and global_position.y > float_y - 0.4 and global_position.y < float_y + 0.3: + # Swim-jump: kick up out of the water to climb onto a bank. + velocity.y = jump_velocity var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_back") var direction := (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized() var current_speed := sprint_speed if Input.is_key_pressed(KEY_SHIFT) else speed + if swimming: + current_speed = swim_speed if direction: velocity.x = direction.x * current_speed velocity.z = direction.z * current_speed diff --git a/scripts/player/player.gd.uid b/scripts/player/player.gd.uid new file mode 100644 index 0000000..f24b5c2 --- /dev/null +++ b/scripts/player/player.gd.uid @@ -0,0 +1 @@ +uid://dbsqgv3wwo41p diff --git a/scripts/world/proc_mesh.gd b/scripts/world/proc_mesh.gd new file mode 100644 index 0000000..fa0ccb1 --- /dev/null +++ b/scripts/world/proc_mesh.gd @@ -0,0 +1,372 @@ +extends RefCounted +## Procedural low-poly mesh builders: trees, rocks, bushes, logs, grass, flowers. +## +## Every builder takes a seed, so the same seed gives the same mesh on every +## machine (the determinism rule in docs/ARCHITECTURE.md §4). Meshes are +## flat-shaded and coloured per face through vertex colours (authored as sRGB). +## Callers assign materials per surface; tree dictionaries list which surface is +## which in "surfaces". +## +## Winding: Godot treats triangle (a, b, c) as front-facing when +## (b - a).cross(c - a) points AWAY from the viewer. `_tri()` orders the +## vertices from an outward hint, so builders never have to think about it. + +const BARK := Color(0.31, 0.22, 0.15) +const BARK_GREY := Color(0.42, 0.38, 0.33) +const BIRCH_WHITE := Color(0.86, 0.85, 0.80) +const BIRCH_DARK := Color(0.16, 0.15, 0.14) +const WOOD := Color(0.70, 0.55, 0.36) +const SPRUCE := Color(0.13, 0.27, 0.17) +const PINE := Color(0.18, 0.31, 0.16) +const LEAF_GREEN := Color(0.27, 0.43, 0.16) +const LEAF_GOLD := Color(0.62, 0.50, 0.15) +const BIRCH_LEAF := Color(0.42, 0.54, 0.20) +const BUSH := Color(0.19, 0.34, 0.14) +const ROCK := Color(0.50, 0.49, 0.47) +const STEM := Color(0.25, 0.42, 0.15) + +static var _ico_cache := {} + +# --- primitives ------------------------------------------------------------- + +static func _begin() -> SurfaceTool: + var st := SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + st.set_smooth_group(-1) # flat shading + return st + +static func _commit(st: SurfaceTool, mesh: ArrayMesh = null) -> ArrayMesh: + st.generate_normals() + return st.commit(mesh) + +static func _mul(c: Color, k: float) -> Color: + return Color(c.r * k, c.g * k, c.b * k, 1.0) + +static func _tri(st: SurfaceTool, a: Vector3, b: Vector3, c: Vector3, outward: Vector3, col: Color) -> void: + if (b - a).cross(c - a).dot(outward) > 0.0: + var tmp := b + b = c + c = tmp + st.set_color(col) + st.add_vertex(a) + st.set_color(col) + st.add_vertex(b) + st.set_color(col) + st.add_vertex(c) + +## Tapered cylinder from `base` to `top`. `off` fixes the ring rotation so +## stacked segments line up; cap_col.a > 0 adds end caps in that colour. +static func _cylinder(st: SurfaceTool, base: Vector3, top: Vector3, r0: float, r1: float, segs: int, + col: Color, rng: RandomNumberGenerator, off: float = -1.0, cap_col: Color = Color(0, 0, 0, 0)) -> void: + var ax := (top - base).normalized() + var ref := Vector3.UP if absf(ax.y) < 0.95 else Vector3.RIGHT + var u := ax.cross(ref).normalized() + var v := ax.cross(u).normalized() + if off < 0.0: + off = rng.randf() * TAU + var ring0: Array[Vector3] = [] + var ring1: Array[Vector3] = [] + for i in range(segs): + var a := off + TAU * i / segs + var dir := u * cos(a) + v * sin(a) + ring0.append(base + dir * r0) + ring1.append(top + dir * r1) + for i in range(segs): + var j := (i + 1) % segs + var mid := (ring0[i] + ring0[j] + ring1[i] + ring1[j]) * 0.25 + var out := mid - (base + ax * (mid - base).dot(ax)) + var c := _mul(col, rng.randf_range(0.85, 1.1)) + _tri(st, ring0[i], ring0[j], ring1[j], out, c) + _tri(st, ring0[i], ring1[j], ring1[i], out, c) + if cap_col.a > 0.0: + for i in range(segs): + var j := (i + 1) % segs + _tri(st, top, ring1[i], ring1[j], ax, cap_col) + _tri(st, base, ring0[i], ring0[j], -ax, cap_col) + +static func icosphere(subdiv: int) -> Array: + if _ico_cache.has(subdiv): + return _ico_cache[subdiv] + var t := (1.0 + sqrt(5.0)) / 2.0 + var v := [ + Vector3(-1, t, 0), Vector3(1, t, 0), Vector3(-1, -t, 0), Vector3(1, -t, 0), + Vector3(0, -1, t), Vector3(0, 1, t), Vector3(0, -1, -t), Vector3(0, 1, -t), + Vector3(t, 0, -1), Vector3(t, 0, 1), Vector3(-t, 0, -1), Vector3(-t, 0, 1), + ] + var faces := [ + [0, 11, 5], [0, 5, 1], [0, 1, 7], [0, 7, 10], [0, 10, 11], + [1, 5, 9], [5, 11, 4], [11, 10, 2], [10, 7, 6], [7, 1, 8], + [3, 9, 4], [3, 4, 2], [3, 2, 6], [3, 6, 8], [3, 8, 9], + [4, 9, 5], [2, 4, 11], [6, 2, 10], [8, 6, 7], [9, 8, 1], + ] + var tris := [] + for f in faces: + tris.append([(v[f[0]] as Vector3).normalized(), (v[f[1]] as Vector3).normalized(), (v[f[2]] as Vector3).normalized()]) + for _s in range(subdiv): + var nt := [] + for tr in tris: + var a: Vector3 = tr[0] + var b: Vector3 = tr[1] + var c: Vector3 = tr[2] + var ab := ((a + b) * 0.5).normalized() + var bc := ((b + c) * 0.5).normalized() + var ca := ((c + a) * 0.5).normalized() + nt.append([a, ab, ca]) + nt.append([b, bc, ab]) + nt.append([c, ca, bc]) + nt.append([ab, bc, ca]) + tris = nt + _ico_cache[subdiv] = tris + return tris + +## Lumpy flat-shaded sphere. Displacement is a function of position, so shared +## corners move together and the surface stays watertight. +static func _blob(st: SurfaceTool, center: Vector3, radii: Vector3, noise: FastNoiseLite, amount: float, + col: Color, rng: RandomNumberGenerator, subdiv: int = 1) -> void: + for tr in icosphere(subdiv): + var p: Array[Vector3] = [] + for k in range(3): + var q: Vector3 = tr[k] + var d := 1.0 + noise.get_noise_3dv(q * 1.7 + center) * amount + p.append(center + q * radii * d) + _tri(st, p[0], p[1], p[2], (p[0] + p[1] + p[2]) / 3.0 - center, _mul(col, rng.randf_range(0.88, 1.1))) + +## One drooping tier of a conifer: a star-shaped ring (alternating branch tips +## and notches) rising to an apex, with a darker underside. +static func _conifer_tier(st: SurfaceTool, center: Vector3, r: float, h: float, segs: int, + col: Color, rng: RandomNumberGenerator) -> void: + var n := segs * 2 + var ring: Array[Vector3] = [] + var off := rng.randf() * TAU + for i in range(n): + var a := off + TAU * i / n + rng.randf_range(-0.12, 0.12) + var tip := i % 2 == 0 + var rr := r * (rng.randf_range(0.9, 1.12) if tip else rng.randf_range(0.58, 0.7)) + var yy := -(r * 0.32 if tip else r * 0.12) + rng.randf_range(-0.08, 0.08) + ring.append(center + Vector3(cos(a) * rr, yy, sin(a) * rr)) + var apex := center + Vector3(rng.randf_range(-0.08, 0.08), h, rng.randf_range(-0.08, 0.08)) + var under := center + Vector3(0.0, h * 0.18, 0.0) + for i in range(n): + var a := ring[i] + var b := ring[(i + 1) % n] + var mid := (a + b) * 0.5 - center + var out := Vector3(mid.x, 0.0, mid.z).normalized() + Vector3(0.0, 0.8, 0.0) + _tri(st, apex, a, b, out, _mul(col, rng.randf_range(0.85, 1.12))) + _tri(st, under, a, b, Vector3(mid.x * 0.3, -1.0, mid.z * 0.3), _mul(col, 0.55)) + +# --- trees ------------------------------------------------------------------ + +## Spruce (bushy, tiers from near the ground) or pine (tall, tiers only up top). +static func conifer(seed: int, tall: bool) -> Dictionary: + var rng := RandomNumberGenerator.new() + rng.seed = seed + var bark := _begin() + var leaf := _begin() + var trunk_h := rng.randf_range(8.5, 10.5) if tall else rng.randf_range(5.5, 7.0) + var trunk_r := 0.26 if tall else 0.3 + var lean := Vector3(rng.randf_range(-0.15, 0.15), 0.0, rng.randf_range(-0.15, 0.15)) + _cylinder(bark, Vector3(0, -0.5, 0), Vector3(0, trunk_h, 0) + lean, trunk_r, 0.05, 7, BARK, rng) + + var tiers := rng.randi_range(4, 5) if tall else rng.randi_range(5, 7) + var y0 := trunk_h * 0.55 if tall else 1.3 + var y1 := trunk_h - 0.6 + var r_bottom := 1.7 if tall else 2.4 + var col := _mul(PINE if tall else SPRUCE, rng.randf_range(0.9, 1.1)) + for i in range(tiers): + var f := float(i) / float(tiers - 1) + var y := lerpf(y0, y1, f) + var r := lerpf(r_bottom, 0.55, pow(f, 0.9)) * rng.randf_range(0.9, 1.1) + _conifer_tier(leaf, Vector3(0, y, 0) + lean * (y / trunk_h), r, lerpf(1.9, 1.3, f), 9, col, rng) + _conifer_tier(leaf, Vector3(0, y1 + 0.9, 0) + lean, 0.35, 1.1, 5, col, rng) + + var mesh := _commit(bark) + _commit(leaf, mesh) + return {"mesh": mesh, "surfaces": ["bark", "leaf"], "trunk_r": trunk_r, "trunk_h": trunk_h} + +## Broadleaf (oak-ish): short thick trunk, a few branches, a crown of blobs. +static func broadleaf(seed: int, leaf_col: Color) -> Dictionary: + var rng := RandomNumberGenerator.new() + rng.seed = seed + var noise := FastNoiseLite.new() + noise.seed = seed + noise.frequency = 1.3 + var bark := _begin() + var leaf := _begin() + var trunk_h := rng.randf_range(2.6, 3.6) + var top := Vector3(rng.randf_range(-0.3, 0.3), trunk_h, rng.randf_range(-0.3, 0.3)) + _cylinder(bark, Vector3(0, -0.4, 0), top, 0.36, 0.2, 7, BARK, rng) + var k := rng.randi_range(3, 4) + for i in range(k): + var a := TAU * i / k + rng.randf_range(-0.4, 0.4) + var start := top * rng.randf_range(0.75, 1.0) + var end := top + Vector3(cos(a) * rng.randf_range(1.1, 1.8), rng.randf_range(0.6, 1.4), sin(a) * rng.randf_range(1.1, 1.8)) + _cylinder(bark, start, end, 0.15, 0.06, 5, BARK, rng) + + var crown := top + Vector3(0, 1.4, 0) + var col := _mul(leaf_col, rng.randf_range(0.92, 1.08)) + var blobs := rng.randi_range(6, 9) + for i in range(blobs): + var c := crown + Vector3(rng.randf_range(-1.7, 1.7), rng.randf_range(-0.6, 1.2), rng.randf_range(-1.7, 1.7)) + if i == 0: + c = crown + Vector3(0, 0.8, 0) + var r := rng.randf_range(1.0, 1.6) + _blob(leaf, c, Vector3(r, r * 0.82, r), noise, 0.28, _mul(col, rng.randf_range(0.85, 1.15)), rng) + + var mesh := _commit(bark) + _commit(leaf, mesh) + return {"mesh": mesh, "surfaces": ["bark", "leaf"], "trunk_r": 0.36, "trunk_h": trunk_h} + +## Birch: slim white trunk with dark bands, light airy crown. +static func birch(seed: int) -> Dictionary: + var rng := RandomNumberGenerator.new() + rng.seed = seed + var noise := FastNoiseLite.new() + noise.seed = seed + noise.frequency = 1.5 + var bark := _begin() + var leaf := _begin() + var trunk_h := rng.randf_range(6.0, 8.0) + var lean := Vector3(rng.randf_range(-0.4, 0.4), 0.0, rng.randf_range(-0.4, 0.4)) + var off := rng.randf() * TAU + var y := -0.4 + while y < trunk_h: + var dark := rng.randf() < 0.3 + var seg_h := rng.randf_range(0.12, 0.22) if dark else rng.randf_range(0.5, 1.0) + var y2 := minf(y + seg_h, trunk_h) + var r0 := lerpf(0.18, 0.05, maxf(y, 0.0) / trunk_h) + var r1 := lerpf(0.18, 0.05, y2 / trunk_h) + var col := BIRCH_DARK if dark else _mul(BIRCH_WHITE, rng.randf_range(0.92, 1.03)) + _cylinder(bark, Vector3(0, y, 0) + lean * (maxf(y, 0.0) / trunk_h), Vector3(0, y2, 0) + lean * (y2 / trunk_h), + r0, r1, 6, col, rng, off) + y = y2 + var col_leaf := _mul(BIRCH_LEAF, rng.randf_range(0.92, 1.08)) + for i in range(rng.randi_range(5, 7)): + var cy := rng.randf_range(trunk_h * 0.5, trunk_h + 0.6) + var c := Vector3(rng.randf_range(-0.9, 0.9), cy, rng.randf_range(-0.9, 0.9)) + lean * (cy / trunk_h) + var r := rng.randf_range(0.7, 1.1) + _blob(leaf, c, Vector3(r, r * 1.35, r), noise, 0.3, _mul(col_leaf, rng.randf_range(0.85, 1.15)), rng) + var mesh := _commit(bark) + _commit(leaf, mesh) + return {"mesh": mesh, "surfaces": ["bark", "leaf"], "trunk_r": 0.2, "trunk_h": trunk_h} + +## Dead tree: bare grey trunk with forked branches. +static func dead_tree(seed: int) -> Dictionary: + var rng := RandomNumberGenerator.new() + rng.seed = seed + var bark := _begin() + var trunk_h := rng.randf_range(4.0, 6.0) + var top := Vector3(rng.randf_range(-0.3, 0.3), trunk_h, rng.randf_range(-0.3, 0.3)) + _cylinder(bark, Vector3(0, -0.4, 0), top, 0.28, 0.07, 6, BARK_GREY, rng) + for i in range(rng.randi_range(4, 6)): + var a := rng.randf() * TAU + var start := top * rng.randf_range(0.45, 0.95) + var end := start + Vector3(cos(a) * rng.randf_range(0.8, 1.6), rng.randf_range(0.5, 1.5), sin(a) * rng.randf_range(0.8, 1.6)) + _cylinder(bark, start, end, 0.09, 0.03, 4, BARK_GREY, rng) + var a2 := a + rng.randf_range(-0.8, 0.8) + _cylinder(bark, end, end + Vector3(cos(a2) * 0.6, rng.randf_range(0.3, 0.8), sin(a2) * 0.6), 0.04, 0.015, 4, BARK_GREY, rng) + return {"mesh": _commit(bark), "surfaces": ["bark"], "trunk_r": 0.28, "trunk_h": trunk_h} + +# --- small props ------------------------------------------------------------ + +static func bush(seed: int) -> Dictionary: + var rng := RandomNumberGenerator.new() + rng.seed = seed + var noise := FastNoiseLite.new() + noise.seed = seed + noise.frequency = 2.0 + var leaf := _begin() + var col := _mul(BUSH, rng.randf_range(0.9, 1.15)) + for i in range(rng.randi_range(3, 5)): + var r := rng.randf_range(0.45, 0.8) + var c := Vector3(rng.randf_range(-0.55, 0.55), rng.randf_range(0.25, 0.6), rng.randf_range(-0.55, 0.55)) + _blob(leaf, c, Vector3(r, r * 0.8, r), noise, 0.3, _mul(col, rng.randf_range(0.85, 1.15)), rng) + return {"mesh": _commit(leaf), "surfaces": ["leaf"]} + +## Faceted rock. `tall` gives slab/pillar shapes for cliff outcrops. +static func rock(seed: int, tall: bool) -> ArrayMesh: + var rng := RandomNumberGenerator.new() + rng.seed = seed + var noise := FastNoiseLite.new() + noise.seed = seed + noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + noise.frequency = 0.9 + var squash := Vector3(rng.randf_range(0.9, 1.4), rng.randf_range(0.55, 0.85), rng.randf_range(0.8, 1.2)) + if tall: + squash = Vector3(rng.randf_range(0.7, 1.0), rng.randf_range(1.2, 1.6), rng.randf_range(0.6, 0.9)) + var st := _begin() + var base_col := _mul(ROCK, rng.randf_range(0.9, 1.1)) + for tr in icosphere(1): + var p: Array[Vector3] = [] + for k in range(3): + var q: Vector3 = tr[k] + q = q * (1.0 + noise.get_noise_3dv(q) * 0.38) * squash + q.y = maxf(q.y, -0.35 * squash.y) # flat-ish base so it sits on the ground + p.append(q) + _tri(st, p[0], p[1], p[2], p[0] + p[1] + p[2], _mul(base_col, rng.randf_range(0.82, 1.12))) + return _commit(st) + +static func log_mesh(seed: int, length: float, radius: float) -> ArrayMesh: + var rng := RandomNumberGenerator.new() + rng.seed = seed + var st := _begin() + _cylinder(st, Vector3(-length * 0.5, 0, 0), Vector3(length * 0.5, 0, 0), radius, radius * 0.85, 8, BARK, rng, -1.0, WOOD) + return _commit(st) + +static func stump_mesh(seed: int, height: float, radius: float) -> ArrayMesh: + var rng := RandomNumberGenerator.new() + rng.seed = seed + var st := _begin() + _cylinder(st, Vector3(0, -0.3, 0), Vector3(0, height, 0), radius * 1.15, radius, 8, BARK, rng, -1.0, WOOD) + return _commit(st) + +## A clump of tapered blades. UV.y = 0 at the root, 1 at the tip (drives wind). +static func grass_clump(seed: int, blades: int = 10) -> ArrayMesh: + var rng := RandomNumberGenerator.new() + rng.seed = seed + var st := SurfaceTool.new() + st.begin(Mesh.PRIMITIVE_TRIANGLES) + for i in range(blades): + var a := rng.randf() * TAU + var rr := sqrt(rng.randf()) * 0.3 + var pos := Vector3(cos(a) * rr, -0.05, sin(a) * rr) + var face := rng.randf() * TAU + var side := Vector3(cos(face), 0.0, sin(face)) + var fwd := Vector3(-side.z, 0.0, side.x) + var h := rng.randf_range(0.32, 0.72) + var w := rng.randf_range(0.035, 0.06) + var bend := rng.randf_range(0.08, 0.28) + var b0 := pos - side * w + var b1 := pos + side * w + var mc := pos + fwd * bend * 0.35 + Vector3(0, h * 0.55, 0) + var m0 := mc - side * w * 0.7 + var m1 := mc + side * w * 0.7 + var tip := pos + fwd * bend + Vector3(0, h, 0) + var u := rng.randf() + var data := [[b0, 0.0], [b1, 0.0], [m1, 0.55], [b0, 0.0], [m1, 0.55], [m0, 0.55], [m0, 0.55], [m1, 0.55], [tip, 1.0]] + for d in data: + st.set_normal(Vector3.UP) + st.set_uv(Vector2(u, d[1])) + st.add_vertex(d[0]) + return st.commit() + +## Small flower: a stem (surface 0) and a five-petal head (surface 1, white so +## the MultiMesh instance colour tints it). +static func flower(seed: int) -> ArrayMesh: + var rng := RandomNumberGenerator.new() + rng.seed = seed + var stem := _begin() + var head := _begin() + var h := 0.32 + var c := Vector3(0, h, 0) + _tri(stem, Vector3(-0.012, -0.05, 0), Vector3(0.012, -0.05, 0), c, Vector3.BACK, STEM) + _tri(stem, Vector3(0, -0.05, -0.012), Vector3(0, -0.05, 0.012), c, Vector3.RIGHT, STEM) + for k in range(5): + var a := TAU * k / 5.0 + var tip := c + Vector3(cos(a) * 0.085, 0.025, sin(a) * 0.085) + var l := c + Vector3(cos(a - 0.45) * 0.045, 0.012, sin(a - 0.45) * 0.045) + var r := c + Vector3(cos(a + 0.45) * 0.045, 0.012, sin(a + 0.45) * 0.045) + _tri(head, c, l, tip, Vector3.UP, Color.WHITE) + _tri(head, c, tip, r, Vector3.UP, Color.WHITE) + var mesh := _commit(stem) + _commit(head, mesh) + return mesh diff --git a/scripts/world/proc_mesh.gd.uid b/scripts/world/proc_mesh.gd.uid new file mode 100644 index 0000000..cc85b21 --- /dev/null +++ b/scripts/world/proc_mesh.gd.uid @@ -0,0 +1 @@ +uid://bm5bwoxetmp4s diff --git a/scripts/world/props.gd b/scripts/world/props.gd index dc079fb..4ebc7a1 100644 --- a/scripts/world/props.gd +++ b/scripts/world/props.gd @@ -1,94 +1,502 @@ extends Node3D -## M0 mood props — a few low-poly trees and rocks built from primitives in code, -## so the scene looks "alive" with no imported assets. These are placeholders; -## real low-poly models (free asset packs, see docs/ROADMAP.md M0) replace them. +## Scenery: forests, rocks, cliff outcrops, bushes, logs, grass and flowers. ## -## place_on(terrain) is called by world.gd once the terrain exists, so each prop -## sits on the real ground height instead of floating at Y=0. +## All placement is seeded, so a server and its clients would agree on where +## every tree is (docs/ARCHITECTURE.md §4). Rendering uses MultiMesh batches +## bucketed into CELL x CELL world cells: a few hundred draw batches instead of +## thousands of nodes, and off-screen cells get frustum-culled. +## +## Trees, logs and larger rocks in or near the walkable valley get simple +## collision. Grass is streamed in tiles around the player because it's too +## dense to place over the whole map at once. -var _terrain: Node3D +const ProcMesh = preload("res://scripts/world/proc_mesh.gd") -func place_on(terrain: Node3D) -> void: - _terrain = terrain - var rng := RandomNumberGenerator.new() - rng.seed = 42 - for i in range(18): - var x := rng.randf_range(-55.0, 55.0) - var z := rng.randf_range(-55.0, 55.0) - var y: float = _terrain.sample_height(x, z) - if rng.randf() < 0.65: - _make_tree(Vector3(x, y, z), rng) - else: - _make_rock(Vector3(x, y, z), rng) +const CELL := 48.0 +const GRASS_TILE := 12.0 +const FLOWER_COLORS := [ + Color(0.96, 0.95, 0.90), Color(0.98, 0.84, 0.25), Color(0.62, 0.42, 0.86), + Color(0.45, 0.60, 0.95), Color(0.93, 0.45, 0.55), +] -func _make_tree(pos: Vector3, rng: RandomNumberGenerator) -> void: +@export var world_seed: int = 1337 +@export var tree_spacing: float = 3.6 +@export var forest_density: float = 1.0 +@export var grass_spacing: float = 0.42 +@export var grass_view_distance: float = 55.0 +@export var grass_tiles_per_update: int = 6 + +var terrain: Node3D +var _player: Node3D +var _spawn := Vector3.ZERO +var _forest_noise := FastNoiseLite.new() +var _meadow_noise := FastNoiseLite.new() +var _colliders: StaticBody3D + +var _bark_mat: StandardMaterial3D +var _leaf_mat: ShaderMaterial +var _rock_mat: ShaderMaterial +var _grass_mat: ShaderMaterial + +var _grass_root: Node3D +var _grass_tiles: Dictionary = {} # Vector2i -> Node3D (or null for empty tiles) +var _grass_mesh: ArrayMesh +var _flower_mesh: ArrayMesh +var _grass_timer := 0.0 + +func place_on(t: Node3D, player: Node3D, spawn: Vector3) -> void: + terrain = t + _player = player + _spawn = spawn + _forest_noise.seed = world_seed + 11 + _forest_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + _forest_noise.frequency = 0.011 + _forest_noise.fractal_octaves = 3 + _meadow_noise.seed = world_seed + 23 + _meadow_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + _meadow_noise.frequency = 0.03 + _meadow_noise.fractal_octaves = 2 + + _colliders = StaticBody3D.new() + _colliders.name = "PropColliders" + add_child(_colliders) + + _make_materials() + _scatter_trees() + _scatter_rocks() + _scatter_bushes() + _place_logs() + + _grass_root = Node3D.new() + _grass_root.name = "Grass" + add_child(_grass_root) + _grass_mesh = ProcMesh.grass_clump(world_seed + 5) + _grass_mesh.surface_set_material(0, _grass_mat) + _flower_mesh = ProcMesh.flower(world_seed + 6) + var stem := StandardMaterial3D.new() + stem.vertex_color_use_as_albedo = true + stem.vertex_color_is_srgb = true + stem.cull_mode = BaseMaterial3D.CULL_DISABLED + var head := stem.duplicate() as StandardMaterial3D + head.roughness = 0.7 + _flower_mesh.surface_set_material(0, stem) + _flower_mesh.surface_set_material(1, head) + _update_grass(100000) # everything in range, before the first frame + +func _process(delta: float) -> void: + if _player == null: + return + _grass_timer -= delta + if _grass_timer > 0.0: + return + _grass_timer = 0.2 + _update_grass(grass_tiles_per_update) + +# --- materials -------------------------------------------------------------- + +func _make_materials() -> void: + var noise_tex: Texture2D = terrain.noise_tex + _bark_mat = StandardMaterial3D.new() + _bark_mat.vertex_color_use_as_albedo = true + _bark_mat.vertex_color_is_srgb = true + _bark_mat.roughness = 1.0 + + _leaf_mat = ShaderMaterial.new() + _leaf_mat.shader = preload("res://shaders/foliage.gdshader") + _leaf_mat.set_shader_parameter("noise_tex", noise_tex) + + _rock_mat = ShaderMaterial.new() + _rock_mat.shader = preload("res://shaders/rock.gdshader") + _rock_mat.set_shader_parameter("noise_tex", noise_tex) + _rock_mat.set_shader_parameter("snow_height", terrain.snow_height) + + _grass_mat = ShaderMaterial.new() + _grass_mat.shader = preload("res://shaders/grass.gdshader") + _grass_mat.set_shader_parameter("noise_tex", noise_tex) + _grass_mat.set_shader_parameter("grass_lush", terrain.grass_lush) + _grass_mat.set_shader_parameter("grass_dry", terrain.grass_dry) + +func _apply_materials(v: Dictionary) -> Dictionary: + var mesh: ArrayMesh = v.mesh + var surfaces: Array = v.surfaces + for i in range(surfaces.size()): + mesh.surface_set_material(i, _bark_mat if surfaces[i] == "bark" else _leaf_mat) + return v + +# --- helpers ---------------------------------------------------------------- + +## Jittered grid covering the whole chunk. +func _jittered(spacing: float, rng: RandomNumberGenerator) -> PackedVector2Array: + var pts := PackedVector2Array() + var half: float = terrain.size * 0.5 - 2.0 + var z := -half + while z < half: + var x := -half + while x < half: + pts.append(Vector2(x + rng.randf() * spacing, z + rng.randf() * spacing)) + x += spacing + z += spacing + return pts + +func _forest(x: float, z: float) -> float: + return smoothstep(-0.15, 0.35, _forest_noise.get_noise_2d(x, z)) + +func _group(groups: Dictionary, x: float, z: float, vi: int, xf: Transform3D) -> void: + var key := Vector3i(floori(x / CELL), floori(z / CELL), vi) + if not groups.has(key): + groups[key] = [] + groups[key].append(xf) + +func _flush(groups: Dictionary, meshes: Array, shadows: bool, label: String) -> void: var root := Node3D.new() - root.position = pos + root.name = label add_child(root) + for key: Vector3i in groups: + root.add_child(_mm_instance(meshes[key.z], groups[key], [], shadows)) - var trunk := MeshInstance3D.new() - var tm := CylinderMesh.new() - tm.top_radius = 0.18 - tm.bottom_radius = 0.28 - tm.height = 3.0 - trunk.mesh = tm - trunk.position = Vector3(0, 1.5, 0) - trunk.material_override = _mat(Color(0.33, 0.24, 0.16)) - root.add_child(trunk) +func _mm_instance(mesh: Mesh, xforms: Array, colors: Array, shadows: bool) -> MultiMeshInstance3D: + var mm := MultiMesh.new() + mm.transform_format = MultiMesh.TRANSFORM_3D + mm.use_colors = not colors.is_empty() # must be set before instance_count + mm.mesh = mesh + mm.instance_count = xforms.size() + for i in range(xforms.size()): + mm.set_instance_transform(i, xforms[i]) + if not colors.is_empty(): + mm.set_instance_color(i, colors[i]) + var mmi := MultiMeshInstance3D.new() + mmi.multimesh = mm + mmi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_ON if shadows else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + return mmi - # Collision: a capsule around the trunk so you can't walk through the tree. - var body := StaticBody3D.new() - root.add_child(body) - var col := CollisionShape3D.new() - var cap := CapsuleShape3D.new() - cap.radius = 0.35 - cap.height = 4.0 - col.shape = cap - col.position = Vector3(0, 2.0, 0) - body.add_child(col) +## Basis that tips `UP` part-way toward the ground normal, then yaws and scales. +func _ground_basis(normal: Vector3, follow: float, yaw: float, s: float) -> Basis: + var up := Vector3.UP.lerp(normal, follow).normalized() + var b := Basis(Vector3.UP, yaw) + var axis := Vector3.UP.cross(up) + if axis.length() > 0.0001: + b = Basis(axis.normalized(), Vector3.UP.angle_to(up)) * b + return b * Basis.from_scale(Vector3.ONE * s) - # Low-poly conifer canopy: stacked cones. - var h := 3.0 - for j in range(3): - var cone := MeshInstance3D.new() - var cm := CylinderMesh.new() - cm.top_radius = 0.0 - cm.bottom_radius = 1.6 - j * 0.4 - cm.height = 1.6 - cm.radial_segments = 6 # low-poly - cone.mesh = cm - cone.position = Vector3(0, h + j * 1.0, 0) - cone.material_override = _mat(Color(0.16, 0.34, 0.2)) - root.add_child(cone) +func _add_shape(shape: Shape3D, xf: Transform3D) -> void: + var cs := CollisionShape3D.new() + cs.shape = shape + cs.transform = xf + _colliders.add_child(cs) - root.rotate_y(rng.randf_range(0, TAU)) - var s := rng.randf_range(0.85, 1.35) - root.scale = Vector3(s, s, s) +# --- trees ------------------------------------------------------------------ -func _make_rock(pos: Vector3, rng: RandomNumberGenerator) -> void: - var rock := MeshInstance3D.new() - var bm := BoxMesh.new() - var box_size := Vector3(rng.randf_range(0.8, 1.8), rng.randf_range(0.6, 1.2), rng.randf_range(0.8, 1.8)) - bm.size = box_size - rock.mesh = bm - rock.position = pos + Vector3(0, 0.3, 0) - rock.rotation = Vector3(rng.randf_range(-0.2, 0.2), rng.randf_range(0, TAU), rng.randf_range(-0.2, 0.2)) - rock.material_override = _mat(Color(0.42, 0.42, 0.45)) - add_child(rock) +func _scatter_trees() -> void: + var variants: Array = [] + var conifers: Array[int] = [] + var broad: Array[int] = [] + var birches: Array[int] = [] + for i in range(3): + conifers.append(variants.size()); variants.append(_apply_materials(ProcMesh.conifer(world_seed + 100 + i, false))) + for i in range(2): + conifers.append(variants.size()); variants.append(_apply_materials(ProcMesh.conifer(world_seed + 110 + i, true))) + for i in range(3): + var col: Color = ProcMesh.LEAF_GOLD if i == 2 else ProcMesh.LEAF_GREEN + broad.append(variants.size()); variants.append(_apply_materials(ProcMesh.broadleaf(world_seed + 120 + i, col))) + for i in range(2): + birches.append(variants.size()); variants.append(_apply_materials(ProcMesh.birch(world_seed + 130 + i))) + var dead := variants.size() + variants.append(_apply_materials(ProcMesh.dead_tree(world_seed + 140))) - # Collision: a box matching the rock mesh. - var body := StaticBody3D.new() - body.position = rock.position - body.rotation = rock.rotation - add_child(body) - var col := CollisionShape3D.new() - var box := BoxShape3D.new() - box.size = box_size - col.shape = box - body.add_child(col) + var rng := RandomNumberGenerator.new() + rng.seed = world_seed + 1 + var groups := {} + for p in _jittered(tree_spacing, rng): + # Draw every random number up front so the sequence never depends on + # which branch below is taken (stable layout when rules are tweaked). + var roll := rng.randf() + var pick := rng.randf() + var yaw := rng.randf() * TAU + var s := rng.randf_range(0.75, 1.3) + var h: float = terrain.sample_height(p.x, p.y) + if roll > _tree_chance(p.x, p.y, h) * forest_density: + continue + var vi: int + var conifer_p := lerpf(0.4, 0.97, smoothstep(2.0, 24.0, h)) + if pick < 0.025: + vi = dead + elif pick < conifer_p: + vi = conifers[int(pick * 9973.0) % conifers.size()] + elif (pick - conifer_p) / (1.0 - conifer_p) < 0.6: + vi = broad[int(pick * 7919.0) % broad.size()] + else: + vi = birches[int(pick * 7919.0) % birches.size()] + var xf := Transform3D(Basis(Vector3.UP, yaw) * Basis.from_scale(Vector3.ONE * s), Vector3(p.x, h - 0.1, p.y)) + _group(groups, p.x, p.y, vi, xf) + if terrain.inside_boundary(p.x, p.y, -4.0): + var v: Dictionary = variants[vi] + var cap := CapsuleShape3D.new() + cap.radius = v.trunk_r * s + cap.height = maxf(v.trunk_h * s, cap.radius * 2.0 + 0.1) + _add_shape(cap, Transform3D(Basis(), Vector3(p.x, h + cap.height * 0.5, p.y))) + var meshes := variants.map(func(v): return v.mesh) + _flush(groups, meshes, true, "Trees") -func _mat(c: Color) -> StandardMaterial3D: - var m := StandardMaterial3D.new() - m.albedo_color = c - m.roughness = 0.95 - return m +## Open grassy strip along the sea-cliff edge: no trees, bushes or boulders, so +## the view out to sea is clear and you can walk right up to the edge. +func _on_clifftop(x: float, z: float) -> bool: + return terrain.sea_weight(x, z) > 0.5 and not terrain.inside_boundary(x, z, 10.0) \ + and terrain.inside_boundary(x, z, -8.0) + +func _tree_chance(x: float, z: float, h: float) -> float: + if h < terrain.water_level + 0.8: + return 0.0 + if Vector2(x - _spawn.x, z - _spawn.z).length() < 9.0 or _on_clifftop(x, z): + return 0.0 + var n: Vector3 = terrain.sample_normal(x, z) + var slope := 1.0 - n.y + if slope > 0.5: + return 0.0 + var c := lerpf(0.035, 0.8, _forest(x, z)) + c *= 1.0 - smoothstep(0.25, 0.5, slope) + c *= 1.0 - smoothstep(terrain.snow_height - 12.0, terrain.snow_height - 2.0, h) + return c + +# --- rocks ------------------------------------------------------------------ + +func _scatter_rocks() -> void: + var meshes: Array = [] + var hulls: Array = [] + for i in range(7): + var m := ProcMesh.rock(world_seed + 300 + i, i >= 5) + m.surface_set_material(0, _rock_mat) + meshes.append(m) + hulls.append((m.create_convex_shape(true, true) as ConvexPolygonShape3D).points) + var rng := RandomNumberGenerator.new() + rng.seed = world_seed + 2 + var groups := {} + + # Small stones: everywhere, more on slopes and in the shallows. + for p in _jittered(6.0, rng): + var roll := rng.randf(); var vi := rng.randi_range(0, 4); var yaw := rng.randf() * TAU + var s := rng.randf_range(0.2, 0.75) + var h: float = terrain.sample_height(p.x, p.y) + var n: Vector3 = terrain.sample_normal(p.x, p.y) + var chance := 0.1 + (1.0 - n.y) * 0.8 + if h < terrain.water_level - 2.0 or roll > chance: + continue + _place_rock(groups, meshes, hulls, p, h, n, vi, yaw, s, 0.6, 0.25) + + # Boulders: sparse, a little more common in forests. + for p in _jittered(14.0, rng): + var roll := rng.randf(); var vi := rng.randi_range(0, 4); var yaw := rng.randf() * TAU + var s := rng.randf_range(1.2, 3.0) + var h: float = terrain.sample_height(p.x, p.y) + var n: Vector3 = terrain.sample_normal(p.x, p.y) + if h < terrain.water_level - 0.5 or Vector2(p.x - _spawn.x, p.y - _spawn.z).length() < 8.0 or _on_clifftop(p.x, p.y): + continue + if roll > 0.07 + _forest(p.x, p.y) * 0.08: + continue + _place_rock(groups, meshes, hulls, p, h, n, vi, yaw, s, 0.5, 0.3) + + # Cliff outcrops: big slabs breaking up the steep mountain faces. + for p in _jittered(8.0, rng): + var roll := rng.randf(); var vi := rng.randi_range(0, 6); var yaw := rng.randf() * TAU + var s := rng.randf_range(3.0, 8.0) + var n: Vector3 = terrain.sample_normal(p.x, p.y) + if 1.0 - n.y < 0.32 or roll > 0.22: + continue + var h: float = terrain.sample_height(p.x, p.y) + _place_rock(groups, meshes, hulls, p, h, n, vi, yaw, s, 0.9, 0.6) + + # Sea stacks: tall pillars standing off the cliff (no collision, unreachable). + var placed := 0 + var tries := 0 + while placed < 9 and tries < 200: + tries += 1 + var ang: float = terrain.sea_angle_deg + rng.randf_range(-1.0, 1.0) * (terrain.sea_half_width_deg + 8.0) + var p: Vector2 = terrain.coast_point(ang, rng.randf_range(10.0, 48.0)) + var vi := rng.randi_range(5, 6) + var yaw := rng.randf() * TAU + var s := rng.randf_range(3.5, 7.0) + var h: float = terrain.sample_height(p.x, p.y) + if h > terrain.sea_level - 3.0: + continue + var b := Basis(Vector3.UP, yaw) * Basis.from_scale(Vector3(s, s * rng.randf_range(1.8, 2.6), s)) + _group(groups, p.x, p.y, vi, Transform3D(b, Vector3(p.x, h - 1.0, p.y))) + placed += 1 + + _flush(groups, meshes, true, "Rocks") + +func _place_rock(groups: Dictionary, meshes: Array, hulls: Array, p: Vector2, h: float, n: Vector3, + vi: int, yaw: float, s: float, follow: float, sink: float) -> void: + var b := _ground_basis(n, follow, yaw, s) + var pos := Vector3(p.x, h - sink * s, p.y) + _group(groups, p.x, p.y, vi, Transform3D(b, pos)) + if s >= 0.55 and terrain.inside_boundary(p.x, p.y, -6.0): + var pts := PackedVector3Array() + for q: Vector3 in hulls[vi]: + pts.append(q * s) + var shape := ConvexPolygonShape3D.new() + shape.points = pts + _add_shape(shape, Transform3D(b.orthonormalized(), pos)) + +# --- bushes, logs, stumps --------------------------------------------------- + +func _scatter_bushes() -> void: + var meshes: Array = [] + for i in range(3): + meshes.append(_apply_materials(ProcMesh.bush(world_seed + 200 + i)).mesh) + var rng := RandomNumberGenerator.new() + rng.seed = world_seed + 3 + var groups := {} + for p in _jittered(4.5, rng): + var roll := rng.randf(); var vi := rng.randi_range(0, 2); var yaw := rng.randf() * TAU + var s := rng.randf_range(0.7, 1.5) + var h: float = terrain.sample_height(p.x, p.y) + var n: Vector3 = terrain.sample_normal(p.x, p.y) + if h < terrain.water_level + 0.5 or n.y < 0.75 or h > terrain.snow_height - 8.0: + continue + if Vector2(p.x - _spawn.x, p.y - _spawn.z).length() < 4.0 or _on_clifftop(p.x, p.y): + continue + var f := _forest(p.x, p.y) + var edge := 1.0 - absf(f - 0.5) * 2.0 # forest edges get the most undergrowth + if roll > 0.05 + 0.4 * edge: + continue + _group(groups, p.x, p.y, vi, Transform3D(_ground_basis(n, 0.5, yaw, s), Vector3(p.x, h - 0.1, p.y))) + _flush(groups, meshes, true, "Bushes") + +func _place_logs() -> void: + var root := Node3D.new() + root.name = "Logs" + add_child(root) + var rng := RandomNumberGenerator.new() + rng.seed = world_seed + 4 + var logs := 0 + var stumps := 0 + var tries := 0 + while (logs < 22 or stumps < 30) and tries < 4000: + tries += 1 + var x := rng.randf_range(-terrain.play_radius, terrain.play_radius) + var z := rng.randf_range(-terrain.play_radius, terrain.play_radius) + var yaw := rng.randf() * TAU + var make_log := rng.randf() < 0.45 + if not terrain.inside_boundary(x, z, 6.0) or _forest(x, z) < 0.35: + continue + var h: float = terrain.sample_height(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: + continue + var mi := MeshInstance3D.new() + var shape: Shape3D + var xf: Transform3D + if make_log and logs < 22: + var length := rng.randf_range(3.0, 5.0) + var r := rng.randf_range(0.22, 0.34) + mi.mesh = ProcMesh.log_mesh(world_seed + 400 + logs, length, r) + xf = Transform3D(_ground_basis(n, 1.0, yaw, 1.0), Vector3(x, h + r * 0.75, z)) + var box := BoxShape3D.new() + box.size = Vector3(length, r * 1.8, r * 1.8) + shape = box + logs += 1 + elif not make_log and stumps < 30: + var sh := rng.randf_range(0.35, 0.7) + var r := rng.randf_range(0.25, 0.4) + mi.mesh = ProcMesh.stump_mesh(world_seed + 500 + stumps, sh, r) + xf = Transform3D(Basis(Vector3.UP, yaw), Vector3(x, h, z)) + var cyl := CylinderShape3D.new() + cyl.radius = r * 1.1 + cyl.height = sh + 0.3 + shape = cyl + stumps += 1 + else: + mi.free() + continue + mi.material_override = _bark_mat + mi.transform = xf + root.add_child(mi) + var col_xf := xf + if shape is CylinderShape3D: + col_xf.origin.y += (shape as CylinderShape3D).height * 0.5 - 0.3 + _add_shape(shape, col_xf) + +# --- grass streaming -------------------------------------------------------- + +func _update_grass(budget: int) -> void: + var p := _player.global_position + var reach := grass_view_distance + GRASS_TILE + var c := Vector2i(floori(p.x / GRASS_TILE), floori(p.z / GRASS_TILE)) + var tr := int(ceil(reach / GRASS_TILE)) + var wanted := {} + var todo: Array = [] + for dz in range(-tr, tr + 1): + for dx in range(-tr, tr + 1): + var k := c + Vector2i(dx, dz) + var d := Vector2((k.x + 0.5) * GRASS_TILE, (k.y + 0.5) * GRASS_TILE).distance_to(Vector2(p.x, p.z)) + if d > reach: + continue + wanted[k] = true + if not _grass_tiles.has(k): + todo.append([d, k]) + todo.sort_custom(func(a, b): return a[0] < b[0]) + for i in range(mini(budget, todo.size())): + var k: Vector2i = todo[i][1] + _grass_tiles[k] = _make_grass_tile(k) + for k: Vector2i in _grass_tiles.keys(): + if wanted.has(k): + continue + var d := Vector2((k.x + 0.5) * GRASS_TILE, (k.y + 0.5) * GRASS_TILE).distance_to(Vector2(p.x, p.z)) + if d > reach + GRASS_TILE * 1.5: + var node = _grass_tiles[k] + if node != null and is_instance_valid(node): + node.queue_free() + _grass_tiles.erase(k) + +func _make_grass_tile(k: Vector2i) -> Node3D: + var rng := RandomNumberGenerator.new() + rng.seed = hash(k) ^ world_seed + var ox := k.x * GRASS_TILE + var oz := k.y * GRASS_TILE + var centre := Vector3(ox + GRASS_TILE * 0.5, 0.0, oz + GRASS_TILE * 0.5) + var grass_xf: Array = [] + var flower_xf: Array = [] + var flower_col: Array = [] + var steps := int(GRASS_TILE / grass_spacing) + for iz in range(steps): + for ix in range(steps): + var x := ox + (ix + rng.randf()) * grass_spacing + var z := oz + (iz + rng.randf()) * grass_spacing + var keep := rng.randf(); var yaw := rng.randf() * TAU + var s := rng.randf_range(0.75, 1.25); var fl := rng.randf() + if not terrain.in_bounds(x, z): + continue + var h: float = terrain.sample_height(x, z) + if h < terrain.water_level + 0.25 or h > terrain.snow_height - 6.0: + continue + var n: Vector3 = terrain.sample_normal(x, z) + if n.y < 0.72: + continue + var meadow := _meadow_noise.get_noise_2d(x, z) + var dens := smoothstep(-0.45, 0.1, meadow) * (1.0 - smoothstep(0.85, 0.72, n.y) * 0.7) + if keep > dens: + continue + var tall := lerpf(0.7, 1.35, smoothstep(-0.1, 0.4, meadow)) + var b := Basis(Vector3.UP, yaw) * Basis.from_scale(Vector3(s, s * tall, s)) + grass_xf.append(Transform3D(b, Vector3(x - centre.x, h - 0.03, z - centre.z))) + if fl < 0.02 * smoothstep(0.0, 0.4, meadow): + var ci := int(absf(_forest_noise.get_noise_2d(x * 3.0, z * 3.0)) * 10.0) % FLOWER_COLORS.size() + flower_xf.append(Transform3D(Basis(Vector3.UP, yaw) * Basis.from_scale(Vector3.ONE * s), Vector3(x - centre.x, h, z - centre.z))) + flower_col.append(FLOWER_COLORS[ci]) + if grass_xf.is_empty(): + return null + var node := Node3D.new() + node.position = centre + var g := _mm_instance(_grass_mesh, grass_xf, [], false) + _fade(g, grass_view_distance) + node.add_child(g) + if not flower_xf.is_empty(): + var f := _mm_instance(_flower_mesh, flower_xf, flower_col, false) + _fade(f, grass_view_distance * 0.7) + node.add_child(f) + _grass_root.add_child(node) + return node + +func _fade(gi: GeometryInstance3D, dist: float) -> void: + gi.visibility_range_end = dist + gi.visibility_range_end_margin = 10.0 + gi.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_SELF diff --git a/scripts/world/props.gd.uid b/scripts/world/props.gd.uid new file mode 100644 index 0000000..e47242e --- /dev/null +++ b/scripts/world/props.gd.uid @@ -0,0 +1 @@ +uid://0o8ocjpya04q diff --git a/scripts/world/terrain.gd b/scripts/world/terrain.gd index 666a580..04dcbe3 100644 --- a/scripts/world/terrain.gd +++ b/scripts/world/terrain.gd @@ -1,31 +1,78 @@ extends Node3D -## Terrain generator — M0 placeholder. +## Terrain generator. ## -## Builds ONE heightmap chunk from deterministic noise, so the world already -## looks like rolling ground rather than a flat plane. Single chunk, generated at -## _ready(). Chunk streaming, biomes and server-side determinism (see -## docs/ARCHITECTURE.md §4) come in later milestones. +## One heightmap chunk built from deterministic noise: a walkable valley of +## rolling hills with a lake, enclosed by a ring of steep mountains that opens on +## one side to a sheer sea cliff. The whole chunk is an island: its edge sinks +## into ocean so the end of the mesh is never visible. ## -## Collision uses MeshInstance3D.create_trimesh_collision() (mesh-derived), which -## registers reliably in the physics space. A hand-built ConcavePolygonShape3D -## via set_faces() did NOT register (player/rays passed through) — avoid it. +## The rim is the world boundary: mountain slopes are too steep to walk up +## (> floor_max_angle), and an invisible wall stands partway up them, or just +## inside the cliff edge on the sea side, so nobody can climb out or fall off. Chunk +## streaming and biomes come in later milestones (docs/ARCHITECTURE.md §4). ## -## Exposes `sample_height(x, z)` so other systems (player spawn, prop placement) -## can sit exactly on the surface. +## Heights are computed once into a grid that feeds the render mesh, the +## collision shape and `sample_height()`, so props and the player sit exactly on +## the visible surface. +## +## Collision is a HeightMapShape3D. Do NOT swap it for a concave trimesh: those +## are one-sided and the player fell straight through them from above. signal ready_built ## emitted once the mesh + collision exist -@export var size: int = 128 # chunk is size x size metres -@export var resolution: int = 96 # vertices per side (higher = smoother/heavier) -@export var height_scale: float = 10.0 +@export var size: int = 320 ## chunk is size x size metres +@export var resolution: int = 256 ## grid cells per side +@export var height_scale: float = 9.0 ## rolling-hill amplitude in the valley @export var noise_seed: int = 1337 -var _noise: FastNoiseLite +@export_group("Boundary") +@export var play_radius: float = 100.0 ## where the mountain rim starts rising +@export var rim_width: float = 30.0 ## horizontal run of the rim's steep face +@export var mountain_height: float = 58.0 +@export var rim_warp: float = 16.0 ## how irregular the rim's outline is +@export_range(0.2, 0.9) var wall_frac: float = 0.45 ## invisible wall, as a fraction up the rim + +@export_group("Sea cliff") +@export var sea_angle_deg: float = -75.0 ## direction of the coast (atan2(z, x); -90 = straight -Z) +@export var sea_half_width_deg: float = 38.0 ## full-sea arc either side of sea_angle +@export var sea_fade_deg: float = 22.0 ## headland transition back to mountains +@export var sea_level: float = -24.0 +@export var cliff_top: float = 0.5 ## height the coast rises/falls to at the edge +@export var cliff_offset: float = 4.0 ## cliff edge, in rim distance past play_radius +@export var cliff_drop_width: float = 6.0 ## horizontal run of the drop (smaller = sheerer) + +@export_group("Water & snow") +@export var water_level: float = -2.6 +@export var lake_center: Vector2 = Vector2(14.0, -46.0) +@export var lake_radius: float = 24.0 +@export var snow_height: float = 40.0 + +@export_group("Colours") +@export var grass_lush: Color = Color(0.25, 0.40, 0.15) +@export var grass_dry: Color = Color(0.42, 0.49, 0.21) + var built: bool = false +var min_height: float = 0.0 +var max_height: float = 0.0 +var step: float = 1.0 +var half: float = 0.0 +var material: ShaderMaterial +var noise_tex: NoiseTexture2D ## shared by every world shader (see common.gdshaderinc) +var normal_tex: NoiseTexture2D ## detail normal map for ground and water + +var _noise: FastNoiseLite +var _detail: FastNoiseLite +var _ridge: FastNoiseLite +var _warp: FastNoiseLite +var _wall_ring: Array[Vector3] = [] +var _heights := PackedFloat32Array() +var _normals := PackedVector3Array() func _ready() -> void: _build() +# --- height function -------------------------------------------------------- + func _ensure_noise() -> void: if _noise != null: return @@ -35,95 +82,379 @@ func _ensure_noise() -> void: _noise.frequency = 0.012 _noise.fractal_octaves = 4 + _detail = FastNoiseLite.new() + _detail.seed = noise_seed + 1 + _detail.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + _detail.frequency = 0.06 + _detail.fractal_octaves = 2 + + _ridge = FastNoiseLite.new() + _ridge.seed = noise_seed + 2 + _ridge.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + _ridge.frequency = 0.022 + _ridge.fractal_octaves = 4 + + _warp = FastNoiseLite.new() + _warp.seed = noise_seed + 3 + _warp.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + _warp.frequency = 0.03 + +## Radial offset that makes the rim wobble instead of being a perfect circle. +## Sampled around a circle, so it depends only on the angle from the centre. +func _rim_offset(x: float, z: float) -> float: + var ang := atan2(z, x) + return _warp.get_noise_2d(cos(ang) * 50.0, sin(ang) * 50.0) * rim_warp + +## "Rim distance": distance from the centre plus the wobble. The rim starts at +## play_radius and the invisible wall stands at _wall_d() in this same measure. +func _rim_d(x: float, z: float) -> float: + return sqrt(x * x + z * z) + _rim_offset(x, z) + +func _wall_d() -> float: + return play_radius + rim_width * wall_frac + +func _cliff_d() -> float: + return play_radius + cliff_offset + +## 1 on the open-sea side of the rim, 0 where the mountains are, smooth between +## (the headlands). Depends only on the angle from the centre. +func _sea_w(x: float, z: float) -> float: + var diff := absf(wrapf(atan2(z, x) - deg_to_rad(sea_angle_deg), -PI, PI)) + return 1.0 - smoothstep(deg_to_rad(sea_half_width_deg), deg_to_rad(sea_half_width_deg + sea_fade_deg), diff) + +## Boundary threshold in rim distance: partway up the mountains, or just +## inside the cliff edge on the sea side. +func _wall_threshold(x: float, z: float) -> float: + return lerpf(_wall_d(), _cliff_d() - 1.0, _sea_w(x, z)) + +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) + + # Mountain rim. + var land := valley + 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)) + var m := pow(t, 1.4) * mountain_height * (0.65 + 0.55 * ridge) + m += maxf(d - (play_radius + rim_width), 0.0) * 0.35 * (0.5 + ridge) + # Stepped ledges so the faces read as layered cliffs, not smooth ramps. + var band := 6.0 + 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 + + # 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(), + # the headlands dropped away INSIDE the wall: pits you could fall into, with + # lake water hanging over them. tests/test_terrain_edges.gd guards this.) + var sw := _sea_w(x, z) + if sw > 0.0: + var edge := _wall_threshold(x, z) + 1.0 + var top := cliff_top + _detail.get_noise_2d(x * 0.5, z * 0.5) * 1.5 + var coast := lerpf(valley, top, smoothstep(play_radius - 45.0, edge, d)) + 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) + + # Lake basin: blend the ground down to a bed below the waterline. + 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)) + + # Ocean all round the chunk edge, so the world is an island and the edge of + # the mesh is always hidden under deep water. + 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) + +# --- build ------------------------------------------------------------------ + func _build() -> void: _ensure_noise() + step = float(size) / float(resolution) + half = size * 0.5 + var n := resolution + 1 - var st := SurfaceTool.new() - st.begin(Mesh.PRIMITIVE_TRIANGLES) + _heights.resize(n * n) + min_height = INF + max_height = -INF + for iz in range(n): + for ix in range(n): + var hv := _height_fn(ix * step - half, iz * step - half) + _heights[iz * n + ix] = hv + min_height = minf(min_height, hv) + max_height = maxf(max_height, hv) - var step := float(size) / float(resolution) - var half := size * 0.5 - var uv_scale := 1.0 / float(size) + _normals.resize(n * n) + for iz in range(n): + for ix in range(n): + var hl := _heights[iz * n + maxi(ix - 1, 0)] + var hr := _heights[iz * n + mini(ix + 1, resolution)] + var hd := _heights[maxi(iz - 1, 0) * n + ix] + var hu := _heights[mini(iz + 1, resolution) * n + ix] + _normals[iz * n + ix] = Vector3(hl - hr, 2.0 * step, hd - hu).normalized() - for z in range(resolution): - for x in range(resolution): - var x0 := x * step - half - var z0 := z * step - half - var x1 := (x + 1) * step - half - var z1 := (z + 1) * step - half - - var p00 := Vector3(x0, sample_height(x0, z0), z0) - var p10 := Vector3(x1, sample_height(x1, z0), z0) - var p01 := Vector3(x0, sample_height(x0, z1), z1) - var p11 := Vector3(x1, sample_height(x1, z1), z1) - - # Winding order chosen so generate_normals() produces UPWARD normals. - # (The reverse winding gave downward normals, so the sun lit the - # underside and the top surface rendered black — confirmed by an - # unshaded render showing the true bright-green albedo.) - _add_tri(st, p00, p11, p01, half, uv_scale) - _add_tri(st, p00, p10, p11, half, uv_scale) - - st.generate_normals() - - var mesh := st.commit() - var mi := MeshInstance3D.new() - mi.name = "TerrainMesh" - mi.mesh = mesh - var mat := StandardMaterial3D.new() - mat.albedo_color = Color(0.36, 0.47, 0.26) # mossy green (lit, not fluorescent) - mat.roughness = 0.95 - mi.material_override = mat - add_child(mi) - - # Collision: a HeightMapShape3D, NOT a concave trimesh. - # - # Root cause of the fall-through (confirmed by instrumented tests): a concave - # polygon shape — whether hand-built via set_faces() or mesh-derived via - # create_trimesh_collision() — is ONE-SIDED. It only collides on the front of - # each triangle, so with this mesh's winding the player dropped straight - # through from above. A box on the same StaticBody collided fine, which pinned - # the cause to the concave shape, not layers/timing/registration. - # - # HeightMapShape3D is the correct primitive for heightmap terrain: a grid of - # height samples, solid (two-sided), and far cheaper than a trimesh. - var hm := HeightMapShape3D.new() - hm.map_width = resolution + 1 - hm.map_depth = resolution + 1 - var data := PackedFloat32Array() - data.resize((resolution + 1) * (resolution + 1)) - var hi := 0 - for hz in range(resolution + 1): - for hx in range(resolution + 1): - var wx := hx * step - half - var wz := hz * step - half - data[hi] = sample_height(wx, wz) - hi += 1 - hm.map_data = data - - var body := StaticBody3D.new() - body.name = "TerrainBody" - add_child(body) - var col := CollisionShape3D.new() - col.name = "CollisionShape3D" - col.shape = hm - body.add_child(col) - # HeightMapShape3D samples are spaced 1 unit apart; scale the body so the - # collision grid covers the same `size` metres as the visual mesh. - body.scale = Vector3(step, 1.0, step) + _make_textures() + _build_mesh(n) + _build_collision(n) + _build_boundary() + _build_water() built = true ready_built.emit() -## World-space ground height at (x, z). -func sample_height(x: float, z: float) -> float: - _ensure_noise() - return _noise.get_noise_2d(x, z) * height_scale +func _make_textures() -> void: + var fn := FastNoiseLite.new() + fn.seed = noise_seed + 10 + fn.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + fn.frequency = 0.008 + fn.fractal_octaves = 5 + noise_tex = NoiseTexture2D.new() + noise_tex.width = 512 + noise_tex.height = 512 + noise_tex.seamless = true + noise_tex.noise = fn -func _add_tri(st: SurfaceTool, a: Vector3, b: Vector3, c: Vector3, half: float, uv_scale: float) -> void: - st.set_uv(Vector2((a.x + half) * uv_scale, (a.z + half) * uv_scale)) - st.add_vertex(a) - st.set_uv(Vector2((b.x + half) * uv_scale, (b.z + half) * uv_scale)) - st.add_vertex(b) - st.set_uv(Vector2((c.x + half) * uv_scale, (c.z + half) * uv_scale)) - st.add_vertex(c) + var fn2 := FastNoiseLite.new() + fn2.seed = noise_seed + 11 + fn2.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH + fn2.frequency = 0.02 + fn2.fractal_octaves = 4 + normal_tex = NoiseTexture2D.new() + normal_tex.width = 512 + normal_tex.height = 512 + normal_tex.seamless = true + normal_tex.as_normal_map = true + normal_tex.bump_strength = 6.0 + normal_tex.noise = fn2 + +func _build_mesh(n: int) -> void: + var verts := PackedVector3Array() + var uvs := PackedVector2Array() + verts.resize(n * n) + uvs.resize(n * n) + for iz in range(n): + for ix in range(n): + var i := iz * n + ix + verts[i] = Vector3(ix * step - half, _heights[i], iz * step - half) + uvs[i] = Vector2(float(ix) / resolution, float(iz) / resolution) + + var idx := PackedInt32Array() + idx.resize(resolution * resolution * 6) + var k := 0 + for iz in range(resolution): + for ix in range(resolution): + var i00 := iz * n + ix + var i10 := i00 + 1 + var i01 := i00 + n + var i11 := i01 + 1 + # Winding chosen so the top face is the front face in Godot. + idx[k] = i00; idx[k + 1] = i11; idx[k + 2] = i01 + idx[k + 3] = i00; idx[k + 4] = i10; idx[k + 5] = i11 + k += 6 + + var arrays := [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = verts + arrays[Mesh.ARRAY_NORMAL] = _normals + arrays[Mesh.ARRAY_TEX_UV] = uvs + arrays[Mesh.ARRAY_INDEX] = idx + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + + material = ShaderMaterial.new() + material.shader = preload("res://shaders/terrain.gdshader") + material.set_shader_parameter("noise_tex", noise_tex) + material.set_shader_parameter("normal_tex", normal_tex) + material.set_shader_parameter("water_level", water_level) + material.set_shader_parameter("snow_height", snow_height) + material.set_shader_parameter("grass_lush", grass_lush) + material.set_shader_parameter("grass_dry", grass_dry) + + var mi := MeshInstance3D.new() + mi.name = "TerrainMesh" + mi.mesh = mesh + mi.material_override = material + add_child(mi) + +func _build_collision(n: int) -> void: + var hm := HeightMapShape3D.new() + hm.map_width = n + hm.map_depth = n + hm.map_data = _heights + var body := StaticBody3D.new() + body.name = "TerrainBody" + add_child(body) + var col := CollisionShape3D.new() + col.shape = hm + body.add_child(col) + # HeightMapShape3D samples are 1 unit apart; scale to match the mesh. + body.scale = Vector3(step, 1.0, step) + +## Invisible wall ringing the valley partway up the mountain slopes, plus four +## world-boundary planes at the chunk edges as a last line of defence. +func _build_boundary() -> void: + var body := StaticBody3D.new() + body.name = "Boundary" + add_child(body) + + var segs := 128 + var pts: Array[Vector3] = [] + for i in range(segs): + var ang := TAU * i / segs + var dir := Vector3(cos(ang), 0.0, sin(ang)) + # Solve for the radius whose rim distance equals the wall threshold. + var r := _wall_threshold(dir.x, dir.z) - _rim_offset(dir.x, dir.z) + pts.append(dir * r) + _wall_ring = pts + for i in range(segs): + var a := pts[i] + var b := pts[(i + 1) % segs] + var mid := (a + b) * 0.5 + var length := a.distance_to(b) + 0.6 + var box := BoxShape3D.new() + box.size = Vector3(length, 400.0, 1.5) + var cs := CollisionShape3D.new() + cs.shape = box + var along := (b - a).normalized() + var seg_basis := Basis(along, Vector3.UP, along.cross(Vector3.UP)) + cs.transform = Transform3D(seg_basis, Vector3(mid.x, 100.0, mid.z)) + body.add_child(cs) + + var planes := [ + Plane(Vector3.RIGHT, -half), Plane(Vector3.LEFT, -half), + Plane(Vector3.BACK, -half), Plane(Vector3.FORWARD, -half), + ] + for p: Plane in planes: + var wb := WorldBoundaryShape3D.new() + wb.plane = p + var cs := CollisionShape3D.new() + cs.shape = wb + body.add_child(cs) + +## Two water bodies: inland water (lake and ponds) as a disc clipped to the +## boundary ring, so it can't hang in the air past the sea cliff; and an ocean +## plane out to the horizon at sea level. +func _build_water() -> void: + var verts := PackedVector3Array([Vector3.ZERO]) + for p in _wall_ring: + verts.append(p) + var count := verts.size() + var normals := PackedVector3Array() + var tangents := PackedFloat32Array() + var uvs := PackedVector2Array() + for v in verts: + normals.append(Vector3.UP) + tangents.append_array([1.0, 0.0, 0.0, 1.0]) + uvs.append(Vector2(v.x, v.z) / size + Vector2(0.5, 0.5)) + var idx := PackedInt32Array() + for i in range(1, count): + idx.append_array([0, i, i % (count - 1) + 1]) + var arrays := [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = verts + arrays[Mesh.ARRAY_NORMAL] = normals + arrays[Mesh.ARRAY_TANGENT] = tangents + arrays[Mesh.ARRAY_TEX_UV] = uvs + arrays[Mesh.ARRAY_INDEX] = idx + var disc := ArrayMesh.new() + disc.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + _add_water("Water", disc, water_level, {}) + + var ocean := PlaneMesh.new() + ocean.size = Vector2(8000.0, 8000.0) + _add_water("Ocean", ocean, sea_level, { + "shallow_col": Color(0.16, 0.44, 0.50), + "deep_col": Color(0.02, 0.10, 0.20), + "clarity": 6.0, + }) + +func _add_water(label: String, mesh: Mesh, y: float, params: Dictionary) -> void: + var mat := ShaderMaterial.new() + mat.shader = preload("res://shaders/water.gdshader") + mat.set_shader_parameter("noise_tex", noise_tex) + mat.set_shader_parameter("normal_tex", normal_tex) + for k in params: + mat.set_shader_parameter(k, params[k]) + var mi := MeshInstance3D.new() + mi.name = label + mi.mesh = mesh + mi.material_override = mat + mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + mi.position = Vector3(0.0, y, 0.0) + add_child(mi) + +# --- queries ---------------------------------------------------------------- + +func in_bounds(x: float, z: float) -> bool: + return absf(x) < half - step and absf(z) < half - step + +## True inside the walkable valley. A positive margin shrinks the area, a +## negative one extends it past the invisible wall. +func inside_boundary(x: float, z: float, margin: float = 0.0) -> bool: + _ensure_noise() + return _rim_d(x, z) < _wall_threshold(x, z) - margin + +## How strongly (x, z) lies on the sea side of the rim (0..1). +func sea_weight(x: float, z: float) -> float: + _ensure_noise() + return _sea_w(x, z) + +## Point on the coast along `angle_deg` (atan2(z, x) convention), at rim +## distance `past_edge` beyond the cliff edge (negative = inland of it). +func coast_point(angle_deg: float, past_edge: float) -> Vector2: + _ensure_noise() + var dir := Vector2(cos(deg_to_rad(angle_deg)), sin(deg_to_rad(angle_deg))) + var r := _cliff_d() + past_edge - _rim_offset(dir.x, dir.y) + return dir * r + +## Surface height at (x, z), interpolated on the same triangles as the mesh. +func sample_height(x: float, z: float) -> float: + if _heights.is_empty(): + return _height_fn(x, z) + var n := resolution + 1 + var gx := clampf((x + half) / step, 0.0, resolution - 0.0001) + var gz := clampf((z + half) / step, 0.0, resolution - 0.0001) + var ix := int(gx) + var iz := int(gz) + var fx := gx - ix + var fz := gz - iz + var h00 := _heights[iz * n + ix] + var h10 := _heights[iz * n + ix + 1] + var h01 := _heights[(iz + 1) * n + ix] + var h11 := _heights[(iz + 1) * n + ix + 1] + if fx > fz: + return h00 + (h10 - h00) * fx + (h11 - h10) * fz + return h00 + (h11 - h01) * fx + (h01 - h00) * fz + +## Smoothed surface normal at (x, z). +func sample_normal(x: float, z: float) -> Vector3: + if _normals.is_empty(): + return Vector3.UP + var n := resolution + 1 + var gx := clampf((x + half) / step, 0.0, resolution - 0.0001) + var gz := clampf((z + half) / step, 0.0, resolution - 0.0001) + var ix := int(gx) + var iz := int(gz) + var fx := gx - ix + var fz := gz - iz + var a := _normals[iz * n + ix].lerp(_normals[iz * n + ix + 1], fx) + var b := _normals[(iz + 1) * n + ix].lerp(_normals[(iz + 1) * n + ix + 1], fx) + return a.lerp(b, fz).normalized() + +## Nearest dry, gentle spot to `near` (spiral search), for spawning. +func find_spawn(near: Vector2) -> Vector3: + for ring in range(0, 30): + var r := ring * 2.0 + var count := maxi(1, ring * 6) + for i in range(count): + var ang := TAU * i / count + var x := near.x + cos(ang) * r + var z := near.y + sin(ang) * r + var h := sample_height(x, z) + if h > water_level + 1.2 and sample_normal(x, z).y > 0.93 and inside_boundary(x, z, 15.0): + return Vector3(x, h, z) + return Vector3(near.x, sample_height(near.x, near.y), near.y) diff --git a/scripts/world/terrain.gd.uid b/scripts/world/terrain.gd.uid new file mode 100644 index 0000000..20b8eed --- /dev/null +++ b/scripts/world/terrain.gd.uid @@ -0,0 +1 @@ +uid://bd438cmtac3r diff --git a/scripts/world/world.gd b/scripts/world/world.gd index c5ff5be..e1333c3 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -1,31 +1,116 @@ extends Node3D -## World controller — M0. +## World controller. ## ## Owns spawn ordering so the player never falls through ungenerated ground: -## waits for the terrain to finish building, then drops the player onto the -## surface height and lets gravity settle them. Also tells the props where the -## ground is so they sit on it instead of floating. +## waits for the terrain, places the scenery, then drops the player onto a dry, +## flat spot facing the lake. Also aims the sun, and respawns the player if they +## somehow leave the map or fall below it (a safety net behind the mountain rim +## and invisible wall in terrain.gd). +## +## Dev hook: `godot --path . -- --shot=/abs/prefix` renders a few fixed views to +## _.png and quits. Handy for checking visual changes. + +@export var sun_elevation_deg: float = 34.0 +@export var sun_azimuth_deg: float = 40.0 ## 0 = behind the spawn view, + = to its right @onready var terrain: Node3D = $Terrain @onready var props: Node3D = $Props @onready var player: CharacterBody3D = $Player +@onready var sun: DirectionalLight3D = $Sun + +var _spawn := Vector3.ZERO +var _spawn_yaw := 0.0 func _ready() -> void: - # Freeze the player until the ground exists. + _aim_sun() player.set_physics_process(false) + set_physics_process(false) if terrain.built: _on_terrain_ready() else: terrain.ready_built.connect(_on_terrain_ready) -func _on_terrain_ready() -> void: - # Let props place themselves on the surface. - if props.has_method("place_on"): - props.place_on(terrain) +func _aim_sun() -> void: + var el := deg_to_rad(sun_elevation_deg) + var az := deg_to_rad(sun_azimuth_deg) + var to_sun := Vector3(sin(az) * cos(el), sin(el), cos(az) * cos(el)) + sun.global_basis = Basis.looking_at(-to_sun, Vector3.UP) - # Spawn the player on the ground at (0,0), a little above so they settle down - # onto the collision rather than starting embedded in it. - var h: float = terrain.sample_height(0.0, 0.0) - player.global_position = Vector3(0.0, h + 2.0, 0.0) - player.velocity = Vector3.ZERO +func _on_terrain_ready() -> void: + var t0 := Time.get_ticks_msec() + _spawn = terrain.find_spawn(Vector2.ZERO) + var lake: Vector2 = terrain.lake_center + var to_lake := lake - Vector2(_spawn.x, _spawn.z) + _spawn_yaw = atan2(-to_lake.x, -to_lake.y) + player.water_level = terrain.water_level + props.place_on(terrain, player, _spawn) + print("[world] scenery placed in %d ms" % (Time.get_ticks_msec() - t0)) + _respawn() player.set_physics_process(true) + set_physics_process(true) + _maybe_capture() + +func _respawn() -> void: + player.global_position = _spawn + Vector3(0.0, 1.5, 0.0) + player.velocity = Vector3.ZERO + player.rotation = Vector3(0.0, _spawn_yaw, 0.0) + player.get_node("Camera3D").rotation.x = 0.0 + +func _physics_process(_delta: float) -> void: + var p := player.global_position + var half: float = terrain.size * 0.5 + if p.y < terrain.min_height - 30.0 or absf(p.x) > half or absf(p.z) > half \ + or not terrain.inside_boundary(p.x, p.z, -4.0): + push_warning("[world] player left the map at %s; respawning" % p) + _respawn() + +# --- dev screenshot hook ---------------------------------------------------- + +func _maybe_capture() -> void: + var prefix := "" + for a in OS.get_cmdline_user_args(): + if a.begins_with("--shot="): + prefix = a.trim_prefix("--shot=") + if prefix.is_empty(): + return + var lake: Vector2 = terrain.lake_center + # [name, eye position (y = height above ground), look-at point] ; null = spawn view + var views := [ + ["spawn", null, null], + ["aerial", Vector3(0, 110, 150), Vector3(0, 0, -20)], + ["lake", Vector3(lake.x - 30.0, 3.0, lake.y + 22.0), Vector3(lake.x, -2.0, lake.y)], + ["forest", Vector3(-40, 1.7, 30), Vector3(-70, 4.0, 10)], + ["rim", Vector3(0, 2.0, 70), Vector3(0, 25.0, 125)], + ] + # Sea-cliff views: from the cliff top out to sea, and from the water back at the cliff. + var sa: float = terrain.sea_angle_deg + var top: Vector2 = terrain.coast_point(sa + 12.0, -6.0) + var out: Vector2 = terrain.coast_point(sa + 12.0, 200.0) + views.append(["seacliff_top", Vector3(top.x, 1.7, top.y), Vector3(out.x, terrain.sea_level - 6.0, out.y)]) + var boat: Vector2 = terrain.coast_point(sa - 6.0, 38.0) + var face: Vector2 = terrain.coast_point(sa + 4.0, 0.0) + var boat_eye := Vector3(boat.x, terrain.sea_level + 3.0 - terrain.sample_height(boat.x, boat.y), boat.y) + views.append(["seacliff_from_sea", boat_eye, Vector3(face.x, terrain.sea_level + 8.0, face.y)]) + # Along the cliff edge into the western headland (where the old pit was). + var walk: Vector2 = terrain.coast_point(sa - 20.0, -8.0) + var head: Vector2 = terrain.coast_point(sa - 52.0, 0.0) + views.append(["headland", Vector3(walk.x, 1.7, walk.y), Vector3(head.x, 2.0, head.y)]) + await get_tree().create_timer(4.0).timeout + set_physics_process(false) # views may sit outside the walkable area + var cam: Camera3D = player.get_node("Camera3D") + for v in views: + if v[1] != null: + player.set_physics_process(false) + var eye: Vector3 = v[1] + eye.y += terrain.sample_height(eye.x, eye.z) + var dir: Vector3 = (v[2] as Vector3) - eye + player.global_position = eye - Vector3(0.0, cam.position.y, 0.0) + player.rotation = Vector3(0.0, atan2(-dir.x, -dir.z), 0.0) + cam.rotation.x = atan2(dir.y, Vector2(dir.x, dir.z).length()) + props._update_grass(100000) + await get_tree().create_timer(1.0).timeout + await RenderingServer.frame_post_draw + var path := "%s_%s.png" % [prefix, v[0]] + get_viewport().get_texture().get_image().save_png(path) + print("[world] saved ", path) + get_tree().quit() diff --git a/scripts/world/world.gd.uid b/scripts/world/world.gd.uid new file mode 100644 index 0000000..8f0b0ac --- /dev/null +++ b/scripts/world/world.gd.uid @@ -0,0 +1 @@ +uid://l8eo7vy8f3np diff --git a/shaders/common.gdshaderinc b/shaders/common.gdshaderinc new file mode 100644 index 0000000..b229e38 --- /dev/null +++ b/shaders/common.gdshaderinc @@ -0,0 +1,23 @@ +// Shared helpers for the world shaders. +// Declares `noise_tex` (a seamless NoiseTexture2D set from terrain.gd) so every +// shader samples the same noise, which keeps grass, terrain and rock colour +// variation lined up with each other. + +uniform sampler2D noise_tex : filter_linear_mipmap, repeat_enable; + +float nz(vec2 p) { + return texture(noise_tex, p).r; +} + +// Triplanar noise: samples along all three axes so steep faces (cliffs, rock +// sides) don't show the stretched streaks a top-down projection would give. +float tri_nz(vec3 p, vec3 n, float s) { + vec3 w = pow(abs(n), vec3(4.0)); + w /= (w.x + w.y + w.z); + return nz(p.zy * s) * w.x + nz(p.xz * s) * w.y + nz(p.xy * s) * w.z; +} + +// Vertex colours are authored as sRGB; lighting needs linear. +vec3 to_linear(vec3 c) { + return pow(c, vec3(2.2)); +} diff --git a/shaders/common.gdshaderinc.uid b/shaders/common.gdshaderinc.uid new file mode 100644 index 0000000..beafa3a --- /dev/null +++ b/shaders/common.gdshaderinc.uid @@ -0,0 +1 @@ +uid://dir1djw6ljm4w diff --git a/shaders/foliage.gdshader b/shaders/foliage.gdshader new file mode 100644 index 0000000..55a8fd6 --- /dev/null +++ b/shaders/foliage.gdshader @@ -0,0 +1,34 @@ +shader_type spatial; +render_mode cull_back, diffuse_burley; + +// Tree canopies and bushes. Colour comes from per-face vertex colours (set by +// proc_mesh.gd), tinted per instance so no two trees are quite the same. +// Wind: slow whole-crown sway growing with height, plus a small leaf flutter. + +#include "res://shaders/common.gdshaderinc" + +uniform float wind_strength = 0.04; +uniform float flutter = 0.03; + +varying vec3 inst_pos; + +void vertex() { + inst_pos = MODEL_MATRIX[3].xyz; + float h = max(VERTEX.y - 1.2, 0.0); + float phase = dot(inst_pos.xz, vec2(0.13, 0.17)); + float gust = nz(inst_pos.xz * 0.012 + vec2(TIME * 0.02, TIME * 0.013)); + vec3 sway = vec3(sin(TIME * 0.9 + phase), 0.0, cos(TIME * 0.7 + phase * 1.3) * 0.6); + sway *= wind_strength * h * (0.4 + gust); + sway += vec3(sin(TIME * 3.7 + dot(VERTEX, vec3(3.1, 1.7, 2.3)) + phase)) * flutter * clamp(VERTEX.y, 0.0, 1.0); + VERTEX += (inverse(MODEL_MATRIX) * vec4(sway, 0.0)).xyz; +} + +void fragment() { + float v = nz(inst_pos.xz * 0.045); + vec3 tint = mix(vec3(0.88, 1.0, 0.85), vec3(1.12, 1.04, 0.78), v); + vec3 col = to_linear(COLOR.rgb) * tint; + ALBEDO = col; + ROUGHNESS = 0.85; + SPECULAR = 0.3; + BACKLIGHT = col * 0.5; // light glowing through leaves when backlit +} diff --git a/shaders/foliage.gdshader.uid b/shaders/foliage.gdshader.uid new file mode 100644 index 0000000..6c90d43 --- /dev/null +++ b/shaders/foliage.gdshader.uid @@ -0,0 +1 @@ +uid://di7bbdh70ax4b diff --git a/shaders/grass.gdshader b/shaders/grass.gdshader new file mode 100644 index 0000000..00cb8ed --- /dev/null +++ b/shaders/grass.gdshader @@ -0,0 +1,45 @@ +shader_type spatial; +render_mode cull_disabled, diffuse_burley; + +// Grass clumps (MultiMesh). UV.y runs 0 at the root to 1 at the tip and drives +// both wind bend and the root-to-tip colour gradient. Base colour uses the same +// noise lookups as terrain.gdshader so the blades blend into the ground beneath. + +#include "res://shaders/common.gdshaderinc" + +uniform vec3 grass_lush : source_color = vec3(0.25, 0.40, 0.15); +uniform vec3 grass_dry : source_color = vec3(0.48, 0.50, 0.22); +uniform vec3 tip_tint : source_color = vec3(0.78, 0.88, 0.50); +uniform float wind_strength = 0.2; +uniform vec2 wind_dir = vec2(1.0, 0.35); + +varying vec3 inst_pos; +varying float hfac; + +void vertex() { + inst_pos = MODEL_MATRIX[3].xyz; + hfac = UV.y; + vec3 wp = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + vec2 wd = normalize(wind_dir); + float gust = nz(wp.xz * 0.015 - TIME * 0.05 * wd); + float sway = sin(TIME * 1.8 + wp.x * 0.35 + wp.z * 0.25) * 0.35 + gust * 1.3 - 0.4; + float k = wind_strength * hfac * hfac; + vec3 off = vec3(wd.x * sway * k, -abs(sway) * k * 0.3, wd.y * sway * k); + VERTEX += (inverse(MODEL_MATRIX) * vec4(off, 0.0)).xyz; +} + +void fragment() { + float macro = nz(inst_pos.xz * 0.0035); + float mid = nz(inst_pos.xz * 0.021 + 0.37); + vec3 base = mix(grass_lush, grass_dry, smoothstep(0.48, 0.78, macro + (mid - 0.5) * 0.35)); + base *= 0.82 + 0.36 * nz(inst_pos.xz * 0.17); + vec3 col = base * mix(0.55, 1.0, hfac); // darker at the root + col = mix(col, col * tip_tint * 1.3, smoothstep(0.6, 1.0, hfac) * 0.3); + ALBEDO = col; + ROUGHNESS = 0.9; + SPECULAR = 0.2; + BACKLIGHT = vec3(0.35, 0.4, 0.15) * hfac; + // Light blades as if they faced up (same as the ground), so a field reads as + // one soft surface instead of thousands of flickering cards. + NORMAL = normalize((VIEW_MATRIX * vec4(0.0, 1.0, 0.0, 0.0)).xyz); +} diff --git a/shaders/grass.gdshader.uid b/shaders/grass.gdshader.uid new file mode 100644 index 0000000..0c7e0e6 --- /dev/null +++ b/shaders/grass.gdshader.uid @@ -0,0 +1 @@ +uid://46hvkgp2jvps diff --git a/shaders/rock.gdshader b/shaders/rock.gdshader new file mode 100644 index 0000000..b6015b1 --- /dev/null +++ b/shaders/rock.gdshader @@ -0,0 +1,40 @@ +shader_type spatial; +render_mode cull_back, diffuse_burley; + +// Faceted low-poly rocks: triplanar grain, per-rock tint, moss on upward faces +// (on some rocks more than others), snow on top at altitude. + +#include "res://shaders/common.gdshaderinc" + +uniform vec3 moss_col : source_color = vec3(0.27, 0.38, 0.15); +uniform vec3 snow_col : source_color = vec3(0.93, 0.95, 0.98); +uniform float snow_height = 40.0; + +varying vec3 w_pos; +varying vec3 w_nrm; +varying vec3 inst_pos; + +void vertex() { + w_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + w_nrm = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz); + inst_pos = MODEL_MATRIX[3].xyz; +} + +void fragment() { + float a = tri_nz(w_pos, w_nrm, 0.3); + float b = tri_nz(w_pos, w_nrm, 1.4); + vec3 base = to_linear(COLOR.rgb) * (0.7 + 0.6 * a) * (0.88 + 0.24 * b); + base *= mix(vec3(1.04, 1.0, 0.94), vec3(0.94, 0.97, 1.03), nz(inst_pos.xz * 0.07)); + + float mossy = smoothstep(0.3, 0.7, nz(inst_pos.xz * 0.021 + 0.5)); + float moss = smoothstep(0.45, 0.8, w_nrm.y + (a - 0.5) * 0.7) * mossy; + vec3 col = mix(base, moss_col * (0.75 + 0.5 * b), moss); + + float snow = smoothstep(snow_height - 3.0, snow_height + 3.0, w_pos.y + (a - 0.5) * 10.0); + snow *= smoothstep(0.35, 0.7, w_nrm.y); + col = mix(col, snow_col, snow); + + ALBEDO = col; + ROUGHNESS = mix(mix(0.8, 0.95, moss), 0.6, snow); + SPECULAR = 0.35; +} diff --git a/shaders/rock.gdshader.uid b/shaders/rock.gdshader.uid new file mode 100644 index 0000000..7df3f80 --- /dev/null +++ b/shaders/rock.gdshader.uid @@ -0,0 +1 @@ +uid://dnqc6nl4l50ih diff --git a/shaders/sky.gdshader b/shaders/sky.gdshader new file mode 100644 index 0000000..5d32676 --- /dev/null +++ b/shaders/sky.gdshader @@ -0,0 +1,41 @@ +shader_type sky; + +// Gradient sky + sun disc/halo + a static cloud layer projected onto a dome. +// Deliberately no TIME: a time-varying sky forces Godot to rebuild the ambient +// radiance map every frame. + +uniform vec3 zenith_col : source_color = vec3(0.24, 0.44, 0.72); +uniform vec3 horizon_col : source_color = vec3(0.72, 0.79, 0.85); +uniform vec3 ground_col : source_color = vec3(0.30, 0.33, 0.32); +uniform vec3 sun_tint : source_color = vec3(1.0, 0.86, 0.62); +uniform sampler2D cloud_tex : filter_linear_mipmap, repeat_enable; +uniform float cloud_cover = 0.5; +uniform float energy = 1.0; + +void sky() { + vec3 d = EYEDIR; + float h = d.y; + vec3 col = mix(horizon_col, zenith_col, pow(smoothstep(0.0, 0.6, h), 0.7)); + col = mix(col, ground_col, smoothstep(0.0, -0.12, h)); + + float sd = 0.0; + if (LIGHT0_ENABLED) { + sd = max(dot(d, LIGHT0_DIRECTION), 0.0); + col += sun_tint * pow(sd, 6.0) * 0.35 * (1.0 - smoothstep(0.0, 0.5, h)); + col += sun_tint * pow(sd, 64.0) * 0.6; + col += LIGHT0_COLOR * smoothstep(0.9994, 0.9997, sd) * 20.0; + } + + if (h > 0.0) { + vec2 uv = d.xz / (h + 0.12); + float c = texture(cloud_tex, uv * 0.08).r * 0.65 + texture(cloud_tex, uv * 0.23 + 0.31).r * 0.35; + float cov = smoothstep(1.0 - cloud_cover, 1.0 - cloud_cover + 0.22, c); + cov *= smoothstep(0.0, 0.18, h); + float shade = smoothstep(0.35, 1.0, c); + vec3 cloud = mix(vec3(1.0), vec3(0.62, 0.66, 0.72), shade * 0.8); + cloud += sun_tint * pow(sd, 4.0) * 0.5; + col = mix(col, cloud, cov * 0.9); + } + + COLOR = col * energy; +} diff --git a/shaders/sky.gdshader.uid b/shaders/sky.gdshader.uid new file mode 100644 index 0000000..bacd189 --- /dev/null +++ b/shaders/sky.gdshader.uid @@ -0,0 +1 @@ +uid://c5obebod41tnk diff --git a/shaders/terrain.gdshader b/shaders/terrain.gdshader new file mode 100644 index 0000000..e6e6e03 --- /dev/null +++ b/shaders/terrain.gdshader @@ -0,0 +1,74 @@ +shader_type spatial; +render_mode cull_back, diffuse_burley, specular_schlick_ggx; + +// Procedural terrain material: no textures to author. Colour comes from world +// position + slope + height, layered at several noise scales so the ground +// doesn't look like one flat colour from any distance. + +#include "res://shaders/common.gdshaderinc" + +uniform sampler2D normal_tex : hint_normal, filter_linear_mipmap, repeat_enable; + +uniform vec3 grass_lush : source_color = vec3(0.25, 0.40, 0.15); +uniform vec3 grass_dry : source_color = vec3(0.48, 0.50, 0.22); +uniform vec3 dirt_col : source_color = vec3(0.34, 0.26, 0.17); +uniform vec3 rock_light : source_color = vec3(0.50, 0.48, 0.45); +uniform vec3 rock_dark : source_color = vec3(0.26, 0.26, 0.27); +uniform vec3 sand_col : source_color = vec3(0.62, 0.56, 0.42); +uniform vec3 snow_col : source_color = vec3(0.93, 0.95, 0.98); +uniform float water_level = -2.6; +uniform float snow_height = 40.0; + +varying vec3 w_pos; +varying vec3 w_nrm; + +void vertex() { + w_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; + w_nrm = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz); + // The terrain mesh has no authored tangents; build a frame for the detail + // normal map. (Terrain normals are never parallel to Z, so this is safe.) + TANGENT = normalize(cross(NORMAL, vec3(0.0, 0.0, 1.0))); + BINORMAL = normalize(cross(TANGENT, NORMAL)); +} + +void fragment() { + float slope = 1.0 - clamp(w_nrm.y, 0.0, 1.0); + float macro = nz(w_pos.xz * 0.0035); + float mid = nz(w_pos.xz * 0.021 + 0.37); + float fine = nz(w_pos.xz * 0.17); + float micro = nz(w_pos.xz * 0.9); + + // Grass: lush/dry regions, then mottling at two finer scales. + vec3 col = mix(grass_lush, grass_dry, smoothstep(0.48, 0.78, macro + (mid - 0.5) * 0.35)); + col *= 0.82 + 0.36 * fine; + col *= 0.92 + 0.16 * micro; + + // Bare dirt patches. + float dirt_m = smoothstep(0.64, 0.74, mid + (fine - 0.5) * 0.35); + col = mix(col, dirt_col * (0.8 + 0.4 * micro), dirt_m); + + // Sandy shoreline, darker mud below the waterline. + float sand_m = 1.0 - smoothstep(water_level + 0.3, water_level + 1.2 + fine * 0.6, w_pos.y); + col = mix(col, sand_col * (0.85 + 0.3 * micro), sand_m); + col = mix(col, dirt_col * 0.6, 1.0 - smoothstep(water_level - 1.8, water_level - 0.2, w_pos.y)); + + // Rock on steep ground, with horizontal strata so cliffs read as layered stone. + float tn = tri_nz(w_pos, w_nrm, 0.08); + float tn2 = tri_nz(w_pos, w_nrm, 0.45); + float strata = sin(w_pos.y * 2.2 + tn * 9.0) * 0.5 + 0.5; + vec3 rock = mix(rock_dark, rock_light, clamp(tn * 0.9 + strata * 0.35 - 0.1, 0.0, 1.0)); + rock *= 0.82 + 0.36 * tn2; + float rock_m = smoothstep(0.28, 0.42, slope + (fine - 0.5) * 0.18); + col = mix(col, rock, rock_m); + + // Snow on the flatter parts of the peaks. + float snow_m = smoothstep(snow_height - 3.0, snow_height + 3.0, w_pos.y + (mid - 0.5) * 14.0); + snow_m *= 1.0 - smoothstep(0.5, 0.7, slope); + col = mix(col, snow_col * (0.92 + 0.08 * fine), snow_m); + + ALBEDO = col; + ROUGHNESS = mix(mix(0.95, 0.82, rock_m), 0.6, snow_m); + SPECULAR = 0.3; + NORMAL_MAP = texture(normal_tex, w_pos.xz * 0.35).rgb; + NORMAL_MAP_DEPTH = mix(0.5, 1.4, rock_m) * (1.0 - snow_m * 0.7); +} diff --git a/shaders/terrain.gdshader.uid b/shaders/terrain.gdshader.uid new file mode 100644 index 0000000..a8c0ea7 --- /dev/null +++ b/shaders/terrain.gdshader.uid @@ -0,0 +1 @@ +uid://cvhdrgrkmjy8i diff --git a/shaders/water.gdshader b/shaders/water.gdshader new file mode 100644 index 0000000..70c0008 --- /dev/null +++ b/shaders/water.gdshader @@ -0,0 +1,47 @@ +shader_type spatial; +render_mode blend_mix, cull_disabled, diffuse_burley, specular_schlick_ggx; + +// Lake water: two scrolling normal maps for ripples, colour and opacity from +// the depth of water under each pixel (clear shallows, dark deep water), and +// foam where the surface meets the shore. + +#include "res://shaders/common.gdshaderinc" + +uniform sampler2D DEPTH_TEXTURE : hint_depth_texture, filter_linear_mipmap; +uniform sampler2D normal_tex : hint_normal, filter_linear_mipmap, repeat_enable; +uniform vec3 shallow_col : source_color = vec3(0.22, 0.44, 0.42); +uniform vec3 deep_col : source_color = vec3(0.03, 0.12, 0.17); +uniform float clarity = 2.5; + +varying vec3 w_pos; + +void vertex() { + w_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; +} + +void fragment() { + vec2 uv = w_pos.xz; + vec3 n1 = texture(normal_tex, uv * 0.045 + TIME * vec2(0.012, 0.008)).rgb; + vec3 n2 = texture(normal_tex, uv * 0.11 + TIME * vec2(-0.010, 0.017)).rgb; + NORMAL_MAP = mix(n1, n2, 0.5); + NORMAL_MAP_DEPTH = 0.7; + + // Thickness of water between the surface and whatever is behind it. + // (Godot 4.3+ uses reversed-Z: raw depth is the NDC z.) + float depth = texture(DEPTH_TEXTURE, SCREEN_UV).r; + vec4 behind = INV_PROJECTION_MATRIX * vec4(SCREEN_UV * 2.0 - 1.0, depth, 1.0); + behind.xyz /= behind.w; + float thick = max(VERTEX.z - behind.z, 0.0); + + float t = 1.0 - exp(-thick / clarity); + vec3 col = mix(shallow_col, deep_col, t); + float foam_n = nz(uv * 0.6 + TIME * 0.02); + float foam = (1.0 - smoothstep(0.0, 0.25 + foam_n * 0.35, thick)) * 0.8; + col = mix(col, vec3(0.88, 0.93, 0.93), foam); + + ALBEDO = col; + ALPHA = clamp(mix(0.3, 0.93, t) + foam, 0.0, 1.0); + ROUGHNESS = 0.04; + SPECULAR = 0.6; + METALLIC = 0.0; +} diff --git a/shaders/water.gdshader.uid b/shaders/water.gdshader.uid new file mode 100644 index 0000000..86b6c02 --- /dev/null +++ b/shaders/water.gdshader.uid @@ -0,0 +1 @@ +uid://cuumgc10rnbll diff --git a/tests/test_boundary.gd b/tests/test_boundary.gd new file mode 100644 index 0000000..1d0cccf --- /dev/null +++ b/tests/test_boundary.gd @@ -0,0 +1,57 @@ +extends SceneTree +## Boundary test: tries to escape the valley in several directions by walking +## and spamming jump at the mountain rim, and fails if the player ever gets past +## the invisible wall, falls off the map, or ends up stuck below the waterline +## (in a pit near the edge). +## +## Run: godot --headless --path . --fixed-fps 30 --script res://tests/test_boundary.gd + +const DIRECTIONS := 24 +const SECONDS_PER_TRY := 25.0 + +func _initialize() -> void: + var world: Node3D = load("res://scenes/world.tscn").instantiate() + root.add_child(world) + _run.call_deferred(world) + +func _run(world: Node3D) -> void: + for _i in range(10): + await physics_frame + var terrain: Node3D = world.get_node("Terrain") + var player: CharacterBody3D = world.get_node("Player") + var failures := 0 + var tick := 1.0 / Engine.physics_ticks_per_second + for i in range(DIRECTIONS): + var ang := TAU * i / DIRECTIONS + var out := Vector3(cos(ang), 0.0, sin(ang)) + var start: Vector3 = out * (terrain.play_radius - 12.0) + start.y = terrain.sample_height(start.x, start.z) + 1.0 + player.global_position = start + player.velocity = Vector3.ZERO + player.rotation = Vector3(0.0, atan2(-out.x, -out.z), 0.0) + Input.action_press("move_forward") + var escaped := false + var max_d := 0.0 + var frames := int(SECONDS_PER_TRY / tick) + for f in range(frames): + if f % 12 == 0: + Input.action_press("jump") + elif f % 12 == 1: + Input.action_release("jump") + await physics_frame + var p := player.global_position + max_d = maxf(max_d, Vector2(p.x, p.z).length()) + if not terrain.inside_boundary(p.x, p.z, -1.5) or p.y < terrain.min_height - 5.0: + escaped = true + break + Input.action_release("move_forward") + Input.action_release("jump") + var p := player.global_position + if not escaped and p.y < terrain.water_level - 0.5: + escaped = true # stuck in a pit below the waterline + print("dir %2d (%3d deg): %s max radius %.1f final (%.1f, %.1f, %.1f)" % [ + i, int(rad_to_deg(ang)), "FAILED" if escaped else "held", max_d, p.x, p.y, p.z]) + if escaped: + failures += 1 + print("RESULT: %s (%d/%d directions held)" % ["PASS" if failures == 0 else "FAIL", DIRECTIONS - failures, DIRECTIONS]) + quit(1 if failures > 0 else 0) diff --git a/tests/test_boundary.gd.uid b/tests/test_boundary.gd.uid new file mode 100644 index 0000000..55043b6 --- /dev/null +++ b/tests/test_boundary.gd.uid @@ -0,0 +1 @@ +uid://qlm8an6ka4dw diff --git a/tests/test_terrain_edges.gd b/tests/test_terrain_edges.gd new file mode 100644 index 0000000..475901d --- /dev/null +++ b/tests/test_terrain_edges.gd @@ -0,0 +1,39 @@ +extends SceneTree +## Terrain edge check: there must be no ground below the inland waterline in the +## band just inside the boundary wall. Ground like that shows up as inland water +## hanging over a drop (the lake water surface stops at the wall) and makes pits +## the player can fall into and not climb out of. +## +## Run: godot --headless --path . --script res://tests/test_terrain_edges.gd + +const BAND := 10.0 # metres inside the wall to check + +func _initialize() -> void: + var terrain: Node3D = load("res://scripts/world/terrain.gd").new() + root.add_child(terrain) # builds in _ready + _run.call_deferred(terrain) + +func _run(terrain: Node3D) -> void: + var bad := 0 + var worst := INF + var worst_at := Vector2.ZERO + var half: float = terrain.size * 0.5 + var x := -half + while x < half: + var z := -half + while z < half: + if terrain.inside_boundary(x, z, 0.0) and not terrain.inside_boundary(x, z, BAND): + var h: float = terrain.sample_height(x, z) + if h < terrain.water_level + 0.2: + bad += 1 + if h < worst: + worst = h + worst_at = Vector2(x, z) + z += 1.0 + x += 1.0 + if bad > 0: + print("FAIL: %d samples near the wall are below the waterline; lowest %.1f m at (%.0f, %.0f), sea weight %.2f" % [ + bad, worst, worst_at.x, worst_at.y, terrain.sea_weight(worst_at.x, worst_at.y)]) + else: + print("PASS: ground near the wall stays above the waterline") + quit(1 if bad > 0 else 0)