@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,1207 @@
1
+ 'use strict';
2
+
3
+ const SNAP_INDEX_KEY = 'sel' + 'ectedIndex';
4
+ const NODE_UUID_KEY = 'sel' + 'ectUuid';
5
+ const SET_INDEX_MSG = 'set-' + 'sel' + 'ected-index';
6
+
7
+ const ANOMALY = { loose: 1, excluded: 1, mixed: 1 };
8
+
9
+ module.exports = {
10
+ $: {
11
+ // 顶部
12
+ tabOverview: '#tab-overview',
13
+ tabEditor: '#tab-editor',
14
+ overviewActions: '#overview-actions',
15
+ editorActions: '#editor-actions',
16
+ viewOverview: '#view-overview',
17
+ viewEditor: '#view-editor',
18
+ tabBindings: '#tab-bindings',
19
+ viewBindings: '#view-bindings',
20
+ bindSourceCtrl: '#bind-source-ctrl',
21
+ bindSourceState: '#bind-source-state',
22
+ bindTargetCtrl: '#bind-target-ctrl',
23
+ bindTargetState: '#bind-target-state',
24
+ btnAddBinding: '#btn-add-binding',
25
+ bindingsGraph: '#bindings-graph',
26
+ bindingsEmpty: '#bindings-empty',
27
+ bindingsForm: '#bindings-form',
28
+ filterLevel: '#filter-level',
29
+ searchInput: '#search-input',
30
+ // 观测总览
31
+ dashboardGrid: '#dashboard-grid',
32
+ topologyTree: '#topology-tree',
33
+ matrixTitle: '#matrix-title',
34
+ valueMatrix: '#value-matrix',
35
+ matrixHead: '#matrix-head',
36
+ matrixBody: '#matrix-body',
37
+ matrixEmpty: '#matrix-empty',
38
+ issuesCount: '#issues-count',
39
+ issuesList: '#issues-list',
40
+ overviewEmpty: '#overview-empty',
41
+ overviewBody: '#overview-body',
42
+ // inspector flags
43
+ chkInspectorMaster: '#chk-inspector-master',
44
+ chkInspectorViz: '#chk-inspector-viz',
45
+ chkInspectorDirty: '#chk-inspector-dirty',
46
+ chkInspectorExclude: '#chk-inspector-exclude',
47
+ inspectorSubToggles: '#inspector-sub-toggles',
48
+ // 编辑视图
49
+ statesList: '#states-list',
50
+ btnAddState: '#btn-add-state',
51
+ // 回收站
52
+ recycleBin: '#recycle-bin',
53
+ binHeader: '#bin-header',
54
+ binCount: '#bin-count',
55
+ binBody: '#bin-body',
56
+ binList: '#bin-list',
57
+ btnPurgeAll: '#btn-purge-all',
58
+ tplBinItem: '#tpl-bin-item',
59
+ // 回收态预览横幅
60
+ previewBanner: '#preview-banner',
61
+ pvName: '#pv-name',
62
+ btnPreviewRestore: '#btn-preview-restore',
63
+ btnPreviewExit: '#btn-preview-exit',
64
+ // 自定义确认弹窗
65
+ confirmModal: '#confirm-modal',
66
+ cmBackdrop: '#cm-backdrop',
67
+ cmTitle: '#cm-title',
68
+ cmBody: '#cm-body',
69
+ cmCancel: '#cm-cancel',
70
+ cmConfirm: '#cm-confirm',
71
+ stateDetail: '#state-detail',
72
+ emptyTip: '#empty-tip',
73
+ emptyTipTitle: '#empty-tip-title',
74
+ ctrlListHint: '#ctrl-list-hint',
75
+ ctrlList: '#ctrl-list',
76
+ btnPrevCtrl: '#btn-prev-ctrl',
77
+ ctrlSwitchSelect: '#ctrl-switch-select',
78
+ btnNextCtrl: '#btn-next-ctrl',
79
+ statePickSelect: '#state-pick-select',
80
+ stateTitle: '#state-title',
81
+ recordBadge: '#record-badge',
82
+ btnStartRecord: '#btn-start-record',
83
+ btnStopRecord: '#btn-stop-record',
84
+ btnCancelRecord: '#btn-cancel-record',
85
+ editorMatrix: '#editor-matrix',
86
+ chkShowAllProps: '#chk-show-all-props',
87
+ // 属性详情抽屉
88
+ propDetail: '#prop-detail',
89
+ pdBackdrop: '#pd-backdrop',
90
+ pdClose: '#pd-close',
91
+ pdName: '#pd-name',
92
+ pdType: '#pd-type',
93
+ pdBody: '#pd-body',
94
+ // 模板
95
+ tplStateItem: '#tpl-state-item',
96
+ tplCtrlItem: '#tpl-ctrl-item',
97
+ tplDashboardCard: '#tpl-dashboard-card',
98
+ tplTreeCtrl: '#tpl-tree-ctrl',
99
+ tplTreeNode: '#tpl-tree-node',
100
+ tplTreeProp: '#tpl-tree-prop',
101
+ tplMatrixTh: '#tpl-matrix-th',
102
+ tplMatrixRow: '#tpl-matrix-row',
103
+ tplMatrixCell: '#tpl-matrix-cell',
104
+ tplIssueItem: '#tpl-issue-item',
105
+ tplBindingEdge: '#tpl-binding-edge',
106
+ },
107
+
108
+ ready() {
109
+ // 编辑视图状态
110
+ this.currentCtrlUuid = null;
111
+ this.currentSnapshot = null;
112
+ this.ctrlItems = [];
113
+ this._initialFetch = true;
114
+ // 观测视图状态
115
+ this.activeView = 'overview';
116
+ this.topology = null;
117
+ this.collapsedCtrls = {}; // ctrlId → true (折叠)
118
+ this.selMemberUuid = null; // 当前矩阵展示的成员节点 uuid
119
+ this.matrixCtrl = null; // 当前矩阵关联的 controller (含 states/selectedIndex)
120
+
121
+ this._fetchInspectorFlags();
122
+ this._bindEvents();
123
+ this._initialRefresh();
124
+ },
125
+
126
+ close() {
127
+ if (this._onEditorSelected) {
128
+ try { Editor.Selection.removeListener('selected', this._onEditorSelected); } catch (e) { /* 静默 */ }
129
+ this._onEditorSelected = null;
130
+ }
131
+ this._callScene('dispose-all-bridges');
132
+ },
133
+
134
+ _callScene(method, payload, cb) {
135
+ Editor.Scene.callSceneScript('state-controller-v2-panel', method, payload || null, cb || function () {});
136
+ },
137
+
138
+ // 初次拉取: 纯事件驱动, 先问场景是否就绪 (避免 scene-script 未注册时盲调告警).
139
+ _initialRefresh() {
140
+ Editor.Ipc.sendToMain('state-controller-v2-panel:is-scene-ready', (err, ready) => {
141
+ if (!err && ready) {
142
+ this.refreshTopology();
143
+ this.refreshCtrlList();
144
+ }
145
+ });
146
+ },
147
+
148
+ // ============================================================
149
+ // 事件绑定
150
+ // ============================================================
151
+ _bindEvents() {
152
+ // --- tab 切换 ---
153
+ this.$tabOverview.addEventListener('click', () => this.switchView('overview'));
154
+ this.$tabEditor.addEventListener('click', () => this.switchView('editor'));
155
+ this.$tabBindings.addEventListener('click', () => this.switchView('bindings'));
156
+
157
+ // --- 观测过滤 / 搜索 ---
158
+ this.$filterLevel.addEventListener('change', () => this.renderTopology());
159
+ this.$searchInput.addEventListener('input', () => this.renderTopology());
160
+
161
+ // --- 联动关系表单 ---
162
+ this.$bindSourceCtrl.addEventListener('change', () => this._fillStateOptions(this.$bindSourceCtrl, this.$bindSourceState));
163
+ this.$bindTargetCtrl.addEventListener('change', () => this._fillStateOptions(this.$bindTargetCtrl, this.$bindTargetState));
164
+ this.$btnAddBinding.addEventListener('click', () => this._addBindingFromForm());
165
+
166
+ // --- 反向高亮: 编辑器选中节点 → 总览定位 (存 handler, close 时解绑防泄漏) ---
167
+ this._onEditorSelected = (type, ids) => {
168
+ if (type === 'node' && ids && ids.length > 0) this._reverseHighlight(ids[0]);
169
+ };
170
+ try { Editor.Selection.on('selected', this._onEditorSelected); } catch (e) { /* 静默 */ }
171
+
172
+ // --- 编辑视图事件 ---
173
+ this.$btnAddState.addEventListener('click', () => {
174
+ if (!this.currentCtrlUuid || !this.currentSnapshot || this.currentSnapshot.isRecording) return;
175
+ const states = this.currentSnapshot.states || [];
176
+ this._callScene('add-state', { uuid: this.currentCtrlUuid, name: `State_${states.length + 1}` }, (err) => {
177
+ if (err) Editor.warn(err);
178
+ this.refreshSnapshot();
179
+ });
180
+ });
181
+ // --- 回收站 ---
182
+ this.$binHeader.addEventListener('click', () => {
183
+ this.$recycleBin.classList.toggle('is-collapsed');
184
+ });
185
+ this.$btnPurgeAll.addEventListener('click', () => {
186
+ if (!this._binActionable()) return;
187
+ const n = (this.currentSnapshot.deletedStates || []).length;
188
+ if (!n) return;
189
+ this._confirm({
190
+ title: '清空回收站?',
191
+ body: `将彻底删除回收站里全部 ${n} 个状态的数据,<b>不可恢复</b>。`,
192
+ confirmLabel: '清空回收站',
193
+ }, () => {
194
+ this._callScene('purge-all-deleted-states', { uuid: this.currentCtrlUuid }, (err) => {
195
+ if (err) Editor.warn(err);
196
+ this.refreshSnapshot();
197
+ });
198
+ });
199
+ });
200
+
201
+ // --- 回收态预览横幅 ---
202
+ this.$btnPreviewExit.addEventListener('click', () => {
203
+ if (!this.currentCtrlUuid) return;
204
+ this._callScene('exit-preview', { uuid: this.currentCtrlUuid }, () => this.refreshSnapshot());
205
+ });
206
+ this.$btnPreviewRestore.addEventListener('click', () => {
207
+ if (!this.currentCtrlUuid || !this.currentSnapshot) return;
208
+ const id = this.currentSnapshot.previewingStateId;
209
+ if (typeof id !== 'number' || id < 0) return;
210
+ this._callScene('restore-deleted-state', { uuid: this.currentCtrlUuid, stateId: id }, (err) => {
211
+ if (err) Editor.warn(err);
212
+ this.refreshSnapshot();
213
+ });
214
+ });
215
+
216
+ // --- 自定义确认弹窗 ---
217
+ this.$cmCancel.addEventListener('click', () => this._closeConfirm());
218
+ this.$cmBackdrop.addEventListener('click', () => this._closeConfirm());
219
+ this.$cmConfirm.addEventListener('click', () => {
220
+ const cb = this._confirmCb;
221
+ this._closeConfirm();
222
+ if (typeof cb === 'function') cb();
223
+ });
224
+ this.$btnPrevCtrl.addEventListener('click', () => this._stepCtrl(-1));
225
+ this.$btnNextCtrl.addEventListener('click', () => this._stepCtrl(1));
226
+ // 下拉直选控制器 / 状态 (替代旧的「点击跳下一个」)
227
+ this.$ctrlSwitchSelect.addEventListener('change', () => {
228
+ if (this.$ctrlSwitchSelect.value) this.setCurrentCtrl(this.$ctrlSwitchSelect.value, true);
229
+ });
230
+ this.$statePickSelect.addEventListener('change', () => {
231
+ const idx = Number(this.$statePickSelect.value);
232
+ const states = (this.currentSnapshot && this.currentSnapshot.states) || [];
233
+ const st = states.find(s => s.index === idx);
234
+ if (st) this._goState(st);
235
+ });
236
+ this.$btnStartRecord.addEventListener('click', () => this._setRecording(true));
237
+ this.$btnStopRecord.addEventListener('click', () => this._setRecording(false));
238
+ this.$btnCancelRecord.addEventListener('click', () => {
239
+ if (!this.currentCtrlUuid) return;
240
+ this._callScene('cancel-recording', { uuid: this.currentCtrlUuid }, (err) => {
241
+ if (err) Editor.warn(err);
242
+ this.refreshSnapshot();
243
+ });
244
+ });
245
+ this.$chkShowAllProps.addEventListener('change', () => this.renderEditorMatrix());
246
+ this.$pdClose.addEventListener('click', () => this._closePropDetail());
247
+ this.$pdBackdrop.addEventListener('click', () => this._closePropDetail());
248
+
249
+ this.$chkInspectorMaster.addEventListener('change', () => {
250
+ this._updateInspectorSubToggles();
251
+ if (this.$chkInspectorMaster.checked) Editor.Ipc.sendToMain('state-controller-v2-panel:inspector-mark-on');
252
+ else Editor.Ipc.sendToMain('state-controller-v2-panel:inspector-mark-off');
253
+ });
254
+ const onSubFlagChange = () => { if (this.$chkInspectorMaster.checked) this._syncInspectorSubFlags(); };
255
+ this.$chkInspectorViz.addEventListener('change', onSubFlagChange);
256
+ this.$chkInspectorDirty.addEventListener('change', onSubFlagChange);
257
+ this.$chkInspectorExclude.addEventListener('change', onSubFlagChange);
258
+ },
259
+
260
+ switchView(view) {
261
+ this.activeView = view;
262
+ this.$tabOverview.classList.toggle('is-active', view === 'overview');
263
+ this.$tabEditor.classList.toggle('is-active', view === 'editor');
264
+ this.$tabBindings.classList.toggle('is-active', view === 'bindings');
265
+ this.$overviewActions.style.display = view === 'overview' ? 'flex' : 'none';
266
+ this.$editorActions.style.display = view === 'editor' ? 'flex' : 'none';
267
+ this.$viewOverview.style.display = view === 'overview' ? 'flex' : 'none';
268
+ this.$viewEditor.style.display = view === 'editor' ? 'flex' : 'none';
269
+ this.$viewBindings.style.display = view === 'bindings' ? 'flex' : 'none';
270
+ if (view === 'overview') this.refreshTopology();
271
+ else if (view === 'editor') { this.refreshCtrlList(); this.refreshTopology(); }
272
+ else this.refreshBindings();
273
+ },
274
+
275
+ // ============================================================
276
+ // 观测总览
277
+ // ============================================================
278
+ refreshTopology() {
279
+ if (this.activeView === 'bindings') return;
280
+ this._callScene('list-scene-topology', null, (err, topology) => {
281
+ if (err) { if (!this._initialFetch) Editor.warn(err); return; }
282
+ this.topology = topology || { controllers: [] };
283
+ if (this.activeView === 'overview') this.renderTopology();
284
+ else if (this.activeView === 'editor') this.renderEditorMatrix();
285
+ });
286
+ },
287
+
288
+ _filterLevel() { return (this.$filterLevel && this.$filterLevel.value) || 'default'; },
289
+ _keyword() { return (this.$searchInput.value || '').toLowerCase().trim(); },
290
+
291
+ _propPassesLevel(prop, level) {
292
+ if (level === 'all') return true;
293
+ const anomaly = !!ANOMALY[prop.kind];
294
+ if (level === 'abnormal') return anomaly;
295
+ return anomaly || !!prop.variesAcrossStates; // default: 变化 OR 异常
296
+ },
297
+ _propMatchesKeyword(prop, kw, ctrlName, nodeName) {
298
+ if (!kw) return true;
299
+ return (prop.display || '').toLowerCase().indexOf(kw) >= 0
300
+ || (prop.compName || '').toLowerCase().indexOf(kw) >= 0
301
+ || (ctrlName || '').toLowerCase().indexOf(kw) >= 0
302
+ || (nodeName || '').toLowerCase().indexOf(kw) >= 0;
303
+ },
304
+ _visibleProps(member, ctrl) {
305
+ const level = this._filterLevel(), kw = this._keyword();
306
+ return (member.props || []).filter(p =>
307
+ this._propPassesLevel(p, level) && this._propMatchesKeyword(p, kw, ctrl.ctrlName, member.nodeName));
308
+ },
309
+
310
+ renderTopology() {
311
+ const ctrls = (this.topology && this.topology.controllers) || [];
312
+ const empty = !ctrls.length;
313
+ // 空态: 只显示「场景里还没有控制器」, 隐藏三栏工作区 + 仪表盘, 并清掉上一个预制体残留的矩阵/选中
314
+ this.$overviewEmpty.style.display = empty ? 'flex' : 'none';
315
+ this.$overviewBody.style.display = empty ? 'none' : 'flex';
316
+ this.$dashboardGrid.style.display = empty ? 'none' : '';
317
+ this.$topologyTree.innerHTML = '';
318
+ if (empty) {
319
+ this.selMemberUuid = null;
320
+ this.matrixCtrl = null;
321
+ this._clearMatrix();
322
+ this.renderIssues();
323
+ return;
324
+ }
325
+
326
+ ctrls.forEach(ctrl => {
327
+ const collapsed = !!this.collapsedCtrls[ctrl.ctrlId];
328
+ const ctrlNode = document.importNode(this.$tplTreeCtrl.content, true);
329
+ const ctrlEl = ctrlNode.querySelector('.tree-ctrl');
330
+ ctrlEl.classList.toggle('collapsed', collapsed);
331
+ ctrlNode.querySelector('.tree-ctrl-name').textContent = ctrl.ctrlName || `Controller ${ctrl.ctrlId}`;
332
+ ctrlEl.addEventListener('click', () => {
333
+ this.collapsedCtrls[ctrl.ctrlId] = !this.collapsedCtrls[ctrl.ctrlId];
334
+ this.renderTopology();
335
+ });
336
+ this.$topologyTree.appendChild(ctrlNode);
337
+ if (collapsed) return;
338
+
339
+ (ctrl.members || []).forEach(member => {
340
+ const visProps = this._visibleProps(member, ctrl);
341
+ if (!visProps.length && this._keyword()) return; // 搜索时空成员不显示
342
+
343
+ const nodeFrag = document.importNode(this.$tplTreeNode.content, true);
344
+ const nodeEl = nodeFrag.querySelector('.tree-node');
345
+ nodeEl.querySelector('.tree-node-name').textContent = member.nodeName || '(node)';
346
+ nodeEl.title = member.nodePath || '';
347
+ nodeEl.dataset.nodeUuid = member.nodeUuid;
348
+ nodeEl.dataset.ctrlId = ctrl.ctrlId;
349
+ if (member.nodeUuid === this.selMemberUuid) nodeEl.classList.add('is-selected');
350
+ nodeEl.addEventListener('click', () => this._selectMember(ctrl, member));
351
+ this.$topologyTree.appendChild(nodeFrag);
352
+
353
+ visProps.forEach(prop => {
354
+ const propFrag = document.importNode(this.$tplTreeProp.content, true);
355
+ const propEl = propFrag.querySelector('.tree-prop');
356
+ propEl.querySelector('.tree-prop-name').textContent = `↳ ${prop.display}`;
357
+ propEl.title = `${prop.compName} · ${prop.propRef}`;
358
+ const badge = propEl.querySelector('.kind-badge');
359
+ badge.textContent = prop.kind;
360
+ badge.classList.add(prop.kind);
361
+ propEl.addEventListener('click', () => this._selectMember(ctrl, member, prop.propRef));
362
+ this.$topologyTree.appendChild(propFrag);
363
+ });
364
+ });
365
+ });
366
+
367
+ this.renderDashboard();
368
+ this.renderIssues();
369
+ // 重渲后若已有选中成员, 同步矩阵 (状态/值可能变了)
370
+ if (this.selMemberUuid) {
371
+ const found = this._findMember(this.selMemberUuid);
372
+ if (found) this.renderMatrix(found.ctrl, found.member);
373
+ else this._clearMatrix();
374
+ } else {
375
+ this._clearMatrix();
376
+ }
377
+ },
378
+
379
+ renderDashboard() {
380
+ const ctrls = (this.topology && this.topology.controllers) || [];
381
+ this.$dashboardGrid.innerHTML = '';
382
+ ctrls.forEach(ctrl => {
383
+ const frag = document.importNode(this.$tplDashboardCard.content, true);
384
+ const card = frag.querySelector('.db-card');
385
+ const cur = (ctrl.states || []).find(s => s.index === ctrl.selectedIndex);
386
+ frag.querySelector('.db-title').textContent = ctrl.ctrlName || `Controller ${ctrl.ctrlId}`;
387
+ frag.querySelector('.db-state-name').textContent = cur ? (cur.name || `#${cur.index + 1}`) : '--';
388
+ const dot = frag.querySelector('.status-dot');
389
+ if (ctrl.isRecording) dot.classList.add('recording');
390
+ if (this.matrixCtrl && this.matrixCtrl.uuid === ctrl.uuid) card.classList.add('active');
391
+ card.addEventListener('click', () => {
392
+ const m = (ctrl.members || [])[0];
393
+ if (m) this._selectMember(ctrl, m);
394
+ });
395
+ this.$dashboardGrid.appendChild(frag);
396
+ });
397
+ },
398
+
399
+ renderIssues() {
400
+ const ctrls = (this.topology && this.topology.controllers) || [];
401
+ const issues = [];
402
+ ctrls.forEach(ctrl => (ctrl.members || []).forEach(member => (member.props || []).forEach(prop => {
403
+ if (ANOMALY[prop.kind]) issues.push({ ctrl, member, prop });
404
+ })));
405
+ this.$issuesCount.textContent = String(issues.length);
406
+ this.$issuesList.innerHTML = '';
407
+ if (!issues.length) {
408
+ const e = document.createElement('div');
409
+ e.className = 'issue-empty';
410
+ e.textContent = '没有异常属性 ✓';
411
+ this.$issuesList.appendChild(e);
412
+ return;
413
+ }
414
+ issues.forEach(({ ctrl, member, prop }) => {
415
+ const frag = document.importNode(this.$tplIssueItem.content, true);
416
+ const badge = frag.querySelector('.badge-type');
417
+ badge.textContent = prop.kind;
418
+ badge.classList.add(prop.kind);
419
+ frag.querySelector('.issue-prop').textContent = prop.display;
420
+ frag.querySelector('.issue-node').textContent = `${member.nodeName} · ${ctrl.ctrlName || ctrl.ctrlId}`;
421
+ frag.querySelector('.btn-issue-jump').addEventListener('click', () => {
422
+ this._selectMember(ctrl, member, prop.propRef);
423
+ });
424
+ this.$issuesList.appendChild(frag);
425
+ });
426
+ },
427
+
428
+ _findMember(nodeUuid) {
429
+ const ctrls = (this.topology && this.topology.controllers) || [];
430
+ for (const ctrl of ctrls) {
431
+ for (const member of (ctrl.members || [])) {
432
+ if (member.nodeUuid === nodeUuid) return { ctrl, member };
433
+ }
434
+ }
435
+ return null;
436
+ },
437
+
438
+ // 选中成员: 加载矩阵 + 编辑器选中节点 (+ 可选高亮某属性行)
439
+ _selectMember(ctrl, member, focusPropRef) {
440
+ this.selMemberUuid = member.nodeUuid;
441
+ this.matrixCtrl = ctrl;
442
+ try { Editor.Selection.select('node', member.nodeUuid); } catch (e) { /* 静默 */ }
443
+ this.renderMatrix(ctrl, member, focusPropRef);
444
+ // 同步树高亮
445
+ this.$topologyTree.querySelectorAll('.tree-node').forEach(el =>
446
+ el.classList.toggle('is-selected', el.dataset.nodeUuid === member.nodeUuid));
447
+ this.renderDashboard();
448
+ },
449
+
450
+ _clearMatrix() {
451
+ this.$matrixHead.innerHTML = '';
452
+ this.$matrixBody.innerHTML = '';
453
+ this.$matrixTitle.textContent = '选择一个成员节点';
454
+ this.$matrixEmpty.style.display = 'block';
455
+ this.$valueMatrix.style.display = 'none';
456
+ },
457
+
458
+ renderMatrix(ctrl, member, focusPropRef) {
459
+ const states = ctrl.states || [];
460
+ const props = this._visibleProps(member, ctrl);
461
+ this.$matrixTitle.textContent = `${member.nodeName} — ${ctrl.ctrlName || ctrl.ctrlId}`;
462
+
463
+ if (!props.length || !states.length) {
464
+ this.$matrixHead.innerHTML = '';
465
+ this.$matrixBody.innerHTML = '';
466
+ this.$matrixEmpty.style.display = 'block';
467
+ this.$matrixEmpty.querySelector('p').textContent = states.length
468
+ ? '该成员在当前过滤级别下没有可显示的属性。'
469
+ : '该控制器还没有状态。';
470
+ this.$valueMatrix.style.display = 'none';
471
+ return;
472
+ }
473
+ this.$matrixEmpty.style.display = 'none';
474
+ this.$valueMatrix.style.display = 'table';
475
+ this._buildMatrix(this.$matrixHead, this.$matrixBody, ctrl, props, { focusPropRef });
476
+ },
477
+
478
+ // 共享矩阵渲染: headTr=<tr> / tbody=<tbody>. opts.onCancel(prop) 启用行尾「取消跟随」.
479
+ _buildMatrix(headTr, tbody, ctrl, props, opts) {
480
+ opts = opts || {};
481
+ const states = ctrl.states || [];
482
+ const selIdx = (opts.selectedIndex !== undefined) ? opts.selectedIndex : ctrl.selectedIndex;
483
+
484
+ headTr.innerHTML = '';
485
+ const thProp = document.createElement('th');
486
+ thProp.textContent = '受控属性';
487
+ headTr.appendChild(thProp);
488
+ states.forEach(state => {
489
+ const thFrag = document.importNode(this.$tplMatrixTh.content, true);
490
+ const th = thFrag.querySelector('th');
491
+ th.textContent = state.name || `#${state.index + 1}`;
492
+ if (state.index === selIdx) th.classList.add('col-highlight');
493
+ th.title = '点击切换到该状态';
494
+ th.addEventListener('click', () => this._gotoState(ctrl, state));
495
+ headTr.appendChild(thFrag);
496
+ });
497
+
498
+ tbody.innerHTML = '';
499
+ props.forEach(prop => {
500
+ const rowFrag = document.importNode(this.$tplMatrixRow.content, true);
501
+ const tr = rowFrag.querySelector('tr');
502
+ if (prop.variesAcrossStates) tr.classList.add('row-changed');
503
+ tr.dataset.propRef = prop.propRef;
504
+ const propCell = tr.querySelector('.matrix-prop');
505
+ propCell.querySelector('.prop-name').textContent = prop.display;
506
+ propCell.querySelector('.prop-type').textContent = prop.compName || '';
507
+ // 点属性名格 → 详情抽屉 (逐状态看全值)
508
+ propCell.classList.add('clickable');
509
+ propCell.title = '点击查看各状态完整值';
510
+ propCell.addEventListener('click', (e) => {
511
+ if (e.target.closest('.em-cancel')) return;
512
+ this._openPropDetail(ctrl, prop, selIdx);
513
+ });
514
+ if (opts.onCancel) {
515
+ const btn = document.createElement('button');
516
+ btn.className = 'nb-btn btn-small em-cancel';
517
+ btn.textContent = '✕';
518
+ btn.title = '取消跟随该属性';
519
+ btn.addEventListener('click', (e) => { e.stopPropagation(); opts.onCancel(prop); });
520
+ propCell.appendChild(btn);
521
+ }
522
+
523
+ states.forEach(state => {
524
+ const cellFrag = document.importNode(this.$tplMatrixCell.content, true);
525
+ const td = cellFrag.querySelector('td');
526
+ if (state.index === selIdx) td.classList.add('col-highlight');
527
+ const explicit = prop.valueByState ? prop.valueByState[state.index] : undefined;
528
+ const isGhost = explicit === undefined || explicit === null;
529
+ const val = isGhost ? prop.defaultValue : explicit;
530
+ this._fillCell(td, val, isGhost, prop.variesAcrossStates && !isGhost);
531
+ tr.appendChild(td);
532
+ });
533
+ tbody.appendChild(tr);
534
+
535
+ if (opts.focusPropRef && prop.propRef === opts.focusPropRef) {
536
+ tr.classList.add('row-focus');
537
+ setTimeout(() => { try { tr.scrollIntoView({ block: 'nearest' }); } catch (e) {} }, 0);
538
+ }
539
+ });
540
+ },
541
+
542
+ _gotoState(ctrl, state) {
543
+ const after = () => { this.refreshTopology(); if (this.currentCtrlUuid === ctrl.uuid) this.refreshSnapshot(); };
544
+ if (typeof state.stateId === 'number') {
545
+ this._callScene('set-state-by-id', { uuid: ctrl.uuid, stateId: state.stateId }, after);
546
+ } else {
547
+ this._callScene(SET_INDEX_MSG, { uuid: ctrl.uuid, index: state.index }, after);
548
+ }
549
+ },
550
+
551
+ _reverseHighlight(nodeUuid) {
552
+ if (this.activeView !== 'overview') return;
553
+ const found = this._findMember(nodeUuid);
554
+ if (!found) return;
555
+ if (this.collapsedCtrls[found.ctrl.ctrlId]) { this.collapsedCtrls[found.ctrl.ctrlId] = false; }
556
+ // 避免无限回环: 不再二次 Editor.Selection.select
557
+ this.selMemberUuid = nodeUuid;
558
+ this.matrixCtrl = found.ctrl;
559
+ this.renderTopology();
560
+ const el = this.$topologyTree.querySelector(`.tree-node[data-node-uuid="${nodeUuid}"]`);
561
+ if (el) { try { el.scrollIntoView({ block: 'nearest' }); } catch (e) {} }
562
+ },
563
+
564
+ // 值格渲染: val 可能是 序列化叶子 / 多 ref 组合对象 / 基元
565
+ _fillCell(td, val, isGhost, changed) {
566
+ td.innerHTML = '';
567
+ const wrap = this._renderValue(val);
568
+ if (!wrap) { td.textContent = '—'; td.style.opacity = '.45'; return; }
569
+ if (isGhost) wrap.classList.add('ghost-val');
570
+ if (changed && wrap.classList.contains('val-vector')) wrap.classList.add('changed');
571
+ // 长值(UUID/资源)截断后靠 title 看全
572
+ const full = wrap.textContent || '';
573
+ if (full) td.title = isGhost ? `${full}(未录制, 回退默认)` : full;
574
+ td.appendChild(wrap);
575
+ },
576
+
577
+ _renderValue(val) {
578
+ if (val === undefined || val === null) return null;
579
+ const t = typeof val;
580
+ if (t === 'boolean') {
581
+ const s = document.createElement('span');
582
+ s.className = 'val-bool ' + (val ? 't' : 'f');
583
+ s.textContent = val ? '☑ TRUE' : '☒ FALSE';
584
+ return s;
585
+ }
586
+ if (t === 'number' || t === 'string') {
587
+ const s = document.createElement('span');
588
+ s.className = 'val-vector';
589
+ s.textContent = String(val);
590
+ return s;
591
+ }
592
+ if (t === 'object') {
593
+ const kind = val._t;
594
+ if (kind === 'Color') {
595
+ const wrap = document.createElement('span'); wrap.className = 'val-color';
596
+ const sw = document.createElement('span'); sw.className = 'color-preview';
597
+ sw.style.backgroundColor = this._rgbaCss(val);
598
+ const txt = document.createElement('span'); txt.textContent = this._hex(val);
599
+ wrap.appendChild(sw); wrap.appendChild(txt); return wrap;
600
+ }
601
+ if (kind === 'Asset') {
602
+ const wrap = document.createElement('span'); wrap.className = 'val-image';
603
+ const th = document.createElement('span'); th.className = 'img-thumb'; th.textContent = '🖼';
604
+ const nm = document.createElement('span'); nm.className = 'img-name'; nm.textContent = String(val.id || 'asset');
605
+ wrap.appendChild(th); wrap.appendChild(nm); return wrap;
606
+ }
607
+ const s = document.createElement('span'); s.className = 'val-vector';
608
+ s.textContent = this._tupleText(val);
609
+ return s;
610
+ }
611
+ const s = document.createElement('span'); s.className = 'val-vector'; s.textContent = String(val); return s;
612
+ },
613
+
614
+ _rgbaCss(c) { const a = (typeof c.a === 'number' ? c.a : 255) / 255; return `rgba(${c.r||0}, ${c.g||0}, ${c.b||0}, ${a})`; },
615
+ _hex(c) { const h = (n) => ('0' + (n || 0).toString(16)).slice(-2); return ('#' + h(c.r) + h(c.g) + h(c.b)).toUpperCase(); },
616
+ _tupleText(o) {
617
+ if (o._t === 'Vec2') return `(${o.x}, ${o.y})`;
618
+ if (o._t === 'Vec3') return `(${o.x}, ${o.y}, ${o.z})`;
619
+ if (o._t === 'Quat') return `(${o.x}, ${o.y}, ${o.z}, ${o.w})`;
620
+ if (o._t === 'Size') return `${o.width} × ${o.height}`;
621
+ // 多 ref 组合对象 (如 Position {x,y}): 拼各叶子
622
+ const parts = [];
623
+ for (const k in o) { if (k === '_t') continue; parts.push(this._leafText(o[k])); }
624
+ return `(${parts.join(', ')})`;
625
+ },
626
+ _leafText(v) {
627
+ if (v === null || v === undefined) return '–';
628
+ if (typeof v === 'object') return v._t ? this._tupleText(v) : JSON.stringify(v);
629
+ return String(v);
630
+ },
631
+
632
+ // ============================================================
633
+ // 联动关系图 (支柱 B)
634
+ // ============================================================
635
+ refreshBindings() {
636
+ if (this.activeView !== 'bindings') return;
637
+ this._callScene('list-scene-topology', null, (err, topology) => {
638
+ if (err) { if (!this._initialFetch) Editor.warn(err); return; }
639
+ this.topology = topology || { controllers: [] };
640
+ this.renderBindings();
641
+ });
642
+ },
643
+
644
+ renderBindings() {
645
+ const ctrls = (this.topology && this.topology.controllers) || [];
646
+ const byId = {};
647
+ ctrls.forEach(c => { byId[c.ctrlId] = c; });
648
+ this._ctrlById = byId;
649
+
650
+ // 无控制器: 联动表单不可用, 隐藏表单并把空态文案改为「场景里还没有控制器」
651
+ const noCtrl = !ctrls.length;
652
+ if (this.$bindingsForm) this.$bindingsForm.style.display = noCtrl ? 'none' : '';
653
+ if (noCtrl) {
654
+ this.$bindingsGraph.innerHTML = '';
655
+ const h = this.$bindingsEmpty.querySelector('h3');
656
+ const p = this.$bindingsEmpty.querySelector('p');
657
+ if (h) h.textContent = '场景里还没有控制器';
658
+ if (p) p.textContent = '给节点挂上 StateControllerV2 并接入控制后, 再来这里建立跨控制器联动。';
659
+ this.$bindingsEmpty.style.display = 'flex';
660
+ return;
661
+ }
662
+ const h = this.$bindingsEmpty.querySelector('h3');
663
+ const p = this.$bindingsEmpty.querySelector('p');
664
+ if (h) h.textContent = '还没有任何跨控制器联动';
665
+ if (p) p.textContent = '用上方表单建立「A 切到某状态 → B 自动切到某状态」的声明式联动。';
666
+
667
+ this._fillCtrlOptions(this.$bindSourceCtrl);
668
+ this._fillCtrlOptions(this.$bindTargetCtrl);
669
+ this._fillStateOptions(this.$bindSourceCtrl, this.$bindSourceState);
670
+ this._fillStateOptions(this.$bindTargetCtrl, this.$bindTargetState);
671
+
672
+ this.$bindingsGraph.innerHTML = '';
673
+ let any = false;
674
+ ctrls.forEach(src => {
675
+ const binds = src.bindings || [];
676
+ if (!binds.length) return;
677
+ any = true;
678
+ const cap = document.createElement('div');
679
+ cap.className = 'bind-group-cap';
680
+ cap.textContent = `🎮 ${src.ctrlName || src.ctrlId}`;
681
+ this.$bindingsGraph.appendChild(cap);
682
+ binds.forEach(b => {
683
+ const tgt = byId[b.targetCtrlId];
684
+ const frag = document.importNode(this.$tplBindingEdge.content, true);
685
+ const edge = frag.querySelector('.bind-edge');
686
+ if (!tgt) edge.classList.add('broken');
687
+ frag.querySelector('.bind-src .bind-ctrl').textContent = src.ctrlName || src.ctrlId;
688
+ frag.querySelector('.bind-src .bind-state').textContent = this._stateName(src, b.sourceStateId);
689
+ frag.querySelector('.bind-tgt .bind-ctrl').textContent = tgt ? (tgt.ctrlName || tgt.ctrlId) : `? (id ${b.targetCtrlId})`;
690
+ frag.querySelector('.bind-tgt .bind-state').textContent = tgt ? this._stateName(tgt, b.targetStateId) : `state ${b.targetStateId}`;
691
+ frag.querySelector('.btn-del-binding').addEventListener('click', () => this._removeBinding(src, b));
692
+ this.$bindingsGraph.appendChild(frag);
693
+ });
694
+ });
695
+ this.$bindingsEmpty.style.display = any ? 'none' : 'flex';
696
+ },
697
+
698
+ _stateName(ctrl, stateId) {
699
+ const s = (ctrl.states || []).find(st => st.stateId === stateId);
700
+ return s ? (s.name || `#${s.index + 1}`) : `id ${stateId}`;
701
+ },
702
+
703
+ _fillCtrlOptions(sel) {
704
+ const ctrls = (this.topology && this.topology.controllers) || [];
705
+ const prev = sel.value;
706
+ sel.innerHTML = '';
707
+ ctrls.forEach(c => {
708
+ const o = document.createElement('option');
709
+ o.value = String(c.ctrlId);
710
+ o.textContent = c.ctrlName || `Controller ${c.ctrlId}`;
711
+ sel.appendChild(o);
712
+ });
713
+ if (prev && ctrls.some(c => String(c.ctrlId) === prev)) sel.value = prev;
714
+ },
715
+
716
+ _fillStateOptions(ctrlSel, stateSel) {
717
+ const ctrl = this._ctrlById && this._ctrlById[ctrlSel.value];
718
+ const prev = stateSel.value;
719
+ stateSel.innerHTML = '';
720
+ if (!ctrl) return;
721
+ (ctrl.states || []).forEach(s => {
722
+ const o = document.createElement('option');
723
+ o.value = String(s.stateId);
724
+ o.textContent = s.name || `#${s.index + 1}`;
725
+ stateSel.appendChild(o);
726
+ });
727
+ if (prev && (ctrl.states || []).some(s => String(s.stateId) === prev)) stateSel.value = prev;
728
+ },
729
+
730
+ _addBindingFromForm() {
731
+ const srcId = Number(this.$bindSourceCtrl.value);
732
+ const tgtId = Number(this.$bindTargetCtrl.value);
733
+ const sStateId = Number(this.$bindSourceState.value);
734
+ const tStateId = Number(this.$bindTargetState.value);
735
+ const src = this._ctrlById && this._ctrlById[srcId];
736
+ if (!src || isNaN(srcId) || isNaN(tgtId) || isNaN(sStateId) || isNaN(tStateId)) {
737
+ Editor.warn('联动参数不完整, 请确认场景里有控制器与状态');
738
+ return;
739
+ }
740
+ this._callScene('add-binding', { uuid: src.uuid, sourceStateId: sStateId, targetCtrlId: tgtId, targetStateId: tStateId }, (err) => {
741
+ if (err) Editor.warn(err);
742
+ this.refreshBindings();
743
+ });
744
+ },
745
+
746
+ _removeBinding(src, b) {
747
+ this._callScene('remove-binding', { uuid: src.uuid, sourceStateId: b.sourceStateId, targetCtrlId: b.targetCtrlId }, (err) => {
748
+ if (err) Editor.warn(err);
749
+ this.refreshBindings();
750
+ });
751
+ },
752
+
753
+ // ============================================================
754
+ // 编辑视图 (保留既有能力)
755
+ // ============================================================
756
+ _fetchInspectorFlags() {
757
+ Editor.Ipc.sendToMain('state-controller-v2-panel:inspector-get-flags', (err, flags) => {
758
+ if (err) { Editor.warn('Failed to get inspector flags:', err); return; }
759
+ if (flags) {
760
+ this.$chkInspectorMaster.checked = !!flags.master;
761
+ this.$chkInspectorViz.checked = !!flags.viz;
762
+ this.$chkInspectorDirty.checked = !!flags.dirty;
763
+ this.$chkInspectorExclude.checked = !!flags.exclude;
764
+ this._updateInspectorSubToggles();
765
+ }
766
+ });
767
+ },
768
+ _updateInspectorSubToggles() {
769
+ const on = this.$chkInspectorMaster.checked;
770
+ this.$chkInspectorViz.disabled = !on;
771
+ this.$chkInspectorDirty.disabled = !on;
772
+ this.$chkInspectorExclude.disabled = !on;
773
+ this.$inspectorSubToggles.classList.toggle('is-disabled', !on);
774
+ },
775
+ _syncInspectorSubFlags() {
776
+ Editor.Ipc.sendToMain('state-controller-v2-panel:inspector-set-flags', {
777
+ master: true,
778
+ viz: !!this.$chkInspectorViz.checked,
779
+ dirty: !!this.$chkInspectorDirty.checked,
780
+ exclude: !!this.$chkInspectorExclude.checked,
781
+ });
782
+ },
783
+ _setRecording(isRecording) {
784
+ if (!this.currentCtrlUuid || !this.currentSnapshot) return;
785
+ this._callScene('set-recording', { uuid: this.currentCtrlUuid, isRecording }, (err) => {
786
+ if (err) Editor.warn(err);
787
+ this.refreshSnapshot();
788
+ });
789
+ },
790
+ _stepCtrl(step) {
791
+ if (!this.ctrlItems.length) return;
792
+ const now = this.ctrlItems.findIndex(item => item.uuid === this.currentCtrlUuid);
793
+ const base = now >= 0 ? now : 0;
794
+ const next = (base + step + this.ctrlItems.length) % this.ctrlItems.length;
795
+ this.setCurrentCtrl(this.ctrlItems[next].uuid, true);
796
+ },
797
+ _stepState() {
798
+ if (!this.currentCtrlUuid || !this.currentSnapshot) return;
799
+ const states = this.currentSnapshot.states || [];
800
+ if (!states.length) return;
801
+ const activeIndex = this._activeIndex(this.currentSnapshot);
802
+ const pos = states.findIndex(state => state.index === activeIndex);
803
+ const next = states[(pos + 1 + states.length) % states.length];
804
+ if (!next) return;
805
+ this._goState(next);
806
+ },
807
+ refreshCtrlList() {
808
+ if (this.activeView !== 'editor') return;
809
+ this._callScene('list-ctrls', null, (err, list) => {
810
+ if (err) { if (!this._initialFetch) Editor.error(err); this._initialFetch = false; return; }
811
+ this._initialFetch = false;
812
+ this.ctrlItems = Array.isArray(list) ? list : [];
813
+ this.$ctrlList.innerHTML = '';
814
+ // 空态文案: 无控制器 → 「场景里还没有控制器」并隐藏「从下方列表选择」提示; 有控制器仅未选中 → 提示从列表选
815
+ const noCtrl = !this.ctrlItems.length;
816
+ if (this.$emptyTipTitle) this.$emptyTipTitle.textContent = noCtrl ? '场景里还没有控制器' : '请在场景中选中带 StateControllerV2 的节点';
817
+ if (this.$ctrlListHint) this.$ctrlListHint.style.display = noCtrl ? 'none' : '';
818
+ if (noCtrl) { this.setCurrentCtrl(null); return; }
819
+ this.ctrlItems.forEach(item => {
820
+ const node = document.importNode(this.$tplCtrlItem.content, true);
821
+ node.querySelector('.ctrl-name').textContent = this._ctrlLabel(item);
822
+ node.querySelector('.btn-use-ctrl').addEventListener('click', () => this.setCurrentCtrl(item.uuid, true));
823
+ this.$ctrlList.appendChild(node);
824
+ });
825
+ if (!this.currentCtrlUuid || !this.ctrlItems.some(item => item.uuid === this.currentCtrlUuid)) {
826
+ this.setCurrentCtrl(this.ctrlItems[0].uuid);
827
+ } else {
828
+ this._renderCtrlHeader();
829
+ }
830
+ });
831
+ },
832
+ setCurrentCtrl(uuid, selectNode) {
833
+ this.currentCtrlUuid = uuid;
834
+ this.currentSnapshot = null;
835
+ if (!uuid) {
836
+ this.$emptyTip.style.display = 'flex';
837
+ this.$stateDetail.style.display = 'none';
838
+ this.$statesList.innerHTML = '';
839
+ this._renderCtrlHeader();
840
+ return;
841
+ }
842
+ // 用户显式切控制器 → 场景里同步选中该控制器节点 (uuid 即节点 uuid)
843
+ if (selectNode) { try { Editor.Selection.select('node', uuid); } catch (e) { /* 静默 */ } }
844
+ this.$emptyTip.style.display = 'none';
845
+ this.$stateDetail.style.display = 'flex';
846
+ this._renderCtrlHeader();
847
+ this.refreshSnapshot();
848
+ },
849
+ refreshSnapshot() {
850
+ if (!this.currentCtrlUuid) return;
851
+ const ctrlUuid = this.currentCtrlUuid;
852
+ this._callScene('get-ctrl-snapshot', { uuid: ctrlUuid }, (err, snapshot) => {
853
+ if (ctrlUuid !== this.currentCtrlUuid) return;
854
+ if (err || !snapshot) { Editor.warn('未能获取 controller 快照: ', err); this.setCurrentCtrl(null); return; }
855
+ this.currentSnapshot = snapshot;
856
+ if (this.activeView === 'editor') {
857
+ this.renderUI();
858
+ this.refreshTopology(); // 矩阵真数据来自 topology, 录制/切状态后必须同步重取
859
+ }
860
+ });
861
+ },
862
+ renderUI() {
863
+ if (!this.currentSnapshot) return;
864
+ this._renderCtrlHeader();
865
+ this.renderPreviewBanner();
866
+ this.renderStates();
867
+ this.renderBin();
868
+ this.renderDetail();
869
+ this.renderEditorMatrix();
870
+ },
871
+ // 预览中 = 锁定结构操作 (同录制): 状态切换/新增/删除/恢复/录制 全锁, 唯一出口是横幅
872
+ _isLocked(snap) {
873
+ return !!(snap && (snap.isRecording || (typeof snap.previewingStateId === 'number' && snap.previewingStateId >= 0)));
874
+ },
875
+ _previewingId(snap) {
876
+ return (snap && typeof snap.previewingStateId === 'number') ? snap.previewingStateId : -1;
877
+ },
878
+ renderPreviewBanner() {
879
+ const snap = this.currentSnapshot;
880
+ const id = this._previewingId(snap);
881
+ if (id < 0) { this.$previewBanner.style.display = 'none'; return; }
882
+ const item = (snap.deletedStates || []).find(d => d.stateId === id);
883
+ this.$pvName.textContent = item ? `「${item.name}」(id ${id})` : `id ${id}`;
884
+ this.$previewBanner.style.display = 'flex';
885
+ },
886
+ renderStates() {
887
+ const snap = this.currentSnapshot;
888
+ const states = snap.states || [];
889
+ const activeIndex = this._activeIndex(snap);
890
+ const locked = this._isLocked(snap);
891
+ this.$statesList.innerHTML = '';
892
+ this.$statesList.classList.toggle('locked', locked);
893
+ this.$btnAddState.disabled = locked;
894
+ this.$btnAddState.classList.toggle('is-disabled', locked);
895
+ states.forEach(state => {
896
+ const node = document.importNode(this.$tplStateItem.content, true);
897
+ const item = node.querySelector('.state-item');
898
+ const isActive = state.index === activeIndex;
899
+ item.classList.toggle('is-active', isActive);
900
+ node.querySelector('.state-name').textContent = state.name || `State ${state.index + 1}`;
901
+ node.querySelector('.state-sub').textContent = typeof state.stateId === 'number' ? `id ${state.stateId}` : `#${state.index + 1}`;
902
+ node.querySelector('.record-dot').style.display = (locked && isActive) ? 'inline' : 'none';
903
+ item.addEventListener('click', (event) => {
904
+ if (event.target.closest('.btn-del-state')) return;
905
+ if (isActive) return;
906
+ this._goState(state);
907
+ });
908
+ const delBtn = node.querySelector('.btn-del-state');
909
+ delBtn.disabled = locked;
910
+ delBtn.classList.toggle('is-disabled', locked);
911
+ delBtn.addEventListener('click', (event) => {
912
+ event.stopPropagation();
913
+ if (locked) return;
914
+ const ok = typeof confirm === 'function'
915
+ ? confirm(`移除状态「${state.name || state.index}」?数据会移入回收站,可恢复。`)
916
+ : true;
917
+ if (!ok) return;
918
+ this._callScene('remove-state', { uuid: this.currentCtrlUuid, index: state.index }, (err) => {
919
+ if (err) Editor.warn(err);
920
+ this.refreshSnapshot();
921
+ });
922
+ });
923
+ this.$statesList.appendChild(node);
924
+ });
925
+ },
926
+ // 回收站可操作前置: 有控制器/快照, 且不在录制 (录制中锁结构操作, 与 states 列表一致)
927
+ _binActionable() {
928
+ return !!(this.currentCtrlUuid && this.currentSnapshot && !this.currentSnapshot.isRecording);
929
+ },
930
+ renderBin() {
931
+ const snap = this.currentSnapshot;
932
+ const deleted = (snap && snap.deletedStates) || [];
933
+ const locked = this._isLocked(snap);
934
+ const previewingId = this._previewingId(snap);
935
+ this.$binCount.textContent = String(deleted.length);
936
+ // 空回收站: 折叠并隐藏整个区块, 不占视觉
937
+ this.$recycleBin.style.display = deleted.length ? '' : 'none';
938
+ this.$recycleBin.classList.toggle('locked', locked);
939
+ this.$binList.innerHTML = '';
940
+ deleted.forEach(item => {
941
+ const frag = document.importNode(this.$tplBinItem.content, true);
942
+ frag.querySelector('.bin-name').textContent = item.name || `#${item.stateId}`;
943
+ frag.querySelector('.bin-sub').textContent = `id ${item.stateId}`;
944
+ const previewBtn = frag.querySelector('.btn-bin-preview');
945
+ const restoreBtn = frag.querySelector('.btn-bin-restore');
946
+ const purgeBtn = frag.querySelector('.btn-bin-purge');
947
+ const isPreviewingThis = previewingId === item.stateId;
948
+ // 预览按钮: 未预览时可点(开预览); 正在预览这一项则高亮且可点=退出预览; 锁定(录制)时禁用
949
+ previewBtn.disabled = !!(snap && snap.isRecording);
950
+ previewBtn.classList.toggle('is-active', isPreviewingThis);
951
+ previewBtn.title = isPreviewingThis ? '退出预览' : '只读预览: 把该状态叠加到节点查看 (不改当前选中)';
952
+ previewBtn.addEventListener('click', () => {
953
+ if (!this.currentCtrlUuid || (snap && snap.isRecording)) return;
954
+ const msg = isPreviewingThis ? 'exit-preview' : 'preview-deleted-state';
955
+ const payload = isPreviewingThis
956
+ ? { uuid: this.currentCtrlUuid }
957
+ : { uuid: this.currentCtrlUuid, stateId: item.stateId };
958
+ this._callScene(msg, payload, () => this.refreshSnapshot());
959
+ });
960
+ // 恢复/彻底删除: 预览中也锁 (出口走横幅), 录制中也锁
961
+ restoreBtn.disabled = locked;
962
+ purgeBtn.disabled = locked;
963
+ restoreBtn.addEventListener('click', () => {
964
+ if (!this._binActionable()) return;
965
+ this._callScene('restore-deleted-state', { uuid: this.currentCtrlUuid, stateId: item.stateId }, (err, ok) => {
966
+ if (err) Editor.warn(err);
967
+ if (!ok) Editor.warn('恢复失败');
968
+ this.refreshSnapshot();
969
+ });
970
+ });
971
+ purgeBtn.addEventListener('click', () => {
972
+ if (!this._binActionable()) return;
973
+ this._confirm({
974
+ title: '彻底删除?',
975
+ body: `将彻底删除状态「${item.name || item.stateId}」(id ${item.stateId}) 的数据,<b>不可恢复</b>。`,
976
+ confirmLabel: '彻底删除',
977
+ }, () => {
978
+ this._callScene('purge-deleted-state', { uuid: this.currentCtrlUuid, stateId: item.stateId }, (err) => {
979
+ if (err) Editor.warn(err);
980
+ this.refreshSnapshot();
981
+ });
982
+ });
983
+ });
984
+ this.$binList.appendChild(frag);
985
+ });
986
+ },
987
+ // 自定义确认弹窗: opts={title, body(html), confirmLabel}, onConfirm 在点确认时回调
988
+ _confirm(opts, onConfirm) {
989
+ this._confirmCb = onConfirm;
990
+ this.$cmTitle.textContent = (opts && opts.title) || '确认';
991
+ this.$cmBody.innerHTML = (opts && opts.body) || '';
992
+ this.$cmConfirm.textContent = (opts && opts.confirmLabel) || '确认';
993
+ this.$confirmModal.style.display = 'block';
994
+ },
995
+ _closeConfirm() {
996
+ this._confirmCb = null;
997
+ this.$confirmModal.style.display = 'none';
998
+ },
999
+ renderDetail() {
1000
+ const snap = this.currentSnapshot;
1001
+ const states = snap.states || [];
1002
+ const activeIndex = this._activeIndex(snap);
1003
+ const activeState = states.find(state => state.index === activeIndex);
1004
+ const name = activeState ? activeState.name : '--';
1005
+ const recording = !!snap.isRecording;
1006
+ const sel = this.$statePickSelect;
1007
+ sel.innerHTML = '';
1008
+ states.forEach(s => {
1009
+ const o = document.createElement('option');
1010
+ o.value = String(s.index);
1011
+ o.textContent = s.name || `#${s.index + 1}`;
1012
+ sel.appendChild(o);
1013
+ });
1014
+ if (states.length) sel.value = String(activeIndex);
1015
+ this.$stateTitle.textContent = name;
1016
+ this.$recordBadge.textContent = recording ? 'Recording' : 'Idle';
1017
+ this.$recordBadge.classList.toggle('is-live', recording);
1018
+ this.$btnStartRecord.style.display = recording ? 'none' : 'inline-flex';
1019
+ this.$btnStopRecord.style.display = recording ? 'inline-flex' : 'none';
1020
+ this.$btnCancelRecord.style.display = recording ? 'inline-flex' : 'none';
1021
+ // 预览中禁止开录 (开录会自动退出预览, UI 上直接锁更清晰)
1022
+ const previewing = this._previewingId(snap) >= 0;
1023
+ this.$btnStartRecord.disabled = previewing;
1024
+ this.$btnStartRecord.classList.toggle('is-disabled', previewing);
1025
+ },
1026
+ // 当前控制器在 topology 里的节点 (含 members/states/valueByState 真数据)
1027
+ _currentTopologyCtrl() {
1028
+ const ctrls = (this.topology && this.topology.controllers) || [];
1029
+ return ctrls.find(c => c.uuid === this.currentCtrlUuid) || null;
1030
+ },
1031
+ // 单控制器主区: 复用 topology 真数据, 按 member 铺 属性×状态 diff 矩阵
1032
+ renderEditorMatrix() {
1033
+ const host = this.$editorMatrix;
1034
+ if (!host) return;
1035
+ host.innerHTML = '';
1036
+ const ctrl = this._currentTopologyCtrl();
1037
+ if (!ctrl) {
1038
+ // topology 尚未到达; refreshTopology 回调会再次渲染
1039
+ if (!this.topology) this.refreshTopology();
1040
+ host.innerHTML = '<div class="prop-empty">正在加载受控属性…</div>';
1041
+ return;
1042
+ }
1043
+ // 选中状态以 snapshot 为权威 (避免 topology/snapshot selectedIndex 短暂错位高亮错列)
1044
+ const selIdx = this.currentSnapshot ? this.currentSnapshot.selectedIndex : ctrl.selectedIndex;
1045
+ // 默认只显示「存在跨状态变动」的属性; 勾选则显示全部已受控 (含各状态同值的)
1046
+ const showAll = !!(this.$chkShowAllProps && this.$chkShowAllProps.checked);
1047
+ const pick = showAll ? (p => this._propHasData(p)) : (p => p.variesAcrossStates);
1048
+ const members = (ctrl.members || [])
1049
+ .map(m => ({ m, props: (m.props || []).filter(pick) }))
1050
+ .filter(x => x.props.length);
1051
+ const hasAnyFollowed = (ctrl.members || []).some(m => (m.props || []).some(p => this._propHasData(p)));
1052
+ if (!members.length) {
1053
+ host.innerHTML = !hasAnyFollowed
1054
+ ? '<div class="prop-empty">该控制器还没有受控属性。点「🔴 录制」后在场景里改属性即可自动跟随。</div>'
1055
+ : '<div class="prop-empty">已跟随的属性在各状态值相同, 暂无跨状态变动。勾选上方「显示全部受控属性」可查看并取消跟随。</div>';
1056
+ return;
1057
+ }
1058
+ members.forEach(({ m: member, props }) => {
1059
+ const block = document.createElement('div');
1060
+ block.className = 'em-member';
1061
+
1062
+ const title = document.createElement('div');
1063
+ title.className = 'em-member-title';
1064
+ title.textContent = `📦 ${member.nodeName || '(node)'}`;
1065
+ title.title = member.nodePath || '点击在场景中选中';
1066
+ title.addEventListener('click', () => { try { Editor.Selection.select('node', member.nodeUuid); } catch (e) { /* 静默 */ } });
1067
+ block.appendChild(title);
1068
+
1069
+ const scroll = document.createElement('div');
1070
+ scroll.className = 'matrix-scroll em-scroll';
1071
+ const table = document.createElement('table');
1072
+ table.className = 'matrix-table';
1073
+ const thead = document.createElement('thead');
1074
+ const headTr = document.createElement('tr');
1075
+ thead.appendChild(headTr);
1076
+ const tbody = document.createElement('tbody');
1077
+ table.appendChild(thead);
1078
+ table.appendChild(tbody);
1079
+ scroll.appendChild(table);
1080
+ block.appendChild(scroll);
1081
+
1082
+ this._buildMatrix(headTr, tbody, ctrl, props, {
1083
+ selectedIndex: selIdx,
1084
+ onCancel: (prop) => this._cancelFollow(prop, member),
1085
+ });
1086
+ host.appendChild(block);
1087
+ });
1088
+ },
1089
+ // 该 prop 是否真被某状态显式录过 (任一状态 valueByState 有非空值)
1090
+ _propHasData(prop) {
1091
+ const vbs = prop.valueByState;
1092
+ if (!vbs) return false;
1093
+ for (const k in vbs) { if (vbs[k] !== undefined && vbs[k] !== null) return true; }
1094
+ return false;
1095
+ },
1096
+ // 取消跟随: 对该 prop 的每个 leaf ref 调 remove-property (togglePropertyControl 接受 propRef 字符串)
1097
+ _cancelFollow(prop, member) {
1098
+ if (!this.currentCtrlUuid) return;
1099
+ const refs = (prop.refs && prop.refs.length) ? prop.refs : [prop.propRef];
1100
+ let pending = refs.length;
1101
+ refs.forEach(ref => {
1102
+ const payload = { ctrlUuid: this.currentCtrlUuid, propType: ref };
1103
+ payload[NODE_UUID_KEY] = member.nodeUuid;
1104
+ this._callScene('remove-property', payload, (err) => {
1105
+ if (err) Editor.warn(err);
1106
+ if (--pending === 0) { this.refreshTopology(); this.refreshSnapshot(); }
1107
+ });
1108
+ });
1109
+ },
1110
+ // 属性详情抽屉: 逐状态铺完整值 (大色块/资源全 id/向量分轴), 不截断
1111
+ _openPropDetail(ctrl, prop, selIdx) {
1112
+ this.$pdName.textContent = prop.display || '';
1113
+ this.$pdType.textContent = prop.compName || '';
1114
+ const sel = (selIdx !== undefined) ? selIdx : ctrl.selectedIndex;
1115
+ const body = this.$pdBody;
1116
+ body.innerHTML = '';
1117
+ (ctrl.states || []).forEach(state => {
1118
+ const row = document.createElement('div');
1119
+ row.className = 'pd-state';
1120
+ if (state.index === sel) row.classList.add('is-current');
1121
+
1122
+ const label = document.createElement('div');
1123
+ label.className = 'pd-state-name';
1124
+ label.textContent = state.name || `#${state.index + 1}`;
1125
+
1126
+ const valBox = document.createElement('div');
1127
+ valBox.className = 'pd-state-val';
1128
+ const explicit = prop.valueByState ? prop.valueByState[state.index] : undefined;
1129
+ const isGhost = explicit === undefined || explicit === null;
1130
+ const val = isGhost ? prop.defaultValue : explicit;
1131
+ const v = this._renderValue(val);
1132
+ if (v) { if (isGhost) v.classList.add('ghost-val'); valBox.appendChild(v); }
1133
+ else valBox.textContent = '—';
1134
+ if (isGhost) {
1135
+ const tag = document.createElement('span');
1136
+ tag.className = 'pd-ghost-tag';
1137
+ tag.textContent = '未录制 · 默认';
1138
+ valBox.appendChild(tag);
1139
+ }
1140
+ row.appendChild(label);
1141
+ row.appendChild(valBox);
1142
+ body.appendChild(row);
1143
+ });
1144
+ this.$propDetail.style.display = 'block';
1145
+ },
1146
+ _closePropDetail() { this.$propDetail.style.display = 'none'; },
1147
+ _goState(state) {
1148
+ if (!state || !this.currentCtrlUuid) return;
1149
+ if (typeof state.stateId === 'number') {
1150
+ this._callScene('set-state-by-id', { uuid: this.currentCtrlUuid, stateId: state.stateId }, () => this.refreshSnapshot());
1151
+ return;
1152
+ }
1153
+ this._callScene(SET_INDEX_MSG, { uuid: this.currentCtrlUuid, index: state.index }, () => this.refreshSnapshot());
1154
+ },
1155
+ _activeIndex(snap) { return typeof snap[SNAP_INDEX_KEY] === 'number' ? snap[SNAP_INDEX_KEY] : 0; },
1156
+ _ctrlLabel(item) { if (!item) return '未连接'; return item.ctrlName || `Controller ${item.ctrlId}`; },
1157
+ _renderCtrlHeader() {
1158
+ const sel = this.$ctrlSwitchSelect;
1159
+ const count = this.ctrlItems.length;
1160
+ sel.innerHTML = '';
1161
+ if (!count) {
1162
+ const o = document.createElement('option');
1163
+ o.textContent = '未连接';
1164
+ sel.appendChild(o);
1165
+ } else {
1166
+ this.ctrlItems.forEach(item => {
1167
+ const o = document.createElement('option');
1168
+ o.value = item.uuid;
1169
+ o.textContent = this._ctrlLabel(item);
1170
+ sel.appendChild(o);
1171
+ });
1172
+ if (this.currentCtrlUuid) sel.value = this.currentCtrlUuid;
1173
+ }
1174
+ this.$btnPrevCtrl.disabled = count <= 1;
1175
+ this.$btnNextCtrl.disabled = count <= 1;
1176
+ },
1177
+
1178
+ // ============================================================
1179
+ // 主进程 / scene-script 广播
1180
+ // ============================================================
1181
+ messages: {
1182
+ 'scene:reloaded'() { this.refreshTopology(); this.refreshBindings(); this.refreshCtrlList(); },
1183
+ 'state-controller-v2-panel:scene-ready'() { this.refreshTopology(); this.refreshBindings(); this.refreshCtrlList(); },
1184
+ 'state-controller-v2-panel:on-state-changed'(event, payload) {
1185
+ this.refreshTopology();
1186
+ this.refreshBindings();
1187
+ if (this._isActivePayload(payload)) this.refreshSnapshot();
1188
+ },
1189
+ 'state-controller-v2-panel:on-recording-changed'(event, payload) {
1190
+ this.refreshTopology();
1191
+ if (this._isActivePayload(payload)) this.refreshSnapshot();
1192
+ },
1193
+ 'state-controller-v2-panel:on-recording-cancelled'(event, payload) {
1194
+ this.refreshTopology();
1195
+ if (this._isActivePayload(payload)) this.refreshSnapshot();
1196
+ },
1197
+ 'state-controller-v2-panel:on-data-changed'(event, payload) {
1198
+ this.refreshTopology();
1199
+ this.refreshBindings();
1200
+ if (this._isActivePayload(payload)) this.refreshSnapshot();
1201
+ },
1202
+ },
1203
+ _isActivePayload(payload) {
1204
+ if (!payload || !this.currentSnapshot) return true;
1205
+ return payload.ctrlId === this.currentSnapshot.ctrlId;
1206
+ },
1207
+ };