Lua 基于组件的对象系统设计与实现

it2022-05-05  135

Lua 基于组件的对象系统设计与实现

简介

MMO 中主角色属性、功能特别多。Character 类可迅速达到上万行的体量。

笔者参考《饥荒:联机版》代码的角色实现,发现各种功能特性是以组件的形式添加到主体上的。使得功能组件非常清晰,复用性也很好,逻辑不会全部堆在一起,方便维护。类似以下形式:

inst:AddComponent("inventoryitem") inst.components.inventoryitem.cangoincontainer = false

故在业务中尝试采用这种形式来减少主文件的长度、合理分割功能模块,提高各模块的独立性以及可测试性。

组件式系统的概念

在组件系统中,一个大的功能模块,比如玩家模块,可以视为由多个组件组成的。例如玩家可以移动,可以攻击,具有 PK 状态。那么玩家就具有移动模块、攻击模块/技能模块、PK状态模块。

在这种观点下,如果我们移除玩家的移动模块,便可轻易实现让玩家定身、冰冻时无法移动的效果。

这仅仅是组件化带来的一种附加好处。组件化最主要的意义在于可以让我们将一个大的功能模块中的小功能块拆分,然后有机地组合成大的功能模块。避免出现上万行的逻辑代码,方便维护也方便测试。

组件可与外界通过接口交互,这样让组件的数据读写都更加清晰规范,重用性也会提高,在新的项目中使用该模块只需保证接口一致便可重用。

组件式系统优点

新增功能时,采用此系统,主文件可以只用加一行代码,新功能在另外文件里面。而若不使用,新功能代码都在主文件里面,主文件会随着功能的增加迅速达到上万行甚至几万行的规模。方便将一个功能拆分成多个独立的子功能,逻辑清楚,方便理解功能主干,同时方便每个子功能进行单元测试。功能模块可重用性提高。

组件式系统缺点

如果功能拆分太细会过于繁琐,也会导致文件过多。过于拆分逻辑会导致逻辑分散。

与装饰者模式的联系

装饰者模式通过继承实现一层层包裹对象添加功能的效果。用于解决子类爆炸的问题。 它的每一个继承层次提供一组新的功能或者修饰,而组件式系统通过添加组件(也就是组合),来给主体提供更多的功能或者修饰。 两者都实现了子功能的分离与组合。

组件式系统更加能够体现合成复用法则。

与 ECS 模式的联系

ECS,即 Entity-Component-System(实体-组件-系统) 的缩写,其模式遵循组合优于继承原则,游戏内的每一个基本单元都是一个实体,每个实体又由一个或多个组件构成,每个组件仅仅包含代表其特性的数据(即在组件中没有任何方法),例如:移动相关的组件MoveComponent包含速度、位置、朝向等属性,一旦一个实体拥有了MoveComponent组件便可以认为它拥有了移动的能力,系统便是来处理拥有一个或多个相同组件的实体集合的工具,其只拥有行为(即在系统中没有任何数据),在这个例子中,处理移动的系统仅仅关心拥有移动能力的实体,它会遍历所有拥有MoveComponent组件的实体,并根据相关的数据(速度、位置、朝向等),更新实体的位置。 参考自:https://blog.csdn.net/qq_14914623/article/details/81451002

ECS 的组件是纯数据,而本文中组件式系统是完整的功能,包含数据与方法。 相比于 ECS 的实现,本文中的方式更加渐进式,可以与旧系统共存,慢慢改造已有系统。

设计与实现

注:这里使用的 class 函数来自 cocoas 提供的 class.lua 实现。主要提供正常的面向对象编程能力,无特别之处。运行示例时请读者自行补上 class 实现。

基类

ComponentBasedObject = class('ComponentBasedObject') function ComponentBasedObject:ctor() self.components = {} end function ComponentBasedObject:addComponent(className, ...) self.components[string.lower(className)] = _G[className].new(...) end

我们这里定义一个具有组件接口的基类,可通过 addComponent 接口,提供新增组件的类名,不定参数部分会传给组件类的构造函数。

在新增组件时,我们直接构造这个组件,然后将其加入到组件表中,其键为类名的小写。这样便可使用 instance.components.classname 这样的形式获取到这个组件。

此处提供的只是一种最简单的实现,在复杂场景下,组件表可以设计成能够支持同类组件多个共存的情况等,添加组件的方法也可以对应变化。

组件类

Component1 = class('Component1') function Component1:ctor() end function Component1:fn1() print('Running fn1 added by component1...') end

组件类没有任何特殊之处。此处提供一个 fn1 函数作为示例。

主功能类

Character = class('Character', ComponentBasedObject) function Character:ctor() self.super.ctor(self) self.value1 = 0 self.value2 = 0 self:addComponent("Component1") end function Character:fn1() print("Running fn1...") end function Character:fn2() print("Running fn2...") end function Character:fn3() print("Running fn3...") end

此处将 Character 类作为主功能类的示例。它继承 ComponentBasedObject。 在构造函数上添加了几个值的示例(value1, value2)。 也添加了几个示例方法 fn1, fn2, fn3。

在构造函数的最后,我们调用了基类提供的 addComponent 方法,给主功能类添加 Component1 这个功能组件。 可以看到我们直接写上功能组件类名便完成添加。

运行示例

基于前面的实现,我们可测试如下:

local c = Character.new() c:fn1() c:fn2() c:fn3() c.components.component1:fn1()

输出结果:

Running fn1... Running fn2... Running fn3... Running fn1 added by component1...

这里我们先创建一个功能类Character的实例。然后依次调用了我们定义的几个示例方法。

可以看到,component1 提供的方法通过 c.components.component1 这个成员即可调用。

如此我们便完成了 component1 所代表的功能与主功能 Character 的代码分离。保持了 Character 的简洁与易读。

总结

本文描述的这种组件化模式对于分离功能代码、解除功能耦合性具有较大的意义。组件化的功能也更加利于进行单元测试和自动化测试。


最新回复(0)