@rbxts/planck 0.3.0-alpha.1 → 0.3.0-alpha.2

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