Using the ECS Framework
1. Framework Introduction
The ECS (Entity Component System) framework in Dora SSR is inspired by Entitas with some minor modifications in functionality. The basic concepts can be understood using Entitas' principle diagram.
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 | Subsets of entities in the context
| | e e | for blazing fast querying
+---> | +------------+
| e | | |
| e | e | e |
+--------|----+ e |
| e |
| e e |
+------------+
Unlike Entitas, in the Dora SSR ECS framework, we manage each field on the entity object as a system component. This introduces some additional performance overhead but greatly simplifies the logic code.
2. Code Example
In this tutorial, we will demonstrate how to use the ECS (Entity Component System) framework in Dora SSR to develop game logic through a code example.
- Yuescript
- Teal
- Lua
Before writing the actual code, we require the modules we will use in this tutorial for Yuescript language.
_ENV = Dora!
First, we create two entity groups, sceneGroup
and positionGroup
, which are used to access and manage entities with the "scene" and "position" component names, respectively.
sceneGroup = Group ["scene",]
positionGroup = Group ["position",]
Next, we use Observers to listen for changes in entities. Sometimes, you need to perform certain actions when specific components are added to entities. In such cases, you can use Observers to listen for entity addition events and execute corresponding logic when these events occur. Here's an example code snippet on how to use an Observer to listen for entity addition events:
with Observer "Add", ["scene",]
\watch (_entity, scene)->
Director.entry\addChild with scene
.touchEnabled = true
\slot "TapEnded", (touch)->
:location = touch
positionGroup\each (entity)->
entity.target = location
First, we create an Observer object using the Observer
class. We specify the event type as "Add" to listen for entity addition events. Additionally, we pass a list containing the string "scene" as the parameter to specify the component types to monitor.
with Observer "Add", ["scene",]
Inside the watch
method of the Observer object, we define a callback function (_entity, scene)->
. This callback function is triggered when an entity addition event occurs. The first parameter represents the entity object that triggered the event, followed by the parameters corresponding to the monitored components.
\watch (_entity, scene)->
Within the callback function, we perform a series of operations. First,we use Director.entry
to add the scene
to the game scene.
Director.entry\addChild with scene
Next, we set the touchEnabled
property of the scene
to true
to enable touch or tap events.
.touchEnabled = true
Then, we add an event handling function for the "TapEnded" event to the scene
. This function will be called when the touch event ends.
\slot "TapEnded", (touch)->
Inside the event handling function, we assign the location of the touch to the location
variable using :location = touch
.
:location = touch
Finally, we use positionGroup\each (entity)->
to iterate over all entities in the positionGroup
and set the target
property of each entity to the location
.
positionGroup\each (entity)->
entity.target = location
In this way, when a new entity with the "scene" component is added, this observer will be triggered, and the defined operations will be executed. The scene node will be added to the game scene, touch events will be enabled, and the target property of entities in the positionGroup will be set to the touch location.
Next, we create additional observers to handle other types of entity changes, such as "Add" and "Remove" events, for the "image" and "sprite" components.
with Observer "Add", ["image",]
\watch (image)=> sceneGroup\each (e)->
with @sprite = Sprite image
\addTo e.scene
\runAction Scale 0.5, 0, 0.5, Ease.OutBack
true
with Observer "Remove", ["sprite",]
\watch => @oldValues.sprite\removeFromParent!
Then, we create an entity group with the components "position", "direction", "speed", and "target". We define an observer to handle changes in these components and update the rotation angle and position properties of the entities based on their speed and the elapsed time.
with Group ["position", "direction", "speed", "target"]
\watch (entity, position, direction, speed, target)=>
return if target == position
dir = target - position
dir = dir\normalize!
newPos = position + dir * speed
newPos = newPos\clamp position, target
entity.position = newPos
entity.target = nil if newPos == target
angle = math.deg math.atan dir.x, dir.y
entity.direction = angle
In this code snippet, we first create a group object using the Group
class and specify the component types as "position", "direction", "speed", and "target".
with Group ["position", "direction", "speed", "target"]
Then, we use the watch
method of the entity group to iterate over all entities in the group every frame and execute the defined callback function to handle the components on each entity.
\watch (entity, position, direction, speed, target)=>
Inside the callback function, we start with some conditional checks. We use return if target == position
to check if the target position is the same as the current position. If they are the same, we return and skip the update operations.
return if target == position
Next, we calculate the direction vector dir
of the entity by subtracting the position from the target position and normalize it.
dir = target - position
dir = dir\normalize!
After that, we calculate the new position newPos
of the entity for the current frame update based on its current position, direction vector, and speed. We multiply the normalized direction vector dir
by the speed speed
and add it to the current position position
.
newPos = position + dir * speed
Then, we use newPos\clamp position, target
to clamp the new position between the current position and the target position. This ensures that the entity does not overshoot its target position. And assigning the corrected position to entity.
newPos = newPos\clamp position, target
entity.position = newPos
Additionally, if the new position is equal to the target position, we clear the target
component of the entity.
entity.target = nil if newPos == target
Finally, we calculate the angle angle
of the entity based on the direction vector dir
using the math.atan
function and convert it from radians to degrees. We update the direction
component of the entity with the calculated angle.
angle = math.deg math.atan dir.x, dir.y
entity.direction = angle
By using this code, the defined callback function will be executed for each entity in the group every frame, updating the rotation angle and position properties based on their speed and target position.
After completing the data calculation and update, we also need to update the data results to the rendered graphics.
with Observer "AddOrChange", ["position", "direction", "sprite"]
\watch (position, direction, sprite)=>
-- Update the display position of the image
sprite.position = position
lastDirection = @oldValues.direction or sprite.angle
-- When the rotation angle of the image changes, we play a rotation animation
if math.abs(direction - lastDirection) > 1
sprite\runAction Roll 0.3, lastDirection, direction
Lastly, we create three entities and add different components to them. This is where the game system begins to run.
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
Before writing the actual code, we require the modules we will use in this tutorial for Teal language.
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")
First, we create two entity groups, sceneGroup
and positionGroup
, which are used to access and manage entities with the "scene" and "position" component names, respectively.
local sceneGroup = Group {"scene"}
local positionGroup = Group {"position"}
Next, we use Observers to listen for changes in entities. Sometimes, you need to perform certain actions when specific components are added to entities. In such cases, you can use Observers to listen for entity addition events and execute corresponding logic when these events occur. Here's an example code snippet on how to use an Observer to listen for entity addition events:
Observer("Add", {"scene"})
:watch(function(_entity: Entity.Type, scene: Node.Type)
Director.entry:addChild(scene)
scene.touchEnabled = true
scene:slot("TapEnded", function(touch: Touch.Type)
local location = touch.location
positionGroup:each(function(entity: Entity.Type): boolean
entity.target = location
return false
end)
end)
end)
First, we create an Observer object using the Observer
class. We specify the event type as "Add" to listen for entity addition events. Additionally, we pass a list containing the string "scene" as the parameter to specify the component types to monitor.
Observer("Add", {"scene"})
Inside the watch
method of the Observer object, we define a callback function (_entity, scene)->
. This callback function is triggered when an entity addition event occurs. The first parameter represents the entity object that triggered the event, followed by the parameters corresponding to the monitored components.
:watch(function(_entity: Entity.Type, scene: Node.Type)
Within the callback function, we perform a series of operations. First,we use Director.entry
to add the scene
to the game scene.
Director.entry:addChild(scene)
Next, we set the touchEnabled
property of the scene
to true
to enable touch or tap events.
scene.touchEnabled = true
Then, we add an event handling function for the "TapEnded" event to the scene
. This function will be called when the touch event ends.
scene:slot("TapEnded", function(touch: Touch.Type)
Inside the event handling function, we assign the location of the touch to the location
variable.
local location = touch.location
Finally, we use positionGroup:each()
to iterate over all entities in the positionGroup
and set the target
property of each entity to the location
.
positionGroup:each(function(entity: Entity.Type): boolean
entity.target = location
return false
end)
In this way, when a new entity with the "scene" component is added, this observer will be triggered, and the defined operations will be executed. The scene node will be added to the game scene, touch events will be enabled, and the target property of entities in the positionGroup will be set to the touch location.
Next, we create additional observers to handle other types of entity changes, such as "Add" and "Remove" events, for the "image" and "sprite" components.
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)
sprite:runAction(Scale(0.5, 0, 0.5, Ease.OutBack))
sprite:addTo(scene)
entity.sprite = sprite
return true
end)
end)
Observer("Remove", {"sprite"}):watch(function(self: Entity.Type)
local sprite = self.oldValues.sprite as Node.Type
sprite:removeFromParent()
end)
Then, we create an entity group with the components "position", "direction", "speed", and "target". We define an observer to handle changes in these components and update the rotation angle and position properties of the entities based on their speed and the elapsed time.
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)
In this code snippet, we first create a group object using the Group
class and specify the component types as "position", "direction", "speed", and "target".
Group({"position", "direction", "speed", "target"})
Then, we use the watch
method of the entity group to iterate over all entities in the group every frame and execute the defined callback function to handle the components on each entity.
:watch(
function(entity: Entity.Type, position: Vec2.Type, _direction: number, speed: number, target: Vec2.Type)
Inside the callback function, we start with some conditional checks. We use return if target == position
to check if the target position is the same as the current position. If they are the same, we return and skip the update operations.
if target == position then
return
end
Next, we calculate the direction vector dir
of the entity by subtracting the position from the target position and normalize it.
local dir = target - position
dir = dir:normalize()
After that, we calculate the new position newPos
of the entity for the current frame update based on its current position, direction vector, and speed. We multiply the normalized direction vector dir
by the speed speed
and add it to the current position position
.
local newPos = position + dir * speed
Then, we use newPos\clamp position, target
to clamp the new position between the current position and the target position. This ensures that the entity does not overshoot its target position. And assigning the corrected position to entity.
newPos = newPos:clamp(position, target)
entity.position = newPos
Additionally, if the new position is equal to the target position, we clear the target
component of the entity.
if newPos == target then
entity.target = nil
end
Finally, we calculate the angle angle
of the entity based on the direction vector dir
using the math.atan
function and convert it from radians to degrees. We update the direction
component of the entity with the calculated angle.
local angle = math.deg(math.atan(dir.x, dir.y))
entity.direction = angle
By using this code, the defined callback function will be executed for each entity in the group every frame, updating the rotation angle and position properties based on their speed and target position.
After completing the data calculation and update, we also need to update the data results to the rendered graphics.
Observer("AddOrChange", {"position", "direction", "sprite"})
:watch(function(entity: Entity.Type, position: Vec2.Type, direction: number, sprite: Sprite.Type)
-- Update the display position of the image
sprite.position = position
local lastDirection = entity.oldValues.direction as number or sprite.angle
-- When the rotation angle of the image changes, we play a rotation animation
if math.abs(direction - lastDirection) > 1 then
sprite:runAction(Roll(0.3, lastDirection, direction))
end
end)
Lastly, we create three entities and add different components to them. This is where the game system begins to run.
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
}
Before writing the actual code, we require the modules we will use in this tutorial for Lua language.
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")
First, we create two entity groups, sceneGroup
and positionGroup
, which are used to access and manage entities with the "scene" and "position" component names, respectively.
local sceneGroup = Group {"scene"}
local positionGroup = Group {"position"}
Next, we use Observers to listen for changes in entities. Sometimes, you need to perform certain actions when specific components are added to entities. In such cases, you can use Observers to listen for entity addition events and execute corresponding logic when these events occur. Here's an example code snippet on how to use an Observer to listen for entity addition events:
Observer("Add", {"scene"})
:watch(function(_entity, scene)
Director.entry:addChild(scene)
scene.touchEnabled = true
scene:slot("TapEnded", function(touch)
local location = touch.location
positionGroup:each(function(entity)
entity.target = location
end)
end)
end)
First, we create an Observer object using the Observer
class. We specify the event type as "Add" to listen for entity addition events. Additionally, we pass a list containing the string "scene" as the parameter to specify the component types to monitor.
Observer("Add", {"scene"})
Inside the watch
method of the Observer object, we define a callback function (_entity, scene)->
. This callback function is triggered when an entity addition event occurs. The first parameter represents the entity object that triggered the event, followed by the parameters corresponding to the monitored components.
:watch(function(_entity, scene)
Within the callback function, we perform a series of operations. First,we use Director.entry
to add the scene
to the game scene.
Director.entry:addChild(scene)
Next, we set the touchEnabled
property of the scene
to true
to enable touch or tap events.
scene.touchEnabled = true
Then, we add an event handling function for the "TapEnded" event to the scene
. This function will be called when the touch event ends.
scene:slot("TapEnded", function(touch)
Inside the event handling function, we assign the location of the touch to the location
variable.
local location = touch.location
Finally, we use positionGroup:each()
to iterate over all entities in the positionGroup
and set the target
property of each entity to the location
.
positionGroup:each(function(entity)
entity.target = location
end)
In this way, when a new entity with the "scene" component is added, this observer will be triggered, and the defined operations will be executed. The scene node will be added to the game scene, touch events will be enabled, and the target property of entities in the positionGroup will be set to the touch location.
Next, we create additional observers to handle other types of entity changes, such as "Add" and "Remove" events, for the "image" and "sprite" components.
Observer("Add", {"image"}):watch(function(entity, image)
sceneGroup:each(function(e)
local sprite = Sprite(image)
sprite:runAction(Scale(0.5, 0, 0.5, Ease.OutBack))
sprite:addTo(e.scene)
entity.sprite = sprite
return true
end)
end)
Observer("Remove", {"sprite"}):watch(function(entity)
entity.oldValues.sprite:removeFromParent()
end)
Then, we create an entity group with the components "position", "direction", "speed", and "target". We define an observer to handle changes in these components and update the rotation angle and position properties of the entities based on their speed and the elapsed time.
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)
In this code snippet, we first create a group object using the Group
class and specify the component types as "position", "direction", "speed", and "target".
Group({"position", "direction", "speed", "target"})
Then, we use the watch
method of the entity group to iterate over all entities in the group every frame and execute the defined callback function to handle the components on each entity.
:watch(
function(entity, position, _direction, speed, target)
Inside the callback function, we start with some conditional checks. We use return if target == position
to check if the target position is the same as the current position. If they are the same, we return and skip the update operations.
if target == position then
return
end
Next, we calculate the direction vector dir
of the entity by subtracting the position from the target position and normalize it.
local dir = target - position
dir = dir:normalize()
After that, we calculate the new position newPos
of the entity for the current frame update based on its current position, direction vector, and speed. We multiply the normalized direction vector dir
by the speed speed
and add it to the current position position
.
local newPos = position + dir * speed
Then, we use newPos\clamp position, target
to clamp the new position between the current position and the target position. This ensures that the entity does not overshoot its target position. And assigning the corrected position to entity.
newPos = newPos:clamp(position, target)
entity.position = newPos
Additionally, if the new position is equal to the target position, we clear the target
component of the entity.
if newPos == target then
entity.target = nil
end
Finally, we calculate the angle angle
of the entity based on the direction vector dir
using the math.atan
function and convert it from radians to degrees. We update the direction
component of the entity with the calculated angle.
local angle = math.deg(math.atan(dir.x, dir.y))
entity.direction = angle
By using this code, the defined callback function will be executed for each entity in the group every frame, updating the rotation angle and position properties based on their speed and target position.
After completing the data calculation and update, we also need to update the data results to the rendered graphics.
Observer("AddOrChange", {"position", "direction", "sprite"})
:watch(function(entity, position, direction, sprite)
-- Update the display position of the image
sprite.position = position
local lastDirection = entity.oldValues.direction or sprite.angle
-- When the rotation angle of the image changes, we play a rotation animation
if math.abs(direction - lastDirection) > 1 then
sprite:runAction(Roll(0.3, lastDirection, direction))
end
end)
Lastly, we create three entities and add different components to them. This is where the game system begins to run.
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
}
This example code demonstrates the basic workflow of using the ECS framework in game development. You can use the provided interfaces of entities, groups, and observers to construct your game logic based on your specific requirements. In the code, you can trigger specific actions based on changes in entity components, perform operations such as adding, removing, or modifying entities and components, and utilize entity groups for grouping and managing entities. By using observers, you can listen for entity change events such as component addition, modification, or removal. In the observer's callback function, you can execute specific logic based on entity changes, such as updating scene nodes, handling user input, or printing debug information.
By organizing the relationships between entities and components effectively and leveraging the listening and processing capabilities of observers, you can build complex game logic and behaviors. In real-world development, you can design and define your own component types based on your game requirements and utilize the provided interfaces of the ECS framework to implement various functionalities and behaviors in your game.
To summarize, the basic workflow of using the Dora SSR ECS framework in game development includes:
- Defining component types for entities and creating entity objects based on your requirements.
- Creating entity groups and adding entities to the corresponding groups for grouping and management purposes.
- Using observers to listen for entity change events, such as component addition, modification, or removal.
- In the callback functions of entity group observers, performing logic operations based on entity changes every frame.
- Designing and implementing other components, systems, and functionalities based on your game requirements.
By following the above workflow using the Dora SSR ECS framework, you can better organize and manage your game logic, improve code maintainability and extensibility, and implement complex game features and behaviors.