Contents

前言

看到使用Unity来制作Galgame,很多人可能会觉得小题大做,现在有很多现成的工具可以使用:krkr、NVL(krkr)、renPy等。我选择使用Unity来开发主要也是因为自由性,对程序控制和美术表现的掌握,现在的许多gal也加入了一些小游戏或交互类的功能,使用Unity开发更容易满足各种各样的需求。

需求分析

目前来说市面上能看到的绝大多数游戏都是以背景、立绘、音乐为表现,但推进游戏的方式不尽相同。

总结了下常见的游戏操作模式与推进方式:

  • 使用分支选择来推进游戏进度,分支决定了不同的故事结局(本篇文章主要的内容)。
  • 视觉小说,基本没有选项,按照剧本走向固定的唯一结局。
  • 通过对话与选项,来提升目标角色好感度,通过好感度来判断进入分支与解锁游戏进度。
  • 对行为或角色的选择,需玩家选择接下来的行为或在地图中选择对应角色推进游戏。

分支选择与视觉小说算为一类,好感度和大地图本篇不做讨论。

游戏中的页面

  • 启动页面:版权声明与Logo显示
  • 标题页面:游戏标题菜单
  • 游戏主页面:
  • 档案页面:储存与读取玩家游戏数据
  • 游戏选项:更改用户设置
  • 附加内容:展示解锁的内容

Galgame常见内容

  • CG、背景、转场
  • 立绘、表情、染色、动画
  • 音乐、音效、语音
  • 游戏选项
  • 游戏菜单、对话历史
  • 剧本演绎、分支选择、存档与读档
  • 附加内容:CG、音乐、结局列表、Tips

一些gal制作的功能与游戏模式:

全屏幕文字:

确认对话框:

CG相册:

结局列表:

存档读档界面:

分支选择:

sd漫画:

游戏选项:

角色跟随环境色着色:

对每张Background进行偏色配置,用Image自带的Color修改即可。

角色心情表情:

在每个角色的Prefab中修改图片层的对应位置

雾效(特效/粒子等)与动画

用unity自带的粒子制作特效prefab,动画使用dotween等插件。

小游戏及其他:

 

除了小游戏以外,有的只有图片、图片、UI,UGUI提供了许多非常便利的组件,所以我的制作是完全使用Canvas(UGUI)来制作。

具体实现

解决方案

引擎:Unity 2019

UI:UGUI

脚本解释器:xLua

关于解决方案在UI和解释器上考虑了很久,最开始我的目标是制作一个完全脱离Unity的Gal引擎,也就是在Unity编译后的所有编辑和制作不靠Unity来完成,维护程序的健壮性,UI则使用FairyGUI解决方案来脱离Unity,脚本使用自制的脚本语言(LScript),所有文件打包成一个“游戏卡带”,而Gal引擎则为“模拟器”。等大体出来之后就发现了问题:需要给其他的开发者来使用的话就需要开发可视化工具与简化编程相关的操作,这会使项目开发周期大大增长,所以最后还是决定让制作者用Unity来编辑游戏。

关于脚本解释器相关内容请跳转至 脚本解释器

场景结构

因为gal全部采用2d,为了更好的自适应各分辨率,直接将所有的2d表现都是用Canvas(UGUI)来实现。

所有的UI界面都制作为prefab,其内部的主体结构一致,这样可以轻易的实现UI的自由切换,如:游戏一开始是很普通的UI,随着剧情的深入通关了一周目后,二周目的UI变得猎奇无比。

场景的层级分为:

  • Launch     启动时的一些版权信息等
  • Game        游戏层,背景立绘以及文本框UI等
  • SaveLoad  存档读档UI
  • Config      选项UI
  • Extra        附加内容:CG、音乐等解锁展示内容
  • Video      播放视频
  • Dialog    对话框UI层
  • KeyNotice  按键UI提示层(主为手柄用户提供)

游戏摄像机也是分开的,为了更方便的添加摄像机特效,如弹出提示框后,后面的所有都会高斯模糊的效果。

内容实现

Flag:Flag是一个字符串,用于表示解锁等状态,例如选择某个选项,添加sel1这个字符串Flag,可以在其他地方进行判断。

全局存档:游戏有个全局存档,每次游戏运行时读取,不定时保存,用来储存Flag信息,如第一条角色线的通关的Flag为“char1”,那就判断这个Flag是否存在于全局存档中。

线路存档:每次Start会新建立一个空Flag的存档,用于保存每次选择的内容,在后面可以对前面选择的Flag判断。

人物环境染色:通过对每张背景图的设置,直接利用Image组件的Color属性对颜色进行更改。

转场:黑白亮度遮罩、Image的动画

程序结构

程序主要都是以状态机来运行,主要有四个状态

LaunchState 游戏启动页面,多数以显示制作组Logo与版权信息为主
TitleState 游戏标题,返回标题页面时切换的状态
GameState 游戏演出,游戏内容主体
ExitState 游戏退出中,播放动画过渡或语音效果

状态机切换后C#先处理逻辑,然后在交给游戏脚本的执行。

脚本解释器

Galgame引擎/框架本身就是一个解释器,解释脚本读取配置文件,进行演出。

保存存档时要将当时脚本的运行状态保存下来,以便读档时继续执行,也就是脚本运行状态的序列化与反序列化。

个人认为gal脚本的代码应保持每条指令的独立性,每一条指令都是上下文无关的,这样序列化工作只需要记录执行的指令位置即可,否则需要对上下文(代码所用到的引用、对象、环境)进行重建。所以构建出“舞台”(C#侧解释器),和“导演”(游戏脚本),这个导演只能用角色名字(角色索引名)发送指令到舞台,让舞台人员来安排角色上下场,播放音乐、一切的状态都由舞台来管理。

伪代码实现:导演创建了一个角色,这个角色叫alice,使用char.png这张立绘,坐标在0,0点,这句指令是从导演发送至舞台由舞台来代理完成创建并托管。

{{EJS0}}

下一句指令:移动角色,其中使用了之前创建的cindex(角色索引名),这句指令也是导演发送至舞台由舞台来执行,上一条指令与该条指令完全独立,因为并没有实际上的运算,实际的运算都在舞台那边,最后也是由舞台来做序列化与反序列化工作。

{{EJS1}}

 

这里提供几种解决方案。

 

一:按行文本

一些不以文本为主并且没有选项之类的简单可能会才用本解决方案。

直接对文本进行行分割,一行就是一句指令

{{EJS2}}

 

二:符号字符标记式脚本

该解决方案可以算是按行解释文本的升级版,用符号和字符来代表语言语法与方法调用,如

{{EJS3}}

符号标记式的语法比较简单,用+-*/=等符号与组合(或英文组合如bg:等写法)来识别本行指令的功能。

或者使用字母和符号混合使用

{{EJS4}}

直接用中文

{{EJS5}}

本解释器为了满足需求,是需要基础流程控制的,如goto

{{EJS6}}

本解决方案需要因为需要编写解释器,多少还是需要点时间的,但是思路清晰简单,只需要按行读取后对字符串切割后的内容判断,进行操作即可。

优点:书写脚本方便,使不会编程的脚本编码者也可以很快上手

缺点:没有代码的优雅,而且不易扩展

难度:★★☆☆☆

 

三:C#之IEnumerator

通过C#的IEnumerator(迭代器)来制作,需要等待继续执行时只需要yield return。

优点:享受智能提示,语法自由

缺点:没办法序列化,需要自己保存索引,需要编译

难度:★☆☆☆☆

 

四:自制一门状态可序列化的脚本语言

开发中实在很难找到一个很好的、可序列化与反序列化运行状态的、易扩展的、语法简洁且功能强大的脚本语言。

所以就自己写了个,为了gal而写的一门脚本语言,自然不会很复杂,掌握基础的编译原理,制作简单的词法分析器与解释器。

这门语言我觉得设计的像bat一样,只有简单的变量赋值,goto,if,与loop(while),方法无法自己声明,只能调用解释器(C#侧)的方法,但支持向解释器传递匿名函数来实现回调。C#侧被调用方法返回的布尔值决定了脚本继续运行,如果想稍后运行则可以把解释器的Next方法闭包到其他事件当中。

为了增加功能添加了预处理器,#define 宏替换等功能。

 

优点:自由,想怎么搞怎么搞

缺点:需要一定基础,耗费精力。

难度:★★★★☆

 

五:匿名函数列表

{{EJS7}}

代码使用Lua写的,但C#也一样使用(委托List)

{{EJS8}}

只需要不断的把数组下标前移就可以了,这个没什么难的。

问题就出在没办法使用goto语句,如果需要goto语句的话,可能会想到把列表(动态数组)改为有序字典,C#中的字典默认就是有序的,Lua中实现有序字典可以看Lua面向对象中的容器之Dictionary有序字典,但是如果使用字典,首先的第一个问题就是key是唯一的,也就是说你要为每一条指令分配一个key,另外又要去解决序列化的问题,因为序列化需要记录运行的行数(下标),可以使用List<KeyValuePair<TKey, TValue>>,不过KeyValuePair<TKey, TValue>是作为List的元素类型存在的,需要实例化,不能通过List初始化器快速添加,所以代码看起来会很长

{{EJS9}}

如果不介意写非常多的new KeyValuePair<string, Action>的话,或者使用

{{EJS10}}

在执行期间对取元素的Type,如果item.GetType == typeof(string) 那就是标签,item.GetType == typeof(Action) 则为指令

Lua实现可能方便点,可以判断表中有几个元素,一个那就是纯方法,两个参数的话那第一就是label

{{EJS11}}

{{EJS12}}

通过type判断元素类型

也许用Lua实现此解决方案是最安全健壮且功能强大的。

 

优点:通过语言安全的实现功能

缺点:C#版代码冗余

难度:★☆☆☆☆

 

六:脚本规则

{{EJS13}}

使用Lua脚本,在运行Lua脚本前对文本进行预处理,按;来分割每个指令,这样就可以做到什么时候执行到;什么时候就停,而且;在lua中是没有意义的合法字符编辑器不会报错,但是此处最好的分割解决方案不是使用Split,而是使用词法分析器,如果想使用Split更完美分割需要把分隔符设计的稍微复杂点,比如;;;。

使用本解决方案goto的处理方法:如规定label比如单独一行,解释器按指令分割时对label特殊对待,并新增解释器侧的goto给脚本调用。

另外,这里的设计是尽量不要与上下文有所关联,否则就要在序列化时对lua的变量进行保存,C#可以通过_G来访问lua全局变量,或者直接通关黑板模式来保存变量。

 

优点:实现简单快速,功能强大

缺点:需要严谨的按照规则编写代码

难度:★☆☆☆☆

 

七:表格脚本

通过Excel来编辑脚本,这个对于剧本编写人员来说可能还算友好,而且还可以更方便的实现多语言,但目前没有找到在Excel表格中写出逻辑的方法。

 

优点:方便剧本的编入与本地化,适合视觉小说

缺点:逻辑实现困难

难度:★☆☆☆☆

 

八:Unity之ScriptableObject

ScriptableObject继承自UnityEngine.Object,可以把自定义脚本序列成Unity对象,

 

脚本解释器的总结:

一开始尝试着使用C#来直接编写脚本,虽然调用方法很方便,但是没办法很好的序列化与编辑,自己编写的脚本解释器维护起来耗费精力,最后目光放在了脚本语言(JavaScript(非UJS)、Python、Lua)上,最终选择Lua作为脚本语言和xLua框架来内嵌Unity开发。