1. 项目概述:为什么需要深挖一个简单的延时动作?
在CocosCreator的游戏开发中,cc.DelayTime大概是每个开发者最早接触、也最常使用的动作之一。它的API简单到令人发指——node.runAction(cc.delayTime(2)),意思就是“等两秒”。乍一看,这有什么好“详解”的?不就是个计时器吗?直接用setTimeout或者schedule不也一样?
这正是我最初的想法,直到我在一个复杂的战斗连招系统中踩了坑。我需要让角色在释放技能A后,延迟0.5秒自动衔接技能B,同时技能A的视觉特效要在延迟期间持续播放。我随手写了个cc.delayTime(0.5),结果在低端设备上或者游戏卡顿时,这个延迟变得飘忽不定,有时快有时慢,连招节奏完全乱套。更头疼的是,当我尝试在延迟过程中取消这个动作时,发现它和序列动作cc.sequence嵌套后,清理逻辑变得异常复杂。
那一刻我才意识到,这个看似简单的“延时”,其背后的时间驱动机制、与引擎生命周期的绑定关系、以及在动作系统中的状态管理,远非一句“等两秒”那么简单。它直接关系到游戏核心循环的稳定性、动画表现的精确性和逻辑控制的可靠性。理解cc.DelayTime的源码,不仅是理解一个类,更是理解CocosCreator动作系统(cc.Action)的设计哲学和实现基石。这对于实现精准的技能CD、流畅的动画序列、可靠的定时回调等游戏核心功能至关重要。
2. 核心架构解析:DelayTime在动作系统中的定位
要理解cc.DelayTime,绝不能孤立地看它,必须把它放回CocosCreator整个动作系统(cc.Action)的上下文中。这个系统是一个典型的命令模式实现,而DelayTime则是其中最为特殊的“空命令”。
2.1 动作系统的三层继承结构
CocosCreator的动作类继承结构非常清晰,主要分为三层:
- 基类
cc.Action:所有动作的抽象基类。它定义了动作的生命周期接口,如step(每帧更新)、update(根据时间比例更新目标状态)、clone(复制)、reverse(反转)等,但大部分是虚方法或需要子类实现。它的核心作用是作为一个与特定节点(target)绑定的可执行单元。 - 有限时间动作
cc.FiniteTimeAction:继承自cc.Action。这是所有具有明确持续时间动作的基类。它引入了_duration(持续时间)这个关键属性。我们常用的移动、缩放、旋转等渐变动作,以及DelayTime,都继承自它。它新增了reverse的基本实现,并提供了与时间相关的抽象。 - 瞬时动作
cc.ActionInstant:同样继承自cc.Action。与有限时间动作相反,它表示瞬间完成的动作(_duration为0),比如回调函数调用cc.callFunc、设置属性cc.set等。
cc.DelayTime直接继承自cc.FiniteTimeAction。这意味着,在引擎看来,延时是一个有明确持续时间、需要逐帧进行更新检查的正式动作,而不是一个游离在系统外的计时器。
2.2 DelayTime的本质:一个“占位符”动作
这是理解DelayTime源码最关键的一点。它本身不改变节点的任何属性(位置、旋转、缩放、颜色等)。它的唯一作用,就是在动作序列(cc.sequence)或动作组合中,占据一段指定的时间,并在这段时间内,让动作系统的更新循环(step)正常运转。
你可以把它想象成音乐会乐谱中的“休止符”。休止符本身不发出声音,但它严格定义了沉默的时长,保证了整个乐曲的节奏不乱。在cc.sequence([moveTo, delayTime, fadeOut])这个序列中,delayTime就是这个休止符,它确保了fadeOut动作一定会在moveTo完成后再等待指定时间才执行,从而形成了精确的时间控制链。
这种设计带来了几个巨大优势:
- 统一管理:所有时间相关的行为都纳入动作系统管理,生命周期(创建、执行、暂停、恢复、销毁)与节点和引擎帧循环同步,避免了手动管理
setTimeout带来的内存泄漏或生命周期错乱问题。 - 序列化组合:可以轻松地与其它动作通过
cc.sequence(顺序执行)、cc.spawn(同时执行)进行组合,构建复杂的动画流。 - 时间缩放:受益于动作系统统一的时间系统,当游戏全局时间缩放(
cc.director.getScheduler().setTimeScale())改变时,DelayTime的持续时间也会按比例缩放,实现全局的慢动作或快进效果。
3. 源码逐行精读与核心逻辑实现
我们打开CocosCreator引擎的cocos2d/actions/action-interval.js(这是cc.FiniteTimeAction及其子类所在文件,不同版本路径可能略有差异),找到cc.DelayTime类的实现。它的代码量极少,却是“少即是多”的典范。
3.1 构造函数与初始化
cc.DelayTime = cc.Class({ name: 'cc.DelayTime', extends: cc.FiniteTimeAction, ctor: function (duration) { cc.FiniteTimeAction.prototype.ctor.call(this); this.initWithDuration(duration); }, initWithDuration: function (duration) { if (cc.FiniteTimeAction.prototype.initWithDuration.call(this, duration)) { // 这里没有额外的属性需要初始化 return true; } return false; }, clone: function () { var action = new cc.DelayTime(this._duration); this._cloneDecoration(action); return action; }, reverse: function () { var action = new cc.DelayTime(this._duration); this._reverseDecoration(action); return action; }, update: function (dt) { // 重点:update函数是空的! // dt 是当前帧与上一帧的时间差(经过时间缩放后的deltaTime) // DelayTime 不需要更新任何目标属性,所以这个函数什么也不做。 } });关键解读:
ctor与initWithDuration:构造函数接收一个duration参数,并调用父类的initWithDuration方法。这个方法主要做了一件事:将传入的duration赋值给内部属性this._duration。这就是我们指定的延迟秒数。clone方法:这是动作系统支持重复使用和序列化的关键。当动作被加入序列或需要复制时,会调用此方法。它创建了一个新的DelayTime实例,并复制了_duration值。_cloneDecoration是父类方法,用于复制一些内部装饰性状态(如tag)。reverse方法:返回一个新的DelayTime实例,其持续时间相同。因为延迟2秒的反转,在逻辑上还是延迟2秒。这保证了动作序列在反转时行为的一致性。update方法——核心中的核心:这是一个空函数!它覆盖了父类的update方法,但内部没有任何代码。这正是DelayTime“占位符”特性的代码体现。
这里必须深入理解update的调用时机和参数dt:动作系统通过cc.director.getScheduler()驱动。每个绑定到节点的动作,在每一帧(update生命周期)都会调用其step方法。step方法内部会根据动作的已运行时间、总持续时间(_duration),计算出一个0到1之间的时间比例(通常称为t或delta),然后调用update(delta)。对于MoveTo这样的动作,update会根据delta去插值计算节点的当前位置。而对于DelayTime,update什么都不做,只是默默地让这个“计算时间比例并调用update”的过程,为了它而运行满_duration这么长的时间。
3.2 与ActionManager和调度器的协同
DelayTime实例本身是静态的,它的生命在于被cc.ActionManager(动作管理器)管理。当你调用node.runAction(delayAction)时:
- 该动作会被设置
target为当前节点。 - 该动作会被添加到节点所属的
ActionManager中。 ActionManager在每帧的更新中,会遍历所有活跃动作,调用其step方法。step方法内部:step: function (dt) { if (this._firstTick) { this._firstTick = false; this._elapsed = 0; // 已用时间 } else { this._elapsed += dt; // 累积时间 } // 计算本次update的时间比例 var updateDt = this._elapsed / this._duration; // 处理循环、pingpong等逻辑,确保updateDt在[0,1]区间 this.update(updateDt > 1 ? 1 : updateDt); // 关键判断:如果已用时间 >= 总持续时间,则标记动作为完成 if (this._elapsed >= this._duration) { this._isDone = true; // 完成标志位 } }- 对于
DelayTime,this.update(updateDt)执行了一个空函数。但_elapsed仍在持续累积。 - 当
_elapsed >= this._duration时,_isDone被置为true。ActionManager在下一帧检测到这个标志,就会将动作从管理列表中移除,并触发可能的完成回调(如果这个DelayTime是某个cc.sequence的一部分,则会触发序列执行下一个动作)。
这就是DelayTime的工作原理:它利用动作系统固有的、基于真实帧时间(dt)的累加和检查机制,来实现精确的延时。其精度与引擎帧率挂钩,受dt影响。
4. 高级应用与性能优化实战
理解了原理,我们就能在实战中扬长避短,解决开篇提到的那些坑。
4.1 精确延时 vs 帧率依赖:如何选择?
cc.DelayTime的延迟精度依赖于每帧的dt。在理想稳定的60帧下,dt≈16.67ms,延迟2秒需要约120帧。但如果设备卡顿,帧率降到30,dt≈33.33ms,同样120帧就需要约4秒。这就是“飘忽不定”的根源。
解决方案与选型建议:
| 场景 | 推荐方案 | 原理与优缺点 |
|---|---|---|
| 动画序列、视觉效果衔接(如UI弹窗依次弹出、技能特效链) | cc.DelayTime | 优点:与动作系统无缝集成,可组合,受全局时间缩放影响,适合表现层。 缺点:精度受帧率影响。 |
| 游戏逻辑计时(如技能CD、倒计时、buff持续时间) | cc.schedule或 自定义基于真实时间的计时器 | 优点:使用真实时间(Date.now()或performance.now()),不受帧率波动影响,精度高。缺点:需要手动管理生命周期,与动作系统结合稍繁琐。 |
| 单次延迟回调 | setTimeout(Web) /setTimeout(Runtime) | 优点:使用系统计时器,简单直接。 缺点:生命周期可能与游戏场景不同步,容易造成内存泄漏(节点销毁了定时器还在),不推荐在复杂游戏逻辑中使用。 |
实战心得:对于核心战斗计时、网络超时等要求高时间精度的逻辑,我绝不会使用cc.DelayTime。我会在节点的update方法中,使用一个基于cc.director.getTotalTime()(引擎启动后的总真实时间)的变量来自己管理计时。而对于一个角色攻击后播放受击特效的延迟,用cc.DelayTime放入序列中则非常合适,因为短暂的帧率波动对视觉体验影响不大,且代码简洁。
4.2 在复杂序列中的使用技巧与陷阱
DelayTime最常见的用法是嵌套在cc.sequence中。但这里有几个容易踩坑的细节。
陷阱一:在延迟中途取消序列
let seq = cc.sequence( cc.moveBy(1, 100, 0), cc.delayTime(2), cc.callFunc(() => { console.log("延迟结束"); }) ); node.runAction(seq); // 在延迟的第1秒时,想要取消整个序列 setTimeout(() => { node.stopAction(seq); // 可以停止动作 // 但注意:callFunc里的回调不会再被触发,符合预期。 }, 1000);注意:
stopAction会立即将动作从管理器中移除。如果DelayTime已经过去一部分时间,这部分时间就“浪费”了,且不可恢复。如果你需要“暂停/恢复”延迟,应该使用cc.director.getScheduler().pauseTarget(node)和resumeTarget(node)来暂停整个节点的所有动作和调度器。
陷阱二:重复序列中的DelayTime
let seq = cc.sequence( cc.moveBy(1, 100, 0), cc.delayTime(0.5), cc.fadeOut(1) ); node.runAction(seq.repeatForever());这段代码会让节点右移、延迟、淡出、然后瞬间回到起点(动作重置)、再次右移……形成一个循环。这里的delayTime在每一次循环中都会正常执行。这是符合设计的。但如果你想要“右移后,永远等待0.5秒再淡出”,那这个逻辑就不对,你需要重新设计动作链。
实战技巧:创建动态参数的DelayTime有时延迟时间需要根据游戏状态动态计算。cc.delayTime是一个工厂函数,返回的是new出来的实例。我们可以封装一个函数:
function createDynamicDelay(getDurationFunc) { // 返回一个自定义的有限时间动作 let action = new cc.FiniteTimeAction(); action._duration = 0; // 先占位 action.update = function (dt) {}; action.clone = function () { return createDynamicDelay(getDurationFunc); }; action.startWithTarget = function (target) { cc.FiniteTimeAction.prototype.startWithTarget.call(this, target); // 在动作真正开始执行时,才计算具体的持续时间 this._duration = getDurationFunc.call(target); // 可以从target(节点)上获取状态 }; return action; } // 使用:延迟时间等于节点的`customDelay`属性值 let dynamicDelay = createDynamicDelay(function() { return this.customDelay; }); node.customDelay = 2.0; let seq = cc.sequence(cc.moveBy(1, 100, 0), dynamicDelay, cc.fadeOut(1));4.3 性能考量与最佳实践
DelayTime本身性能开销极低,因为它的update是空的。它的开销主要来源于:
- 动作管理器遍历开销:只要动作未结束,每帧它都会被
ActionManager遍历到并调用step方法。虽然step逻辑简单,但如果有成千上万个活跃的DelayTime动作,累积的开销也不可忽视。 - 对象创建与GC压力:频繁地
new cc.DelayTime()会产生大量短期小对象,可能触发垃圾回收(GC),导致卡顿。
最佳实践:
- 对象池化:对于频繁使用的、固定时长的延迟(如按钮点击冷却的0.2秒),可以考虑简单的对象池。
let delayTimePool = []; function getDelayTime(duration) { let action = delayTimePool.pop(); if (action) { action._duration = duration; // 复用对象,重置时间 action._isDone = false; action._elapsed = 0; action._firstTick = true; return action; } return cc.delayTime(duration); } function putDelayTime(action) { if (delayTimePool.length < 20) { // 池大小限制 delayTimePool.push(action); } } // 在动作完成回调中回收(注意:需要自己管理,比较麻烦,仅适用于极致优化场景) - 避免滥用:对于非常短的延迟(如0.1秒以内),可以考虑是否真的有必要用一个完整的动作。有时在下一帧直接执行回调(
scheduleOnce)可能更轻量。 - 及时清理:确保在节点销毁(
onDestroy)或场景切换时,所有不必要的延时动作都被stopAllActions清理掉,防止内存泄漏。
5. 常见问题排查与源码调试技巧
即使理解了原理,在实际开发中还是会遇到一些诡异的问题。这里记录几个典型案例和排查思路。
问题一:DelayTime结束后,后续动作不执行。
- 排查步骤:
- 检查序列完整性:确认
cc.sequence中DelayTime后面确实跟了动作,并且没有语法错误(如括号不匹配)。 - 检查动作目标:确保运行序列的
node在整个延迟期间没有被销毁、隐藏(active = false)或从场景中移除。节点不可用会导致其上的所有动作被自动清理。 - 使用调试工具:在Creator编辑器的“调试”面板中,可以查看当前节点上正在运行的所有动作及其状态。确认
DelayTime动作是否存在,其_isDone是否已变为true。 - 源码定位:在
cc.ActionManager的update方法(或step方法)中打日志或断点,查看你的DelayTime动作是否被正常遍历和更新,以及_elapsed是否在增加。
- 检查序列完整性:确认
问题二:延迟时间感觉比设定的长。
- 可能原因:
- 全局时间缩放:检查是否使用了
cc.director.getScheduler().setTimeScale(0.5)等代码。这会使所有动作(包括DelayTime)速度减半。 - 节点时间缩放:检查节点的
pauseAllActions或自定义调度器暂停状态。虽然不常见,但某些插件或代码可能修改了节点的动作更新逻辑。 - 性能瓶颈:如之前所述,帧率下降会导致基于
dt的延迟变长。使用cc.director.getDeltaTime()打印帧时间,确认是否存在卡顿。
- 全局时间缩放:检查是否使用了
问题三:在DelayTime期间,如何获取已过去的时间?
- 标准动作系统不直接提供此API。但你可以通过“曲线救国”:
或者,更优雅的方式是自定义一个let startTime = 0; let delayAction = cc.delayTime(3); let seq = cc.sequence( cc.callFunc(() => { startTime = cc.director.getTotalTime(); }), delayAction, cc.callFunc(() => { let elapsed = cc.director.getTotalTime() - startTime; console.log(`实际延迟了:${elapsed}秒`); }) );DelayTime的子类,在update方法中累积并暴露已过去的时间。
源码调试技巧:
- 在Creator中启用引擎源码调试:在Creator的设置中,勾选“使用引擎源码”,然后就可以在浏览器的开发者工具中直接看到并断点调试
cocos2d-js.js里的cc.DelayTime等源码。 - 关键函数断点:在
cc.ActionInterval.prototype.step(这是FiniteTimeAction的step实现)和cc.DelayTime.prototype.update中设置断点,可以清晰地看到每一帧时间如何累积,以及DelayTime的空update如何被调用。 - 猴子补丁(Monkey Patch):在游戏启动脚本中,临时覆盖
cc.DelayTime.prototype.update,加入日志,可以无侵入地监控所有延迟动作的运行情况。let originalUpdate = cc.DelayTime.prototype.update; cc.DelayTime.prototype.update = function(dt) { console.log(`[DelayTime] target: ${this.target?.name}, dt: ${dt}, elapsed: ${this._elapsed}, duration: ${this._duration}`); originalUpdate.call(this, dt); };
回过头看,cc.DelayTime的源码就像一颗螺丝钉,简单到只有十几行。但正是通过对这颗“螺丝钉”的深入剖析,我们才得以窥见CocosCreator动作系统这座精密“钟表”的内部齿轮是如何咬合运转的。它教会我们的,不仅是如何使用一个API,更是一种设计思想:将复杂的时间控制逻辑,抽象成统一的、可组合的、生命周期受控的对象。下次当你再写下cc.delayTime时,希望你能感受到这简洁背后,引擎设计者对于时间与状态管理的深刻思考。在性能敏感的地方谨慎使用它,在表现层的地方大胆组合它,这才是读懂源码带来的真正价值。