using System.IO; public partial class TerrainEditingControls { // Set by the terrain editing plugin. public EditorUndoRedoManager EditorUndoRedo { get; set; } Terrain _currentTerrain = null; Material _editToolMaterial; public override void _EnterTree() { _editToolMaterial = new StandardMaterial3D { AlbedoColor = Colors.Blue, ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded, DepthDrawMode = BaseMaterial3D.DepthDrawModeEnum.Disabled, NoDepthTest = true, }; } public override void _ExitTree() => ClearEditToolMesh(); public override void _Input(InputEvent ev) { var viewport = !Engine.IsEditorHint() ? GetViewport() : EditorInterface.Singleton.GetEditorViewport3D(); if (Engine.IsEditorHint()) { // Make sure to transform the input event to the 3D scene's viewport. var container = viewport.GetParent(); ev = ev.XformedBy(container.GetGlobalTransform().AffineInverse()); if (ev is InputEventMouse m) m.GlobalPosition = m.Position; } if (ev is InputEventMouse mouse) { if (viewport.GetVisibleRect().HasPoint(mouse.Position) && (RayCastTerrain(mouse) is var (terrain, position))) { var (tile, corner) = FindClosestTile(terrain, position); var drawSize = (ToolShape == ToolShape.Corner) ? 1 : DrawSize; var isEven = (drawSize % 2) == 0; var radius = FloorToInt(drawSize / 2.0f); // Offset tile position by corner. if (isEven) tile = corner switch { Corner.TopLeft => new(tile.X + 0, tile.Y + 0), Corner.TopRight => new(tile.X + 1, tile.Y + 0), Corner.BottomRight => new(tile.X + 1, tile.Y + 1), Corner.BottomLeft => new(tile.X + 0, tile.Y + 1), _ => throw new InvalidOperationException(), }; IEnumerable GetTilesInSquare() { var minX = tile.X - radius; var minY = tile.Y - radius; var maxX = tile.X + radius - (isEven ? 1 : 0); var maxY = tile.Y + radius - (isEven ? 1 : 0); for (var x = minX; x <= maxX; x++) for (var y = minY; y <= maxY; y++) yield return new(x, y); } IEnumerable GetTilesInRadius() { var center = isEven ? new Vector2(tile.X , tile.Y ) : new Vector2(tile.X + 0.5f, tile.Y + 0.5f); var distanceSqr = Pow(isEven ? radius - 0.25f : radius + 0.25f, 2); return GetTilesInSquare() .Where(tile => center.DistanceSquaredTo( new Vector2(tile.X + 0.5f, tile.Y + 0.5f)) < distanceSqr); } var tiles = (ToolShape switch { // TODO: Edit corner, not full tile. ToolShape.Corner => [tile], ToolShape.Circle => GetTilesInRadius(), ToolShape.Square => GetTilesInSquare(), _ => throw new InvalidOperationException(), }).ToHashSet(); // TODO: Handle different tool modes, such as flatten and painting. // TODO: Allow click-dragging which doesn't affect already changed tiles / corners. // TODO: Make mesh generation generate vertical walls between disconnected corners. // TODO: Use ArrayMesh instead of ImmediateMesh. // Raise / lower the terrain if left / right mouse button is pressed. if (mouse is InputEventMouseButton { ButtonIndex: MouseButton.Left, Pressed: true }) { GetViewport().SetInputAsHandled(); var cornersToChange = new HashSet<(TilePos Position, Corner Corner)>(); // Raise selected tiles themselves. foreach (var pos in tiles) foreach (var corner2 in Enum.GetValues()) cornersToChange.Add((pos, corner2)); // If the Connected toggle button is active, move "connected" corners. // Connected corners are the ones that are at the same height as ones already being moved. if (ConnectedToggle.ButtonPressed) { foreach (var pos in tiles) { var tile2 = terrain.GetTile(pos); foreach (var corner2 in Enum.GetValues()) { var height = tile2.Height[corner2]; foreach (var (neighborPos, neighborCorner) in GetNeighbors(pos, corner2)) { if (tiles.Contains(neighborPos)) continue; var neighborHeight = terrain.GetTile(neighborPos).Height[neighborCorner]; if (neighborHeight == height) cornersToChange.Add((neighborPos, neighborCorner)); } } } } var isFlatten = ToolMode == ToolMode.Flatten; var isRaise = RaiseLowerToggle.ButtonPressed; const float AdjustHeight = 0.5f; var amount = isFlatten ? terrain.GetTile(tile).Height[corner] : isRaise ? AdjustHeight : -AdjustHeight; var tilesPrevious = new List<(TilePos, Corners)>(); var tilesChanged = new List<(TilePos, Corners)>(); foreach (var group in cornersToChange.GroupBy(c => c.Position, c => c.Corner)) { var pos = group.Key; var tile2 = terrain.GetTile(pos); tilesPrevious.Add((pos, tile2.Height)); var newHeight = tile2.Height; foreach (var corner2 in group) { if (isFlatten) newHeight[corner2] = amount; else newHeight[corner2] += amount; } tilesChanged.Add((pos, newHeight)); } if (EditorUndoRedo is EditorUndoRedoManager undo) { var name = "Modify terrain"; // TODO: Change name depending on tool mode. undo.CreateAction(name, customContext: terrain, backwardUndoOps: false); undo.AddDoMethod(this, nameof(TerrainModifyHeight), terrain, Pack(tilesChanged)); undo.AddDoMethod(terrain, GodotObject.MethodName.NotifyPropertyListChanged); undo.AddDoMethod(terrain, nameof(Terrain.UpdateMeshAndShape)); undo.AddUndoMethod(this, nameof(TerrainModifyHeight), terrain, Pack(tilesPrevious)); undo.AddUndoMethod(terrain, GodotObject.MethodName.NotifyPropertyListChanged); undo.AddUndoMethod(terrain, nameof(Terrain.UpdateMeshAndShape)); undo.CommitAction(true); } } UpdateEditToolMesh(terrain, tiles); } else { ClearEditToolMesh(); } } } public void TerrainModifyHeight(Terrain terrain, byte[] data) { foreach (var (pos, corners) in Unpack(data)) { var tile = terrain.GetTile(pos); tile.Height = corners; terrain.SetTile(pos, tile); } } static byte[] Pack(IEnumerable<(TilePos Position, Corners Corners)> data) { using var stream = new MemoryStream(); using var writer = new BinaryWriter(stream); foreach (var (pos, corners) in data) { writer.Write(pos.X); writer.Write(pos.Y); writer.Write(corners.TopLeft); writer.Write(corners.TopRight); writer.Write(corners.BottomRight); writer.Write(corners.BottomLeft); } return stream.ToArray(); } static IEnumerable<(TilePos Position, Corners Corners)> Unpack(byte[] data) { using var stream = new MemoryStream(data); using var reader = new BinaryReader(stream); while (stream.Position < stream.Length) { var x = reader.ReadInt32(); var y = reader.ReadInt32(); var corners = new Corners( reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle(), reader.ReadSingle()); yield return (new(x, y), corners); } } void UpdateEditToolMesh(Terrain terrain, IEnumerable tiles) { if (terrain != _currentTerrain) ClearEditToolMesh(); _currentTerrain = terrain; var mesh = terrain.GetOrCreateMesh("EditToolMesh"); mesh.ClearSurfaces(); mesh.SurfaceBegin(Mesh.PrimitiveType.Lines); void AddLine(Vector3 start, Vector3 end) { mesh.SurfaceAddVertex(start); mesh.SurfaceAddVertex(end); } void AddQuad(Vector3 topLeft , Vector3 topRight, Vector3 bottomRight, Vector3 bottomLeft) { AddLine(topLeft , topRight ); AddLine(topRight , bottomRight); AddLine(bottomRight, bottomLeft ); AddLine(bottomLeft , topLeft ); } foreach (var tile in tiles) { var (topLeft, topRight, bottomRight, bottomLeft) = terrain.GetTileCornerPositions(tile); AddQuad(topLeft, topRight, bottomRight, bottomLeft); } mesh.SurfaceEnd(); mesh.SurfaceSetMaterial(0, _editToolMaterial); } void ClearEditToolMesh() => _currentTerrain?.GetNodeOrNull("EditToolMesh")?.QueueFree(); (Terrain Terrain, Vector3 Position)? RayCastTerrain(InputEventMouse ev) { // Ray is cast from the editor camera's view. var camera = EditorInterface.Singleton.GetEditorViewport3D().GetCamera3D(); var from = camera.ProjectRayOrigin(ev.Position); var to = from + camera.ProjectRayNormal(ev.Position) * camera.Far; // Actual collision is done in the edited scene though. var root = (Node3D)EditorInterface.Singleton.GetEditedSceneRoot(); var space = root.GetWorld3D().DirectSpaceState; var query = PhysicsRayQueryParameters3D.Create(from, to); var result = space.IntersectRay(query); var collider = (GodotObject)result.GetValueOrDefault("collider"); return (collider is Terrain terrain) ? (terrain, (Vector3)result["position"]) : null; } static (TilePos, Corner) FindClosestTile(Terrain terrain, Vector3 position) { var local = terrain.ToLocal(position); var tileX = local.X / terrain.TileSize + 0.5 + terrain.Size.X / 2; var tileY = local.Z / terrain.TileSize + 0.5 + terrain.Size.Y / 2; var tile = new TilePos(FloorToInt(tileX), FloorToInt(tileY)); var cornerX = RoundToInt(PosMod(tileX, 1)); var cornerY = RoundToInt(PosMod(tileY, 1)); var corner = (cornerX, cornerY) switch { (0, 0) => Corner.TopLeft, (1, 0) => Corner.TopRight, (1, 1) => Corner.BottomRight, (0, 1) => Corner.BottomLeft, _ => throw new InvalidOperationException(), }; return (tile, corner); } static readonly Dictionary _offsetLookup = new(){ [Corner.TopLeft ] = [(-1, -1, Corner.BottomRight), (-1, 0, Corner.TopRight ), (0, -1, Corner.BottomLeft )], [Corner.TopRight ] = [(+1, -1, Corner.BottomLeft ), (+1, 0, Corner.TopLeft ), (0, -1, Corner.BottomRight)], [Corner.BottomRight] = [(+1, +1, Corner.TopLeft ), (+1, 0, Corner.BottomLeft ), (0, +1, Corner.TopRight )], [Corner.BottomLeft ] = [(-1, +1, Corner.TopRight ), (-1, 0, Corner.BottomRight), (0, +1, Corner.TopLeft )], }; static IEnumerable<(TilePos, Corner)> GetNeighbors(TilePos pos, Corner corner) => _offsetLookup[corner].Select(e => (new TilePos(pos.X + e.X, pos.Y + e.Y), e.Opposite)); }