Games

Games

Personal Projects

Rocket League-clone  

Rocket League-clone

This game was a little challenge for myself over a weekend. I wanted to create a simple sports game with physics, I created an AI opponent to have a quick way of testing the game. To keep it fair, the AI and Player use the same movement-code. It ended up surprisingly addictive and the AI can be tough to beat without exploiting its weaknesses.

Blue player is the AI.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MatchPawn : MonoBehaviour
{
    protected Transform tf;
    protected Rigidbody rb;

    [SerializeField]
    protected Transform goalToScoreIn;

    [Header("Movement")]
    [SerializeField]
    protected float movementSpeed = 8.0f;
    [SerializeField]
    protected float rotationSpeed = 16.0f;
    protected float speedModifier = 0.6f;

    [Header("Dash")]
    [SerializeField]
    protected float dashForce = 800f;
    [SerializeField]
    protected float dashTime = 0.2f;
    [SerializeField]
    protected float dashCooldown = 0.5f;

    [Header("Jump")]
    [SerializeField]
    protected bool jumpEnabled = true;
    [SerializeField]
    protected LayerMask groundLayer;
    [SerializeField]
    protected float jumpForce = 200f;
    [SerializeField]
    protected float jumpTime = 0.2f;
    [SerializeField]
    protected float jumpCooldown = 0.5f;

    protected bool canDash = true;
    public bool isDashing { get; private set; }
    protected bool canJump = true;
    protected bool isJumping = false;

    protected virtual void Awake()
    {
        tf = transform;
        rb = GetComponent<Rigidbody>();

        isDashing = false;
    }

    protected virtual void Gravity()
    {
        // If in the air, apply downward force
        if(!Groundcheck())
            rb.AddForce(Vector3.down * 80f, ForceMode.Acceleration);
    }

    #region Jump
    protected virtual void StartJump()
    {
        canJump = false;
        isJumping = true;
        StartCoroutine(Jump());
    }

    // Adds an upward force to the player
    protected virtual IEnumerator Jump()
    {
        rb.AddForce(Vector3.up * jumpForce);
        // Length (time) of jump
        yield return new WaitForSeconds(jumpTime);
        isJumping = false;
        // Cooldown before can jump again
        yield return new WaitForSeconds(jumpCooldown);
        canJump = true;
    }
    #endregion

    #region Dash
    protected virtual void StartDash()
    {
        canDash = false;
        isDashing = true;
        StartCoroutine(Dash());
    }

    // Adds a forward force to the player
    protected virtual IEnumerator Dash()
    {
        rb.AddForce(tf.forward * dashForce);
        // Length (time) of Dash
        yield return new WaitForSeconds(dashTime);
        isDashing = false;
        // Cooldown before can dash again
        yield return new WaitForSeconds(dashCooldown);
        canDash = true;
    }
    #endregion

    protected virtual bool Groundcheck()
    {
        // Cast a ray straight down, looking for the Ground-layer
        return Physics.Raycast(tf.position, Vector3.down, 0.7f, groundLayer);
    }

    // Reset position when a goal is scored
    public void ResetPosition(Vector3 startPosition, float delay)
    {
        StartCoroutine(MoveToStartPosition(startPosition, delay));
    }

    protected virtual IEnumerator MoveToStartPosition(Vector3 startPosition, float delay)
    {
        rb.velocity = Vector3.zero;
        // Turn of physics for this Rigidbody when moving it without physics
        rb.isKinematic = true;

        tf.position = startPosition;
        yield return new WaitForSeconds(delay);
        // Enable physics again
        rb.isKinematic = false;
    }

    protected virtual bool CanMove()
    {
        // When the game is resetting, make sure no movement can be executed
        return !GameManager.isResetting;
    }
}

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

public class BasicAI : MatchPawn
{
    [Header("AI")]
    [SerializeField]
    private Transform target;
    [SerializeField]
    private Transform goalToDefend;

    // Target = Ball
    private Vector3 targetPosition;

    protected override void Awake()
    {
        base.Awake();

        StartCoroutine(SetTargetPosition());
    }

    private void Update()
    {
        LookRotation();
    }

    private void FixedUpdate()
    {
        if(!CanMove())
            return;

        // Check if should Jump
        if(Groundcheck() && DistanceCheckOnXZ(2f) && BallHeigthDifference() > 2f && canJump && !isJumping)
            StartJump();

        // Check if should Dash to get closer to Ball
        if(DistanceGreaterThanCheckOnXYZ(8f) && !InLineWithOwnGoal() && canDash && !isDashing)
            StartDash();
        // Check if should Dash to shoot the Ball
        else if(DistanceCheckOnXYZ(3f) && InLineWithBall() && !InLineWithOwnGoal() && canDash && !isDashing)
            StartDash();

        Gravity();
        Move();
    }

    #region AI Checks
    private float BallHeigthDifference()
    {
        return target.position.y - tf.position.y;
    }

    // Check angle to ball to decide if should dash for speed or to shoot
    private bool InLineWithBall()
    {
        float angle = Mathf.Abs(Vector3.Angle(target.position - tf.position, tf.forward));
        return angle < 15f;
    }

    // If the angle between ball and own goal is too small, don't shoot
    private bool InLineWithOwnGoal()
    {
        Vector3 ownGoal = new Vector3(goalToDefend.position.x, tf.position.y, goalToDefend.position.z);
        float angle = Mathf.Abs(Vector3.Angle(ownGoal - tf.position, tf.forward));
        return angle < 40f;
    }

    // Look towards opponents goal when lining up a shot to get the right direction
    private bool InLineWithOtherGoal()
    {
        Vector3 otherGoal = new Vector3(goalToScoreIn.position.x, tf.position.y, goalToScoreIn.position.z);
        float angle = Mathf.Abs(Vector3.Angle(otherGoal - tf.position, tf.forward));
        return angle < 20f;
    }

    // Check the distance between Ball and AI on X & Z-axis
    private bool DistanceCheckOnXZ(float minDistance)
    {
        Vector3 mPos = new Vector3(tf.position.x, 0f, tf.position.z);
        Vector3 tPos = new Vector3(target.position.x, 0f, target.position.z);
        float distance = Vector3.Distance(mPos, tPos);
        return distance < minDistance;
    }

    // Check the distance between Ball and AI on X, Y & Z-axis
    private bool DistanceCheckOnXYZ(float minDistance)
    {
        float distance = Vector3.Distance(tf.position, target.position);
        return distance < minDistance;
    }

    // Check if the distance is too big, to dash for speed
    private bool DistanceGreaterThanCheckOnXYZ(float maxDistance)
    {
        float distance = Vector3.Distance(tf.position, target.position);
        return distance > maxDistance;
    }
    #endregion

    private IEnumerator SetTargetPosition()
    {
        while(true)
        {
            UpdateTargetPosition();
            yield return new WaitForSeconds(1f * Time.deltaTime);
        }
    }

    // Set the target position to be behind the ball (in line with opponents goal)
    private void UpdateTargetPosition()
    {
        targetPosition = target.position + (target.position - goalToScoreIn.position).normalized;
        targetPosition.y = 0.5f;
    }

    private void LookRotation()
    {
        // Look at goal before shooting
        if(InLineWithOtherGoal() && isDashing)
            tf.LookAt(goalToScoreIn);
        // Else just look at the Ball at all times
        else
            tf.LookAt(target);

        // Only apply rotation around Y (Up)
        tf.eulerAngles = Vector3.up * tf.eulerAngles.y;
    }

    private void Move()
    {
        // Lower movement speed while jumping
        if(!Groundcheck())
            speedModifier = 0.8f;
        else
            speedModifier = 1f;

        if(!isDashing)
        {
            // Normal movement towards the ball
            Vector3 direction = (targetPosition - tf.position).normalized;
            direction.y = 0f;

            rb.AddForce(direction * (movementSpeed * speedModifier) * Time.deltaTime);
        }
        else
        {
            // When not moving normally, clamp the velocity to the dash force
            rb.velocity = Vector3.ClampMagnitude(rb.velocity, dashForce);
        }
    }

    protected override void StartDash()
    {
        base.StartDash();
    }

    protected override IEnumerator Dash()
    {
        return base.Dash();
    }
}

Dark Souls-clone  

Dark Souls-clone

This game was the result of a 4-week course covering scripting, UI and UX. The assignment was to make a side-scroller with some basic mechanics and UI. I ended up making a game heavily inspired by Dark Souls, with mechanics such as: Bonfires, Inventory, collecting Souls and a Stamina-bar. I also implemented an in-game store where the Player can purchase buffs. The game features 2 levels, 2 different enemies and a boss.

Folder Creation Tool  

Folder Creation Tool

This system was an experiment to learn Unity Editor-scripting. I got tired of creating the same folders each time I made a new project. It adds all the common folders with one click. To challange myself further I added functionality for custom folders and sub-folders. I learned a lot and still use it with every new project.

using UnityEngine;
using UnityEditor;
using System.Collections;
using System.Collections.Generic;

public class CreateFoldersWindow : EditorWindow
{
    private Vector2 scrollPos;
    private Color defaultColor;

    //  Info Message Box
    private string messageBoxInfo = "If a folder already exists, it will be ignored.";

    //  Template presets
    private bool[] projectTypes = new bool[2];
    private int projectTypeInt = 0;
    private string[] projectTypeNames = new string[] { "3D", "2D" };

    //  Template folders
    private List<string> templateFolderNames;
    private int numberOfTemplateFolders;
    private int startTemplateFoldersAmount = 6;

    //  Custom folders
    private List<string> customFolderNames;
    private int numberOfFolders;
    private int newNumberOfFolders;

    //  Main Toolbar tabs
    private static int toolbarInt;
    private string[] toolbarTabNames = new string[] { "Custom", "Standard" };

    //  Settings
    private bool customFolderDestination = false;
    private string folderDestination = "";

    //  OPENS AND INITIALIZES 
	[MenuItem("Window/Create Folders Tool %#q")]    // KEY SHORTCUT: CTRL+SHIFT+Q
    private static void OpenCreateFolders()
    {
        //  CREATE THE WINDOW
        CreateFoldersWindow window = (CreateFoldersWindow)EditorWindow.GetWindow(typeof(CreateFoldersWindow), false, "Create Folders");
        // Load the icon for the window
        Texture icon = AssetDatabase.LoadAssetAtPath<Texture>("Assets/Editor/Create Folders/Icon.png");
        GUIContent tc = new GUIContent("Folders", icon);
        window.titleContent = tc;

        AssetDatabase.Refresh();

        toolbarInt = 1;
        window.defaultColor = GUI.color;

        //  Initialize lists
        window.InitializeCustomFoldersList();
        window.InitializeDefaultFoldersList();
    }

    //  CLOSE THE WINDOW
    private void CloseCreateFolders()
    {
        //  Reset lists
        customFolderNames.Clear();
        numberOfFolders = 0;

        //  Close the window
        EditorWindow.GetWindow<CreateFoldersWindow>().Close();
    }

    /*  INITIALIZE THE CUSTOM LIST
     *  SETTING VALUES TO DEFAULT AND MAKE NEW LIST
     */ 
    private void InitializeCustomFoldersList()
    {
        numberOfFolders = 0;
        newNumberOfFolders = numberOfFolders;

        customFolderNames = new List<string>();
    }

    //  RESET THE CUSTOM LIST
    private void ClearCustomList()
    {
        numberOfFolders = 0;
        newNumberOfFolders = numberOfFolders;

        customFolderNames.Clear();

        customFolderNames = new List<string>();
    }

    //  INITIALIZE DEFAULT NAMES
    private void InitializeDefaultFoldersList()
    {
        if (templateFolderNames == null)
        {
            numberOfTemplateFolders = startTemplateFoldersAmount;
            templateFolderNames = new List<string>();
        }

        else if (templateFolderNames != null)
        {
            templateFolderNames.Clear();
            numberOfTemplateFolders = startTemplateFoldersAmount;
        }

        for (int i = 0; i < projectTypes.Length; i++)
        {
            projectTypes[i] = true;
        }

        DefaultNameTemplates(projectTypeInt);
    }
      
     /*  COMMON FOLDER NAMES TEMPLATE
     *  -CAN BE ADDED TO-
     */ 
    private void DefaultNameTemplates(int type)
    {
        if(type == 0)
        {
            templateFolderNames.Insert(0, "Animation");
            templateFolderNames.Insert(1, "Audio");
            templateFolderNames.Insert(2, "Materials");
            templateFolderNames.Insert(3, "Models");
            templateFolderNames.Insert(4, "Prefabs");
            templateFolderNames.Insert(5, "Scenes");
            templateFolderNames.Insert(6, "Scripts");
        }
        else if (type == 1)
        {
            templateFolderNames.Insert(0, "Animation");
            templateFolderNames.Insert(1, "Audio");
            templateFolderNames.Insert(2, "Materials");
            templateFolderNames.Insert(3, "Prefabs");
            templateFolderNames.Insert(4, "Scenes");
            templateFolderNames.Insert(5, "Scripts");
            templateFolderNames.Insert(6, "Sprites");
        }
    }

    //  GROW TEMPLATE LIST
    private void IncreaseTemplateFolderNamesList()
    {
        if (numberOfTemplateFolders < 10)
        {
            numberOfTemplateFolders++;

            //  INSERT EMPTY ROW TO WRITE NEW NAME ON
            templateFolderNames.Insert(numberOfTemplateFolders, "");
        }
        else
            return;
    }

    //  CLEAR EMPTY ROWS IN TEMPLATE LIST
    private void ClearEmptyRows()
    {
        for(int i = 6; i < templateFolderNames.Count; i++)
        {
            if (templateFolderNames[i] == "")
            {
                templateFolderNames.RemoveAt(i);
                numberOfTemplateFolders--;
            }
        }
    }

    /*  UPDATE NUMBER OF ROWS FOR CUSTOM NAMES
     *  INSERTS NEW IF NUMBER IS HIGHER
     *  REMOVES IF LOWER
     */ 
    private void UpdateCustomFolderNamesList(int index)
    {
        //  INSERT
        for (int i = newNumberOfFolders; i < index; i++)
            customFolderNames.Add("");

        //  REMOVE
        if (newNumberOfFolders > numberOfFolders)
            customFolderNames.RemoveAt(index);

        newNumberOfFolders = index;
    }

    private void OnGUI()
    {
        scrollPos = EditorGUILayout.BeginScrollView(scrollPos);
        
        EditorGUILayout.BeginVertical(GUILayout.Width(40f));
            
        GUILayout.Space(10f);

        //  MODE SELECTION TABS
        toolbarInt = GUILayout.Toolbar(toolbarInt, toolbarTabNames);

        switch(toolbarInt)
        {
            case 0:
                //  CUSTOM FOLDER NAMES
                CustomFoldersView();
                break;
        
            case 1:
                //  TEMPLATE FOLDER NAMES
                DefaultFoldersView();
                break;
        }

        GUI.color = defaultColor;

        EditorGUILayout.HelpBox(messageBoxInfo, MessageType.Info);
            
        EditorGUILayout.EndVertical();
        EditorGUILayout.EndScrollView();
    }

    private void CustomFoldersView()
    {
        GUILayout.Space(10f);

        //  ADVANCED SETTINGS (Sub folders)
        customFolderDestination = EditorGUILayout.BeginToggleGroup("Custom path", customFolderDestination);

        if(customFolderDestination)
        {
            GUILayout.Box("Folder destination:");
            EditorGUILayout.HelpBox("Make sure path exists!", MessageType.Warning);

            EditorGUILayout.BeginHorizontal();

            GUILayout.Label("Assets/");
            folderDestination = EditorGUILayout.TextField(folderDestination);

            EditorGUILayout.EndHorizontal();
        }
        else if(!customFolderDestination)
            folderDestination = "";

        EditorGUILayout.EndToggleGroup();

        ClearEmptyRows();
        GUILayout.Space(10f);
        GUILayout.Label("How many folders:");

        EditorGUILayout.BeginHorizontal();

        numberOfFolders = EditorGUILayout.IntField(Mathf.Clamp(numberOfFolders, 0, 10));

        if(GUILayout.Button("Reset", GUILayout.Width(100f), GUILayout.Height(20f)))
        {
            if(numberOfFolders > 0)
            {
                ClearCustomList();
            }

            return;
        }
        GUILayout.Space(10f);

        EditorGUILayout.EndHorizontal();

        //  Create rows based on how many folders we want(numberOfFolders)
        if(numberOfFolders > 0)
        {
            GUILayout.Space(10f);
            GUILayout.Label("Folder names:");

            EditorGUILayout.BeginVertical();

            //  Create row with text field based on slider
            for(int i = 0; i < numberOfFolders; i++)
            {
                UpdateCustomFolderNamesList(numberOfFolders);

                //  Input name of folder
                customFolderNames[i] = EditorGUILayout.TextField(customFolderNames[i]);
            }

            EditorGUILayout.EndVertical();
        }

        GUILayout.Space(20f);

        GUI.color = Color.cyan;
        //  CREATE FOLDERS BUTTON
        if(GUILayout.Button("Create Folders", GUILayout.Width(200f), GUILayout.Height(50f)))
        {
            CreateFoldersFromList(customFolderNames);
        }
    }

    private void DefaultFoldersView()
    {
        GUILayout.Space(10f);

        EditorGUILayout.BeginVertical(GUILayout.Width(25f));
        //  CHOOSE PRESET (3D or 2D)
        projectTypeInt = GUILayout.Toolbar(projectTypeInt, projectTypeNames);

        switch(projectTypeInt)
        {
            case 0: //  3D
                if(projectTypes[0])
                {
                    //  Clear list and get new template based on type
                    InitializeDefaultFoldersList();

                    /*  Make it only update when switched
                     *  other type is always true before switch */
                    projectTypes[0] = false;
                    projectTypes[1] = true;
                }
                break;

            case 1: //  2D
                if(projectTypes[1])
                {
                    //  Clear list and get new template based on type
                    InitializeDefaultFoldersList();

                    /*  Make it only update when switched
                     *  other type is always true before switch */
                    projectTypes[1] = false;
                    projectTypes[0] = true;
                }
                break;
        }
        EditorGUILayout.EndVertical();

        EditorGUILayout.BeginHorizontal();

        //  Add an empty row to write in
        if(GUILayout.Button("Add Folder", GUILayout.Width(100f), GUILayout.Height(20f)))
        {
            IncreaseTemplateFolderNamesList();
        }

        //  Reset names to default names
        if(GUILayout.Button("Reset", GUILayout.Width(100f), GUILayout.Height(20f)))
        {
            InitializeDefaultFoldersList();
            return;
        }
        EditorGUILayout.EndHorizontal();

        GUILayout.Space(10f);
        GUILayout.Label("Folder names:");

        //  Set template names and name for added empty rows
        for(int i = 0; i <= numberOfTemplateFolders; i++)
        {
            templateFolderNames[i] = EditorGUILayout.TextField(templateFolderNames[i]);
        }

        GUILayout.Space(20f);

        GUI.color = Color.cyan;
        //  CREATE FOLDERS BUTTON
        if(GUILayout.Button("Create Folders", GUILayout.Width(200f), GUILayout.Height(50f)))
        {
            CreateFoldersFromList(templateFolderNames);
        }
    }

    //  CREATE THE FOLDERS (when 'Create Folders' button is pressed)
    private void CreateFoldersFromList(List<string> folderNames)
    {
        //  Check if we have a custom destination for folders
        if (!customFolderDestination)
            folderDestination = "Assets";
        else
        {
            folderDestination = "Assets/" + folderDestination;
        }

        bool createdFolders = false;
        int folderCount = 0;

        //  If folders dont already exist, create them
        for (int i = 0; i < folderNames.Count; i++)
        {
            if (!AssetDatabase.IsValidFolder(folderDestination + "/" + folderNames[i]) && folderNames[i] != "")
            {
                AssetDatabase.CreateFolder(folderDestination, folderNames[i]);
                folderCount++;
                createdFolders = true;
            }
        }

        SetMessageBoxInfo(createdFolders, folderCount);
        DoneCreating();
    }

    /*  SHOW CONSOLE MESSAGE
     *  WHEN DONE CREATING NEW FOLDERS
     *  REFRESH DATABASE THEN RESET ALL VALUES
     */ 
    private void DoneCreating()
    {
        AssetDatabase.Refresh();

        DefaultNameTemplates(0);
        ClearCustomList();
    }

    private void SetMessageBoxInfo(bool success, int amount)
    {
        if(success)
        {
            messageBoxInfo = "Folders created: " + amount;
        }
        else
        {
            messageBoxInfo = "Folder already exists!";
        }
    }
}

Blood System  

Blood System

I wanted to make an FPS-game from scratch with lots of blood and ended up with this system. It works by changing the pixels on a material. The blood-particle system raycasts from its particles and checks if the surface hit can be painted.

using UnityEngine;
using System.Collections;

public class HitParticle : MonoBehaviour
{
    private ParticleSystem ps;
    private ParticleSystem.Particle[] particles;

    [SerializeField]
    private LayerMask collisionMask;

    private bool inUse = false;
    private float timer;
    // Lower value to optimize
    private int maxParticlesToCheck = 7;

    private void Awake()
    {
        ps = GetComponent<ParticleSystem>();
        // Populate the particles-array
        particles = new ParticleSystem.Particle[ps.maxParticles];
    }

    private void LateUpdate()
    {
        DetectCollisions();

        // Start the timer when this particle system is in use
        if(inUse)
        {
            timer -= Time.deltaTime;

            // When the particle systems lifetime is over, re-parent to the object pool
            if(timer <= 0)
            {
                transform.SetParent(HitParticlesPooler.Instance.transform);
                inUse = false;
            }
        }
    }

    private void DetectCollisions()
    {
        int particlesAlive = ps.GetParticles(particles);
        // Update the amount of particles currently active in this system
        ps.SetParticles(particles, particlesAlive);

        for(int i = 0; i < particlesAlive; i++)
        {
            if(i < maxParticlesToCheck && particlesAlive > 0)
            {
                Vector3 dir = particles[i].position - transform.position;
                RaycastHit hit;

                // Raycast from the current particle with its direction from particle emitter
                if(Physics.Raycast(particles[i].position, dir, out hit, 3f, collisionMask))
                {
                    if(hit.collider != null)
                    {
                        // If a paintable surface is hit
                        if(hit.collider.GetComponent<SurfacePainter>())
                        {
                            hit.collider.GetComponent<SurfacePainter>().PaintBlood(hit.textureCoord, hit.normal, ps.startColor);
                        }
                    }
                }
            }
            else
            {
                // Break out of the loop if the amount to check is done or if no particles are alive
                break;
            }
        }
    }

    public void UseParticle()
    {
        ps.Play();

        timer = ps.startLifetime;
        // Start the LateUpdate timer
        inUse = true;
    }
}

using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public class SurfacePainter : MonoBehaviour
{
    private enum SurfaceType { Geometry, LivingEntity };
    [SerializeField]
    private SurfaceType surfaceType;

    private Renderer rend;
    private Texture2D originalTex;
    // The texture instance to manipulate
    private Texture2D tex;
    [SerializeField]
    private Color bulletHoleColor;
    [SerializeField]
    private Color[] shotBloodColor;
    // The color to apply to the current pixel
    private Color pixelColor;

    private void Start()
    {
        rend = GetComponent<Renderer>();
        originalTex = rend.material.mainTexture as Texture2D;
        CreateNewTexture();
        SetPixelColor();
    }

    // Creates a texture instance based on the original texture and assigns it to the material renderer
    private void CreateNewTexture()
    {
        tex = new Texture2D(originalTex.width, originalTex.height);
        tex.filterMode = FilterMode.Point;
        tex.SetPixels(originalTex.GetPixels());
        tex.Apply();
        rend.material.mainTexture = tex;
    }

    // Decide if it's a bullet hole or blood
    private void SetPixelColor()
    {
        switch(IsLivingEntity())
        {
            case false:
                pixelColor = bulletHoleColor;
                break;

            case true:
                pixelColor = shotBloodColor[0];
                break;
        }
    }

    private bool IsLivingEntity()
    {
        if(surfaceType == SurfaceType.LivingEntity)
            return true;

        return false;
    }

    public void PaintBlood(Vector2 hitPoint, Vector3 hitNormal, Color particleColor)
    {
        // Get the UV coordinates of the hit
        int uvX = (int)(hitPoint.x * tex.width);
        int uvY = (int)(hitPoint.y * tex.height);

        Vector2 pixelUV = new Vector2(uvX, uvY);

        // Loop through the pixels of the texture
        for(int y = 0; y < tex.height; y++)
        {
            for(int x = 0; x < tex.width; x++)
            {
                // Update the correct pixel with its new color
                if(x == uvX && y == uvY)
                {
                    tex.SetPixel(uvX, uvY, particleColor);
                    tex.Apply();

                    // Start the blood drip from the wound
                    StartCoroutine(BloodDrip(pixelUV, hitNormal, particleColor, false));
                }
            }
        }
    }

    public void PaintSurface(Vector2 hitPoint, Vector3 hitNormal)
    {
        // Get the UV coordinates of the hit
        int uvX = (int)(hitPoint.x * tex.width);
        int uvY = (int)(hitPoint.y * tex.height);

        Vector2 pixelUV = new Vector2(uvX, uvY);

        // Loop through the pixels of the texture
        for(int y = 0; y < tex.height; y++)
        {
            for(int x = 0; x < tex.width; x++)
            {
                // Update the correct pixel with its new color
                if(x == uvX && y == uvY)
                {
                    tex.SetPixel(uvX, uvY, pixelColor);
                    tex.Apply();
                }
            }
        }
    }

    private IEnumerator BloodDrip(Vector2 pos, Vector3 hitNormal, Color col)
    {
        // Make the length of the drip a bit random
        int dripAmount = Random.Range(2, 5);

        for(int y = 1; y < dripAmount; y++)
        {
            // If the hit normal is pointing up, spread it randomly around the hit position
            if(hitNormal.y >= 1)
            {
                // Store the hit position so it can start from the same position the next loop
                int tempX = (int)pos.x;
                int tempY = (int)pos.y;

                // Drip either left or right
                int xDir = Random.Range(0, 2);
                if(xDir == 0)
                    xDir = -1;

                pos.x += xDir;

                // Drip either forward or back
                int yDir = Random.Range(0, 2);
                if(yDir == 0)
                    yDir = -1;

                pos.y += yDir;

                tex.SetPixel((int)pos.x, (int)pos.y, col);

                // Reset the start position to the hit position
                pos.x = tempX;
                pos.y = tempY;
            }
            else
            {
                // Normal hit, drip down
                tex.SetPixel((int)pos.x, (int)pos.y - y, col);
            }

            tex.Apply();

            // Small delay between drips
            yield return new WaitForSeconds(0.2f);
        }
    }
}

FPS Project

FPS Project

A small project made in 2 weeks with Unreal Engine. In this project I focused on making an interesting level with Scripted Events and AI encounters leading up to a boss fight. All scripting was done using Unreal Blueprints.