视频展示:
unity农场游戏(包含背包、种植系统及npc使用A*寻路)_哔哩哔哩bilibili_演示
目录
种植系统
构建地图信息系统
生成地图数据
挖坑与浇水的实现
地图内容的存储与瓦片随时间更新
种子数据库制作
种下种子和种子随时间生长
收割作物
种植树木和砍树及晃动效果
在实现种植系统前,需要对整个瓦片地图设定不同的块,每个瓦片都应该具有属性。
根据地图的不同块属性切换不同的鼠标:
我们希望当选择了工具,并且鼠标放在可以种地的地方时,然后切换指针。
我们可以给瓦片地图添加一个系统自带的脚本,叫做Grid Information,它可以设置属性:
这是一个很好的思路,但是这个仅局限于将一个格子设置成单一的属性。但我们希望一个格子可以具有多个属性,比如可以种地,也可以丢物品到其上面。
接下来则自己实现一个grid information的系统
创建枚举类型,标识是什么类型的瓦片:
创建一个SO文件,并且该SO文件包含图块的信息:
接下来创建一个脚本,我们让四个需要标注出来瓦片信息的地图挂载这个脚本,当我们去绘制这个瓦片地图时,将绘制出的网格信息通过这个脚本储存在SO中。
脚本如下:
using UnityEditor;
using UnityEngine;
using UnityEngine.Tilemaps;
//我们希望在编辑的模式下运行该代码
[ExecuteInEditMode]
public class GridMap : MonoBehaviour
{
public MapData_SO mapData;
public GridType gridType;
private Tilemap currentTilemap;
private void OnEnable()
{
if (!Application.IsPlaying(this))
{
currentTilemap = GetComponent
if (mapData != null)
mapData.tileProperties.Clear();
}
}
private void OnDisable()
{
if (!Application.IsPlaying(this))
{
currentTilemap = GetComponent
UpdateTileProperties();
#if UNITY_EDITOR
if (mapData != null)
EditorUtility.SetDirty(mapData);//设为脏数据后,才能进行保存和读取
#endif
}
}
private void UpdateTileProperties()
{
currentTilemap.CompressBounds();
if (!Application.IsPlaying(this))
{
if (mapData != null)
{
//已绘制范围的左下角坐标
Vector3Int startPos = currentTilemap.cellBounds.min;
//已绘制范围的右上角坐标
Vector3Int endPos = currentTilemap.cellBounds.max;
//循环遍历地图从左下角到右上角
for (int x = startPos.x; x < endPos.x; x++)
{
for (int y = startPos.y; y < endPos.y; y++)
{
//根据x和y坐标读取场景中的图块的坐标
TileBase tile = currentTilemap.GetTile(new Vector3Int(x, y, 0));
//生成图块信息
if (tile != null)
{
TileProperty newTile = new TileProperty
{
tileCoordinate = new Vector2Int(x, y),
gridType = this.gridType,
boolTypeValue = true
};
mapData.tileProperties.Add(newTile);
}
}
}
}
}
}
}
让四个需要记录瓦片信息的地图挂载脚本并选择对应的类型:
当绘制完地图:
然后关闭时:
即可看到信息被储存下来了。
在上文中我们在SO文件中储存了每个地图块都是什么信息,那么接下来我们需要将其存储在代码中的数据结构。接下来实现这个功能。
首先需要创建一个Manager ,方便其他的类型到这个Manager去调用每一个地图的信息。
这个Manager包含有所有地图信息SO文件的一个List:
在DataCollection中书写一个瓦片的信息,包含了坐标,属性,以及后续会用到的种植系统中相关的物品。SO文件中的瓦片信息将被赋值到这里面。
使用字典这种数据结构来储存每个瓦片的名字和其对应的属性。
//场景名字+坐标和对应的瓦片信息
private Dictionary
接下来根据绘制好的地图中的SO文件去更改:
///
/// 根据地图信息生成字典
///
/// 地图信息
private void InitTileDetailsDict(MapData_SO mapData)
{
foreach (TileProperty tileProperty in mapData.tileProperties)
{
TileDetails tileDetails = new TileDetails
{
gridX = tileProperty.tileCoordinate.x,
gridY = tileProperty.tileCoordinate.y
};
//字典的Key
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);
}
}
赋值完后,还需要提供一个对外的接口,用于让其他物品从manager中根据名字来获取对应的瓦片信息:
///
/// 根据key返回瓦片信息
///
/// x+y+地图名字
///
private TileDetails GetTileDetails(string key)
{
if (tileDetailsDict.ContainsKey(key))
{
return tileDetailsDict[key];
}
return null;
}
接下来实现当鼠标放在不同瓦片时,鼠标会显示不同的指针的功能。
首先需要获取相机和网格的物体,以及鼠标的世界坐标和网格坐标。
我们要获取鼠标的在网格坐标中的位置
获得方法如下,通过调用函数,在正式使用前可以先Debug一下看下是否正确。
这个函数放在Update中调用,当鼠标不和UI交互时使用:
因为上面需要获取场景中的地图信息,而不同的场景其地图不同,因此我们需要在每当场景变更时去重新获取地图,因此使用event来实现:
上面这些处理做完后直接运行会发现报错,原因是因为初始进入场景时并不会执行AfterSceneLoadedEvent,为了使得场景加载后调用这个,我们在Start函数后面添加调用这个事件的代码。
同时为了使得保证在场景加载完毕后再调用,此处想到协程的yield return 后面的内容是在该语句执行完后才会执行的,所以想到可以将原来的Start函数改为协程的方式。
改动后如下:
此时鼠标放到不同位置时可以看到debug的坐标信息了:
但是注意到此处还是会有一个报错,原因是因为,由于Update会一直执行,
因此即便此时场景没加载好,也会调用之前书写的CheckCursorValid函数,而这个函数需要获取地图。但是此时场景没加载好因此没有地图。
因此我们可以书写一个设定,只有当场景加载完成时,才能调用鼠标的相关功能。并且在切换到新的场景前,先禁用鼠标相关函数,切换完毕后,再调用。
此处使用一个bool变量来实现。场景加载完毕才设置为true。
接下来设定鼠标的样式:
在CursorManager中增添三个用来控制鼠标样式的函数:
#region 设置鼠标模式
///
/// 设置鼠标图片
///
///
private void SetCursorImage(Sprite sprite)
{
cursorImage.sprite = sprite;
cursorImage.color = new Color(1, 1, 1, 1);
}
///
/// 设置鼠标可用
///
private void SetCursorValid()
{
cursorPositionValid = true;
cursorImage.color = new Color(1, 1, 1, 1);
}
///
/// 设置鼠标不可用
///
private void SetCursorInValid()
{
cursorPositionValid = false;
cursorImage.color = new Color(1, 0, 0, 0.4f);
}
#endregion
在GripMapManager中添加一个根据当前鼠标位置和场景名字返回瓦片信息的函数
为了外部能调用这个manager别忘了将其改为单例模式。
于是我们就可以在cursorManager中我们获取了鼠标的位置,然后我们通过调用gridMapManager的根据位置返回瓦片信息的函数来获取当前的瓦片信息:
这样就可以实现根据鼠标位置改变不同指针。
选中物品与远程丢置
最终效果:
然后变成这样:
很绝望,突然有一天我这些存储在word文档里的笔记里的图片全部损坏,无法显示,只剩下录制的动图还在了……
于是下面的笔记没有图了…
接下来实现选中物品后人物执行对应的动画以及相关的事件
先在cursorManager中设定接受玩家输入然后执行事件:
我们通过事件来实现:
由于该事件和玩家有关,所以在玩家那里也需要注册:
我们不希望人物刚执行动画时就改变物体,希望动画结束时才执行,因此此处使用新增一个事件:
然后在player点击事件中添加该事件:
在执行人物动画结束后的相关事件我们放在GridMapManager中去执行,是因为这个操作会有很多和修改地图相关的操作。
接下来书写在动画后需要执行的函数:
简单来说就是获取当前的地图和鼠标所指位置,然后在指定地点生成物品。
由于需要获取当前的瓦片信息,所以还需要获得当前的瓦片地图,由于场景变更时地图都会改变,因此使用事件AfterSceneLoadedEvent来为瓦片地图赋值:
这样就可以实现在地图中可以点击的地方点击后,出现该物品。
但是我们还得对应的去修改背包中的物品的信息,所以在InventoryManager中添加一个移除物品的事件:
首先定义事件:
然后在inventoryManager中注册该事件
然后在InventoryManager中添加一个移除物品的函数:
然后让对应的注册事件的函数去执行下这个函数:
此时我们再丢物品时,只要呼叫这个事件即可:
这样即可实现丢物品的功能:
但是会发现,当物体丢完时,会有个报错,且此时高亮依旧在。
那么怎么改这个bug呢?
注意到我们在每次removeItem时,已经去调用了更新UI相关的事件了。
也就是这个函数:
当该格子的数据为空时会调用UpdateEmptySlot函数。
所以只需要修改UpdateEmptySlot函数即可。
在这个函数中添加取消高亮即可。
前面这里犯了一个错误,丢弃一个物品时会全部一起丢弃,原因在于:
原因是因为mouseButton函数写错了,应该是写成GetMouseButtonDown:
此处再补充修一个bug,之前判断物品是否为空是通过判断item.amount是否为0来判别的,但是这个方法不太好,因为当物品消失时,我们希望把item变为空物体,此时无法再获取它的amount变量了。因此相关的判别应该将item.amount==0改为item==null
除此之外还有一个bug,就是丢下的物品在scene窗口中可以看见,在game窗口中却不能看见:
原因是因为生成的物品的位置是按照鼠标位置生成的,而鼠标的位置是和相机的位置一致,
那么此时会导致相机无法将物品拍进去。
解决方法可以是生成物品的时候把物品的z坐标设置为0即可。
接下来实现投掷出去的效果
思路很简单,为了有真实感,需要给物品添加阴影:
阴影用相同的物品,颜色设置为黑色,有一定透明度即可:
为这个阴影添加一个shadow脚本用于获取其sprite render。
接下来实现投掷的效果,只需要让木头以弧线过去,而阴影是直线过去,这样即可模拟投掷的效果。
代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace MFarm.Inventory
{
public class ItemBounce : MonoBehaviour
{
private Transform spriteTrans;
private BoxCollider2D coll;
public float gravity = -3.5f;
private bool isGround;
private float distance;
private Vector2 direction;
private Vector3 targetPos;
private void Awake()
{
spriteTrans = transform.GetChild(0);
coll = GetComponent
coll.enabled = false;//初始时关闭碰撞体,防止刚生成出来又触碰到玩家
}
private void Update()
{
Bounce();
}
public void InitBounceItem(Vector3 target, Vector2 dir)
{
coll.enabled = false;
direction = dir;
targetPos = target;
distance = Vector3.Distance(target, transform.position);
spriteTrans.position += Vector3.up * 1.5f;
}
private void Bounce()
{
isGround = spriteTrans.position.y <= transform.position.y;
if (Vector3.Distance(transform.position, targetPos) > 0.1f)
{
transform.position += (Vector3)direction * distance * -gravity * Time.deltaTime;
}
if (!isGround)
{
spriteTrans.position += Vector3.up * gravity * Time.deltaTime;
}
else
{
spriteTrans.position = transform.position;
coll.enabled = true;
}
}
}
}
在itemManager中将投掷物品的事件的代码改改即可:
最终效果见本章节首部:
思路如下,首先判断玩家是否有鼠标选中的瓦片块其属性是否为提取在inspector窗口中记录的“isDigAble”属性,如果可以,判断该地是否有种子,如果没有种子,则判断该地可以种植,然后将鼠标指针设定为相关的样式。然后如果点击,则接下来执行相应的函数,将瓦片信息更改为对应的图块,然后进行种植,修改该图块上的种子信息ID,及生长的天数。
最终效果如下:
接下来实现胶水和挖坑,我们需要类似之前草地那样的Rule Tile:
然后给两个地图添加上对应的标签:
改变瓦片地图的瓦片使用SetTile函数
创建锄头和浇水壶的物品
然后我们在cursorManager,根据地图该瓦片是否符合规则,改变鼠标能否可以选中的情况:
然后在GridMapManager中无需判断条件,只需要写一个供外部调用的,改变瓦片的接口:
///
/// 显示挖坑瓦片
///
///
private void SetDigGround(TileDetails tile)
{
Vector3Int pos = new Vector3Int(tile.gridX, tile.gridY, 0);
if (digTilemap != null)
digTilemap.SetTile(pos, digTile);
}
///
/// 显示浇水瓦片
///
///
private void SetWaterGround(TileDetails tile)
{
Vector3Int pos = new Vector3Int(tile.gridX, tile.gridY, 0);
if (waterTilemap != null)
waterTilemap.SetTile(pos, waterTile);
}
然后在点击事件中,添加不同的type下执行不同的改变瓦片,和改变当前瓦片信息的代码:
然后即可实现效果。
在GridMapManager中代码:
case ItemType.Commodity:
EventHandler.CallDropItemEvent(itemDetails.itemID, mouseWorldPos);
break;
case ItemType.HoeTool:
SetDigGround(currentTile);
currentTile.daysSinceDug = 0;
currentTile.canDig = false;
currentTile.canDropItem = false;
//音效
break;
case ItemType.WaterTool:
SetWaterGround(currentTile);
currentTile.daysSinceWatered = 0;
//音效
break;
///
/// 显示挖坑瓦片
///
///
private void SetDigGround(TileDetails tile)
{
Vector3Int pos = new Vector3Int(tile.girdX, tile.gridY, 0);
if (digTilemap != null)
digTilemap.SetTile(pos, digTile);
}
///
/// 显示浇水瓦片
///
///
private void SetWaterGround(TileDetails tile)
{
Vector3Int pos = new Vector3Int(tile.girdX, tile.gridY, 0);
if (waterTilemap != null)
waterTilemap.SetTile(pos, waterTile);
}
然后为控制动画的脚本设定一个字典,因为这个动画控制器需要控制身体的三个部位,并且每个部位在不同的工具下可能都会使用不同的动画控制器以达成不同的效果。
接下来实现人物使用工具的动画
为了使得人物可以有一个使用工具的动画,我们对所有动画的基类BaseController中添加一个融合树:
使用工具可以在任何时候进入,进入后则会退出然后回到最初的循环
当满足触发条件时则会进入动画
这个融合树由四个方向的动画组成,由鼠标的参数来控制。
在基类动画控制器中创建好四个动画之后,为arm和hair补充这四个动画进去。
随后在arm的代码的动画控制器字典中添加两个新的动画:
接下来使用代码对动画进行控制:
最后不要忘了在动画控制的脚本中添加新的种类对应的动画:
接下来就可以实现挖坑的动作,而浇水的动作做法同理,不过需要注意,浇水需要改变身体其他地方的动画,因此需要创建如下四个动画控制器并设定好:
然后在代码中做出相应的修改即可
最终效果如下:
最后不要忘了在动画控制的脚本中添加新的种类对应的动画:
接下来就可以实现挖坑的动作,而浇水的动作做法同理,不过需要注意,浇水需要改变身体其他地方的动画,因此需要创建如下四个动画控制器并设定好:
在player中添加代码:
//使用工具动画
private float mouseX;
private float mouseY;
private bool useTool;
private void OnMouseClickedEvent(Vector3 mouseWorldPos, ItemDetails itemDetails)
{
if (useTool)
return;
//TODO:执行动画
if (itemDetails.itemType != ItemType.Seed && itemDetails.itemType != ItemType.Commodity && itemDetails.itemType != ItemType.Furniture)
{
mouseX = mouseWorldPos.x - transform.position.x;
mouseY = mouseWorldPos.y - transform.position.y;
if (Mathf.Abs(mouseX) > Mathf.Abs(mouseY))
mouseY = 0;
else
mouseX = 0;
StartCoroutine(UseToolRoutine(mouseWorldPos, itemDetails));
}
else
{
EventHandler.CallExecuteActionAfterAnimation(mouseWorldPos, itemDetails);
}
}
private IEnumerator UseToolRoutine(Vector3 mouseWorldPos, ItemDetails itemDetails)
{
useTool = true;
inputDisable = true;
yield return null;
foreach (var anim in animators)
{
anim.SetTrigger("useTool");
//人物的面朝方向
anim.SetFloat("InputX", mouseX);
anim.SetFloat("InputY", mouseY);
}
yield return new WaitForSeconds(0.45f);
EventHandler.CallExecuteActionAfterAnimation(mouseWorldPos, itemDetails);
yield return new WaitForSeconds(0.25f);
//等待动画结束
useTool = false;
inputDisable = false;
}
private void SwitchAnimation()
{
foreach (var anim in animators)
{
anim.SetBool("isMoving", isMoving);
anim.SetFloat("mouseX", mouseX);
anim.SetFloat("mouseY", mouseY);
if (isMoving)
{
anim.SetFloat("InputX", inputX);
anim.SetFloat("InputY", inputY);
}
}
}
animator override controller:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using MFarm.Inventory;
public class AnimatorOverride : MonoBehaviour
{
private Animator[] animators;
public SpriteRenderer holdItem;
[Header("各部分动画列表")]
public List animatorTypes;//这个列表是在inspector窗口中去设置的
private Dictionary animatorNameDict = new Dictionary();
private void Awake()
{
animators = GetComponentsInChildren();
foreach (var anim in animators)
{
animatorNameDict.Add(anim.name, anim);//根据在inspector窗口中设定的内容设定字典
}
}
private void OnEnable()
{
EventHandler.ItemSelectedEvent += OnItemSelectedEvent;
EventHandler.BeforeSceneUnloadEvent += OnBeforeSceneUnloadEvent;
EventHandler.HarvestAtPlayerPosition += OnHarvestAtPlayerPosition;
}
private void OnDisable()
{
EventHandler.ItemSelectedEvent -= OnItemSelectedEvent;
EventHandler.BeforeSceneUnloadEvent -= OnBeforeSceneUnloadEvent;
EventHandler.HarvestAtPlayerPosition -= OnHarvestAtPlayerPosition;
}
private void OnBeforeSceneUnloadEvent()
{
holdItem.enabled = false;
SwitchAnimator(PartType.None);
}
private void OnHarvestAtPlayerPosition(int ID)
{
Sprite itemSprite = InventoryManager.Instance.GetItemDetails(ID).itemOnWorldSprite;
if (holdItem.enabled == false)
{
StartCoroutine(ShowItem(itemSprite));
}
}
private IEnumerator ShowItem(Sprite itemSprite)
{
holdItem.sprite = itemSprite;
holdItem.enabled = true;
yield return new WaitForSeconds(1f);
holdItem.enabled = false;
}
private void OnItemSelectedEvent(ItemDetails itemDetails, bool isSelected)
{
//WORKFLOW:不同的工具返回不同的动画在这里补全
PartType currentType = itemDetails.itemType switch
{
ItemType.Seed => PartType.Carry,
ItemType.Commodity => PartType.Carry,
ItemType.HoeTool=>PartType.Hoe,
ItemType.WaterTool => PartType.Water,
ItemType.CollectTool=>PartType.Collect,
ItemType.ChopTool => PartType.Chop,
ItemType.BreakTool=>PartType.Break,
_ => PartType.None
};
if (isSelected == false)
{
currentType = PartType.None;
holdItem.enabled = false;
}
else
{
if (currentType == PartType.Carry)
{
holdItem.sprite = itemDetails.itemOnWorldSprite;
holdItem.enabled = true;
}
else
{
holdItem.enabled = false;
}
}
//Debug.Log("currentType is "+currentType);
SwitchAnimator(currentType);
}
private void SwitchAnimator(PartType partType)
{
foreach (var item in animatorTypes)
{
if (item.partType == partType)
{
animatorNameDict[item.partName.ToString()].runtimeAnimatorController = item.overrideController;
}
}
}
效果如下:
我们希望挖好的坑不会随着地图切换而改变,做法思路如下:
首先需要将当前的瓦片的信息储存下来。
///
/// 更新瓦片信息
///
/// name="tileDetails">
private void UpdateTileDetails(TileDetails tileDetails)
{
string key = tileDetails.gridX + "x" + tileDetails.gridY + "y" + SceneManager.GetActiveScene().name;
if (tileDetailsDict.ContainsKey(key))
{
tileDetailsDict[key] = tileDetails;//找到对应的图块的字典并将信息保存下来
}
}
然后在每次对地图进行操作时都执行改函数,记录下图片:
然后每次重载场景时,都需要将之前储存过的信息更新。以浇过的水为例,根据之前储存下的信息,然后更新图块。
///
/// 根据之前存储下的信息显示地图瓦片
///
/// name="sceneName">场景名字
private void DisplayMap(string sceneName)
{
foreach (var tile in tileDetailsDict)
{
var key = tile.Key;
var tileDetails = tile.Value;
if (key.Contains(sceneName))
{
//if (tileDetails.daysSinceDug > -1)
// SetDigGround(tileDetails);
if (tileDetails.daysSinceWatered > -1)
SetWaterGround(tileDetails);//设定曾经浇过的水
//TODO:种子
}
}
}
然后在加载完场景后要做的event里面调用这个函数:
这样即可实现跨场景保留信息。
接下来实现经过一定天数后,浇的水自动消失:
创建一个新的随时间变化的事件,每过一天执行一次
然后对于该事件来说,该事件首先每天会执行一次,并且将和瓦片中和天数有关的信息进行修改。
///
/// 每天执行1次
///
/// name="day">
/// name="season">
private void OnGameDayEvent(int day, Season season)
{
currentSeason = season;
foreach (var tile in tileDetailsDict)
{
if (tile.Value.daysSinceWatered > -1)
{
tile.Value.daysSinceWatered = -1;
}
if (tile.Value.daysSinceDug > -1)
{
tile.Value.daysSinceDug++;
}
超期消除挖坑(如果挖坑后过了几天仍然没有种子,则坑会自动埋上)
//if (tile.Value.daysSinceDug > 5 && tile.Value.seedItemID == -1)
//{
// tile.Value.daysSinceDug = -1;
// tile.Value.canDig = true;
// //SetDigGround()
//}
}
RefreshMap();//删除某些瓦片信息后更新一下
}
实际去刷新删除一些瓦片信息,是通过RefreshMap这个函数,这个函数操作如下:
///
/// 刷新当前地图(删除某些地图的瓦片信息)
///
private void RefreshMap()
{
//if (digTilemap != null)
// digTilemap.ClearAllTiles();
if (waterTilemap != null)
waterTilemap.ClearAllTiles();
DisplayMap(SceneManager.GetActiveScene().name);
}
这样即可实现跨场景储存地图信息和随天数改变瓦片信息。
首先书写种子所包含的信息:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[System.Serializable]
public class CropDetails
{
public int seedItemID;
[Header("不同阶段需要的天数")]
public int[] growthDays;
public int TotalGrowthDays
{
get
{
int amount = 0;
foreach (var days in growthDays)
{
amount += days;
}
return amount;
}
}
[Header("不同生长阶段物品Prefab")]
public GameObject[] growthPrefabs;
[Header("不同阶段的图片")]
public Sprite[] growthSprites;
[Header("可种植的季节")]
public Season[] seasons;
[Space]
[Header("收割工具")]
public int[] harvestToolItemID;
[Header("每种工具使用次数")]
public int[] requireActionCount;
[Header("转换新物品ID")]
public int transferItemID;
[Space]
[Header("收割果实信息")]
public int[] producedItemID;
public int[] producedMinAmount;
public int[] producedMaxAmount;
public Vector2 spawnRadius;
[Header("再次生长时间")]
public int daysToRegrow;
public int regrowTimes;
[Header("Options")]
public bool generateAtPlayerPosition;
public bool hasAnimation;
public bool hasParticalEffect;
//TODO:特效 音效 等
}
接下来创建一个SO,包含所有种子信息:
然后就可以创建种子的SO文件了:
接下来开始书写种下种子的代码,
在鼠标控制的地方,对于已经挖坑过并且可以种下种子的地方设置鼠标的可用,以便后续使用:
种下种子使用事件来完成,种下种子之前需要判断几件事:
满足条件后则将当前地图块的种子ID从0变更为当前的ID,并且设定种植的天数为0。然后刷新地图中的农作物。
代码如下:
private void OnPlantSeedEvent(int ID, TileDetails tileDetails)
{
CropDetails currentCrop = GetCropDetails(ID);
if (currentCrop != null && SeasonAvailable(currentCrop) && tileDetails.seedItemID == -1) //用于第一次种植
{
tileDetails.seedItemID = ID;
tileDetails.growthDays = 0;
//显示农作物
DisplayCropPlant(tileDetails, currentCrop);
}
else if (tileDetails.seedItemID != -1) //用于刷新地图
{
//显示农作物
DisplayCropPlant(tileDetails, currentCrop);
}
}
种下种子后,设定一个用于显示种子的sprite和prefab的函数即DisplayCropPlant
这个函数不仅用来显示种子,还用来当已有物体时,加载场景后刷新地图中的种子:
为此,需要计算它的成长阶段,并根据不同的阶段设立不同的prefab。
///
/// 显示农作物并判断当前是哪个成长阶段
///
/// name="tileDetails">瓦片地图信息
/// name="cropDetails">种子信息
private void DisplayCropPlant(TileDetails tileDetails, CropDetails cropDetails)
{
//成长阶段
int growthStages = cropDetails.growthDays.Length;
int currentStage = 0;
int dayCounter = cropDetails.TotalGrowthDays;
//倒序计算当前的成长阶段
for (int i = growthStages - 1; i >= 0; i--)
{
if (tileDetails.growthDays >= dayCounter)
{
currentStage = i;
break;
}
dayCounter -= cropDetails.growthDays[i];
}
//不同阶段设定不同的prefab和不同的sprite
GameObject cropPrefab = cropDetails.growthPrefabs[currentStage];
Sprite cropSprite = cropDetails.growthSprites[currentStage];
Vector3 pos = new Vector3(tileDetails.gridX + 0.5f, tileDetails.gridY + 0.5f, 0);
GameObject cropInstance = Instantiate(cropPrefab, pos, Quaternion.identity, cropParent);
cropInstance.GetComponentInChildren
}
当事件设定好时,在实际执行功能的代码中,增添类型为种子时,调用上面的事件。
这样即可实现在场景中种植种子。
接下来实现让种子随时间变更而生长。
为种植的物体新建一个预制体:
那么如何在更新场景中的种子信息呢?
思路如下:在切换场景会自动调用的刷新地图的函数中添加删去场景中所有的种子的代码:
这个删除作物的代码执行完成后会执行生成种子的函数,这个函数通过下面这个事件来进行调用(下面这个事件的作用就是生成种子在不同阶段的农作物)
别忘了要在日期更新的事件中添加让种子种植的天数++
这样即可实现种子随天数而改变。
但是注意到这样背包中的种子数量并没有减少。
使得背包中的种子数量减少的方法前面是在DropItemEvent事件里面实现。
此处希望可以复用这个代码,所以我们让减少背包中种子的方法也是通过调用这个事件来实现。
(不过这个dropItem事件还多了一个将物品实际丢到场景中的功能,但是对于种子来说不需要这个功能,因此可以给这个事件加一个参数,如果是种子类型,则只需要执行其中减少物品的功能即可。)
在ItemManager中的这个事件添加这个代码,这样可以不实际丢物品:
这样则可以实现只减少背包中的库存。
最终即可实现种下种子的效果,并且当天数增加时,种子也会逐渐生长。
需要使用菜篮来进行收割,因此添加新的工具类型,在checkCursorValid中增添新的工具的判断。
然后给人物添加上收割的动画,和前面内容一样不赘述:
随后即可实现点击后收割的动画。
接下来实现收割作物的具体逻辑。
整体思路如下:
首先获取鼠标选中的农作物的物体,然后调用该农作物相关的执行收割的代码(包括需要鼠标点击的次数、收割的产量,物品在背包数量中的增加)。
具体步骤:
首先需要补充一条,在前面将种子种下去时,需要给种子赋值一下它的信息:
由于我们放下的是预制体,当我们收割作物时需要获取这个预制体,使用的方法是通过调用一个API,Physics2D.OverlapPointAll(pos),该api可以获取选中位置周围的碰撞体。
///
/// 通过物理方法判断鼠标点击位置的农作物
///
/// name="mouseWorldPos">鼠标坐标
///
private Crop GetCropObject(Vector3 mouseWorldPos)
{
Collider2D[] colliders = Physics2D.OverlapPointAll(mouseWorldPos);//获取鼠标点击位置周围的所有碰撞体
Crop currentCrop = null;
for (int i = 0; i < colliders.Length; i++)
{
if (colliders[i].GetComponent
currentCrop = colliders[i].GetComponent
}
return currentCrop;
}
获取该农作物的预制体后,然后执行相关的收割方法,也就是下面的ProcessToolAction:
由于不同的工具可以收集不同的类型,因此在cursorManager中添加一个判断工具是否匹配的函数并加入判断:
而不同的物品在收获作物时也需要不同的次数,此处实现这个功能:
///
/// 获取这个工具需要使用几次才能获得该物品
///
/// name="toolID">工具ID
///
public int GetTotalRequireCount(int toolID)
{
for(int i = 0; i < harvestToolItemID.Length; i++)
{
if (harvestToolItemID[i] == toolID)
return requireActionCount[i];
}
return -1;
}
接下来在crop中添加生成物品的代码,要做的事包括
判断点击的次数是否达到,
若达到,判断需要生成多少件该物品,然后调用事件去具体执行相关逻辑:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Crop : MonoBehaviour
{
public CropDetails cropDetails;
private int harvestActionCount;
public void ProcessToolAction(ItemDetails tool)
{
//工具使用次数
int requireActionCount = cropDetails.GetTotalRequireCount(tool.itemID);
if (requireActionCount == -1) return;
//判断是否有动画 树木
//点击了多少次的计数器,判断是否达到需要的次数
if (harvestActionCount < requireActionCount)
{
harvestActionCount++;
//播放粒子
//播放声音
}
if (harvestActionCount >= requireActionCount)
{
if (cropDetails.generateAtPlayerPosition)
{
//生成农作物
SpawnHarvestItems();
}
}
}
///
/// 生成实际的农作物,根据选择的数量生成指定的数量
///
public void SpawnHarvestItems()
{
for (int i = 0; i < cropDetails.producedItemID.Length; i++)
{
int amountToProduce;
if (cropDetails.producedMinAmount[i] == cropDetails.producedMaxAmount[i])
{
//代表只生成指定数量的
amountToProduce = cropDetails.producedMinAmount[i];
}
else //物品随机数量
{
amountToProduce = Random.Range(cropDetails.producedMinAmount[i], cropDetails.producedMaxAmount[i] + 1);
}
//执行生成指定数量的物品
for (int j = 0; j < amountToProduce; j++)
{
if (cropDetails.generateAtPlayerPosition)
EventHandler.CallHarvestAtPlayerPosition(cropDetails.producedItemID[i]);
}
}
}
}
然后在数据库,也就是在InventoryManager中去具体生成该物品。
为了效果更好,增添一个拾取作物后会在人物头上出现的动画,在AnimatorOverride中实现:由于希望图片一会后消失,此处配合协程使用
接下来实现收割完农作物后该农作物会消失
当农作物收获完成时,判断该农作物是否可以重新生长
接下来实现如果它能重新生长,则让其重新生长:
在种子这里要设定时间
思路很简单,刷新地图时先删去所有的种子,然后再重新根据种子的ID重新生成即可:
于是只需要调用refreshMap即可实现:
在gripMapManager里添加:
然后每当种植下去的时候呼叫该事件:
这样即可实现种子的反复种植。
创建树木种子的相关信息:
然后这样即可实现种树
接下来实现砍树的动画
创建树的摇晃相关帧动画后,并设立动画控制器:
注意为什么上面会这么设定,要理解。
对于砍树的操作来说,和收集是大同小异的操作。多了个点击会触发树的动画,和点击次数达到时才可以进行收获。
当还没达到播放次数时:按下则触发动画
当达到砍伐的次数时,执行其对应的动画:
接下来实现当砍树砍完时会有掉落物的情况,并且我们希望是在树倒地的动画播放完毕时才会有物品的现象出现。所以此处使用协程:
由于树的到底动画播放需要时间,当播放完毕时会进入最终状态END,于是我们使用协程的方式判断是否达到了最终状态。如果达到了最终状态才会执行剩下部分的代码。也就是产生可以收获的种子和转变成新的物体(例如树变成树桩)
当砍伐树木后我们希望生成一个树桩,使用一个新的函数,也就是转换物体的函数来实现:
生成的新的树桩我们希望它也是cropBase类型,也可以对它进行砍伐然后获取树木,所以我们在原来的基础上生成一个新的prefab,也就是使用下面的这种方式即可:
然后需要在农作物列表里添加该物品
转换物品的代码如下:
private void CreateTransferCrop()
{
tileDetails.seedItemID = cropDetails.transferItemID;
tileDetails.daysSinceLastHarvest = -1;
tileDetails.growthDays = 0;
EventHandler.CallRefreshCurrentMap();
}
这样即可实现转换树桩。
接下来对树桩做类似的操作也可以实现让其砍伐后产生树木。
接下来另一方面,我们希望当物品掉落时有一个跳跃的掉落效果:
只需要调用之前我们写过的一个功能:
接下来实现落叶飘零的粒子效果:
设定相关
然后为树木的种子增添effect的选项:
创建好粒子系统后,接下来实现调用该粒子系统。
直接调用很简单,此处使用对象池的相关API来实现。
最终我们想要实现的效果就是,当触发粒子效果时就生成一个粒子,一段时间后让其设置Active为false,然后再使用时,如果有SetActive为false的粒子,则使其激活并重置位置。
如果没有active为false的,则再次生成一个。
已经有先有的api了,我们只需要为其定义好函数即可
粒子系统有很多个,使用List存储。
对于不同的粒子系统使用不同的对象池。
所以初始化如下:
public List
private List
我们希望树木摇晃一会后才产生粒子效果,所以使用协程。最终代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Pool;
public class PoolManager : MonoBehaviour
{
public List
private List
private void OnEnable()
{
EventHandler.ParticleEffectEvent += OnParticleEffectEvent;
}
private void OnDisable()
{
EventHandler.ParticleEffectEvent -= OnParticleEffectEvent;
}
private void Start()
{
CreatePool();
}
///
/// 生成对象池
///
private void CreatePool()
{
foreach (GameObject item in poolPrefabs)
{
Transform parent = new GameObject(item.name).transform;
parent.SetParent(transform);
var newPool = new ObjectPool
() => Instantiate(item, parent),
e => { e.SetActive(true); },
e => { e.SetActive(false); },
e => { Destroy(e); }
);
poolEffectList.Add(newPool);
}
}
private void OnParticleEffectEvent(ParticleEffectType effectType, Vector3 pos)
{
//WORKFLOW:根据特效补全
ObjectPool
{
ParticleEffectType.LeavesFalling01 => poolEffectList[0],
ParticleEffectType.LeavesFalling02 => poolEffectList[1],
_ => null,
};
GameObject obj = objPool.Get();
obj.transform.position = pos;
StartCoroutine(ReleaseRoutine(objPool, obj));
}
private IEnumerator ReleaseRoutine(ObjectPool
{
yield return new WaitForSeconds(1.5f);
pool.Release(obj);
}
}
调用时则是在按下按键后触发的事件里进行调用。
如果我们直接在场景中预先添加一个树,但是加载场景后会发现消失了,是因为场景加载时会执行refresh函数消除所有的树。
然后再重新调用displayMap去生成新的树:(条件是只要种子的ID不为-1,即该地有种子即可)
解决办法是在开始执行刷新地图时调用一个新的事件,这个事件会把地图中已有的树的种子ID设定好。
代码如下:
using System.Collections;
using System.Collections.Generic;
using MFarm.Map;
using UnityEngine;
namespace MFarm.CropPlant
{
public class CropGenerator : MonoBehaviour
{
private Grid currentGrid;
public int seedItemID;
public int growthDays;
private void Awake()
{
currentGrid = FindObjectOfType
}
private void OnEnable()
{
EventHandler.GenerateCropEvent += GenerateCrop;
}
private void OnDisable()
{
EventHandler.GenerateCropEvent -= GenerateCrop;
}
private void GenerateCrop()
{
Vector3Int cropGridPos = currentGrid.WorldToCell(transform.position);
if (seedItemID != 0)
{
var tile = GridMapManager.Instance.GetTileDetailsOnMousePosition(cropGridPos);
if (tile == null)
{
tile = new TileDetails();
}
tile.daysSinceWatered = -1;
tile.seedItemID = seedItemID;
tile.growthDays = growthDays;
GridMapManager.Instance.UpdateTileDetails(tile);
}
}
}
}
这样即可实现场景初始时,仍然有该树。
问题在于加载场景时会执行上述功能导致再次产生树。解决方法就是用一个字典储存每个场景是否进行了第一次加载,如果没加载过,才执行上面的初始化场景的方法。