自动化滑冰者 MonoGame 解决方案
开始使用 MonoGame 并使用滑冰者 C# 项目
背景
有一些关于其他 MonoGame 项目的 CodeProject 文章,但没有关于冰上滑冰者的。所以我开始创建这篇文章/技巧。
第一个程序版本在 PathFinding
方面存在一些问题,但现在看起来没问题了。
Using the Code
这是一个快速概览
- 首先,我们需要 Visual Studio 2022 的 MonoGame 扩展,如 [1] 设置 Windows 的开发环境 中所述
- 在创建 MonoGame 项目后,我们可以选择一些 NuGet MonoGame 扩展,例如 [2] MonoGame.Extended
- 从 [2] 中,我们在本项目中使用碰撞和补间动画。
- 用 MonoGame 制作 GUI 是一件痛苦的事情 - 但感谢 MLEM 等扩展,我们可以使用 [3] MLEM 教程
- 在我的演示中,玩家(首席滑冰者)由计算机控制。其他滑冰者似乎随机移动,但他们有速度矢量,当他们相互碰撞时会更新值 [4] MonoGame.Extended - 碰撞
- 寻路器取自 [5] MLEM PathfindingDemo
- 补间动画可用于模拟动画 [6] MonoGame.Extended - 补间动画
- [7] MGCB 编辑器 - MonoGame 内容构建器 (MGCB) 编辑器是 MonoGame 内容构建器项目的前端 GUI 编辑器。
- [8] 滑冰者图像来自 Creazilla
主窗口概念和代码
Program.cs 初始化并运行游戏
static void Main()
{
using var game = new IceSkater.GameControl();
game.Run();
}
GameControl.cs 是游戏的核心,有两个方法 - Update
和 Draw
- 它们在一个循环中每秒被调用 60 次 => 循环间隔为 16.7 毫秒。
GameControl
包含这些重要方法(以及更多)
Initialize()
LoadContent()
:此方法加载项目的 content。Update(GameTime gameTime)
:此方法包含游戏逻辑,例如更新游戏对象的位置。-
Draw(GameTime gameTime)
:
GameControl
还包括对 Pathfinding
的调用以及一个名为 NewEndPos()
的方法。
令我惊讶的是,Pathfinding
并不是主要问题,而挑战在于一次又一次地为首席滑冰者设置一个新的 EndPos
。
使用 AStar2 进行 Pathfinding
(取自 [3])
"A 2-dimensional implementation of AStar<T> that uses for positions, and the manhattan distance as its heuristic.“
整合 - 概念和代码
GUI
游戏可以使用 ENTER 键启动。
补间动画可以开启或关闭。
为了访问复选框事件,需要在加载控件时使用一个变量
cBox1 = box.AddChild(new MLEM.Ui.Elements.Checkbox
(Anchor.AutoLeft, new Vector2(25, 35), " Tweening")
时间以秒为单位,显示的 滑冰者数量是硬编码的。
寻路和补间动画
玩家通过从一个 EndPos
跳到下一个 EndPos
来跟随路径。
可以通过使用 补间动画
功能模拟动画来改进此行为。
取自 [6]:“Inbetweening,或简称补间动画,允许您在中间帧中生成位置、大小、颜色、不透明度等的值,从而产生动画的错觉。”
使用 NewEndPos()
,我们寻找一个离当前位置不太远的空闲区域。
ChiefSkater
在这里和那里移动,并试图避免与其他 Skaters
发生碰撞
这是一个简化方法 - 仅检查了 ChiefSkater
与其他游戏对象之间的距离!
好吧 - 游戏的 code 相当简单 - 尽情享受吧
//
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: https://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;
}
}
}
//
致谢/参考
- [1] 设置 Windows 的开发环境
- [2] MonoGame.Extended
- [3] MLEM 教程
- [4] MonoGame.Extended - 碰撞
- [5] MLEM PathfindingDemo
- [6] MonoGame.Extended - 补间动画
- [7] MGCB 编辑器
- [8] 滑冰者图像来自 Creazilla
历史
- 2023 年 6 月 26 日 - 版本 1.0
- 2023 年 6 月 28 日 - 修复了一些拼写错误,更新了 MainWindow 概念和代码中的内容,并为 MGCB 编辑器和图像添加了致谢