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(事件、服务定位器、引导流程)、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,绝不要使用同步调用。


模式:会话生命周期

设计意图:为游戏会话设定明确的开始/结束边界——例如 Roguelike 一轮、多人比赛或谜题关卡。玩家无需完整重新加载场景即可重新开始。

错误做法(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);
    }
}

脚手架(完整实现):请参阅 references/game-loop-scaffolds.md,了解 SessionConfig、SessionData、SessionResult 和完整的集成示例。

设计接口: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 分配给关卡/会话配置,来设置每种会话结果对应的奖励。无需更改服务代码。

注意事项:元状态必须在场景切换后保留。应使用在 Service Locator(来自 unity-game-architecture)中注册的普通 C# 类,而不是绑定场景的 MonoBehaviour。如果使用 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 字段让设计师可以在 Inspector 中直观地调整难度曲线。新增难度参数只需在 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。

设计钩子:设计师在 Inspector 中直观地编辑节奏曲线,塑造紧张/缓和循环。遭遇生成器订阅 OnThresholdCrossed,以便在设计师定义的强度阈值处触发敌潮、Boss 生成或环境事件。

注意事项:如果游戏会暂停,节奏系统必须使用 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 配置模式

其他资源

评论

支持 Markdown