@rbxts/planck 0.2.4 → 0.3.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,906 +1,1057 @@
1
- --!nonstrict
2
- local DependencyGraph = require("./DependencyGraph")
3
- local Pipeline = require("./Pipeline")
4
- local Phase = require("./Phase")
5
-
6
- local utils = require("./utils")
7
- local hooks = require("./hooks")
8
- local conditions = require("./conditions")
9
-
10
- local getSystem = utils.getSystem
11
- local getSystemName = utils.getSystemName
12
-
13
- local isPhase = utils.isPhase
14
- local isPipeline = utils.isPipeline
15
-
16
- local isValidEvent = utils.isValidEvent
17
- local getEventIdentifier = utils.getEventIdentifier
18
-
19
- -- Recent errors in Planks itself
20
- local recentLogs = {}
21
- local timeLastLogged = os.clock()
22
-
23
- --- @type SystemFn ((U...) -> any)
24
- --- @within Scheduler
25
-
26
- --- @interface SystemTable
27
- --- @within Scheduler
28
- --- .system SystemFn<U...>
29
- --- .phase Phase?
30
- --- .[any] any
31
-
32
- --- @type System SystemFn<U...> | SystemTable<U...>
33
- --- @within Scheduler
34
-
35
- --- @class Scheduler
36
- ---
37
- --- An Object which handles scheduling Systems to run within different
38
- --- Phases. The order of which Systems run will be defined either
39
- --- implicitly by when it was added, or explicitly by tagging the system
40
- --- with a Phase.
41
- local Scheduler = {}
42
- Scheduler.__index = Scheduler
43
-
44
- Scheduler.Hooks = hooks.Hooks
45
-
46
- --- @method addPlugin
47
- --- @within Scheduler
48
- --- @param plugin PlanckPlugin
49
- ---
50
- --- Initializes a plugin with the scheduler, see the [Plugin Docs](/docs/plugins) for more information.
51
- function Scheduler:addPlugin(plugin)
52
- plugin:build(self)
53
- table.insert(self._plugins, plugin)
54
- return self
55
- end
56
-
57
- function Scheduler:_addHook(hook, fn)
58
- assert(self._hooks[hook], `Unknown Hook: {hook}`)
59
- table.insert(self._hooks[hook], fn)
60
- end
61
-
62
- --- @method getDeltaTime
63
- --- @within Scheduler
64
- --- @return number
65
- ---
66
- --- Returns the time since the system was ran last.
67
- --- This must be used within a registered system.
68
- function Scheduler:getDeltaTime()
69
- local systemFn = debug.info(2, "f")
70
- if not systemFn or not self._systemInfo[systemFn] then
71
- error(
72
- "Scheduler:getDeltaTime() must be used within a registered system"
73
- )
74
- end
75
-
76
- return self._systemInfo[systemFn].deltaTime or 0
77
- end
78
-
79
- -- Inspiration from https://github.com/matter-ecs/matter <3
80
- function Scheduler:_handleLogs(systemInfo)
81
- if not systemInfo.timeLastLogged then
82
- systemInfo.timeLastLogged = os.clock()
83
- end
84
-
85
- if not systemInfo.recentLogs then
86
- systemInfo.recentLogs = {}
87
- end
88
-
89
- if os.clock() - systemInfo.timeLastLogged > 10 then
90
- systemInfo.timeLastLogged = os.clock()
91
- systemInfo.recentLogs = {}
92
- end
93
-
94
- local name = debug.info(systemInfo.system, "n")
95
-
96
- for _, logMessage in systemInfo.logs do
97
- if not systemInfo.recentLogs[logMessage] then
98
- task.spawn(error, logMessage, 0)
99
- warn(
100
- `Planck: Error occurred in system{string.len(name) > 0 and ` '{name}'` or ""}, this error will be ignored for 10 seconds`
101
- )
102
- systemInfo.recentLogs[logMessage] = true
103
- end
104
- end
105
-
106
- table.clear(systemInfo.logs)
107
- end
108
-
109
- function Scheduler:runSystem(system)
110
- if self:_canRun(system) == false then
111
- return
112
- end
113
-
114
- local systemInfo = self._systemInfo[system]
115
- local now = os.clock()
116
-
117
- systemInfo.deltaTime = now - (systemInfo.lastTime or now)
118
- systemInfo.lastTime = now
119
-
120
- if not self._thread then
121
- self._thread = coroutine.create(function()
122
- while true do
123
- local fn = coroutine.yield()
124
- self._yielded = true
125
- fn()
126
- self._yielded = false
127
- end
128
- end)
129
-
130
- coroutine.resume(self._thread)
131
- end
132
-
133
- local didYield = false
134
-
135
- local function systemCall()
136
- local function noYield()
137
- local success, err
138
- coroutine.resume(self._thread, function()
139
- success, err = xpcall(system, function(e)
140
- return debug.traceback(e)
141
- end, table.unpack(self._vargs))
142
- end)
143
-
144
- if success == false then
145
- didYield = true
146
- table.insert(systemInfo.logs, err)
147
- hooks.systemError(self, systemInfo, err)
148
- return
149
- end
150
-
151
- if self._yielded then
152
- didYield = true
153
- local source, line = debug.info(self._thread, 1, "sl")
154
- local errMessage = `{source}:{line}: System yielded`
155
- table.insert(
156
- systemInfo.logs,
157
- debug.traceback(self._thread, errMessage, 2)
158
- )
159
- hooks.systemError(
160
- self,
161
- systemInfo,
162
- debug.traceback(self._thread, errMessage, 2)
163
- )
164
- end
165
- end
166
-
167
- hooks.systemCall(self, "SystemCall", systemInfo, noYield)
168
- end
169
-
170
- local function inner()
171
- hooks.systemCall(self, "InnerSystemCall", systemInfo, systemCall)
172
- end
173
-
174
- local function outer()
175
- hooks.systemCall(self, "OuterSystemCall", systemInfo, inner)
176
- end
177
-
178
- if os.clock() - timeLastLogged > 10 then
179
- timeLastLogged = os.clock()
180
- recentLogs = {}
181
- end
182
-
183
- local success, err: string? = pcall(outer)
184
- if not success and not recentLogs[err] then
185
- task.spawn(error, err, 0)
186
- warn(
187
- `Planck: Error occurred while running hooks, this error will be ignored for 10 seconds`
188
- )
189
- hooks.systemError(
190
- self,
191
- systemInfo,
192
- `Error occurred while running hooks: {err}`
193
- )
194
- recentLogs[err] = true
195
- end
196
-
197
- if didYield then
198
- coroutine.close(self._thread)
199
-
200
- self._thread = coroutine.create(function()
201
- while true do
202
- local fn = coroutine.yield()
203
- self._yielded = true
204
- fn()
205
- self._yielded = false
206
- end
207
- end)
208
-
209
- coroutine.resume(self._thread)
210
- end
211
-
212
- self:_handleLogs(systemInfo)
213
- end
214
-
215
- function Scheduler:runPhase(phase)
216
- if self:_canRun(phase) == false then
217
- return
218
- end
219
-
220
- hooks.phaseBegan(self, phase)
221
-
222
- if not self._phaseToSystems[phase] then
223
- self._phaseToSystems[phase] = {}
224
- end
225
-
226
- for _, system in self._phaseToSystems[phase] do
227
- self:runSystem(system)
228
- end
229
- end
230
-
231
- function Scheduler:runPipeline(pipeline)
232
- if self:_canRun(pipeline) == false then
233
- return
234
- end
235
-
236
- local orderedList = pipeline.dependencyGraph:getOrderedList()
237
- assert(
238
- orderedList,
239
- `Pipeline {pipeline} contains a circular dependency, check it's Phases`
240
- )
241
-
242
- for _, phase in orderedList do
243
- self:runPhase(phase)
244
- end
245
- end
246
-
247
- function Scheduler:_canRun(dependent)
248
- local conditions = self._runIfConditions[dependent]
249
-
250
- if conditions then
251
- for _, runIf in conditions do
252
- if runIf(table.unpack(self._vargs)) == false then
253
- return false
254
- end
255
- end
256
- end
257
-
258
- return true
259
- end
260
-
261
- --- @method run
262
- --- @within Scheduler
263
- --- @param phase Phase
264
- --- @return Scheduler
265
- ---
266
- --- Runs all Systems tagged with the Phase in order.
267
-
268
- --- @method run
269
- --- @within Scheduler
270
- --- @param pipeline Pipeline
271
- --- @return Scheduler
272
- ---
273
- --- Runs all Systems tagged with any Phase within the Pipeline in order.
274
-
275
- --- @method run
276
- --- @within Scheduler
277
- --- @param system System
278
- --- @return Scheduler
279
- ---
280
- --- Runs the System, passing in the arguments of the Scheduler, `U...`.
281
-
282
- function Scheduler:run(dependent)
283
- if not dependent then
284
- error("No dependent specified in Scheduler:run(_)")
285
- end
286
-
287
- self:runPipeline(Pipeline.Startup)
288
-
289
- if getSystem(dependent) then
290
- self:runSystem(dependent)
291
- elseif isPhase(dependent) then
292
- self:runPhase(dependent)
293
- elseif isPipeline(dependent) then
294
- self:runPipeline(dependent)
295
- else
296
- error("Unknown dependent passed into Scheduler:run(unknown)")
297
- end
298
-
299
- return self
300
- end
301
-
302
- --- @method runAll
303
- --- @within Scheduler
304
- --- @return Scheduler
305
- ---
306
- --- Runs all Systems within order.
307
- ---
308
- --- :::note
309
- --- When you add a Pipeline or Phase with an event, it will be grouped
310
- --- with other Pipelines/Phases on that event. Otherwise, it will be
311
- --- added to the default group.
312
- ---
313
- --- When not running systems on Events, such as with the `runAll` method,
314
- --- the Default group will be ran first, and then each Event Group in the
315
- --- order created.
316
- ---
317
- --- Pipelines/Phases in these groups are still ordered by their dependencies
318
- --- and by the order of insertion.
319
- --- :::
320
- function Scheduler:runAll()
321
- local orderedDefaults = self._defaultDependencyGraph:getOrderedList()
322
- assert(
323
- orderedDefaults,
324
- "Default Group contains a circular dependency, check your Pipelines/Phases"
325
- )
326
-
327
- for _, dependency in orderedDefaults do
328
- self:run(dependency)
329
- end
330
-
331
- for identifier, dependencyGraph in self._eventDependencyGraphs do
332
- local orderedList = dependencyGraph:getOrderedList()
333
- assert(
334
- orderedDefaults,
335
- `Event Group '{identifier}' contains a circular dependency, check your Pipelines/Phases`
336
- )
337
- for _, dependency in orderedList do
338
- self:run(dependency)
339
- end
340
- end
341
-
342
- return self
343
- end
344
-
345
- --- @method insert
346
- --- @within Scheduler
347
- --- @param phase Phase
348
- --- @return Scheduler
349
- ---
350
- --- Initializes the Phase within the Scheduler, ordering it implicitly by
351
- --- setting it as a dependent of the previous Phase/Pipeline.
352
-
353
- --- @method insert
354
- --- @within Scheduler
355
- --- @param pipeline Pipeline
356
- --- @return Scheduler
357
- ---
358
- --- Initializes the Pipeline and it's Phases within the Scheduler,
359
- --- ordering the Pipeline implicitly by setting it as a dependent
360
- --- of the previous Phase/Pipeline.
361
-
362
- --- @method insert
363
- --- @within Scheduler
364
- --- @param phase Phase
365
- --- @param instance Instance | EventLike
366
- --- @param event string | EventLike
367
- --- @return Scheduler
368
- ---
369
- --- Initializes the Phase within the Scheduler, ordering it implicitly
370
- --- by setting it as a dependent of the previous Phase/Pipeline, and
371
- --- scheduling it to be ran on the specified event.
372
- ---
373
- --- ```lua
374
- --- local myScheduler = Scheduler.new()
375
- --- :insert(myPhase, RunService, "Heartbeat")
376
- --- ```
377
-
378
- --- @method insert
379
- --- @within Scheduler
380
- --- @param pipeline Pipeline
381
- --- @param instance Instance | EventLike
382
- --- @param event string | EventLike
383
- --- @return Scheduler
384
- ---
385
- --- Initializes the Pipeline and it's Phases within the Scheduler,
386
- --- ordering the Pipeline implicitly by setting it as a dependent of
387
- --- the previous Phase/Pipeline, and scheduling it to be ran on the
388
- --- specified event.
389
- ---
390
- --- ```lua
391
- --- local myScheduler = Scheduler.new()
392
- --- :insert(myPipeline, RunService, "Heartbeat")
393
- --- ```
394
-
395
- function Scheduler:insert(dependency, instance, event)
396
- assert(
397
- isPhase(dependency) or isPipeline(dependency),
398
- "Unknown dependency passed to Scheduler:insert(unknown, _, _)"
399
- )
400
-
401
- if not instance then
402
- local dependencyGraph = self._defaultDependencyGraph
403
- dependencyGraph:insertBefore(dependency, self._defaultPhase)
404
- else
405
- assert(
406
- isValidEvent(instance, event),
407
- "Unknown instance/event passed to Scheduler:insert(_, instance, event)"
408
- )
409
-
410
- local dependencyGraph = self:_getEventDependencyGraph(instance, event)
411
- dependencyGraph:insert(dependency)
412
- end
413
-
414
- if isPhase(dependency) then
415
- self._phaseToSystems[dependency] = {}
416
- hooks.phaseAdd(self, dependency)
417
- end
418
-
419
- return self
420
- end
421
-
422
- --- @method insertAfter
423
- --- @within Scheduler
424
- --- @param phase Phase
425
- --- @param after Phase | Pipeline
426
- --- @return Scheduler
427
- ---
428
- --- Initializes the Phase within the Scheduler, ordering it
429
- --- explicitly by setting the after Phase/Pipeline as a dependent.
430
-
431
- --- @method insertAfter
432
- --- @within Scheduler
433
- --- @param pipeline Pipeline
434
- --- @param after Phase | Pipeline
435
- --- @return Scheduler
436
- ---
437
- --- Initializes the Pipeline and it's Phases within the Scheduler,
438
- --- ordering the Pipeline explicitly by setting the after Phase/Pipeline
439
- --- as a dependent.
440
-
441
- function Scheduler:insertAfter(dependent, after)
442
- assert(
443
- isPhase(after) or isPipeline(after),
444
- "Unknown dependency passed in Scheduler:insertAfter(_, unknown)"
445
- )
446
- assert(
447
- isPhase(dependent) or isPipeline(dependent),
448
- "Unknown dependent passed in Scheduler:insertAfter(unknown, _)"
449
- )
450
-
451
- local dependencyGraph = self:_getGraphOfDependency(after)
452
- dependencyGraph:insertAfter(dependent, after)
453
-
454
- if isPhase(dependent) then
455
- self._phaseToSystems[dependent] = {}
456
- hooks.phaseAdd(self, dependent)
457
- end
458
-
459
- return self
460
- end
461
-
462
- --- @method insertBefore
463
- --- @within Scheduler
464
- --- @param phase Phase
465
- --- @param before Phase | Pipeline
466
- --- @return Scheduler
467
- ---
468
- --- Initializes the Phase within the Scheduler, ordering it
469
- --- explicitly by setting the before Phase/Pipeline as a dependency.
470
-
471
- --- @method insertBefore
472
- --- @within Scheduler
473
- --- @param pipeline Pipeline
474
- --- @param before Phase | Pipeline
475
- --- @return Scheduler
476
- ---
477
- --- Initializes the Pipeline and it's Phases within the Scheduler,
478
- --- ordering the Pipeline explicitly by setting the before Phase/Pipeline
479
- --- as a dependency.
480
-
481
- function Scheduler:insertBefore(dependent, before)
482
- assert(
483
- isPhase(before) or isPipeline(before),
484
- "Unknown dependency passed in Scheduler:insertBefore(_, unknown)"
485
- )
486
- assert(
487
- isPhase(dependent) or isPipeline(dependent),
488
- "Unknown dependent passed in Scheduler:insertBefore(unknown, _)"
489
- )
490
-
491
- local dependencyGraph = self:_getGraphOfDependency(before)
492
- dependencyGraph:insertBefore(dependent, before)
493
-
494
- if isPhase(dependent) then
495
- self._phaseToSystems[dependent] = {}
496
- hooks.phaseAdd(self, dependent)
497
- end
498
-
499
- return self
500
- end
501
-
502
- --- @method addSystems
503
- --- @within Scheduler
504
- --- @param systems System
505
- --- @param phase Phase?
506
- ---
507
- --- Adds the System to the Scheduler, scheduling it to be ran
508
- --- implicitly within the provided Phase or on the default Main phase.
509
- function Scheduler:addSystem(system, phase)
510
- local systemFn = getSystem(system)
511
-
512
- if not systemFn then
513
- error("Unknown system passed to Scheduler:addSystem(unknown, phase?)")
514
- end
515
-
516
- local name = getSystemName(systemFn)
517
- if type(system) == "table" and system.name then
518
- name = system.name
519
- end
520
-
521
- local systemInfo = {
522
- system = systemFn,
523
- phase = phase,
524
- name = name,
525
- logs = {},
526
- }
527
-
528
- if not phase then
529
- if type(system) == "table" and system.phase then
530
- systemInfo.phase = system.phase
531
- else
532
- systemInfo.phase = self._defaultPhase
533
- end
534
- end
535
-
536
- self._systemInfo[systemFn] = systemInfo
537
-
538
- if not self._phaseToSystems[systemInfo.phase] then
539
- self._phaseToSystems[systemInfo.phase] = {}
540
- end
541
-
542
- table.insert(self._phaseToSystems[systemInfo.phase], systemFn)
543
-
544
- hooks.systemAdd(self, systemInfo)
545
-
546
- if type(system) == "table" and system.runConditions then
547
- for _, condition in system.runConditions do
548
- self:addRunCondition(systemFn, condition)
549
- end
550
- end
551
-
552
- return self
553
- end
554
-
555
- --- @method addSystems
556
- --- @within Scheduler
557
- --- @param systems { System }
558
- --- @param phase Phase?
559
- ---
560
- --- Adds the Systems to the Scheduler, scheduling them to be ran
561
- --- implicitly within the provided Phase or on the default Main phase.
562
- function Scheduler:addSystems(systems, phase)
563
- if type(systems) ~= "table" then
564
- error("Unknown systems passed to Scheduler:addSystems(unknown, phase?)")
565
- end
566
-
567
- local foundSystem = false
568
- local n = 0
569
-
570
- for _, system in systems do
571
- n += 1
572
- if getSystem(system) then
573
- foundSystem = true
574
- self:addSystem(system, phase)
575
- end
576
- end
577
-
578
- if n == 0 then
579
- error("Empty table passed to Scheduler:addSystems({ }, phase?)")
580
- end
581
-
582
- if not foundSystem then
583
- error(
584
- "Unknown table passed to Scheduler:addSystems({ unknown }, phase?)"
585
- )
586
- end
587
-
588
- return self
589
- end
590
-
591
- --- @method editSystem
592
- --- @within Scheduler
593
- --- @param system System
594
- --- @param newPhase Phase
595
- ---
596
- --- Changes the Phase that this system is scheduled on.
597
- function Scheduler:editSystem(system, newPhase)
598
- local systemFn = getSystem(system)
599
- local systemInfo = self._systemInfo[systemFn]
600
- assert(
601
- systemInfo,
602
- "Attempt to remove a non-exist system in Scheduler:removeSystem(_)"
603
- )
604
-
605
- assert(
606
- newPhase and self._phaseToSystems[newPhase] ~= nil or true,
607
- "Phase never initialized before using Scheduler:editSystem(_, Phase)"
608
- )
609
-
610
- local systems = self._phaseToSystems[systemInfo.phase]
611
-
612
- local index = table.find(systems, systemFn)
613
- assert(index, "Unable to find system within phase")
614
-
615
- table.remove(systems, index)
616
-
617
- if not self._phaseToSystems[newPhase] then
618
- self._phaseToSystems[newPhase] = {}
619
- end
620
- table.insert(self._phaseToSystems[newPhase], systemFn)
621
-
622
- systemInfo.phase = newPhase
623
- return self
624
- end
625
-
626
- function Scheduler:_removeCondition(dependent, condition)
627
- self._runIfConditions[dependent] = nil
628
-
629
- for _, _conditions in self._runIfConditions do
630
- if table.find(_conditions, condition) then
631
- return
632
- end
633
- end
634
-
635
- conditions.cleanupCondition(condition)
636
- end
637
-
638
- --- @method removeSystem
639
- --- @within Scheduler
640
- --- @param system System
641
- ---
642
- --- Removes the System from the Scheduler.
643
- function Scheduler:removeSystem(system)
644
- local systemFn = getSystem(system)
645
- local systemInfo = self._systemInfo[systemFn]
646
- assert(
647
- systemInfo,
648
- "Attempt to remove a non-exist system in Scheduler:removeSystem(_)"
649
- )
650
-
651
- local systems = self._phaseToSystems[systemInfo.phase]
652
-
653
- local index = table.find(systems, systemFn)
654
- assert(index, "Unable to find system within phase")
655
-
656
- table.remove(systems, index)
657
- self._systemInfo[systemFn] = nil
658
-
659
- if self._runIfConditions[system] then
660
- for _, condition in self._runIfConditions[system] do
661
- self:_removeCondition(system, condition)
662
- end
663
-
664
- self._runIfConditions[system] = nil
665
- end
666
-
667
- hooks.systemRemove(self, systemInfo)
668
-
669
- return self
670
- end
671
-
672
- --- @method replaceSystem
673
- --- @within Scheduler
674
- --- @param old System
675
- --- @param new System
676
- ---
677
- --- Replaces the System with a new System.
678
- function Scheduler:replaceSystem(old, new)
679
- local oldSystemFn = getSystem(old)
680
- local oldSystemInfo = self._systemInfo[oldSystemFn]
681
- assert(
682
- oldSystemInfo,
683
- "Attempt to replace a non-existent system in Scheduler:replaceSystem(unknown, _)"
684
- )
685
-
686
- local newSystemFn = getSystem(new)
687
- assert(
688
- newSystemFn,
689
- "Attempt to pass non-system in Scheduler:replaceSystem(_, unknown)"
690
- )
691
-
692
- local systems = self._phaseToSystems[oldSystemInfo.phase]
693
-
694
- local index = table.find(systems, oldSystemFn)
695
- assert(index, "Unable to find system within phase")
696
-
697
- table.remove(systems, index)
698
- table.insert(systems, index, newSystemFn)
699
-
700
- local copy = table.clone(oldSystemInfo)
701
-
702
- oldSystemInfo.system = newSystemFn
703
- oldSystemInfo.name = getSystemName(newSystemFn)
704
-
705
- hooks.systemReplace(self, copy, oldSystemInfo)
706
-
707
- self._systemInfo[newSystemFn] = self._systemInfo[oldSystemFn]
708
- self._systemInfo[oldSystemFn] = nil
709
-
710
- return self
711
- end
712
-
713
- --- @method addRunCondition
714
- --- @within Scheduler
715
- --- @param system System
716
- --- @param fn (U...) -> boolean
717
- ---
718
- --- Adds a Run Condition which the Scheduler will check before
719
- --- this System is ran.
720
-
721
- --- @method addRunCondition
722
- --- @within Scheduler
723
- --- @param phase Phase
724
- --- @param fn (U...) -> boolean
725
- ---
726
- --- Adds a Run Condition which the Scheduler will check before
727
- --- any Systems within this Phase are ran.
728
-
729
- --- @method addRunCondition
730
- --- @within Scheduler
731
- --- @param pipeline Pipeline
732
- --- @param fn (U...) -> boolean
733
- ---
734
- --- Adds a Run Condition which the Scheduler will check before
735
- --- any Systems within any Phases apart of this Pipeline are ran.\
736
-
737
- function Scheduler:addRunCondition(dependent, fn)
738
- local system = getSystem(dependent)
739
- if system then
740
- dependent = system
741
- end
742
-
743
- assert(
744
- system or isPhase(dependent) or isPipeline(dependent),
745
- "Attempt to pass unknown dependent into Scheduler:addRunCondition(unknown, _)"
746
- )
747
-
748
- if not self._runIfConditions[dependent] then
749
- self._runIfConditions[dependent] = {}
750
- end
751
-
752
- table.insert(self._runIfConditions[dependent], fn)
753
-
754
- return self
755
- end
756
-
757
- function Scheduler:_addBuiltins()
758
- self._defaultPhase = Phase.new("Default")
759
- self._defaultDependencyGraph = DependencyGraph.new()
760
-
761
- self._defaultDependencyGraph:insert(Pipeline.Startup)
762
- self._defaultDependencyGraph:insert(self._defaultPhase)
763
-
764
- self:addRunCondition(Pipeline.Startup, conditions.runOnce())
765
- for _, phase in Pipeline.Startup.dependencyGraph.nodes do
766
- self:addRunCondition(phase, conditions.runOnce())
767
- end
768
- end
769
-
770
- function Scheduler:_scheduleEvent(instance, event)
771
- local connect = utils.getConnectFunction(instance, event)
772
- assert(
773
- connect,
774
- "Couldn't connect to event as no valid connect methods were found! Ensure the passed event has a 'Connect' or an 'on' method!"
775
- )
776
-
777
- local identifier = getEventIdentifier(instance, event)
778
-
779
- local dependencyGraph = DependencyGraph.new()
780
-
781
- local callback = function()
782
- local orderedList = dependencyGraph:getOrderedList()
783
-
784
- if orderedList == nil then
785
- local err =
786
- `Event Group '{identifier}' contains a circular dependency, check your Pipelines/Phases`
787
- if not recentLogs[err] then
788
- task.spawn(error, err, 0)
789
- warn(
790
- `Planck: Error occurred while running event, this error will be ignored for 10 seconds`
791
- )
792
- recentLogs[err] = true
793
- end
794
- end
795
-
796
- for _, dependency in orderedList do
797
- self:run(dependency)
798
- end
799
- end
800
-
801
- self._connectedEvents[identifier] = connect(callback)
802
- self._eventDependencyGraphs[identifier] = dependencyGraph
803
- end
804
-
805
- function Scheduler:_getEventDependencyGraph(instance, event)
806
- local identifier = getEventIdentifier(instance, event)
807
-
808
- if not self._connectedEvents[identifier] then
809
- self:_scheduleEvent(instance, event)
810
- end
811
-
812
- return self._eventDependencyGraphs[identifier]
813
- end
814
-
815
- function Scheduler:_getGraphOfDependency(dependency)
816
- if table.find(self._defaultDependencyGraph.nodes, dependency) then
817
- return self._defaultDependencyGraph
818
- end
819
-
820
- for _, dependencyGraph in self._eventDependencyGraphs do
821
- if table.find(dependencyGraph.nodes, dependency) then
822
- return dependencyGraph
823
- end
824
- end
825
-
826
- error("Dependency does not belong to a DependencyGraph")
827
- end
828
-
829
- --- @within Scheduler
830
- ---
831
- --- Disconnects all events, closes all threads, and performs
832
- --- other cleanup work.
833
- ---
834
- --- :::danger
835
- --- Only use this if you intend to not use the associated
836
- --- Scheduler anymore. It will not work as intended.
837
- ---
838
- --- You should dereference the scheduler object so that
839
- --- it may be garbage collected.
840
- --- :::
841
- ---
842
- --- :::warning
843
- --- If you're creating a "throwaway" scheduler, you should
844
- --- not add plugins like Jabby or the Matter Debugger to it.
845
- --- These plugins are unable to properly be cleaned up, use
846
- --- them with caution.
847
- --- :::
848
- function Scheduler:cleanup()
849
- for _, connection in self._connectedEvents do
850
- utils.disconnectEvent(connection)
851
- end
852
-
853
- for _, plugin in self._plugins do
854
- if plugin.cleanup then
855
- plugin:cleanup()
856
- end
857
- end
858
-
859
- if self._thread then
860
- coroutine.close(self._thread)
861
- end
862
-
863
- for _, _conditions in self._runIfConditions do
864
- for _, condition in _conditions do
865
- conditions.cleanupCondition(condition)
866
- end
867
- end
868
- end
869
-
870
- --- @function new
871
- --- @within Scheduler
872
- --- @param args U...
873
- ---
874
- --- Creates a new Scheduler, the args passed will be passed to
875
- --- any System anytime it is ran by the Scheduler.
876
- function Scheduler.new(...)
877
- local self = {}
878
-
879
- self._hooks = {}
880
-
881
- self._vargs = { ... }
882
-
883
- self._eventDependencyGraphs = {}
884
- self._connectedEvents = {}
885
-
886
- self._phaseToSystems = {}
887
- self._systemInfo = {}
888
-
889
- self._runIfConditions = {}
890
-
891
- self._plugins = {}
892
-
893
- setmetatable(self, Scheduler)
894
-
895
- for _, hookName in hooks.Hooks do
896
- if not self._hooks[hookName] then
897
- self._hooks[hookName] = {}
898
- end
899
- end
900
-
901
- self:_addBuiltins()
902
-
903
- return self
904
- end
905
-
906
- return Scheduler
1
+ --!nonstrict
2
+ local DependencyGraph = require(script.Parent.DependencyGraph)
3
+ local Pipeline = require(script.Parent.Pipeline)
4
+ local Phase = require(script.Parent.Phase)
5
+
6
+ local utils = require(script.Parent.utils)
7
+ local hooks = require(script.Parent.hooks)
8
+ local conditions = require(script.Parent.conditions)
9
+
10
+ local getSystem = utils.getSystem
11
+ local getSystemName = utils.getSystemName
12
+
13
+ local isPhase = utils.isPhase
14
+ local isPipeline = utils.isPipeline
15
+
16
+ local isValidEvent = utils.isValidEvent
17
+ local getEventIdentifier = utils.getEventIdentifier
18
+
19
+ -- Recent errors in Planks itself
20
+ local recentLogs = {}
21
+ local timeLastLogged = os.clock()
22
+
23
+ --- @type SystemFn ((U...) -> ())
24
+ --- @within Scheduler
25
+ --- Standard system function that runs every time it's scheduled
26
+
27
+ --- @type InitializerSystemFn ((U...) -> (SystemFn<U...> | (SystemFn<U...>, CleanupFn)))
28
+ --- @within Scheduler
29
+ --- Initializer system that returns the runtime function, optionally with cleanup
30
+
31
+ --- @type CleanupFn (() -> ())
32
+ --- @within Scheduler
33
+ --- Cleanup function called when system is removed
34
+
35
+ --- @interface SystemTable
36
+ --- @within Scheduler
37
+ --- .system SystemFn<U...> | InitializerSystemFn<U...>
38
+ --- .phase Phase?
39
+ --- .name string?
40
+ --- .runConditions {RunCondition}?
41
+ --- .[any] any
42
+
43
+ --- @type System SystemFn<U...> | SystemTable<U...>
44
+ --- @within Scheduler
45
+
46
+ --- @class Scheduler
47
+ ---
48
+ --- An Object which handles scheduling Systems to run within different
49
+ --- Phases. The order of which Systems run will be defined either
50
+ --- implicitly by when it was added, or explicitly by tagging the system
51
+ --- with a Phase.
52
+ local Scheduler = {}
53
+ Scheduler.__index = Scheduler
54
+
55
+ Scheduler.Hooks = hooks.Hooks
56
+
57
+ --- @method addPlugin
58
+ --- @within Scheduler
59
+ --- @param plugin PlanckPlugin
60
+ ---
61
+ --- Initializes a plugin with the scheduler, see the [Plugin Docs](/docs/plugins) for more information.
62
+ function Scheduler:addPlugin(plugin)
63
+ plugin:build(self)
64
+ table.insert(self._plugins, plugin)
65
+ return self
66
+ end
67
+
68
+ function Scheduler:_addHook(hook, fn)
69
+ assert(self._hooks[hook], `Unknown Hook: {hook}`)
70
+ table.insert(self._hooks[hook], fn)
71
+ end
72
+
73
+ --- @method getDeltaTime
74
+ --- @within Scheduler
75
+ --- @return number
76
+ ---
77
+ --- Returns the time since the system was ran last.
78
+ --- This must be used within a registered system.
79
+ function Scheduler:getDeltaTime()
80
+ local systemFn = debug.info(2, "f")
81
+ if not systemFn or not self._systemInfo[systemFn] then
82
+ error(
83
+ "Scheduler:getDeltaTime() must be used within a registered system"
84
+ )
85
+ end
86
+
87
+ return self._systemInfo[systemFn].deltaTime or 0
88
+ end
89
+
90
+ -- Inspiration from https://github.com/matter-ecs/matter <3
91
+ function Scheduler:_handleLogs(systemInfo)
92
+ if not systemInfo.timeLastLogged then
93
+ systemInfo.timeLastLogged = os.clock()
94
+ end
95
+
96
+ if not systemInfo.recentLogs then
97
+ systemInfo.recentLogs = {}
98
+ end
99
+
100
+ if os.clock() - systemInfo.timeLastLogged > 10 then
101
+ systemInfo.timeLastLogged = os.clock()
102
+ systemInfo.recentLogs = {}
103
+ end
104
+
105
+ local name = systemInfo.name
106
+
107
+ for _, logMessage in systemInfo.logs do
108
+ if not systemInfo.recentLogs[logMessage] then
109
+ task.spawn(error, logMessage, 0)
110
+ warn(
111
+ `Planck: Error occurred in system{string.len(name) > 0 and ` '{name}'` or ""}, this error will be ignored for 10 seconds`
112
+ )
113
+ systemInfo.recentLogs[logMessage] = true
114
+ end
115
+ end
116
+
117
+ table.clear(systemInfo.logs)
118
+ end
119
+
120
+ function Scheduler:runSystem(system, justInitialized)
121
+ local systemInfo = self._systemInfo[system]
122
+ local now = os.clock()
123
+
124
+ if justInitialized ~= true then
125
+ if self:_canRun(system) == false then
126
+ return
127
+ end
128
+
129
+ systemInfo.deltaTime = now - (systemInfo.lastTime or now)
130
+ end
131
+
132
+ systemInfo.lastTime = now
133
+
134
+ if not self._thread then
135
+ self._thread = coroutine.create(function()
136
+ while true do
137
+ local fn = coroutine.yield()
138
+ self._yielded = true
139
+ fn()
140
+ self._yielded = false
141
+ end
142
+ end)
143
+
144
+ coroutine.resume(self._thread)
145
+ end
146
+
147
+ local didYield = false
148
+ local hasSystem = false
149
+
150
+ local function systemCall()
151
+ local function noYield()
152
+ local success, errOrSys, cleanup
153
+ coroutine.resume(self._thread, function()
154
+ success, errOrSys, cleanup = xpcall(function()
155
+ return systemInfo.run(table.unpack(self._vargs))
156
+ end, function(e)
157
+ return debug.traceback(e)
158
+ end)
159
+ end)
160
+
161
+ if success == false then
162
+ didYield = true
163
+ table.insert(systemInfo.logs, errOrSys)
164
+ hooks.systemError(self, systemInfo, errOrSys)
165
+ return
166
+ end
167
+
168
+ if self._yielded then
169
+ didYield = true
170
+ local source, line = debug.info(self._thread, 1, "sl")
171
+ local errMessage = `{source}:{line}: System yielded`
172
+ table.insert(
173
+ systemInfo.logs,
174
+ debug.traceback(self._thread, errMessage, 2)
175
+ )
176
+ hooks.systemError(
177
+ self,
178
+ systemInfo,
179
+ debug.traceback(self._thread, errMessage, 2)
180
+ )
181
+ return
182
+ end
183
+
184
+ if not systemInfo.initialized then
185
+ if errOrSys == nil and cleanup == nil then
186
+ systemInfo.initialized = true
187
+ return
188
+ end
189
+
190
+ if type(errOrSys) == "function" then
191
+ systemInfo.run = errOrSys
192
+ systemInfo.initialized = true
193
+ if type(cleanup) == "function" then
194
+ systemInfo.cleanup = cleanup
195
+ end
196
+
197
+ hasSystem = true
198
+ return
199
+ end
200
+
201
+ if type(errOrSys) == "table" then
202
+ hasSystem = type(errOrSys.system) == "function"
203
+ local hasCleanup = type(errOrSys.cleanup) == "function"
204
+
205
+ if hasSystem or hasCleanup then
206
+ if hasSystem then
207
+ systemInfo.run = errOrSys.system
208
+ end
209
+
210
+ if hasCleanup then
211
+ systemInfo.cleanup = errOrSys.cleanup
212
+ end
213
+
214
+ systemInfo.initialized = true
215
+ return
216
+ end
217
+ end
218
+
219
+ local err = string.format(
220
+ "System '%s' initializer returned invalid type. "
221
+ .. "Expected: function, {system?, cleanup?}, or (function, function). "
222
+ .. "Got: %s, %s",
223
+ systemInfo.name,
224
+ type(errOrSys),
225
+ type(cleanup)
226
+ )
227
+ table.insert(systemInfo.logs, err)
228
+ hooks.systemError(self, systemInfo, err)
229
+ systemInfo.initialized = true
230
+ end
231
+ end
232
+
233
+ hooks.systemCall(self, "SystemCall", systemInfo, noYield)
234
+ end
235
+
236
+ local function inner()
237
+ hooks.systemCall(self, "InnerSystemCall", systemInfo, systemCall)
238
+ end
239
+
240
+ local function outer()
241
+ hooks.systemCall(self, "OuterSystemCall", systemInfo, inner)
242
+ end
243
+
244
+ if os.clock() - timeLastLogged > 10 then
245
+ timeLastLogged = os.clock()
246
+ recentLogs = {}
247
+ end
248
+
249
+ local success, err: string? = pcall(outer)
250
+ if not success and not recentLogs[err] then
251
+ task.spawn(error, err, 0)
252
+ warn(
253
+ `Planck: Error occurred while running hooks, this error will be ignored for 10 seconds`
254
+ )
255
+ hooks.systemError(
256
+ self,
257
+ systemInfo,
258
+ `Error occurred while running hooks: {err}`
259
+ )
260
+ recentLogs[err] = true
261
+ end
262
+
263
+ if didYield then
264
+ coroutine.close(self._thread)
265
+
266
+ self._thread = coroutine.create(function()
267
+ while true do
268
+ local fn = coroutine.yield()
269
+ self._yielded = true
270
+ fn()
271
+ self._yielded = false
272
+ end
273
+ end)
274
+
275
+ coroutine.resume(self._thread)
276
+ end
277
+
278
+ self:_handleLogs(systemInfo)
279
+
280
+ if hasSystem and justInitialized ~= true then
281
+ self:runSystem(system, true)
282
+ end
283
+ end
284
+
285
+ function Scheduler:runPhase(phase)
286
+ if self:_canRun(phase) == false then
287
+ return
288
+ end
289
+
290
+ hooks.phaseBegan(self, phase)
291
+
292
+ if not self._phaseToSystems[phase] then
293
+ self._phaseToSystems[phase] = {}
294
+ end
295
+
296
+ for _, system in self._phaseToSystems[phase] do
297
+ self:runSystem(system)
298
+ end
299
+ end
300
+
301
+ function Scheduler:runPipeline(pipeline)
302
+ if self:_canRun(pipeline) == false then
303
+ return
304
+ end
305
+
306
+ local orderedList = pipeline.dependencyGraph:getOrderedList()
307
+ assert(
308
+ orderedList,
309
+ `Pipeline {pipeline} contains a circular dependency, check it's Phases`
310
+ )
311
+
312
+ for _, phase in orderedList do
313
+ self:runPhase(phase)
314
+ end
315
+ end
316
+
317
+ function Scheduler:_canRun(dependent)
318
+ local conditions = self._runIfConditions[dependent]
319
+
320
+ if conditions then
321
+ for _, runIf in conditions do
322
+ if runIf(table.unpack(self._vargs)) == false then
323
+ return false
324
+ end
325
+ end
326
+ end
327
+
328
+ return true
329
+ end
330
+
331
+ --- @method run
332
+ --- @within Scheduler
333
+ --- @param phase Phase
334
+ --- @return Scheduler
335
+ ---
336
+ --- Runs all Systems tagged with the Phase in order.
337
+
338
+ --- @method run
339
+ --- @within Scheduler
340
+ --- @param pipeline Pipeline
341
+ --- @return Scheduler
342
+ ---
343
+ --- Runs all Systems tagged with any Phase within the Pipeline in order.
344
+
345
+ --- @method run
346
+ --- @within Scheduler
347
+ --- @param system System
348
+ --- @return Scheduler
349
+ ---
350
+ --- Runs the System, passing in the arguments of the Scheduler, `U...`.
351
+ function Scheduler:run(dependent)
352
+ if not dependent then
353
+ error("No dependent specified in Scheduler:run(_)")
354
+ end
355
+
356
+ self:runPipeline(Pipeline.Startup)
357
+
358
+ if getSystem(dependent) then
359
+ self:runSystem(dependent)
360
+ elseif isPhase(dependent) then
361
+ self:runPhase(dependent)
362
+ elseif isPipeline(dependent) then
363
+ self:runPipeline(dependent)
364
+ else
365
+ error("Unknown dependent passed into Scheduler:run(unknown)")
366
+ end
367
+
368
+ return self
369
+ end
370
+
371
+ --- @method runAll
372
+ --- @within Scheduler
373
+ --- @return Scheduler
374
+ ---
375
+ --- Runs all Systems within order.
376
+ ---
377
+ --- :::note
378
+ --- When you add a Pipeline or Phase with an event, it will be grouped
379
+ --- with other Pipelines/Phases on that event. Otherwise, it will be
380
+ --- added to the default group.
381
+ ---
382
+ --- When not running systems on Events, such as with the `runAll` method,
383
+ --- the Default group will be ran first, and then each Event Group in the
384
+ --- order created.
385
+ ---
386
+ --- Pipelines/Phases in these groups are still ordered by their dependencies
387
+ --- and by the order of insertion.
388
+ --- :::
389
+ function Scheduler:runAll()
390
+ local orderedDefaults = self._defaultDependencyGraph:getOrderedList()
391
+ assert(
392
+ orderedDefaults,
393
+ "Default Group contains a circular dependency, check your Pipelines/Phases"
394
+ )
395
+
396
+ for _, dependency in orderedDefaults do
397
+ self:run(dependency)
398
+ end
399
+
400
+ for identifier, dependencyGraph in self._eventDependencyGraphs do
401
+ local orderedList = dependencyGraph:getOrderedList()
402
+ assert(
403
+ orderedDefaults,
404
+ `Event Group '{identifier}' contains a circular dependency, check your Pipelines/Phases`
405
+ )
406
+ for _, dependency in orderedList do
407
+ self:run(dependency)
408
+ end
409
+ end
410
+
411
+ return self
412
+ end
413
+
414
+ --- @method insert
415
+ --- @within Scheduler
416
+ --- @param phase Phase
417
+ --- @return Scheduler
418
+ ---
419
+ --- Initializes the Phase within the Scheduler, ordering it implicitly by
420
+ --- setting it as a dependent of the previous Phase/Pipeline.
421
+
422
+ --- @method insert
423
+ --- @within Scheduler
424
+ --- @param pipeline Pipeline
425
+ --- @return Scheduler
426
+ ---
427
+ --- Initializes the Pipeline and it's Phases within the Scheduler,
428
+ --- ordering the Pipeline implicitly by setting it as a dependent
429
+ --- of the previous Phase/Pipeline.
430
+
431
+ --- @method insert
432
+ --- @within Scheduler
433
+ --- @param phase Phase
434
+ --- @param instance Instance | EventLike
435
+ --- @param event string | EventLike
436
+ --- @return Scheduler
437
+ ---
438
+ --- Initializes the Phase within the Scheduler, ordering it implicitly
439
+ --- by setting it as a dependent of the previous Phase/Pipeline, and
440
+ --- scheduling it to be ran on the specified event.
441
+ ---
442
+ --- ```lua
443
+ --- local myScheduler = Scheduler.new()
444
+ --- :insert(myPhase, RunService, "Heartbeat")
445
+ --- ```
446
+
447
+ --- @method insert
448
+ --- @within Scheduler
449
+ --- @param pipeline Pipeline
450
+ --- @param instance Instance | EventLike
451
+ --- @param event string | EventLike
452
+ --- @return Scheduler
453
+ ---
454
+ --- Initializes the Pipeline and it's Phases within the Scheduler,
455
+ --- ordering the Pipeline implicitly by setting it as a dependent of
456
+ --- the previous Phase/Pipeline, and scheduling it to be ran on the
457
+ --- specified event.
458
+ ---
459
+ --- ```lua
460
+ --- local myScheduler = Scheduler.new()
461
+ --- :insert(myPipeline, RunService, "Heartbeat")
462
+ --- ```
463
+ function Scheduler:insert(dependency, instance, event)
464
+ assert(
465
+ isPhase(dependency) or isPipeline(dependency),
466
+ "Unknown dependency passed to Scheduler:insert(unknown, _, _)"
467
+ )
468
+
469
+ if not instance then
470
+ local dependencyGraph = self._defaultDependencyGraph
471
+ dependencyGraph:insertBefore(dependency, self._defaultPhase)
472
+ else
473
+ assert(
474
+ isValidEvent(instance, event),
475
+ "Unknown instance/event passed to Scheduler:insert(_, instance, event)"
476
+ )
477
+
478
+ local dependencyGraph = self:_getEventDependencyGraph(instance, event)
479
+ dependencyGraph:insert(dependency)
480
+ end
481
+
482
+ if isPhase(dependency) then
483
+ self._phaseToSystems[dependency] = {}
484
+ hooks.phaseAdd(self, dependency)
485
+ end
486
+
487
+ return self
488
+ end
489
+
490
+ --- @method insertAfter
491
+ --- @within Scheduler
492
+ --- @param phase Phase
493
+ --- @param after Phase | Pipeline
494
+ --- @return Scheduler
495
+ ---
496
+ --- Initializes the Phase within the Scheduler, ordering it
497
+ --- explicitly by setting the after Phase/Pipeline as a dependent.
498
+
499
+ --- @method insertAfter
500
+ --- @within Scheduler
501
+ --- @param pipeline Pipeline
502
+ --- @param after Phase | Pipeline
503
+ --- @return Scheduler
504
+ ---
505
+ --- Initializes the Pipeline and it's Phases within the Scheduler,
506
+ --- ordering the Pipeline explicitly by setting the after Phase/Pipeline
507
+ --- as a dependent.
508
+ function Scheduler:insertAfter(dependent, after)
509
+ assert(
510
+ isPhase(after) or isPipeline(after),
511
+ "Unknown dependency passed in Scheduler:insertAfter(_, unknown)"
512
+ )
513
+ assert(
514
+ isPhase(dependent) or isPipeline(dependent),
515
+ "Unknown dependent passed in Scheduler:insertAfter(unknown, _)"
516
+ )
517
+
518
+ local dependencyGraph = self:_getGraphOfDependency(after)
519
+ dependencyGraph:insertAfter(dependent, after)
520
+
521
+ if isPhase(dependent) then
522
+ self._phaseToSystems[dependent] = {}
523
+ hooks.phaseAdd(self, dependent)
524
+ end
525
+
526
+ return self
527
+ end
528
+
529
+ --- @method insertBefore
530
+ --- @within Scheduler
531
+ --- @param phase Phase
532
+ --- @param before Phase | Pipeline
533
+ --- @return Scheduler
534
+ ---
535
+ --- Initializes the Phase within the Scheduler, ordering it
536
+ --- explicitly by setting the before Phase/Pipeline as a dependency.
537
+
538
+ --- @method insertBefore
539
+ --- @within Scheduler
540
+ --- @param pipeline Pipeline
541
+ --- @param before Phase | Pipeline
542
+ --- @return Scheduler
543
+ ---
544
+ --- Initializes the Pipeline and it's Phases within the Scheduler,
545
+ --- ordering the Pipeline explicitly by setting the before Phase/Pipeline
546
+ --- as a dependency.
547
+ function Scheduler:insertBefore(dependent, before)
548
+ assert(
549
+ isPhase(before) or isPipeline(before),
550
+ "Unknown dependency passed in Scheduler:insertBefore(_, unknown)"
551
+ )
552
+ assert(
553
+ isPhase(dependent) or isPipeline(dependent),
554
+ "Unknown dependent passed in Scheduler:insertBefore(unknown, _)"
555
+ )
556
+
557
+ local dependencyGraph = self:_getGraphOfDependency(before)
558
+ dependencyGraph:insertBefore(dependent, before)
559
+
560
+ if isPhase(dependent) then
561
+ self._phaseToSystems[dependent] = {}
562
+ hooks.phaseAdd(self, dependent)
563
+ end
564
+
565
+ return self
566
+ end
567
+
568
+ --- @method addSystem
569
+ --- @within Scheduler
570
+ --- @param system System
571
+ --- @param phase Phase?
572
+ --- @return Scheduler
573
+ ---
574
+ --- Adds the System to the Scheduler, scheduling it to be ran
575
+ --- implicitly within the provided Phase or on the default Main phase.
576
+ ---
577
+ --- **Initializer Systems**: Systems can optionally return a function on their
578
+ --- first execution, which becomes the runtime system. This allows one-time
579
+ --- setup logic without creating separate initialization phases.
580
+ ---
581
+ --- ```lua
582
+ --- local function renderSystem(world, state)
583
+ --- -- This runs once on first execution
584
+ --- local renderables = world:query(Transform, Model):cached()
585
+ ---
586
+ --- -- This runs on each subsequent execution
587
+ --- return function(world, state)
588
+ --- for id, transform, model in renderables do
589
+ --- render(transform, model)
590
+ --- end
591
+ --- end, function()
592
+ --- -- Optional cleanup logic runs on removeSystem
593
+ --- end
594
+ --- end
595
+ --- ```
596
+ function Scheduler:addSystem(system, phase)
597
+ local systemFn = getSystem(system)
598
+
599
+ if not systemFn then
600
+ error("Unknown system passed to Scheduler:addSystem(unknown, phase?)")
601
+ end
602
+
603
+ local name = getSystemName(system)
604
+
605
+ local scheduledPhase
606
+ if phase then
607
+ scheduledPhase = phase
608
+ elseif type(system) == "table" and system.phase then
609
+ scheduledPhase = system.phase
610
+ else
611
+ scheduledPhase = self._defaultPhase
612
+ end
613
+
614
+ local systemInfo = {
615
+ system = systemFn,
616
+ run = systemFn,
617
+ cleanup = nil,
618
+ phase = scheduledPhase,
619
+ name = name,
620
+ logs = {},
621
+ initialized = false,
622
+ }
623
+
624
+ self._systemInfo[systemFn] = systemInfo
625
+
626
+ if not self._phaseToSystems[systemInfo.phase] then
627
+ self._phaseToSystems[systemInfo.phase] = {}
628
+ end
629
+
630
+ table.insert(self._phaseToSystems[systemInfo.phase], systemFn)
631
+
632
+ hooks.systemAdd(self, systemInfo)
633
+
634
+ if type(system) == "table" and system.runConditions then
635
+ for _, condition in system.runConditions do
636
+ condition = if typeof(condition) == "table"
637
+ then condition[1]
638
+ else condition
639
+ self:addRunCondition(systemFn, condition)
640
+ end
641
+ end
642
+
643
+ return self
644
+ end
645
+
646
+ --- @method addSystems
647
+ --- @within Scheduler
648
+ --- @param systems { System }
649
+ --- @param phase Phase?
650
+ ---
651
+ --- Adds the Systems to the Scheduler, scheduling them to be ran
652
+ --- implicitly within the provided Phase or on the default Main phase.
653
+ function Scheduler:addSystems(systems, phase)
654
+ if type(systems) ~= "table" then
655
+ error("Unknown systems passed to Scheduler:addSystems(unknown, phase?)")
656
+ end
657
+
658
+ local foundSystem = false
659
+ local n = 0
660
+
661
+ for _, system in systems do
662
+ n += 1
663
+ if getSystem(system) then
664
+ foundSystem = true
665
+ self:addSystem(system, phase)
666
+ end
667
+ end
668
+
669
+ if n == 0 then
670
+ error("Empty table passed to Scheduler:addSystems({ }, phase?)")
671
+ end
672
+
673
+ if not foundSystem then
674
+ error(
675
+ "Unknown table passed to Scheduler:addSystems({ unknown }, phase?)"
676
+ )
677
+ end
678
+
679
+ return self
680
+ end
681
+
682
+ --- @method editSystem
683
+ --- @within Scheduler
684
+ --- @param system System
685
+ --- @param newPhase Phase
686
+ ---
687
+ --- Changes the Phase that this system is scheduled on.
688
+ function Scheduler:editSystem(system, newPhase)
689
+ local systemFn = getSystem(system)
690
+ local systemInfo = self._systemInfo[systemFn]
691
+ assert(
692
+ systemInfo,
693
+ "Attempt to edit a non-existent system in Scheduler:editSystem(_)"
694
+ )
695
+
696
+ assert(
697
+ newPhase and self._phaseToSystems[newPhase] ~= nil or true,
698
+ "Phase never initialized before using Scheduler:editSystem(_, Phase)"
699
+ )
700
+
701
+ local systems = self._phaseToSystems[systemInfo.phase]
702
+
703
+ local index = table.find(systems, systemFn)
704
+ assert(index, "Unable to find system within phase")
705
+
706
+ table.remove(systems, index)
707
+
708
+ if not self._phaseToSystems[newPhase] then
709
+ self._phaseToSystems[newPhase] = {}
710
+ end
711
+ table.insert(self._phaseToSystems[newPhase], systemFn)
712
+
713
+ systemInfo.phase = newPhase
714
+ return self
715
+ end
716
+
717
+ function Scheduler:_removeCondition(dependent, condition)
718
+ self._runIfConditions[dependent] = nil
719
+
720
+ for _, _conditions in self._runIfConditions do
721
+ if table.find(_conditions, condition) then
722
+ return
723
+ end
724
+ end
725
+
726
+ conditions.cleanupCondition(condition)
727
+ end
728
+
729
+ --- @method removeSystem
730
+ --- @within Scheduler
731
+ --- @param system System
732
+ --- @return Scheduler
733
+ ---
734
+ --- Removes the System from the Scheduler.
735
+ ---
736
+ --- If the system provided a cleanup function during initialization,
737
+ --- that cleanup function will be executed before removal.
738
+ ---
739
+ --- ```lua
740
+ --- -- System with cleanup
741
+ --- local function networkSystem(world, state)
742
+ --- local connection = Players.PlayerAdded:Connect(function(player)
743
+ --- -- Player joined logic
744
+ --- end)
745
+ ---
746
+ --- return function(world, state)
747
+ --- -- Runtime logic
748
+ --- end, function()
749
+ --- -- Cleanup runs on removeSystem
750
+ --- connection:Disconnect()
751
+ --- end
752
+ --- end
753
+ ---
754
+ --- scheduler:addSystem(networkSystem, Phase.Update)
755
+ --- -- Later...
756
+ --- scheduler:removeSystem(networkSystem) -- Cleanup executes here
757
+ --- ```
758
+ function Scheduler:removeSystem(system)
759
+ local systemFn = getSystem(system)
760
+ local systemInfo = self._systemInfo[systemFn]
761
+ assert(
762
+ systemInfo,
763
+ "Attempt to remove a non-existent system in Scheduler:removeSystem(_)"
764
+ )
765
+
766
+ if systemInfo.cleanup then
767
+ local success, err =
768
+ pcall(systemInfo.cleanup, table.unpack(self._vargs))
769
+ if success then
770
+ hooks.systemCleanup(self, systemInfo, nil)
771
+ else
772
+ local errMsg = string.format(
773
+ "Cleanup failed for system '%s': %s",
774
+ systemInfo.name,
775
+ tostring(err)
776
+ )
777
+ hooks.systemError(self, systemInfo, errMsg)
778
+ hooks.systemCleanup(self, systemInfo, errMsg)
779
+ end
780
+ end
781
+
782
+ local systems = self._phaseToSystems[systemInfo.phase]
783
+
784
+ local index = table.find(systems, systemFn)
785
+ assert(index, "Unable to find system within phase")
786
+
787
+ table.remove(systems, index)
788
+ self._systemInfo[systemFn] = nil
789
+
790
+ if self._runIfConditions[system] then
791
+ for _, condition in self._runIfConditions[system] do
792
+ self:_removeCondition(system, condition)
793
+ end
794
+
795
+ self._runIfConditions[system] = nil
796
+ end
797
+
798
+ hooks.systemRemove(self, systemInfo)
799
+
800
+ return self
801
+ end
802
+
803
+ --- @method replaceSystem
804
+ --- @within Scheduler
805
+ --- @param old System
806
+ --- @param new System
807
+ ---
808
+ --- Replaces the System with a new System.
809
+ function Scheduler:replaceSystem(old, new)
810
+ local oldSystemFn = getSystem(old)
811
+ local oldSystemInfo = self._systemInfo[oldSystemFn]
812
+ assert(
813
+ oldSystemInfo,
814
+ "Attempt to replace a non-existent system in Scheduler:replaceSystem(unknown, _)"
815
+ )
816
+
817
+ local newSystemFn = getSystem(new)
818
+ assert(
819
+ newSystemFn,
820
+ "Attempt to pass non-system in Scheduler:replaceSystem(_, unknown)"
821
+ )
822
+
823
+ if oldSystemInfo.cleanup then
824
+ local success, err =
825
+ pcall(oldSystemInfo.cleanup, table.unpack(self._vargs))
826
+ if success then
827
+ hooks.systemCleanup(self, oldSystemInfo, nil)
828
+ else
829
+ local errMsg = string.format(
830
+ "Cleanup failed for system '%s': %s",
831
+ oldSystemInfo.name,
832
+ tostring(err)
833
+ )
834
+ hooks.systemError(self, oldSystemInfo, errMsg)
835
+ hooks.systemCleanup(self, oldSystemInfo, errMsg)
836
+ end
837
+ end
838
+
839
+ local systems = self._phaseToSystems[oldSystemInfo.phase]
840
+
841
+ local index = table.find(systems, oldSystemFn)
842
+ assert(index, "Unable to find system within phase")
843
+
844
+ table.remove(systems, index)
845
+ table.insert(systems, index, newSystemFn)
846
+
847
+ local copy = table.clone(oldSystemInfo)
848
+
849
+ oldSystemInfo.system = newSystemFn
850
+ oldSystemInfo.run = newSystemFn
851
+ oldSystemInfo.cleanup = nil
852
+ oldSystemInfo.initialized = false
853
+ oldSystemInfo.name = getSystemName(new)
854
+
855
+ hooks.systemReplace(self, copy, oldSystemInfo)
856
+
857
+ self._systemInfo[newSystemFn] = self._systemInfo[oldSystemFn]
858
+ self._systemInfo[oldSystemFn] = nil
859
+
860
+ return self
861
+ end
862
+
863
+ --- @method addRunCondition
864
+ --- @within Scheduler
865
+ --- @param system System
866
+ --- @param fn (U...) -> boolean
867
+ ---
868
+ --- Adds a Run Condition which the Scheduler will check before
869
+ --- this System is ran.
870
+
871
+ --- @method addRunCondition
872
+ --- @within Scheduler
873
+ --- @param phase Phase
874
+ --- @param fn (U...) -> boolean
875
+ ---
876
+ --- Adds a Run Condition which the Scheduler will check before
877
+ --- any Systems within this Phase are ran.
878
+
879
+ --- @method addRunCondition
880
+ --- @within Scheduler
881
+ --- @param pipeline Pipeline
882
+ --- @param fn (U...) -> boolean
883
+ ---
884
+ --- Adds a Run Condition which the Scheduler will check before
885
+ --- any Systems within any Phases apart of this Pipeline are ran.
886
+ function Scheduler:addRunCondition(dependent, fn)
887
+ fn = if typeof(fn) == "table" then fn[1] else fn
888
+
889
+ local system = getSystem(dependent)
890
+ if system then
891
+ dependent = system
892
+ end
893
+
894
+ assert(
895
+ system or isPhase(dependent) or isPipeline(dependent),
896
+ "Attempt to pass unknown dependent into Scheduler:addRunCondition(unknown, _)"
897
+ )
898
+
899
+ if not self._runIfConditions[dependent] then
900
+ self._runIfConditions[dependent] = {}
901
+ end
902
+
903
+ table.insert(self._runIfConditions[dependent], fn)
904
+
905
+ return self
906
+ end
907
+
908
+ function Scheduler:_addBuiltins()
909
+ self._defaultPhase = Phase.new("Default")
910
+ self._defaultDependencyGraph = DependencyGraph.new()
911
+
912
+ self._defaultDependencyGraph:insert(Pipeline.Startup)
913
+ self._defaultDependencyGraph:insert(self._defaultPhase)
914
+
915
+ self:addRunCondition(Pipeline.Startup, conditions.runOnce())
916
+ for _, phase in Pipeline.Startup.dependencyGraph.nodes do
917
+ self:addRunCondition(phase, conditions.runOnce())
918
+ end
919
+ end
920
+
921
+ function Scheduler:_scheduleEvent(instance, event)
922
+ local connect = utils.getConnectFunction(instance, event)
923
+ assert(
924
+ connect,
925
+ "Couldn't connect to event as no valid connect methods were found! Ensure the passed event has a 'Connect' or an 'on' method!"
926
+ )
927
+
928
+ local identifier = getEventIdentifier(instance, event)
929
+
930
+ local dependencyGraph = DependencyGraph.new()
931
+
932
+ local callback = function()
933
+ local orderedList = dependencyGraph:getOrderedList()
934
+
935
+ if orderedList == nil then
936
+ local err =
937
+ `Event Group '{identifier}' contains a circular dependency, check your Pipelines/Phases`
938
+ if not recentLogs[err] then
939
+ task.spawn(error, err, 0)
940
+ warn(
941
+ `Planck: Error occurred while running event, this error will be ignored for 10 seconds`
942
+ )
943
+ recentLogs[err] = true
944
+ end
945
+ end
946
+
947
+ for _, dependency in orderedList do
948
+ self:run(dependency)
949
+ end
950
+ end
951
+
952
+ self._connectedEvents[identifier] = connect(callback)
953
+ self._eventDependencyGraphs[identifier] = dependencyGraph
954
+ end
955
+
956
+ function Scheduler:_getEventDependencyGraph(instance, event)
957
+ local identifier = getEventIdentifier(instance, event)
958
+
959
+ if not self._connectedEvents[identifier] then
960
+ self:_scheduleEvent(instance, event)
961
+ end
962
+
963
+ return self._eventDependencyGraphs[identifier]
964
+ end
965
+
966
+ function Scheduler:_getGraphOfDependency(dependency)
967
+ if table.find(self._defaultDependencyGraph.nodes, dependency) then
968
+ return self._defaultDependencyGraph
969
+ end
970
+
971
+ for _, dependencyGraph in self._eventDependencyGraphs do
972
+ if table.find(dependencyGraph.nodes, dependency) then
973
+ return dependencyGraph
974
+ end
975
+ end
976
+
977
+ error("Dependency does not belong to a DependencyGraph")
978
+ end
979
+
980
+ --- @within Scheduler
981
+ ---
982
+ --- Disconnects all events, closes all threads, and performs
983
+ --- other cleanup work.
984
+ ---
985
+ --- :::danger
986
+ --- Only use this if you intend to not use the associated
987
+ --- Scheduler anymore. It will not work as intended.
988
+ ---
989
+ --- You should dereference the scheduler object so that
990
+ --- it may be garbage collected.
991
+ --- :::
992
+ ---
993
+ --- :::warning
994
+ --- If you're creating a "throwaway" scheduler, you should
995
+ --- not add plugins like Jabby or the Matter Debugger to it.
996
+ --- These plugins are unable to properly be cleaned up, use
997
+ --- them with caution.
998
+ --- :::
999
+ function Scheduler:cleanup()
1000
+ for _, connection in self._connectedEvents do
1001
+ utils.disconnectEvent(connection)
1002
+ end
1003
+
1004
+ for _, plugin in self._plugins do
1005
+ if plugin.cleanup then
1006
+ plugin:cleanup()
1007
+ end
1008
+ end
1009
+
1010
+ if self._thread then
1011
+ coroutine.close(self._thread)
1012
+ end
1013
+
1014
+ for _, _conditions in self._runIfConditions do
1015
+ for _, condition in _conditions do
1016
+ conditions.cleanupCondition(condition)
1017
+ end
1018
+ end
1019
+ end
1020
+
1021
+ --- @function new
1022
+ --- @within Scheduler
1023
+ --- @param args U...
1024
+ ---
1025
+ --- Creates a new Scheduler, the args passed will be passed to
1026
+ --- any System anytime it is ran by the Scheduler.
1027
+ function Scheduler.new(...)
1028
+ local self = {}
1029
+
1030
+ self._hooks = {}
1031
+
1032
+ self._vargs = { ... }
1033
+
1034
+ self._eventDependencyGraphs = {}
1035
+ self._connectedEvents = {}
1036
+
1037
+ self._phaseToSystems = {}
1038
+ self._systemInfo = {}
1039
+
1040
+ self._runIfConditions = {}
1041
+
1042
+ self._plugins = {}
1043
+
1044
+ setmetatable(self, Scheduler)
1045
+
1046
+ for _, hookName in hooks.Hooks do
1047
+ if not self._hooks[hookName] then
1048
+ self._hooks[hookName] = {}
1049
+ end
1050
+ end
1051
+
1052
+ self:_addBuiltins()
1053
+
1054
+ return self
1055
+ end
1056
+
1057
+ return Scheduler