Как я делаю открытый мир

Хочу поделиться тем как строю систему открытого мира в своей игре (движок Unity).

Поискал разные варианты как строятся открытые миры. Варианты на которое я засматривался были:
1) Всё на одной сцене
2) Additive сцены

Далее как я всё это себе представляю.

А) Данный вариант я представляю себе как сцена которая разбита на секторы

Зеленый - игрок. Красное - "соседние секторы"
Зеленый - игрок. Красное - "соседние секторы"

Каждый сектор представляет из себя игровой объект у которого в дочерних визуал сектора, npc, триггер зоны и тд.

У каждого сектора есть точка входа и выхода. Когда мы заходим из сектора А в сектор В выключаются все сектора и включается сектор В и его соседние сектора. В примере на картинке соседние сектора В это А и С

В) Аддитивные сцены работают так же как и сектора на одной сцене. Но вместо того чтобы всё это было на одной сцене сектора представляют из себя отдельные сцены. Есть главная сцена, на ней грузится всё основное. После этого подгружается необходимая сцена. Переходя между секторами игрок по сути меняет сцену.

Что я выбрал

Я решил что мне второго способа не надо потому-что у меня не столь большой открытый мир. Я решил что мир игры будет состоять из двух или трех (может даже одной) больших сцен с секторами.

Через код я это сделал так что

  • Есть скрипт запуска важных объектов сцены, то есть - игрок, система звуков, главный объект секторов
  • Главный объект секторов. Который хранит в себе инфу какой сектор включаем по умолчанию если зашли в игру первый раз и в целом отвечает за загрузку секторов при запуске сцены
  • Скрипт на секторе который хранит инфу где вход и выход и что является соседними секторами

Последовательность их запуска такая как показана выше.

Такой подход я выбрал потому что, по-моему, лучше делать так как ты понимаешь чем пытаться сходить с ума каждый раз изучая что-то. + Удобнее

1) Код запуска объектов на сцене в нужной последовательности

using System.Collections; using System.Collections.Generic; using UnityEngine; public class SceneBootstrapper : MonoBehaviour { [System.Serializable] public class Step { public string name; public GameObject go; public float delayAfterEnable = 0f; } [Header("Включать в таком порядке")] [SerializeField] private List<Step> steps = new List<Step>(); [Header("Опции")] [SerializeField] private bool disableAllOnAwake = true; [SerializeField] private bool runOnStart = true; private void Awake() { if (disableAllOnAwake) { foreach (var s in steps) { if (s != null && s.go != null) s.go.SetActive(false); } } } private void Start() { if (runOnStart) StartCoroutine(Run()); } public void RunNow() { StopAllCoroutines(); StartCoroutine(Run()); } private IEnumerator Run() { for (int i = 0; i < steps.Count; i++) { var s = steps[i]; if (s == null || s.go == null) continue; if (!s.go.activeSelf) s.go.SetActive(true); if (s.delayAfterEnable > 0f) yield return new WaitForSeconds(s.delayAfterEnable); else yield return null; } } }

2) Скрипт управляющий загрузкой секторов (или чанков)

using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.SceneManagement; public class WorldChunkManager : MonoBehaviour { public static WorldChunkManager Instance { get; private set; } [Header("Startup")] [SerializeField] private Transform player; [SerializeField] private WorldChunk fallbackStartChunk; [Header("Loading rule")] [SerializeField] private bool includeNeighbors = true; [Header("Anti-flicker")] [SerializeField] private float unloadDelay = 0.25f; private WorldChunk _current; private readonly HashSet<WorldChunk> _activeSet = new HashSet<WorldChunk>(); private readonly Dictionary<WorldChunk, Coroutine> _pendingUnload = new Dictionary<WorldChunk, Coroutine>(); // cache: id -> chunk private readonly Dictionary<string, WorldChunk> _chunkById = new Dictionary<string, WorldChunk>(); private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; if (player == null) { var p = GameObject.FindGameObjectWithTag("Player"); if (p != null) player = p.transform; } } private void Start() { StartCoroutine(StartupRoutine()); } private IEnumerator StartupRoutine() { // 1) Если есть сохранение и сцена не совпадает — грузим сохранённую сцену. if (LastSessionSave.TryLoad(out var data)) { string currentScene = SceneManager.GetActiveScene().name; if (!string.Equals(currentScene, data.sceneName)) { // IMPORTANT: этот менеджер должен жить в Boot-сцене (или быть DontDestroyOnLoad), // иначе он уничтожится при LoadScene. yield return SceneManager.LoadSceneAsync(data.sceneName); yield return null; // дать кадр на Awake/Start объектов сцены } } // 2) Регистрируем чанки (важно: включая неактивные) RebuildChunkRegistry(); // 3) Пытаемся восстановить последний чанк if (LastSessionSave.TryLoad(out var saved) && !string.IsNullOrWhiteSpace(saved.lastChunkId)) { if (_chunkById.TryGetValue(saved.lastChunkId, out var chunk) && chunk != null) { EnterChunk(chunk, force: true); if (player != null && saved.hasPlayerPos) player.position = new Vector3(saved.px, saved.py, saved.pz); yield break; } } // 4) Фолбек if (fallbackStartChunk != null) EnterChunk(fallbackStartChunk, force: true); } private void RebuildChunkRegistry() { _chunkById.Clear(); WorldChunk[] all; #if UNITY_2023_1_OR_NEWER all = Object.FindObjectsByType<WorldChunk>(FindObjectsInactive.Include, FindObjectsSortMode.None); #else all = Object.FindObjectsOfType<WorldChunk>(true); // includeInactive=true #endif for (int i = 0; i < all.Length; i++) { var c = all[i]; if (c == null) continue; string id = c.ChunkId; if (string.IsNullOrWhiteSpace(id)) continue; if (_chunkById.ContainsKey(id)) { Debug.LogWarning(quot;[WorldChunkManager] Duplicate chunkId '{id}' found: {c.name}"); continue; } _chunkById.Add(id, c); } } public void EnterChunk(WorldChunk chunk) => EnterChunk(chunk, force: false); private void EnterChunk(WorldChunk chunk, bool force) { if (chunk == null) return; if (!force && _current == chunk) return; _current = chunk; // сохраняем сразу при смене чанка (не каждый кадр!) var sceneName = SceneManager.GetActiveScene().name; var pos = player != null ? (Vector3?)player.position : null; LastSessionSave.Save(sceneName, chunk.ChunkId, pos); HashSet<WorldChunk> newSet = BuildSetFor(chunk); foreach (var c in newSet) { if (c == null) continue; CancelPendingUnload(c); c.SetLoaded(true); } var toUnload = new List<WorldChunk>(); foreach (var old in _activeSet) { if (old == null) continue; if (!newSet.Contains(old)) toUnload.Add(old); } foreach (var u in toUnload) ScheduleUnload(u); _activeSet.Clear(); foreach (var c in newSet) if (c != null) _activeSet.Add(c); } private HashSet<WorldChunk> BuildSetFor(WorldChunk center) { var set = new HashSet<WorldChunk>(); if (center == null) return set; set.Add(center); if (includeNeighbors) { var neigh = center.Neighbors; for (int i = 0; i < neigh.Count; i++) if (neigh[i] != null) set.Add(neigh[i]); } return set; } private void ScheduleUnload(WorldChunk chunk) { if (chunk == null) return; CancelPendingUnload(chunk); if (unloadDelay <= 0f) { chunk.SetLoaded(false); return; } _pendingUnload[chunk] = StartCoroutine(UnloadAfterDelay(chunk, unloadDelay)); } private IEnumerator UnloadAfterDelay(WorldChunk chunk, float delay) { yield return new WaitForSeconds(delay); if (_activeSet.Contains(chunk)) yield break; chunk.SetLoaded(false); _pendingUnload.Remove(chunk); } private void CancelPendingUnload(WorldChunk chunk) { if (chunk == null) return; if (_pendingUnload.TryGetValue(chunk, out var co) && co != null) StopCoroutine(co); _pendingUnload.Remove(chunk); } }

3) Скрипт на самом чанке

using System.Collections.Generic; using UnityEngine; public class WorldChunk : MonoBehaviour { [Header("ID (must be unique)")] [SerializeField] private string chunkId = "chunk_01"; public string ChunkId => chunkId; [Header("Neighbors")] [SerializeField] private List<WorldChunk> neighbors = new(); public List<WorldChunk> Neighbors => neighbors; [Header("Root to enable/disable (optional)")] [SerializeField] private GameObject root; // если null — используем gameObject public void SetLoaded(bool loaded) { var target = root != null ? root : gameObject; if (target.activeSelf != loaded) target.SetActive(loaded); } }
5
4 комментария