Article

Unity WebGL 小游戏实战 02:首个可玩关卡循环(目标/失败/结算闭环)

路线阶段:Unity WebGL 小游戏实战第 2 章。
本章目标:搭建“可玩且可反复验证”的最小关卡循环,为后续内容扩展奠定模板。

学习目标

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

  1. 定义关卡目标、失败条件与胜利判定规则。
  2. 将倒计时、波次、玩家生存状态统一到关卡控制器。
  3. 打通战斗中 UI 提示与结算面板流程。
  4. 支持一键重开与下一关切换,形成持续游玩闭环。

最小可玩闭环定义

一个合格的首关应包含:

  1. 开始:展示关卡目标(例如“120 秒内存活并清理 3 波”)。
  2. 进行:实时显示时间、波次、剩余生命。
  3. 失败:玩家死亡或超时。
  4. 成功:达成目标后进入结算。
  5. 重开/继续:玩家可立刻再来一局。

关卡目标模型

public enum StageObjectiveType
{
    SurviveTime = 0,
    ClearWaves = 1,
    KillBoss = 2
}

[Serializable]
public sealed class StageObjective
{
    public StageObjectiveType Type;
    public int TargetValue;
}

[Serializable]
public sealed class StageRuleConfig
{
    public int StageId;
    public float TimeLimitSeconds;
    public int MaxWaves;

    public List<StageObjective> Objectives;
}

运行时状态

public sealed class StageRuntimeState
{
    public float RemainTime;
    public int CurrentWave;
    public int ClearedWave;
    public bool PlayerDead;

    public bool IsCompleted;
    public bool IsFailed;
}

关卡控制器

public sealed class StageLoopController : IUpdatable
{
    public int Order { get { return 40; } }

    private readonly StageRuleConfig _config;
    private readonly StageRuntimeState _state;
    private readonly WaveDirector _wave;
    private readonly EventBus _eventBus;

    public StageLoopController(StageRuleConfig config, StageRuntimeState state, WaveDirector wave, EventBus eventBus)
    {
        _config = config;
        _state = state;
        _wave = wave;
        _eventBus = eventBus;
    }

    public void Start()
    {
        _state.RemainTime = _config.TimeLimitSeconds;
        _state.CurrentWave = 1;
        _state.ClearedWave = 0;
        _state.PlayerDead = false;
        _state.IsCompleted = false;
        _state.IsFailed = false;

        _wave.StartBattle();

        _eventBus.Publish("StageStarted", _config.StageId);
        _eventBus.Publish("StageTimerChanged", _state.RemainTime);
    }

    public void Tick(float dt, float unscaledDt)
    {
        if (_state.IsCompleted || _state.IsFailed)
        {
            return;
        }

        _state.RemainTime -= dt;
        if (_state.RemainTime < 0f)
        {
            _state.RemainTime = 0f;
        }

        _eventBus.Publish("StageTimerChanged", _state.RemainTime);

        if (_state.PlayerDead)
        {
            Fail("player_dead");
            return;
        }

        if (_state.RemainTime <= 0f)
        {
            Fail("timeout");
            return;
        }

        if (CheckObjectives())
        {
            Complete();
        }
    }

    public void OnWaveCleared(int waveId)
    {
        _state.ClearedWave = waveId;
        _eventBus.Publish("StageWaveCleared", waveId);
    }

    public void OnPlayerDead()
    {
        _state.PlayerDead = true;
    }

    private bool CheckObjectives()
    {
        for (var i = 0; i < _config.Objectives.Count; i++)
        {
            var obj = _config.Objectives[i];
            if (obj.Type == StageObjectiveType.ClearWaves)
            {
                if (_state.ClearedWave < obj.TargetValue)
                {
                    return false;
                }
            }
            else if (obj.Type == StageObjectiveType.SurviveTime)
            {
                var survived = _config.TimeLimitSeconds - _state.RemainTime;
                if (survived < obj.TargetValue)
                {
                    return false;
                }
            }
        }

        return true;
    }

    private void Complete()
    {
        _state.IsCompleted = true;
        _eventBus.Publish("StageCompleted", _config.StageId);
        FoundationLog.Info("Stage", "complete id=" + _config.StageId);
    }

    private void Fail(string reason)
    {
        _state.IsFailed = true;
        _eventBus.Publish("StageFailed", reason);
        FoundationLog.Warn("Stage", "failed reason=" + reason);
    }
}

UI 同步

HUD

显示:

  1. 剩余时间(mm:ss
  2. 当前波次 / 目标波次
  3. 目标文本(例如“清理 3 波敌人”)

Result

成功显示:

  1. 通关时间
  2. 剩余生命
  3. 击杀数

失败显示:

  1. 失败原因(超时/死亡)
  2. 重开按钮

事件接线

public sealed class StageEventBinder : IDisposable
{
    private readonly EventBus _eventBus;
    private readonly StageLoopController _loop;

    public StageEventBinder(EventBus eventBus, StageLoopController loop)
    {
        _eventBus = eventBus;
        _loop = loop;

        _eventBus.Subscribe("WaveCleared", OnWaveCleared);
        _eventBus.Subscribe("PlayerDead", OnPlayerDead);
    }

    public void Dispose()
    {
        _eventBus.Unsubscribe("WaveCleared", OnWaveCleared);
        _eventBus.Unsubscribe("PlayerDead", OnPlayerDead);
    }

    private void OnWaveCleared(object payload)
    {
        _loop.OnWaveCleared((int)payload);
    }

    private void OnPlayerDead(object payload)
    {
        _loop.OnPlayerDead();
    }
}

重开与继续

在 Result 状态面板触发:

  1. Retry -> FlowRequest(Loading) 并复位关卡状态。
  2. Next -> 更新 StageId 后进入下一关加载。

要求:两条路径都通过流程状态机,不直接在 UI 脚本里切场景。

与前面系统联动

  1. 波次系统:驱动 ClearWaves 目标。
  2. 存档系统:通关后写入关卡记录。
  3. 回放系统:记录失败原因与完成帧。
  4. 音频系统:成功/失败切换不同 BGM 层。

WebGL 注意点

  1. 计时与暂停要处理浏览器失焦。
  2. 结算界面资源提前预热,避免首次打开卡顿。
  3. 重开流程避免完整刷新页面,保持体验连贯。

验收清单

  1. 关卡可稳定完成“开始->进行->结算->重开”循环。
  2. 成功与失败判定无冲突。
  3. 计时显示与逻辑时间一致。
  4. 重开 20 次无状态残留。

常见坑

坑 1:目标判定直接依赖 UI 文本状态

逻辑应只依赖运行时状态,不依赖展示层。

坑 2:失败后战斗还在跑

进入 Result 前必须关停战斗输入与 AI Tick。

坑 3:重开只重置玩家不重置波次

会出现波次错位。重开必须完整重置关卡状态。

本月作业

做一个“2分钟生存关卡”:

  1. 目标:120 秒内清理 3 波敌人。
  2. 失败:玩家死亡或超时。
  3. 结算:显示评分(时间、伤害、受击)。

下一章进入 Unity WebGL 小游戏实战 03:经济与升级系统(局内成长、局外解锁)。