从零开始的种田生活-Unity游戏开发
大家好,这里是暴躁老哥酒九。最近了我们的童年记忆《摩尔庄园》在手机上面复活了,不知道大家有没有沉迷于种菜无法自拔呢(反正我是累了)。
种田才是这个游戏本质吧~
在《摩尔庄园》中了为我们玩家提供了很多的玩法比如:钓鱼,烹饪,开餐厅,庄园和玩家自身装扮,职业选择和种田(小声逼逼:虽然我都觉得挺鸡肋的)。但是并不妨碍我们发挥身为的中国人的特长——种田!大家在现实中打完一天工以后,回到家还要坚持在游戏中种田,浇水。你给我翻译翻译什么叫做热爱!
所以今天我们就来试着用Unity做一款种田小游戏。
好啦,进入正片,我们首先想一想我们做的是一个种田的游戏,那么我们种田的流程是怎样的呢?首先玩家需要开垦一块土地,然后在我们的背包栏中选择到对应的种子,再在被开垦好的土地上进行播种,然后就是等待种子生长好了以后收获果实啦,卖出获得金钱,再去商店买种子。
相信大家已经发现了,我们实现这个流程并不困难,但是了这个游戏中的真正的重点是游戏中各项信息本地存储。本片文章了我将为大家介绍一下我个人使用最多的三种Unity中信息存储方式,不过在这里我先按下不表。我们先一步一步的来制作我们的种田小游戏。
首先找到需要的场景素材,无论你是在Unity的官方商城还是万能的某宝上购买都可以,将场景素材下载好了以后,打开Unity新创建一个工程取好工程名设置好工程的文件的保存路径,打开工程将下载好的素材包拖入工程中。搭建一个自己的场景,选择好人物模型,大家可以用自己的方法来控制人物的移动,这里我就不过多嘴了。
但是,想要到达上图的效果我还缺少了一步——摄像机的跟随。所以新建一个脚本命名为CameraFollow,在这个脚本中编写摄像机跟随的脚本。
public class CameraFollow : MonoBehaviour
{
public Transform player;//玩家角色的位置``
public float moveSpeed;//移动的速度
public float turnSpeed;//转向的速度
Vector3 offset;//摄像机和玩家角色之间的向量
RaycastHit rh;//射线打的物体
float dis;//摄像机和玩家角色之间的距离
Vector3[] currentPoints;//玩家被遮挡以后摄像机可以移动的点位数组
void Awake()
{
moveSpeed = 3f;
turnSpeed = 10f;
currentPoints = new Vector3[5];
}
void Start()
{
dis = Vector3.Distance(transform.position, player.position);
offset = player.position - transform.position;
}
void LateUpdate()//放在LateUpdate中在玩家移动后再执行
{
//在一条曲线上面分布好点位
Vector3 startPos = player.position - offset;
Vector3 endPos = player.position + Vector3.up * dis;
currentPoints[0] = startPos;
currentPoints[1] = Vector3.Slerp(startPos, endPos, 0.25f);
currentPoints[2] = Vector3.Slerp(startPos, endPos, 0.5f);
currentPoints[3] = Vector3.Slerp(startPos, endPos, 0.75f);
currentPoints[4] = endPos;
Vector3 view = currentPoints[0];
for (int i = 0; i < currentPoints.Length; i++)
{
if (CheckPlayer(currentPoints[i]))
{
view = currentPoints[i];
break;
}
}
transform.position = Vector3.Lerp(transform.position, view, Time.deltaTime * moveSpeed);
Rotate();
}
public bool CheckPlayer(Vector3 pos) //从摄像机朝玩家方向打一条射线判断玩家是否被挡住
{
Vector3 dir = player.position - pos;
if (Physics.Raycast(pos, dir, out rh))
{
if (rh.collider.CompareTag("Player"))//通过玩家的Tag来判断
{
return true;
}
}
return false;
}
public void Rotate()//摄像机的旋转
{
Vector3 Dir = player.position - transform.position;
Quaternion qua = Quaternion.LookRotation(Dir);
transform.rotation = Quaternion.Lerp(transform.rotation, qua, Time.deltaTime * turnSpeed);
transform.eulerAngles = new Vector3(transform.eulerAngles.x, 0, 0);
}
}
将编写好的CameraFollow脚本拖到场景中的MainCamera上去再把人物拖到CameraFollow下的player中去。然后再场景中去调整摄像机和玩家的位置选择一个合适的距离和角度。再运行游戏就可以看到上图的那种效果了。而且再玩家角色被物体遮挡时(当前的遮挡物又碰撞体)摄像头会自动调整位置看向玩家。这里我就不放图演示了。
好了接下来就是就是重点了(敲黑板),在这个游戏中玩家能种植不同的种子,不同的种子拥有不同的名字,持有数量,描述,成长时间,在场景中显示的模型,在UI界面显示的图片。那么怎么来实现不同的种子具有这些信息了,通过创建包含这些信息不同的预制体?可以。且先不说一款通常的种田游戏中的道具种类和数量都是很多的,如果当游戏需要调整时,制作者就需要一个一个到预制体中去进行修改。这样的方式太麻烦也太笨拙了。那怎么办了。这里就要将上文中按下不表的部分给”表“出来了。这三种信息存储方式分别是:ScriptableObject,TextAsset,Json。接下来我会根据游戏开发的推进来一个一个的为大家介绍使用。
首先是将游戏中的物品的信息进行分类,通常来说一个物品的名字,描述,价格,(种子)成长时间是一开始就设计好了且在游戏游玩中也不会去更改,再来看物品再场景中的模型和在UI界面上显示的图片也可以通过对于模型或图片在工程中的存放路径来读取。而一个物品的玩家持有的数量是会随着玩家消耗或购买增减。这样一对比就把物品的信息分成了两类——在游戏游玩过程中会变的和不会变的。这样说是不是有点太土了?那就是静态数据和动态数据了。这里我选择将物品的静态数据使用表格获取,读取方法就是通过TextAsset了。动态数据就是用我们的Json文件格式来进行读取和存储了。
PS:这里为了方便物品的主要演示的是种子类了(其实就是没有找到合适的素材)。
CSV中的静态数据读取
这里先新建一个Seeds脚本,在里面声明一个Seeds类(种子类)
public class Seeds
{
//动态数据
public int ID;//种子的ID方便找到对于的种子数据
public int count;//种子的数量
//静态数据
public string name;//种子的名字
public string description;//种子的描述
public string[] Prefab;//因为有多个成长阶段对于不同的模型所以要使用数组装
public string picPath;//种子的图片
public float firstStageTime;//第一阶段的成长时间
public float secondStageTime;//第二阶段的成长时间
public float thirdStageTime;//第三阶段的成长时间
public int price; //种子的价格
public Seeds(int dataID) //从存放了静态数据的字典中拿到数据
{
this.name = GetCSVdata.instance.CSVinfo[dataID].name;
this.description = GetCSVdata.instance.CSVinfo[dataID].description;
this.Prefab = GetCSVdata.instance.CSVinfo[dataID].Prefab;
this.picPath = GetCSVdata.instance.CSVinfo[dataID].picPath;
this.firstStageTime = GetCSVdata.instance.CSVinfo[dataID].firstStageTime;
this.secondStageTime = GetCSVdata.instance.CSVinfo[dataID].secondStageTime;
this.thirdStageTime = GetCSVdata.instance.CSVinfo[dataID].thirdStageTime;
this.price = GetCSVdata.instance.CSVinfo[dataID].price;
}
//获取到对应种子的所有模型
public List<GameObject> GetPlantPrefab(string[] prefab)
{
List<GameObject> prefabList = new List<GameObject>();
for (int i = 0; i < prefab.Length; i++)
{
GameObject go = (GameObject)Resources.Load("Prefab/" + prefab[i]);
prefabList.Add(go);
}
return prefabList;
}
//获取不同种子的成长时间
public List<int> GetPlantGrowthTime()
{
List<int> growthTime = new List<int>();
growthTime.Add((int)this.firstStageTime);
growthTime.Add((int)this.secondStageTime);
growthTime.Add((int)this.thirdStageTime);
return growthTime;
}
我们先不看那三个方法,后面我会一个一个的给大家讲到。现在来打开一个XLSX工作表,在表中编写种子的静态数据内容。
那么问题来了怎样才能让Unity从表格中获取到这些数据呢?当前这个文件的格式是seed.xlsx也就是表格我们人看上去直观明了,但是Unity他不认识呀,因此需要我们将这个文件另存为seed.csv的格式。这里简单的介绍一下CSV格式的文件,通俗一点来讲就是使用逗号来分隔不同的数据内容的一种纯文本文件格式。将写好的表格另存为.csv格式后我们用记事本的方式打开。
我们可以看到各项数据之间是用英文的逗号隔开的
这里还需要注意的就是这个文件的编码是不是UTF-8,如果不是的话需要将另存为一个文本文件让后将他的编码选择为UTF-8的选项就好了(否则的话拖进Unity后可能会出现无法读取中文的问题)。现在在工程中新建一个Resources文件夹在该文件夹下再新建一个SeedData的文件夹把转换好的seed文件拖进去。新建一个命名为GetCSVdata的脚本用于读取静态数据。主要方法如下:
public void GetCSVData()
{
//读取seed文件
TextAsset seedData = Resources.Load<TextAsset>("ItemData/seed");
if (seedData == null)
{
Debug.Log("文件不存在");
return;
}
int id = 0;//字典的key值与种子类的ID一一对应
string[] data = seedData.text.Split(new char[] {
'\n' });
for (int i = 1; i < data.Length; i++)
{
CSVMessage info = new CSVMessage();
string[] oneRow = data[i].Split(new char[] {
',' });
for (int j = 0; j < oneRow.Length; j++)
{
switch (j)
{
case 0:
info.name = oneRow[j].ToString();
break;
case 1:
info.description = oneRow[j].ToString();
break;
case 2:
string tmep = oneRow[j].ToString();
info.Prefab = tmep.Split(new char[] {
'|' });
break;
case 3:
info.picPath = oneRow[j].ToString();
break;
case 4:
info.firstStageTime = float.Parse(oneRow[j]);
break;
case 5:
info.secondStageTime = float.Parse(oneRow[j]);
break;
case 6:
info.thirdStageTime = float.Parse(oneRow[j]);
break;
case 7:
if (i == data.Length-1)
{
isOver = true;
}
info.price = int.Parse(oneRow[j]);
break;
}
}
CSVinfo.Add(id, info);
id += 1;
}
这样我们上面种子类中的构造函数部分大家就看的懂了吧,就是从GetCSVdata的字典中把静态数据拿过而已。
Json中的动态数据读取
接下来就是我们的动态数据的部分,前面说了需要用到LitJson这里在网上有很多途径都可以下载到,下载好了以后了在工程的Assets中新建一个Plugin文件夹将下载好的.doll文件拖到里面去。然后来编写需要读取的Json文件信息,在编写Json文件时需要特别注意格式。
但是在编写JsonManager脚本之前了,我们先新建一个BagManager的脚本,进行如下的编写。
public class DynamicData //从json文件中读取到的数据装到一个类里
{
public int jsonID;
public int baseID;
public int jsonCount;
}
public class JsonList //封装好一个动态数据类的List
{
public List<DynamicData> jsonDatalist = new List<DynamicData>();
}
public class BagManager
{
//单例模式
public static object _object = new object();
public static BagManager _instance;
public static BagManager instance
{
get
{
if (_instance == null)
{
lock (_object)
{
if (_instance == null)
{
_instance = new BagManager();
}
}
}
return _instance;
}
}
public List<Seeds> seedsList=new List<Seeds>();
public JsonList jsonList = new JsonList();
public BagManager()
{
GetBagData();
}
public void GetBagData()
{
seedsList = new List<Seeds>();
jsonList = new JsonList();
RefreshBagData();
}
//刷新背包里的数据
public void RefreshBagData()
{
List<Seeds> tempSeed = new List<Seeds>();
for (int i = 0; i < jsonList.jsonDatalist.Count; i++)
{
if (GetCSVdata.instance.isOver)
{
Seeds temp = new Seeds(jsonList.jsonDatalist[i].baseID);//这里通过构造函数将静态数据拿到
temp.ID = jsonList.jsonDatalist[i].jsonID;//赋值动态数据ID
temp.count = jsonList.jsonDatalist[i].jsonCount;//赋值动态数据count
tempSeed.Add(temp);
}
}
seedsList = tempSeed;
}
}
然后现在再来编写JsonManger脚本:
//这里一定要记得调用这两个指令集
using LitJson;
using System.IO
主要内容如下:
string Seedpath = Application.dataPath + "/Resources/ItemData/seedData.json";
//读取背包的数据
public void ReadBagData()
{
if (!File.Exists(Seedpath))
{
return;
}
using (StreamReader sr = new StreamReader(new FileStream(Seedpath, FileMode.Open)))
{
string json = sr.ReadToEnd();
JsonList tempList = new JsonList();
tempList = JsonMapper.ToObject<JsonList>(json);
BagManager.instance.jsonList = tempList;
}
BagManager.instance.RefreshBagData();
}
//存储背包的数据
public void SaveBagData()
{
string json = JsonMapper.ToJson(BagManager.instance.jsonList);
if (!File.Exists(Seedpath))
{
return;
}
using (StreamWriter sw=new StreamWriter(new FileStream(Seedpath,FileMode.Truncate)))
{
sw.Write(json);
}
}
现在一个个包含完整数据的Seeds(种子)都放在了BagManager中的seedsList中去了,现在要做的就是将这些种子在UI界面中显示出来。准备好一个预制体,将需要显示的数据通过对应UI控件来显示出来。所以我们还得写一个BagSeeds的脚本控制预制体将这些数据一一显示出来。我们先看预制体的准备。
在这个预制体中seed是一个Image,Count,name,info都是Text,而instructions一个Plane。再来看我们的BagSeeds脚本。
//记得使用UI和EventSystems相关的指令集
using UnityEngine.UI;
using UnityEngine.EventSystems;
主要的显示方法:
public void SetData(Seeds seed)
{
if (seed == null)
{
return;
}
this.seed = seed;
this.bagIcon.sprite= Resources.Load<Sprite>("Art/"+this.seed.picPath);
this.Count.text = this.seed.count.ToString();
this.Name.text = this.seed.name;
this.info.text = this.seed.description;
}
最后还需要一个UIManager脚本来负责将这些预制体实例化出来,将UIManger编辑好了以后了挂载一场景中的空物体上就好了。下面展示一下主要的方法:
public void ShowBagData()
{
List<Seeds> seedInfo = new List<Seeds>();
seedInfo = BagManager.instance.seedsList;
for (int i = 0; i < seedInfo.Count; i++)
{
if (seedInfo == null || seedInfo.Count == 0)
{
return;
}
//SeedTemp就是准备好的预制体,seedBar是种子背包
GameObject go = Instantiate(SeedTemp, seedBar.transform);
BagSeeds bagSeed = go.GetComponent<BagSeeds>();
if (bagSeed == null)
{
continue;
}
bagSeed.SetData(BagManager.instance.seedsList[i]);
}
}
public void ReadJson()
{
JsonManager.instance.ReadBagData();
ShowBagData();
//这里的bagSeeds是一个BagSeeds的数组
bagSeeds = GetComponentsInChildren<BagSeeds>();
for (int i = 0; i < bagSeeds.Length; i++)
{
foreach (var item in bagSeeds)
{
//这里的列表是存储游戏中实际生成的BagSeeds
BagActualList.Add(item);
}
}
}
这里需要大家自己用UI控件做好背包栏哦,文章中没有详细说明制作方法,大家可以进行自由发挥哦,不过提醒大家记得根据自己需求添加上Grid Layout Group组件或者Horizontal Layout Group组件哦。现在让我们来看看效果。
ScriptableObject的使用
不知道大家发现了吗,在上图中的左下角有一个金钱的显示,金钱的数量也是会即使存储的,而他的实现就是使用的ScriptableObject。这里我只是给大家“抛个砖”简单的介绍一下使用方法。ScriptableObject是一个用于生成单独Asset的结构。同时,它也能被称为是Unity中用于处理序列化的结构。就是我们工程中的所有的Object都能够通过这个方法进行数据的序列化与反序列化,方便从中获取各项数据。因为ScriptableObject,是Unity为我们提供的工具所以在里面我们可以直接定义多种Unity支持的数据类型(比如:Vector3,Quaternion等)。这里我“抛个砖”简单的介绍一下他的使用方法。
首先新建一个Money类,让这个类继承ScriptableObject基类。在这个类中编写好你需要的内容。这个时候注意要在类上面加上这样一段:
[CreateAssetMenu(fileName ="New Money",menuName ="Inventory/New Money")]
这里的意思是在Assets文件夹中点击鼠标右键以后,菜单栏中新加一个按钮选项,此选项的菜单名(menuName)是什么,点击后生成文件名(fileName)为什么的脚本。按照自己的要求编写好以后了,按照上述步骤生成一个Money类。就作为玩家的金钱,再在UI上显示出来就好了。
现在背包部分已经有个样子了,接下来就是土地的部分实现,思路和背包其实是一样的。我们要一个土地类(Land)用来存放土地的各项数据,一个土地管理脚本(FieldManager)负责把读取到的土地放进一个列表中去,一个土地的预制体(newField)用于根据数据在游戏中生成土地,一个挂载在土地预制体上的脚本(Field)负责将信息的记录和更新还有主要的种植过程的逻辑,别忘了编写好存有土地信息的json文件(land.json)和在JsonManager中添加上对土地数据的读写。
public enum landState //通过枚举写出土地的所有状态
{
canReclaim,
canPlant,
needWater,
growth_1,
growth_2,
growth_3,
mature,
}
public class Land //土地主要包含的数据
{
public int ID;
public landState state;
public Location landPos;
public int landPrefab;
public int seedID;
public int startTime;
}
public class Location
{
public int x;
public int y;
public int z;
}
这里大家会发现为什么我把土地的位置单独封装了一个Location类而不是直接使用Vector3类型。为什么呢?是因为LitJson不支持Vector3呀。所以这里大家要注意一下。
Vector3 pos = new Vector3(cLands[i].landPos.x, cLands[i].landPos.y, cLands[i].landPos.z);
GameObject go = Instantiate(landPrefab, pos,landPrefab.transform.rotation );
go.transform.parent = landParents.transform;
将土地生成在指定的地点。
这里说明一下土地在几个状态之间的变化,在可开垦(canReclaim)状态时和在可种植(canPlant)状态下的MeshFileter中的Mesh是不同的。玩家种下种子后土地状态发生改变(growth_1,growth_2,growth_3,)会在土地的中心点上生成对应的种子模型,每一次完成一次成长时间会转到需要浇水的状态(needWater)并且再玩家进行浇水操作之后从新记录开始时间,最后就是成熟(mature)。根据这个流程完成脚本。
//更换田地的Mesh
public void LoadMesh()
{
meshFilter.mesh = GetLandMesh(meshID);
}
//获取到田地的Mesh
public Mesh GetLandMesh(int n)
{
newField f = GameObject.Find("LandManger").GetComponent<newField>();
Mesh mesh = new Mesh();
for (int i = 0; i < f.meshList.Count; i++)
{
if (i == n)
{
mesh = f.meshList[i];
}
}
return mesh;
}
这里需要我事先将准备好的两种Mesh放在了newField脚本中的一个列表里。再根据土地状态的变化来进行获取。
//生成对应的种子预制体
public void CreateSeedModel()
{
if (seedInLand == -1)
{
return;
}
Seeds seed = new Seeds(seedInLand);
PlantGrowingTime = seed.GetPlantGrowthTime();
PlantModel = seed.GetPlantPrefab(seed.Prefab);
GameObject plant = Instantiate(PlantModel[plantedStage], center.position, center.rotation);
plant.transform.parent = center;
}
这里的seedInLand就是当前种植再土地中的种子ID,根据当前种子的ID来获取到所有的预制体,用一个列表来存取使用他们。
好了现在大家编写好了土地的相关内容了,接下来的商店部分和游戏里的时间管理都是基于前面一样的思路完成的。这里单独的提一下在时间管理脚本中大家可以使用协程来完成现实时间和游戏之间的关联这里我就举一个例子:
public IEnumerator TimeIncrease()
{
while (true)
{
while (gameTime.time <= 3600)
{
int newTime = (int)(DateTime.Now - DateTime.Parse("2021-7-7")).TotalSeconds;
int paseTime = newTime - gameTime.lastTime;
gameTime.time += paseTime * 2;
gameTime.lastTime = newTime;
yield return new WaitForSeconds(1);
ShowTheTime();
}
gameTime.day += 1;
ShowTheTime();
}
}
这里还是先声明一个时间类(GameTime),类中的内容大家可以自行设计。上图协程实现的就是游戏中的时间流逝是现实时间的两倍,并且通过计算上一次退出游戏时最后的时间和下一次打开游戏的当前时间来计算出中途经过的时间来计算我们种的菜是否成熟。
最后我们来看看我们的成果: