系列文章目录
麦田物语第二十天
一、构建地图信息系统
我们上一节课已经做好了鼠标的显示,这节课需要构建地图的一些信息,例如:可挖坑,可丢弃物品等地区。我们点击地图时,只有鼠标位于规定的地区时才会出现相应的鼠标图片。
怎么我们怎么编辑才能让Unity知道这块是允许挖坑的呢?
简单实现方法:
1.我们可以创建自定义的瓦片地图,也就是Tile,如果你创建一个新的代码并继承自Tile,那么我们就可以创建一个自定义的Tile。例如我们使用的Rule Tile,他挂载了也是继承自Tile的脚本,所以理论上我们希望某一个格子上面有着一些代码的逻辑或者属性的话,我们就可以创建一个代码,挂载在一个空的物体上,然后将这个物体放入Default Sprite的Default GameObject中作为它的索引,如下图,那么系统就会识别到每一个格子对应的空物体身上的代码以及他的属性了。(这是一个最基本的解决方案,但是只适用于小地图!!!)
2.Unity的2D Tilemap Extras拓展包提供了Grid Information脚本,这个脚本需要挂载在Grid(网格)上,这里面有一些方法,可以设置每一个确定位置坐标的属性,并且可以通过Get的方式去拿到它的属性;在本项目中,一个格子可能包含多个bool属性,可以丢弃物品,可以种东西等,所以也没有办法通过Grid Information来拿到这个属性。
上面介绍的这两种方法在小地图上都是可以使用的,下面我们需要介绍一下本项目中用到的方法。
我们想要手动编写一套系统,让这套系统可以挂载在咱们当时设置的Grid Property下面的四个Tilemap上,我们在每一个Tilemap的瓦片地图上绘制信息,在通过该代码拿到瓦片地图上绘制的信息,传递给一个ScriptObject,用来存储当前坐标的对应属性,例如canDig,canDropItem等,然后将这些与当前的地图连接上(因为存在多个地图),保证每一张地图有着各自的属性。
我们首先需要创建的是单个地图的信息,因此我们需要编写储存信息类型TileProperty,返回DataCollection脚本;这个类型中需要包含网格的位置信息tileCoordinate,接着因为我们需要一个枚举变量了解这个瓦片地图属于什么类型,所以还要回到Enums脚本编写一个枚举类型GridType,最后我们还需要一个bool类型的变量boolTypeValue(我也不知道具体是干嘛的)
Enums脚本的枚举变量GridType代码如下:
public enum GridType
{
Diggable, DropItem, PlaceFurniture, NPCObstacle
}
DataCollection脚本的网格信息类型tileProperty代码如下:
[System.Serializable]
public class TileProperty
{
public Vector2Int tileCoordinate;
public GridType gridType;
public bool boolTypeValue;
}
我们会在Unity将这个数据类型读取到瓦片上,然后按照实际的范围去访问所有的网格坐标,将坐标拿到的值对应存储到SO文件中。
现在我们返回Unity,单独创建Scripts-> Map->Data文件夹来构建地图,接着在Data文件夹中添加脚本MapData_SO,使得每一个场景都可以有一个SO文件用来存储网格的相关属性。
接着编写MapData_SO脚本(和之前的SO文件编写类似),需要注意的就是里面属性的定义,我们需要定义的是场景的名字sceneName,并将其使用咱们当时编写的Attribute,最后我们需要创建的是一个TileProperty的列表,返回Unity我们就可以创建这个SO文件了,并将文件改名为MapData_Field(位置为GameData->Map Data)。
我们可以通过拖拽框选择场景的名字,这些属性我么你也要通过代码将其添加进去,通过这个代码,在我们将所需的范围绘制好了之后,我们将绘制的网格对应的坐标等信息保存到SO文件中。
接着创建脚本Scripts->Map->Logic->GridMap脚本,我们编写的这个脚本是挂载在瓦片地图上的,因此我们添加usingUnityEngine.Tilemap;接着我们需要编写一个变量,首先就是SO变量,接着我们需要创建GridType的枚举变量,用来定义瓦片地图的属性是什么,最后就是我们的瓦片地图了;将Field场景中的Grid Properties设置为可视状态,然后给其下面的四个Tilemap添加Gridmap这个脚本,之后返回Unity进行赋值(记得选择Grid Type);
我们怎么将我们绘制的地图信息存储到我们的SO文件当中呢?
首先这个脚本我们需要在EditMode下去运行,同时希望地图被关闭时去读所有的数据,每次启动地图时会刷新这些网格数据,然后存储到瓦片地图的MapData_SO中,因为我们使用OnEnable和OnDisable方法,我们希望所有的函数都在编辑模式下,而非运行当中,所以我们在OnEnable和OnDisable中进行判断,如果没有处于运行状态,我们就获取这个物体的Tilemap;接着我需要获取得到的mapData是否为空,如果不为空的话,我们将mapData中的信息全部清空。在OnDisable方法中也是如此,只不过不清空属性,而是将网格属性更新到SO文件中。
接着我们查看Unity官方文档,我们查看Tilemap的代码,里面的CompressBounds方法可以获取真实存在的所有网格信息,通过这个方法可以压缩拿到最小的可视性的范围(我其实也不是很懂)。
我们编写UpdateTileProperties方法,在这个方法中我们先调用CompressBounds方法。接下来我们还是要进行一些判断,如上面相同,首先该代码必须不在游戏运行模式下,并且mapData不为空才能接着执行下面的代码,我们要获得坐标的范围,即左下角和右上角网格的坐标(Vector3Int类型),然后循环这里面的所有网格,拿到每一个格子tile,如果tile不为空的话,我们新建一个TileProperty属性,并对其赋值,最后将这个TileProperty添加到mapData中。
我们接下来就可以将UpdateTileProperties方法在OnDisable方法中调用了,但是ScriptObject类型有一个特性,就是你需要保存他,如果你临时修改之后退出Unity再回来这个属性就丢失了。所以我们要将其标记为Dirty,只有这样才能实时的进行保存和修改。并且为了确保该部分代码是在我们的Editor编辑器里面去执行的,所以可以加上#if UNITY_EDITOR和#endif(这个文件是会打包到游戏里面的(不懂,呜呜呜,之后自己去查一下))。
我们已经编写好了代码,接着返回Unity,绘制相应的层(用Collision那一个Tilemap就可),绘制完成后关闭Grid Properties这个物体,我们查看SO 文件就会发现已经存储好了我们需要的网格的数据。
二、生成地图数据
在本节课中我们创建地图数据的管理类。我们可以发现我们上节课保存的格子信息里面同一个格子可能会有好几个属性,但是由于不同的属性就会多存储好几个数据,因为我们可以简化这个格子的数据。
那么怎么去简化这个信息呢,我们可以创建一个类似于ItemDetails类型的类TileDetails,我们在DataCollection中来编写TileDetails类,瓦片信息包括瓦片坐标,是否可挖坑,是否可丢弃,是否可以放置家具,是否为NPC障碍,还有其他的int类型的变量,包括已经被挖了多少天了,已经浇了多少天水了,当前土地的种子信息,当前种子成长了多长时间,距离上次收割的时间。
DataCollection脚本的TileDetails代码如下:
[System.Serializable]
public class TileDetails
{
public int gridX, gridY;
public bool canDig;
public bool canDropItem;
public bool canPlaceFurniture;
public bool isNPCObstacle;
public int daySceneDug = -1;
public int daysSinceWatered = -1;
public int seedItemID = -1;
public int growthDays = -1;
public int daySinceLastHarvest = -1;
}
接着在PresistentScene场景中添加空物体GridMap Manager,然后创建Scripts->Map -> Logic -> GridMapManager脚本,接着编写该脚本,我们首先为其添加命名空间MFarm.Map,我们的游戏会有很多场景,所以会有很多SO文件用来保存每个场景中格子的信息,因为我们首先定义MapData_SO类型的数组,并返回Unity为其赋值(目前只有场景1Filed的SO文件);接着我们定义字典来保存场景名和场景中格子信息,但是该字典的键值对的键采用瓦片的x和y坐标+场景名称,这样可以减少重复。
现在我们要进行初始化字典了InitTileDetailsDict,我们循环mapData的tileProperties数组,每一个tilePropertie都要新建一个TileDetails类型的数据,并对其进行赋值,最后按照我们生成键的方法将该TileDetails添加进去即可。(该字典也会因为地图格子数据的改变而再次改变,所以当我们改变了地图数据后,我们在字典中通过key值查找是否有对应的TileDetails,有的话返回它,没有的话返回null,接着在上方调用这个方法GetTileDetails,如果存在相应的值,那么我们进行更新,但是没有的话我们直接插入键值对就可以了),但是TileDetails除了x,y之外还有其他的变量,那些bool值也需要在该方法中被赋值,我们利用Switch来对这些bool值进行赋值,最后再进行我们之前说的更新即可。
GridMapManager脚本的InitTimeDetailsDict方法和GetTileDetails方法代码如下:
private void InitTileDetailsDict(MapData_SO mapData)
{
foreach (TileProperty tileProperty in mapData.tileProperties)
{
TileDetails tileDetails = new TileDetails
{
gridX = tileProperty.tileCoordinate.x,
gridY = tileProperty.tileCoordinate.y
};
string key = tileDetails.gridX + "x" + tileDetails.gridY + "y" + mapData.sceneName;
if (GetTileDetails(key) != null)
{
tileDetails = GetTileDetails(key);
}
switch(tileProperty.gridType)
{
case GridType.Diggable:
tileDetails.canDig = tileProperty.boolTypeValue;
break;
case GridType.DropItem:
tileDetails.canDropItem = tileProperty.boolTypeValue;
break;
case GridType.PlaceFurniture:
tileDetails.canPlaceFurniture = tileProperty.boolTypeValue;
break;
case GridType.NPCObstacle:
tileDetails.isNPCObstacle = tileProperty.boolTypeValue;
break;
}
if (GetTileDetails(key) != null)
{
tileDetailsDict[key] = tileDetails;
}
else
{
tileDetailsDict.Add(key, tileDetails);
}
}
}
/// <summary>
/// 根据字典的key返回瓦片信息
/// </summary>
/// <param name="key">x+y+地图名字</param>
/// <returns></returns>
private TileDetails GetTileDetails(string key)
{
if (tileDetailsDict.ContainsKey(key))
{
return tileDetailsDict[key];
}
else
{
return null;
}
}
通过这个方法我们就可以生成对应SO的字典数据了,现在我们在Start方法中循环mapDataList对每一个SO文件进行字典数据的初始化。
我们构建好了字典之后,我们来到CursorManager中,当我们选择锤子后,鼠标放在特地位置需要产生相应的效果,就是我们鼠标放在可以建造的位置判断是否可以建造。
接下来我们创建一系列变量来实现这个功能,此时就需要补充两个知识,我们鼠标移动时是屏幕坐标,我们要切换成世界坐标,因此就要拿到主相机(MainCamera),屏幕坐标转换为世界坐标之后,还要转换成网格坐标,因为我们需要判断的都是整数型的网格坐标,因此我们需要拿到Grid(当前场景的网格)。
然后是这两个变量的初始化,我们在Start方法中拿到主相机,但是Grid是当前场景的网格,因此我们需要在切换场景后拿到,所以我们添加EventHandler的事件并添加OnAfterSceneLoadedEvent方法就行(但是这块有一个问题,等会我们来揭晓)。
我们继续编写CheckCursorValid方法来检测鼠标指针是否可用,这个方法最后在Update中去调用,在这个方法中我们要获得鼠标的世界坐标和网格坐标,因此我们需要先去上面定义这两个变量,然后再进行获取(这个获取方法一定要记住),最后为了验证我们使用Debug.Log去输出网格信息。
但是当我们返回Unity运行发现出现了报空错误,因为我们找不到了Grid(当前地图的网格),这就是我刚才说的那个问题,因为我们是在跳转场景之后找到的Grid,但是我们第一次运行游戏时没有得到Grid,因为我们回到TransitionManager脚本中,在Start方法中我们在加载场景后呼叫一下CallAfterSceneLoadedEvent方法就行了(麦扣老师这里吧Start方法改为协程方法,并调用(yield return )LoadSceneSetActive方法,而不是之前通过StartCoroutine,最后呼叫就好了,这个改动我其实有点不太懂),返回Unity,发现还是有报空存在,这次又是因为啥嘞?
因为我们是在TransitionManager脚本中的Start方法是在加载场景后呼叫的,因为在场景加载好之前鼠标是不可用的,所以我们首先声明一个bool类型的值cursorEnable,在添加BeforeSceneUnloadEvent事件后,同时也添加OnBeforeSceneUnloadEvent方法,在OnBeforeSceneUnloadEvent将cursorEnable设置为false,在OnAfterSceneLoadedEvent将cursorEnable设置为true,只有cursorEnable为true时并且不和UI进行互动时,才能执行SetCursorImage(currentSprite);和CheckCursorValid();方法
GridMapManager脚本的代码如下:
namespace MFarm.Map
{
public class GridMapManager : MonoBehaviour
{
[Header("地图信息")]
public List<MapData_SO> mapDataList;
//场景名字+坐标和对应的瓦片信息
private Dictionary<string, TileDetails> tileDetailsDict = new Dictionary<string, TileDetails>();
private void Start()
{
foreach (MapData_SO mapData in mapDataList)
{
InitTileDetailsDict(mapData);
}
}
private void InitTileDetailsDict(MapData_SO mapData)
{
foreach (TileProperty tileProperty in mapData.tileProperties)
{
TileDetails tileDetails = new TileDetails
{
gridX = tileProperty.tileCoordinate.x,
gridY = tileProperty.tileCoordinate.y
};
string key = tileDetails.gridX + "x" + tileDetails.gridY + "y" + mapData.sceneName;
if (GetTileDetails(key) != null)
{
tileDetails = GetTileDetails(key);
}
switch(tileProperty.gridType)
{
case GridType.Diggable:
tileDetails.canDig = tileProperty.boolTypeValue;
break;
case GridType.DropItem:
tileDetails.canDropItem = tileProperty.boolTypeValue;
break;
case GridType.PlaceFurniture:
tileDetails.canPlaceFurniture = tileProperty.boolTypeValue;
break;
case GridType.NPCObstacle:
tileDetails.isNPCObstacle = tileProperty.boolTypeValue;
break;
}
if (GetTileDetails(key) != null)
{
tileDetailsDict[key] = tileDetails;
}
else
{
tileDetailsDict.Add(key, tileDetails);
}
}
}
/// <summary>
/// 根据字典的key返回瓦片信息
/// </summary>
/// <param name="key">x+y+地图名字</param>
/// <returns></returns>
private TileDetails GetTileDetails(string key)
{
if (tileDetailsDict.ContainsKey(key))
{
return tileDetailsDict[key];
}
else
{
return null;
}
}
}
}
CursorManager脚本的代码如下:
public class CursorManager : MonoBehaviour
{
public Sprite normal, tool, seed, item;
//存储当前图片
private Sprite currentSprite;
private Image cursorImage;
private RectTransform cursorCanvas;
//鼠标检测
//屏幕坐标切换为世界坐标就是需要调用mainCamera
private Camera mainCamera;
//将屏幕坐标转化为网格坐标需要拿到Grid,切换场景时要切换成当前场景的Grid
private Grid currentGrid;
private Vector3 mouseWorldPos;
private Vector3Int mouseGridPos;
private bool cursorEnable;
private void Start()
{
cursorCanvas = GameObject.FindGameObjectWithTag("CursorCanvas").GetComponent<RectTransform>();
cursorImage = cursorCanvas.GetChild(0).GetComponent<Image>();
currentSprite = normal;
SetCursorImage(normal);
//MainCamera一定是被标记为MainCamera的相机
mainCamera = Camera.main;
}
private void Update()
{
if (cursorImage == null) return;
cursorImage.transform.position = Input.mousePosition;
if (!InteractWithUI() && cursorEnable)
{
SetCursorImage(currentSprite);
CheckCursorValid();
}
else
{
SetCursorImage(normal);
}
}
private void SetCursorImage(Sprite sprite)
{
cursorImage.sprite = sprite;
cursorImage.color = new Color(1, 1, 1, 1);
}
private void OnEnable()
{
EventHandler.ItemSelectedEvent += OnItemSelectedEvent;
EventHandler.BeforeSceneUnloadEvent += OnBeforeSceneUnloadEvent;
EventHandler.AfterSceneLoadedEvent += OnAfterSceneLoadedEvent;
}
private void OnDisable()
{
EventHandler.ItemSelectedEvent -= OnItemSelectedEvent;
EventHandler.BeforeSceneUnloadEvent -= OnBeforeSceneUnloadEvent;
EventHandler.AfterSceneLoadedEvent -= OnAfterSceneLoadedEvent;
}
private void OnItemSelectedEvent(ItemDetails itemDetails, bool isSelected)
{
if (!isSelected)
{
currentSprite = normal;
}
else
{
//添加所有类型对应图片
currentSprite = itemDetails.itemType switch
{
ItemType.Seed => seed,
ItemType.Commodity => item,
ItemType.ChopTool => tool,
ItemType.HoeTool => tool,
ItemType.WaterTool => tool,
ItemType.BreakTool => tool,
ItemType.ReapTool => tool,
ItemType.Furniture => tool,
_ => normal,
};
}
}
/// <summary>
/// 判断是否跟UI互动
/// </summary>
/// <returns></returns>
private bool InteractWithUI()
{
if (EventSystem.current != null && EventSystem.current.IsPointerOverGameObject())
{
return true;
}
else
return false;
}
private void OnBeforeSceneUnloadEvent()
{
cursorEnable = false;
}
private void OnAfterSceneLoadedEvent()
{
currentGrid = FindObjectOfType<Grid>();
cursorEnable = true;
}
private void CheckCursorValid()
{
mouseWorldPos = mainCamera.ScreenToWorldPoint(new Vector3(Input.mousePosition.x, Input.mousePosition.y,-mainCamera.transform.position.z));
mouseGridPos = currentGrid.WorldToCell(mouseWorldPos);
Debug.Log(mouseGridPos);
}
}
TransitionManager脚本的Start方法如下:
private IEnumerator Start()
{
fadeCanvasGroup = FindObjectOfType<CanvasGroup>();
yield return LoadSceneSetActive(startSceneName);
EventHandler.CallAfterSceneLoadedEvent();
}