[Unity Demo]从零开始制作空洞骑士Hollow Knight第二十三集:制作游戏的NPC系统和以蜗牛萨满为例讲解如何开展围绕NPC的游戏剧情

news/2024/11/26 22:09:21/

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档

文章目录

  • 前言
  • 一、制作游戏的NPC系统
    • 1.控制NPC逻辑行为搭建蜗牛萨满场景
    • 2.通过脚本读取游戏支持语言以及读取TextAsset文件以初步实现游戏多语言功能
  • 二、以蜗牛萨满为例讲解如何开展围绕NPC的游戏剧情
    • 1.搭建蜗牛萨满场景
    • 2.制作第一个NPC蜗牛萨满的第一阶段
    • 3.制作第一个NPC蜗牛萨满的第二阶段
    • 4.制作第一个NPC蜗牛萨满的第三阶段
  • 总结


前言       

  hello大家好久没见,之所以隔了这么久才更新并不是因为我又放弃了这个项目,而是接下来要制作的工作太忙碌了,每次我都花了很长的时间解决完一个部分,然后就没力气打开CSDN写文章就直接睡觉去了,而且还有就是我上上一期写了一万字的内容结果系统出BUG给我没保存直接气晕了,现在刚刚醒来终于有时间整理下我新制作的内容了,还有就是感谢兄弟们的评论支持,我看到评论区有个哥们说让我讲讲游戏的车站系统,我只能说哥们如果你想看的话我还没那么快写这部分的文章,如果你真想看的话可以到我的github下载,我刚好已上传到最新:

GitHub - ForestDango/Hollow-Knight-Demo: A new Hollow Knight Demo after 2 years!

        这一期我们就顺着上一期继续讲我们的NPC系统,以为上一期做的内容有点云里雾里的确实不好解释,那么这一期我们直接一次性做完一个完整的NPC系统,然后再以蜗牛萨满的场景为例讲解如何通过合适的逻辑处理完整一个场景的全部NPC系统以推动剧情发展。 


一、制作游戏的NPC系统

1.控制NPC逻辑行为搭建蜗牛萨满场景

        OK首先我们来创建一个名字叫萨满的NPC吧,

        

制作好以后放到游戏场景中,除了有tk2dSprite和tk2dSpriteAnimator以外它也就只多了一个BoxCollider2D,以及分别控制NPC行为和对话行为的两个playmakerFSM:npc_control和Conversation Control

那么我们就来讲讲上一期我们就用到过的相似的NPC系统:npc_control吧

可以看到啊,和上一期我们讲的Inspection的playmaker差不多,不过它的move to offset要改变

以及少量要改变的变量 

 和上一期一样的内容我就直接贴出来不做解释了:

 大家还记得这个Prompt Marker吧,就是Arrow Prompt New对话提示框的生成位置

 这个变量是为了判断是否NPC要总是朝向玩家的位置

这个特别的In Range Turns是当需要NPC一直朝向玩家的时候触发的状态,可以看到它多了一个行为FaceObject,Face的对象当然是Hero 

这个状态是通过几个变量判断玩家和NPC的朝向,比如如果自己朝向右边,玩家却在左边,而勾选上了变量Turns To Speak即需要更改方向来说话,就发送TURN LEFT事件 

将自己朝向左边:Turn Self L

将自己朝向右边:Turn Self R

OK终于到了上一期没讲到的那五个状态了,首先来看第一个Check Proximity:

       总的来说,这就是一个检查玩家的位置的X是否到达两个指定坐标的X,如果没有到达,就移动玩家的坐标来到指定的位置

等玩家到达正确的位置后,向自己的另一个playmakerFSM:Conversation Control发送事件:CONVO START,并向所有playmakerFSM广播事件NPC CONVO START。

等待Conversation Control发送事件CONVO END事件

然后就是重新获得控制Regain Control:

将自身转到一个正确的位置,这里我设置的是x.scale =1 

等0.5秒回到Idle状态。 

其实讲到这里一个可以包含几乎所有NPC行为的playmakerFSM就被我们写完了,但我们还差一个东西,那就是HUD上面的对话框,我们叫它Dialogue Box,还有一个专门管理对话框的manager叫Dialogue Manage.

来到预制体HUD Camera当中,首先要做的即使新建一个DialogueManager,注意看路径别看错

 我们来看看它的playmakerFSM,首先是第一个初始化全球变量DialogueManager

第二个是用来开关Dialogue Box对话框的Box Open:我们通过外界发送事件来控制Dialogue Box的开关,而视觉效果用的是iTween Scale To这个行为改变Box的Scale。

然后就是DialogueBox,它只有一个背景Sprite,两个边界框来组成,

然后这两个Fleur不就用到了我上期讲的脚本InvAnimateUpAndDown.cs吗?刚好就能用上,这里记得设置好它们的参数,尤其是动画名:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;public class InvAnimateUpAndDown : MonoBehaviour
{public string upAnimation;public string downAnimation;public float upDelay;public int randomStartFrameSpriteMax;private tk2dSpriteAnimator spriteAnimator;private MeshRenderer meshRenderer;private float timer;private bool animatingDown;private bool readyingAnimUp;private void Awake(){spriteAnimator = GetComponent<tk2dSpriteAnimator>();meshRenderer = GetComponent<MeshRenderer>();}private void Update(){if(animatingDown && !spriteAnimator.Playing){meshRenderer.enabled = false;animatingDown = false;}if(timer > 0f){timer -= Time.deltaTime;}if(readyingAnimUp && timer <= 0f){animatingDown = false;meshRenderer.enabled = true;if (randomStartFrameSpriteMax > 0){int frame = Random.Range(0, randomStartFrameSpriteMax);spriteAnimator.PlayFromFrame(upAnimation, frame);}else{spriteAnimator.Play(upAnimation);}readyingAnimUp = false;}}public void AnimateUp(){readyingAnimUp = true;timer = upDelay;}public void AnimateDown(){spriteAnimator.Play(downAnimation);animatingDown = true;}public void ReplayUpAnim(){meshRenderer.enabled = true;spriteAnimator.PlayFromFrame(0);}}

最后记得添加上脚本FadeGroup,设置好参数,还有就是关掉这三个子对象的renderer,我们会通过Fade Group来开关的。

那有了框肯定就要有文字啊,然后这个文字肯定是能够显示无数个文字片段的,实现这种方法可能是通过一个数组,一个Switch,但这些都太费事了,所有使用读取TextAsset文件就能一劳永逸的解决这个问题还顺带能够实现游戏的多语言,但这些都是下一节的内容这里先按下不表。

首先创建一个Text为游戏对象

它的子对象包括Arrow和Stop,我们会通过主对象控制显示哪个

然后它们的playmakerFSM都很简单,就是通过接受UP和DOWN事件控制自身的淡入淡出行为:

来看看主对象,首先它第一个playmakerFSM是全球化,目的是方便我们在任意一个playmakerFSM中调用这个变量,我们在它自身的playmakerFSM中把它初始化。 

除了显示文字以外,我们还需要注意一个问题,就是一个对话框里面的内容有可能一页放不满需要到第二页,综上考虑我们再写一个playmakerFSM来控制这些行为:Dialogue Page Control

第一个状态是初始化对象:

然后就是监听是否有MenuAction的UI输入,如果按下的是Submit的按钮就加速播放(此功能好像用不了),如果按下Cancel按钮就是立刻播放完。 

using UnityEngine;namespace HutongGames.PlayMaker.Actions
{[ActionCategory("Controls")][Tooltip("Listens for menu actions, and safely disambiguates jump/submit/attack/cancel/cast.")]public class ListenForMenuActions : FsmStateAction{public FsmEventTarget eventTarget;public FsmEvent submitPressed;public FsmEvent cancelPressed;public FsmBool ignoreAttack;private GameManager gm;private InputHandler inputHandler;public override void Reset(){submitPressed = null;cancelPressed = null;eventTarget = null;}public override void OnEnter(){gm = GameManager.instance;if(gm == null){LogError("Cannot listen for buttons without game manager.");return;}inputHandler = gm.inputHandler;if(inputHandler == null){LogError("Cannot listen for buttons without input handler.");}}public override void OnUpdate(){if(gm != null && !gm.isPaused && inputHandler != null){HeroActions inputActions = inputHandler.inputActions;bool attackInput = ignoreAttack.Value && inputActions.attack.WasPressed;Platform.MenuActions menuActions = Platform.Current.GetMenuAction(inputActions.menuSubmit.WasPressed, inputActions.menuCancel.WasPressed, inputActions.jump.WasPressed, attackInput, inputActions.cast.WasPressed);if(menuActions == Platform.MenuActions.Submit){Fsm.Event(eventTarget, submitPressed);return;}if (menuActions == Platform.MenuActions.Cancel){Fsm.Event(eventTarget, cancelPressed);}}}}
}

然后就是当某一页的文字内容展示已经全部结束后发送的事件PAGE_END,进入Page End 状态就监听按钮来进入下一页。

这个DialogueBox.cs是等会会讲到Text的另一个脚本,它有个方法ShowNextPage()来进入下一页 

如果对话已经结束了发送CONVERSATION_END,等待Stop Pause秒 

显示Arrow 还是 Stop 

这是显示Arrow:

这是显示Stop:

对话结束状态:

播放音效,

 结束对话,发送事件DOWN给arrow和stop,调用函数DialogueBox的方法HideText()

还有需要注意,当玩家受到伤害后会等一帧后直接到达结束对话状态:

还有两种状态是文字加速和文字直接全部展示,都是通过调用函数的方法来实现,但我好像觉得没用到:

然后下面就是我所说的DialogueBox 脚本,该函数主要控制文字的显示和隐藏,以及通过打字机的方式一个接一个按照一定速度展示文本文字的功能。注意向playmakerFSM发送的事件的名字要打对。 

using System;
using System.Collections;
using TMPro;
using UnityEngine;public class DialogueBox : MonoBehaviour
{[Header("Conversation Info")]public string currentConversation; //记录使用的是哪一个文件名的文字段public int currentPage; //记录当前在第几页[Header("Typewriter")][Tooltip("Enables the typewriter effect.")]public bool useTypeWriter; //使用打字节的效果[Range(1f, 100f)]public float revealSpeed = 20f; //展示速度private float normalRevealSpeed; //常规展现文字速度private TextMeshPro textMesh;private PlayMakerFSM proxyFSM;private bool typing;private bool fastTyping;private bool hidden;private TMP_PageInfo[] pageInfo;private void Start(){textMesh = gameObject.GetComponent<TextMeshPro>();normalRevealSpeed = revealSpeed;HideText();proxyFSM = FSMUtility.LocateFSM(gameObject, "Dialogue Page Control");if (proxyFSM == null){Debug.LogWarning("DialogueBox: Couldn't find an FSM on this GameObject to use as a proxy, events will not be fired from this dialogue box.");}}public void StartConversation(string convName, string sheetName){SetConversation(convName, sheetName);ShowPage(1);PrintPageInfoAll();}public void SetConversation(string convName, string sheetName){currentConversation = convName;currentPage = 1;Debug.LogFormat("Start using Language Text!");textMesh.text = Language.Language.Get(convName, sheetName);textMesh.ForceMeshUpdate();}public void ShowPage(int pageNum){if (pageNum < 1 || pageNum > textMesh.textInfo.pageCount){SendConvEndEvent();return;}if (hidden){hidden = false;}if (useTypeWriter){if (typing){StopTypewriter();}textMesh.pageToDisplay = pageNum;currentPage = pageNum;textMesh.maxVisibleCharacters = 600;//textMesh.maxVisibleCharacters = GetFirstCharIndexOnPage() - 1;string text = textMesh.text;text = text.Replace("<br>", "\n");textMesh.text = text;StartCoroutine("TypewriteCurrentPage");return;}textMesh.pageToDisplay = pageNum;currentPage = pageNum;textMesh.maxVisibleCharacters = 600;//textMesh.maxVisibleCharacters = GetLastCharIndexOnPage();SendEndEvent();}private int GetFirstCharIndexOnPage(){return textMesh.textInfo.pageInfo[currentPage - 1].firstCharacterIndex + 1;}public void HideText(){if (typing){StopTypewriter();}textMesh.maxVisibleCharacters = 0;hidden = true;}public void SpeedupTypewriter(){if (typing && !fastTyping){StopTypewriter();normalRevealSpeed = revealSpeed;revealSpeed = 200f;fastTyping = true;StartCoroutine(TypewriteCurrentPage());}}private IEnumerator TypewriteCurrentPage(){if (!typing){InvokeRepeating("ShowNextChar", 0f, 1f / revealSpeed);typing = true;}while (typing){if(textMesh.maxVisibleCharacters >= GetLastCharIndexOnPage()){StopTypewriter();SendEndEvent();}else{yield return null;}}}private void SendEndEvent(){if(currentPage == textMesh.textInfo.pageCount){SendConvEndEvent();return;}SendPageEndEvent();}private void SendPageEndEvent(){if (proxyFSM != null){proxyFSM.SendEvent("PAGE_END");}}private void SendConvEndEvent(){if (proxyFSM != null){proxyFSM.SendEvent("CONVERSATION_END");}}private void StopTypewriter(){CancelInvoke("ShowNextChar");typing = false;fastTyping = false;revealSpeed = normalRevealSpeed;}private int GetLastCharIndexOnPage(){//这个函数有问题需要修复return textMesh.textInfo.pageInfo[currentPage-1].lastCharacterIndex + 1;}private void ShowNextChar(){TextMeshPro textMeshPro = textMesh;int maxVisibleCharacter = textMeshPro.maxVisibleCharacters;textMeshPro.maxVisibleCharacters = maxVisibleCharacter + 1;}public void PrintPageInfoAll(){Debug.LogFormat("Textmesh Length is" + textMesh.maxVisibleCharacters);Debug.LogFormat("PageInfo: Current conversation {0} contains {1} pages.\n", new object[]{currentConversation,textMesh.textInfo.pageCount});for (int i = 0; i < textMesh.textInfo.pageCount; i++){Debug.LogFormat("[Page {0}] Start/End: {1}/{2}\n", new object[]{i + 1,textMesh.textInfo.pageInfo[i+1].firstCharacterIndex,textMesh.textInfo.pageInfo[i+1].lastCharacterIndex});}}}

2.通过脚本读取游戏支持语言以及读取TextAsset文件以初步实现游戏多语言功能

那么要怎么样才能通过读取TextAsset文件的方式实现获取到Dialogue Text上面的textmeshpro中显示呢,还有游戏是一个多语言的游戏,怎么控制让游戏文本始终是一个同种语言上的呢,其实Unity提供了Localization这个插件来实现多语言功能,但其实我们也可以自己写一个:

首先我们创建一个数组展示游戏支持的语言,这里暂时就只有英语和无两种:

namespace Language
{public enum LanguageCode{N,EN,}
}
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Xml;
using UnityEngine;namespace Language
{public static class Language{public static string settingsAssetPath = "Assets/Localization/Resources/Languages/LocalizationSettings.asset";private static LocalizationSettings _settings = null; //本地化设置private static List<string> availableLanguages; //可支持的语言的列表private static LanguageCode currentLanguage = LanguageCode.N; //当前使用语言private static Dictionary<string, Dictionary<string, string>> currentEntrySheets; //当前拥有的文本框。public static LocalizationSettings settings{get{if (_settings == null){//通过文件路径读取可序列化数据里的设置_settings = (LocalizationSettings)Resources.Load("Languages/" + Path.GetFileNameWithoutExtension(settingsAssetPath), typeof(LocalizationSettings));}return _settings;}}static Language(){LoadAvailableLanguages();LoadLanguage();}/// <summary>/// 加载当前能够使用的语言/// </summary>private static void LoadAvailableLanguages(){availableLanguages = new List<string>();if(settings.sheetTitles == null ||settings.sheetTitles.Length == 0){Debug.Log("None available");return;}foreach (object obj in Enum.GetValues(typeof(LanguageCode))){LanguageCode languageCode = (LanguageCode)obj;if (HasLanguageFile(languageCode.ToString() ?? "", settings.sheetTitles[0])){availableLanguages.Add(languageCode.ToString() ?? "");}}StringBuilder stringBuilder = new StringBuilder("Discovered supported languages: ");for (int i = 0; i < availableLanguages.Count; i++){stringBuilder.Append(availableLanguages[i]);if (i < availableLanguages.Count - 1){stringBuilder.Append(", ");}}Debug.Log(stringBuilder.ToString());//发现英语EN是可以使用的 加入到availableLanguages的列表当中Resources.UnloadUnusedAssets();}/// <summary>/// 格式是语言代号EN_sheetTitle的名字/// </summary>/// <param name="lang"></param>/// <param name="sheetTitle"></param>/// <returns></returns>private static bool HasLanguageFile(string lang, string sheetTitle){return (TextAsset)Resources.Load("Languages/" + lang + "_" + sheetTitle, typeof(TextAsset)) != null;}/// <summary>/// 加载当前使用的语言/// </summary>public static void LoadLanguage(){string text = RestoreLanguageSelection();Debug.LogFormat("Restored language code '{0}'", new object[]{text});SwitchLanguage(text);}/// <summary>/// 暂时先加载UNITY系统正在使用的语言/// </summary>/// <returns></returns>private static string RestoreLanguageSelection(){if (settings.useSystemLanguagePerDefault){SystemLanguage systemLanguage = Platform.Current.GetSystemLanguage();Debug.LogFormat("Loaded system language '{0}'", new object[]{systemLanguage});string text = LanguageNameToCode(systemLanguage).ToString();Debug.LogFormat("Loaded system language code '{0}'", new object[]{text});if (availableLanguages.Contains(text)){return text;}Debug.LogErrorFormat("System language code '{0}' is not an available language", new object[]{text});}Debug.LogFormat("Falling back to default language code '{0}'", new object[]{settings.defaultLangCode});return LocalizationSettings.GetLanguageEnum(settings.defaultLangCode).ToString();}public static bool SwitchLanguage(string langCode){return SwitchLanguage(LocalizationSettings.GetLanguageEnum(langCode));}public static bool SwitchLanguage(LanguageCode code){if(availableLanguages.Contains(code.ToString() ?? "")){DoSwitch(code);return true;}Debug.LogError("Could not switch from language " + currentLanguage.ToString() + " to " + code.ToString());if(currentLanguage == LanguageCode.N){if(availableLanguages.Count > 0){DoSwitch(LocalizationSettings.GetLanguageEnum(availableLanguages[0]));Debug.LogError("Switched to " + currentLanguage.ToString() + " instead");}else{Debug.LogError("Please verify that you have the file: Resources/Languages/" + code.ToString());Debug.Break();}}return false;}private static void DoSwitch(LanguageCode newLang){currentLanguage = newLang;currentEntrySheets = new Dictionary<string, Dictionary<string, string>>();foreach (string text in settings.sheetTitles){currentEntrySheets[text] = new Dictionary<string, string>();string languageFileContents = GetLanguageFileContents(text);if(languageFileContents != ""){using (XmlReader xmlReader = XmlReader.Create(new StringReader(languageFileContents))){while (xmlReader.ReadToFollowing("entry")){xmlReader.MoveToFirstAttribute();string value = xmlReader.Value;xmlReader.MoveToElement();string text2 = xmlReader.ReadElementContentAsString().Trim();text2 = text2.UnescapeXML();currentEntrySheets[text][value] = text2;}}}}//TODO:LocalizeAssetSendMonoMessage("ChangedLanguage", new object[]{currentLanguage});//TODO:Config}private static string GetLanguageFileContents(string sheetTitle){TextAsset textAsset = (TextAsset)Resources.Load("Languages/" + currentLanguage.ToString() + "_" + sheetTitle, typeof(TextAsset));if (!(textAsset != null)){return "";}return textAsset.text;}private static void SendMonoMessage(string methodString, params object[] parameters){if (parameters != null && parameters.Length > 1){Debug.LogError("We cannot pass more than one argument currently!");}foreach (GameObject gameObject in (GameObject[])UnityEngine.Object.FindObjectsOfType(typeof(GameObject))){if (gameObject && gameObject.transform.parent == null){if (parameters != null && parameters.Length == 1){gameObject.gameObject.BroadcastMessage(methodString, parameters[0], SendMessageOptions.DontRequireReceiver);}else{gameObject.gameObject.BroadcastMessage(methodString, SendMessageOptions.DontRequireReceiver);}}}}public static LanguageCode CurrentLanguage(){return currentLanguage;}/// <summary>/// 获取文本默认为第0个sheetTitle/// </summary>/// <param name="key"></param>/// <returns></returns>public static string Get(string key){return Get(key, settings.sheetTitles[0]);}/// <summary>/// 获取文本根据某个特定的sheetTitle,一定要有key在currentEntrySheets[sheetTitle]中/// </summary>/// <param name="key"></param>/// <param name="sheetTitle"></param>/// <returns></returns>public static string Get(string key, string sheetTitle){if (currentEntrySheets == null || !currentEntrySheets.ContainsKey(sheetTitle)){Debug.LogError("The sheet with title \"" + sheetTitle + "\" does not exist!");return "";}if (currentEntrySheets[sheetTitle].ContainsKey(key)){return currentEntrySheets[sheetTitle][key];}return "#!#" + key + "#!#";}public static LanguageCode LanguageNameToCode(SystemLanguage name){if(name == SystemLanguage.English){Debug.LogFormat("Current Activate Language is EN!");return LanguageCode.EN;}return LanguageCode.EN;}}
}

其实说白了就是我们把所有的语言和文字段都存储在一个类型为Dictionary<string, Dictionary<string, string>>的currentEntrySheets ,然后它有个字典有点饶,我先来讲讲里面的那个字典Dictionary<string, string>,这个字典存储的是一个包含了所有语言类型的文字段,然后外面那层的键string其实就是语言的代码(比如EN,CN)通过这个键找到里面的字典的对应语言的文字段。这样我们有了新语言我们就能通过设置新的语言代码和添加对应语言的TextAsset来找到了。我们在Get函数中返回的就是currentEntrySheets[sheetTitle][key];

         if (currentEntrySheets[sheetTitle].ContainsKey(key))
        {
        return currentEntrySheets[sheetTitle][key];
        }

我们通过DoSwitch(LanguageCode newLang)函数来决定游戏使用哪一种语言,通过XmlReader使系统读懂TextAsset里的文件,再发送给全部Unity的游戏对象,那些订阅了事件ChangedLanguage的都会接收到并更换语言。

SendMonoMessage("ChangedLanguage", new object[]
        {
        currentLanguage
        });

接着我们来创建一个可序列化数据脚本LocalizationSettings:

using System;
using Language;
using UnityEngine;[CreateAssetMenu(fileName = "LocalizationSettings", menuName = "Hollow Knight/Localization Settings", order = 1000)]
[Serializable]
public class LocalizationSettings : ScriptableObject
{public string[] sheetTitles;public bool useSystemLanguagePerDefault = true;public string defaultLangCode = "EN";public string gDocURL;public static LanguageCode GetLanguageEnum(string langCode){langCode = langCode.ToUpper();foreach (object obj in Enum.GetValues(typeof(LanguageCode))){LanguageCode result = (LanguageCode)obj;if (result.ToString().Equals(langCode, StringComparison.InvariantCultureIgnoreCase)){return result;}}Debug.LogError("ERORR: There is no language: [" + langCode + "]");return LanguageCode.EN;}
}

 通过ToString()函数我们可以轻松将数组LanguageCode里面的成员转化为string类型,然后再跟currentEntrySheet进行判断。还有一些XML相关知识的补充。

using System;
namespace Language
{public static class StringExtensions{public static string UnescapeXML(this string s){if (string.IsNullOrEmpty(s)){return s;}return s.Replace("&apos;", "'").Replace("&quot;", "\"").Replace("&gt;", ">").Replace("&lt;", "<").Replace("&amp;", "&");}}
}

回到Unity当中,记得像我一样创建文件路径注意不要和脚本的读取路径的名字打错字了。

来看看可序列化数据:LocalizationSettings,默认语言设置为EN(因为我只做了英文),然后有十一个Sheet Titles对应的Dictionary<string, string>一个包括所有语言的文本框的键,然后就是导入它们对应的TextAsset文件,然后这些文件的命名规则也是有讲究的,就比如英文就是EN_.......,代码就会识别这是英文语言的文件。

这是因为我们在Language脚本中就已经规定好了的:

private static string GetLanguageFileContents(string sheetTitle)
    {
        TextAsset textAsset = (TextAsset)Resources.Load("Languages/" + currentLanguage.ToString() + "_" + sheetTitle, typeof(TextAsset));
        if (!(textAsset != null))
        {
        return "";
        }
        return textAsset.text;
    } 

 然后我们把NPC萨满的英文TextAsset导入后,发现他们的每一段对话的内容间隔都是由一个entry开始的:

<entries>
<entry name="SHAMAN_QUAKE">Ah ho! I&#39;m sensing new power about you, one that&#39;ll crack the rock beneath us. A useful thing for one looking to travel ever deeper.&lt;page&gt;My third uncle used to possess similar abilities. He also possessed a ferocious temper! Ohohoho! What a dreadful combination. </entry>
<entry name="SHAMAN_OPEN_GATE">And look! The gate between us has opened. Ohoho!&lt;page&gt;I&#39;m sure you&#39;re eager to move on. Farewell, and have faith! Whatever you are seeking... it will find you! Ohohoho!</entry>
<entry name="SHAMAN_OPENED_GATE">Why do you hesitate? You&#39;ll get nothing more from me, I&#39;m afraid.&lt;page&gt;Though I do admire your persistence! Ohohohoho! </entry>
<entry name="SHAMAN_SUMMONED_1">Don&#39;t be afraid. Have faith! That spell belongs to you now, all you need to do is take it!&lt;page&gt;Ohohoho, you won&#39;t be going much further without it, I promise you!</entry>
<entry name="SHAMAN_MEET">Oho! Who is that creeping out of the darkness? My, you&#39;re looking grim! A strange, empty face and a wicked looking weapon!&lt;page&gt;Something important has drawn you down into Hallownest&#39;s corpse, but I won&#39;t ask what. Perhaps the reason you&#39;ve found me is because you need my help?&lt;page&gt;Say no more, friend. I&#39;m going to give you a gift, a nasty little spell of my own creation. It&#39;s just perfect for a little one like you! Ohoho!</entry>
<entry name="SHAMAN_DREAM">Poking around in the dreams of others... You&#39;re more curious than you look! Oho ho ho ho!</entry>
<entry name="SHAMAN_RETURN">Oho! What brings you back through here, little shadow? Are you lost?&lt;page&gt;Don&#39;t worry about me. I don&#39;t need anything more from you. Ohohoho!</entry>
<entry name="SHAMAN_KILLED_BLOCKER_2">Oho! There you are! I was watching over you while you slept, and must have slipped away myself. I woke up and found you&#39;d disappeared! You are a surprising one, ohoho!&lt;page&gt;Actually, I wanted to ask a small favour of you. You see, there is a certain creature lurking just above us, in the heart of this temple...&lt;page&gt;...Oho? Well, yet another surprise! You&#39;ve slain that creature before I&#39;ve even asked!&lt;page&gt;I scarcely deserve such a friend as you! You&#39;re a marvel! Ohohoho!</entry>
<entry name="SHAMAN_KILLED_BLOCKER_1">Oho! So it&#39;s done then, you&#39;ve slain the beast!&lt;page&gt;The poor thing! It must have been terrified of you. It used to be quite docile, but the rancid air in these caverns filled it with some ancient rage.&lt;page&gt;Still, you did what had to be done! You have my gratitude! Of course, we both know you wouldn&#39;t have made it through without that spell of mine... Ohoho!</entry>
<entry name="SHAMAN_FUNG_DREAM">...Hear...me...</entry>
<entry name="SHAMAN_QUAKE2">My friend. My friend. There&#39;s another quality about you yet. You didn&#39;t perchance visit my fourth aunt?&lt;page&gt;She makes her home beside that Crystal Mount. Leaves quite an impression on those that seek her out. Were I not bound here, I&#39;d love to visit myself.</entry>
<entry name="SHAMAN_SCREAM2">That scream? Ooohh, distorted in such a way... It&#39;s not within the skills of us snails to do such a thing.&lt;page&gt;Wherever you draw this new power from, it&#39;s not a place my kind ever thought to look.</entry>
<entry name="SHAMAN_CRYSTAL_DREAM">...Free...me...</entry>
<entry name="SHAMAN_SUMMONED_2">Oho, you&#39;ve come back! Didn&#39;t you like the look of my gift? You left without saying a word!&lt;page&gt;Perhaps you&#39;re braver than you think! Go ahead and take what belongs to you!</entry>
<entry name="SHAMAN_FIREBALL2">What&#39;s this? My vengeful gift has warped within you. You&#39;ve twisted it into something... else.&lt;page&gt;Ohohohoh! I knew it. My friend! You&#39;re a marvel. Your essence has melded with the spell.&lt;page&gt;You must have found a powerful source to transform it in such a unique, expressive way.</entry>
<entry name="SHAMAN_FUNG">A corpse overgrown with vegetation.</entry>
<entry name="SHAMAN_TRAPPED_1">Oho? You&#39;ve woken at last! I apologise, perhaps I should have warned you about the power of that spell. I was watching over you as you slept, but seem to have slipped away myself! Ohohoho!&lt;page&gt;Now we&#39;re awake, I was wondering whether you would do me a small favour. Not as repayment for my gift of course, simply because we&#39;re now friends.&lt;page&gt;You see, a horrid great beast has made its home in the heart of this temple. Such disrespect! I would be quite grateful if you were to venture deeper in and slay it for me.&lt;page&gt;It&#39;s a hardy creature, but with your new power you&#39;re more than a match for it! Good luck with this small favour, my friend! Ohohohohohoho!</entry>
<entry name="SHAMAN_TRAPPED_2">What is it? Are you wondering about this gate between us? Ohoho! It&#39;s a curious thing, but this door will not open until you have slain that creature lying in the heart of the temple.&lt;page&gt;Don&#39;t worry about it. I&#39;m sure the spirits of my ancestors will be watching over you.</entry>
<entry name="SHAMAN_SCREAM">Pried a spell out&#39;ve my larger cousin did you? Aren&#39;t you the charming one.&lt;page&gt;She&#39;s not usually the generous sort, certainly not as giving as myself, but she does have that wonderful voice! It&#39;s no surprise her spells take on such aural force. </entry>
</entries>

然后我们在DialogueBox的SetConversation函数中就可以通过简单的调用Language的Get函数,设置好conversation name对话名字和sheet Title哪一个对象的文字框,然后textMesh.text就获得了从TextAsset转换为text能看懂的文字了。

二、以蜗牛萨满为例讲解如何开展围绕NPC的游戏剧情

1.搭建蜗牛萨满场景

既然做完了一个NPC那我们就可以据此制作剩余的其它的NPC了,所以这里我们以蜗牛萨满为例子讲讲如何围绕NPC开始推动故事的剧情

在此之前我们先来把蜗牛萨满的场景给搭建好了:

 可以看到我们多创建了两个新敌人,一个是这个会滚来滚去的Roller:我们还是先做好它的tk2dsprite和tk2dspriteanimator:

 

三个子对象,第一个Alert Range New是检查玩家是否到距离了:

第二个滚动生成的粒子系统灰尘:

这个是玩家攻击后自身会弹起来,但我没做到所以先放着 

来到主体,先添加上一个敌人该有的脚本: 

然后就是用用playmakerFSM控制它的行为:

 速度有关的变量要先设置:

 

来看第一个状态初始化,获取变量,设置反方向的速度

等待玩家进入Alert Range:

检查自己和玩家的朝向

设置好滚动持续的时间 

检查滚动方向: 

然后就是看是否碰到墙壁:

碰到墙壁后给它一个特定方向的速度,然后开始落地:

落地后就生成灰尘粒子系统:

还有就是受到横向的攻击以后会发送RECOIL HORIZONTAL事件:

如果只是等到了滚动时间结束,那就到Stop状态;

回到Reset状态:

 接下来看看重点敌人巴洛德,玩过的都知道这是一个很特别的敌人,他会选择远程喷橙汁和上面做到的Roller,但当你靠近它的时候它就会把自己蜷缩起来不受到任何攻击。

我们还是先去做好它的tk2dsprite和tk2dspriteanimator:

然后就是添加上它该有的脚本:

 

先来讲讲它的子对象:

这个是喷射的效果:它有一个简单的playmakerFSM

这个是关闭躯壳产生的灰尘的粒子系统:

来讲讲主对象的playmakerFSM:Blocker Control

 

 初始化阶段,初始自己的变量:

 判断朝向:

等待玩家到达Alert Range的范围

判断玩家的火球等级:fireballLevel

打开躯壳:

等待玩家进入攻击距离:

选择发射的是Roller还是橙汁Goop 

这个Shot Mawlek我们讲第二个BOSS的那期说过,本质是一样的

在发射Roller之前,首先要判断玩家有没有火球,没有的话就不执行发射roller只发射橙汁:

 来看看这个敌人预制体Spawn Roller V2:其实它除了不掉落吉欧geo以外跟正常的roller一样:

发射前的准备:

如果发射的是roller,设置roller的bool变量 Moving Right为Facing Right

发射动画的结束,判断玩家的距离决定是继续攻击还是缩起来

 来看看缩起来的状态:这是第一阶段Close1

第二阶段:

紧闭,等待玩家离远点:就去到Open状态

当然,有时候玩家会突然在blocker发射的时候趁机攻击blocker,所以我们要考虑一种情况,当玩家是近距离攻击(即骨钉)的时候,就赶紧缩壳,远距离的话(法术)就播放受伤动画回到Idle状态

除此之外还需要制作blocker的尸体:

 子对象就是粒子系统

 再来看看主对象的playmakerFSM:Corpse Blocker

 更新shaman的state,同时广播shaman事件:UPDATE SHAMAN(这里你不知道这个shaman state是啥没关系,接下来讲到NPC萨满的不同阶段才会用到)

其实这个尸体最重要的就是更新萨满的阶段Shaman State

r然后就是我们经常用到的战斗场景

先看看子对象,首先是两个 CameraLockArea

然后这个时提示玩家使用火球的playmakerFSM,但是我还没做到这个UI,所以先放着:

判断玩家够不够法力值:

不够就循环等待:

够了就广播事件:REMINDER FIREBALL

如果巴洛德已经受到伤害了,就不用再执行这个playmakerFSM了:

再来看看Battle Scene的playmakerFSM:

初始化变量: 

判断有没有被激活: 

激活后就重新设置新的 CameraLockArea 

然后我们有一个新的门Bone Gate,它有两个playmakerFSM:一个是和骨钉产生互动的先不管,另一个则是Bone Gate控制门的开关:

通过第三阶段的shaman发送事件OPEN给它,门就会打开

播放动画:

关掉它的renderer和collider:

OK我们终于也是搭建了一个规模庞大的场景了。

2.制作第一个NPC蜗牛萨满的第一阶段

        看到这么多副标题你可能会想,怎么一个NPC还分起阶段来了,那是因为它的整个完整行为放在playmakerFSM里面实在是太大了,我们么先想想在游戏里蜗牛萨满豆干了什么?首先是看着玩家,然后玩家和它发生几段对话后,它就聚集法术揉了个法术,然后玩家去接收它之后,玩家睡过去,然后播放确认玩家获得法术火球能力,然后播放醒来后被它关在笼子里,跟它对话它让你去消灭里面的野兽,等玩家消灭完野兽后和萨满发生对话,萨满开门并告诉玩家相互信任,然后等玩家离开场景后他就会回到一开始的位置,这看起来好像不多但做起来却是有几十种状态的,所以我们是做一段销毁上一段并生成下一段,这样保证我们不会明明在第二阶段而NPC却还在执行第一段的行为。

        首先来看看第一阶段的萨满Shaman Meeting

先来看看它的子对象,首先是第一个聚集法术的粒子系统,我这里这是为了展示,你们记得把粒子系统的emission排放量设置为0

用来给玩家拾取的聚集法术 

它的子对象也有一个粒子系统:

它拥有两个playmakerFSM,第一个是等玩家触碰到发送GOT事件

Get状态:停止玩家动画和移动,播放音效,设置玩家的重力为0,激活对象Knight Get Fireball(下面会讲) ,给它的另一个playmakerFSM发送事件END

另一个playmakerFSM:FSM

生成了一个叫White Wave R的游戏对象也就是白色的光波,它有一个脚本WaveEffectControl.cs:控制光波的大小:

using UnityEngine;public class WaveEffectControl : MonoBehaviour
{private float timer;public Color colour;public SpriteRenderer spriteRenderer;public float accel;public float accelStart = 5f;public bool doNotRecycle;public bool doNotPositionZ;public bool blackWave;public bool otherColour;public float scaleMultiplier = 1f;private void Start(){spriteRenderer = GetComponent<SpriteRenderer>();}private void OnEnable(){timer = 0f;if (blackWave){colour = new Color(0f, 0f, 0f, 1f);}else{colour = new Color(1f, 1f, 1f, 1f);}accel = accelStart;if (!doNotPositionZ){transform.position = new Vector3(transform.position.x, transform.position.y, 0.1f);}}private void Update(){timer += Time.deltaTime * accel;float num = (1f + timer * 4f) * scaleMultiplier;transform.localScale = new Vector3(num, num, num);Color color = spriteRenderer.color;color.a = 1f - timer;spriteRenderer.color = color;if (timer > 1f){if (!doNotRecycle){gameObject.Recycle();return;}gameObject.SetActive(false);}}private void FixedUpdate(){accel *= 0.95f;if (accel < 0.5f){accel = 0.5f;}}}

下一个子对象叫VS Summon Fx就是萨满聚集生成法术后的视觉效果:

还有就是我们最熟悉的Prompt Marker了。

这个是梦之钉抽到NPC时的效果展示,但我还没做到先不管了。

再来看看主对象Shaman Meeting的两个playmakerFSM:第一个就是我们上面讲过的npc_control

第二个则是控制这个NPC能说什么话的Conversation Control,比如这个蜗牛萨满在第一阶段就要说第一阶段的话,你总不可能你还没获得火球然后萨满就说哇你把怪兽除掉了啊。这第一阶段的话主要是刚开始和小骑士遇到还有你每次获得一个法术它会发表写评价,在此之前我们要知道萨满的sheetName是Shaman,Convo Name就根据TextAsset里面entry name来定。

初始化阶段就初始自己和小骑士 

然后通过playerdata来判断小骑士是否有火球法术,地震法术,嚎叫法术,这三个都是int类型,0表示没有,1表示白波,2表示黑波(我觉得玩过的都懂),然后通过Float判断存储在FSM里面的bool变量中

 我们还要通过PlayerData里面的int类型变量shaman来判断现在的萨满是第几阶段的,如果shaman小于6或者玩家已经有fireball火球了就销毁第一阶段的萨满对象。既然一开始就进不了第一阶段也就进不了第二阶段,因为除非你开了作弊器不然不可能从其他地方获得了火球,所以这应该也算一种官方设定。

然后到了Check Summoned阶段,将Vengeful Spirit脱离父对象 

由于我们是先对话再聚集法术,所以我们第一遍对话设置shaman State为1,对话完设置为2. 

等待另一个PlayMakerFSM发送事件CONVO_START

这个就是选择使用哪一个对话名来对话,这个Area Title我还没哦做到所以先不管。

可以看到Shaman State = 0表示初次见面,1表示聚集时的对话,2表示聚集后的对话:

这个Turn就是Npc播放完转身动画后的等待

我们先来看这期的重点的几个对话:

首先是初次见面的Meet状态:

 可以看到我们调用了DialogueBox.cs里面的StartConversation函数,而它有两个string变量,分别是对话名SHAMAN_MEET和sheettitle名Shaman,有了它我们就可以在Dialogue Text正常显示内容了。

等待另一个playmakerFSM发送结束对话事件CONVO_FINISH

这个是让玩家转身向左 

然后让自己转向左边,因为有可能玩家从右边跟他对话所以这点还是要考虑的,如果本身就朝着左边就直接略过

然后就是播放聚集法术动画和音效的阶段:我甚至还Ease了粒子的排放量让粒子系统视觉看起来像由少变多的。

然后就是激活视觉效果VS Summon Fx还有Vegenful Spirit,设置shaman的状态为1。

发送事件CONVO END和RESET CONVO让玩家重新获得控制。

然后再来看看Summon 1状态,就是当shaman state为1时:

可以看到它  的对话名叫做: SHAMAN_SUMMONED_1

然后就是对话结束。

然后再来看看Summon 2状态,就是当shaman state为2时:我发现我上面理解错了,这个是玩家没有接受萨满的法术离开场景并回来后所产生的对话。

剩下的状态都是和这个Summon 1和Summon 2差不多的,仅仅只是一段对话我这里直接贴出来吧。

最后我们需要一个替身,就像我们制作玩家碰到地刺hazard死亡的替身一样:Knight Get Fireball

 它仅仅只是一个和小骑士相同的动画而已:

我们来看看它的子对象,先从简单的讲起,首先是背景光:

这个Orbs就是玩家聚集法术生成的轨迹效果,但我好像没做好,实际根本不显示我也不知道为什么,但我还是放出来先。 

 

using System.Collections;
using UnityEngine;public class SoulOrb : MonoBehaviour
{public RandomAudioClipTable soulOrbCollectSounds;public ParticleSystem getParticles;public bool awardSoul = true;public bool dontRecycle;private SpriteRenderer sprite;private TrailRenderer trail;private Rigidbody2D body;private AudioSource source;private Coroutine zoomRoutine;public float stretchFactor = 2f;public float stretchMinY = 1f;public float stretchMaxX = 2f;public float scaleModifier;public float scaleModifierMin = 1f;public float scaleModifierMax = 2f;private Transform target;private float speed;private float acceleration;private void Awake(){sprite = GetComponent<SpriteRenderer>();trail = GetComponent<TrailRenderer>();body = GetComponent<Rigidbody2D>();source = GetComponent<AudioSource>();target = HeroController.instance.transform;}private void Start(){transform.SetPositionZ(Random.Range(-0.001f, -0.1f));}private void OnEnable(){if (sprite){sprite.enabled = true;}if (trail){trail.enabled = true;}if (body){body.isKinematic = false;}if (zoomRoutine != null){StopCoroutine(zoomRoutine);}zoomRoutine = null;GameManager.instance.UnloadingLevel += SceneLoading;scaleModifier = Random.Range(scaleModifierMin, scaleModifierMax);}private void OnDisable(){if (sprite){sprite.enabled = false;}if (trail){trail.enabled = false;}if (body){body.isKinematic = false;}GameManager.instance.UnloadingLevel -= SceneLoading;}private void Update(){if(body && body.velocity.magnitude < 2.5f && zoomRoutine == null){zoomRoutine = StartCoroutine(Zoom(true));}FaceAngle();ProjectileSquash();}private void SceneLoading(){if (zoomRoutine != null){StopCoroutine(zoomRoutine);}zoomRoutine = StartCoroutine(Zoom(false));}private IEnumerator Zoom(bool doZoom = true){if (doZoom){speed = 15f;while (target){speed += acceleration;speed = Mathf.Clamp(speed, 0f, 30f);acceleration += 0.07f;FireAtTarget();if (Vector2.Distance(target.position, transform.position) < 0.8f){goto IL_E8;}yield return null;}Debug.LogError("Soul orb could not get player target!");}IL_E8:body.velocity = Vector2.zero;if (soulOrbCollectSounds){soulOrbCollectSounds.PlayOneShot(source);}if (getParticles){getParticles.Play();}if (sprite){sprite.enabled = false;}if (awardSoul){HeroController.instance.AddMPCharge(2);}SpriteFlash component = HeroController.instance.gameObject.GetComponent<SpriteFlash>();if (component){component.flashSoulGet();}yield return new WaitForSeconds(0.4f);if (dontRecycle){gameObject.SetActive(false);}else{gameObject.Recycle();}}private void FireAtTarget(){float y = target.position.y - transform.position.y;float x = target.position.x - transform.position.x;float num = Mathf.Atan2(y, x) * 57.295776f;Vector2 velocity;velocity.x = speed * Mathf.Cos(num * 0.017453292f);velocity.y = speed * Mathf.Sin(num * 0.017453292f);body.velocity = velocity;}private void FaceAngle(){Vector2 velocity = body.velocity;float z = Mathf.Atan2(velocity.y, velocity.x) * 57.295776f;transform.localEulerAngles = new Vector3(0f, 0f, z);}private void ProjectileSquash(){float num = 1f - body.velocity.magnitude * stretchFactor * 0.01f;float num2 = 1f + body.velocity.magnitude * stretchFactor * 0.01f;if (num2 > stretchMaxX){num2 = stretchMaxX;}if (num < stretchMinY){num = stretchMinY;}num *= scaleModifier;num2 *= scaleModifier;transform.localScale = new Vector3(num2, num, transform.localScale.z);}
}

 再来看看第一个子对象Knight Cutscene Animator:它的playmakerFSM:

首先是让这个过场动画里的小骑士落地,直接碰到地面执行下一个状态: 

播放动画:

这时候就得让场景黑起来:将真正的小骑士转移到下一个状态时应该到达的位置也就是牢内。

同时给小骑士设置为满MP。 

第一个SetBenchRespawn()是和复活相关的,没做到的可以先取消勾选不管他。 

 设置玩家hazard respawn的位置以及保存游戏和记录时间。

生成一个UI Msg Get Item的预制体,并生成对应的Sprite。 

这个就是我之前说过的确认拥有能力的界面,我们来看看这个预制体:

这个是确认界面的图标: 

背景也就是黑幕:

接下来是Text,第一个是获得能力的名字:

吸收还是获得:

 提示你按下哪个按键的:

描述这个能力的文字:

然后它们都有一个color_fader的playmakerFSM,这个也是之前就讲过了的:

再来看UI Msg Get Item的playmakerFSM:Msg Control

 再来看看变量,这些和子对象同名的GameObject类型变量一定要添加上去,比如同名的这个BG

初始化就根据外面传来的string名字来判断是那个能力: 

比如我们这个是火球,这里有个提示就是如果当前语言是日文的话要稍微调整一下距离: 

using System;
using Language;namespace HutongGames.PlayMaker.Actions
{[ActionCategory(ActionCategory.String)][Tooltip("Get currently set language as a string.")]public class GetCurrentLanguageAsString : FsmStateAction{[RequiredField][UIHint(UIHint.Variable)]public FsmString stringVariable;public override void Reset(){stringVariable = null;}public override void OnEnter(){stringVariable.Value = Language.Language.CurrentLanguage().ToString();Finish();}}
}

通过行为GetLanguageString我们可以得到一个Sheet Title对应的Convo Name里面的文字,再把文字传递给text相关的变量并显示出来, 

using UnityEngine;namespace HutongGames.PlayMaker.Actions
{[ActionCategory("Game Text")][Tooltip("Grab a string from the Hollow Knight game text database in the correct language.")]public class GetLanguageString : FsmStateAction{[RequiredField]public FsmString sheetName;[RequiredField]public FsmString convName;[RequiredField][UIHint(UIHint.Variable)]public FsmString storeValue;public override void Reset(){sheetName = null;convName = null;storeValue = null;}public override void OnEnter(){storeValue.Value = Language.Language.Get(convName.Value, sheetName.Value);storeValue.Value = storeValue.Value.Replace("<br>", "\n");Finish();}}
}
using UnityEngine;
using TMPro;namespace HutongGames.PlayMaker.Actions
{[ActionCategory("TextMeshPro")][Tooltip("Set TextMeshPro text.")]public class SetTextMeshProText : FsmStateAction{[RequiredField]public FsmOwnerDefault gameObject;[RequiredField]public FsmString textString;private GameObject go;private TextMeshPro textMesh;public override void Reset(){gameObject = null;textString = null;}public override void OnEnter(){go = Fsm.GetOwnerDefaultTarget(gameObject);if(gameObject != null){go = Fsm.GetOwnerDefaultTarget(gameObject);textMesh = go.GetComponent<TextMeshPro>();if(textMesh != null){textMesh.text = textString.Value;}}Finish();}}
}

这里我们需要导入两个TextAssets文件:EN_UI和EN_Prompts:文字量太大了我就不文本内容了,可以到我的github下载源文件。

导入后播放音乐:

 最后RefreshButtonIcon()函数实现起来有点难,所以可以先取消勾选等我以后做自定义绑定按键在用到。

这里可以看到这些子对象全部都Up了。

然后就监听这几个按键的输入:

按下以后所有的子对象都DOWN:

广播事件:GET ITEM MSG END

那么第一阶段到此就完成了。

3.制作第一个NPC蜗牛萨满的第二阶段

第二阶段就是Shaman Trapped,NPC蜗牛萨满就一直睡觉直到你和它对话,等到你杀死巴洛德的时候它就会被销毁:

经典的Prompt Marker:

它有三个playmakerFSM:advance_conversation

第二个npc_control,直接复制粘贴就完事了:

第三个Conversation Control也很简单,它只会和你产生两段对话:

判断,如果shaman state大于等于4就销毁,火球为0级也销毁

等待对话开始,顺便当RESET CONVO事件发送后会回到Idle状态

 

第一次对话执行Trapped 1状态,后面就一直对话就会执行Trapped 2状态;

 

关闭对话框,播放动画 Sit Talk End

向自身发送事件:CONVO END和RESET CONVO进入Idle状态和玩家恢复控制。

4.制作第一个NPC蜗牛萨满的第三阶段

        最后一个阶段是玩家击杀了巴洛德以后的Shaman Killed Blocker。

子对象Prompt Marker,多了一个playmakerFSM,通过玩家是否到达可以聊天的距离来发送UP和DOWN进行淡入淡出

然后是和梦之钉有关的:

回到主对象Shaman Killed Blocker当中:两个playmakerFSM自然是npc_control

Conversation Control也很简单:

此时的shaman state应该是4和5 

不符合条件的就直接disable

符合条件: 

通过sprite 的ID转身向玩家

发送当玩家击杀了巴洛德的文本:

对话结束就要开门了:

设置shaman为6,当下次在进入场景的时候第二阶段和第三阶段的萨满就不会再显示了,就这样我们通过一个int变量决定了显示哪个阶段的萨满:

状态Killed Blocker 2:是你第二阶段没有和萨满对话就直接干掉了里面的巴洛德触发的对话:

这个是特殊情况下直接就给你开门了(永远不要低估玩家的想象力)

至此我们以蜗牛萨满为例子制作了一个完整的场景游戏剧情,怎么样是不是感觉工程量还挺大的。 


总结

OK做了那么多我们来看看效果怎么样:

这个场景shaman_temple也是通过转移点门进来的:

首先这是第一阶段:

可以看到啊,我的UI Get Msg Item排版并不是很好,还有就是soul orbs没有触发,但好在大部分功能都实现了。

第二阶段:

之所以出现文字混乱是因为我的Dialogue Text脚本中的textmeshpro无法识别页数,所以就搞的这样了。

战斗画面:

第三阶段;

完成剧情离开场景以后,第二和第三阶段消失,萨满的对话:

ok可以看到做完一个完整的剧情确实需要花费很大的经历,看到这里了记得做下眼保健操,下一期的内容依然很劲爆!


http://www.ppmy.cn/news/1550157.html

相关文章

WPF中如何让Textbox显示为一条直线

由于Textbox直接使用是一条直线 设置如下代码 可以让Textbox变为直线输入 <Style TargetType"TextBox"x:Key"UsernameTextBoxStyle"><Setter Property"Template"><Setter.Value><ControlTemplate TargetType"{x:Typ…

硬中断关闭后的堆栈抓取方法

一、背景 性能和稳定性是一个计算机工程里的一个永恒的主题。其中尤其稳定性这块的问题发现和问题分析及问题解决就依赖合适的对系统的观测的手段&#xff0c;帮助我们发现问题&#xff0c;识别问题原因最后才能解决问题。稳定性问题里尤其底层问题里&#xff0c;除了panic问题…

Java安全—JNDI注入RMI服务LDAP服务JDK绕过

前言 上次讲到JNDI注入这个玩意&#xff0c;但是没有细讲&#xff0c;现在就给它详细地讲个明白。 JNDI注入 那什么是JNDI注入呢&#xff0c;JNDI全称为 Java Naming and Directory Interface&#xff08;Java命名和目录接口&#xff09;&#xff0c;是一组应用程序接口&…

android-studio-4.2下载 、启动

下载 分享一个国内的android studio网站&#xff0c;可以下载SDK和一些Android studio开发工具 https://www.androiddevtools.cn/ 启动 JAVA_HOME/app/zulu17.48.15-ca-jdk17.0.10-linux_x64/ /app5/android-studio-home/android-studio-ide-201.6568795-linux-4.2C1/bin/s…

如何修复WordPress .htaccess文件

.htaccess文件是一个隐藏的配置文件&#xff0c;对WordPress网站的运行至关重要。它本质上是Apache Web服务器的指令集&#xff0c;而Apache Web服务器通常由你的WordPress主机运行。其核心功能之一是为你的博客文章和页面创建用户友好的URL。你还可以通过.htaccess文件来实现安…

数据结构 ——— 堆排序算法的实现

目录 前言 向下调整算法&#xff08;默认建大堆&#xff09; 堆排序算法的实现&#xff08;默认升序&#xff09; 前言 在之前几章学习了如何用向上调整算法和向下调整算法对数组进行建大/小堆数据结构 ——— 向上/向下调整算法将数组调整为升/降序_对数组进行降序排序代码…

44.扫雷第二部分、放置随机的雷,扫雷,炸死或成功 C语言

按照教程打完了。好几个bug都是自己打出来的。比如统计周围8个格子时&#xff0c;有一个各自加号填成了减号。我还以为平移了&#xff0c;一会显示是0一会显示是2。结果单纯的打错了。debug的时候断点放在scanf后面会顺畅一些。中间多放一些变量名方便监视。以及mine要多显示&a…

「Chromeg谷歌浏览器/Edge浏览器」篡改猴Tempermongkey插件的安装与使用

1. 谷歌浏览器安装及使用流程 1.1 准备篡改猴扩展程序包。 因为谷歌浏览器的扩展商城打不开&#xff0c;所以需要准备一个篡改猴压缩包。 其他浏览器只需打开扩展商城搜索篡改猴即可。 没有压缩包的可以进我主页下载。 也可直接点击下载&#xff1a;Chrome浏览器篡改猴(油猴…