4. NPC与对话系统
2026/4/14大约 9 分钟
4. 伏魔记——NPC与对话系统
4.1 什么是NPC和对话系统?
想象你走进一家小餐馆。老板娘在柜台后面冲你微笑:"来啦?老样子?"——这就是一个NPC(Non-Player Character,非玩家角色)在和你对话。
在RPG游戏中,NPC就是那些不由玩家控制的角色——村里的老爷爷、商店的老板、给你任务的侠客、路上的妖怪。对话系统则是让这些NPC能"说话"的机制,让你能和他们交流、获取信息、触发事件。
对话系统的核心功能
| 功能 | 说明 | 生活比喻 |
|---|---|---|
| 基础对话 | NPC说一段话,你按确认键继续 | 聊天 |
| 选项分支 | NPC问你问题,你选择回答 | 做选择 |
| 条件对话 | 根据任务进度显示不同内容 | 老板认出老顾客 |
| 任务触发 | 对话完成后开启新任务 | 老板给你派活 |
| 商店功能 | 对话后打开买卖界面 | 走进便利店 |
4.2 对话数据格式
对话内容需要用一种结构化的格式来存储。我们用JSON格式来定义对话——JSON就像一张"表格",每一行是一个对话节点,包含说话者、内容和选项。
对话JSON示例
{
"id": "elder_intro",
"nodes": [
{
"id": "start",
"speaker": "村长",
"text": "年轻人,欢迎来到清风镇!最近山上的妖怪越来越多了...",
"next": "ask_help"
},
{
"id": "ask_help",
"speaker": "村长",
"text": "你能帮我们去看看吗?",
"choices": [
{
"text": "当然可以!",
"next": "accept",
"action": { "type": "start_quest", "quest_id": "clear_forest" }
},
{
"text": "我还没准备好...",
"next": "refuse"
}
]
},
{
"id": "accept",
"speaker": "村长",
"text": "太好了!这是给你的一点盘缠,路上小心!",
"next": "end",
"action": { "type": "give_gold", "amount": 50 }
},
{
"id": "refuse",
"speaker": "村长",
"text": "没关系,等你准备好了再来找我吧。",
"next": "end"
}
]
}对话数据结构
C
using System.Collections.Generic;
using Godot;
/// <summary>
/// 对话节点——对话树中的一个"节点"
/// 每个节点包含一段文字和可能的选项
/// </summary>
public class DialogueNode
{
public string Id { get; set; } // 节点ID
public string Speaker { get; set; } // 说话者名字
public string Text { get; set; } // 对话文字
public string Next { get; set; } // 下一个节点ID(无选项时使用)
public List<DialogueChoice> Choices { get; set; } // 选项列表
public DialogueAction Action { get; set; } // 触发动作
}
/// <summary>
/// 对话选项——玩家可以选择的回答
/// </summary>
public class DialogueChoice
{
public string Text { get; set; } // 选项显示文字
public string Next { get; set; } // 选择后跳转的节点ID
public string ConditionFlag { get; set; } // 显示条件(需要某个标记为true)
public DialogueAction Action { get; set; } // 选择后触发的动作
}
/// <summary>
/// 对话动作——对话中可以触发的效果
/// </summary>
public class DialogueAction
{
public string Type { get; set; } // 动作类型
public string QuestId { get; set; } // 任务ID
public int Amount { get; set; } // 数量(金币等)
public string ItemId { get; set; } // 物品ID
public string FlagName { get; set; } // 标记名称
public bool FlagValue { get; set; } // 标记值
}
/// <summary>
/// 对话数据——一整段对话的完整数据
/// </summary>
public class DialogueData
{
public string Id { get; set; }
public Dictionary<string, DialogueNode> Nodes { get; set; }
/// <summary>
/// 根据节点ID获取对话节点
/// </summary>
public DialogueNode GetNode(string nodeId)
{
return Nodes.TryGetValue(nodeId, out var node) ? node : null;
}
}GDScript
## 对话节点——对话树中的一个"节点"
class_name DialogueNode
var id: String ## 节点ID
var speaker: String ## 说话者名字
var text: String ## 对话文字
var next: String ## 下一个节点ID
var choices: Array ## 选项列表(DialogueChoice数组)
var action: Dictionary ## 触发动作
## 对话选项——玩家可以选择的回答
class_name DialogueChoice
var text: String ## 选项显示文字
var next: String ## 选择后跳转的节点ID
var condition_flag: String ## 显示条件
var action: Dictionary ## 选择后触发的动作
## 对话数据——一整段对话的完整数据
class_name DialogueData
var id: String
var nodes: Dictionary = {} ## 节点字典: {node_id: DialogueNode}
## 根据节点ID获取对话节点
func get_node(node_id: String) -> DialogueNode:
if nodes.has(node_id):
return nodes[node_id]
return null4.3 对话管理器
DialogueManager 是对话系统的"大脑",它负责读取对话数据、控制对话流程、显示文字、处理选项。
C
using Godot;
using System.Collections.Generic;
/// <summary>
/// 对话管理器——控制对话的流程和显示
/// </summary>
public partial class DialogueManager : Node
{
// 当前对话数据
private DialogueData _currentDialogue;
private DialogueNode _currentNode;
// UI引用
private Control _dialogueBox;
private Label _speakerLabel;
private RichTextLabel _textLabel;
private VBoxContainer _choicesContainer;
// 打字机效果
private bool _isTyping = false;
private int _charIndex = 0;
private float _typeTimer = 0;
private string _fullText = "";
// 信号
[Signal] public delegate void DialogueStartedEventHandler(string dialogueId);
[Signal] public delegate void DialogueFinishedEventHandler();
[Signal] public delegate void DialogueActionTriggeredEventHandler(
Dictionary action);
public bool IsDialogueActive => _currentDialogue != null;
public override void _Ready()
{
// 获取UI引用
_dialogueBox = GetNode<Control>("%DialogueBox");
_speakerLabel = GetNode<Label>("%SpeakerLabel");
_textLabel = GetNode<RichTextLabel>("%TextLabel");
_choicesContainer = GetNode<VBoxContainer>("%ChoicesContainer");
// 默认隐藏对话框
_dialogueBox.Visible = false;
}
public override void _Process(double delta)
{
if (!_isTyping) return;
_typeTimer += (float)delta;
while (_typeTimer >= RpgConstants.TextSpeed)
{
_typeTimer -= RpgConstants.TextSpeed;
_charIndex++;
if (_charIndex > _fullText.Length)
{
// 打字完成
_isTyping = false;
_textLabel.Text = _fullText;
ShowChoicesOrContinue();
break;
}
// 显示当前已打出的文字
_textLabel.Text = _fullText.Substring(0, _charIndex);
}
}
/// <summary>
/// 开始一段对话
/// </summary>
public void StartDialogue(DialogueData dialogue)
{
_currentDialogue = dialogue;
_currentNode = dialogue.GetNode("start");
_dialogueBox.Visible = true;
GameManager.Instance.GameState = RpgGameState.Dialogue;
EmitSignal(SignalName.DialogueStarted, dialogue.Id);
ShowNode(_currentNode);
}
/// <summary>
/// 显示一个对话节点
/// </summary>
private void ShowNode(DialogueNode node)
{
if (node == null)
{
EndDialogue();
return;
}
_currentNode = node;
// 显示说话者名字
_speakerLabel.Text = node.Speaker;
// 清空选项
foreach (var child in _choicesContainer.GetChildren())
child.QueueFree();
// 开始打字机效果
_fullText = node.Text;
_charIndex = 0;
_typeTimer = 0;
_isTyping = true;
_textLabel.Text = "";
}
/// <summary>
/// 打字完成后,显示选项或等待继续
/// </summary>
private void ShowChoicesOrContinue()
{
// 如果有选项,显示选项按钮
if (_currentNode.Choices != null && _currentNode.Choices.Count > 0)
{
foreach (var choice in _currentNode.Choices)
{
var button = new Button { Text = choice.Text };
button.Pressed += () => OnChoiceSelected(choice);
_choicesContainer.AddChild(button);
}
}
// 如果没有选项但指定了next,等待玩家按键继续
else if (!string.IsNullOrEmpty(_currentNode.Next))
{
// "按确认键继续" 的提示由UI显示
}
// 否则对话结束
else
{
EndDialogue();
}
}
/// <summary>
/// 玩家按下确认键时调用
/// </summary>
public void OnConfirmPressed()
{
if (_isTyping)
{
// 跳过打字效果,直接显示全部文字
_isTyping = false;
_textLabel.Text = _fullText;
ShowChoicesOrContinue();
return;
}
// 如果没有选项,继续到下一个节点
if (_currentNode.Choices == null
|| _currentNode.Choices.Count == 0)
{
if (!string.IsNullOrEmpty(_currentNode.Next))
{
// 执行当前节点的动作
ExecuteAction(_currentNode.Action);
var nextNode = _currentDialogue.GetNode(_currentNode.Next);
ShowNode(nextNode);
}
else
{
EndDialogue();
}
}
}
/// <summary>
/// 玩家选择了一个选项
/// </summary>
private void OnChoiceSelected(DialogueChoice choice)
{
// 执行选项的动作
ExecuteAction(choice.Action);
// 跳转到下一个节点
var nextNode = _currentDialogue.GetNode(choice.Next);
ShowNode(nextNode);
}
/// <summary>
/// 执行对话动作(给金币、开任务、设标记等)
/// </summary>
private void ExecuteAction(DialogueAction action)
{
if (action == null) return;
switch (action.Type)
{
case "start_quest":
GameManager.Instance._activeQuests.Add(action.QuestId);
GD.Print($"开启任务: {action.QuestId}");
break;
case "give_gold":
GameManager.Instance.AddGold(action.Amount);
break;
case "give_item":
GD.Print($"获得物品: {action.ItemId}");
break;
case "set_flag":
GameManager.Instance.SetFlag(action.FlagName, action.FlagValue);
break;
}
}
/// <summary>
/// 结束对话
/// </summary>
private void EndDialogue()
{
_currentDialogue = null;
_currentNode = null;
_dialogueBox.Visible = false;
GameManager.Instance.GameState = RpgGameState.TownExplore;
EmitSignal(SignalName.DialogueFinished);
}
}GDScript
extends Node
## 对话管理器——控制对话的流程和显示
# 当前对话数据
var _current_dialogue: DialogueData = null
var _current_node: DialogueNode = null
# UI引用
@onready var _dialogue_box: Control = %DialogueBox
@onready var _speaker_label: Label = %SpeakerLabel
@onready var _text_label: RichTextLabel = %TextLabel
@onready var _choices_container: VBoxContainer = %ChoicesContainer
# 打字机效果
var _is_typing: bool = false
var _char_index: int = 0
var _type_timer: float = 0
var _full_text: String = ""
# 信号
signal dialogue_started(dialogue_id: String)
signal dialogue_finished
signal dialogue_action_triggered(action: Dictionary)
var is_dialogue_active: bool:
get: return _current_dialogue != null
func _ready() -> void:
# 默认隐藏对话框
_dialogue_box.visible = false
func _process(delta: float) -> void:
if not _is_typing:
return
_type_timer += delta
while _type_timer >= RpgConstants.TEXT_SPEED:
_type_timer -= RpgConstants.TEXT_SPEED
_char_index += 1
if _char_index > _full_text.length():
# 打字完成
_is_typing = false
_text_label.text = _full_text
_show_choices_or_continue()
break
# 显示当前已打出的文字
_text_label.text = _full_text.substr(0, _char_index)
## 开始一段对话
func start_dialogue(dialogue: DialogueData) -> void:
_current_dialogue = dialogue
_current_node = dialogue.get_node("start")
_dialogue_box.visible = true
GameManager.game_state = RpgGameState.DIALOGUE
dialogue_started.emit(dialogue.id)
_show_node(_current_node)
## 显示一个对话节点
func _show_node(node: DialogueNode) -> void:
if node == null:
_end_dialogue()
return
_current_node = node
# 显示说话者名字
_speaker_label.text = node.speaker
# 清空选项
for child in _choices_container.get_children():
child.queue_free()
# 开始打字机效果
_full_text = node.text
_char_index = 0
_type_timer = 0
_is_typing = true
_text_label.text = ""
## 打字完成后,显示选项或等待继续
func _show_choices_or_continue() -> void:
# 如果有选项,显示选项按钮
if node.choices and node.choices.size() > 0:
for choice in _current_node.choices:
var button = Button.new()
button.text = choice.text
button.pressed.connect(_on_choice_selected.bind(choice))
_choices_container.add_child(button)
elif _current_node.next != "":
pass # 等待玩家按键继续
else:
_end_dialogue()
## 玩家按下确认键时调用
func on_confirm_pressed() -> void:
if _is_typing:
# 跳过打字效果,直接显示全部文字
_is_typing = false
_text_label.text = _full_text
_show_choices_or_continue()
return
# 如果没有选项,继续到下一个节点
if not _current_node.choices or _current_node.choices.size() == 0:
if _current_node.next != "":
# 执行当前节点的动作
_execute_action(_current_node.action)
var next_node = _current_dialogue.get_node(_current_node.next)
_show_node(next_node)
else:
_end_dialogue()
## 玩家选择了一个选项
func _on_choice_selected(choice: DialogueChoice) -> void:
# 执行选项的动作
_execute_action(choice.action)
# 跳转到下一个节点
var next_node = _current_dialogue.get_node(choice.next)
_show_node(next_node)
## 执行对话动作
func _execute_action(action: Dictionary) -> void:
if action == null or action.is_empty():
return
match action.get("type", ""):
"start_quest":
GameManager._active_quests.append(action.quest_id)
print("开启任务: ", action.quest_id)
"give_gold":
GameManager.add_gold(action.amount)
"give_item":
print("获得物品: ", action.item_id)
"set_flag":
GameManager.set_flag(action.flag_name, action.flag_value)
## 结束对话
func _end_dialogue() -> void:
_current_dialogue = null
_current_node = null
_dialogue_box.visible = false
GameManager.game_state = RpgGameState.TOWN_EXPLORE
dialogue_finished.emit()4.4 NPC控制器
NPC是地图上的实体角色,玩家走过去按确认键就能和它对话。
C
using Godot;
/// <summary>
/// NPC控制器——控制NPC的外观和行为
/// </summary>
public partial class NpcController : CharacterBody2D
{
[Export] public string NpcName { get; set; } // NPC名字
[Export] public string DialogueId { get; set; } // 关联的对话ID
[Export] public bool IsShopkeeper { get; set; } // 是否是商店NPC
[Export] public Texture2D HeadIcon { get; set; } // 头像图标
private AnimatedSprite2D _sprite;
private Area2D _interactArea;
// NPC头顶的标记(感叹号/问号)
private Sprite2D _marker;
public override void _Ready()
{
_sprite = GetNode<AnimatedSprite2D>("AnimatedSprite2D");
_interactArea = GetNode<Area2D>("InteractArea");
_marker = GetNode<Sprite2D>("Marker");
// 播放默认动画
_sprite.Play("idle_down");
}
/// <summary>
/// 玩家与此NPC交互
/// </summary>
public void Interact()
{
GD.Print($"与 {NpcName} 交互");
// 如果是商店NPC,打开商店
if (IsShopkeeper)
{
GameManager.Instance.GameState = RpgGameState.Shop;
// 打开商店UI
var shopUI = GetNode<Control>("/root/Main/UILayer/ShopUI");
shopUI.Visible = true;
return;
}
// 普通NPC,开始对话
if (!string.IsNullOrEmpty(DialogueId))
{
var dialogueManager = GetNode<DialogueManager>(
"/root/Main/DialogueManager");
// 这里简化处理,实际应从JSON加载对话数据
GD.Print($"加载对话: {DialogueId}");
}
}
/// <summary>
/// 更新NPC头顶的标记
/// </summary>
public void UpdateMarker(string markerType)
{
switch (markerType)
{
case "exclamation": // 感叹号(有新任务)
_marker.Visible = true;
// 设置感叹号图标
break;
case "question": // 问号(任务进行中)
_marker.Visible = true;
// 设置问号图标
break;
case "none": // 无标记
default:
_marker.Visible = false;
break;
}
}
}GDScript
extends CharacterBody2D
## NPC控制器——控制NPC的外观和行为
@export var npc_name: String ## NPC名字
@export var dialogue_id: String ## 关联的对话ID
@export var is_shopkeeper: bool = false ## 是否是商店NPC
@export var head_icon: Texture2D ## 头像图标
@onready var _sprite: AnimatedSprite2D = $AnimatedSprite2D
@onready var _interact_area: Area2D = $InteractArea
@onready var _marker: Sprite2D = $Marker
func _ready() -> void:
# 播放默认动画
_sprite.play("idle_down")
## 玩家与此NPC交互
func interact() -> void:
print("与 ", npc_name, " 交互")
# 如果是商店NPC,打开商店
if is_shopkeeper:
GameManager.game_state = RpgGameState.SHOP
var shop_ui = get_node("/root/Main/UILayer/ShopUI")
shop_ui.visible = true
return
# 普通NPC,开始对话
if dialogue_id != "":
var dialogue_manager = get_node("/root/Main/DialogueManager")
print("加载对话: ", dialogue_id)
## 更新NPC头顶的标记
func update_marker(marker_type: String) -> void:
match marker_type:
"exclamation": # 感叹号(有新任务)
_marker.visible = true
"question": # 问号(任务进行中)
_marker.visible = true
"none", _: # 无标记
_marker.visible = falseNPC场景结构
NpcBase (CharacterBody2D) → 附加脚本: npc_controller.gd/.cs
├── CollisionShape2D ← 碰撞形状
├── AnimatedSprite2D ← NPC动画精灵
├── InteractArea (Area2D) ← 交互检测区域
│ └── CollisionShape2D
└── Marker (Sprite2D) ← 头顶标记(感叹号/问号)4.5 对话框UI布局
对话框是显示在屏幕底部的半透明面板:
┌──────────────────────────────────────────┐
│ │
│ (游戏画面继续显示) │
│ │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ [头像] 村长 │ │
│ │ ─────────────────────────────────── │ │
│ │ 年轻人,欢迎来到清风镇!最近山上 │ │
│ │ 的妖怪越来越多了... │ │
│ │ │ │
│ │ ┌─────────────┐ ┌──────────────┐ │ │
│ │ │ 当然可以! │ │ 我还没准备好..│ │ │
│ │ └─────────────┘ └──────────────┘ │ │
│ │ ▼ 按确认键继续 │ │
│ └──────────────────────────────────────┘ │
└──────────────────────────────────────────┘4.6 本章小结
| 知识点 | 说明 |
|---|---|
| 对话数据 | 用JSON格式存储对话树,支持分支选项和触发动作 |
| 对话管理器 | 控制对话流程:加载→显示→打字机效果→选项→结束 |
| NPC控制器 | 地图上的交互实体,支持对话和商店功能 |
| 打字机效果 | 逐字显示文字,按确认键可跳过 |
| 对话动作 | 通过对话可以触发:开任务、给金币、设标记 |
下一章我们将实现回合制战斗系统——伏魔记最核心的玩法。
