Polishing the UX of a 2D Space Shooter Player Script (Part 3: Finer Details to Combat and UI)

Alberto Garcia
6 min readFeb 11, 2023

--

These third edits to the player’s game play provide a deeper sense of immersion as these abilities require a higher skill level of tactic and combat. First, a secondary weapon is added, Blaston. This blast has a 10 second cool down and must be charged for two seconds to be released. It is charged with the S, which also lowers the player to the bottom of the screen. Blaston is intended to bring the player to a safer position and clear a wave of enemies with one bullet. The difference to a normal laser shot is that blaston is not destroyed by normal enemies, its impact zone is wider and it looks much more powerful than the normal laser shot.

The boost has an overheat system that when reaching its max point, starts cooling down, disabling the ability to boost manually. And the final spark that gives a nice sense of immersion to the game play is a camera shake that responds through trauma levels. The player has 3 lives, each live lost has a higher trauma, which creates a more intense shake accordingly.

  • Secondary Combat Stance
  • Making the boost a valuable resource
  • Feeling the hit (with Perlin Noise)

Let’s start by dissecting Blaston inside the player script:

[SerializeField]
private float _blastonChargeSpeed;
[SerializeField]
private float _blastonChargeTime;
private bool _blastonIsCharging;
[SerializeField]
private GameObject _blaston;
[SerializeField]
private bool _blastonOnCD;
private WaitForSeconds _blastonRegen = new WaitForSeconds(10);

void ChargeBlaston()
{
if(Input.GetKey(KeyCode.S) && _blastonChargeTime < 2 && !_blastonOnCD)
{
_blastonIsCharging = true;
if(_blastonIsCharging == true)
{
_blastonChargeTime += Time.deltaTime * _blastonChargeSpeed;
}
}
if (Input.GetKeyUp(KeyCode.S) && _blastonChargeTime >= 2 && !_blastonOnCD)
{
ReleaseBlaston();
}
else if (Input.GetKeyUp(KeyCode.S) && _blastonChargeTime < 2)
{
_blastonChargeTime = 0;
_blastonIsCharging = false;
}
}
void ReleaseBlaston()
{
Instantiate(_blaston, transform.position + new Vector3(0, 1.05f, 0), Quaternion.identity);
StartCoroutine(BlastonOnCooldown());
}
IEnumerator BlastonOnCooldown()
{
_blastonIsCharging = false;
_blastonChargeTime = 0;
_blastonOnCD = true;
yield return _blastonRegen;
_blastonOnCD = false;
}

A Blast of fun.

When the player flies backwards for 2 seconds, a charged blast is released.

Blaston has three methods, one that handles the charging mechanism with three if statements: one to check charged time, and two to handle the release of blaston if it’s before the two second mark or release blaston if two seconds have passed. Next, the ReleaseBlaston methods instantiates the super bullet and starts the third method, BlastonOnCooldown to set everything back to normal after ten seconds have passed. If the charge is released before the twosecond mark, the stored charge time simply goes back to zero.

The Manual Boost cooldown system let’s the player take advantage of quick bursts of fast movement without abusing this tool. This implementation has been one of the strongest mind breaking features of this game for me, since there are many routes to accomplish it but not most gave my desired result. Representing the cooldown through a UI Slider has many ways of working, this one simply stores each second passed holding the shift key down and multiplies it by a fill delay, in this case 0.5, dividing each second in half, giving the player a maximum used time of 2 seconds before cooldown starts. The slider’s value is equal to the used time and when it reaches one, the cooldown coroutine begins. The coroutine sets the isCharging bool to true and the value of the slider starts decreasing from 1 to 0 in 5 seconds since the CDDelay is set to 0.2.

    [SerializeField]
private Slider _thrusterCDSlider;
[SerializeField]
private float _thrusterFillDelaySeconds = 0.5f;
[SerializeField]
private float _thrusterCDDelay = 0.2f;
private WaitForSeconds _thrusterRegen = new WaitForSeconds(5);
[SerializeField]
private float _thrusterUsedTime;
[SerializeField]
private bool _thrusterIsCharging;
[SerializeField]
private bool _thrusterIsBoosting;
[SerializeField]
private GameObject _thrusterFire;

void ManualThruster()
{
//boost
if (Input.GetKey(KeyCode.LeftShift) && !_thrusterIsCharging)
{
if (_thrusterIsBoosting == false)
{
_thrusterFire.SetActive(true);
_speed *= _speedMultiplier;
_thrusterIsBoosting = true;
}
if (_thrusterIsBoosting == true)
{
_thrusterUsedTime += Time.deltaTime * _thrusterFillDelaySeconds;
_thrusterCDSlider.value = _thrusterUsedTime;
if (_thrusterCDSlider.value >= 1)
{
_thrusterFire.SetActive(false);
_speed /= _speedMultiplier;
StartCoroutine(ThrusterCoolDown());
}
}
}
if (Input.GetKeyUp(KeyCode.LeftShift) && _thrusterIsBoosting)
{
_thrusterFire.SetActive(false);
_speed /= _speedMultiplier;
_thrusterIsBoosting = false;
}
if (_thrusterIsCharging)
{
_thrusterCDSlider.value -= Time.deltaTime * _thrusterCDDelay;
}
}

private IEnumerator ThrusterCoolDown()
{
_thrusterIsBoosting = false;
_thrusterIsCharging = true;
yield return _thrusterRegen;
_thrusterIsCharging = false;
_thrusterCDSlider.value = 0;
_thrusterUsedTime = 0;
}
UI Slider heating up and down with the manual boost (thruster)

There is room for improvement, but I love a simple and precise implementation for now.

The third enhancement for the experience of this combat game is getting a camera shake whenever the player is damaged. For this, I loved watching this particular talk (Math for Game Programmers, Juicing Your Cameras With Math). I created an empty game object called Camera Handler, made the main camera child of it, and added the CameraShake script to it. Differently to many suggestions of moving the camera to random values from positive one to negative one, the recommended math function for this is Perlin Noise which (from the manual) “The noise does not contain a completely random value at each point but rather consists of ‘waves’ whose values gradually increase and decrease across the pattern.” setting in motion the camera shake more similar to a coil, spring floaty and bouncy swing rather than a random teleportation from side to side.

Better yet, this waves are exponential according to a trauma level (since why else would we use camera shake if not for a traumatic event: getting slapped, falling from a roof, getting shot and exploding your ship, a massive earthquake that tosses everyone to the ground. The traumatic level of each event is different, and for this case of a 2d space shooter with three lives, each life closer to zero has a greater trauma value.

public class CameraShake : MonoBehaviour
{
#region variables
public bool camShakeActive;
[Range(0, 1)] public float trauma;
private float _timeCounter;
public float traumaMult = 5f;
[SerializeField]
private float _traumaMag = 0.8f;
[SerializeField]
private float _traumaRotMag = 1.7f;
public float traumaDecay = 1.3f;

#endregion

#region accessors
public float Trauma
{
get
{
return trauma;
}
set
{
trauma = Mathf.Clamp01(value);
}
}

#endregion

#region methods
float GetFloat(float seed)
{
return (Mathf.PerlinNoise(seed, _timeCounter) - 0.5f) * 2;
}

Vector3 GetVec3()
{
return new Vector3(
GetFloat(1),
GetFloat(10),
0
);
}

void CamShake()
{
if (camShakeActive && trauma > 0)
{
_timeCounter += Time.deltaTime * Mathf.Pow(Trauma, 0.3f) * traumaMult;
Vector3 newPos = GetVec3() * _traumaMag * trauma;
transform.localPosition = newPos;
transform.localRotation = Quaternion.Euler(newPos * _traumaRotMag);
trauma -= Time.deltaTime * traumaDecay * Trauma;
}
else
{
Vector3 newPos = Vector3.Lerp(transform.localPosition, Vector3.zero, Time.deltaTime);
transform.localPosition = newPos;
transform.localRotation = Quaternion.Euler(newPos * _traumaRotMag);
}
}

private void Update()
{
CamShake();
}
#endregion
}

and then there is the player damage function setting trauma levels according to the current lives:

//the camera shake is found and stored in the _camShaker variable. 

else if (_ShieldEnabled == false)
{
_currentHealth --;
_uiManager.UpdateLives(_currentHealth);

if (_currentHealth == 2)
{
_wFR.SetActive(true);
_camShaker.traumaMult = 5f;
_camShaker.traumaDecay = 1.3f;
_camShaker.trauma = 0.5f;
}

if (_currentHealth == 1)
{
_wFL.SetActive(true);
_camShaker.traumaMult = 6.5f;
_camShaker.traumaDecay = 0.8f;
_camShaker.trauma = 0.75f;
}

if (_currentHealth < 1)
{
_camShaker.traumaMult = 8f;
_camShaker.traumaDecay = 0.5f;
_camShaker.trauma = 1f;
_spawnManager.OnPlayerDeath();
_gameManager.GameOver();
_uiManager.GameOver();
Instantiate(_explosionFire, transform.position, Quaternion.identity);
_audioSource.clip = _explosionClip;
_audioSource.pitch = 0.8f;
_audioSource.volume = 1f;
_audioSource.Play();
Destroy(this.gameObject);
}
}

So for each health value from 3 to 0, there is no trauma at 3, at 2 the trauma multiplier is at 5, the decay is faster and the trauma level is set to 0.5f. At one health the decay is slower and the values higher, while getting the game over hit really shakes the camera longer and stronger.

The first and second hit in this clip are close to each other, but clearly the second the camera shakes incrementally more depending on how many lives are left.

This enhancements add much more juice to game play that I’m really excited to keep tweaking this joy.

--

--

Alberto Garcia
Alberto Garcia

No responses yet