An ambitious final project for my scripting for games class at UTD that takes the usual two-player coop game and adds a twist to it. There is a 3d player that exists within a room of randomly generated objects and a 2d player that exists within the screen plane that must jump on the objects to complete each level. The 2d player can interact with 3d objects as if they were platforms in the 2d world. The game’s objects include basic platforms that interact with the 2d player, kill boxes, the goal, and mirrors and portals, which the 2d player can interact through as if they lead to another space. In my code, the 3d player is referred to as “cleric” and the 2d player is referred to as “wisp”.

Wisp and Cleric Gameplay

Wisp Player Controller

The wisp player controller was riddled with challenges. In order to get the correct normal in this simulated 2d environment, I used raycasts aimed from the camera to the next position of wisp to determine if it would impact one of the interacting objects (which have tags to signify the way they interact). If one is hit, additional raycasts are used to determine the nearest perspectively projected edge to wisp by finding the 3 vertices of the hit triangle of the object’s mesh, calculating the distance to each line projected into the screen plane, then checking if there is a closer triangle in case the movement was too fast. The perspectively projected edge gives the normal, which can be used to reduce wisp’s movement to prevent it from entering the object.

public void DoWispPhysicsNext()//Checks wisp's next position based on current movement
    {
        //Debug.DrawLine(transform.position, transform.position + movement, Color.cyan, 10);
        if (Physics.Raycast(currentCamera.transform.position, transform.position + movement - currentCamera.transform.position, out hit))
        {
            Debug.DrawLine(currentCamera.transform.position, hit.point, Color.white);
            //Get the point in space relative to the hit and the camera plane where wisp would be before movement
            currentPosAtHit = hit.point - PerspectiveProjection(transform.position, movement + transform.position - transform.up * gravity * gravMulti, Vector3.Project(hit.point - currentCamera.transform.position, currentCamera.transform.forward).magnitude);
            if (hit.transform.tag == "Portal") hit = hit.collider.GetComponent<Portal>().ContinueRaycast(hit, 0);
            if (hit.transform.tag == "WispWall")
            {
                //Collision Detection
                normal = Vector3.ProjectOnPlane(ForceNormal(hit), currentCamera.transform.forward).normalized;
                //Debug.DrawRay(hit.point, normal, Color.red, 1);
                if(Vector3.Project(normal, transform.up).magnitude > 0.2f)
                {
                    if (!grounded)
                    {
                        Destroy(Instantiate(groundHit, transform.position, Quaternion.identity), 1f);
                        GetComponent<AudioSource>().pitch = Random.Range(.8f, 1.1f);
                        GetComponent<AudioSource>().Play();
                        GetComponent<AudioSource>().time = .3f;
                    }
                    grounded = true;
                    jumpTimer = 0;
                }
                else
                {
                    if (Input.GetKey(KeyCode.Space) && jumpTimer <= 0) movement += -transform.up * gravity/ 2 * gravMulti;
                    else movement += -transform.up * gravity * gravMulti;
                    grounded = false;
                }
                movement -= Vector3.Project(movement, normal);
                //Debug.DrawLine(transform.position, transform.position + movement, Color.red, 10);
                //Rigidbody Movement
                if (hit.rigidbody)
                {
                    movement += PerspectiveProjection(hit.point, hit.point + hit.rigidbody.velocity * Time.fixedDeltaTime, canvas.planeDistance);
                    movement += PerspectiveProjection(hit.point, Quaternion.Euler(hit.rigidbody.angularVelocity * Time.fixedDeltaTime * Mathf.Rad2Deg) * (hit.point - hit.transform.position) + hit.transform.position, canvas.planeDistance);
                    //Debug.DrawLine(hit.point, Quaternion.Euler(hit.rigidbody.angularVelocity * Time.fixedDeltaTime * Mathf.Rad2Deg) * (hit.point - hit.transform.position) + hit.transform.position, Color.red, 50);
                    //Debug.DrawLine(transform.position, transform.position + movement, Color.magenta, 50);
                }
            }
            else
            {
                if (Input.GetKey(KeyCode.Space) && jumpTimer <= 0) movement += -transform.up * gravity / 2;
                else movement += -transform.up * gravity;
                normal = Vector3.zero;
                grounded = false;
            }
        }
private Vector3 ForceNormal(RaycastHit hit)
    {
        Vector3 normal = Vector3.zero;
        ahhh = 0;
        EdgeInfo edge = GetAbsoluteClosestTriangle(hit, 0);
        if(edge != null)
        {
            Debug.DrawLine(edge.center, edge.center + currentCamera.transform.up * .05f, Color.white, 1f);
            Vector3 centerProj = PerspectiveProjection(edge.center, 0.5f);
            Debug.DrawLine(centerProj, centerProj + (Vector3.Project(centerProj - edge.edge[0], edge.edge[1] - edge.edge[0]) + edge.edge[0] - centerProj) * 1.1f, Color.gray, 1);
            Debug.DrawLine(edge.edge[0], edge.edge[1], Color.magenta, 1);
            normal = Vector3.Project(hit.point - edge.center, Vector3.Cross(hit.point - currentCamera.transform.position, edge.edge[0] - edge.edge[1])).normalized;
            Debug.DrawRay(check.point, normal); 
        }
        return normal;
    }
private Vector3[] FindPoints(RaycastHit hit)
    {
        if(ahhh++ < 6)
        {
            MeshCollider meshCollider = hit.collider as MeshCollider;
            if (meshCollider == null || meshCollider.sharedMesh == null)
                return null;
            Mesh mesh = meshCollider.sharedMesh;
            Vector3[] vertices = mesh.vertices;
            int[] triangles = mesh.triangles;
            Vector3[] points = new Vector3[3];
            points[0] = PerspectiveProjection(hit.transform.TransformPoint(vertices[triangles[hit.triangleIndex * 3 + 0]]), canvas.planeDistance);
            points[1] = PerspectiveProjection(hit.transform.TransformPoint(vertices[triangles[hit.triangleIndex * 3 + 1]]), canvas.planeDistance);
            points[2] = PerspectiveProjection(hit.transform.TransformPoint(vertices[triangles[hit.triangleIndex * 3 + 2]]), canvas.planeDistance);
            return points;
        }
        return null;
    }
private float ClosestDistToTriangleEdge(Vector3[] vertices, Vector3 point)
    {
        float dist1 = PointToLineDist(vertices[0], vertices[1], point);
        float dist2 = PointToLineDist(vertices[1], vertices[2], point);
        float dist3 = PointToLineDist(vertices[0], vertices[2], point);
        if (dist1 < dist2 && dist1 < dist3) return dist1;
        else if (dist2 < dist3) return dist2;
        else return dist3;
    }
private Vector3[] ClosestEdge(Vector3[] vertices, Vector3 point)
    {
        float dist1 = PointToLineDist(vertices[0], vertices[1], point);
        float dist2 = PointToLineDist(vertices[2], vertices[1], point);
        float dist3 = PointToLineDist(vertices[0], vertices[2], point);
        Vector3[] edge = null;
        if (dist1 < dist2 && dist1 < dist3)
            edge = new Vector3[] { vertices[0], vertices[1] };
        else if (dist2 < dist3)
            edge = new Vector3[] { vertices[2], vertices[1] };
        else edge = new Vector3[] { vertices[0], vertices[2] };
        return edge;
    }
private Vector3[] SecondClosestEdge(Vector3[] vertices, Vector3 point)
    {
        float dist1 = PointToLineDist(vertices[0], vertices[1], point);
        float dist2 = PointToLineDist(vertices[2], vertices[1], point);
        float dist3 = PointToLineDist(vertices[0], vertices[2], point);
        Vector3[] edge = null;
        if(dist1 > dist2 && dist1 > dist3)//Edge 1 is closest
        {
            if (dist2 > dist3)//Edge 2 is 2nd closest
                edge = new Vector3[] { vertices[1], vertices[2] };
            else//Otherwise Edge 3 is the 2nd closest
                edge = new Vector3[] { vertices[0], vertices[2] };
        }//Else, then edge 1 isnt the closest
        else if(dist1 > dist2 || dist1 > dist3)//Edge 1 is the second closest
        {
            edge = new Vector3[] { vertices[0], vertices[1] };
        }
        else if(dist2 > dist3)//Edge 3 is the second closest because edge 2 is the closest and edge 1 is the farthest
            edge = new Vector3[] { vertices[1], vertices[2] };
        else edge = new Vector3[] { vertices[0], vertices[2] };//Otherwise edge 2 is the second closest
        return edge;
    }
private EdgeInfo GetAbsoluteClosestTriangle(RaycastHit hit, int layer)
    {
        if (hit.collider == null) return null;
        Vector3[] verts = FindPoints(hit);
        if (verts == null) return null;
        Vector3 center = (verts[0] + verts[1] + verts[2]) / 3;
        if (Physics.Raycast(currentCamera.transform.position, PerspectiveProjection(center, canvas.planeDistance) - movement.normalized * (1 + 1 / float.PositiveInfinity) * PointToLineDist(ClosestEdge(verts, transform.position), PerspectiveProjection(center, canvas.planeDistance)), out check, 100f) 
            && check.collider.tag == "WispWall" && layer < 5)
            return GetAbsoluteClosestTriangle(check, layer++);
        else return new EdgeInfo(ClosestEdge(verts, transform.position), verts, center);
    }
    private class EdgeInfo
    {
        public Vector3[] edge;
        public Vector3[] edge1;
        public Vector3[] edge2;
        public Vector3[] triangle;
        public Vector3 center;
        public EdgeInfo(Vector3[] e, Vector3[] t, Vector3 c)
        {
            edge = e;
            triangle = t;
            center = c;
        }
    }

Portals/Mirrors

While these portals do not allow cleric to travel through them, they do allow wisp to interact with anything seen through them. By using Unity’s physical camera settings, I was able to adjust the position, lens shift, focal length, and clipping planes to allow a perfect reflection or continuation of what the player sees. This would require additional cameras to simulate the similar effect on additional mirrors or portals seen through a mirror or portal, but is not currently implemented.

Mirrors and portals continue raycasts sent by wisp’s physics checks, and can even bounce multiple times. The script itself has 3 different modes, repeat, once, and mirror. Currently it only functions as repeat for either portal option.

public class Portal : MonoBehaviour
{
    private enum DrawMode { Repeat, Once, Mirror }
    [SerializeField] private DrawMode mode;
    [SerializeField] public Camera view;
    [SerializeField] public Portal link;
    [SerializeField] public Material portalDisplay;
    [SerializeField] public RenderTexture texture;
    [SerializeField] public Transform top;
    [SerializeField] public Transform bottom;
    private Vector3 prevRendPos;
    private List<Vector3> rendLocations = new List<Vector3>();
    private void Start()
    {
        if (mode == DrawMode.Mirror) link = this;
        view.sensorSize *= new Vector2(transform.localScale.x, transform.localScale.z);
        portalDisplay = new Material(portalDisplay);
        transform.Find("Display").GetComponent<Renderer>().material = portalDisplay;
        texture = new RenderTexture(2560, 2560, 16, RenderTextureFormat.ARGB32);
        view.targetTexture = texture;
    }
    public void Connect(Portal other)
    {
        link = other;
        link.link = this;
    }
    private void Update()
    {
        if (link)
        {
            if(mode == DrawMode.Repeat)
            {
                view.transform.position = transform.position - Quaternion.FromToRotation(transform.forward, link.transform.forward) * (link.transform.position - Camera.main.transform.position);
                view.focalLength = view.nearClipPlane = -view.transform.localPosition.y * transform.localScale.y;
                view.lensShift = new Vector2(view.transform.localPosition.x * transform.localScale.x / -view.sensorSize.x, view.transform.localPosition.z * transform.localScale.z / view.sensorSize.y);
                view.Render();
                link.portalDisplay.mainTexture = texture;
            }
            else if(mode == DrawMode.Once)
            {
                view.transform.position = transform.position - Quaternion.FromToRotation(transform.forward, link.transform.forward) * (link.transform.position - Camera.main.transform.position);
                view.focalLength = view.nearClipPlane = -view.transform.localPosition.y * transform.localScale.y;
                view.lensShift = new Vector2(view.transform.localPosition.x * transform.localScale.x / -view.sensorSize.x, view.transform.localPosition.z * transform.localScale.z / view.sensorSize.y);
                view.Render();
                link.portalDisplay.mainTexture = texture;
            }
        }
        if (mode == DrawMode.Mirror)
        {
            view.transform.position = transform.position - Vector3.Reflect(transform.position - Camera.main.transform.position, transform.up);
            view.focalLength = view.nearClipPlane = -view.transform.localPosition.y * transform.localScale.y;
            view.lensShift = new Vector2(view.transform.localPosition.x * transform.localScale.x / -view.sensorSize.x, view.transform.localPosition.z * transform.localScale.z / view.sensorSize.y);
            view.Render();
            link.portalDisplay.mainTexture = texture;
        }
    }
}