NPCController 是对话控制器,每个NPC挂一个这个脚本。
每个NPC注意管理任务和对话脚本,如下
public class NPCController : MonoBehaviour
{
public ConversationScript[] conversations; // 对话脚本
Quest activeQuest = null; // 当前激活的任务
Quest[] quests; // 这个npc 的任务列表
......
}
和npc 碰撞时会触发对话,触发逻辑如下
public void OnCollisionEnter2D(Collision2D collision)
{
var c = GetConversation();
if (c != null)
{
var ev = Schedule.Add(); // 使用自定义的事件触发系统
ev.conversation = c;
ev.npc = this;
ev.gameObject = gameObject;
ev.conversationItemKey = "";
}
}
ConversationScript GetConversation()
{
if (activeQuest == null) // 如果当前没有任务,执行默认的第一条对话
return conversations[0];
foreach (var q in quests)
{
if (q == activeQuest) // 从任务列表里找到当前任务
{
if (q.IsQuestComplete()) // 如果任务可完成,完成任务,执行完成任务对话
{
CompleteQuest(q);
return q.questCompletedConversation;
}
return q.questInProgressConversation; // 如果任务未完成,执行对应任务的未完成对话
}
}
return null;
}
上面这个触发对话就使用了本系列第一章中所讲的自定义事件触发系统,其中
除了对话,就是对任务的处理。主要是对开始任务和完成任务的处理,开始任务设置当前NPC正激活的任务。
完成任务需要看玩家是否达成完成条件,这里是看有没有某个物品,完成了就发放任务奖励。具体实现如下。
public void StartQuest(Quest q)
{
if (activeQuest != null) throw new System.Exception("Only one quest should be active.");
activeQuest = q; // 一个NPC 同时只能有一个任务
}
public void CompleteQuest(Quest q)
{
if (activeQuest != q) throw new System.Exception("Completed quest is not the active quest.");
foreach (var i in activeQuest.requiredItems) // 看玩家有没有 这个任务需要的物品,有的话从玩家背包删除
{
model.RemoveInventoryItem(i.item, i.count);
}
activeQuest.RewardItemsToPlayer(); // 发放奖励物品
activeQuest.OnFinishQuest();
activeQuest = null;
}
好了,NPC 控制器就这么多东西了,其主要用了对话模块和任务模块,接下来看看这俩个模块是怎么实现的。
对话控制模块和玩家的交互时使用。
显示对话事件先需要显示对话时会启动ShowConversation 对话事件,看看这个事件都有哪些变量。
public class ShowConversation : Event{ public NPCController npc; // 对话所属npc 控制器 public GameObject gameObject; // 对话所属游戏对象 public ConversationScript conversation; // 对话脚本 public string conversationItemKey; // 对话脚本里存的字典,这里是它的key ...... }
注意上一节代码给这个对话事件赋了值
ev.conversation = c; ev.npc = this; ev.gameObject = gameObject; ev.conversationItemKey = "";
自实现的事件系统只要实现Execute() 方法,在时间到的时候会调用这个方法。
这个方法注意实现逻辑:
- 根据赋值获取当前的对话片段
- 当前对话片段是否包含任务,如果有任务就启动任务或者显示任务完成对话。
- 显示对话框UI。
- 播放对话动画、音频。
- 处理对话框的几种选择,根据选择设置下一个对话片段。
完整代码如下:
public override void Execute()
{
ConversationPiece ci; // 获得对话片段
//default to first conversation item if no key is specified, else find the right conversation item.
if (string.IsNullOrEmpty(conversationItemKey))
ci = conversation.items[0];
else
ci = conversation.Get(conversationItemKey);
//if this item contains an unstarted quest, schedule a start quest event for the quest.
if (ci.quest != null) // 如果这个对话片段有任务
{
if (!ci.quest.isStarted) // 如果任务未启动,调用StartQuest 事件。
{
var ev = Schedule.Add(1);
ev.quest = ci.quest;
ev.npc = npc;
}
if (ci.quest.isFinished && ci.quest.questCompletedConversation != null) // 如果任务完成的话,设置任务完成对话
{
ci = ci.quest.questCompletedConversation.items[0];
}
}
//calculate a position above the player's sprite.
var position = gameObject.transform.position;
var sr = gameObject.GetComponent();
if (sr != null) // 调整对话框的位置,在npc头顶显示
{
position += new Vector3(0, 2 * sr.size.y + (ci.options.Count == 0 ? 0.1f : 0.2f), 0);
}
//show the dialog
model.dialog.Show(position, ci.text);
var animator = gameObject.GetComponent();
if (animator != null) // 设置说话动画
{
animator.SetBool("Talk", true);
var ev = Schedule.Add(2); // 说2s, 2s 后调用停止说话事件
ev.animator = animator;
}
if (ci.audio != null) // 如果这个片段有音频的话就播放
{
UserInterfaceAudio.PlayClip(ci.audio);
}
//speak some gibberish at two speech syllables per word.
UserInterfaceAudio.Speak(gameObject.GetInstanceID(), ci.text.Split(' ').Length * 2, 1);
//if this conversation item has an id, register it in the model.
if (!string.IsNullOrEmpty(ci.id)) // 注册这个对话片段,标识已经说过了
model.RegisterConversation(gameObject, ci.id);
//setup conversation choices, if any.
if (ci.options.Count == 0) // 几种对话选择
{
//do nothing
}
else
{
//Create option buttons below the dialog.
for (var i = 0; i < ci.options.Count; i++) // 每个选择对应一个按钮
{
model.dialog.SetButton(i, ci.options[i].text);
}
//if user pickes this option, schedule an event to show the new option.
model.dialog.onButton += (index) => // 给每个按钮添加点击事件
{
//hide the old text, so we can display the new.
model.dialog.Hide();
//This is the id of the next conversation piece.
var next = ci.options[index].targetId; // 取得按钮点击对应的下一个对话片段
//Make sure it actually exists!
if (conversation.ContainsKey(next))
{
//find the conversation piece object and setup a new event with correct parameters.
var c = conversation.Get(next);
var ev = Schedule.Add(0.25f); // 显示新的对话片段事件
ev.conversation = conversation;
ev.gameObject = gameObject;
ev.conversationItemKey = next;
}
else
{
Debug.LogError($"No conversation with ID:{next}");
}
};
}
//if conversation has an icon associated, this will display it.
model.dialog.SetIcon(ci.image);
}
这个显示对话框事件执行时,会检查这个对话框是否有任务,有并且未执行的话会启动一个StartQuest 事件,这个先记下来,在接下来的任务模块中会分析它的用处。
对话使用的组件上述代码中,使用的ConversationPiece、ConversationScript 都是什么呢,实际也很好理解,这小节就自上而下来看看这些组件。
ConversationScript对话脚本,挂在人物身上,包含这个人物所有的对话,可以看下面这两个成员变量,一个List 存所有对话,一个字典映射这些对话。
[HideInInspector] [SerializeField] public Listitems = new List (); Dictionary index = new Dictionary (); // 对话脚本包含的所有对话和映射
其他基本都是一些set、get 操作,不用关心,只需要再关注一点,这个脚本继承了ISerializationCallbackReceiver。
这个是能自己定义序列化时要执行的动作,比如本脚本实现了OnAfterDeserialize(),功能就是在items序列化完成后,写入字典,因为Unity 编辑器默认不支持字典的序列化。
public void OnAfterDeserialize()
{
index.Clear();
foreach (var i in items)
index[i.id] = i;
}
ConversationPiece
多个对话片段组成一个对话脚本,是上面items 中的一个元素,游戏中一段对话会有多句话,这可以理解为其中的一句话。
这个中主要理解下面这两个成员变量就行了。
public Quest quest; // 片段包含的任务
public List options; // 一个对话片段的多个选项,例如“确定” “不确定”等按钮
任务是到这个对话时触发什么任务,比如到最后一段对话了,给玩家发布一个任务,就把这个任务挂在最后一段对话上。
options 是可选的多个选项,游戏中一段对话,给玩家几个选项,点击不同的选项会有不同的响应。
ConversationOption这个就是选项之一,主要关注它的targetId 属性,这个表示目标对话片段,也就是点了这个选项后,NPC 会跳到那个对话片段。
行啦,对话模块就这么多了,结束,这个模块应该是整个项目最复杂的地方了,涉及脚本较多。但经过上面的讲解,逻辑就清晰很多了。
上面还用到了任务模块,这应该也是这个Demo 的最后一段业务逻辑了。
任务模块这个任务模块不难,只有两个脚本,一个是任务本身,另一个是启动任务事件。
启动任务事件如果前几篇文章认真看了,并理解了代码,那这个就很好看懂了,就下面这些代码,完全没啥可说的。就是在事件执行的时候调用任务的OnStartQuest() 和NPC 的StartQuest()。
public class StartQuest : Event任务Quest 两种模式{ public Quest quest; public NPCController npc; public override void Execute() { if (!quest.isStarted) { quest.isStarted = true; quest.OnStartQuest(); npc.StartQuest(quest); } } }
public enum SpawnMode
{
CloneAndEnable, // 克隆并可见
CloneOnly // 仅克隆
}
public GameObject[] spawnOnQuestStart; // 任务开始时生成的物体
任务的两种模式如上,在任务开始触发时,会生成spawnOnQuestStart 里的实例,这两种模式就控制生成的物体是否激活。
任务完成条件和任务奖励判断任务是否完成时要判断是否达成任务要求,完成任务需要的条件是背包里的物品满足任务需要的物品,其定义的结果如下。
public class ItemRequirement
{
public InventoryItem item;
public int count = 1;
}
public ItemRequirement[] requiredItems; // 任务需求物品
public InventoryItem[] rewardItems; // 任务奖励物品
在任务进行中又碰撞NPC时会判断是否完成任务,判断代码如下。看背包里的物品是否满足requiredItems 需要的物品。
public bool IsQuestComplete()
{
var inv = new HashSet(model.InventoryItems);
foreach (var i in requiredItems) // 看背包里的物品是否满足`requiredItems` 需要的物品
{
if (inv.Contains(i.item.name) && model.GetInventoryCount(i.item.name) >= i.count) continue;
return false;
}
return true;
}
如果上一步判断通过,就会进行任务奖励操作,把奖励物品放到背包里。
public void RewardItemsToPlayer()
{
foreach (var i in rewardItems) // 遍历奖励列表,提示并加到背包里
{
MessageBar.Show($"You collected: {i.name} x {i.count}");
model.AddInventoryItem(i);
UserInterfaceAudio.OnCollect();
i.gameObject.SetActive(false);
}
if (outroCutscenePrefab != null) // 如果有过场动画的话就播放过场动画
{
var cs = Instantiate(outroCutscenePrefab);
if (cs.audioClip != null)
{
cs.OnFinish += (i) => model.musicController.CrossFade(model.musicController.audioClip);
}
}
}
在任务开始和结束的时候,会根据设置初始化和销毁一些物体,这些都比较简单,也不关键,这里就不多说了。
好了,关于这个Demo 的大部分Gameplay 都说完了,包含事件系统、背包、任务、对话等玩法,对于一个RPG游戏来说还缺少很多,并且这些已有的模块也不是很完善,这里先作了解,待以后慢慢学习。



