Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/api/scene/particle-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Runtime schemas for the public AI/MCP particle-system operations.
*
* 对应 https://docs.cocos.com/creator/4.0/manual/en/particle-system/
* 与 cocos-editor ParticleManager 暴露给 float-window / inspector 的能力。
*/
import { z } from 'zod';

/** 粒子组件标识:节点路径或组件 uuid 二选一 */
export const SchemaParticleIdentifier = z
.object({
/** 粒子组件所在节点的路径,如 'Canvas/Particles' */
nodePath: z.string().min(1).optional().describe('Path of the node holding the cc.ParticleSystem component, e.g. "Canvas/Particles"'),
/** 粒子组件的 uuid(优先于 nodePath) */
uuid: z.string().min(1).optional().describe('UUID of the cc.ParticleSystem component (takes precedence over nodePath)'),
})
.refine((value) => Boolean(value.nodePath || value.uuid), {
message: 'Either nodePath or uuid is required.',
})
.describe('Particle system identifier: nodePath or uuid');

/** queryPlayInfo / setPlaySpeed 需要指定粒子组件 */
export const SchemaParticleSpeed = z
.object({
nodePath: z.string().min(1).optional().describe('Path of the node holding the cc.ParticleSystem component'),
uuid: z.string().min(1).optional().describe('UUID of the cc.ParticleSystem component (takes precedence over nodePath)'),
/** 播放速度倍率,1 为正常速度 */
speed: z.number().finite().min(0).describe('Simulation speed multiplier, 1 = normal speed'),
})
.refine((value) => Boolean(value.nodePath || value.uuid), {
message: 'Either nodePath or uuid is required.',
})
.describe('Particle system speed options');

/** 粒子运行时信息 */
export const SchemaParticlePlayInfo = z
.object({
speed: z.number().describe('Current simulation speed multiplier'),
time: z.number().describe('Elapsed simulation time in seconds'),
particle: z.number().int().min(0).describe('Number of alive particles'),
isPlaying: z.boolean().describe('Whether the particle system is currently playing'),
/** 找不到组件时返回 null */
found: z.boolean().optional().describe('Whether the particle component was located'),
})
.describe('Particle system runtime info');

/** play/pause/stop/restart 这类操作作用于当前选中的粒子组件 */
export const SchemaParticleAction = z
.object({
/**
* 是否仅作用于指定的粒子组件。未提供时,作用于当前选中的所有粒子组件。
*/
nodePath: z.string().min(1).optional().describe('Optional path of the node holding the cc.ParticleSystem component. When omitted, the action applies to all currently selected particle systems.'),
uuid: z.string().min(1).optional().describe('Optional UUID of the cc.ParticleSystem component (takes precedence over nodePath). When omitted, the action applies to all currently selected particle systems.'),
})
.describe('Particle system action options');

export const SchemaParticleActionResult = z
.object({
action: z.string().describe('The action that was performed'),
applied: z.boolean().describe('Whether the action was applied to at least one particle system'),
})
.describe('Particle system action result');

export type TParticleIdentifier = z.infer<typeof SchemaParticleIdentifier>;
export type TParticleSpeed = z.infer<typeof SchemaParticleSpeed>;
export type TParticlePlayInfo = z.infer<typeof SchemaParticlePlayInfo>;
export type TParticleAction = z.infer<typeof SchemaParticleAction>;
export type TParticleActionResult = z.infer<typeof SchemaParticleActionResult>;
193 changes: 193 additions & 0 deletions src/api/scene/particle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/**
* Public AI/MCP facade for particle-system operations.
*
* 对照 https://docs.cocos.com/creator/4.0/manual/en/particle-system/
* 与 cocos-editor ParticleManager 暴露给 float-window / inspector 的能力:
* - play / pause / stop / restart
* - setPlaySpeed
* - queryPlayInfo
*/
import { COMMON_STATUS, CommonResultType, getCommonErrorStatus } from '../base/schema-base';
import { description, param, result, title, tool } from '../decorator/decorator.js';
import { Scene } from '../../core/scene';
import {
SchemaParticleIdentifier,
SchemaParticleSpeed,
SchemaParticlePlayInfo,
SchemaParticleAction,
SchemaParticleActionResult,
TParticleIdentifier,
TParticleSpeed,
TParticlePlayInfo,
TParticleAction,
TParticleActionResult,
} from './particle-schema';

export class ParticleApi {
/**
* Query runtime info of a particle system.
*/
@tool('particle-query-play-info')
@title('Query particle system runtime info')
@description('Get the current simulation speed, elapsed time, alive particle count and playing state of a cc.ParticleSystem component identified by nodePath or uuid.')
@result(SchemaParticlePlayInfo)
async queryPlayInfo(@param(SchemaParticleIdentifier) options: TParticleIdentifier): Promise<CommonResultType<TParticlePlayInfo>> {
try {
const uuid = await this._resolveUuid(options);
if (!uuid) {
return { code: COMMON_STATUS.NOT_FOUND, reason: `Particle component not found for: ${options.uuid || options.nodePath}` };
}
const info = await Scene.Particle.queryPlayInfo(uuid);
if (!info) {
return { code: COMMON_STATUS.NOT_FOUND, reason: `Particle component not found: ${options.uuid || options.nodePath}` };
}
return { code: COMMON_STATUS.SUCCESS, data: { ...info, found: true } };
} catch (e) {
return { code: getCommonErrorStatus(e), reason: e instanceof Error ? e.message : String(e) };
}
}

/**
* Set the simulation speed of a particle system.
*/
@tool('particle-set-play-speed')
@title('Set particle system simulation speed')
@description('Set the simulation speed multiplier (1 = normal speed) of a cc.ParticleSystem component identified by nodePath or uuid.')
@result(SchemaParticlePlayInfo)
async setPlaySpeed(@param(SchemaParticleSpeed) options: TParticleSpeed): Promise<CommonResultType<TParticlePlayInfo>> {
try {
const uuid = await this._resolveUuid(options);
if (!uuid) {
return { code: COMMON_STATUS.NOT_FOUND, reason: `Particle component not found for: ${options.uuid || options.nodePath}` };
}
await Scene.Particle.setPlaySpeed(uuid, options.speed);
const info = await Scene.Particle.queryPlayInfo(uuid);
if (!info) {
return { code: COMMON_STATUS.NOT_FOUND, reason: `Particle component not found: ${options.uuid || options.nodePath}` };
}
return { code: COMMON_STATUS.SUCCESS, data: { ...info, found: true } };
} catch (e) {
return { code: getCommonErrorStatus(e), reason: e instanceof Error ? e.message : String(e) };
}
}

/**
* Play the selected particle systems.
*/
@tool('particle-play')
@title('Play particle systems')
@description('Play the currently selected cc.ParticleSystem components. When a nodePath or uuid is provided, ensure that component is selected first.')
@result(SchemaParticleActionResult)
async play(@param(SchemaParticleAction) options: TParticleAction): Promise<CommonResultType<TParticleActionResult>> {
return this._runAction('play', options, async () => { await Scene.Particle.play(); });
}

/**
* Pause the selected particle systems.
*/
@tool('particle-pause')
@title('Pause particle systems')
@description('Pause the currently selected cc.ParticleSystem components. When a nodePath or uuid is provided, ensure that component is selected first.')
@result(SchemaParticleActionResult)
async pause(@param(SchemaParticleAction) options: TParticleAction): Promise<CommonResultType<TParticleActionResult>> {
return this._runAction('pause', options, async () => { await Scene.Particle.pause(); });
}

/**
* Stop the selected particle systems.
*/
@tool('particle-stop')
@title('Stop particle systems')
@description('Stop the currently selected cc.ParticleSystem components. When a nodePath or uuid is provided, ensure that component is selected first.')
@result(SchemaParticleActionResult)
async stop(@param(SchemaParticleAction) options: TParticleAction): Promise<CommonResultType<TParticleActionResult>> {
return this._runAction('stop', options, async () => { await Scene.Particle.stop(); });
}

/**
* Restart the selected particle systems.
*/
@tool('particle-restart')
@title('Restart particle systems')
@description('Restart the currently selected cc.ParticleSystem components (stop then play). When a nodePath or uuid is provided, ensure that component is selected first.')
@result(SchemaParticleActionResult)
async restart(@param(SchemaParticleAction) options: TParticleAction): Promise<CommonResultType<TParticleActionResult>> {
return this._runAction('restart', options, async () => { await Scene.Particle.restart(); });
}

private async _runAction(
action: string,
options: TParticleAction,
fn: () => Promise<void>,
): Promise<CommonResultType<TParticleActionResult>> {
try {
// 行为作用于当前选中的粒子组件集合。若调用方指定了具体组件,
// 先选中它的节点,再执行 play/pause/stop/restart,
// 与 cocos-editor float-window 按钮行为一致。
if (options.nodePath || options.uuid) {
const nodePath = await this._resolveNodePath(options);
if (!nodePath) {
return { code: COMMON_STATUS.NOT_FOUND, reason: `Particle component not found for: ${options.uuid || options.nodePath}` };
}
// Selection 服务以节点 path 进行选择。直接通过 RPC 调用,
// 因为 Scene 代理未聚合 Selection 模块。
try {
const { Rpc } = await import('../../core/scene/main-process/rpc');
await Rpc.getInstance().request('Selection', 'select', [nodePath]);
} catch (selectErr) {
// 选中失败不阻断播放行为,仅记录
console.warn('[ParticleApi] select before action failed:', selectErr);
}
}
await fn();
return { code: COMMON_STATUS.SUCCESS, data: { action, applied: true } };
} catch (e) {
return { code: getCommonErrorStatus(e), reason: e instanceof Error ? e.message : String(e) };
}
}

/**
* 解析粒子组件所在节点的路径。优先使用调用方传入的 nodePath;
* 若仅提供 uuid,则通过组件 uuid 反查节点,再由节点 uuid 取节点路径。
*/
private async _resolveNodePath(options: { uuid?: string; nodePath?: string }): Promise<string | null> {
if (options.nodePath) {
return options.nodePath;
}
if (options.uuid) {
// 组件 uuid 与节点 uuid 相同(cc 引擎约定:组件继承自 CCObject,其 uuid 即节点 uuid)。
// Selection 服务以节点 path 进行选择,故需要把 uuid 转为 path。
// NodeProxy 未暴露 getPathByUuid,直接通过 RPC 调用。
try {
const { Rpc } = await import('../../core/scene/main-process/rpc');
const path = await Rpc.getInstance().request('Node', 'getPathByUuid', [options.uuid]);
if (typeof path === 'string' && path.length > 0) {
return path;
}
} catch (e) {
return null;
}
}
return null;
}

/**
* 将 nodePath/uuid 标识解析为粒子组件 uuid。优先使用 uuid。
*/
private async _resolveUuid(options: { uuid?: string; nodePath?: string }): Promise<string | null> {
if (options.uuid) {
return options.uuid;
}
if (options.nodePath) {
// 组件路径 = 节点路径 + 组件类型,与 scene-query-component 约定一致
const componentPath = `${options.nodePath}/cc.ParticleSystem`;
const componentInfo = await Scene.Component.query({ path: componentPath });
if (componentInfo && typeof componentInfo === 'object') {
// IComponentInfo.value.uuid.value 或直接 uuid 字段,兼容两种 dump 结构
const anyInfo = componentInfo as any;
return anyInfo?.value?.uuid?.value ?? anyInfo?.uuid ?? null;
}
}
return null;
}
}
3 changes: 3 additions & 0 deletions src/api/scene/scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,22 @@ import { ComponentApi } from './component';
import { NodeApi } from './node';
import { PrefabApi } from './prefab';
import { ReferenceImageApi } from './reference-image';
import { ParticleApi } from './particle';
import { options } from '../../core/builder/platforms/android/i18n/en';

export class SceneApi {
public component: ComponentApi;
public node: NodeApi;
public prefab: PrefabApi;
public referenceImage: ReferenceImageApi;
public particle: ParticleApi;

constructor() {
this.component = new ComponentApi();
this.node = new NodeApi();
this.prefab = new PrefabApi();
this.referenceImage = new ReferenceImageApi();
this.particle = new ParticleApi();
}

@tool('scene-query-current')
Expand Down
1 change: 1 addition & 0 deletions src/core/scene/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ export * from './preview';
export * from './ui';
export * from './message';
export * from './reference-image';
export * from './particle';
72 changes: 72 additions & 0 deletions src/core/scene/common/particle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* 粒子系统服务接口,与 cocos-editor ParticleManager 对齐。
* 负责管理粒子系统在编辑模式下的播放、停止、暂停、重启、
* 播放速度与运行时信息查询等能力。
*
* 这些方法对应 cocos-editor 中 float-window / inspector
* 通过 callSceneMethod 调用的 playParticle / pauseParticle /
* stopParticle / restartParticle / setParticlePlaySpeed /
* queryParticlePlayInfo 等场景方法。
*/

/**
* queryPlayInfo 返回的粒子运行时数据
*/
export interface IParticlePlayInfo {
/** 粒子系统的模拟速度 */
speed: number;
/** 当前已模拟的时间(秒,保留 2 位小数) */
time: number;
/** 当前存活的粒子数量 */
particle: number;
/** 是否正在播放 */
isPlaying: boolean;
}

export interface IParticleService {
/**
* 请求粒子系统运行时的数据
* @param uuid 粒子组件的 uuid
*/
queryPlayInfo(uuid: string): IParticlePlayInfo | null;

/**
* 设置粒子的运行速度
* @param uuid 组件的 uuid
* @param speed 粒子组件的运行速度
*/
setPlaySpeed(uuid: string, speed: number): void;

/**
* 播放选中的粒子,会递归查找父节点,直到找到非粒子组件的节点为止
*/
play(): void;

/**
* 停止播放选中的粒子
*/
stop(): void;

/**
* 暂停选中的粒子
*/
pause(): void;

/**
* 重新开始播放选中的粒子
*/
restart(): void;
}

/**
* 对外暴露的公共方法集合(通过 RPC 可被主进程调用)。
* 场景进程内部服务 IParticleService 是同步的,但跨进程 RPC 调用必须返回 Promise。
*/
export type IPublicParticleService = {
queryPlayInfo(uuid: string): Promise<IParticlePlayInfo | null>;
setPlaySpeed(uuid: string, speed: number): Promise<void>;
play(): Promise<void>;
stop(): Promise<void>;
pause(): Promise<void>;
restart(): Promise<void>;
};
3 changes: 3 additions & 0 deletions src/core/scene/main-process/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { AssetProxy } from './proxy/asset-proxy';
import { EngineProxy } from './proxy/engine-proxy';
import { PrefabProxy } from './proxy/prefab-proxy';
import { ReferenceImageProxy } from './proxy/reference-image-proxy';
import { ParticleProxy } from './proxy/particle-proxy';

import { assetManager } from '../../assets';
import scriptManager from '../../scripting';
Expand All @@ -31,6 +32,8 @@ export const Scene = {
...EngineProxy,
...PrefabProxy,
ReferenceImage: ReferenceImageProxy,
// 粒子系统相关接口(play/pause/stop/restart/setPlaySpeed/queryPlayInfo)
Particle: ParticleProxy,
// 节点相关的接口
Node: NodeProxy,
// 组件相关的接口
Expand Down
Loading
Loading