7 min read

Let's Make Em Fight

In which I make a (mostly) functional battle system
This is part 5 of a series, and expands on systems created in Part 2

Pokemon's battle system is actually rather unique for a turn-based game. Most games have a distinct turn for each side of combat, but with Pokemon, there's a "prep" phase and then a "combat" one.

Essentially, both sides pick their moves, the game determines which one goes first, then each one performs their moves. This has the advantage of incorperating "priority", where some moves can always go first/last, and some can have specific conditions relating to if they were indeed first or last.

For a traditional turn-based system, you're able to react to the enemy's turn (either previous or oncoming) without much issue. In a game like Pokemon, each turn is a risk. It makes speed singificantly more important, which gives fast Pokemon an edge over the slow ones.

In Legend's Arceus, there's an interesting hybrid approach with Turn Order.

Samurott is able to attack twice due to being faster than Spiritomb

This turns speed and priority into a concept where it determines that Pokemon can attack an additional time (I saw 3 times once as well)

Personally, I prefer this approach, so we're gonna do something like that.

The State Machine

State Machines serve as branches for code, allowing you to decide logic based on what is expected at that moment.

Our approach is going to be rather simple. We'll have an enum for the states (so it can be extended) and a dictionary that stores the functions that the states are tied to. If you remember Fen, we're going to basically do something similar to the way we set up functions. We'll also have a context class for states to share between.

    public enum State { Start, Summon, SummonAnim };
    public State state;

    public struct Context
    {
        public Battler user;
    }

    public Context ctx = new();

    public Dictionary<State, Action> states = new()
    {
        {State.Start,  () => { } },
        {State.Summon,  () => Summon() },
        {State.SummonAnim, () => SummonAnim() }
    };

    static Battler user => instance.ctx.user;

To call up the state we want to do, we'll add:

    private void Update()
    {
        states[state]();
    }

This invokes the action related to the state every frame

So what we're going to do now is a little "refactoring", basically cleaning up the code to make it more compatible with the way our new system works. So we're going to change StartBattle to this:

public Queue<Battler> queue = new();

public void StartBattle()
{
    queue.Enqueue(enemy);
    queue.Enqueue(player);
    state = State.Summon;
}

Explanation

Queues are weird. You can only enqueue one item at a time, and when you retrieve an item via "Dequeue" you remove it from the queue. It's exactly the same as removing an item from the top of a list, just this strange little dedicated method.

Now for the states. First, Summon.

Summon

//Battler class
    public MonsterCard card;
    public enum Side { Player, Opponent };
    public Side side;
    public int index;
//BattleManager class

 static IndividualMonster GetMonster(Battler.Side side, int index) => (side == Battler.Side.Player ? GameManager.party : instance.encounterParty)[index];

    static void Summon()
    {
        if (instance.queue.Count > 0)
        {
            instance.ctx.user = instance.queue.Dequeue();
            user.card.Set(GetMonster(user.side, user.index));
            user.gameObject.SetActive(true);
            user.transform.localScale = Vector3.zero;
            instance.state = State.SummonAnim;
        }
    }

Explanation

  • We add the queued battler to the context
  • GetMonster grabs the monster based on the set index and the "side". Side is defined via the inspector.
  • The Battler gameobject is enabled, and its scale is set to zero
  • The state is set to "SummonAnim"

Then SummonAnim.

SummonAnim

//Battler class
    public Vector3 scale;
public float scaleDistance => Vector3.Distance(scale, transform.localScale);

    //Battlemanager class
    static void SummonAnim()
    {
        if (user.scaleDistance > .01f)
            user.transform.localScale = Vector3.Lerp(user.transform.localScale, user.scale, Time.deltaTime * 7);
        else
        {
            user.transform.localScale = user.scale;
            instance.state = State.Summon;
        }
    }

Explanation

  • Lerps usually don't reach the goal, so we go for a Heuristic approach here.
  • user.scale is set in the inspector and different for each battler
  • if the goal is reached, we set the scale to the user.scale, then set the state back to State.Summon

This gives us...

Not bad, right? Now that that's done, we need to check the turn order.

Turn Order

//Battler class

    public int priority;

//BattleManager class


public Image[] queueIcons;
    public Color playerColor, enemyColor;
    
 static void GetTurnOrder()
 {
     var q = instance.queue.ToArray();

     for(int  i = 0; i < q.Length; i++)
     {
         instance.queueIcons[i].color = q[i].side switch
         {
             Battler.Side.Player => instance.playerColor,
             _ => instance.enemyColor,
         };
         instance.queueIcons[i].transform.GetChild(0).GetComponent<Image>().sprite = q[i].monster.data.face;
     }

     if (instance.queue.Count == 6) { instance.state = State.StartTurn; return; }

     var averageSpeed = (instance.player.monster[StatType.Speed] + instance.enemy.monster[StatType.Speed]) / 2;
     instance.player.priority += instance.player.monster[StatType.Speed];
     instance.enemy.priority += instance.enemy.monster[StatType.Speed];

     var next = instance.player.priority > instance.enemy.priority ? instance.player : instance.enemy;
    
     instance.queue.Enqueue(next);
     next.priority -= averageSpeed;
     Debug.Log($"Next: {next}");
 }

Explanation

  • We color the icons and give them face of the next monster (this repeats every time the queue is changed)
  • Next, we check if there's 6 turns queued up. We reuse the queue we used for summoning
  • We grab the average speed between both monsters
  • The battler's priority is then added by the monster's speed stat
  • we compare both the player and the monster's speed, then queue up the higher of the two "next"
  • Finally, the priority is subtracted by the average speed.
The Queue in all its glory

Turns

With the queue implemented, it's time to actually do the turns.

Enemy Turn

At the start of the enemy's turn, we need to have the monster decide what it's going to do. There's a lot I could do here, but right now I'm just going to grab the first skill in their movelist, just to make sure everything is functional.

//Context struct
public Battler target;
public Skill skill;

//Battler class
    public BattleManager.Context GetBestMove(BattleManager.Context ctx)
    {
        ctx.target = BattleManager.instance.player;
        ctx.skill = monster.skills[0];
        return ctx;
    }

//BattleManager class
static void EnemyTurn()
{
    instance.ctx = user.GetBestMove(instance.ctx);
    instance.state = State.MoveTowards;
}

One it decides the move, we'll have the enemy move towards the player.

//Battler class
    public float moveDistance = 2;

//BattleManager class
    
    static void MoveTowards()
    {
        var distance = Vector3.Distance(user.transform.position, target.transform.position);

        if (distance > user.moveDistance)
        {
            user.transform.position = Vector3.Lerp(user.transform.position, target.transform.position, Time.deltaTime * (7 / user.moveDistance));
        }
        else
        {
            instance.state = State.PerformMove;
        }
    }

Then we do the damage calculation. We're just gonna do some basic stuff for now.

//Skill class
    public int Offense(Stat[] stats) => (range, type) switch
    {
        (Range.Melee, PowerType.Physical) => stats[(int)StatType.Attack].value + stats[(int)StatType.Strength].value,
        (Range.Ranged, PowerType.Physical) => stats[(int)StatType.Attack].value + stats[(int)StatType.Dexterity].value,
        (Range.Melee, PowerType.Magical) => stats[(int)StatType.Magic].value + stats[(int)StatType.Strength].value,
        (Range.Ranged, PowerType.Magical) => stats[(int)StatType.Magic].value + stats[(int)StatType.Dexterity].value,
        _ => 0,
    };

    public int Defense(Stat[] stats) => (range, type) switch
    {
        (Range.Melee, PowerType.Physical) => stats[(int)StatType.Defense].value + stats[(int)StatType.Luck].value,
        (Range.Ranged, PowerType.Physical) => stats[(int)StatType.Defense].value + stats[(int)StatType.Speed].value,
        (Range.Melee, PowerType.Magical) => stats[(int)StatType.Resistance].value + stats[(int)StatType.Luck].value,
        (Range.Ranged, PowerType.Magical) => stats[(int)StatType.Resistance].value + stats[(int)StatType.Speed].value,
        _ => 0,
    };

//BattleManager class
    public int Eval(BattleManager.Context ctx)
    {
        return (int)((Offense(ctx.user.monster.stats) - (Defense(ctx.target.monster.stats) / 2)) * (power / 100f));
    }
    

Then we apply it.

//MonsterCard class
    public void Update()
    {
        hpSlider.value = Mathf.Lerp(hpSlider.value, battler.monster.hp, Time.deltaTime * 3);
        mpSlider.value = Mathf.Lerp(mpSlider.value, battler.monster.sp, Time.deltaTime * 3);
        hpValue.text = ((int)hpSlider.value).ToString();
        mpValue.text = ((int)mpSlider.value).ToString();
    }

//Battler class
public void TakeDamage(int damage)
    {
        monster.hp -= damage;
        card.hp = monster.hp;
    }

//BattleManager class
    static void PerformMove()
    {
        target.TakeDamage(skill.Eval(instance.ctx));
        user.monster.sp -= skill.cost;
        instance.state = State.Return;
    }

And finally, we return to our original position.

//Battler class
    public Vector3 start;
    
    void OnEnable()
    {
        start = transform.position;
    }
    
//BattleManager class
    static void Return()
    {
        var distance = Vector3.Distance(user.transform.position, user.start);

        if (distance > .01f)
        {
            user.transform.position = Vector3.Lerp(user.transform.position, user.start, Time.deltaTime * 7);
        }
        else
        {
            instance.state = State.TurnOrder;
        }
    }

Put it all together, and we get...

You don't want to know how annoying it was to get this gif

Player Turn

The good news is that we handled most of the logic to get the enemy's turn working, so all the player turn needs to be right now is hooking the skill cards to a function that adds our chosen skill to the context. And because literally everything has been set up, that's just:

//Skill Card
    public void SetSkill()
    {
        BattleManager.instance.SetPlayerSkill(id);
    }

//BattleManager
    public void SetPlayerSkill(int id)
    {
        ctx.target = enemy;
        ctx.skill = player.monster.skills[id];
        state = State.MoveTowards;
        skillPage.SetActive(false);
    }
We put SetSkill in our OnClick() event for the ui

Which leads us to this:

Image from Gyazo

And there we go. The groundwork for our battle system is done. Next time, we're going to tackle Attack, Guard, and the Party options.