adventuregamestudio / ags

AGS editor and engine source code
Other
676 stars 159 forks source link

Refactor AGS for 1 game loop and game state managing #1349

Open ericoporto opened 3 years ago

ericoporto commented 3 years ago

AGS has many little internal loops, I proppose we refactor to having one loop and a way to manage game states. This should clarify in which game state it's and make it easier to work with things like updating the screen and SDL events.

There are weird things going on, like game responsabilities in graphics drivers to support fading!

Additionally, having only one game loop would greatly improve performance of the Emscripten port (#1346). In this way, #1338 would only be a different state and it would be more obvious.

Syncing sound would be easier and we would not need things like https://github.com/adventuregamestudio/ags/commit/9634b6d3613f2b066998eaaf853945017f59fa01 .

List of all loops:

additional context

ivan-mogilko commented 3 years ago

~I would not call this a refactor, but rather a rewrite...~

Which branch this task is proposed for?

ericoporto commented 3 years ago

I have a feeling we will live with ags3 and ags4 together for a while, so my guess is ags3.

There are many ways to go for this implementation wise. I created the issue to figure the way to go.

LÖVE engine games are interesting to get ideas since each of them solve this in different ways - the engine only has one game state so the game developers always have to figure how to deal with managing states and there are multiple approaches.

The game states have a looping part and a clean up stage that needs to be run before switching state.

ivan-mogilko commented 3 years ago

I have a feeling we will live with ags3 and ags4 together for a while, so my guess is ags3.

I hope not in 3.6.0 though?

For a full picture I must note that the list above is incomplete. There are also blocking actions that run game loop until certain condition is met. Look up for GameLoopUntil* functions. Each of these blocking actions will have to return back to the script where it was started.


In terms of a system, states may have Begin/Update/End functions, and if we have substates (like a blocking function called from within a dialog), then it may also need a stack of states, where states are pushed and removed to return to their parent state.

ericoporto commented 3 years ago

I hope not in 3.6.0 though?

No, no need for. This doesn't block anything now, but I think will make things easier in the future.

Begin/Update/End functions, and if we have substates (like a blocking function called from within a dialog), then it may also need a stack of states, where states are pushed and removed to return to their parent state

That's literally what I imagined to do! I like this approach.

ivan-mogilko commented 3 years ago

There may be at least two approaches to the stack of states too.

  1. Where only 1 last pushed (topmost or bottom, depending on how you look at this) state runs. Then it will have to control what is allowed to update while it's active.
  2. Where all the states in the stack may run, each having its own responsibilities. Some states may be paused probably.
ericoporto commented 3 years ago

I think 1 is thinking on game states and it was what I had in mind.

2 is something that I don't know how to name (state-tag?) but I do understand what it means, and it could reduce code duplication. For me going to 2 looks harder (probably just because I had not given thought to it before) but I see advantages with this approach too.

ivan-mogilko commented 2 years ago

Perhaps, for ags3 this may be done in a simplest possible way: define states as enum, and have a stack with push/pop mechanic to start the state and get back out to the "main" one. Popping main state would exit the game.

I may propose following approach to the refactor:

  1. Get all loops from the graphic driver classes. Instead run the loop outside and pass draw lists to renderer and call its Render method as usual.
  2. Make sure each internal state have very explicit Begin/Update/End structure split into functions, where "begin" initializes the state and allocates resources, "end" disposes resources and resets variables, "update" runs the game loop and render. You may check DialogOptions for example. When we have all the states structured in same way, it will be much easier to understand what is happening and easier to proceed.
  3. As we have all the states broken into Begin/Update/End, we may actually write a main update function with a big switch, that runs update for the current state, and state switching functions that change current state calling "Begin" when the new state starts and "End" when the state ends before returning to previous one.

EDIT: will make sense to have Render along with Update, because there are situations when we want to draw, but not update, or vice-versa.

ivan-mogilko commented 11 months ago

Since this ticket was mentioned from another PR, a small amendment to the above discussion about state classes. Previously mentioned that the state class should have Begin/Update/End and perhaps Render function. It's clear now they should have virtual input handling methods, like OnKey, OnMouseButton, and so forth, and it will be useful to have a input processing loop in a base state class that runs the general algorithm of reading the input events queue and calling virtual child methods per each event. There's a question of whether we still need to have our own input events queue, or may have the SDL events processing right in this base state class. We may begin with keeping current way where we gather our internal queue first and process later when the state needs to. Then this may be revisited after separating sdl events and game update into 2 or more threads.

ivan-mogilko commented 5 months ago

For the record, in regards to this:

For a full picture I must note that the list above is incomplete. There are also blocking actions that run game loop until certain condition is met. Look up for GameLoopUntil* functions. Each of these blocking actions will have to return back to the script where it was started.

This must be elaborated, because it's one of the major issues in this task. I actually suspect it's the biggest one.

Each blocking script function runs a nested game loop while waiting. In order to unwind these nested loops, the blocking function should only start "waiting" state, and return back to the script interpreter. But script interpreter cannot continue current code, instead it will have to suspend this instance at the point of calling the function, and return from Run(). And this instance will have to be able to resume from the same point afterwards. But that's part of a deal, another part is the blocking function's return value, which cannot be ready until the waiting state ends. So, when resuming an Instance, engine has to somehow pass this return value, which Instance will finally write into the register.

But this is still not all... because that script may be run as a callback in a sequence of callbacks. For example, engine runs event in each script module in order. Next modules cannot be run until previous is done. So, not only we should suspend the run Instance, but we should suspend the event processing, and anything else that wraps it.

ericoporto commented 2 months ago

There is a proposal for browsers for the JavaScript Promise Integration API that has possibilities to enable me to drop Asyncify in the Emscripten port, even if this issue isn't implemented, but this will take at least two years (super optimistically) to be enabled by default in major browsers (Chrome, Safari, Firefox, Edge), assuming the proposal is accepted.

This issue being solved would still be nice for future Apple platforms with SDL3 - I guess something to look into in two years too.

ivan-mogilko commented 2 months ago

The irony here is that the problem with pausing a script (mentioned in my previous comment), which is a major blocking issue, and recently I've been thinking that it would be easier to solve if script VM was run on a separate thread.

In simple words, the multithreaded solution makes script running a sort of a "coroutine", which halts execution before giving control back to the game update, and can continue from the same point later, when control is returned to it. I think that this could have been a much more elegant solution, than figuring out how to break out from the nested script calls.

But I obviously cannot plan this if there's a port that cannot use threads, as it will require a special separate handling anyway...