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.
Section Links
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.
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.
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.
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.
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.
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.