游戏(技能)中的脚本设计

脚本设计

前言和需求情景

每种语言都有自己的惯用思维, 面对领域需求时, 也应该在不同的语言思维环境下寻找解决方案, 而不是生搬硬套另外一种语言的特性, 但是从可计算性的角度上来看, 相同需求的良好解决方案往往具备很强的相似性.

那么更具体的领域中, 我们说说技能系统的场景:

技能系统的复杂度偏向问题域, 如果不去约束问题域的规模, 最好的解决方案一定是通过脚本化方式让技能的设计者直接去写设计者期望的战斗逻辑. 但是作为一门通用的语言, 是需要转化为一个简洁的, 低门槛的领域语言.

这里不讨论如何拆解该系统所面对的问题域, 如何抽象出解决域的模型等, 这部分在之前的技能系统相关的PPT中已经描述过, 这里主要关注的是, 在使用脚本的情况下, 我们如何对脚本这部分进行更具体的设计.

  • 作为开发者, 更关注的是开发测试成本, 即用最简洁的代码, 一劳永逸的提供最丰富的上层接口.
  • 作为设计者, 更关注的是是否提供了足够的封装, 隐藏掉不需要关心的功能实现细节以及流程细节, 并且能够总是通过简单的if else call来完成所有决策, 或者用简单的枚举或者画图 打钩完成所有决策而不需要操刀脚本编写.
  • 更进一步的, 从设计者角度, 按照配置的出场频度和复杂度应该有如下的方案选型排序:
    • 几乎总是需要配置的: 默认配置方案, 什么都不需要做就是应该有的功能或者流程
    • 次高频: 通过开关来切换功能或者流程
    • 高频: 通过枚举来完成多功能或者多流程case
    • 高频低中度复杂: 通过枚举+固定的跟随参数来完成
    • 高频低中度复杂: 开发人员编写特定的功能模块, 条件模块, 并提炼出参数以特定枚举方式提供
    • 中低频复杂条件: 嵌入简短的脚本, 通过数据接口+脚本提供的布尔表达式来完成
    • 中低频复杂逻辑: 开发人员协助设计者编写脚本
    • 中低频中低复杂: 策划自行写脚本 自行验证
    • 低频其他: 脚本兜底实现
  • 对于中小型硬核技能玩法项目 或者设计者本身有一定的编程功底, 那么我们可以实现一个简洁的脚本驱动的内核, 然后任由设计者天马行空的设计和铺展战斗系统.

  • 但对于另外一些情况, 比如存在大规模的低复杂度技能设计, 或者策划人员对脚本的接受能力参差不齐, 我们需要对脚本的适用范围进行收敛, 但是仍然想要灵活的机制来实现丰富的技能体系. 这就需要对涉及到脚本编写部分的更具体的优化设计.

    • 直接提高数据驱动部分的配置在整个技能系统下所包含的范围

      通过堆开发人力来提高. 但是这部分会随着需求的细化和新需求的提出导致不断的重构, 开发人员需要持续跟进
      持续变更的核心代码会降低整个系统的稳定性, 因此这部分的工作需要克制,谨慎的拆解,分析,以及交付测试.

    • 把可以简单替换成脚本的逻辑, 给设计者提供数据驱动的配置接口, 在配表完成阶段或者读取配置阶段翻译成脚本

      相比上面的方案, 该方案不需要修改核心逻辑, 性能略有下降.

    • 提供脚本片段/脚本模版, 配置时候快速复用已有脚本逻辑

      相比上面一条, 该方案设计者可见脚本代码, 但是不需要手写.
      不容易出错, 可以用来熏陶设计者对脚本的接受能力, 降低脚本门槛.

    • 提供脚本级别的封装机制, 对脚本复杂度进行降级

      把复杂的脚本实现拆解成独立的多段脚本函数, 一次测试多次复用
      把多行脚本能完成事情 封装成一行脚本 或者一个函数+参数的形式
      把简单的脚本映射成数据驱动的枚举+参数形式 (直接映射为封装好的函数+参数)
      封装的位置单独存放在脚本文件中, 并且支持热更新方便快速试错和验证

解决方案

脚本作为核心的主体逻辑部分, 和整个技能系统的关系不是简单的集成嵌入, 而是从最初的技能系统框架层面就设计进去的, 这样整个技能系统和脚本的关系才能做到简洁自然, 清晰自洽.

  • 提炼技能系统的meta数据

    这里的meta数据, 意思是把一个能表征技能状态和数据的关键元素提取出来单独维护, 它足够小但足够提供我们关心的所有信息. 例如初始状态, 当前状态, 可追溯的来源信息, 相关联的配置ID, 上下文等.
    这份meta数据会成为脚本的基础数据环境, 通过这个meta信息我们可以查询相关的配置 状态, 以作为某些条件的判定依据, 以及新的行为的参数.
    必要时我们可以拷贝这份meta数据, 或者伪造修饰部分数据来提供更特殊的环境实现.

  • 基础的脚本胶水接口实现 (开发向低级接口)

    一次性提供所有meta数据的访问接口
    提供C侧基于meta数据的功能函数封装, 尽可能的做到原子性

  • 编写模板类, 提供易用 易读 易使用的高级接口(用户向)

    一次性翻译低级接口到高级接口, 这个过程会隐藏掉例如meta数据本身访问等
    复杂逻辑封装

  • 在C++代码中打桩

    把来自设计者的脚本片段和合成一个临时函数并压栈
    把meta数据和调用信息压栈, 作为临时函数的参数去执行.
    这里为了简化不同流程的桩点环境和不同事件的桩点环境不同, 并不会编写不同的桩点代码而是统一使用meta数据, 这样再脚本系统的实现上就做到了统一的埋点处理, 极大的减少了因此带来的桩点代码量.
    桩点中来自策划的脚本片段实际执行会经过’模板类’这个中间层, 而这个中间层存储在可热更的单独文件中.
    打桩的脚本代码在首次执行时会处理为字节码提高性能, 热更配置会清除字节码.
    模板类脚本也为字节码 可热更.

  • 注意项: 对脚本调用可能存在嵌套, 例如在脚本事件中触发新的脚本甚至重入

    在每段可能会触发脚本的关键路径上进行stack计数, 超过计数block掉该流程.
    脚本环境支持嵌套, 或者说脚本环境(包含技能meta和脚本环境实例)应该是放在栈上.

更具体的实现细节

lua的OOP模拟

需要解释下是 lua没有类的概念, 只有实例(table). 但是lua的table是可以聚合函数和数据的, 并且存在metatable这种元表概念, 因此在在lua的语法特性中我们可以用以下方式来完成一个接近OOP的模拟, 基本思路如下:

  • 构造一个全局的table实例作为创建实例的metatable(类的概念)

  • 提供一个公共的new接口来创建一个新表, 并设置好metatable的关系. (实例化)

  • 以:形式来编写所有函数完成C++this指针的作用, 即所有函数的实现默认第一个参数为实例自身, 调用时默认用自身作为函数的第一个参数

  • 其简洁的实现形式如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    -- 定义全局table  
    meta = {}

    --数据成员
    meta.class_name = "meta";

    --函数成员
    function meta:desc(msg)
    print(self.class_name .. ": " .. msg)
    end

    --这个:是一个语法糖, 等价代码:
    --[[
    function meta.desc(self, msg)
    print(self.class_name .. ": " .. msg)
    end
    ]]--

    --实例化方法
    function meta.New(...)
    local inst = {...}
    setmetatable(inst, { __index = meta }) --设置metatable 当inst中不存在某个键,会读出meta相应的元方法
    return inst
    end

    --实例化一个meta的实例
    local inst = meta.New()

    --修改新实例的class_name为inst
    inst.class_name = "inst"

    --子类并没有desc方法 会尝试读meta的desc 并把inst实例以self参数传入该方法
    inst:desc("new inst")
  • output

    inst: new inst

    实际应用

  • 统一的桩点代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    return function (skill_meta, result, ...)
    local inst_env = {skill_meta, result, ...}
    setmetatable(inst_env, { __index = meta_env })
    do
    --配置开始
    --inst_env:cast_skill(...)
    --配置结束
    end
    return 0;
    end
  • 配置方式
    伪脚本片段: 沉默BUFF的实现:

    pre cast skill ```
    1
    2
    ``` 
    if inst_env:skill_has_tag(333) then inst_env:block_flow() end
  • 封装实现
    把上述逻辑封装成一个call

    1
    2
    3
    function inst_env:silence(tag)
    if self:skill_has_tag(333) then self:block_flow() end
    end
    pre cast skill ```
    1
    2
    ``` 
    inst_env:silence(333)