首页 / 入职工作清单 / QTE 时间缩放

QTE 时间缩放

评评① 0 做的剧情演出模块:按键 QTE + 全局时间膨胀(子弹时间)。 场景进演出态时把世界时间缩到 0.5 倍、玩家正常输入,倒计时归零判失败; 成功则把玩家抛物线投掷到目标点并接技能/蒙太奇。 难点全在边界:顶号竞态、首次按键立刻恢复时间、失败事件订阅时序。

0 到 1 优先级队列仲裁 抛物线位移 顶号竞态防御 事件时序防御

1. 工作清单

内容性质
QTE 时间缩放组件:全局时间膨胀接入、倒计时、按键计数、成功位移(直线/抛物线)、失败兜底、UI/序列/蒙太奇联动 0 建
配合序章/演出的 QTE 场景铺开,强化顶号/重登等异常路径的鲁棒性维护强化

2. 理论:时间膨胀仲裁与 QTE 状态机

2.1 全局时间膨胀:多个来源谁说了算

1
多来源注册
QTE、技能演出、相机特写等都可申请全局时间膨胀,各拿一个句柄入队
2
优先级队列仲裁
UpdateTimeDilation 只取队首(优先级最高)且未过期的一项生效,其余全部忽略
3
句柄移除
QTE 首次按键/结束时按句柄移除膨胀,队列评动降级到下一个来源
为什么用优先级队列而不是「最后一次设置生效」 场景是多层演出叠加:QTE 里套技能慢动作、技能里套相机特写。 「最后写入赢」会导致先结束的一方把后结束的一方的慢动作提前解除。 队列仲裁保证:任意时刻只有最高优先级的活跃膨胀生效, 移除一个评动降级,互不踩踏。

2.2 QTE 生命周期状态机

阶段进入条件关键动作退出
未激活初始—Tag 同步到「激活」
进行中激活 Tag(非首帧同步)加全局膨胀 + 绑按键 + 开 UI + 播序列倒计时归零 → 失败;输入达标 → 成功
继承激活态顶号/重登时首帧就带激活 Tag直接判失败,不开 UI延迟一帧 Emit 失败事件
成功CurInputCount ≥ InputCount解绑按键/关 UI/移除膨胀/位移到目标点/接技能或蒙太奇—
失败RemainingTimeMs ≤ 0Emit 失败事件 + StopQte 全量清理—
顶号场景是最容易被漏掉的竞态 玩家 A 在 QTE 进行中断线,玩家 B 顶号登录同一实体—— B 的客户端首帧同步 Tag 时就是「激活态」。 如果当普通流程走 StartQte,B 会看到一个时间戳早已错位的倒计时。 处理是 isFirstSync 判定直接判失败不开 UI—— 宁可玩家看到 QTE 略过,不给一个错乱的 QTE。

3. 代码片段

3.1 StartQte:钳制 + 句柄注册

Src\Game\World\Entity\Character\SceneItem\EtQteTimeScaleComponent.ts:211-243(节选)

private StartQte(): void {
    if (this.CurrentState) { return; }
    if (!this.DataComp) { return; }
    this.CurrentState = true;
    this.RemainingTimeMs = this.DataComp.CountdownTime * TimerSystemConstants.SecondToMillisecond;
    this.CurInputCount = 0;
    this.IsTimeDilationRemoved = false;
    this.IsSuccessTriggered = false;

    const timeDilationScale = Math.max(0, Math.min(1, this.DataComp.TimeDilationScale ?? 0.5));
    this.TimeDilationHandle = TimeDilationController.AddGlobalTimeDilationFromValue(
        timeDilationScale,
        this.DataComp.CountdownTime,
        0,
        ETimeDilationSourceType.Default,
    );

    ControllerHolder.InputDistributeController.BindAction(this.DataComp.KeyValue, this.OnInputKeyPress);

    if (!UiInterfaceHelper.IsViewOpen(EUiViewName.QteButtonContinuousView)) {
        UiInterfaceHelper.OpenView(EUiViewName.QteButtonContinuousView, this.EntityHandle?.Id);
    }
    ...
}
两处防御 ① Math.max(0, Math.min(1, scale)) 双重钳制——配置表写了 0(全冻结)或 3(加速)都不会穿透到时间系统; ② 进入时重置四个状态位(IsTimeDilationRemoved/IsSuccessTriggered 等), QTE 是可重复触发的组件,每次进入必须把上一次的脏状态清干净。 句柄 TimeDilationHandle 是后续移除膨胀的唯一凭据。

3.2 OnInputKeyPress:四重防御 + 首次按键恢复时间

Src\Game\World\Entity\Character\SceneItem\EtQteTimeScaleComponent.ts:423-445(节选)

private readonly OnInputKeyPress = (actionName: string, actionType: EActionType): void => {
    if (!this.CurrentState || !this.DataComp || actionType !== EActionType.Press || this.IsSuccessTriggered) {
        return;
    }

    if (!this.IsTimeDilationRemoved) {                       // 首次有效按键:立刻移除全局膨胀
        TimeDilationController.RemoveGlobalTimeDilation(this.TimeDilationHandle);
        this.IsTimeDilationRemoved = true;
    }

    this.CurInputCount++;
    ...
    if (this.CurInputCount >= this.DataComp.InputCount) {
        this.IsSuccessTriggered = true;
        this.CurrentState = false;
        this.RemainingTimeMs = 0;
        ControllerHolder.InputDistributeController.UnBindAction(this.DataComp.KeyValue, this.OnInputKeyPress);
        ...
    }
};
「首次按键立刻恢复时间」是体验细节 入口一行判断四种不该响应的情况:非进行中 / 无数据 / 非 Press(Release 也回调到这) / 已成功。 真正的设计点在 IsTimeDilationRemoved:玩家第一次按键的瞬间就解除慢动作, 不等 QTE 整体结束——手感和「按键起效」的反馈对齐; 后续按键只计数不再碰时间系统(幂等)。 成功后立即解绑按键 + 置 IsSuccessTriggered,连点不会重入成功链路。

3.3 OnTick:倒计时 + 抛物线位移

Src\Game\World\Entity\Character\SceneItem\EtQteTimeScaleComponent.ts:103-128(节选)

protected override OnTick(deltaMs: number): void {
    if (this.CurrentState) {
        this.RemainingTimeMs -= deltaMs;
        if (this.RemainingTimeMs <= 0) {
            this.OnQteFailed();
        }
    }

    if (this.IsMoving && this.StartLocation && this.TargetLocation) {
        const elapsedMs = Time.GetClientRealTimeMs() - this.MoveStartTime;
        const alpha = Math.min(elapsedMs / this.MoveDurationMs, 1);
        const currentLocation = Vector.Create();

        const movePathType = this.DataComp?.MovePathType ?? EQteMovePathType.Straight;
        if (movePathType === EQteMovePathType.Parabola) {
            const parabolaHeight = this.DataComp?.ParabolaHeight ?? 200.0;
            const horizontalLocation = Vector.Create();
            Vector.Lerp(this.StartLocation, this.TargetLocation, alpha, horizontalLocation);

            const heightOffset = 4 * parabolaHeight * alpha * (1 - alpha);
            currentLocation.X = horizontalLocation.X;
            currentLocation.Y = horizontalLocation.Y;
            currentLocation.Z = horizontalLocation.Z + heightOffset;
        } else {
            Vector.Lerp(this.StartLocation, this.TargetLocation, alpha, currentLocation);
        }
        ...
    }
}
抛物线公式一行流 水平面用 Vector.Lerp 线性走, 高度加 4 * height * alpha * (1 - alpha)—— 标准抛物线参数方程:alpha=0 和 1 时高度偏移为 0(起终点落地), alpha=0.5 时到达峰值 height。 用 GetClientRealTimeMs 而不是累计 deltaMs:位移进度不受掉帧/暂停影响,帧率无关。

3.4 RefreshLevelState:顶号竞态 + 延迟一帧 Emit

Src\Game\World\Entity\Character\SceneItem\EtQteTimeScaleComponent.ts:180-209(节选)

if (tag === GameplayTagUtils.GetGameplayTagByName(entityStateConfig.激活态.激活)) {
    if (isFirstSync) {
        // 顶号场景:B客户端登录时继承了上一个客户端留下的激活态,直接判失败,不显示界面
        // 延迟一帧Emit,等CheckQteCompleteNode等任务节点完成事件订阅,否则Failed事件会被丢失导致任务卡住
        Log.Info(ELogModule.Level, ELogAuthor.ZZB, 'QTE剧情时间缩放-继承激活态直接失败', ['Reason', '顶号/重登']);
        const entityId = this.EntityId!;
        const prefabDataId = this.EntityHandle?.PrefabDataId;
        this.OwnedTimers.Next(() => {
            EventSystem.Emit(EEventName.QteTimeScaleFailed, entityId, prefabDataId);
        }, 'EtQteTimeScaleComponent.RefreshLevelState');
    } else {
        this.StartQte();
    }
}
两个竞态叠在一个分支里 ① 顶号竞态:首帧同步就带激活 Tag = 继承评上个客户端,倒计时时间戳已错位,直接判失败不开 UI; ② 事件订阅时序:失败事件不能立刻 Emit—— 行为树侧 CheckQteCompleteNode 节点可能还没订阅完,立刻 Emit 事件会被丢, 任务卡死永远等不到。用 this.OwnedTimers.Next(下一帧)延迟一拍。
闭包提前捕获 entityId/prefabDataId 是因为下一帧实体可能已在释放中。 这类「时序窗口 bug」日志上表现为偶发卡任务,唯一复现路径是顶号/重连,是排查最狠的一类。

3.5 时间膨胀仲裁:优先级队列只认队首

Src\Game\Module\TimeDilation\TimeDilationNode.ts:58-94(节选)

public UpdateTimeDilation(): void {
    let newTimeDilation = 1;
    let isStrongTimeDilation = false;
    let affectCamera = false;
    if (this.IsPause()) {
        newTimeDilation = 0;
    } else {
        const currentTime = Time.NowSeconds();
        const model = ModelManager.TimeDilationModel!;
        while (!this.TimeDilationPriorityQueue.Empty) {
            // 获取优先级最高的且未结束的时间膨胀,仅计算它的当前值。其他优先级低的时间膨胀不予计算。
            const top = this.TimeDilationPriorityQueue.Top;
            if (!top) { break; }
            const topDilation = model.GetTimeDilation(top)!;
            if (topDilation.EndTime < 0 || topDilation.EndTime > currentTime) {
                newTimeDilation = topDilation.CurrentTimeDilation;
                isStrongTimeDilation = topDilation.IsStrongTimeDilation;
                affectCamera = topDilation.AffectCamera;
                break;
            }
            this.RemoveTimeDilation(top);                          // 队首已过期,弹出看下一个
        }
    }

    if (newTimeDilation !== this.TimeDilationInternal || affectCamera !== this.AffectCameraInternal) {
        this.TimeDilationInternal = newTimeDilation;
        this.AffectCameraInternal = affectCamera;
        if (newTimeDilation >= 0 && newTimeDilation !== Number.POSITIVE_INFINITY) {
            this.TimeDilationChangeHandler(newTimeDilation, isStrongTimeDilation, affectCamera);
        } else {
            Log.Error(ELogModule.TimeDilation, ELogAuthor.CYH, '时间膨胀数值非法!', ['Dilation', newTimeDilation]);
        }
    }
}
仲裁 + 变化检测 + 数值兜底 while 循环把队首所有已过期的膨胀顺手清理(惰性回收),取第一个活着的生效; 生效后只在值真正变化时才通知下游(避免每帧广播); 膨胀值非法(负数/Infinity)显式 Error 不下发给时间系统—— 错一个膨胀值拖垮的是整个世界的 tick。

4. 面试怎么讲

全局时间膨胀为什么用优先级队列仲裁设计
因为演出会叠加:QTE 里套技能慢动作、技能里套相机特写。 如果用「最后一次设置生效」,先结束的一方会把另一方的慢动作提前解除, 或者低优先级的加速覆盖高优先级的冻结。
方案是所有申请方各拿句柄入优先级队列, 每帧仲裁只取队首最高优先级的活跃项生效; 移除某个句柄后队列评动降级到下一项。 QTE 组件的配合动作:进入时 AddGlobalTimeDilationFromValue 拿句柄, 玩家首次有效按键瞬间就 RemoveGlobalTimeDilation 恢复时间—— 不等整体结束,反馈手感优先。
这个模块里你踩过最难的两个坑难点
① 顶号竞态:玩家 A 在 QTE 中断线,B 顶号登录同一实体, 首帧 Tag 同步就是激活态。正常走 StartQte 会拿到错位的倒计时。 用 isFirstSync 判定:首帧就激活 = 继承态,直接判失败不开 UI。
② 事件订阅时序:判失败后立刻 Emit QteTimeScaleFailed, 但行为树 CheckQteCompleteNode 可能还没订阅完,事件被丢、任务卡死。 解法是 OwnedTimers.Next 延迟一帧 Emit,等订阅就位。 这类偶发卡任务 bug 唯一复现路径是顶号/重连,靠 code review 提前防御比事后排查划算。
QTE 成功后的位移怎么实现抛物线算法
水平面 Vector.Lerp 起点到目标点按 alpha 线性插值; 高度加标准抛物线参数方程偏移 4 * height * alpha * (1 - alpha)—— alpha=0/1 时偏移 0(起终点落地),alpha=0.5 到峰值 height。
alpha 用 GetClientRealTimeMs 实时算而不是累计帧 delta: 进度与帧率解耦,掉帧时轨迹不变形。 到达 alpha≥1 时把玩家朝向归一(只留 Yaw)收尾。