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;
            }
        }
    }
}
Advertisements

Actions

Information

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s




%d bloggers like this: