using System; using System.Collections; using System.Collections.Generic; using Framework.Layers; using UnityEngine; using UnityEditor; namespace Framework.Navigation.Platformer { /// /// Assists the user in editing ground definitions for the navigation system /// [CustomEditor(typeof(Ground))] public class GroundEditor : Editor { /// Tolerance below which poly lines are collapsed into single segments /// /// This is used by the automatic ground-polyline generation, which first samples /// the whole of the continuous ground and then simplifies this into a polyline /// that has as few points as possible. /// private const float CollapseTolerance = 0.01f; /// How far from the floor the navigation ground will be placed /// /// This helps prevent flickering and feet sinking into the floor. If the value is /// too high, it would look like actors have magic flying boots on. /// private const float FloorDistance = 0.015f; /// Creates a new ground assigned to the right layer and all [MenuItem("GameObject/Create Other/Ground (Platformer Navigation)")] public static void CreateGround() { int layerIndex = LayerMask.NameToLayer("Navigation"); if(layerIndex == -1) { Debug.LogWarning( "Could not find the 'Navigation' layer. You should add a custom layer with this" + "name and configure Unity's physics settings so that it doesn't collide with " + "anything. Falling back to 'Default' layer for this Ground object." ); } GameObject groundGameObject = new GameObject("NavigationGround"); if(layerIndex != -1) { groundGameObject.layer = layerIndex; } // This allows the navigation system to tell agents where they are BoxCollider collider = groundGameObject.AddComponent(); collider.isTrigger = true; // Create a ground node with two default points Ground ground = groundGameObject.AddComponent(); ground.Heights = new Vector2[] { new Vector2(-0.5f, 0.0f), new Vector2(+0.5f, 0.0f) }; } /// Called when the Unity editor draws the scene overlay GUI protected void OnSceneGUI() { drawGroundVertices(); if(GUI.changed) { EditorUtility.SetDirty(target); } if(this.selectedHeightSampleIndex != -1) { Handles.BeginGUI(); Vector2 buttonPosition = HandleUtility.WorldToGUIPoint(getSelectedHandlePosition()); if(GUI.Button(new Rect(buttonPosition.x + 45, buttonPosition.y - 60, 30, 30), "+")) { insertNewSample(); } if(GUI.Button(new Rect(buttonPosition.x - 75, buttonPosition.y - 60, 30, 30), "-")) { removeCurrentSample(); } Handles.EndGUI(); } } /// Called when Unity wants to layout or draw the inspector public override void OnInspectorGUI() { base.OnInspectorGUI(); if(GUILayout.Button("Fit Ground")) { var ground = (Ground)target; FitGround(ground); } if(GUILayout.Button("Fit Collider")) { var ground = (Ground)target; FitGround(ground); } } /// Retrieves the position of the current selected handle /// The position of the currently selected handle private Vector3 getSelectedHandlePosition() { var ground = (Ground)target; if(this.selectedHeightSampleIndex == -1) { return ground.transform.position; } else { Vector2 height = ground.Heights[this.selectedHeightSampleIndex]; Vector3 position = ground.transform.position; return position + new Vector3(height.x, height.y, 0.0f); } } /// Removes the currently selected height sample from the ground private void removeCurrentSample() { if(this.selectedHeightSampleIndex == -1) { return; } var ground = (Ground)target; Vector2[] previousHeights = ground.Heights; ground.Heights = new Vector2[ground.Heights.Length - 1]; for(int index = 0; index < ground.Heights.Length; ++index) { int previousIndex = index; if(index >= this.selectedHeightSampleIndex) { ++previousIndex; } ground.Heights[index] = previousHeights[previousIndex]; } if(this.selectedHeightSampleIndex >= ground.Heights.Length) { this.selectedHeightSampleIndex = ground.Heights.Length - 1; } } /// Inserts a new sample behind the current selected sample private void insertNewSample() { if(this.selectedHeightSampleIndex == -1) { return; // Or use last sample? } var ground = (Ground)target; Vector2[] previousHeights = ground.Heights; ground.Heights = new Vector2[ground.Heights.Length + 1]; for(int index = 0; index < ground.Heights.Length; ++index) { int previousIndex = index; if(index > this.selectedHeightSampleIndex) { --previousIndex; } ground.Heights[index] = previousHeights[previousIndex]; } // If a height sample is inserted between two other height samples, place it in // the middle of the two samples if(ground.Heights.Length > this.selectedHeightSampleIndex + 2) { Vector2 center = ( ground.Heights[this.selectedHeightSampleIndex] + ground.Heights[this.selectedHeightSampleIndex + 2] ) / 2.0f; ground.Heights[this.selectedHeightSampleIndex + 1] = center; } else if(this.selectedHeightSampleIndex > 0) { Vector2 extrapolated = ground.Heights[this.selectedHeightSampleIndex] + ( ground.Heights[this.selectedHeightSampleIndex] - ground.Heights[this.selectedHeightSampleIndex - 1] ); ground.Heights[this.selectedHeightSampleIndex + 1] = extrapolated; } ++this.selectedHeightSampleIndex; } /// /// Draws editing handles at the locations of the ground's height samples /// private void drawGroundVertices() { var ground = (Ground)target; Vector3 position = ground.transform.position; // Go over each height sample and calculate its position in world coordinates for(int index = 0; index < ground.Heights.Length; ++index) { Vector2 vertex = ground.Heights[index]; var center = new Vector3(position.x, position.y, position.z); center.x += vertex.x; center.y += vertex.y; // Always draw the normal handle drawNormalHandle(index, center); // Allow the user to move the currently selected height sample around if(index == this.selectedHeightSampleIndex) { drawMoveHandle(center); } } } /// Draws a normal handle for a height sample /// Index if the height sample for which a handle will be drawn /// Absolute position of the height sample private void drawNormalHandle(int index, Vector3 center) { bool wasHandleClicked = Handles.Button( center, Quaternion.identity, 0.2f, // size 0.3f, // pickSize Handles.CylinderHandleCap ); if(wasHandleClicked) { this.selectedHeightSampleIndex = index; } } /// Draws a handle that supports moving for a height sample /// Absolute position of the height sample private void drawMoveHandle(Vector3 center) { var ground = (Ground)target; Vector3 position = ground.transform.position; // Let Unity know that this object is being changed so it can take care // of recording its state in the undo history when moving ceases. Undo.RecordObject(ground, "Move Ground Point"); // Draw the handle and update the height sample with the new position Vector3 newPosition = Handles.PositionHandle(center, Quaternion.identity); ground.Heights[this.selectedHeightSampleIndex].x = newPosition.x - position.x; ground.Heights[this.selectedHeightSampleIndex].y = newPosition.y - position.y; } /// Scans the floor and automatically fits a polyline to the walkable area /// Ground that will be fitted to the floor mesh public void FitGround(Ground ground) { IList samples = gatherHeightSamples(ground); simplifyHeightSamples(samples); #if DEBUG_GROUND_FITTING Debug.Log(" Simplified to " + samples.Count + " height samples"); for(int index = 0; index < samples.Count; ++index) { Debug.Log("Sample #" + index.ToString() + " at " + samples[index].ToString()); } #endif if(samples.Count >= 2) { float left = samples[0].x; float right = samples[samples.Count - 1].x; float center = Mathf.Floor((left + right) / 2.0f); float lowestY = samples[0].y; for(int index = 1; index < samples.Count; ++index) { lowestY = samples[index].y; } Vector3 position = ground.transform.position; position.x = center; position.y = lowestY + FloorDistance; position.z = 0; ground.transform.position = position; ground.Heights = new Vector2[samples.Count]; for(int index = 0; index < samples.Count; ++index) { ground.Heights[index] = new Vector2( samples[index].x - center, samples[index].y - lowestY ); } } } /// Gathers height samples for the navigation ground /// All height samples from end to end of the navigation ground private static IList gatherHeightSamples(Ground ground) { var samples = new List(); Vector3 position = ground.transform.position; position.y += 1.0f; int defaultLayerMask = LayerMaskHelper.GetLayerMaskFor("Default"); RaycastHit hitInfo; if(!Physics.Raycast(position, Vector3.down, out hitInfo, 2.0f, defaultLayerMask)) { throw new InvalidOperationException( "Could not find any solid objects below the navigation ground's location" ); } position = hitInfo.point; samples.Add(new Vector2(position.x, position.y)); // Collect samples in negative direction for(var offset = new Vector2(); ; offset.x -= 0.1f) { Vector3 origin = new Vector3(position.x + offset.x, position.y + offset.y + 1.0f, 0.0f); if(!Physics.Raycast(origin, Vector3.down, out hitInfo, 2.0f, defaultLayerMask)) { Debug.Log("Ground fitting: Left end at " + origin.ToString() + " in chasm."); break; } float heightDifference = hitInfo.distance - 1.0f; if(Mathf.Abs(heightDifference) > 0.1f) { Debug.Log("Ground fitting: Left end at " + origin.ToString() + " in wall."); break; } Vector2 sample = new Vector2(hitInfo.point.x, hitInfo.point.y); samples.Insert(0, sample); offset.y = hitInfo.point.y - position.y; } // Collect samples in positive direction for(var offset = new Vector2(); ; offset.x += 0.1f) { Vector3 origin = new Vector3(position.x + offset.x, position.y + offset.y + 1.0f, 0.0f); if(!Physics.Raycast(origin, Vector3.down, out hitInfo, 2.0f, defaultLayerMask)) { Debug.Log("Ground fitting: Right end at " + origin.ToString() + " in chasm."); break; } float heightDifference = hitInfo.distance - 1.0f; if(Mathf.Abs(heightDifference) > 0.1f) { Debug.Log("Ground fitting: Right end at " + origin.ToString() + " in wall."); break; } Vector2 sample = new Vector2(hitInfo.point.x, hitInfo.point.y); samples.Add(sample); offset.y = hitInfo.point.y - position.y; } return samples; } /// Simplifies the speicfied polyline /// Polyline vertices that will be simplified private static void simplifyHeightSamples(IList samples) { if((samples != null) && (samples.Count > 2)) { eliminateOrSplit(samples, 0, samples.Count - 1); } } /// /// Eliminates the vertices between the two indices or finds a new split point /// /// Samples of the poly line /// Index of the first vertex considered for simplification /// Index of the last vertex considered for simplification private static void eliminateOrSplit( IList samples, int leftIndex, int rightIndex ) { float largestDistance = 0.0f; int largestIndex = -1; // Look for the vertex with the largest distance to the line segment connecting // the corner vertices between which we're working for(int index = leftIndex + 1; index < rightIndex; ++index) { float distance = squaredDistanceToSegment( samples[leftIndex], samples[rightIndex], samples[index] ); if(distance > largestDistance) { largestDistance = distance; largestIndex = index; } } // If all vertices were within the tolerance, throw them away and replace them by // a single line segment if(largestDistance < CollapseTolerance) { for(int index = rightIndex - 1; index > leftIndex; --index) { samples.RemoveAt(index); } } else { // Otherwise, use the biggest deviation as a new supporting vertex eliminateOrSplit(samples, largestIndex, rightIndex); eliminateOrSplit(samples, leftIndex, largestIndex); } } /// Calculates the distance of a point to a line segment /// Start of the line segment /// End of the line segment /// Point whose distance to the line segment will be calculated /// The distance of the point to the line segment private static float squaredDistanceToSegment(Vector2 start, Vector2 end, Vector2 point) { Vector2 lineDirection = end - start; Vector2 pointOffset = point - start; float dot = Vector2.Dot(pointOffset, lineDirection); float interval = dot / lineDirection.sqrMagnitude; #if POINTS_CAN_LIE_PAST_LINE_ENDS if(interval <= 0.0f) { return pointOffset.sqrMagnitude; } else if(interval >= 1.0f) { return (point - end).sqrMagnitude; } else { #endif return (point - (start + lineDirection * interval)).sqrMagnitude; #if POINTS_CAN_LIE_PAST_LINE_ENDS } #endif } #if false /// Makes the ground's collider fit the covered area public void FitCollider() { enforceAtLeastTwoHeightsAssigned(); BoxCollider collider = GetComponent(); if(collider == null) { throw new InvalidOperationException( "Cannot fit collider on '" + name + "' because no BoxCollider has been added" ); } Vector2 min = this.Heights[0]; Vector2 max = this.Heights[0]; for(int index = 1; index < this.Heights.Length; ++index) { float x = this.Heights[index].x; float y = this.Heights[index].y; if(x < min.x) { min.x = x; } if(x > max.x) { max.x = x; } if(y < min.y) { min.y = y; } if(y > max.y) { max.y = y; } } max.y += 3.5f; // Player height + jump safety zone collider.size = new Vector3(max.x - min.x, max.y - min.y, 2.0f); collider.center = new Vector3((min.x + max.x) / 2.0f, (min.y + max.y) / 2.0f, 0.0f); } #endif /// Index of the currently selected height sample private int selectedHeightSampleIndex = -1; } } // namespace Framework.Navigation.Platformer