Nicholas Perell

Experience Resume Blog Contact

Black White Red

Black White Red (BWR) is a first-person, action-based, 5v5 PvP game. Get across the map to capture enemy portals in order to score points, all while your abilities change as you traverse to different sections of the map. Defend, fight, evade, and capture.

Product Owner & Systems Programmer

Team Size: 8

Scale: Tech Demo

Development Period: June–August 2022

Tools Used:

C#

C#

Unity

Unity

Git

Git

Start menu showing player avatar shifting shades between the different teams. Buttons read play, change name, options, credits, and quit.

Features

  • Archery for shooting arrow projectiles, and melee combat with swords.
  • Arena divided into different shades that affect your stats and weapons (strongest near your own portal, most evasive near the other team's portal).
  • UI and materials that change to reflect a player's shade and team. Additional UI for Game score and kill feed.
  • Client-side music that changes depending on your shade, rising in intensity as you near capturing the other team's portal.
  • Spatialized sound that has variations for if the sound is from the player's team or enemy team.
Home base for the white team. Stairs on the side go up to an upper level with a glowing portal/doorway. Between the stairs is a red, blood, waterfall fountain.

Contributions

  • Programmed the shade system, and scoring by capturing portals.
  • Implemented combat and weapon swapping.
  • Connected the FMOD parameters to the game’s client-side variables, and programmed a system to call the correct FMOD event for ally versus enemy sounds.
  • Created the UI for player stats and game score.
  • Manged the project's backlog with producer.
  • Programmed the third-person and first-person animators receiving the correct parameters.
  • Created the start and options screen.

Development of Black White Red

BWR was developed by a small team of 8 over the Summer of 2022. Based on a mini-game originally made in Minecraft, we wanted to make this game come to life and see how well it would work as a standalone. It was my first foray into a game project involving network programming, and turned out to be a very fulfilling experience.

Technical information

Archery

// ArrowController.cs Initialization
[ClientRpc(Delivery = RpcDelivery.Reliable)]
public void InitClientRpc(Team _team, ulong _shooterId, int _shadeValue, Vector3 startingPosition, Vector3 startDirection, float amountCharged, float timeShot)
{
    Debug.Log("Arrow Inited");

    Vector3 startingVelocity = startDirection * Mathf.Lerp(minimumStartingVelocity, maximumStartingVelocity, amountCharged);
    float timeSinceShot = NetworkManager.Singleton.LocalTime.TimeAsFloat - timeShot;

    gameObject.SetActive(true);
    rb.constraints = RigidbodyConstraints.None;

    team = _team;
    shadeValue = _shadeValue;
    shooterId = _shooterId;
    transform.position = startingPosition + startingVelocity * timeSinceShot + .5f * Physics.gravity * timeSinceShot * timeSinceShot;
    rb.velocity = startingVelocity + Physics.gravity * timeSinceShot;

    timer = timeBeforeDespawn;
    appearance.forward = rb.velocity;
    landed = false;

    //When defaultly enabled in NGO, the collider would accidentally act as not a trigger, causing "flying" glitch caught by the testers
    sphereCollider.enabled = true;

    if (IsServer || IsHost)
    {
        RaycastHit temp;
        Ray ray = new Ray(startingPosition, transform.position);
        RaycastHit[] raycastHits = Physics.SphereCastAll(ray, .25f, Vector3.Distance(startingPosition, transform.position), collisionDetectionMask);
        for(int i = 0; i < raycastHits.Length; i++)
        {
            temp = raycastHits[i];
            Debug.LogWarning("SphereCastAll index "+ i +": "+ temp.collider.name +" "+temp.distance);
            if (
                temp.collider.gameObject.layer != 6 || //Isn't a player
                temp.collider.gameObject.GetComponent<PlayerController>().CurrentTeam != team //Is on the enemy team
                )
            {
                Debug.LogWarning("PROCESSING SphereCastAll index " + i + ": " + temp.collider.name + " " + temp.distance);
                ProcessCollision(temp.collider);
                break;
            }
        }
    }
}
// ArrowController.cs Processing Collision
private void ProcessCollision(Collider hit)
{

    //Handle ground, bomb, or enemy teammate
    switch (hit.gameObject.layer)
    {
        //Ground
        case 0:
            Debug.Log("Arrow hit ground " + hit.name);
            StickIntoPlaceClientRpc(hit.ClosestPoint(transform.position));
            timer = timeBeforeDespawnOnceLanded;
            landed = true;
            sphereCollider.enabled = false;
            break;
        //Players
        case 6:
            Debug.Log("Arrow hit player " + hit.name);
            PlayerController playerController = hit.GetComponent<PlayerController>();
            if (playerController.CurrentTeam != team)
            {
                //Knockback
                playerController.GetComponent<PlayerKnockbackController>().KnockbackPlayer(rb.velocity, kit.playerStats[shadeValue].bowKnockbackMultiplier);

                //Damage
                playerController.GetComponent<PlayerHealth>().TakeDamage(
                    rb.velocity.magnitude * damageMultiplier * kit.playerStats[shadeValue].bowDamageMultiplier,
                    DamageSource.ARROW,
                    NetworkManager.Singleton.SpawnManager.SpawnedObjects[shooterId].GetComponent<PlayerController>());

                //Unload
                sphereCollider.enabled = false;
                ArrowPool.Instance.UnloadArrow(gameObject);
            }
            break;
        //Bomb
        case 9:
            sphereCollider.enabled = false;
            ArrowPool.Instance.UnloadArrow(gameObject);
            break;
        default:
            break;
    }
}

Music Reacting to the Local Player's Shade

// MusicShadeController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MusicShadeController : MonoBehaviour
{
    public FMODUnity.EventReference musicEventName;
    public FMOD.Studio.EventInstance musicEvent;

    [Space]

    [SerializeField]
    [Tooltip("Parameter name for the shade/layer value")]
    string param;
    [SerializeField]
    int lastSetValue;

    [SerializeField]
    PlayerController localPlayer;

    void Start()
    {
        musicEvent = FMODUnity.RuntimeManager.CreateInstance(musicEventName);
    }

    private void OnEnable()
    {
        MatchManager.onMatchStart += HandleMatchStart;
        ListenToPlayer();
    }

    private void OnDisable()
    {
        MatchManager.onMatchStart -= HandleMatchStart;
        StopListeningToPlayer();
    }

    private void HandleShadeChange(PlayerStats _value)
    {
        musicEvent.setParameterByName(param, localPlayer.ShadeValue);
        lastSetValue = localPlayer.ShadeValue;
    }

    private void ListenToPlayer()
    {
        if (localPlayer != null)
        {
            localPlayer.onShadeChange += HandleShadeChange;
        }
    }

    private void StopListeningToPlayer()
    {
        if(localPlayer != null)
        {
            localPlayer.onShadeChange -= HandleShadeChange;
        }
    }

    private void HandleMatchStart()
    {
        localPlayer = MatchManager.Instance.localPlayerController;

        musicEvent.setParameterByName(param, localPlayer.ShadeValue);
        lastSetValue = localPlayer.ShadeValue;

        musicEvent.start();

        ListenToPlayer();
    }

    private void OnDestroy()
    {
        musicEvent.stop(FMOD.Studio.STOP_MODE.ALLOWFADEOUT);
    }
}