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