Bounce Ball

Splashscreen, Main Menu & Character Select screen of Bounce Ball.

About
Bounce Ball started out as an idea outside of the classroom, called Wall Clash, and
was intended to be a game of wizards firing bouncing projectiles and having the
ability to build up temporary blockades. This idea changed when we decided to focus
more on the mechanic of hitting the ball and utilizing unique personalities and themes
for each character and level.

Stan McClusky

The game’s first character to be made was Stan McClusky: An old baseball veteran here to take the price in the Bounce Ball World Cup.
As previously mentioned, each character has their own unique personality and this is
amplified via their abilities and animations.
Stan McClusky’s animations portray him as an experienced bat swinger who
takes the game way too seriously.

Vulture Dome (left) and Luxembourg (right).

Vulture Dome was the first arena we made and it’s an official Bounce Ball arena in modern style and also the home arena of McClusky.
Luxembourg was the second arena and intended to be the home arena of The King, who would be the third character added, in a medieval setting.

We worked for about 4 months on Bounce Ball, mostly tweaking the player mechanics of moving, swinging, and dodging.
Animations were unique per character to enhance their personality traits and this was further expanded on with each character having a unique ability.

Development
My role in the development of Bounce Ball was primarily that of a scripter.
I begun by creating the character controller. I used the Rigidbody component for movement and collision detection. This would become my largest project so far and so I decided to split-up character functionality in to separate scripts,
these are: Damage, Dash, Data, Marker, Movement, Swing, and Ultimate.
Damage handles taking damage and eventually dying, while also adding temporary invincibility and game-juice to the respawn function.
Dash handles dashing, using forces. Data held all information about the character – model, team/color, playerID, weight-class, spawnpoint and its prefab to load.
Marker handled the colored circle around the characters feet, helping players track their character easier and telling what direction its facing.
Movement handled all things related to movement and rotation, strafing and move-locking. It did not handle the Dash mechanic.
Swing handled the swinging of the bat, charging the swinging and swing-juice.
Finally, Ultimate handled each players unique ability, its cooldown and some more juice. All character related scripts were handled through a Manager class.

I found this method of scripting to be more manageable in terms of scoping each class down to its core necessities and increasing script readability. What I learned in the long-run was that a lot more than I previously imagined can be abstracted and even extracted to separate components. The biggest flaw, I think, is that the scripts relied on too many references that had to be passed and so I had to utilize an Initialize() method which does not seem like a good solution in classes deriving from MonoBehaviour.
This is the PlayerManager class:

using System;
using UnityEngine;
using Player;

namespace CustomManagers {
  [Serializable]
  public class PlayerManager {

    // Settings.
    public Color p_Color;
    public Vector3 p_SpawnPoint;
    public Color[] p_TeamColorList = new Color[4] { Color.red, Color.blue, Color.yellow, Color.magenta };

    // Info.
    [HideInInspector] public int p_Number;
    [HideInInspector] public GameObject p_Instance;
    [HideInInspector] public PlayerTeam p_Team;
    [HideInInspector] public CharacterWeightClass p_WeightClass;
    
    // Reference all required components.
    private Animator p_Animator;
    private AudioSource p_Audio;
    public Transform p_Transform;
    private Rigidbody p_Rigidbody;

    // Reference all required scripts.
    private PlayerData p_Data;
    private PlayerMovement p_Movement;
    private PlayerSwing p_Swing;
    private PlayerDash p_Dash;
    private PlayerDamage p_Damage;
    private PlayerMarker p_Marker;
    private PlayerUltimate p_Ultimate;

    public void InitPlayer() {
      // Get component references.
      p_Transform = p_Instance.transform;
      p_Animator = p_Transform.GetComponent<Animator>();
      p_Audio = p_Transform.GetComponent<AudioSource>();
      p_Rigidbody = p_Transform.GetComponent<Rigidbody>();

      // Get references.
      p_Data = Managers.GetManager_Game.GetPlayerData(p_Number);
      p_Movement = p_Instance.GetComponent<PlayerMovement>();
      p_Swing = p_Instance.GetComponent<PlayerSwing>();
      p_Dash = p_Instance.GetComponent<PlayerDash>();
      p_Damage = p_Instance.GetComponentInChildren<PlayerDamage>();
      p_Marker = p_Instance.GetComponent<PlayerMarker>();
      p_Ultimate = p_Instance.GetComponent<PlayerUltimate>();
      
      // Init references.
      p_Movement.Init(this, p_Animator, p_Audio, p_Transform, p_Rigidbody);
      p_Swing.Init(this, p_Animator, p_Audio, p_Transform, p_Rigidbody);
      p_Dash.Init(this, p_Animator, p_Audio, p_Transform, p_Rigidbody);
      p_Damage.Init(this, p_Animator, p_Audio, p_Transform, p_Rigidbody);
      p_Marker.Init(this, p_Animator, p_Audio, p_Transform, p_Rigidbody);
      p_Ultimate.Init(this, p_Animator, p_Audio, p_Transform, p_Rigidbody);

      // Get player values.
      p_Color = p_Data.d_Color;
      p_SpawnPoint = p_Data.d_SpawnPoint;
      p_Team = p_Data.d_Team;
      p_WeightClass = p_Data.d_WeightClass;
      p_Transform.gameObject.layer = 12 + p_Number;

      // Set camera target.
      PartyCamera.AddTarget(p_Transform);
    }

    public void ToggleMovement(bool state) {
      p_Movement.enabled = state;
    }

    public void ToggleSwing(bool state) {
      p_Swing.enabled = state;
    }

    public void ToggleDash(bool state) {
      p_Dash.enabled = state;
    }

    public void ToggleUltimate(bool state) {
      p_Ultimate.enabled = state;
    }

    public void ToggleAllControl(bool state) {
      ToggleMovement(state);
      ToggleSwing(state);
      ToggleDash(state);
      ToggleUltimate(state);
    }

    public void ResetPlayer() {
      p_Instance.transform.position = p_SpawnPoint;
      p_Instance.transform.rotation = Quaternion.identity;

      p_Instance.SetActive(false);
      p_Instance.SetActive(true);
    }

    public void DestroyPlayer() {
      UnityEngine.Object.Destroy(p_Instance);
    }
  }
}

The character controller was under constant development, but I also worked on other features such as:
The camera, a simple helper tool, the main menu, the character selection screen, the input and match managers, and the UI.
I will round up this portfolio entry with two of the scripts mentioned above.

PartyCamera.cs:

public class PartyCamera : MonoBehaviour {

	public enum ZoomStyle { LocalForward, HeightAngler };

	#region Fields
	// Settings
	[Header("Settings:")]
	public ZoomStyle c_ZoomStyle;
	public bool debugMarker;

	[Header("Tracking:")]
	public bool c_Follow = true;
	public bool c_Zoom = false;

	[Header("Zooming:")]
	public float c_MinZoom = 10f;
	public float c_MaxZoom = 30f;
	public float c_OffsetDistance = 10f;
	public float c_SmoothingSpeed = .125f;
	public float c_SquareFactor = .2f;

	// References
	private Transform c_Transform; // Camera Rig's Transform
	private Camera c_Camera; // Camera Component on Camera GameObject.
	private Transform c_CamTrans; // Camera GameObject's Transform.
	#endregion

	private static List c_Targets = new List();

	public static void AddTarget(Transform target) {
		c_Targets.Add(target);
	}

	public static void RemoveTarget(Transform target) {
		c_Targets.Remove(target);
	}

	private void OnDisable() {
		c_Targets.Clear();
	}

	#region MonoBehaviours
	private void Awake() {
		c_Transform = transform;
		c_Camera = GetComponentInChildren();
		c_CamTrans = c_Camera.transform;

		GetComponent().enabled = debugMarker;
	}

	private void Update() {
		// Only apply camera features if there are any players.
		if(c_Targets.Count > 0) {
			if(c_Follow)
				FollowTarget(FindAveragePosition(c_Targets));
			if(c_Zoom) {
				switch(c_ZoomStyle) {
				case ZoomStyle.LocalForward:
					ZoomDistanceLocal(FindDistance(c_Targets));
					break;
				case ZoomStyle.HeightAngler:
					ZoomDistance(FindDistance(c_Targets));
					c_CamTrans.LookAt(c_Transform); // Will keep the anchor in center.
					break;
				}
			}
		}

        
	}
	#endregion
	#region Positional Tracking
	#region Orientation
	private Vector3 FindAveragePosition(List playerList) {
		// Initialise an empty Vector3 field.
		Vector3 averagePosition = Vector3.zero;

		// Loop through the list of players to follow and
		// add their position to the field.
		foreach(Transform player in playerList) {
            averagePosition += player.position;
		}

		// Divide the position by the amount of players in the list
		// to get the average position.
		averagePosition /= playerList.Count + 1;
		averagePosition.y = 0f; // Height is not used for this part.

		return averagePosition;
	}

	private void FollowTarget(Vector3 target) {
		// Set the position excluding the y-axis value since that is only related to zoom.
		target += new Vector3(0f, c_Transform.position.y, 0f);
		c_Transform.position = Vector3.Lerp(c_Transform.position, target, .125f);
	}
	#endregion
	#region Zooming
	private void ZoomDistance(float distance) {
		// This method takes a distance float and adjusts the cameras local y-position accordingly.
		Vector3 localPos = c_CamTrans.position;
		localPos.y = Mathf.Clamp(Mathf.Lerp(c_CamTrans.position.y, distance + c_OffsetDistance, c_SmoothingSpeed), c_MinZoom, c_MaxZoom);
		c_CamTrans.position = localPos;
	}

	private void ZoomDistanceLocal(float distance) {
		float clampedDistance = Mathf.Clamp(distance, c_MinZoom, c_MaxZoom);
		float camDistance = Mathf.Clamp(Vector3.Distance(c_CamTrans.position, c_Transform.position), c_MinZoom, c_MaxZoom);
		Vector3 targetPos = c_CamTrans.position + (clampedDistance < camDistance ? c_CamTrans.forward : -c_CamTrans.forward) * (Mathf.Max(camDistance, clampedDistance) - Mathf.Min(camDistance, clampedDistance)) * Time.deltaTime;
		c_CamTrans.position = Vector3.Lerp(c_CamTrans.position, targetPos, c_SmoothingSpeed);
	}

	private float FindDistance(List playerList) {
		/*
			This method loops through each player to find the one farthest away from the camera,
			and uses this distance to offset the cameras zoom.
		*/
		float longestDistance = 0f;
		foreach(Transform player in playerList) {
			float playerDistance = (c_Transform.position - player.position).sqrMagnitude;
			if(playerDistance > longestDistance)
				longestDistance = playerDistance;
		}
		return longestDistance * c_SquareFactor;
	}
	#endregion
	#endregion
}

The camera was pretty straightforward – follow the center point of all active characters, and attempt to fit all on the screen.
Bounce Ball is made to be a top-down game but since we want to add so much detail to the characters and the environment
we decided that we would have the camera tilted up by 15 degrees. This lead me to make two different zoom functions as illustrated below.

Programmer’s Art

MatchManager.cs:

public class MatchManager : MonoBehaviour {
	/*
		This script will handle the match phase.
		It will spawn players and set the camera to target them.
	*/

	// Inspector settings
	[Header("Debug Settings:")]
	public DefaultCharacter[] m_DefaultCharacters;

	[Header("Match Settings:")]
	public bool m_UseMatchLoop;
	private bool m_MatchLooping;
	public float m_CountdownTime = 3f; // Private for now.

	// UI Settings
	[Header("UI Settings:")]
	public GameObject countdownObject;
	public GameObject matchOverObject;
	
	[HideInInspector] public Transform[] m_SpawnPoints;
	[HideInInspector] public PlayerManager[] m_Players = new PlayerManager[4];
	[SerializeField] private bool m_IsPaused = false;

	private void Start() {
		// Get the maps spawnpoints.
		m_SpawnPoints = new Transform[4];
		for(int i = 0; i < m_SpawnPoints.Length; i++) { #if UNITY_EDITOR try { m_SpawnPoints[i] = GameObject.FindGameObjectWithTag("SpawnP" + (i + 1)).transform; PlayerData data = Managers.GetManager_Game.GetPlayerData(i + 1); data.SetSpawnPoint(m_SpawnPoints[i].position); } catch { Debug.Log("You need to add spawn points for all four players.\n" + "'Art->SpawnPoints' folder has the prefabs.");
				UnityEditor.EditorApplication.isPlaying = false;
			}
#else
			m_SpawnPoints[i] = GameObject.FindGameObjectWithTag("SpawnP" + (i + 1)).transform;
			PlayerData data = Managers.GetManager_Game.GetPlayerData(i + 1);
			data.SetSpawnPoint(m_SpawnPoints[i].position);
#endif
		}

		SpawnPlayers();
		
		if(m_UseMatchLoop)
			StartCoroutine(GameLoop());
	}

	private void Update() {
		if(!m_MatchLooping) {
			// Check input for pause/unpause.
			if(Input.GetButtonDown("Start")) {
				ToggleGamePauseState(!m_IsPaused);
			}
		}
	}

	private void ToggleGamePauseState(bool state) {
		m_IsPaused = state;
		Time.timeScale = (m_IsPaused ? 0f : 1f);
	}

		private void SpawnPlayers() {
			for(int i = 0; i < m_Players.Length; i++) { if(InputManager.IsPlayerConnected(i + 1)) { // Get the correct players data. PlayerData data = Managers.GetManager_Game.GetPlayerData(i + 1); // If the data has not been set earlier, set it to a default value (Debug reasons). if(data.d_CharacterPrefab == null) { if(data.d_CharacterModel == CharacterModels.None) { if(m_DefaultCharacters.Length == 0 || m_DefaultCharacters[i] == null) continue; data.d_Team = m_DefaultCharacters[i].m_Team; data.SetCharacterPrefab(m_DefaultCharacters[i].m_Character); } else data.SetCharacterPrefab(data.d_CharacterModel); } // Instantiate and set the result to the p_Instance field, // then set the correct player number and begin initializing the player. GameObject newPlayer = Instantiate(data.d_CharacterPrefab, m_SpawnPoints[i].position, Quaternion.identity) as GameObject; m_Players[i].p_Instance = newPlayer; m_Players[i].p_Number = data.d_Number; m_Players[i].p_SpawnPoint = data.d_SpawnPoint; m_Players[i].InitPlayer(); } } } private IEnumerator GameLoop() { // Run the countdown loop. yield return StartCoroutine(MatchCountdown(m_CountdownTime)); // Run the match loop. yield return StartCoroutine(MatchLoop()); // Run the game-over method. yield return StartCoroutine(MatchOver()); // Go back to main menu. yield return StartCoroutine(WaitForInput()); } private IEnumerator WaitForInput() { while(!Input.GetButtonDown("Submit")) { yield return null; } ToggleGamePauseState(false); matchOverObject.SetActive(false); SceneManager.LoadScene(1); } private IEnumerator MatchCountdown(float seconds) { countdownObject.SetActive(true); ToggleAllControls(false); // Don't let anyone move just yet. int spriteNumberIndex = 0; while(seconds > 0) {
				// Turn on new image.
				countdownObject.transform.GetChild(spriteNumberIndex).gameObject.SetActive(true);
				yield return new WaitForSeconds(1f);
				seconds--;
				spriteNumberIndex++;

				// Turn off last image.
				if(spriteNumberIndex > 0)
					countdownObject.transform.GetChild(spriteNumberIndex - 1).gameObject.SetActive(false);
			}
			// Turn on the "GO!" image.
			countdownObject.transform.GetChild(spriteNumberIndex).gameObject.SetActive(true);
			StartCoroutine(TurnOffGOsprite(2f));
		}

	private IEnumerator TurnOffGOsprite(float duration) {
		yield return new WaitForSeconds(duration);
		countdownObject.SetActive(false);
	}

	private IEnumerator MatchLoop() {
		m_MatchLooping = true;
		ToggleAllControls(true); // Now they are allowed to move.
		
		while(TeamsAliveChecker()) {
			yield return null;
		}

		m_MatchLooping = false;
	}

	// Remake all of these checkers to only activate during "hp--;" to save performance cost.
	private bool TeamsAliveChecker() {
		// Before anything else, if there is only one player remaining, the game is still over.
		if(ActivePlayers() <= 1)
			return false;

		// First, we make a new array that will store how many players each team has in the current game.
		int[] playerAliveInTeam = new int[m_Players.Length + 1]; // +1 for team "None".
		foreach(PlayerManager player in m_Players) {
			// Is the player connected and alive?
			if(player.p_Instance != null && player.p_Instance.activeSelf) {
				// Add him to the correct team index of the array.
				playerAliveInTeam[(int)player.p_Team]++;
			}
		}

		// Second, we need to check whether these several players are in separate teams or not.
		int teamsAlive = 0;
		for(int i = 0; i < playerAliveInTeam.Length; i++) { if(playerAliveInTeam[i] > 0) {
				if(teamsAlive == 0) // No other team has been found yet.
					teamsAlive++;
				else // Another team is still alive!
					return true;
			}
		}

		return false; // No 'other' teams are alive.
	}

	private IEnumerator MatchOver() {
		matchOverObject.SetActive(true);

		matchOverObject.transform.GetChild(0).GetComponent().text = "Winner " + RoundWinner() + "!";


		ToggleGamePauseState(true);
		ToggleAllControls(false); // Remove control again.

		print(RoundWinner());
		yield return null;
	}

	private void ToggleAllControls(bool state) {
		foreach(PlayerManager player in m_Players) {
			if(player.p_Instance != null)
				player.ToggleAllControl(state);
		}
	}

	private int ActivePlayers() {
		int playersAlive = 0;
		foreach(PlayerManager player in m_Players) {
			if(player.p_Instance != null && player.p_Instance.activeSelf)
				playersAlive++;
		}
		return playersAlive;
	}

	private string RoundWinner()
	{
		foreach (PlayerManager player in m_Players)
		{
			if (player.p_Instance != null && player.p_Instance.activeSelf)
				return player.p_Team.ToString();
		}

		return "None"; // Fail-safe for now.
	}
}

This class takes heavy inspiration from an official Unity Example Project[Link] but has been modified to better suit the project. For a local-multiplayer party game this design really suited well.

Note:
Bounce Ball was made a prototype as a school assignment, since then we have decided to remake it with a more experienced approach. I will post more when there is something to show.