简单的Windows游戏-第1部分:游戏框架

我已决定使用C#和WinForms创建一个简单的Windows游戏,从而得出一系列见解。 还有其他方法可以完成此任务,但我选择了使事情保持简单并演示如何制作游戏的方法。 更有经验的开发人员会注意到我的方法与Microsoft XNA做事之间的相似之处。 这是故意的,有两个原因...

  1. XNA是一个出色的业余爱好者框架,可以轻松创建简单的游戏。 希望阅读此书的人能够轻松过渡到它。
  2. 我发现XNA类的结构相当自然,因此我尝试在此处轻松进行模拟。 我也在WinForms(而不是XNA)中这样做,以避免读者担心另一件事。 目的是介绍性文章,因此我希望此处介绍的内容将使用户能够学习一些基础知识并创建一些游戏,以使您对事物有感觉。

该系列分为三个部分,分别是:

  • 第1部分:游戏框架-这将带您完成创建可用于构建游戏的一组类的过程。 它将讨论一个简单的游戏循环和游戏元素,以及通过鼠标进行的基本输入。
  • 第2部分:基本的Bricks游戏(将要编写)-在这里,我们将采用我们创建的游戏框架,并制作一个非常简单的Bricks(或Breakout)类型的游戏。 这将使球在屏幕周围弹起并弹起,并消除积木。
  • 第3部分:完整的Bricks游戏(尚待编写)-构建我们简单的Bricks游戏,以提供关卡,计分,生活和某种用户界面。

注意:这些文章是使用

Visual C#Express 2010 (免费下载),所有代码均针对.NET 3.5进行编译。 另外,每篇文章的完整代码将附在最后。 您可能希望自己下载此文件,并在打开它的情况下通读本文,或者您可能想继续阅读本文,编写自己的代码。 你的选择! 但是,请记住,我绝不是完美的……我可能会犯一些错误。 随附的代码将运行,但是我可能会错过本文中的步骤。 如果是这样,请随时让我知道! 最后,本文还假设您对C#编程和高中数学有一定的了解。 这里没有什么棘手的问题,因此应该不难理解,但是我想确保我的目标受众是:)

这样,我们就可以潜水了吗? 我们首先需要的是

吸引我们比赛的地方 。 Windows中几乎所有视觉对象都具有Paint事件。 当对象的OnPaint方法运行时,将触发此事件。 通常,我们有两个选择...我们可以向该对象的Paint事件添加事件处理程序,然后在其中进行所有绘制,也可以从一个对象继承并覆盖其OnPaint方法。 您使用的方法取决于您自己; 但是,由于本文着重于创建框架,因此我选择创建一个新类,该类将从System.Windows.Forms.Panel继承。

首先,让我们创建一个新的

在Visual Studio中的Windows窗体应用程序中,我将其命名为BricksExample,但您可以调用它,因为在下一篇文章中我们不会涉及Bricks部分。 然后,为该解决方案创建一个新的类库项目,并将其命名为GameLibrary 。 您可以删除Visual Studio为您创建的Class1.cs文件,但可以创建一个新类并将其命名为GamePanel 。 我们将要使此类从Panel继承,并且还要使它成为抽象类,因为此处的意图是游戏类将从GamePanel继承并实现适当的方法。

您应该在GamePanel.cs中具有以下代码...

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Windows.Forms;
using System.Drawing; 
namespace GameLibrary
{
    public abstract class GamePanel : Panel
    {
    }
}
(您可能有不同的using语句,这很好。这些是我们最终需要完成该类的语句)

现在,我们要做的第一件事就是认识到Windows窗体的可绘制区域实际上小于窗体本身。 这是有道理的,但是对我们来说,这也让我们有些痛苦,因为我们通常会说:“我们的游戏将是X像素宽X Y像素高!” 如果将“表单”设置为这些尺寸,则可绘制区域将比该区域小一些,因为将从其减去标题栏和边框的高度。 幸运的是,我们可以采用一种相对简单的方法来获取表单大小,以便包含可绘制区域。 我们将利用

为了获得一些有关标题栏和边框大小的信息,请使用SystemInformation类。 我们采用以下方法...
public static Size CalculateWindowBounds(Size gameRectangle, FormBorderStyle borderStyle)
{
    Size s = gameRectangle; 
    switch (borderStyle)
    {
        case FormBorderStyle.Sizable:
            s.Width += SystemInformation.FrameBorderSize.Width * 2;
            s.Height += SystemInformation.CaptionHeight + SystemInformation.FrameBorderSize.Height * 2;
            break;
        case FormBorderStyle.FixedDialog:
            s.Width += SystemInformation.FixedFrameBorderSize.Width * 2;
            s.Height += SystemInformation.CaptionHeight + SystemInformation.FixedFrameBorderSize.Height * 2;
            break;
    } 
    return s;
}
这段代码处理了我们可能会使用的两种边框样式的案例...如果您希望支持更多的案例,请随时添加它们。 通常的想法是,我们采用所需的游戏尺寸,并为该特定样式添加两倍于边框宽度的尺寸和宽度。 我们还将字幕的高度添加到游戏高度中。

让我们快速测试一下...在Windows窗体项目中,确保将引用添加到GameLibrary(右键单击“引用”,然后选择

添加参考 ,然后从“项目”选项卡中选择GameLibrary。 也不要忘记添加一个using语句! 现在,在窗体的构造函数中,在InitializeComponent调用下方,您可以添加以下内容...
this.Size = GamePanel.CalculateWindowBounds(new Size(800, 600), this.FormBorderStyle);
Console.WriteLine(this.ClientRectangle);
运行您的项目。 无论在设计器中设置了什么尺寸,现在都可以正确调整尺寸。 检查调试器中的输出选项卡,并确保将客户端矩形设置为800 x600。您可以删除该WriteLine语句以供将来运行。 另外,使用设计器,您可能希望将FormBorderStyle更改为FixedDialog并将MaximizeBox设置为False 。 将所有对象重新缩放为不同的窗口大小有点麻烦,所以我们只是不允许对表单进行rezied :)

现在,我们将要进一步充实我们的GamePanel ...因为我们要在其上进行绘制,所以我们将要禁用闪烁。 我们需要在这里做两件事。 首先,我们将要覆盖Panel的CreateParams属性,以在WS_EX_COMPOSITED标志中添加,尽管通过个人实验,我发现这会对较旧的操作系统(例如Windows XP)造成负面影响,因此我们将在设置之前进行检查它。 另外,我们将要设置GamePanel的DoubleBuffered属性为true。

对于CreateParams,将以下内容添加到GamePanel ...

protected override CreateParams CreateParams
{
    get
    {
        // This stops the control from flickering when it draws
        CreateParams cp = base.CreateParams; 
        // Only allow this on vista and higher as lower versions seem to not draw lines with this option.
        if (Environment.OSVersion.Version.Major >= 6)
            cp.ExStyle |= 0x02000000; // (this is WS_EX_COMPOSITED) 
        return cp;
    }
}
然后,为GamePanel创建一个构造函数,并在该构造函数中,将DoubleBuffered属性设置为true。 由于我们正在创建构造函数,因此请继续使用一些我们稍后需要的参数来创建它们。 在您的课程中创建一个常量,并将其命名为DEFAULT_FPS,将其设置为60。这将是我们游戏运行的默认每秒帧数。 稍后将对此进行说明。 然后我们可以如下创建两个构造函数...
public GamePanel(int initialWidth, int initialHeight)
    : this(DEFAULT_FPS, initialWidth, initialHeight)
{
} 
public GamePanel(int fps, int gameWidth, int gameHeight)
    : base()
{
    this.DoubleBuffered = true;
    this.FramesPerSecond = fps; 
    m_initialSize = new SizeF(gameWidth, gameHeight);
}
请注意,仅采用size参数的构造函数将使用我们的DEFAULT_FPS值简单地调用三个参数的构造函数。 这使我们可以将所有构造器逻辑放在一个地方。

我们还将需要两个私人成员...

private int m_fps = 0;
private SizeF m_initialSize;
...以及一些公共财产...
public int FramesPerSecond
{
    get { return m_fps; }
    set
    {
        m_fps = value;
    }
} 
public SizeF InitialGameSize
{
    get { return m_initialSize; }
}
我将在短期内解释FPS的功能,但是InitialSize将跟踪游戏创建时的尺寸。 如果我们的窗口大小发生变化,这将非常有用...并且在诸如缩放之类的操作中可能会派上用场。 本文不涉及本文,但是如果您想了解更多有关如何操作的信息,可以阅读我写的另一篇文章http://bytes.com/topic/c-sharp/insig...ng-resolutions 。您可以使用此功能。

在测试之前,我们要对GamePanel做的最后一件事是,我们将向其中添加一些抽象方法。 有问题的两个方法将称为OnUpdate和OnDraw。 OnUpdate将是我们更新所有游戏逻辑的地方。 这可能是当我们在游戏中移动某些东西或游戏应更改状态时。 OnDraw是我们在当前状态下简单渲染游戏的地方。 我将在短期内讨论一个基本的游戏循环,但足以说,我们的更新方法将需要知道自上一次更新以来已经过去了多少时间,因此它将使用TimeSpan参数。 我们的Draw可能想知道这一点,因此我们也将其包括在内,但更重要的是,我们还将为其提供一个Graphics对象,可用于绘图。 一个

可以在System.Drawing命名空间中找到Graphics对象,该命名空间提供了可用于游戏的出色绘图工具。

因此,向GamePanel添加以下内容...

protected abstract void OnUpdate(TimeSpan elapsedTime); 
protected abstract void OnDraw(TimeSpan elapsedTime, Graphics g);
接下来,我们需要一种绘制游戏的方法。 Windows处理绘图的方式是,当它检测到一个窗口或窗口的一部分时,需要重新绘制该窗口,从而触发WM_PAINT事件。 窗体的WndProc方法将拦截此事件并触发OnPaint方法。 如上所述,此方法依次调用Paint事件。

现在,Windows不想不断刷新窗口图形,因此仅在需要时才触发WM_PAINT事件。 通常,这是在重定窗口大小或将另一个窗口拖动到顶部然后移开时。 就我们的目的而言,这根本行不通,我们正在制作一个游戏并使游戏具有动画效果,因此我们将希望我们的游戏能够经常重绘自身。 为此,我们需要创建一个游戏循环。

在这里我要暂停一下,以提及游戏循环是一门相当技术性的科学,实际上已经广泛讨论了它们的应用。 关于互联网,有很多很棒的文章都比我详细介绍了很多。 如果您想进一步了解该主题,建议您使用google搜索。 绝对是好东西。

话虽如此,出于本文的目的,我将采用一种非常简单的方法,其中我们的更新计时器将与绘制计时器相同。 这样可以使事情变得简单明了,但必须注意,这不是最好的方法。 通常,您希望您的更新尽可能频繁地进行,而绘制的次数则少一些,但是只要有时间,就可以进行。 Game Loop文章对此进行了详细讨论,并且对此进行了冗长的数学运算。 同样,出于本文的目的,我们将锁定更新并以每秒两次的相同计时器绘制调用,因为它更容易。

为此,我们将创建一个

System.Windows.Forms.Timer对象,它将间隔间隔计时。 每当此计时器出现问题时,我们都会更新游戏并进行绘制。 使用此方法,我们将始终希望在更新后进行绘制,以便我们的游戏显示最新状态。

让我们为GamePanel创建几个私有成员变量...

private Timer m_updateTimer = new Timer();
private DateTime m_lastEngineCycle;
private DateTime m_currentEngineCycle;
m_updateTimer将成为我们的计时器,它将在一个时间间隔上滴答,m_currentEngineCycle将是当前滴答的时间,而m_lastEngineCycle将是最后滴答的时间。 这将使我们能够生成一个TimeSpan,让我们的update和draw方法知道自上次运行以来已经过去了多少时间。

在游戏的构造函数中,我们将要向计时器的tick事件中添加事件处理程序,启动计时器,并将最近的引擎更新设置为现在。 这会将我们的构造函数更改为以下内容...

public GamePanel(int fps, int gameWidth, int gameHeight)
    : base()
{
    this.DoubleBuffered = true;
    this.FramesPerSecond = fps; 
    m_initialSize = new SizeF(gameWidth, gameHeight); 
    m_updateTimer.Tick += new EventHandler(HandleUpdateTick);
    m_updateTimer.Start();
    m_lastEngineCycle = DateTime.Now;
}
我们还需要更新FramesPerSecond属性,以便它根据每秒的帧数设置计时器间隔。 现在,计时器在一个整数毫秒的间隔上计时。 如果我们有一个每秒帧数的值,我们想将其更改为每帧将经过的秒数。 因此,计时器间隔将始终为1 / frames_per_second,秒。 将其乘以1000得到毫秒数。
public int FramesPerSecond
{
    get { return m_fps; }
    set
    {
        m_fps = value; 
        m_updateTimer.Interval = (int)Math.Round((1f / (float)m_fps * 1000f));
    }
}
我们的滴答事件的事件处理程序将非常简单。 我们要做的就是调用自上次更新以来的时间的update方法,然后调用Invalidate方法,该方法是Control对象(Panel继承并从Panel继承)上的方法,该方法触发WM_PAINT事件,转弯导致平局发生。 我们的代码如下...
private void HandleUpdateTick(object sender, EventArgs e)
{
    m_currentEngineCycle = DateTime.Now; 
    OnUpdate(m_currentEngineCycle - m_lastEngineCycle); 
    this.Invalidate(); 
    m_lastEngineCycle = m_currentEngineCycle;
}
请注意,我们在更新周期开始时获取了当前时间,并将其用于我们的计算,以便更新(和绘制)将具有正确的时间跨度。 如果我们对两个计算都使用DateTime.Now,则无论执行更新还是Windows触发无效调用花费了多长时间,我们都会浪费一点时间。

现在,我们需要重写OnPaint方法,以确保触发了绘制。 同时,我们还重写OnMouseEnter和OnMouseLeave方法以隐藏Windows光标。 您可能希望省略此部分,但通常来说,您不希望Windows箭头指针位于游戏顶部,您甚至可能想要绘制一个自己的指针。

protected override void OnMouseEnter(EventArgs e)
{
    base.OnMouseEnter(e); 
    Cursor.Hide();
} 
protected override void OnMouseLeave(EventArgs e)
{
    base.OnMouseLeave(e); 
    Cursor.Show();
} 
protected override void OnPaint(PaintEventArgs e)
{
    base.OnPaint(e); 
    OnDraw(m_currentEngineCycle - m_lastEngineCycle, e.Graphics);
}
好的,现在假设您已经正确编写了代码并且在粘贴时没有弄错任何东西,我们应该准备进行简单的测试。 在Windows项目中(即不是GameLibrary,而是主窗体所在的位置),创建一个新类,并使该类继承自GamePanel。 我叫我的TestGame。 您将需要为其创建一个构造函数,因为我们在GamePanel中不允许使用无参数的构造函数(如果愿意,可以根据您的需要)。 我们还需要实现GamePanel的两种抽象方法。 在OnDraw实现中,做一些简单的事情以确保其正常运行。
public class TestGame : GamePanel
{
    public TestGame(int width, int height)
        : base(width, height)
    {
    } 
    #region GamePanel Implementation
    protected override void OnUpdate(TimeSpan elapsedTime)
    {
    } 
    protected override void OnDraw(TimeSpan elapsedTime, System.Drawing.Graphics g)
    {
        g.FillRectangle(Brushes.Black, this.ClientRectangle); 
        g.FillRectangle(Brushes.Red, 25, 25, 300, 150);
    }
    #endregion
}
现在,以我们的主要形式,我们需要创建一个TestGame的新实例,并为其提供一个FramesPerSecond值。 我还喜欢将Dock设置为DockStyle.Fill,这样它将占用我们之前设置的窗体的所有可绘制区域。 我的主要形式的代码如下...
public partial class Form1 : Form
{
    private const int GAME_WIDTH = 800;
    private const int GAME_HEIGHT = 600; 
    private TestGame m_game = null; 
    public Form1()
    {
        InitializeComponent(); 
        this.Size = GamePanel.CalculateWindowBounds(new Size(GAME_WIDTH, GAME_HEIGHT), this.FormBorderStyle); 
        m_game = new TestGame(GAME_WIDTH, GAME_HEIGHT);
        m_game.FramesPerSecond = 60;
        m_game.Dock = DockStyle.Fill; 
        this.Controls.Add(m_game);
    }
}
运行此命令会显示一个黑色窗口,上面有一个红色矩形。 我们还要进行快速测试,以确保动画正常工作。 这也将让我介绍动画如何在我们的游戏中起作用的概念。

在我们的TestGame中,让我们为半径创建一个成员变量,并将其默认设置为25。

private float m_radius = 25f;
(注意:通常最好使用浮点数...将像素解析为整数值,但是通过使用浮点数,我们可以确保位置更精确。这样可以减少跳动的动画。)

现在,将我们的OnDraw代码更改为以下内容...

g.FillRectangle(Brushes.Black, this.ClientRectangle); 
g.FillEllipse(Brushes.Red, this.ClientRectangle.Width / 2f - m_radius, this.ClientRectangle.Height / 2f - m_radius, m_radius * 2f, m_radius * 2f);
这将在我们游戏区域的中间绘制一个红色圆圈。

现在,在OnUpdate方法中,我们可以更改半径,以使生成的图形成为动画。 注意,有两种方法可以修改半径...我们可以简单地对其进行添加,也可以根据经过的时间为其添加一个量。 最简单的方法是仅添加半径...

protected override void OnUpdate(TimeSpan elapsedTime)
{
    m_radius += 1;
}
运行此代码将显示我们的圈子越来越大。 让我们做一个快速更改,以便它不会无限增长...我们将创建另一个名为m_speed的成员变量并将其设置为1。
private float m_speed = 1f;
然后,我们将速度添加到半径。 我们还可以进行检查,以查看半径是否已增加到一定程度,这时我们可以将速度更改为负值,这将导致圆缩小。 同样,我们可以检查半径是否缩小到一定量,然后将速度更改为正值。
protected override void OnUpdate(TimeSpan elapsedTime)
{
    m_radius += m_speed; 
    if (m_radius >= this.ClientRectangle.Height / 2f || m_radius <= 25f)
        m_speed *= -1;
}
运行此操作将显示我们的圆不断增长,直到它与形状高一样大,然后缩小到半径25处,这时它将再次增长。

现在让我们讨论一下如何修改半径。 运行该程序,并记下圆改变大小的速率。 然后转到我们的主窗体,更改将FramesPerSecond设置为60的位置,使其改为10。 运行程序。 我们的圈子增长得慢得多。 将FramesPerSecond设置为120,并注意圆会非常Swift地增长。 继续之前将其设置回60。

因此,圆的变化速率直接与每秒拥有的帧数(或更具体而言,每秒更新的次数)相关。 通常这是不可取的,因为我们不能总是保证速率... Windows可能需要更长的时间才能进行该呼叫,更高优先级的进程可能会碰到我们,因此事情可能需要更长的时间才能运行,或者我们的系统可能有点陷入困境 这就是为什么我们的update方法带有一个TimeSpan参数的原因,该参数告诉我们自上次更新以来已经有多长时间了。 我们可以使用它来基于时间更改对象的值,因此我们的对象以恒定的速率移动,而不依赖于每秒有多少次更新。 然后,我们可以使对象通过速率移动,即每秒像素数。 我们可以将其乘以经过的秒数,以得出在该时间段内对象移动了多少像素。

使用这两种方法中的任何一种,都必须注意,对象可能会跳过设置点,因为它们移动得足够快,以至于它们一次更新到设置点之前,而下一次则越过。 我们可以解决这个问题并相应地移动我们的对象。 我将OnUpdate更改为以下内容...

protected override void OnUpdate(TimeSpan elapsedTime)
{
    m_radius += m_speed * (float)elapsedTime.TotalSeconds; 
    if (m_radius > this.ClientRectangle.Height / 2f) m_radius = this.ClientRectangle.Height / 2f;
    else if (m_radius < 25f) m_radius = 25f; 
    if (m_radius >= this.ClientRectangle.Height / 2f || m_radius <= 25f)
        m_speed *= -1;
}
...并将我的m_speed值设置为60f,这意味着我们将在一秒钟内移动60个像素。 这大致等于我们以前的情况。 我们的速度是1,但是我们在一秒钟内更新了60次,所以我们以每秒60像素的速度移动。 现在,我们的速度为每秒60像素,我们将其乘以经过的秒数(通常接近1/60,但可能会略微多一些,具体取决于操作系统何时开始进行更新)调用),现在我们可以更改半径的数量了。

我们检查半径是否超过设定点,如果超过,则将其适当地设置为该点。 这意味着,如果在一个刻度和下一个刻度之间,半径变得大于表单的高度,我们只需将其设置为表单的高度,就不会显得很有趣。 请注意,这仍然略有偏离,但是对于本文而言,我还是采取了一种简单的方法:D话虽如此,我将简要讨论实现此目的的正确方法。 您需要计算对象到达设定点所需的时间,然后将对象移至该设定点。 然后,您将执行反射并将对象向相反方向移动剩余时间。 尽管这很困难,因为在实际游戏中,更改方向后您可能会碰到其他对象,从而造成困难。 同样,出于本文的目的,我们将采取简单的方法,将位置设置为断点,而忽略其可能移动的距离。

现在运行此代码...您应该看到圆以与您之前看到的大致相似的速度增长和缩小。 现在开始使用您的FramesPerSecond值。 圆应该以相同的速度增长,但是根据您将FramesPerSecond设置为什么,圆会变得平滑或不连贯。 通常将FramesPerSecond设置为60足以满足我们的目的,所以我通常使用它。

接下来,我们将提供一种让GamePanel知道鼠标在哪里的方法。 面板上有一个名为MousePosition的方法,但这是鼠标相对于整个屏幕的位置。 我们将要确保我们游戏的鼠标位置不会超出我们游戏的范围。 我们的鼠标可以移动到任何地方,当然,根据游戏的位置应该在游戏的范围之内。 此外,面板还具有一种称为MouseButtons的方法,该方法提供了当前按下的鼠标按钮。 同样,如果光标在我们游戏的范围之外,我们也不想显示任何被按下的按钮。

在我们的GameLibrary项目中,创建一个文件(类)并将其命名为Mouse.cs。 实际上,在此文件中,我们将创建两个对象,一个名为MouseState的结构将保存有关鼠标当前状态的信息,而一个名为Mouse的类将检索我们GamePanel的当前状态。 首先,结构。 我们需要位置,因此它将需要具有PointF属性,并且还希望按下按钮,因此需要System.Windows.Forms.MouseButtons属性。 然后,我们还将提供方法来查看按钮是向上还是向下。

public struct MouseState
{
    private PointF m_position;
    private MouseButtons m_buttons; 
    public MouseState(PointF position, MouseButtons buttons)
    {
        m_position = position;
        m_buttons = buttons;
    } 
    public PointF Position { get { return m_position; } }
    public MouseButtons Buttons { get { return m_buttons; } } 
    public bool IsButtonDown(MouseButtons button)
    {
        return ((this.Buttons & button) == button);
    } 
    public bool IsButtonUp(MouseButtons button)
    {
        return !IsButtonDown(button);
    }
}
我们使用只读属性是因为我们不想允许更改鼠标位置(无论如何,这是一个完整的其他过程,在本文中我不会解决)。 MouseButtons对象只是一个状态,其中每个鼠标按钮都按位进行“或”运算,因此我们可以使用按位“与”检查按钮是否被按下。

接下来,我们将创建Mouse类。 此类将需要保留对拥有它的GamePanel的引用,因为它将需要检查以确保鼠标位于该GamePanel的边界内。 我们将在Mouse上创建一个名为GetState的方法,该方法将简单地返回鼠标的状态。 我们将根据已定义的规则构建该状态。

public class Mouse
{
    private GamePanel m_hostPanel = null; 
    public Mouse(GamePanel hostPanel)
    {
        if (hostPanel == null)
            throw new ArgumentNullException("hostPanel"); 
        m_hostPanel = hostPanel;
    } 
    public MouseState GetState()
    {
        PointF hostLoc = m_hostPanel.PointToScreen(m_hostPanel.Location);
        PointF cursorLoc = GamePanel.MousePosition; 
        PointF mouseLoc = new PointF(cursorLoc.X - hostLoc.X, cursorLoc.Y - hostLoc.Y); 
        MouseButtons buttons = GamePanel.MouseButtons;
        if (!m_hostPanel.ClientRectangle.Contains((int)mouseLoc.X, (int)mouseLoc.Y))
            buttons = MouseButtons.None; 
        if (mouseLoc.X < 0) mouseLoc.X = 0;
        else if (mouseLoc.X > m_hostPanel.Width) mouseLoc.X = m_hostPanel.Width; 
        if (mouseLoc.Y < 0) mouseLoc.Y = 0;
        else if (mouseLoc.Y > m_hostPanel.Height) mouseLoc.Y = m_hostPanel.Height; 
        return new MouseState(mouseLoc, buttons);
    }
}
请注意,Panel对象上有一个名为PointToScreen的方法,该方法会将该Panel上的点转换为它在屏幕上的位置。 这样,我们在屏幕上获得了鼠标的值并减去了主机所在的位置。 这告诉我们mosue在何处相对于其托管面板。 然后我们检查它是否在该面板的边界之外,如果是,请将其锁定为适当的值。 我们还获得了按钮,如果鼠标在面板外,则将按钮设置为MouseButtons.None。

现在,在我们的GamePanel对象中,我们可以为鼠标创建一个成员变量,在构造函数中对其进行初始化,然后通过一个属性公开它。

private Mouse m_mouse = null;
public GamePanel(int fps, int gameWidth, int gameHeight)
    : base()
{
    this.DoubleBuffered = true;
    this.FramesPerSecond = fps; 
    m_initialSize = new SizeF(gameWidth, gameHeight); 
    m_mouse = new Mouse(this); 
    m_updateTimer.Tick += new EventHandler(HandleUpdateTick);
    m_updateTimer.Start();
    m_lastEngineCycle = DateTime.Now;
}
public Mouse Mouse
{
    get { return m_mouse; }
}
现在,我们可以检索有关鼠标的信息。 在我们的TestGame对象中,进行一些小测试。 我们可以将以下代码添加到OnDraw方法的底部...
MouseState ms = this.Mouse.GetState();
g.DrawString(ms.Buttons.ToString(), this.Font, Brushes.White, 0, 0); 
g.FillRectangle(Brushes.Pink, ms.Position.X - 1, ms.Position.Y - 1, 2, 2);
这将获取当前的鼠标状态,绘制Buttons的值,并在光标位置周围绘制一个小矩形。 运行此命令,您将看到一个小的粉红色矩形随鼠标移动,并且单击按钮时左上角的文本也会更改。 请注意,如果您想知道是否单击或释放了鼠标,则可以跟踪鼠标的最后状态。 如果当前状态的按钮处于按下状态,而最后一个状态的按钮处于向上状态,则仅按下一个按钮。 反之亦然,刚刚释放的按钮。

我们快完成了! 我们将在GameLibrary中再创建三个对象,这些对象将定义我们可能要在游戏中使用的各种元素,并将作为指南。 您可能希望创建更多这些,或更改它们以适合您的目的。

首先,我们将创建一个界面,该界面将告诉我们游戏中元素的方法

必须有。 由于我们的游戏执行两项核心任务,即更新和抽签,因此我们的游戏元素自然也必须具有此功能。 因此,请在您的GameLibrary项目中创建一个新类,并将其定义为...
public interface IGameElement
{
    void Draw(TimeSpan elapsedTime, Graphics g);
    void Update(TimeSpan elapsedTime);
}
从IGameElement继承的任何对象都必须具有这些方法,这意味着我们可以在游戏中保留IGameElement对象的列表以用于所需的不同事物。 例如,假设我们有一个台球游戏。 我们可能会保留作为池球的IGameElement列表,这些是从IGameElement继承的称为PoolBall的类。 然后,我们只需要在OnUpdate中调用Update方法,并在OnDraw中调用Draw方法,并传递适当的参数即可。

请注意,我们还可以在GamePanel本身中添加IGameElement列表,并从那里进行所有操作,使用GamePanel自动更新并绘制需要处理的每个子对象。 这绝对是可行的,并且XNA采取了相同的方法。 但是,我的

个人的看法是不要使用这种方法。 我喜欢对对象的绘制和更新顺序进行更多控制,而且我还希望能够单独处理对象列表。 例如,在前面提到的桌球游戏示例中,我们可以将所有的球,线索和桌子对象存储在GamePanel拥有的列表中,并会自动为我们处理。 问题是,如果我们只想检查一下球,就需要处理整个列表。 在此示例中,这似乎还不错,但是如果有很多我们根本不在乎的背景对象该怎么办? 这只是浪费处理器时间。 因此,为此,我想分别跟踪我的对象列表。 您可能希望使用GamePanel中的完整列表,这完全取决于您:)

无论如何,现在我们有了IGameElement的接口,让我们创建一些抽象类,以备以后使用。 通常,游戏具有可移动的对象和不可移动的对象,因此让我们从那里开始构建。 我们将再创建两个对象:StaticElement和DynamicElement。 StaticElement将代表仅具有位置的对象,除了进行环聊之外,它实际上不执行任何操作。 为此,它将具有RectangleF属性,该属性将跟踪其位置和大小。 我们还将为Funsies提供Color属性。 最后,我们要确保该对象具有对创建它的游戏的引用。 这样做的原因是一个对象可能想知道周围的其他对象。 通过授予其访问其主机游戏的权限,它可以查找属性以访问信息。 同样,使用台球游戏示例,Pocket对象可能想知道Ball对象是否超出了它的顶部,因此Pocket对象的Update方法可能会查看它是宿主游戏的Balls属性(假设它公开了一个)。 我们还设置了一些构造函数,以简化对象的创建过程,并且由于我是个好人,我创建了一些额外的属性来显示Rectangle的各个部分,例如Position和Size。 StaticElement的代码如下...

public abstract class StaticElement : IGameElement
{
    #region protected Members
    private GamePanel m_owningGame = null;
    #endregion 
    #region ProtectedProperties
    protected GamePanel OwningGame
    {
        get { return m_owningGame; }
    }
    #endregion 
    #region Public Properties
    public Color Colour { get; set; } 
    public RectangleF Rectangle { get; set; } 
    public PointF Position
    { 
        get { return this.Rectangle.Location; }
        set
        {
            this.Rectangle = new RectangleF(value, this.Rectangle.Size);
        }
    } 
    public SizeF Size
    {
        get { return this.Rectangle.Size; }
        set
        {
            this.Rectangle = new RectangleF(this.Rectangle.Location, value);
        }
    } 
    #endregion 
    #region Constructor(s)
    public StaticElement(GamePanel owningGame, PointF position, SizeF size)
        : this(owningGame, new RectangleF(position, size))
    {
    } 
    public StaticElement(GamePanel owningGame, RectangleF rectangle)
    {
        m_owningGame = owningGame;
        this.Rectangle = rectangle;
        this.Colour = Color.Red;
    }
    #endregion 
    #region IGameElement Implementation
    public abstract void Draw(TimeSpan elapsedTime, Graphics g);
    public abstract void Update(TimeSpan elapsedTime);
    #endregion
}
我只是选择红色作为默认颜色,您可以做任何您想做的事情。 请注意,尽管该对象的名称暗示它是静态的,但可以移动...但是,它实际上并未设置为以任何有意义的方式移动,而不仅仅是手动更改位置。 一个如何使用此示例的例子可能是鼠标光标...让我们更新TestGame以更好地处理鼠标光标。

首先,我们需要在Windows应用程序中创建一个名为MosueCursor的新类。 此类将从StaticElement继承,并且需要实现两个抽象方法。 我们还将创建几个构造函数。 在Draw方法中,我们可以简单地完成之前的操作,即使用对象的Rectangle属性绘制一个粉红色的填充矩形。 对于Update,我们可以从拥有的游戏中获取MouseState并相应地更新位置。 因此,我们有..

public class MouseCursor : StaticElement
{
    public MouseCursor(GamePanel owningGame, Rectangle rect)
        : base(owningGame, rect)
    {
    } 
    public MouseCursor(GamePanel owningGame, PointF position, SizeF size)
        : base(owningGame, position, size)
    {
    } 
    #region IGameElement Implementation
    public override void Draw(TimeSpan elapsedTime, System.Drawing.Graphics g)
    {
        g.FillRectangle(Brushes.Pink, this.Rectangle);
    } 
    public override void Update(TimeSpan elapsedTime)
    {
        MouseState ms = OwningGame.Mouse.GetState();
        this.Position = ms.Position;
    }
    #endregion
}
现在在TestGame中,我们为光标创建一个新的私有成员变量,在构造函数中对其进行初始化,然后在适当的位置调用Draw和Update。 我的TestGame代码现在看起来像这样...
public class TestGame : GamePanel
{
    private float m_radius = 25f;
    private float m_speed = 60f;
    private MouseCursor m_cursor = null;  
    public TestGame(int width, int height)
        : base(width, height)
    {
        m_cursor = new MouseCursor(this, new Rectangle(0, 0, 2, 2)); 
    } 
    #region GamePanel Implementation
    protected override void OnUpdate(TimeSpan elapsedTime)
    {
        m_radius += m_speed * (float)elapsedTime.TotalSeconds; 
        if (m_radius > this.ClientRectangle.Height / 4f) m_radius = this.ClientRectangle.Height / 4f;
        else if (m_radius < 25f) m_radius = 25f; 
        if (m_radius >= this.ClientRectangle.Height / 4f || m_radius <= 25f)
            m_speed *= -1; 
        m_cursor.Update(elapsedTime); 
    } 
    protected override void OnDraw(TimeSpan elapsedTime, System.Drawing.Graphics g)
    {
        g.FillRectangle(Brushes.Black, this.ClientRectangle); 
        g.FillEllipse(Brushes.Red, this.ClientRectangle.Width / 2f - m_radius, this.ClientRectangle.Height / 2f - m_radius, m_radius * 2f, m_radius * 2f); 
        MouseState ms = this.Mouse.GetState();
        g.DrawString(ms.Buttons.ToString(), this.Font, Brushes.White, 0, 0); 
        m_cursor.Draw(elapsedTime, g); 
    }
    #endregion
}
请注意粗体部分。 这样,MouseCursor现在可以自行处理。 如果我们想更改光标的行为方式,可以在MouseCursor类中进行更改,并且我们的TestGame代码不会更改。

我们确定需要的第二个对象是DynamicElement。 这将处理要移动的对象。 为方便起见,我们将为该对象提供PointF速度和PointF加速度。 这些将控制我们元素的位置如何更新。 我们的DynamicElement也将需要一个Position和Size,但是我们已经在StaticElement中编写了它,因此让DynamicElement继承自它。 Update和Draw方法已经在StaticElement中定义,因此我们不需要重新定义它们,但是我们可以创建几个辅助方法来为我们提供下一个速度和下一个位置。 我的DynamicElement代码如下...

public abstract class DynamicElement : StaticElement
{
    #region Public Properties
    public PointF Velocity { get; set; }
    public PointF Acceleration { get; set; }
    #endregion 
    #region Constructor(s)
    public DynamicElement(GamePanel owningGame, PointF position, SizeF size, PointF velocity, PointF acceleration)
        : base(owningGame, position, size)
    {
        this.Velocity = velocity;
        this.Acceleration = acceleration;
    } 
    public DynamicElement(GamePanel owningGame, RectangleF rectangle, PointF velocity, PointF acceleration)
        : base(owningGame, rectangle)
    {
        this.Velocity = velocity;
        this.Acceleration = acceleration;
    }
    #endregion 
    #region Public Methods
    public PointF CalculateNextPosition(TimeSpan elapsedTime)
    {
        return 
            new PointF(
                this.Rectangle.X + (float)elapsedTime.TotalSeconds * this.Velocity.X,
                this.Rectangle.Y + (float)elapsedTime.TotalSeconds * this.Velocity.Y
            );
    } 
    public PointF CalculateNextVelocity(TimeSpan elapsedTime)
    {
        return
            new PointF(
                this.Velocity.X + (float)elapsedTime.TotalSeconds * this.Acceleration.X,
                this.Velocity.Y + (float)elapsedTime.TotalSeconds * this.Acceleration.Y
            );
    }
    #endregion
}
速度是位置随时间的变化,因此我们可以通过将速度乘以过去的总秒数来计算下一个位置,然后将其添加到当前位置。 同样,加速度是速度随时间的变化,因此我们可以基于经过了多少时间来执行类似的计算以获得下一个速度。 让我们在我们的TestGame中查看一个示例。

首先,删除与扩展/收缩圆有关的所有内容,但您可以将光标保留在原处,并且可以将背景设置为黑色。 现在,在Windows应用程序项目中创建一个新类,并将其命名为Ball。 我添加了几个“颜色”属性以提供填充色和轮廓色,然后在“绘制”中适当地绘制它,并在“更新”中更新了“速度”和“位置”。 请注意,我总是首先更新Velocity,作为基于此的Position更新。

public class Ball : DynamicElement
{
    public Color FillColour
    {
        get { return base.Colour; }
        set { base.Colour = value; }
    } 
    public Color OutlineColour { get; set; } 
    public Ball(GamePanel owningGame, RectangleF rectangle, PointF velocity, PointF acceleration)
        : base(owningGame, rectangle, velocity, acceleration)
    {
    } 
    public Ball(GamePanel owningGame, PointF position, SizeF size, PointF velocity, PointF acceleration)
        : base(owningGame, position, size, velocity, acceleration)
    {
    } 
    #region IGameElement Implementation
    public override void Draw(TimeSpan elapsedTime, Graphics g)
    {
        g.FillEllipse(new SolidBrush(this.FillColour), this.Rectangle);
        g.DrawEllipse(new Pen(this.OutlineColour), this.Rectangle);
    } 
    public override void Update(TimeSpan elapsedTime)
    {
        this.Velocity = base.CalculateNextVelocity(elapsedTime);
        this.Position = base.CalculateNextPosition(elapsedTime);
    }
    #endregion
}
现在,在TestGame中,我们可以创建一个ball对象,在构造函数中对其进行初始化,然后调用draw并进行适当的更新。 我的TestGame现在看起来像...
public class TestGame : GamePanel
{
    private Ball m_ball = null;
    private MouseCursor m_cursor = null; 
    public TestGame(int width, int height)
        : base(width, height)
    {
        m_cursor = new MouseCursor(this, new Rectangle(0, 0, 2, 2));
        m_ball = new Ball(this, new RectangleF(10, 10, 25, 25), new PointF(50, 50), new PointF(0, 0))
        {
            FillColour = Color.DarkRed,
            OutlineColour = Color.Red
        };
    } 
    #region GamePanel Implementation
    protected override void OnUpdate(TimeSpan elapsedTime)
    {
        m_ball.Update(elapsedTime);
        m_cursor.Update(elapsedTime);
    } 
    protected override void OnDraw(TimeSpan elapsedTime, System.Drawing.Graphics g)
    {
        g.FillRectangle(Brushes.Black, this.ClientRectangle); 
        m_ball.Draw(elapsedTime, g);
        m_cursor.Draw(elapsedTime, g);
    }
    #endregion
}
运行此命令将显示一个圆圈,该圆圈向下和向右移动直到离开屏幕。 有点无聊,所以让它弹起。 同样,编写这样的代码的好处是,我们无需触摸TestGame即可更改球的行为。 我们可以在Ball类本身中进行所有更新。 让我们检查一下球,看看它的新位置是否达到了我们游戏的边缘。 如果是这样,我们将设置位置将球放置在墙附近,并适当地改变方向。

我的球代码现在...

public class Ball : DynamicElement
{
    public Color FillColour
    {
        get { return base.Colour; }
        set { base.Colour = value; }
    } 
    public Color OutlineColour { get; set; } 
    public Ball(GamePanel owningGame, RectangleF rectangle, PointF velocity, PointF acceleration)
        : base(owningGame, rectangle, velocity, acceleration)
    {
    } 
    public Ball(GamePanel owningGame, PointF position, SizeF size, PointF velocity, PointF acceleration)
        : base(owningGame, position, size, velocity, acceleration)
    {
    } 
    #region IGameElement Implementation
    public override void Draw(TimeSpan elapsedTime, Graphics g)
    {
        g.FillEllipse(new SolidBrush(this.FillColour), this.Rectangle);
        g.DrawEllipse(new Pen(this.OutlineColour), this.Rectangle);
    } 
    public override void Update(TimeSpan elapsedTime)
    {
        this.Velocity = base.CalculateNextVelocity(elapsedTime); 
        PointF nextPosition = base.CalculateNextPosition(elapsedTime);
        PointF newVelocity = this.Velocity; 
        // Check X
        if (nextPosition.X < 0)
        {
            nextPosition.X = 0;
            newVelocity.X *= -1;
        }
        else if (nextPosition.X + this.Size.Width > OwningGame.InitialGameSize.Width)
        {
            nextPosition.X = OwningGame.InitialGameSize.Width - this.Size.Width;
            newVelocity.X *= -1;
        } 
        // Check Y
        if (nextPosition.Y < 0)
        {
            nextPosition.Y = 0;
            newVelocity.Y *= -1;
        }
        else if (nextPosition.Y + this.Size.Height > OwningGame.InitialGameSize.Height)
        {
            nextPosition.Y = OwningGame.InitialGameSize.Height - this.Size.Height;
            newVelocity.Y *= -1;
        } 
        this.Position = nextPosition; 
        if (this.Velocity != newVelocity)
            this.Velocity = newVelocity;
    }
    #endregion
}
(注:我也改变了我的速度在TestGame是每秒200个像素,因为它永远把对那该死的球去的边缘在50个百分点使用任何你喜欢的值不过,并不是每个人都因为心急,因为我:P )

基本上就是这样。 现在,您有了一个用于简单游戏开发的基本库,并希望对如何使用它有所了解。 我将在本文中附加两个文件,GameLibrary的完整代码以及TestGame Windows应用程序的完整代码(在本文结尾处)。 这些只是项目本身,如果要运行它们,则必须自己将它们添加到解决方案文件中。

请随时发表评论/建议。 我可能会在下个月的某个时候写下一篇文章。 该文章将讨论使用此GameLibrary制作一个简单的Bricks游戏,所以请注意:)

谢谢阅读!

附加的文件
文件类型:zip GameLibrary.zip (5.6 KB,397观看次数)
文件类型:zip TestArea.zip (10.7 KB,368视图)

From: https://bytes.com/topic/c-sharp/insights/932420-simple-windows-game-part-1-game-framework

  • 0
    点赞
  • 0
    评论
  • 0
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

表情包
插入表情
评论将由博主筛选后显示,对所有人可见 | 还能输入1000个字符
©️2021 CSDN 皮肤主题: 编程工作室 设计师:CSDN官方博客 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值