New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add asynchronous events & Wait action #3535
Conversation
Nice start, thanks for giving this a try :)
I think that's fine for now. We might want to maybe avoid this to allow things like reporting errors in the future with a link to the "original event" (i.e: mark events having errors in a Diagnostic Manager, and allow the events to display these errors visually in the events sheet, which means the original event pointer/address must be stored). But to begin we can do with your workaround and introduce something later in the code generation if we don't want this anymore :) My main concern for now is that the This is why I think I discussed in another topic the fact that these promises should be stored somewhere inside the game engine (probably the runtime scene?). // In the code generated for the action:
const pickedObjectsMap = ...; // Construct a map of all picked objects (to do at code generation I guess)
runtimeScene.waitForPromise(wait(3000), pickedObjectsMap, eventsListXxx); // eventsListXxx is the function to run after.
// The function
waitForPromise(promise, pickedObjectsMap, continuation) {
const waitedPromise = {
// TODO: handle errors better I guess.
promise: promise.then(() => waitedPromies.settled = true, () => waitedPromies.settled = true),
continuation,
settled: false,
pickedObjectsMap,
};
this._waitedPromises.push(waitedPromise);
}
// In the runtime scene, before events:
for(const waitedPromise of this._waitedPromises) {
if (waitedPromise.settled) continuation(this, waitedPromise.pickedObjectsMap); // TODO: not sure how to "restore" objects
} By doing this, it means that the runtime scene can choose when to run the "continuation" events. So it should flow naturally and never run outside of the scene. It's predictable :) I'm not sure exactly how to handle objects, but pretty sure that when deleting objects, we'll need to go through the |
Yep, that sounds like a good idea. Only a little objection: I do not think it should be part of the main events loop. One of the main advantages of javascript promises is that it doesn't run anytime, it runs when there is no other code running (so it won't run in the middle of rendering, events code, or something, but always between two game frames), and it runs ASAP, where our game event loop, while called often, isn't called nearly as often as necessary for example for making a game server (you wouldn't believe the performance difference between a P2P "server" I made in GDevelop then in JavaScript. I didn't actually benchmark it, but one was fluid while the other was unplayable). If we execute asynchronous callbacks only before scene events, it doesn't really change anything for the user since both happen at the same time in the events loop, except that waiting for the beginning of the next frame makes the processing slightly more delayed for one option. |
Also, I think I've found an IMO better solution, see It seems to work well, I've found no issues after playing for a while with a few configurations of objects and groups. |
Very interesting idea! At least thinking about it I think it should work in theory. Remember that objects like sprites can and will be recycled when there number is high enough. You should ensure there are no callbacks surviving a recycling.
I think the difference is in what the game loop pattern vs a game server is:
But because these are two different use cases, I'm not sure I would discard the pattern of "storing promises then execute the continuation at the next game loop". Our game engine is single threaded for the moment, so it should actually not make a difference for players? For game servers (well, this is not officially supported), in this case I would recommend that we enable a different "mode" ("game server" mode) that would switch to another implementation, following this time your pattern "execute promise asap without storing them for the next frame" (because every milliseconds count). |
Note that you can feel free to continue experimenting without the "stored waiting promises" for now if you don't believe in it, but I still think we'll need this abstraction for the usual "mode" of running things in the game loop. It's not actually far from the reactor pattern/event loop integrated into Node.js/a browser :) It's just that it's tied to the game loop so you're paying extra latency of each frame (especially if the main logic is almost inexistant and so everything is running in short lived promises). |
Fixes edge cases when using multiple async actions and subevents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Self-review time 😎
I think generally we should recheck all variables and functions name as I think most are misleading either objectively or to everyone that isn't me.
It's going at a good pace so far, I am pretty confident that this will end up working fine :)
Core/GDCore/Events/CodeGeneration/EventsCodeGenerationContext.cpp
Outdated
Show resolved
Hide resolved
Core/GDCore/Events/CodeGeneration/EventsCodeGenerationContext.h
Outdated
Show resolved
Hide resolved
Core/GDCore/Events/CodeGeneration/EventsCodeGenerationContext.h
Outdated
Show resolved
Hide resolved
Nice, it now works across all events types! Except... The for each variable. Personally, I think that to be really fixed we need to add block-scoped variables/ declaration of variables for a block, which are kind of out of scope. Would it be acceptable to leave this broken for now, add big warnings on the wiki, and fix it in a later PR, since it would complexify this one too much to do it now? |
That's... Really weird. The wait function seems to work fine in events function, but the events function itself somehow gets (inconsistently, but most of the time) generated as if it was an asynchronous function??? (Which crashes the game since it doesn't return anything to .then) |
I'm marking this ready for review since it is mostly working now, but the work is not finished |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Went through the code. Overall I think it make sense! I'll still be thinking about alternate or slightly different approaches to see if we can simplify some things.
Questions about:
- As discussed I'd like to get the
then
removed from code generation instead call aruntimeScene.waitForPromise
or similar method. In some implementation it could be athen
, but in the classic game engine we'll probably have the waited promise callbacks run before events. - How is this working in extension and, probably more hairy to think about, custom behavior methods? :)
Core/GDCore/Events/CodeGeneration/EventsCodeGenerationContext.h
Outdated
Show resolved
Hide resolved
The problem with that is that the only integrated (not counting user extensions) realtime networking option is P2P, and what most users mess with when trying out multiplayer (as far as I see from my interactions with the community at least). When connecting the games together like this directly though, they are all like a "game server", and that is also what i meant by that. If all games take many milliseconds before beginning to process the messages of each other, the game simply becomes unplayable, and people will criticise GDevelops networking for being way too slow. |
I found a bug thanks to a test: LongLivedObjectsList is indexing objects using their names, but their names are just their "original names" while in a function, they are referred to by another name (the parameter name). See the changes made here (the first test changed, the one which is the simplest, is also failing): 446e94e Note that deletion of objects was working well (before I hit this bug). |
I think I fixed the issue by passing the "object name", as known during code generation. @arthuro555 Is it possible for you to commit some test games you've been using, in GDJS/tests/games? Will be useful to have one or two games in addition to the automated tests. |
// Generate code to backup the objects lists | ||
gd::String objectsListsBackupCode = | ||
parentContext.IsAsync() | ||
? "asyncObjectsList = " |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@arthuro555 This seems dangerous, you assign the parameter asyncObjectsList to a new version made from itself. What happens if this is called multiple times?
You will create an asyncObjectsList from asyncObjectsList, itself from asyncObjectsList.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Async events guarantee that, after one, the current function ends, since the code of any actions/subevents afterward is put in a callback function after the asynchronous task has finished executing. So this piece of code cannot be twice in the same function. Agreed, ideally, we'd use a separate variable (const newAsyncObjectsLists
) but that would add more complexity for generating the arguments of callback functions since they would not match up between the calling end and the invoked end. The definition of the variable is as an argument of the function, setting it only changes the value for the current function, which has reached its end anyway, making it fully harmless. Therefore I judged this as not important enough to be worth increasing the complexity of the arguments code generation.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was concerned about being in an async callback with two subevents having both two async actions.
I think this works because the two subevents will be generated in two different functions, but that's a detail that may not hold in the future.
I think it's safer to do const parentAsyncObjectsLists = asyncObjectsLists; const asyncObjectsLists = gdjs.LongLivedObjectsList.from(parentAsyncObjectsList)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually the trick of declaring a parentXxx variable does not work well because the shadowing of variable does not work as expected ( Cannot access 'asyncObjectsLists' before initialization
) :/
Might still be worth avoiding modifying the parameter so that we can in the future not be afraid of putting sub events in functions or not.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That'd be a syntax error, one cannot declare a variable using the name of an argument:
The event code generation with one function per event is currently not just a detail in my opinion, but a pretty important implementation choice for GDJS. If we were to change it, though I don't see why we would do it in the foreseeable future for GDJS, I'm pretty sure other changes would be needed anyways. We could do such a change then, which would be trivial with scoped blocks and no arguments preventing the "overshadowing".
I have another test that fails because somehow it generates in the final callback, when refering to a group ("MyGroup") that contains "MyParamObject1" and "MyParamObject2":
instead of
As if the context forgot that MyParamObject2 was already referred to before. I'll take a look but this looks suspicious. |
…cts across async actions
void EventsCodeGenerationContext::InheritsAsAsyncCallbackFrom( | ||
const EventsCodeGenerationContext& parent_) { | ||
parent = &parent_; | ||
nearestAsyncParent = this; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@arthuro555 this line seems weird to me. The nearest parent of an async callback is... itself?
Shouldn't this be either the parent, if async, or the nearest async parent of the parent (which can be null)?
Note that if we change this, we probably need to change the InheritsFrom
too and the way we iterate on the for
loops over EventsCodeGenerationContext (to start at this
, rather than at the parent... I guess).
I'm asking because I think this is creating an issue where:
- we want to check if an object is declared in the parent.
- but because we check the current context nearest async parent, which is itself, the object is set to be declared.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is normal, since it is the simplest way to have a parent tell all its children that it is the new nearest context as that pointer is inherited from a parent. This is used in two places: ObjectsListNeeded
, in which we do want this context to be taken into account, as otherwise, the current async action would not know to pass down objects lists to the actions directly following it.
In ShouldUseAsyncObjectsLists
though, it looks like that is an issue, yes. If an object has been used in the previous actions of the current context, it doesn't mean that we should suddenly start using async lists.
What I propose is that instead of changing this line, which is correct and the most simple (as in performant) for most uses, I propose that instead, we change the for loop in ShouldUseAsyncObjectsLists
to start at nearestAsyncParent->parent->nearestAsyncParent
instead of nearestAsyncParent
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just made a commit for that, please tell me if I missed something I have not touched the code for about a month and might remember the full context around it incorrectly.
// `LongLivedObjectsList` to the callback function. | ||
for (const auto& objectUsedInSubTree : | ||
callbackContext.GetAllDeclaredObjectsAcrossChildren()) { | ||
if (parentContext.ObjectAlreadyDeclared(objectUsedInSubTree)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note for me: check if should be also checking what is being declared by the context (and refactor the contexts so that things are not set as declared anymore, this mutation is making things more complex to reason about)
I found the issue: it seems that the documentation of /**
* Return true if an object list has already been declared (or is going to be
* declared).
*/
bool ObjectAlreadyDeclared(const gd::String& objectName) const It seems that last part is not true, because if I replace ObjectAlreadyDeclared with IsToBeDeclared in The description is true only after calling the code generation function for the context, which I guess might not have been yet is some cases. |
I modified the code to also check for IsToBeDeclared if ObjectAlreadyDeclared was false, and the failing tests now pass 👍 |
I got to the same conclusion! I started to rename this to
This would align the reasoning I think between async/non async and make the "declared objects in the parents" consistent? |
This reverts commit 5b39fc1.
Some may fail, I just tested on another branch with a slightly different approach - don't lose time fixing these :)
FYI, I've added some more tests. Some may fail, I just tested on another branch with a slightly different approach - don't lose time fixing these :) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've pushed another round of tests. I have another branch based on this where I've done a few changes on all object lists are declared. I'll make a PR soon and we'll see if there are things to merge/how to proceed :)
Core/GDCore/Events/CodeGeneration/EventsCodeGenerationContext.cpp
Outdated
Show resolved
Hide resolved
@arthuro555 Any idea how we could create a test case that would have failed on the |
I think that should be used when you have such an event:
With a scene that has two object A, one with the variable a being 1 and the other 0. With the previous typo, that last action would have been generated getting the objects from the normal object source (the scene or function context) instead of the saved object list, so we'd need to make the test ensure that after that code, one object has still the value 0, and the other has now the value 2. Which is odd, since we should already be testing for that 🤔 |
Closing as merged in #3852! Wow! 🥳 |
Closes #2975
Aims at adding asynchronous event support to GDevelop.
TODO (polish):
Add an instruction that uses PromiseTask to ensure it worksUnit tests show that it worksasyncObjectsLists
Will be done in another PR since it is a bit complexNevermind, it was extremely easy after all 😅DONE:
This PR uses a kind-of workaround to make this work: While preprocessing the events, iterate over actions, and if an asynchronous one is found, move them all with the subevents to an "asynchronous" subevent. This is because otherwise, it is difficult to generate the asynchronous callback function. The part that would do the generation of that function would be
gd::EventsCodeGenerator::GenerateActionsListCode
. That function doesn't have access to subevents though, it is the responsibility of an event's code generator to mix actions, conditions, and subevents code generation as it likes. Splitting that code generation between both is hard and error prone. By moving affected actions and subevents together to an internal subevent, we can do a custom mix of actions and subevents, allowing us to have one function responsible for all code generation of those subevents and actions again, allowing us to have a less error-prone and concise code generation.Of course, this isn't without issues:
First, while it works like a charm right now, it uses the assumption that moving subevents and actions to a single subevent will not change the behavior of the event. That assumption might not child true for potential future events, for example, if we are to add an "if/else" event, moving actions from the if branch to the subevents of the else branch may break the event completely.
The preprocessing, although I have not benchmarked it, must be adding quite an overhead on code generation.
This can be confusing to contributors since they might not understand why this event is not behaving or being used the same as the other ones or may be confused as to how half of the code works if they are unaware of the other half (preprocessing and code-generation).
Overall though, I think this should be mostly fine, let's get this PR done before assuring it will fully work though! 😁