Tutorial‐zh_CN - Bli-AIk/Undertale-Changer-Template GitHub Wiki

警告

本教程更新于2023年8月13日,与现在的UCT版本(截至本警告撰写时的版本——v1.0.7)具有较大的出入。

因此,此教程仅供参考。如果你有任何模板使用上的的问题,可以通过各种社交媒体来联系并询问我。

请注意,针对模板使用的问题,不要提Issue。Issue是针对模板Bug而提的。但是你可以在Github上对此进行讨论。

日后如果有时间,我会撰写新的教程。

感谢你的支持。

Undertale Changer Template Wiki - 教程

前言

我不知道你是怀着怎么样的一种心态,发现了Undertale Changer Template,并点开了它的Wiki的教程页面。

  • 也许你是一位从未接触过编程的新人,想尝试做同人游戏然后发现了这玩意儿。
  • 也许你曾使用过其他的引擎/模板,但是对其不满而辗转到这里。
  • 也许你的编程水平相当之高,只是偶然听闻。 谁知道呢。

不管怎样,Undertale Changer Template在它出现并开源的那一刻起,便属于每一个使用它的人。

……但你真的需要它吗?

我是说,从Undertale诞生的那一天起,直到现在。

如果你想做一个Undertale的同人游戏,那方法实在是太多了。

  • 单纯的只有一个战斗?你完全可以使用同为Unity引擎编写的Unitale或者CreateYourFrisk,使用Lua编写mod并用它们运行,更轻量,也更好上手。

  • 或者你想更进一步?TML的UNDERTALE Engine,它使用了编写原版Undertale的游戏引擎——GameMaker Studio开发,并且可以实现所有原版Undertale的功能。

  • 你都不满意吗?基于GameMaker Studio、Godot、Clickteam Fusion引擎,C++、Python等等语言的模板/引擎,我都数不清有多少个了,在github、gamejolt上面随便一搜,你的选择多得很,不是么?

  • 你真的,需要Undertale Changer Template吗?

好好想想。


Undertale Changer Template真的不是你最好的选择。

  • Undertale Changer Template没有上述的那些模板/引擎成熟,大概也没它们好用。

  • Undertale Changer Template所制作的游戏内存占用更大。(虽然但是,我确实是一点内存优化也没做)

  • Undertale Changer Template的开发模式也许和上述模板/引擎很不一样(虽然但是,我没用过那些引擎,我也不清楚),这意味着你可能需要一段时间学习。

等等……

不管怎样,我劝退的话已经扯完了。如果你还是坚持使用它的话。

那就来吧。

须知

在下文的内容中,我假定你已经学习过C#教程,基本掌握C#语言。

同时也一定程度上学习过如何使用Unity引擎(尤其是它的2D包的相关内容)。

并且你是第一次使用此模板。

C#与Unity相关教程可以在广袤的互联网上随意寻找。

但如果你问我是从哪里起步的。我的回答是这个这个。(均为C#中文教程)

此外,建议你收藏Unity脚本API网站,以查询Unity API相关内容。

至于Unity引擎的学习教程,网上有又新又好的,去找就是了。

开始

在你第一次打开模板的时候,它应该打开了一个空的场景。大概是长这样。(如果不是也没关系,待会的步骤是一样的) image

由于你第一次使用模板,所以我先教你运行一下整个模板,以此看看模板内大概的样子。

首先找到模板下面的Scenes文件夹,这里面存放着所有模板内的场景。

然后顺着Assets/Scenes/Menu/Story/Story.unity路径,打开Story场景,这就是原版的讲故事场景。 image 点播放,然后按照你玩原作的方式,稍微玩一下吧。

等你玩完一遍之后,我们就可以真正地开始了。

总述

模板内的脚本有注释,可以参考注释进行编写,此处给予模板大体介绍。

我在这里简单写一下此模板的编写思路。

Undertale原作,在我的模板内可认为由以下两部分组成:

战斗内场景,战斗外场景。

很简单,就像它们的字面意思一样。

而战斗内场景也可以细分为地图内场景(Overworld)和杂项场景(故事、标题、菜单、重命名等)

  • 例如,地图内场景有玩家的脚本控制其移动,故事场景有脚本控制幻灯片的淡入淡出,战斗内场景也有脚本组成战斗系统。 也有一部分脚本是全部场景或大部分场景通用的。

每一类场景中有支撑它们运作的脚本。

  • 例如,打字机脚本,以及总控脚本,等等。

在模板的Assets/Scripts内存放着模板内使用的代码,你在这里可以看到以下几个文件夹。 image

  • Battle文件夹——仅在战斗内场景使用的脚本。
  • Control文件夹——其中的脚本均继承自ScriptableObject类,用以创建Resources下的数据存储文件。
  • Debug文件夹——正如其名,是Debug所用的脚本。
  • Default文件夹——通用/未分类的脚本。
  • Obsolete文件夹——弃用的脚本,多为参考他人使用的脚本。
  • Overworld文件夹——仅在战斗外场景使用的脚本。
  • Volume文件夹——自定义后处理相关的脚本。

目前,你只需要对这些个文件夹的分类有个大致印象就好。详细内容会在下文进行讲述。

地图内场景

image

这是一个地图内场景,当然了。


做一个新的地图

要做一个新的地图,我们需要新建一个场景。

按下Ctrl + N,在新建场景的页面,你会看到一个叫Overworld scene的场景模板:

image

这是这个场景模板的存储位置(你知道就行 别打开它 除非你知道你在做什么):

image

新建场景后,就会出现这么一个啥都没有的新的场景。

image

名为Grid的Obj就是瓦片地图,你可以随心所欲的开始铺瓦片,但要注意,瓦片的排序图层一般来说就是“Tilemap”,如图。

image

随便摆了一下。

image

适当加一点光源和后处理。(没错直接搬的Ruins示例场景)

image

然后可以存储一下,改个令你印象深刻的名字,放在Scene文件夹里面,如图。

image

然后给你的瓦片地图加一个碰撞体。我知道可以用Tilemap Collider 2D,但我还是比较习惯用Edge Collider 2D。所以按你喜欢的方式加个碰撞——

image

但这时,如果你运行了场景,它会报一个错,如下。

image

我们顺着报错去找出错的代码,就会找到下面这玩意儿。

...
  string LoadLanguageData(string path)
    {
        if (languagePack < languagePackInsideNum)
        {
            return Resources.Load<TextAsset>("TextAssets/LanguagePacks/" + GetLanguageInsideId(languagePack) + "/" + path).text;
        }
        else
        {
            return File.ReadAllText(Directory.GetDirectories(Application.dataPath + "\\LanguagePacks")[languagePack - languagePackInsideNum] + "\\" + path + ".txt");
        }
    }
...

这里的代码是负责加载语言包的,而每个地图内场景都有一个对应的txt文件,用于存储该场景内的所有数据(对话等)。

我们的解决方案也很简单,顺着内置语言包路径(Assets/Resources/TextAssets/LanguagePacks)找到你目前在使用的语言包,然后在里面的“Overworld”文件夹里,你看到的都是与项目内场景同名的txt文件。

image

咱也加上咱的场景就是了。

image

一些格式和特殊富文本的写法可以打开Example-Corridor看里面的注释。

但具体的事件呢,咱待会再写。现在就放一个空的txt在这就得了。

现在进场景,我们发现摄像机在我们走到左边的时候卡住不动,这是因为我们还没有设置摄像机跟随。

image

MainCamera里面的CameraFollowPlayer就是设置摄像机跟随用的,可以把frisk扔到地图的最左边或者最右边,然后改这个脚本的数值测试摄像机锁定的范围。

当然,也可以吧Limit的√去掉,这样就没跟随限制了。

如果不需要跟随,把这个脚本去掉就行。

image

设置好的效果:

image

然后这个场景就有点样子了,你可以随便乱跑然后不会飞出去之类的。不过还没声。我们在MainControlSummon中解决这个问题。

image

把BGM拖上去,然后照着上面的信息写就完事了,一般来说,你只需要把BGM加上,别的不用管。

搞定这个之后,这个场景更好了一点。不过,它现在没法进行任何交互,但不急,我们马上就要提到它了。


事件

事件是干什么用的?简单来说,他就是让你在游戏内查看某个物体,或者在地图边界进入下一个场景用的。

书接上回,我们的场景现在什么也调查不了,也没有离开场景的方法。

添加一个事件很简单。我们需要在场景里面加一个物体,身上需要有2d碰撞体,就像这样。

image

我的建议是可以把它放在Grid/Detectables下面,方便管理。(Detectables是一个新建坐标重置过的的空物体)

image

我们在上面添加OverworldObjTrigger,然后你会看到一大堆乱七八糟的选项,相关的作用上面的注释有写因此不再赘述。

image

我们只需要在调查的时候能弹出一些字来就好。例如什么“* 这是一个破墙”之类的。但首先,咱还没有把调查需要的文本写进去。

还记得之前提过的内置语言包路径(Assets/Resources/TextAssets/LanguagePacks)么?找到你之前创建的txt文件。

image

我们到里面去进行编辑。你可以参考Example-Corridor内的注释。

CrackedWall\<image=-1><fx=0>* This is a cracked wall.;

如果你懒得看注释,那我简单解释一下。斜杠前面的是OverworldObjTrigger的检测文本,后面就是具体的文本内容。

<image=-1>是打字的头像(-1是没有头像)。头像的精灵存储在BackpackCanvas里面的Sprite Changer里面。

image

<fx=0>是打字的音效。存储在Assets/Resources/AudioControl的FxClipType内。

image

别忘了在OverworldObjTrigger里面写上对应的检测文本。

image

我们走到墙那里去调查,然后字就出来嘞!

image

好了,接下来,我们需要一个离开场景的物体。

我们微调一下场景,弄一个出口。

image

这里要设置为触发器。

image

在OverworldObjTrigger里面设置如下。

image

image

勾选ChangeScene后,检测到玩家就会切换场景。

勾选BanMusic后,音乐会渐出。

SceneName就是切换场景的目的地。

NewPlayerPos便是切换到新场景后玩家的坐标。

现在让我们进游戏测试一下!

image

可以看到我们成功来到了另一个场景。

我们的事件就讲到这儿了,至于OverworldObjTrigger里面的其他选项,例如摄像机移动等,可以去看看Example-Corridor里面怎么设置的。

Tips:如果你这时停止运行,然后再运行,你会发现玩家的位置移动了。

image

这是因为玩家在切换场景时,目的地的坐标会存储在Assets/Resources/OverworldControl的下面这个位置,在加载场景时会把玩家放在这个坐标上。

image


选项事件

有时候我们需要在场景里面添加选项。同样的,我们也要使用OverworldObjTrigger,但组件里不需要额外的设置,跟上文一样就行了。

我们需要额外做的有两项,

首先,你需要在BackpackCanvas里加上OverworldTalkSelect组件。直接加就行。

image

然后,仿照Example-Corridor里的BackMenu的格式,编写文本如下。

Select\<image=-1><fx=1>* (This is a select text.)<enter><enter><fx=-1><size=5> </size>< >< >< >< >< >< >   Ok<size=5> </size>< >< >< >< >< >< >< >< >< > Nope<select>Select;

(小知识,如果你看上面这一长串的玩意儿不顺眼,你可以适当添加换行。)

请注意这串文本最后有一个Select。这是检测文本是否为“选项事件”的关键。

现在我们在场景里面配置好对应的触发物体,然后测试一下。

image

选项有了,但是你点哪个都没反应。这是因为你还没有写选择之后的代码。

我们打开OverworldTalkSelect脚本,然后翻到Update里按下Z键后的相关代码。

 if (MainControl.instance.KeyArrowToControl(KeyCode.Z))
            {
                typeWritter.TypeStop();
                switch (select)
                {
                    case 0://选择了左侧选项
                        switch (typeText)
                        {
                            /*
                            打字机示例
                            case "XXX":

                        typeWritter.TypeOpen(MainControl.instance.ScreenMaxToOneSon(MainControl.instance.OverworldControl.owTextsSave, texts[0]), false, 0, 1);
                        break;
                            */

                            case "BackMenu":
                                typeWritter.forceReturn = true;
                                MainControl.instance.OutBlack("Menu", Color.black, true, 0f);
                                AudioController.instance.audioSource.volume = 0;
                                break;
                            
                            default:
                                break;
                        }
                        break;
                    case 1://选择了右侧选项
                        break;
                }
                heart.color = Color.clear;
                canSelect = false;
                return;
            }

我们的目的是,按下左侧选项,就会播放一个音效,然后没了。

这也很简单。在上面 case "BackMenu":...break;的下面另外写上下面的代码。

case "Select":
    AudioController.instance.GetFx(2, MainControl.instance.AudioControl.fxClipBattle);
    break;

这里的Select就是Select后面的这个Select。

至此,再次运行,看看效果,你应该能听到选择左侧选项后清脆的“叮”声。


存档事件

存档事件相当容易编写——因为我已经提前做好了一个预制体。

在Assets\Prefabs下,你会发现一个叫做Save的预制体,它就是了。把它拖到场景里面去。

image

注意被我框住的部分,这就是让这个物体触发后可以出现保存窗口的选项。

接着我们继续在对应的文本里面输入:

Save\<image=-1><fx=0>* (The save filling you<enter>< >< >with <gradient="White to Red - UTC">determination</gradient>.);

效果如下。

image

image

但存储后,房间的名称显示为null——不然呢?你还没有输入房间名称!

在LanguagePacks\US\UI\Setting.txt里面另起一行,加上Example-Study\Study Scene;

这下好了!

image

按下V键退回菜单,仍然可以看到!

image

Tip:<gradient="White to Red - UTC">使用了TMP的渐变富文本。你也可以在Assets/TextMesh Pro/Examples & Extras/Resources/Color Gradient Presets里面添加你的自定义渐变颜色并使用富文本调用它。


全部脚本简介

此处列出Overworld相关的全部脚本,用于修改模板源码的参考。

image

BackpackBehaviour:Overworld背包系统的管理器

CameraFollowPlayer:Overworld摄像机跟随脚本

OverworldObjTrigger:Overworld物体触发器

OverworldTalkSelect:Overworld选项系统

PlayerBehaviour:地图内场景里的玩家控制脚本

SpriteChanger:Overworld对话中更改Sprite

TalkUIPositionChanger:更改对话框UI位置

TriggerChangeLayer:通过Trigger更改SpriteRenderer的层级

TriggerPlayerOut:用于带动画器的Overworld物体,在玩家进入/离开时,执行代码/播放动画。

战斗场景

image

我希望你不是直接跳过来看的,哈。

打开Assets/Scenes/Battle下的Battle场景,让我们开始吧。


我方回合

上文提到的LanguagePacks文件夹中就有一个单独的文件夹叫Battle。

里面有一个文本叫UIBattleText.txt,另外的Turn文件夹里面存的都是回合里的敌人对话,这个我们待会再说。

我们打开UIBattleText.txt,翻到下面。

下面这块是回合的旁白文本,数字序号为回合数。

Turn\0\* Turn 0.;
Turn\0\* Another version of Turn 0<stop>.<stop>.<stop>.<stop>.<stop>.<stop>.<stop>;
Turn\1\* Welcome to Turn 1<stop>.<stop>.<stop>.<enter>* Of course.;

image

下面这块是怪物的ACT选项,NPC1 / NPC2是怪物名称,接着是选项的具体文字,然后是选择后的内容。

Act\NPC1\Check\* <getEnemiesName> <stop>-<stop> ATK<stop> <getEnemiesATK><stop> DEF<stop> <getEnemiesDEF><stop><enter>* What's this?;
Act\NPC1\Pet\* You pet <getEnemiesName>.<stop><enter>* It makes a sound like "ow.";
Act\NPC1\Glare\* You glare at it fiercely.<stop><enter>* When you gaze into the abyss<stop>.<stop>.<stop>.;
Act\NPC1\Ignore\* You don't look at it.<stop>.<stop>.<stop><enter>< >< >Probably.<stop>.<stop>.;

Act\NPC2\Check\* <getEnemiesName> <stop>-<stop> ATK<stop> <getEnemiesATK><stop> DEF<stop> <getEnemiesDEF><stop><enter>* What's this again?;
Act\NPC2\Compliment\* You compliment <getEnemiesName> on its<enter>< >< >unique appearance.<stop><enter>* <getEnemiesName> looks very puzzled.;
Act\NPC2\Hug\* You pick up <getEnemiesName>.<stop><enter>* <getEnemiesName> seems startled.<stop>.<stop>.<stop><passText>* But it feels happy.;
Act\NPC2\Grin\* You let out an evil grin.<stop><enter>* <getEnemiesName> can't figure out<enter>< >< >what you're doing.;

image

image

怪物名称通过储存在BattleControl中的预制体检测。

image

image

下面这块是怪物的Mercy选项,和ACT类似。

Mercy\NPC1\Spare\Null;
Mercy\NPC1\Flee\* You can't run away.;
Mercy\NPC2\Spare\Null;
Mercy\NPC2\Flee\* You can't run away.;
Mercy\NPC2\Let It Go\* You wish.;

image

对照着修改就好。

怎么让这些选项触发后执行代码呢? 打开SelectUIController,切到440行左右的位置,其中内容如下。

                        switch (selectSon)//在这里写ACT的相关触发代码
                            {
                                case 0://怪物0
                                    switch (selectGrandSon)//选项
                                    {
                                        case 1:

                                            break;
                                        case 2:

                                            Debug.Log(1);
                                            AudioController.instance.GetFx(3, MainControl.instance.AudioControl.fxClipBattle);

                                            break;
                                        case 3:

                                            break;
                                        case 4:

                                            break;
                                    }
                                    break;
                                case 1://怪物1
                                    switch (selectGrandSon)//选项
                                    {
                                        case 1:

                                            break;
                                        case 2:

                                            break;
                                        case 3:

                                            break;
                                        case 4:

                                            break;
                                    }
                                    break;
                                case 2://怪物2
                                    switch (selectGrandSon)//选项
                                    {
                                        case 1:

                                            break;
                                        case 2:

                                            break;
                                        case 3:

                                            break;
                                        case 4:

                                            break;
                                    }
                                    break;
                            }

在对应位置加代码就行了。例如代码里面写的那个GetFx,它会在选择怪物0的第1个选项时会播放一个音效。


敌方回合

在战斗场景的MainControlSummon上额外挂着一个TurnController,这就是回合的管理器,我们在这里面写敌方回合的弹幕。

我们在里面会看到一个IEnumerator叫_TurnExecute。你会注意到这个IEnumerator接着一个<float>。这是因为我在项目中使用了More Effective Coroutines [FREE],以此使协程可以暂停。你可以在这里查看它的相关文档。

模板内已经有一个示例回合了,我在这就简单废话几句吧。

获取弹幕需要使用 objectPools[0]这个弹幕的内存池。

var obj = objectPools[0].GetFromPool().GetComponent<BulletController>();

以此获得一个弹幕。

然后获得了弹幕之后还没完,你需要初始化它。

初始化方法在模板的预设中如下。

    /// <param name="name">设置弹幕的Obj的名称,以便查找。</param>
    /// <param name="typeName">设置弹幕的种类名称,如果种类名称与当前的弹幕一致,则保留原有的碰撞相关参数,反之清空。</param>
    /// <param name="layer">玩家为100,战斗框边缘为50。可参考。</param>
    /// <param name="sprite">一般在Resources内导入。</param>
    /// <param name="size">设置判定箱大小,可设定多个List,但多数情况下需要避免其重叠。(NoFollow情况下设为(0,0),会自动与sprite大小同步)</param>
    /// <param name="offset">设定判定箱偏移,List大小必须与sizes相等。</param>
    /// <param name="hit">设定碰撞箱伤害,List大小必须与sizes相等。</param>
    /// <param name="followMode">设置碰撞箱跟随SpriteRenderer缩放的模式。</param>
    /// <param name="startMask">设置Sprite遮罩模式。</param>
    /// <param name="bulletColor">设置弹幕属性颜色数据</param>
    /// <param name="startPosition">设置起始位置(相对坐标)。</param>
    /// <param name="startRotation">设置旋转角度,一般只需更改Z轴。</param>
    /// <param name="startScale">若弹幕不需拉伸,StartScale一般设置(1,1,1)。检测到Z为0时会归位到(1,1,1)。</param>
    public void SetBullet(
       string name,
       string typeName,
       int layer,
       Sprite sprite,
       Vector2 size,
       int hit,
       Vector2 offset,
       Vector3 startPosition = new Vector3(),
       BattleControl.BulletColor bulletColor = BattleControl.BulletColor.white,
       SpriteMaskInteraction startMask = SpriteMaskInteraction.None,
       Vector3 startRotation = new Vector3(),
       Vector3 startScale = new Vector3(),
       FollowMode followMode = FollowMode.NoFollow
       )
{
    ...
}

初始化的时候就照着上面的注释填写就行了,实际上在你输入代码的时候,如果你的IDE是Visual studio,它会给你提示对应位置要输入什么东西。

                obj.SetBullet(
                    "DemoBullet",
                    "CupCake",
                    40,
                    Resources.Load<Sprite>("Sprites/Bullet/CupCake"),
                    Vector2.zero,
                    1,
                    Vector2.zero,
                    new Vector3(0, -3.35f),
                    BattleControl.BulletColor.white,
                    SpriteMaskInteraction.VisibleInsideMask
                    );

上面这个初始化的方法用人话翻译就是: 初始化一个弹幕,它的物体名称叫做DemoBullet,种类叫做CupCake,放在图层40,加载了"Sprites/Bullet/CupCake"位置的图片作为显示图,判定箱与sprite大小一致,伤害为1,判定箱没有偏移,起始坐标为(0, -3.35),弹幕属性颜色为白色,显示在战斗框遮罩内。

然后你想怎么耍它都可以!我在场景中使用了DoTween组件来控制弹幕(和设置等等地方)的动画,建议你去他们官网看看文档。

类似这样的代码就能让它在两秒内边飞上天飞下来边转360度,简简单单,前提是你得搞明白DoTween组件,咱全靠它呢。

obj.transform.DOMoveY(0, 1).SetEase(Ease.OutSine).SetLoops(2, LoopType.Yoyo);
obj.transform.DORotate(new Vector3(0, 0, 360), 2,RotateMode.WorldAxisAdd).SetEase(Ease.InOutSine);

顺带一提,如果你没找到上述这些示例代码,它们都放在了IEnumerator _TurnNest(Nest nest)内。

这个协程是用来做嵌套弹幕用的,但如果你乐意,你可以在_TurnExecute里面搞个case 100000然后写嵌套弹幕,效果一样(但这不是有病么)。

最后,一定要记得及时回收弹幕!

objectPools[0].ReturnPool(obj.gameObject);

3D场景布置

你应该很早就注意到战斗场景里面有两个摄像机。

是的,得益于Unity(理所应当的)支持3D的(这一从它诞生以来就有的)优良传统,我们可以在场景里面弄一些3D元素出来。

就像我布置的这个场景一样,我觉得布置场景也没啥好说的,学过Unity基础就都会搞。

咱就简单提一嘴这俩摄像机的区别好了。

image

在战斗场景内你需要注意,“Main Camera”默认不是主摄像机,Unity对主摄像机的判定是根据它的标签,而很显然,“Main Camera”的标签是Untagged。

image

而3D Camera才是真的主摄像机,这点需要注意。

image

两个摄像机的拍摄内容在模板内显而易见。

这是“Main Camera”。

image

这是3D Camera。

image

在3D Camera的范围内随意布置你的场景吧。

Tips:理论上来说,你完全可以在我模板的基础上,试着把Overworld场景,或者整个模板……搞成3D的……或者2.5D。如果你有这能力,蛤?


多边形战斗框

战斗框就是战斗场景内的MainFrame,你会发现它下面有很多子物体。四个Point便是绘制战斗框的四个点,而Back绘制战斗框的黑色部分。

image

我们移动其中一个点,战斗框便会进行变形。

image

战斗框的边框使用LineRenderer绘制,而战斗框的黑色部分(以及弹幕的遮罩部分)都使用了shader绘制。

我们如果想要一个加更多点的战斗框,如下操作。

以五边形(五个顶点)的战斗框为例。

首先,在MainFrame的DrawFrameController上,更改顶点数为5。

image

然后加一个子物体Point4,你可以直接复制上面的Point。

image

image

然后,还记得我们说过战斗框的黑色部分以及弹幕的遮罩部分使用了Shader绘制么?因此我们需要小小的修改一下shader,这并不难,照着下文做就行。

在Back中点编辑,以此编辑DrawPolygon这个Shader,或者在Assets/Shaders/Sprites里找到DrawPolygon双击打开。

image

image

双击DrawPolygonFull打开这个Sub-graph。

image

这个Sub-graph里面放着四个DrawPolygon——这同样是一个Sub-graph。不管怎样,让我们观察一下这四个DrawPolygon传入数据的规律。

显而易见。我们从上往下数,传入的点的编号为:

  • 3 , 0;
  • 0 , 1;
  • 1 , 2;
  • 2 , 3.

我们以此类推就好了,首先在左侧加一个Point4,类型为Vector2。

image

下面加上这个。

image

最上面改成这个。

image

这样,传入的编号从上到下就是

  • 4 , 0;
  • 0 , 1;
  • 1 , 2;
  • 2 , 3;
  • 3 , 4.

但新加的这个3 , 4的这个DrawPolygon没有地方连了,我们看右边的这个AddSuperposition,它同样是一个Sub-graph。双击点开它。

image

image

里面的结构显而易见,就像AddSuperposition的含义一样,把一堆Add叠加在了一起而已。

再叠加一层就好了。

image

保存,切回DrawPolygonFull,把该连上的连上。

image

保存,回到DrawPolygon,然后老样子,加一个Point4,然后连上,完事儿!

image

记得保存!

但这还没完,因为你只改好了战斗框的Shader,弹幕遮罩的Shader还没改。我们打开SpriteBattleMask这个shader。它位于Assets/Shaders/Sprites下。

同样的,加一个点,连上,完事。

运行游戏看看吧。

image

可以看到,异形战斗框和弹幕遮罩都正常运行。


全部脚本简介

此处列出战斗内相关的全部脚本,用于修改模板源码的参考。

image

BattlePlayerController:控制战斗内玩家的相关属性

CameraShake:摄像机摇晃脚本

DialogBubbleBehaviour:敌人对话气泡控制

EnemiesController:怪物控制脚本

BoardController:挡板控制器

BulletController:弹幕控制器

EnemiesHpLineController:敌人血条控制器

BulletShaderController:弹幕Shader控制器(包括弹幕遮罩)

GameoverController:Gameover控制器

ItemSelectController:物品选择控制器

SelectUIController: 战斗内UI控制器,同时负责玩家回合的控制

SpriteSplitController:启用该脚本后怪物立马变成灰

SpriteSplitFly:配合SpriteSplitController。控制单个像素的移动轨迹

TargetController:控制Target(fight里面的靶子)

TurnController:回合控制器+弹幕对象池

杂项场景

故事场景(Story)

image

这个场景对应原作刚启动游戏时看到的讲故事场景。

场景布置非常简单,而主要控制这个场景内容的是Story物体下的StorySceneController。

image

编辑这个场景的方式如下:

在Pics里面设置显示的背景图。

image

在语言包的Overworld/Story.txt里面进行编辑。默认文本如下:

Text\<changeX><fx=1>Because it's there.
<passText=2.5><fx=-1> 
<storyFade=6> 
<passText=1><changeX><fx=1>Indeed, it is.
<passText=2.5>  
<storyExit>
;

每一句前的使打字机无法用X键跳过。

<passText=x>x秒后跳字,若要之后没有任何文字,输< >。 <storyFade=x>渐出然后渐入到第X张图。具体ID内置在游戏内。负数为渐入到没有图 <storyMove=(x,y,z)>移动图片到某位置,z轴填移动时间,负数会取绝对值。 开/关背景遮罩(用于背景图移动时只显示一个区域) 渐出结束并退出到Start。


标题场景(Start)


image

对应原作的标题。

这个场景内默认包含一个标题和一个提示文本。

image

你如果想要它只显示标题,直接改代码就行,里面写的很简单我觉得我不用多讲。

另外,虽然我在场景里面使用了图片显示标题(因为我的标题图片很显然的经过处理,并不是直接输入的文本),如果你想使用类似原作标题那样的标题,你可以直接使用模板内包含的MONSTERFRIENDBACK与MONSTERFRIENDFORE。(UT-MSB,UT-MSF)

如下。

image

image

image

image

菜单场景(Menu)


image

该场景由MenuController控制。 相关文本在语言包的同名txt中更改。

重命名场景(Rename)

@HO~QDSDT2XGH(({X1F_B5J

该场景由RenameController控制。 相关文本在语言包的同名txt中更改。

全局设置

如果你对修改全局框架的需求不大,或者单纯就是看不下去下文,可以以后再看,下文属于比较进阶的内容。

概述

我们之前提到了Assets/Scripts内的文件夹,也许你注意到了,Control文件夹的描述很特殊。是的它特别长

我们提到它里面的脚本是“用以创建Resources下的数据存储文件”的,如果你不明白这是什么意思,那么现在点开Assets/Resources文件夹,你会发现里面放着挺多东西的。就像这样。

image

我们先不管红框框里面的内容,看看这些绿框框框住的这些个名字都是“XXXXControl”的文件吧,随便点开一个,然后你就会看到右边的检查器会显示出来一些数据。

  • AudioControl存储音效相关的数据。
  • BattleControl存储战斗内相关的数据。
  • ItemControl存储玩家的物品数据。
  • OverworldControl存储一些通用数据(大多数用于Overworld)
  • PlayerControl存储玩家的数据(同时它也被存档系统所读存)

同样的,有个印象就行,我们会在对应的地方详细讲解它们。


总控

现在,我们要开始讲解整个项目中最重要的脚本,MainControl

MainControl是干啥的?上面的这些xxxControl,都需要通过MainControl来获取,并且MainControl还负责对一些其余数据和语言包的导入。

让我们去看看场景内,然后你会发现,每一个场景里面都有一个预制体叫做MainControlSummon

image

正如其名,它是生成总控的脚本,此外他还会生成设置系统和音频系统,这都是我们这部分要讲的内容。

image

它生成的总控(MainControl)、设置系统(Canvas)与音频系统(BGM Source)都不会在场景切换时销毁。

以下是MainControl的使用方式之一。此代码取自PlayerBehaviour.cs,用于存档时回血。

你可以看到我们通过MainControl获取到了PlayerControl的血量信息。

if (MainControl.instance.PlayerControl.hp < MainControl.instance.PlayerControl.hpMax)
    MainControl.instance.PlayerControl.hp = MainControl.instance.PlayerControl.hpMax;

总控还有一个作用是存储一些会经常调用到的方法,如下。

该方法用于切换场景时进行黑场过渡,一般来说模板内都会使用这个方法进行场景切换。(看不懂没关系 因为我估计你也不会去怎么改它)

  public void OutBlack(string scene, Color color, bool banMusic = false, float time = 0.5f, bool Async = true)
    {
        blacking = true;
        if (banMusic)
        {
            AudioSource bgm = AudioController.instance.transform.GetComponent<AudioSource>();
            if (time > 0)
                DOTween.To(() => bgm.volume, x => bgm.volume = x, 0, time).SetEase(Ease.Linear);
            else if(time == 0)
                bgm.volume = 0;
            else
                DOTween.To(() => bgm.volume, x => bgm.volume = x, 0, Mathf.Abs(time)).SetEase(Ease.Linear);
        }
        OverworldControl.pause = true;
        if (time > 0) 
        { 
            inOutBlack.DOColor(color, time).SetEase(Ease.Linear).OnKill(() => SwitchScene(scene));
            if (!OverworldControl.hdResolution)
                CanvasController.instance.frame.color = new Color(1, 1, 1, 0);
        }
        else if (time == 0)
        {
            inOutBlack.color = color;
            SwitchScene(scene, Async);
        }
        else
        {
            time = Mathf.Abs(time);
            inOutBlack.color = color;
            inOutBlack.DOColor(color, time).SetEase(Ease.Linear).OnKill(() => SwitchScene(scene));
            if (!OverworldControl.hdResolution)
                CanvasController.instance.frame.color = new Color(1, 1, 1, 0);
        }
    }

总控里面的大多数方法都有注释说明用法,你也可以自己写。


打字机

把TypeWritter脚本挂在场景的某个物体上用于打字。 打字机会检测形如<xxx>的富文本。 示例:

typeWritter.TypeOpen("<color=red>text123</color>", false, 0, 0, textUI);

image


后处理

image

在原先Unity自带后处理的基础上,加上了自定义后处理的战斗场景。

我知道你在想什么。停。


模板内有四个新增的后处理。

image

Chromatic Aberration Component是一个简单的滚动色差分离效果。

image

CRT Screen Component是一种类似于老式电视机的效果。

image

Glitch Art Component包含四种用于实现差错效果的选项,参考了KinoGlitch

image

Stretch Post Component会拉伸游戏内的显示区域。我本想以这种方式制作16:9分辨率适配,但我发现拉伸后的显示区域看上去有点模糊。后来我用Viewport矩形解决了这个问题。简而言之,这个效果没啥用。

image

这几个后处理的原shader都放在Assets/Shaders/PostProcessing下。

编写你自己的后处理特效也很简单,在Assets/Scripts/Volume下存放一些脚本。

它们都是以xxxComponent,xxxRendererFeature的格式命名的。

xxxComponent负责把后处理添加在显示列表内。我们以Chromatic Aberration为例,我们在Full Chromatic Aberration.shadergraph中存储了这些数据。

image

特别注意的是这里要使用_MainTex。

image

而相对应的,我们把这些数据都要写在ChromaticAberrationComponent内,格式如下,你可以发现里面的变量都和shadergraph中的数据相对应。(除了MainTex,这个是不需要写的)

[VolumeComponentMenuForRenderPipeline("Custom/Chromatic Aberration",typeof(UniversalRenderPipeline))]
public class ChromaticAberrationComponent : VolumeComponent,IPostProcessComponent
{
    public BoolParameter isShow = new BoolParameter(false, true);
    [Header("Settings")]
    public FloatParameter offset = new FloatParameter(0.02f, true);
    public FloatParameter speed = new FloatParameter(10, true);
    public FloatParameter height = new FloatParameter(0.15f, true);
    public BoolParameter onlyOri = new BoolParameter(false, true);

    public bool IsActive()
    {
        return true;
    }

    public bool IsTileCompatible()
    {
        return false;
    }

}

当你在编写你的后处理脚本时,一种非常简单的操作方式就是,复制原有的xxxComponent,xxxRendererFeature——这么一套脚本,然后打开,ctrl+f然后替换掉该脚本中所有的“ChromaticAberration”(或者类似的东西),改成你自己的。

对于xxxRendererFeature同理,当你改完你的xxxComponent,在里面加上了该加的变量之后,在你新复制的xxxRendererFeature内直接替换掉原有的那些字符就可以了。

唯一需要注意的是该脚本中的Render方法内的数据需要额外修改,我们这里仍然以ChromaticAberration为例,你会发现在Render方法内有几行赋值的操作。

把这些赋值的代码改成你的变量就可以了。

        mat.SetFloat("_Offset", chromaticAberrationVolume.offset.value);
        mat.SetFloat("_Speed", chromaticAberrationVolume.speed.value);
        mat.SetFloat("_Height", chromaticAberrationVolume.height.value);
        mat.SetFloat("_OnlyOri", System.Convert.ToInt32(chromaticAberrationVolume.onlyOri.value));

在改完脚本后,我们切回Assets根目录,有一个叫Universal Render Pipeline Asset_Renderer的文件。

image

你会发现之前内置的后处理都摆在这里,同样的,加上你的后处理就大功告成了。


物品系统

玩家的物品都存储在PlayerControl下的My Items里,你会发现这是一个int类型的List,里面写的都是数字,每一个数字编号对应一个物品。

image

其中,0为没有物品,110000的编号内都是食用品,1000120000都是武器,20001~30000都是防具。

这些物品的信息(运行时)储存在ItemControl内。

image

在食品中,第0个是食品的数据名称,第1个是食用完它之后会变成另一种什么食物(类似于原作的棒冰),第2个就是回复血量(可以是负数),以此类推。

数据名称是为了与语言包显示的名称相区别而存在的,例如你想加一个食物叫“巧克力”,它在不同语言有不同的叫法,但是数据名称只有一个(例如chocolate,或者choco,或者……cc?)。

而武器和盔甲更简单一些,第0个是数据名称,第1个是atk/def,以此类推。

但你并不能在这里进行编辑,你应该能看到ItemControl最上面存储着一个txt文本“ItemData”。

image

我们打开这个txt文本。

baaaaaaaaaaaaaaaa\Foods\@bang\10;
bang\Foods\@Null\5;

pia1\Arms\1\null;
pia2\Arms\999\null;


tatata1\Armors\123\null;
tatata2\Armors\456\null;

格式一目了然,你便可以在这添加新的物品了。

当然,物品加入后需要和语言包匹配。在语言包下的UI/ItemText内进行编辑。

/* I T E M */
Item\baaaaaaaaaaaaaaaa\Two servings\<autoFoodFull><enter>* Another test, ahhhhh<stop>.<stop>.<stop>.<passText>* But it went well.\<autoCheckFood><enter>* An indescribable gadget<stop>.<stop>.<stop>.<passText><color=yellow>* Oh, my God, the test was successful!!!\<autoLoseFood>;
Item\bang\A serving of food\* You tasted the rest of this stuff.<stop><enter><autoFood>\<autoCheckFood><enter>* Still nameless<stop>.<stop>.<stop>.\<autoLoseFood>;

Item\pia1\Broken t.knife\<autoArm>\<autoCheckArm>\<autoLoseArm>;
Item\pia2\<color=yellow>Golden Sword PLUS</color>\<autoArm><enter>* Golden Legend This is.\<autoCheckArm><enter>* Um... wtf is that(\* <color=red>You just throw it away?<stop>?<stop>?</color>;


Item\tatata1\T.P.S\<autoArmor>\<autoCheckArmor>\<autoLoseArmor>;
Item\tatata2\Wearable sth\<autoArmor>\<autoCheckArmor>\<autoLoseArmor>;

音频系统

AudioController是控制音频系统的脚本(注意不要和AudioControl混淆),它放在MainControl生成的BGM Source上。

image

使用形如AudioController.instance.GetFx(0, MainControl.instance.AudioControl.fxClipBattle);的代码来播放音频。

  public void GetFx(int fxNum, List<AudioClip> list, float volume = 0.5f, float pitch = 1, AudioMixerGroup audioMixerGroup = null)
    {
        if (fxNum < 0)
            return;
        GameObject fx = GetFromPool();
        fx.GetComponent<AudioSource>().volume = volume;
        fx.GetComponent<AudioSource>().pitch = pitch;
        fx.GetComponent<AudioSource>().outputAudioMixerGroup = audioMixerGroup;

        fx.GetComponent<AudioPlayer>().Playing(list[fxNum]);
    }

调用的音频存储在AudioControl内。

image


设置系统

image

CanvasController负责包含设置系统在内的对UI的控制,包含设置系统的相关内容。


存档系统


SaveController负责将PlayerControl中的数据存储为json与读取数据。

存档储存在根目录下的Data文件夹,若文件夹不存在会创建一个。

模板内同时使用了PlayerPrefs存储设置内相关的一些数据。如下。

PlayerPrefs.SetInt("languagePack", MainControl.instance.languagePack);
PlayerPrefs.SetInt("dataNum", MainControl.instance.dataNum);
PlayerPrefs.SetInt("hdResolution", Convert.ToInt32(MainControl.instance.OverworldControl.hdResolution));
PlayerPrefs.SetInt("noSFX", Convert.ToInt32(MainControl.instance.OverworldControl.noSFX));
PlayerPrefs.SetInt("vsyncMode", Convert.ToInt32(MainControl.instance.OverworldControl.vsyncMode));

多语言

语言包在MainControl内进行加载。

内置语言包储存在Assets\Resources\TextAssets下。

外置语言包储存在Assets\LanguagePacks下。

导出时的注意事项

  • 导出正式版游戏时记得不要勾选开发构建,反之可以勾上,导出时可以查看报错。

image

  • 在导出完毕之后,要把工程根目录下的Assets\LanguagePacks整个文件夹复制,然后粘贴在导出后的Undertale Changer Template_Data文件夹内,不然游戏会报错。

image

享受你的游戏吧!

尾声

如果你对这个教程有任何建议和意见,可以联系我进行修改——你自己改也行。

感谢你看到这里!希望你能做出一个完美的同人游戏!

⚠️ **GitHub.com Fallback** ⚠️