public partial class Terrain : StaticBody3D { [Export] public Vector2I Size { get; set; } = new(64, 64); [Export] public float TileSize { get; set; } = 2.0f; [Export] public ShaderMaterial Material { get; set; } // If value at position non-existant => [ 0, 0, 0, 0 ] // If value at position is float => [ v, v, v, v ] // If value at position is float[] => value [Export] public Godot.Collections.Dictionary Tiles { get; set; } Material _editToolMaterial; public override void _Ready() { _editToolMaterial = new StandardMaterial3D { VertexColorUseAsAlbedo = true, ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded, DepthDrawMode = BaseMaterial3D.DepthDrawModeEnum.Disabled, NoDepthTest = true, }; UpdateMeshAndShape(); } public bool Contains(TilePos pos) => (pos.X >= 0) && (pos.X < Size.X) && (pos.Y >= 0) && (pos.Y < Size.Y); /// Transforms a 3D position local to the equivalent tile position. public TilePos ToTilePos(Vector3 localPos) => new(RoundToInt(localPos.X / TileSize + Size.X / 2.0f), RoundToInt(localPos.Z / TileSize + Size.Y / 2.0f)); public Corners GetCornerHeights(TilePos pos) => GetTileRaw(pos) switch { { VariantType: Variant.Type.Nil } => default, { VariantType: Variant.Type.Float } v => Corners.From((float)v), { VariantType: Variant.Type.PackedFloat32Array } v => Corners.From((float[])v), _ => throw new Exception("Invalid type in Points dictionary"), }; public void SetCornerHeight(TilePos pos, Corner corner, float value) => SetCornerHeights(pos, default(Corners).With(corner, value)); public void SetCornerHeights(TilePos pos, Corners values) => SetCornerHeights(pos, new Corners(values.TopLeft, values.TopRight, values.BottomRight, values.BottomLeft)); public void SetCornerHeights(TilePos pos, Corners values) { if (!Contains(pos)) return; var current = GetCornerHeights(pos); var changed = false; foreach (var corner in Enum.GetValues()) if (values[corner] is float value) { current = current.With(corner, value); changed = true; } if (!changed) return; if (current.IsZeroApprox()) RemoveTileRaw(pos); else if (current.IsEqualApprox()) SetTileRaw(pos, current.TopLeft); else SetTileRaw(pos, current.ToArray()); } public void SetTileHeight(TilePos pos, float value) { if (!Contains(pos)) return; if (IsZeroApprox(value)) RemoveTileRaw(pos); else SetTileRaw(pos, value); } public void AdjustTileHeight(TilePos pos, float relative) { if (relative == 0) return; if (!Contains(pos)) return; switch (GetTileRaw(pos)) { case { VariantType: Variant.Type.Nil }: SetTileRaw(pos, relative); break; case { VariantType: Variant.Type.Float } num: var newNum = (float)num + relative; if (IsZeroApprox(newNum)) RemoveTileRaw(pos); else SetTileRaw(pos, newNum); break; case { VariantType: Variant.Type.PackedFloat32Array } nums: var newNums = (float[])nums; for (var i = 0; i < 4; i++) newNums[i] += relative; SetTileRaw(pos, newNums); break; default: throw new Exception("Invalid type in Points dictionary"); }; } public Corners GetCornersPosition(TilePos pos) { var heights = GetCornerHeights(pos); var vx = (pos.X - Size.X / 2.0f) * TileSize; var vz = (pos.Y - Size.Y / 2.0f) * TileSize; var half = TileSize / 2; return new(new(vx - half, heights[Corner.TopLeft ], vz - half), new(vx + half, heights[Corner.TopRight ], vz - half), new(vx + half, heights[Corner.BottomRight], vz + half), new(vx - half, heights[Corner.BottomLeft ], vz + half)); } Variant GetTileRaw(TilePos pos) => (Tiles?.TryGetValue(pos.ToVector2I(), out var result) == true) ? result : default; void SetTileRaw(TilePos pos, Variant value) => (Tiles ??= [])[pos.ToVector2I()] = value; void RemoveTileRaw(TilePos pos) => Tiles?.Remove(pos.ToVector2I()); void UpdateMeshAndShape() { var mesh = GetOrCreateMesh("MeshInstance"); var shape = GetOrCreateShape("CollisionShape"); mesh.ClearSurfaces(); mesh.SurfaceBegin(Mesh.PrimitiveType.Triangles); var points = new List(); void AddPoint(Vector3 pos, Vector2 uv) { mesh.SurfaceSetUV(uv); mesh.SurfaceAddVertex(pos); points.Add(pos); } void AddTriangle(Vector3 v1, Vector2 uv1, Vector3 v2, Vector2 uv2, Vector3 v3, Vector2 uv3) { var dir = (v3 - v1).Cross(v2 - v1); mesh.SurfaceSetNormal(dir.Normalized()); AddPoint(v1, uv1); AddPoint(v2, uv2); AddPoint(v3, uv3); } var rnd = new Random(); for (var x = 0; x < Size.X; x++) for (var z = 0; z < Size.Y; z++) { var corners = GetCornersPosition(new(x, z)); // Find the "ideal way" to split the quad for the tile into two triangles. // This is done by finding the corner with the least variance between its neighboring corners. var sorted = corners.ToSorted((a, b) => a.Y.CompareTo(b.Y)); var minDiff = Abs(sorted[0].Value.Y - sorted[2].Value.Y); // Difference between lowest and 3rd lowest point. var maxDiff = Abs(sorted[3].Value.Y - sorted[1].Value.Y); // Difference between highest and 3rd highest point. mesh.SurfaceSetUV2(new(rnd.Next(4), 0.0f)); var first = sorted[(minDiff > maxDiff) ? 0 : 3].Corner; if (first is Corner.TopLeft or Corner.BottomRight) { AddTriangle(corners.TopLeft , new(0.0f, 0.0f), corners.TopRight , new(1.0f, 0.0f), corners.BottomLeft , new(0.0f, 1.0f)); AddTriangle(corners.TopRight , new(1.0f, 0.0f), corners.BottomRight, new(1.0f, 1.0f), corners.BottomLeft , new(0.0f, 1.0f)); } else { AddTriangle(corners.TopRight , new(1.0f, 0.0f), corners.BottomRight, new(1.0f, 1.0f), corners.TopLeft , new(0.0f, 0.0f)); AddTriangle(corners.BottomRight, new(1.0f, 1.0f), corners.BottomLeft , new(0.0f, 1.0f), corners.TopLeft , new(0.0f, 0.0f)); } } mesh.SurfaceEnd(); mesh.SurfaceSetMaterial(0, Material); shape.Data = [.. points]; } ImmediateMesh GetOrCreateMesh(string name) { var meshInstance = (MeshInstance3D)GetNodeOrNull(name); if (meshInstance == null) { meshInstance = new() { Name = name, Mesh = new ImmediateMesh() }; AddChild(meshInstance); meshInstance.Owner = this; } return (ImmediateMesh)meshInstance.Mesh; } ConcavePolygonShape3D GetOrCreateShape(string name) { var collisionShape = (CollisionShape3D)GetNodeOrNull(name); if (collisionShape == null) { collisionShape = new() { Name = name, Shape = new ConcavePolygonShape3D() }; AddChild(collisionShape); collisionShape.Owner = this; } return (ConcavePolygonShape3D)collisionShape.Shape; } public readonly record struct TilePos(int X, int Y) { public TilePos GetNeighbor(Side side) => side switch { Side.Left => new(X - 1, Y), Side.Top => new(X, Y - 1), Side.Right => new(X + 1, Y), Side.Bottom => new(X, Y + 1), _ => throw new ArgumentException($"Invalid Side value '{side}'", nameof(side)), }; public TilePos GetNeighbor(Corner corner) => corner switch { Corner.TopLeft => new(X - 1, Y - 1), Corner.TopRight => new(X + 1, Y - 1), Corner.BottomRight => new(X + 1, Y + 1), Corner.BottomLeft => new(X - 1, Y + 1), _ => throw new ArgumentException($"Invalid Corner value '{corner}'", nameof(corner)), }; public static TilePos From(Vector2I value) => new(value.X, value.Y); public Vector2I ToVector2I() => new(X, Y); } public readonly record struct Corners (T TopLeft, T TopRight, T BottomRight, T BottomLeft) { public T this[Corner corner] { get => corner switch { Corner.TopLeft => TopLeft, Corner.TopRight => TopRight, Corner.BottomRight => BottomRight, Corner.BottomLeft => BottomLeft, _ => throw new ArgumentException($"Invalid Corner value '{corner}'", nameof(corner)), }; init { switch (corner) { case Corner.TopLeft : TopLeft = value; break; case Corner.TopRight : TopRight = value; break; case Corner.BottomRight : BottomRight = value; break; case Corner.BottomLeft : BottomLeft = value; break; default: throw new ArgumentException($"Invalid Corner value '{corner}'", nameof(corner)); } } } public static Corners From(T value) => new() { TopLeft = value, TopRight = value, BottomRight = value, BottomLeft = value }; public static Corners From(T[] values) => new() { TopLeft = values[0], TopRight = values[1], BottomRight = values[2], BottomLeft = values[3] }; public Corners With(Corner corner, T value) => new((corner == Corner.TopLeft) ? value : TopLeft, (corner == Corner.TopRight) ? value : TopRight, (corner == Corner.BottomRight) ? value : BottomRight, (corner == Corner.BottomLeft) ? value : BottomLeft); public T[] ToArray() => [ TopLeft, TopRight, BottomRight, BottomLeft ]; public (Corner Corner, T Value)[] ToSorted(Comparison comparison) { var result = new (Corner Corner, T Value)[] { (Corner.TopLeft , TopLeft ), (Corner.TopRight , TopRight ), (Corner.BottomRight , BottomRight), (Corner.BottomLeft , BottomLeft ), }; Array.Sort(result, (a, b) => comparison(a.Value, b.Value)); return result; } } } public static class CornersExtensions { public static bool IsZeroApprox(this Terrain.Corners self) => Mathf.IsZeroApprox(self.TopLeft) && Mathf.IsZeroApprox(self.TopRight) && Mathf.IsZeroApprox(self.BottomRight) && Mathf.IsZeroApprox(self.BottomLeft); public static bool IsEqualApprox(this Terrain.Corners self) => Mathf.IsEqualApprox(self.TopLeft, self.TopRight) && Mathf.IsEqualApprox(self.TopLeft, self.BottomRight) && Mathf.IsEqualApprox(self.TopLeft, self.BottomLeft); } // TODO: Put this in a different file. public static class TerrainExtensions { public static (Corner, Corner) GetCorners(this Side side) => side switch { Side.Left => (Corner.TopLeft, Corner.BottomLeft), Side.Top => (Corner.TopLeft, Corner.TopRight), Side.Right => (Corner.TopRight, Corner.BottomRight), Side.Bottom => (Corner.BottomLeft, Corner.BottomRight), _ => throw new ArgumentException($"Invalid Side value '{side}'", nameof(side)), }; public static Side GetOpposite(this Side side) => side switch { Side.Left => Side.Right, Side.Top => Side.Bottom, Side.Right => Side.Left, Side.Bottom => Side.Top, _ => throw new ArgumentException($"Invalid Side value '{side}'", nameof(side)), }; public static Corner GetOpposite(this Corner corner) => corner switch { Corner.TopLeft => Corner.BottomRight, Corner.TopRight => Corner.BottomLeft, Corner.BottomRight => Corner.TopLeft, Corner.BottomLeft => Corner.TopRight, _ => throw new ArgumentException($"Invalid Corner value '{corner}'", nameof(corner)), }; }