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在它出现并开源的那一刻起,便属于每一个使用它的人。
……但你真的需要它吗?
我是说,从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引擎的学习教程,网上有又新又好的,去找就是了。
在你第一次打开模板的时候,它应该打开了一个空的场景。大概是长这样。(如果不是也没关系,待会的步骤是一样的)
由于你第一次使用模板,所以我先教你运行一下整个模板,以此看看模板内大概的样子。
首先找到模板下面的Scenes文件夹,这里面存放着所有模板内的场景。
然后顺着Assets/Scenes/Menu/Story/Story.unity路径,打开Story场景,这就是原版的讲故事场景。
点播放,然后按照你玩原作的方式,稍微玩一下吧。
等你玩完一遍之后,我们就可以真正地开始了。
模板内的脚本有注释,可以参考注释进行编写,此处给予模板大体介绍。
我在这里简单写一下此模板的编写思路。
Undertale原作,在我的模板内可认为由以下两部分组成:
战斗内场景,战斗外场景。
很简单,就像它们的字面意思一样。
而战斗内场景也可以细分为地图内场景(Overworld)和杂项场景(故事、标题、菜单、重命名等)。
- 例如,地图内场景有玩家的脚本控制其移动,故事场景有脚本控制幻灯片的淡入淡出,战斗内场景也有脚本组成战斗系统。 也有一部分脚本是全部场景或大部分场景通用的。
每一类场景中有支撑它们运作的脚本。
- 例如,打字机脚本,以及总控脚本,等等。
在模板的Assets/Scripts内存放着模板内使用的代码,你在这里可以看到以下几个文件夹。
- Battle文件夹——仅在战斗内场景使用的脚本。
- Control文件夹——其中的脚本均继承自ScriptableObject类,用以创建Resources下的数据存储文件。
- Debug文件夹——正如其名,是Debug所用的脚本。
- Default文件夹——通用/未分类的脚本。
- Obsolete文件夹——弃用的脚本,多为参考他人使用的脚本。
- Overworld文件夹——仅在战斗外场景使用的脚本。
- Volume文件夹——自定义后处理相关的脚本。
目前,你只需要对这些个文件夹的分类有个大致印象就好。详细内容会在下文进行讲述。
这是一个地图内场景,当然了。
要做一个新的地图,我们需要新建一个场景。
按下Ctrl + N,在新建场景的页面,你会看到一个叫Overworld scene的场景模板:
这是这个场景模板的存储位置(你知道就行 别打开它 除非你知道你在做什么):
新建场景后,就会出现这么一个啥都没有的新的场景。
名为Grid的Obj就是瓦片地图,你可以随心所欲的开始铺瓦片,但要注意,瓦片的排序图层一般来说就是“Tilemap”,如图。
随便摆了一下。
适当加一点光源和后处理。(没错直接搬的Ruins示例场景)
然后可以存储一下,改个令你印象深刻的名字,放在Scene文件夹里面,如图。
然后给你的瓦片地图加一个碰撞体。我知道可以用Tilemap Collider 2D,但我还是比较习惯用Edge Collider 2D。所以按你喜欢的方式加个碰撞——
但这时,如果你运行了场景,它会报一个错,如下。
我们顺着报错去找出错的代码,就会找到下面这玩意儿。
...
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文件。
咱也加上咱的场景就是了。
一些格式和特殊富文本的写法可以打开Example-Corridor看里面的注释。
但具体的事件呢,咱待会再写。现在就放一个空的txt在这就得了。
现在进场景,我们发现摄像机在我们走到左边的时候卡住不动,这是因为我们还没有设置摄像机跟随。
MainCamera里面的CameraFollowPlayer就是设置摄像机跟随用的,可以把frisk扔到地图的最左边或者最右边,然后改这个脚本的数值测试摄像机锁定的范围。
当然,也可以吧Limit的√去掉,这样就没跟随限制了。
如果不需要跟随,把这个脚本去掉就行。
设置好的效果:
然后这个场景就有点样子了,你可以随便乱跑然后不会飞出去之类的。不过还没声。我们在MainControlSummon中解决这个问题。
把BGM拖上去,然后照着上面的信息写就完事了,一般来说,你只需要把BGM加上,别的不用管。
搞定这个之后,这个场景更好了一点。不过,它现在没法进行任何交互,但不急,我们马上就要提到它了。
事件是干什么用的?简单来说,他就是让你在游戏内查看某个物体,或者在地图边界进入下一个场景用的。
书接上回,我们的场景现在什么也调查不了,也没有离开场景的方法。
添加一个事件很简单。我们需要在场景里面加一个物体,身上需要有2d碰撞体,就像这样。
我的建议是可以把它放在Grid/Detectables下面,方便管理。(Detectables是一个新建坐标重置过的的空物体)
我们在上面添加OverworldObjTrigger,然后你会看到一大堆乱七八糟的选项,相关的作用上面的注释有写因此不再赘述。
我们只需要在调查的时候能弹出一些字来就好。例如什么“* 这是一个破墙”之类的。但首先,咱还没有把调查需要的文本写进去。
还记得之前提过的内置语言包路径(Assets/Resources/TextAssets/LanguagePacks)么?找到你之前创建的txt文件。
我们到里面去进行编辑。你可以参考Example-Corridor内的注释。
CrackedWall\<image=-1><fx=0>* This is a cracked wall.;
如果你懒得看注释,那我简单解释一下。斜杠前面的是OverworldObjTrigger的检测文本,后面就是具体的文本内容。
<image=-1>是打字的头像(-1是没有头像)。头像的精灵存储在BackpackCanvas里面的Sprite Changer里面。
<fx=0>是打字的音效。存储在Assets/Resources/AudioControl的FxClipType内。
别忘了在OverworldObjTrigger里面写上对应的检测文本。
我们走到墙那里去调查,然后字就出来嘞!
好了,接下来,我们需要一个离开场景的物体。
我们微调一下场景,弄一个出口。
这里要设置为触发器。
在OverworldObjTrigger里面设置如下。
勾选ChangeScene后,检测到玩家就会切换场景。
勾选BanMusic后,音乐会渐出。
SceneName就是切换场景的目的地。
NewPlayerPos便是切换到新场景后玩家的坐标。
现在让我们进游戏测试一下!
可以看到我们成功来到了另一个场景。
我们的事件就讲到这儿了,至于OverworldObjTrigger里面的其他选项,例如摄像机移动等,可以去看看Example-Corridor里面怎么设置的。
Tips:如果你这时停止运行,然后再运行,你会发现玩家的位置移动了。
这是因为玩家在切换场景时,目的地的坐标会存储在Assets/Resources/OverworldControl的下面这个位置,在加载场景时会把玩家放在这个坐标上。
有时候我们需要在场景里面添加选项。同样的,我们也要使用OverworldObjTrigger,但组件里不需要额外的设置,跟上文一样就行了。
我们需要额外做的有两项,
首先,你需要在BackpackCanvas里加上OverworldTalkSelect组件。直接加就行。
然后,仿照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。这是检测文本是否为“选项事件”的关键。
现在我们在场景里面配置好对应的触发物体,然后测试一下。
选项有了,但是你点哪个都没反应。这是因为你还没有写选择之后的代码。
我们打开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的预制体,它就是了。把它拖到场景里面去。
注意被我框住的部分,这就是让这个物体触发后可以出现保存窗口的选项。
接着我们继续在对应的文本里面输入:
Save\<image=-1><fx=0>* (The save filling you<enter>< >< >with <gradient="White to Red - UTC">determination</gradient>.);
效果如下。
但存储后,房间的名称显示为null——不然呢?你还没有输入房间名称!
在LanguagePacks\US\UI\Setting.txt里面另起一行,加上Example-Study\Study Scene;
这下好了!
按下V键退回菜单,仍然可以看到!
Tip:<gradient="White to Red - UTC">使用了TMP的渐变富文本。你也可以在Assets/TextMesh Pro/Examples & Extras/Resources/Color Gradient Presets里面添加你的自定义渐变颜色并使用富文本调用它。
此处列出Overworld相关的全部脚本,用于修改模板源码的参考。
BackpackBehaviour:Overworld背包系统的管理器
CameraFollowPlayer:Overworld摄像机跟随脚本
OverworldObjTrigger:Overworld物体触发器
OverworldTalkSelect:Overworld选项系统
PlayerBehaviour:地图内场景里的玩家控制脚本
SpriteChanger:Overworld对话中更改Sprite
TalkUIPositionChanger:更改对话框UI位置
TriggerChangeLayer:通过Trigger更改SpriteRenderer的层级
TriggerPlayerOut:用于带动画器的Overworld物体,在玩家进入/离开时,执行代码/播放动画。
我希望你不是直接跳过来看的,哈。
打开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.;
下面这块是怪物的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.;
怪物名称通过储存在BattleControl中的预制体检测。
下面这块是怪物的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.;
对照着修改就好。
怎么让这些选项触发后执行代码呢? 打开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);
你应该很早就注意到战斗场景里面有两个摄像机。
是的,得益于Unity(理所应当的)支持3D的(这一从它诞生以来就有的)优良传统,我们可以在场景里面弄一些3D元素出来。
就像我布置的这个场景一样,我觉得布置场景也没啥好说的,学过Unity基础就都会搞。
咱就简单提一嘴这俩摄像机的区别好了。
在战斗场景内你需要注意,“Main Camera”默认不是主摄像机,Unity对主摄像机的判定是根据它的标签,而很显然,“Main Camera”的标签是Untagged。
而3D Camera才是真的主摄像机,这点需要注意。
两个摄像机的拍摄内容在模板内显而易见。
这是“Main Camera”。
这是3D Camera。
在3D Camera的范围内随意布置你的场景吧。
Tips:理论上来说,你完全可以在我模板的基础上,试着把Overworld场景,或者整个模板……搞成3D的……或者2.5D。如果你有这能力,蛤?
战斗框就是战斗场景内的MainFrame,你会发现它下面有很多子物体。四个Point便是绘制战斗框的四个点,而Back绘制战斗框的黑色部分。
我们移动其中一个点,战斗框便会进行变形。
战斗框的边框使用LineRenderer绘制,而战斗框的黑色部分(以及弹幕的遮罩部分)都使用了shader绘制。
我们如果想要一个加更多点的战斗框,如下操作。
以五边形(五个顶点)的战斗框为例。
首先,在MainFrame的DrawFrameController上,更改顶点数为5。
然后加一个子物体Point4,你可以直接复制上面的Point。
然后,还记得我们说过战斗框的黑色部分以及弹幕的遮罩部分使用了Shader绘制么?因此我们需要小小的修改一下shader,这并不难,照着下文做就行。
在Back中点编辑,以此编辑DrawPolygon这个Shader,或者在Assets/Shaders/Sprites里找到DrawPolygon双击打开。
双击DrawPolygonFull打开这个Sub-graph。
这个Sub-graph里面放着四个DrawPolygon——这同样是一个Sub-graph。不管怎样,让我们观察一下这四个DrawPolygon传入数据的规律。
显而易见。我们从上往下数,传入的点的编号为:
- 3 , 0;
- 0 , 1;
- 1 , 2;
- 2 , 3.
我们以此类推就好了,首先在左侧加一个Point4,类型为Vector2。
下面加上这个。
最上面改成这个。
这样,传入的编号从上到下就是
- 4 , 0;
- 0 , 1;
- 1 , 2;
- 2 , 3;
- 3 , 4.
但新加的这个3 , 4的这个DrawPolygon没有地方连了,我们看右边的这个AddSuperposition,它同样是一个Sub-graph。双击点开它。
里面的结构显而易见,就像AddSuperposition的含义一样,把一堆Add叠加在了一起而已。
再叠加一层就好了。
保存,切回DrawPolygonFull,把该连上的连上。
保存,回到DrawPolygon,然后老样子,加一个Point4,然后连上,完事儿!
记得保存!
但这还没完,因为你只改好了战斗框的Shader,弹幕遮罩的Shader还没改。我们打开SpriteBattleMask这个shader。它位于Assets/Shaders/Sprites下。
同样的,加一个点,连上,完事。
运行游戏看看吧。
可以看到,异形战斗框和弹幕遮罩都正常运行。
此处列出战斗内相关的全部脚本,用于修改模板源码的参考。
BattlePlayerController:控制战斗内玩家的相关属性
CameraShake:摄像机摇晃脚本
DialogBubbleBehaviour:敌人对话气泡控制
EnemiesController:怪物控制脚本
BoardController:挡板控制器
BulletController:弹幕控制器
EnemiesHpLineController:敌人血条控制器
BulletShaderController:弹幕Shader控制器(包括弹幕遮罩)
GameoverController:Gameover控制器
ItemSelectController:物品选择控制器
SelectUIController: 战斗内UI控制器,同时负责玩家回合的控制
SpriteSplitController:启用该脚本后怪物立马变成灰
SpriteSplitFly:配合SpriteSplitController。控制单个像素的移动轨迹
TargetController:控制Target(fight里面的靶子)
TurnController:回合控制器+弹幕对象池
这个场景对应原作刚启动游戏时看到的讲故事场景。
场景布置非常简单,而主要控制这个场景内容的是Story物体下的StorySceneController。
编辑这个场景的方式如下:
在Pics里面设置显示的背景图。
在语言包的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。
对应原作的标题。
这个场景内默认包含一个标题和一个提示文本。
你如果想要它只显示标题,直接改代码就行,里面写的很简单我觉得我不用多讲。
另外,虽然我在场景里面使用了图片显示标题(因为我的标题图片很显然的经过处理,并不是直接输入的文本),如果你想使用类似原作标题那样的标题,你可以直接使用模板内包含的MONSTERFRIENDBACK与MONSTERFRIENDFORE。(UT-MSB,UT-MSF)
如下。
该场景由MenuController控制。 相关文本在语言包的同名txt中更改。
该场景由RenameController控制。 相关文本在语言包的同名txt中更改。
如果你对修改全局框架的需求不大,或者单纯就是看不下去下文,可以以后再看,下文属于比较进阶的内容。
我们之前提到了Assets/Scripts内的文件夹,也许你注意到了,Control文件夹的描述很特殊。是的它特别长
我们提到它里面的脚本是“用以创建Resources下的数据存储文件”的,如果你不明白这是什么意思,那么现在点开Assets/Resources文件夹,你会发现里面放着挺多东西的。就像这样。
我们先不管红框框里面的内容,看看这些绿框框框住的这些个名字都是“XXXXControl”的文件吧,随便点开一个,然后你就会看到右边的检查器会显示出来一些数据。
- AudioControl存储音效相关的数据。
- BattleControl存储战斗内相关的数据。
- ItemControl存储玩家的物品数据。
- OverworldControl存储一些通用数据(大多数用于Overworld)
- PlayerControl存储玩家的数据(同时它也被存档系统所读存)
同样的,有个印象就行,我们会在对应的地方详细讲解它们。
现在,我们要开始讲解整个项目中最重要的脚本,MainControl。
MainControl是干啥的?上面的这些xxxControl,都需要通过MainControl来获取,并且MainControl还负责对一些其余数据和语言包的导入。
让我们去看看场景内,然后你会发现,每一个场景里面都有一个预制体叫做MainControlSummon。
正如其名,它是生成总控的脚本,此外他还会生成设置系统和音频系统,这都是我们这部分要讲的内容。
它生成的总控(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);
在原先Unity自带后处理的基础上,加上了自定义后处理的战斗场景。
我知道你在想什么。停。
模板内有四个新增的后处理。
Chromatic Aberration Component是一个简单的滚动色差分离效果。
CRT Screen Component是一种类似于老式电视机的效果。
Glitch Art Component包含四种用于实现差错效果的选项,参考了KinoGlitch。
Stretch Post Component会拉伸游戏内的显示区域。我本想以这种方式制作16:9分辨率适配,但我发现拉伸后的显示区域看上去有点模糊。后来我用Viewport矩形解决了这个问题。简而言之,这个效果没啥用。
这几个后处理的原shader都放在Assets/Shaders/PostProcessing下。
编写你自己的后处理特效也很简单,在Assets/Scripts/Volume下存放一些脚本。
它们都是以xxxComponent,xxxRendererFeature的格式命名的。
xxxComponent负责把后处理添加在显示列表内。我们以Chromatic Aberration为例,我们在Full Chromatic Aberration.shadergraph中存储了这些数据。
特别注意的是这里要使用_MainTex。
而相对应的,我们把这些数据都要写在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的文件。
你会发现之前内置的后处理都摆在这里,同样的,加上你的后处理就大功告成了。
玩家的物品都存储在PlayerControl下的My Items里,你会发现这是一个int类型的List,里面写的都是数字,每一个数字编号对应一个物品。
其中,0为没有物品,110000的编号内都是食用品,1000120000都是武器,20001~30000都是防具。
这些物品的信息(运行时)储存在ItemControl内。
在食品中,第0个是食品的数据名称,第1个是食用完它之后会变成另一种什么食物(类似于原作的棒冰),第2个就是回复血量(可以是负数),以此类推。
数据名称是为了与语言包显示的名称相区别而存在的,例如你想加一个食物叫“巧克力”,它在不同语言有不同的叫法,但是数据名称只有一个(例如chocolate,或者choco,或者……cc?)。
而武器和盔甲更简单一些,第0个是数据名称,第1个是atk/def,以此类推。
但你并不能在这里进行编辑,你应该能看到ItemControl最上面存储着一个txt文本“ItemData”。
我们打开这个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上。
使用形如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内。
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下。
- 导出正式版游戏时记得不要勾选开发构建,反之可以勾上,导出时可以查看报错。
- 在导出完毕之后,要把工程根目录下的Assets\LanguagePacks整个文件夹复制,然后粘贴在导出后的Undertale Changer Template_Data文件夹内,不然游戏会报错。
享受你的游戏吧!
如果你对这个教程有任何建议和意见,可以联系我进行修改——你自己改也行。
感谢你看到这里!希望你能做出一个完美的同人游戏!