Tasks
Tasks are a useful primitive for state machines, cutscenes and everything that needs to await for a sequence of events.
Example
Here is a cutscene example :
- First of all you pan the camera 1200px down for 3 seconds
- Then, you wait for the user to trigger Action "Jump"
- Then, wait for 10 seconds, doing nothing
- Then, you wait for the user to press the Spacebar, doing so will create a projectile
- Then, you wait for the GameObject projectile to disappear
- Finally, you print "Success !"
Using only events, the implementation could look like this :
local pressed_jump = false; function Event.Actions.Jump(evt) pressed_jump = true; end local pressed_space = false; function Event.Keys.Space(evt) if evt.state == obe.Input.InputButtonState.Pressed then pressed_space = true; end end local step = 1; local tween; function Step1(evt) local camera = Engine.Scene:getCamera(); if tween == nil then local camera_position = camera:getPosition(); tween = obe.Animation.Tween( camera_position, camera_position + obe.Transform.UnitVector(0, 1200, obe.Transform.Units.ScenePixels), 3 ); tween:start(); else local new_position = tween:step(evt.dt); if tween:done() then step = 2; else camera:setPosition(new_position); end end end function Step2(evt) pressed_jump = false; -- Reset in case the Action was triggered before Step 2 if pressed_jump then step = 3; end end local sleep_time = 0; function Step3(evt) sleep_time = sleep_time + evt.dt; if sleep_time > 10 then step = 4; end end function Step4(evt) pressed_space = false; -- Reset in case the Action was triggered before Step 4 if pressed_space then step = 5; -- We spawn a GameObject "Projectile" that deletes itself when colliding with something Engine.Scene:createGameObject("Projectile", "projectile") { velocity = 5, angle = 90 } end end function Step5(evt) if not Engine.Scene:doesGameObjectExists("projectile") then step = 6; end end function Step6(evt) print("Success !"); step = 7; end function Event.Game.Update(evt) if step == 1 then Step1(evt); elseif step == 2 then Step2(evt); elseif step == 3 then Step3(evt); elseif step == 4 then Step4(evt); elseif step == 5 then Step5(evt); elseif step == 6 then Step6(evt); end end
As you can see, the code is quite big for what we want to achieve and it is not very readable.
We encounter this problem because of the what we call "the game loop" : A game can not have blocking code in the main thread otherwise the screen will not refresh, events will not be handled and the program will hang (after a few seconds).
ÖbEngine provides a way to keep this kind of code elegant while keeping the game loop smooth : Tasks.
Let's rewrite the code above using tasks :
function Task.PanCameraDown(ctx) local camera = Engine.Scene:getCamera(); local camera_position = camera:getPosition(); local tween = obe.Animation.Tween( camera_position, camera_position + obe.Transform.UnitVector(0, 1200, obe.Transform.Units.ScenePixels), 3 ); tween:start(); ctx:wait_for(function(evt) local new_position = tween:step(evt.dt); camera:setPosition(new_position); return tween:done(); end); end function Task.Success(ctx) print("Success !"); end function Task.CutScene(ctx) -- Step 1 ctx:wait_for(Task.PanCameraDown); -- Step 2 ctx:wait_for("Event.Actions.Jump"); -- Step 3 ctx:wait_for(10); -- Step 4 ctx:wait_for(Event.Keys.Space, function(evt) return evt.state == obe.Input.InputButtonState.Pressed end); Engine.Scene:createGameObject("Projectile", "projectile") { velocity = 5, angle = 90 }; -- Step 5 ctx:wait_for(function() return not Engine.Scene:doesGameObjectExists("projectile"); end); -- Step 6 return Task.Success(); end function Local.Init() Task.CutScene(); end
Much more readable isn't it ? Let's dive a bit into details.
Creating a Task
Creating a Task is really easy, all you have to do is to define a function in the Task table (just like you do with Events in the Event table) and ÖbEngine will magically turn this function into a Task.
function Task.MyTask(ctx) print("I am a task now"); end
Don't want to put the function into the Task table ? No problem, you can define it anywhere, just wrap the function with a call to the Task function (yes, it is both a table to store tasks and a function to create free tasks).
local MyFreeTask = Task(function(ctx) print("I am a free task"); end);
As you may have noticed, all Tasks take at least a parameter named ctx.
ctx provides a bunch of useful functions such as wait_for or wake, you can see it as a helper object with methods to manipulate the Task flow.
function Task.MyTask(ctx) -- Don't forget to use colon ":" instead of dot "." -- to call ctx methods as it is an object and not a simple table ctx:wait_for(3); end
You can also ask for additional parameters in a Task after the ctx parameter
function Task.MyTask(ctx, name, age) print(("Hello, my name is %s"):format(name)); ctx:wait_for(3); print(("And I am %d years old"):format(age)); end -- Use the dot "." syntax to call the task Task.MyTask("Bob", 50);
⚠️ You do not need to pass the
ctxparameter, it is injected automatically !
Calling a Task
As shown above, calling a task is really easy, it works like a normal function except that the ctx parameter is injected automatically.
However, there is a few pitfalls that you must avoid.
There is 3 different ways to call a Task from another Task :
- Running both concurrently
- Waiting from the child Task to complete
- Moving from the current Task to the next Task
Running both concurrently
One really important thing to understand is that Tasks are not asynchronous, they are just fragment of codes executed by different hooks in an elegant way, if you put blocking code inside a Task, the program will hang.
The first way to execute a Task within another Task is the following one :
local start_epoch; function timed_print(text) local time_elapsed = obe.Time.epoch() - start_epoch; print(("%s\t(%ds)"):format(text, math.floor(time_elapsed))); end function Task.ParentTask(ctx) start_epoch = obe.Time.epoch(); timed_print("[ParentTask] started"); ctx:wait_for(3); Task.ChildTask(); timed_print("[ParentTask] Wait for 1 second"); ctx:wait_for(1); timed_print("[ParentTask] Wait for 1 second done"); timed_print("[ParentTask] Wait for 6 seconds"); ctx:wait_for(6); timed_print("[ParentTask] Wait for 6 seconds done"); end function Task.ChildTask(ctx) timed_print("[ChildTask] started"); -- Synchronous sleep, blocks the whole program for 5 seconds timed_print("[ChildTask] Synchronous sleep for 5 seconds") local start = obe.Time.epoch(); while obe.Time.epoch() - start < 5 do end timed_print("[ChildTask] Synchronous sleep for 5 seconds done") timed_print("[ChildTask] Wait for 3 seconds"); ctx:wait_for(3); timed_print("[ChildTask] Wait for 3 seconds done"); timed_print("[ChildTask] Wait for 4 second"); ctx:wait_for(4); timed_print("[ChildTask] Wait for 4 seconds done"); end Task.ParentTask();
This snippet will result in the following output :
[ParentTask] started (0s) [ChildTask] started (3s) [ChildTask] Synchronous sleep for 5 seconds (3s) [ChildTask] Synchronous sleep for 5 seconds done (8s) [ChildTask] Wait for 3 seconds (8s) [ParentTask] Wait for 1 second (8s) [ParentTask] Wait for 1 second done (9s) [ParentTask] Wait for 6 seconds (9s) [ChildTask] Wait for 3 seconds done (11s) [ChildTask] Wait for 4 second (11s) [ParentTask] Wait for 6 seconds done (15s) [ChildTask] Wait for 4 seconds done (15s)
As you can see, the synchronous sleep blocked the whole execution whereas the "Wait for" instructions allowed for the ParentTask and the ChildTask to execute concurrently (but not in parallel since we are in a synchronous model).
Keep in mind that the wait_for function is not a simple "sleep", what is does behind the scenes is that it yields the running coroutine and hooks on an event to resume the coroutine.
Waiting for the child Task to complete
The wait_for function is a really powerful tool which allows you to await on a lot of different things, one of those thing being another Task !
If we take the previous example but change the way we call the ChildTask
function Task.ParentTask(ctx) -- ... ctx:wait_for(Task.ChildTask); -- ... end
The execution output will now look like this :
[ParentTask] started (0s) [ChildTask] started (3s) [ChildTask] Synchronous sleep for 5 seconds (3s) [ChildTask] Synchronous sleep for 5 seconds done (8s) [ChildTask] Wait for 3 seconds (8s) [ChildTask] Wait for 3 seconds done (11s) [ChildTask] Wait for 4 second (11s) [ChildTask] Wait for 4 seconds done (15s) [ParentTask] Wait for 1 second (15s) [ParentTask] Wait for 1 second done (16s) [ParentTask] Wait for 6 seconds (16s) [ParentTask] Wait for 6 seconds done (22s)
As you can see, instead of 15 seconds, it took 22 seconds this time. ParentTask waited for the ChildTask to complete before continuing its own execution.
⚠️ If you need to wait for a task which requires parameters, you can pass them to the wait_for function and they will be forwarded to the task. Do not wrap the Task call into a function because it has a very different meaning !
function Task.ParentTask(ctx) -- DO THIS ctx:wait_for(Task.ChildTask, "Bob", 50); -- BUT DO NOT DO THIS ctx:wait_for(function() Task.ChildTask("Bob", 50); end); end function Task.ChildTask(ctx, name, age) ctx:wait_for(3); print(("My name is %s and I am %d years old"):format(name, age)); end
Moving from the current Task to the next Task
Tasks are often use for state machines, moving from a state to another.
Here each state can be represented by a Task :
function Task.Step1NonConcurrentTask(ctx) print("Step 1.a"); ctx:wait_for(1); print("Step 1.a done"); end function Task.Step1ConcurrentTask(ctx) print("Step 1.b"); ctx:wait_for(1); print("Step 1.b done"); end function Task.Step1(ctx) print("Step 1"); ctx:wait_for(Task.Step1NonConcurrentTask); Task.Step1ConcurrentTask(); return Task.Step2(); end function Task.Step2(ctx) print("Step 2"); return Task.Step3(); end function Task.Step3(ctx) print("Step 3"); end Task.Step1();
As you could expect, the output is the following one :
Step 1 Step 1.a Step 1.a done Step 1.b Step 2 Step 3 Step 1.b done
There is one very important detail here that you may not have noticed :
return Task.Step2(); -- and return Task.Step3();
Why do we need to use the return keyword ? After all, Step3 returns nothing so we should not have to return in Step1 and Step2 either.
Using the return keyword here has little to do with returning actual values but rather taking advantage of Lua's tail-call-optimization mechanism.
When you do this :
function Task.Step1(ctx) Task.Step2(); end function Task.Step2(ctx) end
The execution flow goes like this :
Step1 starts
Step2 starts
Step2 exits
Step1 exits
Note that yielding from
Step2would have changed the execution flow but let's omit this for now
The problem is that Step1 is kept alive even if it has nothing left to do. For small sequences it is not a big problem but when you start to have more complex state machines with loops and branches, you might accidently hit a stack overflow.
It is good practice to always return the next task if it is the last thing the current task has to do so you can take advantage of tail-call-optimisation.
function Task.Step1(ctx) return Task.Step2(); end function Task.Step2(ctx) end
The execution flow now goes like this :
Step1 starts
Step2 starts
Step1 exits
Step2 exits
The wait_for function
The wait_for function is the component that makes tasks so handy !
As shown in the first example, it can wait for quite a lot of things.
Wait for time
The easiest way to use wait_for is with an amount of seconds :
function Task.MyTask(ctx) -- Waits for 5 seconds ctx:wait_for(5); end
You can also use time conversion functions with it
function Task.MyTask(ctx) -- Equivalent to ctx:wait_for(5); ctx:wait_for(obe.Time.seconds(5)); ctx:wait_for(obe.Time.milliseconds(500)); ctx:wait_for(obe.Time.microseconds(800000)); ctx:wait_for(obe.Time.minutes(24)); ctx:wait_for(obe.Time.hours(12)); ctx:wait_for(obe.Time.days(2)); -- You will probably not need this but who knows ctx:wait_for(obe.Time.weeks(3)); end
Wait for event
The wait_for function accepts Events as parameter, it will then resume the coroutine when the event has been triggered.
function Task.MyTask(ctx) -- Waits for the "K" key state to be modified (Pressed, Released, Hold) ctx:wait_for(Event.Keys.K); -- It also accepts a second parameter where you can add a continuation condition -- In this case it waits until key "K" is "Pressed" (and not just any state) ctx:wait_for(Event.Keys.K, function(evt) return evt.state == obe.Input.InputButtonState.Pressed end); -- You can either use the hook form or the string form, both works the same ctx:wait_for("Event.Keys.K"); -- This allows for easier event consumption, even from namespaces you don't listen to ctx:wait_for("CustomEventNamespace.CustomEventGroup.CustomEvent"); end
When using a secondary condition parameter, the function will receive evt like a normal event so you can use the Event values.
Wait for arbitrary condition
You can pass a function to wait_for, it will hook to Event.Game.Update and check if the function returns a truthy value (not false or nil).
function Task.MyTask(ctx) -- From 1 to 10 in 5 seconds local tween = obe.Animation.Tween(1, 10, 5); tween:start(); ctx:wait_for(function(evt) local value = tween:step(evt.dt); print(value); return tween:done(); end); end
This is quite practical for tweens, since the function is hooked to Event.Game.Update, you can perform updates before returning the continuation value.
Wait for other tasks
See Waiting for the child Task to complete section.
The wake function
The ctx object does not only have the wait_for method. It also has the wake method which is far less common.
The wake function is used to schedule a wake up of the coroutine on the next Event.Game.Update, it is available so you can implement your own wait_for-like functions.
For example, if you wanted to reimplement a simple wait_for_seconds function, it would look like this :
function wait_for_seconds(ctx, seconds) schedule():after(seconds):run(function() ctx:wake(); end); end function Task.MyTask(ctx) wait_for_seconds(ctx, 10); end