diff --git a/terrain/SideAndCornerExtensions.cs b/terrain/SideAndCornerExtensions.cs new file mode 100644 index 0000000..40e2836 --- /dev/null +++ b/terrain/SideAndCornerExtensions.cs @@ -0,0 +1,29 @@ +public static class SideAndCornerExtensions +{ + 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)), + }; +} diff --git a/terrain/Terrain+Editing.cs b/terrain/Terrain+Editing.cs index 03c072a..7b42e7a 100644 --- a/terrain/Terrain+Editing.cs +++ b/terrain/Terrain+Editing.cs @@ -1,9 +1,9 @@ [Tool] public partial class Terrain { - bool _unhandledMouseMotion = false; // Used to detect when mouse moves off the terrain. - TilePos? _tileHover = null; // Position of currently hovered tile. - bool _isSelecting = false; // Whether left mouse is held down to select tiles. + bool _unhandledMotion = false; // Used to detect when mouse moves off the terrain. + TilePos? _tileHover = null; // Position of currently hovered tile. + bool _isSelecting = false; // Whether left mouse is held down to select tiles. (TilePos, TilePos)? _selection = null; public override void _Input(InputEvent ev) @@ -37,11 +37,14 @@ public partial class Terrain var innerPos = selection.GetTileFor(innerCorner); var outerPos = innerPos.GetNeighbor(innerCorner); - var innerHeight = GetCornerHeights(innerPos)[innerCorner]; - var outerHeight = GetCornerHeights(outerPos)[outerCorner]; + var outerTile = GetTile(outerPos); + var innerHeight = GetTile(innerPos).Height[innerCorner]; + var outerHeight = outerTile.Height[outerCorner]; - if (IsEqualApprox(outerHeight, innerHeight)) - SetCornerHeight(outerPos, outerCorner, innerHeight + amount); + if (IsEqualApprox(outerHeight, innerHeight)) { + outerTile.Height[outerCorner] = innerHeight + amount; + SetTile(outerPos, outerTile); + } } // Raise connected sides. @@ -49,40 +52,40 @@ public partial class Terrain foreach (var innerPos in selection.GetTilesFor(side)) { var outerPos = innerPos.GetNeighbor(side); - var innerHeights = GetCornerHeights(innerPos); - var outerHeights = GetCornerHeights(outerPos); + var innerTile = GetTile(innerPos); + var outerTile = GetTile(outerPos); var (innerCorner1, innerCorner2) = side.GetCorners(); var (outerCorner1, outerCorner2) = side.GetOpposite().GetCorners(); - var current = outerHeights; var changed = false; - var matchingCorners = new[]{ (innerCorner1, outerCorner1), (innerCorner2, outerCorner2) }; foreach (var (innerCorner, outerCorner) in matchingCorners) { - var innerHeight = innerHeights[innerCorner]; - var outerHeight = outerHeights[outerCorner]; + var innerHeight = innerTile.Height[innerCorner]; + var outerHeight = outerTile.Height[outerCorner]; if (IsEqualApprox(outerHeight, innerHeight)) { - current = current.With(outerCorner, innerHeight + amount); + outerTile.Height[outerCorner] = innerHeight + amount; changed = true; } } - - if (changed) SetCornerHeights(outerPos, current); + if (changed) SetTile(outerPos, outerTile); } } // Raise selected tiles themselves. - foreach (var tile in selection.GetAllTiles()) - AdjustTileHeight(tile, amount); + foreach (var pos in selection.GetAllTiles()) { + var tile = GetTile(pos); + tile.Height.Adjust(amount); + SetTile(pos, tile); + } UpdateMeshAndShape(); NotifyPropertyListChanged(); } if (ev is InputEventMouseMotion) - _unhandledMouseMotion = true; + _unhandledMotion = true; } public override void _InputEvent(Camera3D camera, InputEvent ev, Vector3 position, Vector3 normal, int shapeIdx) @@ -99,7 +102,7 @@ public partial class Terrain } if (ev is InputEventMouseMotion) { - _unhandledMouseMotion = false; + _unhandledMotion = false; _tileHover = tilePos; if (_isSelecting) _selection = _selection.Value with { Item2 = tilePos }; } @@ -113,7 +116,7 @@ public partial class Terrain _isSelecting = false; } - if (_unhandledMouseMotion) + if (_unhandledMotion) _tileHover = null; if ((_tileHover != null) || (_selection != null)) { @@ -135,7 +138,7 @@ public partial class Terrain } if (_tileHover is TilePos hover) { - var corners = GetCornersPosition(hover); + var corners = GetTileCornerPositions(hover); var margin = 0.1f; mesh.SurfaceSetColor(Colors.Black); AddQuad(corners.TopLeft + new Vector3(-margin, 0, -margin), @@ -147,7 +150,7 @@ public partial class Terrain mesh.SurfaceSetColor(Colors.Blue); if (_selection is (TilePos, TilePos) selection) foreach (var pos in TileRegion.From(selection).GetAllTiles()) { - var corners = GetCornersPosition(pos); + var corners = GetTileCornerPositions(pos); AddQuad(corners.TopLeft, corners.TopRight, corners.BottomLeft, corners.BottomRight); } diff --git a/terrain/Terrain.cs b/terrain/Terrain.cs index 8e8e4eb..4cbe7b9 100644 --- a/terrain/Terrain.cs +++ b/terrain/Terrain.cs @@ -26,6 +26,18 @@ public partial class Terrain } + public Tile GetTile(TilePos pos) + => (Tiles?.TryGetValue(pos.ToVector2I(), out var result) == true) + ? Tile.FromDictionary(result.AsGodotDictionary()) : default; + + public void SetTile(TilePos pos, Tile value) + { + var key = pos.ToVector2I(); + var dict = value.ToDictionary(); + if (dict == null) Tiles.Remove(key); + else Tiles[key] = dict; + } + public bool Contains(TilePos pos) => (pos.X >= 0) && (pos.X < Size.X) && (pos.Y >= 0) && (pos.Y < Size.Y); @@ -35,83 +47,6 @@ public partial class Terrain => 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() { @@ -138,39 +73,51 @@ public partial class Terrain AddPoint(v3, uv3); } - var rnd = new Random(); + // TODO: Don't hardcode. + var num_textures = 4; + var num_blend_textures = 7; + + 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)); + var tile = GetTile(new(x, z)); + var corners = GetTileCornerPositions(new(x, z), tile); + + // Randomly select two different textures and one blend texture. + mesh.SurfaceSetColor(new( + (float)tile.TexturePrimary / num_textures, + (float)tile.TextureSecondary / num_textures, + (float)tile.TextureBlend / num_blend_textures + )); + + var sorted = new (Corner Corner, float Height)[] { + (Corner.TopLeft , tile.Height.TopLeft ), + (Corner.TopRight , tile.Height.TopRight ), + (Corner.BottomRight , tile.Height.BottomRight), + (Corner.BottomLeft , tile.Height.BottomLeft ), + }; + Array.Sort(sorted, (a, b) => a.Height.CompareTo(b.Height)); // 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. + var minDiff = Abs(sorted[0].Height - sorted[2].Height); // Difference between lowest and 3rd lowest point. + var maxDiff = Abs(sorted[3].Height - sorted[1].Height); // Difference between highest and 3rd highest point. + var first = sorted[(minDiff > maxDiff) ? 0 : 3].Corner; -// Randomly select two different textures and one blend texture. - mesh.SurfaceSetColor(new( - rnd.NextSingle(), -rnd.NextSingle(), - rnd.NextSingle() -)); - - 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)); + 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)); + 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)); + 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)); + corners.BottomLeft , new(0.0f, 1.0f), + corners.TopLeft , new(0.0f, 0.0f)); } } @@ -181,11 +128,25 @@ rnd.NextSingle(), } + public Corners GetTileCornerPositions(TilePos pos) + => GetTileCornerPositions(pos, GetTile(pos)); + public Corners GetTileCornerPositions(TilePos pos, Tile tile) + { + var half = TileSize / 2; + var vx = (pos.X - Size.X / 2.0f) * TileSize; + var vz = (pos.Y - Size.Y / 2.0f) * TileSize; + return new(new(vx - half, tile.Height.TopLeft , vz - half), + new(vx + half, tile.Height.TopRight , vz - half), + new(vx + half, tile.Height.BottomRight, vz + half), + new(vx - half, tile.Height.BottomLeft , vz + half)); + } + + ImmediateMesh GetOrCreateMesh(string name) { var meshInstance = (MeshInstance3D)GetNodeOrNull(name); if (meshInstance == null) { - meshInstance = new() { Name = name, Mesh = new ImmediateMesh() }; + meshInstance = new(){ Name = name, Mesh = new ImmediateMesh() }; AddChild(meshInstance); meshInstance.Owner = this; } @@ -196,125 +157,10 @@ rnd.NextSingle(), { var collisionShape = (CollisionShape3D)GetNodeOrNull(name); if (collisionShape == null) { - collisionShape = new() { Name = name, Shape = new ConcavePolygonShape3D() }; + 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)), - }; } diff --git a/terrain/Tile.cs b/terrain/Tile.cs new file mode 100644 index 0000000..839ae93 --- /dev/null +++ b/terrain/Tile.cs @@ -0,0 +1,129 @@ +using Dictionary = Godot.Collections.Dictionary; + +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 struct Tile +{ + public Corners Height; + + // TODO: Replace with enum or something more permanent? + public int TexturePrimary; + public int TextureSecondary; + public int TextureBlend; + + + public static Tile FromDictionary(Dictionary dict) + { + if (dict == null) return default; + + float topLeft, topRight, bottomRight, bottomLeft; + switch (dict["heights"]) { + case { VariantType: Variant.Type.Float } variant: + var height = (float)variant; + (topLeft, topRight, bottomRight, bottomLeft) = (height, height, height, height); + break; + case { VariantType: Variant.Type.PackedFloat32Array } variant: + var heights = (float[])variant; + (topLeft, topRight, bottomRight, bottomLeft) = (heights[0], heights[1], heights[2], heights[3]); + break; + default: throw new Exception("Invalid variant type"); + }; + + int texturePrimary, textureSecondary, textureBlend; + switch (dict["texture"]) { + case { VariantType: Variant.Type.Int } variant: + var texture = (int)variant; + (texturePrimary, textureSecondary, textureBlend) = (texture, 0, 0); + break; + case { VariantType: Variant.Type.PackedInt32Array } variant: + var textures = (int[])variant; + (texturePrimary, textureSecondary, textureBlend) = (textures[0], textures[1], textures[2]); + break; + default: throw new Exception("Invalid variant type"); + }; + + return new(){ + Height = new(topLeft, topRight, bottomRight, bottomLeft), + TexturePrimary = texturePrimary, + TextureSecondary = textureSecondary, + TextureBlend = textureBlend, + }; + } + + public readonly Dictionary ToDictionary() + { + if (Height.IsZeroApprox() && (TexturePrimary == 0) && (TextureBlend == 0)) + return null; + return new(){ + ["heights"] = Height.IsEqualApprox() ? Height.TopLeft : new[]{ Height.TopLeft, Height.TopRight, Height.BottomRight, Height.BottomLeft }, + ["texture"] = (TextureBlend == 0) ? TexturePrimary : new[]{ TexturePrimary, TextureSecondary, TextureBlend }, + }; + } +} + +public struct Corners(T topLeft, T topRight, T bottomRight, T bottomLeft) +{ + public T TopLeft = topLeft; + public T TopRight = topRight; + public T BottomRight = bottomRight; + public T BottomLeft = bottomLeft; + + public T this[Corner corner] { + readonly get => corner switch { + Corner.TopLeft => TopLeft, + Corner.TopRight => TopRight, + Corner.BottomRight => BottomRight, + Corner.BottomLeft => BottomLeft, + _ => throw new ArgumentException($"Invalid Corner value '{corner}'", nameof(corner)), + }; + set { 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 class CornersExtensions +{ + public static void Adjust(this ref Corners self, float amount) + { + self.TopLeft += amount; + self.TopRight += amount; + self.BottomRight += amount; + self.BottomLeft += amount; + } + + public static bool IsZeroApprox(this Corners self) + => Mathf.IsZeroApprox(self.TopLeft ) && Mathf.IsZeroApprox(self.TopRight ) + && Mathf.IsZeroApprox(self.BottomRight) && Mathf.IsZeroApprox(self.BottomLeft); + + public static bool IsEqualApprox(this Corners self) + => Mathf.IsEqualApprox(self.TopLeft, self.TopRight ) + && Mathf.IsEqualApprox(self.TopLeft, self.BottomRight) + && Mathf.IsEqualApprox(self.TopLeft, self.BottomLeft ); +}