分享一些我在游戏开发中的踩过的坑和经验(代码的技巧[上]篇)

哈喽哇,这里是独立游戏开发者-月笼沙-。在上个好用工具推荐篇发布后,收到了大家热烈的反响,赞、收藏和关注如潮水般涌来。感谢各位,只好不辜负大家,再接再厉

在做游戏的过程中,写代码是一件很重要、很难逃开的事情,就算你用的是蓝图,也有可能遇到要写代码的情况。以我自己开发游戏为例,写代码,可能占据了我总时间的60%

足以窥见,这代码啊,犹如在蹲厕时捧着的手机——绝对不能没有啊啊啊!!!

除非你是美术人员(小声嘀咕

我本身是软件工程出身的,那么就来分享下,代码,应该如何写,有哪些技巧?Warning!本篇分享,有些硬核!我会尽量通俗易懂的分享出来。由于代码技巧篇内容有点多,我分为上、下两篇。

上篇:

  • 什么样的代码算是“优雅”

  • 脚本如何分类

  • 来,咱们写几个Unity中常用的工具

下篇(重中之重):

  • 代码的终极艺术:编程设计模式

~~~~~~

一、什么样的代码算是“优雅”

当我们敲下第一行代码的那天起,或许就已经听过“优雅的代码”这个概念。从那天起,你已经在不断地努力接近“优雅”这两个字。

那么,到底什么样的代码,算得上优雅的代码?以我自己的理解,优雅的代码,本质上是一种尊重——尊重你的合作伙伴,也尊重未来的自己。

我们经常吐槽“几十个if嵌套”、“同样的代码复用了1145次”等写法太“屎山”,可以看出屎山代码具有一些特征,反过来,我们是可以提炼出一些特征来衡量代码是否优雅。我总结出优雅代码具有以下几个特征:

1-1 可读性高

好的代码像是成年人读小学生作文,读起来不费脑子,闲庭信步。例如,有2段语义一样句子:

1.今天我去上学,遇到了小明,小明笑嘻嘻给了我两巴掌。

2.于当下之日的存在场域中,主体我向教育机构进行空间位移。其间,遭遇了作为他者的明。此他者主体呈现出一副欢愉的面部表情构型,旋即启动了其身体暴力装置,与我的面部区域进行了两次突发性的、非对称的强力接触。

大家觉得哪一段更加好理解?在文学中,我们当然可以追求个性化的表达,抽象艺术也是艺术(bushi);但是在代码中这么写,分分钟被喊话“你来一下,我保证不打你”。写代码要时刻注意可读性:

  • 类名、变量、方法名,是否一看就知道是干什么用的?

代码只因天上有,人间难得几回闻

这下看懂了,这些是用来计算年龄的

  • 超级if叠叠乐

如果if套的太多,使得代码整体看上去像一个“冲击波”,那么可读性是很差的。为什么呢?因为在这种嵌套下,你每读一层if,脑子里就要多记一个条件,当你读到4个嵌套以上,就会有点难以思考了:

真遇到这种多重判断,该怎么写呢?

可以使用卫语句。大概意思就是先判断不符合的条件,不符合则直接return,后续不执行。所以我们可以改成这样:

是不是变得简洁、好读了呢?

  • 规范的代码命名。

在上面,我们已经知道,如果类名、变量名、方法名起得很潦草的话,可读性会变差,所以我们要规范命名。当然没有绝对的规范,每种语言、每个公司、每个团队里的规范都很有可能不一样。所以可以不用纠结到底怎么命名,而是将重心放在“规范”二字上

如果你使用C#,这里我推荐微软官方的C#命名规范:

https://learn.microsoft.com/zh-cn/dotnet/csharp/fundamentals/coding-style/identifier-names

大致有2种命名法:

PascalCase(帕斯卡命名法):即每个单词的首字母都大写。常用在类名、方法名、属性的命名。如果你要写一个玩家控制器,则关键词有player、controller,那么这个控制器脚本名应该叫:PlayerController。

camelCase(驼峰命名法):第一个单词首字母小写,其后单词首字母大写,就像骆驼,从头到驼峰,先低后高。常用在变量、方法参数的命名。例如:playerHealth。

推荐你在敲代码的同时,网页上挂一个翻译软件。当你想创建类、方法、变量时,先想一想创建出来做什么用的,然后根据这个用处,到翻译软件上翻译成英语,再利用命名法组合起来。

例如,我要创建个方法,用来返回当前场景中怪物的数量,那么我应该去翻译软件上翻译:“获取当前场景的怪物数量”,翻译结果为“Get the number of monsters in the current scene”,利用PascalCase命名法,最终,这个方法名称为:“GetNumOfMonstersInTheCurrentScene()”。

  • 保持代码整洁

代码的整洁也是很重要的,如果你像下面这样写代码,真的要被亲切问候了:

格式杂乱

对于书写格式杂乱的情况,现在的IDE(代码编辑器)都提供了一键整理的快捷键,可以将缩进、换行等进行快速整理。我们应当常用这个功能。

另外,C#中有一些预处理器指令,我非常推荐使用#region。这个指令的使用方法如下:

你可以直接折叠这一块region(只是被折叠,没有消失),使得代码更加整洁和精炼:

1-2 分工明确

我们创建出来的类、变量名、方法,都应该分工明确,让每个代码单元(类、方法、模块)都像游戏里的NPC一样,只提供专属的功能和服务。

  • 类的单一职责原则(SRP)

    理想来说,我们应当尽可能的将每个功能分配在单独的类上。需要使用某功能时,就new出相应的类对象来使用。

  • 方法也是如此。每个方法都应当具有独一无二的功能。

  • 如果你发现某一段代码重复出现了多次,那么应当提取出这段代码,变成一个单独的方法

这样做的好处就是,职责清晰,我该用什么功能,都有对应的类/方法,不容易搞混;且代码复用率高,不容易出现之前写过了的功能还要再写一遍的情况,非常节省时间。

1-3 易于扩展和维护

难以拓展和维护的代码,就像嘴里嚼到没味的口香糖,食之无味,吐掉后变成难以忍受的环境垃圾,再无人愿意多看一眼。

如何做到易于拓展和维护呢?一句话就是:高内聚、低耦合。大概意思就是,让相关的东西更加紧密地结合在一起,让不相关的东西尽量相互远离。

有以下几点可以快速提升你代码的可拓展和维护性:

  • 少用字符串来作为标记,多用枚举

字符串,作为大部分语言里都有的东西,它让我们很方便记录下一串数据。但是如果你将它作为某种标记的话(使用字符串查找对象、使用字符串作为条件进行比对等),是不太靠谱的。首先就是容易遗漏字母,导致结果完全错误;其次就是难以做到快速重命名代码中所有用到的该字符串。

这种情况下,我们可以用其他的东西代表字符串,例如定义const(常量)字段,让该常量等于想要的字符串,例如 const Str = "我的字符串"。然后在代码里需要用到该字符串时,代替为使用该常量。

最推荐使用枚举enum。优点是可读性强,不会出错,甚至暴露在Unity编辑器窗口时还能拉出选框进行选择,非常便捷。

  • 多使用依赖注入

什么是依赖注入呢?其实听起来高大上,概念却很简单:把需要用到的依赖,从外部让别人送进来,而不是在内部自己创建。

依赖注入的概念我们在下篇的编程设计模式中还会讲到,这里就浅浅谈一下。它的优点是解耦、方便测试。

  • 多用接口

编程初学者可能看到接口,有点头疼,我当初也有各种疑惑:

~接口的概念好抽象呀。

~我到底到底该不该在这里加个接口?

~还不如直接写个方法然后通过继承让子类用,单独写个接口多麻烦!

其实接口用多了后会发现非常多优点:降低代码耦合度,实现 “面向抽象编程”;弥补 C# 单继承局限,支持 “多功能聚合”;统一规范,简化团队协作;便于扩展和维护,符合 “开闭原则”等。

接口也是服务于编程设计模式,所以我们在下篇还会讲到。

  • 将脚本放入合适的代码文件夹目录

这一部分见下文【脚本如何分类】。

1-4 健壮可靠

优雅的代码就像大佬一样可靠,遇到绝大多数的问题时,都能轻描淡写,一笑而过。

怎么才能让自己写的代码,在各种条件下都能稳定运行呢?那这就必须要多写、多实践、多投入实际项目。这样才能知道我这一行代码,在真实运行环境中可能遇到什么问题,从而完善代码提前避免;我们也应当在写代码的时候,多思考:“如果是其他条件会怎样呢?”。

二、脚本如何分类?

这个问题其实在问:我的代码文件夹目录结构应当是怎样的?

有了良好的代码目录,添加新功能时,只需要对号入座,根据功能的分类,将该脚本放入对应的文件夹。这对于项目管理、脚本职责划分是非常有帮助的。

在实际开发中,根据游戏类型,代码的文件夹可能千变万化。不过我向你推荐一种大方向的编排:

  • Core

  • Gameplay

  • UI

代码的三层文件夹架构

接下来,我向你详细解释,每个文件夹都是干什么用的,以及为什么要这样划分:

Core(核心层)

定位:项目的基础设施和通用功能。

它相当你家里的工具间,存放着各种工具(事件系统、存储系统、拓展方法、Utilities等)。平常你要什么工具,进去拿就行了。

Core层的代码,除了自身、第三方库、Unity底层的API外,不应当再依赖项目里的其他代码。就好比你家里这个工具间,把它完整拆出来放到别人家,别人也是可以轻松使用的。

Gameplay(游戏逻辑层)

定位:核心游戏机制和玩法的实现代码。

这个是与你实际项目结合最紧的文件夹,你玩法的具体实现(玩家有啥功能、怪物有啥功能)等,都需要放在这里。它相当于你的家,是你真正住的地方。

UI(用户界面层)

定位:所有用户界面相关的逻辑。

这个就比较好理解,HUD界面、菜单系统(主菜单、暂停菜单)、对话框、弹窗、UI动画和过渡效果、本地化文本显示等,放在这里就OK了。

那么,这样的三层代码文件夹架构,有什么优点呢

  • 方便团队协作

这样的三层结构,可以交由不同的人去开发,每个人可以不用管其他的层。

  • 代码复用性强

    Core层可以在新项目中直接复用

    Gameplay系统可以跨场景复用

    UI组件可以模块化复用

  • 易于理解,拓展性强。

    由于三层架构职责比较清晰,在每个层之间添加脚本,对其他层基本没有影响,所以拓展性强。

Core、Gameplay、UI层,它们的依赖关系是如何的

Core层:

Gameplay层:

UI层:

请务必遵循三者的相互依赖关系,否则就像宇宙的熵增:你项目最终也会变得混乱。遵循它们的依赖关系,也是一种学习如何划分脚本职责的过程,对于编写代码大有裨益。

三、来,咱们写几个Unity中常用的工具

UpdatePublisher

强烈建议在实际项目中使用!那么这个UpdatePublisher是何许人也?Unity里的Update我们都知道,这个工具我们可以把它理解成Update的发布者。

以往,我们经常在每个单独的MonoBehaviour脚本中使用Update,这样做很直观,每个Update都服务于当前的脚本。

但是,由于Update每帧都执行,且每次执行都具有隐形的消耗(有人研究过Update背后的机制,Unity先会判断几个条件是否成立,再调用Update方法)。如果场景里有100个脚本,那每帧都有100次Update方法的调用,那么就有100次的隐形消耗。

你可能说,没办法呀,不然我每个脚本靠什么更新呢?

欸,你别说,发明UpdatePublisher的人真是天才!假设有100个脚本,如果不使用自身的Update,而是全部调用1个脚本的Update呢?100个脚本把各自更新所需要的代码,以委托Delegate或者事件Event的方式注入到同1个脚本的Update方法里,让这个单独的Update每帧都执行这100个脚本的更新代码,是不是就减少了99次隐形的消耗?

来看看代码如何实现(简单写法,健壮性不强,大家可以自行问AI究极完全体写法):

UpdatePublisher的简单写法

你只需要调用UpdatePublisher的注册方法,将自己要每帧更新的逻辑放进去:

注册后,即可每时每刻享受闹钟的“嘀嘀嘀”

对比传统Update,UpdatePublisher的优点有非常多:

而且有非常重要的一点,普通的Class里没有Update方法,但是也可以通过调用UpdatePublisher来实现每帧都进行更新

MonoBehaviour单例基类

单例是非常常用的设计模式,也是非常好用的工具,它足够方便,但不建议大量使用。制作一个单例,可以在程序的任何地方调用。下面我向你推荐MonoBehaviour单例的一种写法。

MonoBehaviour单例基类

如何使用它?你需要再创建一个脚本,继承这个基类,然后将新的脚本挂在在场景里。

继承单例基类的Player

之后,你可以在任何地方轻松调用它,无需在编辑器窗口拖拽引用。例如,在怪物脚本里:

怪物攻击时可以直接调用Player单例

递归查找工具

你是否经常在运行时,查找某个Transform的子物体?Unity的 Transform.Find 方法只能查找直接子物体,而无法查找间接子物体(子物体的子物体、子物体的子物体的子物体等)。

下面这种扩展方法(如果对C#的“拓展方法”概念不熟悉,可以去搜索下),能让你查找一个Transform下面所有的子物体,无论藏得多深:

四、最后

能看到最后的朋友,相信都是对游戏开发、编程感兴趣的人,咱们一起加油

🎶为了心中的梦,浴血奋战像一阵狂风🎶。

这一篇的代码技巧,本来想着尽可能将自己所知道的都写上去,但是发现耗费的精力太大了,还请见谅。但是没关系,真正的经验从来都是在实践中获得的,所以朋友们,动起手来吧!

在下一篇,我将探讨有哪些非常优雅、非常好用的编程设计模式,敬请期待!

如果你喜欢我的分享,也可以看看我的游戏哟

更多游戏资讯请关注:电玩帮游戏资讯专区

电玩帮图文攻略 www.vgover.com