mcpField
U
unity-level-design

unity-level-design

github.com/nice-wolf-studio开发4069 次安装6 周前更新
代码仓库

关于 unity-level-design

面向 Unity 关卡设计与玩法开发的技能,提供将设计意图转化为数据配置和可复用系统的模式,涵盖触发器、遭遇战、检查点、演出及交互。

主要功能

  1. 01可配置触发器与事件通道
  2. 02数据驱动的遭遇战波次
  3. 03检查点状态保存与恢复
  4. 04可组合的异步演出序列
  5. 05统一的物体交互模式

适用场景

  1. 01在 Inspector 中配置关卡触发行为
  2. 02无需改代码调整敌人波次
  3. 03玩家死亡后恢复关卡状态
  4. 04在编辑器中编排可跳过的演出

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/nice-wolf-studio/unity-claude-skills --skill unity-level-design

安装后在 Claude Code 中输入/unity-level-design确认已加载。

附带文件references/level-system-scaffolds.md

详细介绍

关卡设计 —— 设计转换模式

前置技能: unity-scene-assets(叠加场景、Addressables)、unity-async-patterns(异步加载、取消)、unity-game-architecture(SO 事件、引导)

Claude 将关卡视为静态场景文件,关卡设计师与游戏程序员之间没有契约层。触发器是一次性脚本,游戏逻辑全塞进 OnTriggerEnter;遭遇战是硬编码的生成序列;检查点在场景重新加载后失效,因为没人考虑过哪些状态需要保留。这些模式建立了转换层:设计师通过数据资源和 Inspector 配置表达意图,程序员提供遵循这些意图的系统。


模式 1:触发器与事件架构

设计意图:关卡设计师在编辑器中放置触发区域,以触发游戏事件——生成敌人、播放对话、打开门。触发器是通用、可复用的组件;响应则通过 ScriptableObject 事件通道在 Inspector 中连接。

错误做法:

// Each trigger is a bespoke script -- 47 of these in the project
public class Door3Trigger : MonoBehaviour
{
    public GameObject door;
    void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Player"))
            door.GetComponent<Animator>().SetTrigger("Open");
    }
}

每个触发器都是独特的 MonoBehaviour。无法复用,也没有设计师控制能力,更无法在不修改代码的情况下禁用或重新配置。

正确做法:使用通用的 TriggerZone 组件,并配置激活规则和 SO 事件通道输出。设计师在 Inspector 中连接事件——无需为每个触发器编写代码。

using UnityEngine;

/// <summary>
/// Generic trigger volume that raises a GameEvent SO channel on activation.
/// Configurable activation count, tag filter, cooldown, and delay.
/// </summary>
[RequireComponent(typeof(Collider), typeof(Rigidbody))]
public class TriggerZone : MonoBehaviour
{
    public enum ActivationMode { Once, Repeating, NTimes }

    [Header("Activation Rules")]
    [SerializeField] ActivationMode _mode = ActivationMode.Once;
    [SerializeField] int _maxActivations = 1;
    [SerializeField] float _cooldownSeconds;
    [SerializeField] float _activationDelay;
    [SerializeField] string _requiredTag = "Player";
    [SerializeField] LayerMask _requiredLayer = ~0;

    [Header("Event Output")]
    [SerializeField] GameEvent _onActivated;

    int _activationCount;
    float _lastActivationTime = float.NegativeInfinity;

    // See unity-game-architecture for GameEvent SO channel pattern

    void Reset()
    {
        // Guarantee Rigidbody is kinematic and collider is trigger
        var rb = GetComponent<Rigidbody>();
        rb.isKinematic = true;
        GetComponent<Collider>().isTrigger = true;
    }

    void OnTriggerEnter(Collider other)
    {
        if (!PassesFilter(other)) return;
        if (!CanActivate()) return;
        _ = ActivateAsync();
    }

    bool PassesFilter(Collider other)
    {
        if (!string.IsNullOrEmpty(_requiredTag) && !other.CompareTag(_requiredTag))
            return false;
        if ((_requiredLayer & (1 << other.gameObject.layer)) == 0)
            return false;
        return true;
    }

    bool CanActivate()
    {
        if (_mode == ActivationMode.Once && _activationCount >= 1) return false;
        if (_mode == ActivationMode.NTimes && _activationCount >= _maxActivations) return false;
        if (Time.time - _lastActivationTime < _cooldownSeconds) return false;
        return true;
    }

    async Awaitable ActivateAsync()
    {
        if (_activationDelay > 0f)
            await Awaitable.WaitForSecondsAsync(_activationDelay, destroyCancellationToken);

        _activationCount++;
        _lastActivationTime = Time.time;
        _onActivated?.Raise();
    }
}

脚手架:

  • TriggerZone MonoBehaviour(见上文)——通用触发器组件
  • GameEvent ScriptableObject 通道——参见 unity-game-architecture
  • TriggerConfig 字段——激活模式、次数、冷却时间、延迟时间、标签/层过滤器

设计挂钩:设计师在场景中放置触发区域,在 Inspector 中分配 GameEvent SO,并配置激活规则。无需为每个触发器编写代码。新增触发器行为 = 新增 GameEvent 监听器,而不是编写新的触发器脚本。

注意事项:OnTriggerEnter 要求一方碰撞体的 isTrigger = true,且这对碰撞体中至少有一个 Rigidbody。TriggerZone 通过 [RequireComponent] 添加自身的运动学 Rigidbody,并在 Reset() 中进行设置,以确保无论进入对象是什么,都能正常工作。否则,当进入对象没有 Rigidbody 时,触发器会悄无声息地失效。


模式 2:遭遇战设计契约

设计意图:设计师将遭遇战(敌人波次、竞技场封锁、谜题)定义为数据资源,并通过预制件将其放置到关卡中。遭遇战定义与遭遇战实例彼此分离。

错误做法:

// Hardcoded encounter -- can't reuse, can't tune without code changes
public class ArenaEncounter : MonoBehaviour
{
    public GameObject zombiePrefab;
    public GameObject skeletonPrefab;

    async Awaitable Start()
    {
        // Wave 1
        Instantiate(zombiePrefab, transform.position, Quaternion.identity);
        Instantiate(zombiePrefab, transform.position + Vector3.right * 3, Quaternion.identity);
        await Awaitable.WaitForSecondsAsync(10f);
        // Wave 2
        Instantiate(skeletonPrefab, transform.position, Quaternion.identity);
    }
}

每场遭遇战都是一个独特脚本。波次时间、敌人类型和生成位置全部硬编码。

正确做法:EncounterConfig SO 将波次定义为数据。EncounterController 预制件读取配置并管理生成。

using System;
using UnityEngine;

/// <summary>
/// Defines a single wave within an encounter: enemy types, counts, and timing.
/// </summary>
[Serializable]
public struct WaveDefinition
{
    /// <summary>Enemy prefab to spawn in this wave.</summary>
    public GameObject EnemyPrefab;
    /// <summary>Number of enemies to spawn.</summary>
    public int Count;
    /// <summary>Seconds to wait before starting this wave (after previous completes).</summary>
    public float DelayBeforeWave;
    /// <summary>If true, all enemies must be defeated before next wave starts.</summary>
    public bool WaitForClear;
}

/// <summary>
/// Data asset defining an entire encounter: sequence of waves and completion rules.
/// </summary>
[CreateAssetMenu(menuName = "Level Design/Encounter Config")]
public class EncounterConfig : ScriptableObject
{
    /// <summary>Ordered list of waves in this encounter.</summary>
    public WaveDefinition[] Waves;
    /// <summary>Event raised when the encounter begins.</summary>
    public GameEvent OnEncounterStart;
    /// <summary>Event raised when all waves are cleared.</summary>
    public GameEvent OnEncounterComplete;
}
using System.Collections.Generic;
using System.Threading;
using UnityEngine;

/// <summary>
/// Runs an encounter defined by an EncounterConfig SO.
/// Place in scene with spawn point transforms as children.
/// </summary>
public class EncounterController : MonoBehaviour
{
    [SerializeField] EncounterConfig _config;
    [SerializeField] Transform[] _spawnPoints;
    [SerializeField] GameEvent _triggerEvent; // Listens for this to start

    readonly List<GameObject> _activeEnemies = new();

    // Full implementation in references/level-system-scaffolds.md
}

脚手架:

  • EncounterConfig SO——波次定义、完成事件
  • WaveDefinition 可序列化结构体——敌人预制件、数量、时间安排、清除条件
  • EncounterController MonoBehaviour——异步推进波次、管理生成

设计挂钩:设计师为每场遭遇战创建 EncounterConfig SO,并在遭遇战位置放置 EncounterController 预制件。生成点是控制器的子 Transform。调整波次数量、时间安排和敌人类型无需修改代码。

注意事项:生成点必须在场景中按遭遇战单独定义(控制器上的 Transform 数组),而不能放在 SO 中。ScriptableObject 无法引用场景对象,因为它们是项目级资源。如果在 SO 中放入 Transform 引用,它们在运行时将为 null。


模式 3:检查点与重生

设计意图:玩家死亡后会在最近激活的检查点重生。检查点状态会在多次尝试间保留——门保持开启,钥匙保持已收集,敌人保持死亡。

错误示例:

// Full scene reload on death -- all progress lost
public class DeathHandler : MonoBehaviour
{
    public void OnPlayerDied()
    {
        SceneManager.LoadScene(SceneManager.GetActiveScene().name);
    }
}

重新加载场景会重置所有内容。门会重新上锁,敌人会重生,谜题会重置。玩家必须重新完成整个关卡。

正确做法:CheckpointSystem 在激活时捕获一个 CheckpointSnapshot。玩家死亡时,恢复快照,而不重新加载场景。需要保存和恢复的对象需实现 ICheckpointable。

using UnityEngine;

/// <summary>
/// Implement on any object that needs to save/restore state at checkpoints.
/// </summary>
public interface ICheckpointable
{
    /// <summary>Unique ID for this object (use a serialized GUID).</summary>
    string CheckpointId { get; }
    /// <summary>Capture current state as serializable data.</summary>
    object CaptureState();
    /// <summary>Restore state from previously captured data.</summary>
    void RestoreState(object state);
}
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// Snapshot of all checkpointable state at a specific checkpoint.
/// </summary>
public class CheckpointSnapshot
{
    /// <summary>Player position at checkpoint activation.</summary>
    public Vector3 PlayerPosition;
    /// <summary>Player rotation at checkpoint activation.</summary>
    public Quaternion PlayerRotation;
    /// <summary>Saved state per checkpointable object, keyed by CheckpointId.</summary>
    public Dictionary<string, object> ObjectStates = new();
}
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

/// <summary>
/// Manages checkpoint save/restore. Singleton, lives in persistent scene.
/// </summary>
public class CheckpointSystem : MonoBehaviour
{
    public static CheckpointSystem Instance { get; private set; }

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
    static void ResetStatics() => Instance = null;

    CheckpointSnapshot _currentSnapshot;

    void Awake()
    {
        if (Instance != null && Instance != this) { Destroy(gameObject); return; }
        Instance = this;
    }

    /// <summary>
    /// Captures a checkpoint snapshot from the player and all ICheckpointable objects.
    /// </summary>
    public void SaveCheckpoint(Transform player)
    {
        _currentSnapshot = new CheckpointSnapshot
        {
            PlayerPosition = player.position,
            PlayerRotation = player.rotation
        };

        foreach (var obj in FindCheckpointables())
            _currentSnapshot.ObjectStates[obj.CheckpointId] = obj.CaptureState();
    }

    /// <summary>
    /// Restores the last checkpoint snapshot. Returns false if no checkpoint exists.
    /// </summary>
    public bool RestoreCheckpoint(Transform player)
    {
        if (_currentSnapshot == null) return false;

        player.position = _currentSnapshot.PlayerPosition;
        player.rotation = _currentSnapshot.PlayerRotation;

        foreach (var obj in FindCheckpointables())
        {
            if (_currentSnapshot.ObjectStates.TryGetValue(obj.CheckpointId, out var state))
                obj.RestoreState(state);
        }
        return true;
    }

    IEnumerable<ICheckpointable> FindCheckpointables() =>
        FindObjectsByType<MonoBehaviour>(FindObjectsSortMode.None).OfType<ICheckpointable>();
}

脚手架:

  • CheckpointSystem 管理器(单例,持久场景)——协调保存与恢复
  • CheckpointSnapshot ——玩家变换信息 + 对象状态字典
  • ICheckpointable 接口——CaptureState() / RestoreState(object)
  • CheckpointTrigger ——扩展 TriggerZone 模式(模式 1),调用 CheckpointSystem.SaveCheckpoint

设计扩展点:对象通过实现 ICheckpointable 来参与检查点的保存与恢复(门、开关、可破坏物)。设计师在场景中放置检查点触发区域,其余工作由系统处理。

注意事项:检查点必须保存所有可变状态。如果某扇门在两个检查点之间被打开,那么开启状态必须包含在快照中。检查每个游戏对象是否实现了 ICheckpointable——只要有一个对象漏掉实现,该对象就会在死亡时重置,而其他对象都会恢复,从而导致状态不可能出现。另请注意:FindObjectsByType 开销很大——如果检查点触发频繁,请缓存结果。


模式 4:过场动画与脚本化序列

设计意图:将游戏内过场动画、镜头移动、NPC 对话和定时事件制作成可编排的序列,让设计师无需编写代码,就能在 Inspector 中进行组合。

错误示例:

// Coroutine chain -- fragile, not reusable, not skippable
IEnumerator PlayCutscene()
{
    camera.transform.position = new Vector3(10, 5, 0);
    yield return new WaitForSeconds(2f);
    npc.GetComponent<Animator>().SetTrigger("Talk");
    dialogueUI.Show("Welcome, hero!");
    yield return new WaitForSeconds(3f);
    door.SetActive(false);
}

位置硬编码、无法取消、不能跳过,并使用了已弃用的协程模式。

正确做法:使用 ISequenceStep 接口及异步方法 ExecuteAsync(CancellationToken)。SequencePlayer 按顺序运行各个步骤。通过 [SerializeReference] 在 Inspector 中组合步骤。

using System.Threading;

/// <summary>
/// A single step in a scripted sequence. Implement for each step type.
/// </summary>
public interface ISequenceStep
{
    /// <summary>
    /// Execute this step. Must respect cancellation for skip support.
    /// When cancelled, the step should immediately apply its end state.
    /// </summary>
    Awaitable ExecuteAsync(CancellationToken ct);
}
using System;
using System.Collections.Generic;
using System.Threading;
using UnityEngine;

/// <summary>
/// Plays a sequence of ISequenceStep instances in order.
/// Steps are composed via [SerializeReference] in the Inspector.
/// </summary>
public class SequencePlayer : MonoBehaviour
{
    [SerializeReference] List<ISequenceStep> _steps = new();
    [SerializeField] bool _playOnStart;
    [SerializeField] GameEvent _onSequenceComplete;

    bool _isPlaying;

    /// <summary>Play the full sequence. Cancellation skips remaining steps.</summary>
    public async Awaitable PlayAsync(CancellationToken ct = default)
    {
        if (_isPlaying) return;
        _isPlaying = true;

        using var linked = CancellationTokenSource.CreateLinkedTokenSource(
            ct, destroyCancellationToken);

        foreach (var step in _steps)
        {
            if (linked.Token.IsCancellationRequested) break;

            try
            {
                await step.ExecuteAsync(linked.Token);
            }
            catch (OperationCanceledException)
            {
                break;
            }
        }

        _isPlaying = false;
        _onSequenceComplete?.Raise();
    }

    async void Start()
    {
        if (_playOnStart)
            await PlayAsync(destroyCancellationToken);
    }
}

步骤实现示例:

using System;
using System.Threading;
using UnityEngine;

/// <summary>Wait for a specified duration. Skippable via cancellation.</summary>
[Serializable]
public class WaitStep : ISequenceStep
{
    [SerializeField] float _duration = 1f;

    public async Awaitable ExecuteAsync(CancellationToken ct)
    {
        await Awaitable.WaitForSecondsAsync(_duration, ct);
    }
}

/// <summary>Enable or disable a GameObject.</summary>
[Serializable]
public class SetActiveStep : ISequenceStep
{
    [SerializeField] GameObject _target;
    [SerializeField] bool _active = true;

    public Awaitable ExecuteAsync(CancellationToken ct)
    {
        if (_target != null) _target.SetActive(_active);
        return Awaitable.NextFrameAsync(ct);
    }
}

脚手架:

  • ISequenceStep 接口——ExecuteAsync(CancellationToken)
  • SequencePlayer MonoBehaviour——按顺序运行 [SerializeReference] 步骤列表
  • 步骤类型:CameraMoveStep、DialogueStep、WaitStep、SetActiveStep

设计扩展点:新增序列步骤 = 实现 ISequenceStep。设计师可以在 Inspector 中通过向 [SerializeReference] 列表添加、移除或重新排序步骤来组合序列。创建新的过场动画无需修改代码。

注意事项:[SerializeReference] 要求具体类型与接口位于同一程序集,或位于引用接口所在程序集的程序集中。Unity 默认的 Inspector 不会为 [SerializeReference] 提供友好的 UI——你需要自定义 PropertyDrawer,或使用 Odin Inspector / SerializeReferenceDropdown 等包。步骤必须妥善处理取消操作:取消(跳过)时,应立即应用最终状态(例如,将镜头直接切换到最终位置),而不是让步骤停在未完成的状态。


模式 5:环境叙事钩子

设计意图:物体会对玩家的靠近或互动做出反应——可阅读的便笺、可播放的音频日志、可触发的环境动画。所有可交互物体都采用一致的 UX 模式。

错误示例:

// Each interactive object is a unique script with bespoke logic
public class Note47 : MonoBehaviour
{
    void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Player") && Input.GetKeyDown(KeyCode.E))
            UIManager.Instance.ShowNote("Note 47 text...");
    }
}

没有通用的交互 UX。每个物体都以不同方式检测输入。提示显示不一致。新增交互类型时都需要从头编写脚本。

正确示例:使用带有 IInteraction 接口的 Interactable 组件。玩家身上的通用 InteractionDetector 负责处理距离检测和输入提示。

using UnityEngine;

/// <summary>
/// Interface for interaction behaviors. Implement per interaction type.
/// </summary>
public interface IInteraction
{
    /// <summary>Text shown on the interaction prompt (e.g., "Read Note").</summary>
    string PromptText { get; }
    /// <summary>Execute the interaction.</summary>
    void Execute(GameObject interactor);
}
using UnityEngine;

/// <summary>
/// Marks a GameObject as interactable. Holds an IInteraction and detection range.
/// </summary>
public class Interactable : MonoBehaviour
{
    [SerializeField] float _interactionRange = 2f;
    [SerializeReference] IInteraction _interaction;

    /// <summary>Maximum distance for interaction.</summary>
    public float InteractionRange => _interactionRange;
    /// <summary>The interaction behavior attached to this object.</summary>
    public IInteraction Interaction => _interaction;
}
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

/// <summary>
/// Player-side component. Detects nearby Interactables and handles input prompting.
/// Cross-ref unity-input-correctness for input handling.
/// </summary>
public class InteractionDetector : MonoBehaviour
{
    [SerializeField] float _detectionRadius = 5f;
    [SerializeField] LayerMask _interactableLayer;

    Interactable _currentTarget;
    readonly Collider[] _overlapBuffer = new Collider[16];

    void Update()
    {
        _currentTarget = FindClosestInteractable();

        if (_currentTarget != null)
        {
            ShowPrompt(_currentTarget.Interaction.PromptText);
            // Input check -- see unity-input-correctness for Input System usage
        }
        else
        {
            HidePrompt();
        }
    }

    Interactable FindClosestInteractable()
    {
        int count = Physics.OverlapSphereNonAlloc(
            transform.position, _detectionRadius, _overlapBuffer, _interactableLayer);

        Interactable closest = null;
        float closestDist = float.MaxValue;

        for (int i = 0; i < count; i++)
        {
            if (!_overlapBuffer[i].TryGetComponent<Interactable>(out var interactable))
                continue;

            float dist = Vector3.Distance(transform.position, interactable.transform.position);
            if (dist > interactable.InteractionRange) continue;
            if (dist < closestDist)
            {
                closest = interactable;
                closestDist = dist;
            }
        }
        return closest;
    }

    void ShowPrompt(string text) { /* UI Toolkit prompt -- see implementation in scaffolds */ }
    void HidePrompt() { /* Hide prompt UI */ }
}

脚手架:

  • Interactable MonoBehaviour——通过 [SerializeReference] 持有 IInteraction,并设置检测范围
  • IInteraction 接口——包含 PromptText、Execute(GameObject interactor)
  • 玩家身上的 InteractionDetector——负责距离检测、提示显示和输入处理
  • 示例:ReadNoteInteraction 实现 IInteraction

设计钩子:新增交互类型 = 实现 IInteraction。设计师可在预制件上配置提示文本、范围和交互类型。所有可交互物体的 UX 保持一致。

注意事项:交互提示必须使用世界坐标到屏幕坐标的转换(Camera.main.WorldToScreenPoint),并处理屏幕外钳制。使用 Physics.OverlapSphereNonAlloc 和预分配的缓冲区——不要每帧都分配内存。Camera.main 底层会调用 FindObjectWithTag;请缓存相机引用。


模式 6:关卡流式加载与加载接缝

设计意图:玩家移动时,大型关卡会加载/卸载各个区域——开放世界、长走廊、由相连区域构成的枢纽。玩家在游戏过程中不会看到加载画面。

错误示例:

// Entire world in one scene
// 500 MB memory usage, 45-second load time, untestable

所有内容都在同一个场景中。美术人员无法并行工作(会产生合并冲突)。内存使用量没有上限。初始加载时间会不断增长。

正确示例:使用 StreamingVolume 触发区域,通过 Addressables 增量加载/卸载场景区块(交叉参考 unity-scene-assets)。根据玩家距离预加载。使用滞后机制防止频繁切换。

using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.ResourceProviders;

/// <summary>
/// Configuration for a streamable level chunk.
/// </summary>
[CreateAssetMenu(menuName = "Level Design/Level Chunk Config")]
public class LevelChunkConfig : ScriptableObject
{
    /// <summary>Addressable scene asset reference for this chunk.</summary>
    public AssetReference SceneReference;
    /// <summary>Distance at which to begin preloading this chunk.</summary>
    public float PreloadDistance = 50f;
    /// <summary>Distance at which to unload this chunk (must be > PreloadDistance).</summary>
    public float UnloadDistance = 80f;
}
using System.Threading;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using UnityEngine.ResourceManagement.ResourceProviders;
using UnityEngine.SceneManagement;

/// <summary>
/// Manages loading/unloading a level chunk based on player proximity.
/// Place at the seam between level sections.
/// Cross-ref unity-scene-assets for Addressable scene loading patterns.
/// </summary>
public class StreamingVolume : MonoBehaviour
{
    [SerializeField] LevelChunkConfig _chunkConfig;
    [SerializeField] Transform _playerTransform;

    AsyncOperationHandle<SceneInstance> _loadHandle;
    bool _isLoaded;
    bool _isLoading;

    void Update()
    {
        if (_playerTransform == null) return;
        float distance = Vector3.Distance(transform.position, _playerTransform.position);

        if (!_isLoaded && !_isLoading && distance < _chunkConfig.PreloadDistance)
            _ = LoadChunkAsync(destroyCancellationToken);
        else if (_isLoaded && distance > _chunkConfig.UnloadDistance)
            _ = UnloadChunkAsync();
    }

    async Awaitable LoadChunkAsync(CancellationToken ct)
    {
        _isLoading = true;
        _loadHandle = Addressables.LoadSceneAsync(
            _chunkConfig.SceneReference, LoadSceneMode.Additive);

        while (!_loadHandle.IsDone)
        {
            ct.ThrowIfCancellationRequested();
            await Awaitable.NextFrameAsync(ct);
        }

        _isLoaded = true;
        _isLoading = false;
    }

    async Awaitable UnloadChunkAsync()
    {
        if (!_loadHandle.IsValid()) return;
        var unload = Addressables.UnloadSceneAsync(_loadHandle);

        while (!unload.IsDone)
            await Awaitable.NextFrameAsync(destroyCancellationToken);

        _isLoaded = false;
    }

    void OnDestroy()
    {
        if (_isLoaded && _loadHandle.IsValid())
            Addressables.UnloadSceneAsync(_loadHandle);
    }
}

脚手架:

  • StreamingVolume MonoBehaviour——根据距离加载/卸载,并采用滞后机制
  • LevelChunkConfig SO——场景引用、预加载距离、卸载距离
  • 异步加载/卸载 Addressable 场景(交叉参考 unity-scene-assets)

设计钩子:设计师在关卡接缝处放置流式加载区域,并分配区块配置。区块配置定义要加载哪个 Addressable 场景以及加载距离。预加载距离 < 卸载距离可实现滞后机制。

注意事项:相邻区块必须有一小段重叠区域。接缝处的物体(例如连接两个区域的桥梁)必须同时存在于两个区块中,或放在永不卸载的持久场景中。卸载距离必须严格大于预加载距离(滞后缓冲区),否则系统会每帧都在加载和卸载之间反复切换。使用 Profiler.GetTotalAllocatedMemoryLong() 测试,确认卸载时区块确实释放了内存。


反模式总结

反模式问题模式修复方案
定制触发器脚本无法复用,设计师无法控制模式 1:通用 TriggerZone + SO 事件
硬编码生成序列无法在不修改代码的情况下调整模式 2:EncounterConfig SO + EncounterController
死亡时重新加载场景所有进度都会丢失模式 3:CheckpointSystem + ICheckpointable
协程过场动画链无法跳过,也无法组合模式 4:ISequenceStep + SequencePlayer
一次性可交互物体UX 不一致,无法复用模式 5:Interactable + IInteraction
单体式单场景内存、性能和协作方面的问题模式 6:StreamingVolume + Addressables

相关技能

  • unity-game-architecture — GameEvent SO 通道模式、引导场景、基于 SO 的配置
  • unity-scene-assets — Addressable 资源加载、叠加场景管理
  • unity-async-patterns — Awaitable 用法、取消令牌、异步生命周期
  • unity-input-correctness — 用于交互提示的 Input System 集成
  • unity-save-system — 持久化存档数据(检查点快照是临时的;存档系统是永久的)
  • unity-physics — 触发碰撞体设置、基于图层的筛选、Rigidbody 要求

其他资源

评论

支持 Markdown