I'd be tempted to handle it like a raffle. Every time an attack slot comes around, everyone gets a number of raffle tickets according to their attack frequency. Then you do a random draw from those tickets, and deduct tickets from the winner. Everyone else keeps their tickets for the next draw, so someone who hasn't been picked in a while will gradually become more likely to get their turn.
Enemy SelectAttacker(List<Enemy> enemies) {
float totalWeight = 0f;
float weightAdded = 0f;
float greatest = float.negativeInfinity;
// Hand out raffle tickets to everyone in line to attack.
foreach(var enemy in enemies) {
// GetAttackRate can change value depending on the character's state,
// like returning 0 if stunned / out of range, or a boosted value if enraged.
float weight = enemy.GetAttackRate();
weightAdded += weight;
enemy.accumulatedAttackWeight += weight;
greatest = Max(greatest, enemy.accumulatedAttackWeight);
totalWeight += Max(enemy.accumulatedAttackWeight, 0f);
}
// Contingency: if the enemies who were holding all the raffle tickets died,
// and everyone remaining is in attack debt, fast-forward a few rounds' worth.
if(greatest <= 0f) {
float advance = (Floor(-greatest / weightAdded) + 1) * weightAdded;
totalWeight = 0f;
foreach(var enemy in enemies) {
enemy.accumulatedAttackWeight += advance;
totalWeight += Max(enemy.accumulatedAttackWeight, 0f);
}
}
// Choose a winner.
float random = Random() * totalWeight;
totalWeight = 0f;
foreach(var enemy in enemies) {
totalWeight += Max(enemy.accumulatedAttackWeight, 0f);
if(totalWeight > random) {
// Penalize this enemy proportionate to the crowd still waiting.
// This keeps the total number of tickets in circulation controlled.
enemy.accumulatedAttackWeight -= weightAdded;
return enemy;
}
}
// Fallback, only hit in the event of invalid input
// (like NaNs / negative attack rates)
return enemies[0];
}
A few neat things about this approach:
Enemies can have fractional attack weights (in case this enemy really needs to attack 2.3 times as often as that other one), without blowing up all your weights to multiples of a common divisor.
All of these weights are relative, so two enemies with GetAttackRate
returning 3 will (on average) attack as often as each other, three times as often as an enemy with GetAttackRate
returning 1, and half as often as an enemy with GetAttackRate
returning 6, no matter the composition of the battle group.
This method scales linearly to any number of enemies, and the overhead remains constant no matter how disparate your weights (compared to drawing cards from a deck, where an enemy that attacks 10 times as often as another needs 10x the storage in the deck)
You can easily vary the attack selection priorities dynamically based on enemy states or other factors. We don't need to do much grooming of the selection data as enemies join, change state, or die.
Enemies that have attacked a lot recently tend to go into an "attack debt" with negative tickets held, limiting runs of the same enemy being chosen.