本文最后更新于 2024-07-13T18:20:01+08:00
背包系统
背包 GUI 的实现、ScriptableObject、数据的存储与读取。
层级结构
Bag 挂载 BagOnDrag 脚本
Grid 挂载 Grid Layout Group 组件,使其子物体(Slot)整齐地排放
Slot 挂载 Slot 脚本,更改其子物体(Item)以实现拖拽
Item 挂载 ItemOnDrag 脚本和 CanvasGroup 组件,以及作为 buttom 用于触发 Slot 中的 OnItemClicked()
Image 和 Count 负责物品图片和数量的显示
Grid Layout Group
后端
物品数据
以 ScriptableObject 的方式存储每一种物品的数据。
1 2 3 4 5 6 7 8 9 [CreateAssetMenu(fileName = "NewItem" ,menuName = "Inventory/New Item" ) ]public class InventoryItem : ScriptableObject { public string itemName; public Sprite itemSprite; public int itemCount = 1 ; [TextArea ] public string itemInfo; }
背包数据
以 ScriptableObject 的方式存储每个背包的数据,每个背包类内维护一个 List 用于存储多个物品。
1 2 3 4 5 [CreateAssetMenu(fileName = "NewBag" , menuName = "Inventory/New Bag" ) ]public class InventoryBag : ScriptableObject { public List<InventoryItem> itemList = new List<InventoryItem>(); }
捡起物品
ItemOnWorld 挂载在物品对象上,随时更新两个 SO 的数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 public class ItemOnWorld : MonoBehaviour { public InventoryItem inventoryItem; public InventoryBag inventoryBag; private void OnTriggerEnter2D (Collider2D collision ) { if (collision.CompareTag("Player" )) { AddItemToBag(); Destroy(gameObject); } } void AddItemToBag () { if (inventoryBag.itemList.Contains(inventoryItem)) { inventoryItem.itemCount++; } else { for (int i = 0 ; i < inventoryBag.itemList.Count; i++) { if (inventoryBag.itemList[i] == null ) { inventoryBag.itemList[i] = inventoryItem; break ; } Debug.Log("Bag Over Flow!" ); } } InventoryManager.UpdateGUI(); } }
前端
Manager
单例模式的 InventoryManager,主要作用是提供更新 GUI 的接口 InventoryManager.UpdateGUI() 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 public class InventoryManager : MonoBehaviour { static InventoryManager instance; public InventoryBag bag; public GameObject grid; public GameObject emptySlot; public Text itemInfo; public List<GameObject> slotList = new List<GameObject>(); private void Awake () { if (instance != null ) { Destroy(gameObject); } instance = this ; UpdateGUI(); } public static void UpdateItemInfo (string info ) { instance.itemInfo.text = info; } public static void UpdateGUI () { instance.slotList.Clear(); for (int i = 0 ; i < instance.grid.transform.childCount; i++) { Destroy(instance.grid.transform.GetChild(i).gameObject); } for (int i = 0 ; i < instance.bag.itemList.Count; i++) { instance.slotList.Add(Instantiate( instance.emptySlot, instance.grid.transform, false )); instance.slotList[i].GetComponent<Slot>() .InitSlot(instance.bag.itemList[i]); } } }
Slot
负责 UpdateGUI() 中针对具体 Slot 中 Item 数据的更新,以及负责 bag 中物品描述的更新。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 public class Slot : MonoBehaviour { public GameObject slotItem; public Image slotImage; public Text slotCount; public string slotInfo; private void OnEnable () { InventoryManager.UpdateItemInfo("" ); } public void OnItemClicked () { InventoryManager.UpdateItemInfo(slotInfo); } public void InitSlot (InventoryItem inventoryItem ) { if (inventoryItem == null ) { slotItem.SetActive(false ); return ; } else { slotImage.sprite = inventoryItem.itemSprite; slotCount.text = inventoryItem.itemCount.ToString(); slotInfo = inventoryItem.itemInfo; } } }
背包内物品的拖拽
实现 EventSystems 中三个接口以响应鼠标的交互。
更改 Item 的父级和 Item 的位置以实现拖拽效果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 using UnityEngine.EventSystems;public class ItemOnDrag : MonoBehaviour , IBeginDragHandler , IDragHandler , IEndDragHandler { public InventoryBag bag; private Transform originalParent; private int firstIndex, secondIndex; public void OnBeginDrag (PointerEventData eventData ) { firstIndex = GetIndex(transform.parent); originalParent = transform.parent; transform.SetParent(transform.parent.parent.parent.parent); transform.position = eventData.position; GetComponent<CanvasGroup>().blocksRaycasts = false ; } public void OnDrag (PointerEventData eventData ) { transform.position = eventData.position; } public void OnEndDrag (PointerEventData eventData ) { GameObject crtObject = eventData.pointerCurrentRaycast.gameObject; if (crtObject == null ) { transform.SetParent(originalParent); transform.position = originalParent.transform.position; GetComponent<CanvasGroup>().blocksRaycasts = true ; return ; } if (crtObject.name == "Image" || crtObject.name == "Count" ) { Transform crtItem = crtObject.transform.parent; Transform crtSlot = crtItem.parent; secondIndex = GetIndex(crtSlot); transform.SetParent(crtSlot); transform.position = crtSlot.position; crtItem.SetParent(originalParent); crtItem.position = originalParent.position; } else if (crtObject.name == "Slot(Clone)" ) { secondIndex = GetIndex(crtObject.transform); transform.SetParent(crtObject.transform); transform.position = crtObject.transform.position; } else { transform.SetParent(originalParent); transform.position = originalParent.transform.position; GetComponent<CanvasGroup>().blocksRaycasts = true ; return ; } Debug.Log("firstIndex: " + firstIndex + " " + "secondIndex: " + secondIndex); InventoryItem tmp = bag.itemList[firstIndex]; bag.itemList[firstIndex] = bag.itemList[secondIndex]; bag.itemList[secondIndex] = tmp; GetComponent<CanvasGroup>().blocksRaycasts = true ; } private int GetIndex (Transform currentSlot ) { Transform grid = currentSlot.parent; for (int i=0 ;i< grid.childCount; i++) { if (grid.GetChild(i) == currentSlot) { return i; } } return 1 ; } }
背包的拖拽
1 2 3 4 5 6 7 8 9 10 public class BagOnDrag : MonoBehaviour , IDragHandler { private RectTransform rectTransform; private void Awake () { rectTransform = GetComponent<RectTransform>(); } public void OnDrag (PointerEventData eventData ) { rectTransform.anchoredPosition += eventData.delta; } }
存储 Scriptable Object
存储时
Scriptable Object 通过 JsonUtility.ToJson(so) 转换为 json。
json 通过 formatter.Serialize(file, json) 写入文件。
读取时
文件通过 formatter.Deserialize(file) 转换为 json。
json 通过 JsonUtility.FromJsonOverwrite(json, so) 写入 Scriptable Object。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 using System.IO;using System.Runtime.Serialization.Formatters.Binary;public class SaveLoadManager : MonoBehaviour { public InventoryBag inventoryBag; public InventoryItem item1; public InventoryItem item2; private string PATH; private void Awake () { PATH = Application.persistentDataPath; } public void SaveGame () { Debug.Log(PATH); if (!Directory.Exists(PATH + "/SaveData" )) { Directory.CreateDirectory(PATH + "/SaveData" ); } SaveData(inventoryBag, nameof (inventoryBag)); SaveData(item1, nameof (item1)); SaveData(item2, nameof (item2)); } private void SaveData <T >(T so, string name ) { BinaryFormatter formatter = new BinaryFormatter(); FileStream file = File.Create(PATH + "/SaveData/" + name + ".bin" ); string json = JsonUtility.ToJson(so); formatter.Serialize(file, json); file.Close(); } public void LoadGame () { LodData(inventoryBag, nameof (inventoryBag)); LodData(item1, nameof (item1)); LodData(item2, nameof (item2)); InventoryManager.UpdateGUI(); } private void LodData <T >(T so, string name ) { BinaryFormatter formatter = new BinaryFormatter(); if (File.Exists(PATH + "/SaveData/" + name + ".bin" )) { FileStream file = File.Open(PATH + "/SaveData/" + name + ".bin" , FileMode.Open); string json = (string )formatter.Deserialize(file); JsonUtility.FromJsonOverwrite(json, so); file.Close(); } } }
相关链接
Scriptable Object
Create Asset Menu Attribute
Instantiate
Supported Events
Rect Transform
persistentDataPath
Directory
JsonUtility
BinaryFormatter
打击感
一般来说,打击感由帧冻结、镜头抖动与运动、敌人的受击反馈(动画、音效、击退等)、特效、环境的反馈等组成。
连击
攻击结束方法可以不在攻击动画的最后一帧调用,而是提前几帧。这是因为连击往往存在预输入,这样做可以提高连贯性,提升手感。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 private Coroutine tmpCoroutine = null ;private void Attack () { if (!animator.GetBool(isAttackID) && !isHurt && !isDash && !isParryStance) { if (uponGround) { if (Input.GetMouseButton(0 )) { if (tmpCoroutine != null ) { StopCoroutine(tmpCoroutine); } tmpCoroutine = StartCoroutine(WaitForAttackOver(4f / 14f )); body.velocity = Vector2.zero; isAttack = true ; timer = interval; combo++; if (combo > finalMaxCombo) { combo = 1 ; } animator.SetBool(isAttackID, true ); animator.SetTrigger(lightAttackID); body.MovePosition(frontPoint.position); animator.SetInteger(comboID, combo); attackName = "Light" ; } if (Input.GetMouseButton(1 )) { if (tmpCoroutine != null ) { StopCoroutine(tmpCoroutine); } tmpCoroutine = StartCoroutine(WaitForAttackOver(6f / 14f )); body.velocity = Vector2.zero; isAttack = true ; animator.SetBool(isAttackID, true ); animator.SetTrigger(haveyAttackID); attackName = "Heavy" ; } } else { if (Input.GetMouseButton(0 )) { animator.SetBool(isAttackID, true ); animator.SetTrigger(lightAttackID); attackName = "Light" ; } if (Input.GetMouseButton(1 )) { if (tmpCoroutine != null ) { StopCoroutine(tmpCoroutine); } tmpCoroutine = StartCoroutine(WaitForAttackOver(6f / 14f )); body.velocity = Vector2.zero; isAttack = true ; isSlam = true ; animator.SetBool(isAttackID, true ); animator.SetTrigger(haveyAttackID); attackName = "Slam" ; } } } if (timer >= 0 ) { timer -= Time.deltaTime; if (timer <= 0 ) { combo = 0 ; } } }private IEnumerator WaitForAttackOver (float time ) { yield return new WaitForSeconds (time ) ; isAttack = false ; isSlam = false ; animator.SetBool(isAttackID, false ); }public void AttackOver () { animator.SetBool(isAttackID, false ); }
帧冻结
帧冻结结束后可以适当提升3-5倍的动画速度以补偿缺失的时间。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public class AttackSense : MonoBehaviour { public static AttackSense instance; private bool isShake; private void Awake () { instance = this ; } public void FrameFreeze (int pauseFrame ) { StartCoroutine(PauseOnAttack(pauseFrame)); } IEnumerator PauseOnAttack (int pauseFrame ) { float pauseTime = pauseFrame / 60f ; Time.timeScale = 0 ; yield return new WaitForSecondsRealtime (pauseTime ) ; Time.timeScale = 1 ; } }
镜头抖动
为相机添加
为玩家的攻击碰撞体添加,并调整对应的参数
反馈
敌人的反馈会在有限状态机中进行具体的实现。
有限状态机
有限状态机是一个具有有限数量状态的模型,并且同时只能处于一种状态,可以通过外部输入等方式触发状态之间的切换。这里实现一个简易的敌人 AI。
接口
所有的状态类都继承这个接口。
1 2 3 4 5 6 7 public interface IState { public void OnEnter () ; public void OnUpdate () ; public void OnFixedUpdate () ; public void OnExit () ; }
FSM
挂载在敌人身上,这里省略一些组件的定义与获取。一些对所有状态通用的方法也可以写在这里。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 public enum StateType { Idle, Run, Attack, Hurt, Throw, SpecialAttack }public class FSM : MonoBehaviour { private IState crtState = null ; private Dictionary<StateType, IState> stateLise = new Dictionary<StateType, IState>(); void Awake () { stateLise.Add(StateType.Idle, new IdleState(this )); stateLise.Add(StateType.Run, new RunState(this )); stateLise.Add(StateType.Attack, new AttackState(this )); stateLise.Add(StateType.Hurt, new HurtState(this )); stateLise.Add(StateType.Throw, new ThrowState(this )); stateLise.Add(StateType.SpecialAttack, new SpecialAttackState(this )); ChangeState(StateType.Idle); } private void Update () { crtState.OnUpdate(); } private void FixedUpdate () { crtState.OnFixedUpdate(); } public void ChangeState (StateType type ) { if (crtState != null ) { crtState.OnExit(); } crtState = stateLise[type]; crtState.OnEnter(); } }
状态类
以普通攻击状态为例。
动画在具体的状态类中进行控制,动画中包含了录制好的攻击碰撞器(Trigger)的启用与变形。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public class AttackState : IState { private FSM fsm; private AnimatorStateInfo animeInfo; public AttackState (FSM fsmIn ) { this .fsm = fsmIn; } public void OnEnter () { fsm.body.velocity = Vector2.zero; fsm.animator.Play("Rogue_Attack" ); } public void OnUpdate () { animeInfo = fsm.animator.GetCurrentAnimatorStateInfo(0 ); if (animeInfo.normalizedTime >= 0.99f ) { fsm.ChangeState(StateType.Idle); } } public void OnFixedUpdate () { } public void OnExit () { fsm.idleStartTime = Time.time; } }
对象池
对于需要大量或者多次反复实例化,且需要销毁的物体(比如子弹、残影等),频繁地创建与销毁物体对于性能的开销过大。
故使用对象池,提前生成一定数量的空物体,在需要时取一进行启用与赋值,一段时间后进行回收,一定程度上减少了动态加载性能的消耗。
这里实现一个角色冲锋产生残影的效果。
预制体
对象池实现
由一单例控制空物体的预生成、启用和回收。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 public class AfterimagePool : MonoBehaviour { public static AfterimagePool instance; public GameObject imagePrefab; private Queue<GameObject> pool = new Queue<GameObject>(); private void Awake () { if (instance == null ) { instance = this ; } FillPoll(10 ); } public void ReturnToPool (GameObject objectIn ) { objectIn.SetActive(false ); pool.Enqueue(objectIn); } public void FillPoll (int num ) { for (int i = 0 ; i < num; i++) { GameObject newImage = Instantiate(imagePrefab); newImage.transform.SetParent(transform); ReturnToPool(newImage); } } public void TakeFromPool () { if (pool.Count <= 0 ) { FillPoll(5 ); } GameObject crtImage = pool.Dequeue(); crtImage.SetActive(true ); } }
启用物体
在 OnEnable() 中将 Player 此刻的 Sprite、位置等信息赋给空物体,然后不断减小其 Alpha 值。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 public class Afterimage : MonoBehaviour { public float activeTime; [Range(0f, 1f) ] public float startAlpha; private Transform player; private SpriteRenderer thisSR, playerSR; private float alpha, startTime; private void OnEnable () { player = GameObject.FindGameObjectWithTag("Player" ).transform; thisSR = GetComponent<SpriteRenderer>(); playerSR = player.GetComponent<SpriteRenderer>(); alpha = startAlpha; startTime = Time.time; thisSR.sprite = playerSR.sprite; transform.position = player.position; transform.localScale = player.localScale; transform.rotation = player.rotation; StartCoroutine(DecreaseAlpha()); } void Update () { thisSR.color = new Color(0.5f , 0.5f , 1 , alpha); if (Time.time - startTime >= activeTime) { AfterimagePool.instance.ReturnToPool(gameObject); } } private IEnumerator DecreaseAlpha () { while (alpha > 0 ) { alpha -= (startAlpha / (activeTime * 50 )); yield return new WaitForFixedUpdate () ; } } }
单例模式
单例有且仅有一个静态的实例,可以直接通过这个实例调用方法,而无需实例化该类的对象。
简单实现
1 2 3 4 5 6 7 8 9 10 11 12 public class ExampleManager : MonoBehaviour { public static ExampleManager instance; private void Awake () { if (instance == null ) { instance = this ; } } public void ExampleFun () { DO SOMETHING; } }
外部调用时只需写作
1 ExampleManager.instance.ExampleFun();
另一种做法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class ExampleManager : MonoBehaviour { private static ExampleManager instance; public static ExampleManager Instance { get { if (instance == null ) { instance = FindObjectOfType<ExampleManager>(); } return instance; } } public void ExampleFun () { DO SOMETHING; } }
外部调用时写作
1 ExampleManager.Instance.ExampleFun();
更优雅的做法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public class ExampleManager <T > : MonoBehaviour where T : ExampleManager <T > { private static T instance; public static ExampleManager Instance { get { return instance; } } protected virtual void Awake () { id(instance == null ){ instance = (T)this ; } } public static bool IsInit{ get { return (instance != null ); } } protected virtual void OnDestory () { if (instance == this ){ instance = null ; } } }
以后任何新的单例类只用继承这个泛型类即可。
我的 SoundManager
音效
首先需要一个挂载 Manager 的 Object
上一个场景的 Manager 实例需要带到下一个场景中去,故使用 DontDestroyOnLoad(gameObject)
调试时若不从第一个场景进入则不存在该 Object,十分不便
若每个场景都放置该 bject,大量不会销毁的 Object 会带着其上挂载的的 Manager 堆积起来
故在 Awake 中做判断销毁任何新加载的 Object
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 public class SFXMananger : MonoBehaviour { public static SFXMananger instance; [SerializeField ] private AudioClip hurtAudio, collectAudio, enemyDestoryAudio; private AudioSource audioSource; private void Awake () { audioSource = GetComponent<AudioSource>(); if (instance == null ) { instance = this ; } else if (instance != this ) { Destroy(gameObject); } DontDestroyOnLoad(gameObject); } public void HurtAudio () { audioSource.clip = hurtAudio; audioSource.Play(); } public void CollectAudio () { audioSource.clip = collectAudio; audioSource.Play(); } public void EnemyDestoryAudio () { audioSource.clip = enemyDestoryAudio; audioSource.Play(); } }
bgm
在满足与音效相同的条件之上,反复加载同一场景时,我不希望 bgm 从头开始播放。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 public class BGMManager : MonoBehaviour { public static BGMManager instance; public static int sceneIndex; [SerializeField ] private AudioClip openingBGM, scene1BGM, scene2BGM, scene3BGM, endingBGM, defautBGM; private AudioSource audioSource; private void Awake () { audioSource = GetComponent<AudioSource>(); int crtIndex = SceneManager.GetActiveScene().buildIndex; if (instance == null ) { instance = this ; sceneIndex = crtIndex; } else if (instance != this ) { Destroy(gameObject); if (sceneIndex != crtIndex) { instance.SwitchBGM(crtIndex); sceneIndex = crtIndex; } } DontDestroyOnLoad(gameObject); } public void SwitchBGM (int i ) { switch (i) { case 0 : audioSource.clip = openingBGM; audioSource.Play(); break ; case 1 : audioSource.clip = scene1BGM; audioSource.Play(); break ; case 2 : audioSource.clip = scene2BGM; audioSource.Play(); break ; case 3 : audioSource.clip = scene3BGM; audioSource.Play(); break ; case 4 : audioSource.clip = endingBGM; audioSource.Play(); break ; default : audioSource.clip = defautBGM; audioSource.Play(); break ; } } }
协程
协程就像一个函数,能够暂停执行并将控制权返还给 Unity 一段时间,可以是等待一帧,可以是等待几秒,也可以是等待另一个协程。当需要一个函数的内容(如一个循环)不需要在单帧内执行完成时往往会使用协程。
使用
1 2 3 4 5 6 7 8 IEnumerator Fun () { DO SOMETHING; yield return null ; yield return new WaitForSeconds (.1 f ) ; yield return StartCoroutine (Fun2( )) ; } StartCoroutine(Fun());
我的主菜单
间隔dt秒依次激活所有 UI。
1 2 3 4 5 6 7 8 9 10 11 12 13 public class MainMenu : MonoBehaviour { public GameObject titleUI, startUI, endUI; void Start () { StartCoroutine(Delayed(0.65f , titleUI, startUI, endUI)); } IEnumerator Delayed (float dt, params GameObject[] list ) { foreach (GameObject obj in list) { yield return new WaitForSeconds (dt ) ; obj.SetActive(true ); } } }
相关链接
Bounds
MovePosition
Animator
AnimatorStateInfo
SpriteRenderer
SceneManager
Scene
DontDestroyOnLoad
Coroutines
YieldInstruction