[SOLVED] Avoiding callback hell

Questions about the LÖVE API, installing LÖVE and other support related questions go here.
Forum rules
Before you make a thread asking for help, read this.
Post Reply
User avatar
master both
Party member
Posts: 255
Joined: Tue Nov 08, 2011 12:39 am
Location: Chile

[SOLVED] Avoiding callback hell

Post by master both » Mon Feb 04, 2019 7:38 pm

Hi, I'm currently working on an RPG where I have scripted sequences and I have code executing one after another and the way I'm dealing with it is using callbacks, which in retrospect wasn't the best way to deal with it, this is how it looks now:

Code: Select all

timer:after(3, function()
	textBox:write("Hi")
	textBox:write("wats up", function()
		moveNpcRight()
		timer:after(5, function()
			stopNpc()
			textBox:write("I just moved to the right", function()
				...
			end)
		end)
	end)
end)
Is there a way to make avoid this? is there a solution like Promises or async/await in javascript?
I did found this library by airstruck, which he added to knife (the knife one works diferently than the one in the post), but I find it verbose and overcomplicated.
There has to be a way (maybe using coroutines) to write easy asynchronous code in Lua.

Thanks in advance!

UPDATE:
Well, it didn't took me much time to find a solution, it was coroutines! sorry for the pointless post.
I manage to make this simple function:

Code: Select all

function async(f)
	local co = coroutine.wrap(f)
	co(coroutine.yield, co)
end
So now my code now looks like this:

Code: Select all

async(function(wait, cont)
	timer:after(3, cont) wait()
	textBox:write("Hi")
	textBox:write("wats up", cont) wait()
	moveNpcRight()
	timer:after(5, cont) wait()
	stopNpc()
	textBox:write("I just moved to the right", cont) wait()
	...
end)
Which is probably the closest thing I'm going to get to write readable asynchronous code.
So I guess it's solved? I'll just leave this here for anyone having the same problem.

User avatar
ivan
Party member
Posts: 1484
Joined: Fri Mar 07, 2008 1:39 pm
Contact:

Re: [SOLVED] Avoiding callback hell

Post by ivan » Tue Feb 05, 2019 8:41 am

To be honest, your code doesn't look too good.
I don't mean that as an insult, but it just isn't very clean.
You don't need any libraries, I'll show you some alternatives:

Event queues. This is the most simple technique that you can do in pure Lua. Queues are good if you want your code to be event based. This works similarly to timers:

Code: Select all

-- implementation
local queue = {}
function qcall(after, func, ...)
  table.insert(queue, { a = after, f = func, ... })
end
function qupdate(dt)
  for i = #queue, 1 do
    local t = queue[i]
    t.a = t.a - dt
    if t.a <= 0 then
      table.remove(queue, i)
      t.f(unpack(t))
    end
  end
end

-- usage
local events = {}
function events.say(s)
  textBox:write(s)
end
function events.move()
  moveNpcRight()
  qcall(1, events.say, "I just moved to the right")
end
-- introduction after 5 seconds
qcall(5, events.say, "Hello")
-- move after 10 seconds
qcall(10, events.move)
function love.update(dt)
  qupdate(dt)
end
The second option is coroutines. As opposed to events, coroutines run continuously.
The trick here is that each coroutine must return or yield a number - this number is the amount of time to wait/sleep until the routine is resumed. If your routine doesn't yield a number then it's automatically terminated!

Code: Select all

-- implementation
local co = {}
local sleep = {}
function coupdate(dt, ...)
  for k, v in pairs(co) do
    sleep[k] = (sleep[k] or 0) - dt
    if sleep[k] <= 0 then
      local ok, wait = coroutine.resume(v, ...)
      if ok and wait then
        sleep[k] = wait
      else
        sleep[k] = nil
        co[k] = nil
      end
    end
  end
end

-- usage
local routines = {}
function routines.npcScript(...)
  coroutine.yield(3)
  textBox:write("Hi")
  coroutine.yield(1)
  textBox:write("wats up")
  moveNpcRight()
  coroutine.yield(5)
  stopNpc()
  textBox:write("I just moved to the right")
end
for k, v in pairs(routines) do
  co[k] = coroutine.create(v)
end
function love.update(dt)
  coupdate(dt)
end

User avatar
master both
Party member
Posts: 255
Joined: Tue Nov 08, 2011 12:39 am
Location: Chile

Re: [SOLVED] Avoiding callback hell

Post by master both » Tue Feb 05, 2019 7:06 pm

Thanks for your response!

I got to admit, I didn't put to much thought into my code, I just went with callbacks since thats what most timer libraries use. There's probably a better way to do action scripts or events.
ivan wrote:
Tue Feb 05, 2019 8:41 am
To be honest, your code doesn't look too good.
I don't mean that as an insult, but it just isn't very clean.
You don't need any libraries, I'll show you some alternatives:

Event queues. This is the most simple technique that you can do in pure Lua. Queues are good if you want your code to be event based. This works similarly to timers:

Code: Select all

-- implementation
local queue = {}
function qcall(after, func, ...)
  table.insert(queue, { a = after, f = func, ... })
end
function qupdate(dt)
  for i = #queue, 1 do
    local t = queue[i]
    t.a = t.a - dt
    if t.a <= 0 then
      table.remove(queue, i)
      t.f(unpack(t))
    end
  end
end

-- usage
local events = {}
function events.say(s)
  textBox:write(s)
end
function events.move()
  moveNpcRight()
  qcall(1, events.say, "I just moved to the right")
end
-- introduction after 5 seconds
qcall(5, events.say, "Hello")
-- move after 10 seconds
qcall(10, events.move)
function love.update(dt)
  qupdate(dt)
end
I did thought to use queues to solve my problem, but they just fall into the same problem, even in your example, you use a function to move the npc right and then queue another event, so without defining the function before it would look like this:

Code: Select all

-- introduction after 5 seconds
qcall(5, events.say, "Hello")
-- move after 10 seconds
qcall(10, function()
	moveNpcRight()
	qcall(1, function()
		stopNpc()
		qcall(1, events.say, "I just moved to the right")
	end)
end)

function love.update(dt)
	qupdate(dt)
end
ivan wrote:
Tue Feb 05, 2019 8:41 am
The second option is coroutines. As opposed to events, coroutines run continuously.
The trick here is that each coroutine must return or yield a number - this number is the amount of time to wait/sleep until the routine is resumed. If your routine doesn't yield a number then it's automatically terminated!

Code: Select all

-- implementation
local co = {}
local sleep = {}
function coupdate(dt, ...)
  for k, v in pairs(co) do
    sleep[k] = (sleep[k] or 0) - dt
    if sleep[k] <= 0 then
      local ok, wait = coroutine.resume(v, ...)
      if ok and wait then
        sleep[k] = wait
      else
        sleep[k] = nil
        co[k] = nil
      end
    end
  end
end

-- usage
local routines = {}
function routines.npcScript(...)
  coroutine.yield(3)
  textBox:write("Hi")
  coroutine.yield(1)
  textBox:write("wats up")
  moveNpcRight()
  coroutine.yield(5)
  stopNpc()
  textBox:write("I just moved to the right")
end
for k, v in pairs(routines) do
  co[k] = coroutine.create(v)
end
function love.update(dt)
  coupdate(dt)
end
Now, this is closer to what I wanted, it looks more like the solution I found and posted on my update.

I really appreciate your solutions, there isn't much information about event systems for scripted cutscenes or dialog events in Lua.
I did found this intresting article after I posted my solution: http://lua.space/gamedev/using-lua-coro ... create-rpg, I probably will end up using a system like that.

User avatar
pgimeno
Party member
Posts: 1785
Joined: Sun Oct 18, 2015 2:58 pm

Re: [SOLVED] Avoiding callback hell

Post by pgimeno » Tue Feb 05, 2019 7:50 pm

I'm still not 100% sure of what you need. But if you're looking for a canned solution, it may be timer.script from vrld's hump or the coil cooperative multithreading lib.

Personally, for certain use cases (not sure if they match yours), I would go with making my own coroutine-based system, where there's a scheduler that starts/resumes the "threads" (similar to coil) and accepts and executes commands that come in the yielded values; the commands would include "wait", but also possibly others that would trigger execution of certain functions, as well as a switch to the next thread.

I have in my to-do list for a long time now, to write a GCS similar to Megazeux which uses such a system for the robots' programs (robots in MZX are any kind of objects, not just NPCs). But the task is so formidable, I doubt I would finish it.

User avatar
ivan
Party member
Posts: 1484
Joined: Fri Mar 07, 2008 1:39 pm
Contact:

Re: [SOLVED] Avoiding callback hell

Post by ivan » Tue Feb 05, 2019 8:12 pm

What's the point of the nested closures?
Why not just:

Code: Select all

-- introduction after 5 seconds
qcall(5, events.say, "Hello")
-- move after 10 seconds
qcall(10, moveNpcRight)
qcall(11, stopNpc)
qcall(12, events.say, "I just moved to the right")

User avatar
master both
Party member
Posts: 255
Joined: Tue Nov 08, 2011 12:39 am
Location: Chile

Re: [SOLVED] Avoiding callback hell

Post by master both » Wed Feb 06, 2019 7:25 am

pgimeno wrote:
Tue Feb 05, 2019 7:50 pm
I'm still not 100% sure of what you need. But if you're looking for a canned solution, it may be timer.script from vrld's hump or the coil cooperative multithreading lib.

Personally, for certain use cases (not sure if they match yours), I would go with making my own coroutine-based system, where there's a scheduler that starts/resumes the "threads" (similar to coil) and accepts and executes commands that come in the yielded values; the commands would include "wait", but also possibly others that would trigger execution of certain functions, as well as a switch to the next thread.

I have in my to-do list for a long time now, to write a GCS similar to Megazeux which uses such a system for the robots' programs (robots in MZX are any kind of objects, not just NPCs). But the task is so formidable, I doubt I would finish it.
Oh, I haven't heard of coil before, it may be what I'm looking for, thank you! it may improve my simple implementation of my update on the OP.
Originally, I did tried using timer.script, since I'm using the hump timer, but my problem persisted when I was waiting for user commands like changing a dialog, I just can't wait X seconds for it, that's what the callback in "textBox:write" was for. But I believe coil can fix this. thanks again.
ivan wrote:
Tue Feb 05, 2019 8:12 pm
What's the point of the nested closures?
Why not just:

Code: Select all

-- introduction after 5 seconds
qcall(5, events.say, "Hello")
-- move after 10 seconds
qcall(10, moveNpcRight)
qcall(11, stopNpc)
qcall(12, events.say, "I just moved to the right")
I'm sorry for my last response, I was in a hurry and I just didn't think it through, you're totally right, queues can be used to solve this problem, they can even can be modified to not only wait for time events, but also user inputs, like pressing a button to advance the dialog, which was my main problem with not using callbacks.

Now I can really begun to experiment with different solutions and see what works better for me.

Thanks a lot!

User avatar
pgimeno
Party member
Posts: 1785
Joined: Sun Oct 18, 2015 2:58 pm

Re: [SOLVED] Avoiding callback hell

Post by pgimeno » Fri Feb 08, 2019 12:42 am

Here's a PoC of the scheduler I mentioned. The code is very sketchy, but it hopefully illustrates the idea. It launches 7 tasks: 5 random walkers, a keyboard-controlled sprite and a RPG-style text display. Use WASD (or ZQSD on French keyboards) to move the red rectangle. Use Space to accelerate text; Enter to show all text at once. Press Enter once again to clear the text when it finishes displaying.

What I love about coroutines is that they reverse the concept of callbacks. You can focus on implementing the logic of your object as a single procedure, with no need to skip from callback to callback implementing the different sections. Except for the drawing, in this case.

The idea of the scheduler is simple: keep executing tasks until all of them are sleeping. In this code I didn't implement any commands that don't sleep, therefore every task switch will also put the task to sleep, but it should be possible to implement commands that keep the task awake, while allowing others to run.

The field that indicates whether the task is sleeping also contains a table with the wake-up function that needs to be called in order to check if it should be woken up, together with its parameters. Care is taken to check every sleeping thread at least once per frame.

There's no function to remove tasks (or objects from the demo, for that matter) in this simple implementation.
Attachments
scheduler-demo.love
(2.59 KiB) Downloaded 47 times

Post Reply

Who is online

Users browsing this forum: Google [Bot], Nelvin and 9 guests