大家好,本文提出了ECS模式。ECS模式是游戏引擎中常用的模式,通常用来组织游戏场景。本文出自我写的开源书《3D编程模式》,该书的更多内容请详见:
Github
在线阅读
普通英雄和超级英雄
需求
我们需要开发一个游戏,游戏中有两种人物:普通英雄和超级英雄,他们具有下面的行为:
- 普通英雄只能移动
- 超级英雄不仅能够移动,还能飞行
我们使用下面的方法来渲染:
- 使用Instance技术来一次性批量渲染所有的普通英雄
- 一个一个地渲染每个超级英雄
实现思路
应该有一个游戏世界,它由多个普通英雄和多个超级英雄组成
一个模块对应一个普通英雄,一个模块对应一个超级英雄。模块应该维护该英雄的数据和实现该英雄的行为
给出UML
领域模型
总体来看,领域模型分为用户、游戏世界、英雄这三个部分
我们看下用户、游戏世界这两个部分:
Client是用户
World是游戏世界,由多个普通英雄和多个超级英雄组成。World负责管理所有的英雄,并且实现了初始化和主循环的逻辑
我们看下英雄这个部分:
一个NormalHero对应一个普通英雄,维护了该英雄的数据,实现了移动的行为
一个SuperHero对应一个超级英雄, 维护了该英雄的数据,实现了移动、飞行的行为
给出代码
首先,我们看下Client的代码;
然后,我们依次看下Client代码中前两个步骤的代码,它们包括:
- 创建WorldState的代码
- 创建场景的代码
然后,因为创建场景时操作了普通英雄和超级英雄,所以我们看下它们的代码,它们包括:
- 普通英雄移动的代码
- 超级英雄移动和飞行的代码
然后,我们依次看下Client代码中剩余的两个步骤的代码,它们包括:
- 初始化的代码
- 主循环的代码
然后,我们看下主循环的一帧中每个步骤的代码,它们包括:
- 主循环中更新的代码
- 主循环中渲染的代码
最后,我们运行Client的代码
Client的代码
Client
let worldState = World.createState()
worldState = _createScene(worldState)
worldState = WorldUtils.init(worldState)
WorldUtils.loop(worldState, [World.update, World.renderOneByOne, World.renderInstances])
Client首先创建了WorldState,用来保存游戏世界中所有的数据;然后创建了场景;然后进行了初始化;最后开始了主循环
创建WorldState的代码
World
export let createState = (): worldState => {
return {
normalHeroes: Map(),
superHeroes: Map()
}
}
createState函数创建了WorldState,它包括两个分别用来保存所有的普通英雄和所有的超级英雄的容器
创建场景的代码
Client
let _createScene = (worldState: worldState): worldState => {
创建和加入normalHero1到worldState.normalHeroes
创建和加入normalHero2到worldState.normalHeroes
normalHero1移动
创建和加入superHero1到worldState.superHeroes
创建和加入superHero2到worldState.superHeroes
superHero1移动
superHero1飞行
return worldState
}
_createScene函数创建了场景,创建和加入了两个普通英雄和两个超级英雄到游戏世界中。其中第一个普通英雄进行了移动,第一个超级英雄进行了移动和飞行
NormalHero
//创建一个普通英雄
export let create = (): [normalHeroState, normalHero] => {
创建它的state数据:
position设置为[0,0,0]
velocity设置为1.0
其中:position为位置,velocity为速度
返回该英雄
}
NormalHero的create函数创建了一个普通英雄,初始化了它的数据
SuperHero
//创建一个超级英雄
export let create = (): [superHeroState, superHero] => {
创建它的state数据:
position设置为[0,0,0]
velocity设置为1.0
maxVelocity设置为1.0
其中:position为位置,velocity为速度,maxVelocity为最大速度
返回该英雄
}
SuperHero的create函数创建了一个超级英雄,初始化了它的数据
普通英雄移动的代码
NormalHero
//一个普通英雄的移动
export let move = (worldState: worldState, normalHero: normalHero): worldState => {
从worldState中获得该英雄的position和velocity
根据velocity,更新position
更新worldState中该英雄的数据
}
move函数实现了移动的行为逻辑,更新了位置
超级英雄移动和飞行的代码
SuperHero
//一个超级英雄的移动
export let move = (worldState: worldState, superHero: superHero): worldState => {
从worldState中获得该英雄的position和velocity
根据velocity,更新position
更新worldState中该英雄的数据
}
//一个超级英雄的飞行
export let fly = (worldState: worldState, superHero: superHero): worldState =&g服务器托管网t; {
从worldState中获得该英雄的position和velocity、maxVelocity
根据maxVelocity、velocity,更新position
更新worldState中该英雄的数据
}
SuperHero的move函数的逻辑跟NormalHero的move函数的逻辑是一样的
fly函数实现了飞行的行为逻辑。它跟move函数一样,也是更新英雄的position。只是因为两者在计算时使用的速度的算法不一样,所以更新position的幅度不同
初始化的代码
WorldUtils
export let init = (worldState) => {
console.log("初始化...")
return worldState
}
init函数实现了初始化。这里没有任何逻辑,只是进行了打印
主循环的代码
WorldUtils
export let loop = (worldState, [update, renderOneByOne, renderInstances]) => {
worldState = update(worldState)
renderOneByOne(worldState)
renderInstances(worldState)
...
requestAnimationFrame(
(time) => {
loop(worldState, [update, renderOneByOne, renderInstances])
}
)
}
loop函数实现了主循环。在主循环的一帧中,首先进行了更新;然后一个一个地渲染了所有的超级英雄;然后一次性批量渲染了所有的普通英雄;最后执行下一帧
主循环中更新的代码
World
export let update = (worldState: worldState): worldState => {
遍历worldState.normalHeroes:
更新每个normalHero
遍历worldState.superHeroes:
更新每个superHero
}
update函数实现了更新,它会遍历所有的normalHero和superHero,调用它们的update函数来更新自己
我们看下NormalHero的update函数的代码:
//更新一个普通英雄
export let update = (normalHeroState: normalHeroState): normalHeroState => {
更新该英雄的position
}
它更新了自己的position
我们看下SuperHero的update函数的代码:
//更新一个超级英雄
export let update = (superHeroState: superHeroState): superHeroState => {
更新该英雄的position
}
它的逻辑跟NormalHero的update是一样的,这是因为两者都使用同样的算法来更新自己的position
主循环中渲染的代码
World
export let renderOneByOne = (worldState: worldState): void => {
worldState.superHeroes.forEach(superHeroState => {
console.log("OneByOne渲染 SuperHero...")
})
}
export let renderInstances = (worldState: worldState): void => {
let normalHeroStates = worldState.normalHeroes
console.log("批量Instance渲染 NormalHeroes...")
}
renderOneByOne函数实现了超级英雄的渲染,它遍历每个超级英雄,一个一个地渲染
renderInstances函数实现了普通英雄的渲染,它一次性获得所有的普通英雄,批量渲染
运行Client的代码
下面,我们运行Client的代码,打印的结果如下:
初始化...
更新NormalHero
更新NormalHero
更新SuperHero
更新SuperHero
OneByOne渲染 SuperHero...
OneByOne渲染 SuperHero...
批量Instance渲染 NormalHeroes服务器托管网...
{"normalHeroes":{"144891":{"position":[0,0,0],"velocity":1},"648575":{"position":[2,2,2],"velocity":1}},"superHeroes":{"497069":{"position":[6,6,6],"velocity":1,"maxFlyVelocity":10},"783438":{"position":[0,0,0],"velocity":1,"maxFlyVelocity":10}}}
通过打印的数据,可以看到运行的步骤如下:
1.进行了初始化
2.更新了所有的人物,包括两个普通英雄和两个超级英雄
3.渲染了2个超级英雄
4.一次性批量渲染了所有的普通英雄
5.打印了WorldState
我们看下打印的WorldState:
- WorldState的normalHeroes中一共有两个普通英雄的数据,其中有一个普通英雄数据的position为[2,2,2]而不是初始的[0,0,0],说明该普通英雄进行了移动操作;
- WorldState的superHeroes中一共有两个超级英雄的数据,其中有一个超级英雄数据的position为[6,6,6],说明该超级英雄进行了移动和飞行操作
值得注意的是:
因为WorldState的normalHeroes和superHeroes中的Key是随机生成的id值,所以每次打印时Key都不一样
提出问题
-
NormalHero和SuperHero中的update、move函数的逻辑是重复的
-
如果英雄增加更多的行为,NormalHero和SuperHero模块会越来越复杂,不容易维护
虽然这两个问题都可以通过继承来解决,即最上面是Hero基类,然后不同种类的Hero层层继承,但是继承的方式很死板,不够灵活
基于组件化的思想改进
概述解决方案
-
基于组件化的思想,用组合代替继承。具体修改如下:
- 将人物抽象为GameObject;
- 将人物的行为抽象为组件,并把人物的相关数据也移到组件中;
- GameObject通过挂载不同的组件,来实现不同的行为
这样就通过GameObject组合不同的组件来代替人物层层继承,从而更加灵活
给出UML
领域模型
总体来看,领域模型分为用户、游戏世界、GameObject、组件这四个部分
我们看下用户、游戏世界这两个部分:
Client是用户
World是游戏世界,由多个GameObject组成。World负责管理所有的GameObject,并且实现了初始化和主循环的逻辑
我们看下GameObject这个部分:
一个GameObject对应一个人物。GameObject负责管理挂载的组件,它可以挂载PositionComponent、VelocityComponent、FlyComponent、InstanceComponent这四种组件,每种组件最多挂载一个
我们看下组件这个部分:
组件负责维护自己的数据,实现自己的行为逻辑。具体来说,是将NormalHero、SuperHero的position数据和move函数、update函数移到了PositionComponent中;将NormalHero、SuperHero的velocity数据移到了VelocityComponent中;将SuperHero的maxVelocity数据和fly函数移到了FlyComponent中
InstanceComponent没有数据和逻辑,它只是一个标记,用来表示挂载该组件的GameObject使用一次性批量渲染的算法来渲染
结合UML图,描述如何具体地解决问题
- 现在只需要实现一次Position组件中的update、move函数,然后将它挂载到不同的GameObject中,就可以实现普通英雄和超级英雄的更新、移动的逻辑,从而消除了之前在NormalHero、SuperHero中因共实现了两次的update、move函数而造成的重复代码
- 因为NormalHero、SuperHero都是GameObject,而GameObject本身只负责管理组件,没有行为逻辑,所以随着人物的行为的增加,GameObject并不会增加逻辑,而只需要增加对应行为的组件,让GameObject挂载该组件即可
通过这样的设计,将行为的逻辑和数据从人物移到了组件中,从而可以通过组合的方式使人物具有多个行为,避免了庞大的人物模块的出现
给出代码
首先,我们看下Client的代码;
然后,我们依次看下Client代码中前两个步骤的代码,它们包括:
- 创建WorldState的代码
- 创建场景的代码
然后,因为创建场景时操作了普通英雄和超级英雄,所以我们看下它们的代码,它们包括:
- 移动的相关代码
- 飞行的相关代码
然后,我们依次看下Client代码中剩余的两个步骤的代码,它们包括:
- 初始化和主循环的代码
然后,我们看下主循环的一帧中每个步骤的代码,它们包括:
- 主循环中更新的代码
- 主循环中渲染的代码
最后,我们运行Client的代码
Client的代码
Client的代码跟之前的Client的代码基本上一样,故省略。不一样的地方是_createScene函数中创建场景的方式不一样,这个等会再讨论
创建WorldState的代码
World
export let createState = (): worldState => {
return {
gameObjects: Map()
}
}
createState函数创建了WorldState,它保存了一个用来保存所有的gameObject的容器
创建场景的代码
Client
let _createScene = (worldState: worldState): worldState => {
创建和加入normalHero1到worldState.gameObjects:
创建gameObject
创建positionComponent
创建velocityComponent
创建instanceComponent
挂载positionComponent、velocityComponent、instanceComponent到gameObject
加入gameObject到worldState.gameObjects
创建和加入normalHero2到worldState.gameObjects
normalHero1移动:
调用normalHero1挂载的positionComponent的move函数
创建和加入superHero1到worldState.gameObjects:
创建gameObject
创建positionComponent
创建velocityComponent
创建flyComponent
挂载positionComponent、velocityComponent、flyComponent到gameObject
加入gameObject到worldState.gameObjects
创建和加入superHero2到worldState.gameObjects
superHero1移动:
调用superHero1挂载的positionComponent的move函数
superHero1飞行:
调用superHero1挂载的flyComponent的fly函数
return worldState
}
_createScene函数创建了场景,场景的内容跟之前一样,都包括了2个普通英雄和2个超级英雄,只是现在创建一个英雄的方式改变了,具体变为:首先创建一个GameObject和相关的组件;然后挂载组件到GameObject;最后加入该GameObject到World中
普通英雄对应的GameObject挂载的组件跟超级英雄对应的GameObject挂载的组件也不一样,其中前者挂载了InstanceComponent(因为普通英雄需要一次性批量渲染),后者则挂载了FlyComponent(因为超级英雄多出了飞行的行为)
另外,现在改为通过调用对应组件的函数而不是直接操作英雄模块,从而实现英雄的“移动”、“飞行”
GameObject
//创建一个gameObject
export let create = (): [gameObjectState, gameObject] => {
创建它的state数据:
没有挂载任何的组件
返回该gameObject
}
GameObject的create函数创建了一个gameObject,初始化了它的数据
PositionComponent
//创建一个positionComponent
export let create = (): positionComponentState => {
创建它的state数据:
gameObject设置为null
position设置为[0,0,0]
其中:position为位置,gameObject为挂载到的gameObject
返回该组件
}
PositionComponent的create函数创建了一个positionComponent,初始化了它的数据
VelocityComponent
//创建一个velocityComponent
export let create = (): velocityComponentState => {
创建它的state数据:
gameObject设置为null
velocity设置为1.0
其中:velocity为速度,gameObject为挂载到的gameObject
返回该组件
}
FlyComponent
//创建一个flyComponent
export let create = (): flyComponentState => {
创建它的state数据:
gameObject设置为null
maxVelocity设置为1.0
其中:maxVelocity为最大速度,gameObject为挂载到的gameObject
返回该组件
}
InstanceComponent
//创建一个instanceComponent
export let create = (): instanceComponentState => {
创建它的state数据:
gameObject设置为null
其中:gameObject为挂载到的gameObject
返回该组件
}
这三种组件的create函数的职责跟PositionComponent的create函数的职责一样,不一样的是InstanceComponent的state数据中只有挂载到的gameObject,没有自己的数据
我们可以看到,组件的state数据中都保存了挂载到的gameObject,这样做的目的是可以通过它来获得挂载到它上的其它组件,从而一个组件可以操作其它挂载的组件
移动的相关代码
PositionComponent
...
//获得一个组件的position
export let getPosition = (positionComponentState: positionComponentState) => {
return positionComponentState.position
}
//设置一个组件的position
export let setPosition = (positionComponentState: positionComponentState, position) => {
return {
...positionComponentState,
position: position
}
}
...
//一个gameObject的移动
export let move = (worldState: worldState, positionComponentState: positionComponentState): worldState => {
//获得该组件的position、gameObject
let [x, y, z] = getPosition(positionComponentState)
//通过该组件的gameObject,获得挂载到该gameObject的velocityComponent组件
//获得它的velocity
let gameObject = getExnFromStrictNull(positionComponentState.gameObject)
let velocity = VelocityComponent.getVelocity(GameObject.getVelocityComponentExn(getGameObjectStateExn(worldState, gameObject)))
//根据velocity,更新该组件的position
positionComponentState = setPosition(positionComponentState, [x + velocity, y + velocity, z + velocity])
更新worldState中该组件挂载的gameObject中的该组件的数据
}
VelocityComponent
//获得一个组件的velocity
export let getVelocity = (velocityComponentState: velocityComponentState) => {
return velocityComponentState.velocity
}
PositionComponent维护了position数据,提供了它的get、set函数。VelocityComponent维护了velocity数据,,提供了它的get函数
另外,PositionComponent的move函数实现了移动的行为逻辑
飞行的相关代码
FlyComponent
//获得一个组件的maxVelocity
export let getMaxVelocity = (flyComponentState: flyComponentState) => {
return flyComponentState.maxVelocity
}
//设置一个组件的maxVelocity
export let setMaxVelocity = (flyComponentState: flyComponentState, maxVelocity) => {
return {
...flyComponentState,
maxVelocity: maxVelocity
}
}
//一个gameObject的飞行
export let fly = (worldState: worldState, flyComponentState: flyComponentState): worldState => {
//获得该组件的maxVelocity、gameObject
let maxVelocity = getMaxVelocity(flyComponentState)
let gameObject = getExnFromStrictNull(flyComponentState.gameObject)
//通过该组件的gameObject,获得挂载到该gameObject的positionComponent组件
//获得它的position
let [x, y, z] = PositionComponent.getPosition(GameObject.getPositionComponentExn(getGameObjectStateExn(worldState, gameObject)))
//通过该组件的gameObject,获得挂载到该gameObject的velocityComponent组件
//获得它的velocity
let velocity = VelocityComponent.getVelocity(GameObject.getVelocityComponentExn(getGameObjectStateExn(worldState, gameObject)))
//根据maxVelocity、velocity,更新positionComponent组件的position
velocity = velocity maxVelocity ? (velocity * 2.0) : maxVelocity
let positionComponentState =
服务器托管,北京服务器托管,服务器租用 http://www.fwqtg.net
机房租用,北京机房租用,IDC机房托管, http://www.fwqtg.net
相关推荐: 金恒科技“钢铁行业一体化智慧运营平台”上榜省优秀工业软件名单
近日,在江苏省工业和信息化厅公示的2023年度江苏省工业软件优秀产品和应用解决方案拟推广公示名单中,金恒科技“钢铁行业一体化智慧运营平台”入围。 该平台以金恒FSI2服务器托管网全栈式工业互联网平台作为基础支撑,实现了数据融合、在线业务流转和数字孪生验证,具备…