Types de blocks
Les blocks sont les briques d'une scène de dialogue — chaque nœud dans le graphe de l'éditeur est un block. Le engine route le flow de block en block et appelle le handler correspondant à chaque type.
Il existe 5 types : Dialog, Choice, Condition, Action et Note. Les quatre premiers sont des blocks de contenu avec un handler dédié (onDialog, onChoice, onCondition, onAction) — les quatre sont required et validés à l'appel de start(). Les blocks Note sont automatiquement ignorés.
Les handlers se déclinent en deux niveaux : les global handlers (enregistrés sur le engine) couvrent toutes les scènes et suffisent pour la plupart des jeux. Les scene handlers (enregistrés sur un SceneHandle) peuvent compléter ou remplacer les globaux pour une scène spécifique. Voir Handlers pour le détail.
DIALOG
Un block dialog représente une réplique — un personnage qui parle, un narrateur, un texte à l'écran. Le engine résout le personnage via le callback onResolveCharacter et l'expose dans context.character. Un handler dialog typique crée une instance de texte dans le jeu (textbox, bulle, sous-titre…), attend que le joueur ou une animation termine, puis appelle next() pour avancer le engine. La fonction de cleanup optionnelle permet de nettoyer les effets de bord quand le engine passe au bloc suivant.
engine.onDialog(({ block, context, next }) => {
const { dialogueText, nativeProperties } = block;
const { character, resolveCharacterPort } = context;
const text = game.getLocalizedText(dialogueText);
const emotion = game.getCharacterEmotion(character);
character && resolveCharacterPort(character.uuid);
game.moveCameraToCharacter(character);
game.animateCharacter(character, emotion);
const dialog = game.createDialog(text, character, emotion);
const shouldWaitInput = game.shouldWaitPlayerInputForDialog(nativeProperties);
// next() tells the engine this block is done — call it when the player
// dismisses the dialog or when the text animation finishes on its own
if (shouldWaitInput) {
dialog.onInput(() => next(), { once: true });
} else {
dialog.then(() =>
game.wait(nativeProperties?.timeout ?? 0).then(() => next()),
);
}
// cleanup: runs when the engine moves to the next block
return () => {
dialog.destroy();
game.animateCharacter(character, false);
};
});engine.OnDialog(args => {
var (scene, block, context, next) = args;
var (text, ch, emotion) = (
Game.GetLocalizedText(block.DialogueText),
context.Character,
Game.GetCharacterEmotion(context.Character)
);
if (ch != null) context.ResolveCharacterPort(ch.Uuid);
Game.MoveCameraToCharacter(ch);
Game.AnimateCharacter(ch, emotion);
var dialog = Game.CreateDialog(text, ch, emotion);
var shouldWaitInput = Game.ShouldWaitPlayerInputForDialog(block.NativeProperties);
// next() tells the engine this block is done — call it when the player
// dismisses the dialog or when the text animation finishes on its own
if (shouldWaitInput)
dialog.OnInput(() => next(), once: true);
else
dialog.Then(() =>
Game.Wait(block.NativeProperties?.Timeout ?? 0).Then(() => next()));
// cleanup: runs when the engine moves to the next block
return () => {
dialog.Destroy();
Game.AnimateCharacter(ch, false);
};
});engine.onDialog([&game](auto* scene, auto* block, auto* ctx, auto next) -> CleanupFn {
auto* ch = ctx->character();
auto text = game.getLocalizedText(block->dialogueText);
auto emotion = game.getCharacterEmotion(ch);
if (ch) ctx->resolveCharacterPort(ch->uuid);
game.moveCameraToCharacter(ch);
game.animateCharacter(ch, emotion);
auto* dialog = game.createDialog(text, ch, emotion);
auto shouldWaitInput = game.shouldWaitPlayerInputForDialog(block->nativeProperties);
// next() tells the engine this block is done — call it when the player
// dismisses the dialog or when the text animation finishes on its own
if (shouldWaitInput) {
dialog->onInput([next]() { next(); });
} else {
dialog->then([&game, next, block]() {
game.wait(block->nativeProperties ? block->nativeProperties->timeout.value_or(0) : 0)
.then([next]() { next(); });
});
}
// cleanup: runs when the engine moves to the next block
return [dialog, &game, ch]() {
dialog->destroy();
game.animateCharacter(ch, false);
};
});engine.on_dialog(func(args):
var block = args["block"]
var ctx = args["context"]
var next_fn = args["next"]
var ch = ctx.character
var text = game.get_localized_text(block.get("dialogueText"))
var emotion = game.get_character_emotion(ch)
if ch:
ctx.resolve_character_port(ch.get("uuid", ""))
game.move_camera_to_character(ch)
game.animate_character(ch, emotion)
var dialog = game.create_dialog(text, ch, emotion)
var should_wait = game.should_wait_player_input(block.get("nativeProperties"))
# next_fn.call() tells the engine this block is done — call it when the player
# dismisses the dialog or when the text animation finishes on its own
if should_wait:
dialog.on_input(func(): next_fn.call(), true)
else:
await dialog.wait()
await game.wait(block.get("nativeProperties", {}).get("timeout", 0))
next_fn.call()
# cleanup: runs when the engine moves to the next block
return func():
dialog.destroy()
game.animate_character(ch, false)
)Quand le narrative designer assigne un output dédié par personnage (portPerCharacter), le handler doit appeler resolveCharacterPort() pour indiquer au engine quel chemin suivre lors du next().
CHOICE
Un block choice représente un embranchement où le joueur choisit — un menu de réponses, des options de dialogue. Le context.choices contient toutes les options disponibles. Quand onResolveCondition() est configuré, chaque option est taggée visible: true | false — le handler filtre et affiche celles qu'il veut. Après l'interaction du joueur, selectChoice(uuid) indique au engine quel chemin suivre, puis next() avance le flow.
engine.onChoice(({ block, context, next }) => {
const { nativeProperties } = block;
const { choices, selectChoice } = context;
const visible = choices.filter(c => c.visible !== false);
const dialog = game.createChoice(visible);
// when the player picks a choice, select it and advance
dialog
.then((selected) => selectChoice(selected))
.finally(() => next());
// optional: if the narrative designer set a timeout on this block
if (nativeProperties?.timeout) {
const timeout = game.wait(nativeProperties.timeout).then(() => next());
dialog.finally(() => timeout.cancel());
}
return () => dialog.destroy();
});engine.OnChoice(args => {
var (scene, block, context, next) = args;
var visible = context.Choices
.Where(c => c.Visible != false).ToList();
var dialog = Game.CreateChoice(visible);
// when the player picks a choice, select it and advance
dialog.OnSelect(selected => {
context.SelectChoice(selected);
next();
});
// optional: if the narrative designer set a timeout on this block
if (block.NativeProperties?.Timeout is { } timeout)
Game.Wait(timeout).Then(() => next());
return () => dialog.Destroy();
});engine.onChoice([&game](auto* scene, auto* block, auto* ctx, auto next) -> CleanupFn {
std::vector<const RuntimeChoiceItem*> visible;
for (const auto& c : ctx->choices())
if (!c.visible.has_value() || c.visible.value())
visible.push_back(&c);
auto* dialog = game.createChoice(visible);
// when the player picks a choice, select it and advance
dialog->onSelect([ctx, next](const auto& selected) {
ctx->selectChoice(selected);
next();
});
// optional: if the narrative designer set a timeout on this block
if (block->nativeProperties && block->nativeProperties->timeout) {
game.wait(*block->nativeProperties->timeout).then([next]() { next(); });
}
return [dialog]() { dialog->destroy(); };
});engine.on_choice(func(args):
var block = args["block"]
var ctx = args["context"]
var next_fn = args["next"]
var visible = []
for c in ctx.choices:
if c.get("visible") != false:
visible.append(c)
var dialog = game.create_choice(visible)
# when the player picks a choice, select it and advance
var selected = await dialog.choice_selected
ctx.select_choice(selected)
next_fn.call()
# optional: if the narrative designer set a timeout on this block
# use a Timer node or game.wait() to handle timeouts natively
return func(): dialog.destroy()
)Voir Choice Visibility pour le système complet de tagging opt-in.
CONDITION
Un block condition est un aiguillage invisible — il évalue l'état du jeu et envoie le flow sur l'un de deux chemins sans que le joueur le voie. Le handler évalue les conditions du block (variables, flags, inventaire…) puis appelle context.resolve(result) — true suit le port 0, false suit le port 1. Les conditions dont la clé commence par choice: référencent un choix précédent du joueur — scene.evaluateCondition(cond) les résout automatiquement via l'historique interne.
Le block condition supporte deux modes d'évaluation :
Mode switch (par défaut) : les groupes de conditions sont évalués en séquence. Le premier groupe qui match route le flow vers son port (
true/case_N). Si aucun ne match, le flow suit le portfalse/default. C'est unswitch/caseavec break implicite.Mode dispatcher (
enableDispatcher= true) : tous les groupes qui matchent déclenchent leur port simultanément en tant que tracks async. Le portfalse/defaultdevient la track principale de continuation ("Continue") et est toujours exécuté, qu'il y ait des matchs ou non. Les blocks connectés aux ports de condition doivent être async. C'est un pattern "fire & dispatch" — idéal pour déclencher des réactions parallèles (multi-NPC, événements simultanés) sans bloquer le flow principal.
engine.onCondition(({ block, context, next }) => {
const { conditionGroups } = context;
const isDispatcher = !!block.nativeProperties?.enableDispatcher;
// conditionGroups are pre-evaluated when onResolveCondition is installed.
// Each group has: conditions, portIndex, result (true/false/undefined).
const matched = conditionGroups
.filter((g) => g.result)
.map((g) => g.portIndex);
// switch mode: first match index or -1 (default port)
// dispatcher mode: all matched indices (each fires an async track)
const result = isDispatcher ? matched : (matched[0] ?? -1);
context.resolve(result);
next();
});engine.OnCondition(args => {
var groups = args.Context.ConditionGroups!;
var isDispatcher = args.Block.NativeProperties?.EnableDispatcher == true;
var matched = groups.Where(g => g.Result == true).Select(g => g.PortIndex).ToList();
object result = isDispatcher ? (object)matched : (object)(matched.Count > 0 ? matched[0] : -1);
args.Context.Resolve(result);
args.Next();
return null;
});engine.onCondition([](auto*, auto* block, auto* ctx, auto next) -> CleanupFn {
// Result is auto-resolved by the engine when onResolveCondition is installed.
// Override with ctx->resolve(result) if needed.
next();
return {};
});engine.on_condition(func(args):
var ctx = args["context"]
var groups = ctx.condition_groups
var np = args["block"].get("nativeProperties")
var is_dispatcher = np is Dictionary and np.get("enableDispatcher", false)
var matched = []
for g in groups:
if g.get("result", false):
matched.append(g.get("port_index", 0))
var result = matched if is_dispatcher else (matched[0] if matched.size() > 0 else -1)
ctx.resolve(result)
args["next"].call()
return Callable()
)ACTION
Un block action déclenche des effets de bord dans le jeu — donner un item, jouer un son, activer un flag. Chaque action référence un actionId que le développeur mappe vers ses propres systèmes. Le handler exécute la liste d'actions puis appelle context.resolve() pour suivre le port "then", ou context.reject(error) pour suivre le port "catch" (fallback sur "then" si aucun "catch" n'existe).
engine.onAction(({ block, context, next }) => {
const { actions } = block;
game
.executeActionsList(actions)
.then(() => context.resolve())
.catch((err) => context.reject(err))
.finally(() => next());
});engine.OnAction(args => {
var (scene, block, context, next) = args;
Game.ExecuteActionsList(block.Actions, onComplete: () => {
context.Resolve();
next();
}, onError: err => {
context.Reject(err);
next();
});
return null;
});engine.onAction([&game](auto* scene, auto* block, auto* ctx, auto next) -> CleanupFn {
auto* ab = dynamic_cast<const ActionBlock*>(block);
game.executeActionsList(ab->actions, [ctx, next]() {
ctx->resolve();
next();
}, [ctx, next](const auto& err) {
ctx->reject(err);
next();
});
return {};
});engine.on_action(func(args):
var block = args["block"]
var ctx = args["context"]
var next_fn = args["next"]
await game.execute_actions_list(block.get("actions", []))
ctx.resolve()
next_fn.call()
)NOTE
Un block note est un pense-bête pour le narrative designer — commentaires, rappels, contexte. Il est automatiquement ignoré pendant la traversée. Il est techniquement possible d'intercepter un block note via onBeforeBlock, mais c'est déconseillé — le block action devrait couvrir tous vos besoins en effets de bord.
Propriétés communes
Tous les blocks partagent ces champs de base (BlueprintBlockBase) :
| Champ | Type | Description |
|---|---|---|
uuid | string | Identifiant unique |
type | BlockType | Type discriminant |
label | string? | Nom lisible par un humain |
parentLabels | string[]? | Hiérarchie des dossiers parents dans l'éditeur |
properties | BlockProperty[] | Propriétés clé-valeur |
userProperties | Record? | Propriétés utilisateur libres |
nativeProperties | NativeProperties? | Propriétés d'exécution |
metadata | BlockMetadata? | Metadata d'affichage (personnages, tags, couleur) |
isStartBlock | boolean? | Marque le block d'entrée |
NativeProperties
| Champ | Type | Description |
|---|---|---|
isAsync | boolean? | Exécuter sur un track async parallèle |
delay | number? | Délai avant exécution (consommé par onBeforeBlock) |
timeout | number? | Timeout d'exécution |
portPerCharacter | boolean? | Un output port par personnage dans les metadata |
skipIfMissingActor | boolean? | Ignorer le block si l'acteur est absent |
debug | boolean? | Flag de debug pour l'éditeur |
waitForBlocks | string[]? | UUIDs de blocks qui doivent être visités avant que ce block puisse progresser |
waitInput | boolean? | Flag passif pour contrôle d'input joueur explicite |
enableDispatcher | boolean? | Mode dispatcher : toutes les conditions valides déclenchent leur port async, le port false/default devient la track de continuation |
