【入门】从学生到成熟:游戏模块设计起步之抽象思维

发表于2015-04-29
评论3 4k浏览

       每个毕业生,都在自信,自我怀疑和对未来的满满憧憬中,走上程序员岗位。每个人都希望自己能够成长,能够在技术领域独当一面。 而有些毕业生成长比较快,能在3,5年后华丽转身,成为团队骨干。也有些毕业生在成长的道路上不得其法,磕磕绊绊。


      为了帮助同学们,我希望能够在《游戏模块设计起步》这个系列中,尽力写出我认为程序员成长道路上最关键的几步,帮助大家完成从新人到成熟程序员的转变。

 

      当然方法论只是一方面,没有代码量的积累,一切设计方法论都是空谈。

     篇幅所限,本文不对UML和SOLID原则详细介绍,读者不了解这些概念并不影响领略本文的主旨,本文主要从需求的角度出发去推导面向对象方法论,大家可以在阅读本文之后再补充相关知识。

 

      本章是系列的第一篇,介绍从学生到成熟程序员的第一步 --- 抽象思维。

 

一.  一个真实案例

让我们以一个毕业生遇到的真实案例开始。

1.1 资源同步服务器

       如图所示,这是一个毕业生在公司面对的第一个完整模块需求。

              策划需要一个为策划编辑器服务的server,策划在编辑器上工作,可以通过该server方便的将本地完成的工作上传到服务器上,并立即看到效果,真正能够让策划立刻在真实游戏中看到自己的成果,提升开发效率。

        如果将需求整理一下的话,大概有下列这些:

        1. 将策划配置的战斗,动作,武将卡等各个系统的资源上传到服务器,并热加载到服务器;

         2. 将策划配置的地图资源上传到服务器,并热加载到服务器;

         3. 将策划配置的关卡上传到服务器,并重新启动服务器;

         4. 将策划配置的脚本上传到服务器,并热加载到服务器;

         5. 可以在指定位置创建机器人。

 

1.2 so easy!

              面对这样简单的策划需求,我们的第一个反应是: so easy!

        很快我们就做出了第一个设计:

        传递战斗,动作,武将卡等资源做个协议,服务器通过该协议收到资源文件,并热加载到服务器;

              传递地图资源做个协议,服务器通过该协议收到地图资源,并热加载到服务器;

              ...

              传递xxx做个协议,服务器通过该协议收到xxx,并热加载到服务器;

              为加载机器人做个协议,服务器通过该协议加载机器人。

 

1.3 设计的下限---这个系统最糟糕的设计形态是什么样?

        面对这样一个翻译型的设计,我常常在问的一个问题就是,这个系统最糟糕的设计形态是什么样

       我们先来看看什么叫翻译型设计

             

程序员将策划文档整理成程序需求,并每一条需求逐一翻译成程序代码

             

这也是每个程序员在第一次接触程序以后,都会做出的最自然,淳朴,原生态的设计。

我认为,系统设计的下限即在于此。

一个新手,在设计系统的过程中,不花任何心思,用最直白的,毫无复用的手段将策划需求一一翻译成代码。

 

这样的工作不需要任何软工经验和知识储备,任何初学者花足够多的时间和细致功夫,都可以完成。而完成这样的工作对系统设计者自身的能力也不带来任何成长。我们称这样的系统设计为 ------ 设计的下限

         BTW,单纯就设计结果而言,这样所谓设计的下限并不一定是最差的,很多过度设计的案例都比直白的翻译更难以维护。

但这种过度设计至少让程序员得到了成长,通过对知识的以及后续维护的挫折体验让程序员拥有更宽广的视野。

因此在实际工作中,相比一个难以维护的简单翻译型设计,我更倾向于可以看到一个同样难以维护的过度设计,至少在这个工作中,团队得到了成长。

 

1.4 翻译型设计所遇到的实际问题

              上面的翻译型设计可以完成工作吗?当然可以!而且完成的非常快,初期会给人非常良好的感觉。

              但是在网游这样一个需求频繁变更和扩展的领域,很快大家就会遇到以下问题:

1.       因为没有任何代码复用,策划提的每一个新需求,都至少需要花1-2天来实现;

2.       因为一些基础逻辑在代码中每个地方都重写了一遍,导致对于某些很小的修改,都需要把系统中所有模块都修改一遍,工作量很大,而且容易错,需要通读代码,仔细寻找才能知道自己要改哪些地方;

3.       因为没有任何代码复用,新代码的正确性没有任何保证,如果换个人维护,则更容易出问题;

4.       整个系统没有框架,任何人接手代码,都必须通读所有代码才能理解代码全貌。

 

如果一个设计者总是做出翻译型设计,那么可以预见的开发节奏是:

1.       设计者很快的完成了模块A,大家很开心;

2.       接手模块B后,还需要抽出一定时间维护模块A的bug和功能修改;

3.       接手模块C后,还需要抽出大量时间维护模块A, B的bug和功能修改;

4.       最后A, B, C的后续功能修改已经耗费了设计者所有的时间和心力,他已经无力再开发别的模块,而模块A, B, C的接手成本同样高昂。

5.       最重要的是,经过了A, B, C三个模块的开发,设计者的设计能力并未得到增长,最后总有一种没功劳也有苦劳郁闷情绪。

 

于是各种负面情绪开始积累…

 

1.5 一个更好的解决方案

              针对问题1.1,我们最终采用了下面这个方案:

              首先我们可以从上面的需求中,整理出三个基本原子操作:

1.       文件同步操作;

2.       GM指令操作;

3.       远程shell命令调用操作。

这样,上面的所有需求都可以被划分为:

原子操作 + 对原子操作的组合

任何一个操作,都只需要简单的实现为多种原子操作的组合,而流程针对的是抽象的原子操作基类,因此绝大多数功能扩展可以很方便的视为对原子操作子类的扩展。

例如:

1.        将策划配置的战斗,动作,武将卡等各个系统的资源上传到服务器,并热加载到服务器;

的实现方式为:

 

2.        将策划配置的关卡上传到服务器,并重新启动服务器;

的实现方式为:

 

              这样,任何新需求都可以迅速的复用以前的成熟原子操作,只需要编写原子操作组合代码即可实现新的需求(当然实现组合功能的代码也可以框架化,这也是一个很有意思的话题,就是我们框架化的深度,这里我们不展开讨论…)

 

1.6是什么带来了进步?

              我们用经典的继承方式实现了依赖倒置,在需求中将各种操作抽象为原子操作,比较好的实现了代码复用。

              当然这篇文章我们不针对面向对象方法论做太多讨论,本篇只关注面向对象方法论的起步,我们的关注点是:

程序员到底具备什么样的能力才能摆脱翻译型编程习惯,转变为使用依赖倒置优化模块设计?

              -------- 抽象能力。

 

二. 抽象

              那什么是抽象呢?

2.1 抽象的定义

              抽象是一种能让你在关注某一概念的同时可以放心地忽略其中一些细节的能力------在不同的层次处理不同的细节。              ---《代码大全》

              简单的说,抽象就是指通过忽略细节达到:

1.       对外隐藏细节;

2.       对内挖掘内在本质。

的作用。

在模块的设计过程中,我们通过摒弃掉模块中频繁变化,挖掘出模块的内在本质(这本质上是个深入建模的过程,我们会在后面讨论建模的文章中详细讨论,目前我们只讨论抽象,抽象的思维模式构成了OO方法论以及深入建模的基础)。

下图比较直观的反映了这个过程。

 

这个过程本质是,将模块中 相对本质和稳定的部分 和 经常变化的部分 割裂开来(SRP),尽可能的复用稳定的部分,通常这部分会与系统运行流程相结合,形成框架(OCP)。

抽象是从翻译型设计转型为面向对象设计中,最关键的一环。

 

在案例1.1中,我们会发现每一条需求的本质是一个由操作组成的线性流程,这个流程里的每一个操作都相对独立,并可以复用,我们把这样的操作叫做: 原子操作。

而我们实际上并不需要关心每个操作是什么,我们只需要把操作视为可以拼接和复用的积木,通过搭建无差别的积木完成每个流程。

 

B是A的抽象,我们将这种关系表述为:

A is a B

A反应了B中更本质,更稳定的部分。

A is a B的程序实现就是公有继承,也就是class A: public B。

 

这个概念也是替换原则的本质(LSP),任何出现B的场合中,我们都可以将A替换进去。

 

也就是说,当面对具体逻辑,我们需要尝试判断: 具体逻辑 is a 什么样的抽象概念?也就是将具体的逻辑需求A,抽象成A is a B的形式。

 

2.2 设计小练习:
例1 武器系统

初始需求版本:

手剑只能出现在主手武器栏上策划对天保证单手剑只能出现在主手武器栏上,盾牌只能出现在副手武器栏上;盾牌不可以被卸下或者打碎

              需求版本2

盾牌可以打碎,盾牌打碎后,副手武器默认替换为小刀同时也支持玩家将副手的盾牌替换为小刀

              需求版本3

盾牌可以被打碎,盾牌打碎后,副手武器默认为拳头

              需求版本4

                            可以在战斗中将副手上的盾牌替换为另一把单手剑!

 

翻译型设计:

              初始需求版本

单手剑只能出现在主手武器栏上(策划强力保证单手剑只能出现在主手武器栏上!),盾牌只能出现在副手武器栏上;盾牌不可以被卸下或者打碎。

 

 在装备主武器的时候检查单手剑是否能放到主武器栏,在装备盾牌的时候检查盾牌是否能放到副手武器栏。

 

  需求版本2

  盾牌可以被打碎,盾牌打碎后,副手武器默认替换为小刀。同时也支持玩家将副手的盾牌替换为小刀。

   在装备盾牌卸下后,系统帮玩家把副手武器变为小刀。

 

    …

    需求版本4

          可以在战斗中将副手上的盾牌替换为另一把单手剑!

 在实际操作中,整个武器装备流程越来越复杂,策划的每个版本改动都需要程序拿出1-2天来支持,代码复杂性越来越高,武器和武器栏之间的匹配规则越来越多,越来越难以维护。这些匹配规则跟武器装备的代码混在一起,整个代码显得又臃肿又难以维护。

 

基于抽象的设计:

首先我们来思考,对于单手剑,拳头,小刀,盾牌,他们的本质是什么?

他们不变的,稳定的部分是:

1.       可以被装备到武器栏上;

他们经常变化的部分是什么呢?

1.       扩充新的武器类型。

针对上述特性,我们对单手剑,拳头,小刀,盾牌进行抽象:

单手剑,拳头,小刀,盾牌 is a 抽象武器

抽象武器的特性是可以被装备到武器栏上。

 

其次,对于装备主武器栏,装备副武器栏这两个操作,他们的本质是什么?

他们不变的,稳定的部分是:

1. 将各种武器装备到各种武器栏上;

他们经常变化的部分是什么呢?

1. 武器与武器栏复杂的对应规则。

针对上述特性,我们对装备主武器栏,装备副武器栏这两个操作进行抽象:

装备主武器栏,装备副武器栏 is a 装备行为

 

再其次,对于 主武器栏 和 副武器栏 本身,他们的本质是什么?

他们不变的,稳定的部分是:

1.  他们可以被武器装备;

他们经常变化的部分是什么呢?

1.  经常可能出现新的武器装备位。

针对上述特性,我们对 主武器栏,副武器栏这两个操作进行抽象:

主武器栏,副武器栏 is a 装备栏

 

整个过程如图所示:

 

最终这个系统被收敛为:

 

也就是,整个系统由两部分组成:

装备物理层:

       所有类型的武器和装备栏都可以被抽象为可被装备的物品和物品栏,他们对玩家属性和行为起的作用都只需要配表。

任何新增武器不需要修改任何代码就可以被装备到玩家身上,并对玩家属性和行为造成影响。

           装备规则层:

           很薄的一层规则层,用来配置哪些武器可以被装备到哪些装备栏上,以及武器之间的互斥。这部分逻辑比较复杂,不是很容易框架化。

 

. 抽象的理论性部分

下面描述一下抽象的理论性部分。我们为什么要抽象,抽象的理论基础是什么?

第三章比较偏理论,暂时看不懂没关系,有问题可以随时找我讨论。

3.1价值观

      在软件设计的世界中,我们追求什么?我们的目标是什么呢?

      我们的目标是 ----- 复用

      在软件设计的路上,绝大部分方法论的目标或最终目标就是复用。

 

3.2 复用之路v1.0 --- 函数

      在软件设计的早期,最具有标志性意义的方法论就是函数的抽象:

      通过引入函数,我们终于可以:

       1. 将多个代码段中重复的部分抽离出来,实现复用;

       2. 被复用的代码段拥有明确的先验条件(函数参数,编写良好的情况下)和后验条件(返回值,也是在编写良好的条件下)。只要满足先验条件和后验条件,函数的执行就可以脱离调用者(上下文)的干扰,最大化实现自己被复用的价值(所以麻麻说我们不要随便使用全局变量!)。

 

       函数的出现是软工史上的伟大进步。

 

3.3 复用之路v2.0 --- 虚函数

      很快,我们发现,函数并不是复用的终点。因为虽然被调用者通过函数的形式独立出来,但是调用者却跟他所调用的函数绑在了一起。

      也就是说,如果我们希望调用者切换行为,那么我们很难复用调用者的代码。

 

       如图所示,在子弹切换弹道的过程中,子弹的代码是无法复用的,因为子弹的代码中直接调用了直线弹道和曲线弹道,所以我们必须修改子弹的代码以切换他调用的函数。

       在图中可以表示为,子弹的代码(黄色)被红色和绿色的实现污染了。

       当然表面上看起来这不算是太复杂的工作,但

1. 如果这个过程重复N次,则我们的代码就需要修改N次;

2. 如果这个过程在M个地方出现,则我们的代码中就有M个地方需求经常修改。

因此如果设计不够谨慎,我们的代码中很快就出现了N * M的修改点。

 

为了解决这个问题,面向对象方法论中最重要的一环出现了:虚函数

 

              上例中多种不同弹道被抽象为------弹道,通过弹道抽象,我们可以将子弹与具体弹道的绑定延迟到子弹编码完成之后。

              也就是说,在修改和扩展弹道的过程中,子弹的代码被保护起来不受这种变化的干扰。

              应用这个原理,我们可以将稳定的调用方独立切割出来。这是SRP的重要组成部分,也是OCP的理论基础(OCP将在下一章 框架 中详细讨论),而这个复用方法的有效性可以用LSP验证。

 

3.4 从具体问题到面向对象

      那么回到我们的命题,如何才能在具体问题中,运用面向对象的方法论呢?

      这就是本章的主旨 --- 抽象。

       通过抽象,我们可以找到具体问题中稳定的,通用的,共性的部分,将具体的问题总结成一个抽象而稳定的模型,更好的为将来的变化服务。

              当然,这样紧扣命题的抽象方式只是迈向软件设计之路的第一步,我们会在后面的章节中,讨论更深层次的建模。

 

四. 习题

. 物品系统

         1. 我们可以把普通物品放到背包栏;

         2. 我们可以把任务物品放到任务物品栏;

         3. 宠物既可以放到背包栏,又可以装备到宠物栏;

         4. 武器既可以放到背包栏,又可以装备到武器栏,装备到武器栏后,武器会影响玩家的数值和技能;

         问题: 请针对上述需求,设计出一个抽象物品系统。

 

 

习二. 移动系统

         1. 步行的玩家可以朝8个方向直线移动,每个方向上速度不同;

          2. 骑马的玩家可以向前加速运动,向后匀速运动,并可以在静止或移动的过程中以恒定角速度左转弯或者右转弯;

          问题:请针对上述需求,设计出一个抽象移动系统。

习二附加题

          1. 攻城车可以在固定路径上向前匀速移动;

          2. 投石车可以在左右两个方向上移动一小段,并可以在原地转向;

          问题: 请针对上述需求,结合习二,设计出一个抽象移动系统。

 

 

. 乘骑系统

        请分析下面这幅漫画,并细化设计

 

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引