前言
欢迎来到【制作100个Unity游戏】系列!本系列将引导您一步步学习如何使用Unity开发各种类型的游戏。在这第26篇中,我们将探索如何用unity制作一个unity2d横版卷轴动作类游戏,我会附带项目源码,以便你更好理解它。
本节主要实现灯光 后处理 存储和持久化存储
存储点
存储点的实现和宝箱类似
新增SavePoint
public class SavePoint : MonoBehaviour, IInteractable { private SpriteRenderer spriteRenderer; public Sprite darkSprite; public Sprite lightSprite; public bool isDone; private void Awake() { spriteRenderer = GetComponent<SpriteRenderer>(); } private void OnEnable() { spriteRenderer.sprite = isDone ? lightSprite : darkSprite; } public void TriggerAction() { if(!isDone){ spriteRenderer.sprite = lightSprite; GetComponent<Collider2D>().enabled = false; isDone = true; Save(); } } //存储数据 private void Save(){ Debug.Log("存储数据"); } }
配置
效果
灯光
具体可以查看文章:【实现100个unity特效之6】Unity2d光源的使用
调低全局灯光
石头添加点灯光
效果
后处理
后处理效果,我之前也做过不少,感兴趣的可以回头去看看
【用unity实现100个游戏之14】Unity2d做一个建造与防御类rts游戏
为了方便测试,记得勾选显示后处理效果,默认都是勾选的
主相机勾选渲染后处理
添加一些简单的后处理效果
实现上面区域比下面区域亮
效果
存储位置信息
新增Data
public class Data { /// <summary> /// 存储角色位置信息的字典,键为角色名称,值为对应的位置坐标(Vector3)。 /// </summary> public Dictionary<string, Vector3> characterPosDict = new Dictionary<string, Vector3>(); }
新增DataManager,为了保证Data Manager可以优先其他代码执行,为它添加特性[DefaultExecutionOrder(-100)]。很多小伙伴没有留意后面会提到的这个内容,发现有ISaveable的注册报错。[DefaultExecutionOrder(-100)] 是 Unity 中的一个属性,用于指定脚本的默认执行顺序。参数 -100 表示该脚本的执行顺序优先级,数值越小,优先级越高,即越先执行。
新输入系统获取键盘的输入,按下L按键读取一下进度。
using System.Collections.Generic; using UnityEngine; using UnityEngine.InputSystem; //指定脚本的默认执行顺序,数值越小,优先级越高 [DefaultExecutionOrder(-100)] public class DataManager : MonoBehaviour { public static DataManager instance; [Header("事件监听")] public VoidEventSO saveDataEvent; // 保存数据事件 /// <summary> /// 存储需要保存数据的 ISaveable 实例的列表。 /// </summary> private List<ISaveable> saveableList = new List<ISaveable>(); /// <summary> /// 保存数据到 Data 对象中。 /// </summary> private Data saveData; private void Awake() { if (instance == null) { instance = this; } else { Destroy(gameObject); } saveData = new Data(); } private void Update() { // 按L 加载测试 if(Keyboard.current.lKey.wasPressedThisFrame){ Debug.Log("加载"); Load(); } } /// <summary> /// 注册需要保存数据的 ISaveable 实例。 /// </summary> /// <param name="saveable">需要保存数据的 ISaveable 实例。</param> public void RegisterSaveData(ISaveable saveable) { if (!saveableList.Contains(saveable)) { saveableList.Add(saveable); } } public void UnRegisterSaveData(ISaveable saveable){ if (saveableList.Contains(saveable)) { // 如果在,就从列表中移除 saveableList.Remove(saveable); } } private void OnEnable() { saveDataEvent.OnEventRaised += Save; // 监听保存数据事件 } private void OnDisable() { saveDataEvent.OnEventRaised -= Save; // 取消监听保存数据事件 } /// <summary> /// 保存数据。 /// </summary> public void Save() { foreach (var saveable in saveableList) { saveable.GetSaveData(saveData); } } /// <summary> /// 加载数据并应用到相应的 ISaveable 实例中。 /// </summary> public void Load() { foreach (var saveable in saveableList) { saveable.LoadData(saveData); } } }
挂载配置
新增接口ISaveable
public interface ISaveable { DataDefination GetDataID(); /// <summary> /// 将该实例注册到数据管理器以便保存数据。 /// </summary> void RegisterSaveData() => DataManager.instance.RegisterSaveData(this); /// <summary> /// 将该实例从数据管理器中注销,停止保存数据。 /// </summary> void UnRegisterSaveData() => DataManager.instance.UnRegisterSaveData(this); /// <summary> /// 获取需要保存的数据并存储到指定的 Data 对象中。 /// </summary> /// <param name="data">保存数据的 Data 对象。</param> void GetSaveData(Data data); /// <summary> /// 从指定的 Data 对象中加载数据并应用到该实例中。 /// </summary> /// <param name="data">包含加载数据的 Data 对象。</param> void LoadData(Data data); }
那么如果有三个野猪的名字完全一样,我们怎么区分每一只野猪具体存储的位置呢,所以接下来我们要创建一个唯一的标识,我们可以直接使用c#为我们设置好的全局唯一标识符,GUID就是个16位的串码,保证它的唯一性
新增枚举
/// <summary> /// 指示数据定义的持久化类型。 /// </summary> public enum PersistentType { /// <summary> /// 可读写的持久化类型,数据会被持久化保存。 /// </summary> ReadWrite, /// <summary> /// 不持久化类型,数据不会被持久化保存。 /// </summary> DoNotPerst }
新增DataDefination
public class DataDefination : MonoBehaviour { /// <summary> /// 持久化类型,指示数据定义的持久化方式。 /// </summary> public PersistentType persistentType; /// <summary> /// 数据定义的唯一标识符。 /// </summary> public string ID; /// <summary> /// 当编辑器中的属性值发生更改时调用,用于自动设置默认的ID值。 /// </summary> private void OnValidate() { if (persistentType == PersistentType.ReadWrite) { if (ID == string.Empty) { ID = System.Guid.NewGuid().ToString(); } } else { ID = string.Empty; } } }
配置挂载脚本,比如我们放在人物身上,生成唯一的UID
修改PlayerController,调用接口
public class PlayerController : MonoBehaviour, ISaveable { //... private void OnEnable() { ISaveable saveable = this; saveable.RegisterSaveData(); } private void OnDisable() { ISaveable saveable = this; saveable.UnRegisterSaveData(); } // 获取数据ID,用于唯一标识当前对象的位置信息 public DataDefination GetDataID() { return GetComponent<DataDefination>(); } // 将对象的位置信息保存到数据中 public void GetSaveData(Data data) { // 检查数据中是否已经存在当前对象的位置信息 if (data.characterPosDict.ContainsKey(GetDataID().ID)) { // 如果已经存在,则更新位置信息 data.characterPosDict[GetDataID().ID] = transform.position; } else { // 如果不存在,则添加新的位置信息 data.characterPosDict.Add(GetDataID().ID, transform.position); } } // 从数据中加载对象的位置信息 public void LoadData(Data data) { // 检查数据中是否存在当前对象的位置信息 if (data.characterPosDict.ContainsKey(GetDataID().ID)) { // 如果存在,则将位置信息设置为对应的数值 transform.position = data.characterPosDict[GetDataID().ID]; } } }
修改SavePoint,调用存储数据
public class SavePoint : MonoBehaviour, IInteractable { private SpriteRenderer spriteRenderer; public Sprite darkSprite; public Sprite lightSprite; public bool isDone; public VoidEventSO saveDataEvent; // 保存数据事件 private void Awake() { spriteRenderer = GetComponent<SpriteRenderer>(); } private void OnEnable() { spriteRenderer.sprite = isDone ? lightSprite : darkSprite; } public void TriggerAction() { if(!isDone){ Save(); spriteRenderer.sprite = lightSprite; GetComponent<Collider2D>().enabled = false; isDone = true; } } //存储数据 private void Save(){ Debug.Log("存储数据"); saveDataEvent.RaiseEvent(); } }
效果,按L测试读取数据,角色回到存储的位置
存储更多数据
修改Data,定义通用的float的类型,所有和float相关的类型都可用它保存
public class Data { //... public Dictionary<string, float> floatSaveData = new Dictionary<string, float>(); }
但是如何区分是人物的血条还是能量呢?我们可以加入不同的后缀,修改PlayerController
// 将对象的位置信息保存到数据中 public void GetSaveData(Data data) { // 检查数据中是否已经存在当前对象的位置信息 if (data.characterPosDict.ContainsKey(GetDataID().ID)) { // 如果已经存在,则更新位置信息 data.characterPosDict[GetDataID().ID] = transform.position; data.floatSaveData[GetDataID().ID + "Health"] = GetComponent<Character>().currentHealth; data.floatSaveData[GetDataID().ID + "Power"] = GetComponent<Character>().currentPower; } else { // 如果不存在,则添加新的位置信息 data.characterPosDict.Add(GetDataID().ID, transform.position); //存储玩家血量和能量 data.floatSaveData.Add(GetDataID().ID + "Health", GetComponent<Character>().currentHealth); data.floatSaveData.Add(GetDataID().ID + "Power", GetComponent<Character>().currentPower); } } // 从数据中加载对象的位置信息 public void LoadData(Data data) { // 检查数据中是否存在当前对象的位置信息 if (data.characterPosDict.ContainsKey(GetDataID().ID)) { // 如果存在,则将位置信息设置为对应的数值 transform.position = data.characterPosDict[GetDataID().ID]; GetComponent<Character>().currentHealth = data.floatSaveData[GetDataID().ID + "Health"]; GetComponent<Character>().currentPower = data.floatSaveData[GetDataID().ID + "Power"]; //更新血条能量UI GetComponent<Character>().OnHealthChanged?.Invoke(GetComponent<Character>()); } }
效果
同理你可以存储其他的比如宝箱,野猪等信息
存储场景信息
修改Data,将场景信息转为json数据进行存取
public string sceneToSave; public void SaveGameScene(SceneField savedScene){ sceneToSave = JsonUtility.ToJson(savedScene); } public SceneField GetSavedScene(){ SceneField loadedData = JsonUtility.FromJson<SceneField>(sceneToSave); return loadedData; }
修改SavePoint,存储场景信息
public SceneField currentLoadedScene; public class SavePoint : MonoBehaviour, IInteractable, ISaveable { //... public DataDefination GetDataID() { return null; } public void GetSaveData(Data data) { data.SaveGameScene(currentLoadedScene);//存储场景 } public void LoadData(Data data) { } }
配置当前场景
修改DataManager,我们希望加载存储场景完成后,再进行其他的LoadData操作,所以加载存储场景的操作我们就不放在LoadData里执行了。可以加入场景过渡渐变,让效果更好,这里我就不加了
/// <summary> /// 加载数据并应用到相应的 ISaveable 实例中。 /// </summary> public void Load() { //获取存储的场景 var scence = saveData.GetSavedScene(); if (scence != null) { // 获取当前活动的场景 Scene activeScene = SceneManager.GetActiveScene(); // 获取所有加载的场景 for (int i = 0; i < SceneManager.sceneCount; i++) { Scene loadedScene = SceneManager.GetSceneAt(i); Debug.Log("Loaded Scene " + i + ": " + loadedScene.name); if (activeScene.name != loadedScene.name) SceneManager.UnloadSceneAsync(loadedScene.name); // 异步卸载所有非主场景 } //加载scence场景 SceneManager.LoadSceneAsync(scence.SceneName, LoadSceneMode.Additive).completed += operation => { if (operation.isDone) { //获取相机边界方法 cameraControl.GetNewCameraBounds(); //加载其他数据 foreach (var saveable in saveableList) { saveable.LoadData(saveData); } } }; //控制按钮的显示隐藏 sceneLoadTrigger.StartMenu(); } }
效果
持久化存储数据
具体可以看我这篇文章:【unity小技巧】Unity存储存档保存——PlayerPrefs、JsonUtility和MySQL数据库的使用
需要注意的是,Dictionary 类型不能直接序列化为 JSON 字符串,因为 JsonUtility.ToJson() 方法只能序列化 Unity 引擎内置支持的类型。解决这个问题的一种方法是创建一个自定义类。其实我在之前的文章早就有用到这种方法:【用unity实现100个游戏之12】unity制作一个俯视角2DRPG《类星露谷物语、浮岛物语》资源收集游戏(附项目源码)
引入Unity 的可序列化字典类
Unity 无法序列化标准词典。这意味着它们不会在检查器中显示或编辑,
也不会在启动时实例化。一个经典的解决方法是将键和值存储在单独的数组中,并在启动时构造字典。
我们使用gitthub大佬的源码即可,此项目提供了一个通用字典类及其自定义属性抽屉来解决此问题。
源码地址:https://github.com/azixMcAze/Unity-SerializableDictionary
你可以选择下载源码,也可以直接复制我下面的代码,我把主要代码提出来了
SerializableDictionary.cs
using System; using System.Collections; using System.Collections.Generic; using System.Runtime.Serialization; using UnityEngine; public abstract class SerializableDictionaryBase { public abstract class Storage {} protected class Dictionary<TKey, TValue> : System.Collections.Generic.Dictionary<TKey, TValue> { public Dictionary() {} public Dictionary(IDictionary<TKey, TValue> dict) : base(dict) {} public Dictionary(SerializationInfo info, StreamingContext context) : base(info, context) {} } } [Serializable] public abstract class SerializableDictionaryBase<TKey, TValue, TValueStorage> : SerializableDictionaryBase, IDictionary<TKey, TValue>, IDictionary, ISerializationCallbackReceiver, IDeserializationCallback, ISerializable { Dictionary<TKey, TValue> m_dict; [SerializeField] TKey[] m_keys; [SerializeField] TValueStorage[] m_values; public SerializableDictionaryBase() { m_dict = new Dictionary<TKey, TValue>(); } public SerializableDictionaryBase(IDictionary<TKey, TValue> dict) { m_dict = new Dictionary<TKey, TValue>(dict); } protected abstract void SetValue(TValueStorage[] storage, int i, TValue value); protected abstract TValue GetValue(TValueStorage[] storage, int i); public void CopyFrom(IDictionary<TKey, TValue> dict) { m_dict.Clear(); foreach (var kvp in dict) { m_dict[kvp.Key] = kvp.Value; } } public void OnAfterDeserialize() { if(m_keys != null && m_values != null && m_keys.Length == m_values.Length) { m_dict.Clear(); int n = m_keys.Length; for(int i = 0; i < n; ++i) { m_dict[m_keys[i]] = GetValue(m_values, i); } m_keys = null; m_values = null; } } public void OnBeforeSerialize() { int n = m_dict.Count; m_keys = new TKey[n]; m_values = new TValueStorage[n]; int i = 0; foreach(var kvp in m_dict) { m_keys[i] = kvp.Key; SetValue(m_values, i, kvp.Value); ++i; } } #region IDictionary<TKey, TValue> public ICollection<TKey> Keys { get { return ((IDictionary<TKey, TValue>)m_dict).Keys; } } public ICollection<TValue> Values { get { return ((IDictionary<TKey, TValue>)m_dict).Values; } } public int Count { get { return ((IDictionary<TKey, TValue>)m_dict).Count; } } public bool IsReadOnly { get { return ((IDictionary<TKey, TValue>)m_dict).IsReadOnly; } } public TValue this[TKey key] { get { return ((IDictionary<TKey, TValue>)m_dict)[key]; } set { ((IDictionary<TKey, TValue>)m_dict)[key] = value; } } public void Add(TKey key, TValue value) { ((IDictionary<TKey, TValue>)m_dict).Add(key, value); } public bool ContainsKey(TKey key) { return ((IDictionary<TKey, TValue>)m_dict).ContainsKey(key); } public bool Remove(TKey key) { return ((IDictionary<TKey, TValue>)m_dict).Remove(key); } public bool TryGetValue(TKey key, out TValue value) { return ((IDictionary<TKey, TValue>)m_dict).TryGetValue(key, out value); } public void Add(KeyValuePair<TKey, TValue> item) { ((IDictionary<TKey, TValue>)m_dict).Add(item); } public void Clear() { ((IDictionary<TKey, TValue>)m_dict).Clear(); } public bool Contains(KeyValuePair<TKey, TValue> item) { return ((IDictionary<TKey, TValue>)m_dict).Contains(item); } public void CopyTo(KeyValuePair<TKey, TValue>[] array, int arrayIndex) { ((IDictionary<TKey, TValue>)m_dict).CopyTo(array, arrayIndex); } public bool Remove(KeyValuePair<TKey, TValue> item) { return ((IDictionary<TKey, TValue>)m_dict).Remove(item); } public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator() { return ((IDictionary<TKey, TValue>)m_dict).GetEnumerator(); } IEnumerator IEnumerable.GetEnumerator() { return ((IDictionary<TKey, TValue>)m_dict).GetEnumerator(); } #endregion #region IDictionary public bool IsFixedSize { get { return ((IDictionary)m_dict).IsFixedSize; } } ICollection IDictionary.Keys { get { return ((IDictionary)m_dict).Keys; } } ICollection IDictionary.Values { get { return ((IDictionary)m_dict).Values; } } public bool IsSynchronized { get { return ((IDictionary)m_dict).IsSynchronized; } } public object SyncRoot { get { return ((IDictionary)m_dict).SyncRoot; } } public object this[object key] { get { return ((IDictionary)m_dict)[key]; } set { ((IDictionary)m_dict)[key] = value; } } public void Add(object key, object value) { ((IDictionary)m_dict).Add(key, value); } public bool Contains(object key) { return ((IDictionary)m_dict).Contains(key); } IDictionaryEnumerator IDictionary.GetEnumerator() { return ((IDictionary)m_dict).GetEnumerator(); } public void Remove(object key) { ((IDictionary)m_dict).Remove(key); } public void CopyTo(Array array, int index) { ((IDictionary)m_dict).CopyTo(array, index); } #endregion #region IDeserializationCallback public void OnDeserialization(object sender) { ((IDeserializationCallback)m_dict).OnDeserialization(sender); } #endregion #region ISerializable protected SerializableDictionaryBase(SerializationInfo info, StreamingContext context) { m_dict = new Dictionary<TKey, TValue>(info, context); } public void GetObjectData(SerializationInfo info, StreamingContext context) { ((ISerializable)m_dict).GetObjectData(info, context); } #endregion } public static class SerializableDictionary { public class Storage<T> : SerializableDictionaryBase.Storage { public T data; } } [Serializable] public class SerializableDictionary<TKey, TValue> : SerializableDictionaryBase<TKey, TValue, TValue> { public SerializableDictionary() {} public SerializableDictionary(IDictionary<TKey, TValue> dict) : base(dict) {} protected SerializableDictionary(SerializationInfo info, StreamingContext context) : base(info, context) {} protected override TValue GetValue(TValue[] storage, int i) { return storage[i]; } protected override void SetValue(TValue[] storage, int i, TValue value) { storage[i] = value; } } [Serializable] public class SerializableDictionary<TKey, TValue, TValueStorage> : SerializableDictionaryBase<TKey, TValue, TValueStorage> where TValueStorage : SerializableDictionary.Storage<TValue>, new() { public SerializableDictionary() {} public SerializableDictionary(IDictionary<TKey, TValue> dict) : base(dict) {} protected SerializableDictionary(SerializationInfo info, StreamingContext context) : base(info, context) {} protected override TValue GetValue(TValueStorage[] storage, int i) { return storage[i].data; } protected override void SetValue(TValueStorage[] storage, int i, TValue value) { storage[i] = new TValueStorage(); storage[i].data = value; } }
调用
修改Data,Dictionary 全部改为SerializableDictionary
public class Data { /// <summary> /// 存储角色位置信息的字典,键为角色名称,值为对应的位置坐标(Vector3)。 /// </summary> public SerializableDictionary <string, Vector3> characterPosDict = new SerializableDictionary <string, Vector3>(); public SerializableDictionary <string, float> floatSaveData = new SerializableDictionary <string, float>(); public SerializableDictionary <string, bool> boolSaveData = new SerializableDictionary <string, bool>(); public string sceneToSave; public void SaveGameScene(SceneField savedScene){ sceneToSave = JsonUtility.ToJson(savedScene); } public SceneField GetSavedScene(){ SceneField loadedData = JsonUtility.FromJson<SceneField>(sceneToSave); return loadedData; } }
修改DataManager
String savePath = "test.json"; /// <summary> /// 保存数据。 /// </summary> public void Save() { //。。。 //持久化存储数据 String jsonData = JsonUtility.ToJson(saveData); File.WriteAllText(savePath, jsonData); } /// <summary> /// 加载数据并应用到相应的 ISaveable 实例中。 /// </summary> public void Load() { //读取数据 string jsonData = File.ReadAllText(savePath); //将JSON数据反序列化为游戏数据对象 Data saveData = JsonUtility.FromJson<Data>(jsonData); //。。。 }
查看存储的test.json数据
效果
游戏结束
比如触碰水死亡,我们直接加个Attack脚本就可以了,把伤害设置很高
人物死亡,返回菜单,修改PlayerController
//死亡 public void PlayerDead() { AudioManager.Instance.PlaySFX("人物死亡"); isDead = true; inputControl.Player.Disable(); //多少秒后重新加载场景 Invoke("RestartGame", 1.5f); } //重新开始 public void RestartGame() { SceneManager.LoadScene(SceneManager.GetActiveScene().name); }
效果
源码
源码不出意外的话我会放在最后一节