C#游戏开发教程

  1. 1、下载文档前请自行甄别文档内容的完整性,平台不提供额外的编辑、内容补充、找答案等附加服务。
  2. 2、"仅部分预览"的文档,不可在线预览部分如存在完整性等问题,可反馈申请退款(可完整预览的文档不适用该条件!)。
  3. 3、如文档侵犯您的权益,请联系客服反馈,我们会尽快为您处理(人工客服工作时间:9:00-18:30)。

C#开发WPF/Silverlight动画及游戏系列教程(Game Course)
(一)让物体动起来①
序:自从QXGame(WPF GAME ENGINE)游戏引擎公布以来,受到很多朋友的热切关注,于是乎有了写教程的想法。那么从今天开始,我将带领大家一步一步的学会如何使用纯C#开发WPF/Silverlight游戏引擎,过程中我会尽量的开源,并对相关小技巧进行解释和介绍,比较复杂的算法原理我会给大家一条绝对可行的思路,至于如何处理这些复杂的算法,那是仁者见仁,智者见智了,或许您写的算法比我的更好呢。
多余的话不多说了,最后来句发自肺腑的话吧:本系列教程的书写编辑花费作者很多心血,没有功劳也有苦劳哇,所以请需要转载的朋友们用突出的字体或颜色标明在您的文章开头,您的大力支持是鼓励我不断写下去的动力。
前言:WPF/Silverlight矢量动画的描述我就不多说了,关于WPF/Silverlight与Flash的比较网上也是一堆一堆的,这里只想客观的告诉读者下面两点:
一、WPF开发的是桌面应用程序,自包括Vista在内以后的Windows系列操作系统均大量以之为主流图形工具,即将全面取代Winform,并且Windows 7将集成.NET3.5+框架,在当今Windows系列操作系统占据90%同类市场的现状下,这意味着什么呢?
二、Silverlight基于一个约4M左右的MINI型.NET框架,目前版本2.0,3.0的beta英文版,从发展趋势看是绝对有与Flash抗衡并且在未来超越它的可能性。Silverlight的优势更表现在它可以用一切.NET语言例如C#,,C++.NET等开发,拓展度与可以参与开发的人群远远高于只能用AS开发的FLASH。
转入正题,网上已经有很多关于如何创建WPF/Silverlight动画的教程,但是均为使用Blend工具制作,或直接写在xaml代码内的动画,这样往往造成很多朋友误以为其实WPF/Silverlight不就是MS的Flash?诚然,如果您真的像那些教程里说的去开发WPF/Silverlight程序,我个人觉得一点意义都没有。这样开发出来的东西根本就超越不了Flash,那何苦还要投入如此多的精力来学习它?
所以本系列教程将全方位的以纯C#程序语言进行动态创建一切可视化对象,从而构建出一个如QXGame(WPF GAME ENGINE)游戏引擎,这才是我本系列教程希望达到的目的。
(注:本教程使用的开发工具为Visual studio 2008 版本sp1)
好了,那么我首先介绍第一种动态创建动画的方法,这也是官方推荐的Storyboard动画。该类型动画您可以在网络上查阅相关资料进行了解,这里不累述了,那么我们直接进入主题:
首先我们新建一个WPF项目,接下来打开Window1.xaml进入视图代码编辑器,这里我们这样写:
xmlns="/winfx/200

6/xaml/presentation"
xmlns:x="/winfx/2006/xaml" Title="WPFGame">
MouseLeftButtonDown="Carrier_MouseLeftButtonDown" />

这段代码我创建了一个名叫Carrier的Canvas(画布)容器布局控件,并设置它的尺寸为800*600,背景银色,最后注册一个鼠标在它上面点击的事件。那么为什么要选择Canvas作为容器呢?因为Canvas可以实现它内部的控件任意的绝对定位,可以很方便的处理物体的移动。
界面容器元素布局好了,那么接下来就动态创建物体对象了:
Rectangle rect;//创建一个方块作为演示对象
public Window1() {
InitializeComponent();
rect = new Rectangle();
rect.Fill = new SolidColorBrush(Colors.Red);
rect.Width = 50;
rect.Height = 50;
rect.RadiusX = 5;
rect.RadiusY = 5;
Carrier.Children.Add(rect);
Canvas.SetLeft(rect, 0);
Canvas.SetTop(rect, 0);
}
这里我创建了一个50*50象素,圆角5*5红色的方块对象,并且将它作为子控件添加进Carrier中,并且初始化它在Carrier中的位置: Canvas.SetLeft(rect, 0); Canvas.SetTop(rect, 0);
对象准备好了,那么接下来就是实现动画了。我们要实现的是鼠标点哪它就移动到哪:
private void Carrier_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) {
//创建移动动画
Point p = e.GetPosition(Carrier);
Storyboard storyboard = new Storyboard();
//创建X轴方向动画
DoubleAnimation doubleAnimation = new DoubleAnimation(
Canvas.GetLeft(rect),
p.X,
new Duration(TimeSpan.FromMilliseconds(500))
);
Storyboard.SetTarget(doubleAnimation, rect);
Storyboard.SetTargetProperty(doubleAnimation, new PropertyPath("(Canvas.Left)"));
storyboard.Children.Add(doubleAnimation);
//创建Y轴方向动画
doubleAnimation = new DoubleAnimation(
Canvas.GetTop(rect),
p.Y,
new Duration(TimeSpan.FromMilliseconds(500))
);
Storyboard.SetTarget(doubleAnimation, rect);
Storyboard.SetTargetProperty(doubleAnimation, new PropertyPath("(Canvas.Top)"));
storyboard.Children.Add(doubleAnimation);
//将动画动态加载进资源内
if (!Resources.Contains("rectAnimation")) {
Resources.Add("rectAnimation", storyboard);
}
//动画播放
storyboard.Begin();
}
从上面代码我们可以看到,首先获取鼠标点击点相对于Carrier中的坐标位置p,然后创建故事板storyboard和Double类型动画do

ubleAnimation,doubleAnimation有3个参数,分别代表开始值,结束值,动画经历时间,接着通过Storyboard.SetTarget()和Storyboard.SetTargetProperty()分别设置动画的目标及要修改的动画目标属性,再下来将doubleAnimation添加进storyboard中,这样重复两次分别实现X轴和Y轴方向的动画。当这些处理完后,最后还需要将storyboard添加进Resources资源内,这样程序才能识别。一切就绪后,通过代码storyboard.Begin()来开始动画。
大家按Ctrl+F5,然后在窗体上随便点点,方块是不是会移动了呢?呵呵。

小结:Storyboard动画是基于时间线的矢量动画,它与传统的基于图片轮换形成的动画不同,它的原理是通过时时的改变对象属性而形成的,第一次接触的朋友们可能会觉得比较吃力,但是慢慢体会一下,多练习一下就会渐渐的理解了。
下一节我将继续讲解动态创建动画的第二种方法,敬请关注。
(二)让物体动起来②
第二种方法,CompositionTarget动画,官方描述为:CompositionTarget对象可以根据每个帧回调来创建自定义动画。其实直接点,CompositionTarget创建的动画是基于每次界面刷新后触发的,与窗体刷新率保持一致,所以频率是固定的,很难人工介入控制。
那么如何使用它?xaml的界面代码还是和上一篇中描述的一样,这里不累述了。那么接下来就是创建对象并注册事件,全部代码如下:
Rectangle rect; //创建一个方块作为演示对象
double speed = 1; //设置移动速度
Point moveTo; //设置移动目标
public Window1() {
InitializeComponent();
rect = new Rectangle();
rect.Fill = new SolidColorBrush(Colors.Red);
rect.Width = 50;
rect.Height = 50;
rect.RadiusX = 5;
rect.RadiusY = 5;
Carrier.Children.Add(rect);
Canvas.SetLeft(rect, 0);
Canvas.SetTop(rect, 0);
//注册界面刷新事件
CompositionTarget.Rendering += new EventHandler(Timer_Tick);
}
private void Carrier_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) {
moveTo = e.GetPosition(Carrier);
}
CompositionTarget的注册事件方法为:
CompositionTarget.Rendering += new EventHandler(Timer_Tick);
因为我们要实现的是鼠标点哪方块就移动到哪,所以我用一个变量moveTo保存鼠标点击点的Point。并在鼠标左键事件中赋值:moveTo = e.GetPosition(Carrier);同时设置方块X,Y方向的速度均为speed。
接下来就是实现Timer_Tick了,它是基于窗体的时时刷新事件。我们这样写:
private void Timer_Tick(object sender, EventArgs e) {
double rect_X = Canvas.GetLeft(rect);
double rect_Y = Canvas.GetTop(rect);
Ca

nvas.SetLeft(rect, rect_X + (rect_X < moveTo.X ? speed : -speed));
Canvas.SetTop(rect, rect_Y + (rect_Y < moveTo.Y ? speed : -speed));
}
首先获取方块的X,Y位置,接下让方块的X,Y与moveTo的X,Y进行比较而判断是+speed还是-speed,这里的逻辑需要朋友们自行领会了。
好了Ctrl+F5测试一下,呵呵,是不是同样也动起来了呢?
可是大家会发现一个很大的问题:这方块移动得也太勉强了吧,抖来抖去的而且移动得也不平滑,是不是CompositionTarget有问题?其实不然,因为之前的Storyboard动画它不存在X,Y轴的速度,只需要设定起点和终点以及过程经历的时间就可以平滑的移动了,而CompositionTarget需要分别设定X,Y轴的速度,而我们这为了简单演示,X,Y轴的速度speed均设置成了5,这在现实使用中是绝对不合理的。因此,如果要模拟实际效果,必须计算终点和起点的正切值Tan,然后再根据直线速度speed通过Tan值计算出speed_X,speed_Y,最后改写成:
Canvas.SetLeft(rect, rect_X + (rect_X < moveTo.X ? speed_X : -speed_X));
Canvas.SetTop(rect, rect_Y + (rect_Y < moveTo.Y ? speed_Y : -speed_Y));
这样才能实现真实的移动(具体算法就不讨论了)。
这一节讲解了如何使用CompositionTarget主界面刷新线程实现基于帧的动画,下一节我将讲解第三种动态创建动画的方法,并会对这三种方法进行一个归纳比较。
(三)让物体动起来③
第三种方法,DispatcherTimer动画,该类型动画与CompositionTarget动画类似,是基于界面线程的逐帧动画,但他与CompositionTarget动画不同,DispatcherTimer动画可以轻松的进行参数设置:
xaml界面代码仍然沿用第一节的,那么接下来我们在后台代码中创建相关对象:
Rectangle rect; //创建一个方块作为演示对象
double speed = 5; //设置移动速度
Point moveTo; //设置移动目标
public Window3() {
InitializeComponent();
rect = new Rectangle();
rect.Fill = new SolidColorBrush(Colors.Red);
rect.Width = 50;
rect.Height = 50;
rect.RadiusX = 5;
rect.RadiusY = 5;
Carrier.Children.Add(rect);
Canvas.SetLeft(rect, 0);
Canvas.SetTop(rect, 0);
//定义线程
DispatcherTimer dispatcherTimer = new DispatcherTimer(DispatcherPriority.Normal);
dispatcherTimer.Tick += new EventHandler(Timer_Tick);
dispatcherTimer.Interval = TimeSpan.FromMilliseconds(50); //重复间隔
dispatcherTimer.Start();
}

private void Carrier_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) {
moveTo = e.GetPosition(Carrier);
}

private void Timer_Tick(object sender, EventArgs e) {
double rect_X

= Canvas.GetLeft(rect);
double rect_Y = Canvas.GetTop(rect);
Canvas.SetLeft(rect, rect_X + (rect_X < moveTo.X ? speed : -speed));
Canvas.SetTop(rect, rect_Y + (rect_Y < moveTo.Y ? speed : -speed));
}
与上一节的代码类似,不同的地方其实也就是声明动画线程处,共4句:
DispatcherTimer dispatcherTimer = new DispatcherTimer(DispatcherPriority.Normal);
dispatcherTimer.Tick += new EventHandler(Timer_Tick);
dispatcherTimer.Interval = TimeSpan.FromMilliseconds(50);
dispatcherTimer.Start();
第一句申明一个界面计时器DispatcherTimer ,并且设置其线程优先级别为Normal,这是标准设置,你可以根据你自己的需求进行更改,一共10个级别。
第二句注册Tick 事件,也就是计时器间隔触发的事件。
第三句设置Tick 事件的间隔,可以有很多方式,我使用的是TimeSpan.FromMilliseconds(),即间隔单位为毫秒。
第四句启动线程。
是不是很简单?这样的话可以很轻松的通过Interval 来控制刷新一个对象属性的频率了。接下来我们同样使用Ctrl+F5来测试一下成果。呵呵,结果和第二种动画方法是一样的,存在同样的问题,因为毕竟两种动画的原理是一致的。
那么到此,三种动态创建动画的方法都已经详细介绍过了,大家可能会有种感觉,比较钟情于第一种WPF/Silverlight推荐的Storyboard动画,既直观又方便使用,而且仿佛不易出错。其实这3种动画都有它特定的使用场合。
第一种动画适合创建简单的对象位移及直接性质的属性更改(在后面的教程中,我还将更深入的挖掘Storyboard动画的潜力,动态创建更复杂的基于KeyFrame的关键帧动画)。
第二种动画适合全局属性的时时更改,例如我们后面要讲到的敌人或NPC以及地图等全体性的相对位移及属性更改时就要用到它了。
第三种动画则非常适合运用在Spirit(角色)的个人动画中,例如角色的移动,战斗,施法等动作。
小结:前三节分别讲解了Storyboard动画,CompositionTarget动画,DispatcherTimer动画,并横向分析了不同的场合对应不同的动画应用模式,这些将是构成WPF/Silverlight游戏引擎的基础。
下一节我将介绍如何使用DispatcherTimer动画让对象活起来,敬请关注。
(四)实现2D人物动画①
通过前面的学习,我们掌握了如何动态创建物体移动动画,那么接下来我将介绍WPF中如何将物体换成2D游戏角色,并通过使用前面所讲的DispatcherTimer计时器来实现2D人物角色的各种动作动画。
动态实现2D人物角色动画目前有两种主流方法,下面我会分别进行介绍。
第一种方法我称之为图片切换法,准备工作:首先通过3DMAX等工具3D渲染2D的方法制作出角色,然后将角色每个动作均导出8个方向每方向若干帧的系列

图片(如果是有方向的魔法图片,很多2D-MMORPG往往会导出16个方向的系列帧图片以求更为逼真),即将每个人物每个动作的各方向的每帧均存成一张图片,如下图仅以从破天一剑游戏中提取的素材为例:
(特别申明:本系列教程所使用的如有注明归属权的图片素材均来源于网络,请勿用于商业用途,否则造成的一切后果均与本人无关。

从上图可以看到,我将人物向右方跑步共8帧图片通过Photoshop分别将画布等比例扩大成150*150象素图片(因为是提取的素材,初始宽和高是不均衡值,所以必须扩大成自己的需求,这样人物会在图片中居中,并且为后期加入武器或坐骑留好余地。稍微的偏离也可以在后期进行微调),并将他们从开始到结束分别命名为0.png,1.png,2.png,3.png,4.png,5.png,6.png,7.png(这里还要顺带一提的是,图片最好背景Alpha透明,否则在算法上还要进行去色,不是多此一举吗?至于为何是png而不是gif,我这里考虑到Silverlight目前只支持png和jpg,为了更多的通用性,当然如果您只用WPF,gif或png均可)。最后在项目中我们新建一个文件夹取名叫Player,然后将这8张图片保存在该目录下,到此准备工作终于结束了,忽忽。。还真够累的。
接下来就是重头戏了,如何通过纯C#来实现动态创建人物跑动动作动画呢?嘿嘿,且看下面代码。
int count = 1;
Image Spirit;
public Window4() {
InitializeComponent();
Spirit = new Image();
Spirit.Width = 150;
Spirit.Height = 150;
Carrier.Children.Add(Spirit);
DispatcherTimer dispatcherTimer = new DispatcherTimer();
dispatcherTimer.Tick += new EventHandler(dispatcherTimer_Tick);
dispatcherTimer.Interval = TimeSpan.FromMilliseconds(150);
dispatcherTimer.Start();
}

private void dispatcherTimer_Tick(object sender, EventArgs e) {
Spirit.Source = new BitmapImage((new Uri(@"Player\" + count + ".png", UriKind.Relative)));
count = count == 7 ? 0 : count + 1;
}
首先我们申明一个count变量用于记录当前切换到了哪张png图片了,接下来创建一个Image控件,取名叫Spirit,一看就知道它就是这节的主角啦,嘿嘿,写了那么多,主角终于要登场啦!
初始化后我们分别设置Spirit宽高各为150,并将之做为子控件添加进Carrier中,到此主角完成了登场过程。
接下来创建DispatcherTimer动画,相关内容可以查看第三节。
最后我们在dispatcherTimer_Tick事件中进行图片的切换操作:即设置每间隔150毫秒后Spirit的图片源为Player文件夹中的count.png图片,设置完后如果count==7即已经到了最后一帧,那么count回到第一帧即c

ount=0;否则count+=1,这是很容易理解的了。
好了,按下Ctrl+F5,嘿嘿,主角会跑动了。当然啦,目前只是原地跑步,但是已经向成功迈出了一大步,难到不是吗?

下一节,我将继续介绍动态创建人物动画的第二种方法,敬请关注。
(五)实现2D人物动画②
第二种方法我称之为图片截取法,准备工作:这里我以创建主角向右方向施法动画为例。首先需要将10帧150*150的图片通过Photoshop或其他方式合成为一张1500*150的大图,如下图:

从图上可以很清晰的看出主角的整个流畅的施法流程。然后将这张图片保存到项目文件夹中的\bin\Debug\文件夹中,如下图:

为什么必须放在这呢?因为后面的代码中BitmapFrame.Create()函数调用相对路径图片只认该文件夹,为什么?问MS
OK,xaml代码仍旧和前面章节的一样,那么接下来就是后台C#代码了:
Image Spirit;
int count = 1;
public Window5() {
InitializeComponent();
Spirit = new Image();
Spirit.Width = 150;
Spirit.Height = 150;
Carrier.Children.Add(Spirit);
DispatcherTimer dispatcherTimer = new DispatcherTimer();
dispatcherTimer.Tick += new EventHandler(dispatcherTimer_Tick);
dispatcherTimer.Interval = TimeSpan.FromMilliseconds(150);
dispatcherTimer.Start();
}

private void dispatcherTimer_Tick(object sender, EventArgs e) {
Spirit.Source = cutImage("PlayerMagic.png", count * 150, 0, 150, 150);
count = count == 9 ? 0 : count + 1;
}

///


/// 截取图片
///

/// 文件名(包括地址+扩展名)
/// 左上角点X
/// 左上角点Y
/// 截取的图片宽
/// 截取的图片高
/// 截取后图片数据源
private BitmapSource cutImage(string imgaddress, int x, int y, int width, int height) {
return new CroppedBitmap(
BitmapFrame.Create(new Uri(imgaddress, UriKind.Relative)),
new Int32Rect(x, y, width, height)
);
}
从上面代码可以看出前半部分和上一节的一样,这里就不累述了,精华就在后面的cutImage方法,该方法可谓集天地之精华,日月之灵气。。。扯远了,该方法的详细描述已经写在上面,大家可以慢慢体会应该不难。
有了该尚方宝剑,那么大家应该也多少有点感觉了吧,最后在dispatcherTimer_Tick方法中,我们即调用该方法实现时时的图片截取来循环生成动画,Ctrl+F5看看,呵呵,主角会放魔法啦!

到此,我

分别介绍了图片切换法和图片截取法两种动态创建角色动画的方法,这两种方式都是很高效快速的,Silverlight只能使用第一种方法,而且也必须使用第一种方法,这涉及到Web下载资源容量问题,如果Silverlight在未来的版本能支持gif图片,那么取代png可以节约更多的资源下载空间。而WPF在这两种方法的取舍上更倾向于后者,后者更加灵活多变,但是需要事先将N多的图片合成,这就涉及到一个预备工作量的问题,当然如果您有好的函数,图片集的名字取得有序,直接就可以通过函数合成,我曾试过用函数直接将488张150*150图片在<3秒合成一张9150*1200的成品图,当然,这需要精致的算法。
下一节我将继续介绍如何将角色自身动画与移动动画相结合,创建完美的鼠标点击实现2D人物移动动画。敬请关注。
(六)完美移动
经过前面的介绍和学习,我们分别掌握了如何点击鼠标让对象移动,并且实现2D人物的动作动画。那么,如何将两者完美的进行融合呢?这一节的内容将涉及到很多重要的技术及技巧,很关键哦。
那么同样的,前台xaml还是保持不变,接下来看后台C#第一部分:
int count = 0;
Image Spirit;
Storyboard storyboard;
public Window6() {
InitializeComponent();
Spirit = new Image();
Spirit.Width = 150;
Spirit.Height = 150;
Carrier.Children.Add(Spirit);
Canvas.SetLeft(Spirit, 0);
Canvas.SetTop(Spirit, 0);
DispatcherTimer dispatcherTimer = new DispatcherTimer();
dispatcherTimer.Tick += new EventHandler(dispatcherTimer_Tick);
dispatcherTimer.Interval = TimeSpan.FromMilliseconds(150);
dispatcherTimer.Start();
}
private void dispatcherTimer_Tick(object sender, EventArgs e) {
Spirit.Source = new BitmapImage((new Uri(@"Player\" + count + ".png", UriKind.Relative)));
count = count == 7 ? 0 : count + 1;
}
上面代码基本上相对于前面几节没有太多改变,只是结合了第一节和第四节的内容。
那么再看C#第二部分:
private void Carrier_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) {
Point p = e.GetPosition(Carrier);
Move(p);
}

private void Move(Point p) {
//创建移动动画
storyboard = new Storyboard();
//创建X轴方向动画
DoubleAnimation doubleAnimation = new DoubleAnimation(
Canvas.GetLeft(Spirit),
p.X,
new Duration(TimeSpan.FromSeconds(1))
);
Storyboard.SetTarget(doubleAnimation, Spirit);
Storyboard.SetTargetProperty(doubleAnimation, new PropertyPath("(Canvas.Left)"));

storyboard.Children.Add(doubleAnimation);
//创建Y轴方向动画
doubleAnimation = new DoubleAnimation(
Canvas.GetTop(Spirit),
p.Y,
new Duration(TimeSpan.FromSeconds(1))
);
Storyboard.SetTarget(doubleAnimation, Spirit);
Storyboard.SetTargetProperty(doubleAnimation, new PropertyPath("(Canvas.Top)"));
storyboard.Children.Add(doubleAnimation);
//将动画动态加载进资源内
if (!Resources.Contains("rectAnimation")) {
Resources.Add("rectAnimation", storyboard);
}
//动画播放
storyboard.Begin();
}
不难看出鼠标左键点击事件中Move()方法,该方法大家如果熟悉第一节的话将非常好理解,通过将这两段代码进行合成,即可以实现鼠标点哪主角就向哪移动,同时主角的动画始终保持为跑步状态。
那么该动画并非完美,存在以下3个问题:
一、主角始终为跑步状态,那么能否当主角移动到目的地后即变成站立状态呢?
of course,方法不要太多,这里我给大家一个小提示,例如我们可以dispatcherTimer_Tick事件中进行如下判断:
if (storyboard != null && storyboard.GetCurrentTime() == TimeSpan.FromSeconds(1)) {
TODO...
//主角的图片源切换成站立系列帧图片即可。
}
当然此方法只是N多方法之一。
二、主角定位的坐标始终处于图片的左上角,能否定位到主角的脚底,例如我鼠标点哪,主角移动到该处后脚的位置站在此点上,实现精确定位?
这其实并不难,涉及到一个图片定位算法问题,您需要设置一个Point Spirit_Position{get;set}属性来存储主角的坐标。并且该坐标的Spirit_Position.X,Spirit_Position.Y值分别定位到主角的脚底,如下图:

然后在以后的调用中都使用该坐标来取代Canvas.getLeft(),Canvas.getTop()。
三、主角朝向如何实现8个方向,即往哪边跑就朝向哪边?
这就是典型的算法问题了,其实也很简单,根据主角移动的目标Target.X和Target.Y分别与主角初始位置的Old.X和Old.Y进行一个角度计算,然后根据判断返回0-7(int),8个数字分别代表8个朝向,这样在Spirit.Source设置时就调用相应角度的图片源系列帧即可。
到此,我们已经能够完美的实现角色的移动与停止等动画,接下来的章节我将就地图结构与主角在地图中的处理进行详细讲解,敬请关注。
(七)传说中的A*寻径算法
关于地图引擎方面的处理涉及到两个方面的知识:
1)地图的实现(包括地图的切割、合成、呈现方式等)
2)地图物件的实现(包括地图中实现寻路、遮罩、传送点等)
为了让大家能更加有兴趣深入后面的知识,我选择先

从地图寻路开始讲解吧:
目前游戏中的寻路最经典的莫过于A*(A Star)寻路了,它是一种寻路思维的合集,那么基于它产生的算法则又有多种,例如曼哈顿启发式算法、对角线取径算法、欧几里德几何算法、最大取值算法等等,不同的算法产生的效果不同:如计算出路径需要的总时间,得到的路径长短优劣等,并且参数的不同也将导致结果的大异。我借助国外一位牛人的A*寻径工具(有兴趣的朋友可以在 /csharp/csharp/cs_misc/designtechniques/article.php/c12527/ 中找到相关资源),分别通过对各种路径使用各种A*算法来寻径,最终通过花费时间和路径的优美性进行了横向与纵向的比较评分,得出速度最快的算法:曼哈顿启发式算法。它在所有的包括就算九曲十八弯的复杂路径计算中均能表现极其优异的速率,下图为测试的部分截图:

图中棕色格子代表障碍物,起点位于左上角,终点在中间的红色边框格子,蓝色格子代表找到的路径。从图中右下角可以看到,就算如此复杂的充满分支的迷宫中仍可以在3毫秒中找到最佳路径,这是极其优异的。那么很多朋友看到这可能会感觉如此复杂的程序,哪是一般人能写出来的?其实说难还是有一些难度的,但是幸运的是,我们的同胞已经将一篇入门文章翻译出来了,我看了一下很不错,该翻译文章地址如下(看该文章请有耐心不长不短,但是看完以后将有非常大的收获!):/message.asp?TopicID=25439,那么我们该如何通过C#代码来实现曼哈顿A*呢?我抛砖引玉简单原理描述一下:从起点开始发散式的寻找周围8个点,通过各种条件筛选出最优的那个点,然后将此点再作为起点继续循环以上过程,最后我们得到的所有起点集合即为最终路径。是不是觉得不太难了?呵呵,那么大家动手写写吧!(大家完全可以参考我给大家的那位外国牛人写的程序,里面有源码,通过参考源码,相信大家花些时间完全可以轻易的实现自己的A*)
接着,我将自己写好的曼哈顿A*寻径所有代码封装在QX.Game.PathFinder这个命名空间中,那么到此才进入本文的关键,如何通过C#来模拟角色寻路:
首先当然是引用:using QX.Game.PathFinder;
接着我们初始化需要的变量:
Rectangle rect;
private IPathFinder PathFinder = null;
private byte[,] Matrix = new byte[1024, 1024]; //寻路用二维矩阵
private int GridSize = 20; //单位格子大小
private System.Drawing.Point Start = System.Drawing.Point.Empty; //移动起点坐标
private System.Drawing.Point End = System.Drawing.Point.Empty; //移动终点坐标
这里要特别讲解一下GridSize这个变量,它定义了窗口坐

标系中以多大一个尺寸来确定游戏坐标系的一个单元格(大家可以这样理解这两种不同的坐标系:假如游戏窗口大小为800*600像素,那么窗口坐标系中的(80,100)这个坐标,根据GridSize = 20来换算,在游戏坐标系中的坐标则为(80/20,100/20)=(4,5))。大家同时可以联想一下SLG类型游戏,人物处于的每个单元格都是由N*N像素组成的方块,GridSize就相当于N了;而该格子在游戏坐标系中的显示坐标则为((N/20), (N/20)),这样应该很好理解了吧。这样根据不同的需要来使用GridSize对坐标系进行缩小(/GridSize)和放大(*GridSize)操作,从而可以非常方便的实现各种效果并且被不同的情况所调用,后面的内容及章节会涉及到相关知识。
那么接下来我们在窗体构造函数中初始化二维矩阵,代码如下:
public Window7() {
InitializeComponent();
ResetMatrix(); //初始化二维矩阵
}
private void ResetMatrix() {
for (int y = 0; y < Matrix.GetUpperBound(1); y++) {
for (int x = 0; x < Matrix.GetUpperBound(0); x++) {
//默认值可以通过(非障碍物)在矩阵中用1表示
Matrix[x, y] = 1;
}
}
//构建障碍物(举例)
for (int i = 0; i < 18; i++) {
//障碍物在矩阵中用0表示
Matrix[i, 12] = 0;
rect = new Rectangle();
rect.Fill = new SolidColorBrush(Colors.Red);
rect.Width = GridSize;
rect.Height = GridSize;
Carrier.Children.Add(rect);
Canvas.SetLeft(rect, i * GridSize);
Canvas.SetTop(rect, 12 * GridSize);
}
//构建其他障碍物……(省略)
Start = new System.Drawing.Point(1, 1); //设置起点坐标
}
那么有了我们前面6节的知识基础并结合相应的注释,这些代码应该很容易可以接受。主要作用是定义起点,初始化矩阵中所有元素(默认都是可以通行的赋值1),然后我们可以设置些障碍物来测试我们寻径的效果,即根据需要将矩阵中需要变成障碍物的元素赋值0,这样我们就将所有的准备工作做好了。
最后就是如何实现寻径啦,代码如下:
private void Carrier_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) {
Point p = e.GetPosition(Carrier);
int x = (int)p.X / GridSize;
int y = (int)p.Y / GridSize;
End = new System.Drawing.Point(x, y); //计算终点坐标

PathFinder = new PathFinderFast(Matrix);
PathFinder.Formula = HeuristicFormula.Manhattan; //使用我个人觉得最快的曼哈顿A*算法
PathFinder.SearchLimit = 2000; //即移动经过方块(20*20)不大于200

0个(简单理解就是步数)

List path = PathFinder.FindPath(Start, End); //开始寻径

if (path == null) {
MessageBox.Show("路径不存在!");
} else {
string output = string.Empty;
for (int i = path.Count - 1; i >= 0; i--) {
output = string.Format(output
+ "{0}"
+ path[i].X.ToString()
+ "{1}"
+ path[i].Y.ToString()
+ "{2}",
"(", ",", ") ");
rect = new Rectangle();
rect.Fill = new SolidColorBrush(Colors.Green);
rect.Width = GridSize;
rect.Height = GridSize;
Carrier.Children.Add(rect);
Canvas.SetLeft(rect, path[i].X * GridSize);
Canvas.SetTop(rect, path[i].Y * GridSize);
}
MessageBox.Show("路径坐标分别为:" + output);
}
}
这里我将鼠标左键点击的点作为寻径的动态终点,然后创建寻径类PathFinder,接着定义好参数后就可以通过List path = PathFinder.FindPath(Start, End);来实现寻径了。最后通过MessageBox将我们找到的路径点逐一打印出来,至此就完成了我们完美的曼哈顿A*寻径了。

上图为程序运行图,绿色代表找到的路径,红色代表障碍物,找到的路径同样如此的完美!是不是很有成就感?
有了这A*算法寻径类,可以说地图引擎就好比完成了一半不为过;那么下一节我将介绍如何通过此节获取的 List path列表来实现按照此列表实现的动态关键帧动画,敬请期待。
(八) 完美实现A*寻径动态动画
本节将紧接着上一节,在它的基础上实现鼠标点击动态创建完美的A*寻路动画。(模拟游戏中人物的真实移动,这次可是有障碍物的,可以说基本上完成了人物移动引擎的一半了呢)
首先,在上一节的代码前部分加入一个叫做player的圆形作为我们将要控制的对象(模拟游戏中的主角,下文均称之为“主角”):
Ellipse player = new Ellipse(); //用一个圆来模拟目标对象
private void InitPlayer() {
player.Fill = new SolidColorBrush(Colors.Blue);
player.Width = GridSize;
player.Height = GridSize;
Carrier.Children.Add(player);
//开始位置(1,1)
Canvas.SetLeft(player, GridSize);
Canvas.SetTop(player, 5 * GridSize);
}
接下来,我们在窗体构造函数中加入InitPlayer()方法:
public Window8() {
InitializeComponent();
ResetMatrix(); //初始化二维矩阵
InitPlayer(); //初始化目标对象

}
如果大家对上一节的障碍物觉得还不过瘾,可以随便再添加,直到你觉得足够复杂来测试我们的A*动画,这里我也在上一节设定的障碍物基础上进行了一些改进,稍微复杂了些。那么我们直接进入本节的重点:如何实现鼠标点击窗体中任意点,实现主角从它当前位置移动到鼠标点击的点,并且幽雅平滑的通过A*用最短的路径越过所有的障碍物,这整个过程都是动态创建的,没有一点xaml的痕迹,嘿嘿,小得意了一下呢。当然讲解之前还是请各位朋友先熟悉前面章节的动画原理,否则还是比较难理解的。接下来看看代码:
private void Carrier_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) {
Point p = e.GetPosition(Carrier);
//进行坐标系缩小
int start_x = (int)Canvas.GetLeft(player) / GridSize;
int start_y = (int)Canvas.GetTop(player) / GridSize;
Start = new System.Drawing.Point(start_x, start_y); //设置起点坐标
int end_x = (int)p.X / GridSize;
int end_y = (int)p.Y / GridSize;
End = new System.Drawing.Point(end_x, end_y); //设置终点坐标

PathFinder = new PathFinderFast(Matrix);
PathFinder.Formula = HeuristicFormula.Manhattan; //使用我个人觉得最快的曼哈顿A*算法
List path = PathFinder.FindPath(Start, End); //开始寻径

if (path == null) {
MessageBox.Show("路径不存在!");
} else {
Point[] framePosition = new Point[path.Count]; //定义关键帧坐标集
for (int i = path.Count - 1; i >= 0; i--) {
//从起点开始以GridSize为单位,顺序填充关键帧坐标集,并进行坐标系放大
framePosition[path.Count - 1 - i] = new Point(path[i].X * GridSize, path[i].Y * GridSize);
}
//创建故事板
Storyboard storyboard = new Storyboard();
int cost = 100; //每移动一个小方格(20*20)花费100毫秒
//创建X轴方向逐帧动画
DoubleAnimationUsingKeyFrames keyFramesAnimationX = new DoubleAnimationUsingKeyFrames();
//总共花费时间 = path.Count * cost
keyFramesAnimationX.Duration = new Duration(TimeSpan.FromMilliseconds(path.Count * cost));
Storyboard.SetTarget(keyFramesAnimationX, player);
Storyboard.SetTargetProperty(keyFramesAnimationX, new PropertyPath("(Canvas.Left)"));
//创建Y轴方向逐帧动画
DoubleAnimationUsingKeyFrames keyFramesAnimationY = new DoubleAnimationUsingKeyFrames();
keyFramesAnimationY.Duration = new Duration(TimeSpan.FromMilliseconds(path.Count * cost));
Storyboard.SetTarget(

keyFramesAnimationY, player);
Storyboard.SetTargetProperty(keyFramesAnimationY, new PropertyPath("(Canvas.Top)"));
for (int i = 0; i < framePosition.Count(); i++) {
//加入X轴方向的匀速关键帧
LinearDoubleKeyFrame keyFrame = new LinearDoubleKeyFrame();
keyFrame.Value = i == 0 ? Canvas.GetLeft(player) : framePosition[i].X; //平滑衔接动画
keyFrame.KeyTime = KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(cost * i));
keyFramesAnimationX.KeyFrames.Add(keyFrame);
//加入X轴方向的匀速关键帧
keyFrame = new LinearDoubleKeyFrame();
keyFrame.Value = i == 0 ? Canvas.GetTop(player) : framePosition[i].Y;
keyFrame.KeyTime = KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(cost * i));
keyFramesAnimationY.KeyFrames.Add(keyFrame);
}
storyboard.Children.Add(keyFramesAnimationX);
storyboard.Children.Add(keyFramesAnimationY);
//添加进资源
if (!Resources.Contains("storyboard")) {
Resources.Add("storyboard", storyboard);
}
//故事板动画开始
storyboard.Begin();
//用白色点记录移动轨迹
for (int i = path.Count - 1; i >= 0; i--) {
rect = new Rectangle();
rect.Fill = new SolidColorBrush(Colors.Snow);
rect.Width = 5;
rect.Height = 5;
Carrier.Children.Add(rect);
Canvas.SetLeft(rect, path[i].X * GridSize);
Canvas.SetTop(rect, path[i].Y * GridSize);
}
}
}
上面的代码配有很详细的注释,这里除了前面章节里的动画知识外,新出现了动态创建关键帧动画的知识。首先我们来看第一小段,它的作用是将主角所处的位置定义为起点,将鼠标点击的位置定义为终点,然后就和上一节中讲解的一样开始通过A*寻径,最终得到路径点的集合List path。因为根据A*原理算出的path是反向序列的,即由终点开始到起点的点集,但是我们需要得到的是正向的点集,这样在后面可以更方便调用。所以这里就用到了反向换算来计算出正向点集Point[] framePosition。万事具备后,我们分别开始创建X轴,Y轴的关键帧动画。具体关于WPF/Silverlight关键帧动画的知识这里不多说了,因为是高级教程嘛,有迷糊的朋友请先查阅相关资料,网络上有很多。这里要提出来特别讲解一下的是int cost这个变量,就如它的注释中讲的每移动一个小方格(20*20)花费100毫秒。有朋友就要问了:我移动到直线邻近方

格的距离(假设为10)和移动到对角线邻近方格距离(则为14.14,根据三角函数计算)是不一样的,统一使用100来衡量是不是不够精确?这里我要特别说的是,如果您将GridSize(上一节有关于它的详细解说)定义得比较小(例如本例中定义为20),那么在程序实际运行中将完全感觉不到不同方向上移动速度的不同,所有方向上的动画感觉都是匀速且非常平滑的。但是如果GridSize定义的值越大(例如>50),那么斜线方向上的速度将明显慢过直线方向上的速度,这是因为Storyboard动画是基于时间轴形成的动画,初中物理学中就有讲解,在相同时间内行走不同长度的路程肯定会导致平均速度的不同。所以,如果想在此条件下进行真实情况模拟,就需要再进行一些数据计算及换算,这样将导致性能上打折扣。并且GridSize>50的情况在现实游戏开发中基本不存在(RPG类型游戏就不说了,GridSize是越小越好,从而得到更精确的定位,但同时带来的是更加复杂精细的地图布局工作。而显式使用格子的SLG类型游戏你有见哪款将每个格子定义为50*50像素的?如果有,800*600的屏幕显示不到10*10个格子,这是相当滑稽可笑的)。所以大家完全可以统一化,将直接和斜线的移动花费时间均统一成100毫秒,GridSize进行合理的设置,这样将大大降低程序的复杂度且性能上得到最佳效果。
回到代码上,在最后,我加入了一段代码用白色点来记录主角移动所经过的痕迹,其实就是Point[] framePosition,这样也可以非常方便大家去理解上面代码的功能作用。
完成以上代码后,我们来测试一下,运行程序我们随便乱点点看看,嘿嘿,主角可以幽雅的越过障碍物移动了呢,而且在移动的过程中你再点别的位置它将很平滑的重新向新的位置移动,可以说近乎完美的模拟了2D RPG游戏中的人物移动:


至此,我们已经实现了WPF/Silverlight游戏中人物的移动动画、越过障碍物、寻路等。那么后面的章节我将引入一个不可移动的地图作为背景并在地图中加入一些障碍物,最后结合第四章及第五章关于2D人物动画的知识模拟出一个RPG游戏场景,敬请关注。
(九) 2D游戏角色在地图上的移动
本节将运用前两节的知识到实际的2D游戏人物在地图上移动中,同时也算是对前面八节的内容进行一次综合运用吧。
那么先从最底层的地图讲起。首先我将一张地图添加进游戏窗口中,这里我同样使用Image控件:
Image Map = new Image();
private void InitMap() {
Map.Width = 800;
Map.Height = 600;
Map.Source = new BitmapImage((new Uri(@"Map\Map.jpg", UriKind.Relative)));
Carrier.Children.Add(Map);
Map.SetV

alue(Canvas.ZIndexProperty, -1);
}
我将一个800*600名叫Map.jpg的地图图片添加进项目Map文件夹中,然后将它的Canvas.Zindex属性设置为-1,这样它就相当于地图背景的作用了。有了这张地图以后,我们需要对它进行障碍物设置:

从上图可以看到,理想的状态下,障碍物为我用蓝色填充的区域,这是理想状态下障碍物的设置。但是实际运用中,就拿本教程来讲,因为GridSize设置为20,那么我们最终得到的障碍物将是这样的:

从上图可以看到,每个绿色格子代表一个20*20像素的障碍物,只能说勉强达到描绘障碍物的效果吧。从而又验证了我们上一节所讲到的GridSize越小,定位将越精确,难道不是至理名言吗!
有了这个思路,接下来我用了3个循环算法实现了左部分的障碍物设定:
//构建障碍物
for (int y = 12; y <= 27; y++) {
for (int x = 0; x <= 7; x++) {
//障碍物在矩阵中用0表示
Matrix[x, y] = 0;
rect = new Rectangle();
rect.Fill = new SolidColorBrush(Colors.GreenYellow);
rect.Opacity = 0.3;
rect.Stroke = new SolidColorBrush(Colors.Gray);
rect.Width = GridSize;
rect.Height = GridSize;
Carrier.Children.Add(rect);
Canvas.SetLeft(rect, x * GridSize);
Canvas.SetTop(rect, y * GridSize);
}
}
int move = 0;
for (int x = 8; x <= 15; x++) {
for (int y = 12; y <= 18; y++) {
Matrix[x, y - move] = 0;
rect = new Rectangle();
rect.Fill = new SolidColorBrush(Colors.GreenYellow);
rect.Opacity = 0.3;
rect.Stroke = new SolidColorBrush(Colors.Gray);
rect.Width = GridSize;
rect.Height = GridSize;
Carrier.Children.Add(rect);
Canvas.SetLeft(rect, x * GridSize);
Canvas.SetTop(rect, (y - move) * GridSize);
}
move = x % 2 == 0 ? move + 1 : move;
}
int start_y = 4;
int end_y = 10;
for (int x = 16; x <= 23; x++) {
for (int y = start_y; y <= end_y; y++) {
Matrix[x, y + move] = 0;
rect = new Rectangle();
rect.Fill = new SolidColorBrush(Colors.GreenYellow);
rect.Opacity = 0.3;
rect.Stroke = new SolidColorBrush(Colors.Gray);
rect.Width = GridSize;
rect.Height = GridSize;
Carrier.Children.Add(rect);
Canvas.SetLeft(

rect, x * GridSize);
Canvas.SetTop(rect, (y + move) * GridSize);
}
start_y = x % 3 == 0 ? start_y + 1 : start_y;
end_y = x % 3 == 0 ? end_y - 1 : end_y;
}
构建好障碍物后运行程序测试的效果如下图:

障碍物终于绘制完毕了,那么接下来就是动画部分了。还记得我们第六章中实现2D人物移动动画吗?其中有提到人物的移动基于它的左上角坐标,这是不真实的,那么我们需要为主角定义X,Y坐标,实现真实的定位到主角的脚底,所以我们这里需要一个逻辑:
int count = 1;
Image Spirit = new Image(); //创建主角
int SpiritCenterX = 4; //主角脚底离主角图片左边的距离(游戏坐标系中)
int SpiritCenterY = 5; //主角脚底离主角顶部的距离(游戏坐标系中)
//游戏坐标系中Spirit坐标(缩小操作)
int _SpiritGameX;
int SpiritGameX {
get { return ((int)Canvas.GetLeft(Spirit) / GridSize) + SpiritCenterX; }
set { _SpiritGameX = value; }
}
int _SpiritGameY;
int SpiritGameY {
get { return ((int)Canvas.GetTop(Spirit) / GridSize) + SpiritCenterY; }
set { _SpiritGameY = value; }
}
//窗口坐标系中Spirit坐标(放大操作)
int SpiritWindowX {
get { return (SpiritGameX - SpiritCenterX) * GridSize; }
}
int SpiritWindowY {
get { return (SpiritGameY - SpiritCenterY) * GridSize; }
}
上一节有说到关于两个不同坐标系同时存在的问题,上面的代码就是对它们的定义并且实现它们之间相互转换,设置好以后,就可以根据情况的需要来分别调用不同坐标系下主角的X,Y坐标了。
定义好地图、障碍物和主角的坐标系以后,接着需要对主角和地图初始化:
public Window9() {
InitializeComponent();
ResetMatrix(); //初始化二维矩阵
InitPlayer(); //初始化目标对象
InitMap(); //初始化地图

DispatcherTimer dispatcherTimer = new DispatcherTimer();
dispatcherTimer.Tick += new EventHandler(dispatcherTimer_Tick);
dispatcherTimer.Interval = TimeSpan.FromMilliseconds(150);
dispatcherTimer.Start();
}
可以看到后面4行代码那么的眼熟?其实就是第三节所讲到的知识。最后就是本节的重头戏,实现鼠标点击事件:
private void Carrier_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) {
Point p = e.GetPosition(Carrier);
//进行坐标系缩小
int start_x = SpiritGameX;
int start_y = SpiritGameY;
Start = new System.Drawing.Point(start_x, start_y); //设置起点坐标
int end_x = (int)p

.X / GridSize;
int end_y = (int)p.Y / GridSize;
End = new System.Drawing.Point(end_x, end_y); //设置终点坐标
……
if (path == null) {
MessageBox.Show("路径不存在!");
} else {
……
for (int i = 0; i < framePosition.Count(); i++) {
//加入X轴方向的匀速关键帧
LinearDoubleKeyFrame keyFrame = new LinearDoubleKeyFrame();
//平滑衔接动画
keyFrame.Value = i == 0 ? Canvas.GetLeft(Spirit) : (framePosition[i].X - SpiritCenterX * GridSize);
keyFrame.KeyTime = KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(cost * i));
keyFramesAnimationX.KeyFrames.Add(keyFrame);
//加入X轴方向的匀速关键帧
keyFrame = new LinearDoubleKeyFrame();
keyFrame.Value = i == 0 ? Canvas.GetTop(Spirit): (framePosition[i].Y - SpiritCenterY * GridSize);
keyFrame.KeyTime = KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(cost * i));
keyFramesAnimationY.KeyFrames.Add(keyFrame);
}
……
}
}
代码和上一节里的没有多大的区别,改动为我用黄色背景色描绘的区域(…….号表示该段代码与上一节不变)。主要就是针对如何进行主角真实脚底坐标在两个坐标系中的换算问题进行了布局修改,大家可以与上一节里的示例代码进行比较,非常容易就可以进行分析理解,这里我就不再累述了。大功告成啦,我将障碍物的表现去掉,然后国际惯例Ctrl+F5测试一下我们的成果吧:

至此,我们就实现了2D游戏人物在地图中的移动。大家再回头看看或许会发现:本节地图中的障碍物均是由正方形块组成,也就是说地图是基于直角坐标系的。但是在实际的游戏制作中,特别是SLG走格子回合制等类型的游戏中,基本都采用斜度的地图构造。那么下一节我将就如何构造斜度坐标系地图进行讲解,敬请关注。
(十)斜度α地图的构造及算法
在当前的网络游戏中,地图基本都是采取一定斜度的拼装地图,这其中存在两种斜度地图的构造方式:
第一种我称之为伪斜度地图:该类型地图表现层图片为斜度的,但地图基底障碍物等的构造则实为正方形,如下图:

其实最典型的例子就是上一节所演示的内容了,地图是斜的,但是我们却用垂直的障碍物对其进行基底布局,这就是典型的伪斜度地图了。
这样的地图优点在于可以使用简单直接的地图构造算法(上一节中有详细的讲解),同样也可以拥有漂亮的画面。但是,当大家将之运用到实际游戏运行中将会发现人物在饶过不规则障碍物时会很别扭。

当然,如果您能制作出优秀的地图编辑器并且拥有与之默契匹配的地图的话,这些或许不会成为大问题。
第二种即为真实的:斜度α地图。下面我将就该类型地图的构造基本原理及其在WPF/Silverlight中的基本实现及算法进行讲解。
首先解释一下关于α角度。通常来讲,对局式或战棋类回合制网络游戏钟爱于60度、45度角的地图构造;而2D-MMORPG网络游戏则无一定规律,可以是任意角度(根据地图开发策划设定进行统一的约束与规范)。下面我们先来看一张图:

该图以梦幻古龙对局战斗时的场景为例进行了非常详细的分析标注。首先我们要讲解实际对应我们WPF窗口的坐标系W坐标系。图中的W(x),W(y)即对应我们窗口坐标系的X轴(Canvas.LeftProperty)和Y轴(Canvas.TopProperty) (当然这其中有相对偏移量,我们后面会讲到)。这两轴是垂直的,也是我们最最常见的直角坐标系了,这很好理解。而该游戏的界面坐标系G坐标系,我在图中用蓝色的线进行了标识,其中G(x)正方向与G(y)负方向的夹角就是α了(在该游戏中为60度)。上图我为了方便演示及说明,假设它的两个坐标系均相交于一个点,这个点我将之定义为坐标原点(0,0)。大家回忆一下前两节讲解的关于障碍物数组Matrix[,]。该数组参数是无法有负值的,如Matrix[-1,5]、Matrix[6,-7]等,这些都是语法中非法的。所以假设按照坐标与障碍物等值对应原理(后面章节还会讲到非等值对应—参数集体偏移量),如Matrix[5,5]对应G坐标系(5,5)、Matrix[8,9]对应G坐标系(8,9),那么构建的地图布局将如上图:红色和蓝色的菱形均代表G坐标系下的坐标点(按照GridSize放大过的),菱形上方也有标识它们在G坐标系下的坐标。很清晰的可以看见,只要x或y值中有负值的,均为红色,此区域为角色无法移动到的区域(在上图中我用浅绿色区域进行标识)。而在其他正值区域中,菱形则均为蓝色的。

如上图,下部份那大片蓝色的区域(G系正值区域)就是我们最终的游戏真实场景所在了,在斜度的游戏世界里,所有人物角色的移动范围均在其中。上一节中有讲过,WPF窗口的左上角为原点(0,0)。但是上图的W坐标系的原点(0,0)却在中上部(已经标识出来,该点与左上角的x距离为a,y距离为b,图中有标注)。如果我们需要在WPF窗口中构造出与上图一模一样的场景效果,就涉及到关于坐标偏移量的计算了。就拿这个例子来说,该游戏此场景中的W(0,0)其实就是WPF的(Canvas.Left(a),Canvas.Top(b));同理,点W(40,60)则为(Canvas.Left(a+40),Canvas.Top(b+60)),以此类推。这样就很简单了不是吗?只要将所有的人物角色对象它们自身的坐标按以上方式进行换算,那么就可

以在WPF中实现以上的地图坐标系构造了。这与上一节中讲解到的关于将主角的坐标定位到它的脚底如出一辙。所以在大多数的游戏中都会存在一个关键点,比如MMORPG最典型了,主角始终处于屏幕的正中间(除非他位于地图的8个边缘,后面的章节会讲到相关内容),显而易见它的脚底坐标就是游戏的关键点,其他所有的物体都以之为参照物进行相对于它的位移。关于地图和物体的移动问题需要大量的篇幅,相关内容我将放在后面的章节中再进行讲解。那么下面的内容就暂时以WPF窗口左上角为W系的(0,0)坐标原点,进行简单演示在此基础上构建的斜度α的地图。
有了以上的基础知识作铺垫,后面的内容可谓小儿科了。
首要任务:构造W坐标系与G坐标系的换算公式。假设W坐标系下某点坐标为(W(x),W(y)),该点在G坐标系中的坐标为(G(x),G(y)),那么它们之间的换算公式即为:
W(x)=(G(x)-G(y))*sinα
W(y)=(G(x)+G(y))*cosα
G(x)=(W(y)*sinα+W(x)*cosα)/2*sinα*cosα
G(y)=(W(y)*sinα-W(x)*cosα)/2*sinα*cosα
这乃本节之精华所在,好比上帝的右手,阿拉丁的神灯无所不能、天下无敌!汗一个。。。好了,有了该法宝,那么我们开始练练手吧,看看一个斜度60的地图是如何构造的。
首先我将该公式用代码来表示写成两个方法,方法名很明确,它们的作用是分别获取某点在G坐标系和W坐标系中的坐标:
//将窗口坐标系中的坐标换算成游戏坐标系中的坐标(缩小操作)
private Point getGamePosition(double x, double y) {
return new Point(
(int)((y + (x / 1.732)) / GridSize),
(int)((y - (x / 1.732)) / GridSize)
);
}
//将游戏坐标系中的坐标换算成窗口坐标系中的坐标(放大操作)
private Point getWindowPosition(double x, double y) {
return new Point(
(x - y) * 0.886 * GridSize,
(x + y) * 0.5 * GridSize
);
}
这里我进行了简单的正弦与余弦的取值,即sin60=1.732,cos60=0.5,那么(sin60)/2=1.732/2=0.886。一张地图中是不可能存在两个α值的,所以本例在定义好α=60度后,我直接取它的正弦与余弦值这将有效的提高运算效率。
接下来就是构建障碍物了,只有通过它我们才能非常直观的看到这个斜度α地图的构造:
//构建障碍物
for (int x = 10; x < 20; x++) {
for (int y = 1; y < 10; y++) {
Matrix[x, y] = 0;
rect = new Rectangle();
//构建菱形
TransformGroup transformGroup = new TransformGroup();
SkewTransform skewTransform = new SkewTransform(-10, -25);

相关文档
最新文档