Home || About/Contact || Resume || Articles || LinkedIn

Steam page

Tools: Unity Editor, Visual C#

Roles: Additional Design, Quality Assurance

Team Size: 15 – 20 people

Development Period: November 2017 – June 2019

Overview: Underworld Ascendant is a game attempting to emulate immersive sims of years past, positioning itself as a spiritual successor to Ultima Underworld. First employed as a Quality Assurance tester, I was able to suggest and even implement a few features during less intense periods of development.

These design tasks were varied: sometimes it involved making new prefabs from meshes that we got from our artists, other times it involved fixing issues on existing prefabs. Here, however, I want to focus on examples where I was able to write C#, whether that was for player-facing features, or internal tools for development.

Filling glass bottles with water: The most compelling thing to me about the immersive sim genre is the embrace of “common sense” when implementing systemic interactions: if a player intuitively thinks two objects should interact in a certain way, then the team should strongly consider implementing that interaction. Case in point: in Underworld, we had empty glass bottles that the player could pick up and throw to create a distracting noise. Similarly, we had bottles full of water that the player could throw at fire sources to extinguish them. So, wouldn’t it make sense for a player to be able to dip an empty bottle in a body of water and pull out a filled one?

I prototyped this in a test map and recorded the clip embedded above. Given that this was a detection of collision between two specific object types, I decided to check for this collision in the OnTriggerEnter() function on the water’s WaterVolume.cs script, as there would only be a few of those per level at most, compared with potentially hundreds of glass bottles in a map. Within OnTriggerEnter(), I checked to see if the other object was an instance of our glass bottle prefab. If so, I instantiated a water bottle, whose prefab was cached in an EnvironmentalPrefabs singleton, and deleted the old bottle.

Additionally, I ran my first iteration by our lead engineer, Will Teixeira. The first approach had used a string comparison to determine if the glass bottle was of the appropriate name. However, Will warned about string comparisons being slow, and recommended adding a blank script to the glass bottle prefab. With that, we could use GetComponent(), a faster operation, to check for this script, named WaterFill.


void OnTriggerEnter(Collider other)
{
    if(!other.attachedRigidbody)
    {
        return;
    }

    Rigidbody otherAttachedRigidibody = other.attachedRigidbody;

    //...code omitted for brevity...

    //checks if other object is an empty bottle
    if (otherAttachedRigidibody.GetComponent())
    {
        FillBottle(otherAttachedRigidibody.gameObject);
    }
}

void FillBottle(GameObject emptyBottle)
{
    GameObject waterBottle = GameObject.Instantiate(EnvironmentPrefabs.Instance.blueBottlePrefab, emptyBottle.transform.position, emptyBottle.transform.rotation); 

    //finds player via playerSystem singleton, grabs component responsible for picking up objects
    PickUpObjectsAbility pickUpObjectsAbility = PlayerSystem.Instance.player.pickUpObjectsAbility; 

    //determines if the empty bottle we're about to fill is being held by the player
    if (pickUpObjectsAbility.CurrentTarget == emptyBottle)
    {
        //if so, omitted code here makes the newly instantiated water bottle the player's held object
    } 

    Destroy(emptyBottle);
}

 

Automated Performance Testing: When first trying to see if I could do some design or programming work, one of our engineers, Chris Maire, said that an automated performance test would be helpful. Suggesting that it teleport the camera to a list of spots and measure the frame times at each, we came up with a solution where a number of Vector3 positions would be read from an external text file, instead of placing empty objects around every scene in the game.

I wrote a script that would be able to read these values in, teleport the player to the corresponding locations, then write out the results to a .csv file, which could be imported into Google Sheets. Knowing that we wanted this to run through multiple scenes, I set up a separate Unity scene called “fpstest” which only contained an empty game object with the automated script attached. The object would then move itself to the DontDestroyOnLoad scene, and run all of its logic in the Update() function. I created an enum called LoadState in order to keep track of if a scene was loading, unloading, or already loaded in order to prevent errors with the script trying to run with no game objects loaded.


void Update()
{
    realTimeDelta = Time.realtimeSinceStartup - prevFrameRealTime;
    prevFrameRealTime = Time.realtimeSinceStartup;

    switch(loadState)
    {
        case LoadState.TO_LOAD:

        //...code omitted for brevity...

        break;

        case LoadState.LOADING:

        //if the active scene is the one we were trying to load, we know it's loaded
        if (LevelSystem.Instance.isFullyLoaded && LevelSystem.Instance.currentLevelName == currentScene)
        {
            loadState = LoadState.IS_LOADED;
            PosIter = 0;
            nextPos = true;

            //re-grabs player pawn with each scene loaded, makes invincible
            TestCamera = PlayerSystem.GetThePlayer().gameObject;
            TestCamera.GetComponent().enabled = false;
            AdminSystem.Instance.ExecuteCommand("cheats 1");
            AdminSystem.Instance.ExecuteCommand("iddqd");

            //writes header for level (i.e. loadtimes, sets up the High/Low/Avg columns
            using (output = File.AppendText(outputLocation))
            {
                if(currentQuest != "")
                {
                    output.Write("Quest: ," + QuestSystem.Instance.CurrentQuest.Name + "\n");
                }
                output.Write("Load Time: " + (Time.realtimeSinceStartup - loadStartTime) + " sec\n\n");
                output.Write(",High FPS,Low FPS,Average FPS,Y Rotation\n");
            }
        }
    break;

    case LoadState.IS_LOADED:

    //true after the camera finishes rotation from the previous position/on loading a new level
    if (nextPos)
    {
        if (!reader.EndOfStream)
        {
            string val = reader.ReadLine();

            //only Vector3 values in external text file have commas in them (i.e. "x,y,z")
            if (val.Contains(","))
            {
                //splits string according to those commas and loads them into a Vector3
                string[] coords = val.Split(',');

                TestCamera.transform.position = new Vector3(float.Parse(coords[0]), float.Parse(coords[1]), float.Parse(coords[2]));
                TestCamera.transform.rotation = Quaternion.identity;
                nextPos = false;

                if (PosIter == 0)
                    currentPauseTime = timeForLevelLoadPause;
                else currentPauseTime = pausePerPosition;

                using (output = File.AppendText(outputLocation))
                {
                     output.Write("Eye " + PosIter + " (" + coords[0] + " " + coords[1] + " " + coords[2] + "),");
                }
            }
            else
            {
                previousScene = currentScene;

                if (val.Substring(0, 10) == "quest load")
                {
                    currentQuest = val;
                }
                else
                {
                    currentQuest = "";
                    currentScene = val;
                }

                loadState = LoadState.TO_LOAD;

                using (output = File.AppendText(outputLocation))
                {
                    output.Write("Map Average:,,," + MapAverageTime() + "\n\n");
                }
                Averages.Clear();
            }
        }
        else
        {
            using (output = File.AppendText(outputLocation))
            {
                output.Write("Map Average:,,," + MapAverageTime() + "\n\n");
            }
            Averages.Clear();

            Application.Quit();
        }

        //for pauses before taking measurements, like on level load, or right after switching positions
        else if (currentPauseTime > 0.0f)
        {
             currentPauseTime -= realTimeDelta;
        }
        //if measuring frametimes
        else
        {
            //adds time since last frame to dataset
            FrameTimes.Add(realTimeDelta);
            currentAngleTime += realTimeDelta;

            if (currentAngleTime >= timeForRotation)
            {
                 TestCamera.transform.rotation = Quaternion.Euler(new Vector3(0, TestCamera.transform.rotation.eulerAngles.y + 45, 0));

                 //keep track of how much camera has rotated in current position
                 currentDegreesRotated += 45;

                 currentAngleTime = 0;
                 //Adds instance of struct PosData that includes high/low/avg frametimes plus a y rotation
                 positionAngles.Add(new PosData(1 / SlowestTime(), 1 / FastestTime(), 1 / AverageTime(), currentDegreesRotated));
                 FrameTimes.Clear();
           }

           //if camera has made a full rotation, record results and switch to next position
           if (currentDegreesRotated >= 360)
           {
               currentDegreesRotated = 0;
               nextPos = true;
               ++PosIter;
               //function that finds the angle that had the lowest low and returns that PosData
               PosData writer = FindLowestAngle();

               using (output = File.AppendText(outputLocation))
               {
                   output.Write(writer.highestFrame + ",");
                   output.Write(writer.lowestFrame + ",");
                   output.Write(writer.frameAverage + ",");
                   output.Write(writer.yAngle + "\n");
                   Averages.Add(writer.frameAverage);
               }
               positionAngles.Clear();
           }
       }
       break;
    }
}

Coordinating with engineering, I was able to add a command to the game that checked for a command line argument of “-fpstest”, and load directly into the test scene if found. This allowed me to set up a desktop shortcut that would run through this automated test and quit the game when finished.

The door to the Vault of Nix: The final level in our game, the Vault of Nix, was a late addition. Adding a structure of collecting keys to the main quest line, our artist Pete Anderson sculpted a door with eight locks that ended up being assigned to me for implementation. Because of this, I was responsible for setting up the prefab for it, scripting the opening animation, and incorporating a shader.

At launch, the door was located in the hub level of Underworld, meaning it needed to be able to respond to players possibly unlocking one or two of its locks at any given moment, saving its state, then restoring its state on load. While I implemented this, the door was moved to a location in our second post-launch update that’s only possible to reach if the player already has all keys, meaning they will only ever see the full unlocking sequence.

Because writing code to animate all the locks in the same way would get repetitive, I set up a struct called LockInfo, which contained the info needed per lock.

//located in VaultOfNixDoor.cs's Update()
if (animating)
{
    currentTime += Time.deltaTime;
    //handles the last case, when the doors actually open
    if (currentKey == KeyState.OPEN)
    {
        leftDoor.transform.RotateAround(leftRotationPoint.transform.position, Vector3.up, -openSpeed);
        rightDoor.transform.RotateAround(rightRotationPoint.transform.position, Vector3.up, openSpeed);

        currentRotation += openSpeed;

        if (currentRotation >= openAngle)
        {
            animating = false;
        }
    }
    //most cases handled here. "currentLock" is of type LockInfo
    else
    {
        //locksAndBars list is set up so that currentBar1 should always be the left, and currentBar2 the right
        if (currentLock.keySymbol != KeyID.Symbol.Sun)
        {
            currentLock.leftBarMesh.transform.localPosition = new Vector3(currentLock.leftBarMesh.transform.localPosition.x + (Time.deltaTime / timeToFadeUp * barDistance),
            currentLock.leftBarMesh.transform.localPosition.y, currentLock.leftBarMesh.transform.localPosition.z);

            currentLock.rightBarMesh.transform.localPosition = new Vector3(currentLock.rightBarMesh.transform.localPosition.x - (Time.deltaTime / timeToFadeUp * barDistance),
            currentLock.rightBarMesh.transform.localPosition.y, currentLock.rightBarMesh.transform.localPosition.z);

            if (currentLock.ShaderParam != "")
            {
                currentLock.leftBarMesh.GetComponent().sharedMaterial.SetFloat(currentLock.ShaderParam, currentTime / timeToFadeUp * targetEmissiveIntensity);
                currentLock.rightBarMesh.GetComponent().sharedMaterial.SetFloat(currentLock.ShaderParam, currentTime / timeToFadeUp * targetEmissiveIntensity);
                currentLock.lockMesh.GetComponent().sharedMaterial.SetFloat(currentLock.ShaderParam, currentTime / timeToFadeUp * targetEmissiveIntensity);
            }
        }
        //handles case of last lock, which has the one vertical bar instead of two horizontal ones
        else if(currentLock.keySymbol == KeyID.Symbol.Sun)
        {
            currentLock.leftBarMesh.transform.position = new Vector3(currentLock.leftBarMesh.transform.position.x,
            currentLock.leftBarMesh.transform.position.y - (Time.deltaTime / timeToFadeUp * 3.2f), currentLock.leftBarMesh.transform.position.z);

            if (currentLock.ShaderParam != "")
            {
                currentLock.leftBarMesh.GetComponent().sharedMaterial.SetFloat(currentLock.ShaderParam, currentTime / timeToFadeUp * targetEmissiveIntensity);
                currentLock.lockMesh.GetComponent().sharedMaterial.SetFloat(currentLock.ShaderParam, currentTime / timeToFadeUp * targetEmissiveIntensity);
                leftDoor.GetComponent().sharedMaterial.SetFloat(currentLock.ShaderParam, currentTime / timeToFadeUp * targetEmissiveIntensity);
                rightDoor.GetComponent().sharedMaterial.SetFloat(currentLock.ShaderParam, currentTime / timeToFadeUp * targetEmissiveIntensity);
            }

            if (currentTime >= timeToFadeUp)
                currentKey = KeyState.OPEN;
        }

        if (currentTime >= timeToFadeUp)
        {
            animating = false;
            sequenceRunning = true;
            currentTime = 0;
            currentShaderParam = "";
        }
    }
}

For the emissive blue shader, Pete coordinated with Tim Stellmach, a designer on the team, to have the eight locks’ UVs correspond to masks on two different textures, four locks per texture. That way each lock would be able to correspond with one of the RGBA channels in the image, allowing for the emission on any one of them to be tweaked individually. With this knowledge, I got to work in Shader Forge, setting up each lock to have a shader parameter that could be accessed via the above script.

VaultOfNixDoorShaderCropped

Back to Level Design Portfolio >>

Advertisements