Featured image of post 麦田物语开发日记(十五)

麦田物语开发日记(十五)

完成游戏菜单的制作及存档内容的设计

创作主菜单及存档

制作主菜单UI

为了实现主菜单的叠层效果,创建3个Panel。在Unity中Panel的渲染规则时优先渲染位置靠下的Panel,因此后续用代码控制Panel的位置即可。

image-20230301223413035

控制代码

最主要的代码就是transform的SetAsLastSibling方法,该方法能让当前GO在Hierarchy窗口中处于当前父GO的最下方

 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
public class MenuUI : MonoBehaviour
{
    public GameObject[] panels;

    /// <summary>
    /// 根据index调换panel位置,以显示在上层
    /// </summary>
    /// <param name="index"></param>
    public void SwitchPanel(int index)
    {
        for (int i = 0; i < panels.Length; i++)
        {
            if (i == index)
            {
                panels[i].transform.SetAsLastSibling();
            }
        }
    }

    public void ExitGame()
    {
        Application.Quit();
        Debug.Log("GameQuit");
    }
}

为每个button设置点击事件,而即可

image-20230301233524379

存档UI

每个存档UI显示游戏进行时间以及所在场景,因此需要代码来获取UI内容

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class SaveSlotUI : MonoBehaviour
{
    public Text dataTime, dataScene;
    private Button _currentBtn;

    private int Index => transform.GetSiblingIndex();

    private void Awake()
    {
        _currentBtn = GetComponent<Button>();
        _currentBtn.onClick.AddListener(LoadGameData);
    }


    private void LoadGameData()
    {
        Debug.Log(Index);
    }
}

使用GetSiblingIndex来获得当前按钮的位置,同步存档的位置。

创建游戏数据存储数据结构

Unity自带的Json保存方法并不能字典和列表数据,因此需要使用新的工具

com.unity.nuget.newtonsoft-json将该名称在package Manager中通过URL连接添加即可

最主要的就是JsonConvert.SerializeObject和相应的反序列化方法

在具体的代码中需要一个专门用于存放游戏内容的脚本GameSaveData,其是描述用于存储的数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
namespace WFarm.Save
{
    [System.Serializable]
    public class GameSaveData
    {
        public string dataSceneName;
        public Dictionary<string, SerializableVector3> characterPosDict;    /* 每个人物的位置 */
        public Dictionary<string, List<SceneItem>> sceneItemDict;   /*每个场景的物品*/
        public Dictionary<string, List<SceneFurniture>> sceneFurnitureDict; /* 每个场景被制造出来的物品 */
        public Dictionary<string, TileDetails> tileDetailsDict;
        public Dictionary<string, bool> firstLoadDict;
        public Dictionary<string, List<InventoryItem>> inventoryDict;   /* 场景和人物存放的数据 */
        public Dictionary<string, int> timeDict;    /* 当前游戏时间 */
        public int playerMoney;
        
        // NPC内容
        public string targetScene;
        public bool npcInteractable;
        public int animationInstanceID;
    }  
    
}
 

然后需要一个专门用于保存的接口ISavable,以及控制存取的代码SaveLoadManager

ISavable需要让对应的类实现三个方法,即能够保存数据,读取数据,以及将自己注册进一个管理GO中

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public interface ISavable
{
    string GUID { get; }
    // c#新特性:接口可以预先编写功能
    void RegisterSavable()
    {
        /* 如果某一个类实现了当前接口,那么其一定会将自己注册进入SaveLoadManager的存档列表中 */
        SaveLoadManager.Instance.RegisterSavable(this);
    }

    GameSaveData GenerateSaveData();

    void RestoreData(GameSaveData data);

}

SaveLoadManager.cs则需要控制所有需要被保存的数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
namespace WFarm.Save
{
    public class SaveLoadManager : Singleton<SaveLoadManager>
    {
        private List<ISavable> _savableList = new List<ISavable>();

        public void RegisterSavable(ISavable savable)
        {
            if (!_savableList.Contains(savable))
                _savableList.Add(savable);
        }
    }

}

为了保存数据的唯一性,需要一个代码DataGUID,这个代码是一致运行的,创建一个GUID,如果当前内容的GUID不存在,则在awake()中生成一个独立的GUID,从而保证自己的唯一性。而每个单独的GameSaveData会有自己独立的GUID

每个需要被保存的数据应该都含有该脚本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
[ExecuteAlways]
public class DataGUID : MonoBehaviour
{
    
    public string guid;
    private void Awake()
    {
        if (guid == String.Empty)
        {
            guid = System.Guid.NewGuid().ToString();
        }
    }
}

player.cs进行存储数据

Player.cs脚本中实现ISavable接口,并装有GUID代码,在start方法中将当前当前方法注册进入SaveLoadManager中,注意为了能够调用该方法,我们需要在start方法中将类的ISavable单独定义出来。

1
2
3
4
5
private void Start()
{
    ISavable iSavable = this;
    iSavable.RegisterSavable(); // 将当前类注册进入SaveLoadManager
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#region 保存数据

public string GUID => GetComponent<DataGUID>().guid;

public GameSaveData GenerateSaveData()
{
    GameSaveData saveData = new GameSaveData();
    /* 存储坐标 */
    saveData.characterPosDict = new Dictionary<string, SerializableVector3>();
    saveData.characterPosDict.Add(this.GUID, new SerializableVector3(transform.position));
    
    
    
    return saveData;
}

public void RestoreData(GameSaveData saveData)
{
    transform.position = saveData.characterPosDict[this.GUID].ToVector3();
}


#endregion

保存背包数据

 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
// inventoryManager.cs
#region 保存数据

public string GUID => GetComponent<DataGUID>().guid;

public GameSaveData GenerateSaveData()
{
    GameSaveData saveData = new GameSaveData();
    saveData.playerMoney = playerCash;
    saveData.inventoryDict = new Dictionary<string, List<InventoryItem>>();
    saveData.inventoryDict.Add(playerBag.name, playerBag.inventoryItemList);
    foreach (var boxData in _boxDataDict)
    {
        saveData.inventoryDict.Add(boxData.Key, boxData.Value);
    }

    return saveData;
}

public void RestoreData(GameSaveData data)
{
     this.playerCash = saveData.playerMoney;
    this.playerBag = Instantiate(playerBagTemp);
    playerBag.inventoryItemList = saveData.inventoryDict[playerBag.name];
            
    foreach (var boxData in data.inventoryDict)
    {
        if (boxData.Key == playerBag.name) continue;
        if (_boxDataDict.ContainsKey(boxData.Key))
        {
            _boxDataDict[boxData.Key] = boxData.Value;
        }
    }

    EventHandler.CallUpdateInventoryUI(InventoryLocation.Player, playerBag.inventoryItemList);
}

#endregion

保存场景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
// itemManager.cs
#region 保存数据

public string GUID => GetComponent<DataGUID>() ? GetComponent<DataGUID>().guid : String.Empty;

public GameSaveData GenerateSaveData()
{
    GameSaveData saveData = new GameSaveData();
    // 默认情况只有加载和卸载场景的时候才获得场景的item和家具,因此此时需要去获取
    GetAllSceneFurniture();
    GetAllSceneItem();
    saveData.sceneItemDict = this._sceneItemDict;
    saveData.sceneFurnitureDict = this._sceneFurnitureDict;


    return saveData;
}

public void RestoreData(GameSaveData data)
{
    this._sceneItemDict = data.sceneItemDict;
    this._sceneFurnitureDict = data.sceneFurnitureDict;
    ReBuildSceneItems();
    ReBuildSceneFurniture();

   
}
#endregion

保存时间

注意由于时间需要进行更新,因此每次加载游戏就需要重新加载场景,而时间也在这时候更新

 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
// TimeManager.cs
#region 保存数据

public string GUID => GetComponent<DataGUID>() ? GetComponent<DataGUID>().guid : String.Empty;

public GameSaveData GenerateSaveData()
{
    GameSaveData data = new GameSaveData();
    data.timeDict = new Dictionary<string, int>();
    data.timeDict.Add("gameYear", _gameYears);
    data.timeDict.Add("gameSeason", (int)_currentSeason);
    data.timeDict.Add("gameMonth", _gameMonth);
    data.timeDict.Add("gameDays", _gameDays);
    data.timeDict.Add("gameHours", _gameHours);
    data.timeDict.Add("gameMinutes", _gameMinutes);
    data.timeDict.Add("gameSeconds", _gameSeconds);
    return data;

}

public void RestoreData(GameSaveData data)
{
    this._gameYears = data.timeDict["gameYear"];
    this._currentSeason = (Season)data.timeDict["gameSeason"];
    this._gameMonth = data.timeDict["gameMonth"];
    this._gameDays = data.timeDict["gameDays"];
    this._gameHours = data.timeDict["gameHours"];
    this._gameMinutes = data.timeDict["gameMinutes"];
    this._gameSeconds = data.timeDict["gameSeconds"];
}

#endregion

并且在加载场景后更新时间内容

1
2
3
4
5
6
7
8
private void OnAfterSceneLoadEvent()
{
    _gameClockPause = false;
    EventHandler.CallGameMinuteEvent(_gameMinutes, _gameHours, _gameDays, _currentSeason);
    EventHandler.CallGameDateEvent(_gameHours, _gameDays, _gameMonth, _gameYears, _currentSeason);
    EventHandler.CallLightShiftChangeEvent(_currentSeason, GetCurrentLightShift(), _timeDifference);

}

地图数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// gridManager.cs
#region 保存数据

public string GUID => GetComponent<DataGUID>() ? GetComponent<DataGUID>().guid : String.Empty;

public GameSaveData GenerateSaveData()
{
    GameSaveData saveData = new GameSaveData();
    saveData.tileDetailsDict = _tileDetailsMap;
    saveData.firstLoadDict = _firstLoadDic;
    return saveData;

}

public void RestoreData(GameSaveData saveData)
{
    this._firstLoadDic = saveData.firstLoadDict;
    this._tileDetailsMap = saveData.tileDetailsDict;
}

#endregion

实现数据存储与加载

  • 保存NPC的数据

在游戏保存时,NPC可能正处于移动状态,因此我们需要需要保存其可能要前往的位置,即NpcMovement

人物数据保存

 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
// NpcMovement.cs
#region 保存数据

public string GUID => GetComponent<DataGUID>() ? GetComponent<DataGUID>().guid : String.Empty;

public GameSaveData GenerateSaveData()
{
    /* 保存人物坐标点,目标坐标点等 */
    GameSaveData saveData = new GameSaveData();
    saveData.characterPosDict = new Dictionary<string, SerializableVector3>();
    saveData.characterPosDict.Add("currentPosition", new SerializableVector3(transform.position));
    saveData.characterPosDict.Add("targetGridPos", new SerializableVector3(_targetScenePos));

    // 保存场景
    saveData.dataSceneName = currentScene;
    saveData.targetScene = _targetScene;

    // 停止的动画片段
    if (_stopAnimationClip != null)
        saveData.animationInstanceID = _stopAnimationClip.GetInstanceID();

    // 是否可互动
    saveData.npcInteractable = interactable;

    return saveData;
}

public void RestoreData(GameSaveData saveData)
{
    this.currentScene = saveData.dataSceneName;
    this._targetScene = saveData.targetScene;

    transform.position = saveData.characterPosDict["currentPosition"].ToVector3();
    _targetScenePos = (Vector3Int)saveData.characterPosDict["targetGridPos"].ToVector2();

    if (saveData.animationInstanceID != 0)
    {
        _stopAnimationClip = Resources.InstanceIDToObject(saveData.animationInstanceID) as AnimationClip;
    }

    interactable = saveData.npcInteractable;

    // 人物已经加载完成,因此不能继续加载
    _isInitialised = true;
}

#endregion
  • 实现存储逻辑

在上述逻辑中,我们将需要存储的信息以GameSaveData的方法返回给了SaveLoadManager,因此多个GameSaveData共同组成一个存档数据,而游戏可以有多个存档数据。

为了将存档与具体的存档栏对应,需要创建一个专门的数据结构DataSlot其将保存整个存档中所有的数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
namespace WFarm.Save
{
    [System.Serializable]
    public class DataSlot
    {
        /** 该类用于将存档与数据链接
         */
        /* 将guid和saveData对应,从而做到唯一性 */
        public Dictionary<string, GameSaveData> dataDict = new Dictionary<string, GameSaveData>();
    }
}

每一个DataSlot对应了一个存档,每一个存档内部有一系列GameSaveData

  • 对数据进行写入和读出

将得到的数据写入存档

调用该方法之前,需要会将所有需要存储数据的start中都挂载以下代码

1
2
ISavable savable = this;
savable.RegisterSavable();

保证_savableList能够找到对应数据,然后将数据写入存档栏中

 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
// SaveLoadManager.cs
/// <summary>
/// 将当前的数据,存放进入存档栏中
/// </summary>
/// <param name="index">存档栏的序号</param>
public void Save(int index)
{
    DataSlot dataSlot = new DataSlot();
    foreach (var item in _savableList)
    {
        // 将guid和每个manager生成的gameSaveData进行组合
        dataSlot.dataDict.Add(item.GUID, item.GenerateSaveData());
    }

    dataSlots[index] = dataSlot;

    var resultPath = _jsonFolder + "SaveData" + index + ".json";
    // 将数据存为json格式,然后序列化排版
    var jsonData = JsonConvert.SerializeObject(dataSlot, Formatting.Indented);
    if (!File.Exists(_jsonFolder))
    {
        Directory.CreateDirectory(_jsonFolder);
    }
    File.WriteAllText(resultPath, jsonData);

}

接着将这些存档写入电脑

1
2
3
4
5
protected override void Awake()
{
    base.Awake();
    _jsonFolder = Application.persistentDataPath + "/SAVE DATA/";
}

Application.persistentDataPath 在windows下,存放位置是C盘的Appdata内部

读出存档内容

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public void Load(int index)
{
    // 获得存档地址
    _currentDataIndex = index;
    var resultPath = _jsonFolder + "SaveData" + index + ".json";
    if (!File.Exists(_jsonFolder))
    {
        Directory.CreateDirectory(_jsonFolder);
    }

    // 转化数据
    var stringData = File.ReadAllText(resultPath);
    DataSlot jsonData = JsonConvert.DeserializeObject<DataSlot>(stringData);

    // 读取数据内容
    foreach (var savable in _savableList)
    {
        savable.RestoreData(jsonData.dataDict[savable.GUID]);
    }
    
}
  • 将读取存档和UI联系起来,并实现场景切换

保存当前的游戏场景

注意在游戏打包以后,一开始只能显示一个场景,其他场景都是被加载出来的。因此UI场景应该是第一个被加载出来的,需要在awake中直接加载。

1
2
3
4
5
6
private void Awake()
{
    /* 开始游戏时启动UI场景 */
    /* 由于UI应该优先出现,因此不使用异步加载,并且需要以叠加的方式呈现 */
    SceneManager.LoadScene("UI", LoadSceneMode.Additive);
}

制作读取游戏时,切换场景的代码

此处代码有渐入渐出效果,且如果是在游玩游戏的过程中加载游戏,那么需要卸载当前处于的游戏场景,且需要保证不是PersistentScene

 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
// TransitionManager.cs
public void RestoreData(GameSaveData saveData)
{
    // 加载场景
    // 不能直接使用Transition 因为Transition方法卸载当前场景,而加载存档的场景是persistentScene
    StartCoroutine(LoadSaveScene(saveData.dataSceneName));
}

/// <summary>
/// 加载存档游戏场景
/// </summary>
/// <param name="sceneName"></param>
/// <returns></returns>
private IEnumerator LoadSaveScene(string sceneName)
{
    yield return SceneFader(1f);
    // 如果当前场景不是PersistentScene,则说明在运行游戏,需要卸载当前场景
    if (SceneManager.GetActiveScene().name != "PersistentScene")
    {
        EventHandler.CallBeforeSceneUnloadEvent();
        yield return SceneManager.UnloadSceneAsync(SceneManager.GetActiveScene().buildIndex);
    }
    SceneManager.LoadSceneAsync(sceneName);
    EventHandler.CallAfterSceneLoadEvent();
    yield return SceneFader(0);
}

完成加载游戏的具体逻辑

将开始游戏UI和游戏连接起来

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// SaveSlotUI.cs
private void OnEnable()
{
    // 当该面板重新启动的时候(回到此处),就重新更新数据
    SetupSlotUI();
}
#region 绑定本文
   
private void SetupSlotUI()
{
    if (_dataSlot != null)
    {
        dataTime.text = _dataSlot.DataTime;
        dataScene.text = _dataSlot.DataScene;
    }
    else
    {
        dataTime.text = "这个时间还未开始";
        dataScene.text = "梦还未开始";
    }
}

#endregion

但是在游戏一开始SaveLoadManager并不能获得存档的相关信息,因此需要先获取所有在电脑中的存储的数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// SaveLoadManager.cs
#region 从电脑中读取所有游戏存储数据

public void ReadSaveData()
{
    if (Directory.Exists(_jsonFolder))
    {
        for (int i = 0; i < dataSlots.Count; i++)
        {
            var resultPath = _jsonFolder + "SaveData" + i + ".json";
            if (File.Exists(resultPath))
            {
                var stringData = File.ReadAllText(resultPath);
                DataSlot jsonData = JsonConvert.DeserializeObject<DataSlot>(stringData);
                dataSlots[i] = jsonData;
            }
        }
    }
}

#endregion

以上方法在SaveLoadManager.csAwake中调用即可

在后在SaveSlotUI.cs通过点击加载游戏,如果当前游戏有数据,则直接读取,没有数据则触发一个事件,该事件将让所有管理器去处理新游戏开启的内容。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// SaveSlotUI.cs
private void LoadGameData()
{
    if (_dataSlot != null)
    {
        SaveLoadManager.Instance.Load(Index);
    }
    else   // 开启新的游戏
    {
        EventHandler.CallStartNewGameEvent(Index);
    }
    
}

然后将所有需要处理新游戏的开启的代码进行处理

player.cs

保证play在主界面的时候不能乱跑

1
2
3
4
5
private void OnStartNewGameEvent(int obj)
{
    _inputDisable = false;
    transform.position = Settings.originPos;    /* 设置初始场景 */
}

InventoryManager.cs

使用一个背包模板,在新游戏的时候,让角色得到背包数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/// <summary>
///  开始新游戏
/// </summary>
/// <param name="obj"></param>
private void OnStartNewGameEvent(int obj)
{
    playerBag = Instantiate(playerBag);
    playerCash = Settings.PlayerStartMoney;
    /* 清空储物箱等数据 */
    _boxDataDict.Clear();
    EventHandler.CallUpdateInventoryUI(InventoryLocation.Player, playerBag.inventoryItemList);
}

TimeManager.cs

加载游戏事件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/// <summary>
/// 开始新游戏
/// </summary>
/// <param name="obj"></param>
private void OnStartNewGameEvent(int obj)
{
    /*初始化游戏事件*/
    NewGameTime();
    _gameClockPause = false;
}

ItemManager.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/// <summary>
/// 开始新游戏
/// </summary>
/// <param name="obj"></param>
private void OnStartNewGameEvent(int obj)
{
    /*情况场景中原有的家具和item*/
    _sceneItemDict.Clear();
    _sceneFurnitureDict.Clear();
}

TransitionManager.cs

在设置早期,为了保证每次开始游戏执行顺序的正确性,将TransitionManagerstart改为了协程的方式,以保证先加载场景,再执行加载场景后的事件函数。

此时由于有新开始游戏事件,因此可以再该事件中呼叫加载场景后的事件

1
2
3
4
private void OnStartNewGameEvent(int obj)
{
    StartCoroutine(LoadSaveScene(startSceneName));
}

由于LoadSaveScene含有加载场景后事件,因此可以直接修改start内容,使其不再是IEumerator

1
2
3
4
5
6
7
// 将IEnumerator进行了修改
private void Start()
{
    ISavable savable = this;
    savable.RegisterSavable();
    sceneFaderCanvasGroup = FindObjectOfType<CanvasGroup>();
}

NPCManager.cs

给所有NPC指定的位置和初始场景

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// npcManager.cs
private void OnStartNewGameEvent(int obj)
{
    foreach (var npcCharacter in npcPositions)
    {
        // NpcPosition中存放了npc的transform
        npcCharacter.npc.position = npcCharacter.startPos;
        npcCharacter.npc.GetComponent<NpcMovement>().StartScene = npcCharacter.startScene;
    }
}

LightManager.cs

保证开始的灯光不为空

1
2
3
4
private void OnStartNewGameEvent(int obj)
{
    _currentLightShift = LightShift.Morning;
}
  • 将数据保存到对应的SaveSlot
1
2
3
4
private void OnStartNewGameEvent(int index)
{
    _currentDataIndex = index;
}

解决加载后的BUG

  • 解决背包问题

由于玩家的背包是通过模板生成的,因此其并没有初始化,当再次加载游戏的时候,此时背包为空,无法从存档中得到数据,因此每次加载的时候,都要重新生成一个背包,然后将背包的数据从存档中取得

1
2
3
4
5
6
7
public void RestoreData(GameSaveData saveData)
{
    this.playerCash = saveData.playerMoney;
    this.playerBag = Instantiate(playerBagTemp);
    playerBag.inventoryItemList = saveData.inventoryDict[playerBag.name];
    // ....
}
  • 光照问题
1
private float _timeDifferent = Settings.LightChangeDuration;
  • Npc移动目标丢失

NPC在移动的过程中保存场景的话,会导致加载存档后,NPC丢失了原本的行程,导致不会再执行行程内容,因此需要在加载场景后,让NPC根据已有信息再次执行行程。

因此只要不是第一次加载游戏,那么就让NPC恢复数据的时候,重新加载行程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// NpcMovement.cs
private void OnAfterSceneLoadEvent()
{
    //....
   
    /* 如果不是第一次加载人物,那么就重新生成路径 */
    if (!_isFirstLoad)
    {
        _currentScenePos = _gird.WorldToCell(transform.position);
        // 直接生成一个新的行程,然后建立一个路径即可
        var schedule = new ScheduleDetails(0, 0, 0, 0, _currentSeason, _targetScene, (Vector2Int)_nextGridPosition,
            _stopAnimationClip, interactable);
        BuildPath(schedule);
        _isFirstLoad = true;

    }
}

在恢复数据的时候设置

1
2
3
4
5
6
7
8
// NpcMovement.cs
public void RestoreData(GameSaveData saveData)
{
    // ..
    _isFirstLoad = false;
    // ..
  
}

同时在每次新创建游戏的时候需要将其设置为true

1
2
3
4
5
6
// NpcMovement.cs
private void OnStartNewGameEvent(int obj)
{
    _isInitialised = false;
    _isFirstLoad = true;
}

暂停菜单和音量控制

  • 制作一个在游戏中暂停的菜单

制作了菜单以后为了便于控制UI逻辑,将UIManager代码挂载到MainCanvas上,并设置其点击功能

 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
public class UIManager : MonoBehaviour
{
    [CanBeNull] private GameObject _menuCanvas;
    public GameObject menuPrefab;

    public Button settingBtn;
    public GameObject pausePanel;
    public Slider volumeSlider;

    private void Awake()
    {
        settingBtn.onClick.AddListener(TogglePausePanel); 
    }

    private void OnEnable()
    {
        EventHandler.AfterSceneLoadEvent += OnAfterSceneLoadEvent;
    }

    private void OnDisable()
    {
        EventHandler.AfterSceneLoadEvent -= OnAfterSceneLoadEvent;
    }

    private void Start()
    {
        _menuCanvas = GameObject.FindWithTag("MenuCanvas");
        
        Instantiate(menuPrefab, _menuCanvas.transform);
    }

    #region 注册事件

    /// <summary>
    /// 加载场景后关闭主菜单
    /// </summary>
    private void OnAfterSceneLoadEvent()
    {
        // 有子物体,则需要删去对应的prefab
        if (_menuCanvas.transform.childCount > 0)
        {
            Destroy(_menuCanvas.transform.GetChild(0).gameObject);
        }
    }
    #endregion

    /// <summary>
    /// 呼出暂停面板
    /// </summary>
    private void TogglePausePanel()
    {
        bool isOpen = pausePanel.activeInHierarchy;
        if (isOpen)
        {
            pausePanel.SetActive(false);
            Time.timeScale = 1;
        }
        else
        {
            System.GC.Collect();
            pausePanel.SetActive(true);
            Time.timeScale = 0;
        }
    }

    /// <summary>
    /// btn的点击事件注册内容
    /// </summary>
    public void ReturnMenuCanvas()
    {
        Time.timeScale = 1;
        StartCoroutine(BackToMenu());
    }

    /// <summary>
    /// 逐步返回
    /// </summary>
    /// <returns></returns>
    private IEnumerator BackToMenu()
    {
        pausePanel.SetActive(false);
        yield return new WaitForSeconds(1f);
        Instantiate(menuPrefab, _menuCanvas.transform);
    }
}

由于UI是通过Button控制,因此许多代码可以直接为btn添加点击事件而执行。

为了保证在主菜单中的时候玩家不能操作,只能在加载场景以后才能移动

Player.cs的awake中添加

1
_inputDisable = true;
  • 添加音量控制

由于在设置AudioMixer的时候有将相关的变量暴露出来,因此可以通过对暴露出的变量进行控制的音量

AudioManager.cs中这是主音量的交方法

1
2
3
4
5
6
7
8
9
//AudioManager.cs
/// <summary>
/// 设置主音量
/// </summary>
/// <param name="value"></param>
public void SetMasterVolume(float value)
{
    gameAudioMixer.SetFloat("MasterVolume", ConvertSoundVolume(value));
}

然后在UIManager中设置滑动条的监听

1
2
3
4
5
6
// UIManager.cs
private void Awake()
{
    settingBtn.onClick.AddListener(TogglePausePanel); 
    volumeSlider.onValueChanged.AddListener(AudioManager.Instance.SetMasterVolume);
}

结束游戏

设置一个结束游戏的事件,在返回主菜单以后调用

需要给player设置在结束游戏时不能移动

NPCMovement中设置当游戏结束时关闭正在移动的协程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// NPCMovement
/// <summary>
/// 结束游戏
/// </summary>
private void OnEndGameEvent()
{
    _loadedScene = false;
    _npcMove = false;
    if (_npcMovementRoutine != null)
        StopCoroutine(_npcMovementRoutine);
}

TimeManager中设置当游戏结束时停止计时。

1
2
3
4
5
6
7
8
// TimeManager.cs
/// <summary>
/// 游戏返回主菜单,停止时间
/// </summary>
private void OnEndGameEvent()
{
    _gameClockPause = true;
}

AudioManager中设置停止游戏静音

1
2
3
4
5
6
7
8
9
/// <summary>
/// 返回主菜单后静音
/// </summary>
private void OnEndGameEvent()
{
    if (_soundRoutine != null)
        StopCoroutine(_soundRoutine);
    muteSnapshot.TransitionTo(1f);
}

SaveLoadManager.cs中设置返回主菜单的时候保存游戏进度

1
2
3
4
5
// SaveLoadManager.cs
private void OnEndGameEvent()
{
    Save(_currentDataIndex);
}

TransitionManager中关闭当前场景,并卸载,因为有渐进渐出效果,因此需要使用协程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
/// <summary>
/// 返回主菜单的时候卸载所有场景
/// </summary>
/// <returns></returns>
private IEnumerator UnloadScene()
{
    EventHandler.CallBeforeSceneUnloadEvent();
    yield return SceneFader(1f);
    yield return SceneManager.UnloadSceneAsync(SceneManager.GetActiveScene().buildIndex);
    yield return SceneFader(0f);
}
  • 检测鼠标位置是否有已经建造的家具

思路在于获得当前鼠标的位置以及当前建造物品的碰撞体大小,然后去以鼠标点为起点,碰撞体大小为范围检测该范围内是否有碰撞体,如果有碰撞体,继续检测碰撞体是否有家具组件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// CursorManager.cs
private bool HaveFurnitureInRadius(BluePrintDetails bluePrintDetails)
{
    var buildItem = bluePrintDetails.buildPrefab;
    Vector2 point = _mouseWorldPos;
    var size = buildItem.GetComponent<BoxCollider2D>().size;

    var otherColl = Physics2D.OverlapBox(point, size, 0);
    return otherColl is not null && otherColl.GetComponent<Furniture>();
}

注意:OverlapBox方法不仅能检测碰撞体,也能检测trigger,而每个场景都有一个巨大的边界trigger,因此需要将这些边界的图层设置为Ignore Raycast

image-20230304234533101

后记

  1. 注意动画是否有退出时间
  2. 不同脚本的各自的awakestart的执行顺序不能确定,但是一定都是先执行完所有的awake在执行start
  3. 切换动画(设置为AniatorOverrideController),一个基本的AnimatorController可以通过上转来交由AniatorOverrideController控制,从而实现动画的替换。
  4. 构建路径的时候使用的是栈,所以在跨场景移动的时候,需要先写入从当前位置到目标位置。NPC跨场景移动的时候先将当前任意位置到当前场景的传送区域的位置压入栈,然后再写下一场景的传送区域到目标位置。
  5. 时间变化量是deltaTime(Time和FixTime)
  6. 自适应UI的三个组件
  7. 由于event事件是在OnEnable中声明的,因此这些时间触发的实际也是在OnEnable的生命周期中,即在StartUpdate之前
  8. 不在同一个场景的内容可以通过标签查找
  9. 将代码的类型改写为IEnumerator,可以控制代码内部的执行顺序,比如先异步加载场景,然后执行场景加载后呼叫的事件;start是唯一一个可以将返回类型改写为IEnumerator的声明周期函数。
Built with Hugo
Theme Stack designed by Jimmy