NPCに話しかけると、長いセリフが一度にドンと表示される。文章が1文字ずつ流れる、あの演出をやりたいのに作り方が分からない。選択肢を出したいが、ボタンを何個も並べて手作業でつなぐしかないのか、と手が止まる。
会話システムは、Data Table と UMG を組み合わせれば、無理なく作れます。この記事では、セリフをどこに置くか、1文字ずつ出すタイプライター表示の作り方、送り操作の2段階、そして選択肢で会話を分岐させる方法を解説します。
この記事でわかること
- セリフは 1行=1セリフ の Data Table に置く
- タイプライター表示は 全文を持ち、表示文字数をTimerで増やす
- 送りは 2段階 (表示途中なら全文表示、表示済みなら次へ)
- 選択肢は ボタンを動的に生成 し、選んだ結果で次の行を変える
- 会話中は Set Input Mode UI Only で移動を止める
- 実践: NPCに話しかけると会話が始まり、選択肢で分岐する
セリフをどこに置くか
最初に決めるのは、 セリフをどこに書くか です。Widgetの中に直接書き込むと、セリフを1行足すたびにWidgetを開いてコンパイルすることになります。会話は数十行、数百行と増えるものなので、ここは外に出します。
向いているのは Data Table です。 1行を1セリフ として、話者・本文・次の行を列にします。

まず、行の形を決める構造体 S_DialogueLine を作ります(構造体の作り方は 構造体の記事 にあります)。
| 変数名 | 型 | 内容 |
|---|---|---|
Speaker | Text | 話している人の名前 |
Line | Text | セリフ本文 |
NextRow | Name | 送ったときに進む次の行。会話の終わりは 空 にする |
Choices | S_DialogueChoice( 配列 ) | 選択肢。無ければ空 |
選択肢の1つ分は、別の小さな構造体 S_DialogueChoice にします。
| 変数名 | 型 | 内容 |
|---|---|---|
ChoiceText | Text | ボタンに出す文言(「はい」など) |
TargetRow | Name | それを選んだときに飛ぶ行 |
これで、会話は 行から行への移動 として表せます。ふつうのセリフは NextRow で次の行を指し、分かれ道になる行だけ Choices に選択肢を並べます。
本文と話者名を String型ではなくText型 で持つのが大事です。Text型は多言語化(ローカライズ)の対象になり、あとから翻訳を差し込めます。String型は翻訳の仕組みに乗りません(→ ローカライゼーションで多言語化する)。
補足:
Choicesのような 入れ子の配列は、CSVでは往復できません 。話者と本文だけならCSVで一括編集できますが、選択肢はData Tableのアセットを開いて直接編集することになります。「ふつうのセリフはCSVで流し込み、分岐だけエディタで足す」と割り切るのが現実的です。
セリフが看板やヒント程度で数行 しか無いなら、Data Tableを使わず、Widgetに Text を直接渡すだけでも足ります。仕組みを重くするかどうかは、会話の量で決めてください。
会話ウィジェットの構成
見た目の器を作ります。WBP_Dialogue という Widget Blueprint を1枚用意し、必要な部品を並べます。

置く部品は次の5つです。 表示に使うものは、詳細パネルで Is Variable にチェック を入れて、Graphから触れるようにします。
| 部品 | 種類 | 役割 |
|---|---|---|
SpeakerText | Text Block | 話者名。上に小さく出す |
BodyText | Text Block | セリフ本文。 Auto Wrap Text をオン にする |
ContinueIcon | Text Block(「▼」) | 表示が 終わったら出す送りの合図 |
ChoiceBox | Vertical Box | 選択肢ボタンをこの中に並べる |
AdvanceButton | Button | 画面全体を覆う透明ボタン。どこを押しても送れるようにする |
BodyText の Auto Wrap Text は必ずオンにしてください。UMGのText Blockは既定で折り返さないため、長いセリフが画面の外へはみ出します。
AdvanceButton は「画面のどこをクリックしても次へ送る」ための仕掛けです。Canvas Panel に置いてアンカーを全画面に引き伸ばし、Background Color の透明度を0にして見えなくします。 重なりの順番(Hierarchyでの上下)で、ChoiceBox を AdvanceButton より下に置く のがポイントです。UMGは後に置いた部品が手前に来るので、選択肢ボタンが送りボタンより手前になり、クリックを先に受け取れます。
選択肢のボタンは、その場で1つずつ作ります。専用の WBP_ChoiceButton を別に用意しておくと、同じ見た目を並べられます。中身は次のとおりです。
| 要素 | 内容 |
|---|---|
Button + Text Block | 見た目 |
変数 TargetRow(Name) | このボタンを押したら飛ぶ行 |
Event Dispatcher OnChosen(Name出力) | 押されたことを親へ知らせる放送 |
WBP_ChoiceButton は、内部のButtonの On Clicked で OnChosen(TargetRow を乗せて)を鳴らすだけにします。誰が聞くかはボタン自身は知りません。この放送の仕組みは Event Dispatcherの記事 にまとめてあります。
タイプライター表示:表示文字数を増やす
いよいよ本題です。1文字ずつ出す表示は、 1文字ずつ足していく のではなく、 全文を持っておき、見せる文字数だけを増やす と素直に作れます。

WBP_Dialogue に、表示のための変数を用意します。
| 変数名 | 型 | 初期値 | 役割 |
|---|---|---|---|
CurrentLine | S_DialogueLine | — | 今表示している行の中身(NextRowや選択肢を後で参照する) |
FullString | String | (空) | 今表示している行の全文 |
VisibleCount | Integer | 0 | 今何文字目まで見せているか |
CharInterval | Float | 0.03 | 1 文字あたりの間隔(秒) |
TypeTimer | Timer Handle | — | ループタイマーの取っ手 |
IsTyping | Boolean | false | 今まさに文字を送り出している最中か |
CurrentLine を1つ持っておくのがポイントです。送りや選択肢の生成で「今の行のNextRowは?」「選択肢はあるか?」と何度も参照するので、行を表示するたびにその中身を覚えておきます。
Text型の本文をそのまま切り出すことはできないので、行を表示するときに 一度String型へ変換 します(To String(Text))。切り出しはString型で行い、表示のときにまたText型へ戻します。
行を表示する ShowLine 関数は、次の流れです。
関数: ShowLine(入力: RowName / Name)
→ Get Data Table Row(Data Table: DT_Dialogue, Row Name: RowName)
→ Branch(Condition: Found Row)
False → Print String("行が見つからない: " + RowName)→ Return ← ★必ず用意する
True ↓
→ Set CurrentLine(= Out Row) ← 以降の「現在行」はこれを見る
→ Break S_DialogueLine(Out Row)
→ Set Text(Target: SpeakerText, In Text: Speaker)
→ Set FullString(= To String(Line))
→ Set VisibleCount = 0
→ Set Visibility(Target: ContinueIcon, New Visibility: Hidden)
→ Clear Children(Target: ChoiceBox)
→ Set IsTyping = true
→ Set Timer by Event(Time: CharInterval, Looping: ✔, Event: RevealNextChar)
→ Set TypeTimer(戻り値の Timer Handle を保存)
Get Data Table Row は、 見つからなくてもエ ラーになりません 。Out Row に空の構造体が返るだけです。Found Row で必ず分岐してください(この落とし穴は Data Tableの記事 で詳しく扱っています)。
タイマーは Set Timer by Event を使います。赤いEventピンから引き出してカスタムイベント RevealNextChar を作り、Looping にチェックを入れます。 Set Timer by Function Name ではなく Set Timer by Event を選ぶのは、関数名を文字列で指定する必要がなく、名前を変えても壊れないからです。
RevealNextChar が、1回呼ばれるたびに1文字ぶん表示を伸ばします。
Custom Event: RevealNextChar
→ Set VisibleCount = VisibleCount + 1
→ Set Text(Target: BodyText,
In Text: To Text(Left(FullString, VisibleCount)))
→ Branch(Condition: VisibleCount >= Len(FullString))
True → FinishTyping
Left(文字列, 数) は、 先頭からその数だけ文字を返す ノードです。VisibleCount が1なら1文字目まで、2なら2文字目まで。これを0.03秒ごとに1ずつ増やすので、文字が左から順に現れて見えます。指定した数が全文より多くても、Left は全文をそのまま返すので、はみ出す心配はありません。
Len(文字列) は文字数を返します。VisibleCount がそこに達したら FinishTyping を呼んで、タイマーを止めます。
関数: FinishTyping
→ Clear and Invalidate Timer by Handle(Handle: TypeTimer)
→ Set IsTyping = false
→ Set Text(Target: BodyText, In Text: CurrentLine の Line(Text型のまま)) ← 全文を確実に表示
→ (選択肢があれば選択肢を作る/無ければ ContinueIcon を Visible にする)
最後に本文をText型でもう一度セットし直しているのは、 確実に全文が出た状態にそろえる ためです。ここまでで、セリフが1文字ずつ流れて、最後まで出たら「▼」が点く動きができます。
送りは2段階にする
送り操作(クリックや決定キー)には、 2つの意味 を持たせます。同じ操作でも、いま文字を送り出している最中か、もう出し終わっているかで、やることが変わります。

| いまの状態 | 送りを押すと |
|---|---|
| 文字を送り出している最中 | 途中でやめて 全文を一気に表示 する |
| 表示し終わっている(選択肢なし) | 次の行 へ進む |
| 表示し終わっている(選 択肢あり) | 何もしない (選択肢を選ぶまで待つ) |
これを AdvanceButton の On Clicked で受けます。
AdvanceButton On Clicked
→ Branch(Condition: IsTyping)
True → FinishTyping ← 途中なら全文表示にジャンプ
False → Branch(Condition: CurrentLine.Choices が空でないか)
True → (何もしない。選択肢の選択を待つ)
False → Branch(Condition: NextRow が空か)
True → EndDialogue ← 会話の終わり
False → ShowLine(NextRow)
この2段階が、テキスト送りの体感を決めます。せっかちなプレイヤーは1回目のクリックで全文を出し、2回目で次へ進めます。ゆっくり読みたいプレイヤーは、流れ終わるのを待ってから1回押せば次へ行きます。 どちらの押し方でも破綻しない のが、この形の狙いです。
NextRow が空のときに会話を閉じる EndDialogue は、UIを片付けて操作を戻す処理です。中身は後半の「会話中は移動を止める」で扱います。
選択肢で分岐する
選択肢は、 その行が持っている Choices の数だけ、ボタンをその場で作って並べる 形にします。ボタンを固定で置いておくと、選択肢が2つの場面でも3つの場面でも同じ枠を使い回すことになり、数の違いに対応できません。

FinishTyping の最後で、CurrentLine に選択肢があれば BuildChoices を呼びます。
関数: BuildChoices(入力: Choices / S_DialogueChoice の配列)
→ Clear Children(Target: ChoiceBox) ← 前の選択肢を消す
→ For Each Loop(Array: Choices)
Loop Body →
Create Widget(Class: WBP_ChoiceButton)
→ Set Choice Text(Array Element の ChoiceText)
→ Set Target Row(Array Element の TargetRow)
→ Bind Event to OnChosen(Target: 作ったボタン)
Event ピン ← Custom Event: HandleChoice(TargetRow を受け取る)
→ Add Child to Vertical Box(Target: ChoiceBox, Content: 作ったボタン)
順番に見ます。 Create Widget でボタンの実体を作り、Add Child to Vertical Box で ChoiceBox の中へ入れます 。この2つがUMGで部品を動的に増やす基本の組み合わせです。作る前に Clear Children で古いボタンを消しておかないと、前の選択肢が残ったまま積み上がります。
作ったボタンごとに Bind Event to OnChosen で放送を聞く登録をして、押されたら HandleChoice が呼ばれるようにします。
Custom Event: HandleChoice(入力: TargetRow / Name)
→ Clear Children(Target: ChoiceBox) ← 選択肢を片付ける
→ ShowLine(TargetRow) ← 選んだ先の行へ進む
選ばれたボタンは自分の TargetRow を放送に乗せて渡すので、HandleChoice はその行を表示するだけで分岐が完成します。「はい」を押せば受諾のセリフの行へ、「いいえ」を押せば辞退のセリフの行へ。 分岐は if の連なりではなく、飛ぶ先の行が違うだけ です。
会話中は移動を止める
会話が始まったのにプレイヤーが歩き回れると、演出が台無しになります。会話中は入力を会話UIへ向けます。
やり方は、Player Controllerの入力モードを切り替えることです。会話を始めるときと終えるときで、状態を対にして戻します。
| タイミング | 呼ぶもの |
|---|---|
| 会話を 始める | Set Input Mode UI Only + Set Show Mouse Cursor(true) |
| 会話を 終える | Set Input Mode Game Only + Set Show Mouse Cursor(false) |
Set Input Mode UI Only にすると、ゲームへの入力がUIへ回されるため、 移動やジャンプの入力が届かなくなり、キャラクターは止まります 。会話の送りは、前に作った AdvanceButton の On Clicked で受けます。ButtonのクリックはUIへのイベントなので、UI Onlyの状態でもちゃんと届きます。
ここに、よくあるつまずきがあります。 会話を開始したキーで送りも兼ねようとしない ことです。たとえば E キーで会話を始めた場合、UI Only中は E のゲーム入力がそもそも遮断されるので、同じ E で送ろうとしても反応しません。送りはキー入力ではなく、画面のButtonで受けるのが確実です。
会話の開 始と終了は、次のように組みます。
StartDialogue(例:NPCの近くで決定を押したとき)
→ Create Widget(Class: WBP_Dialogue, Owning Player: Player Controller)
→ Set DialogueRef(作った実体を保持)
→ Set Data Table / Set Start Row(会話の先頭行)
→ Add to Viewport
→ Get Player Controller
→ Set Input Mode UI Only(In Widget to Focus: DialogueRef)
→ Set Show Mouse Cursor = true
→ DialogueRef → ShowLine(先頭行)
EndDialogue(WBP_Dialogue 内。NextRow が空のとき)
→ Get Owning Player
→ Set Input Mode Game Only
→ Set Show Mouse Cursor = false
→ Remove from Parent
Set Input Mode Game Only だけではマウスカーソルが出たままになるので、 Set Show Mouse Cursor(false) も必ずペアで 呼びます。片方だけ戻すと、会話が終わってもカーソルが画面に残ります。
補足: これは「ポーズ」とは別物です。
Set Game Pausedを使うと、会話の裏で動いているアニメーションやタイマーまで全部止まります。会話中も背景を動かしたいなら、入力モードの切り替えだけで止めるこの形が向いています。ポーズが必要な場面は ポーズメニューの記事 で扱っています。
実践:話しかけて選択肢で分岐する
RPGの村人との会話、ADVのイベントシーン、ホラ ーのメモ書き、脱出ゲームの独り言。 話しかけると文章が流れ、選択肢で反応が変わる という形は、ジャンルを問わず出てきます。ここでは村人に話しかけて、依頼を受けるか断るかで結末が分かれる会話を通します。
動かすとこうなる
村人に近づいて決定キーを押すと会話が始まり、セリフが1文字ずつ流れます。「手伝ってくれるか?」の行で選択肢が2つ出て、「はい」なら礼のセリフ、「いいえ」なら残念がるセリフに分かれて会話が終わります。

再現条件
Third Person テンプレート で新規プロジェクトを作り、次を用意します。
構造体2つ (構造体の記事 の手順で作成)
| 構造体 | 変数 | 型 |
|---|---|---|
S_DialogueChoice | ChoiceText | Text |
TargetRow | Name | |
S_DialogueLine | Speaker | Text |
Line | Text | |
NextRow | Name | |
Choices | S_DialogueChoice(Array) |
Data Table DT_Dialogue (Row Structure に S_DialogueLine を指定)。次の4行を入れます。選択肢は行 Ask にだけ、Data Tableのエディタで直接入力します。
| Row Name | Speaker | Line | NextRow | Choices |
|---|---|---|---|---|
Start | 村人 | こんにちは。旅の人だね。 | Ask | (空) |
Ask | 村人 | よかったら、この村を手伝ってくれないか? | (空) | はい→Yes / いいえ→No |
Yes | 村人 | ありがとう!恩に着るよ。 | (空) | (空) |
No | 村人 | そうか…気が変わったらまた来てくれ。 | (空) | (空) |
Widget 2つ
| Widget | 中身 |
|---|---|
WBP_ChoiceButton | Button + Text、変数 TargetRow(Name)、関数 SetChoiceText、Event Dispatcher OnChosen(Name出力)。ButtonのOn ClickedでOnChosenをCall |
WBP_Dialogue | 「会話ウィジ ェットの構成」の5部品。変数・関数は本文どおり |
BP_NPC (親クラス: Actor)。会話のきっかけを持たせます。
| 要素 | 設定 |
|---|---|
| Box Collision | 話しかけられる範囲。Box Extent 150 / 150 / 100 |
変数 bPlayerInRange(Boolean) | 初期値 false |
変数 StartRow(Name / Instance Editable) | 既定値 Start |
話しかけをつなぐ
BP_NPC で、範囲に入ったかを覚えておき、範囲内で決定キーが押されたら会話を始めます。

BP_NPC
On Component Begin Overlap(Box)
→ Cast To BP_ThirdPersonCharacter
→ Set bPlayerInRange = true
→ Enable Input(Player Controller: Get Player Controller 0) ← これがないとEが届かない
On Component End Overlap(Box)
→ Cast To BP_ThirdPersonCharacter
→ Set bPlayerInRange = false
→ Disable Input(Player Controller: Get Player Controller 0)
Keyboard Event: E (Pressed)
→ Branch(Condition: bPlayerInRange)
True →
Create Widget(Class: WBP_Dialogue, Owning Player: Get Player Controller)
→ Set DialogueRef
→ DialogueRef: Set Data Table = DT_Dialogue
→ Add to Viewport
→ Get Player Controller
→ Set Input Mode UI Only(In Widget to Focus: DialogueRef)
→ Set Show Mouse Cursor = true
→ DialogueRef → ShowLine(StartRow)
見落としやすいのが Enable Input です。 Actor(Pawn以外)は、既定ではキー入力を受け取りません 。入力が来るのはPlayer Controllerが操作しているPawnだけで、BP_NPC のような普通のActorに Keyboard Event: E を置いても、そのままでは発火しないのです。範囲に入ったときに Enable Input を呼んで「このActorにも入力を回してほしい」と登録し、範囲を出たら Disable Input で戻します。これで、近くにいるときだけ E が効きます。
WBP_Dialogue 側は、本文で作った ShowLine / RevealNextChar / FinishTyping / AdvanceButton On Clicked / BuildChoices / HandleChoice / EndDialogue をそのまま使います。FinishTyping の末尾だけ、選択肢の有無で分けます。
FinishTyping の末尾
→ Branch(Condition: CurrentLine.Choices の Length > 0)
True → BuildChoices(CurrentLine.Choices)
False → Set Visibility(Target: ContinueIcon, New Visibility: Visible)
確認する
BP_NPC をレベルに置いてPlayし、近づいて E を押してください。
- 会話が始まると 「こんにちは。旅の人だね。」が1文字ずつ 現れ、止まると 「▼」 が点く
- 流れている途中で画面をクリックすると、 残りが一気に表示 される
- もう一度クリックで 「よかったら、この村を手伝ってくれないか?」 へ進み、下に 「はい」「いいえ」 の2ボタンが出る
- 「はい」 を押すと「ありがとう!恩に着るよ。」、 「いいえ」 を押すと「そうか…気が変わったらまた来てくれ。」に分かれる
- 会話が終わると カーソルが消え、また歩けるようになる
うまくいかないときの切り分けです。
- セリフが一気に全部出る →
Set Timer by EventのLoopingにチェックが入っていない。1回だけ動いて全文になっている - 1文字も出ない/
行が見つからない→StartRowの綴りが行の名前と違う。Row Nameは大文字小文字も区別される(→ Print Stringで確認) - 文字が画面からはみ出す →
BodyTextのAuto Wrap Textがオフ - 選択肢を押しても進まない →
WBP_ChoiceButtonのOnChosenを鳴らしていないか、親でBind Eventしていない - クリックで送れない/選択肢が押せない → Hierarchyで
AdvanceButtonがChoiceBoxより手前にある。AdvanceButtonを下(奥)へ - 会話後もカーソルが出たまま/動けない →
EndDialogueでSet Input Mode Game OnlyとSet Show Mouse Cursor(false)の片方が抜けている - 近づいて
Eを押しても反応しない →Enable Inputを呼んでいない。ActorのKeyboard Eventは既定では発火しない(→ 上の「話しかけをつなぐ」) - 話しかけても反応しない → Box Collisionが小さくて範囲に入れていないか、
bPlayerInRangeの切り替えが繋がっていない
ここで一度、Data Tableの Start 行の Line を長い文章に書き換えてPlayしてみてください。 タイマーの間隔は変えていないのに、長い文ほど流れ終わるまで時間がかかる のが分かります。表示にかかる時間は、文字数 × CharInterval で決まっているからです。テンポを変えたいときは CharInterval を触ります。
ポイントは2つです。
- タイプライターは「文字を足す」でなく「見せる数を増やす」: 全文を
FullStringに持ち、Left(全文, 表示数)で切り出す形にすると、途中スキップ(全文を即表示)が「表示数を最大にするだけ」で済みます。1文字ずつ文字列を継ぎ足す作り方だと、スキップのために別の処理が要ります - 分岐は「次に読む行を変える」だけ: 選択肢の中身を
ifで処理せず、選んだボタンが持つTargetRowをShowLineに渡すだけにします。会話が枝分かれしても、コードは増えず、増えるのはData Tableの行です
この会話で「はい」を選んだらクエストを受注する、という連携は、次の クエスト・目標システムの記事 につながります。セリフの見た目や文字送りの効果音は UMGの記事 の範囲です。
おまけ:先に知っておくと良いこと
- 一度話した相手の内容を変えるなら、フラグを外に持つ: 「初対面か、2回目以降か」で先頭行を変えたいときは、話した事実を Game Instance に覚えさせ、
StartRowをそのフラグで選びます。会話データ側に状態を持たせると、レベルを移るたびに初対面へ戻ります - 日本語の文字送りは1コード単位で問題ないことが多い:
Leftは文字(コードユニット)単位で切り出します。ひらがな・漢字などの一般的な文字は1文字=1単位なので、素直に1文字ずつ出ます。絵文字や一部の特殊文字は複数単位で構成されるため、そこだけ崩れて出ることがあります。会話文では実害が出にくいですが、頭の隅に置いておくと原因不明の乱れに悩まずに済みます - 送りの効果音は「文字が増えた瞬間」に鳴らす:
RevealNextCharの中で短いSEを鳴らすと、あの「カタカタ」という音になります。毎文字だとうるさいので、2〜3文字に1回に間引くと聞き心地が良くなります(音の扱いは サウンドの記事 へ) - 話者ごとに色や位置を変えたいなら、Speakerで見た目を切り替える:
SpeakerTextの中身を見て、パネルの色やアイコンを差し替えると、誰のセリフか一目で分かります。判定が増えるなら、話者の情報も Data Asset で定義に切り出すと散らかりません - 会話をスキップさせるなら、行を飛ばすのでなく最後の行へ飛ばす: 「全部スキップ」ボタンは、
NextRowを辿るのをやめて、その会話の終端行を直接ShowLineするのが安全です。途中の受諾処理などを飛ばしたくない場合は、終端まで内部で早送りする作りにします
まとめ
- セリフは 1行=1セリフ の Data Table に置く。話者・本文・次の行を列にし、本文は Text型 で持つ
- タイプライターは 全文をString型で持ち、表示文字数をTimerで増やして
Leftで切り出す。Set Timer by Eventをループにする - 送りは 2段階 。表示途中なら全文表示へジャンプ、表示済みなら次の行へ。
IsTypingで分ける - 選択肢は
Create Widget→Add Child to Vertical Boxでその場に並べ、選んだボタンのTargetRowをShowLineに渡すだけで分岐する - 会話中は
Set Input Mode UI Onlyで移動を止め、送りは Button の On Clicked で受ける。終わりに Game Only とカーソル非表示をペアで 戻す
いま作っているゲームで、プレイヤーが最初に話しかける相手は誰でしょうか。その1人分の会話を、4行のData Tableから始めてみてください。