@dcl-regenesislabs/opendcl 0.2.1-26165320302.commit-e6effe4 → 0.2.1-26238928766.commit-28648d7

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 (45) hide show
  1. package/README.md +5 -3
  2. package/context/sdk7-cheat-sheet.md +4 -0
  3. package/dist/index.js +0 -12
  4. package/dist/index.js.map +1 -1
  5. package/extensions/dcl-init.ts +58 -6
  6. package/package.json +3 -3
  7. package/prompts/system.md +71 -41
  8. package/skills/add-3d-models/SKILL.md +120 -70
  9. package/skills/add-interactivity/SKILL.md +74 -2
  10. package/skills/advanced-input/SKILL.md +34 -1
  11. package/skills/advanced-rendering/SKILL.md +82 -9
  12. package/skills/animations-tweens/SKILL.md +203 -98
  13. package/skills/audio-analysis/SKILL.md +164 -0
  14. package/skills/audio-video/SKILL.md +184 -83
  15. package/skills/build-ui/SKILL.md +25 -2
  16. package/skills/camera-control/SKILL.md +78 -7
  17. package/skills/create-scene/SKILL.md +56 -13
  18. package/skills/deploy-scene/SKILL.md +12 -0
  19. package/skills/deploy-worlds/SKILL.md +35 -0
  20. package/skills/editor-gizmo/.gitignore +11 -0
  21. package/skills/editor-gizmo/SKILL.md +222 -0
  22. package/skills/editor-gizmo/src/__editor/camera.ts +277 -0
  23. package/skills/editor-gizmo/src/__editor/discovery.ts +186 -0
  24. package/skills/editor-gizmo/src/__editor/drag.ts +265 -0
  25. package/skills/editor-gizmo/src/__editor/gizmo.ts +496 -0
  26. package/skills/editor-gizmo/src/__editor/history.ts +72 -0
  27. package/skills/editor-gizmo/src/__editor/index.ts +137 -0
  28. package/skills/editor-gizmo/src/__editor/input.ts +55 -0
  29. package/skills/editor-gizmo/src/__editor/math-utils.ts +114 -0
  30. package/skills/editor-gizmo/src/__editor/persistence.ts +113 -0
  31. package/skills/editor-gizmo/src/__editor/selection.ts +157 -0
  32. package/skills/editor-gizmo/src/__editor/state.ts +117 -0
  33. package/skills/editor-gizmo/src/__editor/ui.tsx +697 -0
  34. package/skills/game-design/SKILL.md +1 -2
  35. package/skills/lighting-environment/SKILL.md +103 -56
  36. package/skills/multiplayer-sync/SKILL.md +31 -2
  37. package/skills/nft-blockchain/SKILL.md +45 -40
  38. package/skills/npcs/SKILL.md +180 -0
  39. package/skills/optimize-scene/SKILL.md +7 -2
  40. package/skills/particle-system/SKILL.md +222 -0
  41. package/skills/player-avatar/SKILL.md +133 -7
  42. package/skills/player-physics/SKILL.md +93 -0
  43. package/skills/scene-runtime/SKILL.md +9 -5
  44. package/skills/visual-feedback/SKILL.md +1 -0
  45. package/extensions/dcl-setup-ollama.ts +0 -312
@@ -0,0 +1,697 @@
1
+ import { Entity, Transform, VisibilityComponent } from '@dcl/sdk/ecs'
2
+ import { Color4, Quaternion } from '@dcl/sdk/math'
3
+ import { isWeb } from '@dcl/sdk/platform'
4
+ import ReactEcs, { ReactEcsRenderer, UiEntity, Label } from '@dcl/sdk/react-ecs'
5
+ import { getExplorerInformation } from '~system/Runtime'
6
+ import { toggleEditorCamera, focusSelectedEntity } from './camera'
7
+ import { createGizmo } from './gizmo'
8
+ import { undoCount, redoCount, undo, redo } from './history'
9
+ import { selectEntity, deselectEntity } from './selection'
10
+ import { state, selectableInfoMap, toggleEditorActive } from './state'
11
+
12
+ // ── Platform detection ──────────────────────────────────
13
+
14
+ let isBevy = false
15
+ void getExplorerInformation({}).then($ => { isBevy = $.agent === 'bevy' }).catch(() => {})
16
+
17
+ // ── Icons (Lucide via Iconify CDN) ──────────────────────
18
+
19
+ const IC = 'https://api.iconify.design/lucide'
20
+ const ICON = {
21
+ select: `${IC}/mouse-pointer.svg?color=white&width=64&height=64`,
22
+ move: `${IC}/move.svg?color=white&width=64&height=64`,
23
+ rotate: `${IC}/rotate-cw.svg?color=white&width=64&height=64`,
24
+ undo: `${IC}/undo-2.svg?color=white&width=64&height=64`,
25
+ redo: `${IC}/redo-2.svg?color=white&width=64&height=64`,
26
+ camera: `${IC}/video.svg?color=white&width=64&height=64`,
27
+ focus: `${IC}/crosshair.svg?color=white&width=64&height=64`,
28
+ edit: `${IC}/pencil.svg?color=white&width=64&height=64`,
29
+ chevUp: `${IC}/chevron-up.svg?color=white&width=64&height=64`,
30
+ chevDown: `${IC}/chevron-down.svg?color=white&width=64&height=64`,
31
+ model: `${IC}/box.svg?color=white&width=64&height=64`,
32
+ primitive: `${IC}/diamond.svg?color=white&width=64&height=64`,
33
+ help: `${IC}/circle-help.svg?color=white&width=64&height=64`,
34
+ eyeOn: `${IC}/eye.svg?color=white&width=64&height=64`,
35
+ eyeOff: `${IC}/eye-off.svg?color=white&width=64&height=64`,
36
+ }
37
+
38
+ // Unicode fallbacks for Unity renderer
39
+ const UNI = {
40
+ select: '⊙',
41
+ move: '✥',
42
+ rotate: '↻',
43
+ undo: '↶',
44
+ redo: '↷',
45
+ camera: '◉',
46
+ focus: '◎',
47
+ edit: '✏',
48
+ chevUp: '▲',
49
+ chevDown: '▼',
50
+ model: '▣',
51
+ primitive: '◇',
52
+ help: '?',
53
+ eyeOn: '◉',
54
+ eyeOff: '○',
55
+ }
56
+
57
+ /** Render an icon — URL texture on Bevy-web, Unicode label on Unity */
58
+ function Icon(props: { icon: keyof typeof ICON, size: number, color: Color4 }) {
59
+ if (isWeb() || isBevy) {
60
+ return (
61
+ <UiEntity
62
+ uiTransform={{ width: props.size, height: props.size }}
63
+ uiBackground={{
64
+ textureMode: 'stretch',
65
+ texture: { src: ICON[props.icon] },
66
+ color: props.color,
67
+ }}
68
+ />
69
+ )
70
+ }
71
+ return (
72
+ <UiEntity uiTransform={{ width: props.size, height: props.size, justifyContent: 'center', alignItems: 'center' }}>
73
+ <Label
74
+ value={UNI[props.icon]}
75
+ fontSize={props.size}
76
+ color={props.color}
77
+ uiTransform={{ width: props.size, height: props.size }}
78
+ textAlign="middle-center"
79
+ />
80
+ </UiEntity>
81
+ )
82
+ }
83
+
84
+ // ── Theme ───────────────────────────────────────────────
85
+
86
+ const C = {
87
+ // Backgrounds (all fully opaque — partial alpha causes the scene to bleed
88
+ // through and creates apparent two-tone effects on rounded buttons).
89
+ panel: Color4.create(0.10, 0.10, 0.12, 1),
90
+ panelBorder: Color4.create(0.32, 0.32, 0.38, 1),
91
+
92
+ // Buttons
93
+ btn: Color4.create(0.18, 0.18, 0.21, 1),
94
+ btnHover: Color4.create(0.26, 0.26, 0.30, 1),
95
+ btnActive: Color4.create(0.22, 0.42, 0.58, 1),
96
+ btnActiveH: Color4.create(0.28, 0.50, 0.66, 1),
97
+
98
+ // Rows
99
+ rowHover: Color4.create(0.20, 0.20, 0.24, 1),
100
+ rowSel: Color4.create(0.22, 0.42, 0.58, 0.70),
101
+
102
+ // Separators
103
+ sep: Color4.create(0.28, 0.28, 0.32, 0.40),
104
+
105
+ // Text
106
+ text: Color4.create(0.90, 0.90, 0.92, 1),
107
+ textMid: Color4.create(0.62, 0.62, 0.66, 1),
108
+ textDim: Color4.create(0.40, 0.40, 0.44, 1),
109
+ textOff: Color4.create(0.30, 0.30, 0.34, 1),
110
+
111
+ // Axes
112
+ xAxis: Color4.create(0.95, 0.40, 0.40, 1),
113
+ yAxis: Color4.create(0.40, 0.90, 0.40, 1),
114
+ zAxis: Color4.create(0.40, 0.55, 0.95, 1),
115
+
116
+ // Transparent
117
+ none: Color4.create(0, 0, 0, 0),
118
+ }
119
+
120
+ // ── Sizes ───────────────────────────────────────────────
121
+
122
+ const TOOL_SZ = 28
123
+ const PANEL_W = 200
124
+ const ROW_H = 22
125
+ const MAX_ROWS = 12
126
+ const HEADER_H = 26
127
+ const RADIUS_PANEL = 8
128
+ const RADIUS_BTN = 6
129
+
130
+ // ── Interaction ─────────────────────────────────────────
131
+
132
+ let hovered: string | null = null
133
+ let hierScroll = 0
134
+ let hierHov: number | null = null
135
+ let showShortcuts = false
136
+ const hiddenEntities = new Set<Entity>()
137
+
138
+ function toggleVisibility(entity: Entity) {
139
+ const nowHidden = !hiddenEntities.has(entity)
140
+ if (nowHidden) hiddenEntities.add(entity)
141
+ else hiddenEntities.delete(entity)
142
+ VisibilityComponent.createOrReplace(entity, { visible: !nowHidden })
143
+ }
144
+
145
+ // ── Helpers ─────────────────────────────────────────────
146
+
147
+ function pos3() {
148
+ if (state.selectedEntity !== undefined && Transform.has(state.selectedEntity)) {
149
+ const p = Transform.get(state.selectedEntity).position
150
+ return { x: p.x.toFixed(2), y: p.y.toFixed(2), z: p.z.toFixed(2) }
151
+ }
152
+ return { x: '-', y: '-', z: '-' }
153
+ }
154
+
155
+ function rot3() {
156
+ if (state.selectedEntity !== undefined && Transform.has(state.selectedEntity)) {
157
+ const e = Quaternion.toEulerAngles(Transform.get(state.selectedEntity).rotation)
158
+ return { x: e.x.toFixed(1), y: e.y.toFixed(1), z: e.z.toFixed(1) }
159
+ }
160
+ return { x: '-', y: '-', z: '-' }
161
+ }
162
+
163
+ function isHov(id: string) { return hovered === id }
164
+ function setHov(id: string) { hovered = id }
165
+ function clearHov(id: string) { if (hovered === id) hovered = null }
166
+
167
+ // ── Tool Button ─────────────────────────────────────────
168
+
169
+ function toolBg(active: boolean, disabled: boolean, hovering: boolean): Color4 {
170
+ if (disabled) return C.btn
171
+ if (active) return hovering ? C.btnActiveH : C.btnActive
172
+ if (hovering) return C.btnHover
173
+ return C.btn
174
+ }
175
+
176
+ function toolOpacity(active: boolean, disabled: boolean, hovering: boolean): number {
177
+ if (disabled) return 0.25
178
+ if (active) return 1.0
179
+ if (hovering) return 0.8
180
+ return 0.5
181
+ }
182
+
183
+ function Tool(id: string, iconKey: keyof typeof ICON, active: boolean, disabled: boolean, fn: () => void) {
184
+ const h = isHov(id)
185
+ const bg = toolBg(active, disabled, h)
186
+ const opacity = toolOpacity(active, disabled, h)
187
+
188
+ return (
189
+ <UiEntity
190
+ uiTransform={{
191
+ width: TOOL_SZ, height: TOOL_SZ,
192
+ margin: { left: 2, right: 2 },
193
+ justifyContent: 'center', alignItems: 'center',
194
+ borderRadius: RADIUS_BTN,
195
+ }}
196
+ uiBackground={{ color: bg }}
197
+ onMouseEnter={() => setHov(id)}
198
+ onMouseLeave={() => clearHov(id)}
199
+ onMouseDown={() => { if (!disabled) fn() }}
200
+ >
201
+ {Icon({ icon: iconKey, size: 14, color: Color4.create(1, 1, 1, opacity) })}
202
+ </UiEntity>
203
+ )
204
+ }
205
+
206
+ function ToolSep() {
207
+ return (
208
+ <UiEntity
209
+ uiTransform={{ width: 1, height: TOOL_SZ - 8, margin: { left: 3, right: 3 }, alignSelf: 'center' }}
210
+ uiBackground={{ color: C.sep }}
211
+ />
212
+ )
213
+ }
214
+
215
+ // ── Toolbar ─────────────────────────────────────────────
216
+
217
+ function Toolbar(sel: boolean) {
218
+ const mode = state.gizmoMode
219
+ return (
220
+ <UiEntity
221
+ uiTransform={{
222
+ positionType: 'absolute',
223
+ position: { top: 8, left: -8 },
224
+ width: '100%',
225
+ justifyContent: 'center',
226
+ alignItems: 'flex-start',
227
+ flexDirection: 'row',
228
+ }}
229
+ >
230
+ <UiEntity
231
+ uiTransform={{
232
+ padding: { left: 4, right: 4, top: 6, bottom: 6 },
233
+ flexDirection: 'row', alignItems: 'center',
234
+ borderRadius: RADIUS_PANEL,
235
+ }}
236
+ uiBackground={{ color: C.panel }}
237
+ >
238
+ {/* Selection */}
239
+ {Tool('sel', 'select', !sel, false, () => { if (sel) deselectEntity() })}
240
+
241
+ {Tool('mov', 'move', sel && mode === 'translate', !sel, () => { state.gizmoMode = 'translate'; if (sel) createGizmo() })}
242
+ {Tool('rot', 'rotate', sel && mode === 'rotate', !sel, () => { state.gizmoMode = 'rotate'; if (sel) createGizmo() })}
243
+
244
+ {ToolSep()}
245
+
246
+ {/* Undo / Redo */}
247
+ {Tool('und', 'undo', false, undoCount() === 0, () => undo())}
248
+ {Tool('red', 'redo', false, redoCount() === 0, () => redo())}
249
+
250
+ {ToolSep()}
251
+
252
+ {/* Camera */}
253
+ {Tool('cam', 'camera', state.editorCamActive, false, () => toggleEditorCamera())}
254
+ {Tool('foc', 'focus', false, !sel, () => {
255
+ if (!state.editorCamActive) toggleEditorCamera()
256
+ focusSelectedEntity()
257
+ })}
258
+ </UiEntity>
259
+ </UiEntity>
260
+ )
261
+ }
262
+
263
+ // ── Tree ────────────────────────────────────────────────
264
+
265
+ interface TreeRow { e: Entity; name: string; isModel: boolean; depth: number }
266
+
267
+ function buildTree(): TreeRow[] {
268
+ const names = new Map<number, { name: string; isModel: boolean }>()
269
+ const childrenOf = new Map<number, number[]>()
270
+ const rootIds: number[] = []
271
+
272
+ // Only Named entities make it into selectableInfoMap (filtered at
273
+ // discovery), so the hierarchy already excludes runtime-spawned dynamic
274
+ // entities by convention.
275
+ for (const [entity, info] of selectableInfoMap) {
276
+ const id = entity as number
277
+ names.set(id, { name: info.name, isModel: info.isModel })
278
+ const pid = info.parentEntity
279
+ if (pid !== undefined && selectableInfoMap.has(pid as Entity)) {
280
+ const kids = childrenOf.get(pid)
281
+ if (kids) kids.push(id)
282
+ else childrenOf.set(pid, [id])
283
+ } else {
284
+ rootIds.push(id)
285
+ }
286
+ }
287
+
288
+ const byName = (a: number, b: number) => {
289
+ const na = names.get(a)!.name.toLowerCase()
290
+ const nb = names.get(b)!.name.toLowerCase()
291
+ return na < nb ? -1 : na > nb ? 1 : 0
292
+ }
293
+ rootIds.sort(byName)
294
+ for (const [, kids] of childrenOf) kids.sort(byName)
295
+
296
+ const flat: TreeRow[] = []
297
+ const visit = (ids: number[], depth: number) => {
298
+ for (const id of ids) {
299
+ const { name, isModel } = names.get(id)!
300
+ flat.push({ e: id as Entity, name, isModel, depth })
301
+ const kids = childrenOf.get(id)
302
+ if (kids) visit(kids, depth + 1)
303
+ }
304
+ }
305
+ visit(rootIds, 0)
306
+ return flat
307
+ }
308
+
309
+ // ── Hierarchy Panel (top-left) ──────────────────────────
310
+
311
+ function HierarchyPanel() {
312
+ const flat = buildTree()
313
+ const total = flat.length
314
+ const maxScr = Math.max(0, total - MAX_ROWS)
315
+ hierScroll = Math.max(0, Math.min(hierScroll, maxScr))
316
+
317
+ const sel = state.selectedEntity !== undefined
318
+ if (sel) {
319
+ const selIdx = flat.findIndex((n) => n.e === state.selectedEntity)
320
+ if (selIdx >= 0) {
321
+ if (selIdx < hierScroll) hierScroll = selIdx
322
+ else if (selIdx >= hierScroll + MAX_ROWS) hierScroll = selIdx - MAX_ROWS + 1
323
+ }
324
+ }
325
+
326
+ const vis = Math.min(MAX_ROWS, total - hierScroll)
327
+ const canUp = hierScroll > 0
328
+ const canDown = hierScroll < maxScr
329
+
330
+ const rows: ReactEcs.JSX.Element[] = []
331
+ for (let i = 0; i < vis; i++) {
332
+ const node = flat[hierScroll + i]
333
+ const eid = node.e as number
334
+ const isSel = state.selectedEntity === node.e
335
+ const isH = hierHov === eid
336
+
337
+ let bg: Color4
338
+ if (isSel) bg = C.rowSel
339
+ else if (isH) bg = C.rowHover
340
+ else bg = C.none
341
+
342
+ let col: Color4
343
+ if (isSel) col = C.text
344
+ else if (isH) col = C.textMid
345
+ else col = C.textDim
346
+ const pad = 8 + node.depth * 10
347
+ const maxC = Math.max(6, 22 - node.depth * 2)
348
+ const lbl = node.name.length > maxC ? node.name.substring(0, maxC - 1) + '..' : node.name
349
+ const iconKey: keyof typeof ICON = node.isModel ? 'model' : 'primitive'
350
+ const isHidden = hiddenEntities.has(node.e)
351
+
352
+ rows.push(
353
+ <UiEntity
354
+ key={eid}
355
+ uiTransform={{
356
+ width: '100%', height: ROW_H,
357
+ padding: { left: pad, right: 6 },
358
+ alignItems: 'center', flexDirection: 'row',
359
+ borderRadius: 5, margin: { top: 1, bottom: 1 },
360
+ }}
361
+ uiBackground={{ color: bg }}
362
+ onMouseEnter={() => { hierHov = eid }}
363
+ onMouseLeave={() => { if (hierHov === eid) hierHov = null }}
364
+ >
365
+ <UiEntity
366
+ uiTransform={{ flexGrow: 1, flexDirection: 'row', alignItems: 'center', height: ROW_H }}
367
+ onMouseDown={() => selectEntity(eid as Entity)}
368
+ >
369
+ {Icon({ icon: iconKey, size: 12, color: Color4.create(col.r, col.g, col.b, 0.7) })}
370
+ <Label value={lbl} fontSize={11} color={isHidden ? C.textOff : col} uiTransform={{ height: 14, margin: { left: 6 } }} />
371
+ </UiEntity>
372
+ <UiEntity
373
+ uiTransform={{ width: 16, height: 16, margin: { left: 4 } }}
374
+ onMouseDown={() => toggleVisibility(node.e)}
375
+ >
376
+ {Icon({ icon: isHidden ? 'eyeOff' : 'eyeOn', size: 16, color: Color4.create(1, 1, 1, isHidden ? 0.3 : (isH ? 0.7 : 0.3)) })}
377
+ </UiEntity>
378
+ </UiEntity>
379
+ )
380
+ }
381
+
382
+ // Visible row span height (cells + their margins). ROW_H + 2 margin per row.
383
+ const ROW_FULL = ROW_H + 2
384
+ return (
385
+ <UiEntity
386
+ uiTransform={{
387
+ width: PANEL_W, flexDirection: 'column',
388
+ borderRadius: RADIUS_PANEL,
389
+ borderWidth: 1,
390
+ borderColor: C.panelBorder,
391
+ }}
392
+ uiBackground={{ color: C.panel }}
393
+ >
394
+ {/* Header — no separate background, just text inside the panel */}
395
+ <UiEntity
396
+ uiTransform={{
397
+ width: '100%', height: HEADER_H,
398
+ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
399
+ padding: { left: 14, right: 14 },
400
+ }}
401
+ >
402
+ <Label value="HIERARCHY" fontSize={9} color={C.textMid} uiTransform={{ height: 12 }} />
403
+ <Label value={`${total}`} fontSize={9} color={C.textDim} uiTransform={{ height: 12 }} />
404
+ </UiEntity>
405
+
406
+ {/* Thin separator under the header */}
407
+ <UiEntity
408
+ uiTransform={{ width: '100%', height: 1, margin: { left: 12, right: 12 } }}
409
+ uiBackground={{ color: C.sep }}
410
+ />
411
+
412
+ {/* Scroll up */}
413
+ {canUp ? (
414
+ <UiEntity
415
+ uiTransform={{ width: '100%', height: 16, justifyContent: 'center', alignItems: 'center' }}
416
+ onMouseDown={() => { hierScroll = Math.max(0, hierScroll - 5) }}
417
+ >
418
+ {Icon({ icon: 'chevUp', size: 12, color: C.textDim })}
419
+ </UiEntity>
420
+ ) : null}
421
+
422
+ {/* Rows */}
423
+ <UiEntity uiTransform={{ width: '100%', height: vis * ROW_FULL, flexDirection: 'column', padding: { left: 4, right: 4, top: 4, bottom: 4 } }}>
424
+ {rows}
425
+ </UiEntity>
426
+
427
+ {/* Scroll down */}
428
+ {canDown ? (
429
+ <UiEntity
430
+ uiTransform={{ width: '100%', height: 16, justifyContent: 'center', alignItems: 'center' }}
431
+ onMouseDown={() => { hierScroll = Math.min(maxScr, hierScroll + 5) }}
432
+ >
433
+ {Icon({ icon: 'chevDown', size: 12, color: C.textDim })}
434
+ </UiEntity>
435
+ ) : null}
436
+ </UiEntity>
437
+ )
438
+ }
439
+
440
+ // ── Inspector Panel (top-right) ─────────────────────────
441
+
442
+ function InspectorPanel() {
443
+ if (state.selectedEntity === undefined) return null
444
+
445
+ const pos = pos3()
446
+ const rot = rot3()
447
+
448
+ return (
449
+ <UiEntity
450
+ uiTransform={{
451
+ width: PANEL_W, flexDirection: 'column',
452
+ margin: { top: 6 }, borderRadius: RADIUS_PANEL,
453
+ borderWidth: 1,
454
+ borderColor: C.panelBorder,
455
+ }}
456
+ uiBackground={{ color: C.panel }}
457
+ >
458
+ {/* Entity name */}
459
+ <UiEntity
460
+ uiTransform={{
461
+ width: '100%', height: HEADER_H + 4,
462
+ flexDirection: 'row', alignItems: 'center',
463
+ padding: { left: 14, right: 14 },
464
+ }}
465
+ >
466
+ <Label value={state.selectedName} fontSize={12} color={C.text} uiTransform={{ height: 16 }} />
467
+ </UiEntity>
468
+
469
+ {/* Separator */}
470
+ <UiEntity
471
+ uiTransform={{ width: '100%', height: 1, margin: { left: 12, right: 12 } }}
472
+ uiBackground={{ color: C.sep }}
473
+ />
474
+
475
+ {/* TRANSFORM section */}
476
+ <UiEntity uiTransform={{ width: '100%', flexDirection: 'column', padding: { left: 14, right: 14, top: 10, bottom: 12 } }}>
477
+ <Label value="TRANSFORM" fontSize={9} color={C.textMid} uiTransform={{ height: 12, margin: { bottom: 8 } }} />
478
+
479
+ {/* Position */}
480
+ <UiEntity uiTransform={{ width: '100%', flexDirection: 'column', margin: { bottom: 6 } }}>
481
+ <Label value="Position" fontSize={8} color={C.textDim} uiTransform={{ height: 11, margin: { bottom: 3 } }} />
482
+ <UiEntity uiTransform={{ flexDirection: 'row', width: '100%', height: 18 }}>
483
+ {AxisField('X', pos.x, C.xAxis)}
484
+ {AxisField('Y', pos.y, C.yAxis)}
485
+ {AxisField('Z', pos.z, C.zAxis)}
486
+ </UiEntity>
487
+ </UiEntity>
488
+
489
+ {/* Rotation */}
490
+ <UiEntity uiTransform={{ width: '100%', flexDirection: 'column' }}>
491
+ <Label value="Rotation" fontSize={8} color={C.textDim} uiTransform={{ height: 11, margin: { bottom: 3 } }} />
492
+ <UiEntity uiTransform={{ flexDirection: 'row', width: '100%', height: 18 }}>
493
+ {AxisField('X', rot.x, C.xAxis)}
494
+ {AxisField('Y', rot.y, C.yAxis)}
495
+ {AxisField('Z', rot.z, C.zAxis)}
496
+ </UiEntity>
497
+ </UiEntity>
498
+ </UiEntity>
499
+ </UiEntity>
500
+ )
501
+ }
502
+
503
+ function AxisField(label: string, value: string, color: Color4) {
504
+ return (
505
+ <UiEntity
506
+ uiTransform={{
507
+ height: 18,
508
+ margin: { right: 4 },
509
+ flexDirection: 'row',
510
+ alignItems: 'center',
511
+ flexGrow: 1,
512
+ borderRadius: 9,
513
+ padding: { left: 2, right: 4 }
514
+ }}
515
+ uiBackground={{ color: Color4.create(0.06, 0.06, 0.08, 1) }}
516
+ >
517
+ <UiEntity
518
+ uiTransform={{ width: 14, height: 14, justifyContent: 'center', alignItems: 'center', borderRadius: 7, margin: { right: 4 } }}
519
+ uiBackground={{ color: Color4.create(color.r * 0.35, color.g * 0.35, color.b * 0.35, 1) }}
520
+ >
521
+ <Label value={label} fontSize={9} color={Color4.create(color.r, color.g, color.b, 0.95)} uiTransform={{ height: 11 }} textAlign="middle-center" />
522
+ </UiEntity>
523
+ <Label value={value} fontSize={9} color={C.text} uiTransform={{ height: 13 }} />
524
+ </UiEntity>
525
+ )
526
+ }
527
+
528
+ // ── Shortcuts Panel ─────────────────────────────────────
529
+
530
+ function ShortcutRow(key: string, label: string) {
531
+ return (
532
+ <UiEntity uiTransform={{ flexDirection: 'row', alignItems: 'center', height: 16, width: '100%' }}>
533
+ <UiEntity
534
+ uiTransform={{
535
+ width: 28, height: 14,
536
+ justifyContent: 'center', alignItems: 'center',
537
+ margin: { right: 6 },
538
+ }}
539
+ uiBackground={{ color: Color4.create(0.22, 0.22, 0.26, 0.8) }}
540
+ >
541
+ <Label value={key} fontSize={8} color={C.text} uiTransform={{ height: 10 }} textAlign="middle-center" />
542
+ </UiEntity>
543
+ <Label value={label} fontSize={8} color={C.textDim} uiTransform={{ height: 10 }} />
544
+ </UiEntity>
545
+ )
546
+ }
547
+
548
+ function ShortcutsPanel() {
549
+ const camOn = state.editorCamActive
550
+ const sel = state.selectedEntity !== undefined
551
+ const h = isHov('help')
552
+
553
+ return (
554
+ <UiEntity
555
+ uiTransform={{
556
+ positionType: 'absolute',
557
+ position: { bottom: 8, left: 64 },
558
+ flexDirection: 'column',
559
+ alignItems: 'flex-start',
560
+ }}
561
+ >
562
+ {/* Expanded panel */}
563
+ {showShortcuts ? (
564
+ <UiEntity
565
+ uiTransform={{
566
+ flexDirection: 'column',
567
+ padding: { left: 12, right: 14, top: 10, bottom: 10 },
568
+ margin: { bottom: 4 },
569
+ borderRadius: RADIUS_PANEL,
570
+ borderWidth: 1,
571
+ borderColor: C.panelBorder,
572
+ }}
573
+ uiBackground={{ color: C.panel }}
574
+ >
575
+ <UiEntity
576
+ uiTransform={{
577
+ flexDirection: 'column',
578
+ }}
579
+ >
580
+ {/* Header */}
581
+ <Label
582
+ value={camOn ? 'Editor Camera' : 'Shortcuts'}
583
+ fontSize={9}
584
+ color={C.textMid}
585
+ uiTransform={{ height: 14, margin: { bottom: 4 } }}
586
+ />
587
+
588
+ {/* Camera controls */}
589
+ {camOn ? ShortcutRow('WASD', 'Pan') : null}
590
+ {camOn ? ShortcutRow('Space', 'Up') : null}
591
+ {camOn ? ShortcutRow('Shift', 'Down') : null}
592
+ {camOn ? ShortcutRow('2 / 3', 'Zoom in / out') : null}
593
+ {camOn ? ShortcutRow('Drag', 'Orbit') : null}
594
+ {/* Separator */}
595
+ {camOn ? (
596
+ <UiEntity uiTransform={{ width: '100%', height: 1, margin: { top: 4, bottom: 4 } }} uiBackground={{ color: C.sep }} />
597
+ ) : null}
598
+
599
+ {/* General */}
600
+ {ShortcutRow('Click', 'Select')}
601
+ {ShortcutRow('E', 'Move / Rotate')}
602
+ {ShortcutRow('F', 'Deselect')}
603
+ {ShortcutRow('1', camOn ? 'Exit camera' : 'Editor camera')}
604
+ {sel ? ShortcutRow('2', 'Focus selected') : null}
605
+
606
+ </UiEntity>
607
+ </UiEntity>
608
+ ) : null}
609
+
610
+ {/* Toggle button */}
611
+ <UiEntity
612
+ uiTransform={{
613
+ width: 28, height: 28,
614
+ justifyContent: 'center', alignItems: 'center',
615
+ borderRadius: RADIUS_BTN,
616
+ }}
617
+ uiBackground={{ color: showShortcuts ? C.btnActive : h ? C.btnHover : C.btn }}
618
+ onMouseEnter={() => setHov('help')}
619
+ onMouseLeave={() => clearHov('help')}
620
+ onMouseDown={() => { showShortcuts = !showShortcuts }}
621
+ >
622
+ {Icon({ icon: 'help', size: 14, color: Color4.create(1, 1, 1, showShortcuts ? 1.0 : h ? 0.8 : 0.5) })}
623
+ </UiEntity>
624
+ </UiEntity>
625
+ )
626
+ }
627
+
628
+ // ── Editor Toggle Button (preview only) ─────────────────
629
+
630
+ function EditorToggle() {
631
+ const edOn = state.editorActive
632
+ const h = isHov('edt')
633
+
634
+ return (
635
+ <UiEntity
636
+ uiTransform={{
637
+ positionType: 'absolute',
638
+ position: { bottom: 8, right: 8 },
639
+ width: 28, height: 28,
640
+ justifyContent: 'center', alignItems: 'center',
641
+ borderRadius: RADIUS_BTN,
642
+ }}
643
+ uiBackground={{ color: toolBg(edOn, false, h) }}
644
+ onMouseEnter={() => setHov('edt')}
645
+ onMouseLeave={() => clearHov('edt')}
646
+ onMouseDown={() => toggleEditorActive()}
647
+ >
648
+ {Icon({ icon: 'edit', size: 14, color: Color4.create(1, 1, 1, edOn ? 1.0 : h ? 0.8 : 0.5) })}
649
+ </UiEntity>
650
+ )
651
+ }
652
+
653
+ // ── Main UI ─────────────────────────────────────────────
654
+
655
+ function EditorUI() {
656
+ // Deployed scenes (not preview, not studio) never see any editor UI —
657
+ // not even the toggle button. Decided async by realmDetectSystem in
658
+ // index.ts once RealmInfo is published.
659
+ if (!state.isPreview) {
660
+ return <UiEntity uiTransform={{ width: 0, height: 0, display: 'none' }} />
661
+ }
662
+
663
+ // Editor off — just the pencil button.
664
+ if (!state.editorActive) {
665
+ return (
666
+ <UiEntity uiTransform={{ width: '100%', height: '100%' }}>
667
+ {EditorToggle()}
668
+ </UiEntity>
669
+ )
670
+ }
671
+
672
+ // Editor on — full UI.
673
+ const sel = state.selectedEntity !== undefined
674
+ return (
675
+ <UiEntity uiTransform={{ width: '100%', height: '100%' }}>
676
+ {Toolbar(sel)}
677
+ {/* Right-side stack: hierarchy + inspector */}
678
+ <UiEntity
679
+ uiTransform={{
680
+ positionType: 'absolute',
681
+ position: { top: 8, right: 8 },
682
+ flexDirection: 'column',
683
+ alignItems: 'flex-end',
684
+ }}
685
+ >
686
+ {HierarchyPanel()}
687
+ {InspectorPanel()}
688
+ </UiEntity>
689
+ {ShortcutsPanel()}
690
+ {EditorToggle()}
691
+ </UiEntity>
692
+ )
693
+ }
694
+
695
+ export function setupEditorUi() {
696
+ ReactEcsRenderer.setUiRenderer(EditorUI, { virtualWidth: 1280, virtualHeight: 720 })
697
+ }