From 49c6dfb1900d99cf22f9d46892a1fa1e9c295847 Mon Sep 17 00:00:00 2001 From: looopmax Date: Thu, 3 Sep 2026 11:43:25 +0800 Subject: [PATCH 01/11] feat(assets): expose pose graph enter info, drag handlers and stash references - derive pose node enter info (state-machine / animation-blend / stash) without the editor-only `getEnterInfo` prototype methods - add `queryPoseGraphAssetDragHandlers` and the `create-pose-node-on-asset-drag` command to create motion pose nodes from dropped assets - add `queryStateMachineComponentTypes` for state machine component menus - count layer stash references via `visitStashReferences` - add `set-transition-condition-binding-class` and `set-transition-event-binding` commands with transition condition binding dumps and event binding fields Co-authored-by: CommandCodeBot --- .../__snapshots__/dts-snapshot.test.ts.snap | 49 ++- src/core/assets/@types/public.d.ts | 42 ++- src/core/assets/animation-graph-service.ts | 280 +++++++++++++++++- src/core/assets/manager/asset.ts | 4 + .../test/animation-graph-service.test.ts | 240 ++++++++++++++- src/lib/assets/assets.ts | 10 +- 6 files changed, 616 insertions(+), 9 deletions(-) diff --git a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap index 6018bbcaf..20557af6a 100644 --- a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap +++ b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap @@ -7,6 +7,8 @@ exports[`DTS API compatibility assets.d.ts should match snapshot 1`] = ` export declare const animationGraph: { query(uuidOrUrlOrPath: string): Promise; queryInspector(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise; + queryPoseGraphAssetDragHandlers(): Promise; + queryStateMachineComponentTypes(): Promise; setInspectorProperty(uuidOrUrlOrPath: string, request: SetAnimationGraphInspectorPropertyRequest): Promise; resetInspectorProperty(uuidOrUrlOrPath: string, request: AnimationGraphInspectorPropertyOperationRequest): Promise; createInspectorProperty(uuidOrUrlOrPath: string, request: AnimationGraphInspectorPropertyOperationRequest): Promise; @@ -26,7 +28,7 @@ export declare type AnimationGraphCommand = | { type: 'add-layer'; name?: string } | { type: 'remove-layer'; layerIndex: number } | { type: 'move-layer'; layerIndex: number; newIndex: number } -| ({ type: 'add-state'; stateType: AnimationGraphStateType; name?: string; editorData?: Record } & AnimationGraphStateMachineAddress) +| ({ type: 'add-state'; stateType: AnimationGraphStateType; name?: string; clipUuid?: string; editorData?: Record } & AnimationGraphStateMachineAddress) | ({ type: 'remove-state' } & AnimationGraphStateAddress) | ({ type: 'duplicate-state'; includeTransitions?: boolean; editorData?: Record } & AnimationGraphStateAddress) | ({ type: 'set-state-editor-data'; editorData: Record } & AnimationGraphStateAddress) @@ -36,6 +38,8 @@ export declare type AnimationGraphCommand = | { type: 'add-transition-condition'; target: Extract; conditionType: AnimationGraphTransitionConditionType } | { type: 'remove-transition-condition'; target: Extract; conditionIndex: number } | { type: 'set-transition-condition-property'; target: Extract; conditionIndex: number; path: string; value: unknown } +| { type: 'set-transition-condition-binding-class'; target: Extract; conditionIndex: number; bindingClass: string } +| ({ type: 'set-transition-event-binding'; transitionIndex: number; which: 'start' | 'end'; methodName: string } & AnimationGraphStateMachineAddress) | ({ type: 'set-motion'; motionType: AnimationGraphMotionType | 'none'; clipUuid?: string } & (AnimationGraphStateAddress | { poseGraph: AnimationGraphPoseGraphContext; nodeId: number })) | { type: 'add-motion-child'; target: Extract; motionType: AnimationGraphMotionType; clipUuid?: string } | { type: 'remove-motion'; target: Extract } @@ -45,6 +49,7 @@ export declare type AnimationGraphCommand = | ({ type: 'add-state-component'; componentType: string } & AnimationGraphStateAddress) | ({ type: 'remove-state-component'; componentIndex: number } & AnimationGraphStateAddress) | ({ type: 'add-pose-node'; nodeType: string; createArg?: unknown; editorData?: Record } & AnimationGraphPoseGraphAddress) +| ({ type: 'create-pose-node-on-asset-drag'; assetUuid: string; handlerId: string; editorData?: Record } & AnimationGraphPoseGraphAddress) | { type: 'remove-pose-node'; target: Extract } | ({ type: 'duplicate-pose-nodes'; nodeIds: number[] } & AnimationGraphPoseGraphAddress) | { type: 'set-pose-node-editor-data'; target: Extract; editorData: Record } @@ -131,6 +136,14 @@ export declare interface AnimationGraphMotionView { export declare type AnimationGraphPoseGraphAddress = | { layerIndex: number; stateMachinePath: number[]; stateIndex: number } | { poseGraph: AnimationGraphPoseGraphContext }; +export declare interface AnimationGraphPoseGraphAssetDragHandlerInfo { + id: string; + displayName: string; +} +export declare interface AnimationGraphPoseGraphAssetDragHandlersEntry { + assetType: string; + handlers: AnimationGraphPoseGraphAssetDragHandlerInfo[]; +} export declare type AnimationGraphPoseGraphContext = | { kind: 'state-pose-graph'; stateMachine: AnimationGraphStateMachineContext; stateIndex: number } | { kind: 'layer-stash'; layerIndex: number; stashName: string }; @@ -146,6 +159,10 @@ export declare interface AnimationGraphPoseInputView { value?: IProperty; } export declare type AnimationGraphPoseNodeAddress = AnimationGraphPoseGraphAddress & { nodeId: number }; +export declare interface AnimationGraphPoseNodeEnterInfo { + type: 'state-machine' | 'animation-blend' | 'stash'; + stashName?: string; +} export declare interface AnimationGraphPoseNodeView { id: number; type: string; @@ -155,6 +172,7 @@ export declare interface AnimationGraphPoseNodeView { inputInsertInfos: Record; stateMachine?: AnimationGraphStateMachineView; motion?: AnimationGraphMotionView | null; + enterInfo?: AnimationGraphPoseNodeEnterInfo; editorData?: Record; } export declare interface AnimationGraphPoseView { @@ -215,6 +233,7 @@ export declare type AnimationGraphTransitionConditionView = operator: number; lhs: number; lhsBinding: Record; + bindingClass: string; rhs: number; isRhsInteger: boolean; } @@ -247,6 +266,8 @@ export declare interface AnimationGraphTransitionView { exitCondition?: number; destinationStart?: number; relativeDestinationStart?: boolean; + startEvent?: string; + endEvent?: string; editorData?: Record; } export declare interface AnimationGraphVariableView { @@ -7949,6 +7970,8 @@ export declare interface AnimationClipAssetUserData { export declare const animationGraph: { query(uuidOrUrlOrPath: string): Promise; queryInspector(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise; + queryPoseGraphAssetDragHandlers(): Promise; + queryStateMachineComponentTypes(): Promise; setInspectorProperty(uuidOrUrlOrPath: string, request: SetAnimationGraphInspectorPropertyRequest): Promise; resetInspectorProperty(uuidOrUrlOrPath: string, request: AnimationGraphInspectorPropertyOperationRequest): Promise; createInspectorProperty(uuidOrUrlOrPath: string, request: AnimationGraphInspectorPropertyOperationRequest): Promise; @@ -7968,7 +7991,7 @@ export declare type AnimationGraphCommand = | { type: 'add-layer'; name?: string } | { type: 'remove-layer'; layerIndex: number } | { type: 'move-layer'; layerIndex: number; newIndex: number } -| ({ type: 'add-state'; stateType: AnimationGraphStateType; name?: string; editorData?: Record } & AnimationGraphStateMachineAddress) +| ({ type: 'add-state'; stateType: AnimationGraphStateType; name?: string; clipUuid?: string; editorData?: Record } & AnimationGraphStateMachineAddress) | ({ type: 'remove-state' } & AnimationGraphStateAddress) | ({ type: 'duplicate-state'; includeTransitions?: boolean; editorData?: Record } & AnimationGraphStateAddress) | ({ type: 'set-state-editor-data'; editorData: Record } & AnimationGraphStateAddress) @@ -7978,6 +8001,8 @@ export declare type AnimationGraphCommand = | { type: 'add-transition-condition'; target: Extract; conditionType: AnimationGraphTransitionConditionType } | { type: 'remove-transition-condition'; target: Extract; conditionIndex: number } | { type: 'set-transition-condition-property'; target: Extract; conditionIndex: number; path: string; value: unknown } +| { type: 'set-transition-condition-binding-class'; target: Extract; conditionIndex: number; bindingClass: string } +| ({ type: 'set-transition-event-binding'; transitionIndex: number; which: 'start' | 'end'; methodName: string } & AnimationGraphStateMachineAddress) | ({ type: 'set-motion'; motionType: AnimationGraphMotionType | 'none'; clipUuid?: string } & (AnimationGraphStateAddress | { poseGraph: AnimationGraphPoseGraphContext; nodeId: number })) | { type: 'add-motion-child'; target: Extract; motionType: AnimationGraphMotionType; clipUuid?: string } | { type: 'remove-motion'; target: Extract } @@ -7987,6 +8012,7 @@ export declare type AnimationGraphCommand = | ({ type: 'add-state-component'; componentType: string } & AnimationGraphStateAddress) | ({ type: 'remove-state-component'; componentIndex: number } & AnimationGraphStateAddress) | ({ type: 'add-pose-node'; nodeType: string; createArg?: unknown; editorData?: Record } & AnimationGraphPoseGraphAddress) +| ({ type: 'create-pose-node-on-asset-drag'; assetUuid: string; handlerId: string; editorData?: Record } & AnimationGraphPoseGraphAddress) | { type: 'remove-pose-node'; target: Extract } | ({ type: 'duplicate-pose-nodes'; nodeIds: number[] } & AnimationGraphPoseGraphAddress) | { type: 'set-pose-node-editor-data'; target: Extract; editorData: Record } @@ -8073,6 +8099,14 @@ export declare interface AnimationGraphMotionView { export declare type AnimationGraphPoseGraphAddress = | { layerIndex: number; stateMachinePath: number[]; stateIndex: number } | { poseGraph: AnimationGraphPoseGraphContext }; +export declare interface AnimationGraphPoseGraphAssetDragHandlerInfo { + id: string; + displayName: string; +} +export declare interface AnimationGraphPoseGraphAssetDragHandlersEntry { + assetType: string; + handlers: AnimationGraphPoseGraphAssetDragHandlerInfo[]; +} export declare type AnimationGraphPoseGraphContext = | { kind: 'state-pose-graph'; stateMachine: AnimationGraphStateMachineContext; stateIndex: number } | { kind: 'layer-stash'; layerIndex: number; stashName: string }; @@ -8088,6 +8122,10 @@ export declare interface AnimationGraphPoseInputView { value?: IProperty; } export declare type AnimationGraphPoseNodeAddress = AnimationGraphPoseGraphAddress & { nodeId: number }; +export declare interface AnimationGraphPoseNodeEnterInfo { + type: 'state-machine' | 'animation-blend' | 'stash'; + stashName?: string; +} export declare interface AnimationGraphPoseNodeView { id: number; type: string; @@ -8097,6 +8135,7 @@ export declare interface AnimationGraphPoseNodeView { inputInsertInfos: Record; stateMachine?: AnimationGraphStateMachineView; motion?: AnimationGraphMotionView | null; + enterInfo?: AnimationGraphPoseNodeEnterInfo; editorData?: Record; } export declare interface AnimationGraphPoseView { @@ -8157,6 +8196,7 @@ export declare type AnimationGraphTransitionConditionView = operator: number; lhs: number; lhsBinding: Record; + bindingClass: string; rhs: number; isRhsInteger: boolean; } @@ -8189,6 +8229,8 @@ export declare interface AnimationGraphTransitionView { exitCondition?: number; destinationStart?: number; relativeDestinationStart?: boolean; + startEvent?: string; + endEvent?: string; editorData?: Record; } export declare interface AnimationGraphVariableView { @@ -8420,8 +8462,11 @@ export declare namespace Assets { AnimationGraphComponentView, AnimationGraphMotionView, AnimationGraphPoseInputView, + AnimationGraphPoseNodeEnterInfo, AnimationGraphPoseNodeView, AnimationGraphPoseView, + AnimationGraphPoseGraphAssetDragHandlerInfo, + AnimationGraphPoseGraphAssetDragHandlersEntry, AnimationGraphStateView, AnimationGraphTransitionView, AnimationGraphTransitionConditionView, diff --git a/src/core/assets/@types/public.d.ts b/src/core/assets/@types/public.d.ts index dfb1d132c..af15818c3 100644 --- a/src/core/assets/@types/public.d.ts +++ b/src/core/assets/@types/public.d.ts @@ -118,6 +118,11 @@ export interface AnimationGraphPoseInputView { value?: IProperty; } +export interface AnimationGraphPoseNodeEnterInfo { + type: 'state-machine' | 'animation-blend' | 'stash'; + stashName?: string; +} + export interface AnimationGraphPoseNodeView { id: number; type: string; @@ -127,13 +132,40 @@ export interface AnimationGraphPoseNodeView { inputInsertInfos: Record; stateMachine?: AnimationGraphStateMachineView; motion?: AnimationGraphMotionView | null; + enterInfo?: AnimationGraphPoseNodeEnterInfo; editorData?: Record; } +export interface AnimationGraphPoseGraphAddNodeInfo { + typeId: string; + args: unknown; + menu: string; +} + +export interface AnimationGraphPoseGraphAssetDragHandlerView { + displayName: string; +} + +export interface AnimationGraphPoseGraphAssetDragHandlersView { + handlers: Record; +} + export interface AnimationGraphPoseView { context: AnimationGraphPoseGraphContext; rootOutputNodeId: number; nodes: AnimationGraphPoseNodeView[]; + addNodeInfos: AnimationGraphPoseGraphAddNodeInfo[]; + assetDragHandlersMap: Record; +} + +export interface AnimationGraphPoseGraphAssetDragHandlerInfo { + id: string; + displayName: string; +} + +export interface AnimationGraphPoseGraphAssetDragHandlersEntry { + assetType: string; + handlers: AnimationGraphPoseGraphAssetDragHandlerInfo[]; } export interface AnimationGraphStateView { @@ -165,6 +197,8 @@ export interface AnimationGraphTransitionView { exitCondition?: number; destinationStart?: number; relativeDestinationStart?: boolean; + startEvent?: string; + endEvent?: string; editorData?: Record; } @@ -175,6 +209,7 @@ export type AnimationGraphTransitionConditionView = operator: number; lhs: number; lhsBinding: Record; + bindingClass: string; rhs: number; isRhsInteger: boolean; } @@ -211,7 +246,7 @@ export interface AnimationGraphLayerView { additive: boolean; maskUuid: string | null; stashes: string[]; - stashPoseGraphs: Array<{ name: string; poseGraph: AnimationGraphPoseView }>; + stashPoseGraphs: Array<{ name: string; poseGraph: AnimationGraphPoseView; referenceCount?: number }>; stateMachine: AnimationGraphStateMachineView; } @@ -265,7 +300,7 @@ export type AnimationGraphCommand = | { type: 'add-layer'; name?: string } | { type: 'remove-layer'; layerIndex: number } | { type: 'move-layer'; layerIndex: number; newIndex: number } - | ({ type: 'add-state'; stateType: AnimationGraphStateType; name?: string; editorData?: Record } & AnimationGraphStateMachineAddress) + | ({ type: 'add-state'; stateType: AnimationGraphStateType; name?: string; clipUuid?: string; editorData?: Record } & AnimationGraphStateMachineAddress) | ({ type: 'remove-state' } & AnimationGraphStateAddress) | ({ type: 'duplicate-state'; includeTransitions?: boolean; editorData?: Record } & AnimationGraphStateAddress) | ({ type: 'set-state-editor-data'; editorData: Record } & AnimationGraphStateAddress) @@ -275,6 +310,8 @@ export type AnimationGraphCommand = | { type: 'add-transition-condition'; target: Extract; conditionType: AnimationGraphTransitionConditionType } | { type: 'remove-transition-condition'; target: Extract; conditionIndex: number } | { type: 'set-transition-condition-property'; target: Extract; conditionIndex: number; path: string; value: unknown } + | { type: 'set-transition-condition-binding-class'; target: Extract; conditionIndex: number; bindingClass: string } + | ({ type: 'set-transition-event-binding'; transitionIndex: number; which: 'start' | 'end'; methodName: string } & AnimationGraphStateMachineAddress) | ({ type: 'set-motion'; motionType: AnimationGraphMotionType | 'none'; clipUuid?: string } & (AnimationGraphStateAddress | { poseGraph: AnimationGraphPoseGraphContext; nodeId: number })) | { type: 'add-motion-child'; target: Extract; motionType: AnimationGraphMotionType; clipUuid?: string } | { type: 'remove-motion'; target: Extract } @@ -284,6 +321,7 @@ export type AnimationGraphCommand = | ({ type: 'add-state-component'; componentType: string } & AnimationGraphStateAddress) | ({ type: 'remove-state-component'; componentIndex: number } & AnimationGraphStateAddress) | ({ type: 'add-pose-node'; nodeType: string; createArg?: unknown; editorData?: Record } & AnimationGraphPoseGraphAddress) + | ({ type: 'create-pose-node-on-asset-drag'; assetUuid: string; handlerId: string; editorData?: Record } & AnimationGraphPoseGraphAddress) | { type: 'remove-pose-node'; target: Extract } | ({ type: 'duplicate-pose-nodes'; nodeIds: number[] } & AnimationGraphPoseGraphAddress) | { type: 'set-pose-node-editor-data'; target: Extract; editorData: Record } diff --git a/src/core/assets/animation-graph-service.ts b/src/core/assets/animation-graph-service.ts index 6f0bcecf3..cadf7f1e2 100644 --- a/src/core/assets/animation-graph-service.ts +++ b/src/core/assets/animation-graph-service.ts @@ -12,6 +12,9 @@ import type { AnimationGraphLayerView, AnimationGraphMotionType, AnimationGraphMotionView, + AnimationGraphPoseGraphAssetDragHandlersEntry, + AnimationGraphPoseGraphAssetDragHandlersView, + AnimationGraphPoseGraphAddNodeInfo, AnimationGraphPoseGraphContext, AnimationGraphPoseView, AnimationGraphSnapshot, @@ -123,6 +126,106 @@ class AnimationGraphAssetService { }); } + async queryPoseGraphAssetDragHandlers(): Promise { + const api = getNewGenAnim(); + const js = getCC().js; + const result: AnimationGraphPoseGraphAssetDragHandlersEntry[] = []; + for (const [ctor, info] of api.getPoseGraphAssetDragHandlersMap()) { + result.push({ + assetType: js.getClassName(ctor) || ctor.name, + handlers: Object.entries<{ displayName: string }>(info.handlers).map(([id, handler]) => ({ + id, + displayName: handler.displayName, + })), + }); + } + return result; + } + + /** + * Projects the engine's registered pose-node factories and drag handlers for the webview. + * + * ```mermaid + * flowchart LR + * Registry[cc.js class registry] --> Factories[getCreatePoseGraphNodeEntries] + * Factories --> Menu[serialized add-node menu] + * Drag[pose-graph drag registry] --> Handlers[serialized handler map] + * ``` + */ + private _queryPoseGraphEditingMetadata( + document: AnimationGraphDocument, + layerIndex: number, + ): { + addNodeInfos: AnimationGraphPoseGraphAddNodeInfo[]; + assetDragHandlersMap: Record; + } { + const api = getNewGenAnim(); + const js = getCC().js; + const poseNodeBase = js.getClassByName('cc.animation.PoseNode'); + const pureValueNodeBase = js.getClassByName('cc.animation.PureValueNode'); + const registered = (js._nameToClass ?? js._registeredClassNames) as Record object> | undefined; + const addNodeInfos: AnimationGraphPoseGraphAddNodeInfo[] = []; + if (registered && poseNodeBase && pureValueNodeBase) { + const constructors = new Set(Object.values(registered)); + for (const ctor of constructors) { + if (ctor === poseNodeBase || ctor === pureValueNodeBase + || (!js.isChildClassOf(ctor, poseNodeBase) && !js.isChildClassOf(ctor, pureValueNodeBase))) { + continue; + } + const typeId = js.getClassName(ctor) || Object.keys(registered).find(name => registered[name] === ctor); + if (!typeId) { + continue; + } + for (const entry of api.getCreatePoseGraphNodeEntries(ctor as any, { + animationGraph: document.graph, + layerIndex, + })) { + const menu = [ + entry.category, + `i18n:ENGINE.classes.${typeId}.displayName`, + entry.subMenu, + ].filter((segment): segment is string => typeof segment === 'string' && segment.length > 0) + .map(segment => segment.replace(/\/+$/, '')) + .join('/'); + addNodeInfos.push({ typeId, args: entry.arg ?? null, menu }); + } + } + } + + const assetDragHandlersMap: Record = {}; + for (const [ctor, info] of api.getPoseGraphAssetDragHandlersMap()) { + const assetType = js.getClassName(ctor) || ctor.name; + if (assetType) { + assetDragHandlersMap[assetType] = { + handlers: Object.fromEntries(Object.entries(info.handlers as Record).map(([id, handler]) => [id, { + displayName: handler.displayName, + }])), + }; + } + } + return { addNodeInfos, assetDragHandlersMap }; + } + + async queryStateMachineComponentTypes(): Promise { + const js = getCC().js; + const base = js.getClassByName('cc.animation.StateMachineComponent'); + if (!base) { + throw new Error('State machine component base class can not be found: cc.animation.StateMachineComponent'); + } + const result: string[] = []; + // 当前引擎版本的 cc.js 直接暴露 js-typed 的 _nameToClass,_registeredClassNames 仅作兼容兜底。 + const registered = (js._nameToClass ?? js._registeredClassNames) as Record object> | undefined; + if (!registered) { + throw new Error('The engine js class registry is not available.'); + } + for (const [name, ctor] of Object.entries(registered)) { + if (ctor !== base && js.isChildClassOf(ctor, base)) { + result.push(name); + } + } + return result.sort(); + } + async setInspectorProperty( uuidOrUrlOrPath: string, request: SetAnimationGraphInspectorPropertyRequest, @@ -528,6 +631,7 @@ class AnimationGraphAssetService { layerIndex: index, stashName: name, }), + referenceCount: countStashReferences(layer, name), })), stateMachine: this._queryStateMachine(document, layer.stateMachine, stateMachineContext, []), }; @@ -634,6 +738,8 @@ class AnimationGraphAssetService { view.exitConditionEnabled = !!transition.exitConditionEnabled; view.exitCondition = transition.exitCondition; } + view.startEvent = transition.startEventBinding?.methodName ?? ''; + view.endEvent = transition.endEventBinding?.methodName ?? ''; return view; } @@ -646,6 +752,7 @@ class AnimationGraphAssetService { operator: condition.operator, lhs: condition.lhs, lhsBinding: dumpTransitionConditionBinding(condition.lhsBinding), + bindingClass: getClassName(condition.lhsBinding), rhs: condition.rhs, isRhsInteger: condition.lhsBinding?.getValueType?.() === api.TCBindingValueType.INTEGER, }; @@ -731,9 +838,11 @@ class AnimationGraphAssetService { const api = getNewGenAnim(); const nodes = Array.from(poseGraph.nodes() as Iterable); const rootOutputNodeId = this._nodeId(document, poseGraph.outputNode); + const editingMetadata = this._queryPoseGraphEditingMetadata(document, getPoseGraphLayerIndex(context)); return { context: clonePlain(context), rootOutputNodeId, + ...editingMetadata, nodes: nodes.map((node) => { const id = this._nodeId(document, node); const view: import('./@types/public').AnimationGraphPoseNodeView = { @@ -762,7 +871,15 @@ class AnimationGraphAssetService { inputInsertInfos: clonePlain(api.poseGraphOp.getInputInsertInfos(node)), editorData: getEditorData(node), }; - const enterInfo = node.getEnterInfo?.(); + const enterInfo = getPoseNodeEnterInfo(node, api); + if (enterInfo) { + view.enterInfo = { + type: enterInfo.type, + ...(enterInfo.type === 'stash' && typeof enterInfo.stashName === 'string' + ? { stashName: enterInfo.stashName } + : {}), + }; + } const nestedStateMachine = enterInfo?.type === 'state-machine' ? enterInfo.target : isStateMachineLike(node.stateMachine) ? node.stateMachine : undefined; @@ -986,7 +1103,7 @@ class AnimationGraphAssetService { poseGraph: context.poseGraph, nodeId: context.nodeId, }); - const enterInfo = node.getEnterInfo?.(); + const enterInfo = getPoseNodeEnterInfo(node, getNewGenAnim()); const stateMachine = enterInfo?.type === 'state-machine' ? enterInfo.target : isStateMachineLike(node.stateMachine) ? node.stateMachine : undefined; @@ -1155,6 +1272,14 @@ class AnimationGraphAssetService { const stateMachine = this._getStateMachineForAddress(document, command); const state = createState(stateMachine, command.stateType); state.name = command.name || uniqueStateName(stateMachine, defaultStateName(command.stateType)); + if (command.clipUuid !== undefined) { + // clipUuid 仅对动画状态有效:创建后立即挂上 ClipMotion(等价 add-state + set-motion 一次完成)。 + const api = getNewGenAnim(); + if (!(state instanceof api.MotionState)) { + throw new AnimationGraphEditError('INVALID_PROPERTY_PATCH', 'A clip can only be attached to a motion state.', this._version(document)); + } + state.motion = this._createMotion('clip', command.clipUuid); + } assignEditorData(state, command.editorData); return; } @@ -1229,6 +1354,29 @@ class AnimationGraphAssetService { setTransitionConditionProperty(condition, command.path, command.value, api); return; } + case 'set-transition-condition-binding-class': { + const { transition } = this._resolveTransition(document, command.target); + const condition = transition.conditions[command.conditionIndex]; + if (!(condition instanceof api.BinaryCondition)) { + throw this._targetNotFound(document, command); + } + const bindingClass = requireTransitionConditionBindingClass('bindingClass', command.bindingClass); + const ctor = getCC().js.getClassByName(bindingClass); + if (!ctor) { + throw new AnimationGraphEditError('TARGET_NOT_FOUND', `Transition condition binding class can not be found: ${bindingClass}`, this._version(document)); + } + condition.lhsBinding = new ctor(); + return; + } + case 'set-transition-event-binding': { + const { transition } = this._resolveTransition(document, command); + const binding = command.which === 'start' ? transition.startEventBinding : transition.endEventBinding; + if (!binding) { + throw this._targetNotFound(document, command); + } + binding.methodName = requireString('methodName', command.methodName); + return; + } case 'set-motion': { const motion = command.motionType === 'none' ? null @@ -1352,6 +1500,57 @@ class AnimationGraphAssetService { this._nodeId(document, node); return; } + case 'create-pose-node-on-asset-drag': { + const poseGraph = this._resolvePoseGraph(document, command); + const js = getCC().js; + const asset = assetQuery.queryAsset(command.assetUuid); + if (!asset) { + throw new AnimationGraphEditError('TARGET_NOT_FOUND', `Asset can not be found: ${command.assetUuid}`, this._version(document)); + } + const assetType = assetQuery.queryAssetProperty(asset, 'type') as string; + const assetCtor = typeof assetType === 'string' ? js.getClassByName(assetType) : undefined; + if (!assetCtor || !js.isChildClassOf(assetCtor, getCC().Asset)) { + throw new AnimationGraphEditError('TARGET_NOT_FOUND', `Asset type can not be found: ${assetType}`, this._version(document)); + } + // 引擎按资产构造器精确匹配注册表(registry.get(asset.constructor)), + // 这里先自行校验,把引擎的 console.warn + undefined 转换为明确的错误。 + let registered: { handlers: Record } | undefined; + for (const [ctor, info] of api.getPoseGraphAssetDragHandlersMap()) { + if (ctor === assetCtor) { + registered = info; + break; + } + } + if (!registered) { + throw new AnimationGraphEditError( + 'TARGET_NOT_FOUND', + `No pose graph asset drag handlers for asset type: ${assetType}`, + this._version(document), + ); + } + if (!(command.handlerId in registered.handlers)) { + throw new AnimationGraphEditError( + 'TARGET_NOT_FOUND', + `Pose graph asset drag handler can not be found: ${command.handlerId}, existing handlers are ${Object.keys(registered.handlers).join(',')}`, + this._version(document), + ); + } + // serialize.asAsset 生成的 stub 是资产构造器的真实实例(仅设置 _uuid), + // 内置 handler 只是把它赋给 motion.clip 字段,因此 stub 即可满足。 + const reference = this._createAssetReference(command.assetUuid, assetCtor); + const node = api.createPoseNodeOnAssetDrag(reference, command.handlerId); + if (!node) { + throw new AnimationGraphEditError( + 'INVALID_PROPERTY_PATCH', + `Pose graph asset drag handler ${command.handlerId} did not create a pose node for asset: ${command.assetUuid}`, + this._version(document), + ); + } + poseGraph.addNode(node); + assignEditorData(node, command.editorData); + this._nodeId(document, node); + return; + } case 'remove-pose-node': { const { poseGraph, node } = this._resolvePoseNode(document, command.target); if (node === poseGraph.outputNode) { @@ -1707,7 +1906,7 @@ class AnimationGraphAssetService { visitedPoseGraphs.add(poseGraph); for (const node of poseGraph.nodes() as Iterable) { nodes.push(node); - const enterInfo = node.getEnterInfo?.(); + const enterInfo = getPoseNodeEnterInfo(node, getNewGenAnim()); const stateMachine = enterInfo?.type === 'state-machine' ? enterInfo.target : isStateMachineLike(node.stateMachine) ? node.stateMachine : undefined; @@ -1799,6 +1998,34 @@ function getClassName(value: any): string { return getCC().js.getClassName(value) || value.constructor?.name || 'Unknown'; } +/** Counts the pose nodes that reference a Layer Stash; failures degrade to 0 with a warning. */ +function countStashReferences(layer: any, stashName: string): number { + try { + return Array.from(getNewGenAnim().visitStashReferences(layer, stashName)).length; + } catch (error) { + console.warn(`[animation-graph] failed to count references of stash "${stashName}".`, error); + return 0; + } +} + +function getPoseGraphLayerIndex(context: AnimationGraphPoseGraphContext): number { + if (context.kind === 'layer-stash') { + return context.layerIndex; + } + return getStateMachineLayerIndex(context.stateMachine); +} + +function getStateMachineLayerIndex(context: AnimationGraphStateMachineContext): number { + switch (context.kind) { + case 'layer-state-machine': + return context.layerIndex; + case 'pose-node-state-machine': + return getPoseGraphLayerIndex(context.poseGraph); + case 'sub-state-machine': + return getStateMachineLayerIndex(context.stateMachine); + } +} + function getAssetUuid(value: any): string | null { const uuid = value?._uuid || value?.uuid; return typeof uuid === 'string' && uuid ? uuid : null; @@ -1986,6 +2213,34 @@ function isStateMachineLike(value: any): boolean { && typeof value.transitions === 'function'; } +interface PoseNodeEnterInfoLike { + type: 'state-machine' | 'animation-blend' | 'stash'; + target?: unknown; + stashName?: string; +} + +function getPoseNodeEnterInfo(node: any, api: any): PoseNodeEnterInfoLike | undefined { + if (typeof node.getEnterInfo === 'function') { + return node.getEnterInfo(); + } + // CLI 运行时 EDITOR 为 false,引擎不会安装 if (EDITOR) 守卫内的 getEnterInfo 原型方法, + // 这里按引擎实现等价推导(pose-nodes/state-machine.ts、use-stashed-pose.ts、 + // play-or-sample-motion-pose-node-shared.ts)。 + const js = getCC().js; + const stateMachineNodeCtor = js.getClassByName('cc.animation.PoseNodeStateMachine'); + if (stateMachineNodeCtor && node instanceof stateMachineNodeCtor) { + return { type: 'state-machine', target: node.stateMachine }; + } + const useStashedPoseCtor = js.getClassByName('cc.animation.PoseNodeUseStashedPose'); + if (useStashedPoseCtor && node instanceof useStashedPoseCtor) { + return { type: 'stash', stashName: node.stashName }; + } + if (node.motion && node.motion instanceof api.AnimationBlend) { + return { type: 'animation-blend', target: node.motion }; + } + return undefined; +} + function isVec2Like(value: unknown): value is { x: number; y: number } { return !!value && typeof value === 'object' && typeof (value as { x?: unknown }).x === 'number' @@ -2235,6 +2490,22 @@ function createTransitionCondition(api: any, type: import('./@types/public').Ani } } +const transitionConditionBindingClasses: readonly string[] = [ + 'cc.animation.TCVariableBinding', + 'cc.animation.TCAuxiliaryCurveBinding', + 'cc.animation.TCStateWeightBinding', + 'cc.animation.TCStateMotionTimeBinding', +]; + +function requireTransitionConditionBindingClass(path: string, value: unknown): string { + const name = typeof value === 'string' ? value.trim() : ''; + const normalized = name.startsWith('cc.animation.') ? name : `cc.animation.${name}`; + if (!transitionConditionBindingClasses.includes(normalized)) { + throw new Error(`Transition condition property ${path} expects one of: ${transitionConditionBindingClasses.join(', ')}.`); + } + return normalized; +} + function setTransitionConditionProperty(condition: any, path: string, value: unknown, api: any): void { if (condition instanceof api.BinaryCondition) { switch (path) { @@ -2253,6 +2524,9 @@ function setTransitionConditionProperty(condition: any, path: string, value: unk case 'lhsBinding.variableName': condition.lhsBinding.variableName = requireString(path, value); return; + case 'lhsBinding.curveName': + condition.lhsBinding.curveName = requireString(path, value); + return; default: throw new Error(`Unsupported BinaryCondition property path: ${path}`); } diff --git a/src/core/assets/manager/asset.ts b/src/core/assets/manager/asset.ts index 13b7a2ec0..d12396f38 100644 --- a/src/core/assets/manager/asset.ts +++ b/src/core/assets/manager/asset.ts @@ -62,6 +62,8 @@ class AssetManager extends EventEmitter { // ---------- animation graph ---------- queryAnimationGraph = animationGraph.query.bind(animationGraph); queryAnimationGraphInspector = animationGraph.queryInspector.bind(animationGraph); + queryAnimationGraphPoseGraphAssetDragHandlers = animationGraph.queryPoseGraphAssetDragHandlers.bind(animationGraph); + queryAnimationGraphStateMachineComponentTypes = animationGraph.queryStateMachineComponentTypes.bind(animationGraph); setAnimationGraphInspectorProperty = animationGraph.setInspectorProperty.bind(animationGraph); resetAnimationGraphInspectorProperty = animationGraph.resetInspectorProperty.bind(animationGraph); createAnimationGraphInspectorProperty = animationGraph.createInspectorProperty.bind(animationGraph); @@ -392,6 +394,8 @@ export interface TypedAssetManager extends EventEmitter { queryAnimationGraph: typeof animationGraph.query; queryAnimationGraphInspector: typeof animationGraph.queryInspector; + queryAnimationGraphPoseGraphAssetDragHandlers: typeof animationGraph.queryPoseGraphAssetDragHandlers; + queryAnimationGraphStateMachineComponentTypes: typeof animationGraph.queryStateMachineComponentTypes; setAnimationGraphInspectorProperty: typeof animationGraph.setInspectorProperty; resetAnimationGraphInspectorProperty: typeof animationGraph.resetInspectorProperty; createAnimationGraphInspectorProperty: typeof animationGraph.createInspectorProperty; diff --git a/src/core/assets/test/animation-graph-service.test.ts b/src/core/assets/test/animation-graph-service.test.ts index 1fc9e9f2a..ad9c69d30 100644 --- a/src/core/assets/test/animation-graph-service.test.ts +++ b/src/core/assets/test/animation-graph-service.test.ts @@ -293,6 +293,61 @@ describe('animation graph asset service', () => { value: 0.4, }); + const withBindingSwitch = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'set-transition-condition-binding-class', + target: transitionTarget, + conditionIndex: 0, + bindingClass: 'cc.animation.TCAuxiliaryCurveBinding', + }, + expected: withRenamedVariable, + }); + expect(withBindingSwitch.graph.layers[0].stateMachine.transitions[transition!.index].conditions[0]).toMatchObject({ + type: 'BinaryCondition', + bindingClass: 'cc.animation.TCAuxiliaryCurveBinding', + }); + const withCurveName = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'set-transition-condition-property', + target: transitionTarget, + conditionIndex: 0, + path: 'lhsBinding.curveName', + value: 'LeftFoot', + }, + expected: withBindingSwitch, + }); + expect(withCurveName.graph.layers[0].stateMachine.transitions[transition!.index].conditions[0]).toMatchObject({ + bindingClass: 'cc.animation.TCAuxiliaryCurveBinding', + lhsBinding: { curveName: 'LeftFoot' }, + }); + + const withVariableBinding = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'set-transition-condition-binding-class', + target: transitionTarget, + conditionIndex: 0, + bindingClass: 'TCVariableBinding', + }, + expected: withCurveName, + }); + expect(withVariableBinding.graph.layers[0].stateMachine.transitions[transition!.index].conditions[0]).toMatchObject({ + bindingClass: 'cc.animation.TCVariableBinding', + }); + + const withEventBinding = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'set-transition-event-binding', + layerIndex: 0, + stateMachinePath: [], + transitionIndex: transition!.index, + which: 'start', + methodName: 'onTransitionStart', + }, + expected: withVariableBinding, + }); + expect(withEventBinding.graph.layers[0].stateMachine.transitions[transition!.index].startEvent).toBe('onTransitionStart'); + expect(withEventBinding.graph.layers[0].stateMachine.transitions[transition!.index].endEvent).toBe(''); + const withPoseState = await assetManager.executeAnimationGraphCommand(asset.uuid, { command: { type: 'add-state', @@ -301,7 +356,7 @@ describe('animation graph asset service', () => { stateType: 'procedural-pose', name: 'Pose', }, - expected: withRenamedVariable, + expected: withEventBinding, }); const poseState = withPoseState.graph.layers[0].stateMachine.states.find((state) => state.name === 'Pose'); expect(poseState?.poseGraph?.nodes.length).toBeGreaterThan(0); @@ -646,6 +701,7 @@ describe('animation graph asset service', () => { const updatedStash = snapshot.graph.layers[0].stashPoseGraphs.find((stash) => stash.name === 'Nested')!.poseGraph; const stateMachineNode = updatedStash.nodes.find((node) => node.type.includes('PoseNodeStateMachine'))!; expect(stateMachineNode.stateMachine?.states.map((state) => state.type)).toEqual(['entry', 'exit', 'any']); + expect(stateMachineNode.enterInfo).toEqual({ type: 'state-machine' }); snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { command: { @@ -1075,12 +1131,14 @@ describe('animation graph asset service', () => { expected: snapshot, }); const stashed = snapshot.graph.layers[0].stashPoseGraphs.find((stash) => stash.name === 'Locomotion')!.poseGraph; + expect(snapshot.graph.layers[0].stashPoseGraphs.find((stash) => stash.name === 'Locomotion')!.referenceCount).toBe(1); expect(stashed.nodes.find((node) => node.type.includes('PoseNodeBlendTwoPose'))?.editorData).toEqual({ centerX: -80, centerY: 24 }); expect(stashed.nodes.find((node) => node.type.includes('PoseNodeApplyTransform'))?.editorData).toEqual({ centerX: 40, centerY: 24 }); expect(stashed.nodes.some((node) => node.inputs.some((input) => input.connected))).toBe(true); const original = snapshot.graph.layers[0].stateMachine.states[stateIndex].poseGraph!; const useStashNode = original.nodes.find((node) => node.type.includes('PoseNodeUseStashedPose'))!; expect(useStashNode.editorData).toEqual({ centerX: 160, centerY: 24 }); + expect(useStashNode.enterInfo).toEqual({ type: 'stash', stashName: 'Locomotion' }); expect(original.nodes).toHaveLength(2); const document = (animationGraph as unknown as { _documents: Map }>; @@ -1245,6 +1303,129 @@ describe('animation graph asset service', () => { await assetManager.saveAnimationGraph(asset.uuid, motionInspector); }); + it('queries pose graph asset drag handlers and creates pose nodes from dragged assets', async () => { + const handlers = await assetManager.queryAnimationGraphPoseGraphAssetDragHandlers(); + const clipEntry = handlers.find((entry) => entry.assetType === 'cc.AnimationClip'); + expect(clipEntry).toBeDefined(); + expect(clipEntry!.handlers.length).toBeGreaterThan(0); + for (const handler of clipEntry!.handlers) { + expect(handler.id).toBeTruthy(); + expect(typeof handler.displayName).toBe('string'); + } + const handlerId = clipEntry!.handlers[0].id; + + const asset = await assetManager.createAsset({ + target: join(TestGlobalEnv.testRoot, `${name}-asset-drag.animgraph`), + content: getDefaultGraphContent(), + overwrite: true, + }); + const clip = await assetManager.createAsset({ + target: join(TestGlobalEnv.testRoot, `${name}-asset-drag.anim`), + content: readFileSync(join( + TestGlobalEnv.engineRoot, + 'editor/assets/default_file_content/animation-clip/default.anim', + ), 'utf8'), + overwrite: true, + }); + const mask = await assetManager.createAsset({ + target: join(TestGlobalEnv.testRoot, `${name}-asset-drag.animask`), + content: readFileSync(join( + TestGlobalEnv.engineRoot, + 'editor/assets/default_file_content/animation-mask/default.animask', + ), 'utf8'), + overwrite: true, + }); + + let snapshot = await assetManager.queryAnimationGraph(asset.uuid); + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state', + layerIndex: 0, + stateMachinePath: [], + stateType: 'procedural-pose', + name: 'Pose', + }, + expected: snapshot, + }); + const stateIndex = snapshot.graph.layers[0].stateMachine.states.find((state) => state.name === 'Pose')!.index; + const nodeCount = snapshot.graph.layers[0].stateMachine.states[stateIndex].poseGraph!.nodes.length; + + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'create-pose-node-on-asset-drag', + layerIndex: 0, + stateMachinePath: [], + stateIndex, + assetUuid: clip.uuid, + handlerId, + editorData: { centerX: 200, centerY: 40 }, + }, + expected: snapshot, + }); + const poseGraph = snapshot.graph.layers[0].stateMachine.states[stateIndex].poseGraph!; + expect(poseGraph.nodes).toHaveLength(nodeCount + 1); + const createdNode = poseGraph.nodes.find((node) => node.motion?.clipUuid === clip.uuid); + expect(createdNode).toBeDefined(); + expect(createdNode!.editorData).toEqual({ centerX: 200, centerY: 40 }); + + await expect(assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'create-pose-node-on-asset-drag', + layerIndex: 0, + stateMachinePath: [], + stateIndex, + assetUuid: clip.uuid, + handlerId: 'not-a-handler', + }, + expected: snapshot, + })).rejects.toMatchObject({ code: 'TARGET_NOT_FOUND', message: expect.stringContaining('not-a-handler') }); + + await expect(assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'create-pose-node-on-asset-drag', + layerIndex: 0, + stateMachinePath: [], + stateIndex, + assetUuid: mask.uuid, + handlerId, + }, + expected: snapshot, + })).rejects.toMatchObject({ code: 'TARGET_NOT_FOUND', message: expect.stringContaining('cc.AnimationMask') }); + + await expect(assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'create-pose-node-on-asset-drag', + layerIndex: 0, + stateMachinePath: [], + stateIndex, + assetUuid: 'missing-asset-uuid', + handlerId, + }, + expected: snapshot, + })).rejects.toMatchObject({ code: 'TARGET_NOT_FOUND', message: expect.stringContaining('missing-asset-uuid') }); + }); + + it('queries registered state machine component types', async () => { + const cc = require('cc'); + const initial = await assetManager.queryAnimationGraphStateMachineComponentTypes(); + expect(initial).not.toContain('cc.animation.StateMachineComponent'); + + const base = cc.js.getClassByName('cc.animation.StateMachineComponent'); + expect(base).toBeTruthy(); + const className = 'cc.animation.TestAgentStateMachineComponent'; + class TestAgentStateMachineComponent extends base {} + cc.js.setClassName(className, TestAgentStateMachineComponent); + try { + const types = await assetManager.queryAnimationGraphStateMachineComponentTypes(); + expect(types).toContain(className); + expect(types).not.toContain('cc.animation.StateMachineComponent'); + } finally { + cc.js.unregisterClass(TestAgentStateMachineComponent); + } + const restored = await assetManager.queryAnimationGraphStateMachineComponentTypes(); + expect(restored).not.toContain(className); + }); + it('blocks generic overwrite and directory mutations while a graph document is dirty', async () => { const directoryName = `${name}-dirty-directory`; const directoryPath = join(TestGlobalEnv.testRoot, directoryName); @@ -1279,4 +1460,61 @@ describe('animation graph asset service', () => { await assetManager.saveAnimationGraph(target.uuid, dirty); }); + + it('creates a motion state with an attached clip in one command and rejects clip-on-empty', async () => { + const asset = await assetManager.createAsset({ + target: join(TestGlobalEnv.testRoot, `${name}-add-state-clip.animgraph`), + content: getDefaultGraphContent(), + overwrite: true, + }); + const clip = await assetManager.createAsset({ + target: join(TestGlobalEnv.testRoot, `${name}-add-state-clip.anim`), + content: readFileSync(join( + TestGlobalEnv.engineRoot, + 'editor/assets/default_file_content/animation-clip/default.anim', + ), 'utf8'), + overwrite: true, + }); + const initial = await assetManager.queryAnimationGraph(asset.uuid); + + const withClipState = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state', + layerIndex: 0, + stateMachinePath: [], + stateType: 'motion', + name: 'Run', + clipUuid: clip.uuid, + editorData: { centerX: 30, centerY: 60 }, + }, + expected: initial, + }); + const clipState = withClipState.graph.layers[0].stateMachine.states.find((state) => state.name === 'Run'); + expect(clipState).toBeDefined(); + expect(clipState!.motion).toMatchObject({ type: 'clip' }); + expect(clipState!.motion?.clipUuid).toBe(clip.uuid); + expect(clipState!.editorData).toEqual({ centerX: 30, centerY: 60 }); + + await expect(assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state', + layerIndex: 0, + stateMachinePath: [], + stateType: 'empty', + clipUuid: clip.uuid, + }, + expected: withClipState, + })).rejects.toMatchObject({ code: 'INVALID_PROPERTY_PATCH' }); + + await expect(assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state', + layerIndex: 0, + stateMachinePath: [], + stateType: 'motion', + clipUuid: 'missing-asset-uuid', + }, + expected: withClipState, + })).rejects.toMatchObject({ message: expect.stringContaining('missing-asset-uuid') }); + }); }); diff --git a/src/lib/assets/assets.ts b/src/lib/assets/assets.ts index 60ffa5cb7..ac635b463 100644 --- a/src/lib/assets/assets.ts +++ b/src/lib/assets/assets.ts @@ -1,4 +1,4 @@ -import type { AnimationGraphChangedEvent, AnimationGraphExpectedVersion, AnimationGraphInspectorPropertyOperationRequest, AnimationGraphInspectorSnapshot, AnimationGraphSnapshot, AnimationGraphTarget, AssetOperationOption, AssetPropertySchemaMap, CreateAssetByTypeOptions, DeleteAssetOptions, ExecuteAnimationGraphCommandRequest, IAssetFileSystemProvider, IAssetInfo, IAssetMeta, ISupportCreateType, MaterialDump, MaterialEffectInfo, MaterialTechniqueDump, QueryAssetsOption, ReloadAnimationGraphOptions, SerializedAssetPatch, SerializedAssetQueryResult, SetAnimationGraphInspectorPropertyRequest, AnimationMaskChange, AnimationMaskDump } from '../../core/assets/@types/public'; +import type { AnimationGraphChangedEvent, AnimationGraphExpectedVersion, AnimationGraphInspectorPropertyOperationRequest, AnimationGraphInspectorSnapshot, AnimationGraphPoseGraphAssetDragHandlersEntry, AnimationGraphSnapshot, AnimationGraphTarget, AssetOperationOption, AssetPropertySchemaMap, CreateAssetByTypeOptions, DeleteAssetOptions, ExecuteAnimationGraphCommandRequest, IAssetFileSystemProvider, IAssetInfo, IAssetMeta, ISupportCreateType, MaterialDump, MaterialEffectInfo, MaterialTechniqueDump, QueryAssetsOption, ReloadAnimationGraphOptions, SerializedAssetPatch, SerializedAssetQueryResult, SetAnimationGraphInspectorPropertyRequest, AnimationMaskChange, AnimationMaskDump } from '../../core/assets/@types/public'; import type { CreateAssetOptions, IAssetConfig, IAssetDBInfo, ICreateMenuInfo, IUerDataConfigItem, QueryAssetType, ThumbnailInfo, ThumbnailSize } from '../../core/assets/@types/protected'; import type { FilterPluginOptions, IPluginScriptInfo } from '../../core/scripting/interface'; import { assetDBManager, assetManager } from '../../core/assets'; @@ -222,6 +222,14 @@ export const animationGraph = { return assetManager.queryAnimationGraphInspector(uuidOrUrlOrPath, target); }, + queryPoseGraphAssetDragHandlers(): Promise { + return assetManager.queryAnimationGraphPoseGraphAssetDragHandlers(); + }, + + queryStateMachineComponentTypes(): Promise { + return assetManager.queryAnimationGraphStateMachineComponentTypes(); + }, + setInspectorProperty( uuidOrUrlOrPath: string, request: SetAnimationGraphInspectorPropertyRequest, From 29de4ca59037832b643dd732ba6e17a6d63d7d11 Mon Sep 17 00:00:00 2001 From: looopmax Date: Thu, 3 Sep 2026 16:39:44 +0800 Subject: [PATCH 02/11] fix(animation-graph): create typed motion states --- .../__snapshots__/dts-snapshot.test.ts.snap | 37 +++++++++++++++++-- src/core/assets/@types/public.d.ts | 2 +- src/core/assets/animation-graph-service.ts | 8 ++-- .../test/animation-graph-service.test.ts | 30 ++++++++++++++- 4 files changed, 66 insertions(+), 11 deletions(-) diff --git a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap index 20557af6a..a480462e5 100644 --- a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap +++ b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap @@ -28,7 +28,7 @@ export declare type AnimationGraphCommand = | { type: 'add-layer'; name?: string } | { type: 'remove-layer'; layerIndex: number } | { type: 'move-layer'; layerIndex: number; newIndex: number } -| ({ type: 'add-state'; stateType: AnimationGraphStateType; name?: string; clipUuid?: string; editorData?: Record } & AnimationGraphStateMachineAddress) +| ({ type: 'add-state'; stateType: AnimationGraphStateType; name?: string; motionType?: AnimationGraphMotionType; clipUuid?: string; editorData?: Record } & AnimationGraphStateMachineAddress) | ({ type: 'remove-state' } & AnimationGraphStateAddress) | ({ type: 'duplicate-state'; includeTransitions?: boolean; editorData?: Record } & AnimationGraphStateAddress) | ({ type: 'set-state-editor-data'; editorData: Record } & AnimationGraphStateAddress) @@ -109,7 +109,7 @@ export declare interface AnimationGraphLayerView { additive: boolean; maskUuid: string | null; stashes: string[]; - stashPoseGraphs: Array<{ name: string; poseGraph: AnimationGraphPoseView }>; + stashPoseGraphs: Array<{ name: string; poseGraph: AnimationGraphPoseView; referenceCount?: number }>; stateMachine: AnimationGraphStateMachineView; } export declare type AnimationGraphMotionAddress = @@ -133,6 +133,11 @@ export declare interface AnimationGraphMotionView { children?: AnimationGraphMotionView[]; editorData?: Record; } +export declare interface AnimationGraphPoseGraphAddNodeInfo { + typeId: string; + args: unknown; + menu: string; +} export declare type AnimationGraphPoseGraphAddress = | { layerIndex: number; stateMachinePath: number[]; stateIndex: number } | { poseGraph: AnimationGraphPoseGraphContext }; @@ -144,6 +149,12 @@ export declare interface AnimationGraphPoseGraphAssetDragHandlersEntry { assetType: string; handlers: AnimationGraphPoseGraphAssetDragHandlerInfo[]; } +export declare interface AnimationGraphPoseGraphAssetDragHandlersView { + handlers: Record; +} +export declare interface AnimationGraphPoseGraphAssetDragHandlerView { + displayName: string; +} export declare type AnimationGraphPoseGraphContext = | { kind: 'state-pose-graph'; stateMachine: AnimationGraphStateMachineContext; stateIndex: number } | { kind: 'layer-stash'; layerIndex: number; stashName: string }; @@ -179,6 +190,8 @@ export declare interface AnimationGraphPoseView { context: AnimationGraphPoseGraphContext; rootOutputNodeId: number; nodes: AnimationGraphPoseNodeView[]; + addNodeInfos: AnimationGraphPoseGraphAddNodeInfo[]; + assetDragHandlersMap: Record; } export declare interface AnimationGraphSnapshot extends AnimationGraphVersion { uuid: string; @@ -7991,7 +8004,7 @@ export declare type AnimationGraphCommand = | { type: 'add-layer'; name?: string } | { type: 'remove-layer'; layerIndex: number } | { type: 'move-layer'; layerIndex: number; newIndex: number } -| ({ type: 'add-state'; stateType: AnimationGraphStateType; name?: string; clipUuid?: string; editorData?: Record } & AnimationGraphStateMachineAddress) +| ({ type: 'add-state'; stateType: AnimationGraphStateType; name?: string; motionType?: AnimationGraphMotionType; clipUuid?: string; editorData?: Record } & AnimationGraphStateMachineAddress) | ({ type: 'remove-state' } & AnimationGraphStateAddress) | ({ type: 'duplicate-state'; includeTransitions?: boolean; editorData?: Record } & AnimationGraphStateAddress) | ({ type: 'set-state-editor-data'; editorData: Record } & AnimationGraphStateAddress) @@ -8072,7 +8085,7 @@ export declare interface AnimationGraphLayerView { additive: boolean; maskUuid: string | null; stashes: string[]; - stashPoseGraphs: Array<{ name: string; poseGraph: AnimationGraphPoseView }>; + stashPoseGraphs: Array<{ name: string; poseGraph: AnimationGraphPoseView; referenceCount?: number }>; stateMachine: AnimationGraphStateMachineView; } export declare type AnimationGraphMotionAddress = @@ -8096,6 +8109,11 @@ export declare interface AnimationGraphMotionView { children?: AnimationGraphMotionView[]; editorData?: Record; } +export declare interface AnimationGraphPoseGraphAddNodeInfo { + typeId: string; + args: unknown; + menu: string; +} export declare type AnimationGraphPoseGraphAddress = | { layerIndex: number; stateMachinePath: number[]; stateIndex: number } | { poseGraph: AnimationGraphPoseGraphContext }; @@ -8107,6 +8125,12 @@ export declare interface AnimationGraphPoseGraphAssetDragHandlersEntry { assetType: string; handlers: AnimationGraphPoseGraphAssetDragHandlerInfo[]; } +export declare interface AnimationGraphPoseGraphAssetDragHandlersView { + handlers: Record; +} +export declare interface AnimationGraphPoseGraphAssetDragHandlerView { + displayName: string; +} export declare type AnimationGraphPoseGraphContext = | { kind: 'state-pose-graph'; stateMachine: AnimationGraphStateMachineContext; stateIndex: number } | { kind: 'layer-stash'; layerIndex: number; stashName: string }; @@ -8142,6 +8166,8 @@ export declare interface AnimationGraphPoseView { context: AnimationGraphPoseGraphContext; rootOutputNodeId: number; nodes: AnimationGraphPoseNodeView[]; + addNodeInfos: AnimationGraphPoseGraphAddNodeInfo[]; + assetDragHandlersMap: Record; } export declare interface AnimationGraphSnapshot extends AnimationGraphVersion { uuid: string; @@ -8464,6 +8490,9 @@ export declare namespace Assets { AnimationGraphPoseInputView, AnimationGraphPoseNodeEnterInfo, AnimationGraphPoseNodeView, + AnimationGraphPoseGraphAddNodeInfo, + AnimationGraphPoseGraphAssetDragHandlerView, + AnimationGraphPoseGraphAssetDragHandlersView, AnimationGraphPoseView, AnimationGraphPoseGraphAssetDragHandlerInfo, AnimationGraphPoseGraphAssetDragHandlersEntry, diff --git a/src/core/assets/@types/public.d.ts b/src/core/assets/@types/public.d.ts index af15818c3..7d2570104 100644 --- a/src/core/assets/@types/public.d.ts +++ b/src/core/assets/@types/public.d.ts @@ -300,7 +300,7 @@ export type AnimationGraphCommand = | { type: 'add-layer'; name?: string } | { type: 'remove-layer'; layerIndex: number } | { type: 'move-layer'; layerIndex: number; newIndex: number } - | ({ type: 'add-state'; stateType: AnimationGraphStateType; name?: string; clipUuid?: string; editorData?: Record } & AnimationGraphStateMachineAddress) + | ({ type: 'add-state'; stateType: AnimationGraphStateType; name?: string; motionType?: AnimationGraphMotionType; clipUuid?: string; editorData?: Record } & AnimationGraphStateMachineAddress) | ({ type: 'remove-state' } & AnimationGraphStateAddress) | ({ type: 'duplicate-state'; includeTransitions?: boolean; editorData?: Record } & AnimationGraphStateAddress) | ({ type: 'set-state-editor-data'; editorData: Record } & AnimationGraphStateAddress) diff --git a/src/core/assets/animation-graph-service.ts b/src/core/assets/animation-graph-service.ts index cadf7f1e2..856d9a7c1 100644 --- a/src/core/assets/animation-graph-service.ts +++ b/src/core/assets/animation-graph-service.ts @@ -1272,13 +1272,13 @@ class AnimationGraphAssetService { const stateMachine = this._getStateMachineForAddress(document, command); const state = createState(stateMachine, command.stateType); state.name = command.name || uniqueStateName(stateMachine, defaultStateName(command.stateType)); - if (command.clipUuid !== undefined) { - // clipUuid 仅对动画状态有效:创建后立即挂上 ClipMotion(等价 add-state + set-motion 一次完成)。 + if (command.motionType !== undefined || command.clipUuid !== undefined) { + // motionType/clipUuid 仅对动画状态有效:创建后立即挂上 Motion(等价 add-state + set-motion 一次完成)。 const api = getNewGenAnim(); if (!(state instanceof api.MotionState)) { - throw new AnimationGraphEditError('INVALID_PROPERTY_PATCH', 'A clip can only be attached to a motion state.', this._version(document)); + throw new AnimationGraphEditError('INVALID_PROPERTY_PATCH', 'A motion can only be attached to a motion state.', this._version(document)); } - state.motion = this._createMotion('clip', command.clipUuid); + state.motion = this._createMotion(command.motionType || 'clip', command.clipUuid); } assignEditorData(state, command.editorData); return; diff --git a/src/core/assets/test/animation-graph-service.test.ts b/src/core/assets/test/animation-graph-service.test.ts index ad9c69d30..773731a20 100644 --- a/src/core/assets/test/animation-graph-service.test.ts +++ b/src/core/assets/test/animation-graph-service.test.ts @@ -1495,6 +1495,32 @@ describe('animation graph asset service', () => { expect(clipState!.motion?.clipUuid).toBe(clip.uuid); expect(clipState!.editorData).toEqual({ centerX: 30, centerY: 60 }); + const withBlend1DState = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state', + layerIndex: 0, + stateMachinePath: [], + stateType: 'motion', + name: 'Blend 1D', + motionType: 'blend-1d', + }, + expected: withClipState, + }); + expect(withBlend1DState.graph.layers[0].stateMachine.states.find((state) => state.name === 'Blend 1D')!.motion).toMatchObject({ type: 'blend-1d' }); + + const withBlend2DState = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state', + layerIndex: 0, + stateMachinePath: [], + stateType: 'motion', + name: 'Blend 2D', + motionType: 'blend-2d', + }, + expected: withBlend1DState, + }); + expect(withBlend2DState.graph.layers[0].stateMachine.states.find((state) => state.name === 'Blend 2D')!.motion).toMatchObject({ type: 'blend-2d' }); + await expect(assetManager.executeAnimationGraphCommand(asset.uuid, { command: { type: 'add-state', @@ -1503,7 +1529,7 @@ describe('animation graph asset service', () => { stateType: 'empty', clipUuid: clip.uuid, }, - expected: withClipState, + expected: withBlend2DState, })).rejects.toMatchObject({ code: 'INVALID_PROPERTY_PATCH' }); await expect(assetManager.executeAnimationGraphCommand(asset.uuid, { @@ -1514,7 +1540,7 @@ describe('animation graph asset service', () => { stateType: 'motion', clipUuid: 'missing-asset-uuid', }, - expected: withClipState, + expected: withBlend2DState, })).rejects.toMatchObject({ message: expect.stringContaining('missing-asset-uuid') }); }); }); From cd27188db6d5cb585b66c5bb6a1dfabdca6cde3b Mon Sep 17 00:00:00 2001 From: looopmax Date: Fri, 4 Sep 2026 20:48:28 +0800 Subject: [PATCH 03/11] feat(animation-graph): enrich state and motion inspector forms - merge speedMultiplierEnabled into a combined speedMultiplier property - publish blend 2D variables as variable-select fields and hide constant values - add Animation Clip Motion group metadata and rename UI markers - copy scalar createArg fields onto created pose nodes (variableName, stashName) - propagate ui attributes through property dumps for inspector overrides Co-authored-by: CommandCodeBot --- src/core/assets/animation-graph-service.ts | 76 ++++++++++++++---- src/core/assets/serialized-data.ts | 1 + .../test/animation-graph-service.test.ts | 77 +++++++++++++++++-- 3 files changed, 132 insertions(+), 22 deletions(-) diff --git a/src/core/assets/animation-graph-service.ts b/src/core/assets/animation-graph-service.ts index 856d9a7c1..dd724c450 100644 --- a/src/core/assets/animation-graph-service.ts +++ b/src/core/assets/animation-graph-service.ts @@ -953,18 +953,31 @@ class AnimationGraphAssetService { private _createStateBinding(state: any): InspectorBinding { const api = getNewGenAnim(); + const eventBindingAttrs = { type: 'String', default: '', group: { id: 'event-bindings', name: 'Event Bindings' } }; const properties: Record = { - name: directProperty(state, 'name', { type: 'String', default: '' }), + name: directProperty(state, 'name', { type: 'String', default: '', ui: { name: 'animationGraphRename' } }), }; if (state instanceof api.MotionState) { properties.speed = directProperty(state, 'speed', { type: 'Number', default: 1, min: 0 }); - properties.speedMultiplier = directProperty(state, 'speedMultiplier', { type: 'String', default: '' }); - properties.speedMultiplierEnabled = directProperty(state, 'speedMultiplierEnabled', { type: 'Boolean', default: false }); - properties.transitionInEvent = nestedProperty(state.transitionInEventBinding, 'methodName', { type: 'String', default: '' }); - properties.transitionOutEvent = nestedProperty(state.transitionOutEventBinding, 'methodName', { type: 'String', default: '' }); + // Speed Multiplier 与 Enabled 合并为一个字段:value 为 { enabled, multiplier }, + // 由 Inspector 侧 animationGraphSpeedMultiplier 渲染成 checkbox + 输入框。 + properties.speedMultiplier = { + get: () => ({ enabled: !!state.speedMultiplierEnabled, multiplier: String(state.speedMultiplier ?? '') }), + set: (value: any) => { + if (value && typeof value === 'object') { + state.speedMultiplierEnabled = !!value.enabled; + if (typeof value.multiplier === 'string') { + state.speedMultiplier = value.multiplier; + } + } + }, + attrs: { type: 'Object', default: null, displayName: 'Speed Multiplier', ui: { name: 'animationGraphSpeedMultiplier' } }, + }; + properties.transitionInEvent = nestedProperty(state.transitionInEventBinding, 'methodName', eventBindingAttrs); + properties.transitionOutEvent = nestedProperty(state.transitionOutEventBinding, 'methodName', eventBindingAttrs); } else if (state instanceof api.ProceduralPoseState) { - properties.transitionInEvent = nestedProperty(state.transitionInEventBinding, 'methodName', { type: 'String', default: '' }); - properties.transitionOutEvent = nestedProperty(state.transitionOutEventBinding, 'methodName', { type: 'String', default: '' }); + properties.transitionInEvent = nestedProperty(state.transitionInEventBinding, 'methodName', eventBindingAttrs); + properties.transitionOutEvent = nestedProperty(state.transitionOutEventBinding, 'methodName', eventBindingAttrs); } return createAdapterBinding(getClassName(state), properties); } @@ -995,10 +1008,16 @@ class AnimationGraphAssetService { const api = getNewGenAnim(); const properties: Record = {}; if (motion instanceof api.ClipMotion) { - properties.clip = directProperty(motion, 'clip', { type: 'Object', ctor: getCC().AnimationClip, default: null }); + properties.clip = directProperty(motion, 'clip', { + type: 'Object', + ctor: getCC().AnimationClip, + default: null, + displayName: 'Clip', + group: { id: 'animation-clip-motion', name: 'Animation Clip Motion', displayOrder: 0, style: 'tab' }, + }); } if (motion instanceof api.AnimationBlend) { - properties.name = directProperty(motion, 'name', { type: 'String', default: '' }); + properties.name = directProperty(motion, 'name', { type: 'String', default: '', ui: { name: 'animationGraphRename' } }); } if (motion instanceof api.AnimationBlend1D) { properties.variable = nestedProperty(motion.param, 'variable', { type: 'String', default: '' }); @@ -1009,10 +1028,19 @@ class AnimationGraphAssetService { default: 0, enumList: enumList(api.AnimationBlend2D.Algorithm), }); - properties.variableX = nestedProperty(motion.paramX, 'variable', { type: 'String', default: '' }); - properties.valueX = nestedProperty(motion.paramX, 'value', { type: 'Number', default: 0 }); - properties.variableY = nestedProperty(motion.paramY, 'variable', { type: 'String', default: '' }); - properties.valueY = nestedProperty(motion.paramY, 'value', { type: 'Number', default: 0 }); + // Blend 2D 的参数通过变量下拉选择(FLOAT 变量),常量值字段不在表单中展示。 + properties.variableX = nestedProperty(motion.paramX, 'variable', { + type: 'String', + default: '', + ui: { name: 'animationGraphVariableSelect' }, + }); + properties.valueX = nestedProperty(motion.paramX, 'value', { type: 'Number', default: 0, visible: false }); + properties.variableY = nestedProperty(motion.paramY, 'variable', { + type: 'String', + default: '', + ui: { name: 'animationGraphVariableSelect' }, + }); + properties.valueY = nestedProperty(motion.paramY, 'value', { type: 'Number', default: 0, visible: false }); } return createAdapterBinding(getClassName(motion), properties); } @@ -1279,6 +1307,8 @@ class AnimationGraphAssetService { throw new AnimationGraphEditError('INVALID_PROPERTY_PATCH', 'A motion can only be attached to a motion state.', this._version(document)); } state.motion = this._createMotion(command.motionType || 'clip', command.clipUuid); + // 第一层的 motion 名称跟随 state 名称(对齐参考编辑器),避免引擎默认的 motion-0x 名。 + state.motion.name = state.name; } assignEditorData(state, command.editorData); return; @@ -1396,6 +1426,10 @@ class AnimationGraphAssetService { if (!(state instanceof api.MotionState)) { throw this._targetNotFound(document, command); } + if (motion) { + // 第一层的 motion 名称跟随 state 名称(对齐参考编辑器),避免引擎默认的 motion-0x 名。 + motion.name = state.name; + } state.motion = motion; } return; @@ -1495,6 +1529,15 @@ class AnimationGraphAssetService { throw new AnimationGraphEditError('TARGET_NOT_FOUND', `Pose node type can not be found: ${command.nodeType}`, this._version(document)); } const node = api.createPoseGraphNode(ctor, command.createArg); + // createPoseGraphNode 对无 factory 的具体类只做默认构造,这里把 + // createArg 中的同名字段(如 variableName、stashName)补写到节点上。 + if (command.createArg && typeof command.createArg === 'object') { + for (const [key, value] of Object.entries(command.createArg)) { + if (key in node) { + node[key] = value; + } + } + } poseGraph.addNode(node); assignEditorData(node, command.editorData); this._nodeId(document, node); @@ -1717,7 +1760,7 @@ class AnimationGraphAssetService { } const poseGraph = this._getPoseGraphByContext(document, command.poseGraph); const originalNodes = Array.from(poseGraph.nodes() as Iterable); - const stashName = command.stashName ?? uniqueStashName(layer); + const stashName = command.stashName?.trim() || uniqueStashName(layer); if (layer.getStash(stashName)) { throw this._nameConflict(document, 'stash', stashName); } @@ -2248,6 +2291,11 @@ function isVec2Like(value: unknown): value is { x: number; y: number } { } function getNodeTitle(node: any): string { + // GetVariable 系列节点:标题固定为 `Variable {variableName}`, + // 引擎 getTitle 返回的是 i18n key 数组且 variableName 为空时为 undefined。 + if (typeof node.variableName === 'string') { + return `Variable ${node.variableName}`.trim(); + } const title = node.getTitle?.(); if (typeof title === 'string') { return title; diff --git a/src/core/assets/serialized-data.ts b/src/core/assets/serialized-data.ts index 3a0fefdde..0dc5a1906 100644 --- a/src/core/assets/serialized-data.ts +++ b/src/core/assets/serialized-data.ts @@ -52,6 +52,7 @@ const ATTRIBUTE_PROPS = [ 'bitmaskList', 'displayName', 'group', + 'ui', 'multiline', 'step', 'slide', diff --git a/src/core/assets/test/animation-graph-service.test.ts b/src/core/assets/test/animation-graph-service.test.ts index 773731a20..170fdb93e 100644 --- a/src/core/assets/test/animation-graph-service.test.ts +++ b/src/core/assets/test/animation-graph-service.test.ts @@ -874,8 +874,7 @@ describe('animation graph asset service', () => { let inspector = await assetManager.queryAnimationGraphInspector(asset.uuid, stateTarget); for (const [path, patch] of [ ['speed', 1.75], - ['speedMultiplier', 'speed'], - ['speedMultiplierEnabled', true], + ['speedMultiplier', { enabled: true, multiplier: 'speed' }], ] as const) { inspector = await assetManager.setAnimationGraphInspectorProperty(asset.uuid, { target: stateTarget, @@ -906,9 +905,7 @@ describe('animation graph asset service', () => { inspector = await assetManager.queryAnimationGraphInspector(asset.uuid, motionTarget); for (const [path, patch] of [ ['variableX', 'speed'], - ['valueX', 0.25], ['variableY', 'direction'], - ['valueY', -0.5], ] as const) { inspector = await assetManager.setAnimationGraphInspectorProperty(asset.uuid, { target: motionTarget, @@ -928,9 +925,7 @@ describe('animation graph asset service', () => { expect(snapshot.graph.layers[0].stateMachine.states[stateIndex].motion).toMatchObject({ type: 'blend-2d', variableX: 'speed', - valueX: 0.25, variableY: 'direction', - valueY: -0.5, editorData: { centerX: 16, centerY: 32, autoThreshold: false }, }); @@ -1000,9 +995,7 @@ describe('animation graph asset service', () => { editorData: { centerX: 120, centerY: 48, collapsed: true }, motion: expect.objectContaining({ variableX: 'speed', - valueX: 0.25, variableY: 'direction', - valueY: -0.5, editorData: { centerX: 16, centerY: 32, autoThreshold: false }, }), }); @@ -1172,6 +1165,74 @@ describe('animation graph asset service', () => { ])); }); + it('auto names stashes for empty and whitespace-only names and keeps references after reload', async () => { + const asset = await assetManager.createAsset({ + target: join(TestGlobalEnv.testRoot, `${name}-empty-stash-name.animgraph`), + content: getDefaultGraphContent(), + overwrite: true, + }); + let snapshot = await assetManager.queryAnimationGraph(asset.uuid); + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'add-stash', layerIndex: 0, name: 'Stash1' }, + expected: snapshot, + }); + + for (const stateName of ['Empty Name', 'Whitespace Name']) { + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state', + layerIndex: 0, + stateMachinePath: [], + stateType: 'procedural-pose', + name: stateName, + }, + expected: snapshot, + }); + } + + const emptyStateIndex = snapshot.graph.layers[0].stateMachine.states.find((state) => state.name === 'Empty Name')!.index; + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'stash-pose-graph', + poseGraph: snapshot.graph.layers[0].stateMachine.states[emptyStateIndex].poseGraph!.context, + layerIndex: 0, + stashName: '', + }, + expected: snapshot, + }); + const emptyState = snapshot.graph.layers[0].stateMachine.states[emptyStateIndex]; + expect(snapshot.graph.layers[0].stashes).toEqual(expect.arrayContaining(['Stash1', 'Stash2'])); + expect(emptyState.poseGraph!.nodes.find((node) => node.type.includes('PoseNodeUseStashedPose'))!.enterInfo) + .toEqual({ type: 'stash', stashName: 'Stash2' }); + + const whitespaceStateIndex = snapshot.graph.layers[0].stateMachine.states.find((state) => state.name === 'Whitespace Name')!.index; + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'stash-pose-graph', + poseGraph: snapshot.graph.layers[0].stateMachine.states[whitespaceStateIndex].poseGraph!.context, + layerIndex: 0, + stashName: ' ', + }, + expected: snapshot, + }); + const whitespaceState = snapshot.graph.layers[0].stateMachine.states[whitespaceStateIndex]; + expect(snapshot.graph.layers[0].stashes).toEqual(expect.arrayContaining(['Stash1', 'Stash2', 'Stash3'])); + expect(whitespaceState.poseGraph!.nodes.find((node) => node.type.includes('PoseNodeUseStashedPose'))!.enterInfo) + .toEqual({ type: 'stash', stashName: 'Stash3' }); + + const saved = await assetManager.saveAnimationGraph(asset.uuid, snapshot); + const reloaded = await assetManager.reloadAnimationGraph(asset.uuid, { expected: saved }); + expect(reloaded.graph.layers[0].stashes).toEqual(expect.arrayContaining(['Stash1', 'Stash2', 'Stash3'])); + expect(reloaded.graph.layers[0].stashPoseGraphs.find((stash) => stash.name === 'Stash2')).toBeDefined(); + expect(reloaded.graph.layers[0].stashPoseGraphs.find((stash) => stash.name === 'Stash3')).toBeDefined(); + expect(reloaded.graph.layers[0].stateMachine.states[emptyStateIndex].poseGraph!.nodes + .find((node) => node.type.includes('PoseNodeUseStashedPose'))!.enterInfo) + .toEqual({ type: 'stash', stashName: 'Stash2' }); + expect(reloaded.graph.layers[0].stateMachine.states[whitespaceStateIndex].poseGraph!.nodes + .find((node) => node.type.includes('PoseNodeUseStashedPose'))!.enterInfo) + .toEqual({ type: 'stash', stashName: 'Stash3' }); + }); + it('restores temporary editor extras class state when registration fails', async () => { const asset = await assetManager.createAsset({ target: join(TestGlobalEnv.testRoot, `${name}-editor-extras-registration-error.animgraph`), From ef63d23d150cc00f5bc0e09adeff3724f455b857 Mon Sep 17 00:00:00 2001 From: ChiaNing Date: Wed, 2 Sep 2026 17:19:19 +0800 Subject: [PATCH 04/11] feat(scene): support PolygonCollider2D point regeneration via Scene API and MCP (#893) * feat(scene): support PolygonCollider2D point regeneration * feat(mcp): expose PolygonCollider2D point regeneration --- e2e/mcp/api/component.e2e.test.ts | 51 +++ .../__snapshots__/dts-snapshot.test.ts.snap | 12 + src/api/scene/component-schema.ts | 20 + src/api/scene/component.ts | 28 ++ src/core/assets/image-processing.ts | 76 ++++ src/core/assets/manager/asset.ts | 21 ++ src/core/assets/test/image-processing.test.ts | 65 ++++ src/core/scene/common/component.ts | 50 +++ .../main-process/proxy/component-proxy.ts | 8 + .../scene/scene-process/service/component.ts | 78 +++- .../service/component/polygon-collider-2d.ts | 272 ++++++++++++++ .../component/polygon-collider-2d/contour.ts | 128 +++++++ .../component/polygon-collider-2d/simplify.ts | 52 +++ .../scene-process/service/component/utils.ts | 5 - .../component-proxy-asset-validation.test.ts | 20 + .../scene/test/polygon-collider-2d.test.ts | 351 ++++++++++++++++++ .../component-prefab-ui-handling.test.ts | 223 ++++++++++- .../component-polygon-regenerate-api.test.ts | 110 ++++++ 18 files changed, 1559 insertions(+), 11 deletions(-) create mode 100644 src/core/assets/image-processing.ts create mode 100644 src/core/assets/test/image-processing.test.ts create mode 100644 src/core/scene/scene-process/service/component/polygon-collider-2d.ts create mode 100644 src/core/scene/scene-process/service/component/polygon-collider-2d/contour.ts create mode 100644 src/core/scene/scene-process/service/component/polygon-collider-2d/simplify.ts create mode 100644 src/core/scene/test/polygon-collider-2d.test.ts create mode 100644 tests/component-polygon-regenerate-api.test.ts diff --git a/e2e/mcp/api/component.e2e.test.ts b/e2e/mcp/api/component.e2e.test.ts index 4b62af0fb..8c96ff995 100644 --- a/e2e/mcp/api/component.e2e.test.ts +++ b/e2e/mcp/api/component.e2e.test.ts @@ -342,4 +342,55 @@ describe('MCP Component API', () => { expect(eraseResult.data).toEqual(insertResult.data); }); }); + + describe('PolygonCollider2D 专用操作', () => { + it('should regenerate rectangle fallback points through MCP', async () => { + const addResult = await mcpClient.callTool('scene-add-component', { + addComponentInfo: { + nodePath: testNodePath, + component: 'cc.PolygonCollider2D', + }, + }); + expect(addResult.code).toBe(200); + expect(addResult.data).toBeDefined(); + if (!addResult.data) return; + + const regenerateResult = await mcpClient.callTool('scene-regenerate-polygon-2d-points', { + options: { + path: addResult.data.path, + record: false, + }, + }); + + expect(regenerateResult.code).toBe(200); + expect(regenerateResult.data).toEqual({ + path: addResult.data.path, + changed: false, + pointCount: 4, + source: 'rect-fallback', + }); + }); + + it('should reject a non-PolygonCollider2D component', async () => { + const addResult = await mcpClient.callTool('scene-add-component', { + addComponentInfo: { + nodePath: testNodePath, + component: 'cc.Label', + }, + }); + expect(addResult.code).toBe(200); + expect(addResult.data).toBeDefined(); + if (!addResult.data) return; + + const regenerateResult = await mcpClient.callTool('scene-regenerate-polygon-2d-points', { + options: { + path: addResult.data.path, + record: false, + }, + }); + + expect(regenerateResult.code).toBe(400); + expect(regenerateResult.reason).toContain('component is not cc.PolygonCollider2D'); + }); + }); }); diff --git a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap index a480462e5..c5093b10d 100644 --- a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap +++ b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap @@ -6538,6 +6538,7 @@ export declare interface IComponentService extends IServiceEvents { add(params: IAddComponentOptions): Promise; remove(params: IRemoveComponentOptions): Promise; setProperty(params: ISetPropertyOptions): Promise; + regeneratePolygon2DPoints(options: IRegeneratePolygon2DPointsOptions): Promise; query(params: IQueryComponentOptions | string): Promise; queryAll(): Promise; recalculateLODGroupBounds(options: IRecalculateLODGroupBoundsOptions): Promise; @@ -7013,6 +7014,16 @@ export declare interface IReferenceImageState { export declare interface IReferenceImageVisibilityOptions { desiredVisible: boolean; } +export declare interface IRegeneratePolygon2DPointsOptions { + path: string; + record?: boolean; +} +export declare interface IRegeneratePolygon2DPointsResult { + path: string; + changed: boolean; + pointCount: number; + source: Polygon2DPointsSource; +} export declare interface IReloadOptions { urlOrUUID?: string; preserveUndoHistory?: boolean; @@ -7481,6 +7492,7 @@ export declare interface PluginScriptUserData { loadPluginInMiniGame?: boolean; loadPluginInNative?: boolean; } +export declare type Polygon2DPointsSource = 'sprite-alpha' | 'rect-fallback'; export declare interface PrefabAssetUserData { persistent?: boolean; syncNodeName?: string; diff --git a/src/api/scene/component-schema.ts b/src/api/scene/component-schema.ts index f5fd1f87e..ef1843ecf 100644 --- a/src/api/scene/component-schema.ts +++ b/src/api/scene/component-schema.ts @@ -119,6 +119,23 @@ export const SchemaComponent: z.ZodType = SchemaComponentIdentif export const SchemaQueryAllComponentResult = z.array(z.string()).describe('Collection of all components, including built-in and custom components'); // 所有组件集合,包含内置与自定义组件 +// Regenerate PolygonCollider2D points // 重新生成 PolygonCollider2D 顶点 +export const SchemaRegeneratePolygon2DPointsOptions = z.object({ + path: z.string().min(1) + .describe('cc.PolygonCollider2D component path, UUID, or db:// URL'), + record: z.boolean().optional().describe('Whether to record undo, defaults to true'), +}).describe('Information required to regenerate cc.PolygonCollider2D points'); + +export const SchemaPolygon2DPointsSource = z.enum(['sprite-alpha', 'rect-fallback']) + .describe('Source used to generate the PolygonCollider2D points'); + +export const SchemaRegeneratePolygon2DPointsResult = z.object({ + path: z.string().min(1).describe('PolygonCollider2D component identifier supplied for the operation'), + changed: z.boolean().describe('Whether the generated points changed the component'), + pointCount: z.number().int().min(3).describe('Number of generated polygon points'), + source: SchemaPolygon2DPointsSource, +}).describe('Result of regenerating cc.PolygonCollider2D points'); + // Recalculate LODGroup bounds // 重新计算 LODGroup 包围盒 export const SchemaRecalculateLODGroupBoundsOptions = z.object({ path: z.string().min(1).describe('cc.LODGroup component path, e.g. "Root/LOD/cc.LODGroup"'), // cc.LODGroup 组件路径 @@ -170,6 +187,9 @@ export type TQueryComponentOptions = z.infer; export type TSetPropertyOptions = z.infer; export type TComponentResult = z.infer; export type TQueryAllComponentResult = z.infer; +export type TRegeneratePolygon2DPointsOptions = z.infer; +export type TPolygon2DPointsSource = z.infer; +export type TRegeneratePolygon2DPointsResult = z.infer; export type TRecalculateLODGroupBoundsOptions = z.infer; export type TLODGroupBoundsResult = z.infer; export type TInsertLODOptions = z.infer; diff --git a/src/api/scene/component.ts b/src/api/scene/component.ts index c1f5f4d4a..81c962e46 100644 --- a/src/api/scene/component.ts +++ b/src/api/scene/component.ts @@ -6,6 +6,8 @@ import { SchemaQueryAllComponentResult, SchemaQueryComponent, SchemaRemoveComponent, + SchemaRegeneratePolygon2DPointsOptions, + SchemaRegeneratePolygon2DPointsResult, SchemaRecalculateLODGroupBoundsOptions, SchemaLODGroupBoundsResult, SchemaInsertLODOptions, @@ -20,6 +22,8 @@ import { TQueryAllComponentResult, TRemoveComponentOptions, TQueryComponentOptions, + TRegeneratePolygon2DPointsOptions, + TRegeneratePolygon2DPointsResult, TRecalculateLODGroupBoundsOptions, TLODGroupBoundsResult, TInsertLODOptions, @@ -149,6 +153,30 @@ export class ComponentApi { } } + /** + * Regenerate PolygonCollider2D points // 重新生成 PolygonCollider2D 顶点 + */ + @tool('scene-regenerate-polygon-2d-points') + @title('Regenerate PolygonCollider2D points') + @description('Regenerate cc.PolygonCollider2D points from the alpha contour of a Sprite on the same node. Falls back to the UITransform rectangle when no usable Sprite source exists. This overwrites the current points and records undo by default.') + @result(SchemaRegeneratePolygon2DPointsResult) + async regeneratePolygon2DPoints( + @param(SchemaRegeneratePolygon2DPointsOptions) options: TRegeneratePolygon2DPointsOptions, + ): Promise> { + try { + const result = await Scene.Component.regeneratePolygon2DPoints(options); + return { + code: COMMON_STATUS.SUCCESS, + data: result, + }; + } catch (e) { + return { + code: getCommonErrorStatus(e), + reason: e instanceof Error ? e.message : String(e), + }; + } + } + /** * Recalculate LODGroup bounds // 重新计算 LODGroup 包围盒 */ diff --git a/src/core/assets/image-processing.ts b/src/core/assets/image-processing.ts new file mode 100644 index 000000000..53382f01e --- /dev/null +++ b/src/core/assets/image-processing.ts @@ -0,0 +1,76 @@ +import Sharp from 'sharp'; + +export interface IImagePixelExtractionOptions { + rect: { + left: number; + top: number; + width: number; + height: number; + }; + rotation?: 0 | 90; +} + +export interface IExtractedImagePixels { + dataBase64: string; + width: number; + height: number; + channels: number; +} + +/** + * 在 Node 进程中读取图片像素。 + * + * Scene Runtime 可能运行在浏览器中,不能直接加载 Sharp 原生模块,因此只通过 RPC + * 调用此方法并接收可 JSON 序列化的 Base64 数据。 + */ +export async function extractImagePixelsFromFile( + file: string, + options: IImagePixelExtractionOptions, +): Promise { + if (!file) { + throw new Error('Image file path is required.'); + } + + validateExtractionOptions(options); + + const { data, info } = await Sharp(file) + .extract(options.rect) + .rotate(options.rotation ?? 0) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + if (info.channels !== 4) { + throw new Error(`Expected RGBA image data, but Sharp returned ${info.channels} channels.`); + } + + return { + dataBase64: data.toString('base64'), + width: info.width, + height: info.height, + channels: info.channels, + }; +} + +function validateExtractionOptions(options: IImagePixelExtractionOptions): void { + if (!options || !options.rect) { + throw new Error('Image extraction rect is required.'); + } + + const { left, top, width, height } = options.rect; + assertInteger(left, 'rect.left', 0); + assertInteger(top, 'rect.top', 0); + assertInteger(width, 'rect.width', 1); + assertInteger(height, 'rect.height', 1); + + const rotation = options.rotation ?? 0; + if (rotation !== 0 && rotation !== 90) { + throw new Error(`Image extraction rotation must be 0 or 90, but received ${rotation}.`); + } +} + +function assertInteger(value: number, name: string, minimum: number): void { + if (!Number.isInteger(value) || value < minimum) { + throw new Error(`${name} must be an integer greater than or equal to ${minimum}.`); + } +} diff --git a/src/core/assets/manager/asset.ts b/src/core/assets/manager/asset.ts index d12396f38..07da7f75d 100644 --- a/src/core/assets/manager/asset.ts +++ b/src/core/assets/manager/asset.ts @@ -11,6 +11,11 @@ import animationGraphVariant from '../animation-graph-variant'; import animationGraph from '../animation-graph-service'; import * as serializedData from '../serialized-data'; import * as materialService from '../material-service'; +import { + extractImagePixelsFromFile, + type IExtractedImagePixels, + type IImagePixelExtractionOptions, +} from '../image-processing'; /** * 对外暴露一系列的资源查询、操作接口等 @@ -90,6 +95,18 @@ class AssetManager extends EventEmitter { if (!asset) { return null; } return assetHandlerManager.generateThumbnail(asset, size); } + + async extractImagePixels( + urlOrUUIDOrPath: string, + options: IImagePixelExtractionOptions, + ): Promise { + const assetInfo = this.queryAssetInfo(urlOrUUIDOrPath); + if (!assetInfo?.file) { + return null; + } + return extractImagePixelsFromFile(assetInfo.file, options); + } + getEffectBinPath() { return assetHandlerManager.getEffectBinPath(); }; @@ -417,6 +434,10 @@ export interface TypedAssetManager extends EventEmitter { getEffectBinPath: typeof assetHandlerManager.getEffectBinPath; generateThumbnail(urlOrUUIDOrPath: string, size?: ThumbnailSize): Promise; + extractImagePixels( + urlOrUUIDOrPath: string, + options: IImagePixelExtractionOptions, + ): Promise; onReady: typeof assetManager.onReady; onDBReady: typeof assetManager.onDBReady; diff --git a/src/core/assets/test/image-processing.test.ts b/src/core/assets/test/image-processing.test.ts new file mode 100644 index 000000000..7675defb2 --- /dev/null +++ b/src/core/assets/test/image-processing.test.ts @@ -0,0 +1,65 @@ +export {}; + +jest.mock('sharp', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const mockSharp = require('sharp').default as jest.Mock; +const imageProcessingModule = () => require('../image-processing') as typeof import('../image-processing'); + +describe('asset image processing', () => { + beforeEach(() => { + mockSharp.mockReset(); + }); + + it('extracts RGBA pixels in Node and returns JSON-safe Base64 data', async () => { + const data = Buffer.from([ + 255, 0, 0, 255, + 0, 255, 0, 128, + ]); + const pipeline = { + extract: jest.fn().mockReturnThis(), + rotate: jest.fn().mockReturnThis(), + ensureAlpha: jest.fn().mockReturnThis(), + raw: jest.fn().mockReturnThis(), + toBuffer: jest.fn().mockResolvedValue({ + data, + info: { width: 1, height: 2, channels: 4 }, + }), + }; + mockSharp.mockReturnValue(pipeline); + + const result = await imageProcessingModule().extractImagePixelsFromFile( + 'D:/project/assets/atlas.png', + { + rect: { left: 3, top: 4, width: 2, height: 1 }, + rotation: 90, + }, + ); + + expect(mockSharp).toHaveBeenCalledWith('D:/project/assets/atlas.png'); + expect(pipeline.extract).toHaveBeenCalledWith({ left: 3, top: 4, width: 2, height: 1 }); + expect(pipeline.rotate).toHaveBeenCalledWith(90); + expect(pipeline.ensureAlpha).toHaveBeenCalledTimes(1); + expect(pipeline.raw).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + dataBase64: data.toString('base64'), + width: 1, + height: 2, + channels: 4, + }); + }); + + it('rejects invalid extraction rectangles before invoking Sharp', async () => { + await expect(imageProcessingModule().extractImagePixelsFromFile( + 'D:/project/assets/atlas.png', + { + rect: { left: 0, top: 0, width: 0, height: 1 }, + rotation: 0, + }, + )).rejects.toThrow('rect.width must be an integer greater than or equal to 1'); + + expect(mockSharp).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/scene/common/component.ts b/src/core/scene/common/component.ts index 1319beb46..6bfa32caf 100644 --- a/src/core/scene/common/component.ts +++ b/src/core/scene/common/component.ts @@ -124,6 +124,32 @@ export interface IExecuteComponentMethodOptions { args: any[]; } +/** + * PolygonCollider2D 顶点重新生成的数据来源。 + */ +export type Polygon2DPointsSource = 'sprite-alpha' | 'rect-fallback'; + +/** + * 重新生成 PolygonCollider2D.points 的选项。 + */ +export interface IRegeneratePolygon2DPointsOptions { + /** PolygonCollider2D 组件路径、UUID 或 URL。 */ + path: string; + /** 是否记录 Undo;默认 true。 */ + record?: boolean; +} + +/** + * 重新生成 PolygonCollider2D.points 的成功结果。 + * 失败通过 Error 抛出,由 RPC/API 边界决定如何呈现。 + */ +export interface IRegeneratePolygon2DPointsResult { + path: string; + changed: boolean; + pointCount: number; + source: Polygon2DPointsSource; +} + /** * 查询注册类的过滤选项 */ @@ -207,6 +233,30 @@ export interface IComponentService extends IServiceEvents { */ setProperty(params: ISetPropertyOptions): Promise; + /** + * 根据同节点 Sprite 或 UITransform 重新生成 PolygonCollider2D.points。 + * + * Sprite Alpha 轮廓生成、资源读取、校验或提交失败时抛出 Error,不会修改组件现有 points。 + * 没有 Sprite、SpriteFrame 或无法解析源图片时,使用 UITransform 矩形回退。 + * + * @param options - 重新生成选项 + * @param options.path - PolygonCollider2D 组件路径、UUID 或 db:// URL + * @param options.record - 是否记录 Undo,默认 true + * @returns 生成结果,包含是否变更、最终顶点数和顶点来源 + * @throws 组件不存在或类型不正确,以及资源读取、轮廓生成、顶点校验或属性提交失败时抛出 Error + * + * @example + * ```ts + * const result = await regeneratePolygon2DPoints({ + * path: 'Canvas/MyNode/cc.PolygonCollider2D', + * record: true, + * }); + * ``` + */ + regeneratePolygon2DPoints( + options: IRegeneratePolygon2DPointsOptions, + ): Promise; + /** * 查询组件信息 * - 传入 IQueryComponentOptions 时,返回 IComponentInfo diff --git a/src/core/scene/main-process/proxy/component-proxy.ts b/src/core/scene/main-process/proxy/component-proxy.ts index 780d8075c..29b0a7ae1 100644 --- a/src/core/scene/main-process/proxy/component-proxy.ts +++ b/src/core/scene/main-process/proxy/component-proxy.ts @@ -1,5 +1,7 @@ import { IAddComponentOptions, + IRegeneratePolygon2DPointsOptions, + IRegeneratePolygon2DPointsResult, IRemoveComponentOptions, IQueryComponentOptions, IPublicComponentService, @@ -111,6 +113,12 @@ export const ComponentProxy: IComponentProxy = { return true; }, + regeneratePolygon2DPoints( + options: IRegeneratePolygon2DPointsOptions, + ): Promise { + return Rpc.getInstance().request('Component', 'regeneratePolygon2DPoints', [options]); + }, + queryAll(): Promise { return Rpc.getInstance().request('Component', 'queryAll'); }, diff --git a/src/core/scene/scene-process/service/component.ts b/src/core/scene/scene-process/service/component.ts index aaa4891ab..0bf090748 100644 --- a/src/core/scene/scene-process/service/component.ts +++ b/src/core/scene/scene-process/service/component.ts @@ -1,4 +1,4 @@ -import { Component, Constructor, animation, Animation, Node, RigidBody, Collider, ERigidBodyType, EColliderType, MeshCollider, UITransform, director, Canvas, Scene } from 'cc'; +import { Component, Constructor, animation, Animation, Node, RigidBody, Collider, ERigidBodyType, EColliderType, MeshCollider, UITransform, director, Canvas, Scene, PolygonCollider2D } from 'cc'; import { Rpc } from '../rpc'; import { register, Service, BaseService } from './core'; import { @@ -19,6 +19,8 @@ import { IEraseLODOptions, IQueryLODGroupRelativeHeightOptions, ILODGroupLevelsResult, + IRegeneratePolygon2DPointsOptions, + IRegeneratePolygon2DPointsResult, } from '../../common'; import dumpUtil from './dump'; import compMgr from './component/index'; @@ -43,6 +45,14 @@ import { validateLODErase, validateLODInsert, } from './component/lod-group'; +import { + arePolygonPointsEqual, + createPolygonPointsPropertyDump, + generatePolygonPoints, + initializePolygonCollider2DPoints, + requirePolygonCollider2D, + validatePolygonPoints, +} from './component/polygon-collider-2d'; const NodeMgr = EditorExtends.Node; @@ -277,6 +287,9 @@ export class ComponentService extends BaseService implements I this.checkDynamicBodyShape(node); compMgr.onComponentAddedFromEditor(comp); + if (comp instanceof PolygonCollider2D) { + await initializePolygonCollider2DPoints(comp); + } this.emit('node:change', node, { type: NodeEventType.CREATE_COMPONENT }); const dump = dumpUtil.dumpComponent(comp as Component) as IComponent; @@ -397,6 +410,69 @@ export class ComponentService extends BaseService implements I } } + async regeneratePolygon2DPoints( + options: IRegeneratePolygon2DPointsOptions, + ): Promise { + const path = options.path; + + try { + await Service.Editor.lock(); + + const component = await this.findComponent(path); + const collider = requirePolygonCollider2D(component, path); + const generated = await generatePolygonPoints(collider); + validatePolygonPoints(generated.points); + + if (arePolygonPointsEqual(collider.points, generated.points)) { + return { + path, + changed: false, + pointCount: generated.points.length, + source: generated.source, + }; + } + + const componentIndex = collider.node.components.indexOf(collider); + if (componentIndex < 0) { + throw new Error('PolygonCollider2D is no longer attached to its node.'); + } + + const componentDump = dumpUtil.dumpComponent(collider) as IComponent; + const pointsDump = createPolygonPointsPropertyDump( + componentDump.value?.points, + generated.points, + ); + if (!pointsDump) { + throw new Error('Unable to encode PolygonCollider2D.points from the component dump.'); + } + + const nodePath = NodeMgr.getNodePath(collider.node) + || (collider.node === Service.Editor.getRootNode() ? '/' : ''); + if (!nodePath) { + throw new Error('Unable to resolve the PolygonCollider2D node path.'); + } + + const committed = await this.setProperty({ + nodePath, + path: `__comps__.${componentIndex}.points`, + dump: pointsDump, + record: options.record, + }); + if (!committed) { + throw new Error('Failed to commit PolygonCollider2D.points through ComponentService.setProperty().'); + } + + return { + path, + changed: true, + pointCount: generated.points.length, + source: generated.source, + }; + } finally { + Service.Editor.unlock(); + } + } + async setProperty(options: ISetPropertyOptions): Promise { // 多个节点更新值 if (Array.isArray(options.nodePath)) { diff --git a/src/core/scene/scene-process/service/component/polygon-collider-2d.ts b/src/core/scene/scene-process/service/component/polygon-collider-2d.ts new file mode 100644 index 000000000..d6be0dc87 --- /dev/null +++ b/src/core/scene/scene-process/service/component/polygon-collider-2d.ts @@ -0,0 +1,272 @@ +import { Component, Physics2DUtils, PolygonCollider2D, Sprite, UITransform, Vec2, js } from 'cc'; +import type { IProperty } from '../../../@types/public'; +import type { Polygon2DPointsSource } from '../../../common/component'; +import type { IExtractedImagePixels } from '../../../../assets/image-processing'; +import { Rpc } from '../../rpc'; +import { traceAlphaContour } from './polygon-collider-2d/contour'; +import { simplifyContour } from './polygon-collider-2d/simplify'; + +export interface IGeneratePolygonPointsResult { + source: Polygon2DPointsSource; + points: Vec2[]; +} + +const DEFAULT_RECT_SIZE = 100; + +/** + * 将通用组件收窄为 PolygonCollider2D。 + */ +export function requirePolygonCollider2D(component: Component | null, path: string): PolygonCollider2D { + if (!component || !component.isValid) { + throw new Error(`PolygonCollider2D component not found: ${path}`); + } + + if (!(component instanceof PolygonCollider2D)) { + const actualType = js.getClassName(component.constructor) || component.constructor?.name || 'unknown'; + throw new Error( + `Parameter error: component is not cc.PolygonCollider2D: ${path} (received ${actualType})`, + ); + } + + return component; +} + +/** + * 生成候选顶点,不直接修改组件。 + * + * Sprite Alpha 分支异步读取源图片并生成轮廓;无有效 Sprite 来源时回退到节点矩形。 + */ +export async function generatePolygonPoints( + collider: PolygonCollider2D, +): Promise { + const transform = collider.node.getComponent(UITransform); + if (!hasUsableTransform(transform)) { + return { + source: 'rect-fallback', + points: generateRectFallbackPoints(collider), + }; + } + + const sprite = collider.node.getComponent(Sprite); + if (sprite?.spriteFrame) { + const points = await generateSpriteAlphaPolygonPoints(collider, sprite, transform); + if (points) { + return { + source: 'sprite-alpha', + points, + }; + } + } + + return { + source: 'rect-fallback', + points: generateRectFallbackPoints(collider), + }; +} + +/** + * 初始化新添加的 PolygonCollider2D,不参与通用组件新增生命周期。 + * 生成失败时保留引擎默认 points,避免阻断 Add Component。 + */ +export async function initializePolygonCollider2DPoints(collider: PolygonCollider2D): Promise { + try { + const generated = await generatePolygonPoints(collider); + validatePolygonPoints(generated.points); + collider.points = generated.points; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.warn( + `Failed to initialize PolygonCollider2D.points; keeping the engine default points. ${reason}`, + ); + } +} + +/** + * 校验提交前的基础几何条件。 + * 首版只覆盖数量、有限数值和环形相邻重复点;复杂自交校验留给算法阶段。 + */ +export function validatePolygonPoints(points: readonly Readonly[]): void { + if (points.length < 3) { + throw new Error(`Polygon requires at least 3 points, but received ${points.length}.`); + } + + for (let index = 0; index < points.length; index++) { + const point = points[index]; + if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) { + throw new Error(`Polygon point ${index} contains a non-finite coordinate.`); + } + + const next = points[(index + 1) % points.length]; + if (point.x === next.x && point.y === next.y) { + throw new Error(`Polygon points ${index} and ${(index + 1) % points.length} are adjacent duplicates.`); + } + } +} + +/** + * 判断候选点是否会实际改变 points,避免产生空 Undo。 + */ +export function arePolygonPointsEqual( + current: readonly Readonly[], + candidate: readonly Readonly[], +): boolean { + return current.length === candidate.length && current.every((point, index) => { + const other = candidate[index]; + return point.x === other.x && point.y === other.y; + }); +} + +/** + * 使用现有 points Dump 的元素模板编码候选 Vec2[]。 + */ +export function createPolygonPointsPropertyDump( + pointsProperty: unknown, + points: readonly Readonly[], +): IProperty | null { + if (!isProperty(pointsProperty) || !pointsProperty.isArray) { + return null; + } + + const currentValues = Array.isArray(pointsProperty.value) ? pointsProperty.value : []; + const elementTemplate = pointsProperty.elementTypeData ?? currentValues[0]; + if (!isProperty(elementTemplate)) { + return null; + } + + const dump = cloneDump(pointsProperty); + dump.value = points.map((point, index) => { + const item = cloneDump(elementTemplate); + item.name = String(index); + item.value = { x: point.x, y: point.y }; + return item; + }); + return dump; +} + +function generateRectFallbackPoints(collider: PolygonCollider2D): Vec2[] { + const transform = collider.node.getComponent(UITransform); + const usableTransform = hasUsableTransform(transform) ? transform : null; + const width = usableTransform?.contentSize.width ?? DEFAULT_RECT_SIZE; + const height = usableTransform?.contentSize.height ?? DEFAULT_RECT_SIZE; + const anchorX = usableTransform?.anchorX ?? 0.5; + const anchorY = usableTransform?.anchorY ?? 0.5; + + const left = -anchorX * width; + const right = (1 - anchorX) * width; + const bottom = -anchorY * height; + const top = (1 - anchorY) * height; + + return [ + new Vec2(left, bottom), + new Vec2(left, top), + new Vec2(right, top), + new Vec2(right, bottom), + ]; +} + +async function generateSpriteAlphaPolygonPoints( + collider: PolygonCollider2D, + sprite: Sprite, + transform: UITransform, +): Promise { + const spriteFrame = sprite.spriteFrame; + if (!spriteFrame) { + throw new Error('SpriteFrame was removed before PolygonCollider2D points could be generated.'); + } + + const spriteFrameUuid = (spriteFrame as { _uuid?: string })._uuid; + const sourceUuid = spriteFrameUuid?.split('@')[0]; + if (!sourceUuid) { + return null; + } + + const rect = spriteFrame.getRect(); + const frameWidth = rect.width; + const frameHeight = rect.height; + const rotated = spriteFrame.isRotated(); + let imagePixels: IExtractedImagePixels | null; + try { + imagePixels = await Rpc.getInstance().request('assetManager', 'extractImagePixels', [ + sourceUuid, + { + rect: { + left: rect.x, + top: rect.y, + width: rotated ? frameHeight : frameWidth, + height: rotated ? frameWidth : frameHeight, + }, + rotation: rotated ? 90 : 0, + }, + ]) as IExtractedImagePixels | null; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to read source image pixels for SpriteFrame "${spriteFrameUuid}" from asset "${sourceUuid}": ${reason}`, + ); + } + if (!imagePixels) { + return null; + } + + const data = decodeImagePixels(imagePixels); + + let points = traceAlphaContour(data, imagePixels.width, imagePixels.height, true); + points = simplifyContour(points, collider.threshold); + + if ( + points.length > 0 + && points[0].x === points[points.length - 1].x + && points[0].y === points[points.length - 1].y + ) { + points.length -= 1; + } + + const width = transform.contentSize.width; + const height = transform.contentSize.height; + const result = points.map((point) => new Vec2( + point.x * width / frameWidth - transform.anchorX * width, + (frameHeight - point.y) * height / frameHeight - transform.anchorY * height, + )); + + Physics2DUtils.PolygonSeparator.ForceCounterClockWise(result); + return result; +} + +function hasUsableTransform(transform: UITransform | null): transform is UITransform { + return !!transform && !( + transform.contentSize.width === 0 + && transform.contentSize.height === 0 + ); +} + +function decodeImagePixels(imagePixels: IExtractedImagePixels): Uint8Array { + if ( + !Number.isInteger(imagePixels.width) + || imagePixels.width <= 0 + || !Number.isInteger(imagePixels.height) + || imagePixels.height <= 0 + || imagePixels.channels !== 4 + ) { + throw new Error('Invalid RGBA image metadata returned by the asset image processor.'); + } + + const binary = atob(imagePixels.dataBase64); + const data = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + data[index] = binary.charCodeAt(index); + } + + const expectedLength = imagePixels.width * imagePixels.height * imagePixels.channels; + if (data.length !== expectedLength) { + throw new Error(`Invalid RGBA image data length: expected ${expectedLength}, received ${data.length}.`); + } + return data; +} + +function isProperty(value: unknown): value is IProperty { + return !!value && typeof value === 'object' && 'value' in value; +} + +function cloneDump(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} diff --git a/src/core/scene/scene-process/service/component/polygon-collider-2d/contour.ts b/src/core/scene/scene-process/service/component/polygon-collider-2d/contour.ts new file mode 100644 index 000000000..ef79d33a3 --- /dev/null +++ b/src/core/scene/scene-process/service/component/polygon-collider-2d/contour.ts @@ -0,0 +1,128 @@ +export interface IContourPoint { + x: number; + y: number; +} + +enum StepDirection { + NONE, + UP, + LEFT, + DOWN, + RIGHT, +} + +/** + * 提取 RGBA 数据中左上方第一个非透明连通区域的外轮廓。 + */ +export function traceAlphaContour( + data: Uint8Array, + width: number, + height: number, + loop = true, +): IContourPoint[] { + const start = findFirstOpaquePixel(data, width, height); + if (!start) { + return []; + } + + let x = start.x; + let y = start.y; + let previousStep = StepDirection.NONE; + const points: IContourPoint[] = [{ x, y }]; + + do { + const nextStep = resolveNextStep(data, width, height, x, y, previousStep); + previousStep = nextStep; + + switch (nextStep) { + case StepDirection.UP: + y--; + break; + case StepDirection.LEFT: + x--; + break; + case StepDirection.DOWN: + y++; + break; + case StepDirection.RIGHT: + x++; + break; + default: + return []; + } + + if (x >= 0 && x <= width && y >= 0 && y <= height) { + points.push({ x, y }); + } + } while (x !== start.x || y !== start.y); + + if (loop) { + points.push({ x, y }); + } + + return points; +} + +function findFirstOpaquePixel(data: Uint8Array, width: number, height: number): IContourPoint | null { + let offset = 0; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++, offset += 4) { + if (data[offset + 3] > 0) { + return { x, y }; + } + } + } + return null; +} + +function resolveNextStep( + data: Uint8Array, + width: number, + height: number, + x: number, + y: number, + previousStep: StepDirection, +): StepDirection { + const width4 = width * 4; + const index = (y - 1) * width4 + (x - 1) * 4; + const canLeft = x > 0; + const canRight = x < width; + const canDown = y < height; + const canUp = y > 0; + + const upLeft = canUp && canLeft && data[index + 3] > 0; + const upRight = canUp && canRight && data[index + 7] > 0; + const downLeft = canDown && canLeft && data[index + width4 + 3] > 0; + const downRight = canDown && canRight && data[index + width4 + 7] > 0; + + let state = 0; + if (upLeft) state |= 1; + if (upRight) state |= 2; + if (downLeft) state |= 4; + if (downRight) state |= 8; + + switch (state) { + case 1: return StepDirection.UP; + case 2: + case 3: + case 7: + return StepDirection.RIGHT; + case 4: + case 12: + case 14: + return StepDirection.LEFT; + case 5: + case 13: + return StepDirection.UP; + case 6: + return previousStep === StepDirection.UP ? StepDirection.LEFT : StepDirection.RIGHT; + case 8: + case 10: + case 11: + return StepDirection.DOWN; + case 9: + return previousStep === StepDirection.RIGHT ? StepDirection.UP : StepDirection.DOWN; + default: + return StepDirection.NONE; + } +} diff --git a/src/core/scene/scene-process/service/component/polygon-collider-2d/simplify.ts b/src/core/scene/scene-process/service/component/polygon-collider-2d/simplify.ts new file mode 100644 index 000000000..811d1d57e --- /dev/null +++ b/src/core/scene/scene-process/service/component/polygon-collider-2d/simplify.ts @@ -0,0 +1,52 @@ +import type { IContourPoint } from './contour'; + +/** + * 使用与 Creator Editor 相同的 Ramer-Douglas-Peucker 实现简化轮廓。 + */ +export function simplifyContour( + points: readonly IContourPoint[], + epsilon: number, +): IContourPoint[] { + if (points.length < 3) { + return points.map(clonePoint); + } + + const firstPoint = points[0]; + const lastPoint = points[points.length - 1]; + let index = -1; + let distance = 0; + + for (let i = 1; i < points.length - 1; i++) { + const currentDistance = findPerpendicularDistance(points[i], firstPoint, lastPoint); + if (currentDistance > distance) { + distance = currentDistance; + index = i; + } + } + + if (distance > epsilon && index > 0) { + const left = simplifyContour(points.slice(0, index + 1), epsilon); + const right = simplifyContour(points.slice(index), epsilon); + return left.slice(0, left.length - 1).concat(right); + } + + return [clonePoint(firstPoint), clonePoint(lastPoint)]; +} + +function findPerpendicularDistance( + point: IContourPoint, + lineStart: IContourPoint, + lineEnd: IContourPoint, +): number { + if (lineStart.x === lineEnd.x) { + return Math.abs(point.x - lineStart.x); + } + + const slope = (lineEnd.y - lineStart.y) / (lineEnd.x - lineStart.x); + const intercept = lineStart.y - slope * lineStart.x; + return Math.abs(slope * point.x - point.y + intercept) / Math.sqrt(slope * slope + 1); +} + +function clonePoint(point: IContourPoint): IContourPoint { + return { x: point.x, y: point.y }; +} diff --git a/src/core/scene/scene-process/service/component/utils.ts b/src/core/scene/scene-process/service/component/utils.ts index a29ee0b37..4735eead1 100644 --- a/src/core/scene/scene-process/service/component/utils.ts +++ b/src/core/scene/scene-process/service/component/utils.ts @@ -5,7 +5,6 @@ import { SphereCollider, BoxCollider, UITransformComponent, - PolygonCollider2D, MeshCollider, CapsuleCollider, CylinderCollider, @@ -185,10 +184,6 @@ class ComponentUtils { } }, - PolygonCollider2D(component: PolygonCollider2D, node: Node) { - //TODO: PhysicsUtils.resetPoints(component); - }, - Camera(component: Camera, node: Node) { const { Service } = require('../core/decorator'); if (Service.Camera?.is2D) { diff --git a/src/core/scene/test/component-proxy-asset-validation.test.ts b/src/core/scene/test/component-proxy-asset-validation.test.ts index 71e94651c..1b6827478 100644 --- a/src/core/scene/test/component-proxy-asset-validation.test.ts +++ b/src/core/scene/test/component-proxy-asset-validation.test.ts @@ -135,4 +135,24 @@ describe('ComponentProxy Asset validation', () => { expect(setPropertyRequests()).toHaveLength(0); }); + + it('forwards PolygonCollider2D regeneration to the scene process', async () => { + const expected = { + path: '/Canvas/Polygon/cc.PolygonCollider2D', + changed: true, + pointCount: 8, + source: 'sprite-alpha', + }; + mockRequest.mockResolvedValue(expected); + + await expect(ComponentProxy.regeneratePolygon2DPoints({ + path: '/Canvas/Polygon/cc.PolygonCollider2D', + record: false, + })).resolves.toEqual(expected); + + expect(mockRequest).toHaveBeenCalledWith('Component', 'regeneratePolygon2DPoints', [{ + path: '/Canvas/Polygon/cc.PolygonCollider2D', + record: false, + }]); + }); }); diff --git a/src/core/scene/test/polygon-collider-2d.test.ts b/src/core/scene/test/polygon-collider-2d.test.ts new file mode 100644 index 000000000..1158f9d5f --- /dev/null +++ b/src/core/scene/test/polygon-collider-2d.test.ts @@ -0,0 +1,351 @@ +export {}; + +const mockAssetRequest = jest.fn(); + +class MockVec2 { + constructor(public x = 0, public y = 0) {} +} + +class MockNode { + components: MockComponent[] = []; + + attach(component: T): T { + component.node = this; + this.components.push(component); + return component; + } + + getComponent(type: new (...args: any[]) => T): T | null { + return (this.components.find(component => component instanceof type) as T | undefined) ?? null; + } +} + +class MockComponent { + isValid = true; + node!: MockNode; +} + +class MockPolygonCollider2D extends MockComponent { + threshold = 1; + points = [ + new MockVec2(-1, -1), + new MockVec2(1, -1), + new MockVec2(1, 1), + new MockVec2(-1, 1), + ]; +} + +class MockSprite extends MockComponent { + spriteFrame: MockSpriteFrame | null = null; +} + +class MockSpriteFrame { + _uuid = 'texture-uuid@spriteFrame'; + + constructor( + private readonly rect = { x: 0, y: 0, width: 2, height: 2 }, + private readonly rotated = false, + ) {} + + getRect() { + return this.rect; + } + + isRotated() { + return this.rotated; + } +} + +class MockUITransform extends MockComponent { + contentSize = { width: 100, height: 100 }; + anchorX = 0.5; + anchorY = 0.5; +} + +jest.mock('cc', () => ({ + Component: MockComponent, + PolygonCollider2D: MockPolygonCollider2D, + Sprite: MockSprite, + UITransform: MockUITransform, + Vec2: MockVec2, + Physics2DUtils: { + PolygonSeparator: { + ForceCounterClockWise: jest.fn(), + }, + }, + js: { + getClassName: (ctor: { name?: string }) => ctor?.name || '', + }, +})); + +jest.mock('../scene-process/rpc', () => ({ + Rpc: { + getInstance: () => ({ request: mockAssetRequest }), + }, +})); + +const polygonModule = () => require('../scene-process/service/component/polygon-collider-2d') as typeof import('../scene-process/service/component/polygon-collider-2d'); + +describe('PolygonCollider2D regeneration helpers', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockAssetRequest.mockReset(); + }); + + it('uses UITransform size and anchor for the rectangle fallback', async () => { + const node = new MockNode(); + const transform = node.attach(new MockUITransform()); + transform.contentSize = { width: 200, height: 80 }; + transform.anchorX = 0.25; + transform.anchorY = 0.75; + const collider = node.attach(new MockPolygonCollider2D()); + + const result = await polygonModule().generatePolygonPoints(collider as any); + + expect(result).toEqual({ + source: 'rect-fallback', + points: [ + { x: -50, y: -60 }, + { x: -50, y: 20 }, + { x: 150, y: 20 }, + { x: 150, y: -60 }, + ], + }); + }); + + it('uses a centered 100x100 rectangle when UITransform is absent', async () => { + const node = new MockNode(); + const collider = node.attach(new MockPolygonCollider2D()); + + const result = await polygonModule().generatePolygonPoints(collider as any); + + expect(result).toMatchObject({ + source: 'rect-fallback', + points: [ + { x: -50, y: -50 }, + { x: -50, y: 50 }, + { x: 50, y: 50 }, + { x: 50, y: -50 }, + ], + }); + }); + + it('initializes a newly added collider with generated points', async () => { + const node = new MockNode(); + const transform = node.attach(new MockUITransform()); + transform.contentSize = { width: 40, height: 20 }; + const collider = node.attach(new MockPolygonCollider2D()); + + await polygonModule().initializePolygonCollider2DPoints(collider as any); + + expect(collider.points).toEqual([ + { x: -20, y: -10 }, + { x: -20, y: 10 }, + { x: 20, y: 10 }, + { x: 20, y: -10 }, + ]); + }); + + it('keeps engine defaults when add-time point generation fails', async () => { + const node = new MockNode(); + node.attach(new MockUITransform()); + const sprite = node.attach(new MockSprite()); + sprite.spriteFrame = new MockSpriteFrame(); + const collider = node.attach(new MockPolygonCollider2D()); + const originalPoints = collider.points; + mockAssetRequest.mockRejectedValue(new Error('decode failed')); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await expect(polygonModule().initializePolygonCollider2DPoints(collider as any)).resolves.toBeUndefined(); + + expect(collider.points).toBe(originalPoints); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('keeping the engine default points'), + ); + warn.mockRestore(); + }); + + it('generates Sprite Alpha points through the Node image RPC without changing the component directly', async () => { + const node = new MockNode(); + const transform = node.attach(new MockUITransform()); + transform.contentSize = { width: 200, height: 100 }; + transform.anchorX = 0.25; + transform.anchorY = 0.75; + const sprite = node.attach(new MockSprite()); + sprite.spriteFrame = new MockSpriteFrame({ x: 10, y: 20, width: 2, height: 2 }); + const collider = node.attach(new MockPolygonCollider2D()); + collider.threshold = 0; + const oldPoints = collider.points.slice(); + + const rgba = new Uint8Array(2 * 2 * 4); + for (let index = 3; index < rgba.length; index += 4) { + rgba[index] = 255; + } + mockAssetRequest.mockResolvedValue({ + dataBase64: Buffer.from(rgba).toString('base64'), + width: 2, + height: 2, + channels: 4, + }); + + const result = await polygonModule().generatePolygonPoints(collider as any); + + expect(result.source).toBe('sprite-alpha'); + expect(result.points.length).toBeGreaterThanOrEqual(3); + expect(mockAssetRequest).toHaveBeenCalledWith('assetManager', 'extractImagePixels', [ + 'texture-uuid', + { + rect: { left: 10, top: 20, width: 2, height: 2 }, + rotation: 0, + }, + ]); + expect(collider.points).toEqual(oldPoints); + }); + + it('keeps the Editor Marching Squares and RDP behavior in pure helpers', () => { + const { traceAlphaContour } = require('../scene-process/service/component/polygon-collider-2d/contour'); + const { simplifyContour } = require('../scene-process/service/component/polygon-collider-2d/simplify'); + const rgba = new Uint8Array(2 * 2 * 4); + for (let index = 3; index < rgba.length; index += 4) { + rgba[index] = 255; + } + + const contour = traceAlphaContour(rgba, 2, 2, true); + const simplified = simplifyContour(contour, 0); + + expect(contour[0]).toEqual(contour[contour.length - 1]); + expect(simplified.length).toBeGreaterThanOrEqual(4); + }); + + it('swaps the extraction size and rotates packed SpriteFrames', async () => { + const node = new MockNode(); + node.attach(new MockUITransform()); + const sprite = node.attach(new MockSprite()); + sprite.spriteFrame = new MockSpriteFrame({ x: 3, y: 4, width: 2, height: 3 }, true); + const collider = node.attach(new MockPolygonCollider2D()); + collider.threshold = 0; + + const rgba = new Uint8Array(2 * 3 * 4); + for (let index = 3; index < rgba.length; index += 4) { + rgba[index] = 255; + } + mockAssetRequest.mockResolvedValue({ + dataBase64: Buffer.from(rgba).toString('base64'), + width: 2, + height: 3, + channels: 4, + }); + + const result = await polygonModule().generatePolygonPoints(collider as any); + + expect(result.source).toBe('sprite-alpha'); + expect(mockAssetRequest).toHaveBeenCalledWith('assetManager', 'extractImagePixels', [ + 'texture-uuid', + { + rect: { left: 3, top: 4, width: 3, height: 2 }, + rotation: 90, + }, + ]); + }); + + it('falls back to the UITransform rectangle when the SpriteFrame source asset cannot be resolved', async () => { + const node = new MockNode(); + node.attach(new MockUITransform()); + const sprite = node.attach(new MockSprite()); + sprite.spriteFrame = new MockSpriteFrame(); + const collider = node.attach(new MockPolygonCollider2D()); + const originalPoints = collider.points; + mockAssetRequest.mockResolvedValue(null); + + await expect(polygonModule().generatePolygonPoints(collider as any)).resolves.toEqual({ + source: 'rect-fallback', + points: [ + { x: -50, y: -50 }, + { x: -50, y: 50 }, + { x: 50, y: 50 }, + { x: 50, y: -50 }, + ], + }); + + expect(collider.points).toBe(originalPoints); + }); + + it('adds SpriteFrame and source asset context when image extraction fails', async () => { + const node = new MockNode(); + node.attach(new MockUITransform()); + const sprite = node.attach(new MockSprite()); + sprite.spriteFrame = new MockSpriteFrame(); + const collider = node.attach(new MockPolygonCollider2D()); + const originalPoints = collider.points; + mockAssetRequest.mockRejectedValue(new Error('decode failed')); + + await expect(polygonModule().generatePolygonPoints(collider as any)) + .rejects.toThrow( + 'Failed to read source image pixels for SpriteFrame "texture-uuid@spriteFrame" from asset "texture-uuid": decode failed', + ); + + expect(collider.points).toBe(originalPoints); + }); + + it('validates point count, finite coordinates and cyclic adjacent duplicates', () => { + const { validatePolygonPoints } = polygonModule(); + + expect(() => validatePolygonPoints([new MockVec2(), new MockVec2(1, 0)] as any)) + .toThrow('at least 3 points'); + expect(() => validatePolygonPoints([ + new MockVec2(0, 0), + new MockVec2(Number.NaN, 0), + new MockVec2(0, 1), + ] as any)).toThrow('non-finite coordinate'); + expect(() => validatePolygonPoints([ + new MockVec2(0, 0), + new MockVec2(1, 0), + new MockVec2(0, 0), + ] as any)).toThrow('adjacent duplicates'); + expect(() => validatePolygonPoints([ + new MockVec2(0, 0), + new MockVec2(1, 0), + new MockVec2(0, 1), + ] as any)).not.toThrow(); + }); + + it('encodes candidate Vec2 values with the existing points element template', () => { + const { createPolygonPointsPropertyDump } = polygonModule(); + const property = { + name: 'points', + path: '', + type: 'cc.Vec2', + isArray: true, + elementTypeData: { + name: '', + path: '', + type: 'cc.Vec2', + value: { x: 0, y: 0 }, + }, + value: [], + }; + + const result = createPolygonPointsPropertyDump(property, [ + new MockVec2(-2, 3), + new MockVec2(4, 5), + new MockVec2(6, -7), + ] as any); + + expect(result?.value).toEqual([ + expect.objectContaining({ name: '0', type: 'cc.Vec2', value: { x: -2, y: 3 } }), + expect.objectContaining({ name: '1', type: 'cc.Vec2', value: { x: 4, y: 5 } }), + expect.objectContaining({ name: '2', type: 'cc.Vec2', value: { x: 6, y: -7 } }), + ]); + expect(property.value).toEqual([]); + }); + + it('distinguishes missing and wrong component types', () => { + const { requirePolygonCollider2D } = polygonModule(); + const wrong = new MockComponent(); + + expect(() => requirePolygonCollider2D(null, '/PolygonNode/cc.PolygonCollider2D')) + .toThrow('PolygonCollider2D component not found'); + expect(() => requirePolygonCollider2D(wrong as any, '/PolygonNode/cc.Label')) + .toThrow('Parameter error: component is not cc.PolygonCollider2D'); + }); +}); diff --git a/src/core/scene/test/service-core/component-prefab-ui-handling.test.ts b/src/core/scene/test/service-core/component-prefab-ui-handling.test.ts index 2891db226..47b7ad460 100644 --- a/src/core/scene/test/service-core/component-prefab-ui-handling.test.ts +++ b/src/core/scene/test/service-core/component-prefab-ui-handling.test.ts @@ -2,20 +2,63 @@ const mockLock = jest.fn(async () => undefined); const mockUnlock = jest.fn(); const mockGetRootNode = jest.fn(); const mockGetNodeByPath = jest.fn(); +const mockGetNodePath = jest.fn(); const mockCreateShouldHideInHierarchyCanvasNode = jest.fn(); const mockOnComponentAddedFromEditor = jest.fn(); +const mockQueryFromPath = jest.fn(); const mockDumpComponent = jest.fn(() => ({ value: {} })); const mockCaptureMany = jest.fn(() => null); const mockGetClassById = jest.fn(() => null); -const mockGetClassByName = jest.fn((name: string) => name === 'cc.UITransform' ? MockUITransform : null); +const mockGetClassByName = jest.fn((name: string): any => { + if (name === 'cc.UITransform') { + return MockUITransform; + } + if (name === 'cc.PolygonCollider2D') { + return MockPolygonCollider2D; + } + return null; +}); const mockIsChildClassOf = jest.fn(() => true); +const mockAssetRequest = jest.fn(); const mockScene = { name: 'Scene' }; class MockCanvas {} class MockComponent { uuid = `component-${Math.random()}`; + isValid = true; + node!: MockNode; +} +class MockUITransform extends MockComponent { + contentSize = { width: 100, height: 100 }; + anchorX = 0.5; + anchorY = 0.5; +} +class MockVec2 { + constructor(public x = 0, public y = 0) {} +} +class MockSprite extends MockComponent { + spriteFrame: MockSpriteFrame | null = null; +} +class MockSpriteFrame { + _uuid = 'texture-uuid@spriteFrame'; + + getRect() { + return { x: 0, y: 0, width: 2, height: 2 }; + } + + isRotated() { + return false; + } +} +class MockPolygonCollider2D extends MockComponent { + threshold = 1; + points = [ + new MockVec2(-1, -1), + new MockVec2(1, -1), + new MockVec2(1, 1), + new MockVec2(-1, 1), + ]; } -class MockUITransform extends MockComponent {} class MockNode { uuid: string; @@ -25,6 +68,7 @@ class MockNode { components: any[] = []; addComponent = jest.fn((component: any) => { const instance = component === 'cc.UITransform' ? new MockUITransform() : new component(); + instance.node = this; this.components.push(instance); return instance; }); @@ -47,6 +91,7 @@ class MockNode { (global as any).EditorExtends = { Node: { getNodeByPath: mockGetNodeByPath, + getNodePath: mockGetNodePath, }, }; @@ -62,12 +107,21 @@ jest.mock('cc', () => ({ EColliderType: {}, MeshCollider: class MeshCollider {}, Node: MockNode, + PolygonCollider2D: MockPolygonCollider2D, RigidBody: class RigidBody {}, Scene: class Scene {}, + Sprite: MockSprite, UITransform: MockUITransform, + Vec2: MockVec2, + Physics2DUtils: { + PolygonSeparator: { + ForceCounterClockWise: jest.fn(), + }, + }, js: { getClassById: mockGetClassById, getClassByName: mockGetClassByName, + getClassName: (ctor: { name?: string }) => ctor?.name || '', isChildClassOf: mockIsChildClassOf, }, })); @@ -95,17 +149,23 @@ jest.mock('../../scene-process/service/core', () => ({ })); jest.mock('../../scene-process/rpc', () => ({ - Rpc: { getInstance: () => ({ request: jest.fn() }) }, + Rpc: { getInstance: () => ({ request: mockAssetRequest }) }, })); jest.mock('../../scene-process/service/dump', () => ({ __esModule: true, - default: { dumpComponent: mockDumpComponent }, + default: { + dumpComponent: mockDumpComponent, + restoreProperty: jest.fn(async () => undefined), + }, })); jest.mock('../../scene-process/service/component/index', () => ({ __esModule: true, - default: { onComponentAddedFromEditor: mockOnComponentAddedFromEditor }, + default: { + onComponentAddedFromEditor: mockOnComponentAddedFromEditor, + queryFromPath: mockQueryFromPath, + }, })); jest.mock('../../scene-process/service/component/utils', () => ({ @@ -165,7 +225,9 @@ describe('ComponentService prefab UI handling', () => { root.parent = new MockNode('Scene'); mockGetRootNode.mockReturnValue(root); mockGetNodeByPath.mockReturnValue(new MockNode('Target')); + mockGetNodePath.mockReturnValue('/Target'); mockCreateShouldHideInHierarchyCanvasNode.mockResolvedValue(new MockNode('PreviewCanvas')); + mockAssetRequest.mockReset(); }); async function addUITransform(params: Record = {}) { @@ -191,6 +253,40 @@ describe('ComponentService prefab UI handling', () => { expect(mockOnComponentAddedFromEditor).toHaveBeenCalledTimes(1); }); + it('notifies component creation before waiting for PolygonCollider2D points, but delays dumping', async () => { + const target = new MockNode('Target'); + target.addComponent(MockUITransform); + const sprite = target.addComponent(MockSprite) as MockSprite; + sprite.spriteFrame = new MockSpriteFrame(); + mockGetNodeByPath.mockReturnValue(target); + let rejectImageExtraction!: (reason: Error) => void; + mockAssetRequest.mockImplementationOnce(() => new Promise((_resolve, reject) => { + rejectImageExtraction = reject; + })); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const { ComponentService } = require('../../scene-process/service/component'); + const service = new ComponentService(); + const adding = service.add({ + nodePath: '/Target', + component: 'cc.PolygonCollider2D', + }); + + await new Promise((resolve) => setImmediate(resolve)); + expect(mockAssetRequest).toHaveBeenCalled(); + expect(mockOnComponentAddedFromEditor).toHaveBeenCalledWith(expect.any(MockPolygonCollider2D)); + expect(mockDumpComponent).not.toHaveBeenCalled(); + expect(mockCaptureMany).not.toHaveBeenCalled(); + + rejectImageExtraction(new Error('decode failed')); + await expect(adding).resolves.toBeDefined(); + + expect(mockOnComponentAddedFromEditor).toHaveBeenCalledTimes(1); + expect(mockDumpComponent).toHaveBeenCalledTimes(1); + expect(mockCaptureMany).toHaveBeenCalledTimes(1); + warn.mockRestore(); + }); + it('does not create a hidden Canvas when the prefab root already has Canvas', async () => { const root = mockGetRootNode(); @@ -217,4 +313,121 @@ describe('ComponentService prefab UI handling', () => { expect(mockCreateShouldHideInHierarchyCanvasNode).toHaveBeenCalledTimes(2); expect(root.addComponent).not.toHaveBeenCalledWith('cc.UITransform'); }); + + describe('PolygonCollider2D regeneration', () => { + function createColliderFixture() { + const node = new MockNode('PolygonNode'); + const transform = new MockUITransform(); + transform.node = node; + transform.contentSize = { width: 200, height: 80 }; + transform.anchorX = 0.25; + transform.anchorY = 0.75; + const collider = new MockPolygonCollider2D(); + collider.node = node; + node.components.push(transform, collider); + return { node, collider }; + } + + it('commits the generated points through the real component index and setProperty', async () => { + const { node, collider } = createColliderFixture(); + mockQueryFromPath.mockReturnValue(collider); + mockGetNodePath.mockReturnValue('/PolygonNode'); + mockDumpComponent.mockReturnValue({ + value: { + points: { + name: 'points', + path: '', + type: 'cc.Vec2', + isArray: true, + elementTypeData: { + name: '', + path: '', + type: 'cc.Vec2', + value: { x: 0, y: 0 }, + }, + value: [], + }, + }, + }); + + const { ComponentService } = require('../../scene-process/service/component'); + const service = new ComponentService(); + const setProperty = jest.spyOn(service, 'setProperty').mockResolvedValue(true); + + const result = await service.regeneratePolygon2DPoints({ + path: '/PolygonNode/cc.PolygonCollider2D', + record: false, + }); + + expect(result).toEqual({ + path: '/PolygonNode/cc.PolygonCollider2D', + changed: true, + pointCount: 4, + source: 'rect-fallback', + }); + expect(setProperty).toHaveBeenCalledWith(expect.objectContaining({ + nodePath: '/PolygonNode', + path: '__comps__.1.points', + record: false, + dump: expect.objectContaining({ + value: [ + expect.objectContaining({ value: { x: -50, y: -60 } }), + expect.objectContaining({ value: { x: -50, y: 20 } }), + expect.objectContaining({ value: { x: 150, y: 20 } }), + expect.objectContaining({ value: { x: 150, y: -60 } }), + ], + }), + })); + expect(node.components.indexOf(collider)).toBe(1); + expect(mockLock).toHaveBeenCalled(); + expect(mockUnlock).toHaveBeenCalled(); + }); + + it('does not call setProperty when the generated rectangle is unchanged', async () => { + const { collider } = createColliderFixture(); + const transform = collider.node.components[0] as MockUITransform; + transform.contentSize = { width: 2, height: 2 }; + transform.anchorX = 0.5; + transform.anchorY = 0.5; + collider.points = [ + new MockVec2(-1, -1), + new MockVec2(-1, 1), + new MockVec2(1, 1), + new MockVec2(1, -1), + ]; + mockQueryFromPath.mockReturnValue(collider); + + const { ComponentService } = require('../../scene-process/service/component'); + const service = new ComponentService(); + const setProperty = jest.spyOn(service, 'setProperty').mockResolvedValue(true); + + const result = await service.regeneratePolygon2DPoints({ + path: '/PolygonNode/cc.PolygonCollider2D', + }); + + expect(result).toMatchObject({ changed: false, pointCount: 4 }); + expect(setProperty).not.toHaveBeenCalled(); + }); + + it('propagates generation errors and still releases the editor lock', async () => { + const { node, collider } = createColliderFixture(); + const sprite = new MockSprite(); + sprite.node = node; + sprite.spriteFrame = new MockSpriteFrame(); + node.components.splice(1, 0, sprite); + mockQueryFromPath.mockReturnValue(collider); + mockAssetRequest.mockRejectedValue(new Error('decode failed')); + + const { ComponentService } = require('../../scene-process/service/component'); + const service = new ComponentService(); + const setProperty = jest.spyOn(service, 'setProperty').mockResolvedValue(true); + + await expect(service.regeneratePolygon2DPoints({ + path: '/PolygonNode/cc.PolygonCollider2D', + })).rejects.toThrow('decode failed'); + + expect(setProperty).not.toHaveBeenCalled(); + expect(mockUnlock).toHaveBeenCalled(); + }); + }); }); diff --git a/tests/component-polygon-regenerate-api.test.ts b/tests/component-polygon-regenerate-api.test.ts new file mode 100644 index 000000000..ad63ca219 --- /dev/null +++ b/tests/component-polygon-regenerate-api.test.ts @@ -0,0 +1,110 @@ +const mockRegeneratePolygon2DPoints = jest.fn(); + +jest.mock('../src/api/decorator/decorator.js', () => ({ + description: () => jest.fn(), + param: () => jest.fn(), + result: () => jest.fn(), + title: () => jest.fn(), + tool: () => jest.fn(), +}), { virtual: true }); + +jest.mock('../src/core/scene', () => ({ + Scene: { + Component: { + regeneratePolygon2DPoints: (...args: unknown[]) => mockRegeneratePolygon2DPoints(...args), + }, + }, +})); + +import { ComponentApi } from '../src/api/scene/component'; +import { + SchemaRegeneratePolygon2DPointsOptions, + SchemaRegeneratePolygon2DPointsResult, +} from '../src/api/scene/component-schema'; +import { HTTP_STATUS } from '../src/api/base/schema-base'; + +describe('PolygonCollider2D regeneration MCP API', () => { + beforeEach(() => { + mockRegeneratePolygon2DPoints.mockReset(); + }); + + it('validates the dedicated input and result schemas', () => { + expect(SchemaRegeneratePolygon2DPointsOptions.parse({ + path: 'Canvas/Polygon/cc.PolygonCollider2D', + record: false, + })).toEqual({ + path: 'Canvas/Polygon/cc.PolygonCollider2D', + record: false, + }); + expect(() => SchemaRegeneratePolygon2DPointsOptions.parse({ path: '' })).toThrow(); + + expect(SchemaRegeneratePolygon2DPointsResult.parse({ + path: 'Canvas/Polygon/cc.PolygonCollider2D', + changed: true, + pointCount: 4, + source: 'rect-fallback', + })).toEqual({ + path: 'Canvas/Polygon/cc.PolygonCollider2D', + changed: true, + pointCount: 4, + source: 'rect-fallback', + }); + expect(() => SchemaRegeneratePolygon2DPointsResult.parse({ + path: 'Canvas/Polygon/cc.PolygonCollider2D', + changed: true, + pointCount: 2, + source: 'unknown', + })).toThrow(); + }); + + it('forwards to the public scene API and returns the regeneration result', async () => { + const options = { path: 'Canvas/Polygon/cc.PolygonCollider2D', record: false }; + const regenerationResult = { + path: options.path, + changed: true, + pointCount: 8, + source: 'sprite-alpha' as const, + }; + mockRegeneratePolygon2DPoints.mockResolvedValue(regenerationResult); + + const result = await new ComponentApi().regeneratePolygon2DPoints(options); + + expect(mockRegeneratePolygon2DPoints).toHaveBeenCalledWith(options); + expect(result).toEqual({ code: HTTP_STATUS.OK, data: regenerationResult }); + }); + + it('maps an invalid component type to 400', async () => { + mockRegeneratePolygon2DPoints.mockRejectedValue( + new Error('Parameter error: component is not cc.PolygonCollider2D: Canvas/Polygon/cc.Label'), + ); + + const result = await new ComponentApi().regeneratePolygon2DPoints({ + path: 'Canvas/Polygon/cc.Label', + }); + + expect(result.code).toBe(HTTP_STATUS.BAD_REQUEST); + expect(result.reason).toContain('component is not cc.PolygonCollider2D'); + }); + + it('maps a missing component to 404', async () => { + mockRegeneratePolygon2DPoints.mockRejectedValue( + new Error('PolygonCollider2D component not found: Canvas/Polygon/cc.PolygonCollider2D'), + ); + + const result = await new ComponentApi().regeneratePolygon2DPoints({ + path: 'Canvas/Polygon/cc.PolygonCollider2D', + }); + + expect(result.code).toBe(HTTP_STATUS.NOT_FOUND); + }); + + it('maps image processing failures to 500', async () => { + mockRegeneratePolygon2DPoints.mockRejectedValue(new Error('Failed to read source image pixels')); + + const result = await new ComponentApi().regeneratePolygon2DPoints({ + path: 'Canvas/Polygon/cc.PolygonCollider2D', + }); + + expect(result.code).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR); + }); +}); From de104e436a9d012dd9709db09ff38380f535a7df Mon Sep 17 00:00:00 2001 From: looopmax Date: Sat, 5 Sep 2026 12:44:56 +0800 Subject: [PATCH 05/11] fix(animation-graph): address PR #2 review issues (reset contract, atlas and converted image resolution) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P2-1: speedMultiplier combined property uses a full default factory so Inspector Reset restores both enabled and multiplier (was silently ignored) - P2-2: resolve SpriteFrame image source through the original texture reference (atlas plist frames and dynamic atlases), falling back to the frame uuid - P2-3: centralize decodable-file resolution in image-processing (resolveImagePixelSource): converted formats read the importer library PNG, others read the source; extractImagePixels uses it instead of assetInfo.file - add regression tests: set → reset → save/reload round-trip, texture uuid resolution cases, library png / missing product / plain source selection - include motion preview data query and preview service wiring (WIP continuation) Co-authored-by: CommandCodeBot --- docs/zh/animation-graph-data-structure.html | 1276 +++++++++++++++++ docs/zh/animation-graph-data-structure.md | 875 +++++++++++ .../__snapshots__/dts-snapshot.test.ts.snap | 60 +- src/core/assets/@types/public.d.ts | 8 + src/core/assets/animation-graph-service.ts | 59 +- src/core/assets/manager/asset.ts | 2 + .../test/animation-graph-service.test.ts | 108 ++ src/core/scene/common/preview.ts | 24 +- src/core/scene/main-process/index.ts | 2 + .../scene/main-process/proxy/preview-proxy.ts | 55 + .../preview/animation-graph-motion-preview.ts | 382 +++++ .../scene-process/service/preview/index.ts | 46 + src/lib/assets/assets.ts | 9 +- 13 files changed, 2902 insertions(+), 4 deletions(-) create mode 100644 docs/zh/animation-graph-data-structure.html create mode 100644 docs/zh/animation-graph-data-structure.md create mode 100644 src/core/scene/main-process/proxy/preview-proxy.ts create mode 100644 src/core/scene/scene-process/service/preview/animation-graph-motion-preview.ts diff --git a/docs/zh/animation-graph-data-structure.html b/docs/zh/animation-graph-data-structure.html new file mode 100644 index 000000000..605689a2d --- /dev/null +++ b/docs/zh/animation-graph-data-structure.html @@ -0,0 +1,1276 @@ + + + + + +cocos-cli Animation Graph 数据结构 + + + + +
+
+

cocos-cli — Animation Graph 数据结构

+
基于 Mermaid UML 描述 cocos-cli 中动画图(Animation Graph)的完整数据模型:版本与文档管理、寻址体系、视图快照、Pose 图、Inspector 与命令体系,以及内部文档模型与变体结构。
+
+ 12 张 UML 图 + 类型层 public.d.ts + 服务层 animation-graph-service.ts + 变体 animation-graph-variant.ts +
+
+
+ +
+ + +
+

cocos-cli Animation Graph 数据结构

+
本文档梳理 cocos-cli 中与 Animation Graph(动画图) 相关的全部数据结构,使用 Mermaid UML 图描述类型关系,并在各节配以字段速查表。 - 类型层(TypeScript .d.ts):src/core/assets/@types/public.d.ts - 服务层:src/core/assets/animation-graph-service.tssrc/core/assets/animation-graph-variant.ts - 处理器层:src/core/assets/asset-handler/assets/animation-graph.tsanimation-graph-variant.ts - API Schema:src/api/assets/schema.ts
+
+

1 概览

+

1.1 相关代码文件

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
文件角色
src/core/assets/@types/public.d.ts公共类型定义(版本、寻址、视图快照、命令、Inspector)
src/core/assets/animation-graph-service.ts动画图编辑服务(文档缓存、查询、命令执行、Inspector)
src/core/assets/animation-graph-variant.ts动画图变体服务(解读 / 修改 / 保存变体资产)
src/core/assets/asset-handler/assets/animation-graph.ts.animgraph 资源处理器(importer animation-graph
src/core/assets/asset-handler/assets/animation-graph-variant.ts.animgraphvari 资源处理器(importer animation-graph-variant
src/api/assets/schema.tsZod 校验 Schema(变体 dump 的 API 参数/结果契约)
+ +

1.2 组件总览图

+ +
+
Diagram 1

组件总览图

+
资源处理器
AnimationGraphHandler (.animgraph)
AnimationGraphVariantHandler (.animgraphvari)
API Schema (src/api/assets/schema.ts)
SchemaAnimationGraphVariantDump
服务内部结构 (animation-graph-service.ts)
AnimationGraphDocument
SourceFingerprint / InspectorBinding / AdapterProperty
变体结构 (animation-graph-variant.ts)
AnimGraphVariantDump
PendingAnimationGraphVariantEdit
公共类型层 public.d.ts
版本与文档管理
ExpectedVersion / Version / Snapshot / Event
寻址体系
Context / Address / Target
视图快照
ViewDump / Layer / StateMachine / State / Transition / Motion
Pose 图视图
PoseView / PoseNode / PoseInput
Inspector
InspectorSnapshot / Command
AnimationGraphAssetService
动画图编辑服务
AnimationGraphVariantAssetService
+
查看 Mermaid 源码
flowchart TB
+    subgraph PUBLIC["公共类型层 public.d.ts"]
+        P1["版本与文档管理<br/>ExpectedVersion / Version / Snapshot / Event"]
+        P2["寻址体系<br/>Context / Address / Target"]
+        P3["视图快照<br/>ViewDump / Layer / StateMachine / State / Transition / Motion"]
+        P4["Pose 图视图<br/>PoseView / PoseNode / PoseInput"]
+        P5["Inspector<br/>InspectorSnapshot / Command"]
+    end
+
+    subgraph VAR["变体结构 (animation-graph-variant.ts)"]
+        V1["AnimGraphVariantDump"]
+        V2["PendingAnimationGraphVariantEdit"]
+    end
+
+    subgraph INTERNAL["服务内部结构 (animation-graph-service.ts)"]
+        I1["AnimationGraphDocument"]
+        I2["SourceFingerprint / InspectorBinding / AdapterProperty"]
+    end
+
+    subgraph SCHEMA["API Schema (src/api/assets/schema.ts)"]
+        S1["SchemaAnimationGraphVariantDump"]
+    end
+
+    subgraph HANDLER["资源处理器"]
+        H1["AnimationGraphHandler (.animgraph)"]
+        H2["AnimationGraphVariantHandler (.animgraphvari)"]
+    end
+
+    SERVICE["AnimationGraphAssetService<br/>动画图编辑服务"] --> PUBLIC
+    SERVICE --> INTERNAL
+    H1 --> SERVICE
+    H2 --> VARSERVICE["AnimationGraphVariantAssetService"]
+    VARSERVICE --> VAR
+    VAR --> SCHEMA
+

+

2 公共类型层(public.d.ts)

+

2.1 版本与文档管理

+

AnimationGraphExpectedVersion 是乐观并发控制的最小单元;AnimationGraphVersion 追加持久化 / 脏标记状态;AnimationGraphSnapshotquery / execute / save / reload 返回的统一快照。AnimationGraphChangedEvent 用于向监听者广播变更。

+ +
+
Diagram 2

版本与文档管理

+
继承
继承
携带
携带
包含
1
1
AnimationGraphExpectedVersion
+string documentId
+number revision
AnimationGraphVersion
+number persistedRevision
+boolean dirty
+boolean externallyModified
AnimationGraphSnapshot
+string uuid
+string url
+AnimationGraphViewDump graph
AnimationGraphChangedEvent
+string uuid
+string reason
+AnimationGraphVersion version
+string sourceId
+string[] changedPaths
ReloadAnimationGraphOptions
+AnimationGraphExpectedVersion expected
+boolean discardDirty
AnimationGraphEditErrorCode (枚举)
+VERSION_CONFLICT
+DOCUMENT_RELOADED
+SOURCE_CHANGED
+TARGET_NOT_FOUND
+UNSUPPORTED_TARGET
+UNSUPPORTED_PROPERTY_OPERATION
+INVALID_PROPERTY_PATCH
+READONLY_PROPERTY
+NAME_CONFLICT
+DIRTY_DOCUMENT
AnimationGraphViewDump
+AnimationGraphLayerView[] layers
+AnimationGraphVariableView[] variables
+
查看 Mermaid 源码
classDiagram
+    direction LR
+
+    class AVExpected["AnimationGraphExpectedVersion"] {
+        +string documentId
+        +number revision
+    }
+
+    class AVVersion["AnimationGraphVersion"] {
+        +number persistedRevision
+        +boolean dirty
+        +boolean externallyModified
+    }
+
+    class AVSnapshot["AnimationGraphSnapshot"] {
+        +string uuid
+        +string url
+        +AnimationGraphViewDump graph
+    }
+
+    class AVEvent["AnimationGraphChangedEvent"] {
+        +string uuid
+        +string reason
+        +AnimationGraphVersion version
+        +string sourceId
+        +string[] changedPaths
+    }
+
+    class AVReloadOpts["ReloadAnimationGraphOptions"] {
+        +AnimationGraphExpectedVersion expected
+        +boolean discardDirty
+    }
+
+    class AVErrorCode["AnimationGraphEditErrorCode (枚举)"] {
+        +VERSION_CONFLICT
+        +DOCUMENT_RELOADED
+        +SOURCE_CHANGED
+        +TARGET_NOT_FOUND
+        +UNSUPPORTED_TARGET
+        +UNSUPPORTED_PROPERTY_OPERATION
+        +INVALID_PROPERTY_PATCH
+        +READONLY_PROPERTY
+        +NAME_CONFLICT
+        +DIRTY_DOCUMENT
+    }
+
+    class AVViewDump["AnimationGraphViewDump"] {
+        +AnimationGraphLayerView[] layers
+        +AnimationGraphVariableView[] variables
+    }
+
+    AVExpected <|-- AVVersion : 继承
+    AVVersion <|-- AVSnapshot : 继承
+    AVEvent --> AVVersion : 携带
+    AVSnapshot --> AVVersion : 携带
+    AVSnapshot "1" *-- "1" AVViewDump : 包含
+

字段速查

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
类型字段类型说明
AnimationGraphExpectedVersiondocumentIdstring文档唯一 ID(每次加载重新生成)
revisionnumber已提交修改的版本号
AnimationGraphVersionpersistedRevisionnumber已持久化版本号
dirtyboolean是否有未保存修改
externallyModifiedboolean源文件是否被外部改动
AnimationGraphSnapshotuuid / urlstring资产标识
graphAnimationGraphViewDump完整视图快照
AnimationGraphChangedEventreason'inspector' | 'structure' | 'save' | 'reload' | 'external'变更原因
sourceId? / changedPaths?string / string[]变更来源与路径
+ +
+

2.2 寻址体系:Context / Address / Target

+

动画图由 Layer(层)→ StateMachine(状态机)→ State(状态)/ Transition(过渡)→ Motion(动作),以及独立的 Pose Graph(姿态图)→ PoseNode + 内嵌状态机 组成。为了让 Inspector / 命令能够唯一定位任意节点,cocos-cli 定义了一套上下文 → 地址 → 目标的三角寻址体系。

+
说明:图中的继承箭头表示「类型组合/扩展」关系(TypeScript 交叉类型语义),note 块给出判别联合(discriminated union)的 kind 成员。
+ +
+
Diagram 3

寻址体系:Context / Address / Target

+
引用解析
引用解析
扩展
扩展
扩展
扩展
组成
组成
组成
组成
«判别联合»
AnimationGraphStateMachineContext
+layer-state-machine kind
+pose-node-state-machine kind
+sub-state-machine kind
«判别联合»
AnimationGraphPoseGraphContext
+state-pose-graph kind
+layer-stash kind
«判别联合»
AnimationGraphStateMachineAddress
+直接形式(layerIndex + stateMachinePath)
+上下文形式(stateMachine 引用)
AnimationGraphStateAddress
+number stateIndex
«判别联合»
AnimationGraphPoseGraphAddress
+直接形式(layerIndex + stateMachinePath + stateIndex)
+上下文形式(poseGraph 引用)
AnimationGraphPoseNodeAddress
+number nodeId
AnimationGraphMotionAddress
+number[] level 层级路径
«判别联合»
AnimationGraphTarget
+layer 目标
+state 目标
+transition 目标
+motion 目标
+pose-node 目标
+pose-input 目标
+state-component 目标
1) kind=layer-state-machine: { layerIndex, stateMachinePath[] }
2) kind=pose-node-state-machine: { poseGraph, nodeId }
3) kind=sub-state-machine: { stateMachine, stateIndex }
1) kind=state-pose-graph: { stateMachine, stateIndex }
2) kind=layer-stash: { layerIndex, stashName }
+
查看 Mermaid 源码
classDiagram
+    direction TB
+
+    class CMSctx["AnimationGraphStateMachineContext"] {
+        <<判别联合>>
+        +layer-state-machine kind
+        +pose-node-state-machine kind
+        +sub-state-machine kind
+    }
+
+    class CPGctx["AnimationGraphPoseGraphContext"] {
+        <<判别联合>>
+        +state-pose-graph kind
+        +layer-stash kind
+    }
+
+    note for CMSctx "1) kind=layer-state-machine: { layerIndex, stateMachinePath[] }<br/>2) kind=pose-node-state-machine: { poseGraph, nodeId }<br/>3) kind=sub-state-machine: { stateMachine, stateIndex }"
+    note for CPGctx "1) kind=state-pose-graph: { stateMachine, stateIndex }<br/>2) kind=layer-stash: { layerIndex, stashName }"
+
+    class ASMAddr["AnimationGraphStateMachineAddress"] {
+        <<判别联合>>
+        +直接形式 (layerIndex + stateMachinePath)
+        +上下文形式 (stateMachine 引用)
+    }
+
+    class AStateAddr["AnimationGraphStateAddress"] {
+        +number stateIndex
+    }
+
+    class APGAddr["AnimationGraphPoseGraphAddress"] {
+        <<判别联合>>
+        +直接形式 (layerIndex + stateMachinePath + stateIndex)
+        +上下文形式 (poseGraph 引用)
+    }
+
+    class APNAddr["AnimationGraphPoseNodeAddress"] {
+        +number nodeId
+    }
+
+    class AMotionAddr["AnimationGraphMotionAddress"] {
+        +number[] level 层级路径
+    }
+
+    class ATarget["AnimationGraphTarget"] {
+        <<判别联合>>
+        +layer 目标
+        +state 目标
+        +transition 目标
+        +motion 目标
+        +pose-node 目标
+        +pose-input 目标
+        +state-component 目标
+    }
+
+    CMSctx ..> ASMAddr : 引用解析
+    CPGctx ..> APGAddr : 引用解析
+    ASMAddr <|-- AStateAddr : 扩展
+    APGAddr <|-- APNAddr : 扩展
+    AStateAddr <|-- AMotionAddr : 扩展
+    APNAddr <|-- AMotionAddr : 扩展
+    AStateAddr <|-- ATarget : 组成
+    ASMAddr <|-- ATarget : 组成
+    APNAddr <|-- ATarget : 组成
+    AMotionAddr <|-- ATarget : 组成
+

字段速查(关键联合成员)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
类型成员结构
AnimationGraphStateMachineContextlayer-state-machine{ kind, layerIndex, stateMachinePath: number[] }
pose-node-state-machine{ kind, poseGraph, nodeId }
sub-state-machine{ kind, stateMachine, stateIndex }
AnimationGraphPoseGraphContextstate-pose-graph{ kind, stateMachine, stateIndex }
layer-stash{ kind, layerIndex, stashName }
AnimationGraphTargetlayer{ kind, layerIndex }
state{ kind } & StateAddress
transition{ kind, transitionIndex } & StateMachineAddress
motion{ kind } & MotionAddress
pose-node{ kind } & PoseNodeAddress
pose-input{ kind, inputId } & PoseNodeAddress
state-component{ kind, componentIndex } & StateAddress
+ +
+

2.3 视图快照体系

+

服务端将引擎运行时对象投影为只读视图(View),供 Webview / Inspector 消费。树形关系如下:

+
    +
  • AnimationGraphViewDump(整图)→ 多个 LayerView
  • +
  • LayerViewStateMachineViewStateMachineView → 多个 StateViewTransitionView
  • +
  • StateView 可递归内嵌 StateMachineView(子状态机)、携带 MotionView(动作状态)、或挂 PoseView(过程姿态状态)
  • +
  • MotionView 递归包含子 MotionView(1D/2D 混合)
  • +
+ +
+
Diagram 4

视图快照体系

+
1
many
1
many
stateMachine
1
many
states
1
many
transitions
1
many
motion
1
0..1
子状态机
1
0..1
poseGraph
1
0..1
components
1
many
conditions
1
many
children 递归
1
many
AnimationGraphViewDump
+AnimationGraphLayerView[] layers
+AnimationGraphVariableView[] variables
AnimationGraphLayerView
+number index
+string name
+number weight
+boolean additive
+string maskUuid
+string[] stashes
+stashPoseGraphs
+AnimationGraphStateMachineView stateMachine
AnimationGraphStateMachineView
+context
+number[] path
+boolean allowEmptyStates
+AnimationGraphStateView[] states
+AnimationGraphTransitionView[] transitions
+editorData
AnimationGraphStateView
+number index
+type type
+string name
+number[] incomingTransitionIndices
+number[] outgoingTransitionIndices
+components
+speed
+speedMultiplier
+speedMultiplierEnabled
+motion
+stateMachine
+poseGraph
+editorData
AnimationGraphTransitionView
+number index
+type type
+number fromStateIndex
+number toStateIndex
+number priority
+conditions
+duration
+relativeDuration
+exitConditionEnabled
+exitCondition
+destinationStart
+relativeDestinationStart
+startEvent
+endEvent
«判别联合»
AnimationGraphTransitionConditionView
+BinaryCondition
+UnaryCondition
+TriggerCondition
+Unknown
AnimationGraphComponentView
+number index
+string type
AnimationGraphMotionView
+number[] level
+target
+string name
+clipUuid
+variable / value
+variableX / valueX
+variableY / valueY
+threshold
+weight
+children
+type(clip/blend-1d/blend-2d/blend-direct/unknown)
AnimationGraphVariableView
+string name
+number type
+IProperty value
+resetMode
AnimationGraphPoseView
+
查看 Mermaid 源码
classDiagram
+    direction TB
+
+    class AViewDump["AnimationGraphViewDump"] {
+        +AnimationGraphLayerView[] layers
+        +AnimationGraphVariableView[] variables
+    }
+
+    class ALayer["AnimationGraphLayerView"] {
+        +number index
+        +string name
+        +number weight
+        +boolean additive
+        +string maskUuid
+        +string[] stashes
+        +stashPoseGraphs
+        +AnimationGraphStateMachineView stateMachine
+    }
+
+    class ASM["AnimationGraphStateMachineView"] {
+        +context
+        +number[] path
+        +boolean allowEmptyStates
+        +AnimationGraphStateView[] states
+        +AnimationGraphTransitionView[] transitions
+        +editorData
+    }
+
+    class AState["AnimationGraphStateView"] {
+        +number index
+        +type type
+        +string name
+        +number[] incomingTransitionIndices
+        +number[] outgoingTransitionIndices
+        +components
+        +speed
+        +speedMultiplier
+        +speedMultiplierEnabled
+        +motion
+        +stateMachine
+        +poseGraph
+        +editorData
+    }
+
+    class ATrans["AnimationGraphTransitionView"] {
+        +number index
+        +type type
+        +number fromStateIndex
+        +number toStateIndex
+        +number priority
+        +conditions
+        +duration
+        +relativeDuration
+        +exitConditionEnabled
+        +exitCondition
+        +destinationStart
+        +relativeDestinationStart
+        +startEvent
+        +endEvent
+    }
+
+    class ACond["AnimationGraphTransitionConditionView"] {
+        <<判别联合>>
+        +BinaryCondition
+        +UnaryCondition
+        +TriggerCondition
+        +Unknown
+    }
+
+    class AComp["AnimationGraphComponentView"] {
+        +number index
+        +string type
+    }
+
+    class AMotion["AnimationGraphMotionView"] {
+        +number[] level
+        +target
+        +type (clip/blend-1d/blend-2d/blend-direct/unknown)
+        +string name
+        +clipUuid
+        +variable / value
+        +variableX / valueX
+        +variableY / valueY
+        +threshold
+        +weight
+        +children
+    }
+
+    class AVar["AnimationGraphVariableView"] {
+        +string name
+        +number type
+        +IProperty value
+        +resetMode
+    }
+
+    class APose["AnimationGraphPoseView"]
+
+    AViewDump "1" *-- "many" ALayer
+    AViewDump "1" *-- "many" AVar
+    ALayer "1" *-- "many" ASM : stateMachine
+    ASM "1" *-- "many" AState : states
+    ASM "1" *-- "many" ATrans : transitions
+    AState "1" o-- "0..1" AMotion : motion
+    AState "1" o-- "0..1" ASM : 子状态机
+    AState "1" o-- "0..1" APose : poseGraph
+    AState "1" o-- "many" AComp : components
+    ATrans "1" *-- "many" ACond : conditions
+    AMotion "1" *-- "many" AMotion : children 递归
+

字段速查

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
类型关键字段
AnimationGraphViewDumplayers[]variables[]
AnimationGraphLayerViewindexnameweightadditivemaskUuid: string|nullstashes: string[]stashPoseGraphs[]stateMachine
AnimationGraphStateMachineViewcontextpath: number[]allowEmptyStatesstates[]transitions[]editorData?
AnimationGraphStateViewindextypeentry/exit/any/motion/empty/sub-state-machine/procedural-pose/unknown)、nameincoming/outgoingTransitionIndices[]components[]speed?speedMultiplier?speedMultiplierEnabled?motion?stateMachine?poseGraph?editorData?
AnimationGraphTransitionViewindextypeanimation/empty-state/procedural-pose/transition)、fromStateIndextoStateIndexpriorityconditions[]duration?relativeDuration?exitConditionEnabled?exitCondition?destinationStart?relativeDestinationStart?startEvent?endEvent?
AnimationGraphTransitionConditionView判别联合:Binary / Unary / Trigger / Unknown,见下
AnimationGraphComponentViewindextype
AnimationGraphMotionViewlevel[]targettypenameclipUuid?variable?/value?variableX?/valueX?variableY?/valueY?threshold?weight?children?editorData?
AnimationGraphVariableViewnametype: numbervalue: IPropertyresetMode?: number(Trigger 类型才有)
+ +

过渡条件(TransitionConditionView)判别联合成员

+ + + + + + + + + + + + + + + + + + + + + + + +
type字段
BinaryConditionindexoperatorlhslhsBindingbindingClassrhsisRhsInteger
UnaryConditionindexoperatoroperand
TriggerConditionindextrigger
UnknownindexclassName
+ +
+

2.4 Pose 图视图体系

+

Pose 图是一张节点连通图:根输出节点引出,节点间通过输入/输出端口相连;节点可内嵌状态机或动作(Motion),也可以作为 Stash 入口。

+ +
+
Diagram 5

Pose 图视图体系

+
nodes
1
many
addNodeInfos
1
many
assetDragHandlersMap
1
many
inputs
1
many
enterInfo
1
0..1
内嵌状态机
1
0..1
内嵌动作
1
0..1
handlers
1
many
handlers
1
many
AnimationGraphPoseView
+context
+number rootOutputNodeId
+AnimationGraphPoseNodeView[] nodes
+addNodeInfos
+assetDragHandlersMap
AnimationGraphPoseNodeView
+number id
+string type
+string title
+number[] outputTypes
+AnimationGraphPoseInputView[] inputs
+inputInsertInfos
+stateMachine
+motion
+enterInfo
+editorData
AnimationGraphPoseInputView
+string id
+string displayName
+number type
+boolean deletable
+boolean insertPoint
+boolean connected
+producerNodeId
+producerOutputId
+value
AnimationGraphPoseNodeEnterInfo
+stashName
+type(state-machine/animation-blend/stash)
AnimationGraphPoseGraphAddNodeInfo
+string typeId
+args
+string menu
AnimationGraphPoseGraphAssetDragHandlersView
+handlers
AnimationGraphPoseGraphAssetDragHandlerView
+string displayName
AnimationGraphPoseGraphAssetDragHandlersEntry
+string assetType
+handlers
AnimationGraphPoseGraphAssetDragHandlerInfo
+string id
+string displayName
AnimationGraphStateMachineView
AnimationGraphMotionView
+
查看 Mermaid 源码
classDiagram
+    direction TB
+
+    class APoseView["AnimationGraphPoseView"] {
+        +context
+        +number rootOutputNodeId
+        +AnimationGraphPoseNodeView[] nodes
+        +addNodeInfos
+        +assetDragHandlersMap
+    }
+
+    class APoseNode["AnimationGraphPoseNodeView"] {
+        +number id
+        +string type
+        +string title
+        +number[] outputTypes
+        +AnimationGraphPoseInputView[] inputs
+        +inputInsertInfos
+        +stateMachine
+        +motion
+        +enterInfo
+        +editorData
+    }
+
+    class APoseInput["AnimationGraphPoseInputView"] {
+        +string id
+        +string displayName
+        +number type
+        +boolean deletable
+        +boolean insertPoint
+        +boolean connected
+        +producerNodeId
+        +producerOutputId
+        +value
+    }
+
+    class AEnterInfo["AnimationGraphPoseNodeEnterInfo"] {
+        +type (state-machine/animation-blend/stash)
+        +stashName
+    }
+
+    class AAddNode["AnimationGraphPoseGraphAddNodeInfo"] {
+        +string typeId
+        +args
+        +string menu
+    }
+
+    class ADragView["AnimationGraphPoseGraphAssetDragHandlersView"] {
+        +handlers
+    }
+
+    class ADragHandler["AnimationGraphPoseGraphAssetDragHandlerView"] {
+        +string displayName
+    }
+
+    class ADragEntry["AnimationGraphPoseGraphAssetDragHandlersEntry"] {
+        +string assetType
+        +handlers
+    }
+
+    class ADragInfo["AnimationGraphPoseGraphAssetDragHandlerInfo"] {
+        +string id
+        +string displayName
+    }
+
+    class ASM["AnimationGraphStateMachineView"]
+    class AMotion["AnimationGraphMotionView"]
+
+    APoseView "1" *-- "many" APoseNode : nodes
+    APoseView "1" *-- "many" AAddNode : addNodeInfos
+    APoseView "1" *-- "many" ADragView : assetDragHandlersMap
+    APoseNode "1" *-- "many" APoseInput : inputs
+    APoseNode "1" o-- "0..1" AEnterInfo : enterInfo
+    APoseNode "1" o-- "0..1" ASM : 内嵌状态机
+    APoseNode "1" o-- "0..1" AMotion : 内嵌动作
+    ADragView "1" *-- "many" ADragHandler : handlers
+    ADragEntry "1" *-- "many" ADragInfo : handlers
+

字段速查

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
类型关键字段
AnimationGraphPoseViewcontextrootOutputNodeId: numbernodes[]addNodeInfos[]assetDragHandlersMap
AnimationGraphPoseNodeViewidtypetitleoutputTypes: number[]inputs[]inputInsertInfosstateMachine?motion?enterInfo?editorData?
AnimationGraphPoseInputViewiddisplayNametypedeletableinsertPointconnectedproducerNodeId?producerOutputId?value?: IProperty
AnimationGraphPoseNodeEnterInfotype: 'state-machine' | 'animation-blend' | 'stash'stashName?
AnimationGraphPoseGraphAddNodeInfotypeIdargs: unknownmenu(面包屑式路径)
AnimationGraphPoseGraphAssetDragHandlersView/Entryhandlers(按 handlerId 索引) / assetType + handlers[]
+ +
+

2.5 Inspector 快照与命令

+

Inspector 通过「目标 + 属性路径」读写属性;每次操作都携带 expected 版本做乐观并发控制。

+ +
+
Diagram 6

Inspector 快照与命令

+
propertyCapabilities
1
1
target
1
1
继承扩展
携带命令
1
1
AnimationGraphInspectorSnapshot
+string uuid
+AnimationGraphTarget target
+IProperty dump
+propertyCapabilities
AnimationGraphInspectorPropertyCapabilities
+boolean set
+boolean reset
+boolean create
AnimationGraphInspectorPropertyOperationRequest
+AnimationGraphTarget target
+string path
+AnimationGraphExpectedVersion expected
+string sourceId
SetAnimationGraphInspectorPropertyRequest
+patch(IProperty or unknown)
ExecuteAnimationGraphCommandRequest
+AnimationGraphCommand command
+AnimationGraphExpectedVersion expected
+string sourceId
«判别联合»
AnimationGraphTarget
+kind
AnimationGraphCommand
+string type
dump 为序列化后的属性描述树(IProperty);propertyCapabilities 描述 property 支持 set/reset/create 的能力
Inspector 属性设置请求 = 基础请求 + patch
+
查看 Mermaid 源码
classDiagram
+    direction TB
+
+    class AInspSnap["AnimationGraphInspectorSnapshot"] {
+        +string uuid
+        +AnimationGraphTarget target
+        +IProperty dump
+        +propertyCapabilities
+    }
+
+    class AInspCap["AnimationGraphInspectorPropertyCapabilities"] {
+        +boolean set
+        +boolean reset
+        +boolean create
+    }
+
+    class AInspReq["AnimationGraphInspectorPropertyOperationRequest"] {
+        +AnimationGraphTarget target
+        +string path
+        +AnimationGraphExpectedVersion expected
+        +string sourceId
+    }
+
+    class ASetReq["SetAnimationGraphInspectorPropertyRequest"] {
+        +patch (IProperty or unknown)
+    }
+
+    class AExecReq["ExecuteAnimationGraphCommandRequest"] {
+        +AnimationGraphCommand command
+        +AnimationGraphExpectedVersion expected
+        +string sourceId
+    }
+
+    note for AInspSnap "dump 为序列化后的属性描述树(IProperty);propertyCapabilities 描述 property 支持 set/reset/create 的能力"
+    note for ASetReq "Inspector 属性设置请求 = 基础请求 + patch"
+
+    class ATarget["AnimationGraphTarget"] {
+        <<判别联合>>
+        +kind
+    }
+
+    class ACmd["AnimationGraphCommand"] {
+        +string type
+    }
+
+    AInspSnap "1" *-- "1" AInspCap : propertyCapabilities
+    AInspSnap "1" --> "1" ATarget : target
+    AInspReq <|-- ASetReq : 继承扩展
+    AExecReq "1" --> "1" ACmd : 携带命令
+

字段速查

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
类型说明
AnimationGraphInspectorSnapshotuuidtargetdump: IPropertypropertyCapabilities?
AnimationGraphInspectorPropertyCapabilitiesset / reset / create: boolean
AnimationGraphInspectorPropertyOperationRequesttargetpathexpectedsourceId?
SetAnimationGraphInspectorPropertyRequest基础请求 + patch: IProperty | unknown
ExecuteAnimationGraphCommandRequestcommandexpectedsourceId?
+ +
+

2.6 命令体系:AnimationGraphCommand

+

AnimationGraphCommandtype 判别的大型联合类型,覆盖 Layer / State / Transition / Motion / StateComponent / Pose / Variable / Stash 的增删改。所有命令通过 execute() 在服务端执行,并推进文档 revision

+ +
+
Diagram 7

命令体系:AnimationGraphCommand

+
成员
1
many
成员
1
many
成员
1
many
成员
1
many
成员
1
many
成员
1
many
AnimationGraphCommand
+string type 判别字段
Layer / Stash 层领域
+add-layer
+remove-layer
+move-layer
+add-stash
+remove-stash
+rename-stash
+stash-pose-graph
State 状态领域
+add-state
+remove-state
+duplicate-state
+set-state-editor-data
+add-state-component
+remove-state-component
Transition 过渡领域
+add-transition
+remove-transition
+move-transition
+add-transition-condition
+remove-transition-condition
+set-transition-condition-property
+set-transition-condition-binding-class
+set-transition-event-binding
Motion 动作领域
+set-motion
+add-motion-child
+remove-motion
+set-motion-editor-data
+set-motion-threshold
+set-direct-blend-weight
Pose 图领域
+add-pose-node
+create-pose-node-on-asset-drag
+remove-pose-node
+duplicate-pose-nodes
+set-pose-node-editor-data
+connect-pose-nodes
+disconnect-pose-input
+insert-pose-input
+delete-pose-input
Variable 变量领域
+add-variable
+set-variable-value
+set-trigger-reset-mode
+remove-variable
+rename-variable
+
查看 Mermaid 源码
classDiagram
+    direction LR
+
+    class Cmd["AnimationGraphCommand"] {
+        +string type 判别字段
+    }
+
+    class LayerCmds["Layer / Stash 层领域"] {
+        +add-layer
+        +remove-layer
+        +move-layer
+        +add-stash
+        +remove-stash
+        +rename-stash
+        +stash-pose-graph
+    }
+
+    class StateCmds["State 状态领域"] {
+        +add-state
+        +remove-state
+        +duplicate-state
+        +set-state-editor-data
+        +add-state-component
+        +remove-state-component
+    }
+
+    class TransCmds["Transition 过渡领域"] {
+        +add-transition
+        +remove-transition
+        +move-transition
+        +add-transition-condition
+        +remove-transition-condition
+        +set-transition-condition-property
+        +set-transition-condition-binding-class
+        +set-transition-event-binding
+    }
+
+    class MotionCmds["Motion 动作领域"] {
+        +set-motion
+        +add-motion-child
+        +remove-motion
+        +set-motion-editor-data
+        +set-motion-threshold
+        +set-direct-blend-weight
+    }
+
+    class PoseCmds["Pose 图领域"] {
+        +add-pose-node
+        +create-pose-node-on-asset-drag
+        +remove-pose-node
+        +duplicate-pose-nodes
+        +set-pose-node-editor-data
+        +connect-pose-nodes
+        +disconnect-pose-input
+        +insert-pose-input
+        +delete-pose-input
+    }
+
+    class VarCmds["Variable 变量领域"] {
+        +add-variable
+        +set-variable-value
+        +set-trigger-reset-mode
+        +remove-variable
+        +rename-variable
+    }
+
+    Cmd "1" *-- "many" LayerCmds : 成员
+    Cmd "1" *-- "many" StateCmds : 成员
+    Cmd "1" *-- "many" TransCmds : 成员
+    Cmd "1" *-- "many" MotionCmds : 成员
+    Cmd "1" *-- "many" PoseCmds : 成员
+    Cmd "1" *-- "many" VarCmds : 成员
+
大多数命令在 type 之外还内联携带状态机地址(StateMachineAddress)目标(Target),例如 add-stateadd-transitionconnect-pose-nodes 等;地址解析逻辑复用第 2.2 节的寻址体系。
+

相关类型别名

+ + + + + + + + + + + + + + + + + + + +
别名取值
AnimationGraphStateType'motion' | 'empty' | 'sub-state-machine' | 'procedural-pose'
AnimationGraphMotionType'clip' | 'blend-1d' | 'blend-2d' | 'blend-direct'
AnimationGraphTransitionConditionType'binary' | 'unary' | 'trigger'
+ +
+

2.7 AnimationMask(动画掩码)

+

Layer 的 maskUuid 指向一张动画掩码资产,其 dump 结构如下:

+ +
+
Diagram 8

AnimationMask(动画掩码)

+
joints
1
many
children 递归
1
many
AnimationMaskDump
+number version
+string assetUuid
+AnimationMaskJoint[] joints
AnimationMaskJoint
+string path
+boolean enabled
+AnimationMaskJoint[] children
AnimationMaskChange
+string path
+boolean enabled
+boolean recursive
+
查看 Mermaid 源码
classDiagram
+    class MaskDump["AnimationMaskDump"] {
+        +number version
+        +string assetUuid
+        +AnimationMaskJoint[] joints
+    }
+
+    class MaskJoint["AnimationMaskJoint"] {
+        +string path
+        +boolean enabled
+        +AnimationMaskJoint[] children
+    }
+
+    class MaskChange["AnimationMaskChange"] {
+        +string path
+        +boolean enabled
+        +boolean recursive
+    }
+
+    MaskDump "1" *-- "many" MaskJoint : joints
+    MaskJoint "1" *-- "many" MaskJoint : children 递归
+

+

3 服务内部数据结构(animation-graph-service.ts)

+

服务以「文档」为核心缓存:对每个动画图资产维护一个 AnimationGraphDocument,内部处理版本并发、外部写入检测(指纹对比)、节点 ID 分配与变更事件广播。

+ +
+
Diagram 9

服务内部数据结构(animation-graph-service.ts)

+
_documents 缓存
1
many
按需创建
1
many
fingerprint
1
1
属性适配器
1
many
AnimationGraphAssetService
+query()
+queryInspector()
+queryPoseGraphAssetDragHandlers()
+queryStateMachineComponentTypes()
+setInspectorProperty()
+resetInspectorProperty()
+createInspectorProperty()
+execute()
+save()
+reload()
+onChanged()
+runExternalWrite(s)
+assertExternalWriteAllowed()
AnimationGraphDocument
+string uuid
+string url
+string source
+graph 引擎图对象
+string documentId
+number revision
+number persistedRevision
+boolean dirty
+boolean externallyModified
+SourceFingerprint fingerprint
+number nextNodeId
+nodeIds(WeakMap)
+nodesById(Map)
SourceFingerprint
+number mtimeMs
+number assetDbMtime
+string hash(sha256)
InspectorBinding
+IProperty dump
+propertyCapabilities
+apply(path, patch)
+reset(path)
+create(path)
AdapterProperty
+attrs
+get()
+set(value)
AnimationGraphEditError
+message
+currentVersion
+code(AnimationGraphEditErrorCode)
nodeIds/nodesById:为 Pose 图节点分配稳定数字 ID 的双向索引;nextNodeId 为递增计数器
通过 createAdapterBinding / createDecoratedBinding / createAdapterBinding 构造
+
查看 Mermaid 源码
classDiagram
+    direction TB
+
+    class SVC["AnimationGraphAssetService"] {
+        +query()
+        +queryInspector()
+        +queryPoseGraphAssetDragHandlers()
+        +queryStateMachineComponentTypes()
+        +setInspectorProperty()
+        +resetInspectorProperty()
+        +createInspectorProperty()
+        +execute()
+        +save()
+        +reload()
+        +onChanged()
+        +runExternalWrite(s)
+        +assertExternalWriteAllowed()
+    }
+
+    class Doc["AnimationGraphDocument"] {
+        +string uuid
+        +string url
+        +string source
+        +graph 引擎图对象
+        +string documentId
+        +number revision
+        +number persistedRevision
+        +boolean dirty
+        +boolean externallyModified
+        +SourceFingerprint fingerprint
+        +nodeIds (WeakMap)
+        +nodesById (Map)
+        +number nextNodeId
+    }
+
+    class FP["SourceFingerprint"] {
+        +string hash (sha256)
+        +number mtimeMs
+        +number assetDbMtime
+    }
+
+    class Binding["InspectorBinding"] {
+        +IProperty dump
+        +propertyCapabilities
+        +apply(path, patch)
+        +reset(path)
+        +create(path)
+    }
+
+    class Adapter["AdapterProperty"] {
+        +get()
+        +set(value)
+        +attrs
+    }
+
+    class Err["AnimationGraphEditError"] {
+        +code (AnimationGraphEditErrorCode)
+        +message
+        +currentVersion
+    }
+
+    note for Doc "nodeIds/nodesById:为 Pose 图节点分配稳定数字 ID 的双向索引;nextNodeId 为递增计数器"
+    note for Binding "通过 createAdapterBinding / createDecoratedBinding / createAdapterBinding 构造"
+
+    SVC "1" o-- "many" Doc : _documents 缓存
+    SVC "1" o-- "many" Binding : 按需创建
+    Doc "1" *-- "1" FP : fingerprint
+    Binding "1" *-- "many" Adapter : 属性适配器
+

关键说明

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
结构说明
AnimationGraphDocument内存中的可编辑图文档;graph 是引擎反序列化后的对象树;nodeIds/nodesById 为 Pose 图节点分配稳定 ID;每次落盘成功会刷新 fingerprint
SourceFingerprint源文件 sha256 + mtimeMs + 资源库 mtime,用于检测外部修改(externallyModified
InspectorBinding绑定到具体目标(Layer/State/Transition/Motion/PoseNode/PoseInput/StateComponent)的属性读写职责
AdapterProperty属性 getter/setter + 描述元数据(attrs),供编码为 IProperty dump
AnimationGraphEditError编辑异常,携带可枚举错误码(见 2.1 AnimationGraphEditErrorCode
+ +
+

4 动画图变体(animation-graph-variant.ts)

+

动画图变体在引用一个动画图的基础上,覆写其中的动画片段(clip),形成可复用的资源变体。

+ +
+
Diagram 10

动画图变体(animation-graph-variant.ts)

+
_pendingEdits 缓存
1
many
graph
1
0..1
dump
1
1
AnimationGraphVariantAssetService
+query(uuid)
+change(uuid, dump)
+save(uuid)
AnimGraphVariantDump
+string graphUuid
+clips(Map: 原clipUuid -> 替代clipUuid)
+invalids(Map: 未命中项)
PendingAnimationGraphVariantEdit
+string uuid
+string source
+number sourceMtimeMs
+number assetDbMtime
+PendingAnimationGraphSnapshot graph
+sourceOverrides
+AnimGraphVariantDump dump
PendingAnimationGraphSnapshot
+string uuid
+string source
+number sourceMtimeMs
+number assetDbMtime
invalids 表示历史保存的覆写项在原图中已不存在,仅展示不落盘
change() 先修改内存中的 pending 编辑,save() 统一写盘并清理 pending
+
查看 Mermaid 源码
classDiagram
+    direction TB
+
+    class VSVC["AnimationGraphVariantAssetService"] {
+        +query(uuid)
+        +change(uuid, dump)
+        +save(uuid)
+    }
+
+    class VariantDump["AnimGraphVariantDump"] {
+        +string graphUuid
+        +clips (Map: 原clipUuid -> 替代clipUuid)
+        +invalids (Map: 未命中项)
+    }
+
+    class PendingEdit["PendingAnimationGraphVariantEdit"] {
+        +string uuid
+        +string source
+        +number sourceMtimeMs
+        +number assetDbMtime
+        +PendingAnimationGraphSnapshot graph
+        +sourceOverrides
+        +AnimGraphVariantDump dump
+    }
+
+    class PendingSnap["PendingAnimationGraphSnapshot"] {
+        +string uuid
+        +string source
+        +number sourceMtimeMs
+        +number assetDbMtime
+    }
+
+    note for VariantDump "invalids 表示历史保存的覆写项在原图中已不存在,仅展示不落盘"
+    note for PendingEdit "change() 先修改内存中的 pending 编辑,save() 统一写盘并清理 pending"
+
+    VSVC "1" o-- "many" PendingEdit : _pendingEdits 缓存
+    PendingEdit "1" *-- "0..1" PendingSnap : graph
+    PendingEdit "1" *-- "1" VariantDump : dump
+

API Schema(src/api/assets/schema.ts)

+ + + + + + + + + + + + + + + + + + + + + + + +
Zod Schema对应类型说明
SchemaAnimationGraphVariantDumpTAnimationGraphVariantDump变体可编辑 dump:graphUuid(可空)、clips(覆写映射,空串表示无覆写)、invalids?(仅展示)
SchemaAnimationGraphVariantResultTAnimationGraphVariantResultquery / change 的返回
SchemaAnimationGraphVariantSaveResultTAnimationGraphVariantSaveResultsave 的返回(恒为 null
+ +
+

5 资源处理器(asset-handler)

+ +
+
Diagram 11

资源处理器(asset-handler)

+
实现
实现
AssetHandler (接口)
+string name
+string assetType
+createInfo
+importer
AnimationGraphHandler
+name = animation-graph
+assetType = cc.AnimationGraph
+getCreateMenuInfo()
+import(asset)
AnimationGraphVariantHandler
+name = animation-graph-variant
+assetType = cc.AnimationGraphVariant
+getCreateMenuInfo()
+import(asset)
+
查看 Mermaid 源码
classDiagram
+    direction LR
+
+    class Handler["AssetHandler (接口)"] {
+        +string name
+        +string assetType
+        +createInfo
+        +importer
+    }
+
+    class AGHandler["AnimationGraphHandler"] {
+        +name = animation-graph
+        +assetType = cc.AnimationGraph
+        +getCreateMenuInfo()
+        +import(asset)
+    }
+
+    class AGVHandler["AnimationGraphVariantHandler"] {
+        +name = animation-graph-variant
+        +assetType = cc.AnimationGraphVariant
+        +getCreateMenuInfo()
+        +import(asset)
+    }
+
+    Handler <|-- AGHandler : 实现
+    Handler <|-- AGVHandler : 实现
+
+ + + + + + + + + + + + + + + + + + + + +
处理器扩展名模板importerassetType
AnimationGraphHandlerAnimation Graph.animgraph(模板 default.animgraphanimation-graph(版本 1.2.0cc.AnimationGraph
AnimationGraphVariantHandlerAnimation Graph Varint.animgraphvarianimation-graph-variant(版本 1.0.0cc.AnimationGraphVariant
+ +

两者的 import() 均读取源 JSON,原样写入 library(.json),并抽取依赖 UUID 列表(getDependUUIDList)。

+
+

6 整体关系一览

+ +
+
Diagram 12

整体关系一览

+
query / execute / save / reload
投影
递归子节点
Inspector
命令
定位
定位
定位
定位
定位
query / change / save
graphUuid 引用还原
AnimationGraphAssetService
AnimationGraphDocument
AnimationGraphViewDump
AnimationGraphLayerView
AnimationGraphStateMachineView
AnimationGraphStateView
AnimationGraphTransitionView
AnimationGraphMotionView
AnimationGraphPoseView
AnimationGraphTransitionConditionView
InspectorBinding
AnimationGraphCommand
AnimationGraphTarget
AnimationGraphVariantAssetService
AnimGraphVariantDump
+
查看 Mermaid 源码
flowchart LR
+    S["AnimationGraphAssetService"] -->|query / execute / save / reload| D["AnimationGraphDocument"]
+    D -->|投影| V["AnimationGraphViewDump"]
+    V --> La["AnimationGraphLayerView"]
+    La --> SM["AnimationGraphStateMachineView"]
+    SM --> St["AnimationGraphStateView"]
+    SM --> Tr["AnimationGraphTransitionView"]
+    St --> Mo["AnimationGraphMotionView"]
+    St --> Po["AnimationGraphPoseView"]
+    Mo -->|递归子节点| Mo
+    Tr --> Co["AnimationGraphTransitionConditionView"]
+    S -->|Inspector| IB["InspectorBinding"]
+    S -->|命令| Cmd["AnimationGraphCommand"]
+    T["AnimationGraphTarget"] -.定位.-> St
+    T -.定位.-> Tr
+    T -.定位.-> Mo
+    T -.定位.-> Po
+    T -.定位.-> La
+    VS["AnimationGraphVariantAssetService"] -->|query / change / save| VD["AnimGraphVariantDump"]
+    VD -->|graphUuid 引用还原| S
+
+
generated from docs/zh/animation-graph-data-structure.md · diagrams rendered with Mermaid
+
+
+ + + + + \ No newline at end of file diff --git a/docs/zh/animation-graph-data-structure.md b/docs/zh/animation-graph-data-structure.md new file mode 100644 index 000000000..07c9eba88 --- /dev/null +++ b/docs/zh/animation-graph-data-structure.md @@ -0,0 +1,875 @@ +# cocos-cli Animation Graph 数据结构 + +> 本文档梳理 cocos-cli 中与 **Animation Graph(动画图)** 相关的全部数据结构,使用 Mermaid UML 图描述类型关系,并在各节配以字段速查表。 +> +> - 类型层(TypeScript `.d.ts`):`src/core/assets/@types/public.d.ts` +> - 服务层:`src/core/assets/animation-graph-service.ts`、`src/core/assets/animation-graph-variant.ts` +> - 处理器层:`src/core/assets/asset-handler/assets/animation-graph.ts`、`animation-graph-variant.ts` +> - API Schema:`src/api/assets/schema.ts` + +--- + +## 1. 概览 + +### 1.1 相关代码文件 + +| 文件 | 角色 | +|------|------| +| `src/core/assets/@types/public.d.ts` | 公共类型定义(版本、寻址、视图快照、命令、Inspector) | +| `src/core/assets/animation-graph-service.ts` | 动画图编辑服务(文档缓存、查询、命令执行、Inspector) | +| `src/core/assets/animation-graph-variant.ts` | 动画图变体服务(解读 / 修改 / 保存变体资产) | +| `src/core/assets/asset-handler/assets/animation-graph.ts` | `.animgraph` 资源处理器(importer `animation-graph`) | +| `src/core/assets/asset-handler/assets/animation-graph-variant.ts` | `.animgraphvari` 资源处理器(importer `animation-graph-variant`) | +| `src/api/assets/schema.ts` | Zod 校验 Schema(变体 dump 的 API 参数/结果契约) | + +### 1.2 组件总览图 + +```mermaid +flowchart TB + subgraph PUBLIC["公共类型层 public.d.ts"] + P1["版本与文档管理
ExpectedVersion / Version / Snapshot / Event"] + P2["寻址体系
Context / Address / Target"] + P3["视图快照
ViewDump / Layer / StateMachine / State / Transition / Motion"] + P4["Pose 图视图
PoseView / PoseNode / PoseInput"] + P5["Inspector
InspectorSnapshot / Command"] + end + + subgraph VAR["变体结构 (animation-graph-variant.ts)"] + V1["AnimGraphVariantDump"] + V2["PendingAnimationGraphVariantEdit"] + end + + subgraph INTERNAL["服务内部结构 (animation-graph-service.ts)"] + I1["AnimationGraphDocument"] + I2["SourceFingerprint / InspectorBinding / AdapterProperty"] + end + + subgraph SCHEMA["API Schema (src/api/assets/schema.ts)"] + S1["SchemaAnimationGraphVariantDump"] + end + + subgraph HANDLER["资源处理器"] + H1["AnimationGraphHandler (.animgraph)"] + H2["AnimationGraphVariantHandler (.animgraphvari)"] + end + + SERVICE["AnimationGraphAssetService
动画图编辑服务"] --> PUBLIC + SERVICE --> INTERNAL + H1 --> SERVICE + H2 --> VARSERVICE["AnimationGraphVariantAssetService"] + VARSERVICE --> VAR + VAR --> SCHEMA +``` + +--- + +## 2. 公共类型层(public.d.ts) + +### 2.1 版本与文档管理 + +`AnimationGraphExpectedVersion` 是乐观并发控制的最小单元;`AnimationGraphVersion` 追加持久化 / 脏标记状态;`AnimationGraphSnapshot` 是 `query` / `execute` / `save` / `reload` 返回的统一快照。`AnimationGraphChangedEvent` 用于向监听者广播变更。 + +```mermaid +classDiagram + direction LR + + class AVExpected["AnimationGraphExpectedVersion"] { + +string documentId + +number revision + } + + class AVVersion["AnimationGraphVersion"] { + +number persistedRevision + +boolean dirty + +boolean externallyModified + } + + class AVSnapshot["AnimationGraphSnapshot"] { + +string uuid + +string url + +AnimationGraphViewDump graph + } + + class AVEvent["AnimationGraphChangedEvent"] { + +string uuid + +string reason + +AnimationGraphVersion version + +string sourceId + +string[] changedPaths + } + + class AVReloadOpts["ReloadAnimationGraphOptions"] { + +AnimationGraphExpectedVersion expected + +boolean discardDirty + } + + class AVErrorCode["AnimationGraphEditErrorCode (枚举)"] { + +VERSION_CONFLICT + +DOCUMENT_RELOADED + +SOURCE_CHANGED + +TARGET_NOT_FOUND + +UNSUPPORTED_TARGET + +UNSUPPORTED_PROPERTY_OPERATION + +INVALID_PROPERTY_PATCH + +READONLY_PROPERTY + +NAME_CONFLICT + +DIRTY_DOCUMENT + } + + class AVViewDump["AnimationGraphViewDump"] { + +AnimationGraphLayerView[] layers + +AnimationGraphVariableView[] variables + } + + AVExpected <|-- AVVersion : 继承 + AVVersion <|-- AVSnapshot : 继承 + AVEvent --> AVVersion : 携带 + AVSnapshot --> AVVersion : 携带 + AVSnapshot "1" *-- "1" AVViewDump : 包含 +``` + +**字段速查** + +| 类型 | 字段 | 类型 | 说明 | +|------|------|------|------| +| `AnimationGraphExpectedVersion` | `documentId` | `string` | 文档唯一 ID(每次加载重新生成) | +| | `revision` | `number` | 已提交修改的版本号 | +| `AnimationGraphVersion` | `persistedRevision` | `number` | 已持久化版本号 | +| | `dirty` | `boolean` | 是否有未保存修改 | +| | `externallyModified` | `boolean` | 源文件是否被外部改动 | +| `AnimationGraphSnapshot` | `uuid` / `url` | `string` | 资产标识 | +| | `graph` | `AnimationGraphViewDump` | 完整视图快照 | +| `AnimationGraphChangedEvent` | `reason` | `'inspector' \| 'structure' \| 'save' \| 'reload' \| 'external'` | 变更原因 | +| | `sourceId?` / `changedPaths?` | `string` / `string[]` | 变更来源与路径 | + +--- + +### 2.2 寻址体系:Context / Address / Target + +动画图由 **Layer(层)→ StateMachine(状态机)→ State(状态)/ Transition(过渡)→ Motion(动作)**,以及独立的 **Pose Graph(姿态图)→ PoseNode + 内嵌状态机** 组成。为了让 Inspector / 命令能够唯一定位任意节点,cocos-cli 定义了一套**上下文 → 地址 → 目标**的三角寻址体系。 + +> 说明:图中的继承箭头表示「类型组合/扩展」关系(TypeScript 交叉类型语义),`note` 块给出判别联合(discriminated union)的 `kind` 成员。 + +```mermaid +classDiagram + direction TB + + class CMSctx["AnimationGraphStateMachineContext"] { + <<判别联合>> + +layer-state-machine kind + +pose-node-state-machine kind + +sub-state-machine kind + } + + class CPGctx["AnimationGraphPoseGraphContext"] { + <<判别联合>> + +state-pose-graph kind + +layer-stash kind + } + + note for CMSctx "1) kind=layer-state-machine: { layerIndex, stateMachinePath[] }
2) kind=pose-node-state-machine: { poseGraph, nodeId }
3) kind=sub-state-machine: { stateMachine, stateIndex }" + note for CPGctx "1) kind=state-pose-graph: { stateMachine, stateIndex }
2) kind=layer-stash: { layerIndex, stashName }" + + class ASMAddr["AnimationGraphStateMachineAddress"] { + <<判别联合>> + +直接形式 (layerIndex + stateMachinePath) + +上下文形式 (stateMachine 引用) + } + + class AStateAddr["AnimationGraphStateAddress"] { + +number stateIndex + } + + class APGAddr["AnimationGraphPoseGraphAddress"] { + <<判别联合>> + +直接形式 (layerIndex + stateMachinePath + stateIndex) + +上下文形式 (poseGraph 引用) + } + + class APNAddr["AnimationGraphPoseNodeAddress"] { + +number nodeId + } + + class AMotionAddr["AnimationGraphMotionAddress"] { + +number[] level 层级路径 + } + + class ATarget["AnimationGraphTarget"] { + <<判别联合>> + +layer 目标 + +state 目标 + +transition 目标 + +motion 目标 + +pose-node 目标 + +pose-input 目标 + +state-component 目标 + } + + CMSctx ..> ASMAddr : 引用解析 + CPGctx ..> APGAddr : 引用解析 + ASMAddr <|-- AStateAddr : 扩展 + APGAddr <|-- APNAddr : 扩展 + AStateAddr <|-- AMotionAddr : 扩展 + APNAddr <|-- AMotionAddr : 扩展 + AStateAddr <|-- ATarget : 组成 + ASMAddr <|-- ATarget : 组成 + APNAddr <|-- ATarget : 组成 + AMotionAddr <|-- ATarget : 组成 +``` + +**字段速查(关键联合成员)** + +| 类型 | 成员 | 结构 | +|------|------|------| +| `AnimationGraphStateMachineContext` | `layer-state-machine` | `{ kind, layerIndex, stateMachinePath: number[] }` | +| | `pose-node-state-machine` | `{ kind, poseGraph, nodeId }` | +| | `sub-state-machine` | `{ kind, stateMachine, stateIndex }` | +| `AnimationGraphPoseGraphContext` | `state-pose-graph` | `{ kind, stateMachine, stateIndex }` | +| | `layer-stash` | `{ kind, layerIndex, stashName }` | +| `AnimationGraphTarget` | `layer` | `{ kind, layerIndex }` | +| | `state` | `{ kind } & StateAddress` | +| | `transition` | `{ kind, transitionIndex } & StateMachineAddress` | +| | `motion` | `{ kind } & MotionAddress` | +| | `pose-node` | `{ kind } & PoseNodeAddress` | +| | `pose-input` | `{ kind, inputId } & PoseNodeAddress` | +| | `state-component` | `{ kind, componentIndex } & StateAddress` | + +--- + +### 2.3 视图快照体系 + +服务端将引擎运行时对象投影为**只读视图(View)**,供 Webview / Inspector 消费。树形关系如下: + +- `AnimationGraphViewDump`(整图)→ 多个 `LayerView` +- `LayerView` → `StateMachineView`;`StateMachineView` → 多个 `StateView` 与 `TransitionView` +- `StateView` 可递归内嵌 `StateMachineView`(子状态机)、携带 `MotionView`(动作状态)、或挂 `PoseView`(过程姿态状态) +- `MotionView` 递归包含子 `MotionView`(1D/2D 混合) + +```mermaid +classDiagram + direction TB + + class AViewDump["AnimationGraphViewDump"] { + +AnimationGraphLayerView[] layers + +AnimationGraphVariableView[] variables + } + + class ALayer["AnimationGraphLayerView"] { + +number index + +string name + +number weight + +boolean additive + +string maskUuid + +string[] stashes + +stashPoseGraphs + +AnimationGraphStateMachineView stateMachine + } + + class ASM["AnimationGraphStateMachineView"] { + +context + +number[] path + +boolean allowEmptyStates + +AnimationGraphStateView[] states + +AnimationGraphTransitionView[] transitions + +editorData + } + + class AState["AnimationGraphStateView"] { + +number index + +type type + +string name + +number[] incomingTransitionIndices + +number[] outgoingTransitionIndices + +components + +speed + +speedMultiplier + +speedMultiplierEnabled + +motion + +stateMachine + +poseGraph + +editorData + } + + class ATrans["AnimationGraphTransitionView"] { + +number index + +type type + +number fromStateIndex + +number toStateIndex + +number priority + +conditions + +duration + +relativeDuration + +exitConditionEnabled + +exitCondition + +destinationStart + +relativeDestinationStart + +startEvent + +endEvent + } + + class ACond["AnimationGraphTransitionConditionView"] { + <<判别联合>> + +BinaryCondition + +UnaryCondition + +TriggerCondition + +Unknown + } + + class AComp["AnimationGraphComponentView"] { + +number index + +string type + } + + class AMotion["AnimationGraphMotionView"] { + +number[] level + +target + +type (clip/blend-1d/blend-2d/blend-direct/unknown) + +string name + +clipUuid + +variable / value + +variableX / valueX + +variableY / valueY + +threshold + +weight + +children + } + + class AVar["AnimationGraphVariableView"] { + +string name + +number type + +IProperty value + +resetMode + } + + class APose["AnimationGraphPoseView"] + + AViewDump "1" *-- "many" ALayer + AViewDump "1" *-- "many" AVar + ALayer "1" *-- "many" ASM : stateMachine + ASM "1" *-- "many" AState : states + ASM "1" *-- "many" ATrans : transitions + AState "1" o-- "0..1" AMotion : motion + AState "1" o-- "0..1" ASM : 子状态机 + AState "1" o-- "0..1" APose : poseGraph + AState "1" o-- "many" AComp : components + ATrans "1" *-- "many" ACond : conditions + AMotion "1" *-- "many" AMotion : children 递归 +``` + +**字段速查** + +| 类型 | 关键字段 | +|------|---------| +| `AnimationGraphViewDump` | `layers[]`、`variables[]` | +| `AnimationGraphLayerView` | `index`、`name`、`weight`、`additive`、`maskUuid: string\|null`、`stashes: string[]`、`stashPoseGraphs[]`、`stateMachine` | +| `AnimationGraphStateMachineView` | `context`、`path: number[]`、`allowEmptyStates`、`states[]`、`transitions[]`、`editorData?` | +| `AnimationGraphStateView` | `index`、`type`(`entry/exit/any/motion/empty/sub-state-machine/procedural-pose/unknown`)、`name`、`incoming/outgoingTransitionIndices[]`、`components[]`、`speed?`、`speedMultiplier?`、`speedMultiplierEnabled?`、`motion?`、`stateMachine?`、`poseGraph?`、`editorData?` | +| `AnimationGraphTransitionView` | `index`、`type`(`animation/empty-state/procedural-pose/transition`)、`fromStateIndex`、`toStateIndex`、`priority`、`conditions[]`、`duration?`、`relativeDuration?`、`exitConditionEnabled?`、`exitCondition?`、`destinationStart?`、`relativeDestinationStart?`、`startEvent?`、`endEvent?` | +| `AnimationGraphTransitionConditionView` | 判别联合:Binary / Unary / Trigger / Unknown,见下 | +| `AnimationGraphComponentView` | `index`、`type` | +| `AnimationGraphMotionView` | `level[]`、`target`、`type`、`name`、`clipUuid?`、`variable?/value?`、`variableX?/valueX?`、`variableY?/valueY?`、`threshold?`、`weight?`、`children?`、`editorData?` | +| `AnimationGraphVariableView` | `name`、`type: number`、`value: IProperty`、`resetMode?: number`(Trigger 类型才有) | + +**过渡条件(TransitionConditionView)判别联合成员** + +| `type` | 字段 | +|--------|------| +| `BinaryCondition` | `index`、`operator`、`lhs`、`lhsBinding`、`bindingClass`、`rhs`、`isRhsInteger` | +| `UnaryCondition` | `index`、`operator`、`operand` | +| `TriggerCondition` | `index`、`trigger` | +| `Unknown` | `index`、`className` | + +--- + +### 2.4 Pose 图视图体系 + +Pose 图是一张**节点连通图**:根输出节点引出,节点间通过输入/输出端口相连;节点可内嵌状态机或动作(Motion),也可以作为 Stash 入口。 + +```mermaid +classDiagram + direction TB + + class APoseView["AnimationGraphPoseView"] { + +context + +number rootOutputNodeId + +AnimationGraphPoseNodeView[] nodes + +addNodeInfos + +assetDragHandlersMap + } + + class APoseNode["AnimationGraphPoseNodeView"] { + +number id + +string type + +string title + +number[] outputTypes + +AnimationGraphPoseInputView[] inputs + +inputInsertInfos + +stateMachine + +motion + +enterInfo + +editorData + } + + class APoseInput["AnimationGraphPoseInputView"] { + +string id + +string displayName + +number type + +boolean deletable + +boolean insertPoint + +boolean connected + +producerNodeId + +producerOutputId + +value + } + + class AEnterInfo["AnimationGraphPoseNodeEnterInfo"] { + +type (state-machine/animation-blend/stash) + +stashName + } + + class AAddNode["AnimationGraphPoseGraphAddNodeInfo"] { + +string typeId + +args + +string menu + } + + class ADragView["AnimationGraphPoseGraphAssetDragHandlersView"] { + +handlers + } + + class ADragHandler["AnimationGraphPoseGraphAssetDragHandlerView"] { + +string displayName + } + + class ADragEntry["AnimationGraphPoseGraphAssetDragHandlersEntry"] { + +string assetType + +handlers + } + + class ADragInfo["AnimationGraphPoseGraphAssetDragHandlerInfo"] { + +string id + +string displayName + } + + class ASM["AnimationGraphStateMachineView"] + class AMotion["AnimationGraphMotionView"] + + APoseView "1" *-- "many" APoseNode : nodes + APoseView "1" *-- "many" AAddNode : addNodeInfos + APoseView "1" *-- "many" ADragView : assetDragHandlersMap + APoseNode "1" *-- "many" APoseInput : inputs + APoseNode "1" o-- "0..1" AEnterInfo : enterInfo + APoseNode "1" o-- "0..1" ASM : 内嵌状态机 + APoseNode "1" o-- "0..1" AMotion : 内嵌动作 + ADragView "1" *-- "many" ADragHandler : handlers + ADragEntry "1" *-- "many" ADragInfo : handlers +``` + +**字段速查** + +| 类型 | 关键字段 | +|------|---------| +| `AnimationGraphPoseView` | `context`、`rootOutputNodeId: number`、`nodes[]`、`addNodeInfos[]`、`assetDragHandlersMap` | +| `AnimationGraphPoseNodeView` | `id`、`type`、`title`、`outputTypes: number[]`、`inputs[]`、`inputInsertInfos`、`stateMachine?`、`motion?`、`enterInfo?`、`editorData?` | +| `AnimationGraphPoseInputView` | `id`、`displayName`、`type`、`deletable`、`insertPoint`、`connected`、`producerNodeId?`、`producerOutputId?`、`value?: IProperty` | +| `AnimationGraphPoseNodeEnterInfo` | `type: 'state-machine' \| 'animation-blend' \| 'stash'`、`stashName?` | +| `AnimationGraphPoseGraphAddNodeInfo` | `typeId`、`args: unknown`、`menu`(面包屑式路径) | +| `AnimationGraphPoseGraphAssetDragHandlersView/Entry` | `handlers`(按 handlerId 索引) / `assetType + handlers[]` | + +--- + +### 2.5 Inspector 快照与命令 + +Inspector 通过「目标 + 属性路径」读写属性;每次操作都携带 `expected` 版本做乐观并发控制。 + +```mermaid +classDiagram + direction TB + + class AInspSnap["AnimationGraphInspectorSnapshot"] { + +string uuid + +AnimationGraphTarget target + +IProperty dump + +propertyCapabilities + } + + class AInspCap["AnimationGraphInspectorPropertyCapabilities"] { + +boolean set + +boolean reset + +boolean create + } + + class AInspReq["AnimationGraphInspectorPropertyOperationRequest"] { + +AnimationGraphTarget target + +string path + +AnimationGraphExpectedVersion expected + +string sourceId + } + + class ASetReq["SetAnimationGraphInspectorPropertyRequest"] { + +patch (IProperty or unknown) + } + + class AExecReq["ExecuteAnimationGraphCommandRequest"] { + +AnimationGraphCommand command + +AnimationGraphExpectedVersion expected + +string sourceId + } + + note for AInspSnap "dump 为序列化后的属性描述树(IProperty);propertyCapabilities 描述 property 支持 set/reset/create 的能力" + note for ASetReq "Inspector 属性设置请求 = 基础请求 + patch" + + class ATarget["AnimationGraphTarget"] { + <<判别联合>> + +kind + } + + class ACmd["AnimationGraphCommand"] { + +string type + } + + AInspSnap "1" *-- "1" AInspCap : propertyCapabilities + AInspSnap "1" --> "1" ATarget : target + AInspReq <|-- ASetReq : 继承扩展 + AExecReq "1" --> "1" ACmd : 携带命令 +``` + +**字段速查** + +| 类型 | 说明 | +|------|------| +| `AnimationGraphInspectorSnapshot` | `uuid`、`target`、`dump: IProperty`、`propertyCapabilities?` | +| `AnimationGraphInspectorPropertyCapabilities` | `set` / `reset` / `create: boolean` | +| `AnimationGraphInspectorPropertyOperationRequest` | `target`、`path`、`expected`、`sourceId?` | +| `SetAnimationGraphInspectorPropertyRequest` | 基础请求 + `patch: IProperty \| unknown` | +| `ExecuteAnimationGraphCommandRequest` | `command`、`expected`、`sourceId?` | + +--- + +### 2.6 命令体系:AnimationGraphCommand + +`AnimationGraphCommand` 是**按 `type` 判别的大型联合类型**,覆盖 Layer / State / Transition / Motion / StateComponent / Pose / Variable / Stash 的增删改。所有命令通过 `execute()` 在服务端执行,并推进文档 `revision`。 + +```mermaid +classDiagram + direction LR + + class Cmd["AnimationGraphCommand"] { + +string type 判别字段 + } + + class LayerCmds["Layer / Stash 层领域"] { + +add-layer + +remove-layer + +move-layer + +add-stash + +remove-stash + +rename-stash + +stash-pose-graph + } + + class StateCmds["State 状态领域"] { + +add-state + +remove-state + +duplicate-state + +set-state-editor-data + +add-state-component + +remove-state-component + } + + class TransCmds["Transition 过渡领域"] { + +add-transition + +remove-transition + +move-transition + +add-transition-condition + +remove-transition-condition + +set-transition-condition-property + +set-transition-condition-binding-class + +set-transition-event-binding + } + + class MotionCmds["Motion 动作领域"] { + +set-motion + +add-motion-child + +remove-motion + +set-motion-editor-data + +set-motion-threshold + +set-direct-blend-weight + } + + class PoseCmds["Pose 图领域"] { + +add-pose-node + +create-pose-node-on-asset-drag + +remove-pose-node + +duplicate-pose-nodes + +set-pose-node-editor-data + +connect-pose-nodes + +disconnect-pose-input + +insert-pose-input + +delete-pose-input + } + + class VarCmds["Variable 变量领域"] { + +add-variable + +set-variable-value + +set-trigger-reset-mode + +remove-variable + +rename-variable + } + + Cmd "1" *-- "many" LayerCmds : 成员 + Cmd "1" *-- "many" StateCmds : 成员 + Cmd "1" *-- "many" TransCmds : 成员 + Cmd "1" *-- "many" MotionCmds : 成员 + Cmd "1" *-- "many" PoseCmds : 成员 + Cmd "1" *-- "many" VarCmds : 成员 +``` + +> 大多数命令在 `type` 之外还内联携带**状态机地址(StateMachineAddress)** 或 **目标(Target)**,例如 `add-state`、`add-transition`、`connect-pose-nodes` 等;地址解析逻辑复用第 2.2 节的寻址体系。 + +**相关类型别名** + +| 别名 | 取值 | +|------|------| +| `AnimationGraphStateType` | `'motion' \| 'empty' \| 'sub-state-machine' \| 'procedural-pose'` | +| `AnimationGraphMotionType` | `'clip' \| 'blend-1d' \| 'blend-2d' \| 'blend-direct'` | +| `AnimationGraphTransitionConditionType` | `'binary' \| 'unary' \| 'trigger'` | + +--- + +### 2.7 AnimationMask(动画掩码) + +Layer 的 `maskUuid` 指向一张动画掩码资产,其 dump 结构如下: + +```mermaid +classDiagram + class MaskDump["AnimationMaskDump"] { + +number version + +string assetUuid + +AnimationMaskJoint[] joints + } + + class MaskJoint["AnimationMaskJoint"] { + +string path + +boolean enabled + +AnimationMaskJoint[] children + } + + class MaskChange["AnimationMaskChange"] { + +string path + +boolean enabled + +boolean recursive + } + + MaskDump "1" *-- "many" MaskJoint : joints + MaskJoint "1" *-- "many" MaskJoint : children 递归 +``` + +--- + +## 3. 服务内部数据结构(animation-graph-service.ts) + +服务以「文档」为核心缓存:对每个动画图资产维护一个 `AnimationGraphDocument`,内部处理版本并发、外部写入检测(指纹对比)、节点 ID 分配与变更事件广播。 + +```mermaid +classDiagram + direction TB + + class SVC["AnimationGraphAssetService"] { + +query() + +queryInspector() + +queryPoseGraphAssetDragHandlers() + +queryStateMachineComponentTypes() + +setInspectorProperty() + +resetInspectorProperty() + +createInspectorProperty() + +execute() + +save() + +reload() + +onChanged() + +runExternalWrite(s) + +assertExternalWriteAllowed() + } + + class Doc["AnimationGraphDocument"] { + +string uuid + +string url + +string source + +graph 引擎图对象 + +string documentId + +number revision + +number persistedRevision + +boolean dirty + +boolean externallyModified + +SourceFingerprint fingerprint + +nodeIds (WeakMap) + +nodesById (Map) + +number nextNodeId + } + + class FP["SourceFingerprint"] { + +string hash (sha256) + +number mtimeMs + +number assetDbMtime + } + + class Binding["InspectorBinding"] { + +IProperty dump + +propertyCapabilities + +apply(path, patch) + +reset(path) + +create(path) + } + + class Adapter["AdapterProperty"] { + +get() + +set(value) + +attrs + } + + class Err["AnimationGraphEditError"] { + +code (AnimationGraphEditErrorCode) + +message + +currentVersion + } + + note for Doc "nodeIds/nodesById:为 Pose 图节点分配稳定数字 ID 的双向索引;nextNodeId 为递增计数器" + note for Binding "通过 createAdapterBinding / createDecoratedBinding / createAdapterBinding 构造" + + SVC "1" o-- "many" Doc : _documents 缓存 + SVC "1" o-- "many" Binding : 按需创建 + Doc "1" *-- "1" FP : fingerprint + Binding "1" *-- "many" Adapter : 属性适配器 +``` + +**关键说明** + +| 结构 | 说明 | +|------|------| +| `AnimationGraphDocument` | 内存中的可编辑图文档;`graph` 是引擎反序列化后的对象树;`nodeIds`/`nodesById` 为 Pose 图节点分配稳定 ID;每次落盘成功会刷新 `fingerprint` | +| `SourceFingerprint` | 源文件 `sha256` + `mtimeMs` + 资源库 mtime,用于检测外部修改(`externallyModified`) | +| `InspectorBinding` | 绑定到具体目标(Layer/State/Transition/Motion/PoseNode/PoseInput/StateComponent)的属性读写职责 | +| `AdapterProperty` | 属性 getter/setter + 描述元数据(`attrs`),供编码为 `IProperty` dump | +| `AnimationGraphEditError` | 编辑异常,携带可枚举错误码(见 2.1 `AnimationGraphEditErrorCode`) | + +--- + +## 4. 动画图变体(animation-graph-variant.ts) + +动画图变体在引用一个动画图的基础上,覆写其中的**动画片段(clip)**,形成可复用的资源变体。 + +```mermaid +classDiagram + direction TB + + class VSVC["AnimationGraphVariantAssetService"] { + +query(uuid) + +change(uuid, dump) + +save(uuid) + } + + class VariantDump["AnimGraphVariantDump"] { + +string graphUuid + +clips (Map: 原clipUuid -> 替代clipUuid) + +invalids (Map: 未命中项) + } + + class PendingEdit["PendingAnimationGraphVariantEdit"] { + +string uuid + +string source + +number sourceMtimeMs + +number assetDbMtime + +PendingAnimationGraphSnapshot graph + +sourceOverrides + +AnimGraphVariantDump dump + } + + class PendingSnap["PendingAnimationGraphSnapshot"] { + +string uuid + +string source + +number sourceMtimeMs + +number assetDbMtime + } + + note for VariantDump "invalids 表示历史保存的覆写项在原图中已不存在,仅展示不落盘" + note for PendingEdit "change() 先修改内存中的 pending 编辑,save() 统一写盘并清理 pending" + + VSVC "1" o-- "many" PendingEdit : _pendingEdits 缓存 + PendingEdit "1" *-- "0..1" PendingSnap : graph + PendingEdit "1" *-- "1" VariantDump : dump +``` + +**API Schema(src/api/assets/schema.ts)** + +| Zod Schema | 对应类型 | 说明 | +|-----------|---------|------| +| `SchemaAnimationGraphVariantDump` | `TAnimationGraphVariantDump` | 变体可编辑 dump:`graphUuid`(可空)、`clips`(覆写映射,空串表示无覆写)、`invalids?`(仅展示) | +| `SchemaAnimationGraphVariantResult` | `TAnimationGraphVariantResult` | `query` / `change` 的返回 | +| `SchemaAnimationGraphVariantSaveResult` | `TAnimationGraphVariantSaveResult` | `save` 的返回(恒为 `null`) | + +--- + +## 5. 资源处理器(asset-handler) + +```mermaid +classDiagram + direction LR + + class Handler["AssetHandler (接口)"] { + +string name + +string assetType + +createInfo + +importer + } + + class AGHandler["AnimationGraphHandler"] { + +name = animation-graph + +assetType = cc.AnimationGraph + +getCreateMenuInfo() + +import(asset) + } + + class AGVHandler["AnimationGraphVariantHandler"] { + +name = animation-graph-variant + +assetType = cc.AnimationGraphVariant + +getCreateMenuInfo() + +import(asset) + } + + Handler <|-- AGHandler : 实现 + Handler <|-- AGVHandler : 实现 +``` + +| 处理器 | 扩展名模板 | importer | assetType | +|--------|-----------|----------|-----------| +| `AnimationGraphHandler` | `Animation Graph.animgraph`(模板 `default.animgraph`) | `animation-graph`(版本 `1.2.0`) | `cc.AnimationGraph` | +| `AnimationGraphVariantHandler` | `Animation Graph Varint.animgraphvari` | `animation-graph-variant`(版本 `1.0.0`) | `cc.AnimationGraphVariant` | + +两者的 `import()` 均读取源 JSON,原样写入 library(`.json`),并抽取依赖 UUID 列表(`getDependUUIDList`)。 + +--- + +## 6. 整体关系一览 + +```mermaid +flowchart LR + S["AnimationGraphAssetService"] -->|query / execute / save / reload| D["AnimationGraphDocument"] + D -->|投影| V["AnimationGraphViewDump"] + V --> La["AnimationGraphLayerView"] + La --> SM["AnimationGraphStateMachineView"] + SM --> St["AnimationGraphStateView"] + SM --> Tr["AnimationGraphTransitionView"] + St --> Mo["AnimationGraphMotionView"] + St --> Po["AnimationGraphPoseView"] + Mo -->|递归子节点| Mo + Tr --> Co["AnimationGraphTransitionConditionView"] + S -->|Inspector| IB["InspectorBinding"] + S -->|命令| Cmd["AnimationGraphCommand"] + T["AnimationGraphTarget"] -.定位.-> St + T -.定位.-> Tr + T -.定位.-> Mo + T -.定位.-> Po + T -.定位.-> La + VS["AnimationGraphVariantAssetService"] -->|query / change / save| VD["AnimGraphVariantDump"] + VD -->|graphUuid 引用还原| S +``` \ No newline at end of file diff --git a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap index c5093b10d..fde16c19b 100644 --- a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap +++ b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap @@ -7,6 +7,9 @@ exports[`DTS API compatibility assets.d.ts should match snapshot 1`] = ` export declare const animationGraph: { query(uuidOrUrlOrPath: string): Promise; queryInspector(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise; + queryMotionPreviewData(uuidOrUrlOrPath: string, target: Extract): Promise; queryPoseGraphAssetDragHandlers(): Promise; queryStateMachineComponentTypes(): Promise; setInspectorProperty(uuidOrUrlOrPath: string, request: SetAnimationGraphInspectorPropertyRequest): Promise; @@ -115,6 +118,10 @@ export declare interface AnimationGraphLayerView { export declare type AnimationGraphMotionAddress = | (AnimationGraphStateAddress & { level: number[] }) | ({ poseGraph: AnimationGraphPoseGraphContext; nodeId: number; level: number[] }); +export declare interface AnimationGraphMotionPreviewData { + motion: AnimationGraphMotionView | null; + variables: AnimationGraphVariableView[]; +} export declare type AnimationGraphMotionType = 'clip' | 'blend-1d' | 'blend-2d' | 'blend-direct'; export declare interface AnimationGraphMotionView { level: number[]; @@ -128,6 +135,7 @@ export declare interface AnimationGraphMotionView { valueX?: number; variableY?: string; valueY?: number; + algorithm?: number; threshold?: number | { x: number; y: number }; weight?: { value: number; variable: string }; children?: AnimationGraphMotionView[]; @@ -5720,6 +5728,32 @@ export declare interface AnimationClipAssetUserData { name: string; } export declare type AnimationEditorType = 'scene' | 'prefab' | 'unknown'; +export declare type AnimationGraphMotionAddress = +| (AnimationGraphStateAddress & { level: number[] }) +| ({ poseGraph: AnimationGraphPoseGraphContext; nodeId: number; level: number[] }); +export declare type AnimationGraphPoseGraphAddress = +| { layerIndex: number; stateMachinePath: number[]; stateIndex: number } +| { poseGraph: AnimationGraphPoseGraphContext }; +export declare type AnimationGraphPoseGraphContext = +| { kind: 'state-pose-graph'; stateMachine: AnimationGraphStateMachineContext; stateIndex: number } +| { kind: 'layer-stash'; layerIndex: number; stashName: string }; +export declare type AnimationGraphPoseNodeAddress = AnimationGraphPoseGraphAddress & { nodeId: number }; +export declare type AnimationGraphStateAddress = AnimationGraphStateMachineAddress & { stateIndex: number }; +export declare type AnimationGraphStateMachineAddress = +| { layerIndex: number; stateMachinePath: number[] } +| { stateMachine: AnimationGraphStateMachineContext }; +export declare type AnimationGraphStateMachineContext = +| { kind: 'layer-state-machine'; layerIndex: number; stateMachinePath: number[] } +| { kind: 'pose-node-state-machine'; poseGraph: AnimationGraphPoseGraphContext; nodeId: number } +| { kind: 'sub-state-machine'; stateMachine: AnimationGraphStateMachineContext; stateIndex: number }; +export declare type AnimationGraphTarget = +| { kind: 'layer'; layerIndex: number } +| ({ kind: 'state' } & AnimationGraphStateAddress) +| ({ kind: 'transition'; transitionIndex: number } & AnimationGraphStateMachineAddress) +| ({ kind: 'motion' } & AnimationGraphMotionAddress) +| ({ kind: 'pose-node' } & AnimationGraphPoseNodeAddress) +| ({ kind: 'pose-input'; inputId: string } & AnimationGraphPoseNodeAddress) +| ({ kind: 'state-component'; componentIndex: number } & AnimationGraphStateAddress); export declare interface AnimationImportSetting { name: string; duration: number; @@ -6047,6 +6081,21 @@ export declare interface IAnimationExitOptions { restoreSelection?: boolean; restoreSampledSceneState?: boolean; } +export declare interface IAnimationGraphMotionPreviewService { + showAnimationGraphMotion(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise; + hideAnimationGraphMotion(): void; + setAnimationGraphMotionModel(uuid: string): Promise; + setAnimationGraphMotionTime(time: number): void; + playAnimationGraphMotion(): void; + pauseAnimationGraphMotion(): void; + stopAnimationGraphMotion(): void; + setAnimationGraphMotionVariable(name: string, value: number): void; + isAnimationGraphMotionActive(): Promise; + queryAnimationGraphMotionImage(info: { + width: number; + height: number; + }): Promise; +} export declare interface IAnimationKeyValueDump { value: IAnimationValue; default?: IAnimationValue; @@ -6866,7 +6915,7 @@ export declare interface IPreviewInstance { resetCameraView(): void; hide(): void; } -export declare interface IPreviewService { +export declare interface IPreviewService extends IAnimationGraphMotionPreviewService { open(uuid: string): Promise; generateThumbnail(uuid: string, assetType: string, width?: number, height?: number): Promise; } @@ -7995,6 +8044,9 @@ export declare interface AnimationClipAssetUserData { export declare const animationGraph: { query(uuidOrUrlOrPath: string): Promise; queryInspector(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise; + queryMotionPreviewData(uuidOrUrlOrPath: string, target: Extract): Promise; queryPoseGraphAssetDragHandlers(): Promise; queryStateMachineComponentTypes(): Promise; setInspectorProperty(uuidOrUrlOrPath: string, request: SetAnimationGraphInspectorPropertyRequest): Promise; @@ -8103,6 +8155,10 @@ export declare interface AnimationGraphLayerView { export declare type AnimationGraphMotionAddress = | (AnimationGraphStateAddress & { level: number[] }) | ({ poseGraph: AnimationGraphPoseGraphContext; nodeId: number; level: number[] }); +export declare interface AnimationGraphMotionPreviewData { + motion: AnimationGraphMotionView | null; + variables: AnimationGraphVariableView[]; +} export declare type AnimationGraphMotionType = 'clip' | 'blend-1d' | 'blend-2d' | 'blend-direct'; export declare interface AnimationGraphMotionView { level: number[]; @@ -8116,6 +8172,7 @@ export declare interface AnimationGraphMotionView { valueX?: number; variableY?: string; valueY?: number; + algorithm?: number; threshold?: number | { x: number; y: number }; weight?: { value: number; variable: string }; children?: AnimationGraphMotionView[]; @@ -8514,6 +8571,7 @@ export declare namespace Assets { AnimationGraphStateMachineView, AnimationGraphLayerView, AnimationGraphVariableView, + AnimationGraphMotionPreviewData, AnimationGraphViewDump, AnimationGraphSnapshot, AnimationGraphInspectorPropertyCapabilities, diff --git a/src/core/assets/@types/public.d.ts b/src/core/assets/@types/public.d.ts index 7d2570104..c75e5f356 100644 --- a/src/core/assets/@types/public.d.ts +++ b/src/core/assets/@types/public.d.ts @@ -100,6 +100,8 @@ export interface AnimationGraphMotionView { valueX?: number; variableY?: string; valueY?: number; + /** Blend-2D 的插值算法(AnimationBlend2D.Algorithm 数值),供预览/编辑重建使用。 */ + algorithm?: number; threshold?: number | { x: number; y: number }; weight?: { value: number; variable: string }; children?: AnimationGraphMotionView[]; @@ -257,6 +259,12 @@ export interface AnimationGraphVariableView { resetMode?: number; } +/** Motion 预览数据:目标 Motion 的结构化视图与图内全部变量(含当前值)。 */ +export interface AnimationGraphMotionPreviewData { + motion: AnimationGraphMotionView | null; + variables: AnimationGraphVariableView[]; +} + export interface AnimationGraphViewDump { layers: AnimationGraphLayerView[]; variables: AnimationGraphVariableView[]; diff --git a/src/core/assets/animation-graph-service.ts b/src/core/assets/animation-graph-service.ts index 7d242a87d..1708d8353 100644 --- a/src/core/assets/animation-graph-service.ts +++ b/src/core/assets/animation-graph-service.ts @@ -10,6 +10,7 @@ import type { AnimationGraphInspectorPropertyOperationRequest, AnimationGraphInspectorSnapshot, AnimationGraphLayerView, + AnimationGraphMotionPreviewData, AnimationGraphMotionType, AnimationGraphMotionView, AnimationGraphPoseGraphAssetDragHandlersEntry, @@ -24,6 +25,7 @@ import type { AnimationGraphTarget, AnimationGraphTransitionConditionView, AnimationGraphTransitionView, + AnimationGraphVariableView, AnimationGraphVersion, AnimationGraphViewDump, ExecuteAnimationGraphCommandRequest, @@ -126,6 +128,38 @@ class AnimationGraphAssetService { }); } + /** + * 查询 Motion 预览数据:目标 Motion 的结构化描述(供 Scene 进程预览服务重建引擎 + * Motion)以及图内全部变量(含当前值)。 + * + * @param uuidOrUrlOrPath - 动画图资源(uuid / url / 路径)。 + * @param target - 目标 Motion 的地址,与 Inspector 使用的 `AnimationGraphTarget` 一致。 + * @returns Motion 预览数据;目标不存在时 `motion` 为 null。 + * + * ```mermaid + * flowchart LR + * Document[AnimationGraphDocument] --> Resolve[resolve target Motion] + * Resolve --> MotionView[_queryMotion 结构化 Motion 视图] + * Document --> Variables[图内变量 + 当前值] + * MotionView --> Payload[AnimationGraphMotionPreviewData] + * Variables --> Payload + * ``` + */ + async queryMotionPreviewData( + uuidOrUrlOrPath: string, + target: Extract, + ): Promise { + const asset = this._queryAnimationGraphAsset(uuidOrUrlOrPath); + return this._enqueue(asset.uuid, async () => { + const document = await this._getOrLoad(asset); + await this._refreshExternalState(document); + return { + motion: this._queryMotion(this._resolveMotion(document, target), target), + variables: this._queryGraphVariables(document), + }; + }); + } + async queryPoseGraphAssetDragHandlers(): Promise { const api = getNewGenAnim(); const js = getCC().js; @@ -611,6 +645,21 @@ class AnimationGraphAssetService { }; } + private _queryGraphVariables(document: AnimationGraphDocument): AnimationGraphVariableView[] { + const graph = document.graph; + const api = getNewGenAnim(); + return Array.from(graph.variables as Iterable<[string, any]>).map(([name, variable]) => { + const value = encodeSerializedObject(variable.value, api.getVariableValueAttributes(variable), variable, 'value'); + value.path = 'value'; + return { + name, + type: variable.type, + value, + resetMode: variable.type === api.VariableType.TRIGGER ? variable.resetMode : undefined, + }; + }); + } + private _queryLayer(document: AnimationGraphDocument, layer: any, index: number): AnimationGraphLayerView { const stateMachineContext: AnimationGraphStateMachineContext = { kind: 'layer-state-machine', @@ -805,6 +854,7 @@ class AnimationGraphAssetService { view.valueX = motion.paramX.value; view.variableY = motion.paramY.variable; view.valueY = motion.paramY.value; + view.algorithm = motion.algorithm; } if (threshold !== undefined) { view.threshold = isVec2Like(threshold) @@ -971,7 +1021,14 @@ class AnimationGraphAssetService { } } }, - attrs: { type: 'Object', default: null, displayName: 'Speed Multiplier', ui: { name: 'animationGraphSpeedMultiplier' } }, + // default 必须是完整对象工厂:Reset 时 setter 才能同时恢复 enabled 与 multiplier; + // 若为 null,Reset 写入 null 会被 setter 忽略,导致 Reset 静默失效。 + attrs: { + type: 'Object', + default: () => ({ enabled: false, multiplier: '' }), + displayName: 'Speed Multiplier', + ui: { name: 'animationGraphSpeedMultiplier' }, + }, }; properties.transitionInEvent = nestedProperty(state.transitionInEventBinding, 'methodName', eventBindingAttrs); properties.transitionOutEvent = nestedProperty(state.transitionOutEventBinding, 'methodName', eventBindingAttrs); diff --git a/src/core/assets/manager/asset.ts b/src/core/assets/manager/asset.ts index 07da7f75d..2716d4d4d 100644 --- a/src/core/assets/manager/asset.ts +++ b/src/core/assets/manager/asset.ts @@ -67,6 +67,7 @@ class AssetManager extends EventEmitter { // ---------- animation graph ---------- queryAnimationGraph = animationGraph.query.bind(animationGraph); queryAnimationGraphInspector = animationGraph.queryInspector.bind(animationGraph); + queryAnimationGraphMotionPreviewData = animationGraph.queryMotionPreviewData.bind(animationGraph); queryAnimationGraphPoseGraphAssetDragHandlers = animationGraph.queryPoseGraphAssetDragHandlers.bind(animationGraph); queryAnimationGraphStateMachineComponentTypes = animationGraph.queryStateMachineComponentTypes.bind(animationGraph); setAnimationGraphInspectorProperty = animationGraph.setInspectorProperty.bind(animationGraph); @@ -411,6 +412,7 @@ export interface TypedAssetManager extends EventEmitter { queryAnimationGraph: typeof animationGraph.query; queryAnimationGraphInspector: typeof animationGraph.queryInspector; + queryAnimationGraphMotionPreviewData: typeof animationGraph.queryMotionPreviewData; queryAnimationGraphPoseGraphAssetDragHandlers: typeof animationGraph.queryPoseGraphAssetDragHandlers; queryAnimationGraphStateMachineComponentTypes: typeof animationGraph.queryStateMachineComponentTypes; setAnimationGraphInspectorProperty: typeof animationGraph.setInspectorProperty; diff --git a/src/core/assets/test/animation-graph-service.test.ts b/src/core/assets/test/animation-graph-service.test.ts index 170fdb93e..5b5d02d3c 100644 --- a/src/core/assets/test/animation-graph-service.test.ts +++ b/src/core/assets/test/animation-graph-service.test.ts @@ -1604,4 +1604,112 @@ describe('animation graph asset service', () => { expected: withBlend2DState, })).rejects.toMatchObject({ message: expect.stringContaining('missing-asset-uuid') }); }); + + it('queries serializable Motion preview data for clip and blend motions', async () => { + const asset = await assetManager.createAsset({ + target: join(TestGlobalEnv.testRoot, `${name}-motion-preview.animgraph`), + content: getDefaultGraphContent(), + overwrite: true, + }); + const clip = await assetManager.createAsset({ + target: join(TestGlobalEnv.testRoot, `${name}-motion-preview-clip.anim`), + content: readFileSync(join( + TestGlobalEnv.engineRoot, + 'editor/assets/default_file_content/animation-clip/default.anim', + ), 'utf8'), + overwrite: true, + }); + + let snapshot = await assetManager.queryAnimationGraph(asset.uuid); + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'add-variable', name: 'speed', variableType: 0, initialValue: 2.5 }, + expected: snapshot, + }); + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'add-state', + layerIndex: 0, + stateMachinePath: [], + stateType: 'motion', + name: 'Clip Motion', + }, + expected: snapshot, + }); + const stateIndex = snapshot.graph.layers[0].stateMachine.states.find((state) => state.name === 'Clip Motion')!.index; + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'set-motion', + layerIndex: 0, + stateMachinePath: [], + stateIndex, + motionType: 'clip', + clipUuid: clip.uuid, + }, + expected: snapshot, + }); + const clipTarget = snapshot.graph.layers[0].stateMachine.states[stateIndex].motion!.target; + + const clipPreview = await assetManager.queryAnimationGraphMotionPreviewData(asset.uuid, clipTarget); + expect(clipPreview.motion).toMatchObject({ type: 'clip', clipUuid: clip.uuid }); + expect(Array.isArray(clipPreview.motion!.level)).toBe(true); + expect(clipPreview.variables).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'speed', type: 0 }), + ])); + + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { + type: 'set-motion', + layerIndex: 0, + stateMachinePath: [], + stateIndex, + motionType: 'blend-2d', + }, + expected: snapshot, + }); + const blendTarget = snapshot.graph.layers[0].stateMachine.states[stateIndex].motion!.target; + let inspector = await assetManager.queryAnimationGraphInspector(asset.uuid, blendTarget); + for (const [path, patch] of [ + ['variableX', 'speed'], + ['variableY', 'speed'], + ] as const) { + inspector = await assetManager.setAnimationGraphInspectorProperty(asset.uuid, { + target: blendTarget, + path, + patch, + expected: inspector, + }); + } + snapshot = await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'add-motion-child', target: blendTarget, motionType: 'clip', clipUuid: clip.uuid }, + expected: inspector, + }); + const blendWithChild = await assetManager.queryAnimationGraph(asset.uuid); + const blendTargetAfterChild = blendWithChild.graph.layers[0].stateMachine.states[stateIndex].motion!.target; + await assetManager.executeAnimationGraphCommand(asset.uuid, { + command: { type: 'set-motion-threshold', target: blendTargetAfterChild, childIndex: 0, threshold: { x: 0.3, y: 0.6 } }, + expected: blendWithChild, + }); + + const blendPreview = await assetManager.queryAnimationGraphMotionPreviewData(asset.uuid, blendTargetAfterChild); + expect(blendPreview.motion).toMatchObject({ + type: 'blend-2d', + variableX: 'speed', + variableY: 'speed', + }); + expect(typeof blendPreview.motion!.algorithm).toBe('number'); + expect(blendPreview.motion!.children).toEqual([ + expect.objectContaining({ type: 'clip', clipUuid: clip.uuid, threshold: { x: 0.3, y: 0.6 } }), + ]); + + const finalSnapshot = await assetManager.queryAnimationGraph(asset.uuid); + await assetManager.saveAnimationGraph(asset.uuid, finalSnapshot); + + await expect(assetManager.queryAnimationGraphMotionPreviewData(asset.uuid, { + kind: 'motion', + layerIndex: 0, + stateMachinePath: [], + stateIndex: 9999, + level: [], + })).rejects.toMatchObject({ code: 'TARGET_NOT_FOUND' }); + }); }); diff --git a/src/core/scene/common/preview.ts b/src/core/scene/common/preview.ts index 47d40e81d..0061ab296 100644 --- a/src/core/scene/common/preview.ts +++ b/src/core/scene/common/preview.ts @@ -23,13 +23,35 @@ export interface ISpinePreviewInstance extends IPreviewInstance { close(): void; } -export interface IPreviewService { +export interface IPreviewService extends IAnimationGraphMotionPreviewService { open(uuid: string): Promise; generateThumbnail(uuid: string, assetType: string, width?: number, height?: number): Promise; } +/** + * Animation Graph Motion 预览子能力(scene-process Preview 服务按同名方法透传)。 + * `target` 与 Inspector 使用的 `AnimationGraphTarget` 一致。 + */ +export interface IAnimationGraphMotionPreviewService { + showAnimationGraphMotion(uuidOrUrlOrPath: string, target: import('../../assets/@types/public').AnimationGraphTarget): Promise; + hideAnimationGraphMotion(): void; + setAnimationGraphMotionModel(uuid: string): Promise; + setAnimationGraphMotionTime(time: number): void; + playAnimationGraphMotion(): void; + pauseAnimationGraphMotion(): void; + stopAnimationGraphMotion(): void; + setAnimationGraphMotionVariable(name: string, value: number): void; + isAnimationGraphMotionActive(): Promise; + queryAnimationGraphMotionImage(info: { width: number; height: number }): Promise; +} + export type IPublicPreviewService = Pick; // eslint-disable-next-line @typescript-eslint/no-empty-object-type diff --git a/src/core/scene/main-process/index.ts b/src/core/scene/main-process/index.ts index a69ebfc95..7b27880a2 100644 --- a/src/core/scene/main-process/index.ts +++ b/src/core/scene/main-process/index.ts @@ -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 { PreviewProxy } from './proxy/preview-proxy'; import { assetManager } from '../../assets'; import scriptManager from '../../scripting'; @@ -31,6 +32,7 @@ export const Scene = { ...EngineProxy, ...PrefabProxy, ReferenceImage: ReferenceImageProxy, + Preview: PreviewProxy, // 节点相关的接口 Node: NodeProxy, // 组件相关的接口 diff --git a/src/core/scene/main-process/proxy/preview-proxy.ts b/src/core/scene/main-process/proxy/preview-proxy.ts new file mode 100644 index 000000000..3ac293139 --- /dev/null +++ b/src/core/scene/main-process/proxy/preview-proxy.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) SUD. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { AnimationGraphTarget } from '../../../assets/@types/public'; +import type { IAnimationGraphMotionPreviewService } from '../../common'; +import { Rpc } from '../rpc'; + +/** + * 场景进程 PreviewService 的主进程 RPC 代理。 + */ +export const PreviewProxy: IAnimationGraphMotionPreviewService = { + async showAnimationGraphMotion(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise { + const result = await Rpc.getInstance().request('Preview', 'showAnimationGraphMotion', [uuidOrUrlOrPath, target]); + return result === true; + }, + + hideAnimationGraphMotion(): void { + void Rpc.getInstance().request('Preview', 'hideAnimationGraphMotion', []); + }, + + setAnimationGraphMotionModel(uuid: string): Promise { + return Rpc.getInstance().request('Preview', 'setAnimationGraphMotionModel', [uuid]); + }, + + setAnimationGraphMotionTime(time: number): void { + void Rpc.getInstance().request('Preview', 'setAnimationGraphMotionTime', [time]); + }, + + playAnimationGraphMotion(): void { + void Rpc.getInstance().request('Preview', 'playAnimationGraphMotion', []); + }, + + pauseAnimationGraphMotion(): void { + void Rpc.getInstance().request('Preview', 'pauseAnimationGraphMotion', []); + }, + + stopAnimationGraphMotion(): void { + void Rpc.getInstance().request('Preview', 'stopAnimationGraphMotion', []); + }, + + setAnimationGraphMotionVariable(name: string, value: number): void { + void Rpc.getInstance().request('Preview', 'setAnimationGraphMotionVariable', [name, value]); + }, + + async isAnimationGraphMotionActive(): Promise { + const result = await Rpc.getInstance().request('Preview', 'isAnimationGraphMotionActive', []); + return result === true; + }, + + queryAnimationGraphMotionImage(info: { width: number; height: number }): Promise { + return Rpc.getInstance().request('Preview', 'queryAnimationGraphMotionImage', [info]); + }, +}; \ No newline at end of file diff --git a/src/core/scene/scene-process/service/preview/animation-graph-motion-preview.ts b/src/core/scene/scene-process/service/preview/animation-graph-motion-preview.ts new file mode 100644 index 000000000..50a343042 --- /dev/null +++ b/src/core/scene/scene-process/service/preview/animation-graph-motion-preview.ts @@ -0,0 +1,382 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) SUD. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DirectionalLight, Node, Prefab, Scene, instantiate } from 'cc'; +import { InteractivePreview, getBoundaryOfMeshNodes } from './interactive-preview'; +import { loadPreviewAsset, removePreviewAssetCache } from './asset-reload'; +import { Rpc } from '../../rpc'; +import { Service } from '../core/decorator'; +import type { + AnimationGraphMotionPreviewData, + AnimationGraphMotionView, + AnimationGraphTarget, +} from '../../../../assets/@types/public'; + +/** + * engine editor 模块:与动画图资源服务一致的加载方式(scene-process 的 + * engine-bootstrap 已把 cc/editor/new-gen-anim 作为必须模块加载)。 + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function getNewGenAnim(): any { + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require('cc/editor/new-gen-anim'); +} + +/** + * Animation Graph Motion 预览器。 + * + * 负责加载预览 Prefab、根据「目标 Motion 的结构化视图 + 图内变量」重建引擎 Motion、 + * 并驱动 `MotionPreviewer` 采样姿态到模型节点上;`queryPreviewData` 由外层按帧轮询取图。 + * + * ```mermaid + * sequenceDiagram + * participant PinK as PinK 主进程(Preview 代理) + * participant Preview as AnimationGraphMotionPreview(scene-process) + * participant Asset as assetManager RPC(main-process) + * participant Engine as MotionPreviewer(cc/editor/new-gen-anim) + * PinK->>Preview: showMotionPreview(uuid, target) + * Preview->>Asset: request('assetManager','queryAnimationGraphMotionPreviewData',...) + * Asset-->>Preview: { motion: AnimationGraphMotionView, variables } + * Preview->>Engine: new MotionPreviewer(modelNode) + setMotion(rebuilt motion) + * PinK->>Preview: setTime / play / pause / stop / setVariable + * Preview->>Engine: setTime(time) + evaluate() + * PinK->>Preview: queryPreviewData({width,height}) + * Preview-->>PinK: RGBA buffer(模型当前姿态帧) + * ``` + */ +export class AnimationGraphMotionPreview extends InteractivePreview { + private lightComp: DirectionalLight | any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private motionPreviewer: any = null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly loadedClips = new Map(); + private active = false; + private playing = false; + private time = 0; + private lastPlayTick = 0; + // 未等到模型时的待处理 Motion(webview 可能先选 Motion 再拖入模型)。 + private pendingMotion: { uuid: string; target: AnimationGraphTarget } | null = null; + + public createNodes(scene: Scene) { + this.lightComp = new Node('Animation Graph Motion Preview Light').addComponent(DirectionalLight); + this.lightComp.node.setRotationFromEuler(-45, -45, 0); + this.lightComp.node.parent = scene; + } + + public get isActive(): boolean { + return this.active; + } + + public getIsPlaying(): boolean { + return this.playing; + } + + public async setModel(uuid: string): Promise { + if (!uuid) { + console.warn(`Failed to set model in Animation Graph Motion preview, by uuid: ${uuid}`); + return; + } + + const prefabUuid = await this._resolvePrefabUuid(uuid); + if (!prefabUuid) { + throw new Error(`Unable to preview model ${uuid}: the imported cc.Prefab sub-asset is unavailable.`); + } + + removePreviewAssetCache(uuid); + const prefabAsset = await loadPreviewAsset(prefabUuid, 'model', { reloadAsset: true }); + + if (this._modelNode) { + this.scene.removeChild(this._modelNode); + if (this._modelNode.isValid) { + this._modelNode.destroy(); + } + } + + this._modelNode = instantiate(prefabAsset) as Node; + this._modelNode.parent = this.scene; + + // 重建 MotionPreviewer(绑定到新模型根节点的骨骼层级)。 + this._resetMotionPreviewer(); + + // 若此前已下发 Motion,模型就绪后继续接入。 + if (this.pendingMotion) { + const pending = this.pendingMotion; + this.pendingMotion = null; + try { + await this._attachMotion(pending.uuid, pending.target); + } catch (error) { + console.warn(`[AnimationGraphMotionPreview] Failed to attach pending motion:`, error); + } + } + + this.cameraComp.enabled = true; + this.resetCameraView(); + } + + public async showMotionPreview(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise { + if (!this._modelNode) { + // 暂无模型:记住 Motion,等 setModel 后接入;返回 false 表示"等待模型"。 + this.pendingMotion = { uuid: uuidOrUrlOrPath, target }; + return false; + } + this.pendingMotion = null; + await this._attachMotion(uuidOrUrlOrPath, target); + this.active = true; + this.time = 0; + this.lastPlayTick = Date.now(); + this._evaluate(); + return true; + } + + public hideMotionPreview(): void { + this.pendingMotion = null; + this.active = false; + this.pauseMotionPreview(); + if (this.motionPreviewer) { + this.motionPreviewer.destroy?.(); + this.motionPreviewer = null; + } + this.hide(); + } + + public resetMotionPreview(): void { + this.time = 0; + this.pendingMotion = null; + this._resetMotionPreviewer(); + } + + public playMotionPreview(): void { + if (!this.active) { + return; + } + this.playing = true; + this.lastPlayTick = Date.now(); + this._evaluate(); + } + + public pauseMotionPreview(): void { + this.playing = false; + } + + public stopMotionPreview(): void { + this.playing = false; + this.time = 0; + this._evaluate(); + } + + public setTimeMotionPreview(time: number): void { + this.time = Math.max(0, time); + this._evaluate(); + } + + /** + * 更新预览变量。变量实例当前未随数据契约注入 MotionPreviewer(见方案文档的 + * 风险点),因此仅记录调用,等变量实例搭建完成后生效。 + */ + public setMotionPreviewVariable(name: string, value: number): void { + if (!this.motionPreviewer) { + return; + } + try { + this.motionPreviewer.updateVariable(name, value); + } catch (error) { + console.warn(`[AnimationGraphMotionPreview] setVariable failed:`, error); + } + } + + public getMotionPreviewTimelineStats(): { timeLineLength: number } | null { + return this.motionPreviewer?.timelineStats ?? null; + } + + public resetCameraView(): void { + if (this._modelNode) { + this.resetCamera(this._modelNode); + this.perfectCameraView(getBoundaryOfMeshNodes([this._modelNode])); + } + } + + public async queryPreviewData(info: { width: number; height: number }) { + if (this.playing && this.active) { + const now = Date.now(); + const delta = Math.max(0, (now - this.lastPlayTick) / 1000); + this.lastPlayTick = now; + if (delta > 0) { + this.time += delta; + this._evaluate(); + } + } + return super.queryPreviewData(info); + } + + /** + * 重建引擎 Motion 并喂给 MotionPreviewer。 + */ + private async _attachMotion(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data = (await Rpc.getInstance().request( + 'assetManager', + 'queryAnimationGraphMotionPreviewData', + [uuidOrUrlOrPath, target as Extract], + )) as unknown as AnimationGraphMotionPreviewData | null; + if (!data?.motion) { + throw new Error(`Animation Graph Motion preview data is unavailable for target ${JSON.stringify(target)}`); + } + + this._resetMotionPreviewer(); + if (!this.motionPreviewer) { + throw new Error('Animation Graph Motion preview model has not been set.'); + } + + // 先并行加载 Motion 用到的全部动画剪辑,再重建引擎 Motion。 + this.loadedClips.clear(); + await Promise.all( + Array.from(new Set(collectClipUuids(data.motion))) + .filter(Boolean) + .map(async (clipUuid) => { + try { + this.loadedClips.set(clipUuid, await loadPreviewAsset(clipUuid, 'animation-clip')); + } catch (error) { + console.warn(`[AnimationGraphMotionPreview] Failed to load clip ${clipUuid}:`, error); + } + }), + ); + + const motion = this._rebuildMotion(data.motion); + this.motionPreviewer.setMotion(motion); + this.time = 0; + this._evaluate(); + } + + private _resetMotionPreviewer(): void { + if (this.motionPreviewer) { + this.motionPreviewer.destroy?.(); + this.motionPreviewer = null; + } + if (!this._modelNode) { + return; + } + try { + const { MotionPreviewer } = getNewGenAnim(); + if (!MotionPreviewer) { + console.warn('[AnimationGraphMotionPreview] MotionPreviewer is not available in the engine module.'); + return; + } + this.motionPreviewer = new MotionPreviewer(this._modelNode); + } catch (error) { + console.warn('[AnimationGraphMotionPreview] Failed to create MotionPreviewer:', error); + } + } + + /** + * 根据结构化视图重建引擎 Motion。blend-1d/2d/direct 会把变量绑定清空为静态值, + * 以便「未注册变量实例」时仍可按 param 默认值采样(详见 bindOr 的回归行为)。 + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _rebuildMotion(view: AnimationGraphMotionView | null | undefined): any { + const api = getNewGenAnim(); + if (!view) { + return null; + } + switch (view.type) { + case 'clip': { + const clipMotion = new api.ClipMotion(); + if (view.clipUuid) { + clipMotion.clip = this.loadedClips.get(view.clipUuid) ?? null; + } + return clipMotion; + } + case 'blend-1d': { + const blend = new api.AnimationBlend1D(); + blend.param.value = view.value ?? 0; + blend.param.variable = ''; + blend.items = (view.children ?? []).map((child) => { + const item = new api.AnimationBlend1D.Item(); + item.motion = this._rebuildMotion(child); + item.threshold = typeof child.threshold === 'number' + ? child.threshold + : child.threshold?.x ?? 0; + return item; + }); + return blend; + } + case 'blend-2d': { + const blend = new api.AnimationBlend2D(); + blend.paramX.value = view.valueX ?? 0; + blend.paramX.variable = ''; + blend.paramY.value = view.valueY ?? 0; + blend.paramY.variable = ''; + if (typeof view.algorithm === 'number') { + blend.algorithm = view.algorithm; + } + blend.items = (view.children ?? []).map((child) => { + const item = new api.AnimationBlend2D.Item(); + item.motion = this._rebuildMotion(child); + item.threshold.set( + child.threshold && typeof child.threshold === 'object' ? child.threshold.x : 0, + child.threshold && typeof child.threshold === 'object' ? child.threshold.y : 0, + ); + return item; + }); + return blend; + } + case 'blend-direct': { + const blend = new api.AnimationBlendDirect(); + blend.items = (view.children ?? []).map((child) => { + const item = new api.AnimationBlendDirect.Item(); + item.motion = this._rebuildMotion(child); + item.weight.value = child.weight?.value ?? 0; + item.weight.variable = ''; + return item; + }); + return blend; + } + default: + return null; + } + } + + private _evaluate(): void { + if (!this.motionPreviewer) { + return; + } + try { + this.motionPreviewer.setTime(this.time); + this.motionPreviewer.evaluate(); + } catch (error) { + console.warn('[AnimationGraphMotionPreview] evaluate failed:', error); + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async _resolvePrefabUuid(uuid: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const assetInfo = await Rpc.getInstance().request('assetManager', 'queryAssetInfo', [uuid, ['subAssets']]); + if (assetInfo?.type === 'cc.Prefab') { + return assetInfo.uuid || uuid; + } + for (const sub of Object.values(assetInfo?.subAssets || {})) { + if (sub?.type === 'cc.Prefab' || sub?.importer === 'gltf-scene') { + return sub.uuid; + } + } + return null; + } +} + +/** + * 收集 Motion 视图递归引用到的全部动画剪辑 uuid,供预览前并行加载。 + */ +function collectClipUuids(view: AnimationGraphMotionView | null | undefined, out: string[] = []): string[] { + if (!view) { + return out; + } + if (view.type === 'clip' && view.clipUuid) { + out.push(view.clipUuid); + } + for (const child of view.children ?? []) { + collectClipUuids(child, out); + } + return out; +} \ No newline at end of file diff --git a/src/core/scene/scene-process/service/preview/index.ts b/src/core/scene/scene-process/service/preview/index.ts index f12a23ac8..5a2b6ed56 100644 --- a/src/core/scene/scene-process/service/preview/index.ts +++ b/src/core/scene/scene-process/service/preview/index.ts @@ -7,7 +7,9 @@ import { MeshPreview } from './mesh-preview'; import { SkeletonPreview } from './skeleton-preview'; import { PrefabPreview } from './prefab-preview'; import { SpinePreview } from './spine-preview'; +import { AnimationGraphMotionPreview } from './animation-graph-motion-preview'; import { Camera, gfx } from 'cc'; +import type { AnimationGraphTarget } from '../../../../assets/@types/public'; import { BaseService, register, Service } from '../core'; import { Rpc } from '../../rpc'; import type { InteractivePreview } from './interactive-preview'; @@ -35,6 +37,7 @@ export class PreviewService extends BaseService implements IPrev skeletonPreview = new SkeletonPreview(); prefabPreview = new PrefabPreview(); spinePreview = new SpinePreview(); + animationGraphMotionPreview = new AnimationGraphMotionPreview(); get activePreview(): IPreviewInstance | null { return this._activePreview; @@ -51,6 +54,7 @@ export class PreviewService extends BaseService implements IPrev this.initPreview('scene:skeleton-preview', 'query-skeleton-preview-data', this.skeletonPreview); this.initPreview('scene:prefab-preview', 'query-prefab-preview-data', this.prefabPreview); this.initPreview('scene:spine-preview', 'query-spine-preview-data', this.spinePreview); + this.initPreview('scene:animation-graph-preview', 'query-animation-graph-preview-data', this.animationGraphMotionPreview); this.initTypeMap(); console.log('[Preview] PreviewService initialized'); } @@ -110,6 +114,48 @@ export class PreviewService extends BaseService implements IPrev return false; } + // --- Animation Graph Motion 预览(透传到 animationGraphMotionPreview 实例) --- + + public async showAnimationGraphMotion(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise { + return this.animationGraphMotionPreview.showMotionPreview(uuidOrUrlOrPath, target); + } + + public hideAnimationGraphMotion(): void { + this.animationGraphMotionPreview.hideMotionPreview(); + } + + public async setAnimationGraphMotionModel(uuid: string): Promise { + await this.animationGraphMotionPreview.setModel(uuid); + } + + public setAnimationGraphMotionTime(time: number): void { + this.animationGraphMotionPreview.setTimeMotionPreview(time); + } + + public playAnimationGraphMotion(): void { + this.animationGraphMotionPreview.playMotionPreview(); + } + + public pauseAnimationGraphMotion(): void { + this.animationGraphMotionPreview.pauseMotionPreview(); + } + + public stopAnimationGraphMotion(): void { + this.animationGraphMotionPreview.stopMotionPreview(); + } + + public setAnimationGraphMotionVariable(name: string, value: number): void { + this.animationGraphMotionPreview.setMotionPreviewVariable(name, value); + } + + public async isAnimationGraphMotionActive(): Promise { + return this.animationGraphMotionPreview.isActive; + } + + public async queryAnimationGraphMotionImage(info: { width: number; height: number }): Promise { + return this.animationGraphMotionPreview.queryPreviewData(info); + } + // --- 上屏预览 --- async open(uuid: string): Promise { diff --git a/src/lib/assets/assets.ts b/src/lib/assets/assets.ts index ac635b463..4db6a48ce 100644 --- a/src/lib/assets/assets.ts +++ b/src/lib/assets/assets.ts @@ -1,4 +1,4 @@ -import type { AnimationGraphChangedEvent, AnimationGraphExpectedVersion, AnimationGraphInspectorPropertyOperationRequest, AnimationGraphInspectorSnapshot, AnimationGraphPoseGraphAssetDragHandlersEntry, AnimationGraphSnapshot, AnimationGraphTarget, AssetOperationOption, AssetPropertySchemaMap, CreateAssetByTypeOptions, DeleteAssetOptions, ExecuteAnimationGraphCommandRequest, IAssetFileSystemProvider, IAssetInfo, IAssetMeta, ISupportCreateType, MaterialDump, MaterialEffectInfo, MaterialTechniqueDump, QueryAssetsOption, ReloadAnimationGraphOptions, SerializedAssetPatch, SerializedAssetQueryResult, SetAnimationGraphInspectorPropertyRequest, AnimationMaskChange, AnimationMaskDump } from '../../core/assets/@types/public'; +import type { AnimationGraphChangedEvent, AnimationGraphExpectedVersion, AnimationGraphInspectorPropertyOperationRequest, AnimationGraphInspectorSnapshot, AnimationGraphMotionPreviewData, AnimationGraphPoseGraphAssetDragHandlersEntry, AnimationGraphSnapshot, AnimationGraphTarget, AssetOperationOption, AssetPropertySchemaMap, CreateAssetByTypeOptions, DeleteAssetOptions, ExecuteAnimationGraphCommandRequest, IAssetFileSystemProvider, IAssetInfo, IAssetMeta, ISupportCreateType, MaterialDump, MaterialEffectInfo, MaterialTechniqueDump, QueryAssetsOption, ReloadAnimationGraphOptions, SerializedAssetPatch, SerializedAssetQueryResult, SetAnimationGraphInspectorPropertyRequest, AnimationMaskChange, AnimationMaskDump } from '../../core/assets/@types/public'; import type { CreateAssetOptions, IAssetConfig, IAssetDBInfo, ICreateMenuInfo, IUerDataConfigItem, QueryAssetType, ThumbnailInfo, ThumbnailSize } from '../../core/assets/@types/protected'; import type { FilterPluginOptions, IPluginScriptInfo } from '../../core/scripting/interface'; import { assetDBManager, assetManager } from '../../core/assets'; @@ -222,6 +222,13 @@ export const animationGraph = { return assetManager.queryAnimationGraphInspector(uuidOrUrlOrPath, target); }, + queryMotionPreviewData( + uuidOrUrlOrPath: string, + target: Extract, + ): Promise { + return assetManager.queryAnimationGraphMotionPreviewData(uuidOrUrlOrPath, target); + }, + queryPoseGraphAssetDragHandlers(): Promise { return assetManager.queryAnimationGraphPoseGraphAssetDragHandlers(); }, From b4a97cb2b8a5374bff3a6b1d5feac8b467f81ef7 Mon Sep 17 00:00:00 2001 From: looopmax Date: Sat, 5 Sep 2026 15:49:15 +0800 Subject: [PATCH 06/11] feat(scene): expose animation graph motion preview facade via scene module --- .../__snapshots__/dts-snapshot.test.ts.snap | 62 +++++++++++++++++++ src/lib/scene/scene.ts | 57 ++++++++++++++++- 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap index fde16c19b..825bba9de 100644 --- a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap +++ b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap @@ -8973,6 +8973,7 @@ export declare interface GlTFUserData { options: LODsOption[]; }; } +export declare function hideAnimationGraphMotion(): void; export declare namespace i18n { export { i18n_2 as default @@ -9431,6 +9432,7 @@ export declare interface IResolvedCustomJointTextureLayout { textureLength: number; contents: IResolvedChunkContent[]; } +export declare function isAnimationGraphMotionActive(): Promise; export declare interface ISceneCommandProvider { request(module: string, method: string, args?: any[], options?: SceneCommandRequestOptions): Promise; notify?(module: string, method: string, args?: any[]): void; @@ -9725,6 +9727,8 @@ export declare interface ParticleAssetUserData { rotatePerSVar: number; spriteFrameUuid: string; } +export declare function pauseAnimationGraphMotion(): void; +export declare function playAnimationGraphMotion(): void; export declare interface PluginScriptInfo { file: string; uuid: string; @@ -9830,6 +9834,10 @@ export declare interface ProjectInfo { [key: string]: any; } export declare type ProjectType = '2d' | '3d'; +export declare function queryAnimationGraphMotionImage(info: { + width: number; + height: number; +}): Promise; export declare function queryAssetConfigMap(): Promise>; export declare function queryAssetDBInfos(): Promise>; export declare function queryAssetDependencies(uuidOrUrl: string, type?: QueryAssetType): Promise; @@ -9897,6 +9905,16 @@ export declare namespace Scene { startupWorker, setCommandProvider, resetCommandProvider, + showAnimationGraphMotion, + hideAnimationGraphMotion, + setAnimationGraphMotionModel, + setAnimationGraphMotionTime, + playAnimationGraphMotion, + pauseAnimationGraphMotion, + stopAnimationGraphMotion, + setAnimationGraphMotionVariable, + isAnimationGraphMotionActive, + queryAnimationGraphMotionImage, ISceneCommandProvider, SceneCommandProviderRegistration, SceneCommandRequestOptions, @@ -9961,6 +9979,9 @@ export declare function set(key: string, value: T, scope?: ConfigurationScope export declare interface SetAnimationGraphInspectorPropertyRequest extends AnimationGraphInspectorPropertyOperationRequest { patch: IProperty | unknown; } +export declare function setAnimationGraphMotionModel(uuid: string): Promise; +export declare function setAnimationGraphMotionTime(time: number): void; +export declare function setAnimationGraphMotionVariable(name: string, value: number): void; export declare function setCommandProvider(provider: ISceneCommandProvider): SceneCommandProviderRegistration; export declare function setFileSystemProvider(provider: IAssetFileSystemProvider): void; export declare interface SharedSettings { @@ -9978,6 +9999,7 @@ export declare interface SharedSettings { url: string; }; } +export declare function showAnimationGraphMotion(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise; export declare interface SimplifyOptions { targetRatio?: number; enableSmartLink?: boolean; @@ -10036,6 +10058,7 @@ export declare function startCompileScript(assetChanges?: AssetChangeInfo[]): Pr export declare function startEngineCompilation(force?: boolean): Promise; export declare function startupWorker(projectPath: string): Promise; export declare function stop_2(): Promise; +export declare function stopAnimationGraphMotion(): void; export declare const SUPPORT_CREATE_TYPES: readonly ["animation-clip", "typescript", "auto-atlas", "effect", "scene", "prefab", "material", "texture-cube", "terrain", "physics-material", "label-atlas", "render-texture", "directory", "effect-header"]; export declare enum TangentImportSetting { exclude = 0, @@ -10192,13 +10215,47 @@ export { } exports[`DTS API compatibility scene.d.ts should match snapshot 1`] = ` "import type { ChildProcess } from 'child_process'; +export declare type AnimationGraphMotionAddress = +| (AnimationGraphStateAddress & { level: number[] }) +| ({ poseGraph: AnimationGraphPoseGraphContext; nodeId: number; level: number[] }); +export declare type AnimationGraphPoseGraphAddress = +| { layerIndex: number; stateMachinePath: number[]; stateIndex: number } +| { poseGraph: AnimationGraphPoseGraphContext }; +export declare type AnimationGraphPoseGraphContext = +| { kind: 'state-pose-graph'; stateMachine: AnimationGraphStateMachineContext; stateIndex: number } +| { kind: 'layer-stash'; layerIndex: number; stashName: string }; +export declare type AnimationGraphPoseNodeAddress = AnimationGraphPoseGraphAddress & { nodeId: number }; +export declare type AnimationGraphStateAddress = AnimationGraphStateMachineAddress & { stateIndex: number }; +export declare type AnimationGraphStateMachineAddress = +| { layerIndex: number; stateMachinePath: number[] } +| { stateMachine: AnimationGraphStateMachineContext }; +export declare type AnimationGraphStateMachineContext = +| { kind: 'layer-state-machine'; layerIndex: number; stateMachinePath: number[] } +| { kind: 'pose-node-state-machine'; poseGraph: AnimationGraphPoseGraphContext; nodeId: number } +| { kind: 'sub-state-machine'; stateMachine: AnimationGraphStateMachineContext; stateIndex: number }; +export declare type AnimationGraphTarget = +| { kind: 'layer'; layerIndex: number } +| ({ kind: 'state' } & AnimationGraphStateAddress) +| ({ kind: 'transition'; transitionIndex: number } & AnimationGraphStateMachineAddress) +| ({ kind: 'motion' } & AnimationGraphMotionAddress) +| ({ kind: 'pose-node' } & AnimationGraphPoseNodeAddress) +| ({ kind: 'pose-input'; inputId: string } & AnimationGraphPoseNodeAddress) +| ({ kind: 'state-component'; componentIndex: number } & AnimationGraphStateAddress); +export declare function hideAnimationGraphMotion(): void; export declare function init(): Promise; +export declare function isAnimationGraphMotionActive(): Promise; export declare interface ISceneCommandProvider { request(module: string, method: string, args?: any[], options?: SceneCommandRequestOptions): Promise; notify?(module: string, method: string, args?: any[]): void; isConnect?(): boolean | undefined; dispose?(): void; } +export declare function pauseAnimationGraphMotion(): void; +export declare function playAnimationGraphMotion(): void; +export declare function queryAnimationGraphMotionImage(info: { + width: number; + height: number; +}): Promise; export declare function resetCommandProvider(): void; export declare interface SceneCommandProviderRegistration { dispose(): void; @@ -10206,8 +10263,13 @@ export declare interface SceneCommandProviderRegistration { export declare interface SceneCommandRequestOptions { timeout?: number; } +export declare function setAnimationGraphMotionModel(uuid: string): Promise; +export declare function setAnimationGraphMotionTime(time: number): void; +export declare function setAnimationGraphMotionVariable(name: string, value: number): void; export declare function setCommandProvider(provider: ISceneCommandProvider): SceneCommandProviderRegistration; +export declare function showAnimationGraphMotion(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise; export declare function startupWorker(projectPath: string): Promise; +export declare function stopAnimationGraphMotion(): void; export declare class WorkerSceneCommandProvider implements ISceneCommandProvider { private readonly rpc; constructor(process: ChildProcess | NodeJS.Process); diff --git a/src/lib/scene/scene.ts b/src/lib/scene/scene.ts index a7903ce61..0cb472084 100644 --- a/src/lib/scene/scene.ts +++ b/src/lib/scene/scene.ts @@ -1,10 +1,11 @@ -import { init as sceneInit } from '../../core/scene'; +import { init as sceneInit, Scene } from '../../core/scene'; import { GlobalPaths } from '../../global'; import { Rpc } from '../../core/scene/main-process/rpc'; import type { ISceneCommandProvider, SceneCommandProviderRegistration, } from '../../core/scene/main-process/rpc'; +import type { AnimationGraphTarget } from '../../core/assets/@types/public'; export type { ISceneCommandProvider, @@ -42,3 +43,57 @@ export function setCommandProvider( export function resetCommandProvider(): void { Rpc.resetCommandProvider(); } + +// ==================== Animation Graph Motion Preview ==================== +// 将 scene-process PreviewService 的 AnimationGraph Motion 门面经场景进程 RPC 暴露给 PinK。 +// 方法名与 IAnimationGraphMotionPreviewService 一一对应,供主进程 cocosHostScene 通道透传调用。 + +/** 显示指定 Motion 的预览(clip 或 blend 树)。@param uuidOrUrlOrPath 动画图资源标识。@param target 图内目标 Motion 的唯一地址。@returns 是否已成功显示预览。 */ +export async function showAnimationGraphMotion(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise { + return Scene.Preview.showAnimationGraphMotion(uuidOrUrlOrPath, target); +} + +/** 隐藏当前 Motion 预览。 */ +export function hideAnimationGraphMotion(): void { + Scene.Preview.hideAnimationGraphMotion(); +} + +/** 为 Motion 预览设置展示模型资源。 @param uuid 模型资源 UUID。 */ +export async function setAnimationGraphMotionModel(uuid: string): Promise { + return Scene.Preview.setAnimationGraphMotionModel(uuid); +} + +/** 设置 Motion 预览的采样时间。 @param time 时间(秒)。 */ +export function setAnimationGraphMotionTime(time: number): void { + Scene.Preview.setAnimationGraphMotionTime(time); +} + +/** 播放 Motion 预览。 */ +export function playAnimationGraphMotion(): void { + Scene.Preview.playAnimationGraphMotion(); +} + +/** 暂停 Motion 预览。 */ +export function pauseAnimationGraphMotion(): void { + Scene.Preview.pauseAnimationGraphMotion(); +} + +/** 停止 Motion 预览。 */ +export function stopAnimationGraphMotion(): void { + Scene.Preview.stopAnimationGraphMotion(); +} + +/** 设置 Motion 预览使用的变量值。 @param name 变量名。 @param value 变量值。 */ +export function setAnimationGraphMotionVariable(name: string, value: number): void { + Scene.Preview.setAnimationGraphMotionVariable(name, value); +} + +/** 查询当前是否有活跃的 Motion 预览。 @returns 存在返回 true。 */ +export async function isAnimationGraphMotionActive(): Promise { + return Scene.Preview.isAnimationGraphMotionActive(); +} + +/** 查询当前 Motion 预览的渲染图像帧。 @param info 图像尺寸。 @returns 图像帧数据。 */ +export function queryAnimationGraphMotionImage(info: { width: number; height: number }): Promise { + return Scene.Preview.queryAnimationGraphMotionImage(info); +} From deeae683c4ffff335ca84773d3c7a2b418b36829 Mon Sep 17 00:00:00 2001 From: looopmax Date: Sun, 6 Sep 2026 11:14:30 +0800 Subject: [PATCH 07/11] feat(scene): support generic motion preview service --- .../__snapshots__/dts-snapshot.test.ts.snap | 339 ++++++++++++------ src/core/assets/animation-graph-service.ts | 5 +- src/core/scene/common/preview.ts | 85 ++++- .../scene/main-process/proxy/preview-proxy.ts | 71 ++-- .../scene-process/service/preview/index.ts | 82 +++-- ...ph-motion-preview.ts => motion-preview.ts} | 206 ++++++----- src/lib/scene/scene.ts | 80 +++-- 7 files changed, 582 insertions(+), 286 deletions(-) rename src/core/scene/scene-process/service/preview/{animation-graph-motion-preview.ts => motion-preview.ts} (57%) diff --git a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap index 825bba9de..d702f9544 100644 --- a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap +++ b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap @@ -5728,32 +5728,6 @@ export declare interface AnimationClipAssetUserData { name: string; } export declare type AnimationEditorType = 'scene' | 'prefab' | 'unknown'; -export declare type AnimationGraphMotionAddress = -| (AnimationGraphStateAddress & { level: number[] }) -| ({ poseGraph: AnimationGraphPoseGraphContext; nodeId: number; level: number[] }); -export declare type AnimationGraphPoseGraphAddress = -| { layerIndex: number; stateMachinePath: number[]; stateIndex: number } -| { poseGraph: AnimationGraphPoseGraphContext }; -export declare type AnimationGraphPoseGraphContext = -| { kind: 'state-pose-graph'; stateMachine: AnimationGraphStateMachineContext; stateIndex: number } -| { kind: 'layer-stash'; layerIndex: number; stashName: string }; -export declare type AnimationGraphPoseNodeAddress = AnimationGraphPoseGraphAddress & { nodeId: number }; -export declare type AnimationGraphStateAddress = AnimationGraphStateMachineAddress & { stateIndex: number }; -export declare type AnimationGraphStateMachineAddress = -| { layerIndex: number; stateMachinePath: number[] } -| { stateMachine: AnimationGraphStateMachineContext }; -export declare type AnimationGraphStateMachineContext = -| { kind: 'layer-state-machine'; layerIndex: number; stateMachinePath: number[] } -| { kind: 'pose-node-state-machine'; poseGraph: AnimationGraphPoseGraphContext; nodeId: number } -| { kind: 'sub-state-machine'; stateMachine: AnimationGraphStateMachineContext; stateIndex: number }; -export declare type AnimationGraphTarget = -| { kind: 'layer'; layerIndex: number } -| ({ kind: 'state' } & AnimationGraphStateAddress) -| ({ kind: 'transition'; transitionIndex: number } & AnimationGraphStateMachineAddress) -| ({ kind: 'motion' } & AnimationGraphMotionAddress) -| ({ kind: 'pose-node' } & AnimationGraphPoseNodeAddress) -| ({ kind: 'pose-input'; inputId: string } & AnimationGraphPoseNodeAddress) -| ({ kind: 'state-component'; componentIndex: number } & AnimationGraphStateAddress); export declare interface AnimationImportSetting { name: string; duration: number; @@ -6081,21 +6055,6 @@ export declare interface IAnimationExitOptions { restoreSelection?: boolean; restoreSampledSceneState?: boolean; } -export declare interface IAnimationGraphMotionPreviewService { - showAnimationGraphMotion(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise; - hideAnimationGraphMotion(): void; - setAnimationGraphMotionModel(uuid: string): Promise; - setAnimationGraphMotionTime(time: number): void; - playAnimationGraphMotion(): void; - pauseAnimationGraphMotion(): void; - stopAnimationGraphMotion(): void; - setAnimationGraphMotionVariable(name: string, value: number): void; - isAnimationGraphMotionActive(): Promise; - queryAnimationGraphMotionImage(info: { - width: number; - height: number; - }): Promise; -} export declare interface IAnimationKeyValueDump { value: IAnimationValue; default?: IAnimationValue; @@ -6777,6 +6736,42 @@ export declare interface ImageMeta { uri?: string; remap?: string; } +export declare interface IMotionPreviewService { + showMotion(desc: MotionPreviewDesc): Promise; + hideMotion(): void; + setMotionModel(uuid: string): Promise; + setMotionTime(time: number): void; + playMotion(): void; + pauseMotion(): void; + stopMotion(): void; + setMotionVariable(name: string, value: number): void; + setMotionParameter(axis: 'value' | 'x' | 'y', value: number): void; + getMotionTimelineStats(): Promise<{ + timeLineLength: number; + } | null>; + isMotionActive(): Promise; + queryMotionImage(info: { + width: number; + height: number; + }): Promise; + onMotionMouseDown(action: { + x: number; + y: number; + button: number; + }): Promise; + onMotionMouseMove(action: { + movementX: number; + movementY: number; + }): Promise; + onMotionMouseUp(action: { + x: number; + y: number; + }): Promise; + onMotionMouseWheel(action: { + wheelDeltaY: number; + wheelDeltaX: number; + }): Promise; +} export declare interface IMoveArrayElementParams { nodePath: string; path: string; @@ -6915,7 +6910,7 @@ export declare interface IPreviewInstance { resetCameraView(): void; hide(): void; } -export declare interface IPreviewService extends IAnimationGraphMotionPreviewService { +export declare interface IPreviewService extends IMotionPreviewService { open(uuid: string): Promise; generateThumbnail(uuid: string, assetType: string, width?: number, height?: number): Promise; } @@ -7422,6 +7417,46 @@ export declare interface MeshSimplifyOptions { errorRate?: number; lockBoundary?: boolean; } +export declare interface MotionPreviewDesc { + motion: MotionPreviewDescNode | null; + variables: MotionPreviewVariable[]; +} +export declare type MotionPreviewDescNode = { + kind: 'clip'; + clipUuid: string | null; +} | { + kind: 'blend-1d'; + variable: string | null; + value: number; + children: { + motion: MotionPreviewDescNode | null; + threshold: number; + }[]; +} | { + kind: 'blend-2d'; + variableX: string | null; + valueX: number; + variableY: string | null; + valueY: number; + algorithm?: number; + children: { + motion: MotionPreviewDescNode | null; + threshold: { + x: number; + y: number; + }; + }[]; +} | { + kind: 'blend-direct'; + children: { + motion: MotionPreviewDescNode | null; + weight: number; + }[]; +}; +export declare interface MotionPreviewVariable { + name: string; + value: number | null; +} export declare enum NodeEventType { TRANSFORM_CHANGED = "transform-changed", SIZE_CHANGED = "size-changed", @@ -8889,6 +8924,9 @@ export declare function getCurrentToolCallContext(): Readonly; export declare function getInfo_2(): Promise; export declare function getMetadata(): Promise; +export declare function getMotionTimelineStats(): Promise<{ + timeLineLength: number; +} | null>; export declare function getProgrammingFacet(): Promise; export declare function getRenderConfig(): Promise; export declare function getStatus(): { @@ -8973,7 +9011,7 @@ export declare interface GlTFUserData { options: LODsOption[]; }; } -export declare function hideAnimationGraphMotion(): void; +export declare function hideMotion(): void; export declare namespace i18n { export { i18n_2 as default @@ -9432,13 +9470,13 @@ export declare interface IResolvedCustomJointTextureLayout { textureLength: number; contents: IResolvedChunkContent[]; } -export declare function isAnimationGraphMotionActive(): Promise; export declare interface ISceneCommandProvider { request(module: string, method: string, args?: any[], options?: SceneCommandRequestOptions): Promise; notify?(module: string, method: string, args?: any[]): void; isConnect?(): boolean | undefined; dispose?(): void; } +export declare function isMotionActive(): Promise; export declare interface ISocketConfig { connection: (socket: any) => void; disconnect: (socket: any) => void; @@ -9657,6 +9695,46 @@ export declare interface ModuleRenderConfig { version: string; migrationScript?: string; } +export declare interface MotionPreviewDesc { + motion: MotionPreviewDescNode | null; + variables: MotionPreviewVariable[]; +} +export declare type MotionPreviewDescNode = { + kind: 'clip'; + clipUuid: string | null; +} | { + kind: 'blend-1d'; + variable: string | null; + value: number; + children: { + motion: MotionPreviewDescNode | null; + threshold: number; + }[]; +} | { + kind: 'blend-2d'; + variableX: string | null; + valueX: number; + variableY: string | null; + valueY: number; + algorithm?: number; + children: { + motion: MotionPreviewDescNode | null; + threshold: { + x: number; + y: number; + }; + }[]; +} | { + kind: 'blend-direct'; + children: { + motion: MotionPreviewDescNode | null; + weight: number; + }[]; +}; +export declare interface MotionPreviewVariable { + name: string; + value: number | null; +} export declare function moveAsset(source: string, target: string, options?: AssetOperationOption): Promise; export declare enum NormalImportSetting { optional = 0, @@ -9676,6 +9754,23 @@ export declare function onCompileStart(listener: (e: { }) => void): () => void; export declare function onDBReady(listener: (dbInfo: IAssetDBInfo) => void): () => void; export declare function onDidSave(callback: () => void, scope?: ConfigurationScope): () => void; +export declare function onMotionMouseDown(action: { + x: number; + y: number; + button: number; +}): Promise; +export declare function onMotionMouseMove(action: { + movementX: number; + movementY: number; +}): Promise; +export declare function onMotionMouseUp(action: { + x: number; + y: number; +}): Promise; +export declare function onMotionMouseWheel(action: { + wheelDeltaY: number; + wheelDeltaX: number; +}): Promise; export declare function onPackBuildEnd(listener: (e: { targetName: string; }) => void): () => void; @@ -9727,8 +9822,8 @@ export declare interface ParticleAssetUserData { rotatePerSVar: number; spriteFrameUuid: string; } -export declare function pauseAnimationGraphMotion(): void; -export declare function playAnimationGraphMotion(): void; +export declare function pauseMotion(): void; +export declare function playMotion(): void; export declare interface PluginScriptInfo { file: string; uuid: string; @@ -9834,10 +9929,6 @@ export declare interface ProjectInfo { [key: string]: any; } export declare type ProjectType = '2d' | '3d'; -export declare function queryAnimationGraphMotionImage(info: { - width: number; - height: number; -}): Promise; export declare function queryAssetConfigMap(): Promise>; export declare function queryAssetDBInfos(): Promise>; export declare function queryAssetDependencies(uuidOrUrl: string, type?: QueryAssetType): Promise; @@ -9865,6 +9956,10 @@ export declare function queryLayerBuiltin(): Promise<{ export declare function queryMaterial(uuidOrUrlOrPath: string): Promise; export declare function queryMaterialAllEffects(): Promise>; export declare function queryMaterialEffect(effectNameOrUuid: string): Promise; +export declare function queryMotionImage(info: { + width: number; + height: number; +}): Promise; export declare function queryPath(urlOrUuid: string): Promise; export declare function queryPropertySchema(importer: string): Promise; export declare function querySerializedData(uuidOrUrlOrPath: string): Promise; @@ -9905,16 +10000,22 @@ export declare namespace Scene { startupWorker, setCommandProvider, resetCommandProvider, - showAnimationGraphMotion, - hideAnimationGraphMotion, - setAnimationGraphMotionModel, - setAnimationGraphMotionTime, - playAnimationGraphMotion, - pauseAnimationGraphMotion, - stopAnimationGraphMotion, - setAnimationGraphMotionVariable, - isAnimationGraphMotionActive, - queryAnimationGraphMotionImage, + showMotion, + hideMotion, + setMotionModel, + setMotionTime, + playMotion, + pauseMotion, + stopMotion, + setMotionVariable, + setMotionParameter, + getMotionTimelineStats, + isMotionActive, + queryMotionImage, + onMotionMouseDown, + onMotionMouseMove, + onMotionMouseUp, + onMotionMouseWheel, ISceneCommandProvider, SceneCommandProviderRegistration, SceneCommandRequestOptions, @@ -9979,11 +10080,12 @@ export declare function set(key: string, value: T, scope?: ConfigurationScope export declare interface SetAnimationGraphInspectorPropertyRequest extends AnimationGraphInspectorPropertyOperationRequest { patch: IProperty | unknown; } -export declare function setAnimationGraphMotionModel(uuid: string): Promise; -export declare function setAnimationGraphMotionTime(time: number): void; -export declare function setAnimationGraphMotionVariable(name: string, value: number): void; export declare function setCommandProvider(provider: ISceneCommandProvider): SceneCommandProviderRegistration; export declare function setFileSystemProvider(provider: IAssetFileSystemProvider): void; +export declare function setMotionModel(uuid: string): Promise; +export declare function setMotionParameter(axis: 'value' | 'x' | 'y', value: number): void; +export declare function setMotionTime(time: number): void; +export declare function setMotionVariable(name: string, value: number): void; export declare interface SharedSettings { useDefineForClassFields: boolean; allowDeclareFields: boolean; @@ -9999,7 +10101,7 @@ export declare interface SharedSettings { url: string; }; } -export declare function showAnimationGraphMotion(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise; +export declare function showMotion(desc: MotionPreviewDesc): Promise; export declare interface SimplifyOptions { targetRatio?: number; enableSmartLink?: boolean; @@ -10058,7 +10160,7 @@ export declare function startCompileScript(assetChanges?: AssetChangeInfo[]): Pr export declare function startEngineCompilation(force?: boolean): Promise; export declare function startupWorker(projectPath: string): Promise; export declare function stop_2(): Promise; -export declare function stopAnimationGraphMotion(): void; +export declare function stopMotion(): void; export declare const SUPPORT_CREATE_TYPES: readonly ["animation-clip", "typescript", "auto-atlas", "effect", "scene", "prefab", "material", "texture-cube", "terrain", "physics-material", "label-atlas", "render-texture", "directory", "effect-header"]; export declare enum TangentImportSetting { exclude = 0, @@ -10215,44 +10317,78 @@ export { } exports[`DTS API compatibility scene.d.ts should match snapshot 1`] = ` "import type { ChildProcess } from 'child_process'; -export declare type AnimationGraphMotionAddress = -| (AnimationGraphStateAddress & { level: number[] }) -| ({ poseGraph: AnimationGraphPoseGraphContext; nodeId: number; level: number[] }); -export declare type AnimationGraphPoseGraphAddress = -| { layerIndex: number; stateMachinePath: number[]; stateIndex: number } -| { poseGraph: AnimationGraphPoseGraphContext }; -export declare type AnimationGraphPoseGraphContext = -| { kind: 'state-pose-graph'; stateMachine: AnimationGraphStateMachineContext; stateIndex: number } -| { kind: 'layer-stash'; layerIndex: number; stashName: string }; -export declare type AnimationGraphPoseNodeAddress = AnimationGraphPoseGraphAddress & { nodeId: number }; -export declare type AnimationGraphStateAddress = AnimationGraphStateMachineAddress & { stateIndex: number }; -export declare type AnimationGraphStateMachineAddress = -| { layerIndex: number; stateMachinePath: number[] } -| { stateMachine: AnimationGraphStateMachineContext }; -export declare type AnimationGraphStateMachineContext = -| { kind: 'layer-state-machine'; layerIndex: number; stateMachinePath: number[] } -| { kind: 'pose-node-state-machine'; poseGraph: AnimationGraphPoseGraphContext; nodeId: number } -| { kind: 'sub-state-machine'; stateMachine: AnimationGraphStateMachineContext; stateIndex: number }; -export declare type AnimationGraphTarget = -| { kind: 'layer'; layerIndex: number } -| ({ kind: 'state' } & AnimationGraphStateAddress) -| ({ kind: 'transition'; transitionIndex: number } & AnimationGraphStateMachineAddress) -| ({ kind: 'motion' } & AnimationGraphMotionAddress) -| ({ kind: 'pose-node' } & AnimationGraphPoseNodeAddress) -| ({ kind: 'pose-input'; inputId: string } & AnimationGraphPoseNodeAddress) -| ({ kind: 'state-component'; componentIndex: number } & AnimationGraphStateAddress); -export declare function hideAnimationGraphMotion(): void; +export declare function getMotionTimelineStats(): Promise<{ + timeLineLength: number; +} | null>; +export declare function hideMotion(): void; export declare function init(): Promise; -export declare function isAnimationGraphMotionActive(): Promise; export declare interface ISceneCommandProvider { request(module: string, method: string, args?: any[], options?: SceneCommandRequestOptions): Promise; notify?(module: string, method: string, args?: any[]): void; isConnect?(): boolean | undefined; dispose?(): void; } -export declare function pauseAnimationGraphMotion(): void; -export declare function playAnimationGraphMotion(): void; -export declare function queryAnimationGraphMotionImage(info: { +export declare function isMotionActive(): Promise; +export declare interface MotionPreviewDesc { + motion: MotionPreviewDescNode | null; + variables: MotionPreviewVariable[]; +} +export declare type MotionPreviewDescNode = { + kind: 'clip'; + clipUuid: string | null; +} | { + kind: 'blend-1d'; + variable: string | null; + value: number; + children: { + motion: MotionPreviewDescNode | null; + threshold: number; + }[]; +} | { + kind: 'blend-2d'; + variableX: string | null; + valueX: number; + variableY: string | null; + valueY: number; + algorithm?: number; + children: { + motion: MotionPreviewDescNode | null; + threshold: { + x: number; + y: number; + }; + }[]; +} | { + kind: 'blend-direct'; + children: { + motion: MotionPreviewDescNode | null; + weight: number; + }[]; +}; +export declare interface MotionPreviewVariable { + name: string; + value: number | null; +} +export declare function onMotionMouseDown(action: { + x: number; + y: number; + button: number; +}): Promise; +export declare function onMotionMouseMove(action: { + movementX: number; + movementY: number; +}): Promise; +export declare function onMotionMouseUp(action: { + x: number; + y: number; +}): Promise; +export declare function onMotionMouseWheel(action: { + wheelDeltaY: number; + wheelDeltaX: number; +}): Promise; +export declare function pauseMotion(): void; +export declare function playMotion(): void; +export declare function queryMotionImage(info: { width: number; height: number; }): Promise; @@ -10263,13 +10399,14 @@ export declare interface SceneCommandProviderRegistration { export declare interface SceneCommandRequestOptions { timeout?: number; } -export declare function setAnimationGraphMotionModel(uuid: string): Promise; -export declare function setAnimationGraphMotionTime(time: number): void; -export declare function setAnimationGraphMotionVariable(name: string, value: number): void; export declare function setCommandProvider(provider: ISceneCommandProvider): SceneCommandProviderRegistration; -export declare function showAnimationGraphMotion(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise; +export declare function setMotionModel(uuid: string): Promise; +export declare function setMotionParameter(axis: 'value' | 'x' | 'y', value: number): void; +export declare function setMotionTime(time: number): void; +export declare function setMotionVariable(name: string, value: number): void; +export declare function showMotion(desc: MotionPreviewDesc): Promise; export declare function startupWorker(projectPath: string): Promise; -export declare function stopAnimationGraphMotion(): void; +export declare function stopMotion(): void; export declare class WorkerSceneCommandProvider implements ISceneCommandProvider { private readonly rpc; constructor(process: ChildProcess | NodeJS.Process); diff --git a/src/core/assets/animation-graph-service.ts b/src/core/assets/animation-graph-service.ts index 1708d8353..222d1026a 100644 --- a/src/core/assets/animation-graph-service.ts +++ b/src/core/assets/animation-graph-service.ts @@ -1085,19 +1085,18 @@ class AnimationGraphAssetService { default: 0, enumList: enumList(api.AnimationBlend2D.Algorithm), }); - // Blend 2D 的参数通过变量下拉选择(FLOAT 变量),常量值字段不在表单中展示。 properties.variableX = nestedProperty(motion.paramX, 'variable', { type: 'String', default: '', ui: { name: 'animationGraphVariableSelect' }, }); - properties.valueX = nestedProperty(motion.paramX, 'value', { type: 'Number', default: 0, visible: false }); + properties.valueX = nestedProperty(motion.paramX, 'value', { type: 'Number', default: 0 }); properties.variableY = nestedProperty(motion.paramY, 'variable', { type: 'String', default: '', ui: { name: 'animationGraphVariableSelect' }, }); - properties.valueY = nestedProperty(motion.paramY, 'value', { type: 'Number', default: 0, visible: false }); + properties.valueY = nestedProperty(motion.paramY, 'value', { type: 'Number', default: 0 }); } return createAdapterBinding(getClassName(motion), properties); } diff --git a/src/core/scene/common/preview.ts b/src/core/scene/common/preview.ts index 0061ab296..b9085c7f5 100644 --- a/src/core/scene/common/preview.ts +++ b/src/core/scene/common/preview.ts @@ -23,35 +23,82 @@ export interface ISpinePreviewInstance extends IPreviewInstance { close(): void; } -export interface IPreviewService extends IAnimationGraphMotionPreviewService { +export interface IPreviewService extends IMotionPreviewService { open(uuid: string): Promise; generateThumbnail(uuid: string, assetType: string, width?: number, height?: number): Promise; } /** - * Animation Graph Motion 预览子能力(scene-process Preview 服务按同名方法透传)。 - * `target` 与 Inspector 使用的 `AnimationGraphTarget` 一致。 + * 通用 Motion 预览描述(中立、可序列化)。 + * 由业务方(如 AnimationGraph 扩展)翻译自各自的资产数据后传入; + * scene 侧只理解本结构,不理解任何业务资产类型。 */ -export interface IAnimationGraphMotionPreviewService { - showAnimationGraphMotion(uuidOrUrlOrPath: string, target: import('../../assets/@types/public').AnimationGraphTarget): Promise; - hideAnimationGraphMotion(): void; - setAnimationGraphMotionModel(uuid: string): Promise; - setAnimationGraphMotionTime(time: number): void; - playAnimationGraphMotion(): void; - pauseAnimationGraphMotion(): void; - stopAnimationGraphMotion(): void; - setAnimationGraphMotionVariable(name: string, value: number): void; - isAnimationGraphMotionActive(): Promise; - queryAnimationGraphMotionImage(info: { width: number; height: number }): Promise; +export type MotionPreviewDescNode = + | { kind: 'clip'; clipUuid: string | null } + | { + kind: 'blend-1d'; + variable: string | null; + value: number; + children: { motion: MotionPreviewDescNode | null; threshold: number }[]; + } + | { + kind: 'blend-2d'; + variableX: string | null; + valueX: number; + variableY: string | null; + valueY: number; + algorithm?: number; + children: { motion: MotionPreviewDescNode | null; threshold: { x: number; y: number } }[]; + } + | { + kind: 'blend-direct'; + children: { motion: MotionPreviewDescNode | null; weight: number }[]; + }; + +export interface MotionPreviewVariable { + name: string; + value: number | null; +} + +export interface MotionPreviewDesc { + motion: MotionPreviewDescNode | null; + variables: MotionPreviewVariable[]; +} + +/** + * 通用 Motion 预览子能力(scene-process Preview 服务按同名方法透传)。 + * 入参均为中立描述,不携带业务资产寻址信息。 + */ +export interface IMotionPreviewService { + showMotion(desc: MotionPreviewDesc): Promise; + hideMotion(): void; + setMotionModel(uuid: string): Promise; + setMotionTime(time: number): void; + playMotion(): void; + pauseMotion(): void; + stopMotion(): void; + setMotionVariable(name: string, value: number): void; + setMotionParameter(axis: 'value' | 'x' | 'y', value: number): void; + getMotionTimelineStats(): Promise<{ timeLineLength: number } | null>; + isMotionActive(): Promise; + queryMotionImage(info: { width: number; height: number }): Promise; + onMotionMouseDown(action: { x: number; y: number; button: number }): Promise; + onMotionMouseMove(action: { movementX: number; movementY: number }): Promise; + onMotionMouseUp(action: { x: number; y: number }): Promise; + onMotionMouseWheel(action: { wheelDeltaY: number; wheelDeltaX: number }): Promise; } export type IPublicPreviewService = Pick; // eslint-disable-next-line @typescript-eslint/no-empty-object-type diff --git a/src/core/scene/main-process/proxy/preview-proxy.ts b/src/core/scene/main-process/proxy/preview-proxy.ts index 3ac293139..bba9f44df 100644 --- a/src/core/scene/main-process/proxy/preview-proxy.ts +++ b/src/core/scene/main-process/proxy/preview-proxy.ts @@ -3,53 +3,76 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { AnimationGraphTarget } from '../../../assets/@types/public'; -import type { IAnimationGraphMotionPreviewService } from '../../common'; +import type { MotionPreviewDesc, IMotionPreviewService } from '../../common/preview'; import { Rpc } from '../rpc'; /** * 场景进程 PreviewService 的主进程 RPC 代理。 */ -export const PreviewProxy: IAnimationGraphMotionPreviewService = { - async showAnimationGraphMotion(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise { - const result = await Rpc.getInstance().request('Preview', 'showAnimationGraphMotion', [uuidOrUrlOrPath, target]); +export const PreviewProxy: IMotionPreviewService = { + async showMotion(desc: MotionPreviewDesc): Promise { + const result = await Rpc.getInstance().request('Preview', 'showMotion', [desc]); return result === true; }, - hideAnimationGraphMotion(): void { - void Rpc.getInstance().request('Preview', 'hideAnimationGraphMotion', []); + hideMotion(): void { + void Rpc.getInstance().request('Preview', 'hideMotion', []); }, - setAnimationGraphMotionModel(uuid: string): Promise { - return Rpc.getInstance().request('Preview', 'setAnimationGraphMotionModel', [uuid]); + setMotionModel(uuid: string): Promise { + return Rpc.getInstance().request('Preview', 'setMotionModel', [uuid]); }, - setAnimationGraphMotionTime(time: number): void { - void Rpc.getInstance().request('Preview', 'setAnimationGraphMotionTime', [time]); + setMotionTime(time: number): void { + void Rpc.getInstance().request('Preview', 'setMotionTime', [time]); }, - playAnimationGraphMotion(): void { - void Rpc.getInstance().request('Preview', 'playAnimationGraphMotion', []); + playMotion(): void { + void Rpc.getInstance().request('Preview', 'playMotion', []); }, - pauseAnimationGraphMotion(): void { - void Rpc.getInstance().request('Preview', 'pauseAnimationGraphMotion', []); + pauseMotion(): void { + void Rpc.getInstance().request('Preview', 'pauseMotion', []); }, - stopAnimationGraphMotion(): void { - void Rpc.getInstance().request('Preview', 'stopAnimationGraphMotion', []); + stopMotion(): void { + void Rpc.getInstance().request('Preview', 'stopMotion', []); }, - setAnimationGraphMotionVariable(name: string, value: number): void { - void Rpc.getInstance().request('Preview', 'setAnimationGraphMotionVariable', [name, value]); + setMotionVariable(name: string, value: number): void { + void Rpc.getInstance().request('Preview', 'setMotionVariable', [name, value]); }, - async isAnimationGraphMotionActive(): Promise { - const result = await Rpc.getInstance().request('Preview', 'isAnimationGraphMotionActive', []); + setMotionParameter(axis: 'value' | 'x' | 'y', value: number): void { + void Rpc.getInstance().request('Preview', 'setMotionParameter', [axis, value]); + }, + + getMotionTimelineStats(): Promise<{ timeLineLength: number } | null> { + return Rpc.getInstance().request('Preview', 'getMotionTimelineStats', []); + }, + + async isMotionActive(): Promise { + const result = await Rpc.getInstance().request('Preview', 'isMotionActive', []); return result === true; }, - queryAnimationGraphMotionImage(info: { width: number; height: number }): Promise { - return Rpc.getInstance().request('Preview', 'queryAnimationGraphMotionImage', [info]); + queryMotionImage(info: { width: number; height: number }): Promise { + return Rpc.getInstance().request('Preview', 'queryMotionImage', [info]); + }, + + onMotionMouseDown(action: { x: number; y: number; button: number }): Promise { + return Rpc.getInstance().request('Preview', 'onMotionMouseDown', [action]); + }, + + onMotionMouseMove(action: { movementX: number; movementY: number }): Promise { + return Rpc.getInstance().request('Preview', 'onMotionMouseMove', [action]); + }, + + onMotionMouseUp(action: { x: number; y: number }): Promise { + return Rpc.getInstance().request('Preview', 'onMotionMouseUp', [action]); + }, + + onMotionMouseWheel(action: { wheelDeltaY: number; wheelDeltaX: number }): Promise { + return Rpc.getInstance().request('Preview', 'onMotionMouseWheel', [action]); }, -}; \ No newline at end of file +}; diff --git a/src/core/scene/scene-process/service/preview/index.ts b/src/core/scene/scene-process/service/preview/index.ts index 5a2b6ed56..03cced9e6 100644 --- a/src/core/scene/scene-process/service/preview/index.ts +++ b/src/core/scene/scene-process/service/preview/index.ts @@ -7,9 +7,9 @@ import { MeshPreview } from './mesh-preview'; import { SkeletonPreview } from './skeleton-preview'; import { PrefabPreview } from './prefab-preview'; import { SpinePreview } from './spine-preview'; -import { AnimationGraphMotionPreview } from './animation-graph-motion-preview'; +import { MotionPreview } from './motion-preview'; import { Camera, gfx } from 'cc'; -import type { AnimationGraphTarget } from '../../../../assets/@types/public'; +import type { MotionPreviewDesc } from '../../../common/preview'; import { BaseService, register, Service } from '../core'; import { Rpc } from '../../rpc'; import type { InteractivePreview } from './interactive-preview'; @@ -37,7 +37,7 @@ export class PreviewService extends BaseService implements IPrev skeletonPreview = new SkeletonPreview(); prefabPreview = new PrefabPreview(); spinePreview = new SpinePreview(); - animationGraphMotionPreview = new AnimationGraphMotionPreview(); + motionPreview = new MotionPreview(); get activePreview(): IPreviewInstance | null { return this._activePreview; @@ -54,7 +54,7 @@ export class PreviewService extends BaseService implements IPrev this.initPreview('scene:skeleton-preview', 'query-skeleton-preview-data', this.skeletonPreview); this.initPreview('scene:prefab-preview', 'query-prefab-preview-data', this.prefabPreview); this.initPreview('scene:spine-preview', 'query-spine-preview-data', this.spinePreview); - this.initPreview('scene:animation-graph-preview', 'query-animation-graph-preview-data', this.animationGraphMotionPreview); + this.initPreview('scene:motion-preview', 'query-motion-preview-data', this.motionPreview); this.initTypeMap(); console.log('[Preview] PreviewService initialized'); } @@ -114,46 +114,78 @@ export class PreviewService extends BaseService implements IPrev return false; } - // --- Animation Graph Motion 预览(透传到 animationGraphMotionPreview 实例) --- + // --- 通用 Motion 预览(透传到 motionPreview 实例) --- - public async showAnimationGraphMotion(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise { - return this.animationGraphMotionPreview.showMotionPreview(uuidOrUrlOrPath, target); + public async showMotion(desc: MotionPreviewDesc): Promise { + return this.motionPreview.showMotion(desc); } - public hideAnimationGraphMotion(): void { - this.animationGraphMotionPreview.hideMotionPreview(); + public hideMotion(): void { + // 结束未完成的相机手势,避免调用方在释放事件丢失后污染下一次预览。 + if (this.motionPreview.isActive) { + this.motionPreview.onMouseUp({ x: 0, y: 0 }); + } + this.motionPreview.hideMotionPreview(); + } + + public async setMotionModel(uuid: string): Promise { + await this.motionPreview.setModel(uuid); + } + + public setMotionTime(time: number): void { + this.motionPreview.setTimeMotionPreview(time); + } + + public playMotion(): void { + this.motionPreview.playMotionPreview(); + } + + public pauseMotion(): void { + this.motionPreview.pauseMotionPreview(); + } + + public stopMotion(): void { + this.motionPreview.stopMotionPreview(); + } + + public setMotionVariable(name: string, value: number): void { + this.motionPreview.setMotionPreviewVariable(name, value); } - public async setAnimationGraphMotionModel(uuid: string): Promise { - await this.animationGraphMotionPreview.setModel(uuid); + public setMotionParameter(axis: 'value' | 'x' | 'y', value: number): void { + this.motionPreview.setMotionPreviewParameter(axis, value); } - public setAnimationGraphMotionTime(time: number): void { - this.animationGraphMotionPreview.setTimeMotionPreview(time); + public async getMotionTimelineStats(): Promise<{ timeLineLength: number } | null> { + return this.motionPreview.getMotionPreviewTimelineStats(); } - public playAnimationGraphMotion(): void { - this.animationGraphMotionPreview.playMotionPreview(); + public async isMotionActive(): Promise { + return this.motionPreview.isActive; } - public pauseAnimationGraphMotion(): void { - this.animationGraphMotionPreview.pauseMotionPreview(); + public async queryMotionImage(info: { width: number; height: number }): Promise { + return this.motionPreview.queryPreviewData(info); } - public stopAnimationGraphMotion(): void { - this.animationGraphMotionPreview.stopMotionPreview(); + public async onMotionMouseDown(action: { x: number; y: number; button: number }): Promise { + if (!this.motionPreview.isActive) return; + this.motionPreview.onMouseDown(action); } - public setAnimationGraphMotionVariable(name: string, value: number): void { - this.animationGraphMotionPreview.setMotionPreviewVariable(name, value); + public async onMotionMouseMove(action: { movementX: number; movementY: number }): Promise { + if (!this.motionPreview.isActive) return; + this.motionPreview.onMouseMove(action); } - public async isAnimationGraphMotionActive(): Promise { - return this.animationGraphMotionPreview.isActive; + public async onMotionMouseUp(action: { x: number; y: number }): Promise { + if (!this.motionPreview.isActive) return; + this.motionPreview.onMouseUp(action); } - public async queryAnimationGraphMotionImage(info: { width: number; height: number }): Promise { - return this.animationGraphMotionPreview.queryPreviewData(info); + public async onMotionMouseWheel(action: { wheelDeltaY: number; wheelDeltaX: number }): Promise { + if (!this.motionPreview.isActive) return; + this.motionPreview.onMouseWheel(action); } // --- 上屏预览 --- diff --git a/src/core/scene/scene-process/service/preview/animation-graph-motion-preview.ts b/src/core/scene/scene-process/service/preview/motion-preview.ts similarity index 57% rename from src/core/scene/scene-process/service/preview/animation-graph-motion-preview.ts rename to src/core/scene/scene-process/service/preview/motion-preview.ts index 50a343042..80b04d551 100644 --- a/src/core/scene/scene-process/service/preview/animation-graph-motion-preview.ts +++ b/src/core/scene/scene-process/service/preview/motion-preview.ts @@ -8,14 +8,10 @@ import { InteractivePreview, getBoundaryOfMeshNodes } from './interactive-previe import { loadPreviewAsset, removePreviewAssetCache } from './asset-reload'; import { Rpc } from '../../rpc'; import { Service } from '../core/decorator'; -import type { - AnimationGraphMotionPreviewData, - AnimationGraphMotionView, - AnimationGraphTarget, -} from '../../../../assets/@types/public'; +import type { MotionPreviewDesc, MotionPreviewDescNode } from '../../../common/preview'; /** - * engine editor 模块:与动画图资源服务一致的加载方式(scene-process 的 + * engine editor 模块:与动画剪辑预览一致的加载方式(scene-process 的 * engine-bootstrap 已把 cc/editor/new-gen-anim 作为必须模块加载)。 */ // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -25,42 +21,45 @@ function getNewGenAnim(): any { } /** - * Animation Graph Motion 预览器。 + * 通用 Motion 预览器。 * - * 负责加载预览 Prefab、根据「目标 Motion 的结构化视图 + 图内变量」重建引擎 Motion、 - * 并驱动 `MotionPreviewer` 采样姿态到模型节点上;`queryPreviewData` 由外层按帧轮询取图。 + * 只理解中立的 {@link MotionPreviewDesc}:由业务方把各自资产数据翻译成描述后传入, + * 本类负责加载预览 Prefab、按描述重建引擎 Motion、并驱动 `MotionPreviewer` + * 采样姿态到模型节点上;`queryPreviewData` 由外层按帧轮询取图。 * * ```mermaid * sequenceDiagram * participant PinK as PinK 主进程(Preview 代理) - * participant Preview as AnimationGraphMotionPreview(scene-process) - * participant Asset as assetManager RPC(main-process) + * participant Preview as MotionPreview(scene-process) * participant Engine as MotionPreviewer(cc/editor/new-gen-anim) - * PinK->>Preview: showMotionPreview(uuid, target) - * Preview->>Asset: request('assetManager','queryAnimationGraphMotionPreviewData',...) - * Asset-->>Preview: { motion: AnimationGraphMotionView, variables } - * Preview->>Engine: new MotionPreviewer(modelNode) + setMotion(rebuilt motion) - * PinK->>Preview: setTime / play / pause / stop / setVariable + * PinK->>Preview: showMotion(desc) + * Preview->>Engine: new MotionPreviewer(modelNode) + setMotion(built motion) + * PinK->>Preview: setMotionTime / playMotion / pauseMotion / setMotionVariable * Preview->>Engine: setTime(time) + evaluate() * PinK->>Preview: queryPreviewData({width,height}) * Preview-->>PinK: RGBA buffer(模型当前姿态帧) * ``` */ -export class AnimationGraphMotionPreview extends InteractivePreview { +export class MotionPreview extends InteractivePreview { private lightComp: DirectionalLight | any; // eslint-disable-next-line @typescript-eslint/no-explicit-any private motionPreviewer: any = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any + private motionPreview: any = null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any private readonly loadedClips = new Map(); private active = false; private playing = false; private time = 0; private lastPlayTick = 0; - // 未等到模型时的待处理 Motion(webview 可能先选 Motion 再拖入模型)。 - private pendingMotion: { uuid: string; target: AnimationGraphTarget } | null = null; + // 最近一次外部时间下发(Inspector rAF 时钟经 show/play/stop/setTime 写入)的时间戳; + // headless 消费者(MCP/CLI 只轮询取帧)超过阈值未下发时,queryPreviewData 自推进。 + private lastExternalTimeAt = 0; + // 未等到模型时的待处理描述(调用方可能先下发 Motion 描述、再设置模型)。 + private pendingDesc: MotionPreviewDesc | null = null; public createNodes(scene: Scene) { - this.lightComp = new Node('Animation Graph Motion Preview Light').addComponent(DirectionalLight); + this.lightComp = new Node('Motion Preview Light').addComponent(DirectionalLight); this.lightComp.node.setRotationFromEuler(-45, -45, 0); this.lightComp.node.parent = scene; } @@ -75,7 +74,7 @@ export class AnimationGraphMotionPreview extends InteractivePreview { public async setModel(uuid: string): Promise { if (!uuid) { - console.warn(`Failed to set model in Animation Graph Motion preview, by uuid: ${uuid}`); + console.warn(`Failed to set model in Motion preview, by uuid: ${uuid}`); return; } @@ -100,14 +99,14 @@ export class AnimationGraphMotionPreview extends InteractivePreview { // 重建 MotionPreviewer(绑定到新模型根节点的骨骼层级)。 this._resetMotionPreviewer(); - // 若此前已下发 Motion,模型就绪后继续接入。 - if (this.pendingMotion) { - const pending = this.pendingMotion; - this.pendingMotion = null; + // 若此前已下发描述,模型就绪后继续接入。 + if (this.pendingDesc) { + const pending = this.pendingDesc; + this.pendingDesc = null; try { - await this._attachMotion(pending.uuid, pending.target); + await this._attachMotion(pending); } catch (error) { - console.warn(`[AnimationGraphMotionPreview] Failed to attach pending motion:`, error); + console.warn(`[MotionPreview] Failed to attach pending motion:`, error); } } @@ -115,35 +114,37 @@ export class AnimationGraphMotionPreview extends InteractivePreview { this.resetCameraView(); } - public async showMotionPreview(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise { + public async showMotion(desc: MotionPreviewDesc): Promise { if (!this._modelNode) { - // 暂无模型:记住 Motion,等 setModel 后接入;返回 false 表示"等待模型"。 - this.pendingMotion = { uuid: uuidOrUrlOrPath, target }; + // 暂无模型:记住描述,等 setModel 后接入;返回 false 表示“等待模型”。 + this.pendingDesc = desc; return false; } - this.pendingMotion = null; - await this._attachMotion(uuidOrUrlOrPath, target); + this.pendingDesc = null; + await this._attachMotion(desc); this.active = true; this.time = 0; this.lastPlayTick = Date.now(); + this.lastExternalTimeAt = Date.now(); this._evaluate(); return true; } public hideMotionPreview(): void { - this.pendingMotion = null; + this.pendingDesc = null; this.active = false; this.pauseMotionPreview(); if (this.motionPreviewer) { this.motionPreviewer.destroy?.(); this.motionPreviewer = null; } + this.motionPreview = null; this.hide(); } public resetMotionPreview(): void { this.time = 0; - this.pendingMotion = null; + this.pendingDesc = null; this._resetMotionPreviewer(); } @@ -153,6 +154,7 @@ export class AnimationGraphMotionPreview extends InteractivePreview { } this.playing = true; this.lastPlayTick = Date.now(); + this.lastExternalTimeAt = Date.now(); this._evaluate(); } @@ -163,17 +165,20 @@ export class AnimationGraphMotionPreview extends InteractivePreview { public stopMotionPreview(): void { this.playing = false; this.time = 0; + this.lastExternalTimeAt = Date.now(); this._evaluate(); } public setTimeMotionPreview(time: number): void { this.time = Math.max(0, time); + this.lastPlayTick = Date.now(); + this.lastExternalTimeAt = Date.now(); this._evaluate(); } /** - * 更新预览变量。变量实例当前未随数据契约注入 MotionPreviewer(见方案文档的 - * 风险点),因此仅记录调用,等变量实例搭建完成后生效。 + * 更新预览变量。变量实例由业务方随描述下发(静态值)或经本方法注入 + * MotionPreviewer(等价于引擎 updateVariable 语义)。 */ public setMotionPreviewVariable(name: string, value: number): void { if (!this.motionPreviewer) { @@ -182,7 +187,33 @@ export class AnimationGraphMotionPreview extends InteractivePreview { try { this.motionPreviewer.updateVariable(name, value); } catch (error) { - console.warn(`[AnimationGraphMotionPreview] setVariable failed:`, error); + console.warn(`[MotionPreview] setVariable failed:`, error); + } + } + + /** 设置预览中 Blend Motion 的临时参数值,不回写任何资产。 */ + public setMotionPreviewParameter(axis: 'value' | 'x' | 'y', value: number): void { + if (!this.motionPreview || !Number.isFinite(value)) { + return; + } + try { + const api = getNewGenAnim(); + if (this.motionPreview instanceof api.AnimationBlend1D && axis === 'value') { + this.motionPreview.param.value = value; + } else if (this.motionPreview instanceof api.AnimationBlend2D) { + if (axis === 'x') { + this.motionPreview.paramX.value = value; + } else if (axis === 'y') { + this.motionPreview.paramY.value = value; + } else { + return; + } + } else { + return; + } + this._evaluate(); + } catch (error) { + console.warn(`[MotionPreview] setParameter failed:`, error); } } @@ -198,7 +229,10 @@ export class AnimationGraphMotionPreview extends InteractivePreview { } public async queryPreviewData(info: { width: number; height: number }) { - if (this.playing && this.active) { + if (this.playing && this.active && Date.now() - this.lastExternalTimeAt > 500) { + // Headless 兜底:Inspector 以外的消费者(MCP/CLI)只轮询取帧、不下发 setTime, + // 由场景进程按 wall-clock 推进;高频下发 setTime 的调用方存在时跳过, + // 避免两边各推进一次造成双倍播放速度。 const now = Date.now(); const delta = Math.max(0, (now - this.lastPlayTick) / 1000); this.lastPlayTick = now; @@ -211,45 +245,41 @@ export class AnimationGraphMotionPreview extends InteractivePreview { } /** - * 重建引擎 Motion 并喂给 MotionPreviewer。 + * 按中立描述重建引擎 Motion 并喂给 MotionPreviewer。 */ - private async _attachMotion(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const data = (await Rpc.getInstance().request( - 'assetManager', - 'queryAnimationGraphMotionPreviewData', - [uuidOrUrlOrPath, target as Extract], - )) as unknown as AnimationGraphMotionPreviewData | null; - if (!data?.motion) { - throw new Error(`Animation Graph Motion preview data is unavailable for target ${JSON.stringify(target)}`); + private async _attachMotion(desc: MotionPreviewDesc): Promise { + if (!desc?.motion) { + throw new Error(`Motion preview desc is empty, nothing to show.`); } this._resetMotionPreviewer(); if (!this.motionPreviewer) { - throw new Error('Animation Graph Motion preview model has not been set.'); + throw new Error('Motion preview model has not been set.'); } // 先并行加载 Motion 用到的全部动画剪辑,再重建引擎 Motion。 this.loadedClips.clear(); await Promise.all( - Array.from(new Set(collectClipUuids(data.motion))) + Array.from(new Set(collectClipUuids(desc.motion))) .filter(Boolean) .map(async (clipUuid) => { try { this.loadedClips.set(clipUuid, await loadPreviewAsset(clipUuid, 'animation-clip')); } catch (error) { - console.warn(`[AnimationGraphMotionPreview] Failed to load clip ${clipUuid}:`, error); + console.warn(`[MotionPreview] Failed to load clip ${clipUuid}:`, error); } }), ); - const motion = this._rebuildMotion(data.motion); + const motion = this._buildMotion(desc.motion); + this.motionPreview = motion; this.motionPreviewer.setMotion(motion); this.time = 0; this._evaluate(); } private _resetMotionPreviewer(): void { + this.motionPreview = null; if (this.motionPreviewer) { this.motionPreviewer.destroy?.(); this.motionPreviewer = null; @@ -260,73 +290,68 @@ export class AnimationGraphMotionPreview extends InteractivePreview { try { const { MotionPreviewer } = getNewGenAnim(); if (!MotionPreviewer) { - console.warn('[AnimationGraphMotionPreview] MotionPreviewer is not available in the engine module.'); + console.warn('[MotionPreview] MotionPreviewer is not available in the engine module.'); return; } this.motionPreviewer = new MotionPreviewer(this._modelNode); } catch (error) { - console.warn('[AnimationGraphMotionPreview] Failed to create MotionPreviewer:', error); + console.warn('[MotionPreview] Failed to create MotionPreviewer:', error); } } /** - * 根据结构化视图重建引擎 Motion。blend-1d/2d/direct 会把变量绑定清空为静态值, - * 以便「未注册变量实例」时仍可按 param 默认值采样(详见 bindOr 的回归行为)。 + * 根据中立描述重建引擎 Motion。业务方已把变量绑定解析为静态值 + * (`variable` 字段仅保留展示用),此处直接按值采样。 */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - private _rebuildMotion(view: AnimationGraphMotionView | null | undefined): any { + private _buildMotion(node: MotionPreviewDescNode | null | undefined): any { const api = getNewGenAnim(); - if (!view) { + if (!node) { return null; } - switch (view.type) { + switch (node.kind) { case 'clip': { const clipMotion = new api.ClipMotion(); - if (view.clipUuid) { - clipMotion.clip = this.loadedClips.get(view.clipUuid) ?? null; + if (node.clipUuid) { + clipMotion.clip = this.loadedClips.get(node.clipUuid) ?? null; } return clipMotion; } case 'blend-1d': { const blend = new api.AnimationBlend1D(); - blend.param.value = view.value ?? 0; + blend.param.value = node.value; blend.param.variable = ''; - blend.items = (view.children ?? []).map((child) => { + blend.items = (node.children ?? []).map((child) => { const item = new api.AnimationBlend1D.Item(); - item.motion = this._rebuildMotion(child); - item.threshold = typeof child.threshold === 'number' - ? child.threshold - : child.threshold?.x ?? 0; + item.motion = this._buildMotion(child.motion); + item.threshold = child.threshold; return item; }); return blend; } case 'blend-2d': { const blend = new api.AnimationBlend2D(); - blend.paramX.value = view.valueX ?? 0; + blend.paramX.value = node.valueX; blend.paramX.variable = ''; - blend.paramY.value = view.valueY ?? 0; + blend.paramY.value = node.valueY; blend.paramY.variable = ''; - if (typeof view.algorithm === 'number') { - blend.algorithm = view.algorithm; + if (typeof node.algorithm === 'number') { + blend.algorithm = node.algorithm; } - blend.items = (view.children ?? []).map((child) => { + blend.items = (node.children ?? []).map((child) => { const item = new api.AnimationBlend2D.Item(); - item.motion = this._rebuildMotion(child); - item.threshold.set( - child.threshold && typeof child.threshold === 'object' ? child.threshold.x : 0, - child.threshold && typeof child.threshold === 'object' ? child.threshold.y : 0, - ); + item.motion = this._buildMotion(child.motion); + item.threshold.set(child.threshold.x, child.threshold.y); return item; }); return blend; } case 'blend-direct': { const blend = new api.AnimationBlendDirect(); - blend.items = (view.children ?? []).map((child) => { + blend.items = (node.children ?? []).map((child) => { const item = new api.AnimationBlendDirect.Item(); - item.motion = this._rebuildMotion(child); - item.weight.value = child.weight?.value ?? 0; + item.motion = this._buildMotion(child.motion); + item.weight.value = child.weight; item.weight.variable = ''; return item; }); @@ -345,7 +370,7 @@ export class AnimationGraphMotionPreview extends InteractivePreview { this.motionPreviewer.setTime(this.time); this.motionPreviewer.evaluate(); } catch (error) { - console.warn('[AnimationGraphMotionPreview] evaluate failed:', error); + console.warn('[MotionPreview] evaluate failed:', error); } } @@ -366,17 +391,20 @@ export class AnimationGraphMotionPreview extends InteractivePreview { } /** - * 收集 Motion 视图递归引用到的全部动画剪辑 uuid,供预览前并行加载。 + * 收集 Motion 描述递归引用到的全部动画剪辑 uuid,供预览前并行加载。 */ -function collectClipUuids(view: AnimationGraphMotionView | null | undefined, out: string[] = []): string[] { - if (!view) { +function collectClipUuids(node: MotionPreviewDescNode | null | undefined, out: string[] = []): string[] { + if (!node) { return out; } - if (view.type === 'clip' && view.clipUuid) { - out.push(view.clipUuid); + if (node.kind === 'clip') { + if (node.clipUuid) { + out.push(node.clipUuid); + } + return out; } - for (const child of view.children ?? []) { - collectClipUuids(child, out); + for (const child of node.children) { + collectClipUuids(child.motion, out); } return out; -} \ No newline at end of file +} diff --git a/src/lib/scene/scene.ts b/src/lib/scene/scene.ts index 0cb472084..d4a1bb523 100644 --- a/src/lib/scene/scene.ts +++ b/src/lib/scene/scene.ts @@ -5,7 +5,7 @@ import type { ISceneCommandProvider, SceneCommandProviderRegistration, } from '../../core/scene/main-process/rpc'; -import type { AnimationGraphTarget } from '../../core/assets/@types/public'; +import type { MotionPreviewDesc } from '../../core/scene/common/preview'; export type { ISceneCommandProvider, @@ -44,56 +44,86 @@ export function resetCommandProvider(): void { Rpc.resetCommandProvider(); } -// ==================== Animation Graph Motion Preview ==================== -// 将 scene-process PreviewService 的 AnimationGraph Motion 门面经场景进程 RPC 暴露给 PinK。 -// 方法名与 IAnimationGraphMotionPreviewService 一一对应,供主进程 cocosHostScene 通道透传调用。 +// ==================== 通用 Motion 预览 ==================== +// 将 scene-process PreviewService 的 Motion 门面经场景进程 RPC 暴露给 PinK。 +// 方法名与 IMotionPreviewService 一一对应,供主进程 cocosHostScene 通道透传调用。 -/** 显示指定 Motion 的预览(clip 或 blend 树)。@param uuidOrUrlOrPath 动画图资源标识。@param target 图内目标 Motion 的唯一地址。@returns 是否已成功显示预览。 */ -export async function showAnimationGraphMotion(uuidOrUrlOrPath: string, target: AnimationGraphTarget): Promise { - return Scene.Preview.showAnimationGraphMotion(uuidOrUrlOrPath, target); +/** 显示指定 Motion 描述的采样预览。 @param desc 中立的 Motion 预览描述。 @returns 是否已成功显示预览。 */ +export async function showMotion(desc: MotionPreviewDesc): Promise { + return Scene.Preview.showMotion(desc); } /** 隐藏当前 Motion 预览。 */ -export function hideAnimationGraphMotion(): void { - Scene.Preview.hideAnimationGraphMotion(); +export function hideMotion(): void { + Scene.Preview.hideMotion(); } /** 为 Motion 预览设置展示模型资源。 @param uuid 模型资源 UUID。 */ -export async function setAnimationGraphMotionModel(uuid: string): Promise { - return Scene.Preview.setAnimationGraphMotionModel(uuid); +export async function setMotionModel(uuid: string): Promise { + return Scene.Preview.setMotionModel(uuid); } /** 设置 Motion 预览的采样时间。 @param time 时间(秒)。 */ -export function setAnimationGraphMotionTime(time: number): void { - Scene.Preview.setAnimationGraphMotionTime(time); +export function setMotionTime(time: number): void { + Scene.Preview.setMotionTime(time); } /** 播放 Motion 预览。 */ -export function playAnimationGraphMotion(): void { - Scene.Preview.playAnimationGraphMotion(); +export function playMotion(): void { + Scene.Preview.playMotion(); } /** 暂停 Motion 预览。 */ -export function pauseAnimationGraphMotion(): void { - Scene.Preview.pauseAnimationGraphMotion(); +export function pauseMotion(): void { + Scene.Preview.pauseMotion(); } /** 停止 Motion 预览。 */ -export function stopAnimationGraphMotion(): void { - Scene.Preview.stopAnimationGraphMotion(); +export function stopMotion(): void { + Scene.Preview.stopMotion(); } /** 设置 Motion 预览使用的变量值。 @param name 变量名。 @param value 变量值。 */ -export function setAnimationGraphMotionVariable(name: string, value: number): void { - Scene.Preview.setAnimationGraphMotionVariable(name, value); +export function setMotionVariable(name: string, value: number): void { + Scene.Preview.setMotionVariable(name, value); +} + +/** 设置 Motion 预览中 Blend 参数的临时值,不回写任何资产。 @param axis Blend 1D 使用 value,Blend 2D 使用 x/y。 @param value 参数值。 */ +export function setMotionParameter(axis: 'value' | 'x' | 'y', value: number): void { + Scene.Preview.setMotionParameter(axis, value); +} + +/** 查询 Motion 预览时间轴长度。 @returns 当前 Motion 的时间轴统计,尚未建立预览时返回 null。 */ +export function getMotionTimelineStats(): Promise<{ timeLineLength: number } | null> { + return Scene.Preview.getMotionTimelineStats(); } /** 查询当前是否有活跃的 Motion 预览。 @returns 存在返回 true。 */ -export async function isAnimationGraphMotionActive(): Promise { - return Scene.Preview.isAnimationGraphMotionActive(); +export async function isMotionActive(): Promise { + return Scene.Preview.isMotionActive(); } /** 查询当前 Motion 预览的渲染图像帧。 @param info 图像尺寸。 @returns 图像帧数据。 */ -export function queryAnimationGraphMotionImage(info: { width: number; height: number }): Promise { - return Scene.Preview.queryAnimationGraphMotionImage(info); +export function queryMotionImage(info: { width: number; height: number }): Promise { + return Scene.Preview.queryMotionImage(info); +} + +/** 转发预览相机左键/中键按下(轨道旋转 / 中键平移)。 @param action 事件参数(相对画布坐标与按键)。 */ +export function onMotionMouseDown(action: { x: number; y: number; button: number }): Promise { + return Scene.Preview.onMotionMouseDown(action); +} + +/** 转发预览相机鼠标移动(轨道旋转 / 平移)。 @param action 事件参数(相对位移)。 */ +export function onMotionMouseMove(action: { movementX: number; movementY: number }): Promise { + return Scene.Preview.onMotionMouseMove(action); +} + +/** 转发预览相机鼠标释放。 @param action 事件参数(相对画布坐标)。 */ +export function onMotionMouseUp(action: { x: number; y: number }): Promise { + return Scene.Preview.onMotionMouseUp(action); +} + +/** 转发预览相机滚轮缩放。 @param action 事件参数(滚轮增量)。 */ +export function onMotionMouseWheel(action: { wheelDeltaY: number; wheelDeltaX: number }): Promise { + return Scene.Preview.onMotionMouseWheel(action); } From 921d92a4e9289926dc6247f1b614ca640ad65765 Mon Sep 17 00:00:00 2001 From: looopmax Date: Sun, 6 Sep 2026 12:11:49 +0800 Subject: [PATCH 08/11] fix(scene): harden motion preview lifecycle --- src/core/scene/common/preview.ts | 14 +- .../scene/main-process/proxy/preview-proxy.ts | 28 +- .../scene-process/service/preview/index.ts | 34 +-- .../service/preview/motion-preview.ts | 251 +++++++++--------- src/lib/scene/scene.ts | 28 +- 5 files changed, 182 insertions(+), 173 deletions(-) diff --git a/src/core/scene/common/preview.ts b/src/core/scene/common/preview.ts index b9085c7f5..23a5a60d9 100644 --- a/src/core/scene/common/preview.ts +++ b/src/core/scene/common/preview.ts @@ -71,14 +71,14 @@ export interface MotionPreviewDesc { */ export interface IMotionPreviewService { showMotion(desc: MotionPreviewDesc): Promise; - hideMotion(): void; + hideMotion(): Promise; setMotionModel(uuid: string): Promise; - setMotionTime(time: number): void; - playMotion(): void; - pauseMotion(): void; - stopMotion(): void; - setMotionVariable(name: string, value: number): void; - setMotionParameter(axis: 'value' | 'x' | 'y', value: number): void; + setMotionTime(time: number): Promise; + playMotion(): Promise; + pauseMotion(): Promise; + stopMotion(): Promise; + setMotionVariable(name: string, value: number): Promise; + setMotionParameter(axis: 'value' | 'x' | 'y', value: number): Promise; getMotionTimelineStats(): Promise<{ timeLineLength: number } | null>; isMotionActive(): Promise; queryMotionImage(info: { width: number; height: number }): Promise; diff --git a/src/core/scene/main-process/proxy/preview-proxy.ts b/src/core/scene/main-process/proxy/preview-proxy.ts index bba9f44df..98a71f094 100644 --- a/src/core/scene/main-process/proxy/preview-proxy.ts +++ b/src/core/scene/main-process/proxy/preview-proxy.ts @@ -15,36 +15,36 @@ export const PreviewProxy: IMotionPreviewService = { return result === true; }, - hideMotion(): void { - void Rpc.getInstance().request('Preview', 'hideMotion', []); + hideMotion(): Promise { + return Rpc.getInstance().request('Preview', 'hideMotion', []); }, setMotionModel(uuid: string): Promise { return Rpc.getInstance().request('Preview', 'setMotionModel', [uuid]); }, - setMotionTime(time: number): void { - void Rpc.getInstance().request('Preview', 'setMotionTime', [time]); + setMotionTime(time: number): Promise { + return Rpc.getInstance().request('Preview', 'setMotionTime', [time]); }, - playMotion(): void { - void Rpc.getInstance().request('Preview', 'playMotion', []); + playMotion(): Promise { + return Rpc.getInstance().request('Preview', 'playMotion', []); }, - pauseMotion(): void { - void Rpc.getInstance().request('Preview', 'pauseMotion', []); + pauseMotion(): Promise { + return Rpc.getInstance().request('Preview', 'pauseMotion', []); }, - stopMotion(): void { - void Rpc.getInstance().request('Preview', 'stopMotion', []); + stopMotion(): Promise { + return Rpc.getInstance().request('Preview', 'stopMotion', []); }, - setMotionVariable(name: string, value: number): void { - void Rpc.getInstance().request('Preview', 'setMotionVariable', [name, value]); + setMotionVariable(name: string, value: number): Promise { + return Rpc.getInstance().request('Preview', 'setMotionVariable', [name, value]); }, - setMotionParameter(axis: 'value' | 'x' | 'y', value: number): void { - void Rpc.getInstance().request('Preview', 'setMotionParameter', [axis, value]); + setMotionParameter(axis: 'value' | 'x' | 'y', value: number): Promise { + return Rpc.getInstance().request('Preview', 'setMotionParameter', [axis, value]); }, getMotionTimelineStats(): Promise<{ timeLineLength: number } | null> { diff --git a/src/core/scene/scene-process/service/preview/index.ts b/src/core/scene/scene-process/service/preview/index.ts index 03cced9e6..c70b79ff2 100644 --- a/src/core/scene/scene-process/service/preview/index.ts +++ b/src/core/scene/scene-process/service/preview/index.ts @@ -1,5 +1,5 @@ import { PreviewBase } from './preview-base'; -import { scenePreview, ScenePreview } from './scene-preview'; +import { scenePreview } from './scene-preview'; import { MiniPreview } from './mini-preview'; import { MaterialPreview } from './material-preview'; import { ModelPreview } from './model-preview'; @@ -77,8 +77,8 @@ export class PreviewService extends BaseService implements IPrev // importer name → preview type 的映射(用于 assetType 为 cc.Asset 等泛型的回退) private static readonly IMPORTER_MAP: Record = { - 'gltf': 'model', - 'fbx': 'model', + gltf: 'model', + fbx: 'model', 'spine-data': 'spine', }; @@ -120,40 +120,40 @@ export class PreviewService extends BaseService implements IPrev return this.motionPreview.showMotion(desc); } - public hideMotion(): void { + public async hideMotion(): Promise { // 结束未完成的相机手势,避免调用方在释放事件丢失后污染下一次预览。 if (this.motionPreview.isActive) { this.motionPreview.onMouseUp({ x: 0, y: 0 }); } - this.motionPreview.hideMotionPreview(); + await this.motionPreview.hideMotionPreview(); } public async setMotionModel(uuid: string): Promise { await this.motionPreview.setModel(uuid); } - public setMotionTime(time: number): void { - this.motionPreview.setTimeMotionPreview(time); + public async setMotionTime(time: number): Promise { + await this.motionPreview.setTimeMotionPreview(time); } - public playMotion(): void { - this.motionPreview.playMotionPreview(); + public async playMotion(): Promise { + await this.motionPreview.playMotionPreview(); } - public pauseMotion(): void { - this.motionPreview.pauseMotionPreview(); + public async pauseMotion(): Promise { + await this.motionPreview.pauseMotionPreview(); } - public stopMotion(): void { - this.motionPreview.stopMotionPreview(); + public async stopMotion(): Promise { + await this.motionPreview.stopMotionPreview(); } - public setMotionVariable(name: string, value: number): void { - this.motionPreview.setMotionPreviewVariable(name, value); + public async setMotionVariable(name: string, value: number): Promise { + await this.motionPreview.setMotionPreviewVariable(name, value); } - public setMotionParameter(axis: 'value' | 'x' | 'y', value: number): void { - this.motionPreview.setMotionPreviewParameter(axis, value); + public async setMotionParameter(axis: 'value' | 'x' | 'y', value: number): Promise { + await this.motionPreview.setMotionPreviewParameter(axis, value); } public async getMotionTimelineStats(): Promise<{ timeLineLength: number } | null> { diff --git a/src/core/scene/scene-process/service/preview/motion-preview.ts b/src/core/scene/scene-process/service/preview/motion-preview.ts index 80b04d551..0a5ccfc21 100644 --- a/src/core/scene/scene-process/service/preview/motion-preview.ts +++ b/src/core/scene/scene-process/service/preview/motion-preview.ts @@ -7,16 +7,13 @@ import { DirectionalLight, Node, Prefab, Scene, instantiate } from 'cc'; import { InteractivePreview, getBoundaryOfMeshNodes } from './interactive-preview'; import { loadPreviewAsset, removePreviewAssetCache } from './asset-reload'; import { Rpc } from '../../rpc'; -import { Service } from '../core/decorator'; import type { MotionPreviewDesc, MotionPreviewDescNode } from '../../../common/preview'; /** * engine editor 模块:与动画剪辑预览一致的加载方式(scene-process 的 * engine-bootstrap 已把 cc/editor/new-gen-anim 作为必须模块加载)。 */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any function getNewGenAnim(): any { - // eslint-disable-next-line @typescript-eslint/no-var-requires return require('cc/editor/new-gen-anim'); } @@ -42,12 +39,9 @@ function getNewGenAnim(): any { */ export class MotionPreview extends InteractivePreview { private lightComp: DirectionalLight | any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any private motionPreviewer: any = null; - // eslint-disable-next-line @typescript-eslint/no-explicit-any private motionPreview: any = null; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private readonly loadedClips = new Map(); + private loadedClips = new Map(); private active = false; private playing = false; private time = 0; @@ -57,6 +51,7 @@ export class MotionPreview extends InteractivePreview { private lastExternalTimeAt = 0; // 未等到模型时的待处理描述(调用方可能先下发 Motion 描述、再设置模型)。 private pendingDesc: MotionPreviewDesc | null = null; + private operationVersion = 0; public createNodes(scene: Scene) { this.lightComp = new Node('Motion Preview Light').addComponent(DirectionalLight); @@ -68,14 +63,10 @@ export class MotionPreview extends InteractivePreview { return this.active; } - public getIsPlaying(): boolean { - return this.playing; - } - public async setModel(uuid: string): Promise { + const operationVersion = ++this.operationVersion; if (!uuid) { - console.warn(`Failed to set model in Motion preview, by uuid: ${uuid}`); - return; + throw new Error('Motion preview model UUID must be a non-empty string.'); } const prefabUuid = await this._resolvePrefabUuid(uuid); @@ -85,7 +76,42 @@ export class MotionPreview extends InteractivePreview { removePreviewAssetCache(uuid); const prefabAsset = await loadPreviewAsset(prefabUuid, 'model', { reloadAsset: true }); + if (operationVersion !== this.operationVersion) { + return; + } + const nextModelNode = instantiate(prefabAsset) as Node; + let nextMotionPreviewer: any; + const pending = this.pendingDesc; + let nextMotion: any = null; + let nextLoadedClips: Map | undefined; + try { + nextMotionPreviewer = this._createMotionPreviewer(nextModelNode); + if (pending) { + const prepared = await this._prepareMotion(pending); + if (operationVersion !== this.operationVersion) { + nextMotionPreviewer.destroy?.(); + nextModelNode.destroy(); + return; + } + nextMotion = prepared.motion; + nextLoadedClips = prepared.loadedClips; + this._configureMotionPreviewer(nextMotionPreviewer, pending); + nextMotionPreviewer.setMotion(nextMotion); + } + } catch (error) { + nextMotionPreviewer.destroy?.(); + nextModelNode.destroy(); + throw error; + } + + if (operationVersion !== this.operationVersion) { + nextMotionPreviewer.destroy?.(); + nextModelNode.destroy(); + return; + } + + this.motionPreviewer?.destroy?.(); if (this._modelNode) { this.scene.removeChild(this._modelNode); if (this._modelNode.isValid) { @@ -93,21 +119,21 @@ export class MotionPreview extends InteractivePreview { } } - this._modelNode = instantiate(prefabAsset) as Node; + this._modelNode = nextModelNode; this._modelNode.parent = this.scene; - // 重建 MotionPreviewer(绑定到新模型根节点的骨骼层级)。 - this._resetMotionPreviewer(); - - // 若此前已下发描述,模型就绪后继续接入。 - if (this.pendingDesc) { - const pending = this.pendingDesc; + this.motionPreviewer = nextMotionPreviewer; + this.motionPreview = nextMotion; + if (pending) { this.pendingDesc = null; - try { - await this._attachMotion(pending); - } catch (error) { - console.warn(`[MotionPreview] Failed to attach pending motion:`, error); - } + this.active = true; + this.loadedClips = nextLoadedClips ?? new Map(); + this.time = 0; + this._evaluate(); + } else { + this.active = false; + this.playing = false; + this.loadedClips = new Map(); } this.cameraComp.enabled = true; @@ -115,13 +141,33 @@ export class MotionPreview extends InteractivePreview { } public async showMotion(desc: MotionPreviewDesc): Promise { + const operationVersion = ++this.operationVersion; if (!this._modelNode) { // 暂无模型:记住描述,等 setModel 后接入;返回 false 表示“等待模型”。 this.pendingDesc = desc; return false; } this.pendingDesc = null; - await this._attachMotion(desc); + const prepared = await this._prepareMotion(desc); + if (operationVersion !== this.operationVersion) { + return false; + } + const nextMotionPreviewer = this._createMotionPreviewer(this._modelNode); + try { + this._configureMotionPreviewer(nextMotionPreviewer, desc); + nextMotionPreviewer.setMotion(prepared.motion); + } catch (error) { + nextMotionPreviewer.destroy?.(); + throw error; + } + if (operationVersion !== this.operationVersion) { + nextMotionPreviewer.destroy?.(); + return false; + } + this.motionPreviewer?.destroy?.(); + this.motionPreviewer = nextMotionPreviewer; + this.motionPreview = prepared.motion; + this.loadedClips = prepared.loadedClips; this.active = true; this.time = 0; this.lastPlayTick = Date.now(); @@ -130,10 +176,11 @@ export class MotionPreview extends InteractivePreview { return true; } - public hideMotionPreview(): void { + public async hideMotionPreview(): Promise { + ++this.operationVersion; this.pendingDesc = null; this.active = false; - this.pauseMotionPreview(); + await this.pauseMotionPreview(); if (this.motionPreviewer) { this.motionPreviewer.destroy?.(); this.motionPreviewer = null; @@ -142,13 +189,7 @@ export class MotionPreview extends InteractivePreview { this.hide(); } - public resetMotionPreview(): void { - this.time = 0; - this.pendingDesc = null; - this._resetMotionPreviewer(); - } - - public playMotionPreview(): void { + public async playMotionPreview(): Promise { if (!this.active) { return; } @@ -158,18 +199,18 @@ export class MotionPreview extends InteractivePreview { this._evaluate(); } - public pauseMotionPreview(): void { + public async pauseMotionPreview(): Promise { this.playing = false; } - public stopMotionPreview(): void { + public async stopMotionPreview(): Promise { this.playing = false; this.time = 0; this.lastExternalTimeAt = Date.now(); this._evaluate(); } - public setTimeMotionPreview(time: number): void { + public async setTimeMotionPreview(time: number): Promise { this.time = Math.max(0, time); this.lastPlayTick = Date.now(); this.lastExternalTimeAt = Date.now(); @@ -180,41 +221,34 @@ export class MotionPreview extends InteractivePreview { * 更新预览变量。变量实例由业务方随描述下发(静态值)或经本方法注入 * MotionPreviewer(等价于引擎 updateVariable 语义)。 */ - public setMotionPreviewVariable(name: string, value: number): void { + public async setMotionPreviewVariable(name: string, value: number): Promise { if (!this.motionPreviewer) { - return; - } - try { - this.motionPreviewer.updateVariable(name, value); - } catch (error) { - console.warn(`[MotionPreview] setVariable failed:`, error); + throw new Error('Motion preview is not active.'); } + this.motionPreviewer.updateVariable(name, value); + this._evaluate(); } /** 设置预览中 Blend Motion 的临时参数值,不回写任何资产。 */ - public setMotionPreviewParameter(axis: 'value' | 'x' | 'y', value: number): void { + public async setMotionPreviewParameter(axis: 'value' | 'x' | 'y', value: number): Promise { if (!this.motionPreview || !Number.isFinite(value)) { - return; + throw new Error('Motion preview parameter cannot be changed before a valid preview is active.'); } - try { - const api = getNewGenAnim(); - if (this.motionPreview instanceof api.AnimationBlend1D && axis === 'value') { - this.motionPreview.param.value = value; - } else if (this.motionPreview instanceof api.AnimationBlend2D) { - if (axis === 'x') { - this.motionPreview.paramX.value = value; - } else if (axis === 'y') { - this.motionPreview.paramY.value = value; - } else { - return; - } + const api = getNewGenAnim(); + if (this.motionPreview instanceof api.AnimationBlend1D && axis === 'value') { + this.motionPreview.param.value = value; + } else if (this.motionPreview instanceof api.AnimationBlend2D) { + if (axis === 'x') { + this.motionPreview.paramX.value = value; + } else if (axis === 'y') { + this.motionPreview.paramY.value = value; } else { - return; + throw new Error(`Motion preview parameter axis '${axis}' is not valid for Blend 2D.`); } - this._evaluate(); - } catch (error) { - console.warn(`[MotionPreview] setParameter failed:`, error); + } else { + throw new Error(`Motion preview parameter axis '${axis}' is not valid for this Motion.`); } + this._evaluate(); } public getMotionPreviewTimelineStats(): { timeLineLength: number } | null { @@ -244,67 +278,48 @@ export class MotionPreview extends InteractivePreview { return super.queryPreviewData(info); } - /** - * 按中立描述重建引擎 Motion 并喂给 MotionPreviewer。 - */ - private async _attachMotion(desc: MotionPreviewDesc): Promise { + /** 先完成所有资源加载,再创建 Motion,避免失败时破坏当前可用预览。 */ + private async _prepareMotion(desc: MotionPreviewDesc): Promise<{ motion: any; loadedClips: Map }> { if (!desc?.motion) { throw new Error(`Motion preview desc is empty, nothing to show.`); } - this._resetMotionPreviewer(); - if (!this.motionPreviewer) { - throw new Error('Motion preview model has not been set.'); - } - - // 先并行加载 Motion 用到的全部动画剪辑,再重建引擎 Motion。 - this.loadedClips.clear(); + const loadedClips = new Map(); await Promise.all( Array.from(new Set(collectClipUuids(desc.motion))) .filter(Boolean) .map(async (clipUuid) => { - try { - this.loadedClips.set(clipUuid, await loadPreviewAsset(clipUuid, 'animation-clip')); - } catch (error) { - console.warn(`[MotionPreview] Failed to load clip ${clipUuid}:`, error); - } + loadedClips.set(clipUuid, await loadPreviewAsset(clipUuid, 'animation-clip')); }), ); - const motion = this._buildMotion(desc.motion); - this.motionPreview = motion; - this.motionPreviewer.setMotion(motion); - this.time = 0; - this._evaluate(); + return { + motion: this._buildMotion(desc.motion, loadedClips), + loadedClips, + }; } - private _resetMotionPreviewer(): void { - this.motionPreview = null; - if (this.motionPreviewer) { - this.motionPreviewer.destroy?.(); - this.motionPreviewer = null; - } - if (!this._modelNode) { - return; + private _createMotionPreviewer(modelNode: Node): any { + const { MotionPreviewer } = getNewGenAnim(); + if (!MotionPreviewer) { + throw new Error('MotionPreviewer is not available in the engine module.'); } - try { - const { MotionPreviewer } = getNewGenAnim(); - if (!MotionPreviewer) { - console.warn('[MotionPreview] MotionPreviewer is not available in the engine module.'); - return; + return new MotionPreviewer(modelNode); + } + + private _configureMotionPreviewer(previewer: any, desc: MotionPreviewDesc): void { + const api = getNewGenAnim(); + for (const variable of desc.variables ?? []) { + if (!variable.name) { + continue; } - this.motionPreviewer = new MotionPreviewer(this._modelNode); - } catch (error) { - console.warn('[MotionPreview] Failed to create MotionPreviewer:', error); + const value = typeof variable.value === 'number' && Number.isFinite(variable.value) ? variable.value : 0; + const description = api.createVariable(api.VariableType.FLOAT, value); + previewer.addVariable(variable.name, description); } } - /** - * 根据中立描述重建引擎 Motion。业务方已把变量绑定解析为静态值 - * (`variable` 字段仅保留展示用),此处直接按值采样。 - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private _buildMotion(node: MotionPreviewDescNode | null | undefined): any { + private _buildMotion(node: MotionPreviewDescNode | null | undefined, loadedClips: Map): any { const api = getNewGenAnim(); if (!node) { return null; @@ -313,17 +328,17 @@ export class MotionPreview extends InteractivePreview { case 'clip': { const clipMotion = new api.ClipMotion(); if (node.clipUuid) { - clipMotion.clip = this.loadedClips.get(node.clipUuid) ?? null; + clipMotion.clip = loadedClips.get(node.clipUuid) ?? null; } return clipMotion; } case 'blend-1d': { const blend = new api.AnimationBlend1D(); blend.param.value = node.value; - blend.param.variable = ''; + blend.param.variable = node.variable ?? ''; blend.items = (node.children ?? []).map((child) => { const item = new api.AnimationBlend1D.Item(); - item.motion = this._buildMotion(child.motion); + item.motion = this._buildMotion(child.motion, loadedClips); item.threshold = child.threshold; return item; }); @@ -332,15 +347,15 @@ export class MotionPreview extends InteractivePreview { case 'blend-2d': { const blend = new api.AnimationBlend2D(); blend.paramX.value = node.valueX; - blend.paramX.variable = ''; + blend.paramX.variable = node.variableX ?? ''; blend.paramY.value = node.valueY; - blend.paramY.variable = ''; + blend.paramY.variable = node.variableY ?? ''; if (typeof node.algorithm === 'number') { blend.algorithm = node.algorithm; } blend.items = (node.children ?? []).map((child) => { const item = new api.AnimationBlend2D.Item(); - item.motion = this._buildMotion(child.motion); + item.motion = this._buildMotion(child.motion, loadedClips); item.threshold.set(child.threshold.x, child.threshold.y); return item; }); @@ -350,7 +365,7 @@ export class MotionPreview extends InteractivePreview { const blend = new api.AnimationBlendDirect(); blend.items = (node.children ?? []).map((child) => { const item = new api.AnimationBlendDirect.Item(); - item.motion = this._buildMotion(child.motion); + item.motion = this._buildMotion(child.motion, loadedClips); item.weight.value = child.weight; item.weight.variable = ''; return item; @@ -366,17 +381,11 @@ export class MotionPreview extends InteractivePreview { if (!this.motionPreviewer) { return; } - try { - this.motionPreviewer.setTime(this.time); - this.motionPreviewer.evaluate(); - } catch (error) { - console.warn('[MotionPreview] evaluate failed:', error); - } + this.motionPreviewer.setTime(this.time); + this.motionPreviewer.evaluate(); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any private async _resolvePrefabUuid(uuid: string): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any const assetInfo = await Rpc.getInstance().request('assetManager', 'queryAssetInfo', [uuid, ['subAssets']]); if (assetInfo?.type === 'cc.Prefab') { return assetInfo.uuid || uuid; diff --git a/src/lib/scene/scene.ts b/src/lib/scene/scene.ts index d4a1bb523..2d0b7ba39 100644 --- a/src/lib/scene/scene.ts +++ b/src/lib/scene/scene.ts @@ -54,8 +54,8 @@ export async function showMotion(desc: MotionPreviewDesc): Promise { } /** 隐藏当前 Motion 预览。 */ -export function hideMotion(): void { - Scene.Preview.hideMotion(); +export function hideMotion(): Promise { + return Scene.Preview.hideMotion(); } /** 为 Motion 预览设置展示模型资源。 @param uuid 模型资源 UUID。 */ @@ -64,33 +64,33 @@ export async function setMotionModel(uuid: string): Promise { } /** 设置 Motion 预览的采样时间。 @param time 时间(秒)。 */ -export function setMotionTime(time: number): void { - Scene.Preview.setMotionTime(time); +export function setMotionTime(time: number): Promise { + return Scene.Preview.setMotionTime(time); } /** 播放 Motion 预览。 */ -export function playMotion(): void { - Scene.Preview.playMotion(); +export function playMotion(): Promise { + return Scene.Preview.playMotion(); } /** 暂停 Motion 预览。 */ -export function pauseMotion(): void { - Scene.Preview.pauseMotion(); +export function pauseMotion(): Promise { + return Scene.Preview.pauseMotion(); } /** 停止 Motion 预览。 */ -export function stopMotion(): void { - Scene.Preview.stopMotion(); +export function stopMotion(): Promise { + return Scene.Preview.stopMotion(); } /** 设置 Motion 预览使用的变量值。 @param name 变量名。 @param value 变量值。 */ -export function setMotionVariable(name: string, value: number): void { - Scene.Preview.setMotionVariable(name, value); +export function setMotionVariable(name: string, value: number): Promise { + return Scene.Preview.setMotionVariable(name, value); } /** 设置 Motion 预览中 Blend 参数的临时值,不回写任何资产。 @param axis Blend 1D 使用 value,Blend 2D 使用 x/y。 @param value 参数值。 */ -export function setMotionParameter(axis: 'value' | 'x' | 'y', value: number): void { - Scene.Preview.setMotionParameter(axis, value); +export function setMotionParameter(axis: 'value' | 'x' | 'y', value: number): Promise { + return Scene.Preview.setMotionParameter(axis, value); } /** 查询 Motion 预览时间轴长度。 @returns 当前 Motion 的时间轴统计,尚未建立预览时返回 null。 */ From 5a5e3188691fc2e687d50c7df8b40133d44a0ac3 Mon Sep 17 00:00:00 2001 From: looopmax Date: Sun, 6 Sep 2026 14:35:22 +0800 Subject: [PATCH 09/11] fix: address PR #2 review issues --- .../__snapshots__/dts-snapshot.test.ts.snap | 42 +++++++------- src/core/assets/animation-graph-service.ts | 3 +- .../asset-handler/assets/sprite-frame.ts | 11 ++-- src/core/assets/image-processing.ts | 26 +++++++++ src/core/assets/manager/asset.ts | 11 +++- src/core/assets/test/image-processing.test.ts | 41 +++++++++++++ .../service/component/polygon-collider-2d.ts | 14 ++++- .../component/polygon-collider-2d/contour.ts | 6 +- .../scene/test/polygon-collider-2d.test.ts | 58 +++++++++++++++++++ 9 files changed, 172 insertions(+), 40 deletions(-) diff --git a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap index d702f9544..1eec59ea4 100644 --- a/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap +++ b/packages/cocos-cli-types/__tests__/__snapshots__/dts-snapshot.test.ts.snap @@ -6738,14 +6738,14 @@ export declare interface ImageMeta { } export declare interface IMotionPreviewService { showMotion(desc: MotionPreviewDesc): Promise; - hideMotion(): void; + hideMotion(): Promise; setMotionModel(uuid: string): Promise; - setMotionTime(time: number): void; - playMotion(): void; - pauseMotion(): void; - stopMotion(): void; - setMotionVariable(name: string, value: number): void; - setMotionParameter(axis: 'value' | 'x' | 'y', value: number): void; + setMotionTime(time: number): Promise; + playMotion(): Promise; + pauseMotion(): Promise; + stopMotion(): Promise; + setMotionVariable(name: string, value: number): Promise; + setMotionParameter(axis: 'value' | 'x' | 'y', value: number): Promise; getMotionTimelineStats(): Promise<{ timeLineLength: number; } | null>; @@ -9011,7 +9011,7 @@ export declare interface GlTFUserData { options: LODsOption[]; }; } -export declare function hideMotion(): void; +export declare function hideMotion(): Promise; export declare namespace i18n { export { i18n_2 as default @@ -9822,8 +9822,8 @@ export declare interface ParticleAssetUserData { rotatePerSVar: number; spriteFrameUuid: string; } -export declare function pauseMotion(): void; -export declare function playMotion(): void; +export declare function pauseMotion(): Promise; +export declare function playMotion(): Promise; export declare interface PluginScriptInfo { file: string; uuid: string; @@ -10083,9 +10083,9 @@ export declare interface SetAnimationGraphInspectorPropertyRequest extends Anima export declare function setCommandProvider(provider: ISceneCommandProvider): SceneCommandProviderRegistration; export declare function setFileSystemProvider(provider: IAssetFileSystemProvider): void; export declare function setMotionModel(uuid: string): Promise; -export declare function setMotionParameter(axis: 'value' | 'x' | 'y', value: number): void; -export declare function setMotionTime(time: number): void; -export declare function setMotionVariable(name: string, value: number): void; +export declare function setMotionParameter(axis: 'value' | 'x' | 'y', value: number): Promise; +export declare function setMotionTime(time: number): Promise; +export declare function setMotionVariable(name: string, value: number): Promise; export declare interface SharedSettings { useDefineForClassFields: boolean; allowDeclareFields: boolean; @@ -10160,7 +10160,7 @@ export declare function startCompileScript(assetChanges?: AssetChangeInfo[]): Pr export declare function startEngineCompilation(force?: boolean): Promise; export declare function startupWorker(projectPath: string): Promise; export declare function stop_2(): Promise; -export declare function stopMotion(): void; +export declare function stopMotion(): Promise; export declare const SUPPORT_CREATE_TYPES: readonly ["animation-clip", "typescript", "auto-atlas", "effect", "scene", "prefab", "material", "texture-cube", "terrain", "physics-material", "label-atlas", "render-texture", "directory", "effect-header"]; export declare enum TangentImportSetting { exclude = 0, @@ -10320,7 +10320,7 @@ exports[`DTS API compatibility scene.d.ts should match snapshot 1`] = ` export declare function getMotionTimelineStats(): Promise<{ timeLineLength: number; } | null>; -export declare function hideMotion(): void; +export declare function hideMotion(): Promise; export declare function init(): Promise; export declare interface ISceneCommandProvider { request(module: string, method: string, args?: any[], options?: SceneCommandRequestOptions): Promise; @@ -10386,8 +10386,8 @@ export declare function onMotionMouseWheel(action: { wheelDeltaY: number; wheelDeltaX: number; }): Promise; -export declare function pauseMotion(): void; -export declare function playMotion(): void; +export declare function pauseMotion(): Promise; +export declare function playMotion(): Promise; export declare function queryMotionImage(info: { width: number; height: number; @@ -10401,12 +10401,12 @@ export declare interface SceneCommandRequestOptions { } export declare function setCommandProvider(provider: ISceneCommandProvider): SceneCommandProviderRegistration; export declare function setMotionModel(uuid: string): Promise; -export declare function setMotionParameter(axis: 'value' | 'x' | 'y', value: number): void; -export declare function setMotionTime(time: number): void; -export declare function setMotionVariable(name: string, value: number): void; +export declare function setMotionParameter(axis: 'value' | 'x' | 'y', value: number): Promise; +export declare function setMotionTime(time: number): Promise; +export declare function setMotionVariable(name: string, value: number): Promise; export declare function showMotion(desc: MotionPreviewDesc): Promise; export declare function startupWorker(projectPath: string): Promise; -export declare function stopMotion(): void; +export declare function stopMotion(): Promise; export declare class WorkerSceneCommandProvider implements ISceneCommandProvider { private readonly rpc; constructor(process: ChildProcess | NodeJS.Process); diff --git a/src/core/assets/animation-graph-service.ts b/src/core/assets/animation-graph-service.ts index 222d1026a..b874a91cd 100644 --- a/src/core/assets/animation-graph-service.ts +++ b/src/core/assets/animation-graph-service.ts @@ -134,7 +134,8 @@ class AnimationGraphAssetService { * * @param uuidOrUrlOrPath - 动画图资源(uuid / url / 路径)。 * @param target - 目标 Motion 的地址,与 Inspector 使用的 `AnimationGraphTarget` 一致。 - * @returns Motion 预览数据;目标不存在时 `motion` 为 null。 + * @returns Motion 预览数据。 + * @throws {AnimationGraphEditError} 目标不存在或目标不是有效 Motion 时抛出 `TARGET_NOT_FOUND`。 * * ```mermaid * flowchart LR diff --git a/src/core/assets/asset-handler/assets/sprite-frame.ts b/src/core/assets/asset-handler/assets/sprite-frame.ts index 800ab6600..7e579894a 100644 --- a/src/core/assets/asset-handler/assets/sprite-frame.ts +++ b/src/core/assets/asset-handler/assets/sprite-frame.ts @@ -8,6 +8,7 @@ import { SpriteFrameBaseAssetUserData, SpriteFrameAssetUserData } from '../../@t import { getTrimRect, getDependUUIDList } from '../utils'; import i18n from '../../../base/i18n'; import { makeDefaultSpriteFrameBaseAssetUserData } from './texture-base'; +import { resolveImageSourceFile } from '../../image-processing'; try { require('sharp'); @@ -172,13 +173,9 @@ export const SpriteFrameHandler: AssetHandler = { if (asset.parent.meta.importer === 'image') { const userData = asset.userData as SpriteFrameBaseAssetUserData; - let file; - // TODO 此处需要更换通用写法,这样容易漏掉一些新格式支持的更新 - // @ts-ignore - if (['.tga', '.hdr', '.bmp', '.exr', '.znt', '.psd'].includes(asset.parent.extname.toLowerCase())) { - file = asset.parent.library + '.png'; - } else { - file = asset.parent.source; + const file = resolveImageSourceFile(asset.parent); + if (!file) { + return false; } const MIN_SIZE = 1; const imageData = await Sharp(file).raw().toBuffer({ resolveWithObject: true }); diff --git a/src/core/assets/image-processing.ts b/src/core/assets/image-processing.ts index 53382f01e..fa5020e66 100644 --- a/src/core/assets/image-processing.ts +++ b/src/core/assets/image-processing.ts @@ -1,4 +1,7 @@ import Sharp from 'sharp'; +import { existsSync } from 'fs-extra'; +import { extname } from 'path'; +import type { Asset, VirtualAsset } from '@cocos/asset-db'; export interface IImagePixelExtractionOptions { rect: { @@ -17,6 +20,29 @@ export interface IExtractedImagePixels { channels: number; } +/** + * 解析资源导入后的图片文件,优先使用 library 中实际存在的导入产物。 + * + * @param asset 图片源资源或其虚拟子资源 + * @returns 可供图片处理器读取的绝对路径;没有可用文件时返回 null + */ +export function resolveImageSourceFile(asset: Asset | VirtualAsset): string | null { + const sourceAsset = asset.parent ?? asset; + const sourceExtension = extname(sourceAsset.source).toLowerCase(); + const extensions = ['.png', sourceExtension].filter((extension, index, all) => ( + extension && all.indexOf(extension) === index && sourceAsset.meta.files.includes(extension) + )); + + for (const extension of extensions) { + const file = sourceAsset.getFilePath(extension); + if (existsSync(file)) { + return file; + } + } + + return existsSync(sourceAsset.source) ? sourceAsset.source : null; +} + /** * 在 Node 进程中读取图片像素。 * diff --git a/src/core/assets/manager/asset.ts b/src/core/assets/manager/asset.ts index 2716d4d4d..dc911cc23 100644 --- a/src/core/assets/manager/asset.ts +++ b/src/core/assets/manager/asset.ts @@ -13,6 +13,7 @@ import * as serializedData from '../serialized-data'; import * as materialService from '../material-service'; import { extractImagePixelsFromFile, + resolveImageSourceFile, type IExtractedImagePixels, type IImagePixelExtractionOptions, } from '../image-processing'; @@ -101,11 +102,15 @@ class AssetManager extends EventEmitter { urlOrUUIDOrPath: string, options: IImagePixelExtractionOptions, ): Promise { - const assetInfo = this.queryAssetInfo(urlOrUUIDOrPath); - if (!assetInfo?.file) { + const asset = this.queryAsset(urlOrUUIDOrPath); + if (!asset) { + return null; + } + const file = resolveImageSourceFile(asset); + if (!file) { return null; } - return extractImagePixelsFromFile(assetInfo.file, options); + return extractImagePixelsFromFile(file, options); } getEffectBinPath() { diff --git a/src/core/assets/test/image-processing.test.ts b/src/core/assets/test/image-processing.test.ts index 7675defb2..15ae0dbcc 100644 --- a/src/core/assets/test/image-processing.test.ts +++ b/src/core/assets/test/image-processing.test.ts @@ -1,5 +1,7 @@ export {}; +import { join } from 'path'; + jest.mock('sharp', () => ({ __esModule: true, default: jest.fn(), @@ -13,6 +15,45 @@ describe('asset image processing', () => { mockSharp.mockReset(); }); + it('prefers an existing imported library image over the source extension', () => { + const libraryImage = join( + __dirname, + '../../../../tests/fixtures/projects/asset-operation/library/b5/b5929d2c-caf4-4454-8f7e-4e84cb5ce144.png', + ); + const asset = { + source: 'D:/project/assets/image.tga', + library: libraryImage.slice(0, -4), + meta: { importer: 'image', files: ['.json', '.png'] }, + parent: null, + getFilePath: (extension: string) => `${libraryImage.slice(0, -4)}${extension}`, + }; + + expect(imageProcessingModule().resolveImageSourceFile(asset as any)).toBe(libraryImage); + }); + + it('resolves an image library output through a virtual image subasset', () => { + const libraryImage = join( + __dirname, + '../../../../tests/fixtures/projects/asset-operation/library/b5/b5929d2c-caf4-4454-8f7e-4e84cb5ce144.png', + ); + const parent = { + source: 'D:/project/assets/image.png', + library: libraryImage.slice(0, -4), + meta: { importer: 'image', files: ['.json', '.png'] }, + parent: null, + getFilePath: (extension: string) => `${libraryImage.slice(0, -4)}${extension}`, + }; + const asset = { + source: 'D:/project/assets/image.png@texture', + library: 'D:/project/library/image@texture', + meta: { importer: 'texture', files: ['.json'] }, + parent, + getFilePath: (extension: string) => `D:/project/library/image@texture${extension}`, + }; + + expect(imageProcessingModule().resolveImageSourceFile(asset as any)).toBe(libraryImage); + }); + it('extracts RGBA pixels in Node and returns JSON-safe Base64 data', async () => { const data = Buffer.from([ 255, 0, 0, 255, diff --git a/src/core/scene/scene-process/service/component/polygon-collider-2d.ts b/src/core/scene/scene-process/service/component/polygon-collider-2d.ts index d6be0dc87..eaa162f97 100644 --- a/src/core/scene/scene-process/service/component/polygon-collider-2d.ts +++ b/src/core/scene/scene-process/service/component/polygon-collider-2d.ts @@ -1,4 +1,5 @@ import { Component, Physics2DUtils, PolygonCollider2D, Sprite, UITransform, Vec2, js } from 'cc'; +import type { SpriteFrame } from 'cc'; import type { IProperty } from '../../../@types/public'; import type { Polygon2DPointsSource } from '../../../common/component'; import type { IExtractedImagePixels } from '../../../../assets/image-processing'; @@ -175,7 +176,7 @@ async function generateSpriteAlphaPolygonPoints( } const spriteFrameUuid = (spriteFrame as { _uuid?: string })._uuid; - const sourceUuid = spriteFrameUuid?.split('@')[0]; + const sourceUuid = resolveSpriteFrameSourceUuid(spriteFrame); if (!sourceUuid) { return null; } @@ -210,8 +211,9 @@ async function generateSpriteAlphaPolygonPoints( const data = decodeImagePixels(imagePixels); - let points = traceAlphaContour(data, imagePixels.width, imagePixels.height, true); - points = simplifyContour(points, collider.threshold); + const contour = traceAlphaContour(data, imagePixels.width, imagePixels.height, true); + const simplified = simplifyContour(contour, collider.threshold); + const points = simplified.length >= 4 ? simplified : contour; if ( points.length > 0 @@ -232,6 +234,12 @@ async function generateSpriteAlphaPolygonPoints( return result; } +function resolveSpriteFrameSourceUuid(spriteFrame: SpriteFrame): string | undefined { + const originalTextureUuid = (spriteFrame.original as { _texture?: { _uuid?: string } } | null | undefined)?._texture?._uuid; + const textureUuid = originalTextureUuid ?? (spriteFrame.texture as { _uuid?: string } | null | undefined)?._uuid; + return textureUuid?.split('@')[0]; +} + function hasUsableTransform(transform: UITransform | null): transform is UITransform { return !!transform && !( transform.contentSize.width === 0 diff --git a/src/core/scene/scene-process/service/component/polygon-collider-2d/contour.ts b/src/core/scene/scene-process/service/component/polygon-collider-2d/contour.ts index ef79d33a3..e87906ccd 100644 --- a/src/core/scene/scene-process/service/component/polygon-collider-2d/contour.ts +++ b/src/core/scene/scene-process/service/component/polygon-collider-2d/contour.ts @@ -51,15 +51,11 @@ export function traceAlphaContour( return []; } - if (x >= 0 && x <= width && y >= 0 && y <= height) { + if (x >= 0 && x <= width && y >= 0 && y <= height && (loop || x !== start.x || y !== start.y)) { points.push({ x, y }); } } while (x !== start.x || y !== start.y); - if (loop) { - points.push({ x, y }); - } - return points; } diff --git a/src/core/scene/test/polygon-collider-2d.test.ts b/src/core/scene/test/polygon-collider-2d.test.ts index 1158f9d5f..c6871cd34 100644 --- a/src/core/scene/test/polygon-collider-2d.test.ts +++ b/src/core/scene/test/polygon-collider-2d.test.ts @@ -41,6 +41,8 @@ class MockSprite extends MockComponent { class MockSpriteFrame { _uuid = 'texture-uuid@spriteFrame'; + texture = { _uuid: 'texture-uuid@texture' }; + original: { _texture: { _uuid: string } } | null = null; constructor( private readonly rect = { x: 0, y: 0, width: 2, height: 2 }, @@ -202,6 +204,62 @@ describe('PolygonCollider2D regeneration helpers', () => { expect(collider.points).toEqual(oldPoints); }); + it('resolves the original texture UUID when a SpriteFrame is packed into a dynamic atlas', async () => { + const node = new MockNode(); + node.attach(new MockUITransform()); + const sprite = node.attach(new MockSprite()); + sprite.spriteFrame = new MockSpriteFrame(); + sprite.spriteFrame._uuid = 'sprite-frame-uuid@frame'; + sprite.spriteFrame.texture = { _uuid: 'dynamic-atlas-uuid@texture' }; + sprite.spriteFrame.original = { _texture: { _uuid: 'texture-uuid@texture' } }; + const collider = node.attach(new MockPolygonCollider2D()); + collider.threshold = 0; + mockAssetRequest.mockResolvedValue({ + dataBase64: Buffer.from(new Uint8Array(2 * 2 * 4)).toString('base64'), + width: 2, + height: 2, + channels: 4, + }); + + await polygonModule().generatePolygonPoints(collider as any); + + expect(mockAssetRequest).toHaveBeenCalledWith('assetManager', 'extractImagePixels', [ + 'texture-uuid', + expect.any(Object), + ]); + }); + + it('keeps one closing point for a thin opaque contour', () => { + const { traceAlphaContour } = require('../scene-process/service/component/polygon-collider-2d/contour'); + const rgba = new Uint8Array(4); + rgba[3] = 255; + + const contour = traceAlphaContour(rgba, 1, 1, true); + + expect(contour[0]).toEqual(contour[contour.length - 1]); + expect(contour[contour.length - 2]).not.toEqual(contour[0]); + }); + + it('initializes a thin Sprite Alpha collider with at least three points at the default threshold', async () => { + const node = new MockNode(); + node.attach(new MockUITransform()); + const sprite = node.attach(new MockSprite()); + sprite.spriteFrame = new MockSpriteFrame({ x: 0, y: 0, width: 1, height: 1 }); + const collider = node.attach(new MockPolygonCollider2D()); + const originalPoints = collider.points; + mockAssetRequest.mockResolvedValue({ + dataBase64: Buffer.from([0, 0, 0, 255]).toString('base64'), + width: 1, + height: 1, + channels: 4, + }); + + await polygonModule().initializePolygonCollider2DPoints(collider as any); + + expect(collider.points).not.toBe(originalPoints); + expect(collider.points.length).toBeGreaterThanOrEqual(3); + }); + it('keeps the Editor Marching Squares and RDP behavior in pure helpers', () => { const { traceAlphaContour } = require('../scene-process/service/component/polygon-collider-2d/contour'); const { simplifyContour } = require('../scene-process/service/component/polygon-collider-2d/simplify'); From 766bc7ef284dfe545ca6345232c98e7f4122e2b3 Mon Sep 17 00:00:00 2001 From: looopmax Date: Sun, 6 Sep 2026 15:07:50 +0800 Subject: [PATCH 10/11] fix: preserve sprite frame UUID fallback --- .../scene-process/service/component/polygon-collider-2d.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/scene/scene-process/service/component/polygon-collider-2d.ts b/src/core/scene/scene-process/service/component/polygon-collider-2d.ts index eaa162f97..2f68d899f 100644 --- a/src/core/scene/scene-process/service/component/polygon-collider-2d.ts +++ b/src/core/scene/scene-process/service/component/polygon-collider-2d.ts @@ -237,7 +237,8 @@ async function generateSpriteAlphaPolygonPoints( function resolveSpriteFrameSourceUuid(spriteFrame: SpriteFrame): string | undefined { const originalTextureUuid = (spriteFrame.original as { _texture?: { _uuid?: string } } | null | undefined)?._texture?._uuid; const textureUuid = originalTextureUuid ?? (spriteFrame.texture as { _uuid?: string } | null | undefined)?._uuid; - return textureUuid?.split('@')[0]; + const spriteFrameUuid = (spriteFrame as { _uuid?: string })._uuid; + return (textureUuid ?? spriteFrameUuid)?.split('@')[0]; } function hasUsableTransform(transform: UITransform | null): transform is UITransform { From 0cd49e38370f02f1f6457a14cff551d1384ac79d Mon Sep 17 00:00:00 2001 From: looopmax Date: Mon, 7 Sep 2026 12:59:32 +0800 Subject: [PATCH 11/11] docs: remove obsolete animation graph data structure docs animation-graph-data-structure docs are no longer referenced or maintained; delete the stale docs/zh markdown and generated HTML export to avoid doc drift. --- docs/zh/animation-graph-data-structure.html | 1276 ------------------- docs/zh/animation-graph-data-structure.md | 875 ------------- 2 files changed, 2151 deletions(-) delete mode 100644 docs/zh/animation-graph-data-structure.html delete mode 100644 docs/zh/animation-graph-data-structure.md diff --git a/docs/zh/animation-graph-data-structure.html b/docs/zh/animation-graph-data-structure.html deleted file mode 100644 index 605689a2d..000000000 --- a/docs/zh/animation-graph-data-structure.html +++ /dev/null @@ -1,1276 +0,0 @@ - - - - - -cocos-cli Animation Graph 数据结构 - - - - -
-
-

cocos-cli — Animation Graph 数据结构

-
基于 Mermaid UML 描述 cocos-cli 中动画图(Animation Graph)的完整数据模型:版本与文档管理、寻址体系、视图快照、Pose 图、Inspector 与命令体系,以及内部文档模型与变体结构。
-
- 12 张 UML 图 - 类型层 public.d.ts - 服务层 animation-graph-service.ts - 变体 animation-graph-variant.ts -
-
-
- -
- - -
-

cocos-cli Animation Graph 数据结构

-
本文档梳理 cocos-cli 中与 Animation Graph(动画图) 相关的全部数据结构,使用 Mermaid UML 图描述类型关系,并在各节配以字段速查表。 - 类型层(TypeScript .d.ts):src/core/assets/@types/public.d.ts - 服务层:src/core/assets/animation-graph-service.tssrc/core/assets/animation-graph-variant.ts - 处理器层:src/core/assets/asset-handler/assets/animation-graph.tsanimation-graph-variant.ts - API Schema:src/api/assets/schema.ts
-
-

1 概览

-

1.1 相关代码文件

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
文件角色
src/core/assets/@types/public.d.ts公共类型定义(版本、寻址、视图快照、命令、Inspector)
src/core/assets/animation-graph-service.ts动画图编辑服务(文档缓存、查询、命令执行、Inspector)
src/core/assets/animation-graph-variant.ts动画图变体服务(解读 / 修改 / 保存变体资产)
src/core/assets/asset-handler/assets/animation-graph.ts.animgraph 资源处理器(importer animation-graph
src/core/assets/asset-handler/assets/animation-graph-variant.ts.animgraphvari 资源处理器(importer animation-graph-variant
src/api/assets/schema.tsZod 校验 Schema(变体 dump 的 API 参数/结果契约)
- -

1.2 组件总览图

- -
-
Diagram 1

组件总览图

-
资源处理器
AnimationGraphHandler (.animgraph)
AnimationGraphVariantHandler (.animgraphvari)
API Schema (src/api/assets/schema.ts)
SchemaAnimationGraphVariantDump
服务内部结构 (animation-graph-service.ts)
AnimationGraphDocument
SourceFingerprint / InspectorBinding / AdapterProperty
变体结构 (animation-graph-variant.ts)
AnimGraphVariantDump
PendingAnimationGraphVariantEdit
公共类型层 public.d.ts
版本与文档管理
ExpectedVersion / Version / Snapshot / Event
寻址体系
Context / Address / Target
视图快照
ViewDump / Layer / StateMachine / State / Transition / Motion
Pose 图视图
PoseView / PoseNode / PoseInput
Inspector
InspectorSnapshot / Command
AnimationGraphAssetService
动画图编辑服务
AnimationGraphVariantAssetService
-
查看 Mermaid 源码
flowchart TB
-    subgraph PUBLIC["公共类型层 public.d.ts"]
-        P1["版本与文档管理<br/>ExpectedVersion / Version / Snapshot / Event"]
-        P2["寻址体系<br/>Context / Address / Target"]
-        P3["视图快照<br/>ViewDump / Layer / StateMachine / State / Transition / Motion"]
-        P4["Pose 图视图<br/>PoseView / PoseNode / PoseInput"]
-        P5["Inspector<br/>InspectorSnapshot / Command"]
-    end
-
-    subgraph VAR["变体结构 (animation-graph-variant.ts)"]
-        V1["AnimGraphVariantDump"]
-        V2["PendingAnimationGraphVariantEdit"]
-    end
-
-    subgraph INTERNAL["服务内部结构 (animation-graph-service.ts)"]
-        I1["AnimationGraphDocument"]
-        I2["SourceFingerprint / InspectorBinding / AdapterProperty"]
-    end
-
-    subgraph SCHEMA["API Schema (src/api/assets/schema.ts)"]
-        S1["SchemaAnimationGraphVariantDump"]
-    end
-
-    subgraph HANDLER["资源处理器"]
-        H1["AnimationGraphHandler (.animgraph)"]
-        H2["AnimationGraphVariantHandler (.animgraphvari)"]
-    end
-
-    SERVICE["AnimationGraphAssetService<br/>动画图编辑服务"] --> PUBLIC
-    SERVICE --> INTERNAL
-    H1 --> SERVICE
-    H2 --> VARSERVICE["AnimationGraphVariantAssetService"]
-    VARSERVICE --> VAR
-    VAR --> SCHEMA
-

-

2 公共类型层(public.d.ts)

-

2.1 版本与文档管理

-

AnimationGraphExpectedVersion 是乐观并发控制的最小单元;AnimationGraphVersion 追加持久化 / 脏标记状态;AnimationGraphSnapshotquery / execute / save / reload 返回的统一快照。AnimationGraphChangedEvent 用于向监听者广播变更。

- -
-
Diagram 2

版本与文档管理

-
继承
继承
携带
携带
包含
1
1
AnimationGraphExpectedVersion
+string documentId
+number revision
AnimationGraphVersion
+number persistedRevision
+boolean dirty
+boolean externallyModified
AnimationGraphSnapshot
+string uuid
+string url
+AnimationGraphViewDump graph
AnimationGraphChangedEvent
+string uuid
+string reason
+AnimationGraphVersion version
+string sourceId
+string[] changedPaths
ReloadAnimationGraphOptions
+AnimationGraphExpectedVersion expected
+boolean discardDirty
AnimationGraphEditErrorCode (枚举)
+VERSION_CONFLICT
+DOCUMENT_RELOADED
+SOURCE_CHANGED
+TARGET_NOT_FOUND
+UNSUPPORTED_TARGET
+UNSUPPORTED_PROPERTY_OPERATION
+INVALID_PROPERTY_PATCH
+READONLY_PROPERTY
+NAME_CONFLICT
+DIRTY_DOCUMENT
AnimationGraphViewDump
+AnimationGraphLayerView[] layers
+AnimationGraphVariableView[] variables
-
查看 Mermaid 源码
classDiagram
-    direction LR
-
-    class AVExpected["AnimationGraphExpectedVersion"] {
-        +string documentId
-        +number revision
-    }
-
-    class AVVersion["AnimationGraphVersion"] {
-        +number persistedRevision
-        +boolean dirty
-        +boolean externallyModified
-    }
-
-    class AVSnapshot["AnimationGraphSnapshot"] {
-        +string uuid
-        +string url
-        +AnimationGraphViewDump graph
-    }
-
-    class AVEvent["AnimationGraphChangedEvent"] {
-        +string uuid
-        +string reason
-        +AnimationGraphVersion version
-        +string sourceId
-        +string[] changedPaths
-    }
-
-    class AVReloadOpts["ReloadAnimationGraphOptions"] {
-        +AnimationGraphExpectedVersion expected
-        +boolean discardDirty
-    }
-
-    class AVErrorCode["AnimationGraphEditErrorCode (枚举)"] {
-        +VERSION_CONFLICT
-        +DOCUMENT_RELOADED
-        +SOURCE_CHANGED
-        +TARGET_NOT_FOUND
-        +UNSUPPORTED_TARGET
-        +UNSUPPORTED_PROPERTY_OPERATION
-        +INVALID_PROPERTY_PATCH
-        +READONLY_PROPERTY
-        +NAME_CONFLICT
-        +DIRTY_DOCUMENT
-    }
-
-    class AVViewDump["AnimationGraphViewDump"] {
-        +AnimationGraphLayerView[] layers
-        +AnimationGraphVariableView[] variables
-    }
-
-    AVExpected <|-- AVVersion : 继承
-    AVVersion <|-- AVSnapshot : 继承
-    AVEvent --> AVVersion : 携带
-    AVSnapshot --> AVVersion : 携带
-    AVSnapshot "1" *-- "1" AVViewDump : 包含
-

字段速查

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
类型字段类型说明
AnimationGraphExpectedVersiondocumentIdstring文档唯一 ID(每次加载重新生成)
revisionnumber已提交修改的版本号
AnimationGraphVersionpersistedRevisionnumber已持久化版本号
dirtyboolean是否有未保存修改
externallyModifiedboolean源文件是否被外部改动
AnimationGraphSnapshotuuid / urlstring资产标识
graphAnimationGraphViewDump完整视图快照
AnimationGraphChangedEventreason'inspector' | 'structure' | 'save' | 'reload' | 'external'变更原因
sourceId? / changedPaths?string / string[]变更来源与路径
- -
-

2.2 寻址体系:Context / Address / Target

-

动画图由 Layer(层)→ StateMachine(状态机)→ State(状态)/ Transition(过渡)→ Motion(动作),以及独立的 Pose Graph(姿态图)→ PoseNode + 内嵌状态机 组成。为了让 Inspector / 命令能够唯一定位任意节点,cocos-cli 定义了一套上下文 → 地址 → 目标的三角寻址体系。

-
说明:图中的继承箭头表示「类型组合/扩展」关系(TypeScript 交叉类型语义),note 块给出判别联合(discriminated union)的 kind 成员。
- -
-
Diagram 3

寻址体系:Context / Address / Target

-
引用解析
引用解析
扩展
扩展
扩展
扩展
组成
组成
组成
组成
«判别联合»
AnimationGraphStateMachineContext
+layer-state-machine kind
+pose-node-state-machine kind
+sub-state-machine kind
«判别联合»
AnimationGraphPoseGraphContext
+state-pose-graph kind
+layer-stash kind
«判别联合»
AnimationGraphStateMachineAddress
+直接形式(layerIndex + stateMachinePath)
+上下文形式(stateMachine 引用)
AnimationGraphStateAddress
+number stateIndex
«判别联合»
AnimationGraphPoseGraphAddress
+直接形式(layerIndex + stateMachinePath + stateIndex)
+上下文形式(poseGraph 引用)
AnimationGraphPoseNodeAddress
+number nodeId
AnimationGraphMotionAddress
+number[] level 层级路径
«判别联合»
AnimationGraphTarget
+layer 目标
+state 目标
+transition 目标
+motion 目标
+pose-node 目标
+pose-input 目标
+state-component 目标
1) kind=layer-state-machine: { layerIndex, stateMachinePath[] }
2) kind=pose-node-state-machine: { poseGraph, nodeId }
3) kind=sub-state-machine: { stateMachine, stateIndex }
1) kind=state-pose-graph: { stateMachine, stateIndex }
2) kind=layer-stash: { layerIndex, stashName }
-
查看 Mermaid 源码
classDiagram
-    direction TB
-
-    class CMSctx["AnimationGraphStateMachineContext"] {
-        <<判别联合>>
-        +layer-state-machine kind
-        +pose-node-state-machine kind
-        +sub-state-machine kind
-    }
-
-    class CPGctx["AnimationGraphPoseGraphContext"] {
-        <<判别联合>>
-        +state-pose-graph kind
-        +layer-stash kind
-    }
-
-    note for CMSctx "1) kind=layer-state-machine: { layerIndex, stateMachinePath[] }<br/>2) kind=pose-node-state-machine: { poseGraph, nodeId }<br/>3) kind=sub-state-machine: { stateMachine, stateIndex }"
-    note for CPGctx "1) kind=state-pose-graph: { stateMachine, stateIndex }<br/>2) kind=layer-stash: { layerIndex, stashName }"
-
-    class ASMAddr["AnimationGraphStateMachineAddress"] {
-        <<判别联合>>
-        +直接形式 (layerIndex + stateMachinePath)
-        +上下文形式 (stateMachine 引用)
-    }
-
-    class AStateAddr["AnimationGraphStateAddress"] {
-        +number stateIndex
-    }
-
-    class APGAddr["AnimationGraphPoseGraphAddress"] {
-        <<判别联合>>
-        +直接形式 (layerIndex + stateMachinePath + stateIndex)
-        +上下文形式 (poseGraph 引用)
-    }
-
-    class APNAddr["AnimationGraphPoseNodeAddress"] {
-        +number nodeId
-    }
-
-    class AMotionAddr["AnimationGraphMotionAddress"] {
-        +number[] level 层级路径
-    }
-
-    class ATarget["AnimationGraphTarget"] {
-        <<判别联合>>
-        +layer 目标
-        +state 目标
-        +transition 目标
-        +motion 目标
-        +pose-node 目标
-        +pose-input 目标
-        +state-component 目标
-    }
-
-    CMSctx ..> ASMAddr : 引用解析
-    CPGctx ..> APGAddr : 引用解析
-    ASMAddr <|-- AStateAddr : 扩展
-    APGAddr <|-- APNAddr : 扩展
-    AStateAddr <|-- AMotionAddr : 扩展
-    APNAddr <|-- AMotionAddr : 扩展
-    AStateAddr <|-- ATarget : 组成
-    ASMAddr <|-- ATarget : 组成
-    APNAddr <|-- ATarget : 组成
-    AMotionAddr <|-- ATarget : 组成
-

字段速查(关键联合成员)

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
类型成员结构
AnimationGraphStateMachineContextlayer-state-machine{ kind, layerIndex, stateMachinePath: number[] }
pose-node-state-machine{ kind, poseGraph, nodeId }
sub-state-machine{ kind, stateMachine, stateIndex }
AnimationGraphPoseGraphContextstate-pose-graph{ kind, stateMachine, stateIndex }
layer-stash{ kind, layerIndex, stashName }
AnimationGraphTargetlayer{ kind, layerIndex }
state{ kind } & StateAddress
transition{ kind, transitionIndex } & StateMachineAddress
motion{ kind } & MotionAddress
pose-node{ kind } & PoseNodeAddress
pose-input{ kind, inputId } & PoseNodeAddress
state-component{ kind, componentIndex } & StateAddress
- -
-

2.3 视图快照体系

-

服务端将引擎运行时对象投影为只读视图(View),供 Webview / Inspector 消费。树形关系如下:

-
    -
  • AnimationGraphViewDump(整图)→ 多个 LayerView
  • -
  • LayerViewStateMachineViewStateMachineView → 多个 StateViewTransitionView
  • -
  • StateView 可递归内嵌 StateMachineView(子状态机)、携带 MotionView(动作状态)、或挂 PoseView(过程姿态状态)
  • -
  • MotionView 递归包含子 MotionView(1D/2D 混合)
  • -
- -
-
Diagram 4

视图快照体系

-
1
many
1
many
stateMachine
1
many
states
1
many
transitions
1
many
motion
1
0..1
子状态机
1
0..1
poseGraph
1
0..1
components
1
many
conditions
1
many
children 递归
1
many
AnimationGraphViewDump
+AnimationGraphLayerView[] layers
+AnimationGraphVariableView[] variables
AnimationGraphLayerView
+number index
+string name
+number weight
+boolean additive
+string maskUuid
+string[] stashes
+stashPoseGraphs
+AnimationGraphStateMachineView stateMachine
AnimationGraphStateMachineView
+context
+number[] path
+boolean allowEmptyStates
+AnimationGraphStateView[] states
+AnimationGraphTransitionView[] transitions
+editorData
AnimationGraphStateView
+number index
+type type
+string name
+number[] incomingTransitionIndices
+number[] outgoingTransitionIndices
+components
+speed
+speedMultiplier
+speedMultiplierEnabled
+motion
+stateMachine
+poseGraph
+editorData
AnimationGraphTransitionView
+number index
+type type
+number fromStateIndex
+number toStateIndex
+number priority
+conditions
+duration
+relativeDuration
+exitConditionEnabled
+exitCondition
+destinationStart
+relativeDestinationStart
+startEvent
+endEvent
«判别联合»
AnimationGraphTransitionConditionView
+BinaryCondition
+UnaryCondition
+TriggerCondition
+Unknown
AnimationGraphComponentView
+number index
+string type
AnimationGraphMotionView
+number[] level
+target
+string name
+clipUuid
+variable / value
+variableX / valueX
+variableY / valueY
+threshold
+weight
+children
+type(clip/blend-1d/blend-2d/blend-direct/unknown)
AnimationGraphVariableView
+string name
+number type
+IProperty value
+resetMode
AnimationGraphPoseView
-
查看 Mermaid 源码
classDiagram
-    direction TB
-
-    class AViewDump["AnimationGraphViewDump"] {
-        +AnimationGraphLayerView[] layers
-        +AnimationGraphVariableView[] variables
-    }
-
-    class ALayer["AnimationGraphLayerView"] {
-        +number index
-        +string name
-        +number weight
-        +boolean additive
-        +string maskUuid
-        +string[] stashes
-        +stashPoseGraphs
-        +AnimationGraphStateMachineView stateMachine
-    }
-
-    class ASM["AnimationGraphStateMachineView"] {
-        +context
-        +number[] path
-        +boolean allowEmptyStates
-        +AnimationGraphStateView[] states
-        +AnimationGraphTransitionView[] transitions
-        +editorData
-    }
-
-    class AState["AnimationGraphStateView"] {
-        +number index
-        +type type
-        +string name
-        +number[] incomingTransitionIndices
-        +number[] outgoingTransitionIndices
-        +components
-        +speed
-        +speedMultiplier
-        +speedMultiplierEnabled
-        +motion
-        +stateMachine
-        +poseGraph
-        +editorData
-    }
-
-    class ATrans["AnimationGraphTransitionView"] {
-        +number index
-        +type type
-        +number fromStateIndex
-        +number toStateIndex
-        +number priority
-        +conditions
-        +duration
-        +relativeDuration
-        +exitConditionEnabled
-        +exitCondition
-        +destinationStart
-        +relativeDestinationStart
-        +startEvent
-        +endEvent
-    }
-
-    class ACond["AnimationGraphTransitionConditionView"] {
-        <<判别联合>>
-        +BinaryCondition
-        +UnaryCondition
-        +TriggerCondition
-        +Unknown
-    }
-
-    class AComp["AnimationGraphComponentView"] {
-        +number index
-        +string type
-    }
-
-    class AMotion["AnimationGraphMotionView"] {
-        +number[] level
-        +target
-        +type (clip/blend-1d/blend-2d/blend-direct/unknown)
-        +string name
-        +clipUuid
-        +variable / value
-        +variableX / valueX
-        +variableY / valueY
-        +threshold
-        +weight
-        +children
-    }
-
-    class AVar["AnimationGraphVariableView"] {
-        +string name
-        +number type
-        +IProperty value
-        +resetMode
-    }
-
-    class APose["AnimationGraphPoseView"]
-
-    AViewDump "1" *-- "many" ALayer
-    AViewDump "1" *-- "many" AVar
-    ALayer "1" *-- "many" ASM : stateMachine
-    ASM "1" *-- "many" AState : states
-    ASM "1" *-- "many" ATrans : transitions
-    AState "1" o-- "0..1" AMotion : motion
-    AState "1" o-- "0..1" ASM : 子状态机
-    AState "1" o-- "0..1" APose : poseGraph
-    AState "1" o-- "many" AComp : components
-    ATrans "1" *-- "many" ACond : conditions
-    AMotion "1" *-- "many" AMotion : children 递归
-

字段速查

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
类型关键字段
AnimationGraphViewDumplayers[]variables[]
AnimationGraphLayerViewindexnameweightadditivemaskUuid: string|nullstashes: string[]stashPoseGraphs[]stateMachine
AnimationGraphStateMachineViewcontextpath: number[]allowEmptyStatesstates[]transitions[]editorData?
AnimationGraphStateViewindextypeentry/exit/any/motion/empty/sub-state-machine/procedural-pose/unknown)、nameincoming/outgoingTransitionIndices[]components[]speed?speedMultiplier?speedMultiplierEnabled?motion?stateMachine?poseGraph?editorData?
AnimationGraphTransitionViewindextypeanimation/empty-state/procedural-pose/transition)、fromStateIndextoStateIndexpriorityconditions[]duration?relativeDuration?exitConditionEnabled?exitCondition?destinationStart?relativeDestinationStart?startEvent?endEvent?
AnimationGraphTransitionConditionView判别联合:Binary / Unary / Trigger / Unknown,见下
AnimationGraphComponentViewindextype
AnimationGraphMotionViewlevel[]targettypenameclipUuid?variable?/value?variableX?/valueX?variableY?/valueY?threshold?weight?children?editorData?
AnimationGraphVariableViewnametype: numbervalue: IPropertyresetMode?: number(Trigger 类型才有)
- -

过渡条件(TransitionConditionView)判别联合成员

- - - - - - - - - - - - - - - - - - - - - - - -
type字段
BinaryConditionindexoperatorlhslhsBindingbindingClassrhsisRhsInteger
UnaryConditionindexoperatoroperand
TriggerConditionindextrigger
UnknownindexclassName
- -
-

2.4 Pose 图视图体系

-

Pose 图是一张节点连通图:根输出节点引出,节点间通过输入/输出端口相连;节点可内嵌状态机或动作(Motion),也可以作为 Stash 入口。

- -
-
Diagram 5

Pose 图视图体系

-
nodes
1
many
addNodeInfos
1
many
assetDragHandlersMap
1
many
inputs
1
many
enterInfo
1
0..1
内嵌状态机
1
0..1
内嵌动作
1
0..1
handlers
1
many
handlers
1
many
AnimationGraphPoseView
+context
+number rootOutputNodeId
+AnimationGraphPoseNodeView[] nodes
+addNodeInfos
+assetDragHandlersMap
AnimationGraphPoseNodeView
+number id
+string type
+string title
+number[] outputTypes
+AnimationGraphPoseInputView[] inputs
+inputInsertInfos
+stateMachine
+motion
+enterInfo
+editorData
AnimationGraphPoseInputView
+string id
+string displayName
+number type
+boolean deletable
+boolean insertPoint
+boolean connected
+producerNodeId
+producerOutputId
+value
AnimationGraphPoseNodeEnterInfo
+stashName
+type(state-machine/animation-blend/stash)
AnimationGraphPoseGraphAddNodeInfo
+string typeId
+args
+string menu
AnimationGraphPoseGraphAssetDragHandlersView
+handlers
AnimationGraphPoseGraphAssetDragHandlerView
+string displayName
AnimationGraphPoseGraphAssetDragHandlersEntry
+string assetType
+handlers
AnimationGraphPoseGraphAssetDragHandlerInfo
+string id
+string displayName
AnimationGraphStateMachineView
AnimationGraphMotionView
-
查看 Mermaid 源码
classDiagram
-    direction TB
-
-    class APoseView["AnimationGraphPoseView"] {
-        +context
-        +number rootOutputNodeId
-        +AnimationGraphPoseNodeView[] nodes
-        +addNodeInfos
-        +assetDragHandlersMap
-    }
-
-    class APoseNode["AnimationGraphPoseNodeView"] {
-        +number id
-        +string type
-        +string title
-        +number[] outputTypes
-        +AnimationGraphPoseInputView[] inputs
-        +inputInsertInfos
-        +stateMachine
-        +motion
-        +enterInfo
-        +editorData
-    }
-
-    class APoseInput["AnimationGraphPoseInputView"] {
-        +string id
-        +string displayName
-        +number type
-        +boolean deletable
-        +boolean insertPoint
-        +boolean connected
-        +producerNodeId
-        +producerOutputId
-        +value
-    }
-
-    class AEnterInfo["AnimationGraphPoseNodeEnterInfo"] {
-        +type (state-machine/animation-blend/stash)
-        +stashName
-    }
-
-    class AAddNode["AnimationGraphPoseGraphAddNodeInfo"] {
-        +string typeId
-        +args
-        +string menu
-    }
-
-    class ADragView["AnimationGraphPoseGraphAssetDragHandlersView"] {
-        +handlers
-    }
-
-    class ADragHandler["AnimationGraphPoseGraphAssetDragHandlerView"] {
-        +string displayName
-    }
-
-    class ADragEntry["AnimationGraphPoseGraphAssetDragHandlersEntry"] {
-        +string assetType
-        +handlers
-    }
-
-    class ADragInfo["AnimationGraphPoseGraphAssetDragHandlerInfo"] {
-        +string id
-        +string displayName
-    }
-
-    class ASM["AnimationGraphStateMachineView"]
-    class AMotion["AnimationGraphMotionView"]
-
-    APoseView "1" *-- "many" APoseNode : nodes
-    APoseView "1" *-- "many" AAddNode : addNodeInfos
-    APoseView "1" *-- "many" ADragView : assetDragHandlersMap
-    APoseNode "1" *-- "many" APoseInput : inputs
-    APoseNode "1" o-- "0..1" AEnterInfo : enterInfo
-    APoseNode "1" o-- "0..1" ASM : 内嵌状态机
-    APoseNode "1" o-- "0..1" AMotion : 内嵌动作
-    ADragView "1" *-- "many" ADragHandler : handlers
-    ADragEntry "1" *-- "many" ADragInfo : handlers
-

字段速查

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
类型关键字段
AnimationGraphPoseViewcontextrootOutputNodeId: numbernodes[]addNodeInfos[]assetDragHandlersMap
AnimationGraphPoseNodeViewidtypetitleoutputTypes: number[]inputs[]inputInsertInfosstateMachine?motion?enterInfo?editorData?
AnimationGraphPoseInputViewiddisplayNametypedeletableinsertPointconnectedproducerNodeId?producerOutputId?value?: IProperty
AnimationGraphPoseNodeEnterInfotype: 'state-machine' | 'animation-blend' | 'stash'stashName?
AnimationGraphPoseGraphAddNodeInfotypeIdargs: unknownmenu(面包屑式路径)
AnimationGraphPoseGraphAssetDragHandlersView/Entryhandlers(按 handlerId 索引) / assetType + handlers[]
- -
-

2.5 Inspector 快照与命令

-

Inspector 通过「目标 + 属性路径」读写属性;每次操作都携带 expected 版本做乐观并发控制。

- -
-
Diagram 6

Inspector 快照与命令

-
propertyCapabilities
1
1
target
1
1
继承扩展
携带命令
1
1
AnimationGraphInspectorSnapshot
+string uuid
+AnimationGraphTarget target
+IProperty dump
+propertyCapabilities
AnimationGraphInspectorPropertyCapabilities
+boolean set
+boolean reset
+boolean create
AnimationGraphInspectorPropertyOperationRequest
+AnimationGraphTarget target
+string path
+AnimationGraphExpectedVersion expected
+string sourceId
SetAnimationGraphInspectorPropertyRequest
+patch(IProperty or unknown)
ExecuteAnimationGraphCommandRequest
+AnimationGraphCommand command
+AnimationGraphExpectedVersion expected
+string sourceId
«判别联合»
AnimationGraphTarget
+kind
AnimationGraphCommand
+string type
dump 为序列化后的属性描述树(IProperty);propertyCapabilities 描述 property 支持 set/reset/create 的能力
Inspector 属性设置请求 = 基础请求 + patch
-
查看 Mermaid 源码
classDiagram
-    direction TB
-
-    class AInspSnap["AnimationGraphInspectorSnapshot"] {
-        +string uuid
-        +AnimationGraphTarget target
-        +IProperty dump
-        +propertyCapabilities
-    }
-
-    class AInspCap["AnimationGraphInspectorPropertyCapabilities"] {
-        +boolean set
-        +boolean reset
-        +boolean create
-    }
-
-    class AInspReq["AnimationGraphInspectorPropertyOperationRequest"] {
-        +AnimationGraphTarget target
-        +string path
-        +AnimationGraphExpectedVersion expected
-        +string sourceId
-    }
-
-    class ASetReq["SetAnimationGraphInspectorPropertyRequest"] {
-        +patch (IProperty or unknown)
-    }
-
-    class AExecReq["ExecuteAnimationGraphCommandRequest"] {
-        +AnimationGraphCommand command
-        +AnimationGraphExpectedVersion expected
-        +string sourceId
-    }
-
-    note for AInspSnap "dump 为序列化后的属性描述树(IProperty);propertyCapabilities 描述 property 支持 set/reset/create 的能力"
-    note for ASetReq "Inspector 属性设置请求 = 基础请求 + patch"
-
-    class ATarget["AnimationGraphTarget"] {
-        <<判别联合>>
-        +kind
-    }
-
-    class ACmd["AnimationGraphCommand"] {
-        +string type
-    }
-
-    AInspSnap "1" *-- "1" AInspCap : propertyCapabilities
-    AInspSnap "1" --> "1" ATarget : target
-    AInspReq <|-- ASetReq : 继承扩展
-    AExecReq "1" --> "1" ACmd : 携带命令
-

字段速查

- - - - - - - - - - - - - - - - - - - - - - - - - - - -
类型说明
AnimationGraphInspectorSnapshotuuidtargetdump: IPropertypropertyCapabilities?
AnimationGraphInspectorPropertyCapabilitiesset / reset / create: boolean
AnimationGraphInspectorPropertyOperationRequesttargetpathexpectedsourceId?
SetAnimationGraphInspectorPropertyRequest基础请求 + patch: IProperty | unknown
ExecuteAnimationGraphCommandRequestcommandexpectedsourceId?
- -
-

2.6 命令体系:AnimationGraphCommand

-

AnimationGraphCommandtype 判别的大型联合类型,覆盖 Layer / State / Transition / Motion / StateComponent / Pose / Variable / Stash 的增删改。所有命令通过 execute() 在服务端执行,并推进文档 revision

- -
-
Diagram 7

命令体系:AnimationGraphCommand

-
成员
1
many
成员
1
many
成员
1
many
成员
1
many
成员
1
many
成员
1
many
AnimationGraphCommand
+string type 判别字段
Layer / Stash 层领域
+add-layer
+remove-layer
+move-layer
+add-stash
+remove-stash
+rename-stash
+stash-pose-graph
State 状态领域
+add-state
+remove-state
+duplicate-state
+set-state-editor-data
+add-state-component
+remove-state-component
Transition 过渡领域
+add-transition
+remove-transition
+move-transition
+add-transition-condition
+remove-transition-condition
+set-transition-condition-property
+set-transition-condition-binding-class
+set-transition-event-binding
Motion 动作领域
+set-motion
+add-motion-child
+remove-motion
+set-motion-editor-data
+set-motion-threshold
+set-direct-blend-weight
Pose 图领域
+add-pose-node
+create-pose-node-on-asset-drag
+remove-pose-node
+duplicate-pose-nodes
+set-pose-node-editor-data
+connect-pose-nodes
+disconnect-pose-input
+insert-pose-input
+delete-pose-input
Variable 变量领域
+add-variable
+set-variable-value
+set-trigger-reset-mode
+remove-variable
+rename-variable
-
查看 Mermaid 源码
classDiagram
-    direction LR
-
-    class Cmd["AnimationGraphCommand"] {
-        +string type 判别字段
-    }
-
-    class LayerCmds["Layer / Stash 层领域"] {
-        +add-layer
-        +remove-layer
-        +move-layer
-        +add-stash
-        +remove-stash
-        +rename-stash
-        +stash-pose-graph
-    }
-
-    class StateCmds["State 状态领域"] {
-        +add-state
-        +remove-state
-        +duplicate-state
-        +set-state-editor-data
-        +add-state-component
-        +remove-state-component
-    }
-
-    class TransCmds["Transition 过渡领域"] {
-        +add-transition
-        +remove-transition
-        +move-transition
-        +add-transition-condition
-        +remove-transition-condition
-        +set-transition-condition-property
-        +set-transition-condition-binding-class
-        +set-transition-event-binding
-    }
-
-    class MotionCmds["Motion 动作领域"] {
-        +set-motion
-        +add-motion-child
-        +remove-motion
-        +set-motion-editor-data
-        +set-motion-threshold
-        +set-direct-blend-weight
-    }
-
-    class PoseCmds["Pose 图领域"] {
-        +add-pose-node
-        +create-pose-node-on-asset-drag
-        +remove-pose-node
-        +duplicate-pose-nodes
-        +set-pose-node-editor-data
-        +connect-pose-nodes
-        +disconnect-pose-input
-        +insert-pose-input
-        +delete-pose-input
-    }
-
-    class VarCmds["Variable 变量领域"] {
-        +add-variable
-        +set-variable-value
-        +set-trigger-reset-mode
-        +remove-variable
-        +rename-variable
-    }
-
-    Cmd "1" *-- "many" LayerCmds : 成员
-    Cmd "1" *-- "many" StateCmds : 成员
-    Cmd "1" *-- "many" TransCmds : 成员
-    Cmd "1" *-- "many" MotionCmds : 成员
-    Cmd "1" *-- "many" PoseCmds : 成员
-    Cmd "1" *-- "many" VarCmds : 成员
-
大多数命令在 type 之外还内联携带状态机地址(StateMachineAddress)目标(Target),例如 add-stateadd-transitionconnect-pose-nodes 等;地址解析逻辑复用第 2.2 节的寻址体系。
-

相关类型别名

- - - - - - - - - - - - - - - - - - - -
别名取值
AnimationGraphStateType'motion' | 'empty' | 'sub-state-machine' | 'procedural-pose'
AnimationGraphMotionType'clip' | 'blend-1d' | 'blend-2d' | 'blend-direct'
AnimationGraphTransitionConditionType'binary' | 'unary' | 'trigger'
- -
-

2.7 AnimationMask(动画掩码)

-

Layer 的 maskUuid 指向一张动画掩码资产,其 dump 结构如下:

- -
-
Diagram 8

AnimationMask(动画掩码)

-
joints
1
many
children 递归
1
many
AnimationMaskDump
+number version
+string assetUuid
+AnimationMaskJoint[] joints
AnimationMaskJoint
+string path
+boolean enabled
+AnimationMaskJoint[] children
AnimationMaskChange
+string path
+boolean enabled
+boolean recursive
-
查看 Mermaid 源码
classDiagram
-    class MaskDump["AnimationMaskDump"] {
-        +number version
-        +string assetUuid
-        +AnimationMaskJoint[] joints
-    }
-
-    class MaskJoint["AnimationMaskJoint"] {
-        +string path
-        +boolean enabled
-        +AnimationMaskJoint[] children
-    }
-
-    class MaskChange["AnimationMaskChange"] {
-        +string path
-        +boolean enabled
-        +boolean recursive
-    }
-
-    MaskDump "1" *-- "many" MaskJoint : joints
-    MaskJoint "1" *-- "many" MaskJoint : children 递归
-

-

3 服务内部数据结构(animation-graph-service.ts)

-

服务以「文档」为核心缓存:对每个动画图资产维护一个 AnimationGraphDocument,内部处理版本并发、外部写入检测(指纹对比)、节点 ID 分配与变更事件广播。

- -
-
Diagram 9

服务内部数据结构(animation-graph-service.ts)

-
_documents 缓存
1
many
按需创建
1
many
fingerprint
1
1
属性适配器
1
many
AnimationGraphAssetService
+query()
+queryInspector()
+queryPoseGraphAssetDragHandlers()
+queryStateMachineComponentTypes()
+setInspectorProperty()
+resetInspectorProperty()
+createInspectorProperty()
+execute()
+save()
+reload()
+onChanged()
+runExternalWrite(s)
+assertExternalWriteAllowed()
AnimationGraphDocument
+string uuid
+string url
+string source
+graph 引擎图对象
+string documentId
+number revision
+number persistedRevision
+boolean dirty
+boolean externallyModified
+SourceFingerprint fingerprint
+number nextNodeId
+nodeIds(WeakMap)
+nodesById(Map)
SourceFingerprint
+number mtimeMs
+number assetDbMtime
+string hash(sha256)
InspectorBinding
+IProperty dump
+propertyCapabilities
+apply(path, patch)
+reset(path)
+create(path)
AdapterProperty
+attrs
+get()
+set(value)
AnimationGraphEditError
+message
+currentVersion
+code(AnimationGraphEditErrorCode)
nodeIds/nodesById:为 Pose 图节点分配稳定数字 ID 的双向索引;nextNodeId 为递增计数器
通过 createAdapterBinding / createDecoratedBinding / createAdapterBinding 构造
-
查看 Mermaid 源码
classDiagram
-    direction TB
-
-    class SVC["AnimationGraphAssetService"] {
-        +query()
-        +queryInspector()
-        +queryPoseGraphAssetDragHandlers()
-        +queryStateMachineComponentTypes()
-        +setInspectorProperty()
-        +resetInspectorProperty()
-        +createInspectorProperty()
-        +execute()
-        +save()
-        +reload()
-        +onChanged()
-        +runExternalWrite(s)
-        +assertExternalWriteAllowed()
-    }
-
-    class Doc["AnimationGraphDocument"] {
-        +string uuid
-        +string url
-        +string source
-        +graph 引擎图对象
-        +string documentId
-        +number revision
-        +number persistedRevision
-        +boolean dirty
-        +boolean externallyModified
-        +SourceFingerprint fingerprint
-        +nodeIds (WeakMap)
-        +nodesById (Map)
-        +number nextNodeId
-    }
-
-    class FP["SourceFingerprint"] {
-        +string hash (sha256)
-        +number mtimeMs
-        +number assetDbMtime
-    }
-
-    class Binding["InspectorBinding"] {
-        +IProperty dump
-        +propertyCapabilities
-        +apply(path, patch)
-        +reset(path)
-        +create(path)
-    }
-
-    class Adapter["AdapterProperty"] {
-        +get()
-        +set(value)
-        +attrs
-    }
-
-    class Err["AnimationGraphEditError"] {
-        +code (AnimationGraphEditErrorCode)
-        +message
-        +currentVersion
-    }
-
-    note for Doc "nodeIds/nodesById:为 Pose 图节点分配稳定数字 ID 的双向索引;nextNodeId 为递增计数器"
-    note for Binding "通过 createAdapterBinding / createDecoratedBinding / createAdapterBinding 构造"
-
-    SVC "1" o-- "many" Doc : _documents 缓存
-    SVC "1" o-- "many" Binding : 按需创建
-    Doc "1" *-- "1" FP : fingerprint
-    Binding "1" *-- "many" Adapter : 属性适配器
-

关键说明

- - - - - - - - - - - - - - - - - - - - - - - - - - - -
结构说明
AnimationGraphDocument内存中的可编辑图文档;graph 是引擎反序列化后的对象树;nodeIds/nodesById 为 Pose 图节点分配稳定 ID;每次落盘成功会刷新 fingerprint
SourceFingerprint源文件 sha256 + mtimeMs + 资源库 mtime,用于检测外部修改(externallyModified
InspectorBinding绑定到具体目标(Layer/State/Transition/Motion/PoseNode/PoseInput/StateComponent)的属性读写职责
AdapterProperty属性 getter/setter + 描述元数据(attrs),供编码为 IProperty dump
AnimationGraphEditError编辑异常,携带可枚举错误码(见 2.1 AnimationGraphEditErrorCode
- -
-

4 动画图变体(animation-graph-variant.ts)

-

动画图变体在引用一个动画图的基础上,覆写其中的动画片段(clip),形成可复用的资源变体。

- -
-
Diagram 10

动画图变体(animation-graph-variant.ts)

-
_pendingEdits 缓存
1
many
graph
1
0..1
dump
1
1
AnimationGraphVariantAssetService
+query(uuid)
+change(uuid, dump)
+save(uuid)
AnimGraphVariantDump
+string graphUuid
+clips(Map: 原clipUuid -> 替代clipUuid)
+invalids(Map: 未命中项)
PendingAnimationGraphVariantEdit
+string uuid
+string source
+number sourceMtimeMs
+number assetDbMtime
+PendingAnimationGraphSnapshot graph
+sourceOverrides
+AnimGraphVariantDump dump
PendingAnimationGraphSnapshot
+string uuid
+string source
+number sourceMtimeMs
+number assetDbMtime
invalids 表示历史保存的覆写项在原图中已不存在,仅展示不落盘
change() 先修改内存中的 pending 编辑,save() 统一写盘并清理 pending
-
查看 Mermaid 源码
classDiagram
-    direction TB
-
-    class VSVC["AnimationGraphVariantAssetService"] {
-        +query(uuid)
-        +change(uuid, dump)
-        +save(uuid)
-    }
-
-    class VariantDump["AnimGraphVariantDump"] {
-        +string graphUuid
-        +clips (Map: 原clipUuid -> 替代clipUuid)
-        +invalids (Map: 未命中项)
-    }
-
-    class PendingEdit["PendingAnimationGraphVariantEdit"] {
-        +string uuid
-        +string source
-        +number sourceMtimeMs
-        +number assetDbMtime
-        +PendingAnimationGraphSnapshot graph
-        +sourceOverrides
-        +AnimGraphVariantDump dump
-    }
-
-    class PendingSnap["PendingAnimationGraphSnapshot"] {
-        +string uuid
-        +string source
-        +number sourceMtimeMs
-        +number assetDbMtime
-    }
-
-    note for VariantDump "invalids 表示历史保存的覆写项在原图中已不存在,仅展示不落盘"
-    note for PendingEdit "change() 先修改内存中的 pending 编辑,save() 统一写盘并清理 pending"
-
-    VSVC "1" o-- "many" PendingEdit : _pendingEdits 缓存
-    PendingEdit "1" *-- "0..1" PendingSnap : graph
-    PendingEdit "1" *-- "1" VariantDump : dump
-

API Schema(src/api/assets/schema.ts)

- - - - - - - - - - - - - - - - - - - - - - - -
Zod Schema对应类型说明
SchemaAnimationGraphVariantDumpTAnimationGraphVariantDump变体可编辑 dump:graphUuid(可空)、clips(覆写映射,空串表示无覆写)、invalids?(仅展示)
SchemaAnimationGraphVariantResultTAnimationGraphVariantResultquery / change 的返回
SchemaAnimationGraphVariantSaveResultTAnimationGraphVariantSaveResultsave 的返回(恒为 null
- -
-

5 资源处理器(asset-handler)

- -
-
Diagram 11

资源处理器(asset-handler)

-
实现
实现
AssetHandler (接口)
+string name
+string assetType
+createInfo
+importer
AnimationGraphHandler
+name = animation-graph
+assetType = cc.AnimationGraph
+getCreateMenuInfo()
+import(asset)
AnimationGraphVariantHandler
+name = animation-graph-variant
+assetType = cc.AnimationGraphVariant
+getCreateMenuInfo()
+import(asset)
-
查看 Mermaid 源码
classDiagram
-    direction LR
-
-    class Handler["AssetHandler (接口)"] {
-        +string name
-        +string assetType
-        +createInfo
-        +importer
-    }
-
-    class AGHandler["AnimationGraphHandler"] {
-        +name = animation-graph
-        +assetType = cc.AnimationGraph
-        +getCreateMenuInfo()
-        +import(asset)
-    }
-
-    class AGVHandler["AnimationGraphVariantHandler"] {
-        +name = animation-graph-variant
-        +assetType = cc.AnimationGraphVariant
-        +getCreateMenuInfo()
-        +import(asset)
-    }
-
-    Handler <|-- AGHandler : 实现
-    Handler <|-- AGVHandler : 实现
-
- - - - - - - - - - - - - - - - - - - - -
处理器扩展名模板importerassetType
AnimationGraphHandlerAnimation Graph.animgraph(模板 default.animgraphanimation-graph(版本 1.2.0cc.AnimationGraph
AnimationGraphVariantHandlerAnimation Graph Varint.animgraphvarianimation-graph-variant(版本 1.0.0cc.AnimationGraphVariant
- -

两者的 import() 均读取源 JSON,原样写入 library(.json),并抽取依赖 UUID 列表(getDependUUIDList)。

-
-

6 整体关系一览

- -
-
Diagram 12

整体关系一览

-
query / execute / save / reload
投影
递归子节点
Inspector
命令
定位
定位
定位
定位
定位
query / change / save
graphUuid 引用还原
AnimationGraphAssetService
AnimationGraphDocument
AnimationGraphViewDump
AnimationGraphLayerView
AnimationGraphStateMachineView
AnimationGraphStateView
AnimationGraphTransitionView
AnimationGraphMotionView
AnimationGraphPoseView
AnimationGraphTransitionConditionView
InspectorBinding
AnimationGraphCommand
AnimationGraphTarget
AnimationGraphVariantAssetService
AnimGraphVariantDump
-
查看 Mermaid 源码
flowchart LR
-    S["AnimationGraphAssetService"] -->|query / execute / save / reload| D["AnimationGraphDocument"]
-    D -->|投影| V["AnimationGraphViewDump"]
-    V --> La["AnimationGraphLayerView"]
-    La --> SM["AnimationGraphStateMachineView"]
-    SM --> St["AnimationGraphStateView"]
-    SM --> Tr["AnimationGraphTransitionView"]
-    St --> Mo["AnimationGraphMotionView"]
-    St --> Po["AnimationGraphPoseView"]
-    Mo -->|递归子节点| Mo
-    Tr --> Co["AnimationGraphTransitionConditionView"]
-    S -->|Inspector| IB["InspectorBinding"]
-    S -->|命令| Cmd["AnimationGraphCommand"]
-    T["AnimationGraphTarget"] -.定位.-> St
-    T -.定位.-> Tr
-    T -.定位.-> Mo
-    T -.定位.-> Po
-    T -.定位.-> La
-    VS["AnimationGraphVariantAssetService"] -->|query / change / save| VD["AnimGraphVariantDump"]
-    VD -->|graphUuid 引用还原| S
-
-
generated from docs/zh/animation-graph-data-structure.md · diagrams rendered with Mermaid
-
-
- - - - - \ No newline at end of file diff --git a/docs/zh/animation-graph-data-structure.md b/docs/zh/animation-graph-data-structure.md deleted file mode 100644 index 07c9eba88..000000000 --- a/docs/zh/animation-graph-data-structure.md +++ /dev/null @@ -1,875 +0,0 @@ -# cocos-cli Animation Graph 数据结构 - -> 本文档梳理 cocos-cli 中与 **Animation Graph(动画图)** 相关的全部数据结构,使用 Mermaid UML 图描述类型关系,并在各节配以字段速查表。 -> -> - 类型层(TypeScript `.d.ts`):`src/core/assets/@types/public.d.ts` -> - 服务层:`src/core/assets/animation-graph-service.ts`、`src/core/assets/animation-graph-variant.ts` -> - 处理器层:`src/core/assets/asset-handler/assets/animation-graph.ts`、`animation-graph-variant.ts` -> - API Schema:`src/api/assets/schema.ts` - ---- - -## 1. 概览 - -### 1.1 相关代码文件 - -| 文件 | 角色 | -|------|------| -| `src/core/assets/@types/public.d.ts` | 公共类型定义(版本、寻址、视图快照、命令、Inspector) | -| `src/core/assets/animation-graph-service.ts` | 动画图编辑服务(文档缓存、查询、命令执行、Inspector) | -| `src/core/assets/animation-graph-variant.ts` | 动画图变体服务(解读 / 修改 / 保存变体资产) | -| `src/core/assets/asset-handler/assets/animation-graph.ts` | `.animgraph` 资源处理器(importer `animation-graph`) | -| `src/core/assets/asset-handler/assets/animation-graph-variant.ts` | `.animgraphvari` 资源处理器(importer `animation-graph-variant`) | -| `src/api/assets/schema.ts` | Zod 校验 Schema(变体 dump 的 API 参数/结果契约) | - -### 1.2 组件总览图 - -```mermaid -flowchart TB - subgraph PUBLIC["公共类型层 public.d.ts"] - P1["版本与文档管理
ExpectedVersion / Version / Snapshot / Event"] - P2["寻址体系
Context / Address / Target"] - P3["视图快照
ViewDump / Layer / StateMachine / State / Transition / Motion"] - P4["Pose 图视图
PoseView / PoseNode / PoseInput"] - P5["Inspector
InspectorSnapshot / Command"] - end - - subgraph VAR["变体结构 (animation-graph-variant.ts)"] - V1["AnimGraphVariantDump"] - V2["PendingAnimationGraphVariantEdit"] - end - - subgraph INTERNAL["服务内部结构 (animation-graph-service.ts)"] - I1["AnimationGraphDocument"] - I2["SourceFingerprint / InspectorBinding / AdapterProperty"] - end - - subgraph SCHEMA["API Schema (src/api/assets/schema.ts)"] - S1["SchemaAnimationGraphVariantDump"] - end - - subgraph HANDLER["资源处理器"] - H1["AnimationGraphHandler (.animgraph)"] - H2["AnimationGraphVariantHandler (.animgraphvari)"] - end - - SERVICE["AnimationGraphAssetService
动画图编辑服务"] --> PUBLIC - SERVICE --> INTERNAL - H1 --> SERVICE - H2 --> VARSERVICE["AnimationGraphVariantAssetService"] - VARSERVICE --> VAR - VAR --> SCHEMA -``` - ---- - -## 2. 公共类型层(public.d.ts) - -### 2.1 版本与文档管理 - -`AnimationGraphExpectedVersion` 是乐观并发控制的最小单元;`AnimationGraphVersion` 追加持久化 / 脏标记状态;`AnimationGraphSnapshot` 是 `query` / `execute` / `save` / `reload` 返回的统一快照。`AnimationGraphChangedEvent` 用于向监听者广播变更。 - -```mermaid -classDiagram - direction LR - - class AVExpected["AnimationGraphExpectedVersion"] { - +string documentId - +number revision - } - - class AVVersion["AnimationGraphVersion"] { - +number persistedRevision - +boolean dirty - +boolean externallyModified - } - - class AVSnapshot["AnimationGraphSnapshot"] { - +string uuid - +string url - +AnimationGraphViewDump graph - } - - class AVEvent["AnimationGraphChangedEvent"] { - +string uuid - +string reason - +AnimationGraphVersion version - +string sourceId - +string[] changedPaths - } - - class AVReloadOpts["ReloadAnimationGraphOptions"] { - +AnimationGraphExpectedVersion expected - +boolean discardDirty - } - - class AVErrorCode["AnimationGraphEditErrorCode (枚举)"] { - +VERSION_CONFLICT - +DOCUMENT_RELOADED - +SOURCE_CHANGED - +TARGET_NOT_FOUND - +UNSUPPORTED_TARGET - +UNSUPPORTED_PROPERTY_OPERATION - +INVALID_PROPERTY_PATCH - +READONLY_PROPERTY - +NAME_CONFLICT - +DIRTY_DOCUMENT - } - - class AVViewDump["AnimationGraphViewDump"] { - +AnimationGraphLayerView[] layers - +AnimationGraphVariableView[] variables - } - - AVExpected <|-- AVVersion : 继承 - AVVersion <|-- AVSnapshot : 继承 - AVEvent --> AVVersion : 携带 - AVSnapshot --> AVVersion : 携带 - AVSnapshot "1" *-- "1" AVViewDump : 包含 -``` - -**字段速查** - -| 类型 | 字段 | 类型 | 说明 | -|------|------|------|------| -| `AnimationGraphExpectedVersion` | `documentId` | `string` | 文档唯一 ID(每次加载重新生成) | -| | `revision` | `number` | 已提交修改的版本号 | -| `AnimationGraphVersion` | `persistedRevision` | `number` | 已持久化版本号 | -| | `dirty` | `boolean` | 是否有未保存修改 | -| | `externallyModified` | `boolean` | 源文件是否被外部改动 | -| `AnimationGraphSnapshot` | `uuid` / `url` | `string` | 资产标识 | -| | `graph` | `AnimationGraphViewDump` | 完整视图快照 | -| `AnimationGraphChangedEvent` | `reason` | `'inspector' \| 'structure' \| 'save' \| 'reload' \| 'external'` | 变更原因 | -| | `sourceId?` / `changedPaths?` | `string` / `string[]` | 变更来源与路径 | - ---- - -### 2.2 寻址体系:Context / Address / Target - -动画图由 **Layer(层)→ StateMachine(状态机)→ State(状态)/ Transition(过渡)→ Motion(动作)**,以及独立的 **Pose Graph(姿态图)→ PoseNode + 内嵌状态机** 组成。为了让 Inspector / 命令能够唯一定位任意节点,cocos-cli 定义了一套**上下文 → 地址 → 目标**的三角寻址体系。 - -> 说明:图中的继承箭头表示「类型组合/扩展」关系(TypeScript 交叉类型语义),`note` 块给出判别联合(discriminated union)的 `kind` 成员。 - -```mermaid -classDiagram - direction TB - - class CMSctx["AnimationGraphStateMachineContext"] { - <<判别联合>> - +layer-state-machine kind - +pose-node-state-machine kind - +sub-state-machine kind - } - - class CPGctx["AnimationGraphPoseGraphContext"] { - <<判别联合>> - +state-pose-graph kind - +layer-stash kind - } - - note for CMSctx "1) kind=layer-state-machine: { layerIndex, stateMachinePath[] }
2) kind=pose-node-state-machine: { poseGraph, nodeId }
3) kind=sub-state-machine: { stateMachine, stateIndex }" - note for CPGctx "1) kind=state-pose-graph: { stateMachine, stateIndex }
2) kind=layer-stash: { layerIndex, stashName }" - - class ASMAddr["AnimationGraphStateMachineAddress"] { - <<判别联合>> - +直接形式 (layerIndex + stateMachinePath) - +上下文形式 (stateMachine 引用) - } - - class AStateAddr["AnimationGraphStateAddress"] { - +number stateIndex - } - - class APGAddr["AnimationGraphPoseGraphAddress"] { - <<判别联合>> - +直接形式 (layerIndex + stateMachinePath + stateIndex) - +上下文形式 (poseGraph 引用) - } - - class APNAddr["AnimationGraphPoseNodeAddress"] { - +number nodeId - } - - class AMotionAddr["AnimationGraphMotionAddress"] { - +number[] level 层级路径 - } - - class ATarget["AnimationGraphTarget"] { - <<判别联合>> - +layer 目标 - +state 目标 - +transition 目标 - +motion 目标 - +pose-node 目标 - +pose-input 目标 - +state-component 目标 - } - - CMSctx ..> ASMAddr : 引用解析 - CPGctx ..> APGAddr : 引用解析 - ASMAddr <|-- AStateAddr : 扩展 - APGAddr <|-- APNAddr : 扩展 - AStateAddr <|-- AMotionAddr : 扩展 - APNAddr <|-- AMotionAddr : 扩展 - AStateAddr <|-- ATarget : 组成 - ASMAddr <|-- ATarget : 组成 - APNAddr <|-- ATarget : 组成 - AMotionAddr <|-- ATarget : 组成 -``` - -**字段速查(关键联合成员)** - -| 类型 | 成员 | 结构 | -|------|------|------| -| `AnimationGraphStateMachineContext` | `layer-state-machine` | `{ kind, layerIndex, stateMachinePath: number[] }` | -| | `pose-node-state-machine` | `{ kind, poseGraph, nodeId }` | -| | `sub-state-machine` | `{ kind, stateMachine, stateIndex }` | -| `AnimationGraphPoseGraphContext` | `state-pose-graph` | `{ kind, stateMachine, stateIndex }` | -| | `layer-stash` | `{ kind, layerIndex, stashName }` | -| `AnimationGraphTarget` | `layer` | `{ kind, layerIndex }` | -| | `state` | `{ kind } & StateAddress` | -| | `transition` | `{ kind, transitionIndex } & StateMachineAddress` | -| | `motion` | `{ kind } & MotionAddress` | -| | `pose-node` | `{ kind } & PoseNodeAddress` | -| | `pose-input` | `{ kind, inputId } & PoseNodeAddress` | -| | `state-component` | `{ kind, componentIndex } & StateAddress` | - ---- - -### 2.3 视图快照体系 - -服务端将引擎运行时对象投影为**只读视图(View)**,供 Webview / Inspector 消费。树形关系如下: - -- `AnimationGraphViewDump`(整图)→ 多个 `LayerView` -- `LayerView` → `StateMachineView`;`StateMachineView` → 多个 `StateView` 与 `TransitionView` -- `StateView` 可递归内嵌 `StateMachineView`(子状态机)、携带 `MotionView`(动作状态)、或挂 `PoseView`(过程姿态状态) -- `MotionView` 递归包含子 `MotionView`(1D/2D 混合) - -```mermaid -classDiagram - direction TB - - class AViewDump["AnimationGraphViewDump"] { - +AnimationGraphLayerView[] layers - +AnimationGraphVariableView[] variables - } - - class ALayer["AnimationGraphLayerView"] { - +number index - +string name - +number weight - +boolean additive - +string maskUuid - +string[] stashes - +stashPoseGraphs - +AnimationGraphStateMachineView stateMachine - } - - class ASM["AnimationGraphStateMachineView"] { - +context - +number[] path - +boolean allowEmptyStates - +AnimationGraphStateView[] states - +AnimationGraphTransitionView[] transitions - +editorData - } - - class AState["AnimationGraphStateView"] { - +number index - +type type - +string name - +number[] incomingTransitionIndices - +number[] outgoingTransitionIndices - +components - +speed - +speedMultiplier - +speedMultiplierEnabled - +motion - +stateMachine - +poseGraph - +editorData - } - - class ATrans["AnimationGraphTransitionView"] { - +number index - +type type - +number fromStateIndex - +number toStateIndex - +number priority - +conditions - +duration - +relativeDuration - +exitConditionEnabled - +exitCondition - +destinationStart - +relativeDestinationStart - +startEvent - +endEvent - } - - class ACond["AnimationGraphTransitionConditionView"] { - <<判别联合>> - +BinaryCondition - +UnaryCondition - +TriggerCondition - +Unknown - } - - class AComp["AnimationGraphComponentView"] { - +number index - +string type - } - - class AMotion["AnimationGraphMotionView"] { - +number[] level - +target - +type (clip/blend-1d/blend-2d/blend-direct/unknown) - +string name - +clipUuid - +variable / value - +variableX / valueX - +variableY / valueY - +threshold - +weight - +children - } - - class AVar["AnimationGraphVariableView"] { - +string name - +number type - +IProperty value - +resetMode - } - - class APose["AnimationGraphPoseView"] - - AViewDump "1" *-- "many" ALayer - AViewDump "1" *-- "many" AVar - ALayer "1" *-- "many" ASM : stateMachine - ASM "1" *-- "many" AState : states - ASM "1" *-- "many" ATrans : transitions - AState "1" o-- "0..1" AMotion : motion - AState "1" o-- "0..1" ASM : 子状态机 - AState "1" o-- "0..1" APose : poseGraph - AState "1" o-- "many" AComp : components - ATrans "1" *-- "many" ACond : conditions - AMotion "1" *-- "many" AMotion : children 递归 -``` - -**字段速查** - -| 类型 | 关键字段 | -|------|---------| -| `AnimationGraphViewDump` | `layers[]`、`variables[]` | -| `AnimationGraphLayerView` | `index`、`name`、`weight`、`additive`、`maskUuid: string\|null`、`stashes: string[]`、`stashPoseGraphs[]`、`stateMachine` | -| `AnimationGraphStateMachineView` | `context`、`path: number[]`、`allowEmptyStates`、`states[]`、`transitions[]`、`editorData?` | -| `AnimationGraphStateView` | `index`、`type`(`entry/exit/any/motion/empty/sub-state-machine/procedural-pose/unknown`)、`name`、`incoming/outgoingTransitionIndices[]`、`components[]`、`speed?`、`speedMultiplier?`、`speedMultiplierEnabled?`、`motion?`、`stateMachine?`、`poseGraph?`、`editorData?` | -| `AnimationGraphTransitionView` | `index`、`type`(`animation/empty-state/procedural-pose/transition`)、`fromStateIndex`、`toStateIndex`、`priority`、`conditions[]`、`duration?`、`relativeDuration?`、`exitConditionEnabled?`、`exitCondition?`、`destinationStart?`、`relativeDestinationStart?`、`startEvent?`、`endEvent?` | -| `AnimationGraphTransitionConditionView` | 判别联合:Binary / Unary / Trigger / Unknown,见下 | -| `AnimationGraphComponentView` | `index`、`type` | -| `AnimationGraphMotionView` | `level[]`、`target`、`type`、`name`、`clipUuid?`、`variable?/value?`、`variableX?/valueX?`、`variableY?/valueY?`、`threshold?`、`weight?`、`children?`、`editorData?` | -| `AnimationGraphVariableView` | `name`、`type: number`、`value: IProperty`、`resetMode?: number`(Trigger 类型才有) | - -**过渡条件(TransitionConditionView)判别联合成员** - -| `type` | 字段 | -|--------|------| -| `BinaryCondition` | `index`、`operator`、`lhs`、`lhsBinding`、`bindingClass`、`rhs`、`isRhsInteger` | -| `UnaryCondition` | `index`、`operator`、`operand` | -| `TriggerCondition` | `index`、`trigger` | -| `Unknown` | `index`、`className` | - ---- - -### 2.4 Pose 图视图体系 - -Pose 图是一张**节点连通图**:根输出节点引出,节点间通过输入/输出端口相连;节点可内嵌状态机或动作(Motion),也可以作为 Stash 入口。 - -```mermaid -classDiagram - direction TB - - class APoseView["AnimationGraphPoseView"] { - +context - +number rootOutputNodeId - +AnimationGraphPoseNodeView[] nodes - +addNodeInfos - +assetDragHandlersMap - } - - class APoseNode["AnimationGraphPoseNodeView"] { - +number id - +string type - +string title - +number[] outputTypes - +AnimationGraphPoseInputView[] inputs - +inputInsertInfos - +stateMachine - +motion - +enterInfo - +editorData - } - - class APoseInput["AnimationGraphPoseInputView"] { - +string id - +string displayName - +number type - +boolean deletable - +boolean insertPoint - +boolean connected - +producerNodeId - +producerOutputId - +value - } - - class AEnterInfo["AnimationGraphPoseNodeEnterInfo"] { - +type (state-machine/animation-blend/stash) - +stashName - } - - class AAddNode["AnimationGraphPoseGraphAddNodeInfo"] { - +string typeId - +args - +string menu - } - - class ADragView["AnimationGraphPoseGraphAssetDragHandlersView"] { - +handlers - } - - class ADragHandler["AnimationGraphPoseGraphAssetDragHandlerView"] { - +string displayName - } - - class ADragEntry["AnimationGraphPoseGraphAssetDragHandlersEntry"] { - +string assetType - +handlers - } - - class ADragInfo["AnimationGraphPoseGraphAssetDragHandlerInfo"] { - +string id - +string displayName - } - - class ASM["AnimationGraphStateMachineView"] - class AMotion["AnimationGraphMotionView"] - - APoseView "1" *-- "many" APoseNode : nodes - APoseView "1" *-- "many" AAddNode : addNodeInfos - APoseView "1" *-- "many" ADragView : assetDragHandlersMap - APoseNode "1" *-- "many" APoseInput : inputs - APoseNode "1" o-- "0..1" AEnterInfo : enterInfo - APoseNode "1" o-- "0..1" ASM : 内嵌状态机 - APoseNode "1" o-- "0..1" AMotion : 内嵌动作 - ADragView "1" *-- "many" ADragHandler : handlers - ADragEntry "1" *-- "many" ADragInfo : handlers -``` - -**字段速查** - -| 类型 | 关键字段 | -|------|---------| -| `AnimationGraphPoseView` | `context`、`rootOutputNodeId: number`、`nodes[]`、`addNodeInfos[]`、`assetDragHandlersMap` | -| `AnimationGraphPoseNodeView` | `id`、`type`、`title`、`outputTypes: number[]`、`inputs[]`、`inputInsertInfos`、`stateMachine?`、`motion?`、`enterInfo?`、`editorData?` | -| `AnimationGraphPoseInputView` | `id`、`displayName`、`type`、`deletable`、`insertPoint`、`connected`、`producerNodeId?`、`producerOutputId?`、`value?: IProperty` | -| `AnimationGraphPoseNodeEnterInfo` | `type: 'state-machine' \| 'animation-blend' \| 'stash'`、`stashName?` | -| `AnimationGraphPoseGraphAddNodeInfo` | `typeId`、`args: unknown`、`menu`(面包屑式路径) | -| `AnimationGraphPoseGraphAssetDragHandlersView/Entry` | `handlers`(按 handlerId 索引) / `assetType + handlers[]` | - ---- - -### 2.5 Inspector 快照与命令 - -Inspector 通过「目标 + 属性路径」读写属性;每次操作都携带 `expected` 版本做乐观并发控制。 - -```mermaid -classDiagram - direction TB - - class AInspSnap["AnimationGraphInspectorSnapshot"] { - +string uuid - +AnimationGraphTarget target - +IProperty dump - +propertyCapabilities - } - - class AInspCap["AnimationGraphInspectorPropertyCapabilities"] { - +boolean set - +boolean reset - +boolean create - } - - class AInspReq["AnimationGraphInspectorPropertyOperationRequest"] { - +AnimationGraphTarget target - +string path - +AnimationGraphExpectedVersion expected - +string sourceId - } - - class ASetReq["SetAnimationGraphInspectorPropertyRequest"] { - +patch (IProperty or unknown) - } - - class AExecReq["ExecuteAnimationGraphCommandRequest"] { - +AnimationGraphCommand command - +AnimationGraphExpectedVersion expected - +string sourceId - } - - note for AInspSnap "dump 为序列化后的属性描述树(IProperty);propertyCapabilities 描述 property 支持 set/reset/create 的能力" - note for ASetReq "Inspector 属性设置请求 = 基础请求 + patch" - - class ATarget["AnimationGraphTarget"] { - <<判别联合>> - +kind - } - - class ACmd["AnimationGraphCommand"] { - +string type - } - - AInspSnap "1" *-- "1" AInspCap : propertyCapabilities - AInspSnap "1" --> "1" ATarget : target - AInspReq <|-- ASetReq : 继承扩展 - AExecReq "1" --> "1" ACmd : 携带命令 -``` - -**字段速查** - -| 类型 | 说明 | -|------|------| -| `AnimationGraphInspectorSnapshot` | `uuid`、`target`、`dump: IProperty`、`propertyCapabilities?` | -| `AnimationGraphInspectorPropertyCapabilities` | `set` / `reset` / `create: boolean` | -| `AnimationGraphInspectorPropertyOperationRequest` | `target`、`path`、`expected`、`sourceId?` | -| `SetAnimationGraphInspectorPropertyRequest` | 基础请求 + `patch: IProperty \| unknown` | -| `ExecuteAnimationGraphCommandRequest` | `command`、`expected`、`sourceId?` | - ---- - -### 2.6 命令体系:AnimationGraphCommand - -`AnimationGraphCommand` 是**按 `type` 判别的大型联合类型**,覆盖 Layer / State / Transition / Motion / StateComponent / Pose / Variable / Stash 的增删改。所有命令通过 `execute()` 在服务端执行,并推进文档 `revision`。 - -```mermaid -classDiagram - direction LR - - class Cmd["AnimationGraphCommand"] { - +string type 判别字段 - } - - class LayerCmds["Layer / Stash 层领域"] { - +add-layer - +remove-layer - +move-layer - +add-stash - +remove-stash - +rename-stash - +stash-pose-graph - } - - class StateCmds["State 状态领域"] { - +add-state - +remove-state - +duplicate-state - +set-state-editor-data - +add-state-component - +remove-state-component - } - - class TransCmds["Transition 过渡领域"] { - +add-transition - +remove-transition - +move-transition - +add-transition-condition - +remove-transition-condition - +set-transition-condition-property - +set-transition-condition-binding-class - +set-transition-event-binding - } - - class MotionCmds["Motion 动作领域"] { - +set-motion - +add-motion-child - +remove-motion - +set-motion-editor-data - +set-motion-threshold - +set-direct-blend-weight - } - - class PoseCmds["Pose 图领域"] { - +add-pose-node - +create-pose-node-on-asset-drag - +remove-pose-node - +duplicate-pose-nodes - +set-pose-node-editor-data - +connect-pose-nodes - +disconnect-pose-input - +insert-pose-input - +delete-pose-input - } - - class VarCmds["Variable 变量领域"] { - +add-variable - +set-variable-value - +set-trigger-reset-mode - +remove-variable - +rename-variable - } - - Cmd "1" *-- "many" LayerCmds : 成员 - Cmd "1" *-- "many" StateCmds : 成员 - Cmd "1" *-- "many" TransCmds : 成员 - Cmd "1" *-- "many" MotionCmds : 成员 - Cmd "1" *-- "many" PoseCmds : 成员 - Cmd "1" *-- "many" VarCmds : 成员 -``` - -> 大多数命令在 `type` 之外还内联携带**状态机地址(StateMachineAddress)** 或 **目标(Target)**,例如 `add-state`、`add-transition`、`connect-pose-nodes` 等;地址解析逻辑复用第 2.2 节的寻址体系。 - -**相关类型别名** - -| 别名 | 取值 | -|------|------| -| `AnimationGraphStateType` | `'motion' \| 'empty' \| 'sub-state-machine' \| 'procedural-pose'` | -| `AnimationGraphMotionType` | `'clip' \| 'blend-1d' \| 'blend-2d' \| 'blend-direct'` | -| `AnimationGraphTransitionConditionType` | `'binary' \| 'unary' \| 'trigger'` | - ---- - -### 2.7 AnimationMask(动画掩码) - -Layer 的 `maskUuid` 指向一张动画掩码资产,其 dump 结构如下: - -```mermaid -classDiagram - class MaskDump["AnimationMaskDump"] { - +number version - +string assetUuid - +AnimationMaskJoint[] joints - } - - class MaskJoint["AnimationMaskJoint"] { - +string path - +boolean enabled - +AnimationMaskJoint[] children - } - - class MaskChange["AnimationMaskChange"] { - +string path - +boolean enabled - +boolean recursive - } - - MaskDump "1" *-- "many" MaskJoint : joints - MaskJoint "1" *-- "many" MaskJoint : children 递归 -``` - ---- - -## 3. 服务内部数据结构(animation-graph-service.ts) - -服务以「文档」为核心缓存:对每个动画图资产维护一个 `AnimationGraphDocument`,内部处理版本并发、外部写入检测(指纹对比)、节点 ID 分配与变更事件广播。 - -```mermaid -classDiagram - direction TB - - class SVC["AnimationGraphAssetService"] { - +query() - +queryInspector() - +queryPoseGraphAssetDragHandlers() - +queryStateMachineComponentTypes() - +setInspectorProperty() - +resetInspectorProperty() - +createInspectorProperty() - +execute() - +save() - +reload() - +onChanged() - +runExternalWrite(s) - +assertExternalWriteAllowed() - } - - class Doc["AnimationGraphDocument"] { - +string uuid - +string url - +string source - +graph 引擎图对象 - +string documentId - +number revision - +number persistedRevision - +boolean dirty - +boolean externallyModified - +SourceFingerprint fingerprint - +nodeIds (WeakMap) - +nodesById (Map) - +number nextNodeId - } - - class FP["SourceFingerprint"] { - +string hash (sha256) - +number mtimeMs - +number assetDbMtime - } - - class Binding["InspectorBinding"] { - +IProperty dump - +propertyCapabilities - +apply(path, patch) - +reset(path) - +create(path) - } - - class Adapter["AdapterProperty"] { - +get() - +set(value) - +attrs - } - - class Err["AnimationGraphEditError"] { - +code (AnimationGraphEditErrorCode) - +message - +currentVersion - } - - note for Doc "nodeIds/nodesById:为 Pose 图节点分配稳定数字 ID 的双向索引;nextNodeId 为递增计数器" - note for Binding "通过 createAdapterBinding / createDecoratedBinding / createAdapterBinding 构造" - - SVC "1" o-- "many" Doc : _documents 缓存 - SVC "1" o-- "many" Binding : 按需创建 - Doc "1" *-- "1" FP : fingerprint - Binding "1" *-- "many" Adapter : 属性适配器 -``` - -**关键说明** - -| 结构 | 说明 | -|------|------| -| `AnimationGraphDocument` | 内存中的可编辑图文档;`graph` 是引擎反序列化后的对象树;`nodeIds`/`nodesById` 为 Pose 图节点分配稳定 ID;每次落盘成功会刷新 `fingerprint` | -| `SourceFingerprint` | 源文件 `sha256` + `mtimeMs` + 资源库 mtime,用于检测外部修改(`externallyModified`) | -| `InspectorBinding` | 绑定到具体目标(Layer/State/Transition/Motion/PoseNode/PoseInput/StateComponent)的属性读写职责 | -| `AdapterProperty` | 属性 getter/setter + 描述元数据(`attrs`),供编码为 `IProperty` dump | -| `AnimationGraphEditError` | 编辑异常,携带可枚举错误码(见 2.1 `AnimationGraphEditErrorCode`) | - ---- - -## 4. 动画图变体(animation-graph-variant.ts) - -动画图变体在引用一个动画图的基础上,覆写其中的**动画片段(clip)**,形成可复用的资源变体。 - -```mermaid -classDiagram - direction TB - - class VSVC["AnimationGraphVariantAssetService"] { - +query(uuid) - +change(uuid, dump) - +save(uuid) - } - - class VariantDump["AnimGraphVariantDump"] { - +string graphUuid - +clips (Map: 原clipUuid -> 替代clipUuid) - +invalids (Map: 未命中项) - } - - class PendingEdit["PendingAnimationGraphVariantEdit"] { - +string uuid - +string source - +number sourceMtimeMs - +number assetDbMtime - +PendingAnimationGraphSnapshot graph - +sourceOverrides - +AnimGraphVariantDump dump - } - - class PendingSnap["PendingAnimationGraphSnapshot"] { - +string uuid - +string source - +number sourceMtimeMs - +number assetDbMtime - } - - note for VariantDump "invalids 表示历史保存的覆写项在原图中已不存在,仅展示不落盘" - note for PendingEdit "change() 先修改内存中的 pending 编辑,save() 统一写盘并清理 pending" - - VSVC "1" o-- "many" PendingEdit : _pendingEdits 缓存 - PendingEdit "1" *-- "0..1" PendingSnap : graph - PendingEdit "1" *-- "1" VariantDump : dump -``` - -**API Schema(src/api/assets/schema.ts)** - -| Zod Schema | 对应类型 | 说明 | -|-----------|---------|------| -| `SchemaAnimationGraphVariantDump` | `TAnimationGraphVariantDump` | 变体可编辑 dump:`graphUuid`(可空)、`clips`(覆写映射,空串表示无覆写)、`invalids?`(仅展示) | -| `SchemaAnimationGraphVariantResult` | `TAnimationGraphVariantResult` | `query` / `change` 的返回 | -| `SchemaAnimationGraphVariantSaveResult` | `TAnimationGraphVariantSaveResult` | `save` 的返回(恒为 `null`) | - ---- - -## 5. 资源处理器(asset-handler) - -```mermaid -classDiagram - direction LR - - class Handler["AssetHandler (接口)"] { - +string name - +string assetType - +createInfo - +importer - } - - class AGHandler["AnimationGraphHandler"] { - +name = animation-graph - +assetType = cc.AnimationGraph - +getCreateMenuInfo() - +import(asset) - } - - class AGVHandler["AnimationGraphVariantHandler"] { - +name = animation-graph-variant - +assetType = cc.AnimationGraphVariant - +getCreateMenuInfo() - +import(asset) - } - - Handler <|-- AGHandler : 实现 - Handler <|-- AGVHandler : 实现 -``` - -| 处理器 | 扩展名模板 | importer | assetType | -|--------|-----------|----------|-----------| -| `AnimationGraphHandler` | `Animation Graph.animgraph`(模板 `default.animgraph`) | `animation-graph`(版本 `1.2.0`) | `cc.AnimationGraph` | -| `AnimationGraphVariantHandler` | `Animation Graph Varint.animgraphvari` | `animation-graph-variant`(版本 `1.0.0`) | `cc.AnimationGraphVariant` | - -两者的 `import()` 均读取源 JSON,原样写入 library(`.json`),并抽取依赖 UUID 列表(`getDependUUIDList`)。 - ---- - -## 6. 整体关系一览 - -```mermaid -flowchart LR - S["AnimationGraphAssetService"] -->|query / execute / save / reload| D["AnimationGraphDocument"] - D -->|投影| V["AnimationGraphViewDump"] - V --> La["AnimationGraphLayerView"] - La --> SM["AnimationGraphStateMachineView"] - SM --> St["AnimationGraphStateView"] - SM --> Tr["AnimationGraphTransitionView"] - St --> Mo["AnimationGraphMotionView"] - St --> Po["AnimationGraphPoseView"] - Mo -->|递归子节点| Mo - Tr --> Co["AnimationGraphTransitionConditionView"] - S -->|Inspector| IB["InspectorBinding"] - S -->|命令| Cmd["AnimationGraphCommand"] - T["AnimationGraphTarget"] -.定位.-> St - T -.定位.-> Tr - T -.定位.-> Mo - T -.定位.-> Po - T -.定位.-> La - VS["AnimationGraphVariantAssetService"] -->|query / change / save| VD["AnimGraphVariantDump"] - VD -->|graphUuid 引用还原| S -``` \ No newline at end of file