This will be a long post as this will also include all the work I have done over the break. During this time I have really been focusing on upgrading the feeling of the gameplay as well as adding more depth to mechanics and polish overall. I have decided to do this so that if I do want to add more levels in down the line I don't have to worry about finishing the rest of the game.
With that disclaimer out of the way one of the first things I did was update how the NPC racers worked within my game. More specifically how they are activated. As they are recordings of me playing, they are just objects with transforms tied to certain times. My old method of recording them was to sit through the cutscene and timer countdown and then race the laps. However, this leads to the issue of them being dependent on the fps a player is receiving as well as other unknowns like lag and so they wouldn't always start the race at the same time as the player being allowed to move. This lead to them either getting a head start or the player, neither of which felt very good when playing obviously. My fix for this was to instead disable all the cutscenes, record myself again and then add the NPC's to an activation track within the cutscene itself. In this image they are labelled: 1-1 etc.
This means as soon as they turn on they go, and will always turn on at the correct time I have specified leading to a solved issue and no more unknowns.
I think this next change has improved the overall feel of the game the most and that is all the updates I have made to the camera within my game. Originally it was just parented to the player object itself and had quite stiff following options with a set FOV and distance. I think this caused my game to feel sluggish and flat, not something you want with a racing game. I have overhauled the entire system and the camera is programmatic, meaning a script controls all of it. The first thing I did was tie the player speed to the FOV. Now the faster the player moves the further the FOV goes until it reaches it's cap of 90. At a dead stop it rests at 60.
// Get the speed of the player from the Movement script
float speed = Mathf.Abs(vehicleMovement.speed);
// Determine the target FOV based on speed
float targetFOV = speed > speedThreshold ? maxFOV : Mathf.Lerp(minFOV, maxFOV, speed / speedThreshold);
// Interpolate currentFOV towards the target FOV
currentFOV = Mathf.Lerp(currentFOV, targetFOV, Time.deltaTime * fovChangeSpeed);
// Apply the new FOV to the camera
virtualCamera.m_Lens.FieldOfView = currentFOV;
As you can see within my code this happens in the update function and essentially just takes the players speed and uses maths to determine the new FOV of the camera. I also added interpolation to smooth out the change as well as a speed threshold at which the camera stops changing FOV. I set this 85 so that when the player is at 85 they feel the fastest they can. I didn't set it to the true top speed of 100 as I wanted it to be something the player can hit regularly and not only in specific cases. I also added more x damping to the camera. This means there is now more camera sway when turning, making the motion feel more enhanced. I also raised the camera by .15 to allow for better visibility. These changes greatly improved the overall sense of speed and so I continued on trying to improve this.
I increased the vehicle acceleration from 17 to 40, allowing the player to get up to speed much quicker and made slow corners feel much less sluggish.
Before going further I decided to finally implement boost pads into my game. The reason I had been holding off on adding this was due to the way my vehicle movement script worked as it had a terminal velocity of 100 which acted as a top speed cap. However I wanted to keep this during normal gameplay but allow the player to surpass it during boosting. I created the boost pad script itself:
public class BoostPad : MonoBehaviour
{
public float boostMultiplier = 2.0f;
public float boostDuration = 2.0f;
void OnTriggerEnter(Collider other)
{
// get the VehicleMovement component from the parent GameObject
VehicleMovement vehicle = other.GetComponentInParent<VehicleMovement>();
if (vehicle != null)
{
Debug.Log("VehicleMovement component found on player.");
// Activate the boost
vehicle.ActivateBoost(boostMultiplier, boostDuration);
Debug.Log($"BoostMultiplier: {boostMultiplier}, BoostDuration: {boostDuration}");
}
else if (other.CompareTag("Player"))
{
Debug.LogWarning("VehicleMovement component not found on player.");
}
}
}
I had some trouble with getting the boost pad to find the right component on the right game object to start with due the player object being made up of multiple objects such as the mesh and then the actual player itself. I got around this by instead searching for the parent object of the object the boost pad registered entering its collider with the player tag.
And also updated the player movement to allow this change in terminal velocity:
// Save the original terminal velocity
originalTerminalVelocity = terminalVelocity;
originalDriveForce = driveForce;
originalslowingVelFactor = slowingVelFactor;
I changed the movement so that in the Start function it first saves the original terminal velocity, drive force, and slowing factor. This is so that when they get updated during isBoosting to allow the player to surpass 100 speed the game then knows what to return them to once the boost duration ends.
public void ActivateBoost(float boostMultiplier, float duration)
{
isBoosting = true;
boostEndTime = Time.time + duration;
this.boostMultiplier = boostMultiplier;
// Increase terminal velocity during boost
terminalVelocity *= boostMultiplier;
driveForce *= boostMultiplier;
Debug.Log($"Boost activated! New terminalVelocity: {terminalVelocity}, New driveForce: {driveForce}");
}
public void EndBoost()
{
isBoosting = false;
// Reset terminal velocity to original value
terminalVelocity = originalTerminalVelocity;
// Reset drive force to original value
driveForce = originalDriveForce;
Debug.Log($"Boost ended. TerminalVelocity reset to: {terminalVelocity}, DriveForce reset to: {driveForce}");
}
}
The way the boost works is by taking the current players drive force (essentially how strong the engine is) and terminal velocity (top speed) and multiplies it by the boost multiplier. I can set this multiplier to be whatever I want for each boost pad as well as it's boost duration. For now I have them set to multiply by 1.3, allowing the player to reach ~140 speed. This addition of boost pads greatly opens up gameplay and allows for more unique tracks and racing opportunities.
However since I had changed the top speed I then had to update the speed UI along with it. This was a extremely easy fix though as all I have to do is update the top speed public float in levels with boost pads to 140 and the speedometer works just fine, allowing the bar to be at full only when boost is applied.
The keen eyed readers will have noticed in this screen shot that there is post processing going on in this image. And yes I have implemented it and it was a bit of a struggle but extremely worth it. For some odd reason Unity's default post processing was adding a dither effect no matter what, even if I just had PP on with no effects. And so after scouring the web I found a post that showed me how to manually turn off dithering from the final output of the shader unity uses when PP in it's code. After that was fixed I was then free to add effects. I'm still deciding on motion blur due to my games stylized look and lack of motion blur in PSX games.
I did add chromatic aberration though because it looks cool. I added it for when the player boosts, leading to more enhanced sense of speed as if going though a wormhole. It also helps to let the player know if they are still above the normal speed cap as I have scripted it to only turn on after the player exceeds 100 speed.
void Update()
{
// Check if the necessary components are assigned
if (vehicleMovement == null || chromaticAberration == null)
return;
// Get speed of player from VehicleMovement script
float speed = Mathf.Abs(vehicleMovement.speed);
// Determine intensity based on speed threshold
float targetIntensity = speed > speedThreshold ? maxChromaticAberrationIntensity : 0f;
// interpolate chromatic aberration target intensity
chromaticAberration.intensity.value = Mathf.Lerp(chromaticAberration.intensity.value, targetIntensity, Time.deltaTime * intensityChangeSpeed);
}
I also then added noise or camera shake to the camera, with a very small amount always being on and much more added when the player is over 100 speed. This helps to make the player feel less in control and going so fast it's dangerous.
// Determine noise based on speed
targetNoiseAmplitude = speed > 100f ? noiseAmplitudeGain : 0.1f;
// Smoothly adjust noise gain
noise.m_AmplitudeGain = Mathf.Lerp(noise.m_AmplitudeGain, targetNoiseAmplitude, Time.deltaTime * noiseFadeSpeed);
I once again added lerping to all these effects to smooth out the change in screen effects.
After getting my family to play my improved gameplay one big thing I noticed that was hampering the fun was the issue with wall collision that has been a thorn in my side since the beginning of the new movement system. For some reason the walls become almost sticky once the player collides with one. On one hand its good as it stops wall-riding. However it also sucks because it kills all your momentum and also makes you have to release the gas button to pull away from the wall. I tried adding frictionless materials and nothing worked.
However I had made the solution already a long time ago. In my very first prototype I had created a wall avoidance script that used raycasts to push the player away from the walls.
And so with a couple tweaks I implemented it into my game and it works great. I made sure to allow the player to still be able to hit the wall, with them losing speed. However a gentle brush of the wall will not punish them and merely push them away from it. It also allows for the player to get out from the wall while still pressing gas, the main issue of all this. I think this fix is great as it still hampers bad drivers but not unfairly or by feeling buggy. It also allows for leneincy when it comes to simple love taps against the barriers.
On the subject of collision I then added a particle effect for when the player collides with the wall, something that existed in the Unity script but that I hadn't made a particle emitter for yet. I then made sure to make it work against NPC's too, allowing sparks to shower the track as you battle. Rubbin is Racin!
After all of this I then finally updated to the new Unity Input System. My project had been on the old Unity Input Manager and had been hampering the game. It only worked with one controller type, and not with any menus and I had had enough so I threw it out and replaced it. I remapped all the controls allowing for all Gamepads and KBM. It took some minor chnages to syntax within my code to get it running but it was simple and is far better overall.
It also solved my old issue of the controller having far higher sensitivity than the keyboard, allowing for faster turning. I actually liked the way it played on controller better and so I adjusted both inputs deadzones to equalize the playing field.
I also had to update my UI navigation and rewrite some of my carousel script to change the way it scrolled, geting rid of the arrow buttons and just making it scrolled when up and down inputs are detected:
private void HandleKeyboardInput()
{
if (Keyboard.current.upArrowKey.wasPressedThisFrame || Keyboard.current.wKey.wasPressedThisFrame || (Gamepad.current != null && Gamepad.current.dpad.up.wasPressedThisFrame) || (Gamepad.current != null && Gamepad.current.leftStick.y.ReadValue() > 0.5f))
{
MoveToPreviousPage();
Debug.Log("up press");
}
else if (Keyboard.current.downArrowKey.wasPressedThisFrame || Keyboard.current.sKey.wasPressedThisFrame || (Gamepad.current != null && Gamepad.current.dpad.down.wasPressedThisFrame) || (Gamepad.current != null && Gamepad.current.leftStick.y.ReadValue() < -0.5f))
{
MoveToNextPage();
Debug.Log("down press");
}
else if (Keyboard.current.enterKey.wasPressedThisFrame || (Gamepad.current != null && Gamepad.current.buttonSouth.wasPressedThisFrame))
{
LoadTargetScene();
Debug.Log("select press");
}
}
With that all sorted the final thing I did was start adding much needed detail to my third level. As it was space themed I started by taking the planet model I made last time and adding more of them to the scene, and making them giant to dwarf the player. I had to increate the camera render distance but it didnt affect performance and looks far better. I ended up adding two more planets alongside the original purple one. I made a neptune esque planet that reuses the water texture found in Level 2. I then made a Saturn style planet that reuses the rock texture in Level 2 aswell. I added a moon to the purple planet and it just has a blank white material. To make the planet look more 3D I added an atmosphere to them. I did this through duplicating the sphere and making it slightly larger. I then change the material to the fog from Level 1 and voila. A system of planets that mainly recycle already uses textures.
To make the meteor field I just used a primitive shape and textured it a splotchy brown and reshaped it around the planet. I then used the glow sprites from the UI to act as the colour banding you see within rings around the planet. A cheap and effective illusion.
As you can see, they are massive compared to the playable level.
I then made solar panels, and a stadium to fill in the infield of the map and make it feel more alive. The solar panels in my mind help to bring a purpose to why the loop is there, providing solar energy to the station.
One of my favourite views in the level
The stands were both tricky to make and UV. I went about it by converting the edge of the track to a curve that I then extruded along and finally built the stand shape itself. To UV unwrap it I had to unitize the faces first, then cut along the back edge before unfolding along the U direction to get a perfect grid. I found this technique here.
Once the stands were imported I then started to populate them. As they are large, I decided to only have the first two rows animate and the rest just be larger in size and stay still. I then further optimized it by creating a little script that turns the animator off when the player is 100 units away from them. This means they are not constantly running.
public class AnimationCull : MonoBehaviour
{
[SerializeField] private Transform playerTransform;
private Animator animator;
private float cullDistance = 100.0f;
void Start()
{
animator = GetComponent<Animator>();
if (animator == null)
{
Debug.LogError("Animator component not found on " + gameObject.name);
}
}
void Update()
{
if (playerTransform == null)
{
Debug.LogError("Player Transform not assigned");
return;
}
float distance = Vector3.Distance(playerTransform.position, transform.position);
if (distance > cullDistance)
{
if (animator != null && animator.enabled)
{
animator.enabled = false;
}
}
else
{
if (animator != null && !animator.enabled)
{
animator.enabled = true;
}
}
}
}
This green box below is how far the animators go until being deactivated.
Once I got back to University I made even more progress. I updated the level further with more details such as a finish line sign. I then decided to ground the track further by making it be built on some larger asteroids. I then made some simple beam models to 'connect' the race track to the space station as well as the aforementioned asteroids.
After that I decided to finally add the visual components to the boost pad prefab I made earlier. I made a simple plane texture and then used gradient sprites to create a glowing square. To make it stand out further I chose green as it's colour and created a simple particle effect that makes beams of light spawn above the pad. This helps to let the player see the boost pad coming up before they can actually see it, such as round corners.
After letting my friends play test the level they gave me the useful feedback for the way I was currently displaying laps and how it was unclear. This is as the bar only fills once the lap is complete, however the number system starts on 1. Although this is true to life and how the first lap in a race is lap 1, I decided to try remedy this by adding a duller blue bar behind the lap bar. This helps to indicate to the player that the bar will be filled.
Working on UI further, I finally updated the lap time UI to be world space and attached it to the player vehicle like the rest of the UI. I then placed it under the ship in the dead space area of the screen, where the player doesn't need to look there normally. This helped to tie together the entire UI and feel of the game.
With this new laptime UI, I then worked on letting the Game manager access it so it could be displayed on the win screen. I did this through taking the time from the UI script and making it a string within the Game manager that then gets displayed in a text box.
As my game allows both KBM and controller I decided to work on adding sprites to the menu scenes that display the right buttons to allow easier navigation. However I wanted them to chnage depending on what input was detected. Making a simple script that detected the current input and then changed the sprite depending on that input, I then laid out the menu sprites.
private void OnInputDeviceChange(InputUser user, InputUserChange change, InputDevice device)
{
if (change == InputUserChange.DevicePaired || change == InputUserChange.DeviceUnpaired)
{
// Check if device is keyboard or mouse
if (device is Keyboard || device is Mouse)
{
displayImage.sprite = keyboardMouseSprite;
}
// Check if device is gamepad
else if (device is Gamepad)
{
if (device.description.product.ToLower().Contains("xbox"))
{
displayImage.sprite = xboxControllerSprite;
}
else if (device.description.product.ToLower().Contains("dualshock") || device.description.product.ToLower().Contains("playstation"))
{
displayImage.sprite = playStationControllerSprite;
}
else
{
displayImage.sprite = xboxControllerSprite; // Default to Xbox if unknown
}
}
}
}
Wanting a cohesive look without having to make all the sprites myself I found some CC0 sprites that I could used here
Finally I added a little animation that makes the inputs slide into frame on the main menu after 6 seconds, as to help players that get stuck or are confused well maintaining a clean look on load up.
Here is a video of all the changes:
Comments