@fenglimg/cocos-state-controller 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +287 -0
  3. package/assets/script/controller/Capability.ts +100 -0
  4. package/assets/script/controller/Capability.ts.meta +10 -0
  5. package/assets/script/controller/CapabilityRegistry.ts +116 -0
  6. package/assets/script/controller/CapabilityRegistry.ts.meta +10 -0
  7. package/assets/script/controller/EnumPropRefMap.ts +232 -0
  8. package/assets/script/controller/EnumPropRefMap.ts.meta +10 -0
  9. package/assets/script/controller/NestedCtrlData.ts +199 -0
  10. package/assets/script/controller/NestedCtrlData.ts.meta +10 -0
  11. package/assets/script/controller/PrefabIntrospection.ts +151 -0
  12. package/assets/script/controller/PrefabIntrospection.ts.meta +10 -0
  13. package/assets/script/controller/Props.meta +13 -0
  14. package/assets/script/controller/StateControllerV2.ts +1957 -0
  15. package/assets/script/controller/StateControllerV2.ts.meta +10 -0
  16. package/assets/script/controller/StateEnumV2.ts +165 -0
  17. package/assets/script/controller/StateEnumV2.ts.meta +10 -0
  18. package/assets/script/controller/StateErrorManagerV2.ts +217 -0
  19. package/assets/script/controller/StateErrorManagerV2.ts.meta +10 -0
  20. package/assets/script/controller/StatePropHandlerV2.ts +316 -0
  21. package/assets/script/controller/StatePropHandlerV2.ts.meta +10 -0
  22. package/assets/script/controller/StatePropertyControlService.ts +148 -0
  23. package/assets/script/controller/StatePropertyControlService.ts.meta +10 -0
  24. package/assets/script/controller/StateSelectV2.ts +4542 -0
  25. package/assets/script/controller/StateSelectV2.ts.meta +10 -0
  26. package/assets/script/controller/capabilities/AutoSyncCapability.ts +30 -0
  27. package/assets/script/controller/capabilities/AutoSyncCapability.ts.meta +10 -0
  28. package/assets/script/controller/capabilities/EventCapability.ts +144 -0
  29. package/assets/script/controller/capabilities/EventCapability.ts.meta +10 -0
  30. package/assets/script/controller/capabilities/MigrationCapability.ts +94 -0
  31. package/assets/script/controller/capabilities/MigrationCapability.ts.meta +10 -0
  32. package/assets/script/controller/capabilities/MultiCtrlBindingCapability.ts +157 -0
  33. package/assets/script/controller/capabilities/MultiCtrlBindingCapability.ts.meta +10 -0
  34. package/assets/script/controller/capabilities/PropertyControlCapability.ts +124 -0
  35. package/assets/script/controller/capabilities/PropertyControlCapability.ts.meta +10 -0
  36. package/assets/script/controller/capabilities/RecordingCapability.ts +69 -0
  37. package/assets/script/controller/capabilities/RecordingCapability.ts.meta +10 -0
  38. package/assets/script/controller/capabilities/SelectedPageIdCapability.ts +88 -0
  39. package/assets/script/controller/capabilities/SelectedPageIdCapability.ts.meta +10 -0
  40. package/assets/script/controller/capabilities.meta +13 -0
  41. package/assets/script/controller/props/CtrlInspectorGroups.ts +138 -0
  42. package/assets/script/controller/props/CtrlInspectorGroups.ts.meta +10 -0
  43. package/assets/script/controller/props/SelectInspectorGroups.ts +104 -0
  44. package/assets/script/controller/props/SelectInspectorGroups.ts.meta +10 -0
  45. package/bin/csc.js +286 -0
  46. package/package.json +60 -0
  47. package/packages/state-controller-v2-panel/README.md +80 -0
  48. package/packages/state-controller-v2-panel/inspector-inject.js +917 -0
  49. package/packages/state-controller-v2-panel/inspector-probe.json +3767 -0
  50. package/packages/state-controller-v2-panel/lib/handlers.js +534 -0
  51. package/packages/state-controller-v2-panel/main.js +149 -0
  52. package/packages/state-controller-v2-panel/package.json +32 -0
  53. package/packages/state-controller-v2-panel/panel/build.js +23 -0
  54. package/packages/state-controller-v2-panel/panel/logic.js +1207 -0
  55. package/packages/state-controller-v2-panel/panel/styles.css +454 -0
  56. package/packages/state-controller-v2-panel/panel/template.html +296 -0
  57. package/packages/state-controller-v2-panel/scene-accessor.js +657 -0
  58. package/skills/cocos-state-controller/SKILL.md +28 -0
  59. package/skills/cocos-state-controller/refs/cli-usage.md +78 -0
  60. package/skills/cocos-state-controller/refs/editor-guide.md +127 -0
  61. package/skills/cocos-state-controller/refs/migrate.md +106 -0
  62. package/skills/cocos-state-controller/refs/upstream-pr.md +66 -0
  63. package/tools/migration/migrate-prefab-v1-to-v2.js +608 -0
  64. package/tools/state-controller-sync-manifest.json +33 -0
@@ -0,0 +1,4542 @@
1
+ /**
2
+ * 这个类主要目的是为了存以下结构数据:状态对应的属性
3
+ *
4
+ * _ctrlData数据存储结构
5
+ *
6
+ * ctrlId:{
7
+ * //$$lastState$$ : state1
8
+ * $$default$$:{
9
+ * $$changedProp$$:[]
10
+ * $$lastProp$$:EnumPropName.active
11
+ * EnumPropName.active : true,//active
12
+ * 1 : v3,//postion,
13
+ * .....
14
+ * }
15
+ * stateUUId0 : {
16
+ * $$changedProp$$:[]
17
+ * $$lastProp$$:EnumPropName.active
18
+ * EnumPropName.active : true,//active
19
+ * .....
20
+ * },
21
+ * stateUUId1:{
22
+ * $$lastProp$$:EnumPropName.pos
23
+ * 1 : v3,//postion,
24
+ * .....
25
+ * }
26
+ * stateName1:{},
27
+ * }
28
+ *
29
+ */
30
+
31
+ const {
32
+ ccclass, property, menu, executeInEditMode, disallowMultiple,
33
+ } = cc._decorator;
34
+ import { SelectExcludeGroup, SelectRecordGroup, SelectValueOpsGroup } from "./props/SelectInspectorGroups";
35
+ import { StateControllerV2 } from "./StateControllerV2";
36
+ import { EnumCtrlName, EnumExcludeSlot, EnumPropName, EnumStateName } from "./StateEnumV2";
37
+ import { StateErrorManager } from "./StateErrorManagerV2";
38
+ import { PropHandlerManager } from "./StatePropHandlerV2";
39
+ import { PropertyControlService } from "./StatePropertyControlService";
40
+ // W6-2a: 自定义组件 propRef 路径基础设施 (W6-1 引入, 本 task 接入)
41
+ // W6-4: SYSTEM_EXCLUDE 用于 inspector 排除清单 union (excludedPropsDisplay getter)
42
+ import { listTrackableProps, TrackableProp, SYSTEM_EXCLUDE } from "./PrefabIntrospection";
43
+ import { cloneValueByType, eqValueByType } from "./NestedCtrlData";
44
+ import { ENUM_TO_PROPREF, PROPREF_TO_ENUM, LEGACY_DROPPED_ENUMS, enumToPropRef, AMBIGUOUS_DECOMPOSE, isAmbiguousAggregatePropRef } from "./EnumPropRefMap";
45
+ // W6-2b: capability dispatch (propRef 字段派发给 hooks)
46
+ import { CapabilityRegistry } from "./CapabilityRegistry";
47
+
48
+ cc.Enum(EnumCtrlName);
49
+ cc.Enum(EnumStateName);
50
+ cc.Enum(EnumExcludeSlot);
51
+ cc.Enum(EnumPropName);
52
+
53
+ /** 属性类型 */
54
+ export type TPropValue = number | boolean | string | cc.Vec3 | cc.Vec2 | cc.Color | cc.Size | cc.Quat | cc.SpriteFrame | cc.Font | undefined;
55
+
56
+ type TPropDictionary = {
57
+ [propType: number]: TPropValue
58
+ };
59
+
60
+ /** 🔧 架构重构:新的属性数据结构 */
61
+ export type TProp = TPropDictionary & {
62
+ /** 上一次选择的属性 */
63
+ $$lastProp$$?: EnumPropName
64
+
65
+ /** 🔧 新增:受控属性列表(控制复选框状态) */
66
+ $$controlledProps$$?: { [propName: string]: EnumPropName }
67
+
68
+ /** 🔧 新增:已变更属性数据(实际保存的数据) */
69
+ $$propertyData$$?: { [propType: number]: TPropValue }
70
+
71
+ /** 🔧 兼容性:保留原有的changedProp结构(逐步迁移) */
72
+ $$changedProp$$?: { [name: string]: EnumPropName }
73
+ };
74
+
75
+ type TPage = {
76
+ /** v2 storage: state data is keyed by StateValue.stateId, not mutable state index. */
77
+ $$stateKeyMode$$?: "stateId"
78
+ /** 上次选择的状态 */
79
+ // $$lastState$$?: number,
80
+ /** 默认状态属性 */
81
+ $$default$$?: TProp
82
+ [state: number]: TProp
83
+ };
84
+
85
+ type TCtrl = {
86
+ [stateId: string]: TPage
87
+ };
88
+
89
+ // 项目内 Component 脚本不传类名: 引擎按 frame.script(文件名 "StateSelectV2")自动注册,
90
+ // 避免 editor 告警 3616. getComponent('StateSelectV2') 与 cid 序列化不受影响.
91
+ @ccclass
92
+ @menu("State/StateSelectV2")
93
+ @executeInEditMode()
94
+ @disallowMultiple()
95
+ export class StateSelectV2 extends cc.Component {
96
+ // #region 1. 序列化字段与字段访问器
97
+ // cocos @property 层 + 主要 getter/setter, 必须留在 StateSelectV2 类上
98
+ // (服务于反射 / 编辑器 inspector / 场景序列化)
99
+
100
+ /** root节点所有的ctrl */
101
+ @property({ visible: false })
102
+ private _ctrlsMap: { [ctrlId: string]: StateControllerV2 } = {};
103
+
104
+ /** 当前选中的ctrl名称对应的ctrlId (反序列化字段, panel 接管, inspector 隐藏) */
105
+ @property({ type: EnumCtrlName, visible: false })
106
+ private _currCtrlId: number = null;
107
+
108
+ /** 控制器所在节点 (反序列化字段, panel 接管, inspector 隐藏) */
109
+ @property({ type: cc.Node, visible: false })
110
+ private _root: cc.Node = null;
111
+
112
+ /** 控制器所在节点 (root getter, panel 接管, inspector 隐藏) */
113
+ @property({ type: cc.Node, visible: false, tooltip: "控制器所在节点,仅提示用", readonly: true })
114
+ public get root() {
115
+ return this._root;
116
+ }
117
+
118
+ /** 当前状态要改变的属性 (panel 接管, inspector 隐藏) */
119
+ @property({ type: EnumPropName, visible: false })
120
+ private _propKey: EnumPropName = EnumPropName.Non;
121
+
122
+ /** 当前状态要改变的属性值 (panel 接管, inspector 隐藏) */
123
+ @property({ visible: false })
124
+ private _propValue: TPropValue = null;
125
+
126
+ /** 🔧 新增:界面标识变量 - 用于标明当前正在展示属性值的属性类型 (panel 接管, inspector 隐藏) */
127
+ @property({ type: EnumPropName, visible: false })
128
+ private _currentDisplayProp: EnumPropName = EnumPropName.Non;
129
+
130
+ @property({ visible: false })
131
+ private _isDeleteCurr: boolean = false;
132
+
133
+ /** Track1: 切 state 复位 propKey 时置真, 抑制 handleValidPropSelection 的回写/物化 (非序列化, 瞬态). */
134
+ private _suppressPropCapture: boolean = false;
135
+
136
+ // #endregion
137
+
138
+ // #region W6-4 inspector 排除清单 UI
139
+ // 三件套 inspector @property: 排除清单 readonly + 添加排除下拉 + 恢复跟随下拉
140
+ // 走 cocos 2.x 原生 @property + cc.Class.Attr.setClassAttr 动态注入 enumList (套 TASK-001 homePageState 模板).
141
+ // 不在 __preload 之前实例化, 因为 SYSTEM_EXCLUDE 需要 require IntrospectionMod.
142
+
143
+ /**
144
+ * W6-4: inspector 显示当前所有被排除的 prop (SYSTEM_EXCLUDE + _userExcludedProps).
145
+ * Readonly 列表, 用户在下方 + 添加排除 / - 恢复跟随 下拉操作.
146
+ */
147
+ /** 普通访问器 (含 reconcile 副作用), inspector 可见性由 excludeGroup 折叠组代理. */
148
+ public get excludedPropsDisplay(): string[] {
149
+ // W6-4 C 方案: inspector 渲染时机做 reconcile (idempotent, O(N) 小数组). 用户在 _userExcludedProps
150
+ // 数组 inspector +/- 后, 这里 diff 上次快照 → 触发 togglePropertyControl 同步跟随状态.
151
+ this.reconcileUserExcluded();
152
+ // 双标记 (纯显示, 不改 _userExcludedProps 原始数据 / 下拉 enumList): 系统项加 [系统];
153
+ // 用户失效项 (不在当前 listTrackableProps, 多因组件被删 / prop 改名) 加 [失效] 提示可在 - 下拉里清掉.
154
+ // trackable 计算失败时 (node 缺失等) 置 null, 退化为不打失效标记, 避免误标.
155
+ let trackable: Set<string> | null = null;
156
+ try {
157
+ if (this.node) trackable = new Set(listTrackableProps(this.node).map(p => p.propRef));
158
+ }
159
+ catch {
160
+ trackable = null;
161
+ }
162
+ const sysMarked = SYSTEM_EXCLUDE.map(r => `[系统] ${r}`);
163
+ const userMarked = (this._userExcludedProps || []).map(r => (trackable && !trackable.has(r)) ? `[失效] ${r}` : r);
164
+ return [...sysMarked, ...userMarked];
165
+ }
166
+
167
+ /**
168
+ * W6-4 C 方案: diff _userExcludedProps vs _lastSeenExcluded, 同步 togglePropertyControl.
169
+ * 加项 → togglePropertyControl(propRef, false) 从跟随移除. 删项 → togglePropertyControl(propRef, true) 重新接入.
170
+ * idempotent: 已同步过的状态不重复 trigger.
171
+ */
172
+ private reconcileUserExcluded(): void {
173
+ if (!CC_EDITOR) return;
174
+ const current = this._userExcludedProps || [];
175
+ const last = this._lastSeenExcluded || [];
176
+ const currentSet = new Set(current);
177
+ const lastSet = new Set(last);
178
+ // 新加项: 在 current 不在 last → 排除 (从跟随中移除)
179
+ for (const propRef of current) {
180
+ if (!lastSet.has(propRef)) {
181
+ try {
182
+ this.togglePropertyControl(propRef, false);
183
+ }
184
+ catch (e) {
185
+ StateErrorManager.warn("reconcileUserExcluded: 排除失败", { component: "StateSelectV2", method: "reconcileUserExcluded", params: { propRef, error: (e as Error).message } });
186
+ }
187
+ }
188
+ }
189
+ // 删除项: 在 last 不在 current → 重新跟随
190
+ for (const propRef of last) {
191
+ if (!currentSet.has(propRef)) {
192
+ try {
193
+ this.togglePropertyControl(propRef, true);
194
+ }
195
+ catch (e) {
196
+ StateErrorManager.warn("reconcileUserExcluded: 恢复跟随失败", { component: "StateSelectV2", method: "reconcileUserExcluded", params: { propRef, error: (e as Error).message } });
197
+ }
198
+ }
199
+ }
200
+ this._lastSeenExcluded = current.slice();
201
+ // 顺便刷下拉选项 (排除清单变, 下拉可选项要跟着变)
202
+ this.refreshExcludeEnumLists();
203
+ }
204
+
205
+ /**
206
+ * M2b-1: 干净的排除 mutation API — 显式替代 excludedPropsDisplay getter 的副作用路径
207
+ * (reconcileUserExcluded). 插件 inspector 行内排除徽标点击走此方法.
208
+ *
209
+ * 与 reconcile 同效但单次精确: 改 _userExcludedProps + 调 togglePropertyControl 同步跟随态,
210
+ * 并同步 _lastSeenExcluded 快照, 避免后续 excludedPropsDisplay getter reconcile 重复 toggle.
211
+ * 幂等: 重复排除不重复入列; 恢复未排除项 / 排除空 ref 安全.
212
+ *
213
+ * @param propRef 受跟踪 propRef ("cc.Sprite.spriteFrame" / "MyComp.heat" / "cc.Node.x")
214
+ * @param excluded true=排除(退出跟随); false=恢复跟随
215
+ */
216
+ public setPropExcluded(propRef: string, excluded: boolean): void {
217
+ if (!propRef) return;
218
+ if (!this._userExcludedProps) this._userExcludedProps = [];
219
+ const idx = this._userExcludedProps.indexOf(propRef);
220
+ if (excluded) {
221
+ if (idx === -1) this._userExcludedProps.push(propRef);
222
+ try {
223
+ this.togglePropertyControl(propRef, false);
224
+ }
225
+ catch (e) {
226
+ StateErrorManager.warn("setPropExcluded: 排除失败", { component: "StateSelectV2", method: "setPropExcluded", params: { propRef, error: (e as Error).message } });
227
+ }
228
+ }
229
+ else {
230
+ if (idx >= 0) this._userExcludedProps.splice(idx, 1);
231
+ try {
232
+ this.togglePropertyControl(propRef, true);
233
+ }
234
+ catch (e) {
235
+ StateErrorManager.warn("setPropExcluded: 恢复跟随失败", { component: "StateSelectV2", method: "setPropExcluded", params: { propRef, error: (e as Error).message } });
236
+ }
237
+ }
238
+ // 与 reconcile 路径快照一致, 避免下次 getter reconcile 把刚做的 toggle 又翻回去
239
+ this._lastSeenExcluded = this._userExcludedProps.slice();
240
+ this.refreshExcludeEnumLists();
241
+ }
242
+
243
+ /**
244
+ * W6-4 C 方案: "+ 添加排除" 下拉选项缓存 (instance scope, 非序列化).
245
+ * enumList value=v 对应 _addExcludeOptions[v-1] (value=0 是 sentinel "(选一个...)").
246
+ * refreshExcludeEnumLists 注入 enumList 时同步刷新.
247
+ */
248
+ private _addExcludeOptions: string[] = [];
249
+
250
+ /**
251
+ * W6-4 C 方案: "+ 添加排除" 快捷下拉. enumList index=0 是 sentinel "(选一个...)", 真实选项 value 从 1 起.
252
+ * getter 永远返回 0 → 用户操作完显示回到 sentinel (符合"未选"语义). setter 收 0 noop, 收 >0 处理.
253
+ * 处理逻辑: 反查 _addExcludeOptions[value-1] 得 propRef → push 到 _userExcludedProps (cocos 数组同步).
254
+ * 移除跟随由 reconcileUserExcluded 在下一次 inspector 渲染时统一做. 删除走 cocos 数组原生 - 按钮 (不再有 removeExcludeTrigger).
255
+ */
256
+ /** 普通访问器, inspector 可见性 + 动态 enumList 由 excludeGroup 折叠组代理. */
257
+ public get addExcludeTrigger(): number {
258
+ return 0;
259
+ }
260
+
261
+ public set addExcludeTrigger(v: number) {
262
+ if (!CC_EDITOR) return;
263
+ if (typeof v !== "number" || !Number.isFinite(v) || v === 0) return;
264
+ // v 是 enumList value (>=1), 真实选项 index = v-1
265
+ const propRef = this._addExcludeOptions[v - 1];
266
+ if (!propRef) return;
267
+ if (!this._userExcludedProps) this._userExcludedProps = [];
268
+ if (this._userExcludedProps.indexOf(propRef) === -1) {
269
+ this._userExcludedProps.push(propRef);
270
+ }
271
+ // reconcileUserExcluded 会在下次 excludedPropsDisplay getter 调用时同步 togglePropertyControl
272
+ // 但 setter 完成后 inspector 立即重渲染, 通常 getter 紧接着被调用. 这里也手动 reconcile 一下兜底:
273
+ this.reconcileUserExcluded();
274
+ }
275
+
276
+ /**
277
+ * 「- 恢复跟随」下拉选项缓存 (instance scope, 非序列化). 对称 _addExcludeOptions.
278
+ * enumList value=v 对应 _removeExcludeOptions[v-1] (value=0 是 sentinel "(选一个恢复跟随)").
279
+ * 列原始 _userExcludedProps 全列 (含失效项), refreshExcludeEnumLists 注入时同步刷新.
280
+ */
281
+ private _removeExcludeOptions: string[] = [];
282
+
283
+ /**
284
+ * 「- 恢复跟随」快捷下拉 (对称 addExcludeTrigger). enumList[0] 是 sentinel "(选一个恢复跟随)", 真实选项 value 从 1 起.
285
+ * getter 恒返回 0 (操作完回到未选). setter 收 0/非法 noop, 收 >0 反查 _removeExcludeOptions[v-1] 得 propRef →
286
+ * setPropExcluded(propRef, false): 从 _userExcludedProps 移除 (即便 ref 失效, splice 照常完成, togglePropertyControl 安全 no-op),
287
+ * 同时恢复跟随. 一个下拉同时覆盖 "逐项恢复" 与 "清理失效 key" 两个诉求.
288
+ */
289
+ /** 普通访问器, inspector 可见性 + 动态 enumList 由 excludeGroup 折叠组代理. */
290
+ public get removeExcludeTrigger(): number {
291
+ return 0;
292
+ }
293
+
294
+ public set removeExcludeTrigger(v: number) {
295
+ if (!CC_EDITOR) return;
296
+ if (typeof v !== "number" || !Number.isFinite(v) || v === 0) return;
297
+ const propRef = this._removeExcludeOptions[v - 1];
298
+ if (!propRef) return;
299
+ this.setPropExcluded(propRef, false);
300
+ }
301
+
302
+ /**
303
+ * W6-4 C 方案: 注入 addExcludeTrigger 下拉选项.
304
+ *
305
+ * 选项规则: enumList = [sentinel "(选一个...)" value=0, ...listTrackableProps - SYSTEM_EXCLUDE - _userExcludedProps value=1,2,...].
306
+ * 用户选 sentinel = noop, 选其它 = 加入 _userExcludedProps (cocos 数组自动同步显示).
307
+ * 删除走 _userExcludedProps 数组 cocos 原生 - 按钮 (无 removeExcludeTrigger 下拉).
308
+ *
309
+ * 调用时机: __preload 末尾 + addExcludeTrigger setter 后 + reconcileUserExcluded 内. idempotent.
310
+ */
311
+ public refreshExcludeEnumLists(): void {
312
+ if (!CC_EDITOR) return;
313
+ if (!this.node) return;
314
+ const userExcluded = new Set(this._userExcludedProps || []);
315
+ const systemExcluded = new Set(SYSTEM_EXCLUDE);
316
+ let trackableRefs: string[] = [];
317
+ try {
318
+ const list = listTrackableProps(this.node);
319
+ trackableRefs = list.map(p => p.propRef);
320
+ }
321
+ catch (e) {
322
+ StateErrorManager.warn("refreshExcludeEnumLists: listTrackableProps 失败", {
323
+ component: "StateSelectV2",
324
+ method: "refreshExcludeEnumLists",
325
+ params: { error: (e as Error).message },
326
+ });
327
+ }
328
+ // 当前可跟随 = trackable - SYSTEM - user (用户能从中再选一个排除)
329
+ const addList = trackableRefs.filter(r => !systemExcluded.has(r) && !userExcluded.has(r));
330
+ // enumList[0] 是 sentinel, 真实选项 value 从 1 起. setter 反查 _addExcludeOptions[v-1].
331
+ this._addExcludeOptions = addList;
332
+ const addEnum = [
333
+ { name: "(选一个加入排除)", value: 0 },
334
+ ...addList.map((r, i) => ({ name: r, value: i + 1 })),
335
+ ];
336
+ // 动态 enumList 必须注入到 excludeGroup 折叠组「类」上 (SelectExcludeGroup), 不能注入到实例.
337
+ // 编辑器读取嵌套 facade 的枚举选项走类的 __attrs__; 注入到实例只会写到 instance.__attrs__ 的
338
+ // own key (jest 经同一实例回读能过, 但编辑器读不到 → 下拉空 → 无法添加). 注入到类后, 实例的
339
+ // __attrs__ 原型链 (Object.create(类attrs)) 同样能读到, 两条读路径都成立.
340
+ // @ts-expect-error setClassAttr 在 cocos 2.x d.ts 中未声明
341
+ cc.Class.Attr.setClassAttr(SelectExcludeGroup, "addExcludeTrigger", "enumList", addEnum);
342
+
343
+ // 「- 恢复跟随」下拉: 列原始 _userExcludedProps 全列 (含失效项, 不经 trackable 过滤 → 失效 key 也可在此清理).
344
+ const removeList = (this._userExcludedProps || []).slice();
345
+ this._removeExcludeOptions = removeList;
346
+ const removeEnum = [
347
+ { name: "(选一个恢复跟随)", value: 0 },
348
+ ...removeList.map((r, i) => ({ name: r, value: i + 1 })),
349
+ ];
350
+ // @ts-expect-error setClassAttr 在 cocos 2.x d.ts 中未声明
351
+ cc.Class.Attr.setClassAttr(SelectExcludeGroup, "removeExcludeTrigger", "enumList", removeEnum);
352
+ }
353
+ // #endregion W6-4
354
+
355
+ /** 状态数据 (反序列化存储, panel 接管, inspector 隐藏) */
356
+ @property({ visible: false })
357
+ private _ctrlData: TCtrl = {};
358
+
359
+ /**
360
+ * W6-1 用户排除清单 (inspector 隐藏, panel 接管).
361
+ *
362
+ * 用 propRef ("cc.Sprite.spriteFrame" / "MyComp.heat") 标记用户在 inspector
363
+ * 手动取消的 prop. W6-4 将在 inspector 加 UI 维护此列表, W6-2 把它合并到
364
+ * PrefabIntrospection.listTrackableProps 的过滤里.
365
+ *
366
+ * 当前 W6-1 仅占位, 默认空数组, 不破任何老路径.
367
+ */
368
+ /**
369
+ * W6-4 (C 方案): 用户自定义排除清单. cocos 2.x 数组 inspector 原生 +/- 按钮可直接增删.
370
+ * 删项时 excludedPropsDisplay getter 内 reconcile 自动重新接入跟随; 加项时反之自动从跟随中移除.
371
+ * 加项通常通过 "+ 添加排除" 下拉 (sentinel 防误触, 选项带 propRef 提示);
372
+ * 也可以直接点数组 + 自己手输, 但只有合法 propRef (在 listTrackableProps 内) 才会被 reconcile 应用.
373
+ */
374
+ // 序列化字段, inspector 不直显 (visible:false) — 由 excludeGroup.userExcludedProps 代理同一份数组引用展示/编辑.
375
+ @property({ type: [cc.String], visible: false })
376
+ public _userExcludedProps: string[] = []; // eslint-disable-line @typescript-eslint/naming-convention -- 序列化 key 固定, facade 跨类读写, 不可改名/私有
377
+
378
+ /** reconcile 用的上次快照, 用于 diff 触发 togglePropertyControl */
379
+ private _lastSeenExcluded: string[] = [];
380
+
381
+ /**
382
+ * 录制中的 snapshot (Wave 2 prefab diff 路径).
383
+ *
384
+ * onRecordingStart 时, 拍下当前节点上所有 controlled prop 的当前值,
385
+ * 切 state 或 stopRecording 时与当前节点状态做 diff, 把变化的 prop commit 到 fromState。
386
+ *
387
+ * 字段使用 plain (不加 @property), 不参与序列化, 录制态随 ctrl._recording 销毁。
388
+ */
389
+ private _snapshot: TProp | null = null;
390
+
391
+ /**
392
+ * 录制中的全 prop snapshot (含未勾选跟随的 applicable prop).
393
+ *
394
+ * 用途: 手动 stopRecording 时检测"未跟随的 prop 在录制期间被改了" — 弹窗问
395
+ * 是否要追加跟随并保存. 切 state 自动 stop 时仅记日志 (走 D1 路径).
396
+ *
397
+ * 拍快照范围: PropHandlerManager.getValue(prop, node) !== undefined 的所有 prop
398
+ * (== 节点挂了对应 cc.Component 的 prop). 不影响 _snapshot 的 commit 路径.
399
+ */
400
+ private _fullSnapshot: TProp | null = null;
401
+
402
+ /**
403
+ * 录制开始时的"不可变"snapshot (TASK-002 cancelRecording 用).
404
+ *
405
+ * 与 _snapshot 区别: _snapshot 在 commitRecordingDiff 中会被刷新成新值 (作为下一段 diff 起点),
406
+ * 不再代表"录制开始时的原值"; _initialSnapshot 拍完不变, 让 cancel 能精确回滚.
407
+ *
408
+ * 字段非 @property, 不序列化, onRecordingStart 拍, onRecordingStop/cancelRecording 清.
409
+ */
410
+ private _initialSnapshot: TProp | null = null;
411
+
412
+ /**
413
+ * #S3: 录制开始时 fromState propData 中**本就存在**的非 $$ key 集 (propRef / number)。
414
+ * cancelRecording 时, 对"录制前依赖 default(不在此集)"的 key 删除而非硬写, 保持动态 default 兜底。
415
+ * 非 @property, onRecordingStart 拍, applyRecordingSnapshot/onRecordingStop 清。
416
+ */
417
+ private _initialPropDataKeys: Set<string> | null = null;
418
+
419
+ /**
420
+ * 录制开始时被排除 prop 的纯节点值快照 (propRef → 录制前节点上的值).
421
+ *
422
+ * 用户裁定: 任何被排除的属性, 录制结束(停止 stop 或取消 cancel)后都应还原到录制前 —— 排除 =
423
+ * 录制期间完全不影响该属性, 不管最终保存还是丢弃. 已跟随 prop 的改后值在 stop 时正常 commit, 不受影响.
424
+ * 被排除 prop 不进 _snapshot/_initialSnapshot/_fullSnapshot (不变量#8: 排除不进状态数据), 所以
425
+ * stop/cancel 走 ctrlData 那条路够不到它们; 这里单独拍一份, 由 restoreExcludedSnapshotToNode 写回节点.
426
+ * 非 @property, 不序列化, onRecordingStart 拍, onRecordingStop/applyRecordingSnapshot 收尾时还原+清.
427
+ */
428
+ private _excludedSnapshot: { [propRef: string]: any } | null = null;
429
+
430
+ /** 用于检测父节点变化 */
431
+ private lastParent: cc.Node = null;
432
+ private parentCheckInterval: ReturnType<typeof setInterval> = null;
433
+
434
+ // #region 控制器当前状态 (StateSelectV2 上的切 state 快捷入口, 镜像 ctrl.selectedIndex)
435
+ @property({ type: EnumStateName, displayName: "state", tooltip: "切到指定 state (镜像 controller.selectedIndex, 改这里 = 改 ctrl)" })
436
+ public get ctrlState() {
437
+ const ctrl = this.getCurrCtrl();
438
+ if (!ctrl) {
439
+ StateErrorManager.warn("ctrlState getter: 控制器为空", {
440
+ component: "StateSelectV2",
441
+ method: "ctrlState.getter",
442
+ });
443
+ return 0;
444
+ }
445
+ return ctrl.selectedIndex;
446
+ }
447
+
448
+ private set ctrlState(value: number) {
449
+ const ctrl = this.getCurrCtrl();
450
+ if (!ctrl) {
451
+ StateErrorManager.warn("ctrlState setter: 控制器为空", {
452
+ component: "StateSelectV2",
453
+ method: "ctrlState.setter",
454
+ });
455
+ return;
456
+ }
457
+ ctrl.selectedIndex = value;
458
+ }
459
+ // #endregion
460
+
461
+ // #region 控制器名称
462
+ @property({ type: EnumCtrlName, visible: false, displayName: "Ctrl Name", tooltip: "选择的控制器 (panel 接管, inspector 隐藏)" })
463
+ public get currCtrlId() {
464
+ return this._currCtrlId;
465
+ }
466
+
467
+ private set currCtrlId(value: number) {
468
+ if (!CC_EDITOR) {
469
+ return;
470
+ }
471
+ if (!value) {
472
+ StateErrorManager.warn("currCtrlId setter: value is null", {
473
+ component: "StateSelectV2",
474
+ method: "currCtrlId.setter",
475
+ });
476
+ this._currCtrlId = null;
477
+ return;
478
+ }
479
+ this._currCtrlId = value;
480
+ this.updateCtrlPage(this.getCurrCtrl());
481
+ }
482
+ // #endregion
483
+
484
+ /** 🔧 简化:当前选中的属性(内部使用,不显示在编辑器中) */
485
+ public get propKey() {
486
+ return this._propKey;
487
+ }
488
+
489
+ private set propKey(value: EnumPropName) {
490
+ if (!CC_EDITOR) {
491
+ return;
492
+ }
493
+
494
+ StateErrorManager.debug("开始设置属性键", {
495
+ component: "StateSelectV2",
496
+ method: "propKey.setter",
497
+ params: { oldPropKey: EnumPropName[this._propKey], newPropKey: EnumPropName[value] },
498
+ });
499
+
500
+ // 🔧 第一步:验证控制器有效性
501
+ const ctrl = this.getCurrCtrl();
502
+ if (!ctrl) {
503
+ StateErrorManager.warn("propKey setter: 控制器为空", {
504
+ component: "StateSelectV2",
505
+ method: "propKey.setter",
506
+ });
507
+ return;
508
+ }
509
+
510
+ // 🔧 第二步:处理属性设置逻辑
511
+ if (value === EnumPropName.Non) {
512
+ this._propKey = EnumPropName.Non;
513
+ this.setPropValue(EnumPropName.Non);
514
+ StateErrorManager.debug("设置属性为Non", {
515
+ component: "StateSelectV2",
516
+ method: "propKey.setter",
517
+ });
518
+ }
519
+ else {
520
+ this.handleValidPropSelection(value);
521
+ }
522
+
523
+ // 🔧 第三步:更新UI显示
524
+ this.updateChangedProp();
525
+
526
+ StateErrorManager.info("属性键设置完成", {
527
+ component: "StateSelectV2",
528
+ method: "propKey.setter",
529
+ params: { finalPropKey: EnumPropName[this._propKey] },
530
+ });
531
+ }
532
+
533
+ /** 🔧 新增:处理有效属性选择 */
534
+ private handleValidPropSelection(value: EnumPropName) {
535
+ const propValue = this.handleValue(value);
536
+ if (propValue === undefined) {
537
+ StateErrorManager.warn("无法获取属性值", {
538
+ component: "StateSelectV2",
539
+ method: "handleValidPropSelection",
540
+ params: { propType: EnumPropName[value] },
541
+ });
542
+ // 🔧 如果无法获取属性值,保持当前状态不变
543
+ return;
544
+ }
545
+
546
+ // 🔧 第二步:设置属性状态(确保属性值有效后再设置)
547
+ this._propKey = value;
548
+ this.setPropValue(value); // 显示属性值字段
549
+
550
+ // Track1 序列化瘦身: 切 state 触发的 propKey 复位仅为"保持显示选择", 不应把切 state 后
551
+ // 节点(可能刚被上一 state apply 改写)的当前值回写进 ctrlData / 物化到所有 state —— 否则:
552
+ // (1) 每次切 state 即把各 state 物化 (re-bloat, 抵消瘦身);
553
+ // (2) angle/eulerAngles 别名冲突时把被 apply 改坏的节点值污染进 state 存值。
554
+ // 真正的用户改值走录制/setDefaultProp 路径, 不经此 setter。
555
+ if (this._suppressPropCapture) {
556
+ return;
557
+ }
558
+
559
+ // 🔧 第三步:更新数据结构
560
+ this.updatePropData(value, propValue);
561
+
562
+ // 🔧 第四步:处理自动同步(固定启用)
563
+ if (this.autoSyncEnabled) {
564
+ this.syncPropToAllStatesInternal(value);
565
+ }
566
+ }
567
+
568
+ /** 🔧 新增:更新属性数据 */
569
+ private updatePropData(propKey: EnumPropName, propValue: TPropValue) {
570
+ const propData = this.getPropData();
571
+
572
+ // W6-2c2: 写 string propRef key (内置 36 + AMBIGUOUS 3 项), fallback number key.
573
+ this.writePropByEnum(propData, propKey, propValue);
574
+
575
+ // 🔧 记录上次选择的属性
576
+ propData.$$lastProp$$ = propKey;
577
+
578
+ // 🔧 更新已更改属性记录
579
+ propData.$$changedProp$$ = propData.$$changedProp$$ || {};
580
+ propData.$$changedProp$$[EnumPropName[propKey]] = propKey;
581
+ }
582
+
583
+ /** 🔧 简化:内部使用的已改变属性列表(不显示在编辑器中) */
584
+ public changedProp: string[] = [];
585
+
586
+ /** 刷新上次选中属性 */
587
+ private refProp() {
588
+ const propData = this.getPropData();
589
+ const lastProp = propData.$$lastProp$$;
590
+
591
+ // 🔧 修复:确保lastProp不为0(EnumPropName.Non),因为0代表"不选择"状态
592
+ if (lastProp && lastProp > EnumPropName.Non) {
593
+ this.propKey = lastProp;
594
+ }
595
+ else {
596
+ this.propKey = EnumPropName.Non;
597
+ }
598
+ }
599
+
600
+ // #endregion 1.
601
+
602
+ // #region 2. 生命周期
603
+ // __preload / onLoad / onDestroy + 节点变化通知
604
+
605
+ private _isPreloaded = false;
606
+
607
+ // eslint-disable-next-line @typescript-eslint/naming-convention
608
+ protected __preload() {
609
+ if (!CC_EDITOR) {
610
+ return;
611
+ }
612
+ if (this._isPreloaded) {
613
+ StateErrorManager.debug("跳过重复预加载", {
614
+ component: "StateSelectV2",
615
+ method: "__preload",
616
+ });
617
+ return;
618
+ }
619
+ this._isPreloaded = true;
620
+
621
+ // W6-2c1: 极简 migration framework, 当前只丢 GrayScale (LEGACY_DROPPED_ENUMS),
622
+ // c2 扩 ENUM_TO_PROPREF 36 项 number→string key 迁移. 在所有 __preload 主逻辑之前
623
+ // 跑一次, 确保后续 updateCtrlName / autoOptIn / dispatch 看到的 _ctrlData 已清理.
624
+ this.migrateLegacyCtrlData();
625
+
626
+ // Track1 序列化瘦身: 读旧 fat prefab 后就地规范化为 compact (受控集只留 default, 各 state 只留
627
+ // 与 default 不同的 override), 之后内存即 compact, 下次存盘输出紧凑结构. 幂等, compact 数据再跑是 no-op.
628
+ this.compactCtrlData();
629
+
630
+ // inspector 折叠组 facade 的 owner 回引
631
+ this.excludeGroup.owner = this;
632
+ this.recording.owner = this;
633
+ this.valueOps.owner = this;
634
+
635
+ // IMPL-001.6: 通知控制器缓存失效
636
+ this.notifyControllerCacheDirty();
637
+
638
+ StateErrorManager.debug("开始StateSelectV2预加载", {
639
+ component: "StateSelectV2",
640
+ method: "__preload",
641
+ params: { hasCurrentCtrl: !!this.currCtrlId },
642
+ });
643
+
644
+ // 🔧 第一步:初始化控制器映射
645
+ this.updateCtrlName(this.node.parent);
646
+
647
+ // 🔧 第二步:如果没有当前控制器,尝试自动选择第一个
648
+ if (!this.currCtrlId) {
649
+ const ctrlIdKeys = Object.keys(this._ctrlsMap);
650
+ if (ctrlIdKeys.length > 0) {
651
+ // 找到控制器,设置为当前控制器并初始化
652
+ this.currCtrlId = Number(ctrlIdKeys[0]);
653
+ StateErrorManager.info("自动选择控制器", {
654
+ component: "StateSelectV2",
655
+ method: "__preload",
656
+ params: { selectedCtrlId: this.currCtrlId, availableControllers: ctrlIdKeys.length },
657
+ });
658
+ this.migrateStateIndexKeysForCtrl(this.getCurrCtrl());
659
+ this.updateCtrlPage(this.getCurrCtrl());
660
+ this.refProp();
661
+ }
662
+ else {
663
+ // 没有找到控制器,清理状态
664
+ StateErrorManager.warn("未找到可用的控制器", {
665
+ component: "StateSelectV2",
666
+ method: "__preload",
667
+ });
668
+ // @ts-expect-error _onPreDestroy is not typed
669
+ this._onPreDestroy();
670
+ }
671
+ }
672
+ else {
673
+ // 已有当前控制器,更新页面并恢复属性选择
674
+ StateErrorManager.debug("使用现有控制器", {
675
+ component: "StateSelectV2",
676
+ method: "__preload",
677
+ params: { currentCtrlId: this.currCtrlId },
678
+ });
679
+ this.migrateStateIndexKeysForCtrl(this.getCurrCtrl());
680
+ this.updateCtrlPage(this.getCurrCtrl());
681
+ this.refProp();
682
+ }
683
+
684
+ // W6-axis-decomp X 方案: 自动接入完全走 propRef 字符串路径 (autoOptInCustomComponentProps).
685
+ // 老路径 autoOptInApplicableProps (EnumPropName 数字 key) 已废 — 双轨设计在 AMBIGUOUS
686
+ // (Position/Anchor/Size) 上的冲突 (排子项 cc.Node.x 但整体 'Position' 仍跟踪) 由此彻底切根:
687
+ // - cc.Node.x/y/z/scaleX/scaleY/anchorX/anchorY/width/height 等子项独立接入
688
+ // - EnumPropName.Position/Anchor/Size 整体路径自动接入零次
689
+ // EnumPropName / PropHandlerManager 类 facade 保留, 老调用方 togglePropertyControl(EnumPropName.X)
690
+ // 仍可主动调 (会写名字 key 'Position' 进 $$controlledProps$$, 与 propRef 路径共存; bridge
691
+ // 由 isPropertyControlled / togglePropertyControl(_, false) 处理跨 key 一致性).
692
+ if (this.currCtrlId) {
693
+ this.autoOptInCustomComponentProps();
694
+ }
695
+
696
+ // W6-4: inspector 排除清单下拉 enumList 初始化 (idempotent, 重新 __preload 也可调).
697
+ // 必须在所有 controlled 状态稳定后调 (autoOptIn 完, _userExcludedProps 已应用).
698
+ this.refreshExcludeEnumLists();
699
+
700
+ StateErrorManager.info("StateSelectV2预加载完成", {
701
+ component: "StateSelectV2",
702
+ method: "__preload",
703
+ params: { finalCtrlId: this.currCtrlId, propKey: EnumPropName[this._propKey] },
704
+ });
705
+ }
706
+
707
+ /**
708
+ * W6-axis-decomp X 方案: autoOptInApplicableProps 已废 — 自动接入完全走 propRef 字符串路径.
709
+ *
710
+ * 历史: W6-2a 以前用 EnumPropName 数字路径自动接入 8 个 cc.Node 基础 prop (Active/Opacity/Color/
711
+ * Position/Anchor/Size/Scale/Euler), 与 autoOptInCustomComponentProps 双轨并行. AMBIGUOUS 项
712
+ * (Position=Vec3 整体 + cc.Node.x/y/z 子项) 双轨同时跟踪 → 用户排子项 cc.Node.x 不生效.
713
+ *
714
+ * X 方案后 (W6 终态全 cocos 内省): 全部走 propRef 单一路径 — autoOptInCustomComponentProps
715
+ * 取消 isEnumMappedPropRef filter, 改为接入所有 listTrackableProps 返回的 propRef (除 AMBIGUOUS
716
+ * 整体 + state machine 自身组件). EnumPropName / PropHandlerManager facade 保留, 老调用方
717
+ * (panel / capability) 仍可主动调 togglePropertyControl(EnumPropName.X) — bridge 见
718
+ * isPropertyControlled / togglePropertyControl(_, false) 跨 key 一致性处理.
719
+ */
720
+
721
+ /**
722
+ * W6-2a: state controller 自身组件名单. 这些 component 是 state machine 基础设施,
723
+ * 不该被自己控制. (W6-2c 会从 spec 抽出更完整的 controller-system blacklist.)
724
+ */
725
+ private static readonly CONTROLLER_SYSTEM_COMPS: ReadonlyArray<string> = [
726
+ "StateSelectV2", "StateControllerV2", "StateValue",
727
+ // 防御: werewolf 内同存旧版 state-controller, V2 内省一并跳过旧 cid
728
+ "StateSelect", "StateController", "stateValue",
729
+ ];
730
+
731
+ /**
732
+ * W6-axis-decomp X 方案: 节点上所有 trackable prop 自动接入 (走 propRef 字符串路径单一通道).
733
+ *
734
+ * 过滤策略 (相比 W6-2a 取消了 cc.Node + isEnumMappedPropRef 过滤, 改为单一 AMBIGUOUS 整体跳过):
735
+ * 1) compName in CONTROLLER_SYSTEM_COMPS — state machine 自身组件不接入
736
+ * 2) isAmbiguousAggregatePropRef(propRef) — AMBIGUOUS 整体 propRef ('cc.Node.position'/
737
+ * '.anchorPoint'/'.contentSize') 不接入, 让其子项 cc.Node.x/y/z/anchorX/anchorY/width/height
738
+ * 独立接入 (X 方案核心 — 切根 AMBIGUOUS 双轨冲突)
739
+ * 3) tp.readonly — 只读 (getter only) 字段写不进去, 不接入
740
+ * 4) _userExcludedProps 用户黑名单 (W6-1 + W6-4 panel UI 维护)
741
+ * 5) 已在 controlledProps 中跳过 (idempotent)
742
+ *
743
+ * SYSTEM_EXCLUDE 在 listTrackableProps 内部已过滤, 这里不重复.
744
+ */
745
+ private autoOptInCustomComponentProps(): void {
746
+ if (!CC_EDITOR) return;
747
+ if (!this.node) return;
748
+ const userExcluded = new Set(this._userExcludedProps || []);
749
+ const systemComps = new Set(StateSelectV2.CONTROLLER_SYSTEM_COMPS);
750
+ let trackable: TrackableProp[] = [];
751
+ try {
752
+ trackable = listTrackableProps(this.node);
753
+ }
754
+ catch (e) {
755
+ StateErrorManager.warn("autoOptInCustomComponentProps: listTrackableProps 失败", {
756
+ component: "StateSelectV2",
757
+ method: "autoOptInCustomComponentProps",
758
+ params: { error: (e as Error).message },
759
+ });
760
+ return;
761
+ }
762
+ let enabled = 0;
763
+ for (const tp of trackable) {
764
+ // state controller 自身组件不接入
765
+ if (systemComps.has(tp.compName)) continue;
766
+ // W6-axis-decomp X 方案: AMBIGUOUS 整体 propRef 不接入, 让子项独立 (SPEC line52 auto-opt 跳过聚合)。
767
+ // (#V1 驳回: euler 子项全 SYSTEM_EXCLUDE → 默认不自动接入, 此为 spec 设计; euler 仍可手动
768
+ // togglePropertyControl(Euler) 接入, 走整体聚合回退保 z。auto-opt 接入 euler 会破坏 roundTrip 且违 spec。)
769
+ if (isAmbiguousAggregatePropRef(tp.propRef)) continue;
770
+ // readonly 字段 (getter only) 写不进去, 不接入
771
+ if (tp.readonly) continue;
772
+ // 用户黑名单
773
+ if (userExcluded.has(tp.propRef)) continue;
774
+ // 已接入 (e.g. 二次 __preload) 跳过
775
+ if (this.isPropertyControlledByPropRef(tp.propRef)) continue;
776
+ try {
777
+ this.togglePropertyControlByPropRefAllStates(tp.propRef, true);
778
+ enabled++;
779
+ }
780
+ catch (e) {
781
+ StateErrorManager.warn("autoOptInCustomComponentProps: togglePropertyControlByPropRef 失败", {
782
+ component: "StateSelectV2",
783
+ method: "autoOptInCustomComponentProps",
784
+ params: { propRef: tp.propRef, error: (e as Error).message },
785
+ });
786
+ }
787
+ }
788
+ StateErrorManager.info("StateSelectV2 prop 自动接入完成 (X 方案 propRef 单一路径)", {
789
+ component: "StateSelectV2",
790
+ method: "autoOptInCustomComponentProps",
791
+ params: { enabledCount: enabled, trackableCount: trackable.length },
792
+ });
793
+ }
794
+
795
+ /**
796
+ * W6-2a: 把 propRef 接入到当前 ctrl 的**所有 state + default** (与老路径
797
+ * syncPropToAllStatesInternal 同效, 但走 propRef string key). 仅在 __preload
798
+ * 自动接入路径用一次, 后续用户在 panel/inspector 手动 opt-in/out 走单 state 路径.
799
+ */
800
+ private togglePropertyControlByPropRefAllStates(propRef: string, on: boolean): void {
801
+ if (!CC_EDITOR) return;
802
+ if (!this.node) return;
803
+ const ctrl = this.getCurrCtrl();
804
+ if (!ctrl) return;
805
+ const pageData = this.getPageData();
806
+ if (!on) {
807
+ // 全局移除 (含旧 fat 数据各 state 的残留 flag), 复用 #C6 全删路径.
808
+ this.removeControlledFlagAllStates(propRef);
809
+ return;
810
+ }
811
+ // Track1 序列化瘦身: opt-in 只种 $$default$$ (schema flag + baseline 值).
812
+ // 控制是全局 all-or-nothing (#C6), default 即受控真值源 (getControlledPropsMap 据此读),
813
+ // 各 state 不再内联 controlledProps/baseline → 切 state 的值由 apply 路径 state→default 兜底,
814
+ // state 仅在用户真正改值时(commitRecordingDiff/setDefaultProp) 写 override → compact 序列化.
815
+ const tp = this.resolveTrackableProp(propRef);
816
+ const cocosType = tp ? tp.cocosType : undefined;
817
+ const current = this.readNodeValueByPropRef(propRef);
818
+ if (pageData.$$default$$ == null) pageData.$$default$$ = {} as TProp;
819
+ const dd = pageData.$$default$$ as any;
820
+ dd.$$controlledProps$$ = dd.$$controlledProps$$ || {};
821
+ dd.$$controlledProps$$[propRef] = propRef;
822
+ if (dd[propRef] === undefined && current !== undefined) {
823
+ dd[propRef] = cloneValueByType(current, cocosType);
824
+ }
825
+ }
826
+
827
+ /**
828
+ * W6-2a: propRef 字符串版本的 isPropertyControlled. 查 ctrlData 内层 $$controlledProps$$
829
+ * 是否含该 propRef 作 key.
830
+ *
831
+ * T2 双轨统一 (X方案) 后: 内置与自定义 prop 在 $$controlledProps$$ 中**都**用 propRef 字符串
832
+ * 作 key (内置 'cc.Node.active' / 自定义 'MyComp.heat', addPropertyControl 写 propRef 自指 key),
833
+ * 不再有 EnumPropName 反查名字 key ("Active") 的第二条轨. 本方法直接查 propRef key.
834
+ */
835
+ public isPropertyControlledByPropRef(propRef: string): boolean {
836
+ return this.getControlledPropsMap()[propRef] !== undefined;
837
+ }
838
+
839
+ /**
840
+ * Track1 序列化瘦身: 受控集真值源 (ctrl 级). 控制是全局 all-or-nothing (#C6),
841
+ * $$default$$.$$controlledProps$$ 是权威集 —— opt-in 路径恒同时写 default
842
+ * (auto-opt 736/762, 单 state 819-820), opt-out 走 removeControlledFlagAllStates 全删,
843
+ * 故 default 恒为各 state 受控集的超集.
844
+ *
845
+ * 旧 fat 数据各 state 也内联同份 controlledProps; 新 compact 数据仅 default 持有.
846
+ * 取 default ∪ 当前 state 的并集: default 是权威超集(绝大多数 opt-in 都种 default), 但
847
+ * 单 state opt-in 且当时节点值 undefined 时只写了 state flag(819-820 在 current!==undefined 内),
848
+ * 故并上当前 state 以保 compact 前后行为一致. value 保留 number(内置 EnumPropName)/
849
+ * string(自定义 propRef 自指) 双形态, 调用方按 typeof 分发.
850
+ */
851
+ private getControlledPropsMap(ctrlId?: number): { [propRef: string]: EnumPropName | string } {
852
+ const pageData = this.getPageData(ctrlId);
853
+ const dd = pageData.$$default$$ as any;
854
+ const ddMap = (dd && dd.$$controlledProps$$) || {};
855
+ const sd = this.getPropData(undefined, ctrlId) as any;
856
+ const sdMap = (sd && sd.$$controlledProps$$) || {};
857
+ if (Object.keys(sdMap).length === 0) return ddMap;
858
+ if (Object.keys(ddMap).length === 0) return sdMap;
859
+ return { ...ddMap, ...sdMap };
860
+ }
861
+
862
+ /**
863
+ * W6-2a: propRef 字符串版本的 togglePropertyControl. 仅服务于自定义组件 prop (string key 路径).
864
+ * 内置 prop 不该走此方法, 仍用 togglePropertyControl(EnumPropName) (老路径写 number key).
865
+ *
866
+ * 写入策略:
867
+ * - on=true: 在 $$controlledProps$$ 记 propRef → propRef (string key 自指, 标记接入);
868
+ * propData[propRef] 写入节点当前值 (用 cloneValueByType 深拷, 走 cocos type 分发)
869
+ * - on=false: 仅删 $$controlledProps$$[propRef] (保留 propData[propRef] 数据, 与老 removePropertyControl 行为一致)
870
+ */
871
+ private togglePropertyControlByPropRef(propRef: string, on: boolean): void {
872
+ if (!CC_EDITOR) return;
873
+ if (!this.node) return;
874
+ const propData = this.getPropData();
875
+ if (!propData) return;
876
+ propData.$$controlledProps$$ = propData.$$controlledProps$$ || {};
877
+ if (on) {
878
+ propData.$$controlledProps$$[propRef] = propRef as any;
879
+ // 拍当前值作为 baseline
880
+ const tp = this.resolveTrackableProp(propRef);
881
+ const current = this.readNodeValueByPropRef(propRef);
882
+ if (current !== undefined) {
883
+ (propData as any)[propRef] = cloneValueByType(current, tp ? tp.cocosType : undefined);
884
+ // M3-2 修 #1 (apply 漏更新): 单 state 接入也补种 default baseline (若缺).
885
+ // 否则切到"无该 key"的 state 时 applyPropRefKeysToNode 无兜底 → 节点残留上个 state 的值.
886
+ // (auto-opt 走 togglePropertyControlByPropRefAllStates 写 default+全 state, 本单 state 路径
887
+ // 服务 promptUntracked / 晚于 __preload 挂的组件 / 手动单 state 接入, 需对齐补 default.)
888
+ const defaultData = this.getDefaultData();
889
+ if (defaultData && (defaultData as any)[propRef] === undefined) {
890
+ (defaultData as any)[propRef] = cloneValueByType(current, tp ? tp.cocosType : undefined);
891
+ }
892
+ // #C6: 补种 default 时也补 default 的受控 flag —— 否则 apply 门控会把"有值无 flag"的
893
+ // default baseline 当成已取消而 skip(applyMissingDefault 兜底失效)。与 auto-opt 写 default flag 对齐。
894
+ if (defaultData) {
895
+ (defaultData as any).$$controlledProps$$ = (defaultData as any).$$controlledProps$$ || {};
896
+ (defaultData as any).$$controlledProps$$[propRef] = propRef;
897
+ }
898
+ // #T3: 录制中途接入 → 把 baseline 注入 _snapshot, 否则 commitRecordingDiff 只遍历
899
+ // _snapshot(录制开始时拍的)→ 新接入 prop 的后续改动 stop 时丢失。
900
+ const recCtrl = this.getCurrCtrl();
901
+ if (recCtrl && recCtrl.isRecording && this._snapshot
902
+ && (this._snapshot as any)[propRef] === undefined) {
903
+ (this._snapshot as any)[propRef] = cloneValueByType(current, tp ? tp.cocosType : undefined);
904
+ }
905
+ }
906
+ }
907
+ else {
908
+ // #C6: 取消是全局 (用户裁定: 控制 all-or-nothing) —— 移除所有 state + default 的 flag,
909
+ // 配合 applyPropRefKeysToNode 门控, 取消后该 prop 冻结(不随 state 变)。数据保留(可再接入)。
910
+ this.removeControlledFlagAllStates(propRef);
911
+ }
912
+ }
913
+
914
+ /**
915
+ * #C6: 全局移除某 propRef (及其 EnumPropName 名字 key) 的受控 flag —— 所有 state + default。
916
+ * 取消 = 该属性整体退出管理(双端一致), 之后 applyPropRefKeysToNode 门控不再 apply 它 → 冻结。
917
+ */
918
+ private removeControlledFlagAllStates(propRef: string, propType?: EnumPropName): void {
919
+ const ctrl = this.getCurrCtrl();
920
+ const pageData = this.getPageData();
921
+ const nameKey = propType !== undefined ? EnumPropName[propType] : undefined;
922
+ const removeFrom = (data: TProp | undefined) => {
923
+ const cp = data && (data as any).$$controlledProps$$;
924
+ if (!cp) return;
925
+ delete cp[propRef];
926
+ if (nameKey) delete cp[nameKey];
927
+ };
928
+ removeFrom(pageData.$$default$$);
929
+ if (ctrl) {
930
+ for (let i = 0; i < ctrl.states.length; i++) {
931
+ const stateId = this.getStateIdByIndex(ctrl, i);
932
+ if (stateId >= 0) removeFrom(pageData[stateId]);
933
+ }
934
+ }
935
+ }
936
+
937
+ /**
938
+ * W6-2a: 解析 propRef → { compName, propKey, cocosType } (跑一次 listTrackableProps 查表).
939
+ * 慢路径, 仅 (write/apply 入口 + togglePropertyControlByPropRef 落值时) 用一次.
940
+ */
941
+ private resolveTrackableProp(propRef: string): TrackableProp | undefined {
942
+ try {
943
+ const list = listTrackableProps(this.node);
944
+ return list.find(p => p.propRef === propRef);
945
+ }
946
+ catch (_) {
947
+ return undefined;
948
+ }
949
+ }
950
+
951
+ /**
952
+ * W6-2a: 按 propRef 从节点上读当前值. cc.Node.* 走 node[propKey], 其它走 component[propKey].
953
+ * 返回 undefined 表示组件不存在或 prop 不存在.
954
+ *
955
+ * W6-axis-decomp X 方案 修正: 改用 lastIndexOf('.') 分隔, 正确处理含多个 '.' 的内置
956
+ * propRef (e.g. 'cc.Node.x' / 'cc.Label.string'). X 方案自动接入路径同时覆盖内置 + 自定义,
957
+ * 原先的 indexOf 假设 ("仅服务自定义组件, compName 不含 '.'") 已破, 必须按 lastIndexOf 走.
958
+ * 与 readPropFromNodeByPropRef 等价 — 保留独立方法以减小改动面.
959
+ */
960
+ private readNodeValueByPropRef(propRef: string): any {
961
+ if (typeof propRef !== "string") return undefined;
962
+ const lastDot = propRef.lastIndexOf(".");
963
+ if (lastDot <= 0 || lastDot >= propRef.length - 1) return undefined;
964
+ const compName = propRef.substring(0, lastDot);
965
+ const propKey = propRef.substring(lastDot + 1);
966
+ if (compName === "cc.Node") {
967
+ return (this.node as any)[propKey];
968
+ }
969
+ const comp = this.node.getComponent(compName as any);
970
+ if (!comp) return undefined;
971
+ return (comp as any)[propKey];
972
+ }
973
+
974
+ /**
975
+ * W6-2a-fixup: 按 propRef 从节点上读当前值 (Recording 路径专用, 支持所有 propRef).
976
+ *
977
+ * 与 readNodeValueByPropRef 区别: 用 lastIndexOf('.') 分隔, 正确处理含多个 '.' 的内置
978
+ * propRef (e.g. 'cc.Node.active' / 'cc.Label.string' / 'cc.Widget.alignMode'). 不依赖
979
+ * compName !== 'cc.Node' 的过滤前提.
980
+ *
981
+ * 调用方:
982
+ * - readAllApplicablePropsFromNode (扫所有 trackable 写 _fullSnapshot)
983
+ * - collectDirtyControlled (string propRef 分支读当前值)
984
+ * - detectUntrackedDirty (string key 分支读当前值)
985
+ *
986
+ * 返回 undefined 表示组件不存在或 prop 不存在.
987
+ */
988
+ private readPropFromNodeByPropRef(propRef: string): any {
989
+ if (typeof propRef !== "string") return undefined;
990
+ const lastDot = propRef.lastIndexOf(".");
991
+ if (lastDot <= 0 || lastDot >= propRef.length - 1) return undefined;
992
+ const compName = propRef.substring(0, lastDot);
993
+ const propKey = propRef.substring(lastDot + 1);
994
+ if (compName === "cc.Node") {
995
+ return (this.node as any)[propKey];
996
+ }
997
+ const comp = this.node.getComponent(compName as any);
998
+ if (!comp) return undefined;
999
+ return (comp as any)[propKey];
1000
+ }
1001
+
1002
+ /**
1003
+ * W6-2a: 按 propRef 把值写回节点. compName === "cc.Node" 走 node[propKey] = value,
1004
+ * 其它走 component[propKey] = value. cocosType-aware clone 由调用方负责.
1005
+ *
1006
+ * W6-axis-decomp X 方案 修正: 用 lastIndexOf('.') 分隔, 正确处理 'cc.Node.x' / 'cc.Label.string'
1007
+ * 等内置 propRef (含多个 '.'). 与 readNodeValueByPropRef 对称.
1008
+ */
1009
+ private writeNodeValueByPropRef(propRef: string, value: any): void {
1010
+ if (typeof propRef !== "string") return;
1011
+ const lastDot = propRef.lastIndexOf(".");
1012
+ if (lastDot <= 0 || lastDot >= propRef.length - 1) return;
1013
+ const compName = propRef.substring(0, lastDot);
1014
+ const propKey = propRef.substring(lastDot + 1);
1015
+ if (compName === "cc.Node") {
1016
+ (this.node as any)[propKey] = value;
1017
+ return;
1018
+ }
1019
+ const comp = this.node.getComponent(compName as any);
1020
+ if (!comp) return;
1021
+ (comp as any)[propKey] = value;
1022
+ }
1023
+
1024
+ /**
1025
+ * W6-2a: 枚举 propData 中所有 string key (排除 $$ meta key + EnumPropName 数字 key).
1026
+ * 仅自定义 propRef key, 用于 commit/apply 路径单独遍历.
1027
+ */
1028
+ private extractPropRefKeys(data: TProp): string[] {
1029
+ const out: string[] = [];
1030
+ if (!data) return out;
1031
+ for (const k of Object.keys(data)) {
1032
+ if (k.startsWith("$$")) continue;
1033
+ // EnumPropName 数字 key (老路径) — extractNumericPropKeys 处理
1034
+ if (!Number.isNaN(Number(k))) continue;
1035
+ out.push(k);
1036
+ }
1037
+ return out;
1038
+ }
1039
+
1040
+ protected onLoad() {
1041
+ if (!CC_EDITOR) {
1042
+ return;
1043
+ }
1044
+
1045
+ // 记录初始父节点
1046
+ this.lastParent = this.node.parent;
1047
+ // 2.x没有父节点变化监听,需要定时检测
1048
+ this.parentCheckInterval = setInterval(() => {
1049
+ this.checkParentChanged();
1050
+ }, 1000);
1051
+
1052
+ // Wave 2 T10: 删除 8 个 cc 事件 hook (position/color/scale/size/anchor/active/rotation/spriteframe).
1053
+ // 录制现在走 prefab diff 路径 (StateControllerV2.startRecording → snapshot → 切 state/stop 时 diff commit),
1054
+ // 不再依赖运行时 cc 事件; 顺手修复"无 cc 事件的 prop (button.interactable/label.string/widget.top) 无法录制"长期 bug。
1055
+ }
1056
+
1057
+ protected onDestroy() {
1058
+ // 清理父节点检测定时器
1059
+ if (this.parentCheckInterval) {
1060
+ clearInterval(this.parentCheckInterval);
1061
+ this.parentCheckInterval = null;
1062
+ }
1063
+
1064
+ // IMPL-001.6: 销毁时通知控制器缓存失效
1065
+ this.notifyControllerCacheDirty();
1066
+
1067
+ // Wave 2 T10: 8 个 cc 事件 hook 已删, 这里不再需要 off。
1068
+ }
1069
+
1070
+ /**
1071
+ * W6-2c2: ctrlData number key → string propRef key 迁移 (扩 c1 framework).
1072
+ *
1073
+ * 规则 (按 key 处理顺序):
1074
+ * 1) $$xxx$$ 元数据 key ($$controlledProps$$ / $$propertyData$$ / $$lastProp$$ / $$changedProp$$ / $$default$$):
1075
+ * 完全不动 (其内层值仍可能含 EnumPropName 数字, 是合法语义)
1076
+ * 2) 自定义 string propRef key (e.g. "MyComp.heat", 含 "." 的字符串): 完全不动
1077
+ * 3) 数字 key (propType):
1078
+ * a) LEGACY_DROPPED_ENUMS 命中 (e.g. GrayScale=15) → delete (W6-2c1 行为)
1079
+ * b) enumToPropRef() 命中 (ENUM_TO_PROPREF 36 项 + AMBIGUOUS 3 项 = 39 项) → 迁 string propRef key
1080
+ * c) 未命中 (无对应映射, 保守保留): 不动 + warn (理论上不应发生, 39+1 已覆盖所有 EnumPropName 实例)
1081
+ *
1082
+ * 内层 $$propertyData$$ 是 {[propType:number]: TPropValue} 形状, 按同规则扫 (c2 一并迁 string key).
1083
+ * $$default$$ state 是外层 propData 的 sibling, 按同规则递归 (Object.keys(ctrlPage) 已含 $$default$$).
1084
+ *
1085
+ * idempotent — 第二次扫已无数字 key, no-op. 老 .fire 加载后 __preload 跑一次, 之后 ctrlData
1086
+ * 内层 key 全是 string, c3 删 EnumPropName 后整体一致.
1087
+ */
1088
+ /**
1089
+ * Track1 序列化瘦身: 把 fat _ctrlData (每个 state 内联 $$controlledProps$$ + 全量值, auto-opt
1090
+ * 历史遗留) 规范化为 compact —— 受控集真值源上提到 $$default$$, 各 state 只保留与 default 不同的
1091
+ * override. apply 路径 state→default 兜底 (applyDataToNode:2777 / readPropByEnum:1163) 保证等价。
1092
+ *
1093
+ * 幂等: compact 数据再跑是 no-op. 在 __preload 读盘后跑一次, 内存即 compact, 下次存盘紧凑.
1094
+ * 不动 $$default$$ (它是 schema + baseline 的唯一权威副本)。
1095
+ */
1096
+ private compactCtrlData(): void {
1097
+ const ctrlData = this._ctrlData;
1098
+ if (!ctrlData) return;
1099
+ for (const ctrlId of Object.keys(ctrlData)) {
1100
+ const page = (ctrlData as any)[ctrlId];
1101
+ if (!page || typeof page !== "object") continue;
1102
+ const def = page.$$default$$;
1103
+ for (const stateKey of Object.keys(page)) {
1104
+ if (stateKey === "$$default$$") continue;
1105
+ const state = page[stateKey];
1106
+ if (!state || typeof state !== "object") continue;
1107
+ // 受控集真值源在 default → 删 per-state 内联副本 (62KB 级冗余主因)。
1108
+ if (state.$$controlledProps$$ !== undefined) delete state.$$controlledProps$$;
1109
+ // 删与 default 相等的值 (apply 兜底), 只留真正 override。
1110
+ if (def) {
1111
+ for (const pk of Object.keys(state)) {
1112
+ if (pk.startsWith("$$")) continue;
1113
+ if ((def as any)[pk] === undefined) continue; // default 无此键 → state 是唯一来源, 保留
1114
+ const tp = this.resolveTrackableProp(pk);
1115
+ const cocosType = tp ? tp.cocosType : undefined;
1116
+ if (eqValueByType(state[pk], (def as any)[pk], cocosType)) {
1117
+ delete state[pk];
1118
+ }
1119
+ }
1120
+ }
1121
+ }
1122
+ }
1123
+ }
1124
+
1125
+ private migrateLegacyCtrlData(): void {
1126
+ const ctrlData = this._ctrlData;
1127
+ if (!ctrlData) {
1128
+ return;
1129
+ }
1130
+ const dropSet = LEGACY_DROPPED_ENUMS || [];
1131
+ // 处理单一 propType 数字 key: drop | migrate | keep-with-warn
1132
+ // 返回 'dropped' / 'migrated' / 'kept', 调用方对外层 propData 用此结果更新形状
1133
+ const handleNumericKey = (dict: any, propKey: string): "dropped" | "migrated" | "kept" => {
1134
+ const numKey = Number(propKey);
1135
+ if (dropSet.indexOf(numKey) !== -1) {
1136
+ delete dict[propKey];
1137
+ return "dropped";
1138
+ }
1139
+ const propRef = enumToPropRef(numKey);
1140
+ if (propRef !== undefined) {
1141
+ // 迁移: 若 string propRef key 已存在, 优先保留已有 (W6-2a 写路径已迁的情况)
1142
+ if (dict[propRef] === undefined) {
1143
+ dict[propRef] = dict[propKey];
1144
+ }
1145
+ delete dict[propKey];
1146
+ return "migrated";
1147
+ }
1148
+ // 保守保留: 理论上 39+1 已覆盖所有 EnumPropName 实例
1149
+ return "kept";
1150
+ };
1151
+ const sweepPropDictionary = (dict: any): void => {
1152
+ if (!dict || typeof dict !== "object") {
1153
+ return;
1154
+ }
1155
+ for (const propKey of Object.keys(dict)) {
1156
+ // 跳过 $$xxx$$ 元数据 key
1157
+ if (propKey.startsWith("$$")) {
1158
+ continue;
1159
+ }
1160
+ // 仅 /^\d+$/ 才是 propType 数字 key; string propRef key 不动
1161
+ if (!/^\d+$/.test(propKey)) {
1162
+ continue;
1163
+ }
1164
+ handleNumericKey(dict, propKey);
1165
+ }
1166
+ };
1167
+ // W6-axis-decomp X 方案: 第二趟扫 — AMBIGUOUS 整体 propRef 值 (Vec3/Vec2/Size) → 拆子项 key.
1168
+ // 在 number→string 迁移之后跑, 这样 'cc.Node.position'=Vec3 (无论是直接老数据还是迁移产物) 都被覆盖.
1169
+ // 守卫: AMBIGUOUS_DECOMPOSE[propRef] 的拆解函数对非 Vec/Size 形状值 (e.g. stub 字符串) 返回 null,
1170
+ // 保留原值不动 (W6-2c2 老测试 stub-pos 字符串场景保持兼容).
1171
+ const sweepDecomposeAmbiguous = (dict: any): void => {
1172
+ if (!dict || typeof dict !== "object") return;
1173
+ for (const propRef of Object.keys(AMBIGUOUS_DECOMPOSE)) {
1174
+ if (!(propRef in dict)) continue;
1175
+ const decomposer = AMBIGUOUS_DECOMPOSE[propRef];
1176
+ const subPairs = decomposer(dict[propRef]);
1177
+ if (!subPairs) continue; // 形状不符 (e.g. stub 字符串) — 不动
1178
+ for (const [subRef, subVal] of subPairs) {
1179
+ // 整体 Vec3/Vec2/Size 是老 .fire 真实数据, 拆解为子项是数据迁移的权威值,
1180
+ // 强制覆盖 — 即使子项 key 已存在 (autoOptIn 在 __preload 时写过 baseline=0,
1181
+ // migration 必须用老 .fire 整体值替换). 已是子项形态的老用户场景见
1182
+ // sweepPropDictionary 之前的 number→string 迁移, 与本步骤不冲突.
1183
+ dict[subRef] = subVal;
1184
+ }
1185
+ delete dict[propRef];
1186
+ }
1187
+ };
1188
+ // #S4: 取某聚合 propRef 的子项 ref 列表 (用零探针调拆解函数, 仅取 key).
1189
+ const ambiguousSubRefs = (aggRef: string): string[] => {
1190
+ const probe = {
1191
+ x: 0, y: 0, z: 0, width: 0, height: 0,
1192
+ };
1193
+ const decomposer = AMBIGUOUS_DECOMPOSE[aggRef];
1194
+ const pairs = decomposer ? decomposer(probe) : null;
1195
+ return pairs ? pairs.map(p => p[0]) : [];
1196
+ };
1197
+ // #S4: 迁 $$controlledProps$$ 的 key — 数字 key → propRef string key; 聚合(数字或 string)→ 子项 ref.
1198
+ const sweepControlledPropsKeys = (cprops: any): void => {
1199
+ if (!cprops || typeof cprops !== "object") return;
1200
+ for (const key of Object.keys(cprops)) {
1201
+ if (key.startsWith("$$")) continue;
1202
+ if (!/^\d+$/.test(key)) continue; // 已是 string propRef key, 下面统一处理聚合
1203
+ const num = Number(key);
1204
+ if (dropSet.indexOf(num) !== -1) {
1205
+ delete cprops[key];
1206
+ continue;
1207
+ }
1208
+ const ref = enumToPropRef(num);
1209
+ if (ref === undefined) continue;
1210
+ if (isAmbiguousAggregatePropRef(ref)) {
1211
+ for (const sub of ambiguousSubRefs(ref)) cprops[sub] = sub;
1212
+ }
1213
+ else {
1214
+ cprops[ref] = ref;
1215
+ }
1216
+ delete cprops[key];
1217
+ }
1218
+ // 聚合 string ref 残留 (老数据或迁移产物) → 展开为子项 ref, 与 propData 拆子项对齐.
1219
+ for (const aggRef of Object.keys(AMBIGUOUS_DECOMPOSE)) {
1220
+ if (cprops[aggRef] !== undefined) {
1221
+ for (const sub of ambiguousSubRefs(aggRef)) cprops[sub] = sub;
1222
+ delete cprops[aggRef];
1223
+ }
1224
+ }
1225
+ };
1226
+ for (const ctrlIdKey of Object.keys(ctrlData)) {
1227
+ const ctrlPage = ctrlData[ctrlIdKey];
1228
+ if (!ctrlPage || typeof ctrlPage !== "object") {
1229
+ continue;
1230
+ }
1231
+ for (const stateKey of Object.keys(ctrlPage)) {
1232
+ const propData = (ctrlPage as any)[stateKey];
1233
+ if (!propData || typeof propData !== "object") {
1234
+ continue;
1235
+ }
1236
+ // 外层 propData: 扫数字 key (跳过 $$xxx$$ 元数据)
1237
+ sweepPropDictionary(propData);
1238
+ // W6-axis-decomp X 方案: 接着拆 AMBIGUOUS 整体 propRef (Vec3/Vec2/Size) → 子项 key.
1239
+ // 必须在 sweepPropDictionary 之后跑 — 老数字 key 先迁 string propRef, 再统一拆解.
1240
+ sweepDecomposeAmbiguous(propData);
1241
+ // #S4 (NA-8): $$controlledProps$$ 元桶也迁 — 数字 key → propRef string key, 聚合 → 子项 ref.
1242
+ // 否则 C6 apply 门控对老 .fire 迁移数据 (propData 已迁 string, 但 controlledProps 仍数字) 全 skip.
1243
+ sweepControlledPropsKeys(propData.$$controlledProps$$);
1244
+ // 内层 $$propertyData$$: 同规则扫 (number key 也迁 string key, AMBIGUOUS 也拆)
1245
+ if (propData.$$propertyData$$) {
1246
+ sweepPropDictionary(propData.$$propertyData$$);
1247
+ sweepDecomposeAmbiguous(propData.$$propertyData$$);
1248
+ // #U5: 迁移后把 $$propertyData$$ 的 string propRef 值合并到**顶层** propData ——
1249
+ // X 方案 apply (applyPropRefKeysToNode) 只读顶层 key, 老 .fire 的 $$propertyData$$
1250
+ // 子 bucket 值若不上提则永不 apply。已存在的顶层 key 优先(不覆盖)。
1251
+ const pdBucket = propData.$$propertyData$$ as any;
1252
+ for (const k of Object.keys(pdBucket)) {
1253
+ if (k.startsWith("$$")) continue;
1254
+ if ((propData as any)[k] === undefined) {
1255
+ (propData as any)[k] = pdBucket[k];
1256
+ }
1257
+ }
1258
+ }
1259
+ }
1260
+ }
1261
+ }
1262
+
1263
+ /**
1264
+ * W6-2c2: 按 EnumPropName 读 propData, 优先 string propRef key, fallback number key.
1265
+ *
1266
+ * 双 key 兼容期 helper — 解决 production 写路径 (W6-2a) 仍可能在 ctrlData 内层写过 number key 的历史数据.
1267
+ * c2 完成后, 编辑器加载老 .fire __preload 会跑 migrateLegacyCtrlData 把 number key 迁 string,
1268
+ * 但 in-memory 路径 (如 commit/snapshot 前后) 可能短暂双 key 共存, 用本 helper 保证读到正确值.
1269
+ *
1270
+ * 公开 internal: StateControllerV2.promptDirtyAndStart 在外部 commit 时也走此 helper, 否则
1271
+ * 老 number key 写入会绕过 c2 数据规范, 导致 ctrlData 出现 number + string 双 key 不一致.
1272
+ */
1273
+ public readPropByEnum(propData: any, propType: EnumPropName): TPropValue {
1274
+ if (!propData) return undefined;
1275
+ const propRef = enumToPropRef(propType);
1276
+ if (propRef !== undefined && propData[propRef] !== undefined) {
1277
+ return propData[propRef];
1278
+ }
1279
+ return (propData as TPropDictionary)[propType];
1280
+ }
1281
+
1282
+ /**
1283
+ * W6-2c2: 按 EnumPropName 写 propData, 优先写 string propRef key + 清掉同义 number key.
1284
+ *
1285
+ * - propRef 命中 (内置 36 + AMBIGUOUS 3 = 39 项) → propData[propRef] = value; delete propData[number]
1286
+ * - propRef 未命中 (理论不发生) → 回退老路径 propData[number] = value
1287
+ *
1288
+ * 双写清理保证: 老 number key 数据立即清, 不会出现"已迁 string + 残留 number" 的双 key 状态.
1289
+ *
1290
+ * 公开 internal: StateControllerV2.promptDirtyAndStart "保存到当前 state" 路径也走此 helper.
1291
+ */
1292
+ public writePropByEnum(propData: any, propType: EnumPropName, value: TPropValue): void {
1293
+ if (!propData) return;
1294
+ const propRef = enumToPropRef(propType);
1295
+ if (propRef !== undefined) {
1296
+ propData[propRef] = value;
1297
+ delete (propData as TPropDictionary)[propType];
1298
+ // W6-axis-decomp: 若 propRef 是 AMBIGUOUS aggregate (Position/Anchor/Size/Scale/Euler), 同步拆子项写入.
1299
+ // 让老 facade API (togglePropertyControl(EnumPropName.Anchor) + setDefaultProp) 能跟 X 方案 apply 路径
1300
+ // (走 listTrackableProps 子项 readPropFromNodeByPropRef) 协同 — 子项 apply 时能读到 propData['cc.Node.anchorX'] 等.
1301
+ const decompose = AMBIGUOUS_DECOMPOSE[propRef];
1302
+ if (decompose) {
1303
+ const subs = decompose(value);
1304
+ if (subs) {
1305
+ for (const [subRef, subVal] of subs) {
1306
+ propData[subRef] = subVal;
1307
+ }
1308
+ }
1309
+ }
1310
+ }
1311
+ else {
1312
+ (propData as TPropDictionary)[propType] = value;
1313
+ }
1314
+ }
1315
+
1316
+ // #endregion 2.
1317
+
1318
+ // #region 3. 事件观察与控制器迁移
1319
+ // 节点属性变化 hooks + parent check interval + 跨 controller 移动适配
1320
+
1321
+ /**
1322
+ * 🔧 通知当前控制器缓存失效
1323
+ * 当StateSelectV2组件创建/销毁/移动时调用
1324
+ */
1325
+ private notifyControllerCacheDirty(): void {
1326
+ // 直接向上找父链上最近的 StateControllerV2, 不依赖 this.currCtrlId.
1327
+ // 因为 __preload 内调用此方法时 currCtrlId 还没被设置, getCurrCtrl() 会
1328
+ // 返回 undefined, markCacheDirty 不被调用 → 导致"ctrl 比 select 早创建,
1329
+ // 第 2+ 个 select 永远不在 cache, 切 state 时跳过它"的 bug.
1330
+ let parent = this.node ? this.node.parent : null;
1331
+ while (parent && parent.isValid) {
1332
+ const ctrl = parent.getComponent(StateControllerV2);
1333
+ if (ctrl) {
1334
+ ctrl.markCacheDirty();
1335
+ StateErrorManager.debug("已通知控制器缓存失效", {
1336
+ component: "StateSelectV2",
1337
+ method: "notifyControllerCacheDirty",
1338
+ params: { ctrlName: ctrl.ctrlName },
1339
+ });
1340
+ return;
1341
+ }
1342
+ parent = parent.parent;
1343
+ }
1344
+ }
1345
+
1346
+ /**
1347
+ * #F-4 (TASK-004): 某位置轴是否"当前受控且未排除" —— reparent 坐标转换的唯一判据.
1348
+ * 受控 = isPropertyControlled(propRef) (当前 state $$controlledProps$$ 命中);
1349
+ * 未排除 = 不在 _userExcludedProps + SYSTEM_EXCLUDE. 仅这两者皆满足才转换该轴,
1350
+ * propData 残留 baseline (取消跟随/排除后留下) 不再触发转换.
1351
+ */
1352
+ private isAxisConvertible(axisRef: string): boolean {
1353
+ // #S2: 受控判定按**全局**(default 或任一 state 的 controlledProps), 不依赖当前激活 state ——
1354
+ // 否则激活一个新加的空 state 时, 当前 state 无 flag → 误判全轴不受控 → reparent 整体跳过,
1355
+ // 其他 state 存的位置不被换算。
1356
+ if (!this.isAxisControlledGlobally(axisRef)) return false;
1357
+ if ((this._userExcludedProps || []).indexOf(axisRef) >= 0) return false;
1358
+ if (SYSTEM_EXCLUDE.indexOf(axisRef) >= 0) return false;
1359
+ return true;
1360
+ }
1361
+
1362
+ /** #S2: 某 propRef 是否在全局(default 或任一 state)受控 —— reparent 换算判据, 与激活 state 无关. */
1363
+ private isAxisControlledGlobally(propRef: string): boolean {
1364
+ const pageData = this.getPageData();
1365
+ const dd = pageData.$$default$$;
1366
+ if (dd && (dd as any).$$controlledProps$$ && (dd as any).$$controlledProps$$[propRef] !== undefined) {
1367
+ return true;
1368
+ }
1369
+ const ctrl = this.getCurrCtrl();
1370
+ if (ctrl) {
1371
+ for (let i = 0; i < ctrl.states.length; i++) {
1372
+ const stateId = this.getStateIdByIndex(ctrl, i);
1373
+ if (stateId < 0) continue;
1374
+ const pdi = pageData[stateId];
1375
+ if (pdi && (pdi as any).$$controlledProps$$ && (pdi as any).$$controlledProps$$[propRef] !== undefined) {
1376
+ return true;
1377
+ }
1378
+ }
1379
+ }
1380
+ return false;
1381
+ }
1382
+
1383
+ /** 父节点改变 */
1384
+ private parentChanged(oldParent: cc.Node) {
1385
+ this.transPosition(oldParent);
1386
+ }
1387
+
1388
+ /** 检查父节点是否变化 */
1389
+ private checkParentChanged() {
1390
+ // 安全检查:确保节点仍然有效
1391
+ if (!this.node || !this.node.isValid) {
1392
+ return;
1393
+ }
1394
+
1395
+ const currentParent = this.node.parent;
1396
+
1397
+ if (this.lastParent !== currentParent) {
1398
+ const oldParent = this.lastParent;
1399
+ this.lastParent = currentParent;
1400
+
1401
+ // 新增:检查控制器承接
1402
+ this.handleControllerTransition(oldParent, currentParent);
1403
+
1404
+ // 只有当有"当前受控且未排除"的位置轴时才需要转换坐标.
1405
+ // M3-2 修 #2: Position 以子项 cc.Node.x/y/z 存, 改判子项 key.
1406
+ // #F-4 修 (TASK-004): gate 不再按"pageData 有值 key"(残留数据)判定, 改按"受控未排除"判定 —
1407
+ // 取消跟随/排除但 propData 残留 baseline 的轴不应触发坐标转换 (附录A 断言#3).
1408
+ const hasPositionControl
1409
+ = this.isAxisConvertible("cc.Node.x")
1410
+ || this.isAxisConvertible("cc.Node.y")
1411
+ || this.isAxisConvertible("cc.Node.z");
1412
+
1413
+ if (hasPositionControl) {
1414
+ this.parentChanged(oldParent);
1415
+ }
1416
+ }
1417
+ }
1418
+
1419
+ /** 处理控制器承接 */
1420
+ private handleControllerTransition(oldParent: cc.Node, newParent: cc.Node) {
1421
+ StateErrorManager.debug("开始控制器承接处理", {
1422
+ component: "StateSelectV2",
1423
+ method: "handleControllerTransition",
1424
+ params: {
1425
+ hasOldParent: !!oldParent,
1426
+ hasNewParent: !!newParent,
1427
+ currentCtrlId: this.currCtrlId,
1428
+ },
1429
+ });
1430
+
1431
+ // 获取旧控制器
1432
+ const oldCtrls = oldParent ? this.getCtrls(oldParent) : [];
1433
+ const oldCtrl = oldCtrls.find(ctrl => ctrl.ctrlId === this.currCtrlId);
1434
+
1435
+ // 获取新控制器
1436
+ const newCtrls = newParent ? this.getCtrls(newParent) : [];
1437
+ const newCtrl = this.selectBestController(newCtrls, oldCtrl);
1438
+
1439
+ StateErrorManager.debug("控制器分析结果", {
1440
+ component: "StateSelectV2",
1441
+ method: "handleControllerTransition",
1442
+ params: {
1443
+ oldCtrlsCount: oldCtrls.length,
1444
+ newCtrlsCount: newCtrls.length,
1445
+ hasOldCtrl: !!oldCtrl,
1446
+ hasNewCtrl: !!newCtrl,
1447
+ oldCtrlName: oldCtrl?.ctrlName,
1448
+ newCtrlName: newCtrl?.ctrlName,
1449
+ },
1450
+ });
1451
+
1452
+ // 如果新旧都有控制器且不同,执行数据承接
1453
+ if (oldCtrl && newCtrl && oldCtrl.ctrlId !== newCtrl.ctrlId) {
1454
+ // Wave 2 T16: 跨 ctrl 移动前的兜底 commit
1455
+ // 若 oldCtrl 正在录制, 先把当前 diff commit 到 oldCtrl 的当前 state, 再清 snapshot,
1456
+ // 避免数据随 ctrl 切换丢失。
1457
+ if (oldCtrl.isRecording && this._snapshot != null) {
1458
+ StateErrorManager.info("跨 ctrl 移动前自动 commit 录制 diff", {
1459
+ component: "StateSelectV2",
1460
+ method: "handleControllerTransition",
1461
+ params: { fromCtrl: oldCtrl.ctrlName, state: oldCtrl.selectedIndex },
1462
+ });
1463
+ this.commitRecordingDiff(oldCtrl, oldCtrl.selectedIndex);
1464
+ this._snapshot = null;
1465
+ }
1466
+
1467
+ // 1. 备份当前状态数据
1468
+ const oldCtrlData = this._ctrlData[oldCtrl.ctrlId];
1469
+
1470
+ if (oldCtrlData) {
1471
+ // 2. 将数据迁移到新控制器
1472
+ // 需要根据新控制器的状态数量调整数据结构
1473
+ const transferredData = this.adaptDataToNewController(oldCtrlData, newCtrl);
1474
+ this._ctrlData[newCtrl.ctrlId] = transferredData;
1475
+
1476
+ // 3. 清理旧控制器数据
1477
+ delete this._ctrlData[oldCtrl.ctrlId];
1478
+
1479
+ // 4. 更新控制器映射和当前控制器ID
1480
+ this.updateCtrlName(newParent);
1481
+ this._currCtrlId = newCtrl.ctrlId;
1482
+
1483
+ // #T2: 跨 ctrl 移动后两边缓存都失效 —— 否则旧 ctrl 的 _stateSelectCache 仍含本 select(继续
1484
+ // 误更新已移走的节点), 新 ctrl 缓存不含本 select(切 state 不接管本节点)。
1485
+ oldCtrl.markCacheDirty();
1486
+ newCtrl.markCacheDirty();
1487
+
1488
+ // 5. 更新界面
1489
+ this.updateCtrlPage(newCtrl);
1490
+ this.refProp();
1491
+
1492
+ StateErrorManager.info("控制器承接完成", {
1493
+ component: "StateSelectV2",
1494
+ method: "handleControllerTransition",
1495
+ params: { fromController: oldCtrl.ctrlName, toController: newCtrl.ctrlName },
1496
+ });
1497
+ }
1498
+ }
1499
+ else if (newCtrl && !oldCtrl) {
1500
+ // 从无控制器环境移动到有控制器环境
1501
+ StateErrorManager.info("绑定到新控制器", {
1502
+ component: "StateSelectV2",
1503
+ method: "handleControllerTransition",
1504
+ params: { newController: newCtrl.ctrlName },
1505
+ });
1506
+ this.updateCtrlName(newParent);
1507
+ if (!this.currCtrlId) {
1508
+ this._currCtrlId = newCtrl.ctrlId;
1509
+ this.updateCtrlPage(newCtrl);
1510
+ this.refProp();
1511
+ }
1512
+ }
1513
+ else if (oldCtrl && !newCtrl) {
1514
+ // 从有控制器环境移动到无控制器环境
1515
+ // 保留数据但清除当前绑定
1516
+ this._currCtrlId = null;
1517
+ this._propKey = EnumPropName.Non;
1518
+ }
1519
+ }
1520
+
1521
+ /** 适配数据到新控制器 */
1522
+ private adaptDataToNewController(oldData: TPage, newCtrl: StateControllerV2): TPage {
1523
+ const newData: TPage = {};
1524
+
1525
+ // 复制默认数据
1526
+ if (oldData.$$default$$) {
1527
+ newData.$$default$$ = this.deepCloneStateData(oldData.$$default$$);
1528
+ }
1529
+
1530
+ // 根据新控制器的状态数量适配状态数据
1531
+ for (let stateIndex = 0; stateIndex < newCtrl.states.length; stateIndex++) {
1532
+ if (oldData[stateIndex]) {
1533
+ // 如果旧数据有对应状态,直接复制
1534
+ newData[stateIndex] = this.deepCloneStateData(oldData[stateIndex]);
1535
+ }
1536
+ else if (newData.$$default$$) {
1537
+ // 如果旧数据没有对应状态,使用默认数据创建新状态
1538
+ newData[stateIndex] = this.deepCloneStateData(newData.$$default$$);
1539
+ // 清除新状态的lastProp,让用户重新选择
1540
+ delete newData[stateIndex].$$lastProp$$;
1541
+ }
1542
+ }
1543
+
1544
+ return newData;
1545
+ }
1546
+
1547
+ /** 深度克隆状态数据方法 */
1548
+ private deepCloneStateData(data?: TProp): TProp {
1549
+ if (!data) {
1550
+ return {} as TProp;
1551
+ }
1552
+
1553
+ const cloned: Record<string, unknown> = {};
1554
+ const keys = Object.keys(data);
1555
+
1556
+ for (let i = 0, len = keys.length; i < len; i++) {
1557
+ const key = keys[i];
1558
+ const value = (data as Record<string, unknown>)[key];
1559
+
1560
+ if (key === "$$changedProp$$" || key === "$$controlledProps$$" || key === "$$propertyData$$") {
1561
+ cloned[key] = value ? { ...(value as object) } : value;
1562
+ continue;
1563
+ }
1564
+
1565
+ if (key === "$$lastProp$$") {
1566
+ cloned[key] = value;
1567
+ continue;
1568
+ }
1569
+
1570
+ // 🔧 快速处理:基本类型直接复制
1571
+ if (!value || typeof value !== "object") {
1572
+ cloned[key] = value;
1573
+ continue;
1574
+ }
1575
+ const constructor = value.constructor;
1576
+ if (constructor === cc.Vec3) {
1577
+ // 🔧 Vec3: 直接使用现有值创建新对象
1578
+ const vec3Value = value as cc.Vec3;
1579
+ cloned[key] = cc.v3(vec3Value.x, vec3Value.y, vec3Value.z);
1580
+ }
1581
+ else if (constructor === cc.Vec2) {
1582
+ // 🔧 Vec2: 直接使用现有值创建新对象
1583
+ const vec2Value = value as cc.Vec2;
1584
+ cloned[key] = cc.v2(vec2Value.x, vec2Value.y);
1585
+ }
1586
+ else if (constructor === cc.Color) {
1587
+ // 🔧 Color: 直接使用RGBA值创建新对象
1588
+ const color = value as cc.Color;
1589
+ cloned[key] = cc.color(color.r, color.g, color.b, color.a);
1590
+ }
1591
+ else if (constructor === cc.Size) {
1592
+ // 🔧 Size: 直接使用宽高值创建新对象
1593
+ const size = value as cc.Size;
1594
+ cloned[key] = cc.size(size.width, size.height);
1595
+ }
1596
+ else if (value instanceof cc.Asset) {
1597
+ // 🔧 Asset对象:直接保留引用(SpriteFrame、Font等)
1598
+ cloned[key] = value;
1599
+ }
1600
+ else {
1601
+ // 🔧 其他对象:浅拷贝(如$$changedProp$$等元数据对象)
1602
+ cloned[key] = { ...(value as object) };
1603
+ }
1604
+ }
1605
+
1606
+ return cloned as TProp;
1607
+ }
1608
+
1609
+ /** 选择最佳控制器用于承接 */
1610
+ private selectBestController(newCtrls: StateControllerV2[], oldCtrl: StateControllerV2): StateControllerV2 {
1611
+ if (!newCtrls || newCtrls.length === 0) {
1612
+ return null;
1613
+ }
1614
+
1615
+ // 如果只有一个控制器,直接返回
1616
+ if (newCtrls.length === 1) {
1617
+ return newCtrls[0];
1618
+ }
1619
+
1620
+ // 如果有多个控制器,优先选择状态数量相同的控制器
1621
+ if (oldCtrl) {
1622
+ const oldStatesCount = oldCtrl.states.length;
1623
+ const matchingCtrl = newCtrls.find(ctrl => ctrl.states.length === oldStatesCount);
1624
+ if (matchingCtrl) {
1625
+ return matchingCtrl;
1626
+ }
1627
+ }
1628
+
1629
+ // 如果没有状态数量匹配的,选择第一个控制器
1630
+ return newCtrls[0];
1631
+ }
1632
+
1633
+ /** 更新控制器 */
1634
+ public updateCtrlName(node: cc.Node) {
1635
+ if (!CC_EDITOR) {
1636
+ return;
1637
+ }
1638
+ if (!node || !node.isValid) {
1639
+ StateErrorManager.debug("updateCtrlName: 节点无效", {
1640
+ component: "StateSelectV2",
1641
+ method: "updateCtrlName",
1642
+ params: { hasNode: !!node, isValid: node?.isValid },
1643
+ });
1644
+ return;
1645
+ }
1646
+
1647
+ StateErrorManager.debug("开始更新控制器名称", {
1648
+ component: "StateSelectV2",
1649
+ method: "updateCtrlName",
1650
+ params: { nodeName: node.name },
1651
+ });
1652
+
1653
+ const ctrls = this.getCtrls(node);
1654
+ const arr = ctrls.map((val) => {
1655
+ if (this._ctrlsMap[val.ctrlId] == void 0) {
1656
+ this._ctrlsMap[val.ctrlId] = val;
1657
+ }
1658
+ return { name: val.ctrlName, value: val.ctrlId };
1659
+ });
1660
+ // @ts-expect-error setClassAttr is unavailable in Cocos Creator d.ts
1661
+ cc.Class.Attr.setClassAttr(this, "currCtrlId", "enumList", arr);
1662
+
1663
+ StateErrorManager.info("控制器名称更新完成", {
1664
+ component: "StateSelectV2",
1665
+ method: "updateCtrlName",
1666
+ params: { controllersFound: ctrls.length, mappedControllers: Object.keys(this._ctrlsMap).length },
1667
+ });
1668
+ }
1669
+
1670
+ /**
1671
+ * 重新绑定控制器 (拷贝到新 prefab 后用). 清掉悬空缓存 → 按当前祖先链重扫 → 重解析绑定指针.
1672
+ *
1673
+ * 典型场景: 把带 StateSelectV2 的节点拷贝粘贴到另一个 prefab 后, _currCtrlId/_ctrlsMap 仍指向
1674
+ * 老 prefab 的控制器 (新 prefab 里不存在); 自动承接 (checkParentChanged 轮询 lastParent) 又盖不住
1675
+ * "重开 prefab" 路径 (打开即 lastParent === node.parent, 永不触发). 故需手动按当前祖先链重绑,
1676
+ * 无需删组件再加.
1677
+ *
1678
+ * 数据策略: 只在「真切换到一个不同控制器」时才全删旧状态数据 (_ctrlData = {}), 因为老 prefab 的
1679
+ * 数据在新链里绑不上、视为重来. 两个兜底防止无谓破坏:
1680
+ * - 目标与当前是同一控制器 → 不操作 (防误点). 不靠 ctrlId 判 "是否语义一致的 StateController"
1681
+ * (无法可靠判定), 同一即不动.
1682
+ * - 没扫到任何控制器 → 不操作 (无东西可绑, 不删唯一的数据).
1683
+ */
1684
+ public rebindController(): void {
1685
+ if (!CC_EDITOR) {
1686
+ return;
1687
+ }
1688
+
1689
+ // 1. 清掉来自老 prefab 的悬空缓存. updateCtrlName 对 _ctrlsMap 只增不删, 不先清则下拉残留死控制器.
1690
+ const prevCtrlId = this._currCtrlId;
1691
+ this._ctrlsMap = {};
1692
+ this._root = null;
1693
+
1694
+ // 2. 按当前祖先链重扫, 重建 _ctrlsMap 与 currCtrlId 下拉枚举.
1695
+ this.updateCtrlName(this.node.parent);
1696
+
1697
+ // 3. 解析目标控制器: 旧绑定在新链里仍有效则保留, 否则单控制器自动绑、多控制器绑第一个 (下拉已刷新可手改).
1698
+ const ids = Object.keys(this._ctrlsMap);
1699
+ let nextId: number = null;
1700
+ if (prevCtrlId != null && this._ctrlsMap[prevCtrlId]) {
1701
+ nextId = prevCtrlId;
1702
+ }
1703
+ else if (ids.length) {
1704
+ nextId = Number(ids[0]);
1705
+ }
1706
+
1707
+ // 4. 兜底: 没扫到控制器, 或目标与当前一致 → 不操作, 不动数据.
1708
+ if (nextId == null || nextId === prevCtrlId) {
1709
+ StateErrorManager.debug("rebindController: 无新控制器可绑或与当前一致, 跳过", {
1710
+ component: "StateSelectV2",
1711
+ method: "rebindController",
1712
+ params: { prevCtrlId, nextId, controllersFound: ids.length },
1713
+ });
1714
+ return;
1715
+ }
1716
+
1717
+ // 5. 真切到不同控制器: 全删旧状态数据 (老 prefab 数据在新链绑不上, 重来), 再切指针.
1718
+ // 直接赋值 _currCtrlId (不走 setter): 避开 setter 告警, 并保证 updateCtrlPage 比对前指针已就位.
1719
+ this._ctrlData = {};
1720
+ this._currCtrlId = nextId;
1721
+
1722
+ // 6. 刷新页面. updateCtrlPage 内部用 currCtrlId 比对, 须在 _currCtrlId 赋值后调用.
1723
+ const ctrl = this.getCurrCtrl();
1724
+ if (ctrl) {
1725
+ ctrl.markCacheDirty();
1726
+ this.updateCtrlPage(ctrl);
1727
+ this.refProp();
1728
+ }
1729
+
1730
+ StateErrorManager.info("重新绑定控制器完成", {
1731
+ component: "StateSelectV2",
1732
+ method: "rebindController",
1733
+ params: { prevCtrlId, nextId, controllersFound: ids.length },
1734
+ });
1735
+ }
1736
+
1737
+ /** 获取所有的Ctrl */
1738
+ private getCtrls(node: cc.Node): StateControllerV2[] {
1739
+ if (!node || !CC_EDITOR) {
1740
+ if (!node) {
1741
+ StateErrorManager.debug("getCtrls: 节点为空", {
1742
+ component: "StateSelectV2",
1743
+ method: "getCtrls",
1744
+ });
1745
+ }
1746
+ return [];
1747
+ }
1748
+ const ctrls = node.getComponents(StateControllerV2);
1749
+ if (ctrls.length) {
1750
+ this._root = node;
1751
+ StateErrorManager.debug("找到控制器", {
1752
+ component: "StateSelectV2",
1753
+ method: "getCtrls",
1754
+ params: { ctrlCount: ctrls.length, nodeName: node.name },
1755
+ });
1756
+ return ctrls;
1757
+ }
1758
+ return this.getCtrls(node.parent);
1759
+ }
1760
+
1761
+ // #endregion 3.
1762
+
1763
+ // #region 4. 状态数据操作 (state 增删移 + page data 迁移)
1764
+
1765
+ /** 更新状态数量 */
1766
+ public updateCtrlPage(ctrl: StateControllerV2, deleteIndex?: number) {
1767
+ if (!CC_EDITOR) {
1768
+ return;
1769
+ }
1770
+
1771
+ if (!ctrl || ctrl.ctrlId !== this.currCtrlId) {
1772
+ return;
1773
+ }
1774
+
1775
+ if (deleteIndex != void 0 && deleteIndex != -1) {
1776
+ this.handleStateDelete(ctrl, deleteIndex);
1777
+ }
1778
+
1779
+ // 🔧 更新状态枚举列表
1780
+ this.updateStateEnumList(ctrl);
1781
+ }
1782
+
1783
+ /** 🔧 新增:处理状态顺序变更(上移/下移) */
1784
+ public updateStateMove(ctrl: StateControllerV2, moveInfo: { fromIndex: number, toIndex: number }) {
1785
+ if (!CC_EDITOR) {
1786
+ return;
1787
+ }
1788
+
1789
+ if (!ctrl || ctrl.ctrlId !== this.currCtrlId) {
1790
+ return;
1791
+ }
1792
+
1793
+ if (!moveInfo || moveInfo.fromIndex === undefined || moveInfo.toIndex === undefined) {
1794
+ StateErrorManager.warn("状态移动信息无效", {
1795
+ component: "StateSelectV2",
1796
+ method: "updateStateMove",
1797
+ params: { moveInfo },
1798
+ });
1799
+ return;
1800
+ }
1801
+
1802
+ const { fromIndex, toIndex } = moveInfo;
1803
+ if (fromIndex === toIndex) {
1804
+ return;
1805
+ }
1806
+
1807
+ if (fromIndex < 0 || toIndex < 0 || fromIndex >= ctrl.states.length || toIndex >= ctrl.states.length) {
1808
+ StateErrorManager.warn("状态移动索引越界,取消同步", {
1809
+ component: "StateSelectV2",
1810
+ method: "updateStateMove",
1811
+ params: { fromIndex, toIndex, stateCount: ctrl.states.length },
1812
+ });
1813
+ return;
1814
+ }
1815
+
1816
+ // state data is keyed by stable stateId; reorder only changes display order.
1817
+ this.updateChangedProp();
1818
+
1819
+ StateErrorManager.info("状态数据顺序已同步", {
1820
+ component: "StateSelectV2",
1821
+ method: "updateStateMove",
1822
+ params: { fromIndex, toIndex, stateCount: ctrl.states.length },
1823
+ });
1824
+ }
1825
+
1826
+ /**
1827
+ * 状态复制 (EnumUpdateType.Copy 触发)
1828
+ *
1829
+ * 契约: 在 pageData 中, 把 fromIndex 槽位的 prop 数据深拷贝到 toIndex 槽位;
1830
+ * 若 toIndex 槽位以及之后已有数据 (常见 toIndex = fromIndex+1, 中间插入), 先把
1831
+ * pageData[toIndex .. statesLength-2] 整体右移一格, 再把 fromIndex 的深拷贝写入 toIndex。
1832
+ * statesLength 是 *新* states 长度 (含刚插入的 copy state)。
1833
+ *
1834
+ * #C5: 用 deepClonePropData 逐 key 走 cloneValueByType 深拷 (按 cocosType 分发), 保活
1835
+ * cc.Color/Vec3/Vec2/Size/Quat 类实例 —— propData 值在 X 方案下是**活 cc 实例**(cloneValueByType
1836
+ * 写入), 旧的 JSON.parse(JSON.stringify) 会降级成普通对象导致 apply 时类型退化。
1837
+ */
1838
+ public updateStateCopy(ctrl: StateControllerV2, copyInfo: { fromIndex: number, toIndex: number }) {
1839
+ if (!CC_EDITOR) {
1840
+ return;
1841
+ }
1842
+
1843
+ if (!ctrl) {
1844
+ return;
1845
+ }
1846
+
1847
+ if (!copyInfo || copyInfo.fromIndex === undefined || copyInfo.toIndex === undefined) {
1848
+ StateErrorManager.warn("状态复制信息无效", {
1849
+ component: "StateSelectV2",
1850
+ method: "updateStateCopy",
1851
+ params: { copyInfo },
1852
+ });
1853
+ return;
1854
+ }
1855
+
1856
+ const { fromIndex, toIndex } = copyInfo;
1857
+ const statesLength = ctrl.states.length;
1858
+
1859
+ if (fromIndex < 0 || toIndex < 0 || fromIndex >= statesLength || toIndex >= statesLength) {
1860
+ StateErrorManager.warn("状态复制索引越界, 取消同步", {
1861
+ component: "StateSelectV2",
1862
+ method: "updateStateCopy",
1863
+ params: { fromIndex, toIndex, statesLength },
1864
+ });
1865
+ return;
1866
+ }
1867
+
1868
+ const pageData = this.getPageData(ctrl.ctrlId);
1869
+ if (!pageData) {
1870
+ return;
1871
+ }
1872
+
1873
+ const fromStateId = this.getStateIdByIndex(ctrl, fromIndex);
1874
+ const toStateId = this.getStateIdByIndex(ctrl, toIndex);
1875
+ if (fromStateId < 0 || toStateId < 0) return;
1876
+
1877
+ // 深拷贝 source stateId 槽位到 target stateId (#C5: 逐 key cloneValueByType, 保活 cc 实例)
1878
+ const source = pageData[fromStateId];
1879
+ if (source != void 0) {
1880
+ pageData[toStateId] = this.deepClonePropData(source);
1881
+ }
1882
+ else {
1883
+ delete pageData[toStateId];
1884
+ }
1885
+
1886
+ this.updateChangedProp();
1887
+
1888
+ StateErrorManager.info("状态数据已深拷贝", {
1889
+ component: "StateSelectV2",
1890
+ method: "updateStateCopy",
1891
+ params: { fromIndex, toIndex, statesLength },
1892
+ });
1893
+ }
1894
+
1895
+ // #region 专项A-2: 单节点各 state 值 局部操作 (swap/copy/move)
1896
+ /**
1897
+ * 专项A-2: 校验两个 state 槽位 index 合法 (0..states.length-1).
1898
+ * 单节点局部值操作的公共前置: 入参越界则拒绝, 不破坏数据.
1899
+ */
1900
+ private validateStateValueOp(stateA: number, stateB: number, ctrlId?: number): TPage | null {
1901
+ if (!CC_EDITOR) return null;
1902
+ const ctrl = ctrlId != void 0 ? this._ctrlsMap[ctrlId] : this.getCurrCtrl();
1903
+ if (!ctrl || !ctrl.states) {
1904
+ StateErrorManager.warn("局部值操作: 控制器无效", { component: "StateSelectV2", method: "validateStateValueOp", params: { ctrlId } });
1905
+ return null;
1906
+ }
1907
+ const len = ctrl.states.length;
1908
+ if (stateA < 0 || stateB < 0 || stateA >= len || stateB >= len
1909
+ || !Number.isInteger(stateA) || !Number.isInteger(stateB)) {
1910
+ StateErrorManager.warn("局部值操作: state 索引越界", {
1911
+ component: "StateSelectV2",
1912
+ method: "validateStateValueOp",
1913
+ params: { stateA, stateB, stateCount: len },
1914
+ });
1915
+ return null;
1916
+ }
1917
+ return this.getPageData(ctrl.ctrlId);
1918
+ }
1919
+
1920
+ /**
1921
+ * 专项A-2: 交换单节点两个 state 的值数据 (节点级局部操作).
1922
+ * 只动 _ctrlData[ctrlId][stateA] ↔ [stateB] 的 propData, 不碰 selectedIndex、
1923
+ * 不影响其他节点的 _ctrlData、不增删 state 数量结构. 如 swap A1↔B1.
1924
+ */
1925
+ public swapStateValues(stateA: number, stateB: number, ctrlId?: number): boolean {
1926
+ const pageData = this.validateStateValueOp(stateA, stateB, ctrlId);
1927
+ if (!pageData) return false;
1928
+ if (stateA === stateB) return true;
1929
+ const ctrl = ctrlId != void 0 ? this._ctrlsMap[ctrlId] : this.getCurrCtrl();
1930
+ const keyA = this.getStateIdByIndex(ctrl, stateA);
1931
+ const keyB = this.getStateIdByIndex(ctrl, stateB);
1932
+ if (keyA < 0 || keyB < 0) return false;
1933
+ const a = pageData[keyA];
1934
+ const b = pageData[keyB];
1935
+ if (b !== void 0) pageData[keyA] = b;
1936
+ else delete pageData[keyA];
1937
+ if (a !== void 0) pageData[keyB] = a;
1938
+ else delete pageData[keyB];
1939
+ this.updateChangedProp();
1940
+ this.reapplyCurrentStateIfAffected([stateA, stateB]);
1941
+ return true;
1942
+ }
1943
+
1944
+ /** #S8: 局部值操作若涉及当前激活 state, 重新把该 state apply 到节点 (否则 inspector/节点显示脱节). */
1945
+ private reapplyCurrentStateIfAffected(affected: number[]): void {
1946
+ const ctrl = this.getCurrCtrl();
1947
+ if (ctrl && affected.indexOf(ctrl.selectedIndex) >= 0) {
1948
+ this.updateState(ctrl);
1949
+ }
1950
+ }
1951
+
1952
+ /**
1953
+ * 专项A-2: 复制单节点某 state 的值数据到另一 state (深拷, 节点级局部操作).
1954
+ * fromState→toState 深拷覆盖, 源保持不变且与目标独立; 源为空则清空目标. 如 copy A1→B1.
1955
+ */
1956
+ public copyStateValues(fromState: number, toState: number, ctrlId?: number): boolean {
1957
+ const pageData = this.validateStateValueOp(fromState, toState, ctrlId);
1958
+ if (!pageData) return false;
1959
+ if (fromState === toState) return true;
1960
+ const ctrl = ctrlId != void 0 ? this._ctrlsMap[ctrlId] : this.getCurrCtrl();
1961
+ const fromKey = this.getStateIdByIndex(ctrl, fromState);
1962
+ const toKey = this.getStateIdByIndex(ctrl, toState);
1963
+ if (fromKey < 0 || toKey < 0) return false;
1964
+ const source = pageData[fromKey];
1965
+ if (source !== void 0) {
1966
+ // #C5: 逐 key cloneValueByType 深拷, 保活 cc 类实例 (propData 存活 cc.Color/Vec3 等, 同 updateStateCopy).
1967
+ pageData[toKey] = this.deepClonePropData(source);
1968
+ }
1969
+ else {
1970
+ delete pageData[toKey];
1971
+ }
1972
+ this.updateChangedProp();
1973
+ this.reapplyCurrentStateIfAffected([toState]);
1974
+ return true;
1975
+ }
1976
+
1977
+ // #endregion
1978
+
1979
+ /** 🔧 新增:处理状态删除逻辑 */
1980
+ private handleStateDelete(ctrl: StateControllerV2, deleteIndex: number) {
1981
+ StateErrorManager.debug("开始处理状态删除", {
1982
+ component: "StateSelectV2",
1983
+ method: "handleStateDelete",
1984
+ params: { deleteIndex: deleteIndex, ctrlId: ctrl.ctrlId },
1985
+ });
1986
+
1987
+ // deleteIndex 是被删 state 的 *旧* index。
1988
+ // ctrl.states.length 在 setter 触发 SelPage 通知时已经 -1。
1989
+ // 所以当删除末尾 state 时, deleteIndex == ctrl.states.length 是合法的;
1990
+ // 之前用 `>= ctrl.states.length` 的判断把这种情况当成 "无效", 导致 migrateStateData
1991
+ // 错过末尾槽位的 delete pageData[deleteIndex] (B3 数据残留 bug)。
1992
+ if (deleteIndex < 0) {
1993
+ StateErrorManager.warn("删除索引为负", {
1994
+ component: "StateSelectV2",
1995
+ method: "handleStateDelete",
1996
+ params: { deleteIndex: deleteIndex, stateCount: ctrl.states.length },
1997
+ });
1998
+ return;
1999
+ }
2000
+
2001
+ // state data is keyed by stable stateId. Removing a state from active list is soft-delete:
2002
+ // keep its prop data so restore/re-add by stateId brings the exact values back.
2003
+ this.updateChangedProp();
2004
+
2005
+ StateErrorManager.info("状态删除处理完成", {
2006
+ component: "StateSelectV2",
2007
+ method: "handleStateDelete",
2008
+ params: { deletedIndex: deleteIndex, remainingStates: ctrl.states.length, softDelete: true },
2009
+ });
2010
+ }
2011
+
2012
+ /** 🔧 新增:迁移状态数据 */
2013
+ private migrateStateData(pageData: TPage, deleteIndex: number, statesLength: number) {
2014
+ // 🔧 将删除位置后面的状态数据前移
2015
+ for (let state = deleteIndex; state < statesLength; state++) {
2016
+ const nextStateData = pageData[state + 1];
2017
+ if (nextStateData != void 0) {
2018
+ pageData[state] = nextStateData;
2019
+ }
2020
+ else {
2021
+ // 🔧 如果没有下一个状态数据,删除当前位置的数据
2022
+ delete pageData[state];
2023
+ }
2024
+ }
2025
+
2026
+ // 🔧 删除最后一个状态的数据(因为状态数量减少了1)
2027
+ delete pageData[statesLength];
2028
+ }
2029
+
2030
+ /** 🔧 新增:清理被删除状态的属性 */
2031
+ private cleanupDeletedStateProps(pageData: TPage, ctrl: StateControllerV2, deletedStateData: TProp | undefined) {
2032
+ if (!deletedStateData || typeof deletedStateData !== "object") {
2033
+ return;
2034
+ }
2035
+ const defaultData = pageData.$$default$$;
2036
+ if (!defaultData) {
2037
+ this.updateChangedProp();
2038
+ return;
2039
+ }
2040
+
2041
+ // 🔧 遗留 number key (老 .fire 兜底): 其他 state 都没有 → 从 default GC
2042
+ const numKeys = this.extractNumericPropKeys(deletedStateData);
2043
+ for (const prop of numKeys) {
2044
+ if (!this.isOtherHans(ctrl, prop) && defaultData[prop] != void 0) {
2045
+ delete defaultData[prop];
2046
+ }
2047
+ }
2048
+
2049
+ // #C4: string propRef key (X 方案主路径): 同样 GC default 孤儿
2050
+ const refKeys = this.extractPropRefKeys(deletedStateData);
2051
+ for (const propRef of refKeys) {
2052
+ if (!this.isOtherHansByPropRef(ctrl, propRef)) {
2053
+ if ((defaultData as any)[propRef] != void 0) delete (defaultData as any)[propRef];
2054
+ // #V6: 同步清 default 的 $$controlledProps$$ flag, 否则 GC 了值却留受控标记(元桶泄漏)
2055
+ const ddCp = (defaultData as any).$$controlledProps$$;
2056
+ if (ddCp && ddCp[propRef] !== undefined) delete ddCp[propRef];
2057
+ }
2058
+ }
2059
+
2060
+ // 🔧 更新已更改属性的显示
2061
+ this.updateChangedProp();
2062
+ }
2063
+
2064
+ /** #C4: isOtherHans 的 string propRef 版本 — 某 propRef 是否仍存在于任一剩余 state. */
2065
+ private isOtherHansByPropRef(ctrl: StateControllerV2, propRef: string): boolean {
2066
+ const pageData = this.getPageData();
2067
+ for (let i = 0, len = ctrl.states.length; i < len; i++) {
2068
+ const stateId = this.getStateIdByIndex(ctrl, i);
2069
+ if (stateId < 0) continue;
2070
+ const propData = pageData[stateId];
2071
+ if (propData && (propData as any)[propRef] != void 0) {
2072
+ return true;
2073
+ }
2074
+ }
2075
+ return false;
2076
+ }
2077
+
2078
+ /** 🔧 新增:重排状态数据,保持属性与状态顺序一致 */
2079
+ private reorderStateData(pageData: TPage, fromIndex: number, toIndex: number, statesLength: number) {
2080
+ // 将状态数据视为数组进行移动,保留 $$ 开头的元数据
2081
+ const dataArray: Array<TProp | undefined> = [];
2082
+ for (let i = 0; i < statesLength; i++) {
2083
+ dataArray[i] = pageData[i];
2084
+ }
2085
+
2086
+ const [moved] = dataArray.splice(fromIndex, 1);
2087
+ dataArray.splice(toIndex, 0, moved);
2088
+
2089
+ // 回写数据,超出范围的清理掉
2090
+ for (let i = 0; i < statesLength; i++) {
2091
+ const stateData = dataArray[i];
2092
+ if (stateData !== undefined) {
2093
+ pageData[i] = stateData;
2094
+ }
2095
+ else {
2096
+ delete pageData[i];
2097
+ }
2098
+ }
2099
+ }
2100
+
2101
+ /** 🔧 新增:更新状态枚举列表 */
2102
+ private updateStateEnumList(ctrl: StateControllerV2) {
2103
+ if (!ctrl || !ctrl.states) {
2104
+ StateErrorManager.warn("控制器或状态数据无效", {
2105
+ component: "StateSelectV2",
2106
+ method: "updateStateEnumList",
2107
+ });
2108
+ return;
2109
+ }
2110
+
2111
+ // 🔧 生成状态枚举数组
2112
+ const enumList = ctrl.states.map((state, index) => {
2113
+ if (!state || typeof state.name !== "string") {
2114
+ StateErrorManager.warn("状态数据无效", {
2115
+ component: "StateSelectV2",
2116
+ method: "updateStateEnumList",
2117
+ params: { stateIndex: index },
2118
+ });
2119
+ return { name: `状态${index}`, value: index };
2120
+ }
2121
+ return { name: state.name, value: index };
2122
+ });
2123
+
2124
+ // 🔧 更新编辑器属性枚举列表
2125
+ try {
2126
+ // @ts-expect-error cc.Class.Attr.setClassAttr is not typed
2127
+ cc.Class.Attr.setClassAttr(this, "ctrlState", "enumList", enumList);
2128
+ }
2129
+ catch (error) {
2130
+ StateErrorManager.warn("更新状态枚举列表失败", {
2131
+ component: "StateSelectV2",
2132
+ method: "updateStateEnumList",
2133
+ params: { error: error.message },
2134
+ });
2135
+ }
2136
+ }
2137
+
2138
+ /** 控制器被删除 */
2139
+ public updateDelete(ctrl: StateControllerV2) {
2140
+ if (!CC_EDITOR) {
2141
+ return;
2142
+ }
2143
+ delete this._ctrlData[ctrl.ctrlId];
2144
+ if (this.currCtrlId == ctrl.ctrlId) {
2145
+ // @ts-expect-error _onPreDestroy is not typed
2146
+ this._onPreDestroy();
2147
+ }
2148
+ else {
2149
+ setTimeout(() => {
2150
+ this.updateCtrlName(ctrl.node);
2151
+ });
2152
+ }
2153
+ }
2154
+
2155
+ /**
2156
+ * 回收站硬删: 清掉指定 stateId 在本 select 上的页数据 (_ctrlData[ctrlId][stateId]).
2157
+ * 仅删该 state 页, 不碰 $$default$$ 与其它 state。由 StateControllerV2.purgeDeletedState
2158
+ * 经 EnumUpdateType.PurgeStateId 广播触发, 不可恢复。
2159
+ */
2160
+ public purgeStateData(ctrl: StateControllerV2, stateId: number): void {
2161
+ if (!CC_EDITOR) {
2162
+ return;
2163
+ }
2164
+ if (!ctrl || typeof stateId !== "number") {
2165
+ return;
2166
+ }
2167
+ const pageData = this._ctrlData && this._ctrlData[ctrl.ctrlId];
2168
+ if (!pageData) {
2169
+ return;
2170
+ }
2171
+ if ((pageData as any)[stateId] !== undefined) {
2172
+ delete (pageData as any)[stateId];
2173
+ StateErrorManager.info("已清除 state 页数据 (回收站硬删)", {
2174
+ component: "StateSelectV2",
2175
+ method: "purgeStateData",
2176
+ params: { ctrlId: ctrl.ctrlId, stateId },
2177
+ });
2178
+ }
2179
+ }
2180
+
2181
+ /** 已经改变的属性 */
2182
+ public updateChangedProp() {
2183
+ const propdata = this.getPropData();
2184
+ const arr: string[] = [];
2185
+ const changedProps = propdata.$$changedProp$$;
2186
+ if (changedProps) {
2187
+ for (const name of Object.keys(changedProps)) {
2188
+ arr.push(name);
2189
+ }
2190
+ }
2191
+ this.changedProp = arr;
2192
+ }
2193
+
2194
+ /** 提取数值型属性键(排除元数据) */
2195
+ private extractNumericPropKeys(data: TProp): number[] {
2196
+ return Object.keys(data)
2197
+ .filter(key => !key.startsWith("$$"))
2198
+ .map(key => Number(key))
2199
+ .filter(key => !Number.isNaN(key));
2200
+ }
2201
+
2202
+ /**
2203
+ * 提取 propData 中**仅遗留 number key** 对应的 EnumPropName.
2204
+ *
2205
+ * T2 双轨统一 (X方案): 内置 prop 已收敛到 propRef 字符串单一路径 (与自定义对称),
2206
+ * 由 applyPropRefKeysToNode 统一 apply (带 userExcl/sysExcl 排除过滤). 故本方法**不再**
2207
+ * 把 string propRef key 反查桥回 ENUM/batchUpdateUI 路径 —— 那条桥曾导致:
2208
+ * - F-6: ENUM 路径无排除过滤, 排除的内置仍被 batchUpdateUI 写回;
2209
+ * - F-9: 内置同时被 batchUpdateUI(ENUM) + applyPropRefKeysToNode(propRef) 双写.
2210
+ * 现仅返回 number key (尚未被 migrateLegacyCtrlData 迁走的老 .fire 数据兜底),
2211
+ * 迁移后正常数据无 number key, 本方法通常返回空, 内置全部走 propRef apply 单轨.
2212
+ */
2213
+ private extractEnumPropTypes(data: TProp): EnumPropName[] {
2214
+ const seen = new Set<number>();
2215
+ const out: EnumPropName[] = [];
2216
+ if (!data) return out;
2217
+ for (const key of Object.keys(data)) {
2218
+ if (key.startsWith("$$")) continue;
2219
+ if (/^\d+$/.test(key)) {
2220
+ const num = Number(key);
2221
+ if (Number.isFinite(num) && !seen.has(num)) {
2222
+ seen.add(num);
2223
+ out.push(num as EnumPropName);
2224
+ }
2225
+ }
2226
+ // string propRef key 不再桥回 ENUM —— 由 applyPropRefKeysToNode 统一 apply (T2 收敛单轨)
2227
+ }
2228
+ return out;
2229
+ }
2230
+
2231
+ // #endregion 4.
2232
+
2233
+ // #region 5. 属性同步与应用 (state 切换 → node/component apply)
2234
+
2235
+ /** 确保节点在隐藏的时候也会执行__preload(负责stateSelect的显示) */
2236
+ public updatePreLoad(ctrl: StateControllerV2) {
2237
+ if (!ctrl || ctrl.ctrlId != this.currCtrlId) {
2238
+ return;
2239
+ }
2240
+ this.__preload();
2241
+ }
2242
+
2243
+ /** 更新属性 */
2244
+ public updateProp(ctrl: StateControllerV2) {
2245
+ if (!ctrl || ctrl.ctrlId != this.currCtrlId) {
2246
+ return;
2247
+ }
2248
+ this.refProp();
2249
+ }
2250
+
2251
+ // ==============更具控制器更新的状态 主要代码================
2252
+ // Wave 2 T11: _isFromCtrl 标记位删除 (原本用于 setDefaultProp 期间抑制循环写;
2253
+ // setDefaultProp 已随 cc 事件 hook 一起退役, 标记位不再有意义)。
2254
+ /** 更新状态 */
2255
+ public updateState(ctrl: StateControllerV2) {
2256
+ if (!ctrl) {
2257
+ StateErrorManager.warn("updateState: 控制器为空", {
2258
+ component: "StateSelectV2",
2259
+ method: "updateState",
2260
+ });
2261
+ return;
2262
+ }
2263
+ // #U8: 节点失效(销毁/null, 如场景切换/动态清理) → 整体优雅早退, 不继续 batchUpdateUI +
2264
+ // applyPropRefKeysToNode 在死节点上写值刷大量 "写值失败" 警告。
2265
+ if (!this.node || !this.node.isValid) {
2266
+ return;
2267
+ }
2268
+
2269
+ StateErrorManager.debug("开始状态更新", {
2270
+ component: "StateSelectV2",
2271
+ method: "updateState",
2272
+ params: {
2273
+ ctrlId: ctrl.ctrlId,
2274
+ selectedIndex: ctrl.selectedIndex,
2275
+ currentPropKey: EnumPropName[this.propKey],
2276
+ },
2277
+ });
2278
+
2279
+ // 🔧 第一步:保存当前属性选择状态
2280
+ const currentPropKey = this.propKey;
2281
+ const isAutoSync = this.autoSyncEnabled;
2282
+ const shouldKeepPropKey = isAutoSync && currentPropKey !== EnumPropName.Non;
2283
+
2284
+ // 🔧 第二步:获取状态数据
2285
+ const propData = this.getPropData(ctrl.selectedIndex, ctrl.ctrlId);
2286
+ const defaultData = this.getDefaultData(ctrl.ctrlId);
2287
+
2288
+ // 🔧 第三/四步:构建属性批次 + 批量应用 (抽到 applyDataToNode, 与回收站预览共用单一 apply 路径)
2289
+ this.applyDataToNode(propData, defaultData);
2290
+
2291
+ // 🔧 第五步:根据同步模式恢复属性选择
2292
+ if (shouldKeepPropKey) {
2293
+ // 自动同步模式:保持当前选中的属性 (Track1: 仅刷新显示, 抑制回写/物化)
2294
+ this._suppressPropCapture = true;
2295
+ this.propKey = currentPropKey;
2296
+ this._suppressPropCapture = false;
2297
+ StateErrorManager.debug("保持当前属性选择", {
2298
+ component: "StateSelectV2",
2299
+ method: "updateState",
2300
+ params: { keptPropKey: EnumPropName[currentPropKey] },
2301
+ });
2302
+ }
2303
+ else {
2304
+ // 其他模式:使用新状态的lastProp
2305
+ this.refProp();
2306
+ StateErrorManager.debug("使用状态lastProp", {
2307
+ component: "StateSelectV2",
2308
+ method: "updateState",
2309
+ });
2310
+ }
2311
+
2312
+ StateErrorManager.info("状态更新完成", {
2313
+ component: "StateSelectV2",
2314
+ method: "updateState",
2315
+ params: {
2316
+ targetState: ctrl.selectedIndex,
2317
+ finalPropKey: EnumPropName[this._propKey],
2318
+ },
2319
+ });
2320
+ }
2321
+
2322
+ /**
2323
+ * 显式提交当前节点上某 prop 的值到当前 state 的 ctrlData (Wave 2 替代 setDefaultProp).
2324
+ *
2325
+ * 用途: 测试 / 工具 / panel 手动调用 "把节点当前 prop 值持久化到 state".
2326
+ * 与录制路径关系: 录制路径走 snapshot+diff, 这里是直接 commit, 不依赖 snapshot。
2327
+ * 仅当 prop 被 $$controlledProps$$ 标记为受控时才写入, 与原 setDefaultProp 行为一致。
2328
+ */
2329
+ public commitPropFromNode(type: EnumPropName): void {
2330
+ if (!CC_EDITOR) return;
2331
+ if (type === EnumPropName.Non) return;
2332
+ const propData = this.getPropData();
2333
+ // 仅 controlled props 接受 commit。聚合根治: AMBIGUOUS 走子项独立, 受控判定按"任一子项受控"
2334
+ // (decompose 后 controlledProps 记子项 x/y/z, 聚合 key 不存在 → 不能用 readPropByEnum(聚合)判)。
2335
+ const cr = enumToPropRef(type);
2336
+ const crSubs = (cr !== undefined && isAmbiguousAggregatePropRef(cr))
2337
+ ? this.getControllableAmbiguousSubRefs(cr)
2338
+ : [];
2339
+ const controlled = crSubs.length > 0
2340
+ ? crSubs.some(s => this.isPropertyControlledByPropRef(s))
2341
+ : (this.readPropByEnum(propData, type) !== undefined); // 非聚合 / euler 走聚合 key 判
2342
+ if (!controlled) return;
2343
+ const value = PropHandlerManager.getValue(type, this.node);
2344
+ if (value === undefined) return;
2345
+ // writePropByEnum 对 AMBIGUOUS 自动拆子项写入 propData[x/y/z] (聚合 key 也写但 apply 被 #C6 门控跳过)
2346
+ this.writePropByEnum(propData, type, value);
2347
+ if (type === this.propKey) {
2348
+ this._propValue = value;
2349
+ }
2350
+ }
2351
+
2352
+ /**
2353
+ * @deprecated Wave 2 重构: setDefaultProp 已迁移到 commitPropFromNode.
2354
+ * 兼容性 shim, 现有测试仍可调用; 等价于 commitPropFromNode(type)。
2355
+ */
2356
+ public setDefaultProp(type: EnumPropName): void {
2357
+ this.commitPropFromNode(type);
2358
+ }
2359
+
2360
+ // ================== Wave 2: 录制 prefab diff 路径 ==================
2361
+
2362
+ /**
2363
+ * 收集节点上当前所有受控 prop 的"实际"值, 作为 snapshot 基础。
2364
+ *
2365
+ * 数据来源: PropHandlerManager.getValue(node) (返回 clone, 不会被外部 mutate)。
2366
+ * 过滤器: 仅 $$controlledProps$$ 中标记为 controlled 的 prop 才进 snapshot,
2367
+ * 即只 diff 用户显式开启控制的 prop。
2368
+ */
2369
+ private readControlledPropsFromNode(ctrl: StateControllerV2): TProp {
2370
+ const snap: TProp = {} as TProp;
2371
+ if (!ctrl) {
2372
+ return snap;
2373
+ }
2374
+ // Track1: 受控集从 ctrl 级 default 读 (compact 后 state 不再内联 controlledProps).
2375
+ const controlledProps = this.getControlledPropsMap(ctrl.ctrlId);
2376
+ // W6-2a: 双 key 共存. 内置 prop key 是 EnumPropName name string (e.g. "Active"), value 是数字;
2377
+ // 自定义 prop key 和 value 都是 propRef 字符串 (togglePropertyControlByPropRef 写入).
2378
+ // 先用 typeof value 区分: 数字 → 老 PropHandlerManager.getValue; 字符串 → propRef 路径.
2379
+ for (const propName in controlledProps) {
2380
+ const ctrlVal = controlledProps[propName];
2381
+ if (typeof ctrlVal === "number") {
2382
+ // 老路径: number key snapshot
2383
+ const value = PropHandlerManager.getValue(ctrlVal as EnumPropName, this.node);
2384
+ if (value !== undefined) {
2385
+ (snap as TPropDictionary)[ctrlVal] = value;
2386
+ }
2387
+ }
2388
+ else if (typeof ctrlVal === "string") {
2389
+ // 新路径: propRef string key snapshot. 用 cocos type 分发深拷.
2390
+ const tp = this.resolveTrackableProp(ctrlVal);
2391
+ const current = this.readNodeValueByPropRef(ctrlVal);
2392
+ if (current !== undefined) {
2393
+ (snap as any)[ctrlVal] = cloneValueByType(current, tp ? tp.cocosType : undefined);
2394
+ }
2395
+ }
2396
+ }
2397
+ return snap;
2398
+ }
2399
+
2400
+ /**
2401
+ * 撤销录制时, 把 _initialSnapshot (录制开始时拍的不可变副本) 写回 ctrlData[fromState],
2402
+ * 让 ctrlData 回到录制开始前的状态.
2403
+ *
2404
+ * 不调 applyPropDataToNode / setValue: StateControllerV2 在调用本方法后会触发 updateState(State),
2405
+ * StateSelectV2.updateState 会把回滚后的 propData 重新应用到节点.
2406
+ *
2407
+ * 设计要点: 用 _initialSnapshot 而不是 _snapshot. _snapshot 在 commitRecordingDiff 中会被刷新
2408
+ * 成节点新值 (作为下一段 diff 起点), 不再代表"录制开始时的原值"; _initialSnapshot 拍完不变.
2409
+ *
2410
+ * TASK-002 cancelRecording 路径专用. 调完清 _initialSnapshot / _snapshot / _fullSnapshot.
2411
+ */
2412
+ public applyRecordingSnapshot(ctrl: StateControllerV2, fromState: number): void {
2413
+ if (!CC_EDITOR) return;
2414
+ if (!ctrl || ctrl.ctrlId !== this.currCtrlId) return;
2415
+ // 被排除 prop 走独立节点快照还原 (ctrlData 回滚那条路够不到, 因为它们不进状态数据).
2416
+ // 放最前: 即使下面因没拍 controlled snapshot 早退, 被排除 prop 也要还原.
2417
+ this.restoreExcludedSnapshotToNode();
2418
+ if (this._initialSnapshot == null) {
2419
+ // 录制开始时没拍 snapshot (e.g. 控制器关联尚未建立), 直接清场, no-op 回滚
2420
+ this._snapshot = null;
2421
+ this._fullSnapshot = null;
2422
+ return;
2423
+ }
2424
+ const propData = this.getPropData(fromState, ctrl.ctrlId);
2425
+ if (propData) {
2426
+ const snap = this._initialSnapshot as any;
2427
+ // #F-A 修 (TASK-003) + T2 双轨统一: snapshot 双 key 共存 — number key (遗留) 与
2428
+ // string propRef key (readControlledPropsFromNode 对 typeof==string 的受控 prop 所存,
2429
+ // T2 后内置+自定义都走这条). 原实现只 Number(key) 回滚, string key 被跳过 →
2430
+ // 撤销录制对 string propRef 不回滚 (内置经 T2 后也中招, 见 Recording.cancel). 两类都回滚:
2431
+ const origKeys = this._initialPropDataKeys;
2432
+ for (const key of Object.keys(snap)) {
2433
+ const num = Number(key);
2434
+ const isNum = Number.isFinite(num) && !Number.isNaN(num);
2435
+ const propRef = isNum ? enumToPropRef(num as EnumPropName) : key;
2436
+ // #S3: 录制前 propData 本就有此 key → 回滚原值; 录制前依赖 default(不在 origKeys)→ 删除
2437
+ // 录制中可能写入的硬编码, 保持动态 default 兜底。无 origKeys 记录(老路径)→ 退回全回滚。
2438
+ const wasPresent = origKeys
2439
+ ? (origKeys.has(key) || (propRef !== undefined && origKeys.has(propRef)))
2440
+ : true;
2441
+ if (!wasPresent) {
2442
+ if (propRef !== undefined) delete (propData as any)[propRef];
2443
+ if (isNum) delete (propData as any)[num];
2444
+ continue;
2445
+ }
2446
+ if (isNum) {
2447
+ // number key (遗留): writePropByEnum 切回 string propRef key 写入
2448
+ if (num === EnumPropName.Non) continue;
2449
+ this.writePropByEnum(propData, num as EnumPropName, snap[key]);
2450
+ }
2451
+ else {
2452
+ // string propRef key: 直写顶层 propData[propRef] (cocosType-aware clone)
2453
+ const tp = this.resolveTrackableProp(propRef as string);
2454
+ (propData as any)[propRef as string] = cloneValueByType(snap[propRef as string], tp ? tp.cocosType : undefined);
2455
+ }
2456
+ }
2457
+ }
2458
+ this._snapshot = null;
2459
+ this._initialSnapshot = null;
2460
+ this._initialPropDataKeys = null;
2461
+ this._fullSnapshot = null;
2462
+ StateErrorManager.debug("撤销录制: snapshot 回滚到 ctrlData", {
2463
+ component: "StateSelectV2",
2464
+ method: "applyRecordingSnapshot",
2465
+ params: { fromState, ctrlId: ctrl.ctrlId },
2466
+ });
2467
+ }
2468
+
2469
+ /**
2470
+ * 把 _excludedSnapshot (录制开始时被排除 prop 的节点值) 写回节点, 然后清空.
2471
+ *
2472
+ * 停止(stop)与取消(cancel)收尾都调: 被排除 prop 不进 ctrlData, updateState(State) 又会跳过被排除
2473
+ * prop (applyPropRefKeysToNode 过滤), 所以这里直接写节点的还原不会被后续 updateState 覆盖.
2474
+ * 幂等, 无快照时安全 no-op.
2475
+ */
2476
+ private restoreExcludedSnapshotToNode(): void {
2477
+ const snap = this._excludedSnapshot;
2478
+ this._excludedSnapshot = null;
2479
+ if (!snap || !this.node) return;
2480
+ for (const propRef of Object.keys(snap)) {
2481
+ const tp = this.resolveTrackableProp(propRef);
2482
+ this.writeNodeValueByPropRef(propRef, cloneValueByType(snap[propRef], tp ? tp.cocosType : undefined));
2483
+ }
2484
+ }
2485
+
2486
+ /**
2487
+ * 切 state 前 (录制中): commit diff 到 fromState.
2488
+ * 由 StateControllerV2.selectedIndex setter → updateState(StateWillChange, fromIdx) 派发。
2489
+ */
2490
+ public onStateWillChange(ctrl: StateControllerV2, fromState: number): void {
2491
+ if (!CC_EDITOR) return;
2492
+ if (!ctrl || ctrl.ctrlId !== this.currCtrlId) return;
2493
+ // 仅录制中才 diff commit
2494
+ if (!ctrl.isRecording) return;
2495
+ if (this._snapshot == null) return;
2496
+ this.commitRecordingDiff(ctrl, fromState);
2497
+ }
2498
+
2499
+ /**
2500
+ * 切 state 后 (录制中): 重拍 snapshot, 作为新一段 diff 起点.
2501
+ * 由 StateControllerV2 在 updateState(State) 之后再发 (T08 wiring).
2502
+ */
2503
+ public onStateChanged(ctrl: StateControllerV2): void {
2504
+ if (!CC_EDITOR) return;
2505
+ if (!ctrl || ctrl.ctrlId !== this.currCtrlId) return;
2506
+ if (!ctrl.isRecording) return;
2507
+ this._snapshot = this.readControlledPropsFromNode(ctrl);
2508
+ StateErrorManager.debug("录制 snapshot 已重拍", {
2509
+ component: "StateSelectV2",
2510
+ method: "onStateChanged",
2511
+ params: { newState: ctrl.selectedIndex },
2512
+ });
2513
+ }
2514
+
2515
+ /**
2516
+ * 录制开始: 拍双 snapshot.
2517
+ * _snapshot: 仅 controlled prop, 供 commit 路径用
2518
+ * _fullSnapshot: 所有 applicable prop, 供 stop 时检测未跟随 dirty 用
2519
+ * 由 StateControllerV2.startRecording -> updateState(RecordingStart) 派发。
2520
+ */
2521
+ public onRecordingStart(ctrl: StateControllerV2): void {
2522
+ if (!CC_EDITOR) return;
2523
+ if (!ctrl || ctrl.ctrlId !== this.currCtrlId) {
2524
+ return;
2525
+ }
2526
+ this._snapshot = this.readControlledPropsFromNode(ctrl);
2527
+ // TASK-002: 拍一份独立的不可变 snapshot 给 cancel 用 (_snapshot 在 commit 路径会被刷新)
2528
+ this._initialSnapshot = this.readControlledPropsFromNode(ctrl);
2529
+ this._fullSnapshot = this.readAllApplicablePropsFromNode();
2530
+ // 被排除 prop 单独拍纯节点值快照, 供 cancel 还原节点 (不进 ctrlData / _fullSnapshot).
2531
+ this._excludedSnapshot = this.readExcludedPropsFromNode();
2532
+ // #S3: 记录录制前 propData 本就存在的 key, cancel 时据此区分"回滚已有值" vs "删除依赖 default 的硬编码".
2533
+ const curPd = this.getPropData(ctrl.selectedIndex, ctrl.ctrlId);
2534
+ this._initialPropDataKeys = new Set(
2535
+ curPd ? Object.keys(curPd).filter(k => !k.startsWith("$$")) : [],
2536
+ );
2537
+ StateErrorManager.debug("录制双 snapshot 已拍", {
2538
+ component: "StateSelectV2",
2539
+ method: "onRecordingStart",
2540
+ params: {
2541
+ controlledKeys: Object.keys(this._snapshot).length,
2542
+ fullKeys: Object.keys(this._fullSnapshot).length,
2543
+ },
2544
+ });
2545
+ }
2546
+
2547
+ /**
2548
+ * 录制结束: commit controlled diff + 区分 auto/manual 收尾.
2549
+ * auto (ctrl.stopRecordingMode === "auto", 切 state 触发): 静默 commit, Editor.log 反馈
2550
+ * manual (按钮触发): 检测未跟随 prop 是否被改, 弹窗问是否追加跟随
2551
+ * 由 StateControllerV2.stopRecording -> updateState(RecordingStop) 派发。
2552
+ */
2553
+ public onRecordingStop(ctrl: StateControllerV2): void {
2554
+ if (!CC_EDITOR) return;
2555
+ if (!ctrl || ctrl.ctrlId !== this.currCtrlId) {
2556
+ return;
2557
+ }
2558
+ const targetState = ctrl.selectedIndex;
2559
+ // final commit: diff controlled snapshot vs 当前节点, 写 ctrlData[targetState]
2560
+ const committed = this.commitRecordingDiff(ctrl, targetState);
2561
+ // 检测未跟随的 dirty (录制期间被改但没勾跟随的 applicable prop)
2562
+ const untracked = this.detectUntrackedDirty();
2563
+
2564
+ const isAuto = (ctrl as any).stopRecordingMode === "auto";
2565
+ if (isAuto) {
2566
+ // 切 state 自动 stop — 静默反馈
2567
+ if (committed.length > 0) {
2568
+ const names = committed.map(p => EnumPropName[p]).join(", ");
2569
+ this.editorLog(`[StateSelectV2 "${this.node && this.node.name}"] 已保存 ${names} 到 state[${targetState}]`);
2570
+ }
2571
+ if (untracked.length > 0) {
2572
+ // W6-2a-fixup: untracked 是 union 数组 (EnumPropName | propRef string), 兼容显示
2573
+ const names = untracked.map(p => typeof p === "string" ? p : EnumPropName[p]).join(", ");
2574
+ this.editorWarn(`[StateSelectV2 "${this.node && this.node.name}"] 未跟随 prop ${names} 被改但已丢弃 (切 state 自动结束录制)`);
2575
+ }
2576
+ }
2577
+ else if (untracked.length > 0) {
2578
+ // 手动 stop 且有未跟随 dirty — 弹窗
2579
+ this.promptUntrackedAfterStop(ctrl, untracked);
2580
+ }
2581
+
2582
+ // 用户裁定: 停止(保存)也把被排除 prop 还原到录制前 —— 排除 = 录制期间完全不影响该属性,
2583
+ // 不管最终是保存(stop)还是丢弃(cancel). 已跟随 prop 的改后值由上面 commitRecordingDiff 正常提交,
2584
+ // 不受影响. (restore 内含清快照.)
2585
+ this.restoreExcludedSnapshotToNode();
2586
+ this._snapshot = null;
2587
+ this._fullSnapshot = null;
2588
+ // TASK-002: 同步清初始 snapshot
2589
+ this._initialSnapshot = null;
2590
+ this._initialPropDataKeys = null;
2591
+ StateErrorManager.debug("录制 snapshot 已清", {
2592
+ component: "StateSelectV2",
2593
+ method: "onRecordingStop",
2594
+ params: { auto: isAuto, committed: committed.length, untracked: untracked.length },
2595
+ });
2596
+ }
2597
+
2598
+ /**
2599
+ * 给 StateControllerV2.startRecording 用: 扫本 StateSelectV2 的 controlled prop,
2600
+ * 节点当前值 vs ctrlData[ctrl.selectedIndex] 不一致 = dirty.
2601
+ * 返回 [{ propType?, propRef?, current, stored }, ...]. 由 ctrl 端聚合后弹窗.
2602
+ *
2603
+ * W6-2a-fixup: schema 升级 - 双 key 双分支.
2604
+ * $$controlledProps$$ value 规则 (按 typeof 区分, 与 readControlledPropsFromNode 一致):
2605
+ * - number → 内置 prop (value 是 EnumPropName 数字), 走老 PropHandlerManager 路径.
2606
+ * 返回 {propType: EnumPropName, current, stored} (propRef undefined).
2607
+ * - string → 自定义 prop (value 是 propRef 字符串自指), 走 readPropFromNodeByPropRef.
2608
+ * 返回 {propRef: string, current, stored} (propType undefined).
2609
+ */
2610
+ public collectDirtyControlled(ctrl: StateControllerV2): Array<{ propType?: EnumPropName, propRef?: string, current: unknown, stored: unknown }> {
2611
+ const out: Array<{ propType?: EnumPropName, propRef?: string, current: unknown, stored: unknown }> = [];
2612
+ if (!ctrl || ctrl.ctrlId !== this.currCtrlId) return out;
2613
+ if (!this.node) return out;
2614
+ const propData = this.getPropData(ctrl.selectedIndex, ctrl.ctrlId);
2615
+ const defaultData = this.getDefaultData(ctrl.ctrlId);
2616
+ // Track1: 受控集从 ctrl 级 default 读; stored 值 state→default 兜底 (compact 后等于 default 的
2617
+ // 属性不再内联 state, 旧 fat 数据每个 state 都存 baseline, 兜底后两种格式 dirty 判定一致).
2618
+ const controlledProps = this.getControlledPropsMap(ctrl.ctrlId);
2619
+ for (const propName in controlledProps) {
2620
+ const ctrlVal = controlledProps[propName];
2621
+ if (typeof ctrlVal === "number") {
2622
+ // 老路径: 内置 EnumPropName 数字
2623
+ const propType = ctrlVal as EnumPropName;
2624
+ if (propType === EnumPropName.Non) continue;
2625
+ const current = PropHandlerManager.getValue(propType, this.node);
2626
+ if (current === undefined) continue;
2627
+ // W6-2c2: 双 key 读; state 无值则回落 default baseline
2628
+ let stored = this.readPropByEnum(propData, propType);
2629
+ if (stored === undefined) stored = this.readPropByEnum(defaultData, propType);
2630
+ if (stored === undefined) continue; // 已勾跟随但 ctrlData 还没值: 不算 dirty, 不弹
2631
+ if (!PropHandlerManager.isEqual(propType, stored, current)) {
2632
+ out.push({ propType, current, stored });
2633
+ }
2634
+ }
2635
+ else if (typeof ctrlVal === "string") {
2636
+ // W6-2a-fixup 新路径: 自定义 propRef 字符串
2637
+ const propRef = ctrlVal;
2638
+ const current = this.readPropFromNodeByPropRef(propRef);
2639
+ if (current === undefined) continue;
2640
+ let stored = (propData as any)[propRef];
2641
+ if (stored === undefined) stored = (defaultData as any)[propRef];
2642
+ if (stored === undefined) continue;
2643
+ const tp = this.resolveTrackableProp(propRef);
2644
+ const cocosType = tp ? tp.cocosType : undefined;
2645
+ if (!eqValueByType(stored, current, cocosType)) {
2646
+ out.push({ propRef, current, stored });
2647
+ }
2648
+ }
2649
+ }
2650
+ return out;
2651
+ }
2652
+
2653
+ /**
2654
+ * 扫节点上所有 applicable prop, 含未勾选跟随的, 供录制期间"未跟随 dirty"检测.
2655
+ *
2656
+ * W6-axis-decomp X 方案: 单走 listTrackableProps + readPropFromNodeByPropRef (propRef 字符串 key 单一路径).
2657
+ * 老 PropHandlerManager.listRegisteredPropTypes (数字 key) 路径已废 — 全 cocos 内省, AMBIGUOUS
2658
+ * 整体 propRef ('cc.Node.position'/'.anchorPoint'/'.contentSize') 不进 snapshot, 其子项 cc.Node.x/y/z
2659
+ * 等已在 listTrackableProps 内独立返回.
2660
+ *
2661
+ * 过滤 (与 autoOptInCustomComponentProps 对齐):
2662
+ * - state machine 自身 component (CONTROLLER_SYSTEM_COMPS) 跳过
2663
+ * - AMBIGUOUS 整体 propRef 跳过 (子项独立)
2664
+ * - readonly 字段无法 setValue, 不进 dirty detect 范围
2665
+ *
2666
+ * _fullSnapshot 内层 key 类型全部 string propRef (内置 + 自定义统一).
2667
+ */
2668
+ private readAllApplicablePropsFromNode(): TProp {
2669
+ const out: TProp = {} as TProp;
2670
+ if (!this.node) return out;
2671
+ let trackable: TrackableProp[] = [];
2672
+ try {
2673
+ trackable = listTrackableProps(this.node);
2674
+ }
2675
+ catch (_) { /* listTrackableProps 失败时返回空 snapshot — 录制 dirty detect 降级 */ }
2676
+ const systemComps = new Set(StateSelectV2.CONTROLLER_SYSTEM_COMPS);
2677
+ // W6: 用户排除清单的 prop 不进 _fullSnapshot — 排除 = 彻底脱离录制范围.
2678
+ // 否则录制期间误改被排除的 prop, detectUntrackedDirty 会当"未跟随 dirty"弹窗回写, 违背排除语义.
2679
+ // (SYSTEM_EXCLUDE 已在 listTrackableProps 内部过滤, 这里只补用户黑名单.)
2680
+ const userExcluded = new Set(this._userExcludedProps || []);
2681
+ for (const tp of trackable) {
2682
+ if (systemComps.has(tp.compName)) continue;
2683
+ if (isAmbiguousAggregatePropRef(tp.propRef)) continue;
2684
+ if (tp.readonly) continue;
2685
+ if (userExcluded.has(tp.propRef)) continue;
2686
+ const cur = this.readPropFromNodeByPropRef(tp.propRef);
2687
+ if (cur === undefined) continue;
2688
+ (out as any)[tp.propRef] = cloneValueByType(cur, tp.cocosType);
2689
+ }
2690
+ return out;
2691
+ }
2692
+
2693
+ /**
2694
+ * 读用户排除清单里每个 prop 的当前节点值 (propRef → value), 供 cancel 还原节点用.
2695
+ *
2696
+ * 与 _fullSnapshot 互补: _fullSnapshot 故意**不含**被排除 prop (不变量#8), 这里**只含**被排除 prop.
2697
+ * 仅取 _userExcludedProps (用户主动排除的); SYSTEM_EXCLUDE 是引擎内部 plumbing, 用户不感知也不该回写.
2698
+ * cocosType 用 resolveTrackableProp 反查 (失败则 undefined, cloneValueByType 退化为浅拷, 对 number 无碍).
2699
+ */
2700
+ private readExcludedPropsFromNode(): { [propRef: string]: any } {
2701
+ const out: { [propRef: string]: any } = {};
2702
+ if (!this.node) return out;
2703
+ for (const propRef of this._userExcludedProps || []) {
2704
+ const cur = this.readPropFromNodeByPropRef(propRef);
2705
+ if (cur === undefined) continue;
2706
+ const tp = this.resolveTrackableProp(propRef);
2707
+ out[propRef] = cloneValueByType(cur, tp ? tp.cocosType : undefined);
2708
+ }
2709
+ return out;
2710
+ }
2711
+
2712
+ /**
2713
+ * 录制期间, 哪些 applicable prop 被改了**但没勾选跟随**.
2714
+ * 用 _fullSnapshot (start 时全 prop 快照) vs 当前节点 diff, 减去 controlled 部分.
2715
+ *
2716
+ * W6-axis-decomp X 方案: _fullSnapshot 内层全部 string propRef key (readAllApplicablePropsFromNode
2717
+ * 单走 propRef 路径), 不再有数字 key. 单一遍历分支.
2718
+ *
2719
+ * 返回 string[] — 都是 propRef 字符串. 历史调用方 (Recording.modelZ 等) 仍可用 EnumPropName
2720
+ * 反查名字 'cc.Node.color' 等比较.
2721
+ */
2722
+ private detectUntrackedDirty(): Array<EnumPropName | string> {
2723
+ const out: Array<EnumPropName | string> = [];
2724
+ if (!this._fullSnapshot) return out;
2725
+ // #T4: 被排除(用户+系统)的 prop 不算"未跟随 dirty" —— 排除即"故意不进录制范围"(不变量#8),
2726
+ // 否则录制中途 setPropExcluded 的 prop 会被当 untracked → promptUntrackedAfterStop 提交 → 违反排除边界。
2727
+ const userExcl = new Set(this._userExcludedProps || []);
2728
+ const sysExcl = new Set(SYSTEM_EXCLUDE);
2729
+ for (const k of Object.keys(this._fullSnapshot)) {
2730
+ const propRef = k;
2731
+ if (userExcl.has(propRef) || sysExcl.has(propRef)) continue; // #T4: 排除项跳过
2732
+ if (this.isPropertyControlled(propRef)) continue; // 已跟随 — commit 路径已处理
2733
+ const before = (this._fullSnapshot as any)[propRef];
2734
+ const current = this.readPropFromNodeByPropRef(propRef);
2735
+ if (current === undefined) continue;
2736
+ const tp = this.resolveTrackableProp(propRef);
2737
+ const cocosType = tp ? tp.cocosType : undefined;
2738
+ if (!eqValueByType(before, current, cocosType)) {
2739
+ out.push(propRef);
2740
+ }
2741
+ }
2742
+ return out;
2743
+ }
2744
+
2745
+ /**
2746
+ * 手动 stopRecording + 有未跟随 dirty 时弹窗: 是否把这些 prop 自动加入跟随并保存当前值.
2747
+ * Editor.Dialog 异步, 用户点完才操作. 此时录制已停, 数据切 fromState 已 commit, 不冲突.
2748
+ *
2749
+ * W6-2a-fixup: untracked 是 union 数组 (EnumPropName 数字 | propRef 字符串). 内置走老
2750
+ * togglePropertyControl(EnumPropName) + writePropByEnum; 自定义走 togglePropertyControl(propRef)
2751
+ * (W6-2b 联合 API) + 直写 propData[propRef].
2752
+ */
2753
+ private promptUntrackedAfterStop(ctrl: StateControllerV2, untracked: Array<EnumPropName | string>): void {
2754
+ const names = untracked.map(p => typeof p === "string" ? p : EnumPropName[p]);
2755
+ const message = `录制期间这些 prop 被改了, 但未勾选跟随:\n ${names.join("\n ")}\n\n是否自动加入跟随并保存到 state[${ctrl.selectedIndex}]?`;
2756
+ const onConfirm = () => {
2757
+ for (const item of untracked) {
2758
+ if (typeof item === "number") {
2759
+ const propType = item as EnumPropName;
2760
+ this.togglePropertyControl(propType, true);
2761
+ // togglePropertyControl(prop, true) 会写 controlled flag + 默认值;
2762
+ // 这里再 commit 节点当前实际值, 覆盖默认.
2763
+ const current = PropHandlerManager.getValue(propType, this.node);
2764
+ if (current !== undefined) {
2765
+ const propData = this.getPropData(ctrl.selectedIndex, ctrl.ctrlId);
2766
+ // W6-2c2: 写 string propRef key
2767
+ this.writePropByEnum(propData, propType, current);
2768
+ }
2769
+ }
2770
+ else {
2771
+ // W6-2a-fixup: 自定义 propRef 路径
2772
+ const propRef = item;
2773
+ this.togglePropertyControl(propRef, true);
2774
+ const current = this.readPropFromNodeByPropRef(propRef);
2775
+ if (current !== undefined) {
2776
+ const propData = this.getPropData(ctrl.selectedIndex, ctrl.ctrlId);
2777
+ if (propData) {
2778
+ const tp = this.resolveTrackableProp(propRef);
2779
+ (propData as any)[propRef] = cloneValueByType(current, tp ? tp.cocosType : undefined);
2780
+ }
2781
+ }
2782
+ }
2783
+ }
2784
+ this.editorLog(`[StateSelectV2 "${this.node && this.node.name}"] 追加跟随 + 保存: ${names.join(", ")} 到 state[${ctrl.selectedIndex}]`);
2785
+ };
2786
+ this.showDialog({
2787
+ type: "info",
2788
+ title: "录制结束: 未跟随的 prop 被改",
2789
+ message,
2790
+ buttons: ["保存并自动加入跟随", "丢弃"],
2791
+ defaultId: 0,
2792
+ cancelId: 1,
2793
+ }, (idx) => {
2794
+ if (idx === 0) onConfirm();
2795
+ else this.editorLog(`[StateSelectV2 "${this.node && this.node.name}"] 丢弃未跟随 prop: ${names.join(", ")}`);
2796
+ });
2797
+ }
2798
+
2799
+ /**
2800
+ * 弹窗封装 — 见 StateControllerV2.showDialog 同步注释.
2801
+ * cocos 2.x Editor.Dialog 仅 main process 可达, component renderer 用 window.confirm 兜底.
2802
+ */
2803
+ private showDialog(opts: { title: string, message: string, buttons: string[], defaultId?: number, cancelId?: number, type?: string }, cb: (idx: number) => void): void {
2804
+ try {
2805
+ const Ed = (globalThis as any).Editor;
2806
+ if (Ed && Ed.Dialog && typeof Ed.Dialog.messageBox === "function") {
2807
+ let resolved = false;
2808
+ const sync = Ed.Dialog.messageBox(opts, (idx: number) => {
2809
+ if (!resolved) {
2810
+ resolved = true;
2811
+ cb(typeof idx === "number" ? idx : (opts.defaultId || 0));
2812
+ }
2813
+ });
2814
+ if (!resolved && typeof sync === "number") {
2815
+ resolved = true;
2816
+ cb(sync);
2817
+ }
2818
+ if (resolved) return;
2819
+ }
2820
+ }
2821
+ catch (_) { /* fall through */ }
2822
+
2823
+ const nav = (globalThis as any).navigator;
2824
+ const isJsdom = !!(nav && nav.userAgent && nav.userAgent.indexOf("jsdom") >= 0);
2825
+ if (!isJsdom) {
2826
+ try {
2827
+ const w = (globalThis as any).window;
2828
+ if (w && typeof w.confirm === "function") {
2829
+ const head = `${opts.title}\n\n${opts.message}`;
2830
+ if (opts.buttons.length === 2) {
2831
+ const ok = w.confirm(`${head}\n\n确定 = ${opts.buttons[0]}\n取消 = ${opts.buttons[1]}`);
2832
+ cb(ok ? 0 : 1);
2833
+ return;
2834
+ }
2835
+ if (opts.buttons.length === 3) {
2836
+ const first = w.confirm(`${head}\n\n确定 = ${opts.buttons[0]}\n取消 = (进入下一选项)`);
2837
+ if (first) {
2838
+ cb(0);
2839
+ return;
2840
+ }
2841
+ const second = w.confirm(`继续选择:\n\n确定 = ${opts.buttons[1]}\n取消 = ${opts.buttons[2]}`);
2842
+ cb(second ? 1 : 2);
2843
+ return;
2844
+ }
2845
+ }
2846
+ }
2847
+ catch (_) { /* fall through */ }
2848
+ }
2849
+
2850
+ cb(typeof opts.defaultId === "number" ? opts.defaultId : 0);
2851
+ }
2852
+
2853
+ private editorLog(msg: string): void {
2854
+ try {
2855
+ const Ed = (globalThis as any).Editor;
2856
+ if (Ed && typeof Ed.log === "function") Ed.log(msg);
2857
+ }
2858
+ catch (_) { /* noop */ }
2859
+ }
2860
+
2861
+ private editorWarn(msg: string): void {
2862
+ try {
2863
+ const Ed = (globalThis as any).Editor;
2864
+ if (Ed && typeof Ed.warn === "function") Ed.warn(msg);
2865
+ }
2866
+ catch (_) { /* noop */ }
2867
+ }
2868
+
2869
+ /**
2870
+ * 把 (snapshot, 当前节点) 之间的差异 commit 到 ctrlData[targetState].
2871
+ *
2872
+ * 算法: 对 snapshot 中每个 prop, 读节点当前值, 用 PropHandler.isEqual 判断变化;
2873
+ * 有变化 → 写 ctrlData[targetState][prop] = current; 同时刷新 snapshot 为 current
2874
+ * (供下一段 diff 起点)。
2875
+ */
2876
+ private commitRecordingDiff(ctrl: StateControllerV2, targetState: number): EnumPropName[] {
2877
+ const committed: EnumPropName[] = [];
2878
+ if (!this._snapshot) return committed;
2879
+ const propData = this.getPropData(targetState, ctrl.ctrlId);
2880
+ const snap = this._snapshot;
2881
+ // #T4: 录制中途被排除的 prop 不得 commit (不变量#8). 排除清单(用户+系统)在此过滤,
2882
+ // 即使 _snapshot 录制开始时含该 key, 中途 setPropExcluded 后也不写回。
2883
+ const userExcl = new Set(this._userExcludedProps || []);
2884
+ const sysExcl = new Set(SYSTEM_EXCLUDE);
2885
+ const isExcluded = (propRef: string | undefined): boolean =>
2886
+ propRef !== undefined && (userExcl.has(propRef) || sysExcl.has(propRef));
2887
+ for (const key of Object.keys(snap)) {
2888
+ // W6-2a: 双 key 分发 — 数字 key 走老 PropHandlerManager, 字符串 propRef 走新路径.
2889
+ const num = Number(key);
2890
+ if (Number.isFinite(num) && !Number.isNaN(num)) {
2891
+ // 老路径: EnumPropName 数字 key
2892
+ const propType = num;
2893
+ if (propType === EnumPropName.Non) continue;
2894
+ if (isExcluded(enumToPropRef(propType as EnumPropName))) continue; // #T4
2895
+ const currentValue = PropHandlerManager.getValue(propType as EnumPropName, this.node);
2896
+ if (currentValue === undefined) continue;
2897
+ const snapValue = (snap as TPropDictionary)[propType];
2898
+ if (!PropHandlerManager.isEqual(propType as EnumPropName, snapValue, currentValue)) {
2899
+ // W6-2c2: 写 string propRef key (snapshot 仍按 number key 索引, 是 in-memory 临时态)
2900
+ this.writePropByEnum(propData, propType as EnumPropName, currentValue);
2901
+ (snap as TPropDictionary)[propType] = currentValue;
2902
+ committed.push(propType as EnumPropName);
2903
+ StateErrorManager.debug("录制 diff 提交 (enum)", {
2904
+ component: "StateSelectV2",
2905
+ method: "commitRecordingDiff",
2906
+ params: { state: targetState, propType: EnumPropName[propType as EnumPropName] },
2907
+ });
2908
+ }
2909
+ }
2910
+ else {
2911
+ // 新路径: propRef 字符串 key
2912
+ const propRef = key;
2913
+ if (isExcluded(propRef)) continue; // #T4: 排除的 prop 不 commit
2914
+ const tp = this.resolveTrackableProp(propRef);
2915
+ const currentValue = this.readNodeValueByPropRef(propRef);
2916
+ if (currentValue === undefined) continue;
2917
+ const snapValue = (snap as any)[propRef];
2918
+ const cocosType = tp ? tp.cocosType : undefined;
2919
+ if (!eqValueByType(snapValue, currentValue, cocosType)) {
2920
+ const cloned = cloneValueByType(currentValue, cocosType);
2921
+ (propData as any)[propRef] = cloned;
2922
+ (snap as any)[propRef] = cloned;
2923
+ // T2 双轨统一: 内置 prop 现走 propRef 分支提交. 若该 propRef 反查到 EnumPropName
2924
+ // (内置), push 进 committed —— 保证 onRecordingStop 静默 log / commit 计数与
2925
+ // 旧 number 分支等价 (自定义 propRef 无 EnumPropName 映射, 维持原先不计入的行为).
2926
+ const mappedEnum = PROPREF_TO_ENUM[propRef];
2927
+ if (mappedEnum !== undefined) {
2928
+ committed.push(mappedEnum as EnumPropName);
2929
+ }
2930
+ StateErrorManager.debug("录制 diff 提交 (propRef)", {
2931
+ component: "StateSelectV2",
2932
+ method: "commitRecordingDiff",
2933
+ params: { state: targetState, propRef },
2934
+ });
2935
+ }
2936
+ }
2937
+ }
2938
+ return committed;
2939
+ }
2940
+
2941
+ /**
2942
+ * W6-2a: 把 propData 内层的 string propRef key 值应用到节点 (apply 路径补充).
2943
+ *
2944
+ * 优先级: state propData[propRef] > defaultData[propRef]. 与 updateState 数字 key 路径
2945
+ * 一致 — 用 cloneValueByType 走 cocos type 分发写值, 避免共享引用被节点 mutate 后污染 ctrlData.
2946
+ *
2947
+ * 走此路径的 propRef 都是 togglePropertyControlByPropRef 接入的自定义组件 prop,
2948
+ * 因为 EnumPropName 老路径覆盖的 propRef 由 batchUpdateUI 处理.
2949
+ */
2950
+ /**
2951
+ * 把一份 propData(+default 兜底) 应用到节点 (enum 数字路径 batchUpdateUI + propRef 字符串路径).
2952
+ * 从 updateState 抽出, updateState(当前 selectedIndex) 与回收站预览(任意 stateId) 共用单一 apply 路径.
2953
+ */
2954
+ private applyDataToNode(propData: TProp, defaultData: TProp): void {
2955
+ const updateBatch: { type: EnumPropName, value: TPropValue }[] = [];
2956
+ const processedKeys = new Set<number>();
2957
+
2958
+ // W6-2c2: 内置 prop 数据多在 string propRef key 下, 但 PropHandler 按 EnumPropName 数字派发,
2959
+ // 这里仍按 propType 走老路径桥接 (extractEnumPropTypes 把 propRef 反查回 EnumPropName).
2960
+ const defaultPropTypes = this.extractEnumPropTypes(defaultData);
2961
+ for (const propType of defaultPropTypes) {
2962
+ const stateValue = this.readPropByEnum(propData, propType);
2963
+ const defaultValue = this.readPropByEnum(defaultData, propType);
2964
+ const value = stateValue != void 0 ? stateValue : defaultValue;
2965
+ if (value == void 0) {
2966
+ continue;
2967
+ }
2968
+ updateBatch.push({ type: propType, value });
2969
+ processedKeys.add(propType);
2970
+ }
2971
+
2972
+ const statePropTypes = this.extractEnumPropTypes(propData);
2973
+ for (const propType of statePropTypes) {
2974
+ if (processedKeys.has(propType)) {
2975
+ continue;
2976
+ }
2977
+ const value = this.readPropByEnum(propData, propType);
2978
+ if (value == void 0) {
2979
+ continue;
2980
+ }
2981
+ updateBatch.push({ type: propType, value });
2982
+ }
2983
+
2984
+ // enum 数字路径
2985
+ this.batchUpdateUI(updateBatch);
2986
+ // string propRef 路径 (cocos type 分发写回, 带排除/受控门控)
2987
+ this.applyPropRefKeysToNode(propData, defaultData);
2988
+ }
2989
+
2990
+ /**
2991
+ * 回收站预览快照 (非 @property, 不序列化): 进入预览前拍下"即将被 apply 覆盖的所有 key 的当前节点值",
2992
+ * 退出预览时按它精确还原 —— 不依赖"重画激活态", 故回收态与激活态的受控属性集不对称也能干净还原。
2993
+ * enums: EnumPropName 数字路径的原值; refs: propRef 字符串路径的原值 (按 cocosType 深拷)。
2994
+ */
2995
+ private _previewSnapshot: { enums: { [t: number]: TPropValue }, refs: { [ref: string]: any } } | null = null;
2996
+
2997
+ /**
2998
+ * 进入某 stateId 的只读预览: 先快照将被覆盖的 key 的当前节点值, 再 apply 该 state 数据到节点.
2999
+ * 该 select 在此 state 无数据则 no-op (可能别的受控 select 才有)。由 StateControllerV2 经
3000
+ * EnumUpdateType.PreviewEnter 广播触发。幂等性由 controller 侧单实例预览保证。
3001
+ */
3002
+ public enterPreview(ctrl: StateControllerV2, stateId: number): void {
3003
+ if (!CC_EDITOR) return;
3004
+ if (!ctrl || ctrl.ctrlId !== this.currCtrlId) return;
3005
+ if (!this.node || !this.node.isValid) return;
3006
+ const pageData = this.getPageData(ctrl.ctrlId);
3007
+ const previewData = pageData ? (pageData as any)[stateId] : undefined;
3008
+ if (previewData == null) return;
3009
+ const defaultData = this.getDefaultData(ctrl.ctrlId);
3010
+ this._previewSnapshot = this.snapshotNodeForData(previewData, defaultData);
3011
+ this.applyDataToNode(previewData, defaultData);
3012
+ }
3013
+
3014
+ /** 退出预览: 按快照把节点精确还原到预览前, 清快照. 幂等, 无快照安全 no-op. */
3015
+ public exitPreview(): void {
3016
+ if (!CC_EDITOR) return;
3017
+ const snap = this._previewSnapshot;
3018
+ this._previewSnapshot = null;
3019
+ if (!snap || !this.node || !this.node.isValid) return;
3020
+ // propRef 路径还原
3021
+ for (const ref of Object.keys(snap.refs)) {
3022
+ const tp = this.resolveTrackableProp(ref);
3023
+ try {
3024
+ this.writeNodeValueByPropRef(ref, cloneValueByType(snap.refs[ref], tp ? tp.cocosType : undefined));
3025
+ }
3026
+ catch (_) { /* noop */ }
3027
+ }
3028
+ // enum 路径还原
3029
+ for (const k of Object.keys(snap.enums)) {
3030
+ const handler = PropHandlerManager.getHandler(Number(k) as EnumPropName);
3031
+ if (handler) {
3032
+ try {
3033
+ handler.setValue(this.node, (snap.enums as any)[k]);
3034
+ }
3035
+ catch (_) { /* noop */ }
3036
+ }
3037
+ }
3038
+ }
3039
+
3040
+ /** 拍下 applyDataToNode(propData, defaultData) 将写到的所有 key 的当前节点值 (供 exitPreview 还原). */
3041
+ private snapshotNodeForData(propData: TProp, defaultData: TProp): { enums: { [t: number]: TPropValue }, refs: { [ref: string]: any } } {
3042
+ const enums: { [t: number]: TPropValue } = {};
3043
+ const refs: { [ref: string]: any } = {};
3044
+ // enum 数字路径: default + state 的 enum 类型
3045
+ const enumTypes = Array.from(new Set<number>([...this.extractEnumPropTypes(defaultData), ...this.extractEnumPropTypes(propData)]));
3046
+ for (const t of enumTypes) {
3047
+ const cur = PropHandlerManager.getValue(t as EnumPropName, this.node);
3048
+ if (cur !== undefined) enums[t] = cur;
3049
+ }
3050
+ // propRef 字符串路径: default + state 的 propRef key
3051
+ const refSet = Array.from(new Set<string>([...this.extractPropRefKeys(propData), ...this.extractPropRefKeys(defaultData)]));
3052
+ for (const ref of refSet) {
3053
+ const cur = this.readNodeValueByPropRef(ref);
3054
+ if (cur !== undefined) {
3055
+ const tp = this.resolveTrackableProp(ref);
3056
+ refs[ref] = cloneValueByType(cur, tp ? tp.cocosType : undefined);
3057
+ }
3058
+ }
3059
+ return { enums, refs };
3060
+ }
3061
+
3062
+ private applyPropRefKeysToNode(propData: TProp, defaultData: TProp): void {
3063
+ const seen = new Set<string>();
3064
+ // W6-axis-decomp: 排除清单 (系统 + 用户) 的 propRef 不 apply, 即使 propData 残留 baseline 也不 push 回节点.
3065
+ // 修 BUG-10: user 排 cc.Node.x 后 propData['cc.Node.x']=0 baseline 仍存, 不 filter 就 apply 把 x 拽回 0.
3066
+ const userExcl = new Set(this._userExcludedProps || []);
3067
+ const sysExcl = new Set(SYSTEM_EXCLUDE);
3068
+ // Track1: compact 后 state 不内联 controlledProps, 受控真值源在 default. apply state 层时
3069
+ // 以 default 的 controlledProps 作门控回退集 (而非 apply all), 否则 state 残留的未受控值
3070
+ // (取消控制后留下的 baseline) 会被误 apply → 破坏 #C6 冻结。
3071
+ const ddCp = defaultData ? (defaultData as any).$$controlledProps$$ : undefined;
3072
+ const apply = (data: TProp, fallbackCprops?: any) => {
3073
+ if (!data) return;
3074
+ // #C6/#S1: 取消控制(非排除)的 prop 不再 apply → 冻结(SPEC line53)。门控:controlledProps
3075
+ // **存在**→ 严格门控, 不含此 key 则 skip(冻结); 本层缺失则回退到 fallbackCprops(default 受控集);
3076
+ // 两层皆缺失(真老 .fire 无元桶)→ apply all(向后兼容)。
3077
+ // 空 state map 但 default 仍有 schema 时按 compact state 处理, 回退 default; default 也空才表示全取消。
3078
+ let cprops = (data as any).$$controlledProps$$;
3079
+ const hasFallbackControlledInfo = fallbackCprops !== undefined
3080
+ && fallbackCprops !== null
3081
+ && Object.keys(fallbackCprops).length > 0;
3082
+ if (cprops === undefined
3083
+ || cprops === null
3084
+ || (Object.keys(cprops).length === 0 && hasFallbackControlledInfo)) {
3085
+ cprops = fallbackCprops;
3086
+ }
3087
+ const hasControlledInfo = cprops !== undefined && cprops !== null;
3088
+ const keys = this.extractPropRefKeys(data);
3089
+ for (const propRef of keys) {
3090
+ if (seen.has(propRef)) continue;
3091
+ if (userExcl.has(propRef) || sysExcl.has(propRef)) continue;
3092
+ if (hasControlledInfo && cprops[propRef] === undefined) continue;
3093
+ const value = (data as any)[propRef];
3094
+ if (value === undefined) continue;
3095
+ const tp = this.resolveTrackableProp(propRef);
3096
+ const cocosType = tp ? tp.cocosType : undefined;
3097
+ try {
3098
+ this.writeNodeValueByPropRef(propRef, cloneValueByType(value, cocosType));
3099
+ seen.add(propRef);
3100
+ }
3101
+ catch (e) {
3102
+ StateErrorManager.warn("applyPropRefKeysToNode 写值失败", {
3103
+ component: "StateSelectV2",
3104
+ method: "applyPropRefKeysToNode",
3105
+ params: { propRef, error: (e as Error).message },
3106
+ });
3107
+ }
3108
+ }
3109
+ };
3110
+ // 先 state 数据 (compact 时门控回退到 default 受控集), 再 default 兜底 (seen 跳过)
3111
+ apply(propData, ddCp);
3112
+ // cc.Node.angle / eulerAngles / quat 是同一节点旋转的三种别名(不同 propRef key, seen 无法识别).
3113
+ // state 已 apply 其一时, default 兜底的其它别名不得再写 → 否则把 state 旋转拽回 default baseline
3114
+ // (尤以 default 的 quat=单位四元数最隐蔽: 它会把刚写好的 euler 归零)。
3115
+ // compact 后 state 常只控其一而 default 仍含 auto-opt 的另外两个, 不护 alias 会互相覆盖。
3116
+ // (position/scale/anchor/size 聚合走子轴 x/y/z 同 key, 由 seen 天然去重, 无需别名表; 旋转是唯一例外)
3117
+ const ROT_ALIAS = ["cc.Node.angle", "cc.Node.eulerAngles", "cc.Node.quat"];
3118
+ if (ROT_ALIAS.some(r => seen.has(r))) for (const r of ROT_ALIAS) seen.add(r);
3119
+ apply(defaultData);
3120
+ }
3121
+
3122
+ /** 🔧 批量更新UI,使用属性处理器系统和错误处理机制 */
3123
+ private batchUpdateUI(updateBatch: { type: EnumPropName, value: TPropValue }[]) {
3124
+ // 🔧 验证节点有效性
3125
+ if (!StateErrorManager.validateNode(this.node, {
3126
+ component: "StateSelectV2",
3127
+ method: "batchUpdateUI",
3128
+ params: { batchSize: updateBatch.length },
3129
+ })) {
3130
+ return;
3131
+ }
3132
+
3133
+ // 批量应用所有更新
3134
+ for (const update of updateBatch) {
3135
+ const { type, value } = update;
3136
+
3137
+ if (type === EnumPropName.Non || value === undefined) {
3138
+ continue;
3139
+ }
3140
+
3141
+ // 🔧 使用属性处理器系统,带错误处理
3142
+ StateErrorManager.gracefulFallback(
3143
+ () => {
3144
+ const handler = PropHandlerManager.getHandler(type);
3145
+ if (handler) {
3146
+ handler.setValue(this.node, value);
3147
+ }
3148
+ else {
3149
+ StateErrorManager.warn(
3150
+ `属性类型 ${EnumPropName[type]} 尚未迁移到属性处理器系统`,
3151
+ { component: "StateSelectV2", method: "batchUpdateUI", params: { propType: type } },
3152
+ );
3153
+ }
3154
+ },
3155
+ undefined,
3156
+ `设置属性值失败: ${EnumPropName[type]}`,
3157
+ );
3158
+ }
3159
+ }
3160
+
3161
+ private getCurrCtrl() {
3162
+ return this._ctrlsMap[this.currCtrlId];
3163
+ }
3164
+
3165
+ private migrateStateIndexKeysForCtrl(ctrl: StateControllerV2): void {
3166
+ if (!ctrl || !ctrl.states) return;
3167
+ const pageData = this.getPageData(ctrl.ctrlId);
3168
+ if (!pageData || pageData.$$stateKeyMode$$ === "stateId") return;
3169
+ for (let index = 0; index < ctrl.states.length; index++) {
3170
+ const stateId = this.getStateIdByIndex(ctrl, index);
3171
+ if (stateId < 0 || stateId === index) continue;
3172
+ const indexData = pageData[index];
3173
+ if (indexData !== undefined && pageData[stateId] === undefined) {
3174
+ pageData[stateId] = indexData;
3175
+ }
3176
+ delete pageData[index];
3177
+ }
3178
+ pageData.$$stateKeyMode$$ = "stateId";
3179
+ }
3180
+
3181
+ /**
3182
+ * 其他状态是否有存在这个属性
3183
+ * @param ctrl
3184
+ * @param prop
3185
+ */
3186
+ private isOtherHans(ctrl: StateControllerV2, prop: number) {
3187
+ const pageData = this.getPageData();
3188
+ for (let index = 0, len = ctrl.states.length; index < len; index++) {
3189
+ const stateId = this.getStateIdByIndex(ctrl, index);
3190
+ if (stateId < 0) continue;
3191
+ const propData = pageData[stateId];
3192
+ // W6-2c2: 双 key 读
3193
+ if (propData && this.readPropByEnum(propData, prop as EnumPropName) != void 0) {
3194
+ return true;
3195
+ }
3196
+ }
3197
+ return false;
3198
+ }
3199
+
3200
+ /** 获取某个控制器的状态数据 */
3201
+ /**
3202
+ * #C5: 深拷一个 state 的 propData, 逐 key 按 cocos type 分发 cloneValueByType —— 保活
3203
+ * cc.Color/Vec3/Vec2/Size/Quat 类实例 (X 方案下 propData 值是活 cc 实例, JSON 深拷会降级成
3204
+ * 普通对象导致 apply 时类型退化)。
3205
+ * - $$ 元 bucket ($$controlledProps$$/$$changedProp$$ 等, 值为纯 string/number map): JSON 浅深拷即可
3206
+ * - 其余 propRef/number key: cloneValueByType(value, resolveTrackableProp.cocosType)
3207
+ */
3208
+ private deepClonePropData(source: TProp): TProp {
3209
+ const out: any = {};
3210
+ for (const key of Object.keys(source)) {
3211
+ const v = (source as any)[key];
3212
+ if (key.startsWith("$$")) {
3213
+ out[key] = (v !== null && typeof v === "object") ? JSON.parse(JSON.stringify(v)) : v;
3214
+ continue;
3215
+ }
3216
+ const tp = this.resolveTrackableProp(key);
3217
+ out[key] = cloneValueByType(v, tp ? tp.cocosType : undefined);
3218
+ }
3219
+ return out as TProp;
3220
+ }
3221
+
3222
+ private getPageData(ctrlId?: number): TPage {
3223
+ const targetCtrlId = ctrlId != void 0 ? ctrlId : this.currCtrlId;
3224
+ if (targetCtrlId == null) {
3225
+ return {} as TPage;
3226
+ }
3227
+ if (this._ctrlData[targetCtrlId] == void 0) {
3228
+ this._ctrlData[targetCtrlId] = {};
3229
+ }
3230
+ return this._ctrlData[targetCtrlId];
3231
+ }
3232
+
3233
+ private getStateIdByIndex(ctrl: StateControllerV2, index: number): number {
3234
+ const states = ctrl && ctrl.states;
3235
+ if (!states || index < 0 || index >= states.length) return -1;
3236
+ const state = states[index];
3237
+ return state && typeof state.stateId === "number" ? state.stateId : -1;
3238
+ }
3239
+
3240
+ private getStateDataKey(stateIndex?: number, ctrlId?: number): number {
3241
+ const targetCtrlId = ctrlId != void 0 ? ctrlId : this.currCtrlId;
3242
+ const ctrl = targetCtrlId != void 0 ? this._ctrlsMap[targetCtrlId] : this.getCurrCtrl();
3243
+ const targetIndex = stateIndex != void 0 ? stateIndex : this.ctrlState;
3244
+ const stateId = this.getStateIdByIndex(ctrl, targetIndex);
3245
+ return stateId >= 0 ? stateId : targetIndex;
3246
+ }
3247
+
3248
+ private getPropDataByIndex(stateIndex: number, ctrlId?: number): TProp {
3249
+ return this.getPropData(stateIndex, ctrlId);
3250
+ }
3251
+
3252
+ /**
3253
+ * 获取某个状态的属性数据
3254
+ */
3255
+ private getPropData(stateIndex?: number, ctrlId?: number): TProp {
3256
+ const pageData = this.getPageData(ctrlId);
3257
+ pageData.$$stateKeyMode$$ = "stateId";
3258
+ const targetStateKey = this.getStateDataKey(stateIndex, ctrlId);
3259
+ if (pageData[targetStateKey] == void 0) {
3260
+ pageData[targetStateKey] = {} as TProp;
3261
+ }
3262
+ return pageData[targetStateKey];
3263
+ }
3264
+
3265
+ /** 获取默认属性 */
3266
+ private getDefaultData(ctrlId?: number): TProp {
3267
+ const pageData = this.getPageData(ctrlId);
3268
+ if (pageData.$$default$$ == void 0) {
3269
+ pageData.$$default$$ = {} as TProp;
3270
+ }
3271
+ return pageData.$$default$$;
3272
+ }
3273
+
3274
+ private setPropValue(type: EnumPropName) {
3275
+ const value = this.handleValue(type);
3276
+ if (value == void 0) {
3277
+ return void 0;
3278
+ }
3279
+ this._propValue = value;
3280
+ return value;
3281
+ }
3282
+
3283
+ /** 🔧 解析并返回属性值,使用属性处理器系统和错误处理机制 */
3284
+ private handleValue(type: EnumPropName): TPropValue {
3285
+ if (type === EnumPropName.Non) {
3286
+ return undefined;
3287
+ }
3288
+
3289
+ // 🔧 验证节点有效性
3290
+ if (!StateErrorManager.validateNode(this.node, {
3291
+ component: "StateSelectV2",
3292
+ method: "handleValue",
3293
+ params: { propType: EnumPropName[type] },
3294
+ })) {
3295
+ return undefined;
3296
+ }
3297
+
3298
+ // 🔧 使用属性处理器系统,带错误处理
3299
+ return StateErrorManager.gracefulFallback(
3300
+ () => {
3301
+ const handler = PropHandlerManager.getHandler(type);
3302
+ if (handler) {
3303
+ return handler.getValue(this.node);
3304
+ }
3305
+
3306
+ StateErrorManager.warn(
3307
+ `属性类型 ${EnumPropName[type]} 尚未迁移到属性处理器系统`,
3308
+ { component: "StateSelectV2", method: "handleValue", params: { propType: type } },
3309
+ );
3310
+ return undefined;
3311
+ },
3312
+ undefined,
3313
+ `获取属性值失败: ${EnumPropName[type]}`,
3314
+ );
3315
+ }
3316
+
3317
+ /** 父节点改变,转换已经缓存的位置 */
3318
+ private transPosition(oldParent: cc.Node) {
3319
+ if (!CC_EDITOR) {
3320
+ return;
3321
+ }
3322
+
3323
+ const parent = this.node.parent;
3324
+ if (!parent || !oldParent) {
3325
+ return;
3326
+ }
3327
+
3328
+ // 检查oldParent是否是有效的cc.Node对象且具有必要的方法
3329
+ if (!oldParent.isValid || typeof oldParent.convertToWorldSpaceAR !== "function") {
3330
+ StateErrorManager.warn("oldParent 节点无效或已销毁", {
3331
+ component: "StateSelectV2",
3332
+ method: "transPosition",
3333
+ });
3334
+ return;
3335
+ }
3336
+ // 检查parent是否具有必要的方法
3337
+ if (typeof parent.convertToNodeSpaceAR !== "function") {
3338
+ StateErrorManager.warn("parent 节点缺少 convertToNodeSpaceAR 方法", {
3339
+ component: "StateSelectV2",
3340
+ method: "transPosition",
3341
+ });
3342
+ return;
3343
+ }
3344
+
3345
+ const pageData = this.getPageData();
3346
+
3347
+ // #F-4 (TASK-004): 仅"当前受控且未排除"的轴参与坐标转换的写回. 取消跟随/排除但
3348
+ // propData 残留 baseline 的轴不被转换 (附录A 断言#3). 受控态与 state 无关, 循环外算一次.
3349
+ const convX = this.isAxisConvertible("cc.Node.x");
3350
+ const convY = this.isAxisConvertible("cc.Node.y");
3351
+ const convZ = this.isAxisConvertible("cc.Node.z");
3352
+ if (!convX && !convY && !convZ) return;
3353
+
3354
+ // #V3: 缺轴兜底优先用 default 基线, 而非激活 state 的 live 节点坐标 —— 否则换算某 state 时
3355
+ // 误用当前激活 state 的实时坐标污染该 state 的映射 (各 state 应按"自身值 > default > live"还原)。
3356
+ const defData = (pageData as any).$$default$$ || {};
3357
+ for (const state in pageData) {
3358
+ // 跳过非 default 元桶 ($$controlledProps$$/$$changedProp$$/$$lastProp$$/$$propertyData$$) —
3359
+ // 它们的 "cc.Node.x" 值是 flag 字符串非坐标; 但 $$default$$ 是真基线, 必须参与换算
3360
+ // (#V3: 缺轴 state 依赖 default, default 不转则那些 state 位置错)。
3361
+ if (state.startsWith("$$") && state !== "$$default$$") continue;
3362
+ const propData = pageData[state] as any;
3363
+ if (!propData) continue;
3364
+ // M3-2 修 #3: Position 以子项 cc.Node.x/y/z 存. 读子项重组 Vec3 转换后写回**受控未排除**的轴
3365
+ // (缺轴/排除轴用 default 基线兜底参与点换算, 缺 default 才退 live 节点值; 不回写其数据).
3366
+ const sx = propData["cc.Node.x"];
3367
+ const sy = propData["cc.Node.y"];
3368
+ const sz = propData["cc.Node.z"];
3369
+ if (sx === undefined && sy === undefined && sz === undefined) continue;
3370
+ const fb = (sub: string, live: number) =>
3371
+ defData[sub] !== undefined ? defData[sub] : live;
3372
+ const px = sx !== undefined ? sx : fb("cc.Node.x", this.node.x);
3373
+ const py = sy !== undefined ? sy : fb("cc.Node.y", this.node.y);
3374
+ const pz = sz !== undefined ? sz : fb("cc.Node.z", (this.node as any).z || 0);
3375
+ try {
3376
+ // 在 2.x 中,需要手动计算坐标转换
3377
+ const worldPos = oldParent.convertToWorldSpaceAR(cc.v3(px, py, pz));
3378
+ const localPos = parent.convertToNodeSpaceAR(worldPos);
3379
+ if (convX && sx !== undefined) propData["cc.Node.x"] = localPos.x;
3380
+ if (convY && sy !== undefined) propData["cc.Node.y"] = localPos.y;
3381
+ if (convZ && sz !== undefined) propData["cc.Node.z"] = localPos.z;
3382
+ }
3383
+ catch (error) {
3384
+ StateErrorManager.error("坐标转换过程中发生错误", {
3385
+ component: "StateSelectV2",
3386
+ method: "transPosition",
3387
+ params: { error: error.message },
3388
+ });
3389
+ }
3390
+ }
3391
+ }
3392
+
3393
+ /** 同步属性到所有状态 */
3394
+ private syncPropToAllStatesInternal(propKey: EnumPropName) {
3395
+ const ctrl = this.getCurrCtrl();
3396
+ if (!ctrl) {
3397
+ StateErrorManager.error("同步属性失败:控制器为空", {
3398
+ component: "StateSelectV2",
3399
+ method: "syncPropToAllStatesInternal",
3400
+ });
3401
+ return;
3402
+ }
3403
+
3404
+ StateErrorManager.debug("开始同步属性到所有状态", {
3405
+ component: "StateSelectV2",
3406
+ method: "syncPropToAllStatesInternal",
3407
+ params: { propType: EnumPropName[propKey], stateCount: ctrl.states.length },
3408
+ });
3409
+
3410
+ // 🔧 修复:不同步Non属性
3411
+ if (propKey === EnumPropName.Non) {
3412
+ StateErrorManager.warn("不能同步Non属性", {
3413
+ component: "StateSelectV2",
3414
+ method: "syncPropToAllStatesInternal",
3415
+ });
3416
+ return;
3417
+ }
3418
+
3419
+ const pageData = this.getPageData();
3420
+ const currentStateValue = this.handleValue(propKey); // 获取当前节点的属性值作为默认值
3421
+
3422
+ if (currentStateValue === undefined) {
3423
+ StateErrorManager.error("同步失败:无法获取当前属性值", {
3424
+ component: "StateSelectV2",
3425
+ method: "syncPropToAllStatesInternal",
3426
+ params: { propType: EnumPropName[propKey] },
3427
+ });
3428
+ return;
3429
+ }
3430
+
3431
+ // 遍历所有状态
3432
+ let syncedStates = 0;
3433
+ for (let stateIndex = 0; stateIndex < ctrl.states.length; stateIndex++) {
3434
+ const stateId = this.getStateIdByIndex(ctrl, stateIndex);
3435
+ if (stateId < 0) continue;
3436
+ if (pageData[stateId] == void 0) {
3437
+ pageData[stateId] = {};
3438
+ }
3439
+ const statePropData = pageData[stateId];
3440
+
3441
+ // W6-2c2: 双 key 读 (优先 string propRef, fallback number)
3442
+ if (this.readPropByEnum(statePropData, propKey) === undefined) {
3443
+ this.writePropByEnum(statePropData, propKey, currentStateValue);
3444
+ syncedStates++;
3445
+
3446
+ // 🔧 修复:同步属性时,只在该状态没有lastProp时才设置,避免覆盖用户的选择
3447
+ if (!statePropData.$$lastProp$$) {
3448
+ statePropData.$$lastProp$$ = propKey;
3449
+ }
3450
+
3451
+ // 更新该状态的changedProp记录
3452
+ statePropData.$$changedProp$$ = statePropData.$$changedProp$$ || {};
3453
+ statePropData.$$changedProp$$[EnumPropName[propKey]] = propKey;
3454
+ }
3455
+ }
3456
+
3457
+ // 同时更新默认状态
3458
+ const defaultData = this.getDefaultData();
3459
+ if (this.readPropByEnum(defaultData, propKey) === undefined) {
3460
+ this.writePropByEnum(defaultData, propKey, currentStateValue);
3461
+ }
3462
+
3463
+ StateErrorManager.info("属性同步完成", {
3464
+ component: "StateSelectV2",
3465
+ method: "syncPropToAllStatesInternal",
3466
+ params: {
3467
+ propType: EnumPropName[propKey],
3468
+ syncedStates: syncedStates,
3469
+ totalStates: ctrl.states.length,
3470
+ },
3471
+ });
3472
+ this.updateChangedProp();
3473
+ }
3474
+
3475
+ /** 🔧 同步删除所有状态的指定属性 */
3476
+ private syncDeletePropFromAllStates(propKey: EnumPropName) {
3477
+ const ctrl = this.getCurrCtrl();
3478
+ if (!ctrl) {
3479
+ StateErrorManager.error("删除属性失败:控制器为空", {
3480
+ component: "StateSelectV2",
3481
+ method: "syncDeletePropFromAllStates",
3482
+ });
3483
+ return;
3484
+ }
3485
+
3486
+ StateErrorManager.debug("开始同步删除属性", {
3487
+ component: "StateSelectV2",
3488
+ method: "syncDeletePropFromAllStates",
3489
+ params: { propType: EnumPropName[propKey], stateCount: ctrl.states.length },
3490
+ });
3491
+
3492
+ // 🔧 修复:不删除Non属性
3493
+ if (propKey === EnumPropName.Non) {
3494
+ StateErrorManager.warn("不能删除Non属性", {
3495
+ component: "StateSelectV2",
3496
+ method: "syncDeletePropFromAllStates",
3497
+ });
3498
+ return;
3499
+ }
3500
+
3501
+ const pageData = this.getPageData();
3502
+ const name = EnumPropName[propKey];
3503
+ let deletedFromStates = 0;
3504
+
3505
+ // W6-2c2: 删时双 key 一起删 (string propRef + number, 兼容老数据)
3506
+ const propRef = enumToPropRef(propKey);
3507
+ // 遍历所有状态,删除指定属性
3508
+ for (let stateIndex = 0; stateIndex < ctrl.states.length; stateIndex++) {
3509
+ const stateId = this.getStateIdByIndex(ctrl, stateIndex);
3510
+ if (stateId < 0) continue;
3511
+ const statePropData = pageData[stateId];
3512
+ if (statePropData) {
3513
+ // 删除属性值 (string + number 双删)
3514
+ const hadValue = this.readPropByEnum(statePropData, propKey) !== undefined;
3515
+ if (propRef !== undefined) delete (statePropData as any)[propRef];
3516
+ delete (statePropData as TPropDictionary)[propKey];
3517
+ if (hadValue) deletedFromStates++;
3518
+
3519
+ // W6-axis-decomp: 跨所有 state 删 $$controlledProps$$ 中 name key + propRef key
3520
+ // (performPropertyDeletion 只删了当前 state, 这里补全 — 修 bridge isPropertyControlled fallback 残留)
3521
+ if (statePropData.$$controlledProps$$) {
3522
+ delete statePropData.$$controlledProps$$[name];
3523
+ if (propRef !== undefined) delete (statePropData.$$controlledProps$$ as any)[propRef];
3524
+ }
3525
+
3526
+ // 删除changedProp记录
3527
+ const $$changedProp$$ = statePropData.$$changedProp$$ || {};
3528
+ delete $$changedProp$$[name];
3529
+
3530
+ // 如果删除的是当前状态的lastProp,重置为Non
3531
+ if (statePropData.$$lastProp$$ === propKey) {
3532
+ statePropData.$$lastProp$$ = EnumPropName.Non;
3533
+ }
3534
+ }
3535
+ }
3536
+
3537
+ // 删除默认状态的属性 (双删)
3538
+ const defaultData = this.getDefaultData();
3539
+ if (propRef !== undefined) delete (defaultData as any)[propRef];
3540
+ delete (defaultData as TPropDictionary)[propKey];
3541
+ // W6-axis-decomp: 默认状态 $$controlledProps$$ 也双删
3542
+ if (defaultData.$$controlledProps$$) {
3543
+ delete defaultData.$$controlledProps$$[name];
3544
+ if (propRef !== undefined) delete (defaultData.$$controlledProps$$ as any)[propRef];
3545
+ }
3546
+
3547
+ StateErrorManager.info("属性删除完成", {
3548
+ component: "StateSelectV2",
3549
+ method: "syncDeletePropFromAllStates",
3550
+ params: {
3551
+ propType: name,
3552
+ deletedFromStates: deletedFromStates,
3553
+ totalStates: ctrl.states.length,
3554
+ },
3555
+ });
3556
+ this.updateChangedProp();
3557
+ }
3558
+
3559
+ /** 🔧 简化:固定使用自动同步模式,不再提供选择 */
3560
+ private readonly autoSyncEnabled: boolean = true;
3561
+
3562
+ // #endregion 5.
3563
+
3564
+ // #region 6. 属性控制 API (Public) — Phase 5.2 抽出 PropertyControlService 目标
3565
+
3566
+ /** 🔧 检查属性是否可用(节点是否支持该属性类型) */
3567
+ public isPropertyAvailable(propType: EnumPropName): boolean {
3568
+ return PropertyControlService.isPropertyAvailable(this.node, propType);
3569
+ }
3570
+
3571
+ /**
3572
+ * TASK-003: 检查属性是否"适合自动接入"(applicable for opt-in).
3573
+ * 语义等价于 isPropertyAvailable: 节点上能取到这个 prop (节点自带 / 组件存在),
3574
+ * 就 applicable. 提供独立名字让 __preload 自动接入路径 + 外部调用方语义更清晰.
3575
+ */
3576
+ public isApplicableProp(propType: EnumPropName): boolean {
3577
+ return PropertyControlService.isPropertyAvailable(this.node, propType);
3578
+ }
3579
+
3580
+ /**
3581
+ * 🔧 检查属性是否已被控制(使用新的controlledProps结构)
3582
+ *
3583
+ * W6-2b: 公开 API 接受 EnumPropName | string 联合类型.
3584
+ * - number (EnumPropName): 走老路径 PropertyControlService.isPropertyControlled (内置 prop, name key 'Active')
3585
+ * - string (propRef): 走新路径 isPropertyControlledByPropRef (string key 'cc.Node.active')
3586
+ *
3587
+ * W6-axis-decomp X 方案 bridge: EnumPropName 路径 fallback 看 propRef 键 — 老调用方
3588
+ * isPropertyControlled(EnumPropName.Active) 仍能在自动接入走 propRef 路径后正确返回 true.
3589
+ * 命中规则:
3590
+ * - 主路径: propData.$$controlledProps$$ 含名字 key 'Active' (老 API addPropertyControl 写入)
3591
+ * - bridge: propData.$$controlledProps$$ 含 propRef key 'cc.Node.active' (X 方案 autoOptIn 写入)
3592
+ */
3593
+ public isPropertyControlled(propTypeOrRef: EnumPropName | string): boolean {
3594
+ if (typeof propTypeOrRef === "string") {
3595
+ return this.isPropertyControlledByPropRef(propTypeOrRef);
3596
+ }
3597
+ // 主路径: name key
3598
+ if (PropertyControlService.isPropertyControlled(this.getPropData(), propTypeOrRef)) {
3599
+ return true;
3600
+ }
3601
+ // W6-axis-decomp bridge: 看 propRef 等价 key (autoOptInCustomComponentProps 写的)
3602
+ const propRef = enumToPropRef(propTypeOrRef);
3603
+ if (propRef !== undefined) {
3604
+ // 聚合根治 (C1/U3): AMBIGUOUS 聚合走子项独立 —— 全部子项受控才算"聚合受控"(用户本意:
3605
+ // x/y/z 单独控制, 都算影响 position; 部分受控的 ◐ 视觉属专项B, 这里只给布尔基线)。
3606
+ if (isAmbiguousAggregatePropRef(propRef)) {
3607
+ // ANY 语义 (用户裁定): 任一子项受控即算"聚合受控"(isPropertyControlled 答"管不管"非"管全不全";
3608
+ // 部分受控的精确表达=◐, 属专项B)。Euler 子项(rotationX/Y)全 SYSTEM_EXCLUDE → 无可控子项 →
3609
+ // 回退查聚合 key 自身(euler 走整体聚合, 保 z, 见 getControllableAmbiguousSubRefs)。
3610
+ const subs = this.getControllableAmbiguousSubRefs(propRef);
3611
+ if (subs.length > 0) return subs.some(s => this.isPropertyControlledByPropRef(s));
3612
+ }
3613
+ return this.isPropertyControlledByPropRef(propRef);
3614
+ }
3615
+ return false;
3616
+ }
3617
+
3618
+ /** 聚合根治: 取某 AMBIGUOUS 聚合 propRef 的子项 ref 列表 (用零探针调拆解函数取 key). */
3619
+ private getAmbiguousSubRefs(aggRef: string): string[] {
3620
+ const decomposer = AMBIGUOUS_DECOMPOSE[aggRef];
3621
+ if (!decomposer) return [];
3622
+ const pairs = decomposer({
3623
+ x: 0, y: 0, z: 0, width: 0, height: 0,
3624
+ });
3625
+ return pairs ? pairs.map(p => p[0]) : [];
3626
+ }
3627
+
3628
+ /**
3629
+ * 聚合根治: 取**可控**子项 (排除 SYSTEM_EXCLUDE)。Euler 的 rotationX/rotationY 是 2.1 起废弃属性,
3630
+ * 全在 SYSTEM_EXCLUDE → 返空 → 调用方回退"整体聚合"路径(euler 存全 eulerAngles vec3, 保 z 旋转);
3631
+ * Position/Scale/Size/Anchor 的子项可控 → 返非空 → 走 decompose 子项独立。
3632
+ */
3633
+ private getControllableAmbiguousSubRefs(aggRef: string): string[] {
3634
+ return this.getAmbiguousSubRefs(aggRef).filter(s => SYSTEM_EXCLUDE.indexOf(s) < 0);
3635
+ }
3636
+
3637
+ /**
3638
+ * 当前 state 已勾选 prop 的"美化值"列表 (readonly, inspector 极简显示用)。
3639
+ *
3640
+ * 每项形如 `"Color: rgba(192,192,255,255)"`, `"Position: (100, 0, 0)"`,
3641
+ * 匹配 /^[A-Z][a-zA-Z]+: .+\$/。值来源是 pageData[currentStateIndex][propType]
3642
+ * (canonical 存储, 与 panel 同一份数据)。
3643
+ *
3644
+ * Wave 1 panel 未实装期间, 这是用户在 inspector 中唯一能看到的 state 内容摘要。
3645
+ */
3646
+ @property({
3647
+ displayName: "已跟随属性",
3648
+ tooltip: "当前 state 已勾选 prop 的人类可读列表 (readonly, panel 接管后会更丰富)",
3649
+ readonly: true,
3650
+ })
3651
+ public get currentStateProps(): string[] {
3652
+ const result: string[] = [];
3653
+ const ctrl = this.getCurrCtrl();
3654
+ if (!ctrl) {
3655
+ return result;
3656
+ }
3657
+ const propData = this.getPropData();
3658
+ if (!propData) {
3659
+ return result;
3660
+ }
3661
+ // 遍历 EnumPropName, 跳过 Non=0, 收集已勾选的 prop
3662
+ for (const key of Object.keys(EnumPropName)) {
3663
+ const propType = (EnumPropName as any)[key];
3664
+ if (typeof propType !== "number" || propType === EnumPropName.Non) {
3665
+ continue;
3666
+ }
3667
+ if (!this.isPropertyControlled(propType)) {
3668
+ continue;
3669
+ }
3670
+ // W6-2c2: 双 key 读
3671
+ const value = this.readPropByEnum(propData, propType);
3672
+ if (value === undefined) {
3673
+ continue;
3674
+ }
3675
+ const label = EnumPropName[propType]; // 用 enum 反向查表得到大写英文 name
3676
+ result.push(`${label}: ${this.formatPropValue(value)}`);
3677
+ }
3678
+ return result;
3679
+ }
3680
+
3681
+ // #region inspector 折叠组 (排除管理 / 录制 / 值搬运) — facade 代理到本类访问器, owner 在 __preload 注入
3682
+
3683
+ /** 排除管理折叠组 (排除跟随 / + 添加排除 / 用户排除清单). */
3684
+ @property({ type: SelectExcludeGroup, displayName: "排除管理", tooltip: "管理本节点的属性跟随排除清单 (系统 + 用户)" })
3685
+ public excludeGroup = new SelectExcludeGroup();
3686
+
3687
+ /** 录制折叠组 (与 StateControllerV2 共享同一录制态). */
3688
+ @property({ type: SelectRecordGroup, displayName: "录制", tooltip: "录制工作流: 进入/退出录制 (回退整次录制用编辑器 Ctrl+Z)" })
3689
+ public recording = new SelectRecordGroup();
3690
+
3691
+ /** 值搬运折叠组 (当前 state ↔ 下一 state 的节点级值操作). */
3692
+ @property({ type: SelectValueOpsGroup, displayName: "值搬运", tooltip: "在相邻 state 间交换/复制/移动本节点的值数据" })
3693
+ public valueOps = new SelectValueOpsGroup();
3694
+
3695
+ // #endregion inspector 折叠组
3696
+
3697
+ /**
3698
+ * 录制按钮 (Wave 2 实装): 镜像 currCtrl.isRecording, 点击 toggle ctrl.startRecording / stopRecording.
3699
+ * 让用户在 StateSelectV2 inspector 上也能起停录制, 与 StateControllerV2 inspector 共享同一录制态。
3700
+ */
3701
+ /** 普通访问器, inspector 可见性由 recording 折叠组代理. */
3702
+ public get recordTrigger() {
3703
+ const ctrl = this.getCurrCtrl();
3704
+ return !!(ctrl && ctrl.isRecording);
3705
+ }
3706
+
3707
+ public set recordTrigger(_value: boolean) {
3708
+ if (!CC_EDITOR) return;
3709
+ const ctrl = this.getCurrCtrl();
3710
+ if (!ctrl) {
3711
+ StateErrorManager.warn("recordTrigger: 未找到当前控制器", {
3712
+ component: "StateSelectV2",
3713
+ method: "recordTrigger.setter",
3714
+ });
3715
+ return;
3716
+ }
3717
+ if (ctrl.isRecording) {
3718
+ ctrl.stopRecording();
3719
+ }
3720
+ else {
3721
+ ctrl.startRecording();
3722
+ }
3723
+ }
3724
+
3725
+ /**
3726
+ * 撤销本次录制 (TASK-002): 镜像 StateControllerV2.cancelRecordTrigger, 调 ctrl.cancelRecording。
3727
+ * 2026-06-03: 已从 inspector 移除按钮 — 回退整次录制改用编辑器原生 Ctrl+Z (避免自建撤销与原生 undo 双重撤销)。
3728
+ * 访问器 + cancelRecording 底层保留 (panel/测试仍可用), 仅不在折叠组直显。
3729
+ */
3730
+ public get cancelRecordTrigger() {
3731
+ return false;
3732
+ }
3733
+
3734
+ public set cancelRecordTrigger(_value: boolean) {
3735
+ if (!CC_EDITOR) return;
3736
+ const ctrl = this.getCurrCtrl();
3737
+ if (!ctrl) return;
3738
+ if (ctrl.isRecording) {
3739
+ ctrl.cancelRecording();
3740
+ }
3741
+ }
3742
+
3743
+ // #region 专项A-2: 局部值操作 inspector 触发器 (当前 state ↔ 下一 state)
3744
+ /** 专项A-2: 当前 state 与下一 state 的下标对 (无下一 state 返回 null). */
3745
+ private getCurrNextStatePair(): { cur: number, next: number } | null {
3746
+ const ctrl = this.getCurrCtrl();
3747
+ if (!ctrl || !ctrl.states) return null;
3748
+ const cur = ctrl.selectedIndex;
3749
+ const next = cur + 1;
3750
+ if (next >= ctrl.states.length) return null;
3751
+ return { cur, next };
3752
+ }
3753
+
3754
+ /** 专项A-2: 交换当前 state 与下一 state 的值数据 (节点级局部操作). */
3755
+ /** 普通访问器, inspector 可见性由 valueOps 折叠组代理. */
3756
+ public get swapValueWithNext(): boolean {
3757
+ return false;
3758
+ }
3759
+
3760
+ public set swapValueWithNext(_v: boolean) {
3761
+ if (!CC_EDITOR) return;
3762
+ const p = this.getCurrNextStatePair();
3763
+ if (p) this.swapStateValues(p.cur, p.next);
3764
+ }
3765
+
3766
+ /** 专项A-2: 复制当前 state 的值数据到下一 state (节点级局部操作). */
3767
+ /** 普通访问器, inspector 可见性由 valueOps 折叠组代理. */
3768
+ public get copyValueToNext(): boolean {
3769
+ return false;
3770
+ }
3771
+
3772
+ public set copyValueToNext(_v: boolean) {
3773
+ if (!CC_EDITOR) return;
3774
+ const p = this.getCurrNextStatePair();
3775
+ if (p) this.copyStateValues(p.cur, p.next);
3776
+ }
3777
+ // #endregion
3778
+
3779
+ /**
3780
+ * W6-4 hotfix2 #1: 一键刷新 inspector. 用户在 panel/外部改了状态后,
3781
+ * inspector 偶尔不自动 refresh 时手动一键. 同时 reconcile + refresh enumList 兜底.
3782
+ */
3783
+ @property({
3784
+ displayName: "🔄 刷新 inspector",
3785
+ tooltip: "手动刷新 inspector: reconcile 排除清单 + 刷下拉选项 + 强制 cocos refreshSelectedInspector",
3786
+ })
3787
+ public get refreshInspectorTrigger(): boolean {
3788
+ return false;
3789
+ }
3790
+
3791
+ public set refreshInspectorTrigger(_value: boolean) {
3792
+ if (!CC_EDITOR) return;
3793
+ try {
3794
+ this.reconcileUserExcluded();
3795
+ }
3796
+ catch { /* swallow, 兜底 */ }
3797
+ try {
3798
+ this.refreshExcludeEnumLists();
3799
+ }
3800
+ catch { /* swallow */ }
3801
+ try {
3802
+ if (this.node && (Editor as any)?.Utils?.refreshSelectedInspector) {
3803
+ (Editor as any).Utils.refreshSelectedInspector("node", this.node.uuid);
3804
+ }
3805
+ }
3806
+ catch (e) {
3807
+ StateErrorManager.warn("refreshInspectorTrigger: Editor.Utils.refreshSelectedInspector 失败", {
3808
+ component: "StateSelectV2",
3809
+ method: "refreshInspectorTrigger.setter",
3810
+ params: { error: (e as Error).message },
3811
+ });
3812
+ }
3813
+ }
3814
+
3815
+ /**
3816
+ * 一键重新绑定控制器: 把本节点拷贝粘贴到另一个 prefab 后, 勾一下即按当前祖先链重扫重绑,
3817
+ * 无需删组件再加. 切到不同控制器时会清空旧状态数据 (重来); 同一控制器或没扫到控制器则不操作
3818
+ * (详见 rebindController).
3819
+ */
3820
+ @property({
3821
+ displayName: "⟳ 重新绑定控制器",
3822
+ tooltip: "按当前所在祖先链重新解析并绑定 StateControllerV2 (拷贝到新 prefab 后用). 切到不同控制器会清空旧状态数据; 同一控制器不操作",
3823
+ })
3824
+ public get rebindControllerTrigger(): boolean {
3825
+ return false;
3826
+ }
3827
+
3828
+ public set rebindControllerTrigger(_value: boolean) {
3829
+ if (!CC_EDITOR) return;
3830
+ this.rebindController();
3831
+ }
3832
+
3833
+ /** 把 TPropValue 序列化为人类可读字符串 (currentStateProps 内部用) */
3834
+ private formatPropValue(value: unknown): string {
3835
+ if (value === null || value === undefined) {
3836
+ return "-";
3837
+ }
3838
+ if (typeof value === "number") {
3839
+ // 整数直显, 浮点保留 2 位
3840
+ return Number.isInteger(value) ? String(value) : value.toFixed(2);
3841
+ }
3842
+ if (typeof value === "boolean" || typeof value === "string") {
3843
+ return String(value);
3844
+ }
3845
+ if (typeof value === "object") {
3846
+ const v = value as any;
3847
+ // Color: { r,g,b,a }
3848
+ if ("r" in v && "g" in v && "b" in v) {
3849
+ return `rgba(${v.r},${v.g},${v.b},${v.a !== undefined ? v.a : 255})`;
3850
+ }
3851
+ // Vec3 / Position: { x, y, z }
3852
+ if ("x" in v && "y" in v) {
3853
+ if ("z" in v) {
3854
+ return `(${this.formatPropValue(v.x)}, ${this.formatPropValue(v.y)}, ${this.formatPropValue(v.z)})`;
3855
+ }
3856
+ return `(${this.formatPropValue(v.x)}, ${this.formatPropValue(v.y)})`;
3857
+ }
3858
+ // Size: { width, height }
3859
+ if ("width" in v && "height" in v) {
3860
+ return `${v.width}x${v.height}`;
3861
+ }
3862
+ // SpriteFrame / Font 等资源对象, 通常有 _uuid 或 name
3863
+ if (v._uuid) {
3864
+ return `<asset:${v._uuid.slice(0, 8)}>`;
3865
+ }
3866
+ if (v.name) {
3867
+ return String(v.name);
3868
+ }
3869
+ }
3870
+ // 兜底: JSON 单行
3871
+ try {
3872
+ return JSON.stringify(value);
3873
+ }
3874
+ catch {
3875
+ return String(value);
3876
+ }
3877
+ }
3878
+
3879
+ /**
3880
+ * 🔧 切换属性控制状态
3881
+ *
3882
+ * W6-2b: 公开 API 接受 EnumPropName | string 联合类型.
3883
+ * - number (EnumPropName): 走老路径 (add/removePropertyControl, 写 number key)
3884
+ * - string (propRef): 走新路径 (togglePropertyControlByPropRef, 写 string key)
3885
+ *
3886
+ * Dispatch: 无论哪条路径, 完成后派发 onPropertyControlled / onPropertyReleased,
3887
+ * payload 含 propType (number, AMBIGUOUS / 自定义 prop 可能为 undefined) + propRef (string).
3888
+ * 内置 prop 的 propRef 通过 ENUM_TO_PROPREF 派生 (AMBIGUOUS 项无映射 → undefined).
3889
+ * 自定义 prop 的 propType 通过 PROPREF_TO_ENUM 反查 (无映射 → undefined).
3890
+ */
3891
+ public togglePropertyControl(propTypeOrRef: EnumPropName | string, enable: boolean) {
3892
+ if (!CC_EDITOR) {
3893
+ return;
3894
+ }
3895
+
3896
+ // W6-2b: string 路径 — 走 propRef 新路径 (写 string key)
3897
+ if (typeof propTypeOrRef === "string") {
3898
+ this.togglePropertyControlStringPath(propTypeOrRef, enable);
3899
+ return;
3900
+ }
3901
+
3902
+ const propType: EnumPropName = propTypeOrRef;
3903
+ const propRef: string | undefined = ENUM_TO_PROPREF[propType];
3904
+
3905
+ StateErrorManager.debug("切换属性控制状态", {
3906
+ component: "StateSelectV2",
3907
+ method: "togglePropertyControl",
3908
+ params: { propType: EnumPropName[propType], propRef, enable },
3909
+ });
3910
+
3911
+ if (enable) {
3912
+ // 🔧 第一步:启用属性控制
3913
+ this.addPropertyControl(propType);
3914
+
3915
+ // 🔧 第二步:立即更新界面标识变量
3916
+ this._currentDisplayProp = propType;
3917
+
3918
+ StateErrorManager.debug("属性控制已启用,界面标识已更新", {
3919
+ component: "StateSelectV2",
3920
+ method: "togglePropertyControl",
3921
+ params: {
3922
+ propType: EnumPropName[propType],
3923
+ propRef,
3924
+ currentDisplayProp: EnumPropName[this._currentDisplayProp],
3925
+ },
3926
+ });
3927
+
3928
+ // W6-2b: 派发 propRef-aware 事件 (双字段并存 propType + propRef)
3929
+ CapabilityRegistry.dispatch("onPropertyControlled", {
3930
+ ctrl: this.getCurrCtrl(),
3931
+ select: this,
3932
+ propType,
3933
+ propRef,
3934
+ });
3935
+ }
3936
+ else {
3937
+ // 🔧 第一步:禁用属性控制
3938
+ this.removePropertyControl(propType);
3939
+
3940
+ // #C6 + #C7 + 聚合根治: 全局删 propRef 等价 key (所有 state + default)。
3941
+ // AMBIGUOUS 聚合 → 释放各子项 (与接入对称, 子项独立); 其余 → 删该 propRef。
3942
+ const offRef = enumToPropRef(propType);
3943
+ const offSubs = (offRef !== undefined && isAmbiguousAggregatePropRef(offRef))
3944
+ ? this.getControllableAmbiguousSubRefs(offRef)
3945
+ : [];
3946
+ if (offSubs.length > 0) {
3947
+ for (const subRef of offSubs) this.removeControlledFlagAllStates(subRef);
3948
+ }
3949
+ else if (offRef !== undefined) {
3950
+ // 非聚合, 或 euler(无可控子项, 走整体聚合 key 移除)
3951
+ this.removeControlledFlagAllStates(offRef, propType);
3952
+ }
3953
+
3954
+ // 🔧 第二步:如果是当前显示的属性,清空界面标识
3955
+ if (this._currentDisplayProp === propType) {
3956
+ this._currentDisplayProp = EnumPropName.Non;
3957
+ }
3958
+
3959
+ StateErrorManager.debug("属性控制已禁用,界面标识已清空", {
3960
+ component: "StateSelectV2",
3961
+ method: "togglePropertyControl",
3962
+ params: {
3963
+ propType: EnumPropName[propType],
3964
+ propRef,
3965
+ currentDisplayProp: EnumPropName[this._currentDisplayProp],
3966
+ },
3967
+ });
3968
+
3969
+ // W6-2b: 派发 propRef-aware 事件 (双字段并存 propType + propRef)
3970
+ CapabilityRegistry.dispatch("onPropertyReleased", {
3971
+ ctrl: this.getCurrCtrl(),
3972
+ select: this,
3973
+ propType,
3974
+ propRef,
3975
+ });
3976
+ }
3977
+
3978
+ // 嵌套 CCClass 的 setter 触发后,inspector 只刷新子对象区域
3979
+ // 需要强制刷新整个 inspector 以使可见性变更生效
3980
+ // this.forceRefreshInspector();
3981
+ }
3982
+
3983
+ /**
3984
+ * W6-2b: string propRef 路径分发. 调用方传入 "compName.propKey" 形式的 propRef.
3985
+ * - 自定义 prop (无 EnumPropName 映射): 走 togglePropertyControlByPropRef (写 string key)
3986
+ * - 内置 prop (能反查到 EnumPropName, e.g. "cc.Node.active"): 兼容性自动重定向到老路径,
3987
+ * 避免内置 prop 同时出现 number key + string key 的双写混淆.
3988
+ *
3989
+ * 派发: 无论哪条路径, 完成后派发 onPropertyControlled / onPropertyReleased 含 propType + propRef.
3990
+ */
3991
+ private togglePropertyControlStringPath(propRef: string, enable: boolean): void {
3992
+ const mappedEnum = PROPREF_TO_ENUM[propRef];
3993
+ // 内置 propRef → 重定向到 number 路径 (避免双写)
3994
+ if (mappedEnum !== undefined) {
3995
+ this.togglePropertyControl(mappedEnum as EnumPropName, enable);
3996
+ return;
3997
+ }
3998
+
3999
+ // 自定义 propRef → 走 string key 路径
4000
+ StateErrorManager.debug("切换属性控制状态 (propRef 路径)", {
4001
+ component: "StateSelectV2",
4002
+ method: "togglePropertyControlStringPath",
4003
+ params: { propRef, enable },
4004
+ });
4005
+
4006
+ this.togglePropertyControlByPropRef(propRef, enable);
4007
+
4008
+ // W6-2b: 派发 propRef-aware 事件 (自定义 prop, propType=undefined)
4009
+ CapabilityRegistry.dispatch(enable ? "onPropertyControlled" : "onPropertyReleased", {
4010
+ ctrl: this.getCurrCtrl(),
4011
+ select: this,
4012
+ propType: undefined,
4013
+ propRef,
4014
+ });
4015
+ }
4016
+
4017
+ /** 🔧 智能属性推断:扫描节点所有可用的属性 */
4018
+ public scanAvailableProperties(): EnumPropName[] {
4019
+ const availableProps = PropertyControlService.scanAvailableProperties(this.node);
4020
+ StateErrorManager.info("扫描可用属性完成", {
4021
+ component: "StateSelectV2",
4022
+ method: "scanAvailableProperties",
4023
+ params: { count: availableProps.length, props: availableProps.map(p => EnumPropName[p]) },
4024
+ });
4025
+ return availableProps;
4026
+ }
4027
+
4028
+ /**
4029
+ * 智能属性推断: 批量启用所有 applicable prop. TASK-003 之后 __preload 已自动接入,
4030
+ * 此方法保留作为外部工具入口 (例: panel 命令 / 脚本批量配置 / 现有 jest 测试).
4031
+ */
4032
+ public autoConfigureAllProperties(): { enabled: number, skipped: number, failed: number } {
4033
+ if (!CC_EDITOR) {
4034
+ return { enabled: 0, skipped: 0, failed: 0 };
4035
+ }
4036
+
4037
+ StateErrorManager.info("开始批量启用所有可用属性", {
4038
+ component: "StateSelectV2",
4039
+ method: "autoConfigureAllProperties",
4040
+ });
4041
+
4042
+ const result = { enabled: 0, skipped: 0, failed: 0 };
4043
+ const availableProps = this.scanAvailableProperties();
4044
+
4045
+ for (const propType of availableProps) {
4046
+ // 跳过已控制的属性
4047
+ if (this.isPropertyControlled(propType)) {
4048
+ result.skipped++;
4049
+ continue;
4050
+ }
4051
+
4052
+ // 启用属性控制
4053
+ try {
4054
+ this.togglePropertyControl(propType, true);
4055
+ result.enabled++;
4056
+
4057
+ StateErrorManager.debug("属性已自动启用", {
4058
+ component: "StateSelectV2",
4059
+ method: "autoConfigureAllProperties",
4060
+ params: { propType: EnumPropName[propType] },
4061
+ });
4062
+ }
4063
+ catch (error) {
4064
+ result.failed++;
4065
+ StateErrorManager.warn("属性启用失败", {
4066
+ component: "StateSelectV2",
4067
+ method: "autoConfigureAllProperties",
4068
+ params: { propType: EnumPropName[propType], error: error.message },
4069
+ });
4070
+ }
4071
+ }
4072
+
4073
+ // 刷新编辑器界面
4074
+ this.forceRefreshInspector();
4075
+
4076
+ StateErrorManager.info("批量启用完成", {
4077
+ component: "StateSelectV2",
4078
+ method: "autoConfigureAllProperties",
4079
+ params: result,
4080
+ });
4081
+
4082
+ return result;
4083
+ }
4084
+
4085
+ /** 🔧 架构重构:添加属性控制(分离控制状态和数据状态) */
4086
+ private addPropertyControl(propType: EnumPropName) {
4087
+ const propData = this.getPropData();
4088
+ if (!propData) {
4089
+ StateErrorManager.warn("无法获取属性数据", {
4090
+ component: "StateSelectV2",
4091
+ method: "addPropertyControl",
4092
+ params: { propType: EnumPropName[propType] },
4093
+ });
4094
+ return;
4095
+ }
4096
+
4097
+ const propName = EnumPropName[propType];
4098
+ const propRef = enumToPropRef(propType);
4099
+
4100
+ // 聚合根治 (C1/C2/C7 + U1/U2/U3/U6): AMBIGUOUS 聚合 (Position/Scale/Size/Euler/Anchor) 不注册
4101
+ // 聚合 key, 改逐子项接入 (与 auto-opt 一致, 用户本意: x/y/z 单独控制)。子项走 togglePropertyControlByPropRef
4102
+ // (含值/default/录制 snapshot 处理), 避免聚合 key 引发的录制残留/apply 跳过/判定失败等。
4103
+ if (propRef !== undefined && isAmbiguousAggregatePropRef(propRef)) {
4104
+ const subs = this.getControllableAmbiguousSubRefs(propRef);
4105
+ if (subs.length > 0) {
4106
+ for (const subRef of subs) this.togglePropertyControlByPropRef(subRef, true);
4107
+ return;
4108
+ }
4109
+ // euler: 无可控子项 → 落到下方整体聚合注册 (保 z)
4110
+ }
4111
+
4112
+ // 🔧 第一步:确保受控标记结构存在
4113
+ propData.$$controlledProps$$ = propData.$$controlledProps$$ || {};
4114
+
4115
+ // 🔧 第二步:标记受控 + 落值
4116
+ // T2 双轨统一 (X方案): 有 propRef 映射的内置 prop 收敛到 propRef 字符串单一路径 (与自定义 prop 对称) —
4117
+ // $$controlledProps$$ 写 propRef 自指 string key (非名字 key), 值落**顶层** propData[propRef],
4118
+ // 不再建 $$propertyData$$ 子 bucket. 旧双轨的第二条数据轨 (名字 key + $$propertyData$$) 已废,
4119
+ // 消除 F-7/F-8 残留, 并让 applyPropRefKeysToNode 成为内置唯一 apply 轨 (配合 extractEnumPropTypes 去桥).
4120
+ if (propRef !== undefined) {
4121
+ propData.$$controlledProps$$[propRef] = propRef as any;
4122
+ if ((propData as any)[propRef] === undefined) {
4123
+ const currentValue = this.handleValue(propType);
4124
+ if (currentValue === undefined) {
4125
+ StateErrorManager.warn("无法获取属性值,跳过数据创建", {
4126
+ component: "StateSelectV2",
4127
+ method: "addPropertyControl",
4128
+ params: { propType: propName },
4129
+ });
4130
+ return;
4131
+ }
4132
+ (propData as any)[propRef] = currentValue;
4133
+ // 补种 default baseline (与 togglePropertyControlByPropRef 一致), 切到无该 key 的 state 时兜底
4134
+ const defaultData = this.getDefaultData();
4135
+ if (defaultData && (defaultData as any)[propRef] === undefined) {
4136
+ (defaultData as any)[propRef] = currentValue;
4137
+ }
4138
+ // #C6: default baseline 也补受控 flag, 否则 apply 门控会 skip "有值无 flag" 的 default 兜底
4139
+ if (defaultData) {
4140
+ (defaultData as any).$$controlledProps$$ = (defaultData as any).$$controlledProps$$ || {};
4141
+ (defaultData as any).$$controlledProps$$[propRef] = propRef;
4142
+ }
4143
+ }
4144
+ this._propValue = (propData as any)[propRef];
4145
+ }
4146
+ else {
4147
+ // 无 propRef 映射的遗留 prop (正常不走到): 保留老 number key + $$propertyData$$ 兜底
4148
+ propData.$$propertyData$$ = propData.$$propertyData$$ || {};
4149
+ propData.$$controlledProps$$[propName] = propType;
4150
+ if ((propData.$$propertyData$$ as any)[propType] === undefined) {
4151
+ const currentValue = this.handleValue(propType);
4152
+ if (currentValue === undefined) {
4153
+ StateErrorManager.warn("无法获取属性值,跳过数据创建", {
4154
+ component: "StateSelectV2",
4155
+ method: "addPropertyControl",
4156
+ params: { propType: propName },
4157
+ });
4158
+ return;
4159
+ }
4160
+ (propData.$$propertyData$$ as any)[propType] = currentValue;
4161
+ }
4162
+ this._propValue = (propData.$$propertyData$$ as any)[propType];
4163
+ }
4164
+
4165
+ // 🔧 第三步:兼容性处理 - 同步到旧的 changedProp 显示结构 (UI 用, 非数据轨)
4166
+ propData.$$changedProp$$ = propData.$$changedProp$$ || {};
4167
+ propData.$$changedProp$$[propName] = propType;
4168
+
4169
+ // 🔧 第四步:自动同步到其他状态
4170
+ if (this.autoSyncEnabled) {
4171
+ this.syncPropToAllStatesInternal(propType);
4172
+ }
4173
+
4174
+ // 🔧 第五步:设置为当前选中的属性 + 更新显示
4175
+ this._propKey = propType;
4176
+ this.setPropValue(propType);
4177
+ this.updateChangedProp();
4178
+
4179
+ // 🔧 注意:界面标识变量(_currentDisplayProp)由togglePropertyControl统一管理
4180
+
4181
+ StateErrorManager.info("属性控制已添加", {
4182
+ component: "StateSelectV2",
4183
+ method: "addPropertyControl",
4184
+ params: { propType: propName, propRef, isControlled: true },
4185
+ });
4186
+ }
4187
+
4188
+ /** 🔧 架构重构:移除属性控制(只影响控制状态,保留数据) */
4189
+ private removePropertyControl(propType: EnumPropName) {
4190
+ const propData = this.getPropData();
4191
+ if (!propData) {
4192
+ StateErrorManager.warn("无法获取属性数据", {
4193
+ component: "StateSelectV2",
4194
+ method: "removePropertyControl",
4195
+ params: { propType: EnumPropName[propType] },
4196
+ });
4197
+ return;
4198
+ }
4199
+
4200
+ const propName = EnumPropName[propType];
4201
+
4202
+ // 🔧 第一步:从受控属性列表中移除
4203
+ if (propData.$$controlledProps$$) {
4204
+ delete propData.$$controlledProps$$[propName];
4205
+ }
4206
+
4207
+ // 🔧 第二步:兼容性处理 - 从旧的changedProp中移除
4208
+ if (propData.$$changedProp$$) {
4209
+ delete propData.$$changedProp$$[propName];
4210
+ }
4211
+
4212
+ // 🔧 第三步:保留属性数据不变
4213
+ // 重要:$$propertyData$$中的数据保持不变
4214
+ // 重要:直接存储的属性数据也保持不变
4215
+
4216
+ // 🔧 第四步:如果是当前选中的属性,清除选择
4217
+ if (this._propKey === propType) {
4218
+ this._propKey = EnumPropName.Non;
4219
+ this._propValue = null;
4220
+
4221
+ // 🔧 修复:隐藏属性值字段
4222
+ this.setPropValue(EnumPropName.Non);
4223
+ }
4224
+
4225
+ // 🔧 第五步:不进行自动同步删除,保持数据完整性
4226
+ // 注释:不调用syncDeletePropFromAllStates,避免删除其他状态的数据
4227
+
4228
+ // 🔧 第六步:更新显示
4229
+ this.updateChangedProp();
4230
+
4231
+ // 🔧 注意:界面标识变量(_currentDisplayProp)由togglePropertyControl统一管理
4232
+
4233
+ StateErrorManager.info("属性已从控制列表移除(数据完整保留)", {
4234
+ component: "StateSelectV2",
4235
+ method: "removePropertyControl",
4236
+ params: {
4237
+ propType: propName,
4238
+ dataStillExists: propData.$$propertyData$$ && propData.$$propertyData$$[propType] !== undefined,
4239
+ controlRemoved: !propData.$$controlledProps$$ || !propData.$$controlledProps$$[propName],
4240
+ },
4241
+ });
4242
+ }
4243
+
4244
+ // #endregion 6.
4245
+
4246
+ // #region 7. Inspector 按钮 API (Public)
4247
+ // 编辑器面板上手动触发的工具按钮
4248
+
4249
+ /** 🔧 更新可用属性列表(刷新按钮调用) */
4250
+ public updateAvailableProps() {
4251
+ if (!CC_EDITOR) {
4252
+ return;
4253
+ }
4254
+
4255
+ StateErrorManager.info("刷新属性列表", {
4256
+ component: "StateSelectV2",
4257
+ method: "updateAvailableProps",
4258
+ });
4259
+
4260
+ // 强制刷新编辑器界面(简化实现)
4261
+ // 在Cocos Creator 2.x中,属性面板会自动刷新
4262
+ }
4263
+
4264
+ /** 🔧 恢复:强制刷新属性检查器 */
4265
+ public forceRefreshInspector() {
4266
+ if (!CC_EDITOR) {
4267
+ return;
4268
+ }
4269
+ try {
4270
+ Editor.Utils.refreshSelectedInspector("node", this.node.uuid);
4271
+ StateErrorManager.info("属性检查器已刷新", {
4272
+ component: "StateSelectV2",
4273
+ method: "forceRefreshInspector",
4274
+ });
4275
+ }
4276
+ catch (error) {
4277
+ StateErrorManager.warn("刷新属性检查器失败", {
4278
+ component: "StateSelectV2",
4279
+ method: "forceRefreshInspector",
4280
+ params: { error: error.message },
4281
+ });
4282
+ }
4283
+ }
4284
+
4285
+
4286
+ /** 🔧 修复:从内存同步数据(包含复选框状态更新) */
4287
+ /**
4288
+ * inspector "📥 从内存同步数据" 按钮处理函数
4289
+ *
4290
+ * 用于在数据 / inspector 显示走样时手动恢复:
4291
+ * - $$controlledProps$$ 从 $$changedProp$$ 兼容性重建
4292
+ * - $$lastProp$$ 恢复 _propKey / _currentDisplayProp / _propValue 内存
4293
+ * - 刷新一次 inspector
4294
+ */
4295
+ public syncDataFromMemory() {
4296
+ if (!CC_EDITOR) return;
4297
+
4298
+ try {
4299
+ const propData = this.getPropData();
4300
+ if (!propData) {
4301
+ StateErrorManager.warn("无法获取属性数据", {
4302
+ component: "StateSelectV2",
4303
+ method: "syncDataFromMemory",
4304
+ });
4305
+ return;
4306
+ }
4307
+
4308
+ // 确保元数据结构存在
4309
+ propData.$$controlledProps$$ = propData.$$controlledProps$$ || {};
4310
+ propData.$$changedProp$$ = propData.$$changedProp$$ || {};
4311
+
4312
+ // 从 $$changedProp$$ 兼容性重建 $$controlledProps$$
4313
+ // (历史数据 / 早期版本可能只有 changedProp 没有 controlledProps)
4314
+ for (const propName in propData.$$changedProp$$) {
4315
+ if (propData.$$controlledProps$$[propName] === undefined) {
4316
+ propData.$$controlledProps$$[propName] = propData.$$changedProp$$[propName];
4317
+ }
4318
+ }
4319
+
4320
+ // 恢复 lastProp 选中状态 + _propValue 内存
4321
+ const lastProp = propData.$$lastProp$$;
4322
+ if (lastProp !== undefined && lastProp !== EnumPropName.Non) {
4323
+ this._propKey = lastProp;
4324
+ // W6-2c2: 双 key 读
4325
+ this._propValue = this.readPropByEnum(propData, lastProp);
4326
+ this._currentDisplayProp = lastProp;
4327
+ this.setPropValue(lastProp);
4328
+ }
4329
+ else {
4330
+ this._currentDisplayProp = EnumPropName.Non;
4331
+ this.setPropValue(EnumPropName.Non);
4332
+ }
4333
+
4334
+ this.updateChangedProp();
4335
+ this.forceRefreshInspector();
4336
+ }
4337
+ catch (error) {
4338
+ StateErrorManager.error("数据同步失败", {
4339
+ component: "StateSelectV2",
4340
+ method: "syncDataFromMemory",
4341
+ params: { error: (error as Error).message },
4342
+ });
4343
+ }
4344
+ }
4345
+
4346
+ /** 🔧 修复:删除属性(带确认对话框,修复序列化问题) */
4347
+ public deletePropertyWithConfirmation() {
4348
+ if (!CC_EDITOR) {
4349
+ return;
4350
+ }
4351
+
4352
+ // 检查是否有选中的属性
4353
+ if (this._propKey === EnumPropName.Non || !this._propKey) {
4354
+ StateErrorManager.userFriendlyError(
4355
+ "没有选中的属性",
4356
+ "请先选择要删除的属性",
4357
+ { component: "StateSelectV2", method: "deletePropertyWithConfirmation" },
4358
+ );
4359
+ return;
4360
+ }
4361
+
4362
+ // 🔧 修复:保存当前属性值,避免在回调中使用this引用
4363
+ const currentPropKey = this._propKey;
4364
+ const propName = EnumPropName[currentPropKey];
4365
+
4366
+ // 🔧 优化:简化Editor.Dialog调用,静默降级处理
4367
+ const useEditorDialog = () => {
4368
+ try {
4369
+ if (typeof Editor !== "undefined" && Editor.Dialog && Editor.Dialog.messageBox) {
4370
+ // 🔧 修复:使用简化的参数,避免传递复杂对象
4371
+ const dialogOptions = {
4372
+ type: "warning",
4373
+ title: "确认删除属性",
4374
+ message: `确定要删除属性 "${propName}" 吗?\n\n此操作将:\n• 从所有状态中删除该属性数据\n• 删除默认属性值\n• 无法撤销`,
4375
+ buttons: ["取消", "确认删除"],
4376
+ defaultId: 0,
4377
+ cancelId: 0,
4378
+ };
4379
+
4380
+ // 🔧 修复:使用箭头函数并捕获局部变量,避免this引用
4381
+ const handleResponse = (response: number) => {
4382
+ if (response === 1) { // 确认删除
4383
+ this.performPropertyDeletion(currentPropKey);
4384
+ }
4385
+ };
4386
+
4387
+ Editor.Dialog.messageBox(dialogOptions, handleResponse);
4388
+ return true;
4389
+ }
4390
+ return false;
4391
+ }
4392
+ catch (error) {
4393
+ // 🔧 优化:静默处理Editor.Dialog失败,不显示错误日志
4394
+ // 只在开发模式下记录调试信息
4395
+ if (CC_DEV) {
4396
+ StateErrorManager.debug("Editor.Dialog不可用,降级到confirm对话框", {
4397
+ component: "StateSelectV2",
4398
+ method: "deletePropertyWithConfirmation",
4399
+ params: {
4400
+ reason: error.message,
4401
+ propName: propName,
4402
+ },
4403
+ });
4404
+ }
4405
+ return false;
4406
+ }
4407
+ };
4408
+
4409
+ // 🔧 优化:优雅降级机制,静默处理错误
4410
+ const useConfirmDialog = () => {
4411
+ try {
4412
+ const confirmed = confirm(`确定要删除属性 "${propName}" 吗?\n\n此操作将从所有状态中删除该属性数据,无法撤销!`);
4413
+ if (confirmed) {
4414
+ this.performPropertyDeletion(currentPropKey);
4415
+ }
4416
+ return true;
4417
+ }
4418
+ catch (error) {
4419
+ // 🔧 优化:confirm对话框失败是极少见的情况,静默处理
4420
+ if (CC_DEV) {
4421
+ StateErrorManager.debug("确认对话框调用失败", {
4422
+ component: "StateSelectV2",
4423
+ method: "deletePropertyWithConfirmation",
4424
+ params: {
4425
+ reason: error.message,
4426
+ propName: propName,
4427
+ },
4428
+ });
4429
+ }
4430
+ return false;
4431
+ }
4432
+ };
4433
+
4434
+ // 🔧 优化:按优先级尝试不同的确认方式,静默降级
4435
+ if (!useEditorDialog()) {
4436
+ if (!useConfirmDialog()) {
4437
+ // 🔧 优化:只有在所有对话框都失败时才显示错误
4438
+ // 这种情况极其罕见,通常是浏览器环境问题
4439
+ StateErrorManager.warn("无法显示任何确认对话框,删除操作已取消", {
4440
+ component: "StateSelectV2",
4441
+ method: "deletePropertyWithConfirmation",
4442
+ params: { propName: propName },
4443
+ });
4444
+ }
4445
+ }
4446
+ }
4447
+
4448
+ /** 🔧 架构重构:执行属性删除操作(彻底清除所有数据) */
4449
+ private performPropertyDeletion(propType: EnumPropName) {
4450
+ if (!CC_EDITOR || propType === EnumPropName.Non) {
4451
+ return;
4452
+ }
4453
+
4454
+ const propName = EnumPropName[propType];
4455
+
4456
+ try {
4457
+ // W6-2c2: 双 key 删 (string propRef + number, 兼容老数据)
4458
+ const propRef = enumToPropRef(propType);
4459
+
4460
+ // 🔧 第一步:从当前状态删除所有相关数据
4461
+ const propData = this.getPropData();
4462
+ if (propData) {
4463
+ // 删除受控属性列表中的条目
4464
+ if (propData.$$controlledProps$$) {
4465
+ delete propData.$$controlledProps$$[propName];
4466
+ }
4467
+
4468
+ // W6-2c2: 删属性数据 (string + number 双删)
4469
+ if (propData.$$propertyData$$) {
4470
+ if (propRef !== undefined) delete (propData.$$propertyData$$ as any)[propRef];
4471
+ delete propData.$$propertyData$$[propType];
4472
+ }
4473
+
4474
+ // 兼容性:删除旧结构中的数据
4475
+ if (propData.$$changedProp$$) {
4476
+ delete propData.$$changedProp$$[propName];
4477
+ }
4478
+ // W6-2c2: 直接存储的属性 (string + number 双删)
4479
+ if (propRef !== undefined) delete (propData as any)[propRef];
4480
+ delete (propData as TPropDictionary)[propType];
4481
+ }
4482
+
4483
+ // 🔧 第二步:从所有状态删除
4484
+ this.syncDeletePropFromAllStates(propType);
4485
+
4486
+ // 🔧 第三步:删除默认属性
4487
+ const pageData = this.getPageData();
4488
+ if (pageData.$$default$$) {
4489
+ // 删除新结构中的数据
4490
+ if (pageData.$$default$$.$$controlledProps$$) {
4491
+ delete pageData.$$default$$.$$controlledProps$$[propName];
4492
+ }
4493
+ if (pageData.$$default$$.$$propertyData$$) {
4494
+ if (propRef !== undefined) delete (pageData.$$default$$.$$propertyData$$ as any)[propRef];
4495
+ delete pageData.$$default$$.$$propertyData$$[propType];
4496
+ }
4497
+ // 兼容性:删除旧结构中的数据
4498
+ if (pageData.$$default$$.$$changedProp$$) {
4499
+ delete pageData.$$default$$.$$changedProp$$[propName];
4500
+ }
4501
+ if (propRef !== undefined) delete (pageData.$$default$$ as any)[propRef];
4502
+ delete (pageData.$$default$$ as TPropDictionary)[propType];
4503
+ }
4504
+
4505
+ // 🔧 第四步:清除当前选择
4506
+ if (this._propKey === propType) {
4507
+ this._propKey = EnumPropName.Non;
4508
+ this._propValue = null;
4509
+ }
4510
+
4511
+ // 🔧 第五步:清除当前选中属性
4512
+ this._currentDisplayProp = EnumPropName.Non;
4513
+
4514
+ // 🔧 第六步:更新显示
4515
+ this.updateChangedProp();
4516
+
4517
+ StateErrorManager.info("属性彻底删除成功", {
4518
+ component: "StateSelectV2",
4519
+ method: "performPropertyDeletion",
4520
+ params: {
4521
+ propName,
4522
+ message: "属性及其所有数据已从所有状态中彻底删除",
4523
+ deletedFromControlledProps: true,
4524
+ deletedFromPropertyData: true,
4525
+ deletedFromAllStates: true,
4526
+ },
4527
+ });
4528
+ }
4529
+ catch (error) {
4530
+ StateErrorManager.userFriendlyError(
4531
+ "属性删除失败",
4532
+ `删除属性 "${propName}" 时发生错误:${error.message}`,
4533
+ { component: "StateSelectV2", method: "performPropertyDeletion" },
4534
+ );
4535
+ }
4536
+ }
4537
+
4538
+ // #endregion 7.
4539
+ }
4540
+
4541
+ // back-compat 导出别名: 仅 JS 导出名, 不触发 @ccclass (引擎/panel 按 cid "StateSelectV2" 识别).
4542
+ export { StateSelectV2 as StateSelect };