Silverlight 中的 Pong





0/5 (0投票)
一个关于如何编写基于经典 Pong 游戏的 Silverlight 教程。
引言
本文讲解如何使用 Silverlight 编写游戏。文章解释了制作游戏所需的机制,如何实现它们,并展示了它们在一个完整游戏中的应用。
游戏力求简单(但功能齐全)。这使得我们可以通读全部代码并理解其工作原理。我故意没有编写任何精灵框架或辅助函数,以便文章能够专注于代码如何访问 Silverlight 框架和游戏的基本机制。
本文的目的是让您在阅读后能够了解:
- 制作游戏所需的内容
- 您可能需要调用 Silverlight 的哪些功能来实现此游戏
背景
本文的最初想法是看看使用 Silverlight 我能否像 80 年代在我的 TI99/4A 或 BBC Model B 上那样快速编写一个简单的游戏。在 80 年代,仅需几个小时和几行代码就可以编写像打砖块、吃豆人或俄罗斯方块这样的简单游戏。
我决定编写最简单的游戏:只有一个挡板和一个球,并尝试以 80 年代的风格(即不使用面向对象编码)编写代码。
这次实验的结论是,Silverlight 确实提供了一个允许相当容易地编写游戏的平台。完整的游戏源代码不足 200 行 C# 代码和 50 行 XAML 代码。
Using the Code
这就是全部代码。对于一个完整的游戏来说,这并不算长,不是吗?
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Shapes;
namespace SpriteDemo1
{
public partial class MainPage : UserControl
{
enum GameState { RUNNING, PAUSED, GAMEOVER };
private double ballPosX;
private double ballSpeedX;
private double ballPosY;
private double ballSpeedY;
private double paddlePosX;
private DateTime PreviousTime;
private double paddleSpeed;
private GameState gameState;
private int score;
private int HiScore;
private int Life;
private Random rnd = new Random();
public MainPage()
{
InitializeComponent();
HiScore = 0;
this.textBlockGameStatus.Text =
"Please Resize this Window to a pleasant size\n"+
"Then Click on the game area to Start Game";
gameState = GameState.GAMEOVER;
CompositionTarget.Rendering += newFrame;
}
private void NewGame()
{
score = 0;
this.textBlockScore.Text = "Score: " + score.ToString();
this.textBlockHiScore.Text = "Hi Score: " + HiScore.ToString();
Life = 3;
NewLife();
}
private void NewLife()
{
const double START_MARGIN = 10;
Life--;
this.textBlockLife.Text = Life.ToString() + " Balls Left";
ballPosX = START_MARGIN + rnd.NextDouble() *
(this.GameCanvas.ActualWidth - 2 * START_MARGIN);
ballSpeedX = 0.3;
ballPosY = this.GameCanvas.ActualHeight / 2 - this.Ball.ActualHeight;
ballSpeedY = -0.3;
paddleSpeed = 0;
paddlePosX = (this.GameCanvas.ActualWidth - this.Paddle.ActualWidth) / 2;
PreviousTime = DateTime.Now;
this.Paddle.Visibility = System.Windows.Visibility.Visible;
this.Ball.Visibility = System.Windows.Visibility.Visible;
gameState = GameState.PAUSED;
this.textBlockGameStatus.Text = "Ready";
}
protected void newFrame(object sender, EventArgs e)
{
DateTime now;
double ellapsedms;
now = DateTime.Now;
ellapsedms = (now - PreviousTime).Milliseconds;
//this.textBlockHiScore.Text =
// (1000 / ellapsedms).ToString() + " fps";
PreviousTime = now;
if (gameState == GameState.RUNNING)
{
if ((ballSpeedX > 0 && ballPosX +
Ball.ActualWidth > this.GameCanvas.ActualWidth)
|| (ballSpeedX < 0 && ballPosX < 1))
{
ballSpeedX = -ballSpeedX;
this.BeepX.Position = TimeSpan.Zero;
this.BeepX.Play();
}
ballPosX += ballSpeedX * ellapsedms;
if (ballSpeedY > 0
&& ballPosY + Ball.ActualHeight >
this.GameCanvas.ActualHeight - this.Paddle.ActualHeight)
{
if (ballPosX + this.Ball.ActualWidth > paddlePosX
&& ballPosX < paddlePosX + this.Paddle.ActualWidth)
{
this.BeepPaddle.Position = TimeSpan.Zero;
this.BeepPaddle.Play();
ballSpeedY = -ballSpeedY - 0.05;
ballSpeedX += paddleSpeed;
score++;
this.textBlockScore.Text = "Score: " + score.ToString();
}
else
{
gameState = GameState.GAMEOVER;
this.Paddle.Visibility = System.Windows.Visibility.Collapsed;
this.Ball.Visibility = System.Windows.Visibility.Collapsed;
if (Life > 0)
{
this.BeepLost.Position = TimeSpan.Zero;
this.BeepLost.Play();
this.textBlockGameStatus.Text = "Click or Press Space";
}
else
{
this.BeepGameOver.Position = TimeSpan.Zero;
this.BeepGameOver.Play();
if (score > HiScore)
{
HiScore = score;
this.textBlockHiScore.Text = "Hi Score: " + HiScore.ToString();
}
this.textBlockGameStatus.Text =
"Game Over\nClick or Press Space To Start a New Game";
}
}
}
if (ballSpeedY < 0 && ballPosY < 1)
{
ballSpeedY = -ballSpeedY;
this.BeepTop.Position = TimeSpan.Zero;
this.BeepTop.Play();
}
ballPosY += ballSpeedY * ellapsedms;
// move the paddle
if ((paddleSpeed > 0 && paddlePosX +
this.Paddle.ActualWidth < this.GameCanvas.ActualWidth)
|| (paddleSpeed < 0 && paddlePosX > 1))
{ paddlePosX += paddleSpeed * ellapsedms; }
}
//display the sprites at their correct position
this.Ball.SetValue(Canvas.TopProperty, ballPosY);
this.Ball.SetValue(Canvas.LeftProperty, ballPosX);
this.Paddle.SetValue(Canvas.TopProperty,
this.GameCanvas.ActualHeight - this.Paddle.ActualHeight);
this.Paddle.SetValue(Canvas.LeftProperty, paddlePosX);
}
private void LayoutRoot_KeyDown(object sender, KeyEventArgs e)
{
if (gameState == GameState.PAUSED &&
(e.Key == Key.Left || e.Key == Key.Right))
{
gameState = GameState.RUNNING;
this.textBlockGameStatus.Text = "";
}
if (e.Key == Key.Left) { paddleSpeed = -0.5; }
if (e.Key == Key.Right) { paddleSpeed = +0.5; }
}
private void LayoutRoot_KeyUp(object sender, KeyEventArgs e)
{
if (e.Key == Key.Left || e.Key == Key.Right) { paddleSpeed = 0; }
}
private void button1_LostFocus(object sender, RoutedEventArgs e)
{
if (gameState == GameState.RUNNING)
{
gameState = GameState.PAUSED;
this.textBlockGameStatus.Text = "Please Click on The Game";
}
}
private void button1_GotFocus(object sender, RoutedEventArgs e)
{
if (gameState == GameState.PAUSED)
{ this.textBlockGameStatus.Text = "Game Paused"; }
}
private void button1_Click(object sender, RoutedEventArgs e)
{
if (gameState == GameState.GAMEOVER)
{
if (Life > 0)
{ NewLife(); }
else
{ NewGame(); }
}
}
}
}
关注点
游戏循环
对于游戏动画,您必须不断重绘游戏区域以更新每个移动项(称为精灵)的位置。为了获得流畅的移动,重绘速度必须足够快,通常是每秒 50 或 60 次。每个新生成的图像称为一帧,因此我们必须每秒生成 50 或 60 帧(即 50 或 60 fps)。
Silverlight 3 提供了触发重复帧生成的好方法。
CompositionTarget.Rendering+= newFrame
这确保了 newFrame
函数每秒被调用 60 次。
newFrame
函数(您可以为它命名,名字随意)必须具有以下定义:
void newFrame(object sender, EventArgs e)
游戏布局
最初的想法是使用 Canvas
作为整个应用程序,并在其上为精灵绘制形状。但这还不够:我们需要一个元素来接收焦点并检测键盘输入,而 Canvas
和 Shape
都不适合。这就是我添加按钮的原因。理想情况下,该按钮应覆盖整个游戏区域,以便用户单击游戏区域时,会将焦点移交给按钮。
由于 Canvas
不会调整其包含的子项的大小,我被迫添加了一个网格,并将画布和(完全透明的)按钮放在划定游戏区域的网格单元格中。按钮和画布被配置为拉伸以覆盖完整的网格单元格。
为了在游戏区域中心显示消息(例如“游戏结束”),我在 Canvas
和透明按钮之间添加了一个 TextBlock
。我还使用 TextBlock
来显示分数和生命数。但由于它们不是游戏区域的一部分,所以我将它们放在另一个网格行中。
最终,我们得到以下结构:
游戏状态机
游戏关联着一个状态机。当前状态由变量 gameState
跟踪。可能的状态是:
GameState.GAMEOVER
:此状态表示屏幕上没有可见的球,并且需要调用NewGame()
或NewLife()
来为球分配新的起始位置。当球错过挡板时达到此状态。它也是游戏的初始状态。GameState.PAUSED
:此状态表示球的位置正确且可见,但所有精灵动画都被冻结。此状态发生在球的位置设置完成后(即调用NewLife()
之后)或游戏失去焦点时。事实上,当玩家无法再移动挡板,因为另一个窗口获得了焦点时,球仍然继续移动会很烦人。GameState.RUNNING
:这是动画在屏幕上移动精灵的状态。当玩家通过按左箭头或右箭头键开始(或重新开始)游戏时,将出现此状态。
键盘和鼠标处理
键盘和鼠标处理依赖于五个事件处理程序:
LayoutRoot_KeyDown(object sender, KeyEventArgs e)
:当用户按下按键时(从按钮冒泡上来)会发生此事件;如果按键是左箭头或右箭头,则挡板速度(即变量paddleSpeed
)将根据所选按键设置为正值或负值。此事件处理程序还负责从PAUSED
状态到RUNNING
状态的转换。LayoutRoot_KeyUp(object sender, KeyEventArgs e)
:当玩家释放按键时会发生此事件。通过将变量paddleSpeed
设置为零,它可以停止挡板的移动。button1_LostFocus(object sender, RoutedEventArgs e)
:此事件处理程序检测覆盖整个游戏区域的按钮的焦点丢失。由于此按钮是此应用程序中唯一可以获得焦点的元素,因此失去焦点意味着应用程序无法处理键盘,并且状态必须变为PAUSED
。button1_GotFocus(object sender, RoutedEventArgs e)
:此事件处理程序检测焦点何时返回。这不会改变游戏状态(需要按键事件才能改变),因此仅用于更新游戏区域中心的文本。button1_Click(object sender, RoutedEventArgs e)
:最后一个事件处理程序检测覆盖整个游戏区域的透明按钮何时被单击(由于它始终具有焦点,这可能来自鼠标单击或按钮的默认按键:空格键)。此处理程序用于检测用户何时请求启动新球。
帧生成和精灵移动
newFrame
函数每秒由游戏循环调用 60 次。它必须执行以下操作:
- 确定自上次调用
newFrame
以来经过了多少时间。 - 计算每个精灵的新位置(在当前游戏中,我们只有两个精灵:挡板和球,但在更复杂的游戏如太空侵略者中,您可能有许多精灵)。
- 确定碰撞并根据需要更新分数和游戏状态。
- 在正确的位置显示精灵。
1. 确定经过的时间
早期,每台 PC 的 CPU 运行频率为 4.77MHz,当时编写的游戏期望的是该 CPU 时钟频率。然而,当 CPU 速度升级到 12MHz(或更高)时,这些游戏变得无法玩,因为所有精灵的速度都增加了。为避免此问题,我们需要精确知道自上次调用 newFrame
以来经过了多长时间,并使用此经过的时间来确定每个精灵必须移动多远。我们将在变量 private DateTime PreviousTime;
中保留上次调用的时间戳,并使用以下代码来测量经过的时间:
protected void newFrame(object sender, EventArgs e)
{
DateTime now;
double ellapsedms;
now = DateTime.Now;
ellapsedms = (now - PreviousTime).Milliseconds;
PreviousTime = now;
...
2. 计算新的精灵位置
精灵与 4 个变量相关联:X 位置、Y 位置、X 速度和 Y 速度。
(注意:挡板没有 Y 位置和 Y 速度,因为它始终位于屏幕底部。)
private double ballPosX;
private double ballSpeedX;
private double ballPosY;
private double ballSpeedY;
private double paddlePosX;
private double paddleSpeed;
要移动精灵,我们将速度乘以经过的时间加到位置上:
ballPosX += ballSpeedX * ellapsedms;
ballPosY += ballSpeedY * ellapsedms;
paddlePosX += paddleSpeed * ellapsedms;
3. 确定碰撞并根据需要更新分数和游戏状态
在球与墙或挡板碰撞的情况下,我们会反转速度。
以下示例显示了与侧墙碰撞的情况:
if ((ballSpeedX > 0 && ballPosX + Ball.ActualWidth >
this.GameCanvas.ActualWidth) || (ballSpeedX < 0 && ballPosX < 1))
{
ballSpeedX = -ballSpeedX;
this.BeepX.Position = TimeSpan.Zero;
this.BeepX.Play();
}
注意与球撞墙相关的声音生成。
在 newFrame
函数中,当球在游戏区域底部向下运动时,会更新分数和游戏状态。
if (ballSpeedY > 0 && ballPosY + Ball.ActualHeight >
this.GameCanvas.ActualHeight - this.Paddle.ActualHeight)
{
...
如果球击中挡板:
if (ballPosX + this.Ball.ActualWidth > paddlePosX
&& ballPosX < paddlePosX + this.Paddle.ActualWidth)
我们会播放声音,改变球的速度,并增加分数。
this.BeepPaddle.Position = TimeSpan.Zero;
this.BeepPaddle.Play();
ballSpeedY = -ballSpeedY - 0.05;
ballSpeedX += paddleSpeed;
score++;
this.textBlockScore.Text = "Score: " + score.ToString();
否则,挡板错过了球,我们就进入“GAMEOVER
”状态,并隐藏球和挡板。
else
{
gameState = GameState.GAMEOVER;
this.Paddle.Visibility = System.Windows.Visibility.Collapsed;
this.Ball.Visibility = System.Windows.Visibility.Collapsed;
...
4. 在正确的位置显示精灵
要设置精灵在画布中的位置,我们会将每个形状的 Top
和 Left
属性设置为(新计算的)正确值。
this.Ball.SetValue(Canvas.TopProperty, ballPosY);
this.Ball.SetValue(Canvas.LeftProperty, ballPosX);
this.Paddle.SetValue(Canvas.TopProperty,
this.GameCanvas.ActualHeight - this.Paddle.ActualHeight);
this.Paddle.SetValue(Canvas.LeftProperty, paddlePosX);
声音
Silverlight 3 提供了与 MP3 声音进行交互式播放的简单方法。
- 将 .mp3 文件作为 Silverlight 应用程序的资源。
- 为每个 .mp3 文件在 XAML 中声明一个
MediaElement
。 - 当您需要播放声音时,请使用以下代码:
<MediaElement x:Name="BeepGameOver" Source="GameOver.mp3" AutoPlay="False" />
this.BeepGameOver.Position = TimeSpan.Zero;
this.BeepGameOver.Play();
当球靠近角落撞墙时,球有可能同时(但不完全)击中侧墙和顶墙。这意味着我们必须在侧墙“哔”声结束之前生成顶墙的“哔”声。如果我们对两者使用相同的 MediaElement
,第一个“哔”声将被第二个中断。这不是我们想要的。为了避免这种情况,我们在 XAML 中为同一个 MP3 文件创建了两个 MediaElement
:一个用于侧墙,一个用于顶墙,这样播放顶墙的“哔”声就不会停止侧墙的“哔”声。
<MediaElement x:Name="BeepX" Source="beep1.mp3" AutoPlay="False" />
<MediaElement x:Name="BeepTop" Source="beep1.mp3" AutoPlay="False" />
历史
- 2011 年 8 月 31 日:第一个版本。