@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.
- package/LICENSE +21 -0
- package/README.md +287 -0
- package/assets/script/controller/Capability.ts +100 -0
- package/assets/script/controller/Capability.ts.meta +10 -0
- package/assets/script/controller/CapabilityRegistry.ts +116 -0
- package/assets/script/controller/CapabilityRegistry.ts.meta +10 -0
- package/assets/script/controller/EnumPropRefMap.ts +232 -0
- package/assets/script/controller/EnumPropRefMap.ts.meta +10 -0
- package/assets/script/controller/NestedCtrlData.ts +199 -0
- package/assets/script/controller/NestedCtrlData.ts.meta +10 -0
- package/assets/script/controller/PrefabIntrospection.ts +151 -0
- package/assets/script/controller/PrefabIntrospection.ts.meta +10 -0
- package/assets/script/controller/Props.meta +13 -0
- package/assets/script/controller/StateControllerV2.ts +1957 -0
- package/assets/script/controller/StateControllerV2.ts.meta +10 -0
- package/assets/script/controller/StateEnumV2.ts +165 -0
- package/assets/script/controller/StateEnumV2.ts.meta +10 -0
- package/assets/script/controller/StateErrorManagerV2.ts +217 -0
- package/assets/script/controller/StateErrorManagerV2.ts.meta +10 -0
- package/assets/script/controller/StatePropHandlerV2.ts +316 -0
- package/assets/script/controller/StatePropHandlerV2.ts.meta +10 -0
- package/assets/script/controller/StatePropertyControlService.ts +148 -0
- package/assets/script/controller/StatePropertyControlService.ts.meta +10 -0
- package/assets/script/controller/StateSelectV2.ts +4542 -0
- package/assets/script/controller/StateSelectV2.ts.meta +10 -0
- package/assets/script/controller/capabilities/AutoSyncCapability.ts +30 -0
- package/assets/script/controller/capabilities/AutoSyncCapability.ts.meta +10 -0
- package/assets/script/controller/capabilities/EventCapability.ts +144 -0
- package/assets/script/controller/capabilities/EventCapability.ts.meta +10 -0
- package/assets/script/controller/capabilities/MigrationCapability.ts +94 -0
- package/assets/script/controller/capabilities/MigrationCapability.ts.meta +10 -0
- package/assets/script/controller/capabilities/MultiCtrlBindingCapability.ts +157 -0
- package/assets/script/controller/capabilities/MultiCtrlBindingCapability.ts.meta +10 -0
- package/assets/script/controller/capabilities/PropertyControlCapability.ts +124 -0
- package/assets/script/controller/capabilities/PropertyControlCapability.ts.meta +10 -0
- package/assets/script/controller/capabilities/RecordingCapability.ts +69 -0
- package/assets/script/controller/capabilities/RecordingCapability.ts.meta +10 -0
- package/assets/script/controller/capabilities/SelectedPageIdCapability.ts +88 -0
- package/assets/script/controller/capabilities/SelectedPageIdCapability.ts.meta +10 -0
- package/assets/script/controller/capabilities.meta +13 -0
- package/assets/script/controller/props/CtrlInspectorGroups.ts +138 -0
- package/assets/script/controller/props/CtrlInspectorGroups.ts.meta +10 -0
- package/assets/script/controller/props/SelectInspectorGroups.ts +104 -0
- package/assets/script/controller/props/SelectInspectorGroups.ts.meta +10 -0
- package/bin/csc.js +286 -0
- package/package.json +60 -0
- package/packages/state-controller-v2-panel/README.md +80 -0
- package/packages/state-controller-v2-panel/inspector-inject.js +917 -0
- package/packages/state-controller-v2-panel/inspector-probe.json +3767 -0
- package/packages/state-controller-v2-panel/lib/handlers.js +534 -0
- package/packages/state-controller-v2-panel/main.js +149 -0
- package/packages/state-controller-v2-panel/package.json +32 -0
- package/packages/state-controller-v2-panel/panel/build.js +23 -0
- package/packages/state-controller-v2-panel/panel/logic.js +1207 -0
- package/packages/state-controller-v2-panel/panel/styles.css +454 -0
- package/packages/state-controller-v2-panel/panel/template.html +296 -0
- package/packages/state-controller-v2-panel/scene-accessor.js +657 -0
- package/skills/cocos-state-controller/SKILL.md +28 -0
- package/skills/cocos-state-controller/refs/cli-usage.md +78 -0
- package/skills/cocos-state-controller/refs/editor-guide.md +127 -0
- package/skills/cocos-state-controller/refs/migrate.md +106 -0
- package/skills/cocos-state-controller/refs/upstream-pr.md +66 -0
- package/tools/migration/migrate-prefab-v1-to-v2.js +608 -0
- 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
|
+
};
|