Unity3D游戏日记 (1):漫游的琥珀

前言:

  • “游戏场景”可以有多种概念,为了防止语义混淆,以下内容中专指 Unity 中的场景组件时使用 “Scene”,而讨论一般意义上的场景,如代表游戏中的地形的三维物体,使用 “场景”。
  • Unity chan 使用其设定名大鸟琥珀。
  • 关于控制角色移动和镜头脚本,可以参考<这里>和<这里>。

游戏人物在多场景活动的方案

Unity 使用了 Scene 这一概念加载游戏资源,一个 Scene 相当于一个小世界。原则上 Scene 本身的加载和销毁伴随着 Scene 内所有物体的加载和销毁。但也可以为某个 GameObject 物件指定标记,阻止这个物件因为 Scene 销毁而被销毁,形如:

 void Awake()
 {
     DontDestroyOnLoad(gameObject);
 }

对一个 GameObject 物件,在 Unity 编辑器中表示为一个节点。如果阻止某个节点随 Scene 销毁,那么其所有的子孙节点都不会随 Scene 销毁。

在“存在一个玩家和若干个 Scene”的情况下,除了为每一个 Scene 都保存一个玩家角色,也可以为玩家角色单独创建一个 Scene,而其他 Scene 中都没有玩家角色,这样玩家角色可以跨场景使用,保存在角色身上的信息可以不经过额外的处理一直持续,制作者也能专心编辑场景。

除了玩家角色 (包含角色模型、角色脚本和控制器脚本),观察玩家的镜头甚至 UI 组件都可以组装在一个节点下,形如:

 根节点 (挂载管理器脚本,阻止节点随场景转换而销毁)
    *- 摄像机 (包含摄像机组件和控制摄像机的脚本)
    *- 玩家 (包含角色模型、动画组件、角色脚本和控制器脚本等)
    *- UI (包含所需的 UI 组件和脚本,别忘了保持 EventSystem 在根节点下)

管理器脚本除了阻止节点被销毁,还可以有更多用途,如控制加载或销毁的游戏场景,在游戏存档时向配置文件写入保存信息等。
Unity3D游戏日记 (1):漫游的琥珀_第1张图片

把身高一米五的大鸟琥珀模型、控制器、动画状态机和一些其他组件装在一个根节点下,根节点挂管理器脚本设置不随 Scene 转换销毁,然后在管理器或者别的什么地方设置你的第一个有地面的 Scene 然后加载那个 Scene,从玩家 Scene 启动……
Unity3D游戏日记 (1):漫游的琥珀_第2张图片

Unity 会将所有带 DontDestroyOnLoad 标记的物件放在一个同名节点下,而最后一个被加载的 Scene 内还没有被销毁的物件单独放在一处。

现在我们有了能够出现在其他场景中的玩家角色,就可以让这个角色在不同场景间漫游了。创建多个游戏场景的思路有两种:一种是使用一张邻接表管理所有的节点,场景可以表示为 Scene 或者预制体,由一个管理器脚本维护当前所在的场景及与其相邻的场景,保持这些场景在加载并销毁那些不和当前玩家所在场景相邻的场景块,下面的例子中我会使用多个 Scene 的方案。而另一种思路是确保同一时间内只保持一个含有地形的 Scene 被加载,两个相邻的 Scene 有重叠的接缝,需要一些视觉技巧防止切换 Scene 时穿帮。

使用同时加载多个 Scene 的方法时,需要使用一张邻接表维护所有 Scene 的连通性。邻接表可以表示为字典形式,用某个 Scene 的名称 (比如A) 当作键,而从场景A出发,与场景A相邻的所有 Scene 的名称装在一个列表内,作为值,形如:

public Dictionary<string, List<string>> SceneEdges { get; private set; } 

初始化邻接表的动作应该在游戏开始之前,而游戏运行时内这张表不应该再被改动。你可以通过读取配置文件的方式导入信息。比如你有五个游戏场景A、B、C、D、E,A连接B和C,B连接A、C、和E,C连接A、B和D,D连接C和E,E连接B和D,它们的关系看起来就像是:

A -> B, C
B -> A, C, E
C -> A, B, D
D -> C, E
E -> B, D

而在配置文件中,可以将这些场景的关系保存为 csv 文件,你可以使用 Excel 编辑并生成 csv 文件。上述的节点关系在 csv 文件里看起来是这样的:

A,B,C
B,A,C,E
D,C,E
E,B,D

在 Unity 脚本中导入 Resources 文件夹下的配置文件并将文件信息反序列化到邻接表,就完成初始化了。这里我强烈建议建一个数据模型类保存从配置文件反序列化得到的信息,如表示场景连通性的邻接表、游戏中存在的物品的声明列表,和玩家的存档信息,包括存档时玩家所在的场景名称和玩家的世界坐标。这个数据模型类不继承 MonoBehaviour,所以也不能挂载到 Unity 物件上,将它制作成单例,确保其他脚本能访问到它并在整个程序的生命周期里只有一个数据模型实例。


测试算法的正确性,我建立了三个相邻的场景保存在不同的 Scene 中:
Unity3D游戏日记 (1):漫游的琥珀_第3张图片

每个场景预制体包含一个触发器,玩家进入触发器时管理器脚本获取触发器所在的场景名称,并按照当前玩家所在的场景加载与当前场景相邻的节点、销毁不相邻而尚在活动中的节点。场景连通性依据由邻接表提供,在活动中的节点由管理器脚本维护。

管理器脚本为:

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

public class LevelManager : MonoBehaviour
{
    /*
     * 玩家角色、玩家相机、UI 放在同一个预制体下,单开一个场景。
     * 进入游戏或从欢迎画面进入游戏后应总是第一个加载玩家场景。
     * 此脚本挂载在预制体上,加载玩家场景时立即开始加载初始化场景并赋予位置。
     */

    public static LevelManager Instance;
    public string initSceneName;
    public Vector3 initPos;

    Dictionary<string, List<string>> sceneEdges;  // 场景的连通性,表示为图
    public Dictionary<string, Local> sceneActive;   // 场景的开关标记,需要初始化
    Dictionary<string, bool> sceneLock;  // 确保加载动作只执行一次

    public bool _loadAsAsync = true;

    private void Awake()
    {
        Instance = this;  // 单例标记,玩家只有一个
        DontDestroyOnLoad(gameObject);
    }

    private void Start()
    {
        PullSceneData();  // 从数据模型中拉取信息
        InitSceneActive();

        transform.position = initPos;  // 初始化信息,应在设置之前拉取数据模型信息
        ChangeScene(initSceneName);  // 按照当前场景加载和销毁场景
    }

    // DataModel 类为保存数据模型的单例类,拉取玩家存档信息的动作由 DataModel 完成
    private void PullSceneData()
    {
        initPos = DataModel.Instance.InitPos;
        initSceneName = DataModel.Instance.InitSceneName;
        sceneEdges = DataModel.Instance.SceneEdges;
    }

    private void InitSceneActive()
    {
        if (sceneActive == null) { sceneActive = new Dictionary<string, Local>(); }
        if (sceneLock == null) { sceneLock = new Dictionary<string, bool>(); }

        foreach (var single in sceneEdges)
        {
            sceneActive[single.Key] = null;
            sceneLock[single.Key] = false;
        }
    }

    bool _ItemInList(string item, List<string> list)
    {
        foreach (var single in list)
        {
            if (single == item) { return true; }
        }
        return false;
    }

    // 触发改变场景事件时调用此函数。允许被外界调用
    // 传入当前进入的场景名称
    // 加载当前节点及所有邻接点,销毁在活动的非邻接点
    public void ChangeScene(string sceneName)
    {
        if (!sceneLock[sceneName] && sceneActive[sceneName] == null)
        {
            if (_loadAsAsync) { SceneManager.LoadSceneAsync(sceneName); }
            else { SceneManager.LoadScene(sceneName); }
            sceneLock[sceneName] = true;
        }

        //处理每个非邻接点
        foreach (var single in sceneEdges)
        {
            if (sceneName != single.Key && !_ItemInList(sceneName, single.Value))
            {
                // UnLoad single.Key
                var unLink = sceneActive[single.Key];
                if (unLink != null)
                {
                    sceneActive[single.Key] = null;
                    Destroy(unLink.gameObject);
                    sceneLock[single.Key] = false;
                }
            }
        }

        // 处理每个邻接点
        foreach (var single in sceneEdges[sceneName])
        {
            if (sceneActive[single] == null && !sceneLock[single])
            {
                if (_loadAsAsync) { SceneManager.LoadSceneAsync(single); }
                else { SceneManager.LoadScene(single); }
                sceneLock[single] = true;
            }
        }
    }
}

挂载在场景根节点上的脚本为:

using UnityEngine;

[RequireComponent(typeof(Collider))]
public class Local : MonoBehaviour
{
    /*
     * 每个子场景的物件存放在一个父节点下,此脚本挂载在父节点上。
     */

    [Header("_在编辑器中录入脚本所在场景名称_")]
    public string selfSceneName;
    Collider selfTrigger;

    private void Awake()
    {
        DontDestroyOnLoad(gameObject);
        LoginLevelManager();  // 向管理器脚本提交自身的指针

        selfTrigger = GetComponent();
        selfTrigger.isTrigger = true;
    }

    public void LoginLevelManager()
    {
        LevelManager.Instance.sceneActive[selfSceneName] = this;
        print(string.Format("{0} login: {1}", selfSceneName,
            LevelManager.Instance.sceneActive[selfSceneName] != null));
    }

    // 进入触发器调用 LevelManager::ChangeScene()
    private void OnTriggerEnter(Collider other)
    {
        if (other.tag == "Player")
        {
            LevelManager.Instance.ChangeScene(selfSceneName);
            print(string.Format("Scene {0} is current now.", selfSceneName));
        }
    }

    private void OnDestroy()
    {
        print(string.Format("{0} was be destroied.", selfSceneName));
    }
}

启动调试,琥珀出现在场景中:
Unity3D游戏日记 (1):漫游的琥珀_第4张图片

向前走两步,新的场景被加载:
Unity3D游戏日记 (1):漫游的琥珀_第5张图片

退回来,刚才加载的场景消失了,因为它和当前场景不相邻:
Unity3D游戏日记 (1):漫游的琥珀_第6张图片


注意:

  • 如果你使用预制体而非 Scene 为单位加载多场景时,需要建立场景名称和预制体的对应关系,确保预制体被正确加载和销毁。
  • 动态加载预制体或同时存在多个 Scene 会产生光照贴图问题,多个 Scene 下所有 Scene 中的光照贴图同时为最后被加载的 Scene 的光照贴图,而动态加载预制体会造成光照贴图丢失。关于前者还没有很好的解决方式,而后者可以使用一个编辑器扩展方案来完成,见 “https://www.cnblogs.com/verlout/p/5734390.html“(使用的属性部分已过时,可按照 IDE 给出的建议替换为当前版本使用的属性名称)。

你可能感兴趣的:(Unity,C#)