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. 01Inspectorでレベルトリガーを設定
  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 です。再利用性がなく、デザイナーが制御できず、コードを変更せずに無効化や再設定を行う方法もありません。

正しい方法:設定可能なアクティベーションルールと SO イベントチャネル出力を備えた汎用 TriggerZone コンポーネントを使います。デザイナーは 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 として設定します。ウェーブ数、タイミング、敵の種類の調整にコード変更は不要です。

注意点:スポーンポイントは SO ではなく、シーン内でエンカウントごとに定義する必要があります(コントローラー上の Transform 配列)。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 が実装されていることを確認してください。実装が 1 つでも欠けると、そのオブジェクトだけが死亡時にリセットされ、ほかのすべては復元されるため、矛盾した状態が発生します。また、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 を実装して作成します。デザイナーは [SerializeReference] リストでステップを追加、削除、並べ替えして、Inspector からシーケンスを構成できます。新しいカットシーンの作成にコード変更は不要です。

注意点:[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 — 近接判定、プロンプト表示、入力処理
  • 例:IInteraction を実装する ReadNoteInteraction

設計上の仕掛け:新しい操作タイプを追加するには、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

すべてが 1 つのシーンに含まれています。アーティストは並行して作業できません(マージの競合が発生します)。メモリ使用量に上限がありません。初回ロード時間は際限なく長くなります。

正しい例:Addressables を介してシーンのチャンクを加算ロード/アンロードする StreamingVolume トリガーゾーンを使います(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 シーンと距離を定義します。事前ロード距離をアンロード距離より小さくすることで、ヒステリシスを実現します。

注意点:隣接するチャンクには、小さな重なり領域が必要です。継ぎ目にあるオブジェクト(たとえば、2 つのエリアをつなぐ橋)は、両方のチャンクに含めるか、アンロードされない永続シーンに配置してください。アンロード距離は事前ロード距離より必ず大きくする必要があります(ヒステリシスバッファー)。そうしないと、毎フレーム、ロードとアンロードが切り替わる状態が発生します。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 の要件

追加リソース

コメント