For the Climax Studios game jam we created an arcadey first person parkour game in 48 hours. To be clear, I did not create the player movement and grapple hook functionality. My role was creating the checkpoint system, impleneting the UI and audio, and creating an easy system for the designers to create new levels by putting every variable in the inspector.
The Checkpoints are the main gameplay element for our game. When the Player runs into a checkpoint, it adds more time to the timer, and counts towards the total checkpoints required to complete the level. If the timer runs out, it results in a game over and the level must be attempted again.
The CheckpointController script, attached to the object of the same name, contains all the checkpoint related variables, such as the list of active checkpoints and how many should be active at once. The ChangeCheckpoints function is called when the Player collides with a checkpoint, and puts that checkpoint into a seperate "discarded" list so it can't be used again.
The Checkpoint script is attached to each Checkpoint and calls the ChangeCheckpoint function on the CheckpointController script whenever the Player collides with it.
Infinite mode does not have a win condition, instead the Player must accumulate checkpoints until time runs out. This is done with a simple checkbox in the inspector. In the CheckpointController script, it cycles checkpoints between the Active and Disacrded list, ensuring that there are always checkpoints available to run into.
public class CheckpointController : MonoBehaviour
{
public List checkpoints = new List();
public List discardedCheckpoints = new List();
public int currentCheckpoint;
public int checkpointsCollected, checkpointsRequired, numberOfCheckpoints, grappleUses;
public int grapplesUsed;
public float pitchToAdd, maxPitch, startingPitch;
public float checkpointsLeft;
public bool isInfinite;
public static CheckpointController instance;
public GameObject audioManager;
private void Awake()
{
instance = this;
}
// Start is called before the first frame update
void Start()
{
//If playing on normal mode
if (!isInfinite)
{
UIController.instance.checkpointText.gameObject.SetActive(true);
checkpointsLeft = checkpointsRequired;
UIController.instance.checkpointText.text = "Checkpoints remaining: " + checkpointsLeft;
}
audioManager = GameObject.Find("Audio Manager");
// Player starts off with a certain amount of grapples
grapplesUsed = 0;
// If there are checkpoints, set the first one to be active
if (checkpoints != null)
{
for (int i = 0; i < numberOfCheckpoints; i++)
{
currentCheckpoint = Random.Range(0, checkpoints.Count - 1);
checkpoints[currentCheckpoint].gameObject.SetActive(true);
discardedCheckpoints.Add(checkpoints[currentCheckpoint].GetComponent());
checkpoints.RemoveAt(currentCheckpoint);
}
}
}
// Update is called once per frame
void Update()
{
}
//After colliding with a checkpoint
public void ChangeCheckpoints(Checkpoint checkpoint)
{
if (!isInfinite)
{
checkpointsLeft--;
UIController.instance.checkpointText.text = "Checkpoints remaining: " + checkpointsLeft;
}
UIController.instance.timerBreakTimer = UIController.instance.timerBreakLength;
if (startingPitch + (UIController.instance.currentChain * pitchToAdd) > maxPitch)
{
audioManager.GetComponent().sfx[7].pitch = maxPitch;
audioManager.GetComponent().sfx[8].pitch = maxPitch;
audioManager.GetComponent().sfx[9].pitch = maxPitch;
}
else
{
audioManager.GetComponent().sfx[7].pitch = startingPitch + (UIController.instance.currentChain * pitchToAdd);
audioManager.GetComponent().sfx[8].pitch = startingPitch + (UIController.instance.currentChain * pitchToAdd);
audioManager.GetComponent().sfx[9].pitch = startingPitch + (UIController.instance.currentChain * pitchToAdd);
}
int checkpointIndex = Random.Range(7, 10);
audioManager.GetComponent().PlaySFX(checkpointIndex);
// Add 1 to amount of checkpoints collected
// Add more grapple uses
// Change grapple text
checkpointsCollected++;
grapplesUsed = 0;
UIController.instance.ReloadGrapples();
// Meet the required checkpoints to win and you win!
if (checkpointsCollected >= checkpointsRequired && !isInfinite && !UIController.instance.hasWon)
{
{
audioManager.GetComponent().PlaySFX(4);
UIController.instance.hasWon = true;
GameManager.instance.PauseUnPauseGame();
UIController.instance.winText.gameObject.SetActive(true);
}
}
else
{
if (checkpoints.Count > 0)
{
//Choose another checkpoint
currentCheckpoint = Random.Range(0, checkpoints.Count - 1);
checkpoints[currentCheckpoint].gameObject.SetActive(true);
//Add checkpoint to the discard list and remove checkpoint from the active list
discardedCheckpoints.Add(checkpoints[currentCheckpoint].GetComponent());
checkpoints.RemoveAt(currentCheckpoint);
}
else if (isInfinite == true)
{
checkpointsCollected++;
discardedCheckpoints.Remove(checkpoint);
foreach (Checkpoint c in discardedCheckpoints)
{
if (!c.gameObject.activeInHierarchy)
{
checkpoints.Add(c);
}
}
foreach (Checkpoint c in checkpoints)
{
discardedCheckpoints.Remove(c);
}
currentCheckpoint = Random.Range(0, checkpoints.Count - 1);
checkpoints[currentCheckpoint].gameObject.SetActive(true);
discardedCheckpoints.Add(checkpoints[currentCheckpoint].GetComponent());
checkpoints.RemoveAt(currentCheckpoint);
checkpoints.Add(checkpoint);
}
}
}
}
public class Checkpoint : MonoBehaviour
{
public float timeToAdd;
public GameObject audioManager;
// Start is called before the first frame update
void Start()
{
// Make sure this gameobject is setactive(false) in the inspector
audioManager = GameObject.Find("Audio Manager");
}
// Update is called once per frame
void Update()
{
}
private void OnTriggerEnter(Collider other)
{
// If the Player walks into this, add time to the timer
if (other.CompareTag("Player"))
{
UIController.instance.AddTime(timeToAdd);
UIController.instance.AddChain();
CheckpointController.instance.ChangeCheckpoints(this);
gameObject.SetActive(false);
UIController.instance.chainTextAnim.SetTrigger("bounce");
}
}
}
All UI elements, such as the timer, number of checkpoints remaining, and icons representing amount grapple hook uses left, are programmed using the UIController script. It sets all these elements by talking to other scripts like the Checkpoint Controller script, for example when a Player runs through a checkpoint and the timer increases. The Audio is implemented through the Audio Manager script and calls for specific audio files whenever the relevant action is performed.
public class UIController : MonoBehaviour
{
public TextMeshProUGUI timerText, chainText, pauseText, loseText, winText, checkpointText;
public float startTimerLength, timerBreakLength;
[HideInInspector]
public float timerBreakTimer;
private float timer;
[HideInInspector]
public bool hasWon, hasLost;
[HideInInspector]
public int currentChain;
private float chainTimer;
public float chainTimerLength, reduceTimePerChain, minimumChainTime;
public Slider chainSlider;
public Button restartGameButton, exitToMenuButton;
public static UIController instance;
public Image grappleIcon, dot;
public Sprite grappleActive, grappleInactive, dotRed, dotGreen;
public List grappleIcons = new List();
public GameObject audioManager;
public Animator timerAnim, chainTextAnim;
private void Awake()
{
instance = this;
}
// Start is called before the first frame update
void Start()
{
audioManager = GameObject.Find("Audio Manager");
if (CheckpointController.instance.grappleUses > 0 && CheckpointController.instance.grappleUses < 5)
{
for (int i = 0; i < CheckpointController.instance.grappleUses; i++)
{
grappleIcons[i].gameObject.SetActive(true);
}
}
// Starts the timer with a length of time
timer = startTimerLength;
chainSlider.maxValue = chainTimerLength;
}
// Update is called once per frame
void Update()
{
if (timerBreakTimer <= 0)
{
// Counts down the timer
timer -= Time.deltaTime;
}
timerText.text = "Time left: " + timer.ToString("F2");
timerBreakTimer -= Time.deltaTime;
if (Input.GetKeyDown(KeyCode.I))
{
grappleIcon.sprite = grappleActive;
}
if (Input.GetKeyDown(KeyCode.K))
{
grappleIcon.sprite = grappleInactive;
}
Countdown();
// If the timer reaches 0, reload the scene
if (timer <= 0 && !hasLost)
{
audioManager.GetComponent().PlaySFX(6);
hasLost = true;
GameManager.instance.PauseUnPauseGame();
loseText.gameObject.SetActive(true);
}
}
public void Countdown()
{
if (timer < 5f)
{
timerAnim.SetBool("isRunningOutOfTime", true);
}
else
{
timerAnim.SetBool("isRunningOutOfTime", false);
}
if (chainTimer <= 0)
{
if (currentChain > 0)
{
audioManager.GetComponent().PlaySFX(3);
}
chainSlider.gameObject.SetActive(false);
currentChain = 0;
chainText.text = "Chain: " + currentChain;
}
else if (!hasLost)
{
chainTimer -= Time.deltaTime;
chainSlider.value = chainTimer;
}
}
public void AddTime(float timeToAdd)
{
// Adds time to the timer
timer += timeToAdd;
timerText.text = "Time left: " + timer.ToString("F2");
}
//Add one to the chain
public void AddChain()
{
chainSlider.gameObject.SetActive(true);
currentChain++;
chainText.text = "Chain: " + currentChain;
if (chainTimerLength - (currentChain * reduceTimePerChain) < minimumChainTime)
{
chainTimer = minimumChainTime;
chainSlider.maxValue = minimumChainTime;
}
else
{
chainTimer = chainTimerLength - (currentChain * reduceTimePerChain);
chainText.text = "Chain: " + currentChain;
chainSlider.maxValue = chainTimerLength - (currentChain * reduceTimePerChain);
}
}
public void RestartGame()
{
GameManager.instance.ReloadScene();
}
public void ExitToMenu()
{
GameManager.instance.ExitToMenu();
}
public void TurnOffGrappleIcon(int position)
{
grappleIcons[position].GetComponent().sprite = grappleInactive;
}
public void ReloadGrapples()
{
foreach (Image i in grappleIcons)
{
i.sprite = grappleActive;
}
}
public void GrappleIconDenied()
{
foreach (Image i in grappleIcons)
{
i.GetComponent().SetTrigger("pressedWhenInactive");
}
}
}
public class GameManager : MonoBehaviour
{
public static GameManager instance;
public bool isPaused;
private void Awake()
{
instance = this;
}
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
if (Input.GetKeyDown(KeyCode.Escape))
{
if (!UIController.instance.hasLost && !UIController.instance.hasWon)
{
PauseUnPauseGame();
}
}
}
public void ReloadScene()
{
Time.timeScale = 1f;
SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
}
public void PauseUnPauseGame()
{
if (isPaused)
{
Time.timeScale = 1f;
isPaused = false;
UIController.instance.pauseText.gameObject.SetActive(false);
UIController.instance.restartGameButton.gameObject.SetActive(false);
UIController.instance.exitToMenuButton.gameObject.SetActive(false);
}
else
{
Time.timeScale = 0f;
isPaused = true;
if (!UIController.instance.hasWon && !UIController.instance.hasLost)
{
UIController.instance.pauseText.gameObject.SetActive(true);
}
UIController.instance.restartGameButton.gameObject.SetActive(true);
UIController.instance.exitToMenuButton.gameObject.SetActive(true);
}
}
public void ExitToMenu()
{
Time.timeScale = 1f;
SceneManager.LoadScene("Main Menu");
}
}
The time constraint in the project made it all the more important for me to design the code so that the level designers had all the variables they needed to set through the inspector. This was handled using the UIController and CheckpointController. Variables setting things such as how many grapples can be used, to how much time is added when the Player collides with a Checkpoint, to checking a box to set the infinite mode, made it easy for the level designers to tweak difficulty and craft levels that felt different from each other.