C#开发WPF/Silverlight动画及游戏系列教程(Game Tutorial):(二十) 第一部分拓展小结篇
写了20节,一路向追着鬼子打一样都没停过,索性也想暂时休息一下整理整理思绪好完成后面的第二部分更为精彩的内容:诸如主位式地图移动模式、NPC & 怪物 与主角的互动、对象AI、攻击与魔法、各种类型伤害计算、完美的RPG游戏界面……等等等等,激动吗?讲实话:我很激动!
读者声音:还没写就开始激动了,典型的傻子。
^_^||言归正传,本节就先来个承上启下的的小结吧,我打算分4个部分对前20节内容进行补充拓展:
一、完美的改进型A*寻路移动模式
在上一节中,我们虽然实现了精灵的全方向与动作,但是细心的朋友就会发现,精灵在走路的时候一直使用着A*;这将导致两个问题:1、性能上的损失,每次移动不管中间是否有障碍物都启动寻路算法,造成资源的白白浪费;2、在第十二节的结尾我曾轻描淡写的叙述了如何实现改进型A*,虽然通过副本地图简单实现了,但是暂时并不完美。那么,下面我将向大家讲解通过地地道道的方法实现改进型完美A*移动模式。
何谓改进型完美A*移动模式?即主角每次移动时,首先并不启动A*寻路而是直接建立两点间的直线移动;接下来即进行时时的障碍物判断,如果没有碰撞到任何障碍物或对象则将该直线移动保持到终点;但是中途一旦碰到障碍物,则以目的地为终点即时启动A*寻路。
原理很简单,关键技术就是如何对碰撞进行检测?
传统的方法有两种:
第一种我且称之为坐标还原法:即时时记录精灵未碰撞障碍物时的坐标(Old_X,Old_Y),在精灵移动时一旦检测到精灵此时站到了障碍物上,则将精灵此时的坐标进行还原(X=Old_X,Y= Old_Y),然后启动A*寻路。此方法的优点是使用简单,不需要复杂的判断逻辑;缺点是效果不好,在画面上将造成精灵一瞬间被弹开的情况,虽然那一刻非常的短暂且距离微小,但是对于精灵移动动画平滑性的影响是严重的,因此我们最好不要采用此方法。
第二种为启发式预测法:该方法的原理为时时对精灵前方的区域进行预测,一旦发现前方有障碍物,则即时启动A*寻路直到目的地。该方法可谓绝对皇室血统,一个字“正”,集所有优点之大成者;优点多相对的实现起来难度就大些。在WPF/Silverlight中如何实现之?先来看下图:
上图中已经给了很详细的说明,即在直线移动过程中,精灵时时判断此时朝向前方的单元格是否为障碍物,如果是则启动A*寻路饶过它。充分理解了原理后,我们可以通过如下方法来返回精灵是否将要遇到障碍物了:
//判断是否将要碰撞到障碍物(障碍物预测法)
private bool WillCollide() {
switch ((int)Spirit.Direction) {
case 0:
return Matrix[(int)(Spirit.X / GridSize), (int)(Spirit.Y / GridSize) - 1] == 0 ? true : false;
case 1:
return Matrix[(int)(Spirit.X / GridSize) + 1, (int)(Spirit.Y / GridSize) - 1] == 0 ? true : false;
case 2:
return Matrix[(int)(Spirit.X / GridSize) + 1, (int)(Spirit.Y / GridSize)] == 0 ? true : false;
case 3:
return Matrix[(int)(Spirit.X / GridSize) + 1, (int)(Spirit.Y / GridSize) + 1] == 0 ? true : false;
case 4:
return Matrix[(int)(Spirit.X / GridSize), (int)(Spirit.Y / GridSize) + 1] == 0 ? true : false;
case 5:
return Matrix[(int)(Spirit.X / GridSize) - 1, (int)(Spirit.Y / GridSize) + 1] == 0 ? true : false;
case 6:
return Matrix[(int)(Spirit.X / GridSize) - 1, (int)(Spirit.Y / GridSize)] == 0 ? true : false;
case 7:
return Matrix[(int)(Spirit.X / GridSize) - 1, (int)(Spirit.Y / GridSize) - 1] == 0 ? true : false;
default:
return true;
}
}
WillCollide()方法依据精灵的朝向判断精灵前方是否为障碍物(即判断障碍物数组Matrix[,]此时是否为0)。
有了它以后,我们同样还需要像第十二节一样建立一个名为NormalMoveTo()的方法用于精灵直线移动,此时我们只需要在第十二节代码的基础上增加精灵朝向部分即可:
//直线移动
private void NormalMoveTo(Point p) {
//总的移动花费
int totalcost = (int)Math.Sqrt(Math.Pow(p.X - Spirit.X, 2) + Math.Pow(p.Y - Spirit.Y, 2)) / GridSize * UnitMoveCost;
……
//创建主角朝向属性动画
double direction = Super.GetDirectionByTan(p.X, p.Y, Spirit.X, Spirit.Y);
doubleAnimation = new DoubleAnimation(
direction,
direction,
new Duration(TimeSpan.FromMilliseconds(totalcost))
);
Storyboard.SetTarget(doubleAnimation, Spirit);
Storyboard.SetTargetProperty(doubleAnimation, new PropertyPath("Direction"));
storyboard.Children.Add(doubleAnimation);
//动画播放
storyboard.Begin();
}
这里要特别注意的是我用黄色背景注明的totalcost这个变量,它的值代表精灵在两点间移动所需要花费的时间,计算它的目的是因为Storyboard动画是基于时间轴的动画(即在一个规定时间内完成指定动画),第一节中也有相应的说明。因此,为了让精灵在全角度(不仅仅是8个方向,是360度全方位)的任意两点间直线移动时均使用统一速度(每移动一个单元格固定花费UnitMoveCost毫秒),这样不论两点间是30度、40度、55度、76.3度、87.6度等等随意多少角度,精灵均能进行平滑的均速移动。
OK,一切就绪,接下来就是在游戏窗口中的鼠标左键点击事件中启动精灵的直线移动:
private void Carrier_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) {
Point p = e.GetPosition(Map); //点击的地方在Map中的坐标点
//假如点击的地点不是障碍物
if (Matrix[(int)p.X / GridSize, (int)p.Y / GridSize] != 0) {
Spirit.Destination = p; //设置主角的最终移动目的地
Spirit.Action = Actions.Run; //主角动作切换成跑步状态
Spirit.IsAStarMoving = false; //非寻路模式
NormalMoveTo(p); //两点间建立直线移动
}
}
看完上面代码有朋友就要问了:IsAStarMoving是什么东西?简单讲,它是精灵直线移动与A*寻路移动的枢纽。虽然我们实现了NormalMoveTo()和AStarMoveTo()这两种移动方式,但是如何在逻辑中对其进行很好的衔接,这里就必须加入IsAStarMoving这个精灵属性,有了它,我们就可以在窗口刷新事件中这样写:
//游戏窗口刷新主线程间隔事件
private void Timer_Tick(object sender, EventArgs e) {
……
//判断主角是否移动到了目标,如果是则动作切换成停止
if (ArriveTarget()) {
Spirit.Action = Actions.Stop;
} else if (!Spirit.IsAStarMoving && WillCollide()) {
//在寻路移动模式中,主角100%会饶过障碍物的,
//因此只有在非寻路模式中才需要时时判断主角是否将要碰撞障碍物
AStarMoveTo(Spirit.Destination);
Spirit.IsAStarMoving = true;
}
}
通过黄色背景代码部分的逻辑我们可以轻松实现精灵的直线移动与A*移动的转换。即精灵首先进行直线移动,在它没有到达目的地之前(ArriveTarget()==false)我们需要时时判断它是否将要碰撞到障碍物(判断WillCollide()是否==True),并且前提是精灵在此移动中还没启动过A*寻路(IsAStarMoving==false),因为一旦在直线移动中启动过A*寻路,结果100%会引导精灵饶过障碍物到达终点,A*寻路过程中不需要额外再判断是否还会碰撞到障碍物,那是多此一举(如果出现偶然,不要怪别人,怪自己没把A*算法写正确)。如果此两个条件都符合了,则以精灵的移动目标(Destination)为终点启动A*寻路模式,这样就顺利的由直线移动转入到A*寻路移动,完美的衔接与枢纽。
二、完美遮罩层
在第十一节中我曾经讲解了如何实现地图遮罩层。虽然是实现了,但是还有一些小小的瑕疵,如果不屏蔽它,那么这会很大幅度影响到游戏的画面效果。
首先我们按照第十一节中说到的方法来截取我们地图中心的这个标志物,并取名为Mask3.png并且加载到项目Map文件夹中:
接下来我们同样在后台代码中初始化它:
//创建遮罩层
Image Mask = new Image();
private void InitMask() {
Mask.Width = 202;
Mask.Height = 395;
Mask.Source = new BitmapImage((new Uri(@"Map/Mask3.png", UriKind.Relative)));
Mask.Opacity = 0.7;
Carrier.Children.Add(Mask);
Canvas.SetZIndex(Mask, 612); //其中的612 = Mask的高 + Mask的Y值,由于还没引进地图控件暂时这样写
}
最后就是在游戏窗口刷新线程中对它也进行时时的更新:
private void Timer_Tick(object sender, EventArgs e) {
……
//遮罩层跟随移动
Canvas.SetLeft(Mask, MapLeft + 793);
Canvas.SetTop(Mask, MapTop + 217);
//主角跟随地图同时移动
Canvas.SetLeft(Spirit, Spirit.X - Spirit.CenterX + MapLeft);
Canvas.SetTop(Spirit, Spirit.Y - Spirit.CenterY + MapTop);
Canvas.SetZIndex(Spirit, (int)Spirit.Y); //时时的更新它的层次(画家算法)
……
}
这里关键的代码就是黄色部分代码,我通过Canvas.SetZIndex(Mask, 612)设置了遮挡物的层次处于ZIndex:612这个位置,弄过网页的同志对Z-Index应该不陌生,它们的意义是一样的。最后还需要精灵的ZIndex与之配合才能实现完美默契的遮罩效果,因此我们在线程中时时根据精灵的Y坐标来更改它的ZIndex:Canvas.SetZIndex(Spirit, (int)Spirit.Y),这样就算以后增加了其他的NPC、怪物之类的对象物体,只要同样的设置它们的ZIndex=Y,即可以完美的实现时时的层次关系。
三、完美换装
这个就简单多了,因为之前的章节中已经将相关参数与实现方法都定义好了,那么剩下的就是如何调用的问题。这里为了演示方便,我在xaml里面添加了两个下拉列表(ComboBox)分别对应衣服及武器的代号,和一个换装启动按钮(本教程目录中有源码下载,这里就不列出来了)。然后我们在此按钮的点击事件中只需要3行代码就可以轻松的实现换装:
//换装
private void ChangeEquipment(object sender, RoutedEventArgs e) {
Spirit.Equipment[0] = Convert.ToInt32(comboBox1.SelectionBoxItem.ToString());
Spirit.Equipment[1] = Convert.ToInt32(comboBox2.SelectionBoxItem.ToString());
Spirit.Source = Super.EquipPart(Spirit.Equipment, Spirit.DirectionNum, Spirit.DirectionFrameNum, Spirit.TotalWidth, Spirit.TotalHeight,Spirit.SingleWidth, Spirit.SingleHeight);
}
这三行代码实在太简单因此不再多做说明。需要提的是在更换衣服与武器的时候,如果内存中不存在该新衣服与武器搭配,则需要时时的进行合成,这会根据您CPU的速度来决定游戏卡壳的时间,毕竟是一个较大量图片合成计算。这在网络游戏中同样经常会遇到,例如你是否有过这样的经历:当你到了一定级别可以更换更高级的武器时,你双击该武器的时候,游戏会卡住那么以下,然后“哐铛”一声,武器才安装上去;但是如果你再把这把武器取下,再重新换上时却一点也不卡,这就是装备的缓存起到了作用。
四、A*寻路之大补充
鉴于目前很多朋友反馈说A*难度过高而无法理解,因此,我打算就A*寻路的使用及相关要点做一次重大补充说明:
使用A*,首先需要引用QX.dll程序集;接着在程序中创建IPathFinder PathFinder = new PathFinderFast(Matrix);这里有个重要的参数Matrix,它是用来描述寻路坐标系中(即缩小后的坐标系)障碍物的二维矩阵,我们这样创建它byte[,] Matrix = new byte[1024, 1024]; Matrix[x,y]与寻路坐标系中的坐标是一一对应的关系,例如Matrix[150,266]即对应寻路坐标系中的(150,266)这个点,假设GridSize=10,那么寻路坐标系中的(150,266)此点即对应游戏窗口中的Canvas.LeftProperty(1500)、Canvas.TopProperty(2660)。并且如果该点是障碍物,则我们设置Matrix[150,266]=0,如果不是障碍物而是可以通行的地点,则我们设置Matrix[150,266]=1,Matrix[,]数组中的其他所有的点依此类推均设置后即完成了地图中障碍物的布局。这里出现了GridSize这个很重要的概念,它起到缩放坐标系的作用,如何来理解它呢?这里我以byte[,] Matrix = new byte[1024, 1024]为例,1024*1024像素地图仅仅是一张大约我们一个电脑桌面尺寸,但是要在它上面构建障碍物却需要我们对1024*1024=1048576个点进行设置,简单有规律的障碍物布局还好,要是遇到复杂的地图该怎么办?这还是小事,要是地图的尺寸为10000*10000像素(这在MMORPG中再常见不过了),它带来的不仅是一个大内存数组int[,] Matrix = new int[10000, 10000],更可怕的是在没有制作地图编辑器之前去设置其中的100000000个障碍物,简直就是一件让人崩溃致极的事。
因此,我引入GridSize(单位格尺寸)这个参数来对坐标系进行缩放操作,从而达到简化地图构建过程。例如,我设置GridSize=20,那么游戏坐标系中的坐标都是窗口中坐标的1/20,例如窗口中Canvas.getLeft(Spirit)=123;Canvas.getTop(Spirit)=353;则对应游戏坐标系中(6,17)(可以直接用整数相除,结果会取整数部分)。这样的话,一张10000*10000的地图只需要Matrix[500,500]来实现障碍物构造,并且一个角色占据一个20*20尺寸的单元格是非常合理的。以下为关于引入GridSize这个参数概念的几大优势总结:
1、简化障碍物数组,并且使得地图构造伸缩自如:关于简化数组提高性能在上面已经说了,至于伸缩自如是因为我只需要通过改变GridSize的值,其他代码均不变即可以实现不同精度的游戏坐标系。不信?在前面章节中我的GridSize均为20,本节我将之设置成了10,大家可以很明显的看到障碍物精度提高了1倍(如下图):
大家也不妨将GridSize分别设置成1、5、30等,然后相应的修改障碍物(不同GridSize下,障碍物的位置肯定不同)再运行程序看看在不同GridSize下,游戏地图界面是一样的,但是单元格精度却是不同的。特别值得一提的是,当GridSize=1时,此时的寻路坐标系==窗口坐标系,这或许也能让朋友们更好的理解GridSize的意义。
2、提高寻路算法速度。例如我们设置GridSize=20,此时在40*40像素的地图区域内只有2*2=4个游戏坐标系单元格即(0,0)、(0,1)、(1,0)、(1、1);如果你需要让角色从区域左上角移动到右下角,则只需要在这4个点内进行寻路计算出从点(0,0)到点(1,1)的路径;而如果GridSize=1,即不进行游戏坐标系中单元格缩放而以窗口中的像素点作为基础单元格,那么在40*40像素区域内有40*40=1600个坐标点;同样的如果你需要将角色从区域左上角移动到右下角,就必须在这1600个点内进行寻路计算出从点(0,0)到点(40,40)的路径。因此我们将GridSize设置为<=20的值,即不失定位的精度,又大幅度简化及优化地图构造及性能,何乐而不为?
3、SLG、回合制等类型游戏地图引擎制作中的必定参数。如果你说RPG(ARPG、MMORPG等)类型的游戏肯定都有自己的地图编辑器,从而能轻松实现以像素为单位(精确度达到GridSize<=5)的高精度障碍物构造及地图编辑,这我100%赞同(前提:必须有地图编辑器,否则后果就如我上文中提到的,一张大且无规律的地图将让你痛不欲生)。但是,在SLG、回合制等基于N*N尺寸基础单元格的游戏中,就如同它们往往被大家通俗的描述为走格子(战棋类)游戏一样,地图格子的概念无处不在。无论是垂直地图或是斜度地图,通过设置GridSize都可以轻松的将之实现。
归纳补充了那么多关于A*的相关使用,大家是渐渐进入状态了?
本节即将结束,同样标志着第二部分的开始。第二部分我将就本文开头用彩色字所提到的相关知识进行讲解,或许那才是您真正想要了解的,它将引领我们进入一个真正2D游戏制作中,敬请关注。