Block タイプ
block は dialogue scene の構成要素です — エディターグラフの各ノードが block です。engine は block から block へフローをルーティングし、各タイプに対応する handler を呼び出します。
タイプは6種類あります:Dialog、Choice、Condition、Router、Action、Note。Dialog、Choice、Condition、Action は専用の handler(onDialog、onChoice、onCondition、onAction)を持つコンテンツ block です — 4つとも必須で、start() 呼び出し時に検証されます。Router に handler はありません:engine が自分で dispatch します。Note block は自動的にスキップされます。
handler は2つのレベルで構成されます:global handler(engine に登録)はすべての scene をカバーし、ほとんどのゲームではこれだけで十分です。scene handler(SceneHandle に登録)は、特定の scene で global を補完または上書きできます。詳細は Handlers を参照してください。
DIALOG
dialog block はセリフを表します — キャラクターの会話、ナレーター、画面上のテキスト。engine は onResolveCharacter callback で話しているキャラクターを解決し、context.character として公開します。典型的な dialog handler はゲーム内でテキストインスタンス(テキストボックス、吹き出し、字幕…)を作成し、プレイヤーやアニメーションの完了を待ち、next() を呼び出して engine を進めます。オプションの cleanup 関数で、engine が次の block に移る際に副作用をクリーンアップできます。
engine.onDialog(({ block, context, next }) => {
const { text, props } = block;
const { character, resolveCharacterPort } = context;
const line = game.getLocalizedText(text);
const emotion = game.getCharacterEmotion(character);
character && resolveCharacterPort(character.id);
game.moveCameraToCharacter(character);
game.animateCharacter(character, emotion);
const dialog = game.createDialog(line, character, emotion);
// next() tells the engine this block is done. WHEN to call it is answered by three
// natives, and `timeout` comes FIRST because it outranks the other two: all three
// say when the block is left, and the card is the most specific answer.
if (props?.timeout) {
// MILLISECONDS, and the countdown starts when the line has been SAID — `timeout`
// is how long it STAYS on screen afterwards. Arming it on arrival truncates any
// line slower to reveal than the timeout allows.
dialog.onRevealComplete(() => game.wait(props.timeout).then(() => next()));
// A click may HURRY that reveal; it may not dismiss a line that plays its own time.
dialog.onInput(() => dialog.skipReveal(), { once: true });
} else if (game.shouldWaitPlayerInputForDialog(props)) {
dialog.onInput(() => next(), { once: true });
} else {
dialog.onRevealComplete(() => 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.Text),
context.Character,
Game.GetCharacterEmotion(context.Character)
);
if (ch != null) context.ResolveCharacterPort(ch.Id);
Game.MoveCameraToCharacter(ch);
Game.AnimateCharacter(ch, emotion);
var dialog = Game.CreateDialog(text, ch, emotion);
var natives = block.NativeProperties;
// next() tells the engine this block is done. Timeout comes FIRST: it outranks
// WaitInput and it outranks leaving at once. All three say when the block is left,
// and the one the writer put on the card is the most specific answer.
if (natives?.Timeout > 0) {
// MILLISECONDS, and the countdown starts when the line has been SAID — Timeout
// is how long it STAYS on screen afterwards. Arming it on arrival truncates any
// line slower to reveal than the timeout allows.
dialog.OnRevealComplete(() =>
Game.Wait(natives.Timeout.Value).Then(() => next()));
// A click may HURRY that reveal; it may not dismiss a line playing its own time.
dialog.OnInput(() => dialog.SkipReveal(), once: true);
} else if (Game.ShouldWaitPlayerInputForDialog(natives)) {
dialog.OnInput(() => next(), once: true);
} else {
dialog.OnRevealComplete(() => 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->text);
auto emotion = game.getCharacterEmotion(ch);
if (ch) ctx->resolveCharacterPort(ch->id);
game.moveCameraToCharacter(ch);
game.animateCharacter(ch, emotion);
auto* dialog = game.createDialog(text, ch, emotion);
auto natives = lsde::getNativeProperties(*block);
// next() tells the engine this block is done. timeout comes FIRST: it outranks
// waitInput and it outranks leaving at once. All three say when the block is left,
// and the one the writer put on the card is the most specific answer.
if (natives.timeout.value_or(0) > 0) {
// MILLISECONDS, and the countdown starts when the line has been SAID - timeout
// is how long it STAYS on screen afterwards. Arming it on arrival truncates any
// line slower to reveal than the timeout allows.
const auto stayMs = *natives.timeout;
dialog->onRevealComplete([&game, next, stayMs]() {
game.wait(stayMs).then([next]() { next(); });
});
// A click may HURRY that reveal; it may not dismiss a line playing its own time.
dialog->onInput([dialog]() { dialog->skipReveal(); });
} else if (game.shouldWaitPlayerInputForDialog(block->props)) {
dialog->onInput([next]() { next(); });
} else {
dialog->onRevealComplete([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("text"))
var emotion = game.get_character_emotion(ch)
if ch:
ctx.resolve_character_port(ch.get("id", ""))
game.move_camera_to_character(ch)
game.animate_character(ch, emotion)
var dialog = game.create_dialog(text, ch, emotion)
var natives = block.get("props", {})
var stay_ms = natives.get("timeout", 0)
# next_fn.call() tells the engine this block is done. timeout comes FIRST: it
# outranks waitInput and it outranks leaving at once. All three say when the block
# is left, and the one written on the card is the most specific answer.
if stay_ms > 0:
# A click may HURRY the reveal; it may not dismiss a line playing its own time.
dialog.on_input(func(): dialog.skip_reveal(), true)
# MILLISECONDS, counted from the moment the line has been SAID — stay_ms is how
# long it REMAINS on screen afterwards, never how long it has to be said in.
await dialog.reveal_finished
await game.wait(stay_ms)
next_fn.call()
elif game.should_wait_player_input(natives):
dialog.on_input(func(): next_fn.call(), true)
else:
await dialog.reveal_finished
next_fn.call()
# cleanup: runs when the engine moves to the next block
return func():
dialog.destroy()
game.animate_character(ch, false)
)ナラティブデザイナーがキャラクターごとに専用の出力を割り当てた場合(portPerCharacter)、handler は resolveCharacterPort() を呼び出して next() 時にどのパスを辿るかを engine に伝える必要があります。
CHOICE
choice block はプレイヤーが選択する分岐点です — ダイアログメニュー、選択肢リスト。context.options に全ての選択肢が含まれます。onResolveCondition() が設定されている場合、各選択肢は visible: true | false でタグ付けされ、handler が表示する選択肢をフィルタリングします。プレイヤーの操作後、selectChoice(optionId) で engine にどのパスを辿るかを伝え — option の id がそのまま出口 port です(C1、C2…)— next() でフローを進めます。
engine.onChoice(({ block, context, next }) => {
const natives = LsdeUtils.getNativeProperties(block);
const { options, selectChoice } = context;
// EVERY option is handed over, tagged. Filtering is your call — keeping the hidden ones lets
// you grey them out or show "[locked]" instead of making them vanish.
const offered = options.filter((o) => o.visible !== false);
const dialog = game.createChoice(offered);
// The option id IS its exit port (C1, C2…), so hand it straight back.
dialog
.then((selected) => selectChoice(selected))
.finally(() => next());
// Optional: if the writer set a timeout on this block. MILLISECONDS in v2, and a
// CHOICE has no line to reveal, so the countdown starts once the options are readable.
//
// It must still SELECT one. An option id IS the exit port, so a choice left without
// `selectChoice()` resolves to NO link at all and the branch dies silently — the
// engine has nothing to follow. Picking the first offered option is the usual answer;
// a `when` on it lets the writer decide which one that is.
if (natives.timeout) {
const timeout = game.wait(natives.timeout).then(() => {
if (offered.length > 0) selectChoice(offered[0].id);
next();
});
dialog.finally(() => timeout.cancel());
}
return () => dialog.destroy();
});engine.OnChoice(args => {
var natives = LsdeUtils.GetNativeProperties(args.Block);
var offered = args.Context.Options
.Where(o => o.Visible != false).ToList();
var dialog = Game.CreateChoice(offered);
// The option id IS its exit port (C1, C2…), so hand it straight back.
dialog.OnSelect(selected => {
args.Context.SelectChoice(selected);
args.Next();
});
// Optional: MILLISECONDS in v2, counted from the moment the options are readable.
// It must still SELECT one: an option id IS the exit port, so a choice left without
// SelectChoice() resolves to NO link and the branch dies silently.
if (natives.Timeout is { } timeout)
Game.Wait(timeout).Then(() => {
if (offered.Count > 0) args.Context.SelectChoice(offered[0].Id);
args.Next();
});
return () => dialog.Destroy();
});engine.onChoice([&game](auto*, auto* block, auto* ctx, auto next) -> CleanupFn {
std::vector<const lsde::RuntimeChoiceItem*> offered;
for (const auto& option : ctx->options())
if (option.visible.value_or(true))
offered.push_back(&option);
auto* dialog = game.createChoice(offered);
// The option id IS its exit port (C1, C2…), so hand it straight back.
dialog->onSelect([ctx, next](const std::string& selected) {
ctx->selectChoice(selected);
next();
});
// Optional: MILLISECONDS in v2, counted from the moment the options are readable.
// It must still SELECT one: an option id IS the exit port, so a choice left without
// selectChoice() resolves to NO link and the branch dies silently.
auto natives = lsde::getNativeProperties(*block);
if (natives.timeout) {
game.wait(*natives.timeout).then([ctx, next, offered]() {
if (!offered.empty()) ctx->selectChoice(offered.front()->id);
next();
});
}
return [dialog]() { dialog->destroy(); };
});engine.on_choice(func(args):
var ctx = args["context"]
var natives = LsdeUtils.get_native_properties(args["block"])
var offered = []
for option in ctx.options:
if option.get("visible") != false:
offered.append(option)
var dialog = game.create_choice(offered)
# The option id IS its exit port (C1, C2…), so hand it straight back.
var selected = await dialog.choice_selected
ctx.select_choice(selected)
args["next"].call()
# Optional: natives.get("timeout") is in MILLISECONDS. Use a Timer node, started
# once the options are readable. It must still SELECT one: an option id IS the exit
# port, so a choice left without select_choice() resolves to NO link and the branch
# dies silently.
return func(): dialog.destroy()
)visible is a tag, not a filter
With engine.onResolveCondition() installed, each option carries visible: true | false. Without one it is undefined — unknown, not hidden.
That distinction matters: saying "hidden" about a question nobody could answer would take an answer away from the player. Filter on visible !== false, never on visible === true.
完全なオプトイン方式のタグ付けシステムについては Choice の表示制御 を参照してください。
CONDITION
condition block は不可視の分岐器です — ゲーム状態を参照し、プレイヤーに見えないままフローを送り出します。
engine 自身は何も比較しません。 dictionary を読まず、credits の中身を知らず、greaterOrEqual を 実装してもいません。各テストを onResolveCondition() に渡し、答えを 組み立てるだけです。各テストが尋ねられるのは、モードに関わらず一度だけです。
resolver が登録されていれば、engine は handler を呼ぶ前から出口 port を知っています:onCondition は 任意になり、ログ出力や上書きのためのフックになります。handler は context.cases を受け取り、各 case は自身の port と評価済みの result を持ちます。上書きするには、context.resolve(port) にポート名を 渡します — "out"、"default"、あるいは case のポート("K1")。
モードは二つ、それだけです:
portPerCaseなし — すべての case が成立する必要があります。成立すればフローはoutから、 そうでなければdefaultから出ます。portPerCase: true— 最初に成立した case が自身の port(K1、K2…)から出ます。 どれも成立しなければdefault。
when を持たない case は常に真であり、portPerCase モードではそれより下の case を到達不能にします。 これは writer が描いた図であって、報告すべき誤りではありません。case が一つもない block は out から 出ます:何も尋ねられていないので、何も失敗していません。
default は「どの case も成立しなかった」を意味します — 「選ばれた出口に線がない」ではありません。 線のない port はフローを終わらせますが、それは正当な終わり方です。
dictionary が予約語 choice であるテストは、プレイヤーがすでに出した答えを問い合わせます: { dict: "choice", entry: "CHOICE-001", value: "C1" }。engine は scene の履歴から自分で答えるため、 この問いがゲームに届くことはありません。scene.getChoice(blockId) と scene.evaluateCondition(test) も 参照してください。
// Optional once onResolveCondition is installed: the engine has already evaluated every case and
// already knows which port the flow leaves by. This handler is where you log it, or override it.
engine.onCondition(({ block, context, next }) => {
// Each case carries its own exit port and its pre-evaluated result.
const matched = context.cases.filter((c) => c.result).map((c) => c.port);
console.log(`${block.id} matched:`, matched.join(', ') || 'none → default');
// Override the routing with a PORT NAME: 'out', 'default', or a case port like 'K2'.
// context.resolve('default');
next();
});engine.OnCondition(args => {
var matched = args.Context.Cases
.Where(c => c.Result == true)
.Select(c => c.Port)
.ToList();
Console.WriteLine($"{args.Block.Id} matched: {string.Join(", ", matched)}");
// args.Context.Resolve(Ports.Default); // override with a PORT NAME
args.Next();
return null;
});engine.onCondition([](auto*, auto* block, auto* ctx, auto next) -> CleanupFn {
for (const auto& c : ctx->cases()) {
std::cout << c.port << "=" << c.result.value_or(false) << " ";
}
// ctx->resolve(Ports::Default); // override with a PORT NAME
next();
return {};
});engine.on_condition(func(args):
var ctx = args["context"]
var matched = []
for c in ctx.cases:
if c["result"]:
matched.append(c["port"])
print("%s matched: %s" % [args["block"]["id"], ", ".join(matched)])
# ctx.resolve(LsdeTypes.PORT_DEFAULT) # override with a PORT NAME
args["next"].call()
return Callable()
)Two modes, and only two
portPerCase absent — every case must hold: the flow leaves by out if they all do, default otherwise. portPerCase: true — the first case that holds takes its own port (K1, K2…), default when none does.
A case with no when is always true, and makes every case below it unreachable.
The dispatcher mode is gone. It fired every matching case at once, in parallel, which meant a writer read three wires leaving a condition as a choice when it was three simultaneous launches. Use isAsync on the target blocks instead — where a reader of the graph can see it.
ROUTER
router block は condition と同じ cases を持ち、それを逆向きに読みます。condition はどれが成立するかを尋ねて一つの port から出ますが、router はどれらが成立するかを尋ねます:すべての case を評価し、真の case それぞれの port を起動し、そのうえで必ず続行します — すべて成立していれば then、一つでも不成立なら catch から。case を一つも持たない router は、Promise.all([]) が解決するのと同じように then から出ます。
K* ルートは他の port と同じように歩かれます:isAsync のターゲットは自身のトラックを開き、それ以外は順番に歩かれ、継続先は最後に来ます。catch は何も取り消しません — 真の case のトラックはすでに走っています。
onRouter handler は存在せず、start() はそれを要求しません:engine は router を自分で dispatch します。観測するには handle.onBlock(id) を使います。
import type { RouterContext } from '@lsde/dialog-engine';
// A ROUTER has no handler of its own: the engine evaluates EVERY case, launches the port of each
// true one and picks `then` or `catch` before a handler could speak. To WATCH one, target it by id.
handle.onBlock('ROUTER-001', ({ block, context, next }) => {
const { cases } = context as RouterContext;
const launched = cases.filter((c) => c.result).map((c) => c.port);
console.log(`${block.id} launched:`, launched.join(', ') || 'nothing');
// Nothing to resolve: the exits are a tally, not a choice.
next();
});handle.OnBlock("ROUTER-001", args => {
var cases = ((IRouterContext)args.Context).Cases;
var launched = cases.Where(c => c.Result == true).Select(c => c.Port).ToList();
Console.WriteLine($"{args.Block.Id} launched: {string.Join(", ", launched)}");
// Nothing to resolve: the exits are a tally, not a choice.
args.Next();
return null;
});handle->onBlock("ROUTER-001", [](auto*, auto* block, IBaseBlockContext* ctx, auto next) -> CleanupFn {
auto* router = dynamic_cast<IRouterContext*>(ctx);
for (const auto& c : router->cases()) {
if (c.result.value_or(false)) std::cout << block->id << " launched " << c.port << "\n";
}
// Nothing to resolve: the exits are a tally, not a choice.
next();
return {};
});handle.on_block("ROUTER-001", func(args):
var ctx = args["context"] # an LsdeBlockContext.RouterContext
var launched = []
for c in ctx.cases:
if c["result"]:
launched.append(c["port"])
print("%s launched: %s" % [args["block"]["id"], ", ".join(launched)])
# Nothing to resolve: the exits are a tally, not a choice.
args["next"].call()
return Callable()
)A tally, not a choice
A condition asks which one and leaves by a single port. A router asks which ones: every case is evaluated — a false one in the middle hides nothing after it — each true case launches its port, and the flow then continues by then when they all held, by catch when any did not. No case at all is then, the way Promise.all([]) resolves.
The K* routes are walked like any other port: an isAsync target opens its own track, the others are this track's, in turn, and the continuation comes last — which is what keeps then/catch as the main flow when the routes are async. catch cancels nothing: the tracks of the true cases are already running.
完全な契約と図は Router block を、router のルートが通常名指す入口 port inPortPerCharacter については 登場人物の割り当て を参照してください。
ACTION
action block はゲーム内で副作用を発動します — アイテムの付与、サウンドの再生、フラグの設定。 context.calls が呼び出しを保持します:それぞれが宣言済みの function の fn を参照し、その args は位置ではなく名前で渡されます。handler はそれらを実行してから context.resolve() で then port を、context.reject() で catch port を辿ります — designer が catch を 一本も配線していない場合、プレイヤーを置き去りにするのではなく then からフローが続きます。
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
note block はナラティブデザイナーのためのメモです — コメント、リマインダー、コンテキスト。走査中は自動的にスキップされます。onBeforeBlock で note block をインターセプトすることは技術的に可能ですが、推奨されません — action block がすべての副作用のニーズをカバーできます。
共通プロパティ
すべての block は以下の基本フィールドを共有します(BlueprintBlockBase):
| フィールド | 型 | 説明 |
|---|---|---|
id | string | その scene に対する識別子 — DIALOG-002。id は scene をまたいで繰り返されます。 |
key | string | ローカライズファイルが持つ完全な i18n キー |
type | BlockType | dialog、choice、condition、router、action、note |
label | string? | writer が付けた場合の可読名 |
parentLabels | string[]? | エディター内の親フォルダー階層 |
note | string? | writer のメモ |
actors | string[]? | block が参照する card の id、ファイル順 |
emotion | string? | 感情の card id — 各 actor ではなく block に属します |
intensity | number? | その感情の強さ |
text | TextByLocale? | inline 出力の場合のロケール別テキスト |
props | PropertyBag? | ひとつの袋:native と writer 自身のプロパティが、素の id で同居します |
options | Option[]? | CHOICE のみ |
cases | ConditionCase[]? | CONDITION と ROUTER — 同じデータを逆向きに読む |
calls | ActionCall[]? | ACTION のみ |
next | Link[]? | block の出力ワイヤー。 v2 に connection テーブルはありません |
エントリー block は block 側には印されていません:それを指名するのは scene で、scene.start に 書かれます。したがって、ひとつの scene がエントリーを二つ宣言することはできません。
NativeProperties
engine が props から読み取る十のプロパティです。id が衝突することはありません — LSDE は native と同じ名前のプロジェクトプロパティを拒否します — したがって見分けるのは単なる検索です。
| フィールド | 型 | 説明 |
|---|---|---|
isAsync | boolean? | 現在のトラックを続ける代わりに、この block で並列トラックを開きます |
waitForBlocks | string[]? | この scene の block id。それらがすべて完了するまで、block はディスパッチされる前に保持されます — handler は呼ばれません |
delay | number? | block が再生されるまでのミリ秒。onBeforeBlock が適用し、engine は決して適用しません |
timeout | number? | セリフを言い終えたあと block が画面に残るミリ秒。そのあと自分から離れます — block のオートアドバンス。waitInput より優先。そのまま渡され、engine は何も強制しません |
waitInput | boolean? | プレイヤー入力を待つ。そのまま渡され、解釈されません — timeout が優先します |
debug | boolean? | エディタ用デバッグフラグ。そのまま渡されます |
portPerCharacter | boolean? | block は out ではなく、actor の card id で名付けられた port から出ます |
inPortPerCharacter | boolean? | ワイヤーがactor を名指します:link の toPort は card id で、その actor だけが onResolveCharacter に提示されます。登場人物の割り当て を参照 |
skipIfMissingActor | boolean? | そのまま渡されます — 判断はゲーム側です |
portPerCase | boolean? | CONDITION:各 case が out を共有せず、自身の port(K1…)から出ます |
delay と timeout は v2 ではミリ秒です
v1 では秒でした。そして実行時にそれを知らせるものは何もありません:移行したプロジェクトでは 3 秒の間が 3 ミリ秒になります。
timeout は block のオートアドバンス — 数え始めはセリフの終わり
カウントダウンはセリフが言い終わった時点から始まります。block が到着した時点ではありません。ライターが設定するのは、最後の文字が打ち終わった(あるいは最後の音節が発声された)あとにそのセリフが画面に残る時間で、そのあと block は自分から離れます。
到着から数えるのは、読んで自然なのに再生すると間違っている類の誤りです:120 文字のセリフに 2500 ms を設定すると、文の途中で切れます。
これは waitInput より優先され、即座に離れる挙動よりも優先されます。三つはいずれも block を「いつ」離れるかを言っており、ライターがカードに書いたものが最も具体的な答えです。したがってクリックは表示を早送りすることしかできず、block を追い出すことはできません — 自分の時間を演じているセリフを押しても意味がなく、早送りには意味があります。そしてその早送りがカウントダウンを起動します。
そして block を離れることがそれを完了と記すので、timeout はその block を名指す waitForBlocks を解除するものでもあります。
これら十のうち、走査を変えるのは三つだけです:isAsync、waitForBlocks、inPortPerCharacter。残りの七つは そのままゲームに渡され、どう扱うかはゲームが決めます。
