目录
(Editor)使用 UI Toolkit 和 UI Builder 制作物品编辑器【待解决】
视频演示:
unity农场游戏(包含背包、种植系统及npc使用A*寻路)_哔哩哔哩bilibili_演示
搭建初始地图环境
在package manager中删除无用的包可以使速度变快。
素材预处理
找到素材中的:
为了使得其他的素材图片也按照这种方式,可以以当前设定来创建一种预设。
然后就可以同时选中多个物体,让他们应用同样的这个预设。
切割图片的方法:
接下来对人物动画同理做切割:
切割时由于动画是8帧,选择切成8个,锚点选在脚底是为了能够实现一个正确的遮挡效果。
接下来创建人物,
在人物的锚点这里选择底部
创建人物:
遮挡层级效果
为了防止人物的一些部位对其他物品进行遮挡,产生“分家”的现象,添加一个sorting group
在父物体添加一个sorting group可以使得父物体和子物体一起渲染
然后新建一个层,将几个子物体设定为
接下来我们希望实现这样的效果(因为是俯视角的游戏)(当人物走到草丛前面时会遮挡草丛(这个走到前面和后面不是通过z轴,而是通过y轴)):
但是在2d的情况下,在同样的层次下渲染前后遮挡关系是通过z轴实现的。我们希望通过y轴来实现,则修改如下:
(注意物体的锚点要设置在底部)
景观的半遮挡与透明
简单来说就是走到树后面,树会变成半透明。
让树渐变的方法可以当触发trigger函数时使用协程,让树的α值缓慢的从1变成0。
此处使用DOTween。
调用思路:在树的身上挂载一个脚本叫做ItemFader,这个脚本具有淡入和淡出的函数:
using UnityEngine;
using DG.Tweening;
[RequireComponent(typeof(SpriteRenderer))]
public class ItemFader : MonoBehaviour
{
private SpriteRenderer spriteRenderer;
private void Awake()
{
spriteRenderer = GetComponent<SpriteRenderer>();
}
public void FadeIn()
{
Color targetColor = new Color(1, 1, 1, 1);
spriteRenderer.DOColor(targetColor, Settings.fadeDuration);
}
public void FadeOut()
{
Color targetColor = new Color(1, 1, 1, Settings.targetAlpha);
spriteRenderer.DOColor(targetColor, Settings.fadeDuration);
}
}
为了方便后续查找更改的数据,建立一个setting脚本用于储存常量:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Settings
{
public const float fadeDuration = 0.35f;
public const float targetAlpha = 0.45f;
}
当玩家触发了trigger函数时,获取碰撞体的所有子物体的ItemFader脚本,然后调用淡入和淡出的函数。
效果如下:
人物移动
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Player : MonoBehaviour
{
public float speed = 5;
Rigidbody2D rb;
private float inputX;
private float inputY;
private Vector2 movementInput;
private void Awake()
{
rb = GetComponent<Rigidbody2D>();
}
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
//用update函数接受数据,而不是改变物体的刚体
PlayerInput();
}
private void FixedUpdate()
{
//对于改变刚体的运动,使用fixupdate来实现
Movement();
}
private void PlayerInput()
{
inputX = Input.GetAxisRaw("Horizontal");
inputY = Input.GetAxisRaw("Vertical");
if (inputX != 0 && inputY != 0)
{
inputX *= 0.6f;
inputY *= 0.6f;
}
movementInput = new Vector2(inputX, inputY);
}
private void Movement()
{
rb.MovePosition(rb.position + movementInput * speed * Time.deltaTime);
}
}
绘制瓦片地图
为了使得和环境更好交互,创建一系列的瓦片地图,其在不同的sorting layer上:
- 创建规则的瓦片信息
然后根据自己想要设定的规则,可以绘制出有规律的地图
注意到瓦片地图间会有这样的缝隙:
解决方法:
创建一个Sprite Altas,这个图集会将所有的素材打包在一起,引用时忽略该图集。
将Maps以及其他需要打包的地图素材这个文件夹放入,即可实现。
添加这个相机使得
碰撞层
为了产生碰撞效果,且碰撞是个整体,添加这三个组件并设定如下:
接下来绘制空气墙:
可以修改三角形的碰撞体积,通过下面这种方式手动调整:
此时碰撞体积就改变了
接下来根据地图来绘制碰撞体:
添加摄像机的边界
创建一个边界,添加polygon coiilder并设定边界范围:
接下来我们希望通过读取不同的场景时,摄像机会读取该场景的边界并设定边界:
为上面添加的polygon collider添加tag。
然后为虚拟摄像机添加如下代码:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Cinemachine;
public class SwitchBounds : MonoBehaviour
{
private void Start()
{
SwitchConfinerShape();
}
private void SwitchConfinerShape()
{
PolygonCollider2D confinerShape = GameObject.FindGameObjectWithTag("BoundsConfiner").GetComponent<PolygonCollider2D>();
CinemachineConfiner confiner = GetComponent<CinemachineConfiner>();
confiner.m_BoundingShape2D = confinerShape;
//Call this if the bounding shape's points change at runtime
confiner.InvalidatePathCache();
}
}
- 完善地图
创建房子
注意,房子的两个部分要建立在不同的层级,房子下半部分不会遮挡玩家,但有碰撞,吃呢价格i设置在GroundTop,但是房子的上层会遮挡玩家,但无碰撞,因此放在Front1的层级。
- 创建树和其对应的动画
效果:
以及可以砍伐的树木:由树的树木和树干所组成
(Editor)使用 UI Toolkit 和 UI Builder 制作物品编辑器【待解决】
-
要自己写好 ItemDataLis_SO
目标是设计成下面这个样子
UIToolKit可以在可视化的情况下来编辑Customer Editor,在runtime下也可以使用。
创建一个Editor文件夹:(Editor文件夹在打包时不会被打包进去)
创建一个这个
则会生成三个文件:
制作好了编辑器后,添加物品如下:
背包系统
背包数据初始化
采用MVC的模式,将控制、数据、和显示逐一分开,通过inventory manager来管理数据,并通过它来呼叫UI的显示内容。
创建一个DataCollection,我们会将一些由多个自己定义的变量组合成的类都放在这个文件当中,方便集中管理,查找和修改。
接下来创建一个物品所具体拥有的各种信息,
1.为便于管理,这些物品具有ID,名字。
2.为了之后能让玩家根据不同类型的物品有不同的效果,设定itemType。
3.还需为该物体设定背包中的图片展示以及该物体在世界中的图片效果。
4.该物体具有描述信息
5.各种后续可能会用到的一些物品属性,如能否被拾取、丢弃等。
using UnityEngine;
[System.Serializable]
//用来存储物品的详细信息
public class ItemDetails
{
public int itemID;
public string name;
public ItemType itemType;
public Sprite itemIcon;
public Sprite itemOnWorldSprite;
public string itemDescription;
public int itemUseRadius;
public bool canPickedup;
public bool canDropped;
public bool canCarried;
public int itemPrice;
[Range(0, 1)]
public float sellPercentage;
}
-
Enums 创建 ItemType 物品类型
public enum ItemType
{
Seed,Commodity,Furniture,
HoeTool,ChopTool,BreakTool,ReapTool,WaterTool,CollectTool,
ReapableScenery
}
-
生成 ItemDetailsList_SO 文件做为整个游戏的物品管理数据库
创建一个SO菜单栏,其对应的SO包含很多个物品,所以使用的是List<ItemDetails>的数据结构。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(fileName ="ItemDataList_SO",menuName ="Inventory/ItemDataList")]
public class ItemDataList_SO : ScriptableObject
{
public List<ItemDetails> itemDetailsList;
}
创建一个文件夹用来管理数据,并创建一个示例SO。
InventoryManager——总库存管理
InventoryManager是一个用来管理所有背包系统的代码,在后续不断更新中会不断补充代码。
其主要核心包括物品的SO数据(即所有种类物品的一个列表)和背包的SO数据。
而其因为是单例模式,所以我们想获取背包或者某种物品时,也可以通过直接调用InventoryManager.ItemDataList_SO或者InventoryManager.playerBag。
这就是单例模式的作用之一。
下面书写一个泛型单例模式,后续有脚本想让其使用单例模式就可以让其实现这个泛型即可
泛型单例模式代码
using UnityEngine;
public class Singleton<T> : MonoBehaviour where T : Singleton<T>
{
private static T instance;
public static T Instance
{
get => instance;
}
protected virtual void Awake()
{
if (instance != null)
Destroy(gameObject);
else instance = (T)this;
}
protected virtual void OnDestroy()
{
if (instance != this)
instance = null;
}
}
为inventory manager添加命名空间,方便管理数据,避免互相乱调用的耦合情况。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace MFarm.Inventory
{
public class InventoryManager : Singleton<InventoryManager>
{
public ItemDataList_SO ItemDataList_SO;
}
}
添加命名空间后,想在其他的代码中使用inventoryManager,就得using这个命名空间。
为其添加通过ID查找的功能后的完整代码:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace MFarm.Inventory
{
public class InventoryManager : Singleton<InventoryManager>
{
public ItemDataList_SO ItemDataList_SO;
/// <summary>
///
///通过ID返回物品信息
/// </summary>
/// <param name="ID"></param>
/// <returns></returns>
public ItemDetails GetItemDetails(int ID)
{
//找到某个itemDetails,它的ID等于所给ID
return ItemDataList_SO.itemDetailsList.Find(i => i.itemID == ID);
}
}
}
在主场景中添加这个并绑定SO,因为是在主场景,所以场景切换时这个不需要改变。
实现地图上显示数据库中的物品
创建一个itemBase,其作用是,当场景中会生成一些物品,比如砍了树会生成木头,以及果实开花产生种子。我们希望这些情况时,可以通过代码去inventoryManager里面获得ID对应的物品详情。
为其添加碰撞体,作用是比如当玩家触碰时,就将它添加到背包中。
、
接下来通过代码来根据不同的ID显示不同的物品,思路很简单,就是通过ID去物品数据库中获取对应的图片并展示出图片即可。而不同的图片的大小不一样,所以根据图片的大小去修改碰撞体的体积。
(这个物品数据库并不是背包,而是ID为1的物品,它的信息是怎么样的,ID为2的物品,信息是怎么样的)
例如这样:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace MFarm.Inventory
{
public class Item : MonoBehaviour
{
public int itemID;
private SpriteRenderer spriteRenderer;
private BoxCollider2D coll;
private ItemDetails itemDetails;
private void Awake()
{
spriteRenderer = GetComponentInChildren<SpriteRenderer>();
coll = GetComponent<BoxCollider2D>();
}
private void Start()
{
if (itemID != 0)
{
Init(itemID);
}
}
public void Init(int ID)
{
itemID = ID;
//Inventory获得当前数据
itemDetails = InventoryManager.Instance.GetItemDetails(itemID);
//因为返回的结果有可能是空的,如果在非空的情况下显示图片即可
if (itemDetails != null)
{
spriteRenderer.sprite = itemDetails.itemOnWorldSprite != null ? itemDetails.itemOnWorldSprite : itemDetails.itemIcon;
//修改碰撞体尺寸,让其能和不同的图片一一对应
Vector2 newSize = new Vector2(spriteRenderer.sprite.bounds.size.x, spriteRenderer.sprite.bounds.size.y);
coll.size = newSize;
coll.offset = new Vector2(0, spriteRenderer.sprite.bounds.center.y);
}
}
}
}
- 拾取物品
拾取物品的逻辑如下,为玩家添加拾取物品的脚本,当触发trigger时,获取该物体的item组件,然后如果有item组件,则调用数据库InventoryManager中的AddItem函数,添加物品进背包所在的数据库中。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// 当玩家挂载上这个脚本时将使得玩家可以拾取物体
/// </summary>
public class ItemPickUp : MonoBehaviour
{
private void OnTriggerEnter2D(Collider2D collision)
{
Debug.Log("trigger");
Item item = collision.GetComponent<Item>();
if (item != null)
{
if (item.itemDetails.canPickedup)
{
InventoryManager.Instance.AddItem(item, true);
}
}
}
private void OnCollisionEnter2D(Collision2D collision)
{
Debug.Log("collision");
}
}
具体的往数据库里添加信息的代码如下:
在InventoryManager中添加如下代码:
/// <summary>
/// 添加物品到Player背包里
/// </summary>
/// <param name="item"></param>
/// <param name="toDestory">是否要销毁物品</param>
public void AddItem(Item item, bool toDestory)
{
Debug.Log(GetItemDetails(item.itemID).itemID + "Name: " + GetItemDetails(item.itemID).itemName);
if (toDestory)
{
Destroy(item.gameObject);
}
}
背包系统
首先先书写背包中的物品的格式:
[System.Serializable]
/// <summary>
/// 这个是在背包中的物品,具有两个变量,物品的ID,及背包中该物品的数量
/// </summary>
public struct InventoryItem
{
//使用结构而不是类,是因为类需要进行判空,可能会导致一些意想不到的bug
public int itemID;
public int itemAmount;
}
然后背包就是含有这类物品的一个List,将其用SO存储,如下所示:
背包的SO代码:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(fileName ="InventoryBag_SO",menuName ="Inventory/InventoryBag_SO")]
public class InventoryBag_SO : ScriptableObject
{
public List<InventoryItem> itemList;
}
这个背包适用于所有东西,除了玩家自己的背包,还可以是npc的商店。
对于添加一个物体前需要思考,背包是否满了,是否有该物品,这些都需要在AddItem里面实现。
在InventoryManager中添加如下代码即可实现该功能:
/// <summary>
/// 添加物品到Player背包里
/// </summary>
/// <param name="item"></param>
/// <param name="toDestory">是否要销毁物品</param>
public void AddItem(Item item, bool toDestory)
{
var index = GetItemIndexInBag(item.itemID);
AddItemAtIndex(item.itemID,index,1);
//对于添加一个物体前需要思考,背包是否满了,是否有该物品
Debug.Log(GetItemDetails(item.itemID).itemID + "Name: " + GetItemDetails(item.itemID).itemName);
if (toDestory)
{
Destroy(item.gameObject);
}
}
/// <summary>
/// 检查背包是否有空位
/// </summary>
/// <returns></returns>
private bool CheckBagCapacity()
{
for(int i = 0; i < playerBag.itemList.Count;i++)
{
if (playerBag.itemList[i].itemID == 0)
{
return true;
}
}
return false;
}
/// <summary>
/// 根据ID查找包里是否有这个东西
/// </summary>
/// <param name="ID"></param>
/// <returns></returns>
private int GetItemIndexInBag(int ID)
{
for (int i = 0; i < playerBag.itemList.Count; i++)
{
if (playerBag.itemList[i].itemID == ID)
{
return i;
}
}
return -1;
}
private void AddItemAtIndex(int ID,int index,int amount)
{
if (index == -1&&CheckBagCapacity())
{
var item = new InventoryItem { itemID = ID, itemAmount = amount };
for (int i = 0; i < playerBag.itemList.Count; i++)
{
if (playerBag.itemList[i].itemID == 0)
{
playerBag.itemList[i] = item;
break;
}
}
}
else
{
int currentAmount = playerBag.itemList[index].itemAmount + amount;
var item = new InventoryItem { itemID = ID, itemAmount = currentAmount };
playerBag.itemList[index] = item;
}
}
}
添加了以上这些代码后,就可以实现拾取物品后将其 添加到数据库中了
上面实现了代码层面将物品添加到数据库,下面把UI面板展示出来,并且让UI中的每个格子都对应数据库的内容
行动栏
此处使用一个新的场景来实现UI,并创建画布
画布中做出如下更改:
做好行动栏的相关UI并排好布局。
做好背包中的UI
根据数据库中的数据显示背包中的数量
在键盘的navigation中,有这个选项,它是用来实现可以通过键盘来更改选项的。
通过visualize可以看到各个间的关系
此处不需要该功能,因此选择none。
在enums中增添三种枚举:用于区分三种不同背包间的格子类型的type:
接下来需要通过脚本获取这个SlotBag上的一些属性,
并且通过代码来实现,初始化为空(让其变为非选中状态,以及让其不能被选中,图片也关闭的状态,),以及更新格子的代码。
对于这个格子,其获取对应的子物体的image,最高效率的是直接在inspector窗口中拖拽。
(在Awake函数里获取,可能会导致一些意想不到的bug)
完整代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
namespace MFarm.Inventory
{
public class SlotUI : MonoBehaviour
{
[Header("组件获取")]
//使用SerializeField可以使得提前在inspector窗口中提取获取好image
[SerializeField] private Image slotImage;
[SerializeField] private TextMeshProUGUI amountText;
[SerializeField] private Image slotHighlight;
[SerializeField] private Button button;//需要实现无物品的地方不能被点按,所以需要获取button
[Header("格子类型")]
public SlotType slotType;
public bool isSelected;//用于判断是否被选择
public ItemDetails itemDetails;
public int itemAmount;
private void Start()
{
//开始的时候需要判空
isSelected = false;
if (itemDetails.itemID == 0)
{
UpdateEmptySlot();
}
}
/// <summary>
/// 更新格子UI和信息
/// </summary>
/// <param name="item">itemDetails</param>
/// <param name="amount">持有数量</param>
public void UpdateSlot(ItemDetails item, int amount)
{
itemDetails = item;
slotImage.sprite = item.itemIcon;
itemAmount = amount;
amountText.text = amount.ToString();
button.interactable = true;
}
/// <summary>
/// 如果是空的slot则不能让其选择,不能互动
/// </summary>
public void UpdateEmptySlot()
{
if (isSelected)
{
isSelected = false;
}
slotImage.enabled = false;
amountText.text = string.Empty;
button.interactable = false;
}
}
}
上面写的是格子本身的UI脚本,其包含初始化自身,以及提供了一个供外部调用的更新UI的接口。
接下来书写一个Inventory的UI脚本,其用来控制背包自身的一些东西(比如金钱数量的改变),以及控制格子。
在InventoryUI中应该包含所有的格子(包含Action Bar行动栏和背包里的所有格子)
namespace MFarm.Inventory
{
public class InventoryUI : MonoBehaviour
{
[SerializeField] private SlotUI[] playerSlots;
// Start is called before the first frame update
}
}
将行动栏和背包中的Slot拖拽过来:
我们在InventoryManager添加物品以及交换物品时,需要使得对应的UI发生改变。
在此处,我们往更大的范围想,不止局限于背包,比如还有场景中的箱子。因此我们在更新UI时,要确定它是在哪个位置。
所以在enums中添加新的枚举类型,用于说明这个库存在哪个位置
那么如何更新UI信息呢?此处我们摈弃在3D RPG中使用的,通过单例模式的InventoryManger去调取InventoryUI,然后获取变量值去修改的方法。
此处使用一个类似事件中心的方法,每次呼叫这个事件时提供对应的参数,所有注册到这个事件里的函数都会被执行。比如写一个开始新游戏的事件,这个事件的Action一执行,所有注册函数都将执行。
事件的代码逻辑如下:
首先事件我们将其放在一个静态类中,使得其他代码可以直接调用,且不需要继承MonoBehaviour:
public static class EventHandler
{
}
书写一个事件的类型,想要注册到这个事件的函数必须具有相同的参数:
public static event Action<InventoryLocation, List<InventoryItem>> UpdateInventoryUI;
接下来,我们在管理InventoryUI的脚本中添加如下代码:逻辑是将函数注册到这个事件
这个函数是用于更新和Player相关的InventoryUI的信息。
更新UI需要做什么操作?很简单,当前代码是InventoryUI,我们只需要根据list中的物品,如果其物品数量大于0则更新数据,为0则将其更新为0,方法是调用每个格子内部SlotUI提供给外部的更新数据的接口。
注意因为需要注册到这个事件,所以对应的参数必须相同。
根据以上两个要求写出的代码如下:
/// <summary>
/// 更新UI需要做的操作,根据对应的Slot里的物品数量更新信息
/// /// </summary>
/// <param name="location"></param>
/// <param name="list"></param>
private void OnUpdateInventoryUI(InventoryLocation location, List<InventoryItem> list)
{
switch (location)
{
case InventoryLocation.Player:
for(int i = 0; i < playerSlots.Length; i++)
{
if (list[i].itemAmount > 0)
{
var item = InventoryManager.Instance.GetItemDetails(list[i].itemID);
playerSlots[i].UpdateSlot(item, list[i].itemAmount);
}
else
{
playerSlots[i].UpdateEmptySlot();
}
}
break;
}
}
书写完了注册的函数后,接下来需要书写调用这些函数的方法:
在EventHandler中添加这个函数:其功能是唤醒所有注册到了UpdateInventoryUI这个事件上的函数。
public static void CallUpdateInventoryUI(InventoryLocation location,List<InventoryItem> list)
{
UpdateInventoryUI?.Invoke(location, list);//判断是否为空,不为空的话则执行
}
使用示例如下,当比如在InventoryManager中添加物品时,我们去EventHandler中调用这个函数即可:
别忘了开始时若有数据也需要更新一下:
这样即可实现拾取物品时更新对应的UI信息。
此处在注册函数时,把注册函数的时机放在OnEnable中。
OnAwake和OnEnable的区别?
OnAwake是在脚本实例生命周期中仅被调用一次,用来进行初始化的操作。OnEnable是当对象被激活时,就会调用,如果反复激活就会反复调用。
效果如下:
选中物品高亮和背包开关
- 背包按钮的控制开关
逻辑很简单,就是获取Bag的GameObject,然后通过按钮或者按键控制其SetActive即可。
每按一次按键B或者按钮就调用这个函数就可以了。
public void OpenBagUI()
{
bagOpened = !bagOpened;
bagUI.SetActive(bagOpened);
}
- 选中物体时的高亮效果
首先需要在SlotUI中引入事件系统,然后让SlotUI继承这个接口:IPointerClickHandler,这个接口是在按下去时会被触发。
继承了这个接口后,然后就可以重写这些接口所承载的函数,这些函数会在适当的时机自动被调用
接口中的参数包含了很多信息,比如点击的次数还有时间,如果想要获取这个信息只需要调用这个参数的成员变量即可。
起初的逻辑很简单,被点选时高亮即可,代码如下:
public void OnPointerClick(PointerEventData eventData)
{
if (itemAmount == 0) return;//如果没有东西就不做任何操作
isSelected = !isSelected;//切换成相反的状态
slotHighlight.gameObject.SetActive(isSelected);
}
}
但是这样会出现一个问题,那就是两个格子可以同时被选上同时被高亮!
我们只希望同时只有一个能被高亮。最简单方法就是当某个格子按下时,通知其他格子暗下去。但是显然一个格子没有通知其他格子暗下去的方法。但是SlotUI的父物体InventoryUI显然可以控制全部格子。
那方法就是首先在每个格子的SlotUI中获取父物体:
private InventoryUI inventoryUI => GetComponentInParent<InventoryUI>();
当需要只让自己的格子亮,其他的格子暗下时,把当前格子的index传给父物体,让父物体InventoryUI去控制自己亮其他暗。
我们希望点选时有个动画效果,方法如下,在预制体的Highlight中添加动画和动画控制器:
然后创建一个序列帧动画给这个动画控制器即可。最终效果如下:
补充:此处将前面的bug修改一些:
SlotUI中修改下面的代码
更新空物体的UI时,之前漏了把amount也要设置为0才可以
拖拽交换及数据改变
- 物品拖拽
在3d rpg中实现物体拖拽时,是通过直接将该物体拖拽起来,此处换一种方法实现,是新生成一个对应的图片。拖拽完成时将它关闭。
创建一个canvas画布,用于拖拽的物品,因为拖拽的物品要展示在前面,为了使得该画布的渲染顺序在前,在这里需要设定次序:
这里的image有一个点需要注意:选中Raycast Target时它会阻挡鼠标的射线判断。
当鼠标前面有一个图片的时候,这个射线就会被遮挡,就没有办法选中或者测试图片下面其他的东西了。所以这个不能勾选
(其实就是,这个物体是否是射线的目标。如果我们不希望它是射线的目标,我们就不勾选)
别忘了字体的涉嫌遮挡也要剔除:
字体的射线遮挡在extra中:
对于背包,我们不希望背包下面的这三个物体遮挡射线,我们希望射线能直接投射到格子上面,这样我们就可以实现检测到鼠标释放时的射线是射到了哪个格子上,从而实现物品交换。
因此 ,在这下面,我们
这样就可以检测到格子了。
- 实现拖拽物品交换数据
交换背包和交换数据的思想如下,在playerbag的SO中有26个数据,交换对应的数据即可:
代码如下:
在InventoryManager中添加如下代码:
/// <summary>
/// Player背包范围内交换物品
/// </summary>
/// <param name="fromIndex">起始序号</param>
/// <param name="targetIndex">目标数据序号</param>
public void SwapItem(int fromIndex, int targetIndex)
{
InventoryItem currentItem = playerBag.itemList[fromIndex];
InventoryItem targetItem = playerBag.itemList[targetIndex];
if (targetItem.itemID != 0)
{
playerBag.itemList[fromIndex] = targetItem;
playerBag.itemList[targetIndex] = currentItem;
}
else
{
playerBag.itemList[targetIndex] = currentItem;
playerBag.itemList[fromIndex] = new InventoryItem();//如果其中一个物品为空,则新建即可
}
EventHandler.CallUpdateInventoryUI(InventoryLocation.Player, playerBag.itemList);
}
}
在结束拖拽时候,代码如下:
思路是,首先判断射线检测的物品是否非空,是否含格子。如果含格子,是不是同种格子的交换,如果是的话,调用InventoryManager中的函数交换格子即可。
public void OnBeginDrag(PointerEventData eventData)
{
if (itemAmount != 0)
{
inventoryUI.dragItem.enabled = true;
inventoryUI.dragItem.sprite = slotImage.sprite;
inventoryUI.dragItem.SetNativeSize();//如果拖拽的东西很大,通过调用这个选项防止图片失真
isSelected = true;
inventoryUI.UpdateSlotHighlight(slotIndex);
}
}
//拖拽时让拖拽的图片的位置等于鼠标的位置
public void OnDrag(PointerEventData eventData)
{
inventoryUI.dragItem.transform.position = Input.mousePosition;
}
//结束拖拽时让其不可见
public void OnEndDrag(PointerEventData eventData)
{
inventoryUI.dragItem.enabled = false;
Debug.Log(eventData.pointerCurrentRaycast.gameObject);
// Debug.Log(eventData.pointerCurrentRaycast.gameObject);
if (eventData.pointerCurrentRaycast.gameObject != null)
{
if (eventData.pointerCurrentRaycast.gameObject.GetComponent<SlotUI>() == null)
return;
var targetSlot = eventData.pointerCurrentRaycast.gameObject.GetComponent<SlotUI>();
int targetIndex = targetSlot.slotIndex;
//在Player自身背包范围内交换,通过传入index然后让manager进行交换
if (slotType == SlotType.Bag && targetSlot.slotType == SlotType.Bag)
{
InventoryManager.Instance.SwapItem(slotIndex, targetIndex);
}
//清空所有高亮显示
inventoryUI.UpdateSlotHighlight(-1);
}
//else //测试扔在地上
//{
// if (itemDetails.canDropped)
// {
// //获取鼠标对应世界地图坐标,然后作为调用事件的参数即可
// var pos = Camera.main.ScreenToWorldPoint(new Vector3(Input.mousePosition.x, Input.mousePosition.y, -Camera.main.transform.position.z));
// EventHandler.CallInstantiateItemInScene(itemDetails.itemID, pos);
// }
//}
}
在地图上取出背包的物品
在地图上取出背包的物品的思路也是通过事件实现。
完整思路是:在SlotUI中的拖拽物品结束时,如果目的地不是格子而是世界里,那么我们先获取鼠标射线的位置,然后将位置和ID作为参数去调用这个事件。
因此需要在EventHandler中添加事件:
所有背包里的物品是通过一个InventoryManager来对其进行统一管理的,对于世界中的物品Item,我们同样创建一个Manager对其进行管理。
承接上文,我们有了事件,我们需要有具体的函数注册该事件,这样该事件才有用。由于我们通过ItemManager对事件进行管理,所以在ItemManager中书写具体的生成物品的函数。
代码思路很简单,首先将函数注册到该事件,然后书写具体生成物品的函数,生成物品的函数也很简单,根据传入的位置、物品ID,调用Instantiate生成该物品即可。
创建一个专门用来生成物品的prefab,其含有item脚本和boxCollider(勾选trigger)
我们使用Instantiate生成物品时,只需要生成下面这个物品即可,然后我们只要为其设定好ID,其就会自动根据ID在地图中显示它长什么样。
为了统一方便管理,使用一个ItemParent作为它们的父物体:
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace MFarm.Inventory
{
public class ItemManager : MonoBehaviour
{
public Item itemPrefab;//待生成物品的预制体
private Transform itemParent;
private void OnEnable()
{
EventHandler.InstantiateItemInScene += OnInstantiateItemInScene;
}
private void OnDisable()
{
EventHandler.InstantiateItemInScene -= OnInstantiateItemInScene;
}
private void Start()
{
itemParent = GameObject.FindWithTag("ItemParent").transform;
}
private void OnInstantiateItemInScene(int ID, Vector3 pos)
{
var item = Instantiate(itemPrefab, pos, Quaternion.identity,itemParent);
item.itemID = ID;
}
}
}
在上面的ItemManager调用完毕生成了这个预制体后,因为其包含item这个脚本,item脚本则会在此处自动调用Init函数自动根据ID来赋予属性。
效果如下:
设定了verticalLayoutGroup后,所有的一级子物体,会整齐的排列在一起:
如下图所示,下图就是对应的三个框
假如设定了space 下面就会变成这样
显示物品的详细信息
首先创建一个UIImage,和下面的这几个组件
由于其从上到下包含多个信息:如名字,描述。为了让其能纵向排列使用vertical layoutGroup。
为了使其能根据内容拉伸,使用ContentSizeFitter。
设计一个描述的内容ui,
为了实现描述的内容会根据内容多少而扩充长度,可以在父物体使用content Size Filter,并勾选
它会对这个组件以及这个组件的一级子物体,当大小变换时也会随之变换(注意是一级子物体,意思就是子物体变大,自己也会跟着变大,但是如果子物体的子物体变大,那么自己是不会变大的。)
由于contentSizeFilter会随着组件的大小而自动扩充大小,那么当没有字体的时,框的大小就为0,因此可以使用:
书写ItemTooltip,用于具体的实现展示详情的函数,然后在另外一个函数中会有On
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
public class ItemTooltip : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI nameText;
[SerializeField] private TextMeshProUGUI typeText;
[SerializeField] private TextMeshProUGUI descriptionText;
[SerializeField] private Text valueText;
[SerializeField] private GameObject bottomPart;
public void SetupTooltip(ItemDetails itemDetails, SlotType slotType)
{
nameText.text = itemDetails.itemName;
typeText.text = itemDetails.itemType.ToString();
//typeText.text = GetItemType(itemDetails.itemType);
descriptionText.text = itemDetails.itemDescription;
//显示下半部分的售卖金额是多少
if (itemDetails.itemType == ItemType.Seed || itemDetails.itemType == ItemType.Commodity || itemDetails.itemType == ItemType.Furniture)
{
bottomPart.SetActive(true);
var price = itemDetails.itemPrice;
if (slotType == SlotType.Bag)
{
price = (int)(price * itemDetails.sellPercentage);
}
valueText.text = price.ToString();
}
else
{
bottomPart.SetActive(false);
}
//这一行代码是为了防止在格子长度发生改变时,渲染较慢的问题
LayoutRebuilder.ForceRebuildLayoutImmediate(GetComponent<RectTransform>());
}
// private string GetItemType(ItemType itemType)
// {
// return itemType switch
// {
// ItemType.Seed => "种子",
// ItemType.Commodity => "商品",
// ItemType.Furniture => "家具",
// ItemType.BreakTool => "工具",
// ItemType.ChopTool => "工具",
// ItemType.CollectTool => "工具",
// ItemType.HoeTool => "工具",
// ItemType.ReapTool => "工具",
// ItemType.WaterTool => "工具",
// _ => "无"
// };
//}
}
我们在调用itemTooltip里面的东西的时候,我们希望通过其父类进行调用,所以在其父类InventoryUI里需要有一个ItemTooltip的子物体的实例,供showItemTooltip实际调用。
为格子里的物品SlogBag的预制体中添加ShowItemTooltip的代码:
为了实现鼠标放上去时有详情显示,退出时也会小时,让它继承两个接口:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
namespace MFarm.Inventory
{
[RequireComponent(typeof(SlotUI))]
public class ShowItemTooltip : MonoBehaviour,IPointerEnterHandler,IPointerExitHandler
{
private SlotUI slotUI;
private InventoryUI inventoryUI => GetComponentInParent<InventoryUI>();
private void Awake()
{
slotUI = GetComponent<SlotUI>();
}
public void OnPointerEnter(PointerEventData eventData)
{
if (slotUI.itemAmount != 0)
{
inventoryUI.itemTooltip.gameObject.SetActive(true);
inventoryUI.itemTooltip.SetupTooltip(slotUI.itemDetails, slotUI.slotType);
inventoryUI.itemTooltip.GetComponent<RectTransform>().pivot = new Vector2(0.5f, 0);
inventoryUI.itemTooltip.transform.position = transform.position + Vector3.up * 60;
}
else
{
inventoryUI.itemTooltip.gameObject.SetActive(false);
}
}
public void OnPointerExit(PointerEventData eventData)
{
inventoryUI.itemTooltip.gameObject.SetActive(false);
}
}
}
人物的移动及举起物品
- 玩家的移动动画
这个板块比较简单,由于手部动作会有所不同,所以将人物分为三个部分:
三个部分会有不同的动画控制器,对于基础移动只需要继承一个基础控制器即可。
这个基础的控制器包含两个融合树:
idle的融合树如下:
walk的融合树复杂一点,除了在方向上有分支
对于每个方向还分为走和跑两个的一维融合
相关代码如下:
Player中:
private void PlayerInput()
{
inputX = Input.GetAxisRaw("Horizontal");
inputY = Input.GetAxisRaw("Vertical");
if (inputX != 0 && inputY != 0)
{
inputX *= 0.6f;
inputY *= 0.6f;
}
if (Input.GetKey(KeyCode.LeftShift))
{
inputX *= 0.5f;
inputY *= 0.5f;
}
movementInput = new Vector2(inputX, inputY);
isMoving = movementInput != Vector2.zero;
}
private void SwitchAnimation()
{
foreach(var anim in animators)
{
anim.SetBool("isMoving", isMoving);
if (isMoving)
{
anim.SetFloat("InputX", inputX);
anim.SetFloat("InputY", inputY);
}
}
}
实现选中背包物品触发举起动画
首先在player身下创建一个hold item,用于展示举起的物品。
确保其在人物前面显示,层级设置为2。 图片锚点设置在下方。
我们更改人物动画的方式通过使用animatorOverride Controller来实现的:
根据不同的状态,然后切换不同的controller即可
然后我们用一个类将PartType、PartName和animatorOverriderContoller合在一起,方便调用,作为一个AnimatorType这个类给我们更换控制器的脚本使用
然后在切换控制器的脚本里声明一个List,在inspector窗口中如下:
此时根据我们选择的PartType和PartName修改不同的控制器:
添加一个物品被点选后的事件,当物品被点选时触发这个事件:
接下来是修改动画控制器的脚本,思路是首先获取player的三个子物体(hair、body、arm)三个部分的animator。接下来animator对应的动画控制器思路很简单:
Animator.runtimeAnimatorController = 其他的控制器即可。
另一方面,我们需要设定,不同的部位,在不同的状态下时应该使用什么动画控制器?
这个需要我们在inspector窗口中手动设定:
注意到上面给出的不同的部位,是用名字来进行区分的。而我们需要根据名字去获取对应的animator,所以此处使用字典:
于是将其存储起来在字典里,key是名字(即hair),value则是对应的animator。
随后,当我们点击物品时,则会触发点击事件,点击事件会执行绑定对应的函数。
该函数会执行以下的内容:
如果该物品的类型是种子或者商品,说明是可以举起来的,那么接下来就会把当前状态变更为举起的状态:
private void OnItemSelectedEvent(ItemDetails itemDetails, bool isSelected)
{
//WORKFLOW:不同的工具返回不同的动画在这里补全
PartType currentType = itemDetails.itemType switch
{
ItemType.Seed => PartType.Carry,
ItemType.Commodity => PartType.Carry,
_ => PartType.None
};
当前状态改变完毕时,我们要确保当前的物品仍是选中状态,如果不是则不举起,也就是切换回原来的形态:
if (isSelected == false)
{
currentType = PartType.None;
holdItem.enabled = false;
}
else
{
if (currentType == PartType.Carry)
{
holdItem.sprite = itemDetails.itemOnWorldSprite;
holdItem.enabled = true;
}
最后执行切换状态控制器的函数:
SwitchAnimator(currentType);
private void SwitchAnimator(PartType partType)
{
foreach (var item in animatorTypes)
{
if (item.partType == partType)
{
animatorNameDict[item.partName.ToString()].runtimeAnimatorController = item.overrideController;
}
}
}
完整代码:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class AnimatorOverride : MonoBehaviour
{
private Animator[] animators;
public SpriteRenderer holdItem;
[Header("各部分动画列表")]
public List<AnimatorType> animatorTypes;//这个列表是在inspector窗口中去设置的
private Dictionary<string, Animator> animatorNameDict = new Dictionary<string, Animator>();
private void Awake()
{
animators = GetComponentsInChildren<Animator>();
foreach (var anim in animators)
{
animatorNameDict.Add(anim.name, anim);//根据在inspector窗口中设定的内容设定字典
}
}
private void OnEnable()
{
EventHandler.ItemSelectedEvent += OnItemSelectedEvent;
}
private void OnDisable()
{
EventHandler.ItemSelectedEvent -= OnItemSelectedEvent;
}
private void OnItemSelectedEvent(ItemDetails itemDetails, bool isSelected)
{
//WORKFLOW:不同的工具返回不同的动画在这里补全
PartType currentType = itemDetails.itemType switch
{
ItemType.Seed => PartType.Carry,
ItemType.Commodity => PartType.Carry,
_ => PartType.None
};
if (isSelected == false)
{
currentType = PartType.None;
holdItem.enabled = false;
}
else
{
if (currentType == PartType.Carry)
{
holdItem.sprite = itemDetails.itemOnWorldSprite;
holdItem.enabled = true;
}
}
SwitchAnimator(currentType);
}
private void SwitchAnimator(PartType partType)
{
foreach (var item in animatorTypes)
{
if (item.partType == partType)
{
animatorNameDict[item.partName.ToString()].runtimeAnimatorController = item.overrideController;
}
}
}
}
实际效果:
构建游戏的时间系统
时间流逝
代码思路很简单,通过Time.deltaTime让时间进行流逝,然后进行秒++,如果秒到59则进位到分钟,再到时、天、月、季、年
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class TimeManager : MonoBehaviour
{
private int gameSecond, gameMinute, gameHour, gameDay, gameMonth, gameYear;
private Season gameSeason = Season.春天;
private int monthInSeason = 3;
public bool gameClockPause;
private float tikTime;
private void Awake()
{
NewGameTime();
}
private void Update()
{
if (!gameClockPause)
{
tikTime += Time.deltaTime;
if (tikTime >= Settings.secondThreshold)
{
tikTime -= Settings.secondThreshold;
UpdateGameTime();
}
}
}
private void NewGameTime()
{
gameSecond = 0;
gameMinute = 0;
gameHour = 7;
gameDay = 1;
gameMonth = 1;
gameYear = 2022;
gameSeason = Season.春天;
}
private void UpdateGameTime()
{
gameSecond++;
if (gameSecond > Settings.secondHold)
{
gameMinute++;
gameSecond = 0;
if (gameMinute > Settings.minuteHold)
{
gameHour++;
gameMinute = 0;
if (gameHour > Settings.hourHold)
{
gameDay++;
gameHour = 0;
if (gameDay > Settings.dayHold)
{
gameDay = 1;
gameMonth++;
if (gameMonth > 12)
gameMonth = 1;
monthInSeason--;
if (monthInSeason == 0)
{
monthInSeason = 3;
int seasonNumber = (int)gameSeason;
seasonNumber++;
if (seasonNumber > Settings.seasonHold)
{
seasonNumber = 0;
gameYear++;
}
gameSeason = (Season)seasonNumber;
if (gameYear > 9999)
{
gameYear = 2022;
}
}
}
}
}
}
// Debug.Log("Second: " + gameSecond + " Minute: " + gameMinute);
}
}
时间UI及对应的时间变更
创建时间相关的UI组件,并设定好布局
当时间进行切换时,用事件的形式去通知
思路其实很简单,时间流逝->进位->触发事件,事件根据时间修改UI展示内容即可:
代码如下:
TimeManager:用于管理时间的各个单位,让时间流逝,并触发事件
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class TimeManager : MonoBehaviour
{
private int gameSecond, gameMinute, gameHour, gameDay, gameMonth, gameYear;
private Season gameSeason = Season.春天;
private int monthInSeason = 3;
public bool gameClockPause;
private float tikTime;
private void Awake()
{
NewGameTime();
}
private void Start()
{
EventHandler.CallGameDateEvent(gameHour, gameDay, gameMonth, gameYear, gameSeason);
EventHandler.CallGameMinuteEvent(gameMinute, gameHour);
}
private void Update()
{
if (!gameClockPause)
{
tikTime += Time.deltaTime;
if (tikTime >= Settings.secondThreshold)
{
tikTime -= Settings.secondThreshold;
UpdateGameTime();
}
}
if (Input.GetKey(KeyCode.T))
{
for(int i = 0; i < 60; i++)
{
UpdateGameTime();
}
}
}
private void NewGameTime()
{
gameSecond = 0;
gameMinute = 0;
gameHour = 7;
gameDay = 1;
gameMonth = 1;
gameYear = 2022;
gameSeason = Season.春天;
}
private void UpdateGameTime()
{
gameSecond++;
if (gameSecond > Settings.secondHold)
{
gameMinute++;
gameSecond = 0;
if (gameMinute > Settings.minuteHold)
{
gameHour++;
gameMinute = 0;
if (gameHour > Settings.hourHold)
{
gameDay++;
gameHour = 0;
if (gameDay > Settings.dayHold)
{
gameDay = 1;
gameMonth++;
if (gameMonth > 12)
gameMonth = 1;
monthInSeason--;
if (monthInSeason == 0)
{
monthInSeason = 3;
int seasonNumber = (int)gameSeason;
seasonNumber++;
if (seasonNumber > Settings.seasonHold)
{
seasonNumber = 0;
gameYear++;
}
gameSeason = (Season)seasonNumber;
if (gameYear > 9999)
{
gameYear = 2022;
}
}
}
}
EventHandler.CallGameDateEvent(gameHour, gameDay, gameMonth, gameYear, gameSeason);
}
EventHandler.CallGameMinuteEvent(gameMinute, gameHour);
}
// Debug.Log("Second: " + gameSecond + " Minute: " + gameMinute);
}
}
TimeUI:让UI组件和代码进行绑定,实现一个注册到时间改变事件的函数,当触发事件时,相应的去改变UI上显示的信息即可。
using System.Collections;
using System.Collections.Generic;
using DG.Tweening;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
public class TimeUI : MonoBehaviour
{
public RectTransform dayNightImage;
public RectTransform clockParent;
public Image seasonImage;
public TextMeshProUGUI dateText;
public TextMeshProUGUI timeText;
public Sprite[] seasonSprites;
private List<GameObject> clockBlocks = new List<GameObject>();
private void Awake()
{
for (int i = 0; i < clockParent.childCount; i++)
{
clockBlocks.Add(clockParent.GetChild(i).gameObject);
clockParent.GetChild(i).gameObject.SetActive(false);
}
}
private void OnEnable()
{
EventHandler.GameMinuteEvent += OnGameMinuteEvent;
EventHandler.GameDateEvent += OnGameDateEvent;
}
private void OnDisable()
{
EventHandler.GameMinuteEvent -= OnGameMinuteEvent;
EventHandler.GameDateEvent -= OnGameDateEvent;
}
private void OnGameMinuteEvent(int minute, int hour)
{
timeText.text = hour.ToString("00") + ":" + minute.ToString("00");
}
private void OnGameDateEvent(int hour, int day, int month, int year, Season season)
{
dateText.text = year + "年" + month.ToString("00") + "月" + day.ToString("00") + "日";
seasonImage.sprite = seasonSprites[(int)season];
SwitchHourImage(hour);//让六个格子亮起来,每4个小时亮一个格子
DayNightImageRotate(hour);//旋转图片
}
/// <summary>
/// 根据小时切换时间块显示
/// </summary>
/// <param name="hour"></param>
private void SwitchHourImage(int hour)
{
int index = hour / 4;
if (index == 0)
{
foreach (var item in clockBlocks)
{
item.SetActive(false);
}
}
else
{
for (int i = 0; i < clockBlocks.Count; i++)
{
if (i < index)
clockBlocks[i].SetActive(true);
else
clockBlocks[i].SetActive(false);
}
}
}
private void DayNightImageRotate(int hour)
{
var target = new Vector3(0, 0, hour * 15 - 90);
dayNightImage.DORotate(target, 1f, RotateMode.Fast);
}
}
效果如下:
场景间切换
绘制一个新的第二场景,注意添加碰撞层
场景切换
思路很简单,卸载旧的场景,加载新的场景即可。场景切换在TransitionManager使用注册到事件的函数实现。需要设定一个出门点。当触碰到出门点时则触发Ontrigger,在Ontrigger里面调用事件即可。
代码:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace MFarm.Transition
{
public class TransitionManager : MonoBehaviour
{
public string startSceneName = string.Empty;
private void OnEnable()
{
EventHandler.TransitionEvent += OnTransitionEvent;
}
private void OnDisable()
{
EventHandler.TransitionEvent -= OnTransitionEvent;
}
private void Start()
{
StartCoroutine(LoadSceneSetActive(startSceneName));
}
private void OnTransitionEvent(string sceneToGo, Vector3 positionToGo)
{
StartCoroutine(Transition(sceneToGo, positionToGo));
}
/// <summary>
/// 场景切换,卸载旧的场景,并执行加载新场景的协程
/// </summary>
/// <param name="sceneName">目标场景</param>
/// <param name="targetPosition">目标位置</param>
/// <returns></returns>
private IEnumerator Transition(string sceneName, Vector3 targetPosition)
{
yield return SceneManager.UnloadSceneAsync(SceneManager.GetActiveScene());
yield return LoadSceneSetActive(sceneName);
}
/// <summary>
/// 加载场景并设置为激活
/// </summary>
/// <param name="sceneName">场景名</param>
/// <returns></returns>
private IEnumerator LoadSceneSetActive(string sceneName)
{
//使用异步加载的方式,加载模式使用的是在原有的场景中叠加一个新的场景
yield return SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive);
Scene newScene = SceneManager.GetSceneAt(SceneManager.sceneCount - 1);
SceneManager.SetActiveScene(newScene);
}
}
}
设定的出门点的代码:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Teleport : MonoBehaviour
{
public string sceneToGo;
public Vector3 positionToGo;
private void OnTriggerEnter2D(Collider2D other)
{
if (other.CompareTag("Player"))
{
EventHandler.CallTransitionEvent(sceneToGo, positionToGo);
}
}
}
这里别忘了添加事件:
人物跨场景移动以及场景加载前后事件
场景切换时,一些在之前从场景通过Start获取的物体会因为场景切换而丢失目标。
为了防止场景加载时人物移动,添加一个变量
为了使得场景切换时人物不能移动,添加事件控制,卸载场景时使其不能动
移动位置也很简单:
然后进行一些类似上面的操作,在原来需要在Start里面获取的一些物品或者执行的函数,放到OnEnable和OnDisable中通过注册事件的函数来实现,保证不会因场景切换而出现
NullReferenceException: Object reference not set to an instance of an object
这样的bug。
效果如下:
在TransitionManager中添加Fade的协程:
/// <summary>
/// 淡入淡出场景
/// </summary>
/// <param name="targetAlpha">1是黑,0是透明</param>
/// <returns></returns>
private IEnumerator Fade(float targetAlpha)
{
isFade = true;
fadeCanvasGroup.blocksRaycasts = true;//这个时候点击无效
float speed = Mathf.Abs(fadeCanvasGroup.alpha - targetAlpha) / Settings.fadeDuration;
while (!Mathf.Approximately(fadeCanvasGroup.alpha, targetAlpha))
{
fadeCanvasGroup.alpha = Mathf.MoveTowards(fadeCanvasGroup.alpha, targetAlpha, speed * Time.deltaTime);
yield return null;//只要不相等,就会一直执行该协程
}
fadeCanvasGroup.blocksRaycasts = false;
isFade = false;
}
加载场景时调用:
效果如下:
为了存储场景中的物品信息,使得在场景切换时场景中的物品不是过去的信息(比如防止拾取物品后再回来又有重新物品的情况)
我们此处使用一个类来存储场景中的物品信息。
使用一个字典列表来存储场景中的所有物品,key是string类型的,是记录场景的名字,而value就是这个场景中的所有物品组成的列表。:
接下来我们希望在每次读取场景前,都先扫描当前场景有哪些物品,那么这个实现的方法就是在卸载场景后,我们保存信息。这样下次读取场景时就有对应的列表可以更新了。如果当前场景是新场景,则将新场景和新场景有的物品添加到列表中。
/// <summary>
/// 获得当前场景所有Item
/// </summary>
private void GetAllSceneItems()
{
List<SceneItem> currentSceneItems = new List<SceneItem>();
foreach (var item in FindObjectsOfType<Item>())
{
SceneItem sceneItem = new SceneItem
{
itemID = item.itemID,
position = new SerializableVector3(item.transform.position)
};
currentSceneItems.Add(sceneItem);
}
//首先上面先要获取当前场景中的物品
if (sceneItemDict.ContainsKey(SceneManager.GetActiveScene().name))
{
//找到数据就更新item数据列表
sceneItemDict[SceneManager.GetActiveScene().name] = currentSceneItems;
}
else //如果是新场景
{
sceneItemDict.Add(SceneManager.GetActiveScene().name, currentSceneItems);
}
}
调用时机则是在场景卸载前:
private void OnBeforeSceneUnloadEvent()
{
GetAllSceneItems();
}
在场景加载完毕后,我们则更新当前场景
/// <summary>
/// 刷新重建当前场景物品
/// </summary>
private void RecreateAllItems()
{
List<SceneItem> currentSceneItems = new List<SceneItem>();
if (sceneItemDict.TryGetValue(SceneManager.GetActiveScene().name, out currentSceneItems))
{
if (currentSceneItems != null)
{
//清场
foreach (var item in FindObjectsOfType<Item>())
{
Destroy(item.gameObject);
}
foreach (var item in currentSceneItems)
{
Item newItem = Instantiate(itemPrefab, item.position.ToVector3(), Quaternion.identity, itemParent);
newItem.Init(item.itemID);
}
}
}
}
这样即可实现保存场景中的物品效果。
设置鼠标指针根据物品调整
如果在此处使用unity自带的canvas,则当前版本无法修改它的大小。如果希望cursor有额外的效果,放大、渐变的效果,换颜色的话,不太容易。
所以此处使用image来跟随鼠标的位置实现cursor的效果。
思路也很简单,先对图片设置一个跟随,然后根据当前是什么物品然后切换不同的指针图片,并且在选中物品和取消选中时改变。然后还需要注意如果当前鼠标指向的地方是UI界面,则让其恢复为默认状态。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
public class CursorManager : MonoBehaviour
{
public Sprite normal, tool, seed, item;
private Sprite currentSprite; //存储当前鼠标图片
private Image cursorImage;
private RectTransform cursorCanvas;
private void OnEnable()
{
EventHandler.ItemSelectedEvent += OnItemSelectedEvent;
}
private void OnDisable()
{
EventHandler.ItemSelectedEvent -= OnItemSelectedEvent;
}
private void Start()
{
cursorCanvas = GameObject.FindGameObjectWithTag("CursorCanvas").GetComponent<RectTransform>();
cursorImage = cursorCanvas.GetChild(0).GetComponent<Image>();
currentSprite = normal;
SetCursorImage(normal);
}
private void Update()
{
if (cursorCanvas == null) return;
cursorImage.transform.position = Input.mousePosition;
if (!InteractWithUI())
{
SetCursorImage(currentSprite);
}
else
{
SetCursorImage(normal);//如果不是和UI有互动,则设置为普通的图片
}
}
/// <summary>
/// 设置鼠标图片
/// </summary>
/// <param name="sprite"></param>
private void SetCursorImage(Sprite sprite)
{
cursorImage.sprite = sprite;
cursorImage.color = new Color(1, 1, 1, 1);
}
/// <summary>
/// 物品选择事件函数,根据选中不同类型的物品选择不同的类型图片
/// </summary>
/// <param name="itemDetails"></param>
/// <param name="isSelected"></param>
private void OnItemSelectedEvent(ItemDetails itemDetails, bool isSelected)
{
if (!isSelected)
{
currentSprite = normal;//没被选中则切换为普通的图片
}
else //物品被选中才切换图片
{
//WORKFLOW:添加所有类型对应图片
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;
}
return false;
}
}