Lifecycle & Validation
Ordre d'exécution pour chaque block
- Cleanup du block précédent — La fonction de cleanup retournée par le handler du block précédent s'exécute au moment de la transition (quand
next()est appelé) onValidateNextBlock— Validation avant exécutiononBeforeBlock— Pré-traitement (doit appelerresolve()pour continuer)- Handler de type (Tier 2 puis Tier 1)
Events de scène
engine.onSceneEnter(({ scene, context }) => {
// Called when handle.start() is executed
});
engine.onSceneExit(({ scene, context }) => {
// Called when the scene ends. context.reason says why:
// 'completed' | 'cancelled' | 'invalidated' | 'faulted' | 'deadlocked'
// scene.getSceneId() says WHICH scene — a global handler needs it when several play.
if (context.reason === 'faulted') {
console.error(`Scene ${scene.getSceneId()} failed`, context.error);
}
if (context.reason === 'deadlocked') {
console.warn('Scene stuck waiting for', context.waitingFor);
}
});engine.OnSceneEnter(args => {
// Called when handle.Start() is executed
});
engine.OnSceneExit(args => {
// Called when the scene ends. args.Context.Reason says why (SceneEndReason.*)
// args.Scene.GetSceneId() says WHICH scene — a global handler needs it when several play.
if (args.Context.Reason == SceneEndReason.Faulted)
Debug.LogException(args.Context.Error);
if (args.Context.Reason == SceneEndReason.Deadlocked)
Debug.LogWarning($"{args.Scene.GetSceneId()} stuck waiting for {string.Join(", ", args.Context.WaitingFor)}");
});engine.onSceneEnter([](const lsde::SceneLifecycleArgs& args) {
// Called when handle->start() is executed
});
engine.onSceneExit([](const lsde::SceneLifecycleArgs& args) {
// Called when the scene ends. args.context.reason says why (lsde::SceneEndReason::*)
// args.scene->getSceneId() says WHICH scene — a global handler needs it when several play.
if (args.context.reason == lsde::SceneEndReason::Faulted) {
// args.context.error holds the exception: std::rethrow_exception it to read it
}
if (args.context.reason == lsde::SceneEndReason::Deadlocked) {
// args.context.waitingFor names the blocks the scene was stuck on
}
});engine.on_scene_enter(func(args):
pass # Called when handle.start() is executed
)
engine.on_scene_exit(func(args):
# Called when the scene ends. args["context"]["reason"] says why (LsdeTypes.SCENE_END_*)
# args["scene"].get_scene_id() says WHICH scene — a global handler needs it when several play.
if args["context"]["reason"] == LsdeTypes.SCENE_END_DEADLOCKED:
push_warning("%s stuck waiting for %s" % [args["scene"].get_scene_id(), str(args["context"]["waitingFor"])])
)onValidateNextBlock
Intercepte chaque transition de block pour validation. Le handler reçoit le personnage résolu du prochain block (nextContext) et du block précédent (fromContext) :
engine.onValidateNextBlock(({ nextBlock, fromBlock, nextContext, fromContext }) => {
return { valid: true };
});
engine.onInvalidateBlock(({ scene, reason }) => {
console.error('Invalid block:', reason);
scene.cancel();
});engine.OnValidateNextBlock(args => {
// args.NextContext.Character, args.FromContext?.Character
return new ValidationResult { Valid = true };
});
engine.OnInvalidateBlock(args => {
Console.Error.WriteLine($"Invalid block: {args.Reason}");
args.Scene.Cancel();
});engine.onValidateNextBlock([](const auto& args) {
// args.nextContext.character, args.fromContext.character (check args.hasFromContext)
return ValidationResult{true};
});
engine.onInvalidateBlock([](auto* scene, const auto& reason) {
std::cerr << "Invalid block: " << reason << "\n";
scene->cancel();
});engine.on_validate_next_block(func(args):
# args["nextContext"]["character"], args["fromContext"]["character"]
return {"valid": true}
)
engine.on_invalidate_block(func(args):
printerr("Invalid block: %s" % args["reason"])
args["scene"].cancel()
)Character Gating
Utilisez nextContext.character pour contrôler quels blocks peuvent s'exécuter selon l'état du jeu :
// Block if the character is stunned
engine.onValidateNextBlock(({ nextContext }) => {
const { character } = nextContext;
if (!character) return { valid: false, reason: 'no_character' };
if (game.characterHasStatus(character, 'stunned'))
return { valid: false, reason: 'character_stunned' };
return { valid: true };
});engine.OnValidateNextBlock(args => {
var character = args.NextContext.Character;
if (character == null)
return ValidationResult.Fail("no_character");
if (game.CharacterHasStatus(character, "stunned"))
return ValidationResult.Fail("character_stunned");
return ValidationResult.Ok();
});engine.onValidateNextBlock([&game](const auto& args) {
auto* character = args.nextContext.character;
if (!character) return ValidationResult{false, "no_character"};
if (game.characterHasStatus(character, "stunned"))
return ValidationResult{false, "character_stunned"};
return ValidationResult{true};
});engine.on_validate_next_block(func(args):
var character = args["nextContext"]["character"]
if character == null:
return {"valid": false, "reason": "no_character"}
if game.character_has_status(character, "stunned"):
return {"valid": false, "reason": "character_stunned"}
return {"valid": true}
)Utilisez fromContext.character pour valider les transitions entre personnages (ex: relations, cooldowns). fromContext est null pour le premier block d'une scène.
onBeforeBlock
Appelé avant chaque block. resolve() doit être appelé pour continuer :
engine.onBeforeBlock(({ block, resolve }) => {
const delay = LsdeUtils.getNativeProperties(block)?.delay;
if (delay) {
setTimeout(resolve, delay * 1000);
} else {
resolve();
}
});engine.OnBeforeBlock(args => {
var delay = args.Block.NativeProperties?.Delay;
if (delay.HasValue)
{
// use your engine's delay system (coroutine, DOTween, Invoke, etc.)
DelayThenCall((float)delay.Value, args.Resolve);
}
else
{
args.Resolve();
}
});engine.onBeforeBlock([](const auto& args) {
auto delay = args.block->props
? args.block->props->delay : std::nullopt;
if (delay.has_value()) {
// use your engine's timer system (FTimerManager, SDL_AddTimer, etc.)
scheduleDelay(delay.value(), [&args]() { args.resolve(); });
} else {
args.resolve();
}
});engine.on_before_block(func(args):
var delay = args["block"].get("props", {}).get("delay", 0)
if delay > 0:
await get_tree().create_timer(delay).timeout
args["resolve"].call()
)Un resolve() appelé pendant que onBeforeBlock s'exécute encore prend effet à son retour — exactement comme next() appelé dans un handler. Le handler de type est dispatché une fois votre callback terminé : une ligne écrite après resolve() s'exécute donc avant le block, pas après.
Fonctions de cleanup
Un handler peut retourner une fonction de cleanup, appelée quand le block est quitté :
engine.onDialog(({ block, next }) => {
const element = showDialogUI(block);
// next() is called later — by player input, timer, etc.
return () => {
element.remove(); // called when the engine moves to the next block
};
});engine.OnDialog(args => {
var element = ShowDialogUI(args.Block);
// next() is called later — by player input, timer, etc.
return () => element.SetActive(false);
});engine.onDialog([](auto*, auto* block, auto* ctx, auto next) -> CleanupFn {
auto* element = showDialogUI(block);
// next() is called later — by player input, timer, etc.
return [element]() { element->remove(); };
});engine.on_dialog(func(args):
var element = show_dialog_ui(args["block"])
# next is called later — by player input, timer, etc.
return func(): element.queue_free()
)Error Boundaries
Rien n'est avalé. Si votre code throw pendant que le engine parcourt le graphe — un handler de type, une fonction de cleanup, onValidateNextBlock, onInvalidateBlock, onBeforeBlock, onResolveCondition, onResolveCharacter ou onSceneEnter — le engine ferme d'abord toute la scène, puis re-throw l'erreur à celui qui a appelé start(), next() ou resolve().
C'est vrai sur chaque track. Un handler qui throw sur une branche isAsync ferme la scène, pas seulement sa branche.
C'est l'ordre qui rend la chose utilisable. Au moment où l'erreur atteint votre code :
- les fonctions de cleanup ont tourné
- les tracks async sont annulés
onSceneExita été appelé, avecreason: 'faulted'et l'erreur elle-même danscontext.error
Le dialogue s'est arrêté proprement, et c'est vous qui décidez de la suite — continuer sans lui, afficher un écran, ou laisser planter. Mettez votre propre try/catch autour de start(), next() ou resolve().
L'erreur arrive à deux endroits, exprès. Elle est lancée à celui qui a appelé next() — dans un jeu, souvent un clic ou un timer — et elle est remise à onSceneExit dans context.error, là où écoute le code qui attend la fin du dialogue. Journalisez-la à un seul des deux endroits.
Une exception levée par onSceneExit lui-même vous parvient de la même façon, et la scène est libérée malgré tout : engine.isRunning() ne la compte plus. Sauf si la scène se fermait déjà sur une faute : vous recevez alors cette faute, celle qui explique le reste.
GDScript
GDScript n'a pas d'exceptions. Une erreur de script dans un handler part dans le log de Godot et l'appel retourne null : le block attend simplement un next() qui ne viendra jamais. Rien ne ferme la scène à votre place.
Pourquoi cela a changé
La v1 avalait silencieusement l'exception d'un handler — sans même la loguer — pendant qu'une exception venant du cleanup retourné par ce même handler, elle, atteignait l'appelant. Une seule faute, deux comportements opposés, et le silencieux a caché de vrais bugs aussi longtemps qu'un projet a tourné.
La 2.0.0 l'avait corrigé pour le handler de type seulement. Une validation, un onBeforeBlock ou un resolver qui throw — ou un handler sur un track parallèle — laissait encore la scène ouverte sans rien pour la faire avancer, et un jeu qui attendait la fin du dialogue attendait pour toujours.
Pourquoi une scène s'est terminée
onSceneExit reçoit la raison dans context.reason :
reason | Quand |
|---|---|
completed | Le flux n'a plus de graphe |
cancelled | scene.cancel() ou engine.stop() |
invalidated | onValidateNextBlock a refusé le block où entrait le dernier track vivant |
faulted | Votre code a throw pendant le parcours. context.error contient ce qui a été lancé — voir Error Boundaries |
deadlocked | Tous les tracks restants sont parqués sur un waitForBlocks que rien ne peut terminer. context.waitingFor nomme ces blocks |
onSceneEnter ne reçoit pas de raison.
scene.getSceneId() dit quelle scène s'est terminée, et scene.getScenePath() donne son chemin. Un onSceneExit global en a besoin dès que deux scènes jouent en même temps. Stockez l'id, pas le chemin : le chemin change le jour où quelqu'un renomme la scène.
Un interblocage ferme la scène. Un block qui attend un block qu'aucun track ne terminera jamais — par exemple un block d'une branche que le flux n'a pas prise — gardait la scène ouverte pour de bon, sans onSceneExit. La scène se ferme maintenant en deadlocked dès que le dernier track capable d'avancer s'arrête, et waitingFor vous dit quelle jointure est mal câblée.
Un seul thread
Le engine n'est pas thread-safe. Appelez next(), resolve() et cancel() depuis le thread qui a démarré la scène — dans Unity, le thread principal ; dans Unreal, le game thread.
En C# et en C++, un appel venant d'un autre thread est refusé par une exception qui nomme les deux threads, et il ne change rien : le même next() fonctionne encore une fois appelé depuis le bon thread. Dans Unity, revenez sur le thread principal avant d'appeler le engine (await UniTask.SwitchToMainThread(), ou mettez l'appel en file pour le thread principal).
cancel()
Appeler scene.cancel() déclenche cette séquence :
- Tous les async tracks sont annulés
- La fonction de cleanup du block courant est exécutée
- Le handler
onSceneExitest appelé, avecreason: 'cancelled' - La scène est marquée comme terminée
Un cleanup qui appelle scene.cancel() ou engine.stop() pendant que la scène se ferme déjà est ignoré : onSceneExit n'est appelé qu'une fois.
engine.onInvalidateBlock(({ scene, reason }) => {
console.error('Validation failed:', reason);
scene.cancel();
});engine.OnInvalidateBlock(args => {
Console.Error.WriteLine($"Validation failed: {args.Reason}");
args.Scene.Cancel();
});engine.onInvalidateBlock([](auto* scene, const auto& reason) {
std::cerr << "Validation failed: " << reason << "\n";
scene->cancel();
});engine.on_invalidate_block(func(args):
printerr("Validation failed: %s" % args["reason"])
args["scene"].cancel()
)NativeProperties
Propriétés d'exécution qui contrôlent comment un block est dispatché par le engine :
| Champ | Type | Description |
|---|---|---|
isAsync | boolean? | Exécuter sur un track async parallèle |
delay | number? | MILLISECONDES avant que le block joue. Appliqué par onBeforeBlock, jamais par le engine |
timeout | number? | MILLISECONDES pendant lesquelles le block RESTE après que sa réplique a été dite, puis il repart de lui-même — une auto-avance pour les blocks. Prime sur waitInput. Passé tel quel ; le engine n'impose rien |
portPerCharacter | boolean? | Un port de sortie par personnage dans metadata |
skipIfMissingActor | boolean? | Sauter le block si l'acteur référencé est absent |
debug | boolean? | Flag de debug pour l'éditeur |
waitForBlocks | string[]? | Ids de blocks de cette scène. Le block est retenu avant d'être dispatché tant qu'ils ne sont pas tous terminés. Si rien ne peut jamais les terminer, la scène se ferme en deadlocked |
waitInput | boolean? | Flag passif pour contrôle explicite de l'input joueur |
