commit f33b1782f1f2e066e8bd574b6735cba0d6c61070 Author: Pyral Date: Sat Dec 21 13:41:11 2024 -0500 Initial commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..40af2e0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Dinoleaf LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/task.gd b/task.gd new file mode 100644 index 0000000..36e0177 --- /dev/null +++ b/task.gd @@ -0,0 +1,87 @@ +## Task +## +## [color=yellow][b]Note:[/b] You probably won't be generating these yourself. +## Primarily intended to be generated and used by [TaskManager][/color].[br] +## A stateful task container, containing a list of functions. + +class_name Task extends RefCounted + +## Called when this task completes, emitting itself as [param task]. +signal on_finished(task:Task) + +## Nice name. +var name:String = "" +## List of queued commands. +var commands:Array[Callable] = [] +## List of exit conditions. Any set to true will end this process. +var exit_conditions:Array[Callable] = [ + func()->bool: return halt +] +## Internal tracker for showing what's running +var current_execution:int = 0 +## Are we done yet? +var finished := false +## Are we paused? +var paused := false +## Are we flagged to stop when possible? +var halt := false + +## Assign pause state to [param to_pause]. +func set_pause(to_pause:bool)->void: paused = to_pause + +## Assign pause state to [param to_pause] upon all [param tasks]. +static func set_pause_many(tasks:Array[Task], to_pause:bool)->void: + tasks.map(func(t:Task): t.paused = to_pause) + +## Initialize.[br] +## [param _name] Nice name.[br] +## [param _commands] List of commands.[br] +## [param exit_conds] List of functions that take 0 args and return boolean. +## if any of these are true when ticked, the task will terminate. +func _init(_name:String, _commands:Array[Callable], exit_conds:Array[Callable])->void: + name = _name + commands = _commands + for p_exit:Callable in exit_conds: + if p_exit.is_valid(): + exit_conditions.append(p_exit) + + +## Execute all [member Task.commands]. Triggers [signal Task.on_finished] when complete. +func execute()->void: + finished = false + var _check_halt_conds := func(p:Callable)->bool: return p.call() + for index:int in commands.size(): + if exit_conditions.any(_check_halt_conds): + break + while paused: + await Engine.get_main_loop().root.get_tree().process_frame + if exit_conditions.any(_check_halt_conds): + break + current_execution = index + await commands[index].call() + finished = true + on_finished.emit(self) + +## Dump a string of [param task] and all functions in the queue. +static func dump_to_text(task:Task)->String: + var _name_out := "[%s]\n" % "Unnamed tasklist" \ + if task.name.is_empty() else task.name + var _commands_out:String = "" + for index:int in task.commands.size(): + var cmd := task.commands[index] + var _label_id := "[%s]%s" % [ + str(index).pad_zeros(2), + ">" if index == task.current_execution else ""] + var _broken := "" if cmd.is_valid() else "" + var _cmd_out := cmd.get_method() + "(" + for arg in cmd.get_bound_arguments(): + _cmd_out += str(arg) + "," + _cmd_out = _cmd_out.trim_suffix(",") + ")\n" + _commands_out += _label_id + _broken + _cmd_out + return _name_out + _commands_out + +## Dump strings of all given [param tasks]. +static func dump_commands(tasks:Array[Task])->Array[String]: + var task_text:Array[String] = [] + task_text.assign(tasks.map(dump_to_text)) + return task_text diff --git a/task_manager.gd b/task_manager.gd new file mode 100644 index 0000000..1e29dd4 --- /dev/null +++ b/task_manager.gd @@ -0,0 +1,125 @@ +## Task Manager[br] +## +## A simple [Task] scheduler. This can take a list of functions and process +## them, either in the background or asynchronously. +## Multiple instances of these can be set at different scopes. [br] +## +## Note that due to the nature of processing tasks, if we are to destroy +## referenced objects within task closures, we should await [method TaskManager await_shutdown] +## before unloading said references (nodes, internal state objects, etc) +## otherwise a task may continue to process and use-after-free. +class_name TaskManager extends RefCounted + +## Called when a task starts +signal task_started(task:Task) +## Called when a task finishes. +signal task_complete(task:Task) + +## Nice name for distinguishing task processors. +var name:String = "Untitled Task Scheduler" +## Array of tasks that are either processing or paused. +## Halted tasks may get GC'd out at any point. +var tasks:Array[Task] = [] + +## Internal flag. If true, all tasks are to stop when possible. +## Use [method TaskManager.halt_all] if you want this behavior. +var _halt := false + +## Initialize. Give a nice name. +func _init(_name:String)->void: + name = _name + +## Execute a list of commands. Returns the wrapped up [Task]. Try not to mutate +## the task or you will be killed by a clown under your bed in seven days.[br] +## [param task_name] Identifier for task.[br] +## [param commands] List of prepared functions.[br] +## [param exit_cond] Exit predicate, which can interrupt a process early.[br] +## [param block] If true, will await until the task is complete.[br] +## Otherwise, it'll just start running the task without waiting to return. [br] +## Returns the generated task instance should the origin want to observe it. [br] +## [codeblock] +## var task_manager := TaskManager.new("EXAMPLE") +## # Better if you have coroutines that take time to call. ie awaiting a timeout. +## var commands:Array[Callable] = [print("1")] +## var active_task := task_manager.execute_commands("prints", commands, false) +## print(task_manager.dump_text) +## await active_task.on_finished +## print("Done") +## [/codeblock] +func execute_commands( + task_name:String, + commands:Array[Callable], + exit_cond:Callable = Callable(), + block:bool = true + )->Task: + var new_task := Task.new(task_name, commands, [p_halt_check.bind(exit_cond)]) + tasks.append(new_task) + new_task.on_finished.connect(remove_task, CONNECT_ONE_SHOT) + new_task.execute() + if block: + await new_task.finished + return new_task + +## Find a given task in this task reference. +## [param task] Target task. +func remove_task(task:Task)->void: + var task_index := tasks.find(task) + if task_index == -1: + return + tasks.remove_at(task_index) + +## Flag all tasks to be stopped as soon as possible. +func halt_all()->void: + _halt = true + +## Pause or unpause all tasks with [param to_pause]. +func set_pause_on_all(to_pause:bool)->void: + Task.set_pause_many(tasks, to_pause) + +## If we want this task manager to tick itself, throw it onto a separate channel. +## This lets us be lazy and probably regret it later.[br] +## [param tick_rate] is how many seconds to wait until ticking again. +## We do GC-ing and such for finished tasks, which can be expensive every frame. +func self_process(tick_rate:float = 0.5)->void: + while not _halt: + tick_status_check() + await Utils.COMMANDS.wait_seconds(tick_rate) + + +## Effectively garbage-collect finished tasks. +func tick_status_check()->void: + #var _is_ongoing := func _is_ongoing(task:Task)->bool: return not task.finished + var _is_finished := func _is_finished(task:Task)->bool: return task.finished + for task:Task in tasks.map(_is_finished): + remove_task(task) + + +## Sends a halt signal to all processes and awaits until all tasks are safely stopped. [br] +## Await this call to safely ensure you won't have stray processes ticking +## and possibly calling use-after-free references. +func await_shutdown()->void: + halt_all() + # delay until tasks finish and remove themselves. + while not tasks.is_empty(): + await Engine.get_main_loop().root.get_tree().process_frame + + +## Get a string of this scheduler and all the tasks currently in process. +func dump_text()->String: + var scheduler_text := "[%s]" + + var reduce := func(accum:String, current:String)->String: + return accum + current + "\n" + + var active_tasks := Task.dump_commands(tasks) + var task_megastring:String = active_tasks.reduce(reduce, "") + + return scheduler_text + "\n" + task_megastring + + +## Recursive predicate for halt checking.[br] +## [param p_halt] if true, the task should stop.[br] +## If no [param p_halt] is set, we only will halt if the scheduler is told to halt. +func p_halt_check(p_halt:=Callable())->bool: + if not p_halt.is_valid(): return _halt + return _halt or p_halt.call()