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 ≤ 0 | Emit 失败事件 + 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」日志上表现为偶发卡任务,唯一复现路径是顶号/重连,是排查最狠的一类。
闭包提前捕获 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 恢复时间—— 不等整体结束,反馈手感优先。
方案是所有申请方各拿句柄入优先级队列, 每帧仲裁只取队首最高优先级的活跃项生效; 移除某个句柄后队列评动降级到下一项。 QTE 组件的配合动作:进入时 AddGlobalTimeDilationFromValue 拿句柄, 玩家首次有效按键瞬间就 RemoveGlobalTimeDilation 恢复时间—— 不等整体结束,反馈手感优先。
这个模块里你踩过最难的两个坑难点
① 顶号竞态:玩家 A 在 QTE 中断线,B 顶号登录同一实体,
首帧 Tag 同步就是激活态。正常走 StartQte 会拿到错位的倒计时。
用 isFirstSync 判定:首帧就激活 = 继承态,直接判失败不开 UI。
② 事件订阅时序:判失败后立刻 Emit QteTimeScaleFailed, 但行为树 CheckQteCompleteNode 可能还没订阅完,事件被丢、任务卡死。 解法是 OwnedTimers.Next 延迟一帧 Emit,等订阅就位。 这类偶发卡任务 bug 唯一复现路径是顶号/重连,靠 code review 提前防御比事后排查划算。
② 事件订阅时序:判失败后立刻 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)收尾。
alpha 用 GetClientRealTimeMs 实时算而不是累计帧 delta: 进度与帧率解耦,掉帧时轨迹不变形。 到达 alpha≥1 时把玩家朝向归一(只留 Yaw)收尾。