-
Notifications
You must be signed in to change notification settings - Fork 8
/
system.ex
295 lines (237 loc) · 10 KB
/
system.ex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
defmodule Ecspanse.System do
@moduledoc """
The system implements the logic and behaviors of the application
by manipulating the state of the components.
The systems are defined by invoking `use Ecspanse.System` in their module definition.
The system modules must implement the
`c:Ecspanse.System.WithoutEventSubscriptions.run/1` or
`c:Ecspanse.System.WithEventSubscriptions.run/2` callbacks,
depending if the system subscribes to certain events or not.
The return value of the `run` function is ignored.
The Ecspanse systems run either synchronously or asynchronously,
as scheduled in the `c:Ecspanse.setup/1` callback.
Systems are the sole mechanism through which the state of components can be altered.
Running commands outside of a system is not allowed.
Resources can be created, updated, and deleted only by systems that are executed synchronously.
There are some special systems that are created automatically by the framework:
- `Ecspanse.System.CreateStartupResources` - startup system that creates the default framework resources, states, and custom resources inserted at startup.
- `Ecspanse.System.Debug` - used by the `debug/0` function.
- `Ecspanse.System.Timer` - tracks and updates all components using the `Ecspanse.Template.Component.Timer` template.
- `Ecspanse.System.TrackFPS` - tracks and updates the `Ecspanse.Resource.FPS` resource.
> #### Info {: .info}
> The `Ecspanse.Query` and `Ecspanse.Command` functions are imported by default
> for all modules that `use Ecspanse.System`
## Options
- `:lock_components` - a list of component modules
that will be locked for the duration of the system execution.
- `:event_subscriptions` - a list of event modules that the system subscribes to.
## Component locking
Component locking is required only for async systems to avoid race conditions.
For async systems, any components that are to be modified, created, or deleted,
must be locked in the `lock_components` option. Otherwise, the operation will fail.
Wherever it makes sense, it is recommended to lock also components that are queried but not modified,
as they could be modified by other systems.
Not all async systems run concurrently. The systems are grouped in batches,
based on the components they lock.
## Event subscriptions
The event subscriptions enables a system to execute solely in response to certain specified events.
The `c:Ecspanse.System.WithEventSubscriptions.run/2` callback is triggered
for every occurrence of an event type to which the system has subscribed.
These callbacks execute concurrently to enhance performance.
However, they are grouped based on their batch keys (see `Ecspanse.event/2` options)
as a safeguard against potential race conditions.
## Examples
```elixir
defmodule Demo.Systems.Move do
@moduledoc "An async system locking components, that subscribes to an event"
use Ecspanse.System,
lock_components: [Demo.Components.Position],
event_subscriptions: [Demo.Events.Move]
def run(%Demo.Events.Move{entity_id: entity_id, direction: direction}, frame) do
# move logic
end
end
defmodule Demo.Systems.SpawnEnemy do
@moduledoc "A sync system that does not need to lock components, and it is not subscribed to any events"
use Ecspanse.System
def run(frame) do
# spawn logic
end
end
```
"""
# The System process stores several keys to be used by the Commands and Queries.
# - ecs_process_type (:system)
# - system_execution :sync | :async
# - locked_components
# - system_module
@type system_queue ::
:startup_systems
| :frame_start_systems
| :batch_systems
| :frame_end_systems
| :shutdown_systems
@type t :: %__MODULE__{
module: module(),
queue: system_queue(),
execution: :sync | :async,
run_after: list(system_module :: module()),
run_conditions: list({module(), atom()})
}
@enforce_keys [:module, :queue, :execution]
defstruct module: nil,
queue: nil,
execution: nil,
run_after: [],
run_conditions: []
@doc """
Utility function. Gives the current process `Ecspanse.System` abilities to execute commands.
This is a powerful tool for testing and debugging,
as the promoted process can change the components and resources state
without having to be scheduled like a regular system.
See `Ecspanse.TestServer` for more details.
> #### This function is intended for use only in testing and development environments. {: .warning}
"""
@spec debug() :: :ok
def debug do
Process.put(:ecs_process_type, :system)
Process.put(:system_execution, :sync)
Process.put(:system_module, Ecspanse.System.Debug)
Process.put(:locked_components, Ecspanse.System.Debug.__locked_components__())
:ok
end
@doc """
Allows running async code inside a system.
Because commands can run only from inside a system,
running commands in a Task, for example, is not possible.
The `execute_async/3` is a wrapper around `Elixir.Task.async_stream/3`
and is built exactly for this purpose. It allows running commands in parallel.
The result of the processing is ignored. So the function is suitable for cases
when the result is not important. For example, updating components for a list of entities.
> #### Info {: .info}
> This function is already imported for all modules that `use Ecspanse.System`
## Options
- `:concurrent` - the number of concurrent tasks to run.
Defaults to the number of schedulers online.
See `Elixir.Task.async_stream/5` options for more details.
> #### use with care {: .error}
> While the locked components ensure that no other system is modifying
> the same components at the same time, the `execute_async/3` does not offer
> any such guarantees inside the same system.
>
> For example, the same component can be modified concurrently, leading to
> race conditions and inconsistent state.
## Examples
```elixir
Ecspanse.System.execute_async(
enemy_entities,
fn enemy_entity ->
# update the enemy components
end,
concurrent: length(enemy_entities) + 1
)
```
"""
@spec execute_async(Enumerable.t(), (term() -> term()), keyword()) :: :ok
def execute_async(enumerable, fun, opts \\ [])
def execute_async(enumerable, fun, opts)
when is_function(fun, 1) and is_list(opts) do
concurrent = Keyword.get(opts, :concurrent, System.schedulers_online())
system_process_dict = Process.get()
enumerable
|> Task.async_stream(
fn e ->
Enum.each(system_process_dict, fn {k, v} -> Process.put(k, v) end)
fun.(e)
end,
ordered: false,
max_concurrency: concurrent
)
|> Stream.run()
end
defmodule WithoutEventSubscriptions do
@moduledoc """
Systems that run every frame and do not depend on any event.
"""
@doc """
Runs every frame for the current system.
The return value is ignored.
It recives the current `t:Ecspanse.Frame.t/0` struct as the only argument.
"""
@callback run(Ecspanse.Frame.t()) :: any()
end
defmodule WithEventSubscriptions do
@moduledoc """
Systems that run only if specific events are triggered.
"""
@doc """
Runs only if the system is subscribed to the triggering event.
The return value is ignored.
It recives the triggering event struct as the first argument
and the current `t:Ecspanse.Frame.t/0` struct as the second argument.
"""
@callback run(event :: struct(), Ecspanse.Frame.t()) :: any()
end
defmacro __using__(opts) do
quote bind_quoted: [opts: opts], location: :keep do
import Ecspanse.System, only: [execute_async: 3, execute_async: 2]
import Ecspanse.Query
import Ecspanse.Command
locked_components = Keyword.get(opts, :lock_components, [])
event_modules = Keyword.get(opts, :event_subscriptions, [])
case event_modules do
[] ->
@behaviour Ecspanse.System.WithoutEventSubscriptions
event_modules when is_list(event_modules) ->
Ecspanse.Util.validate_events(event_modules)
@behaviour Ecspanse.System.WithEventSubscriptions
event_modules ->
raise ArgumentError,
"#{Kernel.inspect(__MODULE__)} :event_subscriptions option must be a list of event modules. Got: #{Kernel.inspect(event_modules)}"
end
Enum.each(locked_components, fn
component ->
Ecspanse.Util.validate_ecs_type(
component,
:component,
ArgumentError,
"All modules provided to the #{Kernel.inspect(__MODULE__)} System :lock_components option must be Components. #{Kernel.inspect(component)} is not a Component"
)
end)
# IMPORTANT
# even components that are not directly updated must be locked.
# for example a component may be created or deleted
# or we want to make sure some component state does not change durin the system execution
Module.register_attribute(__MODULE__, :ecs_type, accumulate: false)
Module.register_attribute(__MODULE__, :locked_components, accumulate: false)
Module.put_attribute(__MODULE__, :ecs_type, :system)
Module.put_attribute(__MODULE__, :locked_components, locked_components)
### Internal functions ###
# not exposed in the docs
case event_modules do
[] ->
@doc false
def schedule_run(frame) do
run(frame)
end
event_modules ->
@doc false
def schedule_run(frame) do
Enum.each(frame.event_batches, fn events ->
events
|> Enum.filter(&(&1.__struct__ in unquote(event_modules)))
|> Ecspanse.System.execute_async(&run(&1, frame), concurrent: length(events) + 1)
end)
end
end
@doc false
def __ecs_type__ do
@ecs_type
end
@doc false
def __locked_components__ do
@locked_components
end
end
end
end