有人说
编程是份很无聊的工作
因为整个工作时间面对的都是电脑这种机器
因为眼睛盯着的内容都是索然无味的代码
因为总是会有意想不到的bug让你怀疑自己的智商
而我认为
编程是件及其有意思的事情
可观的收入,说起来或许太俗气,当然不止这个
Unity游戏开发 让我从校园里上个世纪的知识,直接过渡到一年以内的技术
半年的实际开发锻炼的逻辑思维,远远强过大学数学专业学习三年所锻炼的思维
当电脑按照自己写出的代码做出了自己预期的事情,带有控制欲的满足感绝对刺激
然而,最让我追随的
确是编程过程中苦苦思索不得结果,却忽然间因为小思路柳暗花明的一刻
一个月之前,公司项目组正式进入了新项目的开发阶段。这也是我人生中第一次开发商业项目,也就是第一次靠自己的技术吃饭。
我分到的任务,看似不多,只有三个小功能,但仔细规划一下,每一个小功能都相当于一个独立的小程序,并且要集成到项目中完成交互,工作量还是相当大的。当然,项目涉及许多我之前从未使用过的技术,例如NGUI、UGUI的Dropdown、WWW上传WWWForm下载、Http通信、Unity协程、Unity与window文件交互等等等等,还有好多未知的后期开发会遇到的技术在后面等着我。
这篇文章,我将讲述一下在Unity中开发画板功能的历程。2017年1月5日,在马上要超出规定的时间之前,终于完成了画板功能的制作,并成功修复了Bug。所以兴奋之余写下这篇文章并分享开发的历程。
开发的开始,我给自己规定了14天的时间,2017年1月6日到期。因为公司之前的项目有画板这个功能,我以为这个任务可以快准狠的结束。可是,开发起来才发现,我以为的并不是我以为的,自己真的是没经验图样。
仔细看了下之前项目的源码,画板功能是用NGUI实现的,而且使用的NGUI版本也追溯到3年甚至之前。游戏开发这种东西,3年的技术早就被人们遗忘的无影无踪,查资料都没地方查。我当初学GUI,了解到NGUI被淘汰后,根本连看都没看一眼,而是直接投入了Unity亲生儿子UGUI的怀抱。现在的项目的确实要求用UGUI开发,可是,既然想借鉴,不知己知彼,怎能开发完成。
能怎么办呢,先往下扒吧,能扒多少扒多少。然而越copy越心凉,NGUI的API零零碎碎的贯穿整个脚本(这里只讲述画笔这个小功能,画板的其他功能不做介绍)。第一反应是能不能理解这些API,然后用UGUI中的API换掉。然而,这个想法很快就被否决了,要是能够这么容易理解,那还用得着封装成API嘛。
没办法,时间再紧,也得去学一下NGUI了。不过欣喜的是,学NGUI并没有碰上什么硬骨头,因为UGUI的研发团队中有NGUI的人,也没有花费很长时间。总体来说,两者很多API都是相似的,而且UGUI的实现会比NGUI简单很多,并且看不见的性能消耗也降低了很多。这就是UGUI亲儿子一出生,NGUI立马没有饭吃的原因。
好啦,接下来继续扒代码。其中有一大片使用了NGUI API的代码,但VS IDE的显示是0个引用,既然0个引用,直接不管它们不就好了。边为自己的小心思庆幸,边泛着嘀咕。果然,脚趾头想想也知道,失败了。
查了一下这两个函数OnPress、OnDrag,确实是NGUI的事件系统。于是找到UGUI的事件系统,有没有与之相对应的呢。想象之中,一致的并没有,初步确认了相似的三个事件接口,OnBeginDrag,OnDrag,OnEndDrag。
//原核心代码 private void OnPress(bool isPressed) { if (isPressed) { RaycastHit hit = UICamera.lastHit; if (null == hit.collider || !hit.collider.CompareTag("Blackboard")) { Debug.Log("Warning : !!!!!!"); return; } Vector3 pos = hit.point; Bounds bounds = hit.collider.bounds; Vector3 min = bounds.min; Vector3 max = bounds.max; Vector3 size = bounds.size; Vector2 uv = Vector2.zero; uv.x = (pos.x - min.x) / size.x; uv.y = (max.y - pos.y) / size.y; MoveTo(uv); LineTo(uv); Refresh(); int touceid = UICamera.currentTouchID; if (!mTouces.ContainsKey(touceid)) { mTouces.Add(touceid, uv); } else { mTouces[touceid] = uv; } } else { int touceid = UICamera.currentTouchID; if (mTouces.ContainsKey(touceid)) { mTouces.Remove(touceid); } } } private void OnDrag(Vector2 delta) { UICamera.currentTouch.clickNotification = UICamera.ClickNotification.None; int touceid = UICamera.currentTouchID; if (!mTouces.ContainsKey(touceid)) { return; } RaycastHit hit = UICamera.lastHit; if (null == hit.collider || !hit.collider.CompareTag("Blackboard")) { return; } Bounds bounds = hit.collider.bounds; Vector3 min = bounds.min; Vector3 max = bounds.max; Vector3 size = bounds.size; Vector2 uv = Vector2.zero; uv.x = (hit.point.x - min.x) / size.x; uv.y = (max.y - hit.point.y) / size.y; Vector2 from = mTouces[touceid]; mTouces[touceid] = uv; MoveTo(from); LineTo(uv); Refresh(); }
//成功完成的代码 public void OnBeginDrag(PointerEventData eventData) { if (null == eventData || !gameObject.CompareTag("Blackboard")) { return; } if (!isChanged) { isChanged = true; buttonEffect.NewDraw(); } Vector2 pos = eventData.position; Vector2 uv = Vector2.zero; uv.x = pos.x / CanvasWidth; uv.y = 1 - pos.y / CanvasHeight; MoveTo(uv); } public void OnDrag(PointerEventData eventdata) { Vector2 pos = eventdata.position; Vector2 uv = Vector2.zero; uv.x = pos.x / CanvasWidth; uv.y = 1 - pos.y / CanvasHeight; LineTo(uv); MoveTo(uv); Refresh(); } public void OnEndDrag(PointerEventData eventdata) { mCurrentPos = Vector2.zero; }
放上两端核心代码。要用三个事件接口去代替两个事件接口,当时的表情确实是懵逼的。而且这种懵逼是在不确定是否能用这三个接口替代的情况下。
其实对于有源代码可以参考的功能,要自己读懂源代码,内心是拒绝的,因为想参考源代码,不就是为了省力吗,要去读懂再做修改,那就基本不省力了。可是,毕竟没有其他选择,接下来的十天左右,就是对源代码一点儿都不了解到了解每一行并能做出改动的过程。
首先,源代码用了UICamera.lastHit这个API,查了一下,这个API与下面四行的代码就是碰撞检测,并且用碰到的标签是否是画板来决定画笔功能是否执行,若碰到的不是画板就直接return跳出函数。
于是,我更换了UGUI的射线检测,并用Raycast2D来进行尝试。啊哈,这不应该失败的。我又找到了Unity官方API提供的示例代码,建了一个全新的工程,居然不能用。这是让我很摸不着头脑的一件事。在QQ群问了一下,一位大神说,Raycast2D中的2D并不能天真的认为是UI。既然这样,这个方法只能放弃了。
放弃了这个方法,然而我并没有其他的方法可用。这种情况在开发过程中太正常了,不过,我有我的解决方法。我不断的在网上搜索着,百度谷歌、CSDN、StackOverflow。并不断的更换着词条,因为我不知道这个功能应该用哪个词条准确的描述,也不知道解决方法的文章是否用了这个词条。就这样翻箱倒柜式的翻找着。看似毫无目的,但确实是在收集信息。因为很可能一行代码就会带给你灵感,而且这种情况我遇到了不止一次。
果然,OnDrag接口的传入参数(PointerEventData eventdata)的eventdata后跟的一点就给了我灵感。我尝试加了点,VS中果然出现了提示,好多没有用过的API,不过这些API的天机在命名中就可以猜出了。用eventdata的API成功的做出了标签比较,这五行代码就可以过去了。
下面的代码,仔细读了下,是获取了点击到的画板的大小,并将鼠标位置做了归一化。好的,这段可以照抄。
再下面,是判断鼠标点击的是哪个键,这在UGUI是没有必要的,我果断把下面十几行代码删掉了。希望不要出什么差错吧,虽然后面确实没有出差错,但是确实也怀疑过。后面遇到的BUG猜想过是不是这些代码的原因。
OnPress函数暂时移植完毕,已移植到OnBeginDrag中。接下来要把NGUI的OnDrag中的代码移植到UGUI的OnDrag中。不过代码内容跟OnPress差不多,整个过程没有花费太多时间。
好啦,扒皮大功告成。运行,测试结果!猜对了,不能用。
转念一想,这些代码里,是怎么把东西画在画布上的呢?根本就没有实现嘛。于是,又去找直接拔下来的代码,盯了半个小时左右,外表看上去跟发呆似的,实际上很烧脑。终于有些看懂了,原来是把画笔的颜色像素和画布的颜色像素做了个插值,最终将混合后的颜色显示在屏幕上。真庆幸没有去找插件,这么底层的技术真是难得。
然而,看懂了实现原理并没有卵用啊。上面的代码直接把封装好的函数调用了,这跟现在的问题没有关系。
又多测试了下,仔细看才发现,原来每次点击拖动鼠标的时候只有在点击的地方有一个点,也就是说,OnBeginDrag函数成功了,并且同时说明,采用像素插值来画画的代码也成功了。那么问题就在OnDrag函数上。
可是这是什么原因就一点儿都不直观了。我决定还是先查一下API吧。不查不要紧,一查下一跳。原来NGUI的OnPress函数是一直按压的时候返回true,松开的时候返回false,而UGUI的OnBeginDrag函数是鼠标第一次按下的时候激发事件,这根本不是一个用途嘛。于是更换了一下UGUI事件系统的OnPointerDown,测试了下发现更不靠谱,这个才是鼠标按下激发事件。可,这两个接口又有什么区别呢,真的是一脸懵逼。还是用OnBeginDrag测试一下吧。用Debug.Log(uv);测试结果显示,OnBeginDrag在按压鼠标拖动的时候会输出语句,坐标为刚开始点击时的坐标,OnDrag会在拖拽过程中一直输出,坐标为当前鼠标坐标。这没问题啊,跟自己想象的功能一样,这两个接口没错。
既然不是这两个接口的原因,那只能继续查代码了。然而灵感是可遇不可求的,所以经常会带个各种怀疑,各种可能下班,睡觉。不过工作和生活还是要分开的,可以在下班时间考虑一下,不强求,工作因素影像自己的生活就划不来了。
接着调Bug,这也不算是Bug,毕竟还没有实现。接着上次的代码继续往下查。换了换脑子,终于发现问题了。查Bug这件事确实不是时间就能解决的,经常换换脑子,从不同的视角看问题说不定就有不一样的发现。我发现将鼠标位置坐归一化的代码有问题。
Vector2 uv = Vector2.zero; uv.x = (hit.point.x - min.x) / size.x; uv.y = (max.y - hit.point.y) / size.y;
这个size是自己定义的,怎么能适配所有屏幕大小呢?运行了一下在scene视图缩小了看了一下,果然,是这儿的问题,鼠标的点点偏了。怎么解决呢,直接用屏幕大小来做归一化不就好了。可是,屏幕大小怎么获取呢?百度了一下,原来是Screen.width,哈哈,自己以前都用过,居然忘了,而且自己还在尝试image.width,还抱怨人家怎么是只读的。
修改好了归一化的代码,迫不及待的又开始了测试。这次,终于可以画出轨迹了!不过不知道为什么画出的轨迹是一些零散的点,不过这也算是初步完成了,至于为什么没有连成线,可以后面调整Bug的时候在处理这个。
确实,我去做UI界面了,因为这个Bug持续几天一点儿头绪都没有。几天过后,终于决心直面这个Bug。
第一个怀疑的对象,就是OnDrag这个接口的刷新频率。然后就又一次查了事件系统的所有借口,又试了一次OnPointerDown。哈哈,事情多了难免会犯点儿傻,明明之前证明了只有OnDrag这个接口可以用。况且我询问了几个大神,并没有得到控制接口刷新帧数的方法。于是这个疑点就先暂时只能记下了。
第二个怀疑对象,具体的划线代码中设置的步数太小。不过现在的步数已经是0.0003了。抱着死马当活马医的心态换个数字试一试吧。果然不行。不对,画的笔迹为什么一点儿都没有变化呢,这不应该。于是回到具体的实现代码中。找到nSteps变量,Debug.Log(nSteps);果然,结果为0。也就是说,to变量和from变量的距离为零,也就是划线笔迹中上一个点与当前点的距离为0。
private void MoveTo(Vector2 pos) { mCurrentPos = pos; } private void LineTo(Vector2 pos) { Vector2 from = mCurrentPos; Vector2 to = pos; Vector2 dir = (to - from).normalized; int nSteps = Mathf.CeilToInt(Vector2.Distance(from, to) / LineStep); Color32 c = mbErase ? new Color32(0, 0, 0, 0) : PenColor; float size = mbErase ? RubberSize : PenSize; for (int i = 0; i <= nSteps; i++) { Vector2 Cur = from + i * LineStep * dir; DrawPattern(Cur.x, Cur.y, c, size); } }
这就跟代码逻辑有关系了。看了下原项目的代码,跟现在的一模一样。这就又丈二的和尚摸不着头脑了。原来的逻辑正常,放在这儿怎么就不对了呢。又是一头雾水。原本的逻辑实现的是先把之前的点存到另一个全局变量,再把当前点的坐标保存到to变量,这样就连续记录了相邻的两个点。可是越想越不对劲,细思恐极,这逻辑明明就不对,源代码是怎么实现的呢?没办法了,之前的代码是不能用了,只能另起炉灶,改写这部分代码了。
又是好久没有思路。一天晚上,睡觉之前想了想,怎么可以在同一段函数中记录两个点呢?这段代码每移动一次都会刷新一次,就会更新一个点。既然这样,那么Unity协程是不是就可以!记录一个点,挂起一帧后再记录一个点。哈哈,太好了,终于有灵感了。
第二天上班果断试了试。可是,结果还是不尽人意,一点用都没有。再仔细配合代码考虑了一下逻辑,确实不对啊。OnDrag是借口函数,不能有返回值,携程有需要写到一个新函数中,这两个函数的配合是受限制的。
没办法,这个想法又被毙了。听段音乐,再换一换脑子,看看能不能有新想法吧。对了,数据结构又一种结构,只能存储两个点,并可以不断更新。翻了翻书,对就是队列!而且C#中的队列还是泛型队列,哈哈,找到这个方法眼前又有了希望。立刻放进代码调试了一下。这种方法在运行中是可以的,可是,第一个点却没法处理,也就是OnBeginDrag得到的第一个点。删掉代码,恢复原样吧。
然而,就是恢复了一下代码,没有做一点改动,居然奇迹般的画出了线!只不过第一个点还是有点儿问题。不管怎么画,第一个点都是从屏幕左上角,也就是(0,0)点,也是初始化变量mCurrentPos的点。如果把OnBeginDrag中得到的点复制给mCurrentPos,那不就大功告成了?
明明逻辑没有问题的,可是却总会画出奇怪的线,这就有点儿百思不得解了,可是就差这一步了,攻克这一步,画板功能就可以大功告成!趁着这股热劲儿,又仔细罗列了一下代码,猛然间发现,是不是是OnBeginDrag中得到的点没有归一化,所以才会有奇怪的笔触。修改了这部分代码,再一次点运行,都不知道这是多少次了。这次终于成功了!
随着这一次点击运行,终于开启了画笔随心画的功能。心里的激动难以掩饰,于是就写了这篇文章,来记录一下第一次商业开发的新路历程。
虽然整个过程中有很多因问题不得解决的苦闷,但也有解决问题后的骄傲,并总是伴随着灵光一现带来灵感的喜悦。这,就是程序员吧。