Unity技术分享Unity编辑器开发分享Unity技术分享

[Unity框架] Core-Data-Actor结构

2018-09-02  本文已影响76人  石膏

概述

  首先要说的是这并不是什么稀奇新颖的概念,我相信很多人会见过或自己就写过很类似的东西,只是我依据自己的理解给它起了这么一个名字。对于Unity开发的新手来说,我相信这篇文章会对你起到不错的作用(尤其是像我一样的非程序科班选手)。当然由于我长期进行的是中小规模的独立游戏开发,我并不确保文章的所有内容都适用于更大的甚至3A的项目里。
  这个结构的灵感可能来源于很多地方,比如守望先锋的ECS、传统软件的MVC、观察者模式等等,这些思想在Unity的GameObject/Component结构(也是现在游戏引擎最常见的结构,可以在别的引擎举一反三)基础上进行了重新的适配,到如今我正在开发的独立游戏项目里,我认为这个思路已经趋于稳定而可用,我打算做一个总结,抛砖引玉。

什么是Core-Data-Actor?

  此结构的目的就在于将 游戏逻辑游戏数值表现效果 这三个部分进行明确的分离。

  电子游戏是如何运作的?电子游戏本质是一堆数据——Data,比如:人物的血量、游戏状态等等。但是仅仅一堆数据并没有什么好玩的,所以游戏世界需要在玩家的输入介入后,发生一些变化,当然本质上也是数据的变化——游戏感知到玩家按了一次空格,游戏中的主角打了坏蛋一拳(玩家动画机变为拳击状态),坏蛋一个踉跄倒在地上(坏蛋状态变为倒地),被击败(血量降为0),你获得了10枚金币(你的金币增加10)。随着数据产生了一系列变化,游戏才逐渐变得有趣了起来。此处响应了玩家输入并执行了一系列数据变化的家伙,我称其为Core。但是如果只是游戏的内在数据变化,你在画面上所见的内容并不会改变(坏蛋的HP值确实下降了,但是UI的血条并没有变化啊!),所以就需要Actor,将玩家看不见的数值转化为人们感官可以感知的具体表现。

  Core要做的是接收玩家的输入,并应用相关逻辑造成数值变化。原则上整个游戏的Core个数需要尽量少,一种不复杂的玩法只需要一个Core全部负责即可,各个Core所负责的功能尽量保证相对独立、低耦合,避免出现多个Core操作同一个Data的情况。这篇文章暂不深入探讨Core,因为真要说的话有好多值得一说的,可以细分出很多做法。(我现在一般会采用有限状态机的做法)

  Data在实际应用里常常还是会采用一个OOP的思路。比如Player对象,是构成一个玩家所需的HealthPoint、AttackPower、List<Equipment>等等属性数据的集合体。但是需要注意的是,Data不允许有任何“行为”,广义上理解就是对象内部不应该有任何操作数据的能力(包括自己或他人的),狭义上理解就是不应该有“方法”吧。

对于数据初始化是否需要Awake或Start?
既然原则上Data不应该有任何方法,自然也不应该有Awake和Start,但是如果只是初始化数据也并没有什么问题,但我还是不推荐这么做。Unity有Inspector,可以很方便的对数值进行初始设定。在Unity中某些复杂数据无法原生支持序列化,使用插件能解决这个问题,可以看我之前写的【Odin插件】Unity补完计划

  Actor必须要依附于一个Data,根据相应的数据进行表现。Actor本身可能会带有一些数据,但不会是游戏的关键逻辑数值,一般都是表现层面所需的引用对象,或是实现动画效果所需的buffer类变量。后面我会举一玩家UI血条的例子,它其实就是一个对Player.HealthPoint数值的表现器,让玩家可以感知到人物的血量。
  Actor不可以改变Data中数据。它要做的只是观察数值变化,并以一个预期的效果呈现在游戏画面上。所以即使关闭游戏中所有的Actor,游戏实际依然可以完全正常的运作,只是你将无法在画面上看到任何变化罢了。更高级的用法,你可以通过切换Actor来实现对同一组数据的不同呈现(2D呈现?3D呈现?),当然绝大部分情况用不到这种情况,每个Data和Actor都是一一对应的,所以你也可以将Data和Actor写进同一个脚本。

实际应用

  接下来我会以一个实际的例子展现Data-Actor的运作方式,就以角色的血量为例。
  首先,编写一个Data——Character类。

using UnityEngine;

public class Character : MonoBehaviour
{
    [Range(0, 100)]
    public int HealthPoint;

    //其他的一些角色数值...比如
    public int AttackPower;
    public string CurrentAnimation;
    //...
}

  这应该很好理解,一个角色当然就会有血量(HealthPoint)、攻击力(AttackPower)等等的属性,不多解释了。但是多提一句:传统OOP的思想里,一个角色当然应该可以攻击Attack()、休息Rest()、吃喝拉撒……但是这个结构中,这是Data,所以这个类型里就只有数据和状态,不允许有“行为”
  然后我们编写一个Actor的基类,实现Actor基本的运作方式。

using UnityEngine;

[ExecuteInEditMode]
public class Actor<T> : MonoBehaviour where T : MonoBehaviour
{
    T targetData;
    public T TargetData
    {
        get
        {
            if (!targetData) { targetData = GetComponent<T>(); }
            return targetData;
        }
    }

    private void OnEnable()
    {
#if UNITY_EDITOR
        UnityEditor.EditorApplication.update += LateUpdate;
#endif
    }

    private void OnDisable()
    {
#if UNITY_EDITOR
        UnityEditor.EditorApplication.update -= LateUpdate;
#endif
    }

    private void LateUpdate()
    {
        UpdateActor();
    }

    protected virtual void UpdateActor()
    {
        //编写更新
    }
}

  这里需要解释一下,OnDisable()OnDisable()里的代码配合[ExecuteInEditMode]用以实现这个脚本在Editor中实时更新。这样会给后面的调试带来极大的方便,不需要每次都运行再看效果,另外由于Actor的独立性,即使这么做他也不会给游戏的其他组件带来任何意想之外的Bug。但是有一点需要注意,Actor的子类不要再覆写OnEnable()OnDisable()LateUpdate()这三个方法了,会导致基类中的失效。
  接下来,我们就从Actor<T>类中派生,编写针对Character类的表现器吧。

using UnityEngine;
using UnityEngine.UI;

public class CharacterActor : Actor<Character>
{
    [SerializeField]
    Slider slider_Hp;
    [SerializeField]
    Image image_HpSliderFill;
    [SerializeField]
    Text text_HpText;

    [SerializeField]
    Gradient gradient_HpSliderFill;

    private void Start()
    {
        temp_HpBuffer = TargetData.HealthPoint;
    }

    protected override void UpdateActor()
    {
        slider_Hp.value = TargetData.HealthPoint / 100.0f;
        text_HpText.text = TargetData.HealthPoint.ToString();
        image_HpSliderFill.color = gradient_HpSliderFill.Evaluate(slider_Hp.value);
    }
}

  这里我们就针对Character.HealthPoint进行一个血量条的表现。表现会用到的对象有:血量条的SliderImage、显示血量的Textgradient_HpSliderFill用来配置血量的从高到低的颜色渐变。
  最后通过重载UpdateActor()来实现对各个表现的更新:Slider和血量的比例同步、Text显示当前的血量、血量条的颜色也会变化(高血量绿色到低血量红色)。代码应该不难理解,在一个物体上挂载CharacterCharacterActor两个脚本,看一下现在的效果。


  效果可以接受,并且我们无需运行直接在Inspector里调整滑块就可以看到这样的效果。但是,如果我需要更动感一些呢?大坏蛋Boss打了我一拳,我血量一下从100变为了10,血量条会一下子掉了一大截,我觉得如果有一个缓冲的动画会更加好一些。所以我们做一些修改:
    float temp_HpBuffer;
    protected override void UpdateActor()
    {
        temp_HpBuffer = Mathf.MoveTowards(temp_HpBuffer, TargetData.HealthPoint, 0.5f);
        slider_Hp.value = temp_HpBuffer / 100.0f;
        text_HpText.text = Mathf.FloorToInt(temp_HpBuffer).ToString();
        image_HpSliderFill.color = gradient_HpSliderFill.Evaluate(slider_Hp.value);
    }

  增加了一个temp_HpBuffer的作为血量增减的缓冲,再看看效果。


  我们血量确实是一个瞬间的变化,但是血量条在表现上却呈现了一种缓动的效果。试想一下,我们喝下一瓶回血药剂(原素瓶?),看到血量条噌噌往上涨的动画,肯定比一下子到位的效果更好吧!更重要的是,我们并不需要通过修改内部逻辑来实现这个动画,回血依然是一句HealthPoint += 50;来实现HP的增加,而不需要去写一个协程或是什么办法在每一帧里增加一部分血量,为了实现一个动画效果而那么做就太奇怪了。

结语

  血量到UI的表现只是一个容易理解的范例,其实Data-Actor可以继续推广到所有的游戏数据中。比如:游戏时间Game.Time可以表现在天空盒(Skybox)的变化、平行光(Directional Light)的角度(Rotation)和强弱(Intensity)变化、雾效(Fog)的变化、玩家手表上的指针变化……等等等等。
  感谢你的阅读,希望这篇文章对你有帮助,接下来可能会写一篇用有限状态机形式实现Core的内容……

上一篇下一篇

猜你喜欢

热点阅读