mcpField
U
unity-game-loop

unity-game-loop

github.com/nice-wolf-studio開発4070 回インストール6 週間前に更新
リポジトリ

unity-game-loop について

ゲームプレイの設計意図をコード構造に落とし込む、Unity開発者向けのゲームループ設計スキルです。フェーズ、セッション、勝敗条件、メタ進行、難易度、遭遇のペース配分を扱います。

主な機能

  1. 01順序に沿って実行するゲームフェーズ
  2. 02セッション状態とライフサイクル管理
  3. 03組み合わせ可能な勝敗条件
  4. 04セッション進行とメタ進行の分離
  5. 05調整可能な難易度カーブ
  6. 06遭遇のペースと強度カーブ

活用シーン

  1. 01探索やクラフトを含むゲームループの構築
  2. 02シーンを再読み込みせずにセッションを再開
  3. 03レベルごとの勝敗ルール設定
  4. 04難易度や遭遇ペースをデザイナーが調整

インストール

コマンドをコピーしてターミナルで実行します。

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

インストール後、Claude Code で/unity-game-loopを入力して読み込まれたか確認します。

同梱ファイルreferences/game-loop-scaffolds.md

詳細

ゲームループと進行システム -- 設計をコードに反映するパターン

前提となるスキル: unity-state-machines(セッション FSM)、unity-game-architecture(イベント、Service Locator、ブートストラップ)、unity-data-driven(難易度/ペース配分用の SO 設定)

Claude で最もよく見られるゲームプレイの失敗は、統一されたループ構造を持たせずに機能を個別に実装することです。移動、インベントリ、戦闘、クラフトの各システムを個別には動作するように作成しても、まとまりのあるゲームにはなりません。その結果、進行システムもセッションの境界も勝敗条件もなく、デザイナーがペース配分や難易度を調整する手段もない、プレイ可能なサンドボックスになってしまいます。ここで紹介するパターンは、「プレイヤーが探索し、収集し、クラフトしてから、夜を生き延びる」という設計意図を、フェーズの順序を強制し、セッション状態を追跡し、デザイナーが調整できる箇所を提供するコードアーキテクチャへと反映します。


パターン:コアループの骨組み

設計意図:デザイナーはゲームを「探索、収集、クラフト、夜を生き延びる」と説明しています。このサイクルを強制し、順番に実行される拡張可能なフェーズを備えたコードの骨組みが必要です。

誤り(Claude のデフォルト):

// Systems built independently with no phase concept -- everything runs simultaneously.
// ExplorationManager, InventoryManager, CraftingManager, CombatManager all live in the scene
// and run their Update loops at the same time. There is no concept of "it is now the crafting
// phase" or "exploration has ended." The game is a flat sandbox.
public class GameManager : MonoBehaviour
{
    [SerializeField] private ExplorationManager _exploration;
    [SerializeField] private InventoryManager _inventory;
    [SerializeField] private CombatManager _combat;

    private void Start()
    {
        _exploration.Enable();
        _inventory.Enable();
        _combat.Enable(); // all systems active from frame 1
    }
}

正しい例:

// GameLoopController manages phase transitions via IGamePhase interface.
// Each phase gets Enter/Tick/Exit. Phases are configured as SO assets.
// Only one phase is active at a time, enforcing the designer's intended cycle.
public interface IGamePhase
{
    /// <summary>Name shown in debug UI and logs.</summary>
    string PhaseName { get; }

    /// <summary>Called when this phase becomes active. Load assets, enable systems.</summary>
    Awaitable EnterAsync(CancellationToken ct);

    /// <summary>Called every frame while this phase is active.</summary>
    void Tick(float deltaTime);

    /// <summary>Called when transitioning away. Cleanup, disable systems.</summary>
    Awaitable ExitAsync(CancellationToken ct);
}

[CreateAssetMenu(menuName = "Game Loop/Phase Config")]
public class GamePhaseConfig : ScriptableObject
{
    [Tooltip("Display name for this phase")]
    public string phaseName;

    [Tooltip("Assembly-qualified type name of the IGamePhase implementation")]
    public string phaseTypeName;

    [Tooltip("Duration in seconds, 0 = phase decides when to end")]
    public float maxDuration;
}

骨組み(完全な実装):

using System;
using System.Collections.Generic;
using System.Threading;
using UnityEngine;

/// <summary>
/// Drives the core game loop through a sequence of phases.
/// Phases are defined by <see cref="GamePhaseConfig"/> assets and cycle in order.
/// </summary>
public class GameLoopController : MonoBehaviour
{
    [SerializeField] private List<GamePhaseConfig> _phaseConfigs;

    private readonly List<IGamePhase> _phases = new();
    private int _currentIndex = -1;
    private IGamePhase _currentPhase;
    private bool _transitioning;

    /// <summary>Fires when a new phase begins. Arg: phase index.</summary>
    public event Action<int> OnPhaseStarted;

    /// <summary>Fires when a phase ends. Arg: phase index.</summary>
    public event Action<int> OnPhaseEnded;

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
    static void ResetStatics() { }

    private async void Start()
    {
        foreach (var config in _phaseConfigs)
        {
            var type = Type.GetType(config.phaseTypeName);
            if (type == null)
            {
                Debug.LogError($"Phase type not found: {config.phaseTypeName}");
                continue;
            }
            _phases.Add((IGamePhase)Activator.CreateInstance(type));
        }

        if (_phases.Count > 0)
            await TransitionToPhaseAsync(0);
    }

    private void Update()
    {
        if (_currentPhase != null && !_transitioning)
            _currentPhase.Tick(Time.deltaTime);
    }

    /// <summary>Advance to the next phase. Wraps to index 0 after the last phase.</summary>
    public async Awaitable AdvancePhaseAsync()
    {
        int next = (_currentIndex + 1) % _phases.Count;
        await TransitionToPhaseAsync(next);
    }

    /// <summary>Jump to a specific phase by index.</summary>
    public async Awaitable TransitionToPhaseAsync(int index)
    {
        if (_transitioning) return;
        _transitioning = true;

        var ct = destroyCancellationToken;

        if (_currentPhase != null)
        {
            await _currentPhase.ExitAsync(ct);
            OnPhaseEnded?.Invoke(_currentIndex);
        }

        _currentIndex = index;
        _currentPhase = _phases[_currentIndex];

        await _currentPhase.EnterAsync(ct);
        OnPhaseStarted?.Invoke(_currentIndex);

        _transitioning = false;
    }
}

設計上の利点:新しいフェーズを追加するには、新しい IGamePhase クラスと GamePhaseConfig SO アセットを作成するだけで済み、GameLoopController を変更する必要はありません。デザイナーは Inspector で SO のリストを並べ替えたり、フェーズを追加したりできます。

注意点:フェーズ遷移は非同期で行う必要があります(アセットの読み込み、画面のフェード、システムの有効化/無効化など)。即時に切り替えると、フレームスパイクによるカクつきや、遷移中にシステムが有効状態を確認することで発生する競合状態の原因になります。必ず Awaitable を使い、同期呼び出しは使わないでください。


パターン:セッションのライフサイクル

設計意図:ローグライクのラン、マルチプレイヤーのマッチ、パズルのレベルなど、プレイセッションの明確な開始と終了の境界を設けます。シーン全体を再読み込みせずに、プレイヤーが再スタートできるようにします。

誤り(Claude のデフォルト):

// Gameplay starts in Awake/Start with no session concept.
// "Restarting" means reloading the entire scene, losing any cross-session state.
// Score, timer, and run-specific data are scattered across multiple MonoBehaviours.
public class GameManager : MonoBehaviour
{
    public int score;
    public float timer;

    private void Start()
    {
        score = 0;
        timer = 0;
    }

    public void Restart()
    {
        SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex); // nuclear option
    }
}

正しい例:

// SessionManager owns all session-scoped state. Explicit StartSession/EndSession
// with events. Systems subscribe to session events to init/cleanup. Restart
// without scene reload by ending then starting a new session.
using System;
using System.Threading;
using UnityEngine;

/// <summary>
/// Manages the lifecycle of a single play session (run, match, level attempt).
/// Separates session state from persistent meta progression.
/// </summary>
public class SessionManager : MonoBehaviour
{
    public static SessionManager Instance { get; private set; }

    [SerializeField] private SessionConfig _defaultConfig;

    /// <summary>Current session data. Null when no session is active.</summary>
    public SessionData CurrentSession { get; private set; }

    /// <summary>True while a session is in progress.</summary>
    public bool IsSessionActive => CurrentSession != null;

    public event Action<SessionConfig> OnSessionStarted;
    public event Action<SessionResult> OnSessionEnded;

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

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

    /// <summary>Begin a new session with the given configuration.</summary>
    public void StartSession(SessionConfig config = null)
    {
        config ??= _defaultConfig;
        CurrentSession = new SessionData(config);
        OnSessionStarted?.Invoke(config);
    }

    /// <summary>End the current session and publish the result.</summary>
    public SessionResult EndSession(bool victory)
    {
        if (CurrentSession == null) return default;

        var result = new SessionResult
        {
            Config = CurrentSession.Config,
            Victory = victory,
            Score = CurrentSession.Score,
            ElapsedTime = CurrentSession.ElapsedTime,
            EndTime = DateTime.UtcNow
        };

        OnSessionEnded?.Invoke(result);
        CurrentSession = null;
        return result;
    }

    /// <summary>Restart the session without reloading the scene.</summary>
    public void RestartSession()
    {
        var config = CurrentSession?.Config ?? _defaultConfig;
        EndSession(false);
        StartSession(config);
    }
}

骨組み(完全な実装):SessionConfig、SessionData、SessionResult、および完全な統合例については、references/game-loop-scaffolds.md を参照してください。

設計上の利点:SessionConfig SO で開始条件(初期ライフ、制限時間、開始時のインベントリ)を定義します。ゲームプレイシステムは OnSessionStarted/OnSessionEnded を購読することで、マネージャーに依存せず、セッションごとの状態を初期化したり破棄したりできます。

注意点:セッション状態(スコア、タイマー、ラン中のインベントリ)は、永続状態(メタ進行、アンロック、通貨)と厳密に分離する必要があります。両者を混在させると、「再スタートしてもリセットされない」不具合が発生し、メタ値が消去されたり、セッションの値が次のランに引き継がれたりします。


パターン:勝敗条件のアーキテクチャ

設計意図:デザイナーが、各レベルで何を勝利または敗北とするか(「敵を全滅させる」「3 分間生き延びる」「出口に到達する」など)を、変更のたびにプログラマーの手を借りずに素早く調整できるようにします。

誤り(Claude のデフォルト):

// Hardcoded compound condition buried in a manager.
// Adding a new condition means editing this class. Cannot vary per level.
public class LevelManager : MonoBehaviour
{
    private void Update()
    {
        if (enemies.Count == 0 && objectiveCaptured && timer > 30f)
        {
            WinGame(); // hardcoded, untestable, not reusable
        }
        if (playerHealth <= 0)
        {
            LoseGame();
        }
    }
}

正しい例:

// IWinCondition/ILoseCondition interfaces evaluated by a ConditionEvaluator.
// Conditions are SO assets dragged onto the level's evaluator in the Inspector.
// Composable AND/OR groups. Event-driven evaluation, not per-frame polling.
using UnityEngine;

/// <summary>
/// A single win condition that can be evaluated at any time.
/// Implement as a ScriptableObject for Inspector assignment.
/// </summary>
public abstract class WinConditionBase : ScriptableObject
{
    /// <summary>Human-readable description for the Inspector.</summary>
    public abstract string Description { get; }

    /// <summary>Initialize this condition for a new session/level.</summary>
    public abstract void Initialize();

    /// <summary>Returns true when the condition is satisfied.</summary>
    public abstract bool IsMet();

    /// <summary>Cleanup when the session/level ends.</summary>
    public abstract void Teardown();
}

/// <summary>
/// A single lose condition.
/// </summary>
public abstract class LoseConditionBase : ScriptableObject
{
    /// <summary>Human-readable description for the Inspector.</summary>
    public abstract string Description { get; }

    public abstract void Initialize();
    public abstract bool IsMet();
    public abstract void Teardown();
}

骨組み(完全な実装):

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

/// <summary>
/// Evaluates a set of win and lose conditions for the current level.
/// Supports AND (all must be true) and OR (any must be true) composition.
/// </summary>
public class ConditionEvaluator : MonoBehaviour
{
    public enum CompositionMode { AllRequired, AnyRequired }

    [SerializeField] private CompositionMode _winMode = CompositionMode.AllRequired;
    [SerializeField] private List<WinConditionBase> _winConditions;
    [SerializeField] private CompositionMode _loseMode = CompositionMode.AnyRequired;
    [SerializeField] private List<LoseConditionBase> _loseConditions;

    private bool _resolved;

    /// <summary>Fires when win conditions are satisfied.</summary>
    public event Action OnWin;

    /// <summary>Fires when lose conditions are satisfied.</summary>
    public event Action OnLose;

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
    static void ResetStatics() { }

    private void OnEnable()
    {
        _resolved = false;
        foreach (var c in _winConditions) c.Initialize();
        foreach (var c in _loseConditions) c.Initialize();
    }

    private void OnDisable()
    {
        foreach (var c in _winConditions) c.Teardown();
        foreach (var c in _loseConditions) c.Teardown();
    }

    /// <summary>
    /// Call this from gameplay events (enemy killed, timer tick, objective captured)
    /// instead of polling every frame.
    /// </summary>
    public void Evaluate()
    {
        if (_resolved) return;

        if (CheckLose())
        {
            _resolved = true;
            OnLose?.Invoke();
            return;
        }

        if (CheckWin())
        {
            _resolved = true;
            OnWin?.Invoke();
        }
    }

    private bool CheckWin()
    {
        if (_winConditions.Count == 0) return false;
        return _winMode == CompositionMode.AllRequired
            ? _winConditions.TrueForAll(c => c.IsMet())
            : _winConditions.Exists(c => c.IsMet());
    }

    private bool CheckLose()
    {
        if (_loseConditions.Count == 0) return false;
        return _loseMode == CompositionMode.AnyRequired
            ? _loseConditions.Exists(c => c.IsMet())
            : _loseConditions.TrueForAll(c => c.IsMet());
    }
}

設計上の利点:デザイナーは Inspector で条件 SO をレベルの ConditionEvaluator にドラッグします。プログラマーは WinConditionBase または LoseConditionBase を継承して、新しい条件の種類を追加できます。評価コードを変更する必要はありません。

注意点:条件は途中参加(マルチプレイヤー)や、レベル進行中のルール変更に対応する必要があります。単一の時点でチェックするのではなく、ゲームプレイイベント(敵の撃破、目標の占領など)に応じて評価してください。ゲームプレイイベントを購読し、ConditionEvaluator.Evaluate() を呼び出します。


パターン:メタループのフック

設計意図:セッションの合間に、通貨、アンロック、アップグレード、アカウント XP などの永続的な進行要素が蓄積されます。このメタレイヤーが個々のプレイに意味を与え、長期的な継続プレイを促します。

誤り(Claude のデフォルト):

// Meta progression mixed directly into session code.
// XP increments happen inside the combat script. Unlock checks are in the UI.
// Restarting a session wipes currency. Nothing persists.
public class EnemyHealth : MonoBehaviour
{
    public static int totalXP; // static, no reset safety, no persistence

    public void TakeDamage(int dmg)
    {
        hp -= dmg;
        if (hp <= 0)
        {
            totalXP += 50; // meta progression buried in combat code
            Destroy(gameObject);
        }
    }
}

正しい例:

// MetaProgressionService is a plain C# class registered via Service Locator.
// It persists between sessions. It listens for SessionResult events and
// dispatches IMetaReward SO assets. Save/load is delegated to unity-save-system.
using System;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// A reward granted after a session ends. Implement as ScriptableObject.
/// </summary>
public abstract class MetaRewardBase : ScriptableObject
{
    /// <summary>Human-readable description.</summary>
    public abstract string Description { get; }

    /// <summary>Apply this reward to the meta progression state.</summary>
    public abstract void Apply(MetaProgressionState state);
}

/// <summary>
/// Persistent player progression state that survives between sessions.
/// </summary>
[Serializable]
public class MetaProgressionState
{
    public int Currency;
    public int AccountXP;
    public int AccountLevel;
    public List<string> UnlockedItemIds = new();
}

/// <summary>
/// Service that manages meta progression. Registered via Service Locator,
/// not a MonoBehaviour -- survives scene transitions naturally.
/// </summary>
public class MetaProgressionService
{
    private MetaProgressionState _state;

    /// <summary>Fires after rewards are applied.</summary>
    public event Action<MetaProgressionState> OnProgressionUpdated;

    public MetaProgressionState State => _state;

    public MetaProgressionService(MetaProgressionState loadedState = null)
    {
        _state = loadedState ?? new MetaProgressionState();
    }

    /// <summary>Process end-of-session rewards.</summary>
    public void ProcessSessionResult(SessionResult result, List<MetaRewardBase> rewards)
    {
        foreach (var reward in rewards)
        {
            reward.Apply(_state);
        }
        OnProgressionUpdated?.Invoke(_state);
    }

    /// <summary>Check whether an item is unlocked.</summary>
    public bool IsUnlocked(string itemId) => _state.UnlockedItemIds.Contains(itemId);
}

スキャフォールド(完全な実装):CurrencyReward、XPReward の例と SessionManager との統合については、references/game-loop-scaffolds.md を参照してください。

設計フック:新しい報酬タイプを追加するには、新しい MetaRewardBase サブクラスの SO を作成するだけです。デザイナーは、報酬 SO をレベル/セッションの設定に割り当てることで、セッションの結果ごとに付与する報酬を設定できます。サービスコードを変更する必要はありません。

注意点:メタ状態はシーン遷移後も保持されなければなりません。シーンに依存する MonoBehaviour ではなく、Service Locator(unity-game-architecture のもの)に登録する通常の C# クラスを使用してください。MonoBehaviour を使用する場合は、DontDestroyOnLoad を使用し、SubsystemRegistration によるドメインリロードへの対応が必要です。


パターン:調整可能な難易度システム

設計意図:デザイナーはコードに触れずに、レベルごと、またはプレイヤーのパフォーマンスに応じて動的に、敵の数、出現率、ダメージ倍率、リソースの希少度を調整する必要があります。

誤り(Claude のデフォルト):

// Magic numbers scattered across multiple scripts.
// Changing difficulty means finding and editing every script.
// No way for designers to tune without a programmer.
public class EnemySpawner : MonoBehaviour
{
    private float spawnRate = 2f;        // magic number
    private int maxEnemies = 10;         // magic number
    private float enemyHealthMult = 1f;  // magic number

    private void Update()
    {
        // hardcoded scaling that nobody can tune
        if (Time.timeSinceLevelLoad > 60f)
            spawnRate = 1f;
        if (Time.timeSinceLevelLoad > 120f)
            spawnRate = 0.5f;
    }
}

正しい例:

// DifficultyConfig SO with AnimationCurve fields for visual tuning.
// DifficultyProvider service that systems query for current values.
// Supports static (per-level SO) and dynamic (adaptive) modes.
using System;
using UnityEngine;

/// <summary>
/// Defines difficulty parameters as curves over normalized progression (0-1).
/// 0 = level start (or lowest skill), 1 = level end (or highest skill).
/// </summary>
[CreateAssetMenu(menuName = "Game Loop/Difficulty Config")]
public class DifficultyConfig : ScriptableObject
{
    [Header("Enemy Scaling")]
    [Tooltip("Max concurrent enemies over normalized time (0=start, 1=end)")]
    public AnimationCurve maxEnemiesCurve = AnimationCurve.Linear(0, 5, 1, 20);

    [Tooltip("Seconds between spawns over normalized time")]
    public AnimationCurve spawnIntervalCurve = AnimationCurve.Linear(0, 3f, 1, 0.5f);

    [Header("Damage Scaling")]
    [Tooltip("Enemy damage multiplier over normalized time")]
    public AnimationCurve enemyDamageMultCurve = AnimationCurve.Linear(0, 1f, 1, 2.5f);

    [Tooltip("Player damage multiplier over normalized time")]
    public AnimationCurve playerDamageMultCurve = AnimationCurve.Constant(0, 1, 1f);

    [Header("Resource Scaling")]
    [Tooltip("Resource drop rate multiplier over normalized time")]
    public AnimationCurve resourceDropRateCurve = AnimationCurve.Linear(0, 1.5f, 1, 0.5f);
}

/// <summary>
/// Provides current difficulty values by evaluating curves at the current progression point.
/// Register via Service Locator. Systems query this instead of using magic numbers.
/// </summary>
public class DifficultyProvider
{
    private DifficultyConfig _config;
    private float _normalizedProgress;

    public DifficultyProvider(DifficultyConfig config)
    {
        _config = config ?? throw new ArgumentNullException(nameof(config));
    }

    /// <summary>Set the current progression point (0-1).</summary>
    public void SetProgress(float normalized)
    {
        _normalizedProgress = Mathf.Clamp01(normalized);
    }

    /// <summary>Current max enemies allowed.</summary>
    public int MaxEnemies => Mathf.RoundToInt(_config.maxEnemiesCurve.Evaluate(_normalizedProgress));

    /// <summary>Current spawn interval in seconds.</summary>
    public float SpawnInterval => _config.spawnIntervalCurve.Evaluate(_normalizedProgress);

    /// <summary>Current enemy damage multiplier.</summary>
    public float EnemyDamageMult => _config.enemyDamageMultCurve.Evaluate(_normalizedProgress);

    /// <summary>Current player damage multiplier.</summary>
    public float PlayerDamageMult => _config.playerDamageMultCurve.Evaluate(_normalizedProgress);

    /// <summary>Current resource drop rate multiplier.</summary>
    public float ResourceDropRate => _config.resourceDropRateCurve.Evaluate(_normalizedProgress);

    /// <summary>Swap the active config (e.g., for a new level or difficulty setting).</summary>
    public void SetConfig(DifficultyConfig config) => _config = config;
}

スキャフォールド(完全な実装):スポーナーや戦闘システムとの完全な統合については、references/game-loop-scaffolds.md を参照してください。

設計フック:AnimationCurve フィールドを使うと、デザイナーはインスペクターで難易度の上昇カーブを視覚的に調整できます。新しい難易度パラメーターを追加するには、SO に新しいカーブフィールドを追加し、プロバイダーに対応するプロパティを追加するだけです。利用側のコードを変更する必要はありません。

注意点:AnimationCurve.Evaluate() は正規化された時間(0~1)を使用します。0 と 1 が何を表すのか、レベルの開始/終了、プレイヤーの死亡回数の範囲、セッション経過時間の比率など、明確に文書化する必要があります。ここが曖昧だと、意図したとおりに感じられず、デバッグもできない難易度カーブになってしまいます。


パターン:ペーシングとエンカウントのタイミング

設計意図:ゲームにはリズムがあります。緊張と緩和のサイクル、激しさの高まり、エンカウント間の休息時間です。一定間隔やランダムに敵を出現させると、単調で変化のないゲームプレイになります。

誤り(Claude のデフォルト):

// Fixed-interval spawning with no concept of intensity or pacing.
// Every 5 seconds, an enemy appears. No tension curve, no rest periods,
// no escalation. The experience is flat from start to finish.
public class EnemySpawner : MonoBehaviour
{
    [SerializeField] private float _spawnInterval = 5f;
    private float _timer;

    private void Update()
    {
        _timer += Time.deltaTime;
        if (_timer >= _spawnInterval)
        {
            SpawnEnemy();
            _timer = 0;
        }
    }
}

正しい例:

// PacingDirector tracks a designer-authored intensity curve over session time.
// Triggers encounter events when intensity thresholds are crossed.
// Spawners and encounter systems subscribe to intensity events.
using System;
using UnityEngine;

/// <summary>
/// Defines the target intensity curve for a session.
/// X axis = normalized session time (0-1), Y axis = intensity (0-1).
/// </summary>
[CreateAssetMenu(menuName = "Game Loop/Pacing Curve Config")]
public class PacingCurveConfig : ScriptableObject
{
    [Tooltip("Target intensity over normalized session time. Y: 0=calm, 1=max intensity.")]
    public AnimationCurve intensityCurve = new(
        new Keyframe(0f, 0.1f),
        new Keyframe(0.2f, 0.5f),
        new Keyframe(0.35f, 0.2f),  // rest period
        new Keyframe(0.6f, 0.7f),
        new Keyframe(0.75f, 0.3f),  // rest period
        new Keyframe(0.9f, 0.9f),
        new Keyframe(1f, 1f)        // climax
    );

    [Tooltip("Total session duration in seconds for normalizing time")]
    public float sessionDurationSeconds = 600f;

    [Tooltip("Intensity thresholds that trigger encounter events")]
    public float[] intensityThresholds = { 0.25f, 0.5f, 0.75f, 0.9f };
}

/// <summary>
/// Reads the pacing curve and fires intensity events that encounter systems consume.
/// Uses unscaled time so pausing does not break the curve.
/// </summary>
public class PacingDirector : MonoBehaviour
{
    [SerializeField] private PacingCurveConfig _config;

    private float _sessionStartTime;
    private float _currentIntensity;
    private readonly bool[] _thresholdsFired = new bool[16];

    /// <summary>Current intensity value (0-1).</summary>
    public float CurrentIntensity => _currentIntensity;

    /// <summary>Fires every frame with the current intensity value.</summary>
    public event Action<float> OnIntensityChanged;

    /// <summary>Fires when an intensity threshold is crossed upward. Arg: threshold value.</summary>
    public event Action<float> OnThresholdCrossed;

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
    static void ResetStatics() { }

    /// <summary>Begin tracking pacing for a new session.</summary>
    public void BeginSession()
    {
        _sessionStartTime = Time.realtimeSinceStartup;
        _currentIntensity = 0f;
        Array.Clear(_thresholdsFired, 0, _thresholdsFired.Length);
    }

    private void Update()
    {
        if (_config == null) return;

        float elapsed = Time.realtimeSinceStartup - _sessionStartTime;
        float normalized = Mathf.Clamp01(elapsed / _config.sessionDurationSeconds);

        _currentIntensity = _config.intensityCurve.Evaluate(normalized);
        OnIntensityChanged?.Invoke(_currentIntensity);

        for (int i = 0; i < _config.intensityThresholds.Length && i < _thresholdsFired.Length; i++)
        {
            if (!_thresholdsFired[i] && _currentIntensity >= _config.intensityThresholds[i])
            {
                _thresholdsFired[i] = true;
                OnThresholdCrossed?.Invoke(_config.intensityThresholds[i]);
            }
        }
    }
}

スキャフォールド(完全な実装):クールダウンのリセットやエンカウントスポーナーとの統合を含む完全な PacingDirector については、references/game-loop-scaffolds.md を参照してください。

設計フック:デザイナーはインスペクターでペーシングカーブを視覚的に編集し、緊張と緩和のサイクルを形作れます。エンカウントスポーナーは OnThresholdCrossed を購読し、デザイナーが定義した激しさのしきい値に応じて、ウェーブ、ボスの出現、または環境イベントを発生させます。

注意点:ゲームが一時停止する場合、ペーシングには Time.realtimeSinceStartup またはスケールされない時間を使用する必要があります。Time.time や Time.deltaTime を使うと、一時停止メニュー中にペーシングカーブが停止し、再開時に蓄積した激しさが一度に反映されます。その結果、デザイナーが慎重に作り込んだリズムが崩れてしまいます。


アンチパターン早見表

アンチパターン症状修正方法
フェーズの概念がないすべてのシステムが同時に動作し、進行の構造がないIGamePhase を使った GameLoopController
リスタート時にシーンを再読み込みするリトライのたびにロード画面が表示され、メタ状態が失われる開始/終了を明示する SessionManager
勝利条件のハードコードレベルのバリエーションごとにプログラマーが必要SO ベースの条件を使った ConditionEvaluator
セッションコードにメタ状態を置くリスタートで通貨が消える、または消えるべきでない通貨が残るセッションから分離した MetaProgressionService
難易度にマジックナンバーを使う調整のたびにコードの変更と再コンパイルが必要AnimationCurve フィールドを持つ DifficultyConfig SO
一定間隔で敵を出現させる緊張と緩和がなく、ペーシングが単調になるデザイナーが作成した激しさのカーブを使う PacingDirector

関連スキル

  • unity-save-system -- アプリケーションの起動間でメタ進行状態を保持します
  • unity-npc-behavior -- ペーシングディレクターの強度レベルに反応する敵 AI
  • unity-state-machines -- セッションとフェーズの管理に使用される基盤となる FSM パターン
  • unity-game-architecture -- 全体で使用されるサービスロケーター、イベントチャネル、ブートストラップシーケンス
  • unity-data-driven -- フェーズ、難易度、ペーシングに使用される ScriptableObject の設定パターン

追加リソース

コメント