Automated Ice Skater MonoGame Solution
Get started MonoGame and use IceSkater C# project
Background
There are some CodeProject articles about other MonoGame projects, but nothing with IceSkater. So I started to create this article/tip.
The first program versions had some issues with PathFinding
but now it looks OK.
Using the Code
Here is a Quick Overview
- First, we need the MonoGame extension for Visual Studio 2022 as described in [1] Setting up your development environment for Windows
- After we create a MonoGame project, we can choose some NuGet MonoGame Extensions like [2] MonoGame.Extended
- From [2], we use Collision and Tweening in this project.
- Making GUI with MonoGame is a pain - but thanks to Extensions like MLEM which we can use [3] MLEM Tutorials
- In my demo, the player (Chief Ice Skater) is controlled by the computer. The other Ice Skaters seem to move randomly but they have velocity vectors which get updated values when they collide with each other [4] MonoGame.Extended - collision
- The Pathfinder is taken from [5] MLEM PathfindingDemo
- Tweening can be used to simulate an animation [6] MonoGame.Extended - Tweening
- [7] MGCB Editor - The MonoGame Content Builder (MGCB) Editor is the front-end GUI editor for MonoGame content builder projects.
- [8] Skater Images are from Creazilla
MainWindow Concept and Code
Program.cs initializes and runs the game:
static void Main()
{
using var game = new IceSkater.GameControl();
game.Run();
}
GameControl.cs is the core of the game and has two methods - Update
and Draw
- which are called in a loop 60 times per second =>The loop interval is 16.7 milliseconds.
GameControl
includes these important methods (and more):
Initialize()
LoadContent()
: This method loads the content of the project.Update(GameTime gameTime)
: This method contains game logic, like updating the positions of the game objects.-
Draw(GameTime gameTime)
:
GameControl
includes also a call for Pathfinding
and a method called NewEndPos()
.
To my big surprise, Pathfinding
wasn’t the main issue but the challenge is to set a new EndPos
for the Chief Skater again and again.
Pathfinding
with AStar2 (taken from [3]):
"A 2-dimensional implementation of AStar<T> that uses for positions, and the manhattan distance as its heuristic.“
Putting Things Together - Concept and Code
GUI
The game can be started with ENTER key.
Tweening can be switched on or off.
To get access to the checkbox events, a variable is needed when the control is loaded:
cBox1 = box.AddChild(new MLEM.Ui.Elements.Checkbox
(Anchor.AutoLeft, new Vector2(25, 35), " Tweening")
Time is in seconds and the displayed number of skaters is hard coded.
Pathfinding and Tweening
The player follows the path by jumping from one EndPos
to the next EndPos
.
This behaviour can be improved by simulating an animation with the Tweening
feature.
Taken from [6]: „Inbetweening, or just tweening for short, allows you to generate values for position, size, color, opacity, etc in intermediate frames giving the illusion of animation“.
With NewEndPos()
, we look for a free area not too far away from current position.
The ChiefSkater
moves here and there and tries to avoid collisions with the other Skaters
:
It’s a simplified method - only the distance between the ChiefSkater
and others game objects is checked!
Well - the code for the game is rather simple – enjoy it:
//
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using IceSkater.GameObjects;
using System;
using System.Diagnostics;
using IceSkater.GameMngr;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Linq;
using IceSkater.Interfaces;
using MonoGame.Extended.Collisions;
using MonoGame.Extended.Tweening;
using MLEM.Ui;
using MLEM.Ui.Style;
using MLEM.Extensions;
using MLEM.Pathfinding;
using MLEM.Ui.Elements;
using MLEM.Textures;
using MLEM.Font;
using MonoGame.Extended;
namespace IceSkater
{
public class GameControl : Game
{
// The texture is what we show or draw on the screen
Texture2D _texture;
Texture2D _skaterSprite;
Texture2D _chiefSkaterSprite;
SpriteFont gameFont;
Vector2 _position;
Vector2 endPosAI;
Vector2 oldChiefPos;
ChiefSkater mySkater = new ChiefSkater();
Mngr gameMngr = new Mngr();
public UiSystem UiSystem;
public Panel panel;
public Panel box;
public Panel boxBottom;
private Paragraph txtTime;
private Paragraph txtObst;
private Checkbox cBox1;
public bool gamePause = false;
private GraphicsDeviceManager _graphics;
public SpriteBatch _spriteBatch;
private Color _backgroundColour = Color.Snow;
private List<Component> _gameComponents;
private List<Skater> waste= new List<Skater>();
private bool[,] world;
private bool crash;
private bool init;
private bool processing = true;
private AStar2 pathfinder;
private List<Point> path;
private int scale = 38;
private int interval = 0;
private int i;
private int iColX =19;
private int veloAngle;
bool findPath;
bool moveRev;
public Vector2 Linear;
private readonly Tweener _tweener = new Tweener();
public Vector2 Size = new Vector2(50, 50);
public readonly Random Random = new Random(Guid.NewGuid().GetHashCode());
public readonly CollisionComponent _collisionComponent;
public const int MapWidth = 880;
public const int MapHeight = 800;
public GameControl()
{
_graphics = new GraphicsDeviceManager(this);
Content.RootDirectory = "Content";
IsMouseVisible = true;
// Collision
_collisionComponent = new CollisionComponent
(new MonoGame.Extended.RectangleF(0, 0, MapWidth, MapHeight));
Content.RootDirectory = "Content";
IsMouseVisible = true;
}
protected override void Initialize()
{
// initialization logic
_graphics.PreferredBackBufferWidth = 900;
_graphics.PreferredBackBufferHeight = 800;
_graphics.ApplyChanges();
endPosAI.X = 2;
endPosAI.Y = 2;
mySkater.position.X = 40;
mySkater.position.Y = 30;
processing = true;
base.Initialize();
_graphics.PreferredBackBufferHeight = MapHeight;
_graphics.PreferredBackBufferWidth = MapWidth;
_graphics.ApplyChanges();
}
protected override void LoadContent()
{
// TODO: use this.Content to load your game content here
// Create a new SpriteBatch, which can be used to draw textures.
_spriteBatch = new SpriteBatch(GraphicsDevice);
_skaterSprite = Content.Load<Texture2D>("Skaters/ice-skater-clipart-md");
_chiefSkaterSprite = Content.Load<Texture2D>("Skaters/skating-clipart-md");
_texture = Content.Load<Texture2D>("Textures/Test");
// (0, 0) is the top-left corner
_position = new Vector2(0, 0);
gameFont = Content.Load<SpriteFont>("Fonts/spaceFont");
this.world = new bool[20, 20];
_spriteBatch.Begin();
NewEndPos();
_spriteBatch.End();
this.InitPathFinding();
//Initialize the Ui system
var style = new UntexturedStyle(this._spriteBatch)
{
PanelTexture = null,
//TextScale = 0.75F,
Font = new GenericSpriteFont(this.Content.Load<SpriteFont>
("Fonts/spaceFont")),
ButtonTexture = new NinePatch(new TextureRegion
(this._texture, 24, 8, 16, 16), 4),
CheckboxTexture = new NinePatch(new TextureRegion
(this._texture, 24, 8, 16, 16), 4),
CheckboxCheckmark = new TextureRegion(this._texture, 24, 0, 8, 8),
};
this.UiSystem = new UiSystem(this, style);
panel = new Panel(Anchor.AutoLeft, size: new Vector2(250, 660),
positionOffset: Vector2.Zero);
this.UiSystem.Add("ExampleUi", panel);
box = new Panel(Anchor.AutoLeft, new Vector2(250, 1), Vector2.Zero,
setHeightBasedOnChildren: true);
txtTime = box.AddChild(new Paragraph(Anchor.TopCenter, 1, "Time: " +
Math.Floor(gameMngr.totalTime).ToString()));
txtObst = box.AddChild(new Paragraph(Anchor.AutoCenter, 1, "Obstacles: "));
cBox1 = box.AddChild(new MLEM.Ui.Elements.Checkbox
(Anchor.AutoLeft, new Vector2(25, 35), " Tweening")
{
PositionOffset = new Vector2(0, 2)
});
boxBottom = new Panel(Anchor.BottomLeft, new Vector2(150, 1), Vector2.Zero,
setHeightBasedOnChildren: true);
boxBottom.AddChild(new MLEM.Ui.Elements.Button(Anchor.AutoLeft,
new Vector2(125, 35), "Pause")
{
OnPressed = element => this.Pause(),
PositionOffset = new Vector2(0, 2)
});
boxBottom.AddChild(new MLEM.Ui.Elements.Button(Anchor.AutoLeft,
new Vector2(125, 35), "Go on")
{
OnPressed = element => this.Go(),
PositionOffset = new Vector2(0, 2)
});
boxBottom.AddChild(new MLEM.Ui.Elements.Button(Anchor.AutoLeft,
new Vector2(125, 35), "Stop")
{
OnPressed = element => this.Stop(),
PositionOffset = new Vector2(0, 2)
});
boxBottom.AddChild(new MLEM.Ui.Elements.Button(Anchor.AutoLeft,
new Vector2(125, 35), "Exit")
{
OnPressed = element => this.Exit(),
PositionOffset = new Vector2(0, 2)
});
this.UiSystem.Add("InfoBox", box);
this.UiSystem.Add("BotttomBox", boxBottom);
processing = true;
cBox1.Checked = true;
}
protected override void Update(GameTime gameTime)
{
if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed
|| Keyboard.GetState().IsKeyDown(Keys.Escape))
Exit();
_tweener.TweenTo(this, a => a.Linear, new Vector2(mySkater.position.X,
mySkater.position.Y), duration: 1, delay: 0)
.RepeatForever(repeatDelay: 0.0f)
.AutoReverse()
.Easing(EasingFunctions.BackOut);
var elapsedSeconds = gameTime.GetElapsedSeconds();
_tweener.Update(elapsedSeconds);
base.Update(gameTime);
interval += 1;
// Update the Ui system
this.UiSystem.Update(gameTime);
if (gamePause == false)
{
if (gameMngr.inGame)
{
crash = false;
mySkater.Update(gameTime);
foreach (var item in waste)
{
gameMngr.skaters.Remove(item);
}
}
gameMngr.conUpdate(gameTime, this, MapWidth, MapHeight);
foreach (IEntity entity in gameMngr._entities)
{
entity.Update(gameTime);
}
// Collision
foreach (IEntity entity in gameMngr._entities)
{
// simple collision detection
int sum2 = 30 + mySkater.radius;
if (Vector2.Distance
(entity.Bounds.Position, mySkater.position) < sum2)
{
gameMngr.inGame = false;
mySkater.position = ChiefSkater.defaultPosition;
crash = true;
}
}
_collisionComponent.Update(gameTime);
base.Update(gameTime);
}
}
protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(Color.CornflowerBlue);
this._spriteBatch.Begin(SpriteSortMode.Deferred,
null, SamplerState.PointClamp,
null, null, null, Matrix.CreateScale(scale));
if (processing)
{
NewEndPos();
}
if (findPath == true && processing || Math.Floor(gameMngr.totalTime) < 2)
{
this.InitPathFinding();
}
_spriteBatch.End();
_spriteBatch.Begin();
// Collision
foreach (IEntity entity in gameMngr._entities)
{
entity.Draw(_spriteBatch);
_spriteBatch.Draw(_skaterSprite,
new Vector2(entity.Bounds.Position.X - 20,
entity.Bounds.Position.Y - 60), Color.White);
}
if (crash == true && Math.Floor(gameMngr.totalTime) > 1)
_spriteBatch.DrawString(gameFont, "Obstacles: " + "COLLISION !!",
new Vector2(3, 172), Color.White);
if (cBox1.Checked)
{
_spriteBatch.Draw(_chiefSkaterSprite, new Vector2(Linear.X - 40,
Linear.Y - 40), Color.White);
}
else
{
_spriteBatch.Draw(_chiefSkaterSprite,
new Vector2(mySkater.position.X - 40,
mySkater.position.Y - 40), Color.White);
}
oldChiefPos.X = mySkater.position.X;
oldChiefPos.Y = mySkater.position.Y;
if (gameMngr.inGame == false)
{
string mnuMessage = "Press Enter to Start the Game!";
Vector2 sizeOfText = gameFont.MeasureString(mnuMessage);
int halfWidth = _graphics.PreferredBackBufferWidth / 2;
_spriteBatch.DrawString(gameFont, mnuMessage,
new Vector2(halfWidth - sizeOfText.X / 2, 200), Color.White);
}
_spriteBatch.End();
// Call Draw at the end to draw the Ui on top of your game
txtTime.Text = "Time: " + Math.Floor(gameMngr.totalTime).ToString();
txtObst.Text = "Skaters: " + gameMngr._entities.Count.ToString().ToString();
this.UiSystem.Draw(gameTime, this._spriteBatch);
base.Draw(gameTime);
}
// SOURCE: http://github.com/Ellpeck/MLEM/blob/main/Demos/PathfindingDemo.cs
private async void InitPathFinding()
{
this.path = null;
// generate a simple world for testing, where true is walkable area,
// and false is a wall
var random = new Random();
for (var x = 0; x < 20; x++)
{
for (var y = 0; y < 20; y++)
{
if (this.world[x, y] != false)
this.world[x, y] = true;
}
}
// Create a cost function, which determines how expensive (or difficult) it
// should be to move from a given position
// to the next, adjacent position. In our case, the only restriction should
// be walls and out-of-bounds positions, which
// both have a cost of AStar2.InfiniteCost, meaning they are completely
// unwalkable.
// If your game contains harder-to-move-on areas like, say, a muddy pit,
// you can return a higher cost value for those
// locations. If you want to scale your cost function differently,
// you can specify a different default cost in your
// pathfinder's constructor
float Cost(Point pos, Point nextPos)
{
if (nextPos.X < 0 || nextPos.Y < 0 || nextPos.X >= 20 || nextPos.Y >= 20)
return float.PositiveInfinity;
return this.world[nextPos.X, nextPos.Y] ? 1 : float.PositiveInfinity;
}
// Actually initialize the pathfinder with the cost function, as well as
// specify if moving diagonally between tiles should be
// allowed or not (in this case it's not)
this.pathfinder = new AStar2(Cost, false);
// Now find a path from the top left to the bottom right corner and store
// it in a variable
// If no path can be found after the maximum amount of tries
// (10000 by default),
// the pathfinder will abort and return no path (null)
var foundPath = await Task.Run(()
=> this.pathfinder.FindPath(new Point((int)(mySkater.position.X / scale),
(int)(mySkater.position.Y / scale)),
new Point((int)endPosAI.X, (int)endPosAI.Y)));
this.path = foundPath != null ? foundPath.ToList() : null;
if (this.path == null && gameMngr.inGame == true)
{
if (iColX > 4 && moveRev == false) { iColX -= 1; }
processing = true;
_spriteBatch.Begin();
NewEndPos();
_spriteBatch.End();
mySkater.position.X = endPosAI.X * scale;
mySkater.position.Y = endPosAI.Y * scale;
this.InitPathFinding();
if (iColX == 4)
{
moveRev = true;
}
if (iColX == 19) { moveRev = false; }
if (moveRev && interval % 20 == 9) { iColX += 1; }
}
else
{
{
// draw the path
// in a real game, you obviously make your characters walk along
// the path instead of drawing it
if (this.path != null && this.path.Count > 1 && gamePause == false)
{
float oldEndPosAI = endPosAI.Y;
for (i = 1; i < this.path.Count; i++)
{
var first = this.path[i - 1];
var second = this.path[i];
if (i < this.path.Count)
{
processing = false;
endPosAI.X = second.X;
endPosAI.Y = second.Y;
mySkater.position.X = endPosAI.X * scale;
mySkater.position.Y = endPosAI.Y * scale;
}
}
processing = true;
if (this.path.Count == 1)
{
var first = this.path[0];
processing = false;
endPosAI.X = first.X;
endPosAI.Y = first.Y;
mySkater.position.X = endPosAI.X * scale;
mySkater.position.Y = endPosAI.Y * scale;
processing = true;
}
}
}
}
}
public void NewEndPos()
{
findPath = false;
if (iColX > 4 && moveRev == false && interval % 20 == 9) { iColX -= 1; }
if (iColX == 4)
{
moveRev = true;
}
if (iColX == 19) { moveRev = false; }
if (moveRev && interval % 20 == 9) { iColX += 1; }
var tex = this._spriteBatch.GetBlankTexture();
// draw the world with simple shapes
// 2nd version with less delta x / y
for (var z = 5; z > 1; z--)
{
for (var x = 0; x < 20; x++)
{
for (var y = 19; y > -1; y--)
{
this.world[x, y] = true;
if (this.world[x, y])
{
var random = new Random();
foreach (var item in gameMngr._entities)
{
Vector2 texPosition = new Vector2(x, y);
int sum = 30 + tex.Width * scale / 2;
if (Vector2.Distance(item.Bounds.Position,
scale * texPosition) < sum)
{
this.world[x, y] = false;
}
}
if (this.world[x, y] == false)
this._spriteBatch.Draw(tex, new Rectangle(x, y, 1, 1),
Color.Transparent);
}
if (gameMngr.inGame && x > 2 && x < 19 && y > 2 && y < 19 &&
this.world[x, y] && this.world[x - 1, y] &&
this.world[x + 1, y] &&
this.world[x, y - 1] && this.world[x, y + 1])
{
if ((int)(mySkater.position.X / scale) - x > - z
&& x - (int)(mySkater.position.X / scale) > - z
&& (int)(mySkater.position.Y / scale) - y > - z
&& y - (int)(mySkater.position.Y / scale) > - z)
{
if (x < iColX + z)
{
endPosAI.X = x;
endPosAI.Y = y;
findPath = true;
}
}
}
}
}
}
}
void Pause()
{ gamePause = true; }
void Go()
{ gamePause = false; }
void Stop()
{
gameMngr.inGame = false;
gameMngr._entities.Clear();
init = true;
endPosAI.X = 2;
endPosAI.Y = 2;
mySkater.position.X = 40;
mySkater.position.Y = 30;
}
}
}
//
Credits / Reference
- [1] Setting up your development environment for Windows
- [2] MonoGame.Extended
- [3] MLEM Tutorials
- [4] MonoGame.Extended - collision
- [5] MLEM PathfindingDemo
- [6] MonoGame.Extended - Tweening
- [7] MGCB Editor
- [8] Skater Images are from Creazilla
History
- 26th June, 2023 - Version 1.0
- 28th June, 2023 - Fixed some typos, updated content in MainWindow Concept and Code and added Credits for MGCB Editor and for Images