注:本文面向初学者。经验丰富的程序员很可能对这些内容都已耳熟能详。但即使是老手们,也可能从这里找到一些有用的信息。

Unity 的组件化设计使得对象/类之间很容易修改彼此的变量(字段)。例如,我们正在制作一个地下城探索游戏,我们遵循一些良好的设计原则,例如将我们的健康放在一个组件中,将装备放在另一个组件中,将战斗统计放在第三个组件中。

这种代码很常见:

private void AttackEnemy(Fighter target)
{
    int baseDamage = CalculateMyDamage();
    baseDamage -= target.CalculateMyDefense();
    target.myHealth.current -= baseDamage;
    if (target.myHealth.current < 0)
    {
        target.PlayOnDeathAnimation();
        int xp = target.CalculateEarnedExperience();
        myXPComponent.xp += xp;
    }
}

这不是非常糟糕的代码。我们是使用像CalculateMyDamage、CalculateMyDefense和CalculateEarnedExperience这样的函数,而不是在AttackEnemy函数中写入这些内容。

然而,我们仍然是紧密耦合的攻击者和防御者。攻击者不应该负责检查防御者是否死亡或者不应该知道何时播放死亡动画。事实上,它甚至不应该负责改变防御者的血量。

因为想象一下,如果我们现在引入地形伤害。我们创建一个名为Hazard的新对象,它每秒都会对目标造成伤害。如果我们继续以相同的方式编写代码,我们可能会得到:

private void CauseHazardDamage(Fighter target)
{
    int baseDamage = CalculateMyHazardDamage();
    baseDamage -= target.CalculateMyDefense();
    target.myHealth.current -= baseDamage;
    if (target.myHealth.current < 0)
    {
        target.PlayOnDeathAnimation();
        int xp = target.CalculateEarnedExperience();
        myXPComponent.xp += xp;
    }
}

你已经可以看到这几乎是从AttackEnemy中复制过来的,这是一个红旗。例如,如果我们想在敌人死亡时添加一些奖励,我们现在必须添加以下代码到个函数:

if (UnityEngine.Random.Range(0, 1f) <= target.GetTreasureChance())
{
    Treasure reward = target.GenerateTreasure();
    // do spawn logic here
}

然后如果我们想在某些Fighter上添加一个避免致命打击的效果,我们可能需要修改以下代码:

target.myHealth.current -= baseDamage;
if (target.myHealth.current < 0)
{
    if (target.HasStatus("avoid_fatal_blow") && UnityEngine.Random.Range(0, 1f) <= AVOID_FATAL_BLOW_CHANCE)
    {
        target.myHealth.current = 1;
    }
    else
    {
        // 正常的死亡代码
    }
}

或者如果Fighter有其他状态效果或装备,它们会在受到伤害时反应,我们的代码可能会像这样:

private void CauseHazardDamage(Fighter target)
{
    int baseDamage = CalculateMyHazardDamage();
    baseDamage -= target.CalculateMyDefense();
    target.myHealth.current -= baseDamage;

    if (target.HasStatus("reactive_damage_ability"))
    {
        // do some cool stuff here
    }

    if (target.myHealth.current < 0)
    {
        if (target.HasStatus("avoid_fatal_blow") && UnityEngine.Random.Range(0, 1f) <= AVOID_FATAL_BLOW_CHANCE)
        {
            target.myHealth.current = 1;
        }
        else 
        {
            target.PlayOnDeathAnimation();
            int xp = target.CalculateEarnedExperience();
            myXPComponent.xp += xp;
            if (UnityEngine.Random.Range(0, 1f) <= target.GetTreasureChance())
            {
                Treasure reward = target.GenerateTreasure();
                // do spawn logic here
            }
        }
    }
}

它只是变得越来越糟糕。现在有很多种方法可以设计你的代码,以避免陷入这种情况。但是为了本文的目的,我想专注于这个想法:

如果你发现自己直接获取、修改和设置其他对象的变量,这应该告诉你你可能正在编写难以维护的代码。

我们可以在写下以下行时就意识到这一点:

target.myHealth.current -= baseDamage;

不深入过多的细节,这个更可维护的方法是:

private void AttackEnemy(Fighter target)
{
    // 我们可以播放VFX/SFX这里...
    int baseDamage = CalculateMyDamage();

    // 但我们相信目标会自己决定如何处理我们计算的伤害
    target.OnAttacked(this, baseDamage);  
}

private void OnAttacked(Fighter attacker, int baseDamage)
{
    int defense = CalculateMyDefense();
    baseDamage -= defense;
    OnDamageReceived(attacker, baseDamage);
}

// 这个逻辑从OnAttacked中分离出来,因为我们可以肯定地接收来自其他来源的伤害
// 例如,如果我们中毒了,那么可能会忽略防御完全
// 在这种情况下,我们只需要运行OnDamageReceived(poisonDamage)
private void OnDamageReceived(Fighter attacker, int damageAmount)
{
    // 这个函数不应该知道或关心我们拥有的状态效果做了什么
    // 我们将信赖状态效果自己来修改它。
    foreach(StatusEffect se in myStatusEffects)
    {
        damageAmount = se.OnDamageReceived(damageAmount);
    }

    // 我们的状态效果可能已经将伤害降为零了!
    if (damageAmount == 0)
    {
        // 播放一些“DEFLECT!”的VFX和SFX。
        return;
    }

    myHealth.ReduceHealthFromDamage(attacker, damageAmount)
}

// ---- 现在我们是在HealthComponent类中 -----

private void ReduceHealthFromDamage(Fighter attacker, int damageAmount)
{
    current -= damageAmount;
    OnHealthChanged();
    if (current > 0) return;
    OnTookLethalDamage(attacker);
}

private void OnTookLethalDamage(Fighter whoKilledMe)
{  
    // 和OnDamageReceived一样,我们可能有状态效果会在我们受到致命伤害时做一些疯狂的事情
    // 我们可能会运行它们并退出,如果其中任何一个将我们带回来>0。
    foreach(StatusEffect se in myStatusEffects)
    {
        current = se.OnHealthReducedToZero();
        if (current > 0)
        {
            // 我们奇迹般地存活了下来!
            OnHealthChanged();
            return;
        }
    } 

    OnDeath(whoKilledMe);
}

private void OnDeath(Fighter whoKilledMe)
{
    // ... 给whoKilledMe一些奖励或什么的!
}

这不是完美的,还是有很多地方可以改进它。但是它将我们的“关注点”分离得更好。

  • 如果我们想添加一些新的阻挡/闪避机制,我们只需要在一个地方做它:OnAttacked
  • 如果我们添加新的状态效果,我们不需要在这些函数中写入任何新的代码
  • 如果我们想改变Fighter死亡时发生的事情,有一个函数负责它
  • 如果我们添加新的伤害来源——陷阱、危害、中毒、诅咒装备等——我们的现有函数会处理它得很好

... 和如此如此!希望你会发现这有用。我的目标不是为编程架构提供特定的解决方案,因为每个游戏都不同,而是认识到获取/设置其他对象变量的过度使用为潜在的“代码臭味”。