MENU

Ropes' Adventure

Ropes' Adventure is a third person single player adventure game for all ages. You explore temples and a wonderful stylized world trying to find the monkey treasure and your grandfather who disappeared years ago while trying to find it.

Winner

Game of the Year 2017
Best Execution in Design


Nominated

Best Execution in Art
Best Technical Execution
Peoples Choice

Platform: PC

Genre: Singleplayer Adventure

Duration: 7 weeks

Engine: Unity 5 

Team: 4 Designers & 5 Artists 

Project Role: 
Game Designer & Scripter

Responsebilities Included:

  • Concept Development
  • Story writing & world building
  • Camera & Cinematic System
  • Core Mechanic Utilization Design
  • Develop 1.5 year Marketing plan

World Design

  At the start of the project I was the main concept developer who consolidated the design teams ideas into a clear vision. We worked together in the design team to come up with the core mechanics for the game. From that I developed a concept of a hub based exploration and adventure game where you as a player could find pieces of the story in different sections of the level. Those pieces would then be placed in the HUB center in a monument. These pieces will act as a key to then allow the player to proceed to the next area.

This would allow the player a sense of freedom and be able to explore a larger area while still keeping focus and providing them with a clear task. In turn this would allow us to scale the project accordingly throughout development by the amount of challenge areas we could complete.

By using a hub based world design instead of a more linear experience the central hub would be revisited by the players multiple times. Reusing the same area over and over again while having entrances and exits to the different challenge areas be different, players would still get a sense of it being varied instead of repetition. Enhancing this with an interactable object in the center that you always have to pass through to get to the other areas would be key. Here players were shown progression by putting in their story pieces and would clearly see that they are closer to their goal, to move forward in the world.

The overview concept on the right was the core of how the hub would work, larger navigational challenges that connected the main challenge areas to the hub center. As the project progressed we choose to add two main navigational challenges to the hub to teach the player how some of the puzzle mechanics worked. In the slider below you can see the result of that. However, the way the challenge areas are still connected to the hub center with smaller navigational challenges as depicted in the original concept. Only that they are now a core part of the challenge area and not just connections. They are still working as alternating routes depending on how you enter or exit an area which provides player variety.

HubLayout

In the end the game art style and core navigational mechanic changed a lot from the original concept but what remained true was how the world was going to be built and the hub world design. The task of finding pieces in challenge areas, connected by navigational challenges that taught the player the mechanics for each area and showing the player a visual progression in the hub center was going to be a core rewarding element for the player in the end.


Camera & Basic Cinematic System

I was in charge of the camera system for the game. The goal was to have a smoothly following but controlled camera, a spring arm and have a filtered collision detection. The collision detection needed to ignore plants, trigger boxes and other smaller objects while adapting to walls and large obstacles. In turn it would autmatically adjust the distance toward the player camera.

Later in the project we saw the need for cinematics and forced player perspective in order to guide the player through our hub-based world. This lead to me creating a basic cinematic system to control the player camera.

Camera Collision

The Camera Collision is running every frame and casts a ray between the springarm position and the camera. If something is closer than the default distance it checks that objects layermask value and if it is the same value that we set in the inspector inside unity. If it is something else, we will not collide with the object. In Ropes' Adventure the camera will collide with the world except plants, the rope and smaller objects that have been given another layermask value. Then the camera position is smoothly adjusted until it is no longer colliding.

Click Code to Expand
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
    public LayerMask layerMask = 0; //maskfilter
    public bool UseCameraCollission = true; //Collision toggle
    Collider cameraCollision;
    float cameraDefaultDistance;//Set on awake
    int cameraCollisionRayLenght = 10;
    float cameraCollisionSize = 0.2f;
    float cameraCollisionAdjustmentSpeed = 0.12f;

//Hit Detection is run on Update with other functions

    void HitDetection()
    {
        Ray ray;
        RaycastHit rayHit;
        Vector3 cameraTransfromTarget;

        // Ray from spring arm towards the camera
        Vector3 heading = this.gameObject.transform.position - springArm.transform.position;

        ray = new Ray(springArm.transform.position, -transform.forward * cameraCollisionRayLenght);

        float lenght = cameraDefaultDistance - cameraCollisionSize;

        //Check if ray is hitting object closer than default distance, use mask as Filter
        if (Physics.CapsuleCast(springArm.transform.position, springArm.transform.position, cameraCollisionSize, -transform.forward, out rayHit, lenght, layerMask.value) && rayHit.distance < cameraDistance)
        {
            cameraCollisionHit = rayHit;
            cameraTransfromTarget = ray.GetPoint(rayHit.distance);
        }
        else
        {
            cameraTransfromTarget = springArm.transform.position + (-transform.forward * cameraDistance);
        }

        //Update the camera position
        transform.position = Vector3.Lerp(transform.position, cameraTransfromTarget, Time.deltaTime / cameraCollisionAdjustmentSpeed);
    }

 

Player Follow & Camera Rotation

The standard player follow function needed to feel smooth while always adjusting for the player since the characters movement were modified from the cameras perspective. The cameras rotation is applied on the springarm location and is clamped to a maximum and minimum angle. Further the camera needed to adjust quickly if the player started to swing and follow that movement without lag so the character felt responive to player input.

Click Code to Expand
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
    public float cameraHeight = 1.3f;
    public float cameraDistance = 3f;
    public float cameraHeightSwinging = 0.3f;
    public float aimSensitivityHorizontal = 100.0f;
    public float aimSensitivityVertical = 100.0f;

    public bool invertCamera = false;

    [Range(0.00f, 3.00f)]
    public float cameraSmooth = 0.2f;

    [Range(0.00f, 1.00f)]
    public float cameraMouseSmooth = 0.06f;

    TimerSystem timer;

    float springarmHor = 0.0f;
    float springarmVer = 0.0f;
    float maxCameraAngle = 75.0f;

    float currentSmooth;
    float minCameraDistance = 1.85f;
    float minCameraSmooth = 0.03f;

    void playerCamera()
    {

        followPlayer();
        cameraSpringArmRotation();

        if (UseCameraCollission)
            HitDetection();

    }

    void cameraSpringArmRotation()
    {
        float horizontal = (Input.GetAxis("RotateCameraX") * Time.deltaTime) * aimSensitivityHorizontal;
        float vertical = (Input.GetAxis("RotateCameraY") * Time.deltaTime) * aimSensitivityVertical;

        if (invertCamera)
            vertical = vertical * -1;

        float camRotSmooth = cameraMouseSmooth;

        springarmHor += horizontal;
        springarmVer = Mathf.Clamp(springarmVer + vertical, -maxCameraAngle, maxCameraAngle);

        Quaternion targetSpringArmRotation = Quaternion.Euler(springarmVer, springarmHor, 0f);

        springArm.transform.rotation = Quaternion.Slerp(springArm.transform.rotation, targetSpringArmRotation, Time.deltaTime / camRotSmooth);
    }

    void smoothFollowPosition()
    {
        float dist = Vector3.Distance(transform.position, (playerTarget.transform.position + (Vector3.up * cameraHeight)));

        float followSmoothPercentage = (dist - minCameraDistance) / (cameraDefaultDistance - minCameraDistance);

        if (followSmoothPercentage > 1)
        {
            followSmoothPercentage = 1f;
        }

        currentSmooth = Mathf.Lerp(minCameraSmooth, cameraSmooth, followSmoothPercentage);
        
    }

    void followPlayer()
    {
        smoothFollowPosition();
        if (playerTarget.GetComponent().playerStates.isSwinging)
        {
            float swingSmooth = Mathf.Lerp(currentSmooth, 0.00f, timer.runLerpTimer(2));
            springArm.transform.position = Vector3.Lerp(springArm.transform.position, playerTarget.transform.position + (Vector3.up * cameraHeightSwinging), Time.deltaTime / swingSmooth);
        }
        else
        {
            timer.resetTimer();
            springArm.transform.position = Vector3.Lerp(springArm.transform.position, playerTarget.transform.position + (Vector3.up * cameraHeight), Time.deltaTime / currentSmooth);
        }
    }

 

Camera Trigger & Cinematic Sequence

The Camera Trigger script can be used for multiple purposes by selecting a different enum. It uses gameobject transforms as targets for the player camera that gets fed to the camera controller with it's enterTransition() method.

If a cinematic sequence is supposed to be played we can add multiple targets to the sequence list and define different transition times for each position. During a camera sequence and camera position change the camera contoller is always moving the camera toward the last transform fed to it while still keeping older ones in a list.

A timer is run based on the transition time and when the camera have reached it's position the next target in the list is fed to the camera controller which moves the camera. When we reach the end of the sequence we empty the targets inside the camera controller and sets it to follow the player again. There's also the option to only trigger the event once to a player.

Click Code to Expand
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
using UnityEngine;
using System.Collections;
using System.Collections.Generic;


public class CameraTrigger_New : MonoBehaviour
{

    public cameraTag cameraFunction;
    public GameObject cameraTarget;
    public bool followPlayerInTrigger = false;
    public float forceCameraSpeed = 2f;
    public float resetCameraSpeed = 2f;

    public List sequenceList;
    public bool playOnce = true;
    public float sequenceTransitionTime = 2.0f;
    public List<float> transitionTimes;

    GameObject playerTarget;
    public Camera MainCamera;
    public GameObject springArm;
    public CameraController2 MainCameraController;
    bool playSequence = false;
    int currentSequenceTarget;
    int playCount = 0;
    TimerSystem sequenceTimer;
    [SerializeField]
    Color TriggerColor;


    void Update()
    {
        if (playSequence)
        {
            float currentTime = sequenceTimer.runTimer(transitionTimes[currentSequenceTarget]);
            if (currentTime >= transitionTimes[currentSequenceTarget] && currentSequenceTarget < sequenceList.Count - 1)
            {
                currentSequenceTarget++;
                MainCameraController.enterTransition(transitionTimes[currentSequenceTarget], sequenceList[currentSequenceTarget], cameraTag.cameraPosition, this.gameObject, followPlayerInTrigger);
                sequenceTimer.resetTimer();
            }
            else if (currentTime >= transitionTimes[currentSequenceTarget] && currentSequenceTarget == sequenceList.Count - 1)
            {

                foreach (GameObject g in sequenceList)
                {
                    MainCameraController.TargetList.Remove(g);
                }

                MainCameraController.currentEnum = cameraTag.playerFollow;
                MainCameraController.resetCameraToPlayer();
                sequenceTimer.resetTimer();
                currentSequenceTarget = 0;
                MainCameraController.useCustomCurve = true;
                playerTarget.GetComponent().playerRestrictions.canMoveViaInput = true;
                playSequence = false;

            }

        }
    }

    void OnTriggerEnter(Collider col)
    {
        if (col.gameObject.CompareTag("Player"))
        {
            playerTarget = col.gameObject;

            springArm = col.gameObject.GetComponent().springArmReference;

            MainCameraController = springArm.GetComponentInChildren();

            switch (cameraFunction)
            {
                case cameraTag.playerFollow:
                    break;

                case cameraTag.lookAtObject:
                    setCamerLookTarget();
                    break;

                case cameraTag.cameraPosition:
                    moveCameraToTransform();
                    break;

                case cameraTag.cameraPositionCenterBased:
                    MainCameraController.enterTransition(forceCameraSpeed, cameraTarget, cameraFunction, this.gameObject, followPlayerInTrigger);
                    break;

                case cameraTag.teleportCamera:
                    MainCameraController.teleportCamera(cameraTarget, cameraFunction);
                    break;

                case cameraTag.cameraSequence:
                    if (!playOnce || (playOnce && playCount < 1))
                    {
                        playCount++;
                        col.gameObject.GetComponent().playerRestrictions.canMoveViaInput = false;
                        MainCameraController.useCustomCurve = false;
                        MainCameraController.enterTransition(sequenceTransitionTime, sequenceList[0], cameraTag.cameraPosition, this.gameObject, followPlayerInTrigger);
                        currentSequenceTarget = 0;
                        playSequence = true;
                    }
                    break;

                default:
                    Debug.Log("Entered Default Camera State");
                    break;
            }
        }
    }

    void OnTriggerExit(Collider col)
    {
        if (col.gameObject.CompareTag("Player"))
        {

            switch (cameraFunction)
            {
                case cameraTag.playerFollow:
                    break;

                case cameraTag.lookAtObject:
                    MainCameraController.exitTransition(resetCameraSpeed, cameraTarget, cameraTag.returnToPlayer, followPlayerInTrigger);
                    break;

                case cameraTag.cameraPosition:
                    MainCameraController.exitTransition(resetCameraSpeed, cameraTarget, cameraTag.returnToPlayer, followPlayerInTrigger);
                    break;

                case cameraTag.cameraPositionCenterBased:
                    returnCameraToPlayerDefault();
                    break;

                case cameraTag.teleportCamera:

                    returnCameraToPlayerDefault();
                    break;

                case cameraTag.cameraSequence:
                    break;

                default:
                    returnCameraToPlayerDefault();
                    break;
            }
        }
    }

    void setCamerLookTarget()
    {
        MainCameraController.enterTransition(0f, cameraTarget, cameraFunction, this.gameObject, followPlayerInTrigger);
    }

    void moveCameraToTransform()
    {
        MainCameraController.enterTransition(forceCameraSpeed, cameraTarget, cameraFunction, this.gameObject, followPlayerInTrigger);
    }

    void returnCameraToPlayerDefault()
    {
        MainCameraController.exitTransition(resetCameraSpeed, cameraTarget, cameraTag.returnToPlayer, followPlayerInTrigger);
    }
}

 

By using a list of targets stored in the camera controller, and the controller always moving the camera toward the last target in the list, makes the camera highly dynamic. It allows me to add new targets while removing old ones simultaneously and the camera will still move to the correct target.

In the example below you can see the player jump into a trigger that moves the camera to an overview and then gets fed a new target as the player interacts with the key board. That target is then removed and the camera moves by itself to the last position in the list. As the player moves out of the trigger box, that target gets removed from the target list and the camera moves back to following the player. Finally the player is given back camera control when the camera reached the character.

Click Code to Expand
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
    public void enterTransition(float transitionLength, GameObject transitionTarget, cameraTag transitionTag, GameObject currentTrigger, bool trackPlayer)
    {
        if (currentTargetTimerValue != 0) //Set timer == 0 for new transition
        {
            t.resetTimer();
            currentTargetTimerValue = 0f;
        }

        moveToTargetSpeed = transitionLength;
        cameraStartPosition = springArm.transform.position;
        cameraStartRotation = springArm.transform.rotation;
        lookatPlayer = trackPlayer;
        TargetList.Add(transitionTarget); //Add new transition target to List
        currentCameraTrigger = currentTrigger; //Save ref to current camera trigger
        currentEnum = transitionTag; //Set cameracontroller function enumeration
    }

    public void exitTransition(float transitionLength, GameObject transitionTarget, cameraTag transitionTag, bool trackPlayer)
    {
        cameraStartPosition = springArm.transform.position;
        cameraStartRotation = springArm.transform.rotation;
        moveToTargetSpeed = transitionLength;
        lookatPlayer = trackPlayer;

        if (TargetList.Count <= mintargetListCount) //Check if target list is at default count
        {
            float customTransitionLength = transitionLength;
            currentEnum = transitionTag;
        }

        currentTargetTimerValue = 0;
        t.resetTimer();

        TargetList.Remove(transitionTarget); //remove target for list
    }    
    
    void moveCameraToTarget()
    {
        if (useCustomCurve) //Check if transition should use curve for ease-in-out
        {
            springArm.transform.position = Vector3.Lerp(cameraStartPosition, lastItemInTargetList.transform.position, transitionCurve.Evaluate(currentTargetTimerValue));
            springArm.transform.rotation = Quaternion.Slerp(cameraStartRotation, lastItemInTargetList.transform.rotation, transitionCurve.Evaluate(currentTargetTimerValue));
        }
        else
        {
            springArm.transform.position = Vector3.Lerp(cameraStartPosition, lastItemInTargetList.transform.position, currentTargetTimerValue);
            springArm.transform.rotation = Quaternion.Slerp(cameraStartRotation, lastItemInTargetList.transform.rotation, currentTargetTimerValue);
        }
    }

    public void resetMoveCameraToTarget()
    {

        followPlayer();//Start moving toward player position

        if (lookatPlayer) //Reset camera local look rotation
            resetLookAtTarget();
        else
            resetCameraLocalRotation = true;

        //Update Camera Rotation Input values
        springarmHor = springArm.transform.rotation.eulerAngles.y;
        springarmVer = springArm.transform.rotation.eulerAngles.x;
        
        cameraSpringArmRotation();
        
        //Check if camera position is in range of default position
        if (Vector3.Distance(springArm.transform.position, playerTarget.transform.position + (Vector3.up * cameraHeight)) < 1f && resetCameraLocalRotation)
        {

            currentEnum = cameraTag.playerFollow; //Give all control back to player
            resetCameraLocalRotation = false;
            t.resetTimer();
            currentTargetTimerValue = 0;
        }
    }

 

This also lets me use multiple stacked trigger boxes placed within eachother with different target locations each. However since the the boxes remove their targets as the players exit and the camera controller uses the last target fed to it I can move the cameras around in a smart way to ease player navigation.