Project Stealth
Description
Project Stealth is an isometric action-adventure game inspired by the likes of Tunic and Death's Door, but with a focus on stealth gameplay. The player has a variety of abilities for dealing with enemy characters, like distractions, sneaking up and disabling them, or teleporting past them altogether.
Technical highlights
Creating a character trail effect
The class below creates a visual "ghost trail" effect by capturing snapshots of a character's skinned meshes and then fading their material alpha out over time.
In detail:
- On initialisation, it collects the skinned mesh renderers from a given mesh GameObject and uses them to bake mesh "snapshots".
- It instantiates new GameObjects with these baked meshes, and gives each one a trail material and a class for lerping the alpha of its materials when it is time for the ghosts to disappear.
- The class uses coroutines to schedule multiple ghost trails with delays and, after a set lifetime, it starts a fade-out effect before finally destroying the ghost objects.
With this approach, the "snapshots" of the character mesh aren't suitable candidates for object pooling. When the effect is over they can't easily be reused, so they are destroyed, which creates garbage to be collected. With the number of snapshots kept low, that was perfectly fine for Project Stealth, which was intended for PC.
In a more performance-critical context, I could have tried creating a flipbook of animation frames in advance (for rolling forward, rolling left and rolling right, say) and spawning and despawning those pre-made frames with object pooling. That approach would be less flexible and more difficult to implement, but likely more performant.
Show the GhostTrails class
public class GhostTrails : MonoBehaviour
{
[Header("References")]
public GameObject mesh;
public GameObject trailParent;
public Material trailMaterial;
[Header("Settings")]
public int trailCount = 3;
public float timeBetweenTrails = 0.5f;
[Tooltip("Time until ghost starts to dissolve")] public float trailLifetime = 0.5f;
[Tooltip("Speed of the ghost dissolve when trail lifetime complete")] public float dissolveSpeed;
private List<SkinnedMeshRenderer> _renderers = new();
private Queue<Dictionary<GameObject, MaterialLerp>> _trailObjects = new();
private void Awake()
{
if (mesh == null)
{
Debug.LogError("Missing mesh Game Object on " + name);
return;
}
_renderers.AddRange(mesh.GetComponentsInChildren<SkinnedMeshRenderer>());
}
public void CreateGhosts()
{
CreateGhosts(trailCount);
}
public void CreateGhosts(int ghostCount)
{
for (int i = 0; i < ghostCount; i++)
{
StartCoroutine(CreateTrail(timeBetweenTrails * i));
}
}
public void CreateSingleGhost(float delay)
{
StartCoroutine(CreateTrail(delay));
}
private IEnumerator CreateTrail(float delay)
{
yield return new WaitForSeconds(delay);
Dictionary<GameObject, MaterialLerp> trailGroup = new();
Transform t = transform;
for (int i = 0; i < _renderers.Count; i++)
{
GameObject ghostPart = new GameObject($"GhostTrailPart_{i}");
ghostPart.transform.SetParent(trailParent.transform);
ghostPart.transform.position = t.position;
ghostPart.transform.rotation = t.rotation;
MeshRenderer meshRenderer = ghostPart.AddComponent<MeshRenderer>();
MeshFilter meshFilter = ghostPart.AddComponent<MeshFilter>();
MaterialLerp materialLerp = ghostPart.AddComponent<MaterialLerp>();
Mesh newMesh = new Mesh();
_renderers[i].BakeMesh(newMesh);
meshFilter.mesh = newMesh;
Material[] materials = new Material[_renderers[i].materials.Length];
for (int j = 0; j < materials.Length; j++)
{
materials[j] = new Material(trailMaterial);
}
meshRenderer.materials = materials;
trailGroup.Add(ghostPart, materialLerp);
}
_trailObjects.Enqueue(trailGroup);
StartCoroutine(DeleteTrailAfterLifetime());
}
private IEnumerator DeleteTrailAfterLifetime()
{
yield return new WaitForSeconds(trailLifetime);
if (_trailObjects.Count < 1) yield break;
Dictionary<GameObject, MaterialLerp> trailGroup = _trailObjects.Dequeue();
foreach (var pair in trailGroup)
{
pair.Value.LerpAlpha(dissolveSpeed, 0.5f, 0);
pair.Value.OnLerpComplete += (sender, args) => Destroy(pair.Key);
}
}
private void OnDisable()
{
StopAllCoroutines();
}
}Smooth clamping a character controller to the screen edge
In Project Stealth, the player can control an aiming crosshair independently of the camera's movement, as in a twin-stick shooter.
I wanted to prevent the crosshair from going off screen, and to gradually slow it down as it approaches the edge of the screen.
This required first calculating the expected position of the character controller, given its current speed and direction, in viewport space.
The viewport position is a normalised position relative to the bottom-left corner of the camera viewport. I needed a position relative to the middle of the viewport, so I used Mathf.Min to get the lower of the viewport position and 1 minus the viewport position. That way a viewport x position of 0.7, for example, becomes 0.3, and that value is used.
Then I return the distance to the nearest screen edge, again using Mathf.Min, to get the lower of the x and y coordinates of the modified viewport position. By doing this, rather than using the actual distance to the screen edge (viewportPosition.magnitude, say), corners are treated the same as the middle of the screen edges.


Show the GetDistanceToScreenEdge method
private float GetDistanceToScreenEdge(CharacterController controller, Vector3 direction)
{
// Determine the next position based on the current direction and speed and convert that to viewport space
Vector3 currentPosition = controller.transform.position;
Vector3 projectedPosition = currentPosition + (direction.normalized * (currentSpeed * Time.deltaTime));
Vector3 viewportPosition = mainCamera.WorldToViewportPoint(projectedPosition);
// Determine the distance from the point to the closest edge of the viewport.
float distanceX = Mathf.Min(viewportPosition.x, 1 - viewportPosition.x);
float distanceY = Mathf.Min(viewportPosition.y, 1 - viewportPosition.y);
return Mathf.Min(distanceX, distanceY);
}The method below manages the actual movement of the character controller.
It uses Lerp to smoothly accelerate or decelerate between the current speed and a speed value multiplied by the magnitude of the input from a gamepad stick.
That speed is then multiplied by a value obtained by evaluating an animation curve with the distance to the screen edge. The curve can be set up however you like, to get the speed you want at each distance from the edge. In my case, if the distance is greater than 0.15 the speed is multiplied by 1, so it's unchanged, and below 0.15 it declines smoothly to 0 at a distance of 0.
Show the MoveWithController method
private void MoveWithController(CharacterController controller)
{
// Determine input magnitude based on the movement mode.
float inputMagnitude = _inputData.analogMovement ? 1f : _inputData.aim.magnitude;
// Smoothly accelerate towards the target speed.
currentSpeed = Mathf.Lerp(currentSpeed, controllerMoveSpeed * inputMagnitude, Time.deltaTime * controllerAcceleration);
Vector3 currentTargetDirection = GetTargetDirection();
// Calculate the distance to the nearest screen edge.
float distanceToEdge = GetDistanceToScreenEdge(controller, currentTargetDirection);
float speedModifier = speedModifierCurve.Evaluate(distanceToEdge);
// Move the controller using the adjusted speed and direction.
Vector3 motion = currentTargetDirection.normalized * (currentSpeed * speedModifier * Time.deltaTime);
controller.Move(motion);
}Finally, GetTargetDirection calculates the direction the character controller should move in, taking into account the player's input and the orientation of the camera.
Show the GetTargetDirection method
private Vector3 GetTargetDirection()
{
float targetRotation = 0;
if (_inputData.aim != Vector2.zero)
{
// Convert aim input to a normalized world-space direction.
Vector3 inputDirection = new Vector3(_inputData.aim.x, 0.0f, _inputData.aim.y).normalized;
// Compute the rotation angle from the input direction and adjust based on camera orientation.
targetRotation = Mathf.Atan2(inputDirection.x, inputDirection.z) * Mathf.Rad2Deg + mainCamera.transform.eulerAngles.y;
}
// Return the forward vector based on the computed target rotation.
return Quaternion.Euler(0.0f, targetRotation, 0.0f) * Vector3.forward;
}