using System; using System.Collections; using System.Collections.Generic; using System.Linq; using RSG; using UnityEditor; using UnityEditor.AnimatedValues; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.SceneManagement; namespace HexTiles.Editor { /// /// Editor for hex tile maps. Contains code for interacting with and painting tiles /// in the editor. /// [CustomEditor(typeof(HexTileMap))] public class HexTileMapEditor : UnityEditor.Editor { /// /// Struct containg normal and selected icons for tool buttons. /// private struct ButtonIcon { public Texture2D NormalIcon; public Texture2D SelectedIcon; } private ButtonIcon[] toolIcons = {}; /// /// States that the UI can be in. /// private static readonly string[] States = { "Select", "Paint tiles", "Material paint", "Erase", "Settings" }; /// /// Root state for state machine. /// private IState rootState; /// /// Index of the currently selected tool. /// private int selectedToolIndex = 0; private static int hexTileEditorHash = "HexTileEditor".GetHashCode(); /// /// The object we're editing. /// private HexTileMap hexMap; private IEnumerable highlightedTiles = Enumerable.Empty(); private IEnumerable nextTilePositions = Enumerable.Empty(); private AnimBool showTileCoordinateFormat; /// /// Center of the current selection. /// private HexCoords centerSelectedTileCoords; /// /// Current size of the area we want to effect by adding/removing/paining over tiles. /// int brushSize = 1; private static readonly string undoMessage = "Edited hex tiles"; private void Initialise() { rootState = new StateMachineBuilder() .State("Select") .Enter(evt => selectedToolIndex = 0) .Update((state, dt) => { EditorUtilities.ShowHelpBox("Select", "Pick a hex tile to manually edit its properties."); HexTileData currentTile; if (hexMap.SelectedTile != null && hexMap.TryGetTile(hexMap.SelectedTile, out currentTile)) { // Tile info GUILayout.Label("Tile position", EditorStyles.boldLabel); EditorUtilities.ShowReadonlyIntField("Column", hexMap.SelectedTile.Q); EditorUtilities.ShowReadonlyIntField("Row", hexMap.SelectedTile.R); EditorUtilities.ShowReadonlyFloatField("Elevation", currentTile.Position.Elevation); // Tile settings GUILayout.Label("Settings", EditorStyles.boldLabel); var currentMaterial = currentTile.Material; var newMaterial = (Material)EditorGUILayout.ObjectField("Material", currentMaterial, typeof(Material), false); if (currentMaterial != newMaterial) { hexMap.ReplaceMaterialOnTile(currentTile.Position.Coordinates, newMaterial); MarkSceneDirty(); } } }) .Event("SceneClicked", (state, eventArgs) => { if (eventArgs.Button == 0) { var tile = TryFindTileForMousePosition(eventArgs.Position); if (tile != null) { hexMap.SelectedTile = tile.Coordinates; } } }) .End() .State("Paint tiles") .Enter(state => { selectedToolIndex = 1; }) .Update((state, dt) => { EditorUtilities.ShowHelpBox("Paint tiles", "Click and drag to add hex tiles at the specified height."); bool sceneNeedsRepaint = false; var newBrushSize = EditorGUILayout.IntSlider("Brush size", brushSize, 1, 10); if (newBrushSize != brushSize) { brushSize = newBrushSize; sceneNeedsRepaint = true; } var paintHeight = EditorGUILayout.FloatField("Paint height", state.PaintHeight); if (paintHeight != state.PaintHeight) { state.PaintHeight = paintHeight; foreach (var tile in highlightedTiles) { tile.Elevation = paintHeight; } sceneNeedsRepaint = true; } var paintOffsetHeight = EditorGUILayout.FloatField("Height offset", state.PaintOffset); if (paintOffsetHeight != state.PaintOffset) { state.PaintOffset = paintOffsetHeight; foreach (var tile in nextTilePositions) { tile.Elevation = paintHeight + paintOffsetHeight; } sceneNeedsRepaint = true; } hexMap.CurrentMaterial = (Material)EditorGUILayout.ObjectField("Material", hexMap.CurrentMaterial, typeof(Material), false); if (sceneNeedsRepaint) { if (centerSelectedTileCoords != null) { UpdateHighlightedTiles(centerSelectedTileCoords.CoordinateRange(brushSize - 1), state.PaintHeight, state.PaintOffset); } SceneView.RepaintAll(); } }) .Event("MouseMove", state => { var highlightedPosition = EditorUtilities.GetWorldPositionForMouse(Event.current.mousePosition, state.PaintHeight); if (highlightedPosition != null) { centerSelectedTileCoords = hexMap.QuantizeVector3ToHexCoords(highlightedPosition.GetValueOrDefault()); UpdateHighlightedTiles(centerSelectedTileCoords.CoordinateRange(brushSize - 1), state.PaintHeight, state.PaintOffset); } Event.current.Use(); }) .Event("SceneClicked", (state, eventArgs) => { if (eventArgs.Button == 0) { var position = EditorUtilities.GetWorldPositionForMouse(eventArgs.Position, state.PaintHeight); if (position != null) { // Select the tile that was clicked on. centerSelectedTileCoords = hexMap.QuantizeVector3ToHexCoords(position.GetValueOrDefault()); var coords = centerSelectedTileCoords.CoordinateRange(brushSize - 1); foreach (var hex in coords) { // Keep track of which chunks we've modified so that // we only record undo actions for each once. var oldChunk = hexMap.FindChunkForCoordinates(hex); if (oldChunk != null && !state.ModifiedChunks.Contains(oldChunk)) { RecordChunkModifiedUndo(oldChunk); state.ModifiedChunks.Add(oldChunk); } var newChunk = hexMap.FindChunkForCoordinatesAndMaterial(hex, hexMap.CurrentMaterial); if (newChunk != null && newChunk != oldChunk && !state.ModifiedChunks.Contains(newChunk)) { RecordChunkModifiedUndo(newChunk); state.ModifiedChunks.Add(newChunk); } // TODO: add feature for disabling wireframe again. var paintHeight = state.PaintHeight + state.PaintOffset; var action = hexMap.CreateAndAddTile( new HexPosition(hex, paintHeight), hexMap.CurrentMaterial); if (action.Operation == ModifiedTileInfo.ChunkOperation.Added) { RecordChunkAddedUndo(action.Chunk); } } hexMap.SelectedTile = centerSelectedTileCoords; MarkSceneDirty(); } } }) .Event("MouseUp", state => { // Flush list of modified chunks so that they are not included in // the next undo action. state.ModifiedChunks.Clear(); }) .Exit(state => { highlightedTiles = Enumerable.Empty(); nextTilePositions = Enumerable.Empty(); hexMap.NextTilePositions = null; }) .End() .State("Material paint") .Enter(state => { selectedToolIndex = 2; }) .Update((state, dt) => { bool sceneNeedsRepaint = false; EditorUtilities.ShowHelpBox("Material paint", "Paint over existing tiles to change their material."); var newBrushSize = EditorGUILayout.IntSlider("Brush size", brushSize, 1, 10); if (newBrushSize != brushSize) { brushSize = newBrushSize; sceneNeedsRepaint = true; } hexMap.CurrentMaterial = (Material)EditorGUILayout.ObjectField("Material", hexMap.CurrentMaterial, typeof(Material), false); EditorGUILayout.Space(); if (GUILayout.Button("Apply to all tiles")) { ApplyCurrentMaterialToAllTiles(); MarkSceneDirty(); sceneNeedsRepaint = true; } if (sceneNeedsRepaint) { SceneView.RepaintAll(); } }) .Event("MouseMove", state => { HighlightTilesUnderMousePosition(); Event.current.Use(); }) .Event("SceneClicked", (state, eventArgs) => { if (eventArgs.Button == 0) { var tilePosition = TryFindTileForMousePosition(eventArgs.Position); if (tilePosition != null && hexMap.ContainsTile(tilePosition.Coordinates)) { // Select that the tile that was clicked on. hexMap.SelectedTile = tilePosition.Coordinates; // Change the material on the tile var tilesUnderBrush = tilePosition.Coordinates.CoordinateRange(brushSize - 1) .Where(coords => hexMap.ContainsTile(coords)); foreach (var coords in tilesUnderBrush) { ReplaceMaterialOnTile(coords, state.ModifiedChunks); } } Event.current.Use(); } }) .Event("MouseUp", state => { // Flush list of modified chunks so that they are not included in // the next undo action. state.ModifiedChunks.Clear(); }) .End() .State("Erase") .Enter(evt => selectedToolIndex = 3) .Update((state, dt) => { EditorUtilities.ShowHelpBox("Erase", "Click and drag on existing hex tiles to remove them."); var newBrushSize = EditorGUILayout.IntSlider("Brush size", brushSize, 1, 10); if (newBrushSize != brushSize) { brushSize = newBrushSize; SceneView.RepaintAll(); } }) .Event("MouseMove", state => { HighlightTilesUnderMousePosition(); Event.current.Use(); }) .Event("SceneClicked", (state, eventArgs) => { if (eventArgs.Button == 0) { bool removedTile = false; var centerTile = TryFindTileForMousePosition(eventArgs.Position); if (centerTile != null) { foreach (var tile in centerTile.Coordinates.CoordinateRange(brushSize - 1)) { var chunk = hexMap.FindChunkForCoordinates(tile); if (chunk != null && !state.ModifiedChunks.Contains(chunk)) { RecordChunkModifiedUndo(chunk); state.ModifiedChunks.Add(chunk); } // Destroy tile removedTile |= hexMap.TryRemovingTile(tile); } } if (removedTile) { MarkSceneDirty(); } } }) .Event("MouseUp", state => { // Flush list of modified chunks so that they are not included in // the next undo action. state.ModifiedChunks.Clear(); }) .End() .State("Settings") .Enter(state => { selectedToolIndex = 4; state.HexSize = hexMap.tileDiameter; state.ChunkSize = hexMap.ChunkSize; showTileCoordinateFormat.value = hexMap.DrawHexPositionHandles; }) .Update((state, dt) => { EditorUtilities.ShowHelpBox("Settings", "Configure options for the whole tile map."); var shouldDrawPositionHandles = EditorGUILayout.Toggle("Show tile positions", hexMap.DrawHexPositionHandles); if (shouldDrawPositionHandles != hexMap.DrawHexPositionHandles) { hexMap.DrawHexPositionHandles = shouldDrawPositionHandles; SceneView.RepaintAll(); MarkSceneDirty(); showTileCoordinateFormat.target = shouldDrawPositionHandles; } if (EditorGUILayout.BeginFadeGroup(showTileCoordinateFormat.faded)) { var newHandleFormat = (HexTileMap.HexCoordinateFormat) EditorGUILayout.EnumPopup("Tile coordinate format", hexMap.HexPositionHandleFormat); if (newHandleFormat != hexMap.HexPositionHandleFormat) { hexMap.HexPositionHandleFormat = newHandleFormat; SceneView.RepaintAll(); } } EditorGUILayout.EndFadeGroup(); state.HexSize = EditorGUILayout.FloatField("Tile size", state.HexSize); if (state.HexSize != hexMap.tileDiameter) { state.Dirty = true; } var newChunkSize = EditorGUILayout.IntField("Chunk size", state.ChunkSize); if (state.ChunkSize != newChunkSize) { state.ChunkSize = newChunkSize; state.Dirty = true; } if (GUILayout.Button("Re-generate all tile geometry")) { hexMap.RegenerateAllTiles(); MarkSceneDirty(); } if (GUILayout.Button("Clear all tiles")) { if (EditorUtility.DisplayDialog("Clear all tiles", "Are you sure you want to delete all tiles in this hex tile map?", "Clear", "Cancel")) { hexMap.ClearAllTiles(); MarkSceneDirty(); } } EditorGUILayout.Space(); GUI.enabled = state.Dirty; EditorGUILayout.BeginHorizontal(); GUILayout.FlexibleSpace(); if (GUILayout.Button("Apply", GUILayout.Width(160))) { Debug.Log("Saving settings"); hexMap.tileDiameter = state.HexSize; hexMap.ChunkSize = state.ChunkSize; hexMap.RegenerateAllTiles(); MarkSceneDirty(); state.Dirty = false; } GUILayout.FlexibleSpace(); EditorGUILayout.EndHorizontal(); GUI.enabled = true; }) .End() .Build(); rootState.ChangeState("Select"); if (EditorGUIUtility.isProSkin) { toolIcons = new ButtonIcon[] { new ButtonIcon{ NormalIcon = LoadImage("mouse-pointer_44_pro"), SelectedIcon = LoadImage("mouse-pointer_44_selected") }, new ButtonIcon{ NormalIcon = LoadImage("add-hex_44_pro"), SelectedIcon = LoadImage("add-hex_44_selected") }, new ButtonIcon{ NormalIcon = LoadImage("paint-brush_44_pro"), SelectedIcon = LoadImage("paint-brush_44_selected") }, new ButtonIcon{ NormalIcon = LoadImage("eraser_44_pro"), SelectedIcon = LoadImage("eraser_44_selected") }, new ButtonIcon{ NormalIcon = LoadImage("cog_44_pro"), SelectedIcon = LoadImage("cog_44_selected") }, }; } else { toolIcons = new ButtonIcon[] { new ButtonIcon{ NormalIcon = LoadImage("mouse-pointer_44"), SelectedIcon = LoadImage("mouse-pointer_44_selected") }, new ButtonIcon{ NormalIcon = LoadImage("add-hex_44"), SelectedIcon = LoadImage("add-hex_44_selected") }, new ButtonIcon{ NormalIcon = LoadImage("paint-brush_44"), SelectedIcon = LoadImage("paint-brush_44_selected") }, new ButtonIcon{ NormalIcon = LoadImage("eraser_44"), SelectedIcon = LoadImage("eraser_44_selected") }, new ButtonIcon{ NormalIcon = LoadImage("cog_44"), SelectedIcon = LoadImage("cog_44_selected") }, }; } } private void UpdateHighlightedTiles(IEnumerable coords, float paintHeight, float paintOffset) { highlightedTiles = coords.Select(tile => new HexPosition(tile, paintHeight)); nextTilePositions = coords.Select(tile => new HexPosition(tile, paintHeight + paintOffset)); } /// /// Replace the material on the specified tile with the currently selected /// material. A HashSet of the chunks that have been modified so far in this /// action must be passed in so that this can record which chunks were affected /// for the purposes for registering undo actions. /// private void ReplaceMaterialOnTile(HexCoords coords, HashSet modifiedChunks) { var oldChunk = hexMap.FindChunkForCoordinates(coords); // Skip if the material is already the same. if (oldChunk.Material == hexMap.CurrentMaterial) { return; } if (oldChunk != null && !modifiedChunks.Contains(oldChunk)) { RecordChunkModifiedUndo(oldChunk); modifiedChunks.Add(oldChunk); } var newChunk = hexMap.FindChunkForCoordinatesAndMaterial(coords, hexMap.CurrentMaterial); if (newChunk != null && newChunk != oldChunk && !modifiedChunks.Contains(newChunk)) { RecordChunkModifiedUndo(newChunk); modifiedChunks.Add(newChunk); } var action = hexMap.ReplaceMaterialOnTile(coords, hexMap.CurrentMaterial); if (action.Operation == ModifiedTileInfo.ChunkOperation.Added) { RecordChunkAddedUndo(action.Chunk); } } /// /// Applies the material stored in hexMap.CurrentMaterial to all the tiles in hexMap /// private void ApplyCurrentMaterialToAllTiles() { var modifiedChunks = new HashSet(); foreach (var tile in hexMap.GetAllTiles().ToArray()) { ReplaceMaterialOnTile(tile.Position.Coordinates, modifiedChunks); } } /// /// Tell Unity that a change has been made and we have to save the scene. /// private void MarkSceneDirty() { #if UNITY_5_3_OR_NEWER // TODO: Undo.RecordObject also marks the scene dirty, so this will no longer be necessary once undo support is added. EditorSceneManager.MarkSceneDirty(hexMap.gameObject.scene); #else EditorUtility.SetDirty(hexMap.gameObject); #endif } /// /// Record an undo action for a chunk. /// private void RecordChunkModifiedUndo(HexChunk chunk) { Undo.RegisterCompleteObjectUndo(chunk, undoMessage); Undo.RegisterCompleteObjectUndo(chunk.MeshFilter, undoMessage); Undo.RegisterCompleteObjectUndo(chunk.MeshCollider, undoMessage); } /// /// Record that a chunk was added. /// private void RecordChunkAddedUndo(HexChunk chunk) { Undo.RegisterCreatedObjectUndo(chunk.gameObject, undoMessage); } void OnSceneGUI() { if (hexMap.DrawHexPositionHandles) { DrawHexPositionHandles(); } hexMap.HighlightedTiles = highlightedTiles; hexMap.NextTilePositions = nextTilePositions; // Handle mouse input var controlId = GUIUtility.GetControlID(hexTileEditorHash, FocusType.Passive); switch (Event.current.GetTypeForControl(controlId)) { case EventType.MouseMove: rootState.TriggerEvent("MouseMove"); break; case EventType.MouseDrag: case EventType.MouseDown: // Don't do anything if the user alt-left clicks to rotate the camera. if ((Event.current.button == 0 && Event.current.alt) || Event.current.button != 0) { break; } rootState.TriggerEvent("MouseMove"); var eventArgs = new SceneClickedEventArgs { Button = Event.current.button, Position = Event.current.mousePosition }; rootState.TriggerEvent("SceneClicked", eventArgs); // Disable the normal interaction with objects in the scene so that we // can do things with tiles. if (Event.current.button == 0) { Repaint(); Event.current.Use(); } break; case EventType.MouseUp: // Don't do anything if the user alt-left clicks to rotate the camera. if ((Event.current.button == 0 && Event.current.alt) || Event.current.button != 0) { break; } rootState.TriggerEvent("MouseUp"); break; case EventType.Layout: HandleUtility.AddDefaultControl(controlId); break; } if (GUI.changed) { EditorUtility.SetDirty(target); } } /// /// Draw handles with the position of each hex tile above that tile in the scene. /// private void DrawHexPositionHandles() { foreach (var tile in hexMap.GetAllTiles()) { var position = hexMap.HexPositionToWorldPosition(tile.Position); // Only draw this handle if the tile is in front of the camera. var cameraTransform = SceneView.currentDrawingSceneView.camera.transform; var cameraToTile = cameraTransform.position - position; if (Vector3.Dot(cameraToTile, cameraTransform.forward) > 0) { continue; } var hexCoords = hexMap.QuantizeVector3ToHexCoords(position); var labelText = string.Empty; switch (hexMap.HexPositionHandleFormat) { case HexTileMap.HexCoordinateFormat.Axial: labelText = hexCoords.ToString(); break; case HexTileMap.HexCoordinateFormat.OffsetOddQ: labelText = hexCoords.ToOffset().ToString("0"); break; case HexTileMap.HexCoordinateFormat.WorldSpacePosition: labelText = position.ToString(); break; } Handles.Label(position, labelText); } } void OnEnable() { hexMap = (HexTileMap)target; // Init anim bools showTileCoordinateFormat = new AnimBool(Repaint); Initialise(); Undo.undoRedoPerformed += OnUndoPerformed; } void OnDisable() { Undo.undoRedoPerformed -= OnUndoPerformed; } public override void OnInspectorGUI() { var toolbarContent = new GUIContent[] { new GUIContent(GetToolButtonIcon(0), "Select"), new GUIContent(GetToolButtonIcon(1), "Paint tiles"), new GUIContent(GetToolButtonIcon(2), "Material paint"), new GUIContent(GetToolButtonIcon(3), "Delete"), new GUIContent(GetToolButtonIcon(4), "Settings") }; GUILayout.BeginHorizontal(); GUILayout.FlexibleSpace(); var newSelectedTool = GUILayout.Toolbar(selectedToolIndex, toolbarContent, "command"); if (newSelectedTool != selectedToolIndex) { rootState.ChangeState(States[newSelectedTool]); SceneView.RepaintAll(); } GUILayout.FlexibleSpace(); GUILayout.EndHorizontal(); rootState.Update(Time.deltaTime); if (hexMap.UpdateTileChunks()) { SceneView.RepaintAll(); } } /// /// Callback triggered when an undo is performed. /// private void OnUndoPerformed() { // This must be done in case the undo operation deleted a chunk // or added a new one. This would be more efficient if we could // actually know whether the undo operation affected this object // rather than doing it after every undo. hexMap.ClearChunkCache(); } /// /// Helper function to get the correct icon for a tool button. /// private Texture2D GetToolButtonIcon(int index) { return selectedToolIndex == index ? toolIcons[index].SelectedIcon : toolIcons[index].NormalIcon; } Texture2D LoadImage(string resource) { var image = Resources.Load(resource); if (image == null) { throw new ApplicationException("Failed to load image from resource \"" + resource + "\""); } return image; } /// /// Try to find a tile by raycasting from the specified mouse position. /// Returns null if no tile was found. /// private HexPosition TryFindTileForMousePosition(Vector2 mousePosition) { var ray = HandleUtility.GUIPointToWorldRay(mousePosition); return Physics.RaycastAll(ray, 1000f) .Where(hit => hit.collider.GetComponent() != null) .OrderBy(hit => hit.distance) .Select(hit => new HexPosition(hexMap.QuantizeVector3ToHexCoords(hit.point), hit.point.y)) .FirstOrDefault(); } /// /// Highlights all the tiles under the current mouse position. /// private void HighlightTilesUnderMousePosition() { var centerTile = TryFindTileForMousePosition(Event.current.mousePosition); if (centerTile != null) { var newHighlightedTiles = new List(); foreach (var tile in centerTile.Coordinates.CoordinateRange(brushSize - 1)) { HexTileData tileData; if (hexMap.TryGetTile(tile, out tileData)) { newHighlightedTiles.Add(new HexPosition(tile, tileData.Position.Elevation)); } } highlightedTiles = newHighlightedTiles; } else { highlightedTiles = Enumerable.Empty(); } } /// /// State for when we're painting tiles. /// private class PaintState : ChunkEditingState { public float PaintHeight; public float PaintOffset; } /// /// Base class for states that modify tile chunks. /// private class ChunkEditingState : AbstractState { public HashSet ModifiedChunks = new HashSet(); } /// /// State for when we're in the map settings mode. /// private class SettingsState : AbstractState { /// /// Whether or not a value has been changed and needs to be saved. /// public bool Dirty = false; public int ChunkSize; public float HexSize; } /// /// Event args for when the user clicks in the scene. Passed on to whatever /// the active tool is. /// private class SceneClickedEventArgs : EventArgs { public int Button; public Vector2 Position; } } }