Skip to main content

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.

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

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:

  1. Defining component types for entities and creating entity objects based on your requirements.
  2. Creating entity groups and adding entities to the corresponding groups for grouping and management purposes.
  3. Using observers to listen for entity change events, such as component addition, modification, or removal.
  4. In the callback functions of entity group observers, performing logic operations based on entity changes every frame.
  5. 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.