Postmortem: Endless Swarm

29 04 2015

What Went Right

The project started off well; we met several times the first week to discuss design and team organization. We settled fairly quickly on a Sci-Fi themed twin-stick shooter. The original design had several rooms laid out in a circle, with a smaller circle of rooms inside, repeated until a single room at the center. The idea was that the player would need to work their way inwards against increasing threats. We wanted procedural levels, and upgrades for the character. The design work was performed quickly, and documented effectively, which aided the rest of the project immensely.


Diagram 1 – Initial layout for the map; we didn’t end up using this unfortunately, and went with a simpler, linear path.

We determined that the work for each week would be discussed in advance, and distributed as needed. We would be working in Unity, and keeping the work together with Git. The latter proved a problem early on, but became a great asset later in development. We would meet to merge, giving each team member a chance to explain what they’d done, and how it fit into the rest of the project. As a result we each mostly understood the others work well enough to interface with it later without delays. Additionally, having a remote repository meant no work was lost when I had to replace my hard-drive, and there was always an “official” current version.

As the project continued the most effective times for me were weeks with large blocks of programming. In particular, the weeks in which I worked on the Hive-Mind AI, and the MapBuilder class were especially productive, as I was able to sit down and work towards a goal with no dependency or merging delays.


Diagram 2 – Feature branching work-flow in Git.

After some trouble with Git, we sat down and determined a work-flow. Each week we would each create a branch off the “master” branch, though we didn’t settle on a naming-scheme which caused some minor confusion later. All work for the week would be done in our individual branches, and at the end of the week we could meet and merge them one at a time as explained earlier. We didn’t manage to stick to the workflow as closely as I would have liked, but overall it worked fairly well.

Part way through the project we determined that we needed to upgrade Unity to version 5. This gave us access to additional lighting options, and fixed some problems one of my team-mates was having with the prior release. The upgrade was entirely painless – the Unity team provided a utility which automatically updated all of our code to use the new APIs, and the upgrade was completed within a week, along with the rest of the work for the week.

The distribution of work was surprisingly smooth throughout the project, despite not having any particular process for doing so. Most weeks we determined a list of tasks which needed to be completed for the next version, then took turns picking things we’d be interested in doing. Each member of the team had somewhat different interests, so we rarely ran into any overlap, and had no problems even when we did.

For the most part, determining what needed to be done in advance permitted us to avoid getting locked up on dependencies. We were able to plan far enough ahead to avoid assigning tasks which were dependent on other tasks from that week or future weeks. The one exception we encountered arose from delays in the availability of assets for the enemy creatures, which lasted approximately 1-2 weeks.

Finally, while it took some tweaking on several occasions over the life of the project, we managed to adjust the player movement and camera tracking to something that feels right. Fast and snappy, but still smooth enough that it’s not jarring for the player.

What Went Wrong

At the beginning of the project we were constantly plagued by problems with Git. The repo set-up was relatively simple, but implementing a work-flow, and ensuring everyone was comfortable, took several weeks. During that set-up time work was slowed noticeably by delays triggered by misunderstandings of Git, mistakes made while committing or merging, and similar problems. These were mostly alleviated as time passed and we all settled into a work-flow.

Some of the Git problems remained throughout the project, however. In particular, the merging of Unity files (Prefabs, Scenes, etc) caused problems almost every week. The built in merging tools had trouble with the format of the Unity files, and tiny mistakes while merging would cause Unity to crash with no error. Eventually I wrote a script to search the meta-files (a particularly common source of problems) for the artifacts left by unmerged or poorly merged content, to make finding the source of the errors somewhat less painful. For future projects it would be prudent to research some of the merging tools available on the Unity Asset Store before beginning work.

Despite the script to find errors and our other solutions, the problems encountered with hosting Unity files in Git still slowed work that took place in Scenes or Prefabs. Even causing development of relatively simple systems, such as weapon swapping, to take just as long as the code heavy development of the Hive-Mind AI system.

In addition to Git problems, we encountered several issues which arose from our varied working environments. We each had different set-ups on our PCs, and as a result would periodically encounter errors which would appear only on one machine and not the others. Given the difficulty of finding times to meet in person, solving these problems was occasionally quite time-consuming. This was not entirely a disadvantage however, as making it work in different environments forced us to pay more attention to ensuring the game would run on different platforms.

While the design stayed mostly constant, we ended up cutting a few of our original ideas due to time constraints. In particular, we originally wanted many rooms connected by gates that could only be opened when supplied with sufficient power. This ended up being dropped for a more traditional rogue-like level design, with each floor being randomly generated with a portal to the next. Instead of falling back to the entrance to upgrade the player is given the chance between floors.

We also never managed to get the dual-stick controls working. This was largely due to a combination of time constraints, and a lack of consistent hardware. Since we only ever had the game working on the PC anyways we’ve stuck with mouse-and-keyboard controls for the final release.

Finally, with all of us both working and attending school, maintaining a regular meeting schedule grew more difficult as the project went on. In my opinion the source of the problem was that we had no one person responsible for setting meetings. In future projects I think it would be wise to assign the organization of meetings to a single individual, or to have it cycle each week on a predictable pattern.

Related Courses and Other Influences

Perhaps the earliest influence, CGDD 2002, Fundamentals of Game Design, provided us with a familiar vocabulary with which to discuss the design for the game. In particular, the concept of the MDA (Mechanics, Dynamics, Aesthetics), and the different methods for documenting a software design provided us with common ground to work on. A design document on Google Drive, maintained and adjusted through the life of the project, gave us a place to discuss and record design decisions for current as well as future work.

Our choice of Unity as a development engine allowed me to draw heavily on skills learned during my CGDD 3103, Application Extension and Scripting, course. The experience working in Unity, and the understanding of Component-Based Design were particularly helpful in organizing the project in a way that wouldn’t become overwhelming as the project grew.

As with most of my games projects, the skilled acquired in CGDD 4003, Digital Media and Interaction, were applied in abundance. Flocking-style behavior for the AI, and vector math for bullet trajectories and character movement, just to name a few. Additionally, while Unity already has a built-in particle system, an understanding of how they work allowed me to employ the existing system more effectively.

When I took CS 3424, Data Structures, Dr. Karam provided excellent instruction over and above the usual Data Structures material, including coverage of algorithm design and analysis. The application of which was used in the design of the Hive-Mind AI and map builder quite heavily. He also provided excellent exercises in debugging, and while the tools differ between C++ on Linux and Unity on Windows, the principles I picked up in that course allowed me to solve problems we encountered far faster than I could have hoped to otherwise.

In addition to skills acquired in college courses, the source control and team-management skills I’ve picked up while working with the Colonial Pipeline Company proved invaluable. The Git workflow we eventually settled on, as well as the conduction of the meetings and presentations were all heavily influences by my experiences at work. In particular, the heavy documentation before we started work was inspired in part by the documentation I had done for projects at work.

Finally, while no one member of the team was decidedly more experienced in all areas than the others, I was able to mentor in my areas of strength largely because of my experience working in the CSE lab as a tutor. My time working in the lab taught me a great deal about how different individuals learn, as well as how to identify and utilize these various teaching methods.





Code Snippet: Map Generation

28 04 2015

Overview

My capstone project, a dual-stick shooter called “Endless Swarm,” employed procedurally generated levels. To improve the appearance of these levels I developed a system which would select appropriate tiles to piece together a 3D level from a 2D character array. The system is contained in a single MonoBehavior script which accepts as inputs the aforementioned character array and a positional offset at which to create the map.

The generator works by looping through each character in the array and counting the number of each type of tile found adjacent to the current one. E.G. A tile might have one wall tile next to it, and three floor tiles. Tiles diagonal to the origin are not considered. This, combined with the type of tile the algorithm is currently considering, is enough to determine which object or objects need to be placed in 3D space.

The code is organized into two core methods and a number of helper methods. The first, Build() is the publicly exposed method, and is responsible for loading the array, and making the call to ConvertMap(), a method which converts the character array to an array of a TileType enumeration. Then it loops through all the tiles; if a floor is found it calculates the location and creates the floor. Otherwise, if it sees a wall, it calls into the second key method, BuildWallPiece().

public void Build(char[,] input, float xOffset = 0.0f, float yOffset = 0.0f) {
    TileType[,] map = ConvertMap(input);
    int mapWidth = map.GetLength(0);
    int mapHeight = map.GetLength(1);
    for(int x=0; x<mapWidth; ++x) {
        for(int y=0; y<mapHeight; ++y) {
            TileType type = GetTileType(map, x, y);
            if(type == TileType.Floor) {
                Vector3 position = new Vector3((-x - xOffset) * TileSize, 0, (y + yOffset) * TileSize);
                CreateFloor(position);
                floorPositions.Add (position);  //add floor tile positions to a series of lists...
                spawnPositions.Add (position);
                enemyPositions.Add (position);
            } else if (type == TileType.Wall) {
                BuildWallPiece(map, x, y, xOffset, yOffset);
            }
        }
    }
}
private TileType[,] ConvertMap(char[,] input) {
    TileType[,] map = new MapBuilder.TileType[input.GetLength(1), input.GetLength(0)];
    for(int x=0; x<input.GetLength(1); ++x) {
        for(int y=0; y<input.GetLength(0); ++y) {
            var type = MapBuilder.TileType.Void;
            if(input[y,x]=='F') type = MapBuilder.TileType.Floor;
            if(input[y,x]=='W') type = MapBuilder.TileType.Wall;
            int xPos = input.GetLength(1) - (1 + x);
            map[xPos, y] = type;
        }
    }
    return map;
}

BuildWallPiece() takes a similar set of arguments, as well as the coordinates of the tile it is to consider. The method is responsible for counting the adjacent peices and determining which objects to place. Helper methods are used to place the objects into the world.

public void BuildWallPiece(TileType[,] map, int x, int y, float xOffset, float yOffset) {
    Vector3 position = new Vector3((-x - xOffset) * TileSize, 0, (y + yOffset) * TileSize);
    
    TileType north, south, east, west;
    north = GetTileType(map, x, y - 1);
    south = GetTileType(map, x, y + 1);
    east = GetTileType(map, x + 1, y);
    west = GetTileType(map, x - 1, y);
    
    int numWalls = 0, numFloors = 0;
    if(north == TileType.Wall) ++numWalls;
    else if(north == TileType.Floor) ++numFloors;
    if(south == TileType.Wall) ++numWalls;
    else if(south == TileType.Floor) ++numFloors;
    if(east == TileType.Wall) ++numWalls;
    else if(east == TileType.Floor) ++numFloors;
    if(west == TileType.Wall) ++numWalls;
    else if(west == TileType.Floor) ++numFloors;
    
    if(numFloors == 1) {
        float angle = 0;
        if(north == TileType.Floor) angle = 90;
        else if(east == TileType.Floor) angle = 180;
        else if(south == TileType.Floor) angle = 270;
        
        CreateWall1(angle, position);
    } else if(numFloors == 2) {
        if(north == TileType.Floor && south == TileType.Floor) {
            CreateWall1(90, position);
            CreateWall1(270, position);
        } else if (east == TileType.Floor && west  == TileType.Floor) {
            CreateWall1(0, position); 
            CreateWall1(180, position);
        } else if(north == TileType.Floor && west == TileType.Floor) {
            CreateWall2(0, position);
        } else if(north == TileType.Floor && east == TileType.Floor) {
            CreateWall2(90, position);
        } else if(south == TileType.Floor && east == TileType.Floor) {
            CreateWall2(180, position);
        } else if(south == TileType.Floor && west == TileType.Floor) {
            CreateWall2(270, position);
        }
    } else if(numFloors == 3) {
        if(north != TileType.Floor) {
            CreateWall3(0, position);
        } else if(east != TileType.Floor) {
            CreateWall3(90, position);
        } else if(south != TileType.Floor) {
            CreateWall3(180, position);
        } else if(west != TileType.Floor) {
            CreateWall3(270, position);
        }
    } else if(numFloors == 4) {
        CreateWall4(position);
    }
    
    if(numWalls > 1) {
        if(south == TileType.Wall && west == TileType.Wall) {
            CreateWall5(0, position);
        }
        if(north == TileType.Wall && west == TileType.Wall) {
            CreateWall5(90, position);
        }
        if(north == TileType.Wall && east == TileType.Wall) {
            CreateWall5(180, position);
        }
        if(south == TileType.Wall && east == TileType.Wall) {
            CreateWall5(270, position);
        }
    }
}

Complete Source

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

public class MapBuilder : MonoBehaviour {
    public Transform Floor, Wall1, Wall2, Wall3, Wall4, Wall5;
    public float TileSize = 5.0f;
    public List<Vector3> floorPositions = new List<Vector3> ();
    public List<Vector3> spawnPositions = new List<Vector3> ();
    public List<Vector3> enemyPositions = new List<Vector3> ();
    
    public enum TileType { Floor, Wall, Void };
    
    public void Build(char[,] input, float xOffset = 0.0f, float yOffset = 0.0f) {
        TileType[,] map = ConvertMap(input);
        int mapWidth = map.GetLength(0);
        int mapHeight = map.GetLength(1);
        for(int x=0; x<mapWidth; ++x) {
            for(int y=0; y<mapHeight; ++y) {
                TileType type = GetTileType(map, x, y);
                if(type == TileType.Floor) {
                    Vector3 position = new Vector3((-x - xOffset) * TileSize, 0, (y + yOffset) * TileSize);
                    CreateFloor(position);
                    floorPositions.Add (position);  //add floor tile positions to a series of lists...
                    spawnPositions.Add (position);
                    enemyPositions.Add (position);
                } else if (type == TileType.Wall) {
                    BuildWallPiece(map, x, y, xOffset, yOffset);
                }
            }
        }
    }
    
    public void BuildWallPiece(TileType[,] map, int x, int y, float xOffset, float yOffset) {
        Vector3 position = new Vector3((-x - xOffset) * TileSize, 0, (y + yOffset) * TileSize);
        
        TileType north, south, east, west;
        north = GetTileType(map, x, y - 1);
        south = GetTileType(map, x, y + 1);
        east = GetTileType(map, x + 1, y);
        west = GetTileType(map, x - 1, y);
        
        int numWalls = 0, numFloors = 0;
        if(north == TileType.Wall) ++numWalls;
        else if(north == TileType.Floor) ++numFloors;
        if(south == TileType.Wall) ++numWalls;
        else if(south == TileType.Floor) ++numFloors;
        if(east == TileType.Wall) ++numWalls;
        else if(east == TileType.Floor) ++numFloors;
        if(west == TileType.Wall) ++numWalls;
        else if(west == TileType.Floor) ++numFloors;
        
        if(numFloors == 1) {
            float angle = 0;
            if(north == TileType.Floor) angle = 90;
            else if(east == TileType.Floor) angle = 180;
            else if(south == TileType.Floor) angle = 270;
            
            CreateWall1(angle, position);
        } else if(numFloors == 2) {
            if(north == TileType.Floor && south == TileType.Floor) {
                CreateWall1(90, position);
                CreateWall1(270, position);
            } else if (east == TileType.Floor && west  == TileType.Floor) {
                CreateWall1(0, position); 
                CreateWall1(180, position);
            } else if(north == TileType.Floor && west == TileType.Floor) {
                CreateWall2(0, position);
            } else if(north == TileType.Floor && east == TileType.Floor) {
                CreateWall2(90, position);
            } else if(south == TileType.Floor && east == TileType.Floor) {
                CreateWall2(180, position);
            } else if(south == TileType.Floor && west == TileType.Floor) {
                CreateWall2(270, position);
            }
        } else if(numFloors == 3) {
            if(north != TileType.Floor) {
                CreateWall3(0, position);
            } else if(east != TileType.Floor) {
                CreateWall3(90, position);
            } else if(south != TileType.Floor) {
                CreateWall3(180, position);
            } else if(west != TileType.Floor) {
                CreateWall3(270, position);
            }
        } else if(numFloors == 4) {
            CreateWall4(position);
        }
        
        if(numWalls > 1) {
            if(south == TileType.Wall && west == TileType.Wall) {
                CreateWall5(0, position);
            }
            if(north == TileType.Wall && west == TileType.Wall) {
                CreateWall5(90, position);
            }
            if(north == TileType.Wall && east == TileType.Wall) {
                CreateWall5(180, position);
            }
            if(south == TileType.Wall && east == TileType.Wall) {
                CreateWall5(270, position);
            }
        }
    }
    
    public TileType GetTileType(TileType[,] map, int x, int y) {
        if(x < 0 || x >= map.GetLength(0) || y < 0 || y >= map.GetLength(1)) {
            return TileType.Void;
        } else {
            return map[x, y];
        }
    }
    
    private TileType[,] ConvertMap(char[,] input) {
        TileType[,] map = new MapBuilder.TileType[input.GetLength(1), input.GetLength(0)];
        for(int x=0; x<input.GetLength(1); ++x) {
            for(int y=0; y<input.GetLength(0); ++y) {
                var type = MapBuilder.TileType.Void;
                if(input[y,x]=='F') type = MapBuilder.TileType.Floor;
                if(input[y,x]=='W') type = MapBuilder.TileType.Wall;
                int xPos = input.GetLength(1) - (1 + x);
                map[xPos, y] = type;
            }
        }
        return map;
    }
    
    private void CreateFloor(Vector3 position) {
        Quaternion rotation = Quaternion.AngleAxis(0, Vector3.up);
        var piece = GameObject.Instantiate(Floor, position, rotation) as Transform;
        piece.parent = transform;
    }
    private void CreateWall1(float angle, Vector3 position) {
        Quaternion rotation = Quaternion.AngleAxis(angle, Vector3.up);
        var piece = GameObject.Instantiate(Wall1, position, rotation) as Transform;
        piece.parent = transform;
    }
    private void CreateWall2(float angle, Vector3 position) {
        Quaternion rotation = Quaternion.AngleAxis(angle, Vector3.up);
        var piece = GameObject.Instantiate(Wall2, position, rotation) as Transform;
        piece.parent = transform;
    }
    private void CreateWall3(float angle, Vector3 position) {
        Quaternion rotation = Quaternion.AngleAxis(angle, Vector3.up);
        var piece = GameObject.Instantiate(Wall3, position, rotation) as Transform;
        piece.parent = transform;
    }
    private void CreateWall4(Vector3 position) {
        Quaternion rotation = Quaternion.AngleAxis(0, Vector3.up);
        var piece = GameObject.Instantiate(Wall4, position, rotation) as Transform;
        piece.parent = transform;
    }
    private void CreateWall5(float angle, Vector3 position) {
        Quaternion rotation = Quaternion.AngleAxis(angle, Vector3.up);
        var piece = GameObject.Instantiate(Wall5, position, rotation) as Transform;
        piece.parent = transform;
    }
}




Code Snippet: Hive-Mind

28 04 2015

Overview

While working with my team on Endless Swarm, a dual-stick shooter built as a capstone project, we determined that we would need a way to manage both the rate at which enemies spawned, and keep those enemies balanced by type. E.G. The number of low-level enemies should be greater than the number of high-level, boss-like enemies.

To this end I designed an AI system called the Hive-Mind intended to manage our enemy entities on three fronts: balancing and purchasing, spawning, and commanding. Unfortunately, due to time restraints, only the balancing and purchasing, and spawning systems were ever completed.

Purchasing/Balancing

This section of the AI is responsible for selecting which types of alien units will eventually enter play, and how many of each should be added. It uses a point-buy system similar to what is found in most Table-Top Wargames. The AI gains points at a rate determined by the current difficulty level and the number and types of currently existing enemies. Each type of enemy is assigned a point value, and the AI expends points to “purchase” units. Purchased units are added to a pool to be spawned at a later time. Which unit is purchased is dependent on the numbers and types of currently existing enemies.

The points gain system is simple enough, a PointsPerSecond value is kept in the Hive-Mind component, and adjusted by difficulty. Before points are added each frame they are multiplied by the time which has passed and a multiplier based on the number of existing enemies:

private float PointsPerSecond = 5.0f;
private float pointsMultiplier {
    get {
        return Mathf.Max(0, 40 - currentRatio.Total) / 20.0f;
    }
}
void Update() {
    points += PointsPerSecond * pointsMultiplier * Time.deltaTime;
    // If we have enough points, purchase the next enemy to spawn.
}

In contrast to the simple point-buy system, the balancing is somewhat more complex. The system is built on a “ratio” class I developed for this purpose. The class stores an arbitrary number of values which are converted to proportions by dividing each value by the sum of the values. E.G. Given a ratio of [3:1] the proportions would be [0.75:0.25].

public Ratio(params float[] values) {
    this.Values = values;
    this.total = values.Sum();
    this.proportions = new float[values.Length];
    CalculateProportions();
}
private void CalculateProportions() {
    if(total > 0) {
        for(int i=0; i<Values.Length; ++i) {
            proportions[i] = Values[i] / total;
        }
    }
}

Two ratios are stored by the Hive-Mind AI system. One represents the desired ratio for all spawned enemies, the other contains the actual counts of each enemy type as its values. The meat of the ratio class is in the GreatestDeficiencyIndex() method, which compares two ratios and determines which value to increase (I.E. Which enemy to spawn) to bring the two ratios as close to each other as possible.

public int GetGreatestDeficiencyIndex(Ratio target) {
    if(target.proportions.Length != proportions.Length) return -1;
    float maxDeficiency = 0.0f;
    int maxDeficiencyIndex = -1;
    for(int i=0; i < proportions.Length; ++i) {
        float deficiency = target.proportions[i] - proportions[i];
        if(deficiency > maxDeficiency) {
            maxDeficiency = deficiency;
            maxDeficiencyIndex = i;
        }
    }
    return maxDeficiencyIndex;
}

If the proportion values of both ratios are identical the method returns a -1; otherwise it returns the index of the proportional values which differ by the greatest degree. In the case of a -1, the Hive-Mind defaults to spawning the weakest enemy, which unbalances the ratios again.

Finally, once enough enemies have been added to the spawn pool (determined by a points cap on the pool) they are all instantiated into the world at a randomly chosen spawn point.

public void SpawnGroup() {
    int numSpawned = 0;
    var spawnPoints = GameObject.FindGameObjectsWithTag("EnemySpawnPoint");
    var spawnPoint = spawnPoints[Random.Range (0, spawnPoints.Length-1)];
    for(int i=0; i<spawnGroup.Values.Length; ++i) {
        for(int j=0; j<spawnGroup.Values[i]; ++j) {
            var offset = numSpawned == 0 ? 0f : spawnOffset * (((numSpawned-1) / 8) + 1);
            var rotation = Quaternion.AngleAxis((float)numSpawned * 45f, Vector3.up);
            var spawnPos = spawnPoint.transform.position + (rotation * (Vector3.right * offset));
            spawnEnemy(spawnPos, (EnemyType)j);
            ++numSpawned;
        }
    }
    spawnGroup = new Ratio(TargetRatio.Values.Length);
}

Complete Source

HiveMindController.cs

using UnityEngine;
using System.Linq;
using System.Collections;

public class HiveMindController : MonoBehaviour {
    public EnemySpawnScript enemySpawnScript;
    public PlayerController playerScript;

    public Ratio TargetRatio = new Ratio(20, 7, 5, 2);
    public float PointsPerSecond = 5.0f;
    public int MinPointsToSpawn = 50; 

    private Ratio currentRatio, spawnGroup;
    private float points, nextCost;
    private EnemyType nextSpawn;
    private readonly float spawnOffset = 3f;
    
    private float pointsMultiplier {
        get {
            return Mathf.Max(0, 40 - currentRatio.Total) / 20f;
        }
    }
    
    void Start() {
        currentRatio = new Ratio(TargetRatio.Values.Length);
        spawnGroup = new Ratio(TargetRatio.Values.Length);
        nextSpawn = EnemyType.Swarm;
        points = 0;
    }
    
    void Update() {
        points += PointsPerSecond * pointsMultiplier * Time.deltaTime;
        if(points >= nextCost) {
            points -= nextCost;
            addCreature(nextSpawn);
            if(getTotalCost(spawnGroup) > MinPointsToSpawn) {
                SpawnGroup();
            }
        }
    }
    
    private void determineNextSpawn() {
        int nextSpawnId = currentRatio.GetGreatestDeficiencyIndex(TargetRatio);
        if(nextSpawnId == -1) nextSpawnId = 0;
        nextSpawn = EnemyTypeHelper.GetById(nextSpawnId);
        nextCost = nextSpawn.GetPointCost();
    }
    
    private void addCreature(EnemyType type) {
        currentRatio.Values[type.GetIndex()] += 1;
        spawnGroup.Values[type.GetIndex()] += 1;     
        determineNextSpawn();
    }
    
    /*public void RemoveCreature(EnemyType type) {
        int newValue = (int)Mathf.Max(0, currentRatio.Values[type.GetIndex()] - 1);
        currentRatio.Values[type.GetIndex()] = newValue;
        determineNextSpawn();
    }*/
    
    public void SpawnGroup() {
        int numSpawned = 0;
        var spawnPoints = GameObject.FindGameObjectsWithTag("EnemySpawnPoint");
        var spawnPoint = spawnPoints[Random.Range (0, spawnPoints.Length-1)];
        for(int i=0; i<spawnGroup.Values.Length; ++i) {
            for(int j=0; j<spawnGroup.Values[i]; ++j) {
                var offset = numSpawned == 0 ? 0f : spawnOffset * (((numSpawned-1) / 8) + 1);
                var rotation = Quaternion.AngleAxis((float)numSpawned * 45f, Vector3.up);
                var spawnPos = spawnPoint.transform.position + (rotation * (Vector3.right * offset));
                spawnEnemy(spawnPos, (EnemyType)j);
                ++numSpawned;
            }
        }
        spawnGroup = new Ratio(TargetRatio.Values.Length);
    }
    
    private void spawnEnemy(Vector3 position, EnemyType type) {
        switch (type.GetIndex ()) {
        case 0:
            enemySpawnScript.CreateEnemy1(position);
            break;
        case 1:
            enemySpawnScript.CreateEnemy2(position);
            break;
        case 2:
            enemySpawnScript.CreateEnemy3(position);
            break;
        case 3:
            enemySpawnScript.CreateEnemy4(position);
            break;
        }
    }
    
    private int getTotalCost(Ratio ratio) {
        int total = 0;
        for(int i=0; i<spawnGroup.Values.Length; ++i) {
            EnemyType type = (EnemyType)i;
            total += (int)(type.GetPointCost() * spawnGroup.Values[i]);
        }
        return total;
    }
}

Ratio.cs

using System;
using System.Linq;

[Serializable]
public class Ratio {
    public float[] Values;
    public float[] Proportions { get { return proportions; } }
    public float Total { get { return total; } }
    
    private float[] proportions;
    private float total;    
    
    public Ratio(int numValues) {
        this.Values = new float[numValues];
        for(int i=0; i<numValues; ++i) {
            this.Values[i] = 0.0f;
        }
        this.total = 0.0f;
        this.proportions = new float[Values.Length];
        CalculateProportions();
    }
    
    public Ratio(params float[] values) {
        this.Values = values;
        this.total = values.Sum();
        this.proportions = new float[values.Length];
        CalculateProportions();
    }
    
    public int GetGreatestDeficiencyIndex(Ratio target) {
        CalculateProportions ();
        target.CalculateProportions ();
        if(target.proportions.Length != proportions.Length) return -1;
        float maxDeficiency = 0.0f;
        int maxDeficiencyIndex = -1;
        for(int i=0; i<proportions.Length; ++i) {
            float deficiency = target.proportions[i] - proportions[i];
            if(deficiency > maxDeficiency) {
                maxDeficiency = deficiency;
                maxDeficiencyIndex = i;
            }
        }
        return maxDeficiencyIndex;
    }
    
    public void CalculateProportions() {
        total = Values.Sum();
        if(total > 0) {
            for(int i=0; i<Values.Length; ++i) {
                proportions[i] = Values[i] / total;
            }
        }
    }
}




Git and Unity

14 04 2015

Over the course of the last semester my capstone team has explored the use of Git revision control with Unity3D, both in theory and in practice. In this post I will outline the solutions we’ve come up with. The first part of this post examines the steps required to utilize Git reversion control with Unity3D. The second part outlines a work-flow for small teams based on feature-branching. At the end I will outline several potential pitfalls.

Setting Up Revision Control

Unity

Unity hides a great deal of meta-data for the asset files, so before we can set up the repository we first need to adjust some settings in Unity. For the following steps I assume you have already opened the project you wish to place under source control in Unity.

First we need to set the repository mode. If you are using Unity version 4.5 or later you can skip this step. Navigate to Unity → Preferences → Packages and setRepository to External.

Next we need to set the version control mode. This allows Git to see the meta files, so that it can track them. Navigate to Edit → Project Settings → Editor and setVersion Control Mode to Visible Meta Files.

Next we’ll set the asset serialization mode. Git does not handle binary files nearly as well as it does text (more on that later), so we’ll force it to use plain-text to store all assets. Navigate to Edit → Project Settings → Editor and set Asset Serialization Mode to Force Text.

Finally, save the scene and project to store the changes.

Git

Now that we have Unity’s files prepared, we can set up the repository. There are two decisions to make at this stage: which client to use (if any) and which host to use (if any). We’ll start local, with the client, and shift to the host once we have the local repository initialized.

The Client

The first thing to do is decide on a client. I’ll leave this choice to the reader but I use SourceTree. Other popular options include the GitHub app and the git command line. Other tutorials exist for each of the aforementioned applications, so I won’t be going into detail here. Ensure you have a client installed before moving forwards.

Once we have a client ready we need to create the repository. The exact steps to do so vary based on the client, but all should execute the command git init. Do this in the root folder for your Unity project, the one with the Assets subfolder.

Before we commit any changes to the repository we’ll want to adjust the .gitignore file. The format of that file is beyond the scope of this document, but details can be found in the Git documentation. In particular, we want to prevent git from tracking the Library and Temp folders. We also want to ignore any .sln, .csproj,.unityproj, .orig, and .userprefs files. All of these are re-generated when Unity builds the project, and will show as updating for every commit if included.

Once the ignore file is updated we’re ready to make the first commit to the local repository. Do this using your client of choice, or the command line git commit.

The Server

If you’re working alone, and only from one machine you can skip this step, although I would advise maintaining a server repository as a backup regardless.

First you’ll need to decide where to host your repository. I use BitBucket, but GitHub and many other locations also offer free repository hosting. Alternatively, if you own a server of your own you can install Git there. Wherever you host, create an empty repository.

Make note of the repository URL, this is usually provided in a copy-paste box, and often ends with .git. Return to your client of choice, open your local repository (if you haven’t already), and add a new remote. (The command for this should be something like git remote add origin yoururl) While not mandatory, the primary remote is conventionally named origin.

First Push and Additional Contributers

Once you’ve set up your remote you’re ready to push the commit you made earlier. You’ll want to push to the remote repository you set up earlier, and to a new branch. (The command for this should be something like git push origin master) Again, while not mandatory, conventionally this main branch is named master.

Once you’ve made the push ensure the changes have appeared on the host you chose previously. If so, you’re ready to bring in any additional contributers for the project.

Each additional contributer should start by acquiring a client of their choosing. Ensure each additional contributer has a client installed before continuing, and give them all the URL for the remote repository.

Instead of creating a local repository they will be cloning the remote you set up earlier. How this is done will vary from client to client, but all should execute the command git clone yoururl. The code from the remote should be downloaded and a local repository created. At this point git and Unity are set up, and you can begin work on your project with whatever work-flow you may choose.

A Unity-Git Work-Flow

Overview

While having local and remote copies is, on its own, quite useful, perhaps the greatest value in using Git (or alternative source control systems) comes from the support for parallel work by multiple team members. For this to work smoothly, however, the team should decide on a work-flow in advance of beginning work on the project.

For the project which inspired this post I worked on a game in Unity with a team of two (2) other developers. Each of us had separate weekly milestones, with presentations every Monday with the combined changes. As such, we made heavy use of Git’s branching and merging features to allow each of us to make changes without interfering with the work of the others, and to merge as a group.

Details

For the work-flow we settled on the master branch served as a release branch, with each commit representing a release-version of the software. While we didn’t do so, I would suggest using tags to label each commit with a release number.

Feature Branching Image

Because of the decision to use the master branch for releases no changes are permitted to be made directly to it. Instead every week we would each create a branch off of master named for the features we intended to add, or with a user-name and date. What you use is relatively unimportant so long as they can be kept distinct and consistent.

At the end of each week would sat down and worked together to merge all the changes back into master one at a time. As a result, we were all given the opportunity to examine each other’s code, offer suggestions for later improvements, and ensure everything fit together properly.

Caveats and Common Problems

As mentioned at the beginning of this post, Git doesn’t work as well with binary files as it does with text. Since it cannot identify changes, it will instead store a new copy of the file every time it changes. For large assets this can quickly eat though the space allotted for many free Git hosts. As such, it may be prudent to maintain a DropBox or other file-sharing solution in addition to the Git repository, and store any binary assets undergoing regular modifications there.

Additionally, while converting the meta-files to text helps Git keep track, they, and most asset files, are hard to read and modify by hand. As such, merging them can be an absolute nightmare. Our solution was to each maintain a separate scene for working in, then combine the changes by hand at the end of each week. The merged scene could then be copied into each individual scene to keep everything up to date.

Alternatively, there are several tools in the Unity store which propose to simplify the process with a graphical interface for merging such files. I have not had the opportunity to try them, but it may be worth the investment if a project is likely to be more asset heavy than code-heavy.

Finally, for teams new to Git ensure before each merge that everyone is certain of which branch is being merged into which; the interface for merging can be confusing, and it is easy to accidentally merge the master into a feature branch instead of vice-versa, and harder to fix it.