去年,小说君写了一篇关于独立游戏的文章。
如今,距离文中提到的这款游戏的发布已经过去一年了。
这段时间,小说君趁着清闲,整理了一波开发过程中的技术沉淀,分享给不熟悉Unity开发或游戏开发、但是又想做独立游戏的各位。
小说君认识的很多朋友,对独立游戏或多或少都有误解。
最典型的误解是会把独立游戏和演示类游戏混为一谈。
需要探讨技术议题的独立游戏,至少要是一款能称为产品的游戏,而不是技术、美术、玩法演示。
如果你打算做这么一款独立游戏,那么这篇文章的内容一定能帮到你。
小说君本打算写一个系列。但是考虑到之前的更新间隔,还是决定一鼓作气,都写在这一篇文章中。
整篇文章的字数大概一万多字,分成六个部分。
篇幅所限,覆盖到的每个topic,只能点到为止,大家可以拿来当索引用。
如果对哪一块特别关注,也可以后台留言,告诉小说君。
先来个big picture。
小说君在开发《最后的木头》时,采用了Unity2018、.Net 4.5、ugui。
不需要关注热更新,所以逻辑尽量全写在C#里,C#7.3语法。
json描述存档格式。
逻辑部分的主要插件:
EasyTouch
ConsolePro
DoTween
FMOD
JsonNet
Steamworks.Net
UWA
tolua
独立游戏开发,虽然不如工业游戏开发对图形、优化、网络的高标准要求,但是仍然有大量需要量产的逻辑。
RimWorld这么一款独立游戏,持续更新维护了十年,代码行数超百万,其中,AI逻辑和面板是最重要的两部分。
好的开发框架,可以大大提升量产这两部分逻辑时的生活质量。因此,小说君会把主要篇幅放在AI逻辑和面板两部分开发框架的设计上。
然后,小说君会简单分享下独立游戏开发中的配置和存档、多语言本地化相关需要考虑的问题和设计。以及,用Unity开发独立游戏(PC和OSX),选mono还是IL2CPP作为脚本后端需要考虑的问题。
最后,还有一堆开发过程中的杂烩沉淀,一并整理出来以供参考。
一、逻辑开发
Unity+C#,逻辑开发其实没有什么花样,小说君在这篇文章主要想讲讲协程。
协程简单地说就是单线程异步。复杂地说,小说君之前写了一篇文章,比较详细地介绍了协程的意义以及各种语言如何实现协程。
Unity内建协程能解决的问题有限。
很多时候,我们都要实现自己的一套协程框架。
先看看Unity内建协程能用来做什么事。
资源加载。
比如加载bundle常用的接口:
public class AssetBundle : Object
{
public static AssetBundleCreateRequest LoadFromFileAsync(string path)
{
//
}
}
AssetBundleCreateRequest是一个YieldInstruction,也是Unity内建协程所有能yield的基础概念。
拿到一个AssetBundle实例,用来加载asset的接口:
public AssetBundleRequest LoadAssetAsync(string name)
{
//
}
AssetBundleRequest也是一个YieldInstruction。
2.各种显示逻辑的Delay语义。常用的做法是在MonoBehaviour上挂一个等一段时间做个什么事的Coroutine。
3.Http。
那这套机制有什么限制呢?
我们从一个具体情景出发。模拟建设类游戏,小人的AI是重中之重。我们自然会想去用协程描述AI逻辑。
假设有某个小人行为——小人走到a点,拿起东西i,等个2秒,再走到b点,做一个行为X。
用Unity内建协程描述:
IEnumerator SomeAI()
{
yield return MoveTo(a);
yield return PickUp(i);
yield return WaitForSeconds(2f);
yield return MoveTo(b);
yield return DoAction(X);
}
其中,MoveTo、PickUp、DoAction,都可以理解为是嵌套的Unity风格子协程。
能发现有这样几个问题:
模拟类游戏中,停掉小人当前的工作,是一个常见的指令。然而Unity内建的Coroutine,如果正在执行子流程,比如如果正在跑MoveTo,这时候去StopCoroutine,SomeAI虽然不会继续跑了,但是MoveTo是不会停掉的。
WaitForSeconds这种写法过于verbose。
AI流程,很多子流程都需要依赖前一个流程的结果,比如小人如果PickUp失败了,就不再需要等2秒,而是做个其他的什么操作。
要想解决这些问题,就没办法用Unity原生协程了,只能自己重新实现一套。
实现完之后,上面的SomeAI可以改成这样:
IEnumerator SomeAI()
{
var h = MoveTo(a).Yield();
yield return h;
if (h.Result != BehaviourResult.Success)
{
yield break;
}
yield return PickUp(i);
yield return 2;
yield return MoveTo(b);
yield return DoAction(X);
}
两点需要注意的:
第一次MoveTo,用一个协程实体包装了下。方便后面拿到结果。
协程的signature保持IEnumerator,可以继续支持yield return任意类型的值。比如下面的yield return 2,就是之前yield return WaitForSeconds(2f)的简化。
实现起来还是很简单的,核心函数就两个,Resume和Yield。
贴一个比较典型的实现。
public bool Resume()
{
while (true)
{
if (disposed)
{
return false;
}
if (routineStacks.Count == 0)
{
return false;
}
if (MoveNext(routineStacks.Peek()))
{
return true;
}
// 只有这里表示完整结束
if (Result == BehaviourResult.None)
{
routineStacks.Pop();
if (routineStacks.Count > 0)
{
continue;
}
// 如果Routine里没设Result,那就默认是Success
Result = BehaviourResult.Succcess;
}
Dispose();
return false;
}
}
再看看Yield的逻辑:
private bool Yield(IEnumerator routine)
{
var obj = routine.Current;
if (obj == null)
{
BahaviourManager.NextTickDispatch(this);
return true;
}
if (obj is BehaviourResult r)
{
if (r != BehaviourResult.None)
{
Result = r;
return false;
}
// yield一个BehaviourResult.None表示控制权交给caller
return true;
}
if (obj is IConvertible convertible)
{
obj = new WaitTimeBehaviour(Convert.ToSingle(convertible), BahaviourManager);
}
else if (obj is IEnumerator enumerator)
{
obj = new ContinueBehaviour(BahaviourManager, enumerator);
}
if (obj is IYieldable toYield)
{
callee = toYield;
toYield.ResumeStart(this);
return true;
}
throw new NotSupportedException($"yield return type not supported. type={obj.GetType().Name}");
}
这个实现基本解决了之前提出的几个问题,支持yield结果,支持级联Stop掉子协程,可以定制各种yield结果。
C#实现自定义的协程,大体上有两种技术路线。
一种是基于callback改造。
一种是朴素oneloop实现。
第一种,好处是同时维护的只有少数协程实例。
第二种,好处是每个协程的栈比较浅。
实现中,通常我们看到的是两种技术路线的融合。
比如第一种路线,要实现Unity风格的yield return null语义(等一帧),就需要把当前协程挂在一个队列里(例子里的BahaviourManager.NextTickDispatch(this)),下一次Update取出来Resume。
比如第二种路线,某个协程yield在某个完成时间不确定的事件上,如果还是朴素OneLoop轮询,就显得太蠢,不如挂个callback再resume,降低轮询压力、减少维护的协程实例数量。
前面贴出的例子,可以算作第一种。
Unity内建协程,我们从AsyncOperation的定义有isDone和progress看的话,更像是第二种。
有了一套自研的协程框架,我们可以拿来做很多事。
比如写AI。
独立游戏开发,一般不需要用复杂的工业流程去写AI,比如各种带编辑器和runtime的行为树或流程图解决方案——纯属高射炮打蚊子。
我们需要的是一个能提高生产力的机制。
协程可以描述所有同步异步逻辑,自然能描述任何AI。
经过简单的设计,我们能像拖拽行为树节点一样,方便地用协程描述各种AI组合语义。
设计之前,我们可以先思考行为树方案的好处是什么。
首先,最大的好处,是可以让不会写代码的策划通过拖拽节点实现游戏逻辑。
其次,声明式地组织游戏逻辑,结构性的通用部分实现为语义节点,可以直观地看到AI的流程。
我们在设计时,第一点自然是不用考虑,主要看第二点。
之前的例子中,SomeAI本身可以理解为一个复杂的行为树节点集合。
看看常用的有哪些通用结构性语义,然后我们实现这些行为树中的常见语义,可以让逻辑整体看起来更容易理解。
我们看下行为树中一些常见的语义,在行为树中是什么定位,到我们的框架中,又对应为什么概念,可以用在什么地方。
Check
行为树中,Check节点会驱动修饰的子节点,根据结果,向上返回成功或失败。
Check可以用来声明式描述行为的predicate。
构造方式:
public static Check New(Func<bool> func, Func<IEnumerator> action)
只有func通过了,才继续走action。
Sequence
行为树中,Sequence节点会依次驱动子节点,有失败就中断并返回失败,全部成功返回成功。
Sequence对应到两个概念。
在某个协程实现中,代码天然就是Sequence序列,顺次执行。
用来组合节点时,比如要声明式地描述哪些行为需要跟固定的后置逻辑。
构造方式:
public static Sequence New(params IBehaviour[] sub)
Drive的时候按Sequence语义来就行了。
Select
行为树中,Select节点会依次驱动子节点,有成功就中断并返回成功,全部失败返回失败。
Select可以用来声明式描述行为的分组关系。比如Select1描述小人的吃喝玩乐,Select2描述小人的工作ABCD,两个Select可以单独控制开关和组的优先级,实现模拟建设游戏中的工作管理机制。
Select的构造方式和实现都跟Sequence类似,不再赘述。
Parallel
行为树中,Parallel节点会同时开始执行所有子节点。等所有子节点都出结果了,根据这些结果,确定整体是成功还是失败。
Parallel是异步流程的重要组合语义,简单说就是Task.WhenAll,很容易理解。
Race
行为树中,Race节点会同时开始执行所有子节点。等某一子节点出结果了,根据配置策略,确定整体是直接出结果还是继续等下个子节点出结果。
Race是异步流程的另一个重要组合语义,Task.WhenAny,也很容易理解。
协程框架+行为树风格的组合语义节点,写AI同时兼具了写代码的简单直接快速以及行为树配置的易于理解两个优点。
两个补充话题。
1.还有其他实现自研协程框架的动机。
比如可以实现类型化的协程,把对yield的支持程度直接暴露在类型信息里。
如果限定signature是IEnumerator<T>,那么能yield的必须都是一种T。
IEnumerator<YieldInstruction> Coroutine()
{
yield return 1;
}
就像这样,yield return 1,C#的静态检查可以报错。
2.现在的协程框架里拿子协程返回值的方式比较难受。
这里确实存在更优雅的解决方案,那就是await。
unity2018已经可以用await。
但是如果直接用原生await,会带来过多的overhead,而且unity跑async函数是未定义行为。
先看下await风格的SomeAI是什么效果:
async AwaitableTask SomeAI()
{
var h = await MoveTo(a);
if (h != BehaviourResult.Success)
{
return;
}
await PickUp(i);
await Delay(2);
await MoveTo(b);
await DoAction(X);
}
不仅可以await MoveTo,还可以await任何想返回值给caller的异步过程。
然后我们考虑两个问题。
一、这样一行代码,实际上发生了什么。
var h = await MoveTo(a);
二、一个async函数定义,会产生什么。
第一个问题。
假设MoveTo(a)的返回值类型是SimpleTask<bool>。
private async void Test(int a)
{
var h = await MoveTo(a);
}
直接这样写是无法过编译的。
但是编译器告诉我们,需要有一个GetAwaiter函数,可以是扩展方法:
public static SimpleTask<T> GetAwaiter<T>(this SimpleTask<T> self)
{
return self;
}
也可以是实例方法:
class SimpleTask<T> : INotifyCompletion
{
public SimpleTask<T> GetAwaiter()
{
return this;
}
}
然后编译器会提示我们,拿到的这个Awaiter,需要提供这样一组方法:
class SimpleTask<T> : INotifyCompletion
{
public bool IsCompleted => false;
public T GetResult() => default(T);
public void OnCompleted(Action continuation){}
}
这样就好理解了。
var h = await MoveTo(a);
编译器做两件事:
根据整个表达式的返回值,需要通过实例无参方法或单参扩展方法拿到返回值的Awaiter。
直接检查Awaiter的IsComplete,
伪代码大概是这样:
private void Test0(int a)
{
var expr = MoveTo(a);
var awaiter = expr.GetAwaiter();
if (!awaiter.IsCompleted)
{
awaiter.OnCompleted(() =>
{
var result = awaiter.GetResult();
// next
});
return;
}
{
var result = awaiter.GetResult();
// next
}
}
这个流程跟我们之前按callback思路设计的协程框架如出一辙。
之前的根据不同类型Yield的逻辑,现在就转为了针对不同可Yield的类型,实现个GetAwaiter。
之前的Resume,由编译器自动帮我们实现了这个调度流程。
具体的实现细节这里就不再展开了。
第二个问题。
我们还是先写个示例,看看编译器怎么提示:
private async SimpleTask Test(int a)
{
var h = await MoveTo(a);
}
每个async函数,最后都会转换为一个Task。
如果我们想自定义async,转换成自定义的低成本Task,就要给编译器hint。
具体的hint方式很模式化,简单说就是加个Attribute:
[AsyncMethodBuilder(typeof(AwaitableTaskMethodBuilder))]
AwaitableTaskMethodBuilder的典型实现可以参考UniRx实现,本篇文章就不再赘述了。
这样,我们的协程框架就可以支持await,但是又能脱离成本很高的Task机制。
二、UI开发
说到用C#写UI,大家想必都知道MVVM。
Model-View-ViewModel,与其说是一种设计模式,不如说是一种设计思路。
程序员的核心能力是抽象,concept -> modeling -> abstraction。
一个写UI的程序员,思路转变的最关键节点,是从用各种控件硬写UI逻辑,到对视图做抽象,建立模型,对视图逻辑和视图状态拆分。
抽象视图,建立模型,得到的东西就是ViewModel。
用ugui的时候,这两种写法分别怎么对应?
一个简单的例子。
模拟建设游戏中,有个带Title的面板显示所有的小人列表。列表中的每个元素包括:实时刷新的小人hp、一个点一下摄像机会跳转到小人的按钮。
小人的数据结构定义。
public class Person
{
public float Hp;
}
先看下常见的用控件硬写UI逻辑方式。Panel类。一个Refresh函数。
public void Refresh(List<Person> data)
{
foreach (var person in data)
{
var element = Object.Instantiate(
Template,
Slot
);
// element初始化
}
}
element挂一下onClick。Refresh的时候重刷一下Title,显示下当前多少个小人。
再看如何对视图做抽象。
抽象都要做什么?
两件事情:
对视图做个建模,视图展示的是什么状态(state)?视图可能会产生什么行为(behaviour)?
定义状态,实现行为。在这个简单的例子里,视图要渲染,需要的是一个小人数据列表。视图中的按钮点击,需要触发一个函数——参数传一个小人,逻辑是摄像机跳转。
整体组合一下,我们定义一个小人ViewModel类。
public class PersonsViewModel : ViewModelBase
{
public ObservableCollection<PersonViewModel> Persons { get; }
= new ObservableCollection<PersonViewModel>();
public void MoveTo(PersonViewModel person)
{
// 摄像机跳转
}
}
然后,我们可以加个关联函数,里面做的事情就是视图抽象(状态和行为)的变更同步到视图,勉强可以做到把状态和行为与界面逻辑拆分开来。
public PersonsViewModel()
{
Persons.CollectionChanged +=
PersonsViewHelperUtils.OnPersonsChanged;
}
这种实现方式,是一种比较朴素的设计模式,Presentation Model。
虽然已经做了一些视图和逻辑分离的尝试,但是还留下了很多问题:
从设计上,视图模型和视图结构耦合严重,PersonsViewHelperUtils.OnPersonsChanged需要同时知道两者具体结构,做个关联。
从实现上,耦合的逻辑,不同面板的实现其实大同小异。同样的onClick,同样的text同步,同样的根据Template去Object.Instantiate等等。
我们更需要的是,抽象出的视图模型更纯粹,而且理应不知晓View的存在。
而且同一个PersonsViewModel对象只维护跟业务有关的数据和逻辑。同一ViewModel实例既能对应到一个小人列表面板,也能对应到一个整体小人一览面板——每个View只是它的一个特定投影。
总结一下业务、视图模型、视图,三者的关系。
业务,对后两者完全不知情。
视图模型,对视图不知情,对业务模型做一层包装。
视图,描述自己需要依赖怎么样的一个视图模型。
改造一番,变成这样:
prefab里的配置。
面板的标题Text绑定到Title和Persons,分别求值拼成字符串。
ScrollView的Content,绑定到Persons,指定一个DateTemplate。
这样一来,前面的两个问题都解决了:
视图模型跟视图完全解耦。
从视图模型到视图的同步代码,不再需要。
视图逻辑通过声明式定义,实现交给框架去做。不仅减少了工作量,不用写这些重复代码,还能避免潜在bug。并且,视图模型现在更纯粹了,方便做单元测试。
最后,还有一个额外的好处,而且需要强调的是,这并不是用MVVM的最主要好处:拼面板工作和开发工作现在可以完全分离开。
工业流程中,程序不需要把精力放在拖节点上。
独立开发中,虽然Unity的拖节点机制已经提供了比较方便的工作流。但是改用MVVM,仍然可以在迭代面板的过程中,减少中间状态出现的可能性(比如说有些地方的节点类型变化,两边修改不同步,会产生错误的中间状态)。
以上就是MVVM的基本概念,接下来我们简单过一下如何实现。
需要补充的是,MVVM开发框架,以及前面提到的协程框架,小说君都不建议用第三方库。一方面是代码量很小,另一方面这两块的开发框架只要有清晰定义,具体代码实现都很简单。
我们想在Unity中实现一个MVVM开发框架,至少要解决两个问题:
ViewModel怎么定义。
View,或者说prefab里面,怎么去描述某个控件的某个属性跟ViewModel的关联。
第一个问题,很容易解决。
C#的FCL已经给我们提供了两个简单的模型。
public interface INotifyPropertyChanged
{
event PropertyChangedEventHandler PropertyChanged;
}
public interface INotifyCollectionChanged
{
event NotifyCollectionChangedEventHandler CollectionChanged;
}
INotifyPropertyChanged,在属性变化时触发PropertyChanged。
一个简单的ViewModelBase实现:
public abstract class ViewModelBase: INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
[NotifyPropertyChangedInvocator]
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
一个简单的ViewModel:
public class EntryViewModel : ViewModelBase
{
private string name;
public string Name
{
get => name;
set
{
name = value;
OnPropertyChanged();
}
}
}
任意属性变化的时候,需要触发一下这个事件。
FCL有现成的INotifyCollectionChanged,ObservableCollection<T>会在集合元素变化的时候,触发相应的事件——添加、移除、换位置等。
第二个问题,其实就是数据绑定机制。
先确定几个概念:
数据绑定(Data Binding,下文简称绑定),描述View的控件属性与ViewModel属性的关系,至少包括路径和数据流向两部分。
绑定数据源(Binding Source,下文简称源),源和目标是相对的,通常源用来指ViewModel。
绑定目标(Binding Target,下文简称目标),通常目标指View的控件属性。
数据上下文(Data Context,下文简称上下文),可以简单理解为是某个View的ViewModel。这个View的所有绑定,描述的路径都是相对于数据上下文确定的。数据上下文是一种层次结构。如果不显式指定某个节点的DataContext,那这个节点会默认继承父节点的DataContext。对于集合类型的控件,比如三种LayoutGroup,如果指定了ItemsSource,那么每个子节点的DataContext是ItemsSource的一个元素。
关于数据绑定的实现,我们从两个方面去入手:
绑定中,源和目标是什么关系。
绑定的目标和源怎么样同步。
我们先看下这两者都会有哪些关系。
目标改变,源同步改变。View的控件属性改变,ViewModel关联属性也对应改变。例,InputField的text编辑,改变关联的ViewModel对应属性。
源该表,目标同步改变。ViewModel的属性改变,关联这个属性的View控件属性也对应改变。例,Text的text关联的属性发生变化,显示也要对应发生变化。
目标在源设置的时候初始化一次。View的控件属性,在设置数据上下文的时候确定值,之后如果数据上下文不变,值也不变。
所以,排列组合一下,有这么几种绑定模式:
OneWayToSource,第1种。
OneWay,第2种。
TwoWay,第1种和第2种的组合。
OneTime,第3种。
跟WPF定义的绑定模式一致。
接下来看绑定中的目标和源如何同步。
先给可以作为绑定目标的控件属性分一下类:
普通的值,例,Button.isInteractable。
自带AddListener的值,例,Toggle.isOn。
Commnad,例,Button.onClick。
列表元素,例,三种LayoutGroup的Children。
从绑定使用者的角度来看,前两种可以归为一类,只有实现上的不同。
整体数据绑定机制的设计,小说君的习惯做法是不侵入现有的控件。如果没有必要,不要去继承个新的重来。
需要对控件做数据绑定,就外挂一个新的节点,组合式描述当前节点的UI控件的绑定表达式。
绑定表达式,可能包含的元素有这样几个:
绑定模式。
绑定路径(Path)。用来从上下文中获取绑定源。
DataTemplate。
从使用方式上,我们看下三类绑定有什么不同。
值,需要配置路径和数据流向。
集合,需要路径和DataTemplate。
Command,只需要路径,不需要数据流向。
然后我们再从实现细节上,过下三类绑定。
1.值绑定。
按WPF里的概念,我们把能作为绑定目标的值称为DependencyProperty。所有支持绑定的UI控件属性都是DependencyProperty。
先看数据从Source同步到Target的实现细节。
private static DependencyProperty ToggleIsOn =
RegisterDependencyProperty<Toggle, bool>(
e => e.isOn,
(e,v)=>e.isOn = v,
(t,action)=>
{
t.onValueChanged.AddListener(action);
});
private static DependencyProperty TextText =
RegisterDependencyProperty<Text, string>(
e => e.text,
(e,v)=>e.text = v
);
统一定义DependencyProperty。
设置DataContext的时候,查当前节点上绑的绑定表达式。
DataContext改变,找每个绑定表达式对应的Target,根据DependencyProperty定义的Setter,更新Target。
不同绑定模式下,实现稍有不同:
OneTime,实现比较简单,就是DataContext变的时候统一取值再设置就行了。
OneWay,TwoWay,需要根据DataContext和Path,拿到绑定数据源,建立绑定通路。如果实例是个INotifyPropertyChanged,那就注册一下。在数据源实例的状态发生变化时,检查这个状态是不是当前DependencyProperty绑定的,是的话就更新Target;如果实例不是INotifyPropertyChanged,那只能保持OneTime语义。
再看Target改变时,如何同步到Source。
Target改变同步到Source,一般适用于像InputField的text,或者Toggle的isOn这种玩家操作改变的UI控件。
玩家操作 -> UI控件状态改变 -> 同步到Source。
而其他的普通属性,比如Button的isInteractable,改变的源头有可能是其他视图逻辑。如果这些普通属性也需要同步到Source,那就要给普通属性包装一层。其他页面逻辑修改这些普通属性的时候,也要调用包装版本的接口。
2.集合绑定。
集合和值都属于状态,实现上跟值绑定大同小异。
区别有两点:
不需要支持从Target到Source的同步。
一方面,ugui的控件比较简单,集合型控件只有LayoutGroup和DropDown,最多再加个ToggleGroup。这些控件都不支持玩家操作可以直接修改控件子节点。
另一方面,要在页面逻辑里修改上述控件的子节点,方法很多,随便Destroy或者SetParent就实现了,要想完全接管这些操作复杂而且没有必要。
根据集合类型Source(ItemsSource)构造Target的子节点,需要引入DataTemplate的概念。
我们平时在构造一个LayoutGroup的Children的时候,做的事情基本都是根据一个数据集合,挨个Instantiate子控件。这部分工作理应交给框架去完成。
DataTemplate描述每个子控件应该如何根据DataContext构造。
ItemsSource的绑定表达式,额外指定的DataTemplate就是在实例化子控件时候的模板。
3.Command绑定。
Command,可以简单理解为Button.onClick。绑定表达式中,Path描述的是一个实例方法名,可以直接绑定到一个无参实例方法。
例如:
public class PersonViewModel : ViewModelBase
{
public void MoveTo()
{
// 摄像机跳转
}
}
还可以继续扩展,比如,可以绑定到一个仅有一个参数版本的函数。然后运行时根据DataContext的类型匹配到具体重载版本;把Command的CanExecute交给框架去做;支持额外配置Command的Validation表达式等。
最后,我们总结下资源和代码如何组织。
View层,一个Panel.prefab,以及对应的Panel.cs。绑定表达式直接声明式配置在prefab上,cs文件里写一些UI逻辑。
ViewModel层,不需要定义地过于严谨。对于简单的面板来说,Panel.cs本身也可以相当于一个的ViewModel。
Model层,是一个抽象的概念,视图以下的状态都可以是Model,而且Model自身也可以分层。
继续展望下,如何在Unity里用更现代的方式进行UI编程?
prefab的编辑,已经比较接近XML声明式+拖拽节点,而且是一种WYSIWYG(所见即所得)式的编辑。相比现代UI编程,略显不足的的是级联样式表还没有抽象出来。希望Unity的UIElements能支持运行时UI之后,这一点能有改善。
Binding Source,目前仍然需要在Property的setter里面显式调一下OnPropertyChanged。这一步骤在现代UI编程框架中已经能做到打包的时候parse依赖关系自动确定触发变更时机,代码中也不再需要手动加这一行代码。
做法也很直接:
语义分析Binding Source。
级联地找dependency。
AOP 所有属性,插入OnPropertyChanged。
构造运行时能读到的变更依赖树。
三、数据
游戏中的数据,包括静态的配置数据和动态的存档数据。
小说君把这两块归在同一节讨论。
配置数据
配置是静态的游戏数据。
比如针对每种建筑,建筑的基础建造时间,建筑在特定情况下的行为,都属于配置数据。
配置数据可以分为三类。我们看看分别适合怎么样维护。
1.状态值和数值,一般是primitive type。比如建筑的建造条件,建筑的基础建造时间等。
小说君以前追求配置脚本化。能用公式描述的数据,一定要写代码搞定。数据表里只保留一次数据。
然而,数据量级一上来,没有专门的工具,这种脚本化的配置维护成本过高。
举个简单的例子,生产建设类游戏,我们要经常地调整生产过程中的资源换算体系。
一种是在代码里设计层层嵌套的公式。
一种是直接excel里配置计算辅助列统一处理平铺的数据。
后者不仅易于迭代,而且更直观。因此这部分数据,应该直接维护在excel。
2.复杂类型的状态值。比如用一个字典去描述打掉某个敌人有可能获得什么奖励。
这种数据,可以理解为是一个跟模板id关联的静态数据。
应该直接维护在逻辑代码中。C#定义。
3.逻辑。
相当于跟模板id关联的同signature函数。
同样应该直接维护在逻辑代码中。C#定义。
独立游戏开发,我们不需要把所有类型的数据全放excel维护,然后对三种类型数据的导表分别实现。
需要拉到excel的数据就放excel,其他数据就留在C#。
一次性导表,excel导出的数据覆盖掉C#定义即可。
这里,tolua就可以派上用场。
写个工具把excel的数据直接导成lua脚本。
先C#配置构造,然后执行一遍lua脚本,覆盖掉数值配置。
存档数据
首先找一种熟悉的序列化形式。
像其他游戏一样选择json/xml会让事情变容易很多。
但是这只解决了第一步,状态空间的落地和恢复才是这块工作的大头。
单机游戏,玩家对存档恢复的要求远高于网游。正确地实现单机游戏的存读档难度不亚于一个MMO服务器全局状态的停服重启后恢复。
网游重新读档一般对「完全还原」的要求比较低,因为已经接受了重新进来就是原地重新加载出来这个设定。
单机就不一样了,读档进来需要的是连续的体验,一草一木都要跟存档时一样。
模拟建造类游戏,二进制存档不做压缩的话,少说也有几M;MMO的玩家存档,超过1M已经算很大的量级了。
个中差别,可想而知。
四、Script Backend
Unity游戏,PC/OSX平台,打包的Script backend选mono还是IL2CPP是个需要慎重考虑的问题。
两个选项的特点:
mono的适用范围更广,更稳定。但是加密很难做。
IL2CPP很多FCL的库没有实现。但是反编译的门槛比较高。
mono支持而IL2CPP不支持的特点,小说君简单分享两点:
对动态代码生成的完备支持。以及伴随而来的数不清的life quality。
简单列举一些:
泛型虚方法。这是IL2CPP的命门,也很难有解决的办法。
steamworks.Net。其中有一段代码是这样:
m_CallbackBaseVTable = new CCallbackBaseVTable() {
m_RunCallResult = OnRunCallResult,
m_RunCallback = OnRunCallback,
m_GetCallbackSizeBytes = OnGetCallbackSizeBytes
};
m_pVTable = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(CCallbackBaseVTable)));
Marshal.StructureToPtr(m_CallbackBaseVTable, m_pVTable, false);
OnRunCallResult是一个泛型类里的方法,Marshal m_CallbackBaseVTable的时候会直接报:
NotSupportedException: IL2CPP does not support marshaling delegates that point to generic methods.
表达式树。
表达式树可以非常方便地用来动态代码生成。
steam的很多API非常蠢,如果你想提高抽象层次,你需要大量的动态代码生成(Steam统计等)。
比如Steam的统计API,现在假设有这么个统计数据结构:
public class SteamStatistics
{
public int TotalGamePlayCount;
public int TotalNumLoseCount;
}
保存和加载的接口:
return s =>
{
SteamUserStats.SetStat("TotalGamePlayCount", s.TotalGamePlayCount);
SteamUserStats.SetStat("TotalNumLoseCount", s.TotalNumLoseCount);
}
return s =>
{
s.TotalGamePlayCount = SteamUserStats.GetStatInt32("TotalGamePlayCount");
s.TotalNumLoseCount = SteamUserStats.GetStatInt32("TotalNumLoseCount");
}
字段只有两个,看起来还好。
但是大部分情况下,统计的字段是成百上千,这样就很痛苦了。
换成表达式树,就这么几行代码就搞定了:
var parameterExpression = Expression.Parameter(typeof(SteamStatistics), "s");
var storeExpressions = new List<Expression>();
var fields = typeof(SteamStatistics).GetFields(BindingFlags.Instance | BindingFlags.Public);
foreach (var fieldInfo in fields)
{
var method = typeof(SteamUserStats).GetMethods(BindingFlags.Static|BindingFlags.Public).Single(m =>
m.Name == "SetStat" && m.GetParameters()[1].ParameterType == fieldInfo.FieldType);
storeExpressions.Add(Expression.Call(method, Expression.Constant(fieldInfo.Name),
Expression.PropertyOrField(parameterExpression, fieldInfo.Name)));
}
var lambdaExpression = Expression.Lambda<Action<SteamStatistics>>(
Expression.Block(storeExpressions), parameterExpression);
return lambdaExpression.Compile();
没有表达式树,程序员可能真的就成码农了。
2.FCL里的对操作系统API的直接包装。
IL2CPP,Process库不能用,意味着要自己包一层win32API或OSX API去实现进程重启。
3.更强的稳定性。
小说君在游戏刚发布的时候,遇到了很多很难查的偶发crash,一切mono,什么问题都没了。
IL2CPP在移动平台的issue已经够unity去处理了,PC平台的只能说是解决有无问题。
Unity本身就已经带来了很多不确定性,IL2CPP会带来更多不确定性。
IL2CPP的特点比较单纯。反编译一个C# assembly基本上是零门槛的;但是反编译一个加壳的C++程序,需要一定门槛。
小说君从游戏发布的前几个月开始,切换到了IL2CPP,到游戏发布后的一两个月,切回了mono,感觉得到了救赎。
五、本地化
众所周知,Steam上国区市场份额只占不到10%。而要做一款面向所有玩家的游戏,localization不可避免。
游戏中,需要本地化的资源包括:
文本。
图片。
部分配置值。比如文本阅读顺序,有些节点的尺寸,都要根据不同语言做调整。
文本数据是本地化中的最大头。
独立游戏文本的本地化基本要靠社区完成,也就是说,开发者只提供英语和母语的文本,其他交给玩家社区来做。
这中间就涉及一些流程问题。
社区玩家怎么样成本最低地输出不同语言的文本。
开发者怎么成本最低地把这些文本集成在游戏中。
先看流程。
以google sheet为核心,我们维护的社区sheet大概类似这样:
English | French | German | ... |
1,word1 | |||
2,word2 |
其中,1、2这些数字对应的是枚举值。English格子里是英文文本,其他语言的格子是对应语言的文本。
平时的流程是,游戏文本有更新,同步到google sheet。
大更新前,定期从google sheet拉下来社区文本,用手写的工具拆成目前游戏本地化框架需要的目录结构,工具查错,人肉diff并merge。
总结下来,还有很多可以优化的地方。
社区玩家是玩家而不是策划,他们只是有空的时候随便上去填个格子,完全不在意文本格式。
因此面向社区的文本,一定要是无格式的。
前面的示例表格里,就把csv的格式带了进去。实践过程中,小说君也花了大量额外的时间去查csv的格式错误。
格式性质的信息完全去掉之后,表格内容大概类似这样:
English | French | German | ... |
word1 | |||
word2 |
这样,每次需要更新的时候,工具把整个表格拉下来,根据English文本可以反向确定枚举值。后面不同语言的文本同步到具体目录结构即可。
游戏文本到google sheet的同步工作,也比较机械。我们目前是由一位法国的热心社区志愿者完成,建议直接写google sheet脚本自动化导入。
id为key最好改成枚举名为key,防止枚举插入导致的id整个错乱。
有了数据源,还要替换到游戏里。
所有文本控件,比如Text和InputField,要支持动态根据当前语言替换字体,替换文本。TMP可以帮忙解决很多问题。
然后是图片等资源的本地化。
资源生产,只能开发者自己搞定。
资源加载,方案很多。最简单可以特殊语言的对应资源加个后缀,没有的话就fallback到默认的资源上。
prefab上配置值的本地化,也可以外挂一些组件根据当前语言类型来切换参数。
小说君对本地化框架仅做了最简单的实现。
枚举 -> id -> key-value。
游戏逻辑引用枚举,不同枚举类型转换为不同的id域,csv维护id到值的kv对集合。
小说君也是在里面的本地化框架差不多成型之后才了解了I18N的存在。
不同语言需要不同的字体。小说君采用的是一种预烘焙字体方案。
针对不同语言,扫描语言的所有文本字符,烘焙为不同的字体资源文件。
六、其他话题
1.性能
对于生存建造游戏来说,性能是重中之重。
但是PC游戏的好处在于,我们在性能优化上花的精力可以比手游少一个数量级。
有一些需要注意的点,我们分成几块来看。
CPU
CPU的优化工作,效果跟投入时间差不多成正比。
优化工具可以用Unity自带的profiler,或者用UWA看一些稍微详细点的数据。
模拟生存建造游戏,AI和寻路逻辑一般是CPU消耗的大头。
AI实体的数量级和建筑物的数量级都会让小的代码失误扩大成明显的卡顿。
关于寻路,相比于其他类型的游戏,值得庆幸的就是可以远离导航网格,以及背后能牵扯出的上百人天。
寻路逻辑简单实现就可以,dijkstra和A*的运算上下文和流程独立,运算上下文可以提前缓存好。
Asset Store的插件只要跟逻辑有关,那么大部分都需要花时间做profiling,否则基本不可用。
内存
内存的优化分成两块。
一是用unity的profiler查mono gc alloc,减少分配的增量。
二是通过防御性设计,避免GameObject被hold在不知什么地方。
第一块实操起来问题不大。但是大部分内存泄露需要通过第二块解决。
小说君试过Unity自带的memory profiler,在处理这块类型上并不是很好用。做一次snapshot要很久,做diff拿到的结果也不符合预期。
一个级联遍历当前状态空间内所有类型的static对象的工具是有必要的。
可以检查出当前还有没有什么地方hold住了已经Destroy的GameObject。这种地方一般是造成内存泄露的第一案发现场。
渲染
小说君是泥腿子出身,这块知识也在学习中,所以能分享的有限。
独立游戏一般没有工业化的美术资源产出流程。项目里可能有大量商店买的资源。制作方式不统一,shader不统一,贴图格式不统一。
即使是独立游戏,资源的规格也应该是前期就要规划好的,否则到后期东西铺开来,很难做合批。
小说君降drawcall,基本只靠减面降阴影。
2.Unity的难点
Unity是一个典型的易上手难精通引擎和工具链。
学习几天,能整出来一个有模有样的游戏,会让人产生一种做游戏很简单的错觉。
稍微想精细化一点,就会发现各处都是坑,要投进去人天去研究。
模拟真实的物理效果,rigidbody下面挂collider数量上去以后直接让帧率降到个位数。
ugui的AutoLayout整套机制,小说君至今还没完全搞明白LayoutGroup的子节点挂上ContentSizeFitter,如果子节点的尺寸变化,如何才能不代码里显式SetDirty,自适应处理整个控件的布局。
大量潜藏的bug。小说君之前遇到了一个bug,特定时区下,比如某些装了盗版windows,以及某些国家或地区的操作系统,取当前时区会直接报错。一直等了十几个patch,unity才终于修复。
3.静态检查
对游戏静态数据,包括配置和资源做静态检查,是游戏工业化的第一步。
产出工业级产品的流水线,有很多点值得独立游戏开发者学习。
流水线上各个环节环环相扣,所以每个环节都有检查机制。
独立游戏,即使是一两个人开发全部,这些检查机制也不能少。
所有因为配置错误或资源问题导致的bug,查到立刻补上对应的检查机制。
当然,也需要有一个好的通用检查抽象。
unity已经很方便了,多写点editor工具函数,打包流程前自动跑一遍,能避免掉很多问题。
4.调试
调试不仅是代码的debug,还有整个游戏状态的debug和inspect。
我们看3A的幕后纪录片,各种编辑期和运行时的工具眼花缭乱,独立游戏开发虽然做不到这么夸张,但可以有基本的运行时状态空间inspector。
小说君实现了一个比较简单的版本:
打开面板的过程中,需要保证这些属性能实时刷新,表达式树这时候就能派上大用场。
当然,除了游戏内的,也可以定制一个editor版本的。
这里推荐一位朋友的库,很好用。
https://github.com/C41-233/UnityHelper
实践下来,Editor里面反射的实时性已经足够了,也不再需要改成表达式树实现。
还有一种调试方式,不得不提。
一个游戏内报bug的机制,以及一个被动实现上传bug包和回复的web server,很多时候能帮大忙。
需要的数据包括:
存档。
截图。
描述性质文本。
Crashes目录,如果有的话。
output_log.txt。
一些其他的描述信息,比如版本、SteamBuildId、国家等等。
5.程序生成内容
很多rougelike游戏,用了大量的程序生成内容机制,可以有效降低配置工作量。
但是,程序生成内容能降低的配置工作量极其有限,尤其是对于独立游戏开发。
小说君之前在三个地方用了比较纯粹的程序自动生成。
小人的背景生成。小说君作为矮人要塞的拥趸,会在生成小人背景之前,模拟跑几百个回合,让小人之前的背景故事形成关联。
小人在游戏里的社交谈话。小说君翻译过《Game AI Pro》,在这个地方,像素级复刻了其中一篇《An Architecture for Character-Rich Social Simulation》。结构比较精妙,但是基本原理就是文本替换。
游戏里的世界生成。这个就更没什么技术含量了,基本上网上随便拿一个minecraft的随机地图生成拿来改改就能满足需求。
虽然从结果上看,程序生成内容可以起到涌现(emerge)的效果,但是实际独立游戏开发还是比较难hold住。
拿前两个自动生成的情景来说,仍然涉及了大量游戏内容的预配置。
第三个情景就更不用说,如果不投入人力去调校生成的随机地图,基本上只是随机而没有任何可玩性,违背初衷。
感谢观看!如果您对这篇文章感兴趣,不妨点赞、在看、分享、关注。