好吧,这不算编程入门内容,但我真的很想分享这个模式——因为它是我从2017年左右开始,在游戏开发中用过最强大实用的编程模式之一。谷歌说这叫"策略"模式,虽然不完全吻合我的用法,但就这么叫吧。

通过这个模式,你可以(举例而言):

  • 创建完全独立于任何怪物类的自定义怪物行为,让设计师无需修改文本文件就能切换行为
  • 创建单一状态效果,然后让十件不同魔法道具拥有该效果的不同实现,完全无需重复代码或数据
  • 用完全干净模块化的方式编写新代码,安全编辑且不会破坏游戏其他部分
  • 在代码路径的任何位置注入疯狂离谱的行为和功能,同时不影响核心函数的复杂度、长度和可读性

第一部分 - 基础概述

总结策略模式最简单的方式就是:将函数名保存为字符串(比如在游戏数据.txt文件中),然后用名为✨反射✨的魔法将字符串与游戏代码中的实际函数匹配,最后执行它。当然我们也可以保存函数参数

假设你在做某种卡牌游戏。在数据文件中定义卡牌,写入这样一段文本(具体格式不重要):

cardname=火球术
damage=12
visual_function=朝目标发射火球
visual_args=火球数量:5

当游戏实际运行时,这样设置卡牌执行逻辑(伪代码):

void PlayCard(Card cardObject)
{
  ... // 游戏逻辑
  if (cardObject.HasVisualFunction())
  {
    cardObject.visualFunction.Run(cardObject.visual_args);
  }
}

PlayCard函数完全不知道要执行什么视觉函数,它也不关心。你可以在别处编写该函数的代码,比如CardVisualFunctions类:

void 朝目标发射火球(Dictionary<string,string> args)
{
  // 让卡牌旋转
  // 生成火焰粒子
  // 将火焰粒子射向目标
  // 重复上述步骤args["火球数量"]次
}

C#等语言有"委托"的概念。如果你熟悉它,可能觉得策略模式很相似——没错!

"委托"本质上是把函数当作参数,可以调用的东西,让我们能干净地分配行为。上面例子的委托版本大概是这样:

public class CardObject()
{
  public Action<Dictionary<string,string>> VisualFunction
}

...

void PlayCard(Card cardObject)
{
  if (cardObject.VisualFunction != null)
  {
    cardObject.VisualFunction.Invoke();
  }
}

策略模式的惊人之处在于,你可以完全通过文本字符串使用它——如果你想把游戏数据存储在外部文件中,方便设计师访问和玩家改装,这简直完美。

第二部分 - 解决了什么问题?

这个模式有多实用、为我省了多少麻烦,能写一本书。直接看更多例子吧。假设游戏有状态效果,每个效果根据触发器执行。触发器可以是挥动武器、未命中、被击中、格挡攻击、移动等。先用枚举定义不同战斗触发器:

public enum 战斗触发器 { 基础攻击挥出, 闪避, 格挡, 暴击, 迈步, 招架, 移动 }

然后在代码其他位置设置"钩子":

void 攻击时(战斗者 目标)
{
  foreach(状态效果 效果 in 我的状态效果列表)
  {
    if (!效果.检查触发器(战斗触发器.基础攻击挥出)) continue;
    效果.触发(战斗触发器.基础攻击挥出);
  }
}

状态效果类只需定义效果和触发它的BattleTriggers字段。很好!

但再深入想想:如果状态效果要求用匕首而非剑挥动时触发?闪避Boss级怪物但非普通怪物时触发?每三次挥动触发?移动每15米而非仅仅移动时触发?格挡火焰攻击而非其他元素时触发?

这时代码就不太妙了。用上述方法得创建更多枚举,用大量条件判断污染代码。不好!糟糕!

所以改为给状态效果添加新字段:

public class 状态效果()
{
  public 效果 my效果;
  public 战斗触发器 trigger;
  public string 需求函数;
  public Dictionary<string, string> 需求函数参数;
}

这样能保持战斗触发器列表更简短合理。用上述列表创建两个状态效果:

  • "匕首强化"在基础攻击挥出时触发,但必须装备匕首
  • "三重麻烦"在基础攻击挥出时触发,但每3次挥动触发一次

数据文件这样写:

状态="匕首强化"
触发器=基础攻击挥出
效果=[此处定义效果]
需求函数=检查武器类型
需求函数参数=匕首

状态="三重麻烦"
触发器=基础攻击挥出
效果=[此处定义效果]
需求函数=每几次挥动触发
需求函数参数=3

模式应该逐渐清晰了。编写状态效果的触发逻辑时:

void 触发(战斗触发器 the触发器)
{
  if (存在需求函数())
  {
    bool 有效结果 = 执行需求函数(需求函数参数);
    if (!有效结果) return;
  }
  // ... 正常执行效果
}

检查武器类型每几次挥动触发的逻辑需要放在某处,但可以放在独立干净的文件中。具体逻辑不重要,重点是它们能解耦到代码库的其他位置。

如果设计师想修改每几次挥动触发的挥动次数,轻松搞定。如果要复制"匕首强化"并改为武器类型"长矛",同样可行。两种修改都无需代码。

这只是个例子。在代码库各处使用这个模式能产生巨大效果。

当你发现自己在干净代码路径中为各种"边界情况"逻辑做大量硬编码和笨拙条件判断时,就该用这个模式了。

第三部分 - 技术细节(实际操作)

之前用的是伪代码,现在来看具体实现。在C#中,先创建字典来"缓存"函数查找(性能考虑):

public static Dictionary<Type, Dictionary<string, MethodInfo>> dict未缓存方法;

然后在工具类中使用辅助函数根据字符串获取方法。代码较长,放在这里了。写好后无需再修改。

使用策略模式时,先检查字符串不为空、方法存在,再用try/catch安全执行。这是我代码库中的具体例子:

// customRequirementsFunction是字符串
if (!string.IsNullOrEmpty(template.customRequirementsFunction))
{
  // AbilityRequirementsScripts是包含函数逻辑的静态类
  MethodInfo runscript = CustomAlgorithms.TryGetMethod(typeof(AbilityRequirementsScripts), template.customRequirementsFunction);

  if (runscript != null)
  {
    // 3是自定义的参数数量:技能使用者、技能本身、参数列表
    object[] paramList = new object[3];
    paramList[0] = fighterOwner;
    paramList[1] = abil;
    paramList[2] = template.customRequirementsFunctionArgs;

    try 
    {
      bool usable = (bool)runscript.Invoke(null, paramList);
      if (!usable) return false;    
    }
    catch(Exception e)
    {
      Debug.Log("执行需求函数失败,原因:" + e);
    }
  }
}

第四部分 - 常见问题(大概?还没人问过)

问:性能好吗?
答:经验来看很好。方法的"解箱"操作是最大开销,但只需执行一次。之后就没问题了。每秒跑上万次?大概不行。但用于非高频路径的游戏事件完全OK。即使在Switch这类低配硬件上也没出过问题。

问:有缺点吗?
答:主要问题是失去编译器名称检查。如果设计师把"每几次挥动触发"写成"每次挥动触发",在C#中写实际函数名会被发现,但用字符串时只能在运行时发现。需要自己做验证。

问:肯定还有其他缺点吧?
答:暂时想不出。在三款游戏中用了这个模式,从未出问题。这确实是超棒的模式。在开发中的《Tangledeep 2》里,约41种不同方法类型都用了它,而且还在增加。效果拔群。

问:这不就是委托吗?
答:有点像——都是把函数/方法存为变量稍后调用的理念。但神奇之处在于,完全可以通过编辑文本改变函数和参数。我尽可能把游戏数据存在外部文件中,主要是为了方便改装,也为了无需重新编译或打开Visual Studio就能修改。不会C#的设计师也能轻松修改。

问:让改装者执行任意代码不会有安全问题吗?
答:完全不会。仔细看上面例子,游戏只会尝试执行代码库中已存在且必须属于预选类的函数。即使代码中有危险函数,玩家也无法通过修改状态效果的需求函数来调用它。

问:这个模式在[XYZ]其他语言中也能用吗?
答:我只用C#配合Unity开发,不清楚。在C++中肯定能用。GDSCript或GML就不知道了。

...以上就是全部内容了!感谢耐心看完我的拖延之作。欢迎提问。