
C_开发WPF_Silverlight动画.pdf
154页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# , VB.NET , C+ + .NET等开发, 拓展度与可以参与开发的人群远远高于只能用AS开发的FLASH.转入正题, 网上已经有很多关于如何创建WPF/Silverlight动画的教程, 但是均为使用Blend工具制作 , 或直接写在xaml代码内的动画, 这样往往造成很多朋友误以为其实WPF/Silverlight不就是MS的Flash ?诚然, 如果您真的像那些教程里说的去开发WPF/Silverlight程序, 我个人觉得一点意义都没有。
这样开发出来的东西根本就超越不了 Flash , 那何苦还要投入如此多的精力来学习它?所以本系列教程将全方位的以纯C#程序语言进行动态创建一切可视化对象, 从而构建出一个如QXGame(WPF GAME ENGINE)游戏弓摩, 这才是我本系列教程希望达到的目的 注 : 本教程使用的开发工具为Visual studio 2008版本s p l)好了, 那么我首先介绍第一种动态创建动画的方法, 这也是官方推荐的Storyboard动画该类型动画您可以在网络上查阅相关资料进行了解, 这里不累述了, 那么我们直接进入主题:首先我们新建一个WPF项目,接下来打开WindowLxaml进入视图代码编辑器,这里我们这样写:< Window x:Class="WpfApplicationl.Windowl"xmlns= " Title="WPFGame">这段代码我创建了一个名叫Carrier的Canvas( 画布) 容器布局控件, 并设置它的尺寸为800*600 ,背景银色, 最后注册一个鼠标在它上面点击的事件。
那么为什么要选择Canvas作为容器呢? 因为Canvas可以实现它内部的控件任意的绝对定位, 可以很方便的处理物体的移动界面容器元素布局好了, 那么接下来就动态创建物体对象了:Rectangle rect;〃创建一个方块作为演示对象public WindowlQ {InitializeComponentQ;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 Xnew Duration(TimeSpan.FromMilliseconds(500)));Storyboard.SetTarget(doubleAnimation, rect);Storyboard.SetTargetProperty(doubleAnimation, new RropertyPath(n(Canvas.Left)n));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(M(Canvas.Top)M));storyboard.Children.Add(doubleAnimation);〃将动画动态加载进资源内if (!Resources.Contains("rectAnimation")) {Resources.Add("rectAnimationw, storyboard);)〃动画播放storyboard.Begin();)从上面代码我们可以看到, 首先获取鼠标点击点相对于Carrier中的坐标位置p ,然后创建故事板storyboard 和 Double 类型动画 doubleAnimation , doubleAnimation 有 3 个参数, 分别代表开始值,结束值, 动画经历时间, 接着通过 Storyboard.SetTargetQ^Q Storyboard.SetTargetPropertyO分别设置动画的目标及要修改的动画目标属性, 再下来将doubleAnimation添加进storyboard中 , 这样重复两次分别实现X轴和Y轴方向的动画. 当这些处理完后, 最后还需要将storyboard添加进Resources资源内,这样程序才能识别( 将它去掉也同样可以通过编译, 后面章节中才会用到它, 这里只是提前做个说明1 -切就绪后, 通过代码storyboard.Begin。
来开始动画大家按Ctrl + F5 ,然后在窗体上随便点点, 方块是不是会移动了呢? 呵呵小 结 :Storyboard动画是基于时间线的矢量动画, 它与传统的基于图片轮换形成的动画不同, 它的原理是通过时时的改变对象属性而形成2让物体动起来②第二种方法,CompositionTarget动画, 官方描述为:CompositionTarget对象可以根据每个帧回调来创建自定义动画其实直接点,CompositionTarget创建的动画是基于每次界面刷新后触发的, 与窗体刷新率保持一致, 所以频率是固定的, 很难人工介入控制那么如何使用它? xam l的界面代码还是和上二篇中描述的一样, 这里不累述了那么接下来就是创建对象并注册事件, 全部代码如下:Rectangle rect; 〃创建一个方块作为演示对象double speed = 1; 〃设置移动速度Point moveTo; 〃设置移动目标public WindowlQ {InitializeComponentO;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);Canvas.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测试一下, 呵呵, 是不是同样也动起来了呢?Tindov3可是大家会发现一个很大的问题: 这方块移动得也太勉强了吧, 抖来抖去的而且移动得也不平滑, 是不是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.SetTopfrect, rect_Y + (rect_Y < moveTo.Y ? speed_Y : -speed_Y));这样才能实现真实的移动( 具体算法就不讨论了) .这一节讲解了如何使用CompositionTarget主界面刷新线程实现基于帧的动画, 下一节我将讲解第三种动态创建动画的方法,并会对这三种方法进行一个归纳比3让物体动起来③第三种方法,DispatcherTimer动画, 该类型动画与CompositionTarget动画类似,是基于界面线程的逐帧动画, 但他与CompositionTarget动画不同,DispatcherTimer动画可以轻松的进行参数设置:xaml界面代码仍然沿用第二芭的, 那么接下来我们在后台代码中创建相关对象:Rectangle rect; 〃创建一个方块作为演示对象double speed = 5; 〃设置移动速度Point moveTo; 〃设置移动目标public Window3() {InitializeComponentO;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(rectz 0);Canvas.SetTop(rect, 0);〃定义线程DispatcherTimer dispatcherTimer = newDispatcherTimer(DispatcherPriority.Normal);dispatcherTimer.Tick += new EventHandler(Timer_T>ck);dispatcherTimer.Interval = TimeSpan.FromMilliseconds(50); 〃重复间隔dispatcherTimer.StartO;)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.StartQ;第一句申明一个界面计时器DispatcherTimer ,并且设置其线程优先级别为Normal , 这是标准设置,你可以根据你自己的需求进行更改,一共10个级别。
第二句注册Tick事件, 也就是计时器间隔触发的事件第三句设置Tick事件的间隔, 可以有很多方式, 我使用的是TimeSpan.FromMilliseconds(),即间隔单位为毫秒第四句启动线程是不是很简单? 这样的话可以很轻松的通过Interval来控制刷新一个对象属性的频率了 接下来我们同样使用Ctrl+F5来测试一下成果呵呵, 结果和第二种动画方法是一样的, 存在同样的问题, 因为毕竟两种动画的原理是一致的那么到此, 三种动态创建动画的方法都已经详细介绍过了, 大家可能会有种感觉, 比较钟情于第一种WPF/Silverlight推荐的Storyboard动画, 既直观又方便使用, 而且仿佛不易出错其实这3种动画都有它特定的使用场合第一种动画适合创建简单的对象位移及直接性质的属性更改( 在后面的教程中, 我还将更深入的挖掘Storyboard动画的潜力, 动态创建更复杂的基于KeyFrame的关键帧动画\第二种动画适合全局属性的时时更改, 例如我们后面要讲到的敌人或NPC以及地图等全体性的相对位移及属性更改时就要用到它了.第三种动画则非常适合运用在Spirit( 角色) 的个人动画中, 例如角色的移动, 战斗, 施法等动作。
小 结 : 前三节分别讲解了 Storyboard动画,CompositionTarget动画,DispatcherTimer动画,并横向分析了不同的场合对应不同的动画应用模式, 这些将是构成WPF/Silverlight游戏引擎的基础下一节我将介绍如何使用DispatcherTimer动画让对象活起来, 敬请关注4实现2D人物动画①通过前面的学习, 我们掌握了如何动态创建物体移动动画, 那么接下来我将介绍WPF中如何将物体换成2D游戏角色, 并通过使用前面所讲的DispatcherTimer计时器来实现2D人物角色的各种动作动画动态实现2D人物角色动画目前有两种主流方法, 下面我会分别进行介绍第一种方法我称之为图片切换法,准备工作: 首先通过3DMAX等工具3D渲染2D的方法制作出角色,然后将角色每个动作均导出8个方向每方向若干帧的系列图片( 如果是有方向的魔法图片, 很多2D-MM0RPG往往会导出16个方向的系列帧图片以求更为逼真) , 即将每个人物每个动作的各方向的每帧均存成一张图片, 如下图仅以从破天一剑游戏中提取的素材为例:( 特别申明: 本系列教程所使用的如有注明归属权的图片素材均来源于网络, 请勿用于商业用途, 否则造成的一切后果均与本人无关。
100%6. png 8 10---- . |jn|o50-loo1皆— "ill R ? ? iiiiii ” … ? - 191 * a ■ 1 1 a ■ ? ( < 1 1 1 1 1 ■ I ^ 9 ? । ■ 1 1 1 ■100%o 二5 二0 ;100%1 二0 二0 ;从上图可以看到, 我将人物向右方跑步共8帧图片通过Photoshop分别将画布等比例扩大成150*150象素图片( 因为是提取的素材, 初始宽和高是不均衡值, 所以必须扩大成自己的需求, 这样人物会在图片中居中, 并且为后期加入武器或坐骑留好余地稍微的偏离也可以在后期进行微调) , 并将他们从开始到结束分别命名为 Opng , l.png , 2.png , 3.png , 4.png , S.png , 6.png , 7.png (这里还要顺带一提的是,图片最好背景Alpha透 明 , 否则在算法上还要进行去色, 不是多此一举吗? 至于为何是png而不是gif ,我这里考虑到Silverlight目前只支持png和jpg ,为了更多的通用性, 当然如果您只用WPF , gif或png均 可1最后在项目中我们新建一个文件夹取名叫Player,然后将这8张图片保存在该目录下, 到此准备工作终于结束了, 忽忽。
还真够累的接下来就是重头戏了, 如何通过纯C#来实现动态创建人物跑动动作动画呢? 嘿 嘿 , 且看下面代码int count = 1;Image Spirit;public Window4( ) {InitializeComponentO;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 Bitmaplmage((new Un(@"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回到第一帧即count=0;否则count+=l ,这是很容易理解的了好 了 , 按 下Ctrl+F5 ,嘿 嘿 , 主角会跑动了当然啦, 目前只是原地跑步, 但是已经向成功迈出了一大步 , 难到不是吗?下一节, 我将继续介绍动态创建人物动画的第二种方法, 敬请关注5实现2D人物动画②第二种方法我称之为图片截取法, 准备工作: 这里我以创建主角向右方向施法动画为例首先需要将10帧150*150的图片通过Photoshop或其他方式合成为一张1500*150的大图, 如下图:从图上可以很清晰的看出主角的整个流畅的施法流程 接着,我将该文件取名叫PlayerMagic.png保存然后在上一节中建立的Player文件夹上点鼠标右键- > 添加- > 现有项- > 找到PlayerMagic.png图片后并加入进Player文件夹。
接下来的就是重点了, 如何才能使该图片被WPF/Silverlight程序识别呢? 我们可以在这张图片上点右键属 性 , 接着将以下两个属性①复制到输出目录- > 改为" 如果较新则复制" ②生成操作- > 改为" 嵌入到资源" , 如下图:O P l a y e r□ Si 0 . p n g1 1- P n g3 2 . p n gd a l 3 P n gJ 4. p n gj&l 5. p n ga 6 . p n g_ 阖L晔a l P l a y e r M a g i c . p n gA p p . xa n i lWi n d o w l . xa m lWi n d o w l O . xa m lw;11 ”屋性 ▼ dP l& yerla g ic. png 文件属性复制到输出目录I日 高级生成操作 嵌入的资源自定义工具自定义工具命名日完整路役 E : \H P FGa n >e C o ur s e \文件名 P l a y e r M a g i c . p n g这样,当我们编译完项目后,Player文件夹将包PlayerMagic.png文件一起发布在Bin或Debug文件夹中, 此时PlayerMagic.png才能轻松的被BitmapFrame.Create。
方法所调用( 在此, 我要特别感谢函恨云愁纠正本文之前所范的低级错误1如下图:!\WFFGameCourse\bin\DebugWPFGameCourse. v s ...I vshost. exe[] M icrosoft Corpor...SQX.dllJx0 0 0WPFGameCoursePDB文件188 KBOK , xaml代码仍旧和前面章节的一样, 那么接下来就是后台C#代码了 :Image Spirit;int count = 1;public Window5() {InitializeComponentO;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.StartO;)private void dispatcherTimer_Tick(object sender, EventArgs e) {Spirit.Source = cutImage(nPlayerMagic.png", count * 150, 0,150,150);count)count9 ? 0 : count + 1;///
扯远了, 该方法的详细描述已经写在上面, 大家可以慢慢体会应该不难有了该尚方宝剑,那么大家应该也多少有点感觉了吧, 最 后 在dispatcherTimer_Tick方 法中,我们即调用该方法实现时时的图片截取来循环生成动画,Ctrl+F5看 看 , 呵 呵 , 主角会放魔法啦!到 此 , 我分别介绍了图片切换法和图片截取法两种动态创建角色动画的方法, 这两种方式都是很高效快速 的 ,Silverlight只能使用第一种方法, 而且也必须使用第一种方法, 这涉及到Web下载资源容量问题,如 果Silverlight在未来的版本能支持gif图 片 , 那么取代png可以节约更多的资源下载空间而WPF在这两种方法的取舍上更倾向于后者, 后者更加灵活多变, 但是需要事先将N多的图片合成, 这就涉及到一个预备工作量的问题, 当然如果您有好的函数, 图片集的名字取得有序, 直接就可以通过函数合成, 我曾试过用函数直接将488张150*150图片在< 3秒合成一张9150*1200的成品图, 当 然 , 这需要精致的算法下一节我将继续介绍如何将角色自身动画与移动动画相结合, 创建完美的鼠标点击实现2D人物移动动画。
敬请关注6完美移动经过前面的介绍和学习, 我们分别掌握了如何点击鼠标让对象移动, 并且实现2 D人物的动作动画那么 , 如何将两者完美的进行融合呢? 这一节的内容将涉及到很多重要的技术及技巧, 很关键哦那么同样的, 前台xaml还是保持不变, 接下来看后台C#第一部分:int count = 0;Image Spirit;Storyboard storyboard;public Window6() {InitializeComponentO;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 Bitmaplmage((new Un(@"Player\M + count + ”.png",Uri Kind.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(l)));Storyboard.SetTarget(doubleAnimation, Spirit);Storyboard.SetTargetProperty(doubleAnimation, new RropertyPath(n(Canvas.Left)"));storyboard.Children.Add(doubleAnimation);〃创建Y轴方向动画doubleAnimation = new DoubleAnimation(Canvas.GetTopCSpirit);P.Y,new Duration(TimeSpan.FromSeconds(l)));Storyboard.SetTarget(doubleAnimation, Spirit);Storyboard.SetTargetProperty(doubleAnimation, new PropertyPath(n(Canvas.Top)n));storyboard.Children.Add(doubleAnimation);〃将动画动态加载进资源内if (!Resources.Contains("rectAnimationn)) {Resources.Add("rectAnimation", storyboard);)〃动画播放storyboard.BeginQ;)不难看出鼠标左键点击事件中Move。
方法, 该方法大家如果熟悉第一节的话将非常好理解, 通过将这两段代码进行合成, 即可以实现鼠标点哪主角就向哪移动, 同时主角的动画始终保持为跑步状态那么该动画并非完美, 存在以下3个问题:一、主角始终为跑步状态, 那么能否当主角移动到目的地后即变成站立状态呢?of course ,方法不要太多, 这里我给大家f 小提示, 例如我们可以dispatcherTimer_Tick事件中进行如下判断:if (storyboard != null && storyboard.GetCurrentTimeQ == TimeSpan.FromSeconds(l)) {TODO...〃主角的图片源切换成站立系列帧图片即可)当然此方法只是N多方法之一二、主角定位的坐标始终处于图片的左上角, 能否定位到主角的脚底, 例如我鼠标点哪, 主角移动到该处后脚的位置站在此点上, 实现精确定位?这其实并不难,涉及到一个图片定位算法问题, 您需要设置一个Point Spirit_Position{get;set}属性来存储主角的坐标并且该坐标的Spirit_Position.X , Spirit_Position.Y值分别定位到主角的脚底, 如下图:然后在以后的调用中者B使用该坐标来取代Canvas.getLeft(),Canvas.getTop()。
三、主角朝向如何实现8个方向, 即往哪边跑就朝向哪边?这就是典型的算法问题了, 其实也很简单, 根据主角移动的目标TargetX和Target.Y分别与主角初始位置的O dX和Old.Y进行一个角度计算,然后根据判断返回0-7(int) , 8个数字分别代表8个朝向,这样在Spirit.Source设置时就调用相应角度的图片源系列帧即可到此, 我们已经能够完美的实现角色的移动与停止等动画, 接下来的章节我将就地图结构与主角在地图中的处理进彳五羊细讲解, 敬请关注7传说中的A*寻径算法关于地图引擎方面的处理涉及到两个方面的知识:1 )地图的实现( 包括地图的切割、合成、呈现方式等)2 )地图物件的实现( 包括地图中实现寻路、遮罩、传送点等)为了让大家能更加有兴趣深入后面的知识, 我选择先从地图寻路开始讲解吧:目前游戏中的寻路最经典的莫过于A*(A Star)寻路了, 它是一种寻路思维的合集, 那么基于它产生的算法则又有多种, 例如曼哈顿启发式算法、对角线取径算法、欧几里德几何算法、最大取值算法等等, 不同的算法产生的效果不同: 如计算出路径需要的总时间, 得到的路径长短优劣等, 并且参数的不同也将导致结 果 的 大 异 。
我 借 助 国 外 一 位 牛 人 的A*寻 径 工 具 (有 兴 趣 的 朋 友 可 以 在http:〃 misc/designtechniques/article.php/cl2527/ 中 找 到相关资源) , 分别通过对各种路径使用各种A*算法来寻径, 最终通过花费时间和路径的优美性进行了横向与纵向的比较评分, 得出速度最快的算法: 曼哈顿启发式算法它在所有的包括就算九曲十八弯的复杂路径计算中均能表现极其优异的速率, 下图为测试的部分截图:图中棕色格子代表障碍物, 起点位于左上角, 终点在中间的红色边框格子, 蓝色格子代表找到的路径从图中右下角可以看到, 就算如此复杂的充满分支的迷宫中仍可以在3毫秒中找到最佳路径, 这是极其优异的那么很多朋友看到这可能会感觉如此复杂的程序, 哪是一般人能写出来的? 其实说难还是有一些难度 的 , 但是幸运的是, 我们的同胞已经将一篇入门文章翻译出来了, 我看了一下很不错, 该翻译文章地址如 下 (看 该 文 章 请 有 耐 心 不 长 不 短 ,但 是 看 完 以 后 将 有 非 常 大 的 收 获 !):http:〃 ,那么我们该如何通过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 ResetMatrixQ {for (int y = 0; y < Matrix.GetUpperBound(l); y++) {for (int x = 0; x < Matrix.GetUpperBound(O); x++) {〃默认值可以通过( 非障碍物) 在矩阵中用1表示Matrix[x, y] = 1;))〃构建障碍物( 举例)for (int i = 0; i < 18; i++) {〃障碍物在矩阵中用0表示Matrix[i, 12] = 0;rect = new RectangleQ;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(l, 1); 〃设置起点坐标}那么有了我们前面6节的知识基础并结合相应的注释, 这些代码应该很容易可以接受。
主要作用是定义起点,初始化矩阵中所有元素( 默认都是可以通行的赋值1 ),然后我们可以设置些障碍物来测试我们寻径的效果, 即根据需要将矩阵中需要变成障碍物的元素赋值0 , 这样我们就将所有的准备工作做好了.最后就是如何实现寻径啦, 代码如下:private void Carrier_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) {Point p = e.GetPosition(Carrier);int x = (int)p.X / GridSize;inty = (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)不大于2000个( 简单理解就是步数)List
最后通过MessageBox将我们找到的路径点逐一打印出来, 至此就完成了我们完美的曼哈顿A*寻径了路径坐标分别为:(1,1) (2,2) (3,3) (4,4) 6,5) (6,6) (7,7) (8,8) (9,9) (10,10) (11,11) (12,11) (13(14,11) (15,11) (16,11) (17,11) (18,12) (18,13) (18,14) (18,15) (18,16) (17,17) (16,17) (15,17)(14,17) (13, 17) (12, 17) (11,17) (10,17)⑼ 17) (8,17) (7,17) (6,17)⑸ 17) (4,17) (3,17) (2,16)(4,15) <5, 15) (6,15) (7,15) (8,15) (9,15) (10,15)?hv. — Il上图为程序运行图, 绿色代表找到的路径, 红色代表障碍物, 找到的路径同样如此的完美! 是不是很有成就感?有了这A*算法寻径类, 可以说地图引擎就好比完成了一半不为过;那么下一节我将介绍如何通过此节获取的List
8完美实现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);〃开始位置(LI)Canvas.SetLeft(player, GridSize);Canvas.SetTop(player, 5 * GridSize);)接下来, 我们在窗体构造函数中加入InitPlayer()方法:public Window8() {InitializeComponentQ;ResetMatrix。
; 〃初始化二维矩阵InitPlayer();〃初始化目标对象)如果大家对上一节的障碍物觉得还不过瘾, 可以随便再添加, 直到你觉得足够复杂来测试我们的A*动画 , 这里我也在上一节设定的障碍物基础上进行了一些改进, 稍微复杂了些那么我们直接进入本节的重点 : 如何实现鼠标点击窗体中任意点,实现主角从它当前位置移动到鼠标点击的点, 并且幽雅平滑的通过A*用最短的路径越过所有的障碍物, 这整个过程都是动态创建的, 没有一点xam l的痕迹, 嘿嘿, 小得意了一下呢当然讲解之前还是请各位朋友先熟悉前面章节的动画原理, 否则还是比较难理解的接下来看看代码: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
所以这里就用到了反向换算来计算出正向点集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游戏中的人物移动:Vindov8Tindov8至此, 我们已经实现了 WPF/Silverlight游戏中人物的移动动画、越过障碍物、寻路等。
那么后面的章节我将引入一个不可移动的地图作为背景并在地图中加入一些障碍物,最后结合第四章及第五章关于2D人物动画的知识模拟出一个RPG游戏场景, 敬请关注92D游戏角色在地图上的移动本节将运用前两节的知识到实际的2D游戏人物在地图上移动中, 同时也算是对前面八节的内容进行一次综合运用吧那么先从最底层的地图讲起首先我将一张地图添加进游戏窗口中, 这里我同样使用Image控 件 :Image Map = new Image();private void InitMapO {M 叩.Width = 800;Map.Height = 600;Map.Source = new Bitmaplmage((new Uri(@" Map\Map.jpg", Uri Kind.Relative)));Carrier.Children.Add(Map);Map.SetValue(Canvas.ZIndexProperty, -1);}我将一个800*600名叫M叩.jpg的地图图片添加进项目M叩文件夹中然后将它的Canvas.Zindex属性设置为-1 ,这样它就相当于地图背景的作用了有了这张地图以后, 我们需要对它进行障碍物设置:从上图可以看到, 理想的状态下, 障碍物为我用蓝色填充的区域, 这是理想状态下障碍物的设置。
但是实际运用中, 就拿本教程来讲, 因为GridSize设置为20 ,那么我们最终得到的障碍物将是这样的:0 ;从上图可以看到, 每个绿色格子代表一个20*20像素的障碍物, 只能说勉强达到描绘障碍物的效果吧从而又验证了我们上一节所讲到的GridSize越小, 定位将越精确, 难道不是至理名言吗!有了这个思路, 接下来我用了 3个循环算法实现了左部分的障碍物设定:〃构建障碍物for (int y = 12; y < = 27; y++) {for (int x = 0; x <= 7; x++) {〃障碍物在矩阵中用表示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;)构建好障碍物后运行程序测试的效果如下图:¥indov9IP&■■■障碍物终于绘制完毕了, 那么接下来就是动画部分了。
还记得我们第六章中实现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() {InitializeComponentQ;ResetMatrix(); //初始化二维矩阵InitPlayer();〃初始化目标对象InitMapO; //初始化地图DispatcherTimer dispatcherTimer = new DispatcherTimer();dispatcherTimer.Tick += new EventHandler(dispatcherTimer_Tick);dispatcherTimer.Interval = TimeSpan.FromMilliseconds(150);dispatcherTimer.StartO;}可以看到后面4行代码那么的眼熟? 其实就是第 三 所 讲到的知识最后就是本节的重头戏, 实现鼠标点击事件:private void Carrier_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) {Point p = e.GetPosition(Carrier);〃进行坐标系缩小int start_x = SpiritGameX;int start_y = SpiritGameY;Start = new Sy ste m. D ra wi n g. Po i nt (st a rt_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 LinearDoubleKeyFrameQ;〃平滑衔接动画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走格子回合制等类型的游戏中, 基本都采用斜度的地图构造那么下一节我将就如何构造斜度坐标系地图进行讲 解 , 敬请关注.10斜度a地图的构造及算法在当前的网络游戏中, 地图基本都是采取一定斜度的拼装地图, 这其中存在两种斜度地图的构造方式:第一种我称之为伪斜度地图: 该类型地图表现层图片为斜度的, 但地图基底障碍物等的构造则实为正方形 , 如下 图 :其实最典型的例子就是上二芭所演示的内容了, 地图是斜的, 但是我们却用垂直的障碍物对其进行基底布 局 , 这就是典型的伪斜度地图了 .这样的地图优点在于可以使用简单直接的地图构造算法( 上一节中有详细的讲解) , 同样也可以拥有漂亮的画面但 是 , 当大家将之运用到实际游戏运行中将会发现人物在饶过不规则障碍物时会很别扭当 然 ,如果您能制作出优秀的地图编辑器并且拥有与之默契匹配的地图的话, 这些或许不会成为大问题。
第二种即为真实的: 斜度a地图 下面我将就该类型地图的构造基本原理及其在WPF/Silverlight中的基本实现及算法进行讲解首先解释一下关于a角度通常来讲, 对局式或战棋类回合制网络游戏钟爱于60度、4 5度角的地图构 造 ; 而2D-MMORPG网络游戏则无一定规律, 可以是任意角度( 根据地图开发策划设定进行统一的约束与规范\ 下面我们先来看一张图:该图以梦幻古龙对局战斗时的场景为例进行了非常详细的分析标注首先我们要讲解实际对应我们WPF窗口的坐标系W坐标系图中的W(x) , W(y)即对应我们窗口坐标系的X M(Canvas.LeftProperty)和Y轴(Canvas.TopProperty)( 当然这其中有相对偏移量, 我们后面会讲到) 这两轴是垂直的, 也是我们最最常见的直角坐标系了, 这很好理解而该游戏的界面坐标系G坐 标系, 我在图中用蓝色的线进行了标识 , 其 中G(x)正方向与G(y)负方向的夹角就是a了( 在该游戏中为60度) 上图我为了方便演示及说明, 假设它的两个坐标系均相交于一个点, 这个点我将之定义为坐标原点(0,0)大家回忆一下前两节讲解的关于障碍物数组Matrix。
] . 该数组参数是无法有负值的, 如Matrix[-1,5]. Matrix[6,-刀 等 , 这些都是语法中非法的所以假设按照坐标与障碍物等值对应原理( 后面章节还会讲到非等值对应一参数集体偏移量) , 如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));同 理 , 点0(40,60)则为仁2n丫25.1^仕自+ 40)(2门 丫251(^9 + 60)),以此类推. 这样就很简单了不是吗? 只要将所有的人物角色对象它们自身的坐标按以上方式进行换算,那么就可以在WPF中实现以上的地图坐标系构造了这与上一节中讲解到的关于将主角的坐标定位到它的脚底如出一辙所以在大多数的游戏中都会存在一个关键点, 比 如MMORPG最典型了, 主角始终处于屏幕的正中间( 除非他位于地图的8个 边缘,后面的章节会讲到相关内容) , 显而易见它的脚底坐标就是游戏的关键点, 其他所有的物体都以之为参照物进行相对于它的位移关于地图和物体的移动问题需要大量的篇幅, 相关内容我将放在后面的章节中再进行讲解.那么下面的内容就暂时以WPF窗口左上角为W系的(0,0)坐标原点, 进行简单演示在此基础上构建的斜度a的地图有了以上的基础知识作铺垫, 后面的内容可谓小儿科了首要任 务 : 构造W坐标系与G坐标系的换算公式假设W坐标系下某点坐标为(W(x),W(y)), 该点在G坐标系中的坐标为(G(x),G(y)),那么它们之间的换算公式即为:W(x)=(G(x)-G(y))*sinaW(y)=(G(x)+G(y))*cosaG(x)=(W(y)*sina+W(x)*cosa)/2*sina*cosaG(y)=(W(y)*sina-W(x)*cosa)/2*sina*cosa这乃本节之精华所在, 好比上帝的右手, 阿拉丁的神灯无所不能、天下无敌! 汗一个。
好 了 , 有了该法 宝 , 那么我们开始练练手吧, 看看一个斜度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那k (sin60)/2=1.732/2=0.886o一张地图中是不可能存在两个a值 的 , 所以本例在定义好a=60度 后 , 我直接取它的正弦与余弦值这将有效的提高运算效率。
接下来就是构建障碍物了, 只有通过它我们才能非常直观的看到这个斜度a地图的构造:〃构建障碍物for (int x = 10; x < 20; x++) {for (int y = 1; y < 10; y++) {Matrix[x, y] = 0;rect = new Rectangle();〃构建菱形TransformGroup transformGroup = new TransformGroupO;SkewTransform skewTransform = new SkewTransform(-10, -25);RotateTransform rotateTransform = new RotateTransform(54);transformGroup.Children.Add(skewTransform);transformGroup.Children.Add(rotateTransform);rect.RenderTransform = transformGroup;rect.Fill = new SolidColorBrush(Colors.GreenYellow);rect.Opacity = 0.3;rect.Stroke = new SolidColorBrush(Colors.Gray);rect.Width = GridSize;rect.Height = GridSize+2;Carrier.Children.Add(rect);Point p = getWindowPosition(x, y);Canvas.SetLeft(rect, p.X);Canvas.SetTop(rect, p.Y);))这里我用菱形方块真实的模拟障碍物视觉效果。
接下来就是在上一节代码的基础上将窗口鼠标左键事件中 相 关 的 坐 标 值 通 过 上 面 写 的 两 个 方 法getGamePosition(double x, double y )和getWindowPosition(double x, double y)进行替换, 实际上改动的地方不过4处 , 我用黄色背景色进行了标识( . … … 号表示该段代码与上一节不变) , 具体如下:private void Carrier_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) {Point p = e.GetPosition(Carrier);〃进行坐标系缩小Point start = getGamePosition(Canvas.GetLeft(Spirit) + SpiritCenterX,Canvas.GetTop(Spirit) + SpiritCenterY);Start = new System.Drawing.Point((int)start.X, (int)start.Y); 〃设置起点坐标Point end = getGamePosition(p.X, p.Y);End = new System.Drawing.Point((int)end.X, (int)end.Y); 〃设置终点坐标if (path == null) {MessageBoxShow( " 路径不存在! " ) ;} else {Point[] framePosition = new Point[path.Count]; 〃定义关键帧坐标集for (int i = path.Count -1; i > = 0; i— ) {〃从起点开始以GridSize为单位, 顺序填充关键帧坐标集, 并进行坐标系放大framePosition[path.Count - 1 - i] = getWindowPosition(path[i].X, path[i].Y);)〃用白色点记录移动轨迹for (int i = path.Count - 1; i > = 0; i— ) {rect = new RectangleQ;rect.Fill = new SolidColorBrush(Colors.Snow);rect.Width = 4;rect.Height = 4;Carrier.Children.Add(rect);Point target = getWindowPosition(path[i].X, path[i].Y);Canvas.SetLeft(rect, target.X);Canvas.SetTop(rect, target.Y);)))如果大家能将上一节中讲解的内容都吸收的话, 那么可以将修改的部分与上一节的代码进行对比, 再结合本节前部分内容的讲解就会慢慢的理解了( 请大家发散自己的思维吧X到这我们就完成了该斜度60的地图构造。
按Ctrl + F5看看我们的成果吧:坐前带动系度的为移标60点均法坐斜原将,无语的改蔽.均度嚣.呼比,面设好所布:温前由•此‘我%据地公域了根在那E.明嘿 嘿 ,A*寻路将我们的路径描绘得非常明显, 显然主角是沿着这样一条斜度6 0的路线饶过这个片菱形障碍物区域的而因为此例我将W(0,0)点 和G ( 0 ,0 )都定位在窗口的左上角, 所以根据本节前部分关于G坐标系的讲解, 上图中红色的区域即为含有负值的区域, 所以不被寻路方法所识别您可以尝试对该区域进行点击, 它将告诉您路径不存在, 从而也证明了我们这个坐标系的构建是成功的.最后为了让朋友们能更好的理解匕匕较, 我将本节例子中的障碍物代码拷贝替换掉上一节的障碍物代码,并将菱形换回成正方形, 代码如下:〃构建障碍物for (int x = 10; x < 20; x++) {for (int y = 0; y < 10; y++) {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);Point p = getWindowPosition(x, y);Canvas.SetLeft(rect, p.X);Canvas.SetTop(rect, p.Y);)然后大家可以尝试运行一下新的Window9.xaml ,运行效果图如下:同样的障碍物代码在第九芭的直角地图坐标系中是垂直方型显示的, 而在本节中则为菱形方式显示。
同样证明了本节斜度a地图的成功构造!Good idea ! 难道不是吗? 嘿嘿, 比较复杂也是非常重要的一节如果你能掌握它, 想想A*寻路在不同模式地图中可以完全忽略基本单元格的样式( 无论是正方形的, 或是菱形的, 甚至六边形的) 可谓无所不能, 想想斜a地图在实际游戏开发中的运用几乎无处不在, 这难道不是莫大的成就吗?至 此 , 关于地图表层的基础知识基本都讲解完了, 地图构造原理涉及的知识方方面面, 有人就打这样的比 方 : 一个好的地图编辑器决定着一款游戏的成功与否, 这毫不为过所以我们离真正完成它还有很长的路要走下一节我将介绍如何实现地图的遮罩效果, 敬请关注.11地图遮罩层的实现前面的章节主要针对地图表现层进行讲解通常来说, 简单的游戏光有它就足够了; 但是为了达到更加真实的光影效果, 模拟真实的虚拟世界, 我们还得继续在地图上下大工夫本节将就如何实现地图中的遮罩层, 即物体对角色的遮挡进行详细讲解首先我们来看一张比较完善的地图应该包含哪些内容:从上图可以看到, 我将一张地图引擎结构分成了 3层 ( 难道这就是传说中的地图三层架构? 汗一个先 .I中间的图片代表地图的表现层, 也就是我们视觉上直接看到的地图界面。
关 于它,前面的章节中已有非常多的讲解, 这里就不再累述了接着我们再看最下面那张: 地图底层, 它由黑白两大颜色组成,似乎还有一圈黄色在右小角呢有的朋友觉得它很奇怪, 似乎摸不着头脑, 好象和地图没啥关系吧? 其实只要将它和第二张图进行分析比较就会发现, 它上面的黑色就是地图中障碍物区域, 白色则为可以通行的区 域 , 那黄色呢? ? 还有朋友要问了 : 前面的章节不是有讲A*寻路吗? 通 过Matrix 数组来构建障碍物不是很完美吗? 那为什么还要多此一举再为每张地图构造一张同比例的障碍物底层图呢? 我只想告诉广大的朋友 们 : 它的作用可大了, 尤其尤其在目前的Silverlight游戏开发中, 它的作用及拓展性可谓承前启后,用科学发展观的话讲就是: 面向对象的思维开发Silverlight游戏太多太多的悬念, 才能有更多的期待,那么关于这张神秘底层图的讲解, 请听下回分析读者声音: 同 志 , 你也太假了吧,这样就讲完这节啦? BS你一下作 者 : 安 啦 , 怎么可能嘛, 这叫倒叙懂不?( 啥叫倒叙其实俺也不太 ” ? 嘿 嘿 )不瞎扯啦, 还剩一张图没讲呢, 对 啦 , 本节的主角就是它了: 地图遮罩层。
首先来讲讲实现原理吧: 我们可以从地图表现层( 下文直接就称之地图好了) 中看到, 遮挡人物的只有一棵树•那么我们想要在此地图上实现遮罩效果, 首先就得用Photoshop将这棵树给截出来, 当然越精确越 好 , 然后将它单独保存成一张背景透明的图片( 通常Windows桌 面RPG游戏中会将所有的遮挡物统一规 格 , 例 如50*50一 张 ( 如大于则分两张、三张… 等 等 ) , 然后将全部遮挡物图片放进一个庞大的二进制文件 中 , 显然这对于Silverlight基于网页的游戏是不容许的) , 如果一张地图上有多个遮挡物, 同样将他们都截取出来然后依次命名保存准备工作做完后, 我们就需要将遮罩层的图片放在顶层, 将地图放在底层,人物等放在中间层最后分别将遮罩层的所有图片布局到它们应该遮挡的位置上, 这样就完成了所有的遮挡工作了下面我将用代码来实现它这里我以下图作为地图实例:很明显该地图有三处障碍物, 两处遮挡物障碍物我用绿色区域描绘出来了, 遮挡物则为两棵数,我用Photoshop将它们分别截取了出来命名为:Maskl.png和Mask2.png匆忙了点, 截得不好可不要见怪哪! 谁让这两棵树长得如此奇怪呢? 嘿嘿。
0K ,接下我以熟 苣 的 代码为基础进行修改, 首先构建障碍物:〃构建障碍物for (int y = 22; y < = 24; y++) {for (int x = 5; x < = 16; x++) {〃障碍物在矩阵中用0表示. . . . . 渺... 忸... | 密 ……晚……loo-150-25oMatrix[x, y] = 0;rect = new RectangleO;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(rectz y * GridSize);))for (int y = 11; y < = 14; y++) {for (int x = 27; x < = 31; x++) {〃障碍物在矩阵中用0表示Matrix[x, y] = 0;rect = new RectangleO;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);))for (int y = 18; y <= 21; y++) {for (int x = 33; x < = 37; x++) {〃障碍物在矩阵中用。
表示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(rectz y * GridSize);))三个循环分别构建了上图中的三处障碍物, 这几章都对它进行了修改, 大家应该再熟悉不过了接下来就是遮挡物那两棵树了, 这里我用Image控件作为遮挡物的容器:〃创建遮罩层Image Maskl = new Image();Image Mask2 = new Image();private void InitMask() {Maskl.Width = 238;Maskl.Height = 244;Maskl.Source = new Bitmaplmage((new Uri(@nMap\Maskl.png", UriKind.Relative)));Maskl.Opacity = 0.7;Carrier.Children.Add(Maskl);Canvas.SetZIndex(Maskl, 10000);Canvas.SetLeft(Maskl, 185);Canvas.SetTop(Maskl, 220);Mask2.Width = 198;Mask2.Height = 221;Mask2.Source = new Bitmaplmage((new Uri(@"Map\Mask2.png\ UriKind.Relative)));Mask2.Opacity = 0.7;Carrier.Children.Add(Mask2);Canvas.SetZIndex(Mask2,10000);Canvas.SetLeft(Mask2, 466);Canvas.SetTop(Mask2, 11);)这样就将遮挡物加入进了游戏窗体。
有了前面那么多章节关于Image控件的使用知识, 上面的代码应该不难理解这里特别要说一下的是为什么要将它们的Opacity设置为0.7 :因为这样的遮挡物会有一定的透明度, 当角色置身其中时会若隐若现, 从而达到真实模拟MMORPG的效果至于为什么要将遮挡物的Z index属性设置为10000呢 ? 这关系到游戏运行时地图中不光只有一个角色, 还会有非常多的物体及对象角色的存在, 它们之间也同样有着相互遮挡与被遮挡的关系而在WPF/Silverlight游戏中, 物体的遮 挡 顺 序 一 样 可 以 使 用 壁 鳗 , 该算法原理简单描述就是近物遮挡远物, 幸运的是在WPF/Silverlight中 , 我们可以很方便的只要动态更新( 一个对象的Z index属性) = ( 它的Y属性) 即可以巧妙的实现此效果,是不是有点邪恶? 嘿嘿. 所以要将遮盖物的Z index设置得足够大以防止任何一个物体它的Y属性大过遮盖物的Z index属 性 , 从而造成画面显示BUG.其他的代码均和第九章的一样, 到这, 本节的目标已经达到了那么让我们运行测试一下吧:大家可以随便在地图上点击, 会发现只要主角有经过这两棵树的地方都会被树以0.7的透明度遮挡, 并且障碍物也同样并行存在着, 主角如有经过同样会饶过它。
障碍物, 人物, 遮罩层次分明, 互不干预, 完美默契的并行着.至 此 , 地图引擎就基本完成了下一节将讲解本节开始所提到的神秘第三层, 它 在WPF/Silverlight游戏辅助方面起着非常大的拓展作用, 敬请关12神奇的副本地图前面几节详细的讲解了游戏地图的完整构造, 匕瞰有难度的是关于地图内层如障碍物的实现A*算法往往能让众多的初学者望而止步, 斜度a地图则更需要一定的几何知识及抽象思维很多朋友就问了 : 什么年代了, 者解说面向对象、提高开发效率, 难道就没有大众化可以让各层次能力的朋友们都能轻松制作地图引擎的方法吗? 大家是否还记得上一节中遗留的一个小悬念, 杀手涧就是它了: 神奇的副本地图,….忸 ...Iffl… … I用I空I空.I整……照……I咨…” .I格I空…, /用……IW, “ 小隰…大家先看上图, 左边的是地图表现层, 它的尺寸为800*600.右边的则是我通过Photoshop在原图基础上勾勒出来的该地图的副本,同样它的尺寸也为800*600.这里特别要提的是该副本是由简单纯色调组成的, 因此能够压缩到极小的容量,几乎忽略不计, 这是它能作为我们得力工具的前提,也是Silverlight制作基于网页游戏的必要条件。
好 了,接下来我们详细介绍一下此副本: 大家对照原图很容易会发现它上面的黑色其实代表的就是地图中的障碍物, 那大片的白色区域呢? 其实就是我们可以任意通行的区域了至于黄色, 聪明的朋友应该也不难猜到, 它代表的是地图中的传送点. 当然, 您还可以在此副本中增加例如红色代表陷阱, 绿色代表特殊NPC等等.是否觉得像画画一样的? 嘿嘿, 这就是我主张的面向对象的游戏编程创新思想了到此地图副本制作完成了, 那么该如何利用它呢?精华又出现啦, 来看看优美的拾色方法( 此时, 我们需要将Deeper.jpg副本图片按照复 瑾 中 的方法添加进Map文件夹中, 以便被下面方法更好的识别) :〃图片拾色private Color pickColor(BitmapSource bitmapsource, int xz int y) {CroppedBitmap crop = new CroppedBitmap(bitmapsource as BitmapSource, newInt32Rect(x, y, 1, 1));byte口 pixels = new byte[4];try(crop.CopyPixels(pixels, 4, 0);crop = null;} catch (Exception ee) {MessageBox.Show(ee.ToStringQ);)〃蓝 pixels[0]绿 p ixels[l]红 pixels[2]透明度 pixels[3]return Color.FromArgb(pixels[3], pixels[2], pixels[l], pixels[0]);)太强大了, 有了它就好比吕布掌上方天画戟- 游刃有余•( 该方法只能在W PF使 用,至于如何在Silverlight中调用,Silverlight3.0将会给您一个完美的解决方案。
八J )副本地图的作用是非常凶猛的, 在它上面我们可以自由绘画出红黄蓝绿青橙紫等等N多颜色来描绘不同的地图属性, 然后实现类似以下操作:1、如果主角采到的点是黑色就相当于主角碰到了障碍物, 这时主角的动作即为停止2、如果是传送点, 则根据坐标范围( 或其他条件等) 判断是传送到哪张地图;3、如果是陷阱则将触发什么事件, 如去血或被传送, 或是刷怪等等;4、当然还可以有其他颜色, 假如游戏中有飞行坐骑等元素存在( 实 现2D地图中的三维空间) , 那么同样可以用一个例如蓝色来代表空中障碍物区域, 或用紫色来代表陆地和空中均属的障碍物, 这些都是相当灵活的.5、白的则为可以通行, 主角在上面可以正常移动更美妙的是, 此方法可以与A*寻径相结合, 从而创造出更加优美的角色移动( 例如帝国时代中采取的就是它独特的改进型A*, 所以根据游戏自身的特点您可以对A*进行优化,Gameres论坛有很多高手的文章,大家可以参考一下X接下来大家来回忆一下筮土也讲 到 的A*寻径算法, 此方法找到的路径中如果有经过障碍物倒还好, 但是如果没有障碍物的, 那么此路径全程中将或多或少会有些折叠的地方( 如下图\人类总是希望将东西做得完美导致本节的重点出现了 : 如何通过神奇的副本地图来优化A*寻 路 , 让它更加贴近真实呢? 这里我们需要先理解一个关键知识点: 主角所处的地图中所有的点与副本地图中所有的 点 都 是 一 对 应 ( 映 射)的关系。
例如假设主角在地图中的坐标为( 356,248) ,那么此坐标对应副本地图坐标也同样为( 356,248) ,这样我们就可以通过函数方法, 将主角的坐标点作为参数在副本地图中找该点的颜 色 , 看看颜色分别是黑的, 还是白的, 或是黄的等等, 从而映射回主角的地图可知主角当前处于地图中是障碍物, 还是可同行区域, 或是传送点等等了解了原理后, 下面我就用代码来实现它:首先我们需要写出两种移动方法( 具体代码就不列出来了, 在本教程的目录中有下载) : 第一种我定义 为NormalMove移动方法, 它就是 我 赵 玉 中 讲 到的点与点之间的直线移动, 在此方法中我稍微改动了 一 些 , 使坐标定位到主角的脚底, 并且将移动目标终点记录到Point Target中. 第二我将之定义为AStarMove移动方法, 它就是我前面几节讲到的A*寻路方式设置好后, 我们就可以根据从副本地图中获取的点的颜色判断来调用相应的移动模式了那么在主角移动的时候, 它 的X , Y坐标属性是时时更新的, 因此我们需要一个线程去捕获它, 并且在此线程中时时判断主角是否采在了黑色点上( 障 碍 物 ) 那么这里我采用了筮三苴中所讲到的CompositionTarget界面线程, 注册了该线程dispatcherTimerl_Tick事件后接下来就进入关键代码了 :private void Carrier_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) {Point p = e.GetPosition(Carrier);〃假如点到的地方不是障碍物if (pickColor(Deeper, (int)p.X, (int)p.Y) != Colors.Black) {target = p;NormalMove(p); /值线移动〃AStarMove(p); 〃A*寻路移动))BitmapSource Deeper = new Bitmaplmage((new Uri(@H Map\Deeper.jpg",UriKind.Relative))); 〃设置地图副本int X, Y; 〃主角当前的窗口真实坐标( 非缩放)Point target; 〃主角移动的最终目的private void dispatcherTimerl_Tick(object sender, EventArgs e) {X = Convert.ToInt32(Canvas.GetLeft(Spirit) + SpiritCenterX * GridSize);Y = ConvertToInt32(Canvas.GetTop(Spirit) + SpiritCenterY * GridSize);//message.Text =" 坐标: " + X + " " + Y;message.Text = pickColor(Deeper, X, Y).ToString();〃假如碰到障碍物则采用A*寻路if (pickColor(Deeper, X, Y) == Colors.Black) {AStarMove(target);} else if (pickColor(Deeper, X, Y) = = Colors.Yellow) {〃假如是传送点则条到坐标(200,20)storyboard.Stop();Canvas.SetLeft(Spirit, 200 - SpiritCenterX * GridSize);Canvas.SetTop(Spirit, 20 - SpiritCenterY * GridSize);)〃用白色点记录移动轨迹rect = new Rectangle();rect.Fill = new SolidColorBrush(Colors.Snow);rect.Width = 5;rect.Height = 5;Carrier.Children.Add(rect);Canvas.SetLeft(rect, X);Canvas.SetTop(rect, Y);)上面代码已经进行了很详细的描述: 首先我在鼠标左键事件中判断点击的地方是否是障碍物( 点击的点在副本地图中是否是黑色的) , 是的话主角就不移动, 否则就启动简单的直线移动。
接下来我们需要设置几个变量它们分别存储副本地图的图片源、主 角 的X , Y精确坐标以及主角最终移动目的点设置好后最后就是在界面线程中时时获取主角的X , Y坐 标 , 并且判断主角当前位置是否是障碍物了如果是则将当前的移动由普通直线移动转换成A*寻路移动同时我们还可以判断如果是黄色的话则传送到点(200,20),当然大家还可以设置其他很多颜色来启动相应的事件, 是不是很神奇? 嘿嘿F面两图分别是采用A*寻路的主角移动和改进型的A*寻路( 直线移动+A*寻路) 的主角移动:从上图可以明显看到, 单纯的使用A*进行主角移动及饶过障碍物是不自然的, 路线很机械且并不真实而通过直线移动+ 副本地图+A*实现的改进型A*寻路所实现的主角移动则可谓几乎接近完美, 与现实吻合这里要顺带提一下的是: 本节只为演示需要所以我使用的副本地图只有一窗口大小, 因此当主角移动到窗口外时pickColor 方法的参数X , Y超出了副本地图的大小导致抛出异常并使程序关闭这是肯定的而并非BUG ,这样也同样证明了 pickColor()方法的正确性小 结 : 关于如何对A*进行改进其实也并非一定需要配合该副本地图, 我们同样的可以通过时时比较主角的(SpiritWindowX , SpiritWindowY)坐标对应的障碍物 Matrix[SpiritWindowX, SpiritWindowY]是否==1来判断是否启动寻路. 但是本节的目的是要告诉大家副本地图的万用功能, 别的方法能做的, 它同样能 做 , 而且它扩展性更强, 描述对象更简单且直观, 这才叫解放思想、面向对象! 其实类似此副本地图的使用在魔兽世界等著名的游戏中都有用到, 只是我这个算是简单版的。
虽然它构造简单但是在辅助地图引擎方面却显示出强大的威力. 目前我感到唯一的缺憾就是还没有研究出如何使用它单独的进行有效精确的寻 径( 即完全抛弃复杂的A*寻径算法) , 曾想过在副本地图中画些寻路点( 即障碍物旁边可以起到诱导饶过障碍物的点) , 当主角碰到障碍物时, 寻找副本地图中离自己最近的寻路点作为临时目标进行移动, 到了此临时目标后再向最终目标移动, 这样就可以巧妙饶过障碍物了当然这在不太复杂的地图中是完全可行的 , 而且仿佛比A*更强悍且简单, 但是一旦遇到复杂的地图了呢? 我们该如何优化它? 还需要对此感兴趣的朋友们开动一下脑筋,或许真能想出完美的解决方案呢.不管怎样, 些许的缺陷是永远无法掩埋它的伟大的连美工都可以轻松的参与到游戏地图引擎的主要设计中, 难道不是游戏设计界中神圣的革新吗? 快速开发是我们的理想与追求, 只需要一张副本图片就可以较好的替代以往制作一个游戏地图引擎必须的四大步骤: 切割、拼图、编辑、转换是不是很邪恶?简化了过程却实现同样的效果, 让人觉得措手不及! 这就是21世 纪 ! YEAR 一个下一节我将就主角与地图及其他对象进行相对移动进行详细讲解, 敬请关注。
13牵引式地图移动模式①在前面诸多的章节里, 我就地图构造的实现做了讲解, 至此还遗留着一个关键问题: 在游戏中是角色在移动还是地图在移动? 它们之间的移动( 位 移 ) 关系是如何实现的?那么在接下来的章节中我将围绕这两个问题进行详细的分析解说首 先 , 还得从游戏模式开始说起目前2D 俯视游戏中以即使战略、SLG、RPG( ARPG) 等类型的游戏为主流在即时战略、SLG大地图中, 地图的移动原理是: 当鼠标处于游戏窗口的8 个边缘时, 地图即开始移动, 我暂且称之为牵引式地图移动模式t 鼠标进入此区域则地图沿Y轴地图向I 鼠标进入此区域则地图沿Y轴鼠标进入此区域左边这个窗口是我们运行游戏时的窗口,它的尺寸为而背景地图尺寸为1400*1000大过窗口尺寸, 所以窗口是无法全部显示完整的地图的. 此时就需要进行如左边红色字描述的方式进行操作, 此时背景地图才会相应移动表现出来的效果即为从视鼠标进X 股区域觉上实现了地图的移动.地图向 移动―: , 实 ; : , 学 % 鼠标进入此区域地图向 移动鼠 标 进 入 此 区 域 ; 石地图向 移动*-鼠标进入此区域地图向 移动京标进龙匕区域地图向 移 动 f如上图, 我们可以打这样一个比方: 将游戏窗口比做我们的摄象机( 上图中的W indow sl3窗口 ) ,在地图世界里不断的取景, 我们从摄象机中看的只有摄象机镜头( 游戏窗口) 中能看到的区域( 其他虚的地图部分窗口中是无法显示的\ 但是游戏窗口又相当于是用支架固定着不能移动的镜头, 那该如何才能看到景色的各个部位呢? 那当然只有去移动背景地图图片让我们需要的景色部分呈现在窗口中。
因 此 , 根据上图描述的原理, 当鼠标进入这8个区域( 蓝色和棕色区域) 时即触发地图的移动为更方便大家的理解, 我以窗口中左边那块蓝色区域为例: 当我的鼠标在游戏窗口内在左移动快接近边缘时, 此时地图图片就开始反回的进行一定速度或加速度移动; 同样的, 地图中的所有对象均跟着地图图片以同样的方向与速度进行移动, 这样给我们视觉上产生一种感觉: 我们在通过鼠标牵引游戏窗口包朝去探寻地图及物体对象有的朋友就要问了 : 如果一张地图上有1000个 对象, 难道我每10毫秒都要去移动这1000个对象吗? 这样性能上说是完全不科学的! 对 , 在实际开发中如果一张地图拥有大量物体对象的话, 我们肯定不会这样做( 如果地图是小地图, 或者物体不多, 这样做是完全可行的, 并且更容易实 现1在理解了这个原理后, 我们看看在WPF/Silverlight中是如何进行这些操作的首先需要做的就在地图移动的时候, 根据地图移动方向时时( 在界面刷新线程CompositionTarget或 间 隔 为1 0毫秒的DispatcherTimer中 ) 通 过Foreach高性能的对所有物体对象(Spirit)的X , Y坐标进行修改;而什么时候才需要将这些物体对象显示出来呢? 判断当前游戏窗口中心点对应的地图坐标点;并以该点为中点( 圆心)进行一个矩形范围或半径为R的圆形范围( 下文我简称为地图中心范围) 搜 索 : 如果某物体对象的X , Y坐标在此范围内则动态将它的显示实体加载它进入窗体画布(Carrier.Children.Add(Spirit)),然后再将之布局到它对应的X , Y坐标位置上(Canvas.setLeft(Spirit,X);Canvas.setTop(Spirit,Y);) ,并且继续根据地图的移动而移动( 时时修改Canvas.setLeft(),Canvas.setTop()) ;同样的,在地图移动中地图中心坐标是时时改变的, 如果某些物体对象的X , Y坐标超出了地图中心范围, 那么我们就将之从窗体画布中移除掉( Carrier.Children.Remove(Spirit)) ,此时这些物体对象相当于重新回到了等候显示的状态, 它们的X , Y坐标同样在后台线程中时时更改, 只要某个时候当地图中心再度出现在它们的附近时, 它们又会重复以上的步骤再显示出来。
大致原理有了, 如何通过代码来具体实现呢?这里我提供两种方法:直逐陋法为通过载体来实现地图移动 具体为首先向游戏窗体中添加8个完全透明的滚动介质( 就好比图中那8块区域, 其中4个蓝的,4个棕的) 分别布局在地图边缘的8个位置上( 它们相对于游戏窗体来说永远是不动的) , 然后在界面线程中时时判断鼠标是否悬停在它们中的某个上从而进行相应的地图移动这里我以正下方的滚动介质为例, 这样来创建它:int scrollspeed = 5; 〃定义滚动速度Rectangle roller = new Rectangle ; 〃创建滚动介质private void InitRoller() {roller.Width = 800;roller.Height = 20;roller.Opacity = 0.3;roller.Fill = new SolidColorBrush(Colors.Blue);Carrier.Children.Add(roller);Canvas.SetZIndex(roller, 10001);Canvas.SetTop(roller, 490);)这里为了演示需要, 我将它的透明度暂且设置为0 3而不是0 ,目的是为方便大家可以看到它。
接下来我们就需要在CompositionTarget的Timer_Tick事件中时时判断鼠标是否在它上面:private void Timer_Tick(object sender, EventArgs e) {〃时时判断如果鼠标停留在了该滚动介质上, 则地图相应滚动if (rollerlsMouseOver) {Canvas.SetTop(Map, Canvas.GetTop(Map) - scrollspeed);))这样就创建好了这8个区域中的其中一个( 正下方区域) , 其 他7个的创建和实现方法依次类推, 很简单就不累述了. 好了,我 们 按CTRL+F5来测试一下, 当鼠标停留在窗体正下方的那个蓝色长方形区域时 , 地图会非常平滑的向上移动, 这样就实现了我们视觉上的窗口向下取景效果从上图可以看出, 地图向上移从而实现窗口向下取景的视觉效果, 这就是地图的相对移动原理通过实体介质来实现地图移动的方式具有直观、代码简单、逻辑不复杂的特性, 但性能不好接下来看第二种方法, 此方式不需要创建滚动介质, 而是时时根据鼠标位置是否处于这8个区域中的任意一个进行对应的地图移动 这种方法相对于上一种方法来说虽然不够直观且需要的逻辑代码较多而繁,但它具有更高的性能与实用性, 也是我推荐的方法。
至于要如何实现它, 我们首先需要写一个方法, 该方法用来判断鼠标当前的位置并返回一个数字:〃根据鼠标的位置获取鼠标所处的区域代号//0代表正上方区域( 即0点钟位置) 然后其他7个区域按顺时针依次为123,4,5,6,7int distance = 80; 〃定义距离边缘多少即开始牵引地图private int getMouseAreaQ {Point MousePosition = Mouse.GetPosition(Carrier); 〃获取鼠标当前处于窗口中的位置int result = -1;〃如果鼠标未超出窗口if (MousePosition.X >= 0 && MousePosition.Y >= 0) {〃根据8种情况返回8个数字if (MousePosition.X >= 190 && MousePosition.X <= 570) {if (MousePosition.Y <= distance) {result = 0;} else if (MousePosition.Y >= 500 - distance) {result = 4;)} else if (MousePosition.Y >= 125 && MousePosition.Y <= 375) {if (MousePosition.X <= distance) {result = 6;} else if (MousePosition.X >= 760 - distance) {result = 2;}} else if ((MousePosition.X < 190 && MousePosition.Y <= distance)|| (MousePosition.Y < 125 && MousePosition.X <= distance)) {result = 7;} else if ((MousePosition.X > 570 && MousePosition.Y <= distance)|| (MousePosition.Y < 125 && MousePosition.X >= 760 - distance)) {result = 1;} else if ((MousePosition.X > 570 && MousePosition.Y >= 500 - distance)|| (MousePosition.Y > 375 && MousePosition.X >= 760 - distance)) {result = 3;} else if ((MousePosition.X < 190 && MousePosition.Y >= 500 - distance)|| (MousePosition.Y > 375 && MousePosition.X <= distance)) {result = 5;))return result;}然后我们通过这个数字就可以对应地图边缘的8个区域看是需要将地图下移还是上移或是左上移动等等。
这里需要注意一个地方, 当地图已经移动到了某个方向的尽头时, 地图是不能再移动的所以综合以上, 我们在TimejTick事件中这样来实现地图滚动:private void Timer_Tick(object sender, EventArgs e) {〃第二种方法double mapleft = Canvas.GetLeft(Map);double maptop = Canvas.GetTop(Map);switch (getMouseAreaQ) {case 0:if (maptop < 0) {Canvas.SetTop(Map, Canvas.GetTop(Map) + scrollspeed);)break;case 1:if (maptop < 0) {Canvas.SetTop(Map, Canvas.GetTop(Map) + scrollspeed);)if (M叩.Width + mapleft > this.ActualWidth) {Canvas.SetLeft(Map, Canvas.GetLeft(Map) - scrollspeed);)break;case 2:if (M叩.Width + mapleft > this.ActualWidth) {Canvas.SetLeft(Map, Canvas.GetLeft(Map) - scrollspeed);)break;case 3:if (Map.Width + mapleft > this.ActualWidth) {Canvas.SetLeft(Map, Canvas.GetLeft(Map) - scrollspeed);)if (Map.Height + maptop > this.ActualHeight) {Canvas.SetTop(Map, Canvas.GetTop(Map) - scrollspeed);)break;case 4:if (Map.Height + maptop > this.ActualHeight) {Canvas.SetTop(Map, Canvas.GetTop(Map) - scrollspeed);)break;case 5:if (Map.Height + maptop > this.ActualHeight) {Canvas.SetTop(Map, Canvas.GetTop(Map) - scrollspeed);)if (mapleft < 0) {Canvas.SetLeft(Map, Canvas.GetLeft(Map) + scrollspeed);)break;case 6:if (mapleft < 0) {Canvas.SetLeft(Map, Canvas.GetLeft(Map) + scrollspeed);)break;case 7:if (maptop < 0) {Canvas.SetTop(Map, Canvas.GetTop(Map) + scrollspeed);)if (mapleft < 0) {Canvas.SetLeft(Map, Canvas.GetLeft(Map) + scrollspeed);)break;)}以上的代码主要就进行一些位置计算并判断, 重复的部分很多并不复杂•最后大家可以按下CTRL+F5 ,嘿 嘿 ! 地图可以任意移动了。
效果上来说地图是动了, 可是主角还是始终处于窗口中的某一个位置保持不变( 不管地图怎么移, 它始终在窗口的左上角I要实现跟随移动效果, 这就需要我们根据前面所说的原理, 在地图移动的同时对主角 的X , Y坐标进行时时改变从而实现它的移动关于主角如何在牵引式地图移动模式中的地图上移动及行 走 , 我将在下一节进行详细的讲解, 敬请关注14精灵控件横空出世! ①在上一节中, 我们实现了地图牵引式移动, 同时还遗留着一个小尾巴: 主角和障碍物该如何跟随着地图的移动而移动?上节中有点到, 只要在地图移动的同时, 时时根据主角等对象物体的X , Y坐标进行相对于地图的X ,Y坐标移动即可达到目的但是由此又引来了新问题: 主角为Image控 件 , 障碍物则为矩形控件, 它们都没有X , Y这两个属性, 我们该如何对它们的坐标进彳五己录呢?最简单且最直接的方法莫过于将它们的X, 丫坐标通过分隔符连接然后记录进Tag属 性中, 在调用的时候再将它分离取出例如我们可以在构建障碍物的时候这样做:〃构建障碍物( 本节只为演示, 随便建一个)for (int y = 11; y <= 14; y++) {for (int x = 31; x < = 40; x++) {〃障碍物在矩阵中用0表示Matrix[x, y] = 0;rect = new RectangleQ;〃目前暂时不新创一个自定义控件, 而把坐标储存在Tag属性中rect.Tag = x + + y;})其中上图中黄色的代码即为将障碍物的X , Y坐标记录进它的Tag属 性,然后我们可以通过下面的函数在需要的时候对Tag属性进彳亍分离调用:〃从矩形障碍物的Tag属性中分离出它的坐标Pointprivate Point getPointFromTag( object tag) {string[] str = tag.ToString( ) .Split( new char[] { } ) ;return new Point( Convert.ToDouble( str[0]) , Convert.ToDouble( str[l]) ) ;)但是, 这样做的效率是极其低下的; 更主要的是它毫无扩展性可言。
障碍物还好对付, 如果是主角呢?它不光有X ,Y两个属性, 还有名字、门派、血条、蓝条、金木水火土、力量、智慧. …、线程参数、方向、装备代号等等等等( 晕了. . 列不完的) , 太多太多的五花八门的属性, 难不成全者陵记录进这一个Tag属性中? 将霸王龙关进笼子里这是件很可怕的事情, 装也难, 取也难! 两个字: 恐怖.读者声音: 老大, 那该怎么办? 搞不定难道还要上吊呀?作 者 : 安 啦 , 急什么? 下面才是重点可要认真看呀, 超大一个精华! ! !如果有做过游戏开发的朋友, 或者说有了解过游戏开发相关内容的朋友一定会发现, 游戏中除了地图引擎外, 最关键的莫过于精灵的创建精灵是游戏中大家见得最多的对象物体,它可以是主角, 可以是其他玩家, 可以是N PC ,可以是怪物及BOSS ,甚至可以是坐骑、障碍物等等. 很多初学的朋友往往在为如何使用外国人制作好的通用精灵而发愁( 毕竟繁杂的英文专业术语及超量的对象属性及方法是大多数人无法轻易弄通的\ 幸运的是, 在WPF/Silverlight中我们可以找到强大且使用简单的相关支持, 它就是下文中我将详细讲解的传说中的精灵控件!我们首先来看看如何在WPF/Silverlight中创建一个精灵控件.第一步要做的是在项目中添加一个文件夹( 取名Controls)来归类保存它, 接下来在该文件夹上点右键添加一个用户控件( 取名叫QXSpirit.xaml,嘿嘿, 当然其他名字都可啦, 高兴就好) , 如下图。
吊 IPFGaaeCourse人 田 m Propertiesf f i 7 引用新建项3 …添加也) ►回现有项⑥) . . .从项目中排除Q)LJ新建文件夹也)为翦切复)n窗口(! ) . . .会复制9Q页也) …阂 粘 贴9国用尸控件也) . . .X删除也)山资源字典国) . . .重命名也)勺类©…[J 在Windows资源管理器中打开文件夹量)总) 属性®准备工作完成啦, 第二步就是去丰富这个控件, 多多给它加内容, 让它强大起来. 那么我们双击QXSpirit.xaml ,窗口中显示的就是它的界面了, 暂时是一片空白的, 我们首要添加的当然就是迫不及待想要出世的角色了, 所以我们这样写:
需要特别说明的是: 这么几个字,用TextBlock或Label现成的控件不就行了, 何必要劳师动众那么夸张自己去写个控件来实现? 对WPF/Silverlight中的中文字有一定了解的朋友都知道, 在WPF/Silverlight中 , 文字都是矢量的, 它在显示时被处理过(仿佛像是Photoshop中的文字锐利效果) ,因此显得模糊不清例如假设我将本例的3个描述身份控件全用TextBlock来替换, 那么效果将如下图:破天一8普通的矢量字为是没有边畿的, , 《 门因 此 它 磔 方 ; 外上 不 飒 雅 口• /我们可以很清晰的看到, 在位图中12像素的字是不会显示成这样的; 但是在WPF/Silverlight中, 它的效果看上去是带模糊而 我 在QXControl:BorderText控 件 中 添 加 了 一 个Stroke属性和一个StrokeThickness属 性 , 它们分别用来设置文字的描边线颜色和描边线粗细并 且StrokeThickness是double型 , 这样我就可以以任意想要的粗细对文字描边进行设置了在上文代码中, 我将之设置为0.1 ,这样显示出来的效果如下图:喳诏大帅姐:破 和2哮0 1的黑色手体备阚果., 可以让总比较有垂隹感,虽然送不到完美!•至少能让 WPF/SilveG,ight 中的矢量文字优美很多.■虽然还无法达到最好的效果, 但比起普通无描边的文字来说会美化些,由于文字只有12像素大小, 在它上面描边无法很清晰的显示。
因 为,为了让大家 更 好 理 解WPF/Silverlight中TextBlock和QXControkBorderText文字效果区别, 我分别在这两个控件中输入“ 深蓝色"3个 字 ,4 8像素, 其中QXControkBorderText以黑色1.5像素描边( 该控件默认字体为“ 微软雅黑 " ) , 得到以下效果比较图:左边为〈TextBlock FontSize="48" FontFam"y=" 微软雅黑"Text=" 深蓝色"Foreground="Pink” />创建的 ,右边为< QXControl:BorderText FontSize="48" Stroke="Black" StrokeThickness="1.5" Text="深蓝色" Fill="Pink" / >创建的, 不用我说大家都会明白谁更幽雅漂亮了吧? 更可贵的是, 它提供了另一种WPF/Silverlight中 关 于 中 文 字 体 模 糊 的 解 决 方 案 因 此 ,在 后 面 的 游 戏 设 计 中 ,我将以QXControlBorderText作为主要的文字控件使用呼呼, 到此终于将我们可爱的植雪性界面xaml代码写完,剩下的就是在Behind代码中丰富精灵的内部了。
在下一节中, 我将就精灵控件后台代码及上一节中遗留的问题: 在地图移动中, 主角( 精灵) 与障碍物如何跟随移动进行讲解, 敬请关注15精灵控件横空出世! ②紧接着上一节, 我们打开QXSpirit.xaml.cs文件在游戏设计中, 为了能够轻易控制及管理精灵的各项属性及功能等, 我赋予每个精灵一个专属线程, 它在精灵的使用中起到关键作用:public QXSpirit( ) {InitializeComponentQ;InitThread(); //初始化精灵缆呈}DispatcherTimer Timer = new DispatcherTimer();private void InitThread() {Timer.Tick += new EventHandler(Timer_Tick);Timer.StartO;}〃精灵线程间隔事件int count = 0;private void Timer_Tick(object sender, EventArgs e) {Body.Source = new Bitmaplmage((new Uri(ImageAddress + count + ".png",Uri Kind.Relative)));count = count == 7 ? 0 : count + 1;)DispatcherTimer线程的创建在前面的章节中见得不要太多, 这里不再累述了. 那为何要在精灵控件中配置一个线程呢? 打这样一个比方吧: 我们可以把精灵比做一个人, 它的生命就好比这个线程, 每个人只有一条命, 在精子与卵子结合之后( 控件的初始化中创建) , 生命即开始鲜活( 创建后即启动) 简单说就是“ 命悬一线" 啦。
汗一个… 目前该线程与前面章节中的一样, 暂时只做精灵动作图片切换用Timer_Tick()),至于其他功能, 我将在后面的章节中进行讲解赋予了精灵生命以后, 接着需要培养它的性格, 让它拥有更多的能力、更多的属性当 然 , 大家首先迫切想要实现的就是前两节遗留下来关于精灵的X , Y属性那么先来看代码:〃精灵X坐标( 关联属性)public double X {get { return (double)GetValue(XProperty);}set { SetValue(XProperty, value);})public static readonly Dependencyproperty XProperty = DependencyProperty.Register("X、〃属性名typeof(double), 〃属性类型typeof(QXSpirit), 〃属性主人类型new FrameworkPropertyMetadata((double)O, 〃初始值 0FrameworkPropertyMetadataOptions.None, ^ 定界面修改〃不需要属性改变回调null,//new PropertyChangedCallback(QXSpiritlnvalidated),〃不使用强制回调null));〃精灵Y坐标( 关联属性)public double Y {get { return (double)GetValue(YProperty);}set { SetValue(YProperty, value);}}public static readonly DependencyProperty YProperty = DependencyProperty.Register("Y",typeof(double),typeof(QXSpirit),new FrameworkPropertyMetadata((double)O,FrameworkPropertyMetadataOptions.None,null,null));以上代码实现了 QXSpirit控件的X , Y关联属性. 大家不要被看似复杂的代码所吓着, 其实很简单的,让 我 一 道 来 。
首先将以上代码分成两部分:X坐标为第一部分,Y坐标为第二部分. 它们的结构是一模一样的, 我们可以忽略Y坐 标 , 只要理解了 X关联属性的实现, 将X换成Y即可.关于关联属性( 上一节中的AttachProperty(附加属性) 其实也是通过关联属性来实现的) 的相关知识,网上不要太多, 它不是本教程的重点所以就不多做解释了理解它的朋友都明白, 上面代码是它的标准创建形式,public double X 是它的属性访问器,public static readonly DependencyProperty XProperty则是定义它就如上面代码注释中写到的, 分别定义它的属性名、类型、所处类名等等这样, 一个完整的X关联属性就完成了 有的朋友又困惑了, 为什么要那么麻烦去创建关联属性? 我直接这样写不就得了 :public double X { get; set;}即传统又简单但 是 , 我想告诉大家的是, 在WPF/Silverlight中 , 只有关联属性才能被更好的使用及识, 例如在属性的绑定,Storyboard目标属性的设定等等中, 都必须使用到关联属性来实现, 后面的章节中会讲到它的必要性。
而像如上的属性访问器只能用于创建纯描述性属性, 例如精灵图片地址目录等,就可以使用属性访问器:〃精灵图片源目录地址public string ImageAddress { get; set;}至此, 我们完成了一个初具雏形的精灵控件, 接下来就是如何将之加入到游戏中了首先要做的当然是添加精灵控件的引用:using WPFGameCourse.Controls;接下来就是创建精灵控件实例并将之添加进窗口的Carrier控件中:QXSpirit Spirit = new QXSpirit();private void InitSpiritQ {Spirit.X = 300; 〃为精灵关联属性X赋值Spirit.Y = 400; 〃为精灵关联属性Y赋值Spirit.Timer.Interval = TimeSpan.FromMilliseconds(150); 〃精灵图片切换频率Spirit.ImageAddress = @"..Wayer\"; 〃精灵图片源地址Carrier.Children.Add(Spirit);)从代码可以看出, 我们已经可以自由的使用Spirit的X , Y属性了, 并且轻松的控制该精灵的图片切换频率( 为什么我们需要去控制它的切换频率呢? 因为在游戏中, 角色施放魔法有施法速度; 物理攻击时有攻击速度、甚至可能会被冻结( 移动速度减1麻 痹 ( 精 灵 不 动 \ 加 速 移 动 攻 击BUFF等 等 , 这些不光需要更改角色的相关属性逻辑, 更需要在游戏窗口表现时通过调整精灵图片切换速率来实现之, 因此意义是相当相当重大的) , 是不是有些成就感了 ?至于我们在牵引地图移动的同时, 如何实现角色及障碍物的跟随移动? 有了 X , Y属性以后, 接下来的就再简单不过了,首先来看这张图:然 谈 前 的i v a s .s设P i r i t.X + C a n v a s : g e tL e f t (M a p ) ; Sp i r i t. Y + G誓a矗 幽施这并不是最臻显示的位置,由于。
5丫 &5 . .此式,. 与匚嬴公, 哀汗$0两个方法显示图片位置是对应到图片左上角的点因此, 我们在窗口中显示的主角位置还需要减去主角脚底离生图片左上角的距离最终主角在游戏窗口中显示的位置为:最终主角的坐标在J点 , 而它在窗口中鬲显示位置则在KA+ 地图此时的C a n y a s ..g A tL e £;t (H a p )二 ~ AC a n v a s f g e (To p (M a p ) = _E他Y由此并结合地图图片处于窗口中的位置可以推出主角'在 地 图 图 片 中 . 的 坐 标 反 布F=Spirit.Y:' 然后根据 =£-h正F -B;最终可以得到精灵脚底的位置为e t L e f t (Sp i r i t, Sp i r i t. X + m a p l e f t - Sp i n tC e n te r Xo p (Sp i r 11 Sp i r i t Y + m a p to p - Sp i n tC e n te r Yj誓町 盯 过 吊 吐 吸 ? 反而使王电也跟鲁名 酬 遇 酬因为地图图片在随鼠标牵引的情况下, 它 的Canvas.getLeft(Map)和Canvas.getTop(Map)属性是时时更新的, 在第十三节中有提到。
那么在地图动的时候, 当已知主角在地图图片中的坐标(SpiritX,Spirit.Y )后 , 即可以按上图根据公式计算出它在游戏窗口中显示的时时位置为: Spirit.X+ Canvas.getLeft(Map)-SpiritCenterX * GridSize , Spirit.Y+ Canvas.getTop(Map) - SpiritCenterY * GridSizeo 有了它, 在后面章节中包括A*寻路计算, 直线移动等方面的计算不再需要考虑SpiritCenterX和SpiritCenterY, 这将大大简化游戏设计据 此 , 我们在游戏窗口的TimejTick事件中这样写:int scrollspeed = 3; 〃定义地图滚动速度private void Timer_Tick(object sender, EventArgs e) {double mapleft = Canvas.GetLeft(Map);double maptop = Canvas.GetTop(Map);〃主角跟随地图同时移动Canvas.SetLeft(Spirit, Spirit.X + mapleft - SpiritCenterX * GridSize);Canvas.SetTop(Spirit, Spirit.Y + maptop - SpiritCenterY * GridSize);〃所有障碍物实体同样跟随移动( 实际中并不需要下面代码, 这里只为测试用)foreach (UIEIement uie in Carrier.Children) {if (uie is Rectangle) {Rectangle r = uie as Rectangle;Point p = getPointFromTag(r.Tag); 〃此方法在上一节有介绍Canvas.SetLeft(r, mapleft + p.X * Gridsize);Canvas.SetTop(r, maptop + p.Y * GridSize);})}上面黄色代码部分即为通过公式来改变主角在游戏窗口中的显示。
由于此时的障碍物实体为Rectangle ,因此可以通过foreach来改变窗口中所有障碍物显示实体Rectangle对象的显示位置来描绘障碍物同样随着地图的移动而移动( 实际中并不需要此段代码, 只为了演示\到此为止就完成了牵引式地图移动模式中的所有对象跟随地图移动最后大家按下CTRL+F5并任意移动移动地图看看:■ findovl4_l&置不变, 从而证明了我、 电功能的实现③此前暮箱对于地图仍然位于屋顶左边这位嘿 嘿 , 都能跟随移动了呢, 但是仿佛还遗漏了什么? ? 对 了 , 还没实现此模式下主角通过鼠标点击进行走路呢, 而且在走路的同时如果我们牵引地图移动, 主角也能同样的显示在正确窗口位置上, 这又涉及到多个坐标系中坐标的换算, 就让这头疼的问题留给下节去处理吧, 敬请关注16牵引式地图移动模式②精灵控件让游戏开发更美好! 有了它, 离完善牵引式地图移动模式可谓一步之遥只剩下最后一个环节了 ,大家加油吧上 一 节,我在界面线程中通过时时设置 Canvas.SetLeft(Spirit, Spirit.X + Canvas.GetLeft(Map)-SpiritCenterX * GridSize);和 Canvas.SetTop(Spirit, Spirit.Y + Canvas.GetTop(Map) - SpiritCenterY *GridSize);来实现主角跟随着地图移动。
从该公式我们可以分析出影响主角在窗口中显示位置的两个因素:第一个为地图图片(Image Map)相对于窗口的位置(Canvas.GetLeft(Map) , Canvas.GetTop(Map)) ,它是在鼠标牵引地图移动的时候时时改变的, 与主角在地图上的走动无关; 第二个则为主角自身的X , Y坐标属性(SpiritX , Spirit、) , 当主角在地图上走动时, 它是时时更改的由此可以得到一个结论: 要实现主角在此模式地图上的移动, 只需要在它走路的时候时时更新它的坐标SpiritX和SpiritY即可, 这样界面线程中会同步更新主角在窗口中的位置而达到完美的游戏动画衔接找到了切入点, 那么实现起来就简单多了这里, 我们首先需要对前面章节中的A*寻路方法进行一些改进在前面的章节中, 由于地图是固定死不动的 , 且尺寸相当于窗口大小, 这样我们简单的将地图和窗口示为一体 因此, 在A*寻路过程(AStarMoveQ )中同时实现了主角相对于地图的移动,即 基 于 对 象 关 联 属 性 为PropertyPath("Canvas.Left"),PropertyPath("Canvas.Top")的Storyboard动画。
但是在牵引式地图移动模式中就不能这样做了, 根据前面分析的原理, 则必须改为基于对象关联属性为PropertyPath("X") , PropertyPath("Y")0<] Storyboard动画此时的动画或许将之理解为从寻路得到的路径序列点中连续取出坐标的计时器更加贴切, 因为它只负责改变Spirit的X , Y属性而不负责在界面中更新Spirit的位置实现动画但是这已经足够了, 因为它已经满足了原理中更新精灵坐标SpiritX和SpiritY的目的( 剩下的任务交由界面线程去做就好了, 代码与上一节中的一样, 我们不需要理会X那好, 接下来就看我如何对A*寻路再次进行改造( 可别怕, 目前的A*寻路Storyboard动画方法已经是很成熟的了,只需要对它的几个关节进行修改即可以达到不同的使用目的, 其实在第九节、第十节中已经对其进行过修改了\接下来就是对A*寻路移动方法进行改造了 :private void AStarMoveTo(Point p) {〃进行坐标系缩小int start_x = (int)(Spirit.X) / GridSize;int start_y = (int)(Spirit.Y) / 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); 〃设置终点坐标if (path == null) {〃MessageBox.Show("路径不存在! " ) ;} else {〃创建X轴方向逐帧动画DoubleAnimationllsingKeyFrames keyFramesAnimationX = newDoubleAnimationUsingKeyFrames();〃总共花费时间= path.Count * costkeyFramesAnimationX.Duration = newDuration(TimeSpan.FromMilliseconds(path.Count * cost));Storyboard.SetTarget(keyFramesAnimationX, Spirit);Storyboard.SetTargetProperty(keyFramesAnimationX, new PropertyPath("X"));〃创建Y轴方向逐帧动画DoubleAnimationUsingKeyFrames keyFramesAnimationY = newDoubleAnimationUsingKeyFramesQ;keyFramesAnimationY.DurationnewDuration(TimeSpan.FromMilliseconds(path.Count * cost));Storyboard.SetTarget(keyFramesAnimationY, Spirit);Storyboard.SetTargetProperty(keyFramesAnimationY new PropertyPath("Y"));for (int i = 0; i < framePosition.Count(); i + +) {〃加入X轴方向的匀速关键帧LinearDoubleKeyFrame keyFrame = new LinearDoubleKeyFrame();〃平滑衔接动画( 将寻路坐标系中的坐标放大回地图坐标系中的坐标)keyFrame.Value = i == 0 ? Spirit.X : framePosition[i].X * GridSize;keyFrame.KeyTi m eKeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(cost * i));keyFramesAnimationX.KeyFrames.Add(keyFrame);〃加入X轴方向的匀速关键帧keyFrame = new LinearDoubleKeyFrameO;keyFrame.Value = i == 0 ? Spirit.Y : framePosition[i].Y * GridSize;keyFrame.KeyTimeKeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(cost * i));keyFramesAnimationY.KeyFrames.Add(keyFrame);}})以上代码中用黄色背景突出的即为需要修改的地方,其 中Storyboard.SetTargetProperty(keyFramesAnimationX, new PropertyPath("X")); 和Storyboard.SetTargetProperty(keyFramesAnimationY, new PropertyPath("Y"));这两句作用是将主角( Spirit的X ,Y属性值作为Storyboard动画的更新对象目标,值得注意的是keyFrame.Value = i == 0?Spirit.X : framePosition[i].X * GridSize;-^ keyFrame.Value = i == 0 ? Spirit.Y : framePosition[i].Y *GridSize;这两句话, 它们的作用是将寻路后得到的所有路径点按从角色起点到终点这样的顺序依次做为Storyboard的关键帧添加进Storyboard动画中, 并且首先舍弃寻路得到的第一个点而以坐标(Spirit.X ,Spirit.Y )作为动画起点(...i = = 0 ? Spirit.X ......i == 0 ? Spirit.Y...)从而起到平滑衔接动画的效果。
千万别小看它, 很多人往往忽略了它导致动画衔接粗糙( 大家可以尝试将keyFrame.Value = i == 0 ? Spirit.X:framePosition[i].X * GridSize;替 换 成 keyFrame.Value = framePosition[i].X * GridSize;将keyFrame.Value = i == 0 ? Spirit.Y : framePosition[i].Y * GridSize潜换成 keyFrame.Value =framePosition[i].Y * GridSize后再运行一下程序看看, 当角色正在移动且还未到达终点的时候, 此时你用鼠标再点击别的地方让主角向新的目的地移动, 由于未采取平滑处理将导致角色会突然叔励一下的效果( 起始坐标定位错误BUG \为了配合大家更好的理解, 我用张图来说明( 图中的网格即为单位为20*20的单元格, 即GridSize=20的效果) :Tindovl6A*寻路方法改造完成后, 最后就是在鼠标左键点击事件中去启动它了 :private void Carrier_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) {Point pl = e.GetPosition(Map); 〃点击的地方在M叩中的坐标点//Point p2 = TranslatePoint(e.GetPosition(Carrier), Map); 〃此方法效果和上面一样AStarMoveTo(pl);)这里的e.GetPosition(Map)我们可以理解为: 在M叩地图图片上点击, 返 回M叩上该点的坐标。
而我们同样的可以通过一个名为TranslatePoint的方法来达到相同的目的, 即我们在Carrier画布上点击,返回Carrier上该点的坐标, 接下来再将该点转换成Map地图图片上的位置点, 因此返回的结果都是一样的TranslatePoint 的形式很直观, 虽然在此处使用效果不太好, 但是将之用于地图边缘判断、物体与物体的碰撞与重叠判断、射程计算等问题上不失为一种优雅的解决方案0K , 一切就绪, 激动人心的时刻就要到了 ! 按 下CTRL+F5 , 尽情的在地图上随便点击吧, 并且在任何的时候你用鼠标去牵引地图移动, 主角和障碍物都会平滑的显示在正确位置上, 什么叫完美? This isPerfect!Right?后面的章节中, 我将完善前十六节中一直遗留着的一个很多很多朋友迫切想要解决的大问题: 如何实现植灵的八个方向? 重磅炸弹即将粉墨登场,敬请关注17完美精灵之八面玲珑( WPF Only) ①通过第十四节、第十五节的学习, 我们掌握了如何创建一个初具雏形的精灵控件目前我已经赋予了它少量的属性, 但是离完美还有很长的距离因此, 我打算在后面的章节中以辅助游戏设计为前提, 对该精灵控件进行全方位的包装, 使之更加趋于完美与和谐(A-A|| \在前面所有章节的示例中, 角色均只有一个方向, 且动作均为跑步状态, 因此很多朋友都问我该如何实现角色的全方向及全动作。
尽管我在文中已经提级了相关的解决方案;但是,对于初次接触WPF/Silverlight游戏制作的朋友在逻辑上或许还是有较大难度的那么本节将实现主角8方向( 在下文中凡涉及到角色方向时, 我均会以时钟0点的位置为0 , 然后顺时针方向依次为1、2、3、4、5、6、7 , 通俗来讲即方向代号代表角色面朝北、1代表东北、2代表东、3代表东南、4代表南、5代表西南、6代表西、7代表西北 ) 如下图:5大基本动作( 每个动作均由若干帧组成, 就拿本教程后面都要用到的主角图片为例, 其中站立动作由5张图片连续播放形成、跑动8张、攻击7张、施法6张、翘辫子8张当然, 在高质量的游戏中一个角色并不只这5个动作, 还有例如受伤、眩晕、上马下马等等动作, 本教程以教会大家如何去基础使用与常规制作为目的, 关于更精细的活还得靠大家自行发挥与创新至于你问我这些动作的帧数设置是否是最合理 , 我可以告诉大家肯定不是( 图片为破天一剑的 , 我保留了原先的帧配置未做大的改动\ 如果你打算开发的是Silverlight游戏, 我建议尽量让每个动作帧数不要超过5帧 , 实践证明这在网页游戏中能达到性能与效果的黄金分割; 至于在WPF这样带客户端的游戏中, 你大可将所有的动作均设置为10+帧都无所谓,在后面的教程中我将给大家见识一下剑侠世界中的16帧动作) 如下图:站立5帆£ 速 城全面讲解即将开始, 一定要认真听哦! 坐 正 啦 !首先我们需要通过3DMAX等工具制作出一个拥有这5个动作的3D动画角色, 然后将之导出为8个方向5个动作的系列帧图片( 大家如果只是做练习用, 可以从网上下载相关素材或通过提取工具来提取, 但是请勿用于商业用途, 否则是需要承担法律责任的\ 这个过程不在本教程范围内, 所以就不详细讲解了.接下来为了节省时间, 我就不再重新提取素材了 , 我将以QXGame( WPF GAME ENGINE)游戏引擎中的主角为例进行相关解说。
如果素材是通过3D渲 染2D的方式得到的, 那么素材均为统一标准尺寸的连续单幅图片( 不是的话找美工的麻烦) ; 但是如果是从网上下载或提取的话, 每帧图片的尺寸并不统一因 此 , 我们首要做的是将这些图片通过Photoshop中的" 动作”功能进行批量处理, 使所有帧图片尺寸规格统一就以本节中的主角为例, 我将它的所有帧图片规格均统一为150*150像 素 , 并且主角在图片中的水平及垂直方向均居中,如下图( 该图为局部,一共有8*34=272张 尺寸规格统一好以后, 接下来还需要对所有图片进行微调( 可想而知, 游戏制作中对美工人数的需求是极其庞大的就如我在前面章节中说到的, 游戏开发的成功, 极大幅度取决于美工, 程序逻辑方面仅位列游戏需求分析、界面地图美术设计之后排在第三\ 所谓微调, 就是将角色的各帧图片通过测试工具让它运动起来( 如第四节、第五节中的方法) , 然后观察每帧图片重叠起来位置是否吻合, 不吻合的则需要通过Photoshop进行微调, 使它上面的角色处于图片正确的位置上( 如下图则为错误的叠加, 我们必须将所有帧图片完全对齐, 这样连续切换的时候才能不漏破绽) :至此就完成了素材准备阶段。
一切就绪后, 接下来的工作就是将主角8方向5动作的所有帧图片( 本例子中的272张)进行处理,最终合成一张将这所有图片按一定规律排列的8方 向5动作整合图就以这272张图片为例, 如何使用W PF类库中的方法将它们合并为一张图片呢? 来看本节的第一个精华Composelmage 方 法 :III
上图上部分即为我调用 Super.ComposeImage( @"E:\Body\", "BodyO.png", 272, 150, 150) ;方法合成的主角8 方向5 动作的一张宽150*34=5100 ( 34=5+8+7+6+8 ) 像素、高 150*8=1200 ( 8=8个方向,按照上文中的顺序) 像素的整合图 ( 由于该图尺寸过大( 5100*1200像素) , 所以我将之缩小为原尺寸的15%左右以供给大家展示)从上图下部分中( 上部分的局部放大图) , 大家可以很清晰的发现图片排列的规律: 即 8 行 34歹 (] ; 从行看 , 由上至下的8 行分别为代号0-7这 8 个方向的所有图片; 从列看, 1-5列为站立帧图片, 6-13列为跑动帧图片, 14-20列为攻击帧图片, 21-26列为施法帧图片, 27-34列为死亡帧图片.理清了规律后, 如何对它进行局部单图截取? 嘿嘿, 且听下回分解18完美精灵之八面玲珑( WPF Only)②紧接着上一节, 首先得解释一下为什么需要将这272张图片合成为一张大图 因为如果游戏中还有装备、坐骑等其他设置,那么我们就需要对图片源进行时时的合成; 同时对272张甚至更多的图片进行合成效率高还是对2张大图进行合成效率高这是显而易见的。
在本节例子中, 主角由身体( 衣服) 及武器两个部分组成 ; 因此, 我们还需要定义一个交错数组来保存已经加载的角色装备合成图到内存中:///
…依此类推当然, 你也可以使用Hashtable (哈希表\ Dictionary (字典) 等来代替Partlmage[,][,]o但是在数字类型键与对象值对应保存的方式中, 我更倾向于交错数组, 因为它更清晰、优雅且高效有了承接角色的载体, 下面就是如何对上二至中合成的角色大图与武器大图( 提取及合成方法同上一节相同) 进行拼装, 最后分帧存储进Partimage.嘿嘿, 又现精华:///
] 为武器代号, 本例中装备只由衣服+ 武器组成〃假如内存中没有该装备的角色现成图片源则进行读取if (PartImage[Equipment[O], Equipment[l]] == null) {BitmapSource[,] bitmap = new BitmapSource[rowNum, colNum];〃加载角色衣服( 身体) 大图BitmapSource bitmapSource = new Bitmaplmage(new Uri(@"Images\Body" +Equipment[0].ToString() + ".gif", Uri Kind.Relative));〃假如武器不是0 ,即如果角色手上有武器而非空手if (Equipment[l] != 0) {〃加载武器大图, 并与衣服大图组装BitmapSource bitmapSourcel = new Bitmaplmage(newUri(@"Images\Weapon" + Equipment[l].ToString() + ".gif", UriKind.Relative));DrawingVisual drawingVisual = new DrawingVisual();Rect rect = new Rect(0, 0, totalwidth, totalHeight);DrawingContext drawingContext = drawingVisual.RenderOpen();drawingContext.DrawImage(bitmapSource, rect);drawingContext.DrawImage(bitmapSourcel, rect);drawingContext.CloseO;RenderTargetBitmap renderlargetBitmap = newRenderTargetBitmap(totalWidthz totalHeight, 0, 0, PixelFormats.Pbgra32);renderTargetBitmap.Render(drawingVisual);bitmapSource = renderTargetBitmap;〃降低图片质量以提高系统性能( 由于本身图片已经为低质量的g if类型, 因此效果不大)//RenderOptions.SetBitmapScalingMode(bitmapSource,BitmapScalingMode.LowQuality);)for (int i = 0; i < rowNum; i++) {for (int j = 0; j < colNum; j++) {bitmap[i, j] = new CroppedBitmap(bitmapSource, new Int32Rect(j *singleWidth, i * singleHeight, singleWidth, singleHeight));))〃将装备合成图放进内存PartImage[Equipment[0], Equipment[l]] = bitmap;return bitmap;} else {〃如果内存中已存在该装备的角色图片源则从内存中返回合成图, 极大提高性能return PartImage[Equipment[0], Equipment[l]];)}该方法我已经做了非常详细的注释, 大致原理就是将上一节中合成的角色身体大图(5100*1200那张)与一张同样尺寸的武器大图进行合成, 组装成一张5100*1200像素的带武器的角色图, 最后再将这张图进行所有序列单帧按150*150尺寸进行切割存储进Partimage这个数组中:有了 EquipPart()方法后还暂时无法使用它, 因为精灵控件还缺少一些能与之对接的属性。
因此我们首先还得为可爱的精灵控件添加如下属性:/ / 精灵当前调用的图片源( 二维数组) : 第一个表示角色方向,0朝上4朝下,/ / 顺时针依次为0,123,4,5,6,7;第二个表示该方向帧数public BitmapSource[,] Source { get; set;}/ / 精灵方向数量, 默认为8个方向public int DirectionNum { get; set;}/ / 精灵当前动作状态public Actions Action { get; set;}/ / 精灵之前动作状态public Actions OldAction { get; set;}/ / 精灵各动作对应的帧列范围( 暂时只有5个动作)public int[] EachActionFrameRange { get; set;}/ / 精灵每方向总列数public int DirectionFrameNum { get; set;}/ / 精灵当前动作开始图片列号public int CurrentStartFrame { get; set;}/ / 精灵当前动作结束图片列号public int CurrentEndFrame { get; set;}/ / 每张精灵合成大图总宽public int TotalWidth { get; set;}/ / 每张精灵合成大图总高public int TotalHeight { get; set;}/ / 精灵单张图片宽, 默 认150public int SingleWidth { get; set;}/ / 精灵单张图片高, 默 认150public int SingleHeight { get; set;}///
这里需要对几个特别的属性进行些说明:BitmapSource[,] Source是我们可以通过EquipPart( )方法获取的图片源, 在精灵生命线程中调用以显示对应的精灵图片;Actions Action和Actions OldAction是两个精灵动作的枚举属性, 该枚举构造如下:public enum Actions {///
接下来该让精灵动一下了,我们可以将精灵的生命线程进行如下改进:〃帧推进器public int FrameCounter { get; set;}〃精灵线程间隔事件private void Timer_Tick(object sender, EventArgs e) {〃假如精灵动作发生改变, 则调用ChangeAction()方法进行相关参数设置if (OldAction != Action) {ChangeAction();)〃动态更改精灵图片源以形成精灵连续动作Body.Source = Source[(int)Direction, FrameCounter];FrameCounter = FrameCounter == CurrentEndFrame ? CurrentStartFrame :FrameCounter + 1;)这里我将前面章节中的count改成了 FrameCounter( 即帧推进器, 意义差不多, 但是在此处效果不同,它更加动态, 大家需要承上启下的分析后上啜容易理解) , 然后在生命线程事件中首先判断主角当前的动作状态是否改变( 例如主角默认是站立的, 当在地图上点击了一下后动作即变成跑动状态) , 如果改变则调用ChangeAction。
方 法,该方法完整代码如下:III
Timer_Tick()事件中判断完精灵动作状态后, 就需要动态的配置精灵的图片源了 :Body.Source = Source[(int)Direction, FrameCounter];Source的第一个参数为精灵当前的朝向, 第二个参数为帧推进器有的朋友就问了 : 前面增加的属性中并没有Direction这个属性呀? 是 的 , 我就是为了突出该属性的重要所以特别在此再申明, 具体如下:〃精灵当前朝向:0朝上4朝下, 顺时针依次为0,123,4,5,6,7(关联属性)public double Direction {get { return (double)GetValue(DirectionProperty);}set { SetValue(DirectionProperty, value);})public static readonly Dependencyproperty Directionproperty =DependencyProperty.Register("Direction",typeof(double),typeof(QXSpirit));跟着我教程学习的朋友一看就知道它是一个关联属性( 参考第十五节) , 为什么需要将精灵的朝向单独作为一个关联属性来定义? 因为我将在主角的Storyboard移动动画中对精灵的方向进行时时修改, 以使得寻路移动动画更加平滑( 本例中的Storyboard仍然沿用DoubleAnimation类型逐帧动画, 而不是objectAnimation类型;因此为了与前面章节更好的兼容,Direction在此设置为double爨 \0K ,到匕已经写了那么多属性和方法, 休息休息看一下我们的成果吧:终于看到了久违的主角站立动作, 是否有种感动得想要流涕的冲动? 再看一张虽然我们可以通过点击地图上的点进行移动, 但是无论如何移动, 主角的方向始终都是朝着0 (即北)这个方向的。
那么如何利Direction这个关联属性让主角在任何动作中均可以显示正确的朝向? 请听下回分解19完美精灵之八面玲珑(WPF Only)③首先我要对上一节中最后的ChangeAction()方法进行一些补充说明. 该方法的作用之一是根据精灵的当前动作(Action)来设置精灵切图动画的起始帧和结束帧:0 1 2 5 4 5 6 7 8 9 10 11 12 15 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 23 30 31 32 33当动作为站立时: 当动作为死亡时:CurrentStartFrame = 0 CurrentStartFrame = 26CurrentEnd Frame = 4 CurrentEnd Frame = 33当动作为跑步时:CurrentStartFrame = 5CurrentEndFrame = 12当动作为施法时:CurrentStartFrame = 20CurrentEndFrame = 25当动作为攻击时:CurrentStartFrame = 13CurrentEndFrame = 19如 上 图 , 我们可以很清楚的看到精灵这5个 动 作 所 分 别 对 应 的CurrentStartFrame和CurrentEndFrame.这两个参数很重要是因为在精灵的生命线程中我们可以通过如下黄色区域代码来实现动态的更新精灵角色图片以形成连续动画:〃精灵线程间隔事件private void Timer_Tick(object sender, EventArgs e) {〃动态更改精灵图片源以形成精灵连续动作Body.Source = Source[(int)Direction, FrameCounter];FrameCounter = FrameCounter == CurrentEndFrame ? CurrentStartFrame :FrameCounter + 1;)举个例子: 当精灵在跑步的时候FrameCounter从5开始记数, 然后以1为单位阶梯推进, 目标是12 ,当到了 12后再返回5继续重复前面的过程; 当精灵在施法的时候,FrameCounter从20开始记数 , 然后以1为单位阶梯推进, 目标是25 , 当到了 25后再返回20重复前面过程。
其他的以此类推. . . . . .充实了精灵动画原理后, 我们再重新回到本节的主题上: 如何使精灵在移动的时候表现出正确的朝向以及精确的定位与停止大家是否还记得我在朝 芭 结 尾的地方略有提到相关的实现方法, 但是并未对之进行实现, 也算留给大家的一个小思考吧但是本节我既然起了完美精灵这个题目, 就不打算辜负所有朋友们的期待, 我们首先分析实现8方向精灵的步骤:1、获取主角当前的坐标, 这在 筮 土 建 中 已 经完美实现了, 而且同样是定位3」 脚 底的(SpiritX ,Spirit.Y \2、获取目标坐标, 即鼠标左健( 或右键) 点击的点的坐标, 该坐标我们可以通过鼠标左键( 或右键 ) 点击事件轻松得到, 这在前面的章节里有大量的提及3、以以上两个坐标为参数, 通过正切值计算公式计算出主角当前的朝向并返回一个数字代号(0-7分别对应8个方向)具体如何操作, 且看下图:假设点A为主角当前坐标, 点B为移动目的坐标, 那么我们首先通过两点之间的正切值计算公式计算出点B与点A的正切值:TanCBA) = (B. y - A. y) / 0 . x - A. x)然后我们可以通过判断该正切值介于线L与线啰正切值之间, 即 :Tan (K) <= Tan (BA) <= Tan (L)其中Tan CL); Tan (67. 5°)Tan 0 0 =Tan (22. 5°)从而判断出精灵向目标B点移动时精灵的朝向代号为1, 其他的以此类推。
原理在上图右半部分的注释中描述很清楚; 我依据此原理写了个通用判断朝向的方法, 精 华 哦 :III 或许你写的算法更优秀呢?有了该方法, 接下来就是在鼠标左键点击事件中获取目标点, 并且将主角的当前动作切换成跑步状态 , 并启动A*寻 路 :private void Carrier_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) {Point p = e.GetPosition(Map); 〃点击的地方在M叩中的坐标点Spirit.Action = Actions.Run; 〃主角动作切换成跑步状态AStarMoveTo(p); 〃开始寻路)上两节的AstarMoveT")方法中的Storyboard动画只创建X , Y序列点, 而为了实现角色时时朝向 , 我们还需要创建对应的角色方向(Direction)序列点, 因此我们还需要对本节中的AstarMoveTo()方法进行如下改进:private void AStarMoveTo(Point p) {〃创建X轴方向逐帧动画DoubleAnimationllsingKeyFrames keyFramesAnimationX = newDoubleAnimationUsingKeyFramesQ;〃总共花费时间= path.Count * costkeyFramesAnimationX.Duration = newDuration(TimeSpan.FromMilliseconds(path.Count * cost));Storyboard.SetTarget(keyFramesAnimationX, Spirit);Storyboard.SetTargetProperty(keyFramesAnimationX, new PropertyPath(nXn));〃创建Y轴方向逐帧动画DoubleAnimationUsingKeyFrames keyFramesAnimationY = newDoubleAnimationUsingKeyFramesQ;keyFramesAnimationY.Duration = newDuration(TimeSpan.FromMilliseconds(path.Count * cost));Storyboard.SetTarget(keyFramesAnimationY, Spirit);Storyboard.SetTargetProperty(keyFramesAnimationY, new PropertyPath("Y"));〃创建朝向动画DoubleAnimationUsingKeyFrames keyFramesAnimationDirection = newDoubleAnimationUsingKeyFramesQ;keyFramesAnimationDirection.Duration = newDuration(TimeSpan.FromMilliseconds(path.Count * cost));Storyboard.SetTarget(keyFramesAnimationDirection, Spirit);Storyboard.SetTargetPropertyCkeyFramesAnimationDirection, newPropertyPath("Direction"));for (int i = 0; i < framePosition.Count(); i++) {〃加入X轴方向的匀速关键帧LinearDoubleKeyFrame keyFrame = new LinearDoubleKeyFrame();〃平滑衔接动画( 将寻路坐标系中的坐标放大回地图坐标系中的坐标)keyFrame.Value = i == 0 ? Spirit.X : framePosition[i].X * GridSize;keyFrame.KeyTime =KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(cost * i));keyFramesAnimationX.KeyFrames.Add(keyFrame);〃加入X轴方向的匀速关键帧keyFrame = new LinearDoubleKeyFrame();keyFrame.Value = i == 0 ? Spirit.Y : framePosition[i].Y * GridSize;keyFrame.KeyTime =KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(cost * i));keyFramesAnimationY.KeyFrames.Add(keyFrame);〃加入朝向匀速关键帧keyFrame = new LinearDoubleKeyFrameO;keyFrame.Value = i == framePosition.GetllpperBound(O)? Super.GetDirectionByTan(framePosition[i].X, framePosition[i].YframePosition[i - 1].X, framePosition[i - 1].Y): Super.GetDirectionByTan(framePosition[i + 1].X, framePosition[i + 1].Y,framePosition[i].X, framePosition[i].Y)keyFrame.KeyTimeKeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(cost * i));keyFramesAnimationDirection.KeyFrames.Add(keyFrame);}storyboard.Children.Add(keyFramesAnimationX);storyboard.Children.Add(keyFramesAnimationY);storyboard.Children.Add(keyFramesAnimationDirection);)该方法中的黄色部分即为新增加的内容,Direction动画创建与X , Y两个关联属性动画如出一辙,大家对比分析一下就可以轻松理解, 原理大致是将寻路中得到的点序列进行后一个点与前一个点之间计算正切值以确定方向, 再将这些方向序列值作为关键帧加入进Storyboard逐帧动画中。 一切就绪, 让我们启动程序看看成果吧:嘿嘿, 角色可以显示正确的方向了呢! 但是还存在一个小问题, 当主角到达目的地后仍然保持着跑步状态而非站立这里, 我通过以下方法来判断并返回是否到达了目的地:〃判断是否移动到目的地private bool ArriveTarget() {return (storyboard != null && storyboard.GetCurrentProgressQ == 1) ? true : false;最后, 我将ArriveTarget()方法加入到游戏窗口界面刷新主线程中, 判断当主角到达目的地时, 切换主角的动作为站立状态:〃游戏窗口刷新主线程间隔事件private void Timer_Tick(object sender, EventArgs e) {〃判断主角是否移动到了目标, 如果是则动作切换成停止if (ArriveTargetO) {Spirit.Action = Actions.Stop;))终于完成了, 忽忽… 用了 3节才写完呢, 真够累的欣赏一下最终成果, 犒劳犒劳一下自己吧:神奇的虚拟世界还需要我们继续去锻造完善, 下一节我将对前面19节的内容来个补遗及拓展, 也算给本教程第一大部分( 1-20节) 做个小结吧, 敬请关注。 20第一部分拓展小结篇写了 20节 , 一路向追着鬼子打一样都没停过, 索性也想暂时休息一下整理整理思绪好完成后面的第二部分更为精彩的内容: 诸如主位式地图移动模式、N P C &怪物与主角的互动、对象A L攻击与魔法、各种类型伤害计算、完美的RPG游戏界面. . . . . . 等等等等, 激动吗?讲实话: 我很激动!读者声音: 还没写就开始激动了,典型的傻子人_ 人| | 言归正传, 本节就先来个承上启下的的小结吧, 我打算分4个部分对前20节内容进行补充拓展:一 . 完美的改进型A*寻路移动模式在上一节中 , 我们虽然实现了精灵的全方向与动作, 但是细心的朋友就会发现, 精灵在走路的时候一直使用着A* ; 这将导致两个问题:1、性能上的损失, 每次移动不管中间是否有障碍物都启动寻路算法,造成资源的白白浪费;2、在夏匕 池 的结尾我曾轻描淡写的叙述了如何实现改进型A* ,虽然通过副本地图简单实现了, 但是暂时并不完美那么, 下面我将向大家讲解通过地地道道的方法实现改进型完美A*移动模式何谓改进型完美A*移动模式? 即主角每次移动时, 首先并不启动A*寻路而是直接建立两点间的直线移动; 接下来即进行时时的障碍物判断, 如果没有碰撞到任何障碍物或对象则将该直线移动保持到终点;但是中途一旦碰到障碍物, 则以目的地为终点即时启动A*寻路。 原理很简单, 关键技术就是如何对碰撞进行检测?传统的方法有两种:第一种我且称之为坐标还原法: 即时时记录精灵未碰撞障碍物时的坐标(Qd_X , Old_Y),在精灵移动时一旦检测到精灵此时站到了障碍物上, 则将精灵此时的坐标进行还原(X=Old_X , Y= O ld_Y),然后启动A*寻路此方法的优点是使用简单, 不需要复杂的判断逻辑; 缺点是效果不好, 在画面上将造瑞S灵一瞬间被弹开的情况, 虽然那一刻非常的短暂且距离微小, 但是对于精灵移动动画平滑性的影响是严重的,因此我们最好不要采用此方法第二种为启发式预测法: 该方法的原理为时时对精灵前方的区域进行预测, 一旦发现前方有障碍物,则即时启动A*寻路直到目的地该方法可谓绝对皇室血统, 一个字" 正" , 集所有优点之大成者; 优点多相对的实现起来难度就大些在WPF/SilveHight中如何实现之? 先来看下图:精灵在两点之间进行直线移动时, 它的朝向100幅固定的例如左图中, 该精灵从点A向点B移动, 此过程中精灵的朝向Directi on始终为3 , 那么我们就可以通过预测3这个方向上, 精灵前方是否有障碍物, 即判断点C是否为障碍物, 如果是即启动A*寻路饶过它,否则继续延着直线L前进, 直到目的地点及直线L -上图中已经给了很详细的说明, 即在直线移动过程中, 精灵时时判断此时朝向前方的单元格是否为障碍物, 如果是则启动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;})WillCollideO方法依据精灵的朝向判断精灵前方是否为障碍物( 即判断障碍物数组Matrix口此时是否为01有了它以后, 我们同样还需要像筮土二芭一样建立一个名为^ ^ 。 「0^ ^ ” 6丁 ( ) 的方法用于精灵直线移动 , 此时我们只需要在第十二节代码的基础上增加精灵朝向部分即可:〃直线移动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.BeginQ;)这里要特别注意的是我用黄色背景注明的totalcost这个变量,它的值代表精灵在两点间移动所需要花费的时间, 计算它的目的是因为Storyboard动画是基于时间轴的动画( 即在一个规定时间内完成指定动画 ) , 第一节中也有相应的说明. 因此, 为了让精灵在全角度( 不仅仅是8个方向, 是360度全方位) 的任意两点间直线移动时均使用统一速度( 每移动一个单元格固定花费UnitMoveCost毫秒) , 这样不论两点间是30度、40度、55度、76.3度、87.6度等等随意多少角度, 精灵均能进行平滑的均速移动.0K ,一切就绪, 接下来就是在游戏窗口中的鼠标左键点击事件中启动精灵的直线移动:private void Carrier_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) {Point p = e.GetPosition(Map); 〃点击的地方在M叩中的坐标点〃假如点击的地点不是障碍物if (Matrixf(int)p.X / GridSize, (int)p.Y / GridSize] != 0) {Spirit.Destination = p; 〃设置主角的最终移动目的地Spirit.Action = Actions.Run; 〃主角动作切换成跑步状态Spirit.IsAStarMoving = fake; 〃非寻路模式NormalMoveTo(p); / 廊点间建立直线移动))看完上面代码有朋友就要问了 : IsAStarMoving是什么东西? 简单讲, 它是精灵直线移动与A*寻路移动的枢纽。 虽然我们实现了 NormalMoveTo()和AStarMoveTo()这两种移动方式, 但是如何在逻辑中对其进行很好的衔接, 这里就必须加入IsAStarMoving这个精灵属性, 有了它, 我们就可以在窗口刷新事件中这样写:〃游戏窗口刷新主线程间隔事件private void Timer_Tick(object sender, EventArgs e) {〃判断主角是否移动到了目标, 如果是则动作切换成停止if (ArriveTargetO) {Spirit.Action = Actions.Stop;} else if (ISpirit.IsAStarMoving && WillCollideO) {〃在寻路移动模式中, 主角100%会饶过障碍物的,〃因此只有在非寻路模式中才需要时时判断主角是否将要碰撞障碍物AStarMoveTo(Spirit.Destination);Spirit.IsAStarMoving = true;}}通过黄色背景代码部分的逻辑我们可以轻松实现精灵的直线移动与A*移动的转换即精灵首先进行直线移动, 在它没有到达目的地之前(ArriveTarget()==false )我们需要时时判断它是否将要碰撞到障碍物(判断 WillCollideO是 否==True ) ,并 且 前 提 是 精 灵 在 此 移 动 中 还 没 启 动 过A*寻路( IsAStarMoving ==false ) ,因为一旦在直线移动中启动过A*寻路, 结果100%会引导精灵饶过障碍物到达终点,A*寻路过程中不需要额外再判断是否还会碰撞到障碍物, 那是多此一举( 如果出现偶然, 不要怪别人, 怪自己没把A*算法写正确\ 如果此两个条件都符合了, 则以精灵的移动目标(Destination )为终点启动A*寻路模式, 这样就顺利的由直线移动转入到A*寻路移动, 完美的衔接与枢纽。 二、完美遮罩层在第士二芭中我曾经讲解了如何实现地图遮罩层虽然是实现了, 但是还有一些小小的瑕疵, 如果不屏蔽它, 那么这会很大幅度影响到游戏的画面效果.首先我们按照第十一节中说到的方法来截取我们地图中心的这个标志物, 并取名为Mask3.png并且加载到项目M叩文件夹中:5000350000050接下来我们同样在后台代码中初始化它:〃创建遮罩层Image Mask = new Image();private void InitMask() {Mask.Width = 202;Mask.Height = 395;Mask.Source = new Bitmaplmage((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 + M叩Left);Canvas.SetTop(Spirit, Spirit.Y - SpiritCenterY + MapTop);Canvas.SetZIndex(Spirit, (int)Spirit.Y); //时时的更新它的层次( 画家算法)}这里关键的代码就是黄色部分代码, 我通过Canvas.SetZIndex(Mask, 612)设置了遮挡物的层次处于ZIndex : 612这个位置, 弄过网页的同志对Z -Index应该不陌生, 它们的意义是一样的。 最后还需要精灵的Z Index与之配合才能实现完美默契的遮罩效果,因此我们程中时时根据精灵的丫坐标来更改它的ZIndex : Canvas.SetZIndex(Spirit, (int)Spirit.Y),这样就算以后增加了其他的NPC、怪物之类的对象物体 , 只要同样的设置它们的ZIndex=Y ,即可以完美的实现时时的层次关系遁蜜肥窈鳄馥—特别提醒: 在窗口坐标系中, 越向下, 丫越大.三、完美换装这个就简单多了, 因为之前的章节中已经将相关参数与实现方法都定义好了, 那么剩下的就是如何调用的问题这里为了演示方便, 我在xaml里面添加了两个下拉列表(ComboBox )分别对应衣服及武器的代号, 和一个换装启动按钮( 本教程目录中有源码下载, 这里就不列出来了I然后我们在此按钮的点击事件中只需要3行代码就可以轻松的实现换装:〃换装private void ChangeEquipment(object sender, RoutedEventArgs e) {Spirit.Equipment[O] = Convert.ToInt32(comboBoxl.SelectionBoxItem.ToString());Spirit.Equipment[l] = Convert.ToInt32(comboBox2.SelectionBoxItem.ToString());Spirit.Source = Super.EquipPart(Spirit.Equipment, Spirit.DirectionNum,Spirit.DirectionFrameNum, Spirit.TotalWidth, Spirit.TotalHeight,Spirit.SingleWidth,Spirit.SingleHeight);)这三行代码实在太简单因此不再多做说明。 需要提的是在更换衣服与武器的时候, 如果内存中不存在该新衣服与武器搭配, 则需要时时的进行合成, 这会根据您CPU的速度来决定游戏卡壳的时间, 毕竟是一个较大量图片合成计算. 这在网络游戏中同样经常会遇到, 例如你是否有过这样的经历: 当你到了一定级别可以更换更高级的武器时, 你双击该武器的时候, 游戏会卡住那么以下, 然后 " 唯 铛 " 一 声 , 武器才安装上去; 但是如果你再把这把武器取下, 再重新换上时却一点也不卡, 这就是装备的缓存起到了作用我承仁1忒3 E 上司 我"m m 医科« » 「 三 ]A 8 | < )J 依鳌 1 « « 匚]Ka , _ 3 [i i ]R' I l f If e 4、' 邱」〕 五3 i兄四、A*寻路之大补充鉴于目前很多朋友反馈说A*难度过高而无法理解, 因此, 我打算就A*寻路的使用及相关要点做一次重大补充说明:使 用A* ,首先需要引用QX.dll程 序 集 ; 接着在程序中创建IPathFinder PathFinder = newPathFinderFast(Matrix);这里有个重要的参数Matrix ,它是用来描述寻路坐标系中( 即缩小后的坐标系)障碍物的二维矩阵, 我们这样创建它byte[,] Matrix = new byte[1024, 1024]; Matrix[x,y]与寻路坐标系中的坐标是一一对应的关系, 例 如 乂21以050,266]即对应寻路坐标系中的(150,266)这 个 点 , 假设GridSize=10 , 那么寻路坐标系中的(150,266)此点即对应游戏窗口中的Canvas.LeftProperty(1500),Canvas.TopProperty(2660),并且如果该点是障碍物, 则我们设置Matrix[150,266]=0 ,如果不是障碍物而是可以通行的地点, 则我们设置Matrix[150,266]=l , Matrix],]数组中的其他所有的点依此类推均设置后即完成了地图中障碍物的布局.这里出现了 GridSize这个很重要的概念, 它起到缩放坐标系的作用,如何来理解它呢? 这里我以byte[,] Matrix = new byte[1024,1024]为例,1024*1024像素地图仅仅是一张大约我们一个电脑桌面尺寸, 但是要在它上面构建障碍物却需要我们对1024*1024=1048576个点进行设置 , 简单有规律的障碍物布局还好, 要是遇到复杂的地图该怎么办? 这还是小事, 要是地图的尺寸为10000*10000像 素 ( 这在MMORPG中再常见不过了), 它带来的不仅是一个大内存数组int[,] Matrix =new int[10000,10000], 更可怕的是在没有制作地图编辑器之前去设置其中的100000000个障碍物, 简直就是T 牛让人崩溃致极的事。 因此, 我引入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=l时 , 此时的寻路坐标系= = 窗口坐标系, 这或许也能让朋友们更好的理解GridSize的意义2、提高寻路算法速度例如我们设置GridSize=20 , 此时在40*40像素的地图区域内只有2*2=4个游戏坐标系单元格即( 0 ,0 \ ( 0 ,1 \ ( 1 ,0 ] ( 1、1 ) ; 如果你需要让角色从区域左上角移动到右下角,则只需要在这4 个点内进行寻路计算出从点(0 ,0 ) 到点(1, 1 ) 的路径;而如果GridSize=l , 即不进行游戏坐标系中单元格缩放而以窗口中的像素点作为基础单元格, 那 么 在 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游戏制作中, 敬请关注21主位式地图移动模式是否期待了很久? 本节就来个重量级的做为开场白吧: 主位式地图移动模式 何谓主位式地图移动模式,即以主角为中心, 它的移动带动着所有对象包括地图、物体对象、其他玩家、怪物等等的相对移动, 这些对象的移动都是以主角为参照物的.最典型例子莫过于当前流行的MMORPG 了 , 你控制的角色在地图中永远是处于窗口正中心的位置(除了 8个角落外) , 这就是主位式地图移动模式( 如下图\有朋友开始焦躁了: 我的妈呀, 才刚学完牵引式地图移动模式, 还没完全吸收呢, 又来个什么鬼主位式地图移动模式, 头都大冽!其实一点也不用担心, 一个结构贼清晰的程序只需要您一次轻轻的手术即可以实现功能的革新是否对谦谦的魔术记忆犹新? 神奇的时刻即将降临,Let me show you the principle first:左 上主角在此区域 中 - -X、Y方 向 均 移 动K------------------------------------------------中上主角在此区域中只做丫方向移动X方向始终水平居中(950, 390)^(400, 390)右上主角在此区域中X、丫方向均移动左中正中右中主角在此区域中只做X方向移动丫方向始终垂直居中主角在此区域中只做X方向移动Y方向始终垂直居中(400, 1050)(950,1050)主角在此区域中X、丫方向均移动左下主角在此区域中只做丫方向移动X方向始终水平居中中下主角在此区域中X、丫方向均移动右下游戏窗口尺寸仍然暂定为800 * 600 , 如上图, 我将游戏地图( 尺 寸1750 * 1440 )分 为9个 部分;当主角处于夫卜( Spirit.X<=400 && Spirit.Y<=390 \ ( Spirit.X>=950 && Spirit.Y<=390 \ S T( Spirit.X<=400 && Spirit.Y>=1050 \ ( Spirit.X>=950 && Spirit.Y> = 1050 )这 4 个角落区域时 , 地图静止, 主角 如 勤 走 中 的 一样可以任意在窗口中移动, 直接讲就是主角在窗口中的显示位置即是它的图片左上角点(X-CenterX,Y-CenterY);当 主 角 处 于 近 (Spirit.X>400 && Spirit.X<950 && Spirit.Y <=390 X ( Spirit.X>400 &&Spirit.X<950 && Spirit.Y > = 1050 )这2个边缘区域时, 主角在水平方向上始终居中, 移动时它在窗口中只会做上下移动, 水平方向上通过地图相对反向移动形成主角水平方向前进的视觉效果;当 主 角 处 于^$_(Spirit.>390 && Spirit.Y<1050 && Spirit.X<=400)、有 史(Spirit.>390 &&Spirit.Y<1050 && Spirit.X>=950)这2个边缘区域时, 主角在垂直方向上始终居中, 移动时它在窗口中只会做左右水平移动, 垂直方向上通过地图反向移动形成主角垂直方向前进的视觉效果;当主角处于正中区域, 也就是游戏中主角最多的时候, 主角此时始终处于游戏窗口的正中位置( 定位到脚 底 ) , 当它移动时, 在窗口中通过地图的反向移动从而在视觉上形成主角在移动( 实际上主角是静止的,只做方向动画移动) , 齿 数 土 池 中的牵引式地图移动模式有异曲同工之处, 只是两者刚好相反: 前者主角不动, 地图反向移动; 后者为地图随鼠标的牵引而移动, 主角不动。 最后得出结论: 我们只需将第二十节中的AIIMove()方法进行修改, 即可以实现完美的模式转换原理就这么简单, 至于其中的数字是如何得到的, 我们只需要预先定义好两个参数WindowCenterX ,WindowCenterY.它们其实就是游戏窗体尺寸的5折( 如果有标题栏则需要减去标题栏的高度约20像素) ,以800*600的 窗 口 模 式 游 戏 窗 体 为 例 ,那 么 它 的 WindowCenterX=800/2 =400 ,WindowCenterY=(600-20)/2=390 ,那 么1024*768呢 ? 以此类推理请了思路, 接下来就让我们来实现主位式地图移动模式下的AIIMove()方 法 , 这里我以主角位于左上这个区域为例:private void AIIMoveQ {if (SpiritX <= WindowCenterX && Spirit.Y <= WindowCenterY) {〃地图左上〃所有精灵以主角为参照相对移动for (int i = 0; i < Carrier.Children.Count; i++) {if (Carrier.Children[i] is QXSpirit) {〃假如子控件为精灵类型, 则获取之QXSpirit spirit = Carrier.Children[i] as QXSpirit;〃设置精灵在游戏窗口中的显示位置Canvas.SetLeft(spirit, spirit.X - spirit.CenterX);Canvas.SetTop(spirit, spiritY - spirit.CenterY);〃画家方法, 使所有精灵之间的遮挡关系由近及远Canvas.SetZIndex(spirit, Convert.ToInt32(spirit.Y);} else if (Carrier.Children[i] is Innage) {〃假如是地图/ 遮罩Image Map = Carrier.Children[i] as Image;Canvas.SetLeft(Map, 0);Canvas.SetTop(Map, 0);))))我 们 首 先 判 断 主 角 是 否 在 左 上 的 区 域(Spirit.X < = WindowCenterX && Spirit.Y < =WindowCenterY),如果是,那么我们循环遍历画布中的所有子控件,假如某个控件是精灵类型(QXSpirit),那么我们捕获它。 由于此时主角处于的是地图左上区域, 按我们前面的分析, 它在此区域内的显示位置就是它的坐标减去中心点值(CenterX,CenterY),因为精灵坐标是定位到脚底的, 而窗口显示它的位置时是定位到精灵图片左上角点的那么其他方向以次类推( 源码中有这里就不再列罗列\做到这, 有朋友忍不住要问了:对于遍历子控件, 我可拿手了, 用Foreach不是更能胜任, 为何还要用老土的For呢 ?深蓝色: 这涉及到在Foreach中动态添加和删除子控件的问题举个最简单的例子, 游戏中有一个怪物(monster), 你一个如来神掌不小心把它给挂了(monster.Life=0),那么画布就需要对其控件进行移除(Carrier.Children.Reomve(monster)); 好 , 此时问题来了,Carrier.Children 这个 Collection 集合的内容发 生 了 变 化 ( 少 了 一 个monster ) ,这将导致系统十分的不高兴: * 的 ! 谁 动 了 我 的 怪 ! ( 抛出InvalidOperationException异常) , 这就是臭名昭著的在Foreach遍历中由于对Collection内容进行更改而引发的血案! 如何屏蔽它? 用Try{}Catch{}?我非常拒绝在我的代码中出现这对兄弟, 还剩下谁? 惟有善良且和谐的For能肩此重任。 又有朋友问了 : 我们先判断了子控件是否为QXSpirit类型, 恩 , 这很好很强大; 但是后面接着将地图和遮罩当作Image来判断是不是有些太牵强?深蓝色: 嘿嘿! 等你多时了伟大的地图控件华丽登场:有了第±四范关于创建精灵控件的知识, 这地图控件只需要依葫芦画瓢, 整一个轻松. 那么我们依照第十四节中创建QXSpirit控件的方法, 在Controls文件夹上点右键添加一个用户控件, 取名叫QXMapL3f ControlsB " QXMap. xamlQXMap. xaml. csEl f QXSpiri t. xaml*1 Q XSpirit.xam l.es并为其添加如下属性://地图关键点X定位到左上角0>public int CenterX { get; set;}//地图关键点丫定位到左上角0public int CenterY { get; set;}//地图X坐标public double X { get; set;}//地图丫坐标public double Y { get; set;}//地图宽public double Width_ { get; set;}//地图高public double Height_ { get; set;}//地图图片源public ImageSource Source { get; set;}//地图透明度public double Opacity_ { get; set;}由于地图与遮罩拥有几乎一样的属性, 因此为了简单且统Tt ,我只建立一个名为QXMap的控件进行实现( 当 然 , 您将之分成QXMap和QXMask两个控件亦可) , 下文中为了区分, 我均称地图为地表图层( 简称地表) , 遮罩为遮罩图层( 简称遮罩) , 这样可以让大家更好的理解QXMap的不同实现。 回到它的属性上, 其中的X , Y代表坐标, 如果是地表则为0 , 因为它自己相对于自身的坐标当然是(0,0);如果是遮罩 , 那么它的X, 丫则是它图片左上角位于地表中的坐标CenterX , CenterY目前暂时不会用到, 因此均默认为0即可; 至于其他属性都很好理解这里就不再讲解地图控件创建完成, 接下来我们将原先的Image M叩 =new Image ; 用QXMap MapSurface = newQXMap();代 替 ,Image Mask = new Image();用 QXMap Mask = new QXMap();代替, 并设置好相应的属性, 这样就完成了通过地图控件对地表与遮罩的初始化到此, 第二位朋友的问题已经云开见日, 我们只需轻轻一扫键盘:else if (Carrier.Children[i] is QXMap) {〃假如是地图/ 遮罩QXMap M叩= Carrier.Children[i] as QXMap;Canvas.SetLeft(Map, 0);Canvas.SetTop(M叩,0);)这样完美多了不是, 嘿嘿, 得瑟一下深蓝色! 我还有问题!更加深邃了我心中的理念: 青春就是热血与激情!深 蓝 色 ! 我发誓这是最后一个问题:你前面不是说游戏后期还会加入怪物(monster)、NPC ( npc )等乱七八糟的东西, 那么在判断的时候不是要这样写:for (int i = 0; i < Carrier.Children.Count; i++) {if (Carrier.Children[i] is QXSpirit) {} else if (Carrier.Children[i] is QXMap) {} else if (Carrier.Children[i] is QXMonster) {} else if (Carrier.Children[i] is QXNpc) {))这不是没完没了了呀? 而且这还是左上区域的实现代码, 还有其他8个区域呢? 维护起来不成了是典型的牵一发而动全身?不提我还真差点给忘记了, 如何将这些对象物体控件进行一个归类呢? 分 析 : 首先这些控件均为用户控件 , 用户控件继承自UserControl类 ; 这道好了, 在C#中只能单类继承,UserControl类在用户控件出生的时候就已经将这个尊位给踞为己有, 哎 , 杂办可好? ? 郁闷之时, 接口天籁般的魔音再次缭绕于我的耳 边 : 老 大 , 还有我们捏! 对 呀 ! 差点把软哥赐予我们神圣的接口姐妹给忘了。 使用接口即可以对这众多的对象物体用户控件进行规范, 又能被类一对多继承, 很酷不是吗?那么接下来我们添加一个接口取名叫:QXObject.cs ,并对其进行如下设定:interface QXObject {int CenterX { get; set;}int CenterY { get; set;}double X { get; set;}double Y { get; set;})如此一来, 只要对继承此接口的类设定好如上属性, 再对现有的QXSpirit与QXMap两个控件添加对此接口的继承:public partial class QXSpirit: UserControl, QXObject { ……}public partial class QXMap : UserControl, QXObject { ……}最后再次对前面的方法进行如下修改:for (int i = 0; i < Carrier.Children.Count; i++) {if (Carrier.Children[i] is QXObject) {QXObject Object = Carrier.Children[i] as QXObject;Canvas.SetLeft(Object, Object.X - Object.CenterX);Canvas.SetTop(Object, Object.Y - Object.CenterY);Canvas.SetZIndex(Object, Convert.ToInt32(Object.Y));})忽忽, 大功告成!当我们将AIIMove()的9区域代码均补充完整后, 替换掉第二十节中的AIIMove。 方法, 其他的代码一个也不用改, 结果就像变魔术一样, 地图的移动模式转眼由牵引式地图移动模式转变成主位式地图移动模瞬间的模式转换是否让大家感到措手不及, 匆忙中让太多的代码与属性显得臃肿冗余且无章可循, 那么下一节我将对本教程源码进行第一次大规模重构, 从设计升华到艺术, 这是每T 立开发者无上的追求,敬请关注22重构- 让代码插上翅膀自由飞翔上一节, 我将游戏地图模式进行了一次重大的变动, 这在实际开发中意味着项目大规模重置, 虽然表面上显得游刃有余, 仅仅一个AHMove()方法的改变即实现了完美转型, 这全得归功于前20节所搭建起的相对高度可扩展平台. 但是, 随着开发不断深入, 我慢慢的感到些许的不安, 因为代码上的日益松散与结构的渐渐稀疏如同Windows系统的磁盘碎片与日俱增, 未来维护时的烦琐与痛心疾首已历历在目代码向我发出了求救信号, 用什么来拯救你- 我的代码? 是时候亮剑了 一我的第一次亲密重构下面我将分几点对上二芭中的代码进行重构:一、统一化代码格式, 让代码可读性发挥到极至:我以上一节中创建地图地表层的代码为例:private void InitMapSurface() {M 叩 Surface.Width = 1750;MapSurface.Height = 1440;MapSurface.Source = BitmapFrame.Create((new Uri(@"Map\O\O.jpg",Uri Kind.Relative)));Carrier.Children.Add(MapSurface);Canvas.SetLeft(MapSurface, -320);Canvas.SetTop(MapSurface, -200);MapSurface.SetValue(Canvas.ZIndexProperty, -1);)大家先看InitMapO方法中的前3行代码, 它们均以MapSurface打头进行赋值书写; 接着看倒数2、3行 , 却又是以Canvas.Set...()模式来设置MapSurface的属性; 更可怕的是最后一行, 明明可以写成Canvas.SetZIndexQ.)的形式, 好歹也与它前面两行凑合凑合, 可是这个作者赶着写项目, 五花八门的写法都出来了。 尽 管 , 这达到实现功能的要求; 可是仅仅不上十行的代码可读性已达到了 " 神出鬼没”的地步 , 你是否曾想过如此类似的代码一旦多起来, 除了你, 还有谁能进行维护? 上帝知道其实这段代码改造起来是很简单的, 不外呼统一书写格式, 从而提高代码的可读性下面且看我是如何操作的:1、分 析 ,Canvas.SetLeft、Canvas.SetTop 与 Canvas.ZindexProperty 这 3 个东西设置的是地表层图片左上角距离游戏窗口的X距离、 丫距离以及它在游戏窗口中的深度( 层次) 并且, 它们的赋值范围均为整数( 正、负、0皆可) .2、思 考 ,WPF与Silverlight同属于不同形式的应用, 一个桌面, 一个浏览器; 但是它们却可以使用相同的xaml进行表现层设计, 是M S刻意拉近两者的距离? 这无从考证, 未来可以回答我们, 但是从两着的共性让我不禁联想起了网页3、匕 匕 较 , 网页对象的Style ( Css样式表) 属性中有3个属性与上面的3个属性名称与作用惊人的相似:left、top, z-index ,只是它们必须在position:absolute模式下使用, 但这又切中了我们下怀,Canvas画布的内部布局同样是采用基于点的绝对定位, 两者看来仿若一物。
二、通过加载配置文件, 进行系统参数设置:在游戏设计中, 很多参数是在启动游戏时就必须加载的, 即游戏的初始化读取(Loading Data…)例如当前的地图、障碍物、遮挡物、声音等资料数据这些数据往往在游戏开发初期习惯性的被程序员放在代码中( 内存中) , 目的是方便频繁的修改及调试" 旦是, 当项目进展到需要实现具体功能的实质性阶段,此时迫切需要将这些数据进行归类并统一放到一些配置文件中, 这样我们可以通过修改外围配置文件实现不同的游戏启动配置而不必再重新编译, 从而极大幅度的提高设计的拓展性且易于维护和更新举个最简单的例子,网络游戏在运营中如果服务器地址发生变更, 由IP:145.10.6.8换 成IP:167.10.8.9 ,那么你会怎么做? 在游戏代码中更改服务器连接IP, 然后重新编译发布后告诉所有的玩家: ” 请重新下载游戏新版本客户端, 否则将无法登陆服务器 " 这是极其愚蠢的做法不是吗? 因此, 网络游戏在启动时均会检测更新,通过接收更新服务器传来的新版文件替换掉每个客户端的旧配置文件, 这样游戏启动时即可以加载新的配置参数连接上新的服务器地址那 么 , 在WPF/Silverlight中我们应该以什么作为配置文件载体? ini文件? 不 , 那太原始了。
xml文件才是.NET开发者的追求下面我以设置地表层与遮罩层配置为例, 向大家讲解在WPF/SHverlight中如何加载xml配置文件首先我们需要在项目中添加一个名为System的文件夹, 然后在其中新建一个名为Config.xml的配置文件并写入如下内容:
设置完配置文件后, 接下来的任务就是在代码中调用之目前加载xm l文件的方法很多, 我选择XLINQ(UNQ TO XML),为什么? 因为我喜欢LINQ ,它是我见过最具艺术感的语法尤物话不多说, 先看本节的精华方法GetTreeNodeIII
接着就是使用它来加载地图表层Surface配 置 :III
其他的如地图遮罩、障碍物数组等配置的加载如出一辙, 源码中有这里就不累述了这 样 , 我们在游戏中换地图时只需重新加载相应代号地图节点, 然后读取其中的地表层与遮罩层相关信息即可实现场景轻松切换并 且 , 如果游戏客户端需要添加几张新地图, 或是要对现有地图配置进行修改,那么我们只需更新xml文 件 , 然后让对方( 客户端) 下载替换即可以进行版本的升级, 这就是典型的面向对象的分层开发模式三、取其精华、 去掉糟粕, 让代码质量得到质的飞跃:在WPF/Silverlight中 , 大家是否有发现f 比较古怪的情况, 每个控件都有这样两个属性:x:Name和Name ,它们的区别到底在哪? 我可以谨慎的告诉大家, 其实使用起来两者效果是一模一样的例如我设 置x:Name=〃A〃 ,或 设 置Name二 〃A〃 ,在Behind代码中两种方式均可以将〃A〃值识别这可头大了,难 道MS在搞飞机? 其实区别仅仅是上帝创造的先后问题, 这对于绝大多数人来说毫无意义.因此我们可以将之归纳到重复属性的范畴, 其他的类似情况在WPF/Silverlight中还有很多, 连带头老大哥都这样龌龊 , 我们的开发中出现类似情况也算情有可原。
所 以 , 在重构时, 我们还需要对所有的属性进行理性的审视 , 是否有重复的, 是否有不合理的, 是否有没用到却还凳在那的, 这些统统得回炉再造惟有如此,才能给程序的扩展提供更便利的支持同样的, 我以一个活生生的例子给大家讲解是否还记得上一节中, 要 实 现9区域的主角移动, 首先得定义WindowCenterX与WindowCenterY这两个变量, 然后通过让它俩参与到范围判断中从而得到主角当前所处的区域但是大家有没想过, 如果游戏窗口尺寸是可变的, 为了兼容前面实现的功能, 每次窗口尺寸改变( 如拖动边缘、最大化、窗口化等)时 , 我们都得重新设置WindowCenterX和WindowCenterY这两个值, 不但增加了代码量, 而且毫无扩展性而言, 这是相当糟糕的因 此 , 我使用游戏窗口现有变量:Actualwidth与ActualHeight来取代WindowCenterX 与 WindowCenterY ,即 Actualwidth /2=WindowCenterX ,ActualHeight/2=WindowCenterY ,然 后 替 换 掉 全 部 其 他 所 有 调 用 到 WindowCenterX与WindowCenterY的地方。
结果是, 我们不论如何调整窗体尺寸, 者坏需要再更改任何代码,Actualwidth与ActualHeight就好比心有灵犀的得力助手, 为您提供时时的游戏窗口实际宽度与高度.当 然 , 重构的方式还有很多很多, 但是它们的最终目的都只有一个: 让代码插上翅膀自由飞翔可以这么 说 , 本节的代码在保证前一节功能不变的前提下我对其进行了大幅度的代码重构, 不仅优化结构, 更可贵的是将整个架构提升到极具拓展性的高度当 然 , 嘴上说的没有一点价值, 事实将胜于雄辩: 下节我将给您演示短短十几行代码轻松实现WPF下窗口及其内部所有对象的任意缩放, 完美比拟MMORPG中的全屏与窗口模式切换, 敬请关注23自适应性窗口化与全屏化(WPF Only)上一节中曾有提到, 检测系统架构是否合理的评判标准之一就是系统的拓展性. 在.NET网站应用中, 一个优秀的架构可以在不同数据库之间相互转换, 可以与不同的银行接口轻松对接, 可以随意集成各种插件,而实现这些仅仅需要对局部进行小小手术而已; 同样的, 在游戏设计中, 窗口化与全屏化的自适应完美切换同样是对游戏架构合理性的严肃考验,Are you ready ?游戏窗口化与全屏化之间的切换方式有两种, 第一种为仅对可视范围面积进行扩大与缩小而不缩放所有对象物体的尺寸。
此方式在本游戏设计中实现起来非常简单, 我们首先添加一个按钮作为测试按钮, 然后为 其 添 加ChangeWindowMode事 件 , 接着定义四个变量分别记录全屏时的尺寸与窗口化时的尺寸:double ScreenWidth, ScreenHeight, WindowWidth, WindowHeight,并来在游戏初始化对它们进行赋 值 :private void InitializeGameSettingO {〃设置尺寸ScreenWidth = SystemParameters.PrimaryScreenWidth;ScreenHeight = SystemParameters.PrimaryScreenHeight;WindowWidth = 800;WindowHeight = ScreenHeight * WindowWidth / ScreenWidth; 〃根据屏幕分辨率计算出窗口模式下高度)前三个属性都很好理解, 而第四个WindowHeight为什么非要用公式来计算出值而不是直接取600来得干脆? 这涉及到窗口自适应用户电脑分辨率的问题如果您的电脑是4 : 3类型的分辨率, 如800*600、1024*768、1280*960等 这 样 的 传 统 分 辨 率 ,你 大 可 以 直 接 设 置 WindowWidth=800 .WindowHeight=600 ;但是用户的电脑如果是宽屏的( 如16 : 9等 ) , 此时设置窗口模式尺寸为800*600将导致全屏化切换错误。
一般网络游戏中会给予几个或多个可选分辨率让用户自行设置窗口化/ 全屏化切换 , 这些切换的实现基于对系统分辨率及刷新率进行更改的基础上;而WPF中实现起来简单多了, 不需要再去调用WindowsAPI更改系统设置而是直接通过修改窗体自身属性WindowStyle与WindowState轻松实现, 具体方法如下:private void ChangeWindowMode(object sender, RoutedEventArgs e) {Button button = e.Source as Button;string mode = button.Content.ToStringO;if (mode ==" 全屏" ) {this.WindowStyle = WindowStyle.None;this.WindowState = WindowState.Maximized;button.Content =" 窗口" ;}else if (mode == " 窗口" ) {this.WindowStyle = WindowStyle.SingleBorderWindow;this.WindowState = WindowState.Normal;button.Content =" 全屏" ;))需要切换全屏时, 我们只需要将窗口的标题栏与边框去掉(WindowStyle.None),并且设置窗口模式为最大化(WindowState.Maximized )即可; 而如果需要将游戏窗口化则只需将窗口模式设置为单边框窗口 ( WindowStyle.SingleBorderWindow )并还原窗口( WindowState.Normal )即可。
通过此方法实现的窗口化与全屏化的效果图如下:0窗口模式下与全屏模式下人物大小是一样的只是屏幕的可视区域( 索敌区域) 不同第二种方式我称之为按比例缩放模式, 顾名思义, 使用此模式进行切换时, 所有的对象包括人物, 地 图 ,障碍物等东西均进行等比例的放大/ 缩小实现此方法, 首要任务是进行需求分析: 在切换时, 什么属性在发生变化了? 当然是游戏中的一切内容它们自身及子内容的尺寸在改变这里要特别说明的是, 在前面的章节中, 障碍物均为正方形, 只用一个GridSize来定义它的边长; 但是要是用户电脑是宽屏的, 那么缩放时必须以使用矩形作为基础单元格, 因此将GridSize拆分成GridSizeX与GridSizeY两个变量分别代表单元格的宽与高, 惟有这样才能胜任按比例缩放窗口的工作那么根据此原理, 我进行如下编写实现该模式下的窗口模式切换方法:double ratioX, ratioY; 〃定义X,Y方向上的缩放比例III
这样仅仅二十来行代码, 即实现了对窗口中的所有对象物体的按比例缩放大家不妨在不同的分辨率下或不同尺寸的显示器上进行此缩放操作测试, 结果都是很完美的:在此切换模式下, 全屏时的主角( 包括它的描述信息等) 与地图的尺寸都明显的被按比例进行了拉伸, 这就是目前很多游戏中使用的基于像素的窗口/ 全屏切换方式.这里需要特别说明的是,WPF是基于矢量的图形引擎, 但是如果您使用的是像素图片, 例如本例中的角色与地图, 那么它同样会被基于像素进行拉伸, 因为它并未被转换成矢量图本节内容很简单, 但是简单的背后是不为人知的烦琐与枯燥的调试 大家或许会因自觉本节内容毫无价值而十分恼火, 但是我想告诉大家的是, 不要小看了这不起眼的功能与调试, 它不仅仅是对系统架构的一次有力考验( 如果架构存在缺陷, 就算勉强实现了表面上的窗口切换, 角色一旦移动起来将会导致系统及画面漏洞百出);同 时 , 就好比现在的网站需要符合W3C标 准 , 需要同时兼容正与FIREFOX 一 样 , 软件是做给客户用的, 一款软件能够满足各种各样不同客户的使用需求, 这才是价值两个字的深层体现.本节没有诗情画意的知识描述, 只为一下节的华丽登场做好铺垫: 帅气的主角将不再孤单: 怪物们都出来吧!敬请关注。
24Be careful ! 前方怪物出没游戏的精灵框架到此为止算告一段落, 让我们一同来体验它带来的神奇效应一个安静的黄昏,主角悠闲的甩着它帅气的毛发独跑于林阴大道. 怎知天色已晚即将进入月亮的领 地 , 嘿 嘿 , 我们的故事就从这里开始:Be careful , 前方怪物出没!实在不忍心让主角空有一身武艺而无处施展, 本节为了不再让它孤单, 我将向游戏中加入可爱的妖精妹妹与之为伴:好象在哪见过呢? 对 , 就是她了, 可爱吧(QXGameEngine中的怪物, 八_ 八| | 难怪这眼熟\妖精怪物属于精灵类型, 因此要让它在游戏中出现, 我们只需创建QXSpirit的实例; 这里首先我添加一个刷怪方法InitMonster,接着循环添加怪物精灵实例及参数:QXSpirit[] Monster;///
; 〃测试用, 随机数坐标for (int i = 0; i < num; i + +) {if (Carrier.FindName(wMagicer" + i.ToStringO) == null) {Monster[i] = new QXSpirit();Monster[i].Name = "Magicer" + i.ToStringO;Carrier.RegisterName(Monster[i].Name/ Monster[i]);Monster[i].Equipment[0] = 100;Monster[i].Equipment[l] = 0;Monster[i].X = 2000 - random.Next(1000);Monster[i].Y = 1500 - random.Next(1000);Carrier.Children.Add(Monster[i]);)))代码太多我就不一罗列了, 下面我提取部分重要的参数进行讲解首先我定义一个Random类型随机函数用于随机数值的产生, 接着判断每一个有名字的精灵是否存在, 如果不存在则加入到游戏中这里为精灵注册名字的目的是为了以后方便管理, 例如精灵死掉了, 我们需要找到它的实例并将之移除,而惟有通过它的名字或ID之类的方能将之捕获;同时,在定时刷怪的机制下,我们得首先判断某精灵是否还存在, 如果存在则不可能再多刷一个, 就好比网络游戏中大家是否都有过蹲点等刷BOSS暴装备的经历,一个萝卜一个坑, 同一点上刷出两个双胞胎BOSS ,这是很匪夷所思的事。
那么定义完怪物的名字后, 我们接着还需要定义它的身体图片代号,本例中它的代号为100 (为了与主角类精灵用的身体图片区别,我以0・ 9 9代表主角类身体( 衣服) 代号范围,100-N代表怪物(NPQ类身体代号范围) , 最后通过前面的random来定义它们的初始坐标: 以(2000,1500)为中心边长为1000的正方形范围内的随机位置就这么完啦? 对 , 简单吧, 还是那句老话, 拓展性优良的架构是经得起全方位的考验滴嘿嘿,刷它30个怪(InitMonster(30))测试一下:怪物满天飞, 主角此时激动的心情是难以用人类的语言来形容的可 是 , 当主角向四周望去时,蒙 了 : 杂都和我名同姓捏? 作者你脑神经搭到脚底了吧? 我还真没注意到哪在前面的章节里, 我将精灵的3个身份描述都定义在了 xaml里 面 (Faction门 派 ,Clan家 族 ,Sname名 字 \ 此 时 , 我们对精灵的重命名必须在精灵初始化的同时进行但由此带来的新问题: 如果每个怪物都有不同的身份描述, 而且需要经常性的修改调整, 写在内存里的东西是无法扩展的这不禁让我联想到了第二十二节中的xml配置文件解决方案。
网络游戏的服务器会根据地图区域代号加载相应的地图xml配置文件, 其中包括地图及遮挡物以及地图上相当重要的精灵对象信息当需要时我们只需对xml文件进行稍稍修改, 例如精灵怪物的位置, 描 述 , 等级等相关信息即可以达到更新的目的根据此原理, 我对原有的Config配置文件进行如下改进, 并添加怪物参数设置:
