From cc55db6801672c15af9d552414e5cc302888a6a8 Mon Sep 17 00:00:00 2001 From: copygirl Date: Wed, 28 Aug 2024 23:33:12 +0200 Subject: [PATCH] Improve terrain editing, smooth slopes --- terrain/Terrain+Editing.cs | 140 +++++++++++++++++++----- terrain/Terrain.cs | 215 ++++++++++++++++++++++++++----------- 2 files changed, 263 insertions(+), 92 deletions(-) diff --git a/terrain/Terrain+Editing.cs b/terrain/Terrain+Editing.cs index 6935728..8c47f2c 100644 --- a/terrain/Terrain+Editing.cs +++ b/terrain/Terrain+Editing.cs @@ -2,9 +2,9 @@ public partial class Terrain { bool _unhandledMouseMotion = false; // Used to detect when mouse moves off the terrain. - Vector2I? _tileHover = null; // Position of currently hovered tile. + TilePos? _tileHover = null; // Position of currently hovered tile. bool _isSelecting = false; // Whether left mouse is held down to select tiles. - (Vector2I Start, Vector2I End)? _selection = null; + (TilePos, TilePos)? _selection = null; public override void _Input(InputEvent ev) { @@ -15,17 +15,69 @@ public partial class Terrain GetViewport().SetInputAsHandled(); } - if ((ev is InputEventMouseButton { ButtonIndex: var wheel, ShiftPressed: true }) - && (wheel is MouseButton.WheelUp or MouseButton.WheelDown)) + if ((ev is InputEventMouseButton { ButtonIndex: var wheel, Pressed: var pressed, ShiftPressed: true }) + && (wheel is MouseButton.WheelUp or MouseButton.WheelDown) && (_selection != null)) { + // NOTE: Potential bug in the Godot editor? + // Does it zoom both when mouse wheel is "pressed" and "released"? + // Because just cancelling one of them still causes zooming to occur. + GetViewport().SetInputAsHandled(); + if (!pressed) return; + const float AdjustHeight = 0.5f; - var value = (wheel == MouseButton.WheelUp) ? 1.0f : -1.0f; - foreach (var tile in GetSelectedTiles()) - AdjustTileHeight(tile, value * AdjustHeight); - if (_selection != null) - UpdateMeshAndShape(); + var value = (wheel == MouseButton.WheelUp) + ? AdjustHeight : -AdjustHeight; + + var selection = TileRegion.From(_selection.Value); + + // Raise connected corners. + foreach (var innerCorner in Enum.GetValues()) { + var outerCorner = innerCorner.GetOpposite(); + + var innerPos = selection.GetTileFor(innerCorner); + var outerPos = innerPos.GetNeighbor(innerCorner); + + var innerHeight = GetCornerHeights(innerPos)[innerCorner]; + var outerHeight = GetCornerHeights(outerPos)[outerCorner]; + + if (IsEqualApprox(outerHeight, innerHeight)) + SetCornerHeight(outerPos, outerCorner, innerHeight + value); + } + + // Raise connected sides. + foreach (var side in Enum.GetValues()) { + foreach (var innerPos in selection.GetTilesFor(side)) { + var outerPos = innerPos.GetNeighbor(side); + + var innerHeights = GetCornerHeights(innerPos); + var outerHeights = GetCornerHeights(outerPos); + + var (innerCorner1, innerCorner2) = side.GetCorners(); + var (outerCorner1, outerCorner2) = side.GetOpposite().GetCorners(); + + var current = outerHeights; + var changed = false; + + foreach (var (innerCorner, outerCorner) in new[]{ (innerCorner1, outerCorner1), (innerCorner2, outerCorner2) }) { + var innerHeight = innerHeights[innerCorner]; + var outerHeight = outerHeights[outerCorner]; + + if (IsEqualApprox(outerHeight, innerHeight)) { + current = current.With(outerCorner, innerHeight + value); + changed = true; + } + } + + if (changed) SetCornerHeights(outerPos, current); + } + } + + // Raise selected tiles themselves. + foreach (var tile in selection.GetAllTiles()) + AdjustTileHeight(tile, value); + + UpdateMeshAndShape(); NotifyPropertyListChanged(); - GetViewport().SetInputAsHandled(); } if (ev is InputEventMouseMotion) @@ -40,7 +92,7 @@ public partial class Terrain var tilePos = ToTilePos(localPos); if (ev is InputEventMouseButton { ButtonIndex: MouseButton.Left, Pressed: true }) { - _selection = (tilePos, tilePos); + _selection = new(tilePos, tilePos); _isSelecting = true; GetViewport().SetInputAsHandled(); } @@ -48,7 +100,7 @@ public partial class Terrain if (ev is InputEventMouseMotion) { _unhandledMouseMotion = false; _tileHover = tilePos; - if (_isSelecting) _selection = _selection.Value with { End = tilePos }; + if (_isSelecting) _selection = _selection.Value with { Item2 = tilePos }; } } @@ -82,8 +134,8 @@ public partial class Terrain AddLine(bottomLeft , topLeft ); } - if (_tileHover is Vector2I hover) { - var corners = GetGridCorners(hover); + if (_tileHover is TilePos hover) { + var corners = GetCornersPosition(hover); var margin = 0.1f; mesh.SurfaceSetColor(Colors.Black); AddQuad( @@ -95,10 +147,11 @@ public partial class Terrain } mesh.SurfaceSetColor(Colors.Blue); - foreach (var tilePos in GetSelectedTiles()) { - var corners = GetGridCorners(tilePos); - AddQuad(corners.TopLeft, corners.TopRight, corners.BottomLeft, corners.BottomRight); - } + if (_selection is (TilePos, TilePos) selection) + foreach (var pos in TileRegion.From(selection).GetAllTiles()) { + var corners = GetCornersPosition(pos); + AddQuad(corners.TopLeft, corners.TopRight, corners.BottomLeft, corners.BottomRight); + } mesh.SurfaceEnd(); mesh.SurfaceSetMaterial(0, _editToolMaterial); @@ -119,15 +172,48 @@ public partial class Terrain } } - IEnumerable GetSelectedTiles() + + readonly record struct TileRegion(int Left, int Top, int Right, int Bottom) { - if (_selection is not (Vector2I start, Vector2I end)) yield break; - // Ensure start.X/Y is smaller than end.X/Y. - (start, end) = (new(Min(start.X, end.X), Min(start.Y, end.Y)), - new(Max(start.X, end.X), Max(start.Y, end.Y))); - // Go over all tiles in the range and yield each one. - for (var x = start.X; x <= end.X; x++) - for (var y = start.Y; y <= end.Y; y++) - yield return new(x, y); + public TilePos TopLeft => new(Left , Top); + public TilePos TopRight => new(Right, Top); + public TilePos BottomRight => new(Right, Bottom); + public TilePos BottomLeft => new(Left , Bottom); + + public int Width => Right - Left + 1; + public int Height => Bottom - Top + 1; + + public static TileRegion From((TilePos, TilePos) selection) + => From(selection.Item1, selection.Item2); + public static TileRegion From(TilePos a, TilePos b) + => new(Min(a.X, b.X), Min(a.Y, b.Y), Max(a.X, b.X), Max(a.Y, b.Y)); + + public TilePos GetTileFor(Corner corner) + => corner switch { + Corner.TopLeft => TopLeft, + Corner.TopRight => TopRight, + Corner.BottomRight => BottomRight, + Corner.BottomLeft => BottomLeft, + _ => throw new ArgumentException($"Invalid Corner value '{corner}'", nameof(corner)), + }; + + public IEnumerable GetTilesFor(Side side) + { + var (left, top, right, bottom) = this; + return side switch { + Side.Left => Enumerable.Range(Top, Height).Select(y => new TilePos(left, y)), + Side.Top => Enumerable.Range(Left, Width).Select(x => new TilePos(x, top)), + Side.Right => Enumerable.Range(Top, Height).Select(y => new TilePos(right, y)), + Side.Bottom => Enumerable.Range(Left, Width).Select(x => new TilePos(x, bottom)), + _ => throw new ArgumentException($"Invalid Side value '{side}'", nameof(side)), + }; + } + + public IEnumerable GetAllTiles() + { + for (var x = Left; x <= Right; x++) + for (var y = Top; y <= Bottom; y++) + yield return new(x, y); + } } } diff --git a/terrain/Terrain.cs b/terrain/Terrain.cs index 5cc7af7..3454e29 100644 --- a/terrain/Terrain.cs +++ b/terrain/Terrain.cs @@ -31,92 +31,91 @@ public partial class Terrain } - /// Returns if this terrain grid contains the specified tile position. - public bool ContainsTilePos(Vector2I tilePos) - => (tilePos.X >= 0) && (tilePos.X < Size.X) - && (tilePos.Y >= 0) && (tilePos.Y < Size.Y); + 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 Vector2I ToTilePos(Vector3 localPos) + public TilePos ToTilePos(Vector3 localPos) => new(RoundToInt(localPos.X / TileSize + Size.X / 2.0f), RoundToInt(localPos.Z / TileSize + Size.Y / 2.0f)); - public float GetCornerHeight(Vector2I tilePos, Corner corner) - => GetTileRaw(tilePos) switch { - { VariantType: Variant.Type.Nil } => 0, - { VariantType: Variant.Type.Float } num => (float)num, - { VariantType: Variant.Type.PackedFloat32Array } nums => ((float[])nums)[(int)corner], + 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(Vector2I tilePos, Corner corner, float value) + 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 (!ContainsTilePos(tilePos)) return; - var existing = GetTileRaw(tilePos) switch { - { VariantType: Variant.Type.Nil } => [ 0, 0, 0, 0 ], - { VariantType: Variant.Type.Float } num => [ (float)num, (float)num, (float)num, (float)num ], - { VariantType: Variant.Type.PackedFloat32Array } nums => (float[])nums, - _ => throw new Exception("Invalid type in Points dictionary"), - }; - existing[(int)corner] = value; - if (existing.All(IsZeroApprox)) - Tiles?.Remove(tilePos); - if (IsEqualApprox(existing[0], existing[1]) - && IsEqualApprox(existing[0], existing[2]) - && IsEqualApprox(existing[0], existing[3])) - SetTileRaw(tilePos, existing[0]); - else - SetTileRaw(tilePos, existing); + 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(Vector2I tilePos, float value) + public void SetTileHeight(TilePos pos, float value) { - if (!ContainsTilePos(tilePos)) return; - if (IsZeroApprox(value)) Tiles?.Remove(tilePos); - else SetTileRaw(tilePos, value); + if (!Contains(pos)) return; + if (IsZeroApprox(value)) RemoveTileRaw(pos); + else SetTileRaw(pos, value); } - public void AdjustTileHeight(Vector2I tilePos, float relative) + public void AdjustTileHeight(TilePos pos, float relative) { if (relative == 0) return; - if (!ContainsTilePos(tilePos)) return; - switch (GetTileRaw(tilePos)) { + if (!Contains(pos)) return; + switch (GetTileRaw(pos)) { case { VariantType: Variant.Type.Nil }: - SetTileRaw(tilePos, relative); + SetTileRaw(pos, relative); break; case { VariantType: Variant.Type.Float } num: var newNum = (float)num + relative; - if (IsZeroApprox(newNum)) Tiles?.Remove(tilePos); - else SetTileRaw(tilePos, newNum); + 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(tilePos, newNums); + SetTileRaw(pos, newNums); break; default: throw new Exception("Invalid type in Points dictionary"); }; } - public GridCorners GetGridCorners(Vector2I tilePos) + public Corners GetCornersPosition(TilePos pos) { - var halfSize = TileSize / 2; - var vx = (tilePos.X - Size.X / 2.0f) * TileSize; - var vz = (tilePos.Y - Size.Y / 2.0f) * TileSize; - - return new() { - TopLeft = new(vx - halfSize, GetCornerHeight(tilePos, Corner.TopLeft ), vz - halfSize), - TopRight = new(vx + halfSize, GetCornerHeight(tilePos, Corner.TopRight ), vz - halfSize), - BottomLeft = new(vx - halfSize, GetCornerHeight(tilePos, Corner.BottomLeft ), vz + halfSize), - BottomRight = new(vx + halfSize, GetCornerHeight(tilePos, Corner.BottomRight), vz + halfSize), - }; + 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(Vector2I tilePos) - => (Tiles?.TryGetValue(tilePos, out var result) == true) ? result : default; - - void SetTileRaw(Vector2I tilePos, Variant value) - => (Tiles ??= [])[tilePos] = value; + 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() @@ -146,7 +145,7 @@ public partial class Terrain for (var x = 0; x < Size.X; x++) { for (var z = 0; z < Size.Y; z++) { - var corners = GetGridCorners(new(x, z)); + var corners = GetCornersPosition(new(x, z)); AddTriangle(corners.TopLeft , new(0.0f, 0.0f), corners.TopRight , new(1.0f, 0.0f), corners.BottomLeft , new(0.0f, 1.0f)); @@ -186,19 +185,105 @@ public partial class Terrain } - public enum Corner + public readonly record struct TilePos(int X, int Y) { - TopLeft, - TopRight, - BottomLeft, - BottomRight, + 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 struct GridCorners + public readonly record struct Corners + (T TopLeft, T TopRight, T BottomRight, T BottomLeft) { - public Vector3 TopLeft { get; init; } - public Vector3 TopRight { get; init; } - public Vector3 BottomLeft { get; init; } - public Vector3 BottomRight { get; init; } + 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)), + }; + 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 readonly T[] ToArray() + => [ TopLeft, TopRight, BottomRight, BottomLeft ]; } } + +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)), + }; +}