Implementing melee combat

Melee combat is the fun part of a roguelike, being able to kill enemies and pick up their hard earned loot! It’s also not too complicated to implement. We should best start with defining how our combat logic works by create a new folder in our root called “Logic”
and adding a new class “MeleeCombatLogic.cs” inside of it.

This class will be a static class to define how we calculate our damage and how we apply it to our actors.

Lets start with making the class static and implementing a method to CalculateDamage between attacker and defender actors.

internal static class MeleeCombatLogic
{
    internal static int CalculateDamage(ActorStats attacker, ActorStats defender, out bool isCriticalHit)
    {
		isCriticalHit = false;
    }
}

Okay so we need to take in mind a few things. We have 4 major stats to consider:

  • Attack
  • Defense
  • Dodge chance
  • Critical chance

The easiest and relatively feel good mechanic to implement is “proportional damage scaling”. This we can do by scaling the damage proportionally to the attacker’s strength (attack stat) relative to the defender’s resistance (defense stat).
The formula for that would be something like:

The formula is designed to make the damage asymptotically approach the attacker’s Attack value as Defense gets smaller, and approach zero as Defense grows very large. This scaling ensures no extremes in damage, making it smoother and more balanced.

Lets start implementing this:

internal static int CalculateDamage(ActorStats attacker, ActorStats defender, out bool isCriticalHit)
{
    isCriticalHit = false;
	// Base Damage (Proportional Scaling with randomness)
	int baseDamage = (int)Math.Round((float)attacker.Attack * attacker.Attack / (attacker.Attack + defender.Defense));

	return Math.Max(1, baseDamage);
}

This is a very basic setup, quite straight forward. Now its also quite “bland” it would feel better if we add some random variance to this so that we don’t always hit the same numbers when we get some more attack gear. I was thinking a 15% variance would still be balanced. We can do this like so:

internal static int CalculateDamage(ActorStats attacker, ActorStats defender, out bool isCriticalHit)
{
    isCriticalHit = false;
    var random = ScreenContainer.Instance.Random;

	// Base Damage (Proportional Scaling with randomness)
	int baseDamage = (int)Math.Round((float)attacker.Attack * attacker.Attack / (attacker.Attack + defender.Defense));
	baseDamage = random.Next((int)Math.Floor(baseDamage * 0.85), (int)Math.Ceiling(baseDamage * 1.15)); // Add 15% variance

	return Math.Max(1, baseDamage);
}

Now we still have two stats left: Dodge chance and Crit chance.
These are also fairly easy to add, dodge chance is what we check first to know if we should deal damage or not. It’s just a random chance based on our dodge chance %:

internal static int CalculateDamage(ActorStats attacker, ActorStats defender, out bool isCriticalHit)
{
	isCriticalHit = false;
	var random = ScreenContainer.Instance.Random;

	// Dodge chance
	if (random.Next(0, 100) < defender.DodgeChance)
	{
		return 0; // No damage dealt
	}
	
	// Base Damage (Proportional Scaling with randomness)
	int baseDamage = (int)Math.Round((float)attacker.Attack * attacker.Attack / (attacker.Attack + defender.Defense));
	baseDamage = random.Next((int)Math.Floor(baseDamage * 0.85), (int)Math.Ceiling(baseDamage * 1.15)); // Add 15% variance

	return Math.Max(1, baseDamage);
}

And now for critical chance, we just do the same but add some % modifier to our baseDamage:

internal static int CalculateDamage(ActorStats attacker, ActorStats defender, out bool isCriticalHit)
{
    isCriticalHit = false;
	var random = ScreenContainer.Instance.Random;

	// Dodge chance
	if (random.Next(0, 100) < defender.DodgeChance)
	{
		return 0; // No damage dealt
	}

	// Critical Hit Check
	isCriticalHit = random.Next(0, 100) < attacker.CritChance;
	float critMultiplier = isCriticalHit ? 1.5f : 1.0f;

	// Base Damage (Proportional Scaling with randomness)
	int baseDamage = (int)Math.Round((float)attacker.Attack * attacker.Attack / (attacker.Attack + defender.Defense));
	baseDamage = random.Next((int)Math.Floor(baseDamage * 0.85), (int)Math.Ceiling(baseDamage * 1.15)); // Add 15% variance
	baseDamage = (int)Math.Round(baseDamage * critMultiplier); // Apply critical hit multiplier

	return Math.Max(1, baseDamage);
}

We’re gonna do a 50% damage increase when we hit a crit!

That will do fine for our damage calculation, now we need a method to apply it.
In our Actor class lets add a method “ApplyDamage”:

public void ApplyDamage(int health)
{
	Stats.Health -= health;

	if (!IsAlive && ScreenContainer.Instance.World.ActorManager.Contains(this))
	{
		OnDeath();
	}
}

And also a method to handle when the actor dies, in this case we want to remove them from the ActorManager, we’ll make it virtual maybe we wanna override it later for some actors.

protected virtual void OnDeath()
{
	// Remove from manager so its no longer rendered
	ScreenContainer.Instance.World.ActorManager.Remove(this);
}

We still lack the Contains method, lets add it to our ActorManager.cs:

public bool Contains(Actor actor)
{
	return _actors.TryGetValue(actor.Position, out var actorAtPos) && actorAtPos.Equals(actor);
}

Now back to our “MeleeCombatLogic.cs” class lets add a Method to attack:

internal static void Attack(Actor attacker, Actor defender)
{
	var damage = CalculateDamage(attacker.Stats, defender.Stats, out bool isCriticalHit);

	if (damage > 0)
	{
		System.Console.WriteLine($"{attacker.Name} has attacked {defender.Name} for {damage}{(isCriticalHit ? "critical" : "")} damage.");
		defender.ApplyDamage(damage);
	}
	else
	{
		System.Console.WriteLine("The attack was dodged!");
	}
}

We call our CalculateDamage method with the stats of the attacker and the defender.
If we deal damage, we will ApplyDamage to the defender, and we’ll log in the console what happened. (we will replace this later to write to the game window instead.)

Now we need to know when to apply the damage, the easiest way is to send a “Tick” whenever the player does a move. On this tick we can then couple game logic to be executed. So each time the player moves we can easily hookup logic to be executed.

Lets make a new class “GameLogic.cs” inside Logic folder.

internal static class GameLogic
{
    private static Player Player => ScreenContainer.Instance.World.Player;
    private static ActorManager ActorManager => ScreenContainer.Instance.World.ActorManager;

    /// <summary>
    /// A tick is executed once the player attempts to move to the target position.
    /// <br>This doesn't mean the movement was succesful. The real position can be retrieved from the Player's Position property.</br>
    /// </summary>
    /// <param name="intendedPosition">The position the player was trying to move to on this tick.</param>
    internal static void Tick(Point intendedPosition)
    {

    }
}

We are passing also the “intended” position that the player tried to move towards, this doesn’t mean the player succeeded in moving there. Ex: the player could run into an actor or an obstruction but a game tick will still occur, we can use this intended position to see what the player was trying to do.

We also exposed the Player and the ActorManager for ease of access.

Lets hookup this Tick() method in the player’s movement.
Start by making the Move(int x, int y) method virtual inside of Actor.cs:

public virtual bool Move(int x, int y)

Also to be a bit more correct, we should not return true on a move when we are trying to move onto the same position the actor is already on so we can adjust the !IsAlive line like this:

if (!IsAlive || (Position.X == x && Position.Y == y)) return false;

The method should end up looking like this:

public virtual bool Move(int x, int y)
{
    var tilemap = ScreenContainer.Instance.World.Tilemap;
    var actorManager = ScreenContainer.Instance.World.ActorManager;

    if (!IsAlive || (Position.X == x && Position.Y == y)) return false;
    
    // If the position is out of bounds, don't allow movement
    if (!tilemap.InBounds(x, y)) return false;
    
    // If another actor already exists at the location, don't allow movement
    if (actorManager.ExistsAt((x, y))) return false;

    // Don't allow movement for these cases
    var obstruction = tilemap[x, y].Obstruction;
    switch (obstruction)
    {
        case World.ObstructionType.FullyBlocked:
        case World.ObstructionType.MovementBlocked:
            return false;
    }

    // Set new position
    Position = new Point(x, y);
    return true;
}

Then inside of Player.cs lets override this method and call GameLogic.Tick with the intended position:

public override bool Move(int x, int y)
{
	var moved = base.Move(x, y);

	// Execute a game logic tick on movement, even if movement failed
	GameLogic.Tick(new Point(x, y));

	return moved;
}

Now that we hooked up the tick logic, we can start making some things happen.
What is the goal:

  • Npcs that are in the player’s field of view will chase after the player
  • Once an npc attempts to step onto the player’s position the npc attacks the player
  • Once the player attempts to step onto an npc’s position the player attacks the npc

To succeed in our first goal we need some kind of pathfinding for the npc to reach the player. Thankfully GoRogue library has our back again! To our “WorldScreen.cs” lets add a FastAStar pathfinder.

public readonly FastAStar Pathfinder;

public WorldScreen(int width, int height) : base(width, height)
{
    // .. previous code

	// Setup the pathfinder
	Pathfinder = new FastAStar(new LambdaGridView<bool>(Tilemap.Width, Tilemap.Height, (a) => !BlocksMovement(Tilemap[a.X, a.Y].Obstruction)), Distance.Manhattan);
}

What is AStar?: Its a graph traversal algorithm, which attempts to find the “shortest” path by calculating the cost of traversal (each node has its own cost to traverse).
What is the difference between FastAStar and regular AStar?: It’s effectively the same algorithm but changed in a way that it returns still a valid path but it may not be the shortest pay. But it may significantly be much faster in performance.

The pathfinding algorithm needs to know which tiles it can traverse and which it can’t, to do this we can simply provide a lambda grid view to determine if a tile blocks movement or not.
The method to check for movement blocking is very simple, any obstruction that is “MovementBlocked” or “FullyBlocked” cannot be moved onto, anythign else does not block movement.

private static bool BlocksMovement(ObstructionType obstructionType)
{
	return obstructionType switch
	{
		ObstructionType.MovementBlocked or ObstructionType.FullyBlocked => true,
		_ => false,
	};
}

Now that we have our pathfinder ready, we can go back to our GameLogic.cs class and add a new method “HandlePathfindingAndCombat”:

private static void HandlePathfindingAndCombat(Point intendedPosition)
{
    var hasMoved = Player.Position == intendedPosition;
    var npcAtIntendedPosition = ActorManager.Get(intendedPosition);

	if (!hasMoved && npcAtIntendedPosition != null)
	{
		// The player didn't move but an actor is at the intended position, so we attempted to move into the actor
		// This counts as an attack from the player to the actor
		MeleeCombatLogic.Attack(Player, npcAtIntendedPosition);
	}
}

This will handle the player running into goblins and attacking them, but it won’t make goblins move to the player and attack the player.
We need to tell the pathfinder where to go and give us the path to apply to the npcs.
We can first take all npcs in our current FOV (except the player) and loop over them:

private static void HandlePathfindingAndCombat(Point intendedPosition)
{
    var hasMoved = Player.Position == intendedPosition;
    var npcAtIntendedPosition = ActorManager.Get(intendedPosition);

	if (!hasMoved && npcAtIntendedPosition != null)
	{
		// The player didn't move but an actor is at the intended position, so we attempted to move into the actor
		// This counts as an attack from the player to the actor
		MeleeCombatLogic.Attack(Player, npcAtIntendedPosition);
	}

	var npcsInFov = Player.FieldOfView.CurrentFOV
		.Where(ActorManager.ExistsAt)
		.Select(ActorManager.Get)
		.Where(a => a != Player)
        .ToArray();

	foreach (var npcInFov in npcsInFov)
	{

	}
}

Now to calculate the path we can use the Pathfinder.ShortestPath method with the start position and the end position supplied, this will then return us a Path with steps to take to reach the end position.

Lets add a helper Pathfinder variable at the top of our GameLogic:

private static FastAStar Pathfinder => ScreenContainer.Instance.World.Pathfinder;

Now lets implement the pathfinding:

private static void HandlePathfindingAndCombat(Point intendedPosition)
{
    var hasMoved = Player.Position == intendedPosition;
    var npcAtIntendedPosition = ActorManager.Get(intendedPosition);

    if (!hasMoved && npcAtIntendedPosition != null)
    {
        // The player didn't move but an actor is at the intended position, so we attempted to move into the actor
        // This counts as an attack from the player to the actor
        MeleeCombatLogic.Attack(Player, npcAtIntendedPosition);
    }

    // Calculate for each actor in the player's FOV, to move one tile towards the player.
    // If any actor is next to the player and they attempt to move onto the player, the actor will attack the player.
    var npcsInFov = Player.FieldOfView.CurrentFOV
        .Where(ActorManager.ExistsAt)
        .Select(ActorManager.Get)
        .Where(a => a != Player)
        .ToArray();

    foreach (var npcInFov in npcsInFov)
    {
        var shortestPath = Pathfinder.ShortestPath(npcInFov.Position, Player.Position);
        if (shortestPath == null || shortestPath.Length == 0) continue;

        // Move npc towards the player
        var nextStep = shortestPath.GetStep(0);

        if (!npcInFov.Move(nextStep.X, nextStep.Y))
        {
            // Check if npc ran into the player
            if (nextStep == Player.Position)
                MeleeCombatLogic.Attack(npcInFov, Player);
        }
    }
}

So here we attempt to get a path, if no path exists or length is 0 then the path is simply unreachable by the npc and we continue to the next npc.
If the path is valid, we take the next step after the starting point.

And we attempt to move the npc to this next step, if the next step is the player’s position we are trying to move onto the player so this means a valid time for the npc to attack the player!

Now we just call the method in the Tick method and test it out:

internal static void Tick(Point intendedPosition)
{
	HandlePathfindingAndCombat(intendedPosition);
}

If you have the Console type still enabled you will see input happening when in combat:

If you don’t have the console appearing then you can enable it by going into Properties (Alt + Enter) on your project and changing the “Output type” to Console Application instead of “Windows Application”.

Checkout the next article to continue with the series!

You can find all the code for this series down in the repository here:
https://github.com/Ven0maus/Code2DTutorials/tree/Tutorials/Roguelike

Leave a comment

Design a site like this with WordPress.com
Get started