Article

Unity WebGL 小游戏实战 04:难度曲线与导演系统(节奏控制与随机约束)

路线阶段:Unity WebGL 小游戏实战第 4 章。
本章目标:让关卡节奏从“固定脚本”升级为“可控动态系统”,兼顾挑战性与公平性。

学习目标

完成本章后,你应该能做到:

  1. 设计可量化难度曲线(时间轴 + 强度目标)。
  2. 实现导演系统动态调节波次、掉落和精英出现率。
  3. 通过随机约束避免连续极端事件。
  4. 将难度调节结果记录进回放用于平衡分析。

为什么需要导演系统

固定波次脚本常见问题:

  1. 新手前期崩盘,高手后期无聊。
  2. 同一关卡重复游玩缺乏变化。
  3. 调整难度只能改大量配置,效率低。

导演系统思路:先定义目标难度,再用规则实时逼近目标。

难度曲线模型

[Serializable]
public sealed class DifficultyCurvePoint
{
    public float TimeSeconds;
    public float Intensity; // 0~1
}

[Serializable]
public sealed class DifficultyCurveConfig
{
    public List<DifficultyCurvePoint> Points;
}

曲线采样

public static class DifficultyCurveSampler
{
    public static float Evaluate(DifficultyCurveConfig cfg, float t)
    {
        if (cfg == null || cfg.Points == null || cfg.Points.Count == 0) return 0f;
        if (cfg.Points.Count == 1) return cfg.Points[0].Intensity;

        for (var i = 1; i < cfg.Points.Count; i++)
        {
            var a = cfg.Points[i - 1];
            var b = cfg.Points[i];
            if (t <= b.TimeSeconds)
            {
                var d = Mathf.Max(0.0001f, b.TimeSeconds - a.TimeSeconds);
                var k = Mathf.Clamp01((t - a.TimeSeconds) / d);
                return Mathf.Lerp(a.Intensity, b.Intensity, k);
            }
        }

        return cfg.Points[cfg.Points.Count - 1].Intensity;
    }
}

玩家表现指标

导演系统不只看时间,还看玩家状态:

  1. 最近 30 秒受击次数
  2. 最近 30 秒击杀效率
  3. 当前 HP 比例
  4. 当前局内升级层数
public sealed class PlayerPerformanceSnapshot
{
    public float HpRate;
    public float KillsPerMinute;
    public float DamageTakenPerMinute;
    public int RunUpgradeCount;
}

导演决策输出

public sealed class DirectorDecision
{
    public float SpawnRateScale;      // 刷怪频率缩放
    public float EliteChance;         // 精英概率
    public float RewardScale;         // 资源奖励缩放
    public int MaxAliveEnemy;         // 同屏上限
}

导演系统实现

public sealed class StageDirectorSystem : IUpdatable
{
    public int Order { get { return 220; } }

    private readonly DifficultyCurveConfig _curve;
    private readonly StageRuntimeState _stage;
    private readonly PerformanceTracker _perf;
    private readonly EventBus _eventBus;

    private float _timer;

    public DirectorDecision CurrentDecision { get; private set; }

    public StageDirectorSystem(
        DifficultyCurveConfig curve,
        StageRuntimeState stage,
        PerformanceTracker perf,
        EventBus eventBus)
    {
        _curve = curve;
        _stage = stage;
        _perf = perf;
        _eventBus = eventBus;

        CurrentDecision = new DirectorDecision
        {
            SpawnRateScale = 1f,
            EliteChance = 0.1f,
            RewardScale = 1f,
            MaxAliveEnemy = 24
        };
    }

    public void Tick(float dt, float unscaledDt)
    {
        _timer += dt;
        if (_timer < 1.0f)
        {
            return;
        }

        _timer = 0f;

        var elapsed = _stage.ElapsedSeconds;
        var targetIntensity = DifficultyCurveSampler.Evaluate(_curve, elapsed);
        var perf = _perf.BuildSnapshot();

        CurrentDecision = Resolve(targetIntensity, perf);
        _eventBus.Publish("DirectorDecisionUpdated", CurrentDecision);
    }

    private static DirectorDecision Resolve(float targetIntensity, PlayerPerformanceSnapshot perf)
    {
        var pressure = targetIntensity;

        if (perf.HpRate < 0.35f)
        {
            pressure -= 0.18f;
        }

        if (perf.DamageTakenPerMinute > 260f)
        {
            pressure -= 0.12f;
        }

        if (perf.KillsPerMinute > 90f)
        {
            pressure += 0.10f;
        }

        pressure = Mathf.Clamp01(pressure);

        var d = new DirectorDecision();
        d.SpawnRateScale = Mathf.Lerp(0.75f, 1.45f, pressure);
        d.EliteChance = Mathf.Lerp(0.05f, 0.35f, pressure);
        d.RewardScale = Mathf.Lerp(1.1f, 0.85f, pressure);
        d.MaxAliveEnemy = Mathf.RoundToInt(Mathf.Lerp(16f, 42f, pressure));
        return d;
    }
}

随机约束(防极端)

1. 精英生成冷却

public sealed class RandomGuard
{
    private int _lastEliteTick;

    public bool CanSpawnElite(int currentTick)
    {
        return currentTick - _lastEliteTick >= 240; // 4s @60fps
    }

    public void MarkElite(int currentTick)
    {
        _lastEliteTick = currentTick;
    }
}

2. 失败保护

当玩家连续濒死:

  1. 降低 20% 刷怪速率
  2. 限制精英刷新
  3. 增加治疗掉落权重

与刷怪系统接入

public sealed class AdaptiveWaveSpawner
{
    private readonly StageDirectorSystem _director;

    public AdaptiveWaveSpawner(StageDirectorSystem director)
    {
        _director = director;
    }

    public SpawnPlan BuildPlan(BaseSpawnPlan basePlan)
    {
        var d = _director.CurrentDecision;

        var p = new SpawnPlan();
        p.SpawnInterval = basePlan.SpawnInterval / Mathf.Max(0.1f, d.SpawnRateScale);
        p.EliteChance = d.EliteChance;
        p.MaxAlive = d.MaxAliveEnemy;

        return p;
    }
}

与经济系统接入

导演输出 RewardScale 后,掉落奖励乘以系数:

var finalReward = Mathf.CeilToInt(baseReward * decision.RewardScale);

这样难度升高时可适当压缩收益,避免“越难越爆富”。

平衡分析日志

每 5 秒记录:

  1. 目标强度
  2. 实际压力
  3. 导演决策
  4. 玩家表现快照
FoundationLog.Info("Director",
    "t=" + elapsed +
    " target=" + targetIntensity +
    " spawnScale=" + d.SpawnRateScale +
    " elite=" + d.EliteChance +
    " reward=" + d.RewardScale +
    " hp=" + perf.HpRate);

回放一致性

为保证可重现:

  1. 记录导演决策与随机种子。
  2. 回放时使用同决策流而非重新计算。
  3. 对比实时与回放的波次时间点。

WebGL 注意点

  1. 决策计算频率控制在 1Hz~2Hz。
  2. 避免复杂统计遍历,每帧只增量更新指标。
  3. 导演调节不要过猛,避免“体感突变”。

验收清单

  1. 新手场景下难度可自适应下降,避免早期崩盘。
  2. 高水平玩家不会在后期无聊,精英与节奏会提升。
  3. 同一 seed 的回放可还原导演决策序列。
  4. 调整曲线配置即可显著改变关卡体感。

常见坑

坑 1:导演直接改过多系统参数

会造成联动难控。建议只输出少数关键决策变量。

坑 2:用短窗口指标强行调节

会来回抖动。需要平滑和节流。

坑 3:随机完全放开

会出现连续极端情况。必须有冷却与保护规则。

本月作业

做一版“可视化导演调试面板”:

  1. 实时显示目标强度、当前决策、玩家指标。
  2. 支持运行时修改曲线点位并立即生效。
  3. 导出一局完整决策日志用于离线分析。

下一章进入 Unity WebGL 小游戏实战 05:关卡内容管线(数据驱动关卡配置与热更新准备)。