diff --git a/scripts/world/terrain.gd b/scripts/world/terrain.gd index 7862701..7a73ca4 100644 --- a/scripts/world/terrain.gd +++ b/scripts/world/terrain.gd @@ -6,11 +6,12 @@ extends Node3D ## _ready(). Chunk streaming, biomes and server-side determinism (see ## docs/ARCHITECTURE.md §4) come in later milestones. ## -## The mesh is generated in code (no asset needed) so the project runs the moment -## Godot opens it, with nothing to import. +## 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. ## ## Exposes `sample_height(x, z)` so other systems (player spawn, prop placement) -## can sit exactly on the surface instead of guessing a Y and falling through. +## can sit exactly on the surface. signal ready_built ## emitted once the mesh + collision exist @@ -25,13 +26,18 @@ var built: bool = false func _ready() -> void: _build() -func _build() -> void: +func _ensure_noise() -> void: + if _noise != null: + return _noise = FastNoiseLite.new() _noise.seed = noise_seed _noise.noise_type = FastNoiseLite.TYPE_PERLIN _noise.frequency = 0.012 _noise.fractal_octaves = 4 +func _build() -> void: + _ensure_noise() + var st := SurfaceTool.new() st.begin(Mesh.PRIMITIVE_TRIANGLES) @@ -39,8 +45,6 @@ func _build() -> void: var half := size * 0.5 var uv_scale := 1.0 / float(size) - # Build a grid of quads, two triangles each. Each vertex carries a planar UV - # (world-XZ mapped to 0..1) so the surface is ready for texturing later. for z in range(resolution): for x in range(resolution): var x0 := x * step - half @@ -56,43 +60,60 @@ func _build() -> void: _add_tri(st, p00, p01, p11, half, uv_scale) _add_tri(st, p00, p11, p10, half, uv_scale) - # Normals give the lighting its shape. Tangents are only needed for normal - # maps, which this low-poly material doesn't use — so we skip them. st.generate_normals() var mesh := st.commit() var mi := MeshInstance3D.new() mi.name = "TerrainMesh" mi.mesh = mesh - - # Simple ground material. Low-poly look = flat-ish colour + lighting does the rest. var mat := StandardMaterial3D.new() mat.albedo_color = Color(0.33, 0.45, 0.22) # mossy green mat.roughness = 0.95 mi.material_override = mat add_child(mi) - # Collision so the player can walk on it. Build the body BEFORE marking built - # so anything spawning on ready_built already has ground under it. + # 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" - var shape := CollisionShape3D.new() - shape.shape = mesh.create_trimesh_shape() - body.add_child(shape) 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) built = true ready_built.emit() -## World-space ground height at (x, z). Safe to call before or after _build() -## because it lazily creates the noise object. +## World-space ground height at (x, z). func sample_height(x: float, z: float) -> float: - if _noise == null: - _noise = FastNoiseLite.new() - _noise.seed = noise_seed - _noise.noise_type = FastNoiseLite.TYPE_PERLIN - _noise.frequency = 0.012 - _noise.fractal_octaves = 4 + _ensure_noise() return _noise.get_noise_2d(x, z) * height_scale func _add_tri(st: SurfaceTool, a: Vector3, b: Vector3, c: Vector3, half: float, uv_scale: float) -> void: