本片文章系个人总结,无任何教学指导意义
前言
接下来的这几天将更多地围绕这次GGJ的项目进行复盘,大概思路就是先分析项目构成,然后分解正几个部分,回忆制作问题,最后提出改进方案。但是最后这点可能不会那么顺风顺水。
我们项目主要成员只有四个:两个程序,一个美术,一个策划。在还没正式开始项目前,我们自信满满地一致同意想做一款《吸血鬼LIKE》游戏,或者更多的是模仿了《土豆兄弟》,反正无论如何整个游戏类型大体分支应该是RPG>Roguelike/半Roguelike。
这次我们几个做了整整7天有余,什么?GGJ哪有7天?我们当然是线上抢跑的。Make Me Laugh 这个主体已经不重要了,它带给我们的启示仅仅只是让我们放宽心去做一个“比较搞笑”的游戏,于是乎,这款烂梗跟BUG并存的轻度Roguelike游戏诞生了——Emoji 大神。
B站视频链接,不是广告。
这篇文章也只是对整个项目进行记录复盘个大概,因此不会对代码内容进行解释。
游戏构成
介绍:
我们的游戏以一个滑稽Emoji作为主角,在“贴吧”领域生存,在一波又一波“怪物”的攻击下,存活并升级,更新装备,最终活到20波后游戏胜利。
整个游戏是2D平面俯视角。具体画面设计可以看看链接的视频。
这里的游戏构成将是我从一个程序的角度出发,因为我们选择了半逆半正的工作模式,啥意思呢,就是正向有我们自己构思的结构,逆向有我们借鉴其他游戏分析得出的结构(抄袭)。
其实这个半逆半正并非我所设想,只是在我和策划出现思路分歧时不得已采取的“求同存异”方案。
![](/qrcode.jpg)
这个问题经过我们的谈论,原因在程策沟通少,加上我们团队组织上的存在的原因。程序(我)很喜欢按自己的构想来写,有时候是我自作主张,有时候是策划案里没有明确设计,所以我当机立断,占山为王(自己想怎么样就怎么写)。策划是因为没有和程序讲清楚,导致思路对不上(这不是我自己说的,是策划说的)。还有一个根本因素就是,其实我们团队没有明确岗位,大家都是会啥做啥,所谓的什么程序策划单纯是方便在报名时填那个表,所以我们的策划不像岗位上的策划一样要策划很多东西,更多的是记录一下开发日志,更新开发进度,整理开发数据。
我们其他什么程序美术也会有自己想加进游戏的东西,那个时候我们就也变成了策划,所以整个游戏可以说是大家策划的,策划负责管理的。正因如此,我们才不免的出现与策划原本设计不一样的问题,有时是因为策划没想得那么细,大家都在凭感觉做游戏。
主要部分:
1.战斗系统
2.商店系统
3.表格系统
两个主要部分,也对应了两个主要场景。
战斗系统
1.玩家
玩家Player作为玩家得主要行动单位,负责接受玩家输入和战斗,没什么好说的。
但是我却在一开始就埋了祸根,一是我并没有把玩家跟敌人看作是同一种东西,或者说他们没有继承自同一个父类,因为我一开始并不觉得玩家和敌人有很多共性。结果一看策划案!完蛋了。
具体情况就是,我觉得玩家有武器,暴击率,伤害加成等等,然后敌人没有,但后来发现策划案里敌人和玩家同源,玩家有敌人没有的属性可以设为0就好了。这是第一个小坑。
第二个就是玩家类,或者说单位类代码上的问题,就是我太执着于属性了(Property),每个属性我都想设计得很合适,注意我说的这个属性应该是(Attributes),Property属性是一种相对安全的设计方案?但是属性(Attributes)是我们要投入游戏里的数据。也就是说我太执着角色的每个Attribute都用Property来封装,代码看起来就比较冗杂。当然我不是说属性不好,我只是说单纯用字段也挺方便的。可能遇到功能复杂的系统会有很多属性需求,但是我在做的时候感觉用字段非常方便,可能还没到需要很多属性的境界吧。
角色的移动方面我用了新输入系统,该说不说非常好用。
private void Move()
{
Vector2 vector2;
vector2 = BaseControls.Player.Movement.ReadValue<Vector2>();
rb.velocity = vector2 * MoveSpeed;
}
三言两语就解决了基本移动,这里的BaseControls是自定义的Input Actions,不过要记得生成代码 。大道至简 ✔
2.敌人
如同上面所说的,我因为一开始的设计问题,把两个可以一样的东西设计成了一样但是多写一份代码的东西。所以说制作之前的设计和构思非常重要,要对想做的东西经过一定思考,脑补框架和结构。事实上,我在后面做商店系统的时候就成功收益了。
敌人的移动和玩家类似:
protected virtual void BaseAction()
{
//BaseAction: Chasing Player
Vector2 playerPos = Player.Instance.transform.position;
Vector2 ownPos = transform.position;
Vector2 dir = new Vector2(playerPos.x-ownPos.x,playerPos.y-ownPos.y).normalized;
rb.velocity = dir * MoveSpeed;
}
这里是持续追击玩家,当然有留个virtual等着重写,因为还设计有远程敌人不会追击玩家但会射击。
这里的追击十分简单,没有复杂的算法。大道至简×2 ✔
3.武器
设计上武器分为两种,近战武器和远程武器。近战武器会绕着玩家转圈,远程武器会自动攻击最近的敌人。
近战武器的机制还好说一句话的事:
private void FixedUpdate()
{
transform.RotateAround(Player.Instance.transform.position, Vector3.forward, Omiga * Time.fixedDeltaTime);
}
这里因为是2D俯视角所以旋转轴跟Z轴平行就好了。
远程武器实现方式大概就是通过触发器Trigger检测范围内敌人,更新敌人列表,排序敌人位置,选择距离最近的敌人射出子弹。
战斗系统总结
这个战斗系统不算复杂,但是还是有一些小问题,比如远程武器的判别方式可能会导致一些空引用的错误,因为不影响运行我当时就没改。
其实我个人觉得这样射击战斗方式还挺有意思的,因为我个人很喜欢模拟器类型,RTS类型的游戏,自动战斗是非常重要的,又简单有有效,让程序设计可以往更多方向深入。当然游戏类型不同侧重点也不一样,像格斗游戏那种对打击感有很大需求,所以战斗机制非常复杂。
除此之外战斗系统应该还包括属性的升级等方面,这里就完全按策划案里的去做。但是我觉得我写的还不是很好,算是一个待改进方面。
商店系统
这个商店系统一开始并不是由我来做的,但是后来合并的时候发现功能与需求不一样,又得我来补救,所以策划一定要与各个岗位沟通好才行。
1.商店管理器
这个东西是整个商店系统的核心,很多相关功能和商店UI的处理就是写在这个脚本。
特别地,这里对UI的处理我还是有所领悟的,因为一开始做的时候本来想尝试一下兼容手柄的,
但是实际上手后发现很多问题:
1.手柄操作导航问题:
在整个商店UI上有很多按钮,比如说那些一个个的格子,刷新,下一波按钮。这些按钮的导航如果不加以设置的话会出现很多问题:
试想一下,当你想出售一个东西时,会弹出确认出售的按钮,我自然而然的会用一个Panel挡住确认面板后面的UI元素,避免鼠标点击到它们。但是如果你用手柄操作,那么会面临两个问题,一是面板弹出后,不会自动跳转到子面板上的按钮,所选择的对象还是原来的那个;二是虽然鼠标无法接触到选择对象(因为被Panel挡住了),但是导航却可以导航到Panel后面的面板。如果误触将会导致很多BUG。
第一个问题还好解决,只要代码调整一下当前事件系统的选择对象即可。
但是第二个问题我曾尝试过写一大堆判断方法规范按钮之间的导航,但是结果又是一堆BUG,真不如直接手动设置导航。比如按钮,就在组件的Navigation里选择Explicit就好了,然后分别指定或留空四个方位的导航。
2.鼠标互动的问题
我说过,一开始我是想讲这个游戏制作成兼容手柄的,或者说,本身是为手柄诞生的。于是我在设计一些UI交互的时候,不得不与Selectable打交道。比如,当选择对象改变时,改变提示信息,就像下面要讲的商店提示。
但是鼠标操作有一个问题就是只有在点击触发后才会改变选择对象,所以想要更改信息就得一直点击,非常不方便。所以就得用代码检测鼠标停留处的UI元素,并将其变成当前选择的对象。
可能看得不太明白,总的来说我所追求的操作手感趋同于怪猎的那种。
2.商店格子
要提到这个,就必须先简单提一提表格数据输入对整个游戏的影响了,有了表格数据的加持,可以让我摆脱狂造预制体的悲惨命运。
这个商店格子其实就是一个很简单的脚本。
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
[RequireComponent(typeof(Button))]
public class Block : MonoBehaviour
{
public Image image;
public Image color;
public TextMeshProUGUI levelText;
[Header("储存的信息")]
public BlockInfo blockInfo;
[Header("等级范围对应的颜色")]
public RankColor[] rankColors;
private void Update()
{
RefreshImage();
}
//根据格子自己的信息刷新图像显示
public void RefreshImage()
{
//略...
}
//根据等级设置底板颜色
private Color SetColor(int rank)
{
//略...
}
[Serializable]
public class RankColor
{
[Header("对应的等级范围")]
public int MinRank;
public int MaxRank;
[Header("对应的颜色")]
public Color color;
}
}
重点在于一个叫BlockInfo的自定义结构体 :
[Serializable]//unity inspector监视可序列化类型,对类可用
public struct BlockInfo
{
public BlockType type;
public int index;
public BlockInfo(BlockType type = BlockType.Null, int index = 0)
{
this.type = type;
this.index = index;
}
}
public enum BlockType
{
Null,
Weapons,
Props,
}
顾名思义,BlockInfo结构体用来记录当前这个格子的信息,只有两个:格子类型和编号。
在此前,我已经将表格数据存入好几个字典,并通过编号访问对应信息。
比如这里有武器字典,道具字典。
所以这几个格子只需要存储对应物品类型和编号,我就可以通过它们找到该物品的信息,然后将其读取并对提示信息作出修改。
3.商店提示
这个商店提示和格子大同小异,思路就是,判断当前选择对象是不是一个格子,如果是那么就获取Block组件,再获取BlockInfo,最后根据类型,编号,查字典找到对应信息,最好刷新一下即可。
商店系统总结
虽然是赶工赶出来的,但是得益于开工前思考,做出来的效果还是不错的,也总结了很多小细节,小问题。
也许看不懂,但是整个商店系统,甚至整个游戏的数据其实都是建立在读表的基础上实现交互地。所以重点其实是读表嘿嘿。
表格系统
游戏里的导入的外部表格格式都为CSV。
过去式
其实这根本不算个系统,我只是为了突出在做这个项目时读取表格对我的影响和意义。
其实我也是后期才发现读表的强大和重要性。一开始因为赶工,我直接抱着有多少种怪物就做多少个预制体的打算,但是策划给我发了个表是这样的:
好吧,这其实我是经过考虑后设计的表格并让策划把数值填上去的。但是主要问题是,有相当一部分怪物是用的同一个预制体,比方说小丑。但是我不可能根据这个表去制作22个相同的预制体,不同的只有预制体身上挂载的属性的数据吧(小丑是个预制体,挂载有敌人组件)。
况且预制体是静态的,所以我们只能在生成实例后对实例上的敌人组件赋值,思路大体是:
根据编号生成预制体,根据编号对应的每行的信息给预制体上的组件赋值。
这也是为什么我要在每一行之前设置一个编号。
private void InitializeEnemiesList()
{
string path = Path.Combine(Application.streamingAssetsPath, "ExtendData/怪物编号表.csv");
using (FileStream fs = new(path, FileMode.Open, FileAccess.Read))
{
using (StreamReader sr = new StreamReader(fs, Encoding.UTF8))
{
string line = "";
bool firstLine = true; //省略首行
while ((line = sr.ReadLine()) != null)
{
if (firstLine)
{
firstLine = false;
continue;
}
string[] s = line.Split(",", 2);
EnemiesList.Add(int.Parse(s[0]), s[1]);
}
}
}
}
这是其中一个数据处理的方法,对象就是上面的那个表格。观察代码可以看到,最终,表格种数据会被存放进一个叫EnemiesList的字典,字典类型为<int,string>,分别对应表格每一行(除首行外)的编号,和每一行(除编号外)的文本信息。
然后赋值方法就是根据编号得到文本信息,拆分文本信息并分析赋值。
string[] s3 = enemyDetail.Split(",");
//怪物细节信息 格式: 怪物等级,怪物名称,怪物最大生命值,怪物伤害,怪物移动速度,怪物掉落金钱,怪物掉落经验,怪物攻击间隔(远程)
try
{
enemy.GetComponent<Enemy>().Lv = int.Parse(s3[0]);
enemy.GetComponent<Enemy>().MaxHP = float.Parse(s3[2]);
enemy.GetComponent<Enemy>().Damage = float.Parse(s3[3]);
enemy.GetComponent<Enemy>().MoveSpeed = float.Parse(s3[4]);
enemy.GetComponent<Enemy>().DropMoney = int.Parse(s3[5]);
enemy.GetComponent<Enemy>().DropEXP = int.Parse(s3[6]);
}
catch { }
上面是拆分赋值的例子。
通过上述一系列操作,让不同种类的敌人,武器,物品等等以数据的方式流通,而不是以一个实例的方式,只有在需要创建它们的实例时,才将数据赋值给实例。这就是我在临时制作时的大体思路。
比如我的装备,在某个列表或数组中,只是一个个int类型的编号,但是在商店中,通过Block脚本的自动更新,会通过获取装备列表里的各个编号,来显示编号对应的图像。
敌人也是一样的,通过编号对应的信息,实例化对象并赋值。
现在时
当然上面的都是过去式,因为这些是在GJ赶工的情况下逼出来的,代码之间和整个架构还是有很多问题的,比如:
1.表格数据若是都暂存到字典,而且在上面的设计中,所有相关字典是在整个游戏程序开始时生成的,并且存在知道游戏关闭,等同于游戏过程全程占用到内存。如果数据非常多,会不会因此影响到内存?
2.赋值过程,存在在很多时候,比如上面的敌人生成时要赋值,武器生成时要赋值,物品显示时要赋值。有没有更统一的管理方案?
3.在第一次打包中,表格数据并没有被一起打包,原因是Unity并没有将它们打包,所以需要放在一个可以被一起打包的路径。
将来时
对于上面的第3个问题,目前已经有两种解决方案:
1.放在Resources文件夹里,随程序打包并加密。
2.放在StreamingAssets文件夹里,随程序打包但不加密,保留原样。
事实上,这两文件夹也是目前唯二会被Unity打包的文件夹。可以按需选择。
对于其余两个问题,我还需要时间反复琢磨。
如果写路径的话,特别注意路径不能写死,因为每个平台的环境都不同。我就在一开始将路径写死,结果只能在Editor正常运行。
其他问题的话有待解决吧。
总结
这个项目让我感触最深的便是表格数据的重要性。表格真的是这种需要复杂数据的游戏的一大得力助手。也正因那几天的辛劳,让我觉得这种游戏不太适合GJ。它的优点和缺点一样。
可能接下来的好几天,我都会围绕从各个角度优化这个项目做文章记录。
有任何意见,建议,分享,思路,提示,构思,修正,批评,欢迎致信。