Adding player stats UI and messages UI

Now that we have a relatively functional game already, lets focus a bit on the user interface. Currently we have only the game world being rendered, and some temporary “rectangles” that are placeholders for our UI.

Lets begin with the sizes of the UI, right now the messages surface is actually a bit too small to properly show messages. It makes more sense to display it underneath the world surface. Lets adjust the sizes in the ScreenContainer.cs

public ScreenContainer()
{
	if (_instance != null)
		throw new Exception("Only one ScreenContainer instance can exist.");
	_instance = this;

	Random = new Random();

	// World screen
	World = new WorldScreen(Game.Instance.ScreenCellsX.PercentageOf(70), Game.Instance.ScreenCellsY.PercentageOf(70));
	Children.Add(World);

	// Player stats screen
	PlayerStats = new ScreenSurface(Game.Instance.ScreenCellsX.PercentageOf(30), Game.Instance.ScreenCellsY)
	{
		Position = new Point(World.Position.X + World.Width, World.Position.Y)
	};
	Children.Add(PlayerStats);

	// Messages screen
	Messages = new ScreenSurface(Game.Instance.ScreenCellsX.PercentageOf(70), Game.Instance.ScreenCellsY.PercentageOf(30))
	{
		Position = new Point(World.Position.X, PlayerStats.Height.PercentageOf(70))
	};
	Children.Add(Messages);
}

The WorldScreen surface we adjusted the height to take only 70% of the screen
The PlayerStats surface we adjusted the height to take 100% of the screen
The Messages surface we adjusted the width to take 70% of the screen and the height to take only 30% of the screen.
Additionally we moved the position of the Messages surface to the left at 0 X and the Y to match the height of the world surface.

Our screen should look like this now:

I will first focus on creating the PlayerStats window, since right now its a simple “ScreenSurface” object it is enough to render to the screen, but we want to store some logic somewhere to draw what we want, and update the stats when some stat is adjusted.
So I will create my own class inside Screens folder called “PlayerStatsScreen.cs”
which will inherit from ScreenSurface.

internal class PlayerStatsScreen : ScreenSurface
{
	private static Player Player => ScreenContainer.Instance.World.Player;

	public PlayerStatsScreen(int width, int height) : base(width, height)
	{ }
}

I’ve also exposed the Player property here for quick and ease of access for our future methods.

I’d like to draw some kind of border around the screen, to do that I’ll add a new extension method inside the Extensions.cs this because the messages screen will also be having a border.

internal static void DrawBorderWithTitle(this ICellSurface surface, string title, Color borderColor, Color titleColor)
{
	// Draw borders
	var shapeParams = ShapeParameters.CreateStyledBox(ICellSurface.ConnectedLineThick, new ColoredGlyph(borderColor), ignoreBorderBackground: true);
	surface.DrawBox(new Rectangle(0, 0, surface.Width, surface.Height), shapeParams);

	// Print title
	surface.Print(surface.Width / 2 - title.Length / 2, 0, new ColoredString(title, titleColor, Color.Transparent));
}

Here we have the ICellSurface as our parameter and we also pass title, border color and title color.
We then use the ShapeParameters helper to create a styled box using the ConnectedLineThick which is a collection of glyphs to created a connected line.
We use DrawBox method on the surface with the shapeParams to draw the border.

And additionally we print the title in the middle of the top border.

In the PlayerStatsScreen.cs we need to expose a public method we can call whenever we need to update the player’s stats on the screen.

public void UpdatePlayerStats()
{
	Surface.Clear();
	Surface.DrawBorderWithTitle("Attributes", Color.Gray, Color.Magenta);
	DrawPlayerAttributes();
}

private void DrawPlayerAttributes()
{
	Surface.Print(2, 2, $"HP:    {Player.Stats.Health}/{Player.Stats.MaxHealth}");
	Surface.Print(2, 3, $"ATK:   {Player.Stats.Attack}");
	Surface.Print(2, 4, $"DEF:   {Player.Stats.Defense}");
	Surface.Print(2, 5, $"AGI:   {Player.Stats.DodgeChance}");
	Surface.Print(2, 6, $"CRIT:  {Player.Stats.CritChance}");
}

Here we first clear the surface, then we draw the border with our title and then we draw each one of our attributes of the player.
The first two parameters in the print are the X and Y location where the text will start printing. It will always start on the second column (looks nice here), and then counting down the Y for each row.

Now we need to replace the ScreenSurface in the constructor of ScreenContainer.cs with our new PlayerStatsScreen lets do that.

public PlayerStatsScreen PlayerStats { get; }

public ScreenContainer()
{
	if (_instance != null)
		throw new Exception("Only one ScreenContainer instance can exist.");
	_instance = this;

	Random = new Random();

	// World screen
	World = new WorldScreen(Game.Instance.ScreenCellsX.PercentageOf(70), Game.Instance.ScreenCellsY.PercentageOf(70));
	Children.Add(World);

	// Player stats screen
	PlayerStats = new PlayerStatsScreen(Game.Instance.ScreenCellsX.PercentageOf(30), Game.Instance.ScreenCellsY)
	{
		Position = new Point(World.Position.X + World.Width, World.Position.Y)
	};
	Children.Add(PlayerStats);

	// Messages screen
	Messages = new ScreenSurface(Game.Instance.ScreenCellsX.PercentageOf(70), Game.Instance.ScreenCellsY.PercentageOf(30))
	{
		Position = new Point(World.Position.X, World.Height)
	};
	Children.Add(Messages);
}

Note that I also removed the temporary .Fill for the background colors on the PlayerStats and Messages window.

Now we need to call this UpdatePlayerStats method somewhere..
Lets go to WorldScreen.cs in the method where we CreatePlayer right at the end,
We can do our first call to do the initial render of the player stats:

public void CreatePlayer()
{
	Player = new Player(_dungeonRooms[0].Center);
	ActorManager.Add(Player);

	// Initial player stats draw
	ScreenContainer.Instance.PlayerStats.UpdatePlayerStats();
}

If you run the game now you will see the window displayed nicely:

Now we need to also update the PlayerStats when the stats are changed, we need to do this in two places:

  • When the player loses health (in ApplyDamage)
  • When the .Set method on ActorStats is called, and the ActorStats belongs to the Player

Lets start with the first one in ApplyDamage in our Actor.cs lets make that method virtual and override it in the Player.cs:

In Actor.cs

public virtual void ApplyDamage(int health)

In Player.cs

public override void ApplyDamage(int health)
{
	base.ApplyDamage(health);
	ScreenContainer.Instance.PlayerStats.UpdatePlayerStats();
}

Now for the last place, lets go to ActorStats.cs and we need to know which Actor it belongs to, so we need to add a property for the actor, i’ll call it Parent, we can pass this via the constructor.

public Actor Parent { get; }

public ActorStats(Actor actor, int maxHealth)
{
	Parent = actor;
	MaxHealth = Math.Max(1, maxHealth);
	Health = MaxHealth;
}

Now in our Actor.cs constructor:

protected Actor(Color foreground, Color background, int glyph, int zIndex, int maxHealth) : base(foreground, background, glyph, zIndex)
{
	Stats = new ActorStats(this, maxHealth);
}

We pass along the actor using the “this” keyword.

Now in our ActorStats.Set method lets check if the Parent is the Player if so we will update the player stats window!

public void Set(int? atk = null, int? def = null, int? dodge = null, int? crit = null)
{
	Attack = Math.Max(1, atk ?? Attack);
	Defense = Math.Max(0, def ?? Defense);
	DodgeChance = Math.Max(0, Math.Min(dodge ?? DodgeChance, 50)); // 50% max dodge chance
	CritChance = Math.Max(0, crit ?? CritChance);

	if (Parent is Player)
		ScreenContainer.Instance.PlayerStats.UpdatePlayerStats();
}

Cool now we have all scenarios covered for our player stats. Lets work on the messages window. For this window we’ll do something a bit more special. Since we are using a 16×16 font it means that we cannot write as much text as for example an 8×8 or 8×16 font would fit. (effectively double the width).
So what we will do is we will make the main Message window use the 16×16 font + we will also draw the border with this window’s surface. But for the actual message text
we’ll render a smaller 8×16 font window inside the main Message window.
And we’ll use that window’s surface to draw the text so we can fit much more and not make the text look so big.

Lets make a new class called MessagesScreen.cs in the “Screens” folder.

internal class MessagesScreen : ScreenSurface
{
    private readonly ScreenSurface _messageSurface;

    public MessagesScreen(int width, int height) : base(width, height)
    {
        // We will use a surface with a smaller width font, so we have more room for showing text

        // Since our parent is 16x16 and the IBM font is 8x16 we must translate the width properly to match the original size
        int translatedWidth = (width - 1) * 2; // *2 to match the original 16x16 -1 to exclude borders left/right
        int translatedHeight = height - 2; // Exclude 2 to substract the top/bottom borders
        _messageSurface = new ScreenSurface(translatedWidth, translatedHeight)
        {
            Font = Game.Instance.Fonts["IBM_8x16"], // Use default sadconsole font
            Position = new Point(1, 1) // Position within the border
        };

        // You can use this so see if the surface fits within the screen
        //_messageSurface.Fill(background: Color.Blue);

        // Add to children so the parent can handle its rendering
        Children.Add(_messageSurface);

        // Draw the main border around the message screen
        Surface.DrawBorderWithTitle("Messages", Color.Gray, Color.Magenta);
    }
}

Here we translate our internal screen’s size to match the new font size.
Since our main screen is based on a 16×16 fontsize, it means that a window with the same size but in 8×16 will have effectively its width divided by 2. To counteract this we must multiply our width by 2. Our height stays the same since the height of the font is still 16.
Additionally we exclude our borders so the internal surface is positioning within the border not on top of it.

Also we use the main Surface to draw the border so its drawn on the Surface of the MessagesScreen.
Now that we have the baseline ready, we need to add code to add messages and show these messages.

Lets add a list to store the messages and a method to add a message to this list.

private readonly List<string> _messages = [];

public void AddMessage(string message)
{
	// Remove the oldest message if we arrived at our limit
	if (_messages.Count == _messageSurface.Height - 2)
		_messages.RemoveAt(0);

	_messages.Add(message);
}

Here we add a simple string message into the list, and once we reach our maximum amount of messages that will fit on our surface (we’ll exclude 2 so we don’t render the top and bottom row of the panel to make it look nicer) we’ll remove the oldest message (the one that sits at index 0).

Now to render our messages we can simply print them in a similar fashion as the PlayerStats to the _messageSurface like so:

private void DrawMessages()
{
	_messageSurface.Surface.Clear();

	// Print the "oldest" message at the top, newest at the bottom
	var startPos = new Point(2, 1);
	for (int i=0; i < _messages.Count; i++)
	{
		// Print the message with the given color
		var message = _messages[i];
		_messageSurface.Surface.Print(startPos.X, startPos.Y, message);
		startPos += Direction.Down;
	}
}

Start pos starts at 2x same as PlayerStats and Y will start at 1 to skip the first row.
And at the bottom of AddMessage method we can call our draw method:

public void AddMessage(string message)
{
	// Remove the oldest message if we arrived at our limit
	if (_messages.Count == _messageSurface.Height - 2)
		_messages.RemoveAt(0);

	_messages.Add(message);

	// Re-draw
	DrawMessages();
}

Now what I like to do is add a static method so we can write quickly to the message screen without having to go through ScreenContainer instance everytime like so:

public static void WriteLine(string message)
{
	// Static shortcut
	ScreenContainer.Instance.Messages.AddMessage(message);
}

Lets actually replace our ScreenSurface Messages in the ScreenContainer to make this work:

public MessagesScreen Messages { get; }

And in the constructor adjust:

// Messages screen
Messages = new MessagesScreen(Game.Instance.ScreenCellsX.PercentageOf(70), Game.Instance.ScreenCellsY.PercentageOf(30))
{
	Position = new Point(World.Position.X, World.Height)
};
Children.Add(Messages);

Now lets replace our System.Console.WriteLine’s with our new MessagesScreen.WriteLine in MeleeCombatLogic.cs:

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

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

Also in Actor.cs lets add a message to indicate when an actor dies:

protected virtual void OnDeath()
{
    // Remove from manager so its no longer rendered
    ScreenContainer.Instance.World.ActorManager.Remove(this);
    MessagesScreen.WriteLine($"{Name} has died.");
}

Lets test out some combat and see if we receive any messages:

Nice!

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