using System; using System.Diagnostics; using UnityEngine; using Framework.Geometry.Lines; using Framework.Support; namespace Framework.Navigation.Platformer { /// Stretch of ground that navigation agents can walk upon public class Ground : MonoBehaviour { /// Heights at different points on the ground /// /// The heights are connected to form a polyline which defines the ground heights /// public Vector2[] Heights; /// Other ground that can be reached from here /// /// These are other locations that doesn't necessarily have to be reachable /// by all agents (path searches could put limits on jump height or ground /// type, for example). /// public Ground[] JumpTargets; /// Whether actors can jump and down through this floor /// /// For 2D platformers, this is simply a convention, for 2.5D platformers, /// the floor is usually placed a bit further back, so jumping up or down /// means going on step back or forth in depth. /// public bool CanPassThroughFloor; /// X coordinate of the left end of the ground public float Left { get { enforceAtLeastTwoHeightsAssigned(); return transform.position.x + this.Heights[0].x; } } /// X coordinate of the right end of the ground public float Right { get { enforceAtLeastTwoHeightsAssigned(); return transform.position.x + this.Heights[this.Heights.Length - 1].x; } } /// Gets the X coordinate of the ground's center public float Center { get { enforceAtLeastTwoHeightsAssigned(); float left = this.Heights[0].x; float right = this.Heights[this.Heights.Length - 1].x; return (left + right) / 2.0f + transform.position.x; } } /// Determines the height of the ground at the specific X position /// Position at which the height of the ground will be determined /// The ground height at the specified position /// /// Querying heights outside of the ground will clamp to the left or right sample /// respectively. This is done for reasons of robustness, the navigation agent /// should take care not to leave the limits of the ground. /// public float GetGroundHeightAt(float x) { enforceAtLeastTwoHeightsAssigned(); // Special case if there is only one height. Zero heights will be caught by the guard // in debug builds and cause an IndexOutOfRangeException here if a release build is // compiled containing misbehaving code (fix it at the source, not here!) int length = this.Heights.Length; if(length <= 1) { return this.Heights[0].y; } int index = getIndexByX(x); if(index == -1) { // -1 means that no support point was left of X return this.Heights[0].y + transform.position.y; } else if(index >= length - 1) { // means X is right of all support points return this.Heights[index].y + transform.position.y; } else { Vector2 left = this.Heights[index]; Vector2 right = this.Heights[index + 1]; x -= transform.position.x; return Mathf.Lerp( left.y, right.y, (x - left.x) / (right.x - left.x) ) + transform.position.y; } } /// Gets the normal vector of the ground at the specified location /// Location at which the normal vector will be returned /// The normal vector at the specified location public Vector2 GetGroundNormalAt(float x) { enforceAtLeastTwoHeightsAssigned(); // Special case if there is only one height. Zero heights will be caught by the guard // in debug builds and cause an IndexOutOfRangeException here if a release build is // compiled containing misbehaving code (fix it at the source, not here!) int length = this.Heights.Length; if(length <= 1) { return Vector2.up; } Vector2 normal; int index = getIndexByX(x); if(index == -1) { // -1 means that no support point was left of X normal.x = this.Heights[0].y - this.Heights[1].y; normal.y = this.Heights[1].x - this.Heights[0].x; } else if(index >= length - 1) { // means X is right of all support points normal.x = this.Heights[length - 2].y - this.Heights[length - 1].y; normal.y = this.Heights[length - 1].x - this.Heights[length - 2].x; } else { normal.x = this.Heights[index].y - this.Heights[index + 1].y; normal.y = this.Heights[index + 1].x - this.Heights[index].x; } return normal.normalized; } #if UNITY_EDITOR /// Called to visualize the walkable area in the editor protected virtual void OnDrawGizmos() { Matrix4x4 oldMatrix = Gizmos.matrix; Color oldColor = Gizmos.color; try { if(this.JumpTargets != null) { drawJumpGizmos(); } if(this.Heights != null) { drawGroundGizmo(); } } finally { Gizmos.color = oldColor; Gizmos.matrix = oldMatrix; } } /// Draws the editor gizmo for the ground private void drawGroundGizmo() { Vector3 position = transform.position; for(int index = 0; index < this.Heights.Length - 1; ++index) { var left = new Vector2(this.Heights[index].x, this.Heights[index].y); var right = new Vector2(this.Heights[index + 1].x, this.Heights[index + 1].y); if(left.x < right.x) { Gizmos.color = new Color(0.333f, 1.0f, 0.333f, 0.667f); } else { Gizmos.color = new Color(1.0f, 0.333f, 0.333f, 0.667f); } // Calculate the angle of this line segment var up = new Vector2(left.y - right.y, right.x - left.x); up.Normalize(); float angle = Mathf.Atan2(up.y, up.x) * Mathf.Rad2Deg - 90.0f; // Select a matrix that translates to the where the ground segment // begins and is rotated to the normal of the ground at that location Quaternion orientation = Quaternion.AngleAxis(angle, Vector3.forward); var translation = new Vector3(position.x + left.x, position.y + left.y, position.z); Gizmos.matrix = Matrix4x4.TRS(translation, orientation, Vector3.one); // Using the transformation matrix from before, draw the line segment // as a cube (because this uses the depth buffer and makes ground placement // much easier to check) float length = (right - left).magnitude; Gizmos.DrawCube( new Vector3(length / 2.0f, -0.05f, 0.0f), new Vector3(length, 0.1f, 0.5f) ); } } /// Draws the jump targets of the ground into the scene private void drawJumpGizmos() { for(int index = 0; index < this.JumpTargets.Length; ++index) { if(this.JumpTargets[index] == null) { continue; } Segment2? jumpPath = JumpCalculator.GetJumpPath( this, this.JumpTargets[index], 0.0f, Constraints.Infinite ); if(jumpPath.HasValue) { var curve = new CubicBezierCurve() { P0 = jumpPath.Value.From, P3 = jumpPath.Value.To }; float apex = Math.Max(jumpPath.Value.From.y, jumpPath.Value.To.y) + 0.5f; curve.P1 = new Vector3(Mathf.Lerp(curve.P0.x, curve.P3.x, 0.50f), apex, 0.0f); curve.P2 = new Vector3(Mathf.Lerp(curve.P0.x, curve.P3.x, 0.75f), apex, 0.0f); if(curve.P0.y > curve.P3.y) { Gizmos.color = Color.blue; } else { Gizmos.color = Color.red; } drawCubicBezierCurve(curve); } else { Vector3 from; from.x = Center; from.y = GetGroundHeightAt(from.x); from.z = 0.0f; Vector3 to; to.x = this.JumpTargets[index].Center; to.y = this.JumpTargets[index].GetGroundHeightAt(to.x); to.z = 0.0f; Gizmos.color = Color.white; Gizmos.DrawLine(from, to); } } } /// Draws a cubic bezier curve using the provided supporting points /// Curve with supporting points that will be drawn private static void drawCubicBezierCurve(CubicBezierCurve curve) { Vector3 previous; Vector3 current = curve.P0; for(int index = 1; index < 10; ++index) { previous = current; current = curve.GetPointAtTime((float)index / 10.0f); Gizmos.DrawLine(previous, current); } Gizmos.DrawLine(current, curve.P3); } #endif // UNITY_EDITOR /// Determines the index of closest supporting point left of X /// X coordinate to which the closest support point will be found /// The index of the closest supporting point left of X of -1 if none private int getIndexByX(float x) { int result = -1; x -= transform.position.x; // Assumption: typical ground nodes have so few supporting points that // a binary search would take longer than just iterating over the points. for(int index = 0; index < this.Heights.Length; ++index) { if(this.Heights[index].x < x) { result = index; } else { break; } } return result; } /// /// Throws an exception if there is are at least two supporting points /// [Conditional("DEBUG")] private void enforceAtLeastTwoHeightsAssigned() { if((this.Heights == null) || (this.Heights.Length < 2)) { throw new InvalidOperationException( "Navigation.Ground node must have at least two heights assigned" ); } } } } // namespace Framework.Navigation.Platformer