Visibilité des choix
Aperçu
Quand un block CHOICE est dispatché, context.options contient toujours tous les choix définis dans le blueprint — rien n'est pré-filtré. Le engine n'enlève jamais de choix du array.
Pour du filtrage de visibilité (ex. cacher des choix basés sur le game state ou des sélections précédentes), le engine fournit un système de tagging opt-in. Un condition resolver est installé une seule fois, et le engine tag chaque choix avec visible: true | false avant que le handler onChoice le reçoive.
Setup
Enregistrez un condition resolver sur le engine — une seule fois, avant de démarrer une scène :
engine.onResolveCondition((condition) => {
// Evaluate game-state conditions only.
// choice: conditions are handled internally by the engine.
return gameState.check(condition.key, condition.operator, condition.value);
});engine.OnResolveCondition(cond => {
return GameState.Check(cond.Key, cond.Operator, cond.Value);
});engine.onResolveCondition([](const ConditionTest& cond) {
return gameState.check(cond.key, cond.op, cond.value);
});engine.on_resolve_condition(func(cond):
return game_state.check(cond)
)Quand le resolver est installé, le engine évalue les when de chaque choix avant d'appeler onChoice. Le même resolver pré-évalue aussi les groupes de condition blocks -- voir Condition blocks pour les détails.
- Conditions
choice:(qui référencent des sélections précédentes du joueur) sont résolues automatiquement par le engine via son historique de choix interne — le callback ne les reçoit jamais. - Conditions de game-state (tout le reste) sont déléguées au callback.
- Le chaining avec
&(AND) et|(OR) fonctionne correctement entre les deux types.
Filtrage dans onChoice
Dans le handler, le filtrage se fait avec une seule ligne :
engine.onChoice(({ context, next }) => {
// `visible` is undefined when no resolver is installed: unknown, not hidden.
const offered = context.options.filter(o => o.visible !== false);
showOptionsUI(offered, (optionId) => {
context.selectChoice(optionId); // the option id IS the exit port (C1, C2…)
next();
});
});engine.OnChoice(args => {
var offered = args.Context.Options
.Where(o => o.Visible != false).ToList();
ShowChoicesUI(offered, optionId => {
args.Context.SelectChoice(optionId);
args.Next();
});
return null;
});engine.onChoice([](auto*, auto*, auto* ctx, auto next) -> lsde::CleanupFn {
std::vector<const lsde::RuntimeChoiceItem*> offered;
for (const auto& o : ctx->options())
if (!o.visible.has_value() || *o.visible)
offered.push_back(&o);
showOptionsUI(offered, [ctx, next](const std::string& optionId) {
ctx->selectChoice(optionId);
next();
});
return {};
});engine.on_choice(func(args):
var offered = []
for o in args["context"].options:
if o.get("visible") != false:
offered.append(o)
show_options_ui(offered, func(option_id):
args["context"].select_choice(option_id)
args["next"].call()
)
return Callable()
)Pourquoi visible !== false et pas === true?
Quand aucun resolver n'est installé, visible est undefined. Comme undefined !== false donne true, tous les choix passent — rétrocompatible par défaut. Quand un resolver est installé, les choix sont taggés true ou false explicitement.
Valeur de visible | Signification | !== false |
|---|---|---|
true | Resolver installé, le choix passe | true |
false | Resolver installé, choix caché | false |
undefined | Pas de resolver installé | true |
RuntimeChoiceItem
Chaque entrée de context.options est un RuntimeChoiceItem — l'Option du blueprint, plus le tag visible :
interface RuntimeChoiceItem extends Option {
visible?: boolean; // true | false | undefined
}public class RuntimeChoiceItem : Option
{
public bool? Visible { get; set; } // true | false | null
}struct RuntimeChoiceItem : Option {
std::optional<bool> visible; // true | false | nullopt
};# RuntimeChoiceItem is a Dictionary with an extra "visible" key:
# { "id": "C1", "key": "...", "text": {...}, "visible": true/false/absent }Sans resolver, les choix sont toujours des RuntimeChoiceItem mais visible reste undefined/null/nullopt/absent. L'Option elle-même porte id, key, text et when — et son id est le port de sortie (C1, C2…).
Exemples
Standard — afficher les choix visibles
engine.onChoice(({ context, next }) => {
const offered = context.options.filter(c => c.visible !== false);
ui.showOptions(visible, (optionId) => {
context.selectChoice(optionId);
next();
});
});engine.OnChoice(args => {
var visible = args.Context.Options
.Where(c => c.Visible != false).ToList();
ShowChoicesUI(visible, optionId => {
args.Context.SelectChoice(optionId);
args.Next();
});
return null;
});engine.onChoice([](auto*, auto*, auto* ctx, auto next) -> CleanupFn {
std::vector<const RuntimeChoiceItem*> visible;
for (const auto& c : ctx->options())
if (!c.visible.has_value() || c.visible.value())
visible.push_back(&c);
showOptionsUI(visible, [ctx, next](const auto& optionId) {
ctx->selectChoice(optionId);
next();
});
return {};
});engine.on_choice(func(args):
var visible = []
for c in args["context"].options:
if c.get("visible") != false:
visible.append(c)
show_options_ui(visible, func(option_id):
args["context"].select_choice(option_id)
args["next"].call()
)
return Callable()
)Choix minuté — auto-select au timeout
engine.onChoice(({ block, context, next }) => {
const offered = context.options.filter(c => c.visible !== false);
const timeout = LsdeUtils.getNativeProperties(block)?.timeout;
const resolve = (choice) => {
context.selectChoice(choice.id);
next();
};
// `timeout` is MILLISECONDS in v2 — no × 1000 — and on a CHOICE it is counted from
// the moment the options are readable. It still has to SELECT one: an option id IS the
// exit port, so a choice left without selectChoice() resolves to no link at all.
if (timeout) {
const timer = setTimeout(() => resolve(offered[0]), timeout);
ui.showOptions(offered, (optionId) => {
clearTimeout(timer);
resolve(offered.find(c => c.id === optionId));
});
} else {
ui.showOptions(offered, (optionId) => resolve(offered.find(c => c.id === optionId)));
}
});engine.OnChoice(args => {
var (_, block, context, next) = args;
var visible = context.Options
.Where(c => c.Visible != false).ToList();
var timeout = block.NativeProperties?.Timeout;
void Resolve(RuntimeChoiceItem choice) {
context.SelectChoice(choice.Id);
next();
}
if (timeout.HasValue)
{
// use your engine's timer — cancel on player selection
var timer = ScheduleTimer((float)timeout.Value, () => Resolve(visible[0]));
ShowChoicesUI(visible, optionId => {
timer.Cancel();
Resolve(visible.First(c => c.Id == optionId));
});
}
else
{
ShowChoicesUI(visible, optionId => Resolve(visible.First(c => c.Id == optionId)));
}
return null;
});engine.onChoice([](auto*, auto* block, auto* ctx, auto next) -> CleanupFn {
std::vector<const RuntimeChoiceItem*> visible;
for (const auto& c : ctx->options())
if (!c.visible.has_value() || c.visible.value())
visible.push_back(&c);
auto timeout = block->props
? block->props->timeout : std::nullopt;
auto resolve = [ctx, next](const std::string& optionId) {
ctx->selectChoice(optionId);
next();
};
if (timeout.has_value()) {
// use your engine's timer — cancel on player selection
auto timer = scheduleDelay(timeout.value(), [&]() { resolve(visible[0]->id); });
showOptionsUI(visible, [resolve, timer](const auto& optionId) {
timer->cancel();
resolve(optionId);
});
} else {
showOptionsUI(visible, resolve);
}
return {};
});engine.on_choice(func(args):
var ctx = args["context"]
var next_fn = args["next"]
var block = args["block"]
var visible = []
for c in ctx.options:
if c.get("visible") != false:
visible.append(c)
var timeout_val = block.get("props", {}).get("timeout", 0)
if timeout_val > 0:
# use your engine's timer — cancel on player selection
var timer = get_tree().create_timer(timeout_val)
timer.timeout.connect(func():
ctx.select_choice(visible[0]["id"])
next_fn.call()
)
show_options_ui(visible, func(option_id):
timer.time_left = 0 # cancel
ctx.select_choice(option_id)
next_fn.call()
)
else:
show_options_ui(visible, func(option_id):
ctx.select_choice(option_id)
next_fn.call()
)
return Callable()
)Choix cachés affichés en grisé
engine.onChoice(({ context, next }) => {
for (const choice of context.options) {
if (choice.visible === false) {
ui.addGreyed(choice); // show but disabled
} else {
ui.addNormal(choice); // selectable
}
}
// wait for player selection...
});engine.OnChoice(args => {
foreach (var choice in args.Context.Options)
{
if (choice.Visible == false)
AddGreyed(choice); // show but disabled
else
AddNormal(choice); // selectable
}
// wait for player selection...
return null;
});engine.onChoice([](auto*, auto*, auto* ctx, auto next) -> CleanupFn {
for (const auto& choice : ctx->options()) {
if (choice.visible.has_value() && !choice.visible.value())
addGreyed(choice); // show but disabled
else
addNormal(choice); // selectable
}
// wait for player selection...
return {};
});engine.on_choice(func(args):
for choice in args["context"].options:
if choice.get("visible") == false:
add_greyed(choice) # show but disabled
else:
add_normal(choice) # selectable
# wait for player selection...
return Callable()
)Tutorial — ignorer complètement la visibilité
tutorial.onChoice(({ context, next }) => {
// force-select the first choice, no filtering
context.selectChoice(context.options[0].id);
next();
});tutorial.OnChoice(args => {
// force-select the first choice, no filtering
args.Context.SelectChoice(args.Context.Options[0].Id);
args.Next();
return null;
});tutorial->onChoice([](auto*, auto*, auto* ctx, auto next) -> CleanupFn {
// force-select the first choice, no filtering
ctx->selectChoice(ctx->options()[0].id);
next();
return {};
});tutorial.on_choice(func(args):
# force-select the first choice, no filtering
args["context"].select_choice(args["context"].options[0]["id"])
args["next"].call()
return Callable()
)Partager l'évaluateur
Avec onResolveCondition, un seul callback gère à la fois la visibilité des choix et la pré-évaluation des condition blocks. Plus besoin de dupliquer la logique :
// ONE evaluator answers everything about game state: option visibility AND condition cases.
// A test on the reserved `choice` dictionary never reaches it — the engine answers those from the
// history it kept during the scene.
engine.onResolveCondition((test) => game.evaluate(test.dict, test.entry, test.op, test.value));
// onCondition is now optional: the engine already knows which port to take.
// Keep it to log what matched, or to override with a PORT NAME.
engine.onCondition(({ block, context, next }) => {
const matched = context.cases.filter((c) => c.result).map((c) => c.port);
game.log(`${block.id} → ${matched[0] ?? 'default'}`);
next();
});engine.OnResolveCondition(test => Game.Evaluate(test.Dict, test.Entry, test.Op, test.Value));
engine.OnCondition(args => {
var matched = args.Context.Cases
.Where(c => c.Result == true)
.Select(c => c.Port)
.FirstOrDefault() ?? Ports.Default;
Game.Log($"{args.Block.Id} → {matched}");
args.Next();
return null;
});engine.onResolveCondition([&game](const lsde::ConditionTest& test) {
return game.evaluate(test.dict, test.entry, test.op, test.value);
});
engine.onCondition([&game](auto*, auto* block, auto* ctx, auto next) -> lsde::CleanupFn {
for (const auto& c : ctx->cases()) {
if (c.result.value_or(false)) { game.log(block->id + " -> " + c.port); break; }
}
next();
return {};
});engine.on_resolve_condition(func(test: Dictionary) -> bool:
return game.evaluate(test["dict"], test["entry"], test["op"], test["value"]))
engine.on_condition(func(args):
for c in args["context"].cases:
if c["result"]:
game.log("%s -> %s" % [args["block"]["id"], c["port"]])
break
args["next"].call()
return Callable()
)One resolver, two jobs — but not one answer
The same callback feeds option visibility and condition routing, so you write your game-state lookup once.
Inside, the engine keeps them apart on purpose. Routing has to pick a branch, so a question it cannot answer becomes false and the flow takes default. An option has no such obligation: saying false about a question nobody could answer would hide an answer from the player, so visible stays undefined instead.
Pourquoi un seul callback?
Avant onResolveCondition, la même logique gameState.check(...) devait être enregistrée séparément dans onResolveCondition et onCondition. Avec le resolver unifié, c'est un seul callback — le engine gère les deux automatiquement.
Avancé : tagger soi-même
Si un resolver global n'est pas souhaité, LsdeUtils.tagOptionVisibility fait le même travail à la demande. Il prend deux arguments — les options et l'évaluateur — et rend la liste entière, taguée :
import { LsdeUtils, type ConditionEvaluator } from '@lsde/dialog-engine';
const evaluator: ConditionEvaluator = t => gameState.check(t.dict, t.entry, t.op, t.value);
const offered = LsdeUtils.tagOptionVisibility(block.options, evaluator);var offered = LsdeUtils.TagOptionVisibility(
block.Options,
t => GameState.Check(t.Dict, t.Entry, t.Op, t.Value));lsde::ConditionEvaluatorFn evaluator = [](const lsde::ConditionTest& t) {
return gameState.check(t.dict, t.entry, t.op, t.value);
};
auto offered = lsde::LsdeUtils::TagOptionVisibility(block->options, &evaluator);var offered = LsdeUtils.tag_option_visibility(
block.get("options", []),
func(t): return GameState.check(t["dict"], t["entry"], t["op"], t["value"]))Les conditions choice: ne sont pas résolues pour vous ici
Le raccourci qui les traitait automatiquement n'existe pas : sans le engine dans la boucle, un test sur le dictionnaire réservé choice arrive à votre évaluateur. Redirigez-le vers la scène, qui tient l'historique :
const evaluator: ConditionEvaluator = t =>
LsdeUtils.isChoiceCondition(t) ? scene.evaluateCondition(t)
: gameState.check(t.dict, t.entry, t.op, t.value);tagOptionVisibility remplace le filterVisibleChoices de la v1, qui raccourcissait la liste et vous enlevait la possibilité d'afficher une réponse verrouillée.
