Skip to content

Add component coroutine support#172

Open
shrinktofit wants to merge 1 commit into
cocos:v4.0.0from
shrinktofit:codex/component-coroutine-v4.0.0
Open

Add component coroutine support#172
shrinktofit wants to merge 1 commit into
cocos:v4.0.0from
shrinktofit:codex/component-coroutine-v4.0.0

Conversation

@shrinktofit

@shrinktofit shrinktofit commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Re: #

Changelog

  • Add component coroutine support.

Motivation

Many gameplay and UI component flows are naturally sequential, but they often need to span multiple frames: wait for an animation to finish, delay for a short amount of time, poll for a condition, then continue with the next step. Without a component-owned coroutine mechanism, those flows have to be rewritten as explicit state machines spread across component fields and update().

For example, a simple enemy spawn warning flow can become bookkeeping-heavy:

enum SpawnState {
    Idle,
    ShowingWarning,
    WaitingForSpawn,
    Spawning,
    Done,
}

class EnemySpawner extends Component {
    private _state = SpawnState.Idle;
    private _elapsed = 0;
    private _warningReady = false;

    public beginSpawn (): void {
        this._state = SpawnState.ShowingWarning;
        this._elapsed = 0;
        this._warningReady = false;
        this.showWarningEffect(() => {
            this._warningReady = true;
        });
    }

    public update (dt: number): void {
        switch (this._state) {
        case SpawnState.ShowingWarning:
            if (!this._warningReady) {
                return;
            }
            this._state = SpawnState.WaitingForSpawn;
            this._elapsed = 0;
            return;
        case SpawnState.WaitingForSpawn:
            this._elapsed += dt;
            if (this._elapsed < 0.5) {
                return;
            }
            this._state = SpawnState.Spawning;
            this.spawnEnemy();
            this._state = SpawnState.Done;
            return;
        }
    }
}

The real intent is linear: show the warning, wait until it is ready, wait half a second, then spawn. Coroutines let component authors express that intent directly while still running on the engine frame loop:

class EnemySpawner extends Component {
    public beginSpawn (): void {
        this.startCoroutine(this.spawnFlow());
    }

    private *spawnFlow (): CoroutineIterator {
        this.showWarningEffect();
        yield waitUntil(() => this.warningReady);
        yield waitFor(0.5);
        this.spawnEnemy();
    }
}

This PR adds that missing component-level control-flow primitive with Unity-style timing: coroutine continuations resume after component update() callbacks and before later frame phases.

Usage

Components can start a generator-based coroutine directly:

class LoadingPanel extends Component {
    protected onLoad (): void {
        this.startCoroutine(this.playIntro());
    }

    private *playIntro (): CoroutineIterator {
        this.fadeIn();
        yield waitFor(0.25);
        yield waitUntil(() => this.assetsReady);
        yield null;
        this.showReadyState();
    }
}

startCoroutine() returns a Coroutine handle. The handle can be passed to stopCoroutine(handle), while stopAllCoroutines() stops every coroutine owned by the component. Supported waits include:

yield null;
yield undefined;
yield nextFrame();
yield waitFor(seconds);
yield waitUntil(predicate);
yield waitWhile(predicate);

Details

  • startCoroutine() runs synchronously until the first yield, so any work before that first yield happens immediately in the caller's stack.
  • yield null, yield undefined, and yield nextFrame() resume during the coroutine phase no earlier than the next frame.
  • The coroutine phase runs after all component update() callbacks, before director systems update, and before component lateUpdate() callbacks.
  • Coroutines use the same execution-order grouping as component updates, so components with lower execution order resume before components with higher execution order.
  • Coroutines started during update() or during another coroutine continuation are queued for a later coroutine phase and do not resume in the same frame.
  • waitFor(seconds) resumes after enough frame dt has accumulated. waitUntil(predicate) resumes when the predicate becomes true. waitWhile(predicate) resumes when the predicate becomes false.
  • Setting component.enabled = false does not pause or stop coroutines owned by that component.
  • Setting node.active = false stops coroutines owned by affected components. Component destruction also stops owned coroutines.
  • stopCoroutine(handle), stopAllCoroutines(), and an aborted signal stop coroutines and run generator cleanup, including finally blocks.
  • Stopping a later coroutine while the coroutine phase is iterating skips it cleanly, and stopAllCoroutines() is safe to call from inside a coroutine continuation or cleanup.

Implementation

  • Adds cocos/scene-graph/component-coroutine.ts with public coroutine types and helper instructions.
  • Extends cc.Component with protected startCoroutine(), stopCoroutine(), and stopAllCoroutines() methods.
  • Adds a coroutine scheduler phase immediately after component update() and before director systems update.
  • Reuses component execution-order grouping and deferred mutation behavior, so coroutines started during update or coroutine execution cannot resume in the same frame.
  • Stops component-owned coroutines when the component is destroyed or when its node becomes inactive.

Tests

Added tests/scene-graph/component-coroutine.test.ts covering the behavior above, including lifecycle, ordering, waits, generator cleanup, and mutation safety.

Verified with:

  • npx jest tests/scene-graph/component-coroutine.test.ts --runInBand
  • npm run test

Continuous Integration

This pull request:

  • needs automatic test cases check.

    Manual trigger with @cocos-robot run test cases afterward.

  • does not change any runtime related code or build configuration

    If any reviewer thinks the CI checks are needed, please uncheck this option, then close and reopen the issue.


Compatibility Check

This pull request:

  • changes public API, and have ensured backward compatibility with deprecated features.
  • affects platform compatibility, e.g. system version, browser version, platform sdk version, platform toolchain, language version, hardware compatibility etc.
  • affects file structure of the build package or build configuration which requires user project upgrade.
  • introduces breaking changes, please list all changes, affected features and the scope of violation.

@github-actions

Copy link
Copy Markdown

Code Size Check Report

Wechat (WASM) Before After Diff
2D Empty (legacy pipeline) 1014459 bytes 1019737 bytes ⚠️ +5278 bytes
2D All (legacy pipeline) 2681984 bytes 2687329 bytes ⚠️ +5345 bytes
2D All (new pipeline) 2773761 bytes 2779097 bytes ⚠️ +5336 bytes
(2D + 3D) All 10030977 bytes 10036317 bytes ⚠️ +5340 bytes
Web (WASM + ASMJS) Before After Diff
(2D + 3D) All 16867390 bytes 16872736 bytes ⚠️ +5346 bytes

Interface Check Report

! WARNING this pull request has changed these public interfaces:

@@ -25686,8 +25686,27 @@
          * @deprecated since v3.5.0, this is an engine private interface that will be removed in the future.
          */
         _instantiate(cloned?: Component): Component | undefined;
         /**
+         * @en Starts a coroutine owned by this component.
+         * @zh 启动一个由当前组件持有的协程。
+         * @param coroutine The coroutine iterator to start.
+         * @param opts Optional start options.
+         * @returns An opaque coroutine handle that can be passed to stopCoroutine.
+         */
+        protected startCoroutine(coroutine: CoroutineIterator, opts?: StartCoroutineOptions): Coroutine;
+        /**
+         * @en Stops a coroutine.
+         * @zh 停止一个协程。
+         * @param coroutine The coroutine handle returned by startCoroutine.
+         */
+        protected stopCoroutine(coroutine: Coroutine): void;
+        /**
+         * @en Stops all coroutines owned by this component.
+         * @zh 停止当前组件持有的所有协程。
+         */
+        protected stopAllCoroutines(): void;
+        /**
          * @en
          * Use Scheduler system to schedule a custom task.<br/>
          * If the task is already scheduled, then the interval parameter will be updated without scheduling it again.
          * @zh
@@ -26999,8 +27018,81 @@
          */
         activate(scene: Scene): void;
     }
     /**
+     * Resume the coroutine on the next coroutine update.
+     */
+    export function nextFrame(): CoroutineInstruction;
+    /**
+     * Resume the coroutine after at least the given duration.
+     */
+    export function waitFor(seconds: number): CoroutineInstruction;
+    /**
+     * Resume the coroutine when the predicate becomes true.
+     */
+    export function waitUntil(predicate: CoroutinePredicate): CoroutineInstruction;
+    /**
+     * Resume the coroutine when the predicate becomes false.
+     */
+    export function waitWhile(predicate: CoroutinePredicate): CoroutineInstruction;
+    export function stopCoroutine(coroutine: Coroutine): void;
+    /**
+     * Frame data passed to a coroutine when it resumes.
+     */
+    export interface CoroutineFrame {
+        /**
+         * Time elapsed since the previous coroutine update, in seconds.
+         */
+        readonly deltaTime: number;
+        /**
+         * Time elapsed since this coroutine started running, in seconds.
+         */
+        readonly elapsedTime: number;
+        /**
+         * Number of coroutine update frames since the owner component started running coroutines.
+         */
+        readonly frame: number;
+    }
+    /**
+     * Handle returned by `Component.startCoroutine`.
+     */
+    export interface Coroutine {
+        readonly [__private._cocos_scene_graph_component_coroutine__CoroutineBrand]: never;
+    }
+    /**
+     * Yield instruction returned by coroutine wait helpers.
+     */
+    export interface CoroutineInstruction {
+        readonly [__private._cocos_scene_graph_component_coroutine__CoroutineInstructionBrand]: never;
+    }
+    /**
+     * Iterator shape accepted by `Component.startCoroutine`.
+     */
+    export type CoroutineIterator = Generator<__private._cocos_scene_graph_component_coroutine__CoroutineYield, void, CoroutineFrame>;
+    /**
+     * Predicate evaluated with the current coroutine frame.
+     */
+    export type CoroutinePredicate = (frame: CoroutineFrame) => boolean;
+    /**
+     * Options used when starting a component coroutine.
+     */
+    export interface StartCoroutineOptions {
+        /**
+         * Abort signal that stops the coroutine when aborted.
+         */
+        readonly signal?: __private._cocos_scene_graph_component_coroutine__CoroutineAbortSignal;
+    }
+    export class CoroutineRunner {
+        start(coroutine: CoroutineIterator, firstResumeFrame: number, opts?: StartCoroutineOptions): Coroutine;
+        update(deltaTime: number, directorFrame: number): void;
+        stop(handle: Coroutine): void;
+        stopAll(): void;
+        get frame(): number;
+        get deltaTime(): number;
+        get directorFrame(): number;
+        get empty(): boolean;
+    }
+    /**
      * @internal
      * @deprecated since v3.5
      */
     export class PrivateNode extends Node {
@@ -67145,9 +67237,9 @@
              * @deprecated since v3.4
              */
             static markOpacityTree(node: any, isDirty?: boolean): void;
         }
-        export type _cocos_scene_graph_component_scheduler__InvokeFunc = (...args: unknown[]) => void;
+        export type _cocos_scene_graph_component_scheduler__InvokeFunc = (iterator: any, dt?: any, directorFrame?: any) => void;
         function _cocos_scene_graph_component_scheduler__stableRemoveInactive(iterator: any, flagToClear: any): void;
         export class _cocos_scene_graph_component_scheduler__LifeCycleInvoker {
             static stableRemoveInactive: typeof _cocos_scene_graph_component_scheduler__stableRemoveInactive;
             protected _zero: js.array.MutableForwardIterator<any>;
@@ -67174,8 +67266,16 @@
             onLoad: _cocos_scene_graph_component_scheduler__OneOffInvoker;
             onEnable: _cocos_scene_graph_component_scheduler__OneOffInvoker;
         }
         export type _cocos_asset_assets_material__MaterialPropertyFull = renderer.MaterialProperty | _cocos_asset_assets_texture_base__TextureBase | gfx.Texture | null;
+        export const _cocos_scene_graph_component_coroutine__CoroutineBrand: unique symbol;
+        export const _cocos_scene_graph_component_coroutine__CoroutineInstructionBrand: unique symbol;
+        export type _cocos_scene_graph_component_coroutine__CoroutineYield = CoroutineInstruction | null | undefined;
+        export interface _cocos_scene_graph_component_coroutine__CoroutineAbortSignal {
+            readonly aborted: boolean;
+            addEventListener?: (type: "abort", listener: () => void) => void;
+            removeEventListener?: (type: "abort", listener: () => void) => void;
+        }
         export enum _cocos_misc_camera_component__CameraEvent {
             TARGET_TEXTURE_CHANGE = "tex-change"
         }
         export const _cocos_misc_camera_component__ProjectionType: typeof renderer.scene.CameraProjection;
@@ -67212,9 +67312,9 @@
         export class _cocos_scene_graph_component_scheduler__ReusableInvoker extends _cocos_scene_graph_component_scheduler__LifeCycleInvoker {
             constructor(invokeFunc: _cocos_scene_graph_component_scheduler__InvokeFunc);
             add(comp: Component): void;
             remove(comp: Component): void;
-            invoke(dt: number): void;
+            invoke(dt: number, directorFrame?: number): void;
         }
         /**
          * @en The Manager for Component's life-cycle methods.
          * It collaborates with [[NodeActivator]] to schedule and invoke life cycle methods for components
@@ -67236,8 +67336,13 @@
              * @en The invoker of `lateUpdate` callback
              * @zh `lateUpdate` 回调的调度器
              */
             lateUpdateInvoker: _cocos_scene_graph_component_scheduler__ReusableInvoker;
+            /**
+             * @en The invoker of component coroutine callbacks
+             * @zh 组件协程回调的调度器
+             */
+            coroutineInvoker: _cocos_scene_graph_component_scheduler__ReusableInvoker;
             constructor();
             /**
              * @en Cancel all future callbacks, including `start`, `update` and `lateUpdate`
              * @zh 取消所有未来的函数调度,包括 `start`,`update` 和 `lateUpdate`
@@ -67275,8 +67380,15 @@
              * @param dt @en Time passed after the last frame in seconds @zh 距离上一帧的时间,以秒计算
              */
             updatePhase(dt: number): void;
             /**
+             * @en Process coroutine phase for registered components
+             * @zh 为当前注册的组件执行协程阶段任务
+             * @param dt @en Time passed after the last frame in seconds @zh 距离上一帧的时间,以秒计算
+             * @param directorFrame @en Current director frame @zh 当前导演帧号
+             */
+            coroutineUpdatePhase(dt: number, directorFrame: number): void;
+            /**
              * @en Process late update phase for registered components
              * @zh 为当前注册的组件执行 late update 阶段任务
              * @param dt @en Time passed after the last frame in seconds @zh 距离上一帧的时间,以秒计算
              */

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant