🔬 TeleportController Seq 嵌套回调与循环问题分析

概述

在 TeleportController 的 LoadTransitionLevelAsync() 和 ExitLoadLevelAsync() 中,Seq1→Seq2→Seq3 的切换通过 OnFinished 回调 + LoadRoleLevelSequence 异步加载嵌套实现。这条链路存在两个严重问题:

问题 级别 位置 一句话描述
嵌套回调 P1 LoadTransitionLevelAsync Seq1 的 OnFinished 内异步加载 Seq2,回调层级达到 5 层深
OnFinished 循环重入 P0 LoadTransitionLevelAsync L1570 OnFinished 处理器在异步窗口内未清除,可被重复触发→无限循环
Seq2 缺少 OnFinished 非 Bug LoadTransitionLevelAsync L1579 Seq2 是循环序列(LoopCount=1),不应加 OnFinished — 3s 定时器是正确的 resolve 方式
3s 硬定时器与 Seq 脱节 合理 LoadTransitionLevelAsync L1605 循环序列的结束只能靠定时器控制,这是正确做法

现状代码 BUG

以下是 LoadTransitionLevelAsync() 中 Seq1→Seq2 的完整嵌套结构,已标注问题点:

BUG LoadTransitionLevelAsync — Seq1→Seq2 嵌套回调
// ┌─────────────────────────────────────────────────────────────────┐
// │  OnLevelShown 回调 (depth 0)                                     │
// └─────────────────────────────────────────────────────────────────┘

// 加载 Seq1 资源 (异步)
this.LoadRoleLevelSequence(path1, (levelSequence) => {            // depth 1
    if (levelSequence) {
        this.LevelSequenceActor!.SetSequence(levelSequence);
        this.ApplyCameraToLevelSequence(this.LevelSequenceActor!);
        const actorPlayer = this.LevelSequenceActor!.GetSequencePlayer();

        if (actorPlayer) {
//          ┌──────────────────────────────────────────────────────┐
//          │ ⚠️ Clear 旧回调,但 Add 新回调后没在进入时立即 Clear     │
//          └──────────────────────────────────────────────────────┘
            actorPlayer.OnFinished.Clear();
            actorPlayer.OnFinished.Add(() => {                        // depth 2: Seq1 播完回调 [A]
                // ❌ 没有在这里 Clear 掉自身!
                // 进入异步加载窗口,[A] 仍然挂在 OnFinished 上

                this.LoadRoleLevelSequence(path2, (seq2) => {        // depth 3: 异步加载 Seq2 [B]
                    if (seq2) {
                        this.LevelSequenceActor!.SetSequence(seq2);
                        this.ApplyCameraToLevelSequence(this.LevelSequenceActor!);

                        actorPlayer.OnFinished.Clear();     // [C] 清 [A] — 但在异步回调里!
                        actorPlayer.PlaybackSettings.LoopCount = new UE.MovieSceneSequenceLoopCount(1);

                        // ❌ 没有给 Seq2 补 OnFinished 处理器!

                        this.ApplyAvatarToLevelSequence(this.LevelSequenceActor!, true, () => { // depth 4
                            this.SafePlaySequencePlayer(actorPlayer);     // depth 5: Play Seq2
                        });
                    } else {
                        reject(new Error('传送-加载退出序列失败'));
                    }
                });
            });

            this.ApplyAvatarToLevelSequence(this.LevelSequenceActor!, true, () => {
                this.SafePlaySequencePlayer(actorPlayer);    // Play Seq1
            });
        }
    } else {
        reject(new Error('传送-加载退出序列失败'));
    }
});

// ┌─────────────────────────────────────────────────────────────────┐
// │  ⚠️ 3 秒硬定时器,与 Seq 播放完全无关                            │
// └─────────────────────────────────────────────────────────────────┘
TimerSystem.Delay(() => {
    resolve();  // ← 不管 Seq 播到哪,3 秒后强制 resolve
}, 3000);

嵌套层级统计

层级代码位置说明
depth 0OnLevelShown.Add关卡加载完成回调
depth 1LoadRoleLevelSequence(path1, cb)Seq1 资源异步加载回调
depth 2actorPlayer.OnFinished.AddSeq1 播放完成回调 [A] — 这里是循环重入点
depth 3LoadRoleLevelSequence(path2, cb)Seq2 资源异步加载回调 [B]
depth 4ApplyAvatarToLevelSequence(cb)Seq2 换装回调
depth 5SafePlaySequencePlayer实际 Play Seq2
📐 嵌套问题:5 层回调嵌套,可读性极差。每一层的错误处理(reject)分散在各处,很难统一管理。如果需要在 Seq2 播完后做后续逻辑,还需再加一层回调。

📐 嵌套问题详解 P1

当前 Seq1→Seq2 的切换嵌套在 OnFinished 回调内完成,形成了典型的"回调地狱"(Callback Hell):

结构嵌套层次展开
OnLevelShown.Add(() => {                                    // depth 0
    LoadRoleLevelSequence(path1, (seq1) => {                 // depth 1
        SetSequence(seq1)
        ApplyAvatarToLevelSequence(cb => {                   // depth 2
            Play(seq1)
        })
        OnFinished.Add(() => {                                // depth 2 (并行)
            LoadRoleLevelSequence(path2, (seq2) => {          // depth 3
                SetSequence(seq2)
                ApplyAvatarToLevelSequence(cb => {           // depth 4
                    Play(seq2)                                // depth 5
                })
            })
        })
    })
    TimerSystem.Delay(resolve, 3000)                          // ← 完全脱离回调链
})

// 最多 5 层缩进,每一层都是一个异步回调
// 错误处理散落在各层,无法统一 catch
// resolve 靠 3s 定时器而非回调链驱动
危害:
  • 阅读困难 — 维护者需要逐层追踪回调跳转
  • 错误分散 — 每层各自 reject,无统一错误处理
  • 扩展困难 — 如需在 Seq2 后加逻辑,要再嵌一层
  • 调试困难 — 异步栈断裂,调用栈无法追踪

🔁 循环重入问题详解 P0

这是最严重的问题。OnFinished 处理器 [A] 在进入异步加载窗口后未及时清除自身,可被重复触发,导致循环。

核心代码片段

BUGOnFinished 处理器 [A] 的生命周期
actorPlayer.OnFinished.Clear();         // 清掉旧的
actorPlayer.OnFinished.Add(() => {         // [A] 注册 Seq1 完成处理器
    // ❌ 进入 [A] 时没有 Clear 自身!
    //    → [A] 在整个异步加载窗口内仍然挂在 OnFinished 上

    this.LoadRoleLevelSequence(path2, (seq2) => {  // 异步,可能耗时数百ms
        // ...
        actorPlayer.OnFinished.Clear();  // [C] 这里才清 [A]
        // ...
        Play(seq2)
    });
});

异步窗口分析

危险窗口:从 [A] 被触发 到 [B] 异步回调执行 [C] Clear 之间,[A] 仍然注册在 OnFinished 上。

如果在这个窗口内 OnFinished 再次触发(原因见下表),[A] 会再次执行 → 又发起一次 LoadRoleLevelSequence(path2) → 产生第二个 [B] 回调 → 多次 SetSequence + Play → 循环。

OnFinished 在异步窗口内重复触发的可能原因

触发原因说明
SetSequence 副作用 UE SetSequence 可能重置 Player 状态并触发 OnFinished
LoopCount 残留 Seq1 的 LoopCount 如果非 0,Seq1 可能重播并再次完成
UE 内部重评估 引擎 Tick 中的 Sequence 评估可能在资源切换时触发
连续传送 玩家快速连续传送时,旧 [A] 残留 + 新 OnFinished 叠加

循环展开

BUG循环路径展开
第 1 轮:
  Seq1 播完 → OnFinished 触发 → [A] 执行
    → LoadRoleLevelSequence(path2) 异步发起 [B1]
      ← (异步窗口,[A] 仍在)

  ★ OnFinished 再次触发(SetSequence 副作用 / 重播 / ...)
    → [A] 又执行 → LoadRoleLevelSequence(path2) 异步发起 [B2]
      ← (两个 B 都在加载)

第 2 轮:
  [B1] 回调 → SetSequence(seq2) → Clear → Play(seq2)
  [B2] 回调 → SetSequence(seq2) → Clear → Play(seq2) ← 重复操作

  Seq2 循环播放(LoopCount=1),OnFinished 空触发 ← 无害但无意义

  ★ 如果 [A] 在 [B2] Clear 之前又被触发 → 回到第 1 轮
    → 无限循环

  最终只有 3s 硬定时器能打破循环 → resolve()
  但此时可能有多组 Play/Stop 叠加 → 表现混乱
🔄 [A] 未清除 → 异步窗口内 OnFinished 重触发 → [A] 再执行 → 再发 LoadSeq2 → 循环

⏱️ 时序对比图

❌ 现状(BUG)

Play Seq1SafePlaySequencePlayer
↓
Seq1 播完OnFinished → [A]
↓
进入 [A]❌ 未 Clear 自身
↓
异步加载 Seq2LoadRoleLevelSequence(path2)
↓ ⚠️ 异步窗口
正常路径
[B] 回调到达SetSequence(seq2)
↓
Clear [A]OnFinished.Clear()
↓
Play Seq2LoopCount=1 循环播放
↓
Seq2 循环结束OnFinished 空触发(无害)
↓
3s 定时器resolve() ← 正确
★ 异常路径
OnFinished 重触发[A] 还在!
↓
[A] 再执行又发 LoadSeq2
↓
[B2] 回调重复 SetSeq + Play
↓
循环 ↩️回到 OnFinished 重触发

✅ 修复后

Play Seq1SafePlaySequencePlayer
↓
Seq1 播完OnFinished → [A]
↓
进入 [A] → 立即 Clear✅ OnFinished.Clear()
↓
异步加载 Seq2窗口内 [A] 已清除,不会重入
↓
[B] 回调到达SetSequence(seq2)
↓
LoopCount = 1Seq2 循环播放(保持原设计)
↓
Play Seq2不加 OnFinished(循环序列)
↓
3s 定时器 → resolve✅ 循环序列由定时器控制结束

✅ 修复方案 FIX

核心原则

1. OnFinished 处理器 [A] 进入时立即 Clear 自身 — 杜绝异步窗口内的重入。
2. Seq2 是循环播放(LoopCount = 1),不能加 OnFinished — 否则每次循环结束都会触发 → resolve 时机错误。
3. Seq2 保留 3s 定时器驱动 resolve — 循环序列的结束时机由定时器控制,这是正确做法。
4. Seq3 OnFinished 进入时立即 Clear — 同理防重入。
⚠️ 为什么 Seq2 不能加 OnFinished?

现状代码中 Seq2 设了 LoopCount = 1(循环播放 2 遍)。UE 的 OnFinished 会在每次循环结束时触发:
  • 第 1 遍播完 → OnFinished 触发 → 如果有 handler 就会执行 resolve() → 提前 resolve,第 2 遍还没播
  • 即使 handler 里 Clear 了自身,时机也不对 — 观众只看了 1 遍
  • 如果不 Clear → 第 2 遍播完又触发 → resolve() 被调第二次(虽然 Promise 幂等,但后续逻辑可能重复执行)

循环播放序列的结束只能靠外部定时器控制,这正是现有 3s 定时器的合理用途。

修复后代码:LoadTransitionLevelAsync(Seq1→Seq2)

FIXSeq1→Seq2 修复后
this.LoadRoleLevelSequence(path1, (levelSequence) => {
    if (!levelSequence) {
        reject(new Error('传送-加载 Seq1 失败'));
        return;
    }

    this.LevelSequenceActor!.SetSequence(levelSequence);
    this.ApplyCameraToLevelSequence(this.LevelSequenceActor!);
    const actorPlayer = this.LevelSequenceActor!.GetSequencePlayer();
    if (!actorPlayer) {
        reject(new Error('传送-Seq1 无播放器'));
        return;
    }

    // Seq1 → Seq2
    actorPlayer.OnFinished.Clear();
    actorPlayer.OnFinished.Add(() => {
        // ✅ 进入时立即清除自身,杜绝异步窗口内重入
        actorPlayer.OnFinished.Clear();

        this.LoadRoleLevelSequence(path2, (seq2) => {
            if (!seq2) {
                reject(new Error('传送-加载 Seq2 失败'));
                return;
            }

            this.LevelSequenceActor!.SetSequence(seq2);
            this.ApplyCameraToLevelSequence(this.LevelSequenceActor!);
            actorPlayer.PlaybackSettings.LoopCount = new UE.MovieSceneSequenceLoopCount(1);  // 循环播放,保持原设计

            // ✅ Seq2 是循环序列,不加 OnFinished
            //    循环播放的结束由外部 3s 定时器控制
            //    如果加 OnFinished → 每次循环结束都会触发 → resolve 时机错误

            this.ApplyAvatarToLevelSequence(this.LevelSequenceActor!, true, () => {
                this.SafePlaySequencePlayer(actorPlayer);
            });
        });
    });

    // Play Seq1
    this.ApplyAvatarToLevelSequence(this.LevelSequenceActor!, true, () => {
        this.SafePlaySequencePlayer(actorPlayer);
    });
});

// ✅ 保留 3s 定时器 — Seq2 是循环序列,只能靠定时器控制 resolve 时机
TimerSystem.Delay(() => {
    Log.Debug(ELogModule.Debug, ELogAuthor.ZZB, '传送-加载场景处理完成');
    resolve();
}, 3000);

修复后代码:ExitLoadLevelAsync(Seq3)

FIXSeq3 OnFinished 修复
actorPlayer.OnFinished.Clear();
actorPlayer.OnFinished.Add(() => {
    // ✅ 进入时立即清除自身
    actorPlayer.OnFinished.Clear();

    Log.Info(ELogModule.Debug, ELogAuthor.ZZB, '传送-Seq3 OnFinished 触发');
    ControllerHolder.CameraController.SetCameraCollisionEnabled(true);

    if (!this.HasResetCameraByLevelSeq) {
        this.HasResetCameraByLevelSeq = true;
        ControllerHolder.CameraController.ResetCameraByLevelSeq();
    }

    this.UnLoadTransitionLevel();
    EventSystem.Emit(EEventName.TeleportDownSkillComplete);
});

📋 修复对比

对比项 ❌ 现状 ✅ 修复后
OnFinished 进入时 不清除自身,异步窗口内可重入 立即 Clear(),杜绝重入
Seq2 OnFinished ❌ 没有(且不应有 — 循环序列) ✅ 仍然不加 — LoopCount = 1 循环播放,OnFinished 会每次循环触发
resolve 驱动方式 3s 硬定时器 保留 3s 定时器(循环序列只能靠定时器控制结束时机)
循环重入风险 P0 [A] 异步窗口内可无限重入 消除 [A] 进入即 Clear,Seq2 不挂 OnFinished
Seq2 循环误触 无 OnFinished,循环结束空触发 无 OnFinished,循环结束空触发(不变,无副作用)
嵌套深度 5 层 5 层(可进一步用 async/await 扁平化)
错误处理 分散在各层 reject 每层 reject,但结构更清晰
进一步优化(可选):用 async/await 将 LoadRoleLevelSequence 和 ApplyAvatarToLevelSequence 包装为 Promise,彻底消除回调嵌套:
const seq1 = await this.LoadLevelSequenceAsync(path1);
this.LevelSequenceActor!.SetSequence(seq1);
await this.ApplyAvatarAsync(...);
await this.PlayAndWaitAsync(actorPlayer);  // 等 OnFinished
// → Seq1 完成,继续 Seq2...