
我想分享由两个开发人员和一个艺术家进行的简单手机游戏的开发过程。 本文主要是对技术实现的描述。
注意,很多文字!
尽管我希望读者可以从中学习到一些有用的东西,但本文不是指导性或课程。 为熟悉Unity并具有一定编程经验的开发人员设计。
内容:
主意游戏玩法情节发展历程核心- 电气元件
- 解算器
- ElementsProvider
- 电路生成器
游戏类- 开发方法和DI
- 构型
- 电气元件
- 游戏管理
- 水平加载
- 过场动画
- 额外的游戏玩法
- 营利
- 使用者介面
- 分析工具
- 相机位置和图表
- 配色方案
编辑器扩展
- 发电机组
- 解算器
有用的- 断言帮助
- SceneObjectsHelper
- 协程启动器
- 小发明
测试中发展总结主意
目录内容有一个想法是在短时间内制作一个简单的手机游戏。
条款:
- 易于实现的游戏
- 最低艺术要求
- 开发时间短(几个月)
- 轻松自动化内容创建(关卡,位置,游戏元素)
- 如果游戏包含有限数量的关卡,则可以快速创建关卡
为了决定,但是实际上是做什么的? 毕竟,提出游戏的想法,而不是游戏的想法。 决定从App Store寻求灵感。
在以上项目中添加:
- 该游戏应在玩家中具有一定的知名度(下载次数+评分)
- 应用商店不应挤满类似游戏
发现具有基于逻辑门的游戏玩法的游戏。 没有大量类似的游戏,该游戏具有许多下载量和正面评分。 但是,经过尝试,您的游戏中仍存在一些缺点。
游戏的玩法是,关卡是具有许多输入和输出的数字电路。 播放器必须选择输入的组合,以使输出为逻辑1。听起来并不困难。 该游戏还具有自动生成的关卡,这表明可以自动创建关卡,尽管听起来并不简单。 这个游戏也很适合学习,我非常喜欢。
优点:
- 游戏玩法的技术简化
- 看起来很容易通过自动测试进行测试
- 自动生成关卡的能力
缺点:
现在探索启发游戏的缺陷。
- 无法适应自定义的宽高比,例如18:9
- 没有办法跳过困难的水平或获得提示
- 在评论中,关于少数几个级别的投诉
- 评论抱怨缺乏各种要素
我们进行游戏的计划:
- 我们使用标准逻辑门(AND,NAND,OR,NOR,XOR,XNOR,NOR,NOT)
- 门是用图片而不是文字显示的,这很容易区分。 由于元素具有标准的ANSI符号,因此我们使用它们。
- 我们丢弃了将一个输入连接到一个输出的开关。 由于它需要您单击自己,因此有点不适合实际的数字元素。 是的,很难想象芯片中的拨动开关。
- 添加编码器和解码器的元素。
- 我们引入一种模式,玩家必须在该模式中选择电路输入端具有固定值的单元中的所需元素。
- 我们为玩家提供帮助:提示+跳过级别。
- 添加一些情节会很好。
游戏玩法
目录内容模式1:播放器接收电路并有权更改输入上的值。
模式2:玩家收到一个电路,在其中他可以更改元素,但不能更改输入的值。
游戏将采用预先准备的关卡形式。 完成关卡后,玩家必须得到一些结果,这将根据传球的结果以传统的三颗星的形式完成。
绩效指标可以是:
动作数量:与游戏元素的每次互动都会增加计数器。
结果状态与原始状态的差异数。 不考虑玩家必须完成多少次尝试。 不幸的是,它不适合第二种制度。
添加具有随机级别生成的相同模式会很好。 但是现在,将其推迟。
情节
目录内容在考虑游戏玩法并开始开发时,出现了各种想法来改进游戏。 并且出现了一个足够有趣的想法-添加情节。
关于设计电路的工程师。 还不错,但是还不完整,也许值得根据玩家的表现来展示芯片的制造? 不知何故,没有一个简单易懂的结果。
这个主意! 工程师使用其逻辑电路开发了一款出色的机器人。 机器人是一件相当容易理解的事情,非常适合游戏玩法。
还记得第一段“对艺术的最低要求”吗? 某些情节中的过场动画不适合。 然后,一位熟悉的艺术家来营救,他同意帮助我们。
现在,让我们决定过场动画的格式和集成方式。
该情节必须显示为过场动画,不计分或文字说明,以消除本地化问题,简化其理解以及许多在移动设备上播放而没有声音的情况。 游戏是数字电路中非常真实的元素,也就是说,很可能将它与现实联系起来。
过场动画和关卡应该是分开的场景。 在确定级别之前,将加载特定场景。
好了,任务已经确定,有资源可以完成,工作已经开始沸腾。
发展历程
目录内容我立即决定在平台上,这是Unity。 是的,有点矫kill过正,但我仍然认识她。
在开发过程中,代码会立即与测试一起编写,甚至在编写之后。 但是对于整体叙述,测试放在下面的单独部分中。 当前部分将与测试分开描述开发过程。
核心
目录内容游戏的核心看起来非常简单,并且与引擎无关,因此我们从C#代码的形式开始设计。 看来您可以选择一个单独的核心核心逻辑。 将其带到一个单独的项目。
Unity使用C#解决方案,对于常规的.Net开发人员而言,内部项目有些不寻常,.sln和.csproj文件是由Unity本身生成的,这些文件中的更改不接受Unity方面的考虑。 他将简单地覆盖它们并删除所有更改。 要创建一个新项目,必须使用
程序集定义文件。


现在,Unity会生成一个具有适当名称的项目。 .asmdef文件所在的文件夹中的所有内容都将与此项目和程序集相关。
电气元件
目录内容任务是在代码中描述逻辑元素之间的相互作用。
- 一个元素可以有多个输入和多个输出。
- 元素的输入必须连接到另一个元素的输出
- 元素本身必须包含自己的逻辑。
让我们开始吧。
- 该元素包含自己的操作逻辑并链接到其输入。 当从元素请求值时,它从输入中获取值,对其应用逻辑,然后返回结果。 可能有多个输出,因此请求特定输出的值,默认值为0。
- 要在输入处获取值,将有一个输入连接器 p,它存储到另一个连接器-输出连接器。
- 输出连接器引用特定元素,并存储到其元素的链接,当请求值时,它从该元素请求它。

箭头指示数据的方向,即元素在相反方向上的依赖性。
定义连接器的接口。 您可以从中获得价值。
public interface IConnector { bool Value { get; } }
只是如何将其连接到另一个连接器?
定义更多接口。
public interface IInputConnector : IConnector { IOutputConnector ConnectedOtherConnector { get; set; } }
IInputConnector是一个输入连接器,它具有到另一个连接器的链接。
public interface IOutputConnector : IConnector { IElectricalElement Element { set; get; } }
输出连接器引用其元素,它将请求一个值。
public interface IElectricalElement { bool GetValue(byte number = 0); }
电气元件必须包含在特定输出上返回值的方法,数字是输出的编号。
我将其称为IElectricalElement,尽管它仅传输逻辑电压电平,但另一方面,它可以是根本不添加逻辑的元素,只是像导体一样传递一个值。现在让我们继续执行
public class InputConnector : IInputConnector { public IOutputConnector ConnectedOtherConnector { get; set; } public bool Value { get { return ConnectedOtherConnector?.Value ?? false; } } }
传入连接器可能未连接,在这种情况下它将返回false。
public class OutputConnector : IOutputConnector { private readonly byte number; public OutputConnector(byte number = 0) { this.number = number; } public IElectricalElement Element { get; set; } public bool Value => Element.GetValue(number); } }
输出应具有指向其元素的链接以及与元素有关的编号。
此外,他使用该数字从元素请求一个值。
public abstract class ElectricalElementBase { public IInputConnector[] Input { get; set; } }
所有元素的基类仅包含输入数组。
元素的示例实现:
public class And : ElectricalElementBase, IElectricalElement { public bool GetValue(byte number = 0) { bool outputValue = false; if (Input?.Length > 0) { outputValue = Input[0].Value; foreach (var item in Input) { outputValue &= item.Value; } } return outputValue; } }
该实现完全基于逻辑操作而没有硬性事实表。 也许不像该表那样明确,但是它将很灵活,可以在任意数量的输入上使用。
所有逻辑门都有一个输出,因此输出上的值将不取决于输入数字。
反转元素如下所示:
public class Nand : And, IElectricalElement { public new bool GetValue(byte number = 0) { return !base.GetValue(number); } }
值得注意的是,这里的GetValue方法已被覆盖,实际上并未被覆盖。 这是基于以下逻辑完成的:如果Nand保存到And,他将继续表现为And。 也可以应用合成,但这将需要额外的代码,这没有多大意义。
除常规阀外,还创建了以下元素:
来源-0或1的恒定值来源。
导体-相同的导体或导体,但用途略有不同,请参见生成。
AlwaysFalse-始终返回第二模式所需的0。
解算器
目录内容接下来,一个类对于自动查找在电路输出端给出1的组合很有用。
public interface ISolver { ICollection<bool[]> GetSolutions(IElectricalElement root, params Source[] sources); } public class Solver : ISolver { public ICollection<bool[]> GetSolutions(IElectricalElement root, params Source[] sources) {
解决方案是蛮力的。 为此,确定最大数量,该最大数量可以由一组比特来表示,其数量等于源的数量。 也就是说,4个源= 4位=最大数量15。我们对从0到15的所有数字进行排序。
ElementsProvider
目录内容为了方便生成,我决定为每个元素定义一个数字,为此,我使用IElementsProvider接口创建了ElementsProvider类。
public interface IElementsProvider { IList<Func<IElectricalElement>> Gates { get; } IList<Func<IElectricalElement>> Conductors { get; } IList<ElectricalElementType> GateTypes { get; } IList<ElectricalElementType> ConductorTypes { get; } } public class ElementsProvider : IElementsProvider { public IList<Func<IElectricalElement>> Gates { get; } = new List<Func<IElectricalElement>> { () => new And(), () => new Nand(), () => new Or(), () => new Nor(), () => new Xor(), () => new Xnor() }; public IList<Func<IElectricalElement>> Conductors { get; } = new List<Func<IElectricalElement>> { () => new Conductor(), () => new Not() }; public IList<ElectricalElementType> GateTypes { get; } = new List<ElectricalElementType> { ElectricalElementType.And, ElectricalElementType.Nand, ElectricalElementType.Or, ElectricalElementType.Nor, ElectricalElementType.Xor, ElectricalElementType.Xnor }; public IList<ElectricalElementType> ConductorTypes { get; } = new List<ElectricalElementType> { ElectricalElementType.Conductor, ElectricalElementType.Not }; }
前两个列表就像工厂一样,以指定的编号提供商品。 由于Unity的功能,最后两个列表是必须使用的拐杖。 关于它进一步。
电路生成器
目录内容现在,开发中最困难的部分是电路生成。
任务是生成一个方案列表,然后您可以在编辑器中从中选择所需的方案。 仅对于简单的阀门才需要生成。
设置方案的某些参数,这些参数是:层数(元素的水平线)和层中元素的最大数量。 还必须确定需要从哪些门生成电路。
我的方法是将任务分为两部分-结构生成和选项选择。
结构生成器确定逻辑元素的位置和连接。
变量生成器选择位置中元素的有效组合。
结构生成器
该结构由逻辑元件层和导体/反相器层组成。 整个结构不包含真实元素,而是包含它们的容器。
容器是从IElectricalElement继承的类,该类内部包含有效元素的列表,并且可以在它们之间进行切换。 每个项目在列表中都有其自己的编号。
ElectricalElementContainer : ElectricalElementBase, IElectricalElement
容器可以将“自身”设置为列表中的元素之一。 在初始化期间,必须给它提供将创建项目的代表列表。 在内部,它调用每个委托并获取项目。 然后,您可以设置此元素的特定类型,这会将内部元素连接到与容器中相同的输入,并且容器的输出将从该元素的输出中获取。

设置元素列表的方法:
public void SetElements(IList<Func<IElectricalElement>> elements) { Elements = new List<IElectricalElement>(elements.Count); foreach (var item in elements) { Elements.Add(item()); } }
接下来,您可以通过以下方式设置类型:
public void SetType(int number) { if (isInitialized == false) { throw new InvalidOperationException(UnitializedElementsExceptionMessage); } SelectedType = number; RealElement = Elements[number]; ((ElectricalElementBase) RealElement).Input = Input; }
之后,它将作为指定项目。
为该电路创建了以下结构:
public class CircuitStructure : ICloneable { public IDictionary<int, ElectricalElementContainer[]> Gates; public IDictionary<int, ElectricalElementContainer[]> Conductors; public Source[] Sources; public And FinalDevice; }
此处的词典将层号存储在键中,并存储该层的一组容器。 接下来是一系列源和一个将所有内容都连接到的FinalDevice。
因此,结构生成器创建容器并将它们彼此连接。 这都是从下到上的分层创建的。 底部是最宽的(大多数元素)。 上面的层包含的元素要少两倍,依此类推,直到达到最小值。 顶层所有元素的输出都连接到最终设备。
逻辑元素层包含用于门的容器。 在导体层中,存在具有一个输入和输出的元素。 元素可以是导体或NO元素。 导体将输入的内容传递到输出,而NO元素在输出处返回取反的值。
第一个创建源数组。 生成从下往上进行,首先生成导体层,然后生成逻辑层,然后再从逻辑层输出导体。

但是这样的方案很无聊! 我们想进一步简化生活,并决定使生成的结构更有趣(复杂),并决定添加结构修改,并通过许多层进行分支或连接。
好吧,说“简化”-这意味着使您的生活变得更加复杂。
产生具有最大可修改性水平的电路被证明是费力的并且不是很实际的任务。 因此,我们的团队决定采取符合以下条件的措施:
这项任务的开发并不需要很多时间。
或多或少地产生了修饰结构。
导体之间没有交叉点。
经过漫长而艰辛的编程,该解决方案于下午4点编写。
让我们看一下代码和̶̶̶̶̶̶̶̶̶̶。
在这里遇到OverflowArray类。 由于历史原因,它是在基本结构世代之后添加的,并且与变体世代有更多关系,因此位于下面。 友情链接 public IEnumerable<CircuitStructure> GenerateStructure(int lines, int maxElementsInLine, StructureModification modification) { var baseStructure = GenerateStructure(lines, maxElementsInLine); for (int i = 0; i < lines; i++) { int maxValue = 1; int branchingSign = 1; if (modification == StructureModification.All) { maxValue = 2; branchingSign = 2; } int lengthOverflowArray = baseStructure.Gates[(i * 2) + 1].Length; var elementArray = new OverflowArray(lengthOverflowArray, maxValue); double numberOfOption = Math.Pow(2, lengthOverflowArray); for (int k = 1; k < numberOfOption - 1; k++) { elementArray.Increase(); if (modification == StructureModification.Branching || modification == StructureModification.All) { if (!CheckOverflowArrayForAllConnection(elementArray, branchingSign, lengthOverflowArray)) { continue; } }
查看此代码后,我想了解其中发生了什么。
不用担心! 简短的解释,没有详细信息,请您尽快处理。
我们要做的第一件事是创建一个普通的(基本)结构。
var baseStructure = GenerateStructure(lines, maxElementsInLine);
然后,作为简单检查的结果,我们将分支符号(branchingSign)设置为适当的值,为什么这样做是必要的? 进一步将是清楚的。
int maxValue = 1; int branchingSign = 1; if (modification == StructureModification.All) { maxValue = 2; branchingSign = 2; }
现在,我们确定OverflowArray的长度并对其进行初始化。
int lengthOverflowArray = baseStructure.Gates[(i * 2) + 1].Length; var elementArray = new OverflowArray(lengthOverflowArray, maxValue);
为了使我们能够继续对该结构进行操作,我们需要找出OverflowArray的可能变体的数量。 为此,在下一行应用了一个公式。
int lengthOverflowArray = baseStructure.Gates[(i * 2) + 1].Length;
接下来是一个嵌套循环,其中发生了所有“魔术”,并且有了所有这些序言,从一开始,我们就增加了数组的值。
elementArray.Increase();
之后,我们会看到一个验证检查,因此我们将继续进行下一个迭代或下一个迭代。
if (modification == StructureModification.Branching || modification == StructureModification.All) { if (!CheckOverflowArrayForAllConnection(elementArray, branchingSign, lengthOverflowArray)) { continue; } }
如果数组通过了验证检查,那么我们将克隆我们的基本结构。 需要克隆,因为我们将修改结构以进行更多迭代。
最后,我们开始修改结构并清除不必要的元素。 由于结构修改,它们变得不必要。
ModifyStructure(structure, elementArray, key, modification); ClearStructure(structure);
我没有更详细地分析深度在“某处”执行的许多小功能的要点。
变体发生器
应该包含在其中的结构+元素称为CircuitVariant。
public struct CircuitVariant { public CircuitStructure Structure; public IDictionary<int, int[]> Gates; public IDictionary<int, int[]> Conductors; public IList<bool[]> Solutions; }
第一个字段是指向结构的链接。 第二两个字典,其中的键是层的编号,而值是一个数组,其中包含元素在结构中位置的数量。
我们继续选择组合。 我们可以有一定数量的有效逻辑元素和导体。 总共可以有6个逻辑元件和2个导体。
您可以想象一个以6为底的数字系统,并在每个类别中获取与元素相对应的数字。 因此,通过增加此十六进制数,您可以遍历元素的所有组合。
即,三位数的十六进制数将是3个元素。 仅值得考虑的是,可以传输的元素数不是6而是4。
为了释放这样一个数字,我确定了结构
public struct ClampedInt { public int Value { get => value; set => this.value = Mathf.Clamp(value, 0, MaxValue); } public readonly int MaxValue; private int value; public ClampedInt(int maxValue) { MaxValue = maxValue; value = 0; } public bool TryIncrease() { if (Value + 1 <= MaxValue) { Value++; return false; }
接下来是一个具有奇怪名称
OverflowArray的类 。 其本质是,它存储
ClampedInt数组并在
低位发生溢出的情况下增加高位,依此类推,直到它在所有单元中都达到最大值。
根据每个ClampedInt,设置相应的ElectricalElementContainer的值。 因此,可以对所有可能的组合进行分类。 值得注意的是,如果要使用元素生成方案(例如,And(0)和Xor(4)),则无需对所有选项进行排序,包括元素1,2,3。 为此,在生成过程中,元素将获得其本地编号(例如,And = 0,Xor = 1),然后将它们转换回全局编号。
因此,您可以遍历所有元素中的所有可能组合。
设置容器中的值之后,使用
Solver检查电路中是否有解决方案。 如果电路通过判决,则返回。
生成电路后,将检查解决方案的数量。 它不应超过限制,并且不应具有完全由0或1组成的决策。
很多代码 public interface IVariantsGenerator { IEnumerable<CircuitVariant> Generate(IEnumerable<CircuitStructure> structures, ICollection<int> availableGates, bool useNot, int maxSolutions = int.MaxValue); } public class VariantsGenerator : IVariantsGenerator { private readonly ISolver solver; private readonly IElementsProvider elementsProvider; public VariantsGenerator(ISolver solver, IElementsProvider elementsProvider) { this.solver = solver; this.elementsProvider = elementsProvider; } public IEnumerable<CircuitVariant> Generate(IEnumerable<CircuitStructure> structures, ICollection<int> availableGates, bool useNot, int maxSolutions = int.MaxValue) { bool manyGates = availableGates.Count > 1; var availableLeToGeneralNumber = GetDictionaryFromAllowedElements(elementsProvider.Gates, availableGates); var gatesList = GetElementsList(availableLeToGeneralNumber, elementsProvider.Gates); var availableConductorToGeneralNumber = useNot ? GetDictionaryFromAllowedElements(elementsProvider.Conductors, new[] {0, 1}) : GetDictionaryFromAllowedElements(elementsProvider.Conductors, new[] {0}); var conductorsList = GetElementsList(availableConductorToGeneralNumber, elementsProvider.Conductors); foreach (var structure in structures) { InitializeCircuitStructure(structure, gatesList, conductorsList); var gates = GetListFromLayersDictionary(structure.Gates); var conductors = GetListFromLayersDictionary(structure.Conductors); var gatesArray = new OverflowArray(gates.Count, availableGates.Count - 1); var conductorsArray = new OverflowArray(conductors.Count, useNot ? 1 : 0); do { if (useNot && conductorsArray.EqualInts) { continue; } SetContainerValuesAccordingToArray(conductors, conductorsArray); do { if (manyGates && gatesArray.Length > 1 && gatesArray.EqualInts) { continue; } SetContainerValuesAccordingToArray(gates, gatesArray); var solutions = solver.GetSolutions(structure.FinalDevice, structure.Sources); if (solutions.Any() && solutions.Count <= maxSolutions && !(solutions.Any(s => s.All(b => b)) || solutions.Any(s => s.All(b => !b)))) { var variant = new CircuitVariant { Conductors = GetElementsNumberFromLayers(structure.Conductors, availableConductorToGeneralNumber), Gates = GetElementsNumberFromLayers(structure.Gates, availableLeToGeneralNumber), Solutions = solutions, Structure = structure }; yield return variant; } } while (!gatesArray.Increase()); } while (useNot && !conductorsArray.Increase()); } } private static void InitializeCircuitStructure(CircuitStructure structure, IList<Func<IElectricalElement>> gates, IList<Func<IElectricalElement>> conductors) { var lElements = GetListFromLayersDictionary(structure.Gates); foreach (var item in lElements) { item.SetElements(gates); } var cElements = GetListFromLayersDictionary(structure.Conductors); foreach (var item in cElements) { item.SetElements(conductors); } } private static IList<Func<IElectricalElement>> GetElementsList(IDictionary<int, int> availableToGeneralGate, IReadOnlyList<Func<IElectricalElement>> elements) { var list = new List<Func<IElectricalElement>>(); foreach (var item in availableToGeneralGate) { list.Add(elements[item.Value]); } return list; } private static IDictionary<int, int> GetDictionaryFromAllowedElements(IReadOnlyCollection<Func<IElectricalElement>> allElements, IEnumerable<int> availableElements) { var enabledDic = new Dictionary<int, bool>(allElements.Count); for (int i = 0; i < allElements.Count; i++) { enabledDic.Add(i, false); } foreach (int item in availableElements) { enabledDic[item] = true; } var availableToGeneralNumber = new Dictionary<int, int>(); int index = 0; foreach (var item in enabledDic) { if (item.Value) { availableToGeneralNumber.Add(index, item.Key); index++; } } return availableToGeneralNumber; } private static void SetContainerValuesAccordingToArray(IReadOnlyList<ElectricalElementContainer> containers, IOverflowArray overflowArray) { for (int i = 0; i < containers.Count; i++) { containers[i].SetType(overflowArray[i].Value); } } private static IReadOnlyList<ElectricalElementContainer> GetListFromLayersDictionary(IDictionary<int, ElectricalElementContainer[]> layers) { var elements = new List<ElectricalElementContainer>(); foreach (var layer in layers) { elements.AddRange(layer.Value); } return elements; } private static IDictionary<int, int[]> GetElementsNumberFromLayers(IDictionary<int, ElectricalElementContainer[]> layers, IDictionary<int, int> elementIdToGlobal = null) { var dic = new Dictionary<int, int[]>(layers.Count); bool convert = elementIdToGlobal != null; foreach (var layer in layers) { var values = new int[layer.Value.Length]; for (int i = 0; i < layer.Value.Length; i++) { if (!convert) { values[i] = layer.Value[i].SelectedType; } else { values[i] = elementIdToGlobal[layer.Value[i].SelectedType]; } } dic.Add(layer.Key, values); } return dic; } }
每个生成器都使用yield语句返回一个变体。 因此,使用StructureGenerator和VariantsGenerator的CircuitGenerator生成IEnumerable(具有收益率的方法在将来很有帮助,请参见下文)。
根据选项生成器接收结构列表这一事实。 您可以为每个结构独立生成选项。 这可以并行化,但是添加AsParallel不起作用(可能是产量干扰)。 手动并行化将花费很长时间,因为我们放弃了此选项。
实际上,我尝试进行并行生成,但确实可行,但是存在一些困难,因为它没有进入存储库。游戏类
开发方法和DI
目录内容该项目是在
依赖注入 (DI)下构建的。 这意味着类可以简单地要求自己对应于该接口的某种对象,而无需参与创建该对象。 有什么好处:
- 依赖对象的创建和初始化位置定义在一个位置,并且与依赖类的逻辑分开,从而消除了代码重复。
- 无需挖掘整个依赖关系树并实例化所有依赖关系。
- 使您可以轻松更改在许多地方使用的接口的实现。
Zenject作为项目中的DI容器使用。
Zenject有几个上下文,我仅使用其中两个:
- 项目上下文-整个应用程序中依赖项的注册。
- 场景上下文:仅在特定场景中存在的类的注册,其寿命受场景寿命的限制。
- 静态上下文是所有事物的通用上下文,其独特之处在于它存在于编辑器中。 我在编辑器中使用注入
类注册存储在
Installer中 。 我将
ScriptableObjectInstaller用于项目上下文,将
MonoInstaller用于场景上下文。
我在AsSingle中注册的大多数类,因为它们不包含状态,所以很可能只是方法的容器。我将AsTransient用于存在内部状态不应该为其他类所共有的类。之后,您需要以某种方式创建将代表这些元素的MonoBehaviour类。我还根据Core项目将与Unity相关的类分配给一个单独的项目。
对于MonoBehaviour类,我更喜欢创建自己的接口。除了接口的标准优点之外,这还允许您隐藏大量的MonoBehaviour成员。为了方便起见,DI通常创建一个简单的类来运行所有逻辑,并为其提供MonoBehaviour包装器。例如,该类具有Start和Update方法,我在该类中创建此类方法,然后在MonoBehaviour类中添加一个依赖项字段,并在相应的方法中称为Start和Update。这为构造函数提供了“正确”的注入,使主类与DI容器分离,并且可以轻松进行测试。构型
内容通过配置,我的意思是整个应用程序共有的数据。就我而言,这些是预制件,广告和购买的标识符,标签,场景名称等。为此,我使用ScriptableObjects:- 对于每个数据组,将分配一个ScriptableObject后代类。
- 它创建必要的可序列化字段
- 添加了来自这些字段的读取属性。
- 具有以上字段的界面将突出显示
- 类注册到DI容器中的接口
- 获利
public interface ITags { string FixedColor { get; } string BackgroundColor { get; } string ForegroundColor { get; } string AccentedColor { get; } } [CreateAssetMenu(fileName = nameof(Tags), menuName = "Configuration/" + nameof(Tags))] public class Tags : ScriptableObject, ITags { [SerializeField] private string fixedColor; [SerializeField] private string backgroundColor; [SerializeField] private string foregroundColor; [SerializeField] private string accentedColor; public string FixedColor => fixedColor; public string BackgroundColor => backgroundColor; public string ForegroundColor => foregroundColor; public string AccentedColor => accentedColor; private void OnEnable() { fixedColor.AssertNotEmpty(nameof(fixedColor)); backgroundColor.AssertNotEmpty(nameof(backgroundColor)); foregroundColor.AssertNotEmpty(nameof(foregroundColor)); accentedColor.AssertNotEmpty(nameof(accentedColor)); } }
对于配置,使用单独的安装程序(代码缩写): CreateAssetMenu(fileName = nameof(ConfigurationInstaller), menuName = "Installers/" + nameof(ConfigurationInstaller))] public class ConfigurationInstaller : ScriptableObjectInstaller<ConfigurationInstaller> { [SerializeField] private EditorElementsPrefabs editorElementsPrefabs; [SerializeField] private LevelCompletionSteps levelCompletionSteps; [SerializeField] private CommonValues commonValues; [SerializeField] private AdsConfiguration adsConfiguration; [SerializeField] private CutscenesConfiguration cutscenesConfiguration; [SerializeField] private Colors colors; [SerializeField] private Tags tags; public override void InstallBindings() { Container.Bind<IEditorElementsPrefabs>().FromInstance(editorElementsPrefabs).AsSingle(); Container.Bind<ILevelCompletionSteps>().FromInstance(levelCompletionSteps).AsSingle(); Container.Bind<ICommonValues>().FromInstance(commonValues).AsSingle(); Container.Bind<IAdsConfiguration>().FromInstance(adsConfiguration).AsSingle(); Container.Bind<ICutscenesConfiguration>().FromInstance(cutscenesConfiguration).AsSingle(); Container.Bind<IColors>().FromInstance(colors).AsSingle(); Container.Bind<ITags>().FromInstance(tags).AsSingle(); } private void OnEnable() { editorElementsPrefabs.AssertNotNull(); levelCompletionSteps.AssertNotNull(); commonValues.AssertNotNull(); adsConfiguration.AssertNotNull(); cutscenesConfiguration.AssertNotNull(); colors.AssertNOTNull(); tags.AssertNotNull(); } }
电气元件
目录现在您需要以某种方式想象一下电气元件 public interface IElectricalElementMb { GameObject GameObject { get; } string Name { get; set; } IElectricalElement Element { get; set; } IOutputConnectorMb[] OutputConnectorsMb { get; } IInputConnectorMb[] InputConnectorsMb { get; } Transform Transform { get; } void SetInputConnectorsMb(InputConnectorMb[] inputConnectorsMb); void SetOutputConnectorsMb(OutputConnectorMb[] outputConnectorsMb); } [DisallowMultipleComponent] public class ElectricalElementMb : MonoBehaviour, IElectricalElementMb { [SerializeField] private OutputConnectorMb[] outputConnectorsMb; [SerializeField] private InputConnectorMb[] inputConnectorsMb; public Transform Transform => transform; public GameObject GameObject => gameObject; public string Name { get => name; set => name = value; } public virtual IElectricalElement Element { get; set; } public IOutputConnectorMb[] OutputConnectorsMb => outputConnectorsMb; public IInputConnectorMb[] InputConnectorsMb => inputConnectorsMb; }
public interface IInputConnectorMb : IConnectorMb { IOutputConnectorMb OutputConnectorMb { get; set; } IInputConnector InputConnector { get; } }
public class InputConnectorMb : MonoBehaviour, IInputConnectorMb { [SerializeField] private OutputConnectorMb outputConnectorMb; public Transform Transform => transform; public IOutputConnectorMb OutputConnectorMb { get => outputConnectorMb; set => outputConnectorMb = (OutputConnectorMb) value; } public IInputConnector InputConnector { get; } = new InputConnector(); #if UNITY_EDITOR private void OnDrawGizmos() { if (outputConnectorMb != null) { Handles.DrawLine(transform.position, outputConnectorMb.Transform.position); } } #endif }
我们有一行public IElectricalElement Element {get; 设置 }
只有这里是如何安装此项目?一个不错的选择是使通用:公共类ElectricalElementMb:MonoBehaviour,IElectricalElementMb其中T:IElectricalElement但是,要注意的是Unity在MonoBehavior类中不支持通用。而且,Unity不支持属性和接口的序列化。不过,在运行时,很有可能传入IElectricalElement Element {get; 设置 }
期望值。我做了枚举ElectricalElementType,其中将包含所有必需的类型。Enum已由Unity很好地序列化,并在检查器中很好地显示为下拉列表。定义了两种类型的元素:一种是在运行时创建的,另一种是在编辑器中创建的并且可以保存的。因此,存在IElectricalElementMb和IElectricalElementMbEditor,它们另外包含类型为ElectricalElementType的字段。第二种类型也需要在运行时初始化。为此,有一类在开始时将绕过所有元素并根据枚举字段中的类型对其进行初始化。如下: private static readonly Dictionary<ElectricalElementType, Func<IElectricalElement>> ElementByType = new Dictionary<ElectricalElementType, Func<IElectricalElement>> { {ElectricalElementType.And, () => new And()}, {ElectricalElementType.Or, () => new Or()}, {ElectricalElementType.Xor, () => new Xor()}, {ElectricalElementType.Nand, () => new Nand()}, {ElectricalElementType.Nor, () => new Nor()}, {ElectricalElementType.NOT, () => new NOT()}, {ElectricalElementType.Xnor, () => new Xnor()}, {ElectricalElementType.Source, () => new Source()}, {ElectricalElementType.Conductor, () => new Conductor()}, {ElectricalElementType.Placeholder, () => new AlwaysFalse()}, {ElectricalElementType.Encoder, () => new Encoder()}, {ElectricalElementType.Decoder, () => new Decoder()} };
游戏管理
内容接下来,出现一个问题,将游戏本身的逻辑放在哪里(检查段落的条件,计算段落的读数并帮助玩家)?..还有一些关于保存和加载进度,设置等内容的逻辑的位置的问题。为此,我区分了负责某类任务的某些经理类。DataManager负责根据传递用户和游戏设置的结果存储数据。它由AsSingle在项目上下文中注册。这意味着他是整个应用程序中的一员。在应用程序运行时,数据直接存储在DataManager内部的内存中。他使用IFileStoreService,它负责加载和保存数据以及IFileSerializer负责以现成的格式序列化文件以进行保存。LevelGameManager是单个场景中的游戏管理器。我得到了一点GodObject,因为他仍然负责UI,即打开和关闭菜单以及对按钮的反应。但是,考虑到项目的规模以及无需扩展项目,这是可以接受的,因此操作序列更加简单,清晰。有两种选择。这就是分别为模式1和2 调用LevelGameManager1和LevelGameManager2的内容。在第一种情况下,逻辑基于对一个源中的值发生更改的反应,并检查电路输出端的值。在第二种情况下,逻辑响应元素更改事件,并检查电路输出端的值。当前有一些关卡信息,例如关卡号和玩家帮助。有关当前级别的数据存储在CurrentLevelData中。一个关卡号存储在其中-一个布尔属性,带有检查帮助,用于评估游戏的offer标志和用于帮助玩家的数据。 public interface ICurrentLevelData { int LevelNumber { get; } bool HelpExist { get; } bool ProposeRate { get; } } public interface ICurrentLevelDataMode1 : ICurrentLevelData { IEnumerable<SourcePositionValueHelp> PartialHelp { get; } } public interface ICurrentLevelDataMode2 : ICurrentLevelData { IEnumerable<PlaceTypeHelp> PartialHelp { get; } }
第一种模式的帮助是它们上的源编号和值。在第二种模式下,这是需要在单元格中设置的元素的类型。集合包含存储必须在指定位置设置的位置和值的结构。字典会更漂亮,但是Unity无法序列化字典。不同模式的场景之间的差异在于,在场景的上下文中,设置了另一个LevelGameManager和另一个ICurrentLevelData。总的来说,我有一种事件驱动的元素沟通方法。一方面,这是合乎逻辑且方便的。另一方面,有必要在不取消订阅的情况下解决问题。不过,这个项目没有问题,规模也不算太大。通常,订阅会在场景开始时针对您需要的所有内容进行。在运行时几乎不会创建任何内容,因此不会造成混乱。水平加载
内容游戏中的每个关卡都由一个Unity场景表示,它必须包含一个关卡前缀和一个数字,例如“ Level23”。前缀包含在配置中。级别的加载按名称进行,该名称由前缀组成。因此,LevelsManager类可以按数字加载级别。过场动画
过场动画的内容是普通的统一场景,在标题中带有数字,与级别类似。动画本身是使用时间轴实现的。不幸的是,我既没有动画技能,也没有与时间轴合作的能力,所以“不要射击钢琴家,他会尽其所能。”
事实证明,一个逻辑过场动画应该由具有不同对象的不同场景组成。事实证明,这有点迟了,但决定很简单:将过场动画的一部分放在舞台上的不同位置,然后立即移动摄像机。
额外的游戏玩法
内容游戏是通过每个级别的动作数和线索的使用来评估的。动作越少越好。使用工具提示可将最大评级降低为2星,而将级别降低为1星。为了评估通过,存储通过的步骤数。它由两个值组成:最小值(3星)和最大值(1星)。通过级别的步骤数不存储在场景文件本身中,而是存储在配置文件中,因为您需要显示通过的级别的星数。这稍微增加了创建关卡的过程。看到版本控制系统中的更改特别有趣:
尝试猜测它属于哪个级别。当然,可以存储字典,但是首先它不会被Unity序列化,而第二个则必须手动设置数字。如果玩家难以完成关卡,他可以得到提示-一些输入的正确值,或第二模式下的正确元素。尽管它可以自动执行,但也可以手动完成。如果玩家的帮助没有帮助,他可以完全跳过该级别。万一缺少关卡,玩家将获得1星。通过提示的关卡的用户无法在一段时间内重新运行该关卡,因此很难以新鲜的内存重新运行该关卡,就像没有提示一样。营利
内容游戏中有两种获利类型:展示广告和为钱而禁用广告。广告显示包括在各个级别之间显示广告,以及查看奖励广告以跳过某个级别。如果玩家愿意为禁用广告付费,那么他可以这样做。在这种情况下,将不会显示级别之间以及跳过级别时的广告。对于广告,创建了一个名为AdsService的类,并带有一个接口 public interface IAdsService { bool AdsDisabled { get; } void LoadBetweenLevelAd(); bool ShowBetweenLevelAd(int level, bool force = false); void LoadHelpAd(Action onLoaded = null); void ShowHelpAd(Action onRewarded, Action onClosed); bool HelpAdLoaded { get; } }
这里的HelpAd是用于跳过级别的奖励广告。最初,我们将帮助称为部分和完全帮助。部分是提示,完全是跳过级别。此类在首次启动游戏后便包含了按时间显示广告的频率的限制。该实现使用Google Mobile Ads Unity插件。通过奖励性广告,我踩到了耙子-事实证明,可以在另一个线程中调用忠诚的代表,原因还不是很清楚。因此,最好是那些代表不要在与Unity相关的代码中调用任何东西。如果购买了禁用广告的广告,则不会显示该广告,并且代表将立即成功执行该广告的显示。有一个购物界面 public interface IPurchaseService { bool IsAdsDisablePurchased { get; } event Action DisableAdsPurchased; void BuyDisableAds(); void RemoveDisableAd(); }
在实现中使用了Unity IAP ,这是购买广告断开连接的技巧。Google Play似乎没有提供有关玩家购买的信息。只需确认一下她就通过了。但是,如果您在购买后未完成但正在等待中放置产品状态,这将使您可以检查hasReceipt产品的属性。如果为真,则购买已完成。尽管这当然会使这种方法感到困惑,但我怀疑这可能并不十分顺利。测试时需要RemoveDisableAd方法,它可以消除购买的广告中断。使用者介面
内容所有界面元素均按照面向事件的方法进行工作。接口元素本身通常不包含除Unity可以使用的公共方法调用的事件以外的逻辑。尽管它也恰好执行一些仅与接口有关的职责。 public abstract class UiElementBase : MonoBehaviour, IUiElement { public event Action ShowClick; public event Action HideCLick; public void Show() { gameObject.SetActive(true); ShowClick?.Invoke(); } public void Hide() { gameObject.SetActive(false); HideCLick?.Invoke(); } } public class PauseMenu : UiElementEscapeClose, IPauseMenu { [SerializeField] private Text levelNumberText; [SerializeField] private LocalizedText finishedText; [SerializeField] private GameObject restartButton; private int levelNumber; public event Action GoToMainMenuClick; public event Action RestartClick; public int LevelNumber { set => levelNumberText.text = $"{finishedText.Value} {value}"; } public void DisableRestartButton() { restartButton.SetActive(false); } public void GoToMainMenu() { GoToMainMenuClick?.Invoke(); } public void Restart() { RestartClick?.Invoke(); } }
实际上,并非总是如此。最好将这些元素保留为活动视图,从中创建事件侦听器,就像控制器一样,它将触发对管理者的必要操作。分析工具
内容在阻力最小的道路上,选择了Unity分析。易于实施,尽管仅限于免费订阅-无法导出源数据。事件数也有限制-每位玩家每小时100个。对于分析,创建了包装类AnalyticsService。它具有每种事件类型的方法,接收必要的参数,并使用Unity内置的工具发送事件。从整体上来说,为每个事件创建方法当然不是最佳实践,但是在一个已知的小项目中,这比做大而复杂的事情要好。使用的所有事件均为CustomEvent。。它们是根据事件的名称以及字典参数的名称和值构建的。AnalyticsService从参数中获取所需的值,并在其中创建字典。所有事件名称和参数都放在常量中。不能采用ScriptableObject的传统方法的形式,因为这些值永远都不应更改。方法示例: public void LevelComplete(int number, int stars, int actionCount, TimeSpan timeSpent, int levelMode) { CustomEvent(LevelCompleteEventName, new Dictionary<string, object> { {LevelNumber, number}, {LevelStars, stars}, {LevelActionCount, actionCount}, {LevelTimeSpent, timeSpent}, {LevelMode, levelMode} }); }
相机位置和图表
内容任务是将FinalDevice放置在屏幕顶部,与上边框的距离相同,而Sources与底部的距离也始终与下边框相等。此外,屏幕的纵横比不同,您需要在启动水平仪之前调整摄像机的尺寸,以使其正确适合电路。为此,创建了CameraAlign类。尺寸算法:- 在舞台上找到所有必要的元素
- 根据宽高比找到最小宽度和高度
- 确定相机尺寸
- 将相机置于中央
- 将FinalDevice移到屏幕顶部
- 将源移到屏幕底部
public class CameraAlign : ICameraAlign { private readonly ISceneObjectsHelper sceneObjectsHelper; private readonly ICommonValues commonValues; public CameraAlign(ISceneObjectsHelper sceneObjectsHelper, ICommonValues commonValues) { this.sceneObjectsHelper = sceneObjectsHelper; this.commonValues = commonValues; } public void Align(Camera camera) { var elements = sceneObjectsHelper.FindObjectsOfType<IElectricalElementMb>(); var finalDevice = sceneObjectsHelper.FindObjectOfType<IFinalDevice>(); var sources = elements.OfType<ISourceMb>().ToArray(); if (finalDevice != null && sources.Length > 0) { float leftPos = elements.Min(s => s.Transform.position.x); float rightPos = elements.Max(s => s.Transform.position.x); float width = Mathf.Abs(leftPos - rightPos); var fPos = finalDevice.Transform.position; float height = Mathf.Abs(sources.First().Transform.position.y - fPos.y) * camera.aspect; float size = Mathf.Max(width * commonValues.CameraOffset, height * commonValues.CameraOffset); camera.orthographicSize = Mathf.Clamp(size, commonValues.MinCameraSize, float.MaxValue); camera.transform.position = GetCenterPoint(elements, -1); fPos = new Vector2(fPos.x, camera.ScreenToWorldPoint(new Vector2(Screen.width, Screen.height)).y - commonValues.FinalDeviceTopOffset * camera.orthographicSize); finalDevice.Transform.position = fPos; float sourceY = camera.ScreenToWorldPoint(Vector2.zero).y + commonValues.SourcesBottomOffset; foreach (var item in sources) { item.Transform.position = new Vector2(item.Transform.position.x, sourceY); } } else { Debug.Log($"{nameof(CameraAlign)}: No final device or no sources in scene"); } } private static Vector3 GetCenterPoint(ICollection<IElectricalElementMb> elements, float z) { float top = elements.Max(e => e.Transform.position.y); float bottom = elements.Min(e => e.Transform.position.y); float left = elements.Min(e => e.Transform.position.x); float right = elements.Max(e => e.Transform.position.x); float x = left + ((right - left) / 2); float y = bottom + ((top - bottom) / 2); return new Vector3(x, y, z); } }
当场景在包装类中启动时,将调用此方法。配色方案
内容由于游戏将具有非常原始的界面,因此我决定使用黑白两种配色方案进行制作。为此,创建了一个界面 public interface IColors { Color ColorAccent { get; } Color Background { get; set; } Color Foreground { get; set; } event Action ColorsChanged; }
可以直接在Unity编辑器中设置颜色;该颜色可用于测试。然后可以将它们切换为两种颜色。背景和前景颜色可以更改,在任何模式下都可以改变一种颜色。由于播放器可以设置非标准主题,因此颜色数据必须存储在设置文件中。如果设置文件不包含颜色数据,则将使用标准值填充它们。然后有几个类:CameraColorAdjustment-负责设置相机的背景色,UiColorAdjustment-设置界面元素和TextMeshColorAdjustment的颜色-设置来源上数字的颜色。UiColorAdjustment也使用标签。在编辑器中,可以用标记标记每个元素,该标记将指示应设置的颜色类型(背景,前景,AccentColor和FixedColor)。全部设置在场景开始时或通过更改配色方案来设置。结果:


编辑器扩展
目录为了简化和加快开发过程,通常需要创建正确的工具,而标准编辑器工具没有提供该工具。Unity中的传统方法是创建一个EditorWindow后代类。UiElements也有一种方法,但是它仍在开发中,因此我决定使用传统方法。如果您仅创建一个使用UnityEditor命名空间中某些内容的类,然后在游戏的其他类旁边使用该类,则该项目将不会被汇编,因为该命名空间在构建中不可用。有几种解决方案:- 为编辑器脚本选择一个单独的项目
- 将文件放在Assets / Editor文件夹中
- 将这些文件包装在#if UNITY_EDITOR中
项目使用第一种方法,有时使用#if UNITY_EDITOR,如有必要,将编辑器的一小部分添加到构建所需的类中。我在程序集中定义的编辑器中仅需要的所有类,这些类仅在编辑器中可用。她不会去玩游戏。
现在在您的编辑器扩展中添加DI会很好。为此,我使用Zenject.StaticContext。为了在编辑器中进行设置,使用了带有InitializeOnLoad属性的类,该类中有一个静态构造函数。 [InitializeOnLoad] public class EditorInstaller { static EditorInstaller() { var container = StaticContext.Container; container.Bind<IElementsProvider>().To<ElementsProvider>().AsSingle(); container.Bind<ISolver>().To<Solver>().AsSingle(); .... } }
为了在静态上下文中注册ScriptableObject类,我使用以下代码: BindFirstScriptableObject<ISceneNameConfiguration, SceneNameConfiguration>(container); private static void BindFirstScriptableObject<TInterface, TImplementation>(DiContainer container) where TImplementation : ScriptableObject, TInterface { var obj = GetFirstScriptableObject<TImplementation>(); container.Bind<TInterface>().FromInstance(obj).AsSingle(); } private static T GetFirstScriptableObject<T>() where T : ScriptableObject { var guids = AssetDatabase.FindAssets("t:" + typeof(T).Name); string path = AssetDatabase.GUIDToAssetPath(guids.First()); var obj = AssetDatabase.LoadAssetAtPath<T>(path); return obj; }
仅此行要求TImplementation AssetDatabase.LoadAssetAtPath(路径)无法向构造函数添加依赖项。而是将[Inject]属性添加到窗口类中的依赖项字段,并在窗口启动时调用StaticContext.Container.Inject(this);我还建议向窗口更新周期中添加对相关字段之一的空检查,如果该字段为空,请执行上面的行。因为更改项目中的代码后,Unity可以重新创建窗口,而无需在其上调用Awake。发电机组
目录
生成器的初始视图,该窗口应提供一个界面以生成带参数的方案列表,显示方案列表并将所选方案放置在当前场景上。该窗口从左到右包括三个部分:使用EditorGUILayout.BeginVertical()和EditorGUILayout.EndVertical()创建列。不幸的是,它不能用来固定和限制大小,但这并不是那么关键。事实证明,大量电路的生成过程并不那么快。使用I的元素可获得很多组合。如剖析器所示,最慢的部分是电路本身。并行化不是一个选项;所有选项都使用一种方案,但是很难克隆该结构。然后,我认为编辑器扩展的所有代码都可能在Debug模式下工作。在“发行版”下,调试效果不佳,断点无法停止,行被跳过等。确实,在测量了性能之后,事实证明,Unity中生成器的速度与从控制台应用程序启动的Debug程序集相对应,它的速度比Release慢6倍。请记住这一点。
或者,您可以进行外部组装,并随该组装一起添加到Unity DLL中,但这会使组装和项目编辑变得非常复杂。立即将生成过程放入一个包含以下代码的单独Task中:circuitGenerator.Generate(行,maxElementsInLine,availableLogicalElements,useNOT,modification)。但是仍然有必要等待很长时间,几分钟(在大型电路上超过20分钟)。另外,存在一个问题,即任务无法如此轻松地完成,并且一直持续到生成完成为止。很多代码 internal static class Ext { public static IEnumerable<CircuitVariant> OrderVariants(this IEnumerable<CircuitVariant> circuitVariants) { return circuitVariants.OrderBy(a => a.Solutions.Count()) .ThenByDescending(a => a.Solutions .Select(b => b.Sum(i => i ? 1 : -1)) .OrderByDescending(b=>b) .First()); } } public interface IEditorGenerator : IDisposable { CircuitVariant[] FilteredVariants { get; } int LastPage { get; } void FilterVariants(int page); void Start(int lines, int maxElementsInLine, ICollection<int> availableGates, bool useNOT, StructureModification? modification, int maxSolutions); void Stop(); void Fetch(); } public class EditorGenerator : IEditorGenerator { private const int PageSize = 100; private readonly ICircuitGenerator circuitGenerator; private ConcurrentBag<CircuitVariant> variants; private List<CircuitVariant> sortedVariants; private Thread generatingThread; public EditorGenerator(ICircuitGenerator circuitGenerator) { this.circuitGenerator = circuitGenerator; } public void Dispose() { generatingThread?.Abort(); } public CircuitVariant[] FilteredVariants { get; private set; } public int LastPage { get; private set; } public void FilterVariants(int page) { CheckVariants(); if (sortedVariants == null) { Fetch(); } FilteredVariants = sortedVariants.Skip(page * PageSize) .Take(PageSize) .ToArray(); int count = sortedVariants.Count; LastPage = count % PageSize == 0 ? (count / PageSize) - 1 : count / PageSize; } public void Fetch() { CheckVariants(); sortedVariants = variants.OrderVariants() .ToList(); } public void Start(int lines, int maxElementsInLine, ICollection<int> availableGates, bool useNOT, StructureModification? modification, int maxSolutions) { if (generatingThread != null) { Stop(); } variants = new ConcurrentBag<CircuitVariant>(); generatingThread = new Thread(() => { var v = circuitGenerator.Generate(lines, maxElementsInLine, availableGates, useNOT, modification, maxSolutions); foreach (var item in v) { variants.Add(item); } }); generatingThread.Start(); } public void Stop() { generatingThread?.Abort(); sortedVariants = null; variants = null; generatingThread = null; FilteredVariants = null; } private void CheckVariants() { if (variants == null) { throw new InvalidOperationException("VariantsGeneration is not started. Use Start before."); } } ~EditorGenerator() { generatingThread.Abort(); } }
想法是应生成背景,并应要求更新排序选项的内部列表。然后,您可以逐页选择选项。因此,无需每次都进行排序,这大大加快了大型列表的工作。方案按“有趣程度”分类:按解决方案的数量,增加的数量以及解决方案要求各种值的方式。也就是说,解为1 1 1 1的电路比1 0 1 1有趣。
因此,事实证明,无需等待生成结束,就已经为该电平选择了电路。另一个优点是,由于分页,编辑器不会像牛一样放慢速度。Unity功能非常令人不安,因为当您单击“播放”时,窗口的内容会像所有生成的数据一样被重置。如果它们易于序列化,则可以将它们存储为文件。这样,您甚至可以缓存生成的结果。但是,遗憾的是,很难序列化对象之间相互引用的复杂结构。另外,我在每个门上都添加了行 if (Input.Length == 2) { return Input[0].Value && Input[1].Value; }
从而大大提高了性能。解算器
目录在编辑器中组装电路时,您需要能够快速了解它是否正在解决以及有多少解决方案。为此,我创建了一个“求解器”窗口。它以文本形式提供当前方案的解决方案
,其“后端”的逻辑是: public string GetSourcesLabel() { var sourcesMb = sceneObjectsHelper.FindObjectsOfType<SourceMb>().OrderBy(s => s.name); var sourcesLabelSb = new StringBuilder(); foreach (var item in sourcesMb) { sourcesLabelSb.Append($"{item.name.Replace("Source", "Src")}\t"); } return sourcesLabelSb.ToString(); } public IEnumerable<bool[]> FindSolutions() { var elementsMb = sceneObjectsHelper.FindObjectsOfType<IElectricalElementMbEditor>(); elementsConfigurator.Configure(elementsMb); var root = sceneObjectsHelper.FindObjectOfType<FinalDevice>(); if (root == null) { throw new InvalidOperationException("No final device in scene"); } var sourcesMb = sceneObjectsHelper.FindObjectsOfType<SourceMb>().OrderBy(s => s.name); var sources = sourcesMb.Select(mb => (Source) mb.Element).ToArray(); return solver.GetSolutions(root.Element, sources); }
有用的
目录内容断言帮助
内容为了验证值是否在资产中设置,我使用在OnEnable中调用的扩展方法 public static class AssertHelper { public static void AssertType(this IElectricalElementMbEditor elementMbEditor, ElectricalElementType expectedType) { if (elementMbEditor.Type != expectedType) { Debug.LogError($"Field for {expectedType} require element with such type, but given element is {elementMbEditor.Type}"); } } public static void AssertNOTNull<T>(this T obj, string fieldName = "") { if (obj == null) { if (string.IsNullOrEmpty(fieldName)) { fieldName = $"of type {typeof(T).Name}"; } Debug.LogError($"Field {fieldName} is not installed"); } } public static string AssertNOTEmpty(this string str, string fieldName = "") { if (string.IsNullOrWhiteSpace(str)) { Debug.LogError($"Field {fieldName} is not installed"); } return str; } public static string AssertSceneCanBeLoaded(this string name) { if (!Application.CanStreamedLevelBeLoaded(name)) { Debug.LogError($"Scene {name} can't be loaded."); } return name; } }
尽管可以加载场景,但有时无法验证场景是否具有加载能力。也许这是Unity中的错误。使用示例: mainMenuSceneName.AssertNOTEmpty(nameof(mainMenuSceneName)).AssertSceneCanBeLoaded(); levelNamePrefix.AssertNOTEmpty(nameof(levelNamePrefix)); editorElementsPrefabs.AssertNOTNull(); not.AssertType(ElectricalElementType.NOT);
SceneObjectsHelper
内容要使用场景元素,SceneObjectsHelper类也很有用:很多代码 namespace Circuit.Game.Utility { public interface ISceneObjectsHelper { T[] FindObjectsOfType<T>(bool includeDisabled = false) where T : class; T FindObjectOfType<T>(bool includeDisabled = false) where T : class; T Instantiate<T>(T prefab) where T : Object; void DestroyObjectsOfType<T>(bool includeDisabled = false, bool immediate = false) where T : class; void Destroy<T>(T obj, bool immediate = false) where T : Object; void DestroyAllChildren(Transform transform); void Inject(object obj); T GetComponent<T>(GameObject obj) where T : class; } public class SceneObjectsHelper : ISceneObjectsHelper { private readonly DiContainer diContainer; public SceneObjectsHelper(DiContainer diContainer) { this.diContainer = diContainer; } public T GetComponent<T>(GameObject obj) where T : class { return obj.GetComponents<Component>().OfType<T>().FirstOrDefault(); } public T[] FindObjectsOfType<T>(bool includeDisabled = false) where T : class { if (includeDisabled) { return Resources.FindObjectsOfTypeAll(typeof(Object)).OfType<T>().ToArray(); } return Object.FindObjectsOfType<Component>().OfType<T>().ToArray(); } public void DestroyObjectsOfType<T>(bool includeDisabled = false, bool immediate = false) where T : class { var objects = includeDisabled ? Resources.FindObjectsOfTypeAll(typeof(Object)).OfType<T>().ToArray() : Object.FindObjectsOfType<Component>().OfType<T>().ToArray(); foreach (var item in objects) { if (immediate) { Object.DestroyImmediate((item as Component)?.gameObject); } else { Object.Destroy((item as Component)?.gameObject); } } } public void Destroy<T>(T obj, bool immediate = false) where T : Object { if (immediate) { Object.DestroyImmediate(obj); } else { Object.Destroy(obj); } } public void DestroyAllChildren(Transform transform) { int childCount = transform.childCount; for (int i = 0; i < childCount; i++) { Destroy(transform.GetChild(i).gameObject); } } public T FindObjectOfType<T>(bool includeDisabled = false) where T : class { if (includeDisabled) { return Resources.FindObjectsOfTypeAll(typeof(Object)).OfType<T>().FirstOrDefault(); } return Object.FindObjectsOfType<Component>().OfType<T>().FirstOrDefault(); } public void Inject(object obj) { diContainer.Inject(obj); } public T Instantiate<T>(T prefab) where T : Object { var obj = Object.Instantiate(prefab); if (obj is Component) { var components = ((Component) (object) obj).gameObject.GetComponents<Component>(); foreach (var component in components) { Inject(component); } } else { Inject(obj); } return obj; } } }
在这里,在需要高性能的地方有些事情可能不是很有效,但是很少有人要求我这样做,并且不会产生任何影响。但是它们允许您通过界面来查找对象,例如,看起来很漂亮。协程启动器
内容发布协程只能具有MonoBehaviour。因此,我创建了CoroutineStarter类,并将其注册到场景的上下文中。 public interface ICoroutineStarter { void BeginCoroutine(IEnumerator routine); } public class CoroutineStarter : MonoBehaviour, ICoroutineStarter { public void BeginCoroutine(IEnumerator routine) { StartCoroutine(routine); } }
除了方便之外,此类工具的引入使自动测试变得更加容易。例如,在测试中执行协程: coroutineStarter.When(x => x.BeginCoroutine(Arg.Any<IEnumerator>())).Do(info => { var a = (IEnumerator) info[0]; while (a.MoveNext()) { } });
小发明
内容为了方便显示不可见元素,建议您使用仅在场景中可见的Gizmo图片。它们使您只需单击即可轻松选择不可见的元素。还以线的形式进行元素的连接: private void OnDrawGizmos() { if (outputConnectorMb != null) { Handles.DrawLine(transform.position, outputConnectorMb.Transform.position); } }

测试中
内容我想从自动测试中获得最大的收益,因为在可能且易于使用的地方都使用了测试。对于单元测试,习惯上使用模拟对象而不是实现测试类所依赖的接口的类。为此,我使用了NSubstitute库。什么很高兴。Unity不支持NuGet,因此我必须单独获取DLL,然后将程序集作为依赖项添加到AssemblyDefinition文件中,并且可以正常使用。
对于自动测试,Unity提供了TestRunner,它可以与非常流行的NUnit测试框架一起使用。从TestRunner的角度来看,有两种类型的测试:- EditMode — , . Nunit . , . GameObject Monobehaviour . , EditMode .
- PlayMode — .
编辑模式以我的经验,这种模式会带来许多不便和奇怪的行为。但是,它们很方便地自动检查整个应用程序的运行状况。它们还为诸如Start,Update之类的方法中的代码提供诚实的验证。PlayMode测试可以描述为正常的NUnit测试,但是还有另一种选择。在播放模式下,您可能需要等待一段时间或一定数量的帧。为此,必须以类似于协程的方式描述测试。返回的值应该是IEnumerator / IEnumerable,并且在里面,要跳过时间,必须使用例如: yield return null;
或
yield return new WaitForSeconds(1);
还有其他返回值。这样的测试需要设置UnityTest属性。只要有属性UnitySetUp UnityTearDown和谁在一起,你要使用类似的方法。反过来,我将共享用于模块化和集成的EditMode测试。单元测试仅测试一个类,使其与其他类完全隔离。这样的测试通常可以更轻松地为测试的类准备环境,而错误通过后,可以使您更准确地定位问题。在单元测试中,我测试了许多Core类以及游戏中直接需要的类。电路元件测试非常相似,因此我创建了一个基类 public class ElectricalElementTestsBase<TElement> where TElement : ElectricalElementBase, IElectricalElement, new() { protected TElement element; protected IInputConnector mInput1; protected IInputConnector mInput2; protected IInputConnector mInput3; protected IInputConnector mInput4; [OneTimeSetUp] public void Setup() { element = new TElement(); mInput1 = Substitute.For<IInputConnector>(); mInput2 = Substitute.For<IInputConnector>(); mInput3 = Substitute.For<IInputConnector>(); mInput4 = Substitute.For<IInputConnector>(); } protected void GetValue_3Input(bool input1, bool input2, bool input3, bool expectedOutput) {
进一步的元素测试如下所示: public class AndTests : ElectricalElementTestsBase<And> { [TestCase(false, false, false)] [TestCase(false, true, false)] [TestCase(true, false, false)] [TestCase(true, true, true)] public new void GetValue_2Input(bool input1, bool input2, bool output) { base.GetValue_2Input(input1, input2, output); } [TestCase(false, false)] [TestCase(true, true)] public new void GetValue_1Input(bool input, bool expectedOutput) { base.GetValue_1Input(input, expectedOutput); } }
从易于理解的角度来看,这可能是一个复杂的问题,通常在测试中没有必要,但是我不想将同一件事复制粘贴11次。也有GameManagers的测试。由于它们有很多共同点,因此它们也具有测试的基类。两种模式下的游戏管理器应具有相同的功能,但应具有不同的功能。对于每个后继者,将使用相同的测试来测试常规事物,并且还会测试特定的行为。尽管采用了事件方法,但是测试事件执行的行为并不困难: [Test] public void FullHelpAgree_FinishLevel() {
在集成测试中,我还测试了编辑器的类,并从DI容器的静态上下文中获取了它们。因此,检查包括正确的注射,这与单元测试同样重要。 public class PlacerTests { [Inject] private ICircuitEditorPlacer circuitEditorPlacer; [Inject] private ICircuitGenerator circuitGenerator; [Inject] private IEditorSolver solver; [Inject] private ISceneObjectsHelper sceneObjectsHelper; [TearDown] public void TearDown() { sceneObjectsHelper.DestroyObjectsOfType<IElectricalElementMb>(immediate: true); } [OneTimeSetUp] public void Setup() { var container = StaticContext.Container; container.Inject(this); } [TestCase(1, 2)] [TestCase(2, 2)] [TestCase(3, 4)] public void PlaceSolve_And_NoModifications_AllVariantsSolved(int lines, int elementsInLine) { var variants = circuitGenerator.Generate(lines, elementsInLine, new List<int> {0}, false); foreach (var variant in variants) { circuitEditorPlacer.PlaceCircuit(variant); var solutions = solver.FindSolutions(); CollectionAssert.IsNOTEmpty(solutions); } } [TestCase(1, 2, StructureModification.Branching)] [TestCase(1, 2, StructureModification.ThroughLayer)] [TestCase(1, 2, StructureModification.All)] [TestCase(2, 2, StructureModification.Branching)] [TestCase(2, 2, StructureModification.ThroughLayer)] [TestCase(2, 2, StructureModification.All)] public void PlaceSolve_And_Modifications_AllVariantsSolved(int lines, int elementsInLine, StructureModification modification) { var variants = circuitGenerator.Generate(lines, elementsInLine, new List<int> {0}, false, modification); foreach (var variant in variants) { circuitEditorPlacer.PlaceCircuit(variant); var solutions = solver.FindSolutions(); CollectionAssert.IsNOTEmpty(solutions); } }
该测试使用所有依赖项的真实实现,并在舞台上设置对象,这在EditMode测试中是很可能的。测试它确实使它们理智是正确的-我几乎不知道如何做,所以我检查发布的电路是否有解决方案。在集成中,还对CircuitGenerator(StructureGenerator + VariantsGenerator)和Solver进行测试 public class CircuitGeneratorTests { private ICircuitGenerator circuitGenerator; private ISolver solver; [SetUp] public void Setup() { solver = new Solver(); var gates = new List<Func<IElectricalElement>> { () => new And(), () => new Or(), () => new Xor() }; var conductors = new List<Func<IElectricalElement>> { () => new Conductor(), () => new Not() }; var elements = Substitute.For<IElementsProvider>(); elements.Conductors.Returns(conductors); elements.Gates.Returns(gates); var structGenerator = new StructureGenerator(); var variantsGenerator = new VariantsGenerator(solver, elements); circuitGenerator = new CircuitGenerator(structGenerator, variantsGenerator); } [Test] public void Generate_2l_2max_ReturnsVariants() {
PlayMode测试用作系统测试。他们检查预制件,注射剂等。一个好的选择是使用现成的场景,其中测试仅加载并产生一些交互。但是我使用准备好的空白场景进行测试,其中的环境不同于游戏中的环境。曾经尝试使用PlayMode测试整个游戏过程,例如进入菜单,进入关卡等等,但是这些测试的工作结果很不稳定,因此决定将其推迟(以后再也不做)。使用覆盖率评估工具编写测试很方便,但不幸的是,我还没有找到任何可与Unity配合使用的解决方案。我发现一个问题,因为将Unity升级到2018.3,测试开始工作的速度慢得多,最多慢了10倍(在一个综合示例中)。该项目包含288个EditMode测试,这些测试运行11秒钟,尽管到目前为止没有进行任何操作。发展总结
内容
游戏级别的屏幕快照无论平台如何,都可以制定某些游戏的逻辑。在早期阶段,这可以通过自动测试简化开发和可测试性。DI很方便。即使考虑到Unity本身没有它的事实,侧面的螺钉也可以忍受。Unity使您可以自动测试项目。没错,因为所有内置的GameObject组件都没有接口,只能直接用于模拟Collider,SpriteRenderer,MeshRenderer等。将无法解决。尽管GetComponent允许您在界面上获取组件。作为一种选择,为所有内容编写自己的包装器。使用自动测试简化了生成初始逻辑的过程,而代码没有用户界面。测试多次会在开发过程中立即发现错误,自然,错误会进一步出现,但是通常可以编写其他测试/更改现有测试,然后自动捕获它。 DI,预制件,可编写脚本的对象等错误,测试很难捕获,但是有可能,因为您可以为Zenject使用真正的安装程序,这会加强依赖关系,因为它会在构建过程中发生。Unity会产生大量的错误,崩溃。通常,错误是通过重新启动编辑器来解决的。面对对预制件中对象的引用的奇怪丢失。有时,按引用的预制件被破坏(ToString()返回“ null”),尽管一切看起来正常,但预制件被拖到场景上,链接也不为空。有时在所有场景中都会失去一些联系。一切似乎都已安装并且可以正常工作,但是当切换到另一个分支时,所有场景都被破坏了-元素之间没有链接。幸运的是,这些错误通常可以通过重新启动编辑器或有时删除“库”文件夹来纠正。从构思到在Google Play上发布,总共已有大约半年的时间。开发本身花费了大约3个月的时间,而主要工作却没有时间。