你这个问题很初级,但是很常见,并且问题里透露了一个行业里常见的错误,尤其是培训班出来的孩子特别容易犯这个错误,所以我决定回答一下。

首先记住一个口诀

oop里面的一个设计类的核心口诀是:

不同类要做同一件事情用接口,同一个类做同一件事情不同效果用委托。

这句话什么意思呢?就是在不同的类要在同一个流程里进行处理的时候,最好的选择是用接口,比如你游戏中角色能挨打,建筑物也能同样的攻击方式打损坏,还有一些小物件比如场景里一个棒球一样可以被攻击,被钝器攻击飞走,被斩击攻击变两半,这里角色(Character)、场景建筑(Construction)和小物件(Doodad)其实是不同的类,他们都需要能挨打,所以要一个ICanBeAttacked接口,需要他们实现一个类似public xxx BeAttacked(…)的函数。而如果都是角色(Character),A角色受到攻击的反馈与B角色不同,那么Character这个类里就需要一个delegate xxx BeAttacked(…),也就是Func<…,xxx>也许是Action BeAttacked,这取决于你游戏的具体设计,不同的角色的BeAttacked的值不同,但是核心流程都是 xxx?.BeAttacked?.Invoke(…) ?? ooo这样的写法来做到。

当你记住这个口诀的时候,就可以正式进入你的这个问题了,你的这个问题其实具体下来有很多很多个问题:

抛开正确设计谈你这个需求(coder层出发看问题)

GetComponent可以拿到Interface

首先你要知道,Unity是可以GetComponent()的,换而言之,到这里就可以解答你的问题,如果不负责的话。

你的设计(不考虑对错),完全可以是

  1. public interface IBeAttacked{
  2. //假设你的流程里伤害不用返回东西,并且只要一个伤害力参数
  3. public void BeAttacked(int damage);
  4. }
  5. public class Enemy1:MonoBehaviour, IBeAttacked{}
  6. public class Enemy2:MonoBehaviour, IBeAttacked{}
  7. public class Enemy3:MonoBehaviour, IBeAttacked{}

只要确保他们都是派生自MonoBehaviour的,这些Component就能在GetComponent()中取到,你可以用xxx is Enemy1等方法来判断他们是什么玩意儿。

那假如你说我即使是Enemy1,他的2个实例要在受到伤害时不一样怎么办?比如一般的Enemy1受到伤害就掉血,鲁大师(Enemy1)受到伤害不仅是2倍于damage参数的,还会当damage>10的时候尿裤子怎么办?可以改这个接口函数

  1. public interface IBeAttacked{
  2. public Action<int> BeAttacked(int damage);
  3. }
  4. public class Enemy1:MonoBehaviour, IBeAttacked{
  5. public int hp;
  6. public Action<int> OnBeAttacked;
  7. public Action<int> BeAttacked(int damage){
  8. OnBeAttacked?.Invoke(damage);
  9. }
  10. }

这个是用interface的做法,但实际上unity,或者确切地说是unity期望你使用的是组件模式,即一种数据驱动(data-driven)的设计模式,而非是oop的继承模式,所以这个interface就不太对劲了,it just works。

直接用Component是unity的期望

所以在使用unity的这个EC架构(也就是组件式)的时候,你最好先定义好什么叫“受到攻击”,然后将这个做一个MonoBehaviour,换而言之,所有能受到攻击的gameObject,都应该有这个Component:

  1. public class BeAttacked:MonoBehaviour{
  2. public int hp;
  3. public Action<int> BeAttacked;
  4. //给其他逻辑调用的
  5. public void AttackMe(int damage){
  6. BeAttacked?.Invoke(damage);
  7. //side-effect: 当处理完伤害发现我的hp<=0,我就死了呗
  8. if (hp <= 0) Destroy(gameObject);
  9. }
  10. }

也就是无论何物,他的受击是被这个Component所操作的,有这个Component的GameObject会“被打死”,没有的就不会——这才是标准的数据驱动(data-driven),而不是我们常听到的“填了表他就能工作”(这是excel驱动,或者好听点叫“数值驱动”,不是程序级别的data-driven)。

当然我们依然可能会发生这个BeAttacked只适合于角色,是吧,虽然data-driven中已经没有角色概念了,但是我们脑袋里还有:“他是个角色,所以他有BeAttacked”这个人脑的正确(但是对于计算机来说是错误的)归纳。所以你可能还会想,如果我是一个建筑物,我挨打不一样呢?这时候确实你得有另一个ConstructionBeAttack: MonoBehaviour,而unity对此提供的解决方案,就是这些XXBeAttacked: MonoBehaviour, ISomeInterface这个方案,也就是上面一段说过的那个。

正确的实现应该是怎样的?(programmer层出发看问题)

到了真正的程序员级别了,就是要对业务进行正确的分析和归纳,而不再是根据需求打字儿了。那么你这里犯了什么错误,要如何纠正呢?

首先一个是对事物抽象的问题

在你的抽象里,分出了player, enemy1, enemy2, enemy3,你能告诉我他们【本质的】区别吗?注意,我把【本质的】标出来了,很多人嘴上都爱说“本质上”,实际上那都不是本质,真正的本质,是对事物的抽象。

你可能会说:首先player和enemy肯定要分开吧,毕竟player受玩家控制这就是一个典型的抽象错误,正确的抽象是怎样的?或者说这件事情的本质,他是这样的——

无论是你理解的player还是enemy,他们在游戏中都会收到一些指令,根据自己的能力(ability)来执行这些指令,比如“从当前位置移动到哪儿”。无论你是玩家输入的指令,还是ai脚本运算结果,到了角色这一层,都是“给我去那里!”,而至于这个指令是不是有玩家操作摇杆发出的,对于角色这个类来说,根本不关心。所以【本质上】对于角色来说——命令源头,不应该造成区分,也就是说,并不存在你理解的player和enemy的区别,这个区别是我们地球人的理解,也就是玩家看到的现象,其本质是其他业务逻辑分别“翻译了”来自手柄和ai脚本的指令,传递给了他的目标角色,所以绝对不能用“是否接受玩家操作”来分类,这根本是一个对业务理解和抽象的错误

接着你还会对enemy做出解释:比如enemy1是飞着走的,enemy2一边走一边拉屎,enemy3是个炮台,他不会走路,所以他们有一个父类是enemy,然后各自有不同的能力,再放到不同的子类里。其实这也是错误的

思考一个最简单的问题,这些不同的逻辑,由谁来执行?你可能说我每个mono自己执行自己对吧?这里又是大错特错的,因为比如移动之类的能力,都是无法自我执行的,必须有一个gameManager来执行,因为只有GameManager同时依赖了角色和地形(Map,或者更确切的硕士地图),你角色不能依赖于这个Map,很简单的道理,因为当你地图上还有别的角色会跟你碰撞的时候,按照你的理解是不是我map上得记录有什么角色在哪儿?因此Map依赖于Character,反之Character移动要判断阻挡,阻挡不仅是别的character还有map上的阻挡物对吧,那你Character又要依赖Map,是不是典型的耦合(互相依赖)?而实际上在这个业务中,或者说在一个游戏中,他可以有map没有Character,也可以有Character没有Map,或者两者皆有或皆无——因此Map和Character之间本身就不互相依赖,所以这里是个典型的依赖关系错误。

所以最终的,不管你是enemy几,都得被GameManager所管理,那么请问,我要怎么写enemy1的移动是飞着走,enemy2边走边拉呢?我应该这样吗?

  1. public class GameManager{
  2. private void MoveEnemy1(){}
  3. private void MoveEnemy2(){}
  4. //那当我策划设计了1000种不同移动方式的enemy你就写到1000吗?
  5. }

所以正如我前面说的, 你的每个角色都打算移动,所以他们都有一个delegate V3 Move(…),这才是对的——这是一个函数式编程的思维方式,也是我的MBF(全球第一个正确抽象技能和游戏开发的框架,这里有一篇实际范例:

猴与花果山:用Unity制作一个极具扩展性的顶视角射击游戏战斗系统834 赞同 · 75 评论文章

的中心思想——就是当你在游戏开发中,发现一件事情需要枚举的时候,其实他需要的是一个函数作为值,MBF诞生于2010年,真正可以用于unity,是在C#3.0 .net3.5引入lambda之后。接下来举一个典型的例子:

比如游戏中的子弹,dota类游戏,比如莫甘娜的q这种都算是子弹,子弹的弹道是怎样的?有很多菜鸟会分析,把子弹弹道做成一个枚举,比如:直线子弹、曲线子弹,最后子弹弹道经过coder就变成了一个union。但是你有没有想过,如果有些子弹弹道在你的枚举里没有你要怎么办?比如现在我要一个先飞sin函数2秒,然后直线向回飞3秒(速度会根据子弹命中过的人改变,比如每打中一个人减速当前速度的10%)的弹道你怎么办?再加一个枚举?

所以当我们把思维逆转过来的时候,会发现,事实上子弹移动这件事情的【本质】就是——在这一帧为子弹找到他的坐标。是不是,那么这个本质是什么?返回值为坐标(V3或者transform)的一个函数,也就是它的值是Func,即(Bullet, ….)=>Transform这个函数作为值(lambda支持)。

用这个思维你再去看角色,其实Character本当就是一个类,因为他在GameManager逻辑中,无论你是什么样的character,都是“一视同仁”的,都是一样的【处理流程】,只是【不同Character实例,在同一个流程中,可能会执行不同的东西】对吧,还记得本篇开头我们那句口诀说了什么吗?所以无论你的player还是enemy几号,他们都应该是class Character,只是不同的实例值不同——再次强调函数式编程中,函数是第一公民,所以可以视作值和参数,lambda就是让传统oop中允许函数作为值,所以允许匿名函数,就像int a=3,SomeMethod(a)==SomeMethod(3)一样:

  1. public void LogHelloWorld(){
  2. Debug.log("Hello World");
  3. }
  4. //等同于
  5. ()=>Debug.log("Hello World");

因为当你函数本体就可以说明他是什么的时候,你完全可以不用为这件“一句话就说清楚的事情”起个名,就像你不必为3起个名叫a一样自然。

基于这个思想(函数式思想和MBF),再回头去看游戏设计,理解这些业务,就会有截然不同,但是绝对正确的抽象思维

然后是一个对oop的理解问题

要指出的是,在你的理解里还有一个问题——就是你企图在Player里面写PlayerAttack函数来调用Enemy的BeAttacked,这是一个使用oop的类的典型错误。

在oop中,一个类的所有可执行能力(Methods),是这个类的(人类理解的)能力(Abilities)

简单地举个例子,我能打出一拳,我能受到伤害——这两个都是我(作为一个人类的实例)的能力,任何人都能打出一拳,任何人都能受到伤害,所以这是human这个类里面的Methods,因为这是人的ability,但是对别人造成伤害(调用别人的BeAttacked),这并不是我的能力,而是大自然(GameManager)的能力,我打出一拳,能否命中别人,能命中谁,能造成什么样的伤害,都是大自然运算出来的(物理、几何等规则,自然科学就是一种大自然的规则),然后大自然决定了是否去调用以及怎样调用他们的BeAttacked,而不是我(人类)调用的。

就像游戏中的移动,“我”(人类)有挪动坐标的能力,但是我能挪动到哪儿,不是我的能力决定的,是大自然(GameManager)所决定的。所以,任何把角色移动写在角色类的都是错误抽象(我这里就不指名道姓世界第一引擎UE犯了这个错误了),真正的“角色移动”是GameManager的能力。

这里你的构思,就透露了你没有理解好这个业务,也没有理解oop中类的设计。

最后是对unity的GameObject的理解问题

而unity的一个GameObject,指代的是——受到unity场景(Scene)逻辑管理的一个对象,他可能只是一个渲染体,他恰好包含描述了一个角色,但是角色,并不等于是GameObject。

用代码来说,就是:

  1. //这是在游戏中渲染出来的GameObject的核心数据,代表了他是一个“角色”
  2. public class CharacterObj:MonoBehaviour{
  3. //这是这个被渲染角色所指向的真正的角色(仅仅只是一条数据,而非GameObject)
  4. public Character Character{get;private set;}
  5. }

他们是这样的关系,List allCharacters的长度一定小于等于List allGuys的长度。这,才是真正的那句人们常说的“逻辑渲染分离”

来自: (1 封私信) Unity有没有办法让GetComponent<>()调用脚本不依赖其具体的名字? - 知乎