-
-
Notifications
You must be signed in to change notification settings - Fork 35
/
Loop.lua
516 lines (405 loc) · 13.7 KB
/
Loop.lua
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
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
local RunService = game:GetService("RunService")
local rollingAverage = require(script.Parent.rollingAverage)
local topoRuntime = require(script.Parent.topoRuntime)
local recentErrors = {}
local recentErrorLastTime = 0
local function systemFn(system: System)
if type(system) == "table" then
return system.system
end
return system
end
local function systemName(system: System)
local fn = systemFn(system)
return debug.info(fn, "s") .. "->" .. debug.info(fn, "n")
end
local function systemPriority(system: System)
if type(system) == "table" then
return system.priority or 0
end
return 0
end
--[=[
@class Loop
The Loop class handles scheduling and *looping* (who would have guessed) over all of your game systems.
:::caution Yielding
Yielding is not allowed in systems. Doing so will result in the system thread being closed early, but it will not
affect other systems.
:::
]=]
local Loop = {}
Loop.__index = Loop
--[=[
Creates a new loop. `Loop.new` accepts as arguments the values that will be passed to all of your systems.
So typically, you want to pass the World in here, as well as maybe a table of global game state.
```lua
local world = World.new()
local gameState = {}
local loop = Loop.new(world, gameState)
```
@param ... ...any -- Values that will be passed to all of your systems
@return Loop
]=]
function Loop.new(...)
return setmetatable({
_systems = {},
_skipSystems = {},
_orderedSystemsByEvent = {},
_state = { ... },
_stateLength = select("#", ...),
_systemState = {},
_middlewares = {},
_systemErrors = {},
_systemLogs = {},
profiling = nil,
trackErrors = false,
}, Loop)
end
--[=[
@within Loop
@type System SystemTable | (...any) -> ()
Either a plain function or a table defining the system.
]=]
--[=[
@within Loop
@interface SystemTable
.system (...any) -> () -- The system function
.event? string -- The event the system runs on. A string, a key from the table you pass to `Loop:begin`.
.priority? number -- Priority influences the position in the frame the system is scheduled to run at.
.after? {System} -- A list of systems that this system must run after.
A table defining a system with possible options.
Systems are scheduled in order of `priority`, meaning lower `priority` runs first.
The default priority is `0`.
]=]
type System = (...any) -> () | { system: (...any) -> (), event: string?, priority: number?, after: nil | {} }
--[=[
Schedules a set of systems based on the constraints they define.
Systems may optionally declare:
- The name of the event they run on (e.g., RenderStepped, Stepped, Heartbeat)
- A numerical priority value
- Other systems that they must run *after*
If systems do not specify an event, they will run on the `default` event.
Systems that share an event will run in order of their priority, which means that systems with a lower `priority`
value run first. The default priority is `0`.
Systems that have defined what systems they run `after` can only be scheduled after all systems they depend on have
already been scheduled.
All else being equal, the order in which systems run is stable, meaning if you don't change your code, your systems
will always run in the same order across machines.
:::info
It is possible for your systems to be in an unresolvable state. In which case, `scheduleSystems` will error.
This can happen when your systems have circular or unresolvable dependency chains.
If a system has both a `priority` and defines systems it runs `after`, the system can only be scheduled if all of
the systems it depends on have a lower or equal priority.
Systems can never depend on systems that run on other events, because it is not guaranteed or required that events
will fire every frame or will always fire in the same order.
:::
:::caution
`scheduleSystems` has to perform nontrivial sorting work each time it's called, so you should avoid calling it multiple
times if possible.
:::
@param systems { System } -- Array of systems to schedule.
]=]
function Loop:scheduleSystems(systems: { System })
for _, system in ipairs(systems) do
self._systems[system] = system
self._systemState[system] = {}
if RunService:IsStudio() then
-- In Studio, we start logging immediately.
self._systemLogs[system] = {}
end
end
self:_sortSystems()
end
--[=[
Schedules a single system. This is an expensive function to call multiple times. Instead, try batch scheduling
systems with [Loop:scheduleSystems] if possible.
@param system System
]=]
function Loop:scheduleSystem(system: System)
return self:scheduleSystems({ system })
end
--[=[
Removes a previously-scheduled system from the Loop. Evicting a system also cleans up any storage from hooks.
This is intended to be used for hot reloading. Dynamically loading and unloading systems for gameplay logic
is not recommended.
@param system System
]=]
function Loop:evictSystem(system: System)
if self._systems[system] == nil then
error("Can't evict system because it doesn't exist")
end
self._systems[system] = nil
self._systemErrors[system] = nil
topoRuntime.start({
system = self._systemState[system],
}, function() end)
self._systemState[system] = nil
self._systemLogs[system] = nil
self:_sortSystems()
end
--[=[
Replaces an older version of a system with a newer version of the system. Internal system storage (which is used
by hooks) will be moved to be associated with the new system. This is intended to be used for hot reloading.
@param old System
@param new System
]=]
function Loop:replaceSystem(old: System, new: System)
if not self._systems[old] then
error("Before system does not exist!")
end
self._systems[new] = new
self._systems[old] = nil
self._systemState[new] = self._systemState[old] or {}
self._systemState[old] = nil
if self._skipSystems[old] then
self._skipSystems[old] = nil
self._skipSystems[new] = true
end
for system in self._systems do
if type(system) == "table" and system.after then
local index = table.find(system.after, old)
if index then
system.after[index] = new
end
end
end
self:_sortSystems()
end
local function orderSystemsByDependencies(unscheduledSystems: { System })
table.sort(unscheduledSystems, function(a, b)
local priorityA = systemPriority(a)
local priorityB = systemPriority(b)
if priorityA == priorityB then
return systemName(a) < systemName(b)
end
return priorityA < priorityB
end)
local scheduledSystemsSet = {}
local scheduledSystems = {}
local explore = 1
local visited = 2
while #scheduledSystems < #unscheduledSystems do
local index = 1
while index <= #unscheduledSystems do
local system = unscheduledSystems[index]
if scheduledSystemsSet[system] == visited then
index += 1
continue
end
scheduledSystemsSet[system] = explore
local allScheduled = true
if type(system) == "table" and system.after then
for _, dependency in ipairs(system.after) do
if scheduledSystemsSet[dependency] == explore then
error("Unable to schedule systems due to cycle")
elseif scheduledSystemsSet[dependency] ~= visited then
allScheduled = false
break
end
end
end
if allScheduled then
scheduledSystemsSet[system] = visited
table.insert(scheduledSystems, system)
--Once this system is scheduled we want to start from the beginning to schedule systems that have a dependency on this system
break
end
index += 1
end
end
return scheduledSystems
end
function Loop:_sortSystems()
local systemsByEvent = {}
for system in pairs(self._systems) do
local eventName = "default"
if type(system) == "table" then
if system.event then
eventName = system.event
end
if system.after then
if system.priority then
error(`{systemName(system)} shouldn't have both priority and after defined`)
end
if #system.after == 0 then
error(
`System "{systemName(system)}" "after" table was provided but is empty; did you accidentally use a nil value or make a typo?`
)
end
for _, dependency in system.after do
if not self._systems[dependency] then
error(
`Unable to schedule "{systemName(system)}" because the system "{systemName(dependency)}" is not scheduled.\n\nEither schedule "{systemName(
dependency
)}" before "{systemName(
system
)}" or consider scheduling these systems together with Loop:scheduleSystems`
)
end
end
end
end
if not systemsByEvent[eventName] then
systemsByEvent[eventName] = {}
end
table.insert(systemsByEvent[eventName], system)
end
self._orderedSystemsByEvent = {}
for eventName, systems in pairs(systemsByEvent) do
self._orderedSystemsByEvent[eventName] = orderSystemsByDependencies(systems)
end
end
--[=[
Connects to frame events and starts invoking your systems.
Pass a table of events you want to be able to run systems on, a map of name to event. Systems can use these names
to define what event they run on. By default, systems run on an event named `"default"`. Custom events may be used
if they have a `Connect` function.
```lua
loop:begin({
default = RunService.Heartbeat,
Heartbeat = RunService.Heartbeat,
RenderStepped = RunService.RenderStepped,
Stepped = RunService.Stepped,
})
```
Returns a table similar to the one you passed in, but the values are `RBXScriptConnection` values (or whatever is
returned by `:Connect` if you passed in a synthetic event).
@param events {[string]: RBXScriptSignal} -- A map from event name to event objects.
@return {[string]: RBXScriptConnection} -- A map from your event names to connection objects.
]=]
function Loop:begin(events)
local connections = {}
for eventName, event in pairs(events) do
local lastTime = os.clock()
local generation = false
local function stepSystems()
if not self._orderedSystemsByEvent[eventName] then
-- Skip events that have no systems
return
end
local currentTime = os.clock()
local deltaTime = currentTime - lastTime
lastTime = currentTime
generation = not generation
local dirtyWorlds: { [any]: true } = {}
local profiling = self.profiling
for _, system in ipairs(self._orderedSystemsByEvent[eventName]) do
topoRuntime.start({
system = self._systemState[system],
frame = {
generation = generation,
deltaTime = deltaTime,
dirtyWorlds = dirtyWorlds,
logs = self._systemLogs[system],
},
currentSystem = system,
}, function()
if self._skipSystems[system] then
if profiling then
profiling[system] = nil
end
return
end
local fn = systemFn(system)
debug.profilebegin("system: " .. systemName(system))
local thread = coroutine.create(fn)
local startTime = os.clock()
local success, errorValue = coroutine.resume(thread, unpack(self._state, 1, self._stateLength))
if profiling ~= nil then
local duration = os.clock() - startTime
if profiling[system] == nil then
profiling[system] = {}
end
rollingAverage.addSample(profiling[system], duration)
end
if coroutine.status(thread) ~= "dead" then
coroutine.close(thread)
task.spawn(
error,
(
"Matter: System %s yielded! Its thread has been closed. "
.. "Yielding in systems is not allowed."
):format(systemName(system))
)
end
for world in dirtyWorlds do
world:optimizeQueries()
end
table.clear(dirtyWorlds)
if not success then
if os.clock() - recentErrorLastTime > 10 then
recentErrorLastTime = os.clock()
recentErrors = {}
end
local errorString = systemName(system)
.. ": "
.. tostring(errorValue)
.. "\n"
.. debug.traceback(thread)
if not recentErrors[errorString] then
task.spawn(error, errorString)
warn("Matter: The above error will be suppressed for the next 10 seconds")
recentErrors[errorString] = true
end
if self.trackErrors then
if self._systemErrors[system] == nil then
self._systemErrors[system] = {}
end
local errorStorage = self._systemErrors[system]
local lastError = errorStorage[#errorStorage]
if lastError and lastError.error == errorString then
lastError.when = os.time()
else
table.insert(errorStorage, {
error = errorString,
when = os.time(),
})
if #errorStorage > 100 then
table.remove(errorStorage, 1)
end
end
end
end
debug.profileend()
end)
end
end
for _, middleware in ipairs(self._middlewares) do
stepSystems = middleware(stepSystems, eventName)
if type(stepSystems) ~= "function" then
error(
("Middleware function %s:%s returned %s instead of a function"):format(
debug.info(middleware, "s"),
debug.info(middleware, "l"),
typeof(stepSystems)
)
)
end
end
connections[eventName] = event:Connect(stepSystems)
end
return connections
end
--[=[
Adds a user-defined middleware function that is called during each frame.
This allows you to run code before and after each frame, to perform initialization and cleanup work.
```lua
loop:addMiddleware(function(nextFn)
return function()
Plasma.start(plasmaNode, nextFn)
end
end)
```
You must pass `addMiddleware` a function that itself returns a function that invokes `nextFn` at some point.
The outer function is invoked only once. The inner function is invoked during each frame event.
:::info
Middleware added later "wraps" middleware that was added earlier. The innermost middleware function is the internal
function that actually calls your systems.
:::
@param middleware (nextFn: () -> (), eventName: string) -> () -> ()
]=]
function Loop:addMiddleware(middleware: (nextFn: () -> ()) -> () -> ())
table.insert(self._middlewares, middleware)
end
return Loop