如何使用 ECS 框架
1. 框架介绍
Dora SSR 的 ECS 框架是受 Entitas 启发而来,并做了功能上的略微改动,其基本概念可以借助 Entitas 的原理图进行理解。
Entitas ECS
+-----------------+
| Context |
|-----------------|
| e e | +-----------+
| e e--|----> | Entity |
| e e | |-----------|
| e e e | | Component |
| e e | | | +-----------+
| e e | | Component-|----> | Component |
| e e e | | | |-----------|
| e e e | | Component | | Data |
+-----------------+ +-----------+ +-----------+
|
|
| +-------------+ Groups:
| | e | 实体组是全体游戏实体对象下,通过所包含的组件区分的一组子集
| | e e | 用于快速遍历和查询有特定组件的对象
+---> | +------------+
| e | | |
| e | e | e |
+--------|----+ e |
| e |
| e e |
+------------+
与Entitas不同的是,在 Dora SSR 的 ECS 框架中,我们以实体对象上的一个字段就作为一个系统组件进行管理。这样会导致一些额外的性能损耗,但是能大幅简化逻辑代码的编写。
2. 代码示例
在这个教程中,我们会通过一个代码示例,展示如何使用 Dora SSR 的 ECS(Entity Component System)框架进行游戏逻辑的编写。
- Lua
- Teal
- TypeScript
- YueScript
在编写实际的代码之前,我们先为Lua语言引入这篇教程中要用到的功能模块。
local Group <const> = require("Group")
local Observer <const> = require("Observer")
local Entity <const> = require("Entity")
local Node <const> = require("Node")
local Director <const> = require("Director")
local Touch <const> = require("Touch")
local Sprite <const> = require("Sprite")
local Scale <const> = require("Scale")
local Ease <const> = require("Ease")
local Vec2 <const> = require("Vec2")
local Roll <const> = require("Roll")
首先,我们创建两个实体组 sceneGroup
和 positionGroup
,分别用于访问和管理所有具有 "scene" 和 "position" 组件名称的实体。
local sceneGroup = Group {"scene"}
local positionGroup = Group {"position"}
接下来,我们使用观察器(Observer)来监听实体的变化。当你在使用ECS框架开发游戏时,有时你需要在实体添加特定组件时触发一些操作。这时,你可以使用观察器(Observer)来监听实体的添加事件,并在添加事件发生时执行相应的逻辑。接下来是关于如何使用观察器监听实体添加事件的示例代码段:
Observer("Add", {"scene"})
:watch(function(_entity, scene)
Director.entry:addChild(scene)
scene:onTapEnded(function(touch)
local location = touch.location
positionGroup:each(function(entity)
entity.target = location
end)
end)
end)
首先,使用 Observer
类创建一个观察器对象,并指定观察器监测的事件类型为 "Add",表示监听实体的添加事件。同时,通过传入一个包含字符串 "scene" 的列表作为参数,指定观察器要监测的组件类型包括 "scene"。
Observer("Add", {"scene"})
接下来,在观察器对象的 watch
方法中,定义了一个回调函数 (_entity, scene)->
。这个回调函数在实体添加事件发生时被触发。它接收的第一个参数为触发事件的实体对象,后面的参数与监测的组件列表相对应。
:watch(function(_entity, scene)
在回调函数内部,我们执行了一系列操作。首先,通过 Director.entry
将 scene
添加到游戏场景中。
Director.entry:addChild(scene)
然后,我们给 scene
添加了一个 "onTapEnded" 的事件处理函数,当触摸结束事件发生时,这个处理函数会被调用。
scene:onTapEnded(function(touch)
在事件处理函数内部,我们先获取了触摸点的位置赋值给 location
变量。
local location = touch.location
最后,通过 positionGroup:each()
遍历了 positionGroup
实体组中的所有实体,并为每个实体设置了 target
属性为 触摸点的位置location
。
positionGroup:each(function(entity)
entity.target = location
end)
这样,当有新的实体添加了 "scene" 组件时,该观察器会触发并执行以上操作,并将场景节点添加到游戏中,并完成一系列的初始化操作。
接下来,我们还要再创建一些观察器,分别处理其它 "Add" 和 "Remove" 类型的实体变化,并指定要监测的组件为sprite
。
Observer("Add", {"image"}):watch(function(entity, image)
sceneGroup:each(function(e)
local sprite = Sprite(image)
sprite:addTo(e.scene)
sprite:runAction(Scale(0.5, 0, 0.5, Ease.OutBack))
return true
end)
end)
Observer("Remove", {"sprite"}):watch(function(entity)
local sprite = entity.oldValues.sprite
sprite:removeFromParent()
end)
然后,我们创建一个具有 "position", "direction", "speed", "target" 组件的实体组,并定义了观察器来处理实体组内组件的变化,并在每一帧游戏更新时对一组特定的实体进行遍历,并根据实体的速度. 更新时间等信息来更新实体的旋转角度和位置属性。
Group({"position", "direction", "speed", "target"}):watch(
function(entity, position, _direction, speed, target)
if target == position then
return
end
local dir = target - position
dir = dir:normalize()
local newPos = position + dir * speed
newPos = newPos:clamp(position, target)
entity.position = newPos
if newPos == target then
entity.target = nil
end
local angle = math.deg(math.atan(dir.x, dir.y))
entity.direction = angle
end)
在这段代码中,首先,我们使用 Group
类创建了一个实体组对象,并指定了组中包含的组件类型为 "position". "direction". "speed" 和 "target"。
Group({"position", "direction", "speed", "target"})
然后,我们使用了实体组的 watch
方法来每帧遍历所有组内的实体,执行我们定义的回调函数来处理 实体上的组件。
:watch(
function(entity, position, _direction, speed, target)
在回调函数内部,我们首先进行了一些条件判断。通过 return if target == position
判断实体的目标位置和当前位置是否相等,如果相等则直接返回,不进行后续的更新操作。
if target == position then
return
end
接下来,我们计算了实体的方向向量 dir
,它等于目标位置减去当前位置,并进行了归一化操作 。
local dir = target - position
dir = dir:normalize()
然后,我们根据实体的速度和方向向量,计算实体在当前帧更新时的新位置 newPos
。通过将方向向量 dir
乘以速度 speed
,然后将其加上当前位置 position
,即可得到新的位置。
local newPos = position + dir * speed
接着,我们使用 newPos
和实体的目标位置 target
来进行位置的修正。通过 newPos\clamp position, target
,我们确保新位置在当前位置和目标位置之间,并将修正后的最终位置赋值回实体的 position
组件。
newPos = newPos:clamp(position, target)
entity.position = newPos
接下来,如果新位置等于目标位置的话,我们就清空实体的目标位置 target
。
if newPos == target then
entity.target = nil
end
最后,我们计算了实体的旋转角度 angle
,通过使用 math.atan
函数来计算方向向量 dir
的弧度,并将其转换为角度,更新实体的旋转角度组件 direction
为计算得到的角度值。
local angle = math.deg(math.atan(dir.x, dir.y))
entity.direction = angle
这样,当每一帧游戏更新时,实体组内的就会触发这段代码对每个实体做逻辑处理,并对实体当前的 "position". "direction". "speed" 或 "target" 组件做更新操作。
在完成数据计算和更新后,我们还需要把数据结果更新到渲染图形上。
Observer("AddOrChange", {"position", "direction", "sprite"})
:watch(function(entity, position, direction, sprite)
-- 更新图片的显示位置
sprite.position = position
local lastDirection = entity.oldValues.direction or sprite.angle
-- 当图片的旋转角度变化时,我们就播放一个旋转的动画
if math.abs(direction - lastDirection) > 1 then
sprite:runAction(Roll(0.3, lastDirection, direction))
end
end)
最后,我们创建三个实体,并为它们添加不同的组件。这时游戏系统将开始正式运行。
Entity { scene = Node() }
Entity {
image = "Image/logo.png",
position = Vec2.zero,
direction = 45.0,
speed = 4.0
}
Entity {
image = "Image/logo.png",
position = Vec2(-100, 200),
direction = 90.0,
speed = 10.0
}
在编写实际的代码之前,我们先为Teal语言引入这篇教程中要用到的功能模块。
local Group <const> = require("Group")
local Observer <const> = require("Observer")
local Entity <const> = require("Entity")
local Node <const> = require("Node")
local Director <const> = require("Director")
local Touch <const> = require("Touch")
local Sprite <const> = require("Sprite")
local Scale <const> = require("Scale")
local Ease <const> = require("Ease")
local Vec2 <const> = require("Vec2")
local Roll <const> = require("Roll")
首先,我们创建两个实体组 sceneGroup
和 positionGroup
,分别用于访问和管理所有具有 "scene" 和 "position" 组件名称的实体。
local sceneGroup = Group {"scene"}
local positionGroup = Group {"position"}
接下来,我们使用观察器(Observer)来监听实体的变化。当你在使用ECS框架开发游戏时,有时你需要在实体添加特定组件时触发一些操作。这时,你可以使用观察器(Observer)来监听实体的添加事件,并在添加事件发生时执 行相应的逻辑。接下来是关于如何使用观察器监听实体添加事件的示例代码段:
Observer("Add", {"scene"})
:watch(function(_entity: Entity.Type, scene: Node.Type)
Director.entry:addChild(scene)
scene:onTapEnded(function(touch: Touch.Type)
local location = touch.location
positionGroup:each(function(entity: Entity.Type): boolean
entity.target = location
return false
end)
end)
end)
首先,使用 Observer
类创建一个观察器对象,并指定观察器监测的事件类型为 "Add",表示监听实体的添加事件。同时,通过传入一个包含字符串 "scene" 的列表作为参数,指定观察器要监测的组件类型包括 "scene"。
Observer("Add", {"scene"})
接下来,在观察器对象的 watch
方法中,定义了一个回调函数 (_entity, scene)->
。这个回调函数在实体添加事件发生时被触发。它接收的第一个参数为触发事件的实体对象,后面的参数与监测的组件列表相对应。
:watch(function(_entity: Entity.Type, scene: Node.Type)
在回调函数内部,我们执行了一系列操作。首先,通过 Director.entry
将 scene
添加到游戏场景中 。
Director.entry:addChild(scene)
然后,我们给 scene
添加了一个 "onTapEnded" 的事件处理函数,当触摸结束事件发生时,这个处理函数会被调用。
scene:onTapEnded(function(touch: Touch.Type)
在事件处理函数内部,我们先获取了触摸点的位置赋值给 location
变量。
local location = touch.location
最后,通过 positionGroup:each()
遍历了 positionGroup
实体组中的所有实体,并为每个实体设置了 target
属性为 触摸点的位置location
。
positionGroup:each(function(entity: Entity.Type): boolean
entity.target = location
return false
end)
这样,当有新的实体添加了 "scene" 组件时,该观察器会触发并执行以上操作,并将场景节点添加到游戏中,并完成一系列的初始化操作。
接下来,我们还要再创建一些观察器,分别处理其它 "Add" 和 "Remove" 类型的实体变化,并指定要监测的组件为 image
和 sprite
。
Observer("Add", {"image"}):watch(function(entity: Entity.Type, image: string)
sceneGroup:each(function(e: Entity.Type): boolean
local scene = e.scene as Node.Type
local sprite = Sprite(image)
if sprite then
sprite:runAction(Scale(0.5, 0, 0.5, Ease.OutBack))
sprite:addTo(scene)
entity.sprite = sprite
end
return true
end)
end)
Observer("Remove", {"sprite"}):watch(function(self: Entity.Type)
local sprite = self.oldValues.sprite as Node.Type
sprite:removeFromParent()
end)
然后,我们创建一个具有 "position", "direction", "speed", "target" 组件的实体组,并定义了观察器来处理实体组内组件的变化,并在每一帧游戏更新时对一组特定的实体进行遍历,并根据实体的速度. 更新时间等信息来更新实体的旋转角度和位置属性。
Group({"position", "direction", "speed", "target"}):watch(
function(entity: Entity.Type, position: Vec2.Type, _direction: number, speed: number, target: Vec2.Type)
if target == position then
return
end
local dir = target - position
dir = dir:normalize()
local newPos = position + dir * speed
newPos = newPos:clamp(position, target)
entity.position = newPos
if newPos == target then
entity.target = nil
end
local angle = math.deg(math.atan(dir.x, dir.y))
entity.direction = angle
end)
在这段代码中,首先,我们使用 Group
类创建了一个实体组对象,并指定了组中包含的组件类型为 "position". "direction". "speed" 和 "target"。
Group({"position", "direction", "speed", "target"})
然后,我们使用了实体组的 watch
方法来每帧遍历所有组内的实体,执行我们定义的回调函数来处理实体上的组件。
:watch(
function(entity: Entity.Type, position: Vec2.Type, _direction: number, speed: number, target: Vec2.Type)
在回调函数内部,我们首先进行了一些条件判断。通过 return if target == position
判断实体的目标位置和当前位置是否相等,如果相等则直接返回,不进行后续的更新操作。
if target == position then
return
end
接下来,我们计算了实体的方向向量 dir
,它等于目标位置减去当前位置,并进行了归一化操作。
local dir = target - position
dir = dir:normalize()
然后,我们根据实体的速度和方向向量,计算实体在当前帧更新时的新位置 newPos
。通过将方向向量 dir
乘以速度 speed
,然后将其加上当前位置 position
,即可得到新的位置。
local newPos = position + dir * speed
接着,我们使用 newPos
和实体的目标位置 target
来进行位置的修正。通过 newPos\clamp position, target
,我们确保新位置在当前位置和目标位置之间,并将修正后的最终位置赋值回实体的 position
组件。
newPos = newPos:clamp(position, target)
entity.position = newPos
接下来,如果新位置等于目标位置的话,我们就清空实体的 目标位置 target
。
if newPos == target then
entity.target = nil
end
最后,我们计算了实体的旋转角度 angle
,通过使用 math.atan
函数来计算方向向量 dir
的弧度,并将其转换为角度,更新实体的旋转角度组件 direction
为计算得到的角度值。
local angle = math.deg(math.atan(dir.x, dir.y))
entity.direction = angle
这样,当每一帧游戏更新时,实体组内的就会触发这段代码对每个实体做逻辑处理,并对实体当前的 "position". "direction". "speed" 或 "target" 组件做更新操作。
在完成数据计算和更新后,我们还需要把数据结果更新到渲染图形上。
Observer("AddOrChange", {"position", "direction", "sprite"})
:watch(function(entity: Entity.Type, position: Vec2.Type, direction: number, sprite: Sprite.Type)
-- 更新图片的显示位置
sprite.position = position
local lastDirection = entity.oldValues.direction as number or sprite.angle
-- 当图片的旋转角度变化时,我们就播放一个旋转的动画
if math.abs(direction - lastDirection) > 1 then
sprite:runAction(Roll(0.3, lastDirection, direction))
end
end)
最后,我们创建三个实体,并为它们添加不同的组件。这时游戏系统将开始正式运行。
Entity { scene = Node() }
Entity {
image = "Image/logo.png",
position = Vec2.zero,
direction = 45.0,
speed = 4.0
}
Entity {
image = "Image/logo.png",
position = Vec2(-100, 200),
direction = 90.0,
speed = 10.0
}
在编写实际的代码之前,我们先为 TypeScript 语言引入这篇教程中要用到的功能模块。
import {
Group, Observer,
Entity, Node,
Director, Touch,
Sprite, Scale,
Ease, Vec2, Roll,
EntityEvent
} from "Dora";
首先,我们创建两个实体组 sceneGroup
和 positionGroup
,分别用于访问和管理所有具有 "scene" 和 "position" 组件名称的实体。
const sceneGroup = Group(["scene"]);
const positionGroup = Group(["position"]);
接下来,我们使用观察器(Observer)来监听实体的变化。当你在使用ECS框架开发游戏时,有时你需要在实体添加特定组件时触发一些操作。这时,你可以使用观察器(Observer)来监听实体的添加事件,并在添加事件发生时执行相应的逻辑。接下来是关于如何使用观察器监听实体添加事 件的示例代码段:
Observer(EntityEvent.Add, ["scene"])
.watch((_entity, scene: Node.Type) => {
Director.entry.addChild(scene);
scene.onTapEnded(touch => {
const {location} = touch;
positionGroup.each(entity => {
entity.target = location;
return false;
})
});
return false;
});
首先,使用 Observer
类创建一个观察器对象,并指定观察器监测的事件类型为 "Add",表示监听实体的添加事件。同时,通过传入一个包含字符串 "scene" 的列表作为参数,指定观察器要监测的组件类型包括 "scene"。
Observer(EntityEvent.Add, ["scene"])
接下来,在观察器对象的 watch
方法中,定义了一个回调函数 (_entity, scene) =>
。这个回调函数在实体添加事件发生时被触发。它接收的第一个参数为触发事件的实体对象,后面的参数与监测的组件列表相对应。
.watch((_entity, scene: Node.Type) => {
在回调函数内部,我们执行了一系列操作。首先,通过 Director.entry
将 scene
添加到游戏场景中。
Director.entry.addChild(scene);
然后,我们给 scene
添加了一个 "onTapEnded" 的事件处理函数,当触摸结束事件发生时,这个处理函数会被调用。
scene.onTapEnded(touch => {
在事件处理函数内部,我们先获取了触摸点的位置赋值给 location
变量。
const {location} = touch;
最后,通过 positionGroup.each()
遍历了 positionGroup
实体组中的所有实体,并为每个实体设置了 target
属性为 触摸点的位置 location
。
positionGroup.each(entity => {
entity.target = location;
return false;
});
这样,当有新的实体添加了 "scene" 组件时,该观察器会触发并执行以上操作,并将场景节点添加到游戏中,并完成一系列的初始化操作。
接下来,我们还要再创建一些观察器,分别处理其它 "Add" 和 "Remove" 类型的实体变化,并指定要监测的组件为 image
和 sprite
。
Observer(EntityEvent.Add, ["image"]).watch((entity, image: string) => {
sceneGroup.each(e => {
const scene = e.scene as Node.Type;
const sprite = Sprite(image);
if (sprite) {
sprite.runAction(Scale(0.5, 0, 0.5, Ease.OutBack));
sprite.addTo(scene);
entity.sprite = sprite;
}
return true;
});
return false;
});
Observer(EntityEvent.Remove, ["sprite"]).watch(self => {
const sprite = self.oldValues.sprite as Node.Type;
sprite.removeFromParent();
});
然后,我们创建一个具有 "position", "direction", "speed", "target" 组件的实体组,并定义了观察器来处理实体组内组件的变化,并在每一帧游戏更新时对一组特定的实体进行遍历,并根据实体的速度. 更新时间等信息来更新实体的旋转角度和位置属性。
Group(["position", "direction", "speed", "target"]).watch(
(entity, position: Vec2.Type, _direction: number, speed: number, target: Vec2.Type) => {
if (target.equals(position)) {
return;
}
const dir = target.sub(position).normalize();
const newPos = position.add(dir.mul(speed));
entity.position = newPos.clamp(position, target);
if (newPos.equals(target)) {
entity.target = nil;
}
const angle = math.deg(math.atan(dir.x, dir.y));
entity.direction = angle;
});
在这段代码中,首先,我们使用 Group
类创建了一个实体组对象,并指定了组中包含的组件类型为 "position". "direction". "speed" 和 "target"。
Group(["position", "direction", "speed", "target"])
然后,我们使用了实体组的 watch
方法来每帧遍历所有组内的实体,执行我们定义的回调函数来处理实体上的组件。
.watch(
(entity, position: Vec2.Type, _direction: number, speed: number, target: Vec2.Type) => {