@dcl-regenesislabs/opendcl 0.2.0 → 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 +7 -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,496 @@
1
+ /** Gizmo creation — translate arrows and rotate discs. */
2
+
3
+ import {
4
+ engine,
5
+ Entity,
6
+ Transform,
7
+ MeshRenderer,
8
+ MeshCollider,
9
+ Material,
10
+ MaterialTransparencyMode,
11
+ pointerEventsSystem,
12
+ InputAction,
13
+ ColliderLayer,
14
+ } from '@dcl/sdk/ecs'
15
+ import { Vector3, Quaternion, Color4, Color3 } from '@dcl/sdk/math'
16
+ import {
17
+ Axis,
18
+ MAX_PARENT_DEPTH,
19
+ state,
20
+ editorEntities,
21
+ selectableInfoMap,
22
+ gizmoEntities,
23
+ gizmoRoot,
24
+ setGizmoRoot,
25
+ handleAxisMap,
26
+ handleDiscMap,
27
+ handleArrowMap,
28
+ setGizmoClickConsumed,
29
+ } from './state'
30
+ import { copyVec3, copyQuat, getOtherAxes } from './math-utils'
31
+ import { getActiveCameraTransform } from './camera'
32
+ // startDrag/startPlaneDrag injected to avoid circular dependency with drag.ts
33
+ let _startDrag: ((axis: Axis) => void) | undefined
34
+ let _startPlaneDrag: ((normalAxis: Axis) => void) | undefined
35
+ export function setStartDragHandler(fn: (axis: Axis) => void) { _startDrag = fn }
36
+ export function setStartPlaneDragHandler(fn: (normalAxis: Axis) => void) { _startPlaneDrag = fn }
37
+
38
+ // ---- Constants ----
39
+
40
+ const SHAFT_LENGTH = 1.2
41
+ const SHAFT_RADIUS = 0.04
42
+ const TIP_LENGTH = 0.3
43
+ const TIP_RADIUS = 0.12
44
+ const HANDLE_RADIUS = 0.18
45
+
46
+ const RING_RADIUS = 1.0
47
+ const RING_THICKNESS = 0.05
48
+ const RING_COLLIDER_THICKNESS = 0.2
49
+
50
+ // Gizmo scales with camera distance — this factor controls apparent size
51
+ // At 10m distance, gizmo scale = 10 * 0.12 = 1.2 (roughly 1.5m arrows)
52
+ const GIZMO_SCALE_FACTOR = 0.12
53
+ const GIZMO_MIN_SCALE = 0.8
54
+ const GIZMO_MAX_SCALE = 5.0
55
+
56
+ const AXIS_COLORS: Record<Axis, { c4: Color4; c3: Color3 }> = {
57
+ x: { c4: Color4.create(0.95, 0.15, 0.15, 1), c3: Color3.create(0.95, 0.15, 0.15) },
58
+ y: { c4: Color4.create(0.2, 0.9, 0.2, 1), c3: Color3.create(0.2, 0.9, 0.2) },
59
+ z: { c4: Color4.create(0.2, 0.3, 0.95, 1), c3: Color3.create(0.2, 0.3, 0.95) },
60
+ }
61
+
62
+ const AXIS_ROTATION: Record<Axis, { x: number; y: number; z: number; w: number }> = {
63
+ x: Quaternion.fromEulerDegrees(0, 0, -90),
64
+ y: Quaternion.Identity(),
65
+ z: Quaternion.fromEulerDegrees(90, 0, 0),
66
+ }
67
+
68
+ const ARROW_EMISSIVE_DEFAULT = 0.6
69
+ const ARROW_EMISSIVE_HOVER = 2.5
70
+ const DISC_ALPHA_DEFAULT = 0.2
71
+ const DISC_ALPHA_HOVER = 0.6
72
+ const DISC_EMISSIVE_DEFAULT = 0.4
73
+ const DISC_EMISSIVE_HOVER = 1.5
74
+
75
+ // ---- Public ----
76
+
77
+ /** Compute cumulative world rotation of an entity's parent chain.
78
+ * For root entities (no parent), returns identity. */
79
+ export function getParentWorldRotation(entity: Entity): { x: number; y: number; z: number; w: number } {
80
+ let worldRot = Quaternion.Identity()
81
+ const info = selectableInfoMap.get(entity)
82
+ let parentId = info?.parentEntity
83
+ let depth = 0
84
+
85
+ // Collect parent rotations bottom-up, then multiply top-down
86
+ const rotations: { x: number; y: number; z: number; w: number }[] = []
87
+ while (parentId && depth < MAX_PARENT_DEPTH) {
88
+ const pe = parentId as Entity
89
+ if (!Transform.has(pe)) break
90
+ rotations.push(Transform.get(pe).rotation)
91
+ const parentInfo = selectableInfoMap.get(pe)
92
+ parentId = parentInfo?.parentEntity
93
+ depth++
94
+ }
95
+
96
+ // Multiply top-down: grandparent * parent * ...
97
+ for (let i = rotations.length - 1; i >= 0; i--) {
98
+ worldRot = Quaternion.multiply(worldRot, rotations[i])
99
+ }
100
+
101
+ return worldRot
102
+ }
103
+
104
+ /** Compute world position of an entity by walking up the parent chain.
105
+ * Accounts for parent scale: childWorldPos = parentPos + parentRot * (parentScale * childLocalPos) */
106
+ function getWorldPosition(entity: Entity): Vector3 {
107
+ const t = Transform.get(entity)
108
+ let worldPos = Vector3.create(t.position.x, t.position.y, t.position.z)
109
+
110
+ const info = selectableInfoMap.get(entity)
111
+ let parentId = info?.parentEntity
112
+ let depth = 0
113
+
114
+ while (parentId && depth < MAX_PARENT_DEPTH) {
115
+ const pe = parentId as Entity
116
+ if (!Transform.has(pe)) break
117
+ const pt = Transform.get(pe)
118
+ // Scale the position by parent's scale, then rotate, then translate
119
+ const scaled = Vector3.create(
120
+ worldPos.x * pt.scale.x,
121
+ worldPos.y * pt.scale.y,
122
+ worldPos.z * pt.scale.z,
123
+ )
124
+ worldPos = Vector3.add(pt.position, Vector3.rotate(scaled, pt.rotation))
125
+ const parentInfo = selectableInfoMap.get(pe)
126
+ parentId = parentInfo?.parentEntity
127
+ depth++
128
+ }
129
+
130
+ return worldPos
131
+ }
132
+
133
+ export function getGizmoCenter(entity: Entity): Vector3 {
134
+ const worldPos = getWorldPosition(entity)
135
+ const info = selectableInfoMap.get(entity)
136
+ const offset = info?.centerOffset ?? Vector3.Zero()
137
+ return Vector3.create(worldPos.x + offset.x, worldPos.y + offset.y, worldPos.z + offset.z)
138
+ }
139
+
140
+ export function createGizmo() {
141
+ destroyGizmo()
142
+ if (state.selectedEntity === undefined) return
143
+
144
+ const center = getGizmoCenter(state.selectedEntity)
145
+ const root = engine.addEntity()
146
+ Transform.create(root, { position: center })
147
+ gizmoEntities.push(root)
148
+ editorEntities.add(root)
149
+ setGizmoRoot(root)
150
+
151
+ if (state.gizmoMode === 'translate') {
152
+ createArrow('x', root)
153
+ createArrow('y', root)
154
+ createArrow('z', root)
155
+ // Plane handles between each pair of axes
156
+ createPlaneHandle('y', root) // XZ plane (horizontal)
157
+ createPlaneHandle('z', root) // XY plane
158
+ createPlaneHandle('x', root) // YZ plane
159
+ } else {
160
+ createRotationHandle('x', root)
161
+ createRotationHandle('y', root)
162
+ createRotationHandle('z', root)
163
+ }
164
+ }
165
+
166
+ export function destroyGizmo() {
167
+ for (const e of gizmoEntities) {
168
+ if (handleAxisMap.has(e) || planeHandleMap.has(e)) {
169
+ pointerEventsSystem.removeOnPointerDown(e)
170
+ pointerEventsSystem.removeOnPointerHoverEnter(e)
171
+ pointerEventsSystem.removeOnPointerHoverLeave(e)
172
+ handleDiscMap.delete(e)
173
+ handleArrowMap.delete(e)
174
+ handleAxisMap.delete(e)
175
+ planeHandleMap.delete(e)
176
+ ringSegmentsMap.delete(e)
177
+ }
178
+ editorEntities.delete(e)
179
+ engine.removeEntity(e)
180
+ }
181
+ gizmoEntities.length = 0
182
+ setGizmoRoot(undefined)
183
+ }
184
+
185
+ export function gizmoFollowSystem() {
186
+ if (gizmoRoot === undefined || state.selectedEntity === undefined) return
187
+ if (!Transform.has(state.selectedEntity)) return
188
+
189
+ const entityT = Transform.get(state.selectedEntity)
190
+ const g = Transform.getMutable(gizmoRoot)
191
+
192
+ const center = getGizmoCenter(state.selectedEntity)
193
+ copyVec3(g.position, center)
194
+
195
+ // Gizmo always world-aligned (both translate arrows and rotate rings)
196
+ g.rotation.x = 0; g.rotation.y = 0; g.rotation.z = 0; g.rotation.w = 1
197
+
198
+ // Scale gizmo: max of camera-distance-based and entity-size-based
199
+ // 1) Camera distance → constant screen size
200
+ const camT = getActiveCameraTransform()
201
+ const dx = camT.position.x - center.x
202
+ const dy = camT.position.y - center.y
203
+ const dz = camT.position.z - center.z
204
+ const dist = Math.sqrt(dx * dx + dy * dy + dz * dz)
205
+ const distScale = dist * GIZMO_SCALE_FACTOR
206
+
207
+ // 2) Entity size → arrows must extend beyond the mesh
208
+ const sc = entityT.scale
209
+ const maxDim = Math.max(Math.abs(sc.x), Math.abs(sc.y), Math.abs(sc.z))
210
+ // Arrow total length is ~1.5 units at scale 1, we want arrows to extend ~50% beyond the mesh radius
211
+ // meshRadius ≈ maxDim * 0.5 (for primitives), arrow reaches gizmoScale * 1.5
212
+ // gizmoScale * 1.5 > maxDim * 0.5 * 1.5 → gizmoScale > maxDim * 0.5
213
+ const sizeScale = maxDim * 0.55
214
+
215
+ const s = Math.min(GIZMO_MAX_SCALE, Math.max(GIZMO_MIN_SCALE, distScale, sizeScale))
216
+ g.scale.x = s
217
+ g.scale.y = s
218
+ g.scale.z = s
219
+ }
220
+
221
+ // ---- Translate arrows ----
222
+
223
+ export function setArrowMaterial(entity: Entity, axis: Axis, hovered: boolean) {
224
+ const { c4, c3 } = AXIS_COLORS[axis]
225
+ Material.setPbrMaterial(entity, {
226
+ albedoColor: c4,
227
+ emissiveColor: c3,
228
+ emissiveIntensity: hovered ? ARROW_EMISSIVE_HOVER : ARROW_EMISSIVE_DEFAULT,
229
+ metallic: 0.7,
230
+ roughness: 0.25,
231
+ })
232
+ }
233
+
234
+ function createArrow(axis: Axis, root: Entity) {
235
+ const rot = AXIS_ROTATION[axis]
236
+
237
+ const container = engine.addEntity()
238
+ Transform.create(container, { position: Vector3.Zero(), rotation: rot, parent: root })
239
+ gizmoEntities.push(container)
240
+ editorEntities.add(container)
241
+
242
+ const shaft = engine.addEntity()
243
+ Transform.create(shaft, {
244
+ position: Vector3.create(0, SHAFT_LENGTH / 2, 0),
245
+ scale: Vector3.create(1, SHAFT_LENGTH, 1),
246
+ parent: container,
247
+ })
248
+ MeshRenderer.setCylinder(shaft, SHAFT_RADIUS, SHAFT_RADIUS)
249
+ setArrowMaterial(shaft, axis, false)
250
+ gizmoEntities.push(shaft)
251
+ editorEntities.add(shaft)
252
+
253
+ const tip = engine.addEntity()
254
+ Transform.create(tip, {
255
+ position: Vector3.create(0, SHAFT_LENGTH + TIP_LENGTH / 2, 0),
256
+ scale: Vector3.create(1, TIP_LENGTH, 1),
257
+ parent: container,
258
+ })
259
+ MeshRenderer.setCylinder(tip, TIP_RADIUS, 0)
260
+ setArrowMaterial(tip, axis, false)
261
+ gizmoEntities.push(tip)
262
+ editorEntities.add(tip)
263
+
264
+ const handle = engine.addEntity()
265
+ Transform.create(handle, {
266
+ position: Vector3.create(0, (SHAFT_LENGTH + TIP_LENGTH) / 2, 0),
267
+ scale: Vector3.create(1, SHAFT_LENGTH + TIP_LENGTH, 1),
268
+ parent: container,
269
+ })
270
+ MeshCollider.setCylinder(handle, HANDLE_RADIUS, HANDLE_RADIUS, ColliderLayer.CL_POINTER)
271
+ gizmoEntities.push(handle)
272
+ editorEntities.add(handle)
273
+
274
+ handleAxisMap.set(handle, axis)
275
+ handleArrowMap.set(handle, [shaft, tip])
276
+
277
+ pointerEventsSystem.onPointerDown(
278
+ { entity: handle, opts: { button: InputAction.IA_POINTER, hoverText: `Move ${axis.toUpperCase()}`, maxDistance: 100 } },
279
+ () => { setGizmoClickConsumed(true); _startDrag?.(axis) }
280
+ )
281
+
282
+ pointerEventsSystem.onPointerHoverEnter({ entity: handle, opts: { maxDistance: 100 } }, () => {
283
+ for (const [h, parts] of handleArrowMap) {
284
+ const a = handleAxisMap.get(h)
285
+ if (!a) continue
286
+ for (const p of parts) setArrowMaterial(p, a, h === handle)
287
+ }
288
+ })
289
+
290
+ pointerEventsSystem.onPointerHoverLeave({ entity: handle, opts: { maxDistance: 100 } }, () => {
291
+ if (state.isDragging && state.dragAxis === axis) return
292
+ for (const [h, parts] of handleArrowMap) {
293
+ const a = handleAxisMap.get(h)
294
+ if (!a) continue
295
+ for (const p of parts) setArrowMaterial(p, a, false)
296
+ }
297
+ })
298
+ }
299
+
300
+ // ---- Plane handles (two-axis drag) ----
301
+
302
+ // Offset from center along each axis (fraction of shaft length)
303
+ const PLANE_OFFSET = 0.4
304
+ const PLANE_SIZE = 0.3
305
+ const PLANE_ALPHA = 0.55
306
+ const PLANE_ALPHA_HOVER = 0.8
307
+ const PLANE_EMISSIVE = 1.2
308
+ const PLANE_EMISSIVE_HOVER = 2.5
309
+
310
+ /** Map from plane handle entity → its visual entity + normal axis */
311
+ const planeHandleMap = new Map<Entity, { visual: Entity; normalAxis: Axis }>()
312
+
313
+ /** Get blended color for a plane from the two axes it spans. */
314
+ function planeColor(normalAxis: Axis): { c4: Color4; c3: Color3 } {
315
+ // The plane's color is a mix of the two axes it contains
316
+ const axes = getOtherAxes(normalAxis)
317
+ const a = AXIS_COLORS[axes[0]]
318
+ const b = AXIS_COLORS[axes[1]]
319
+ return {
320
+ c4: Color4.create((a.c4.r + b.c4.r) * 0.5, (a.c4.g + b.c4.g) * 0.5, (a.c4.b + b.c4.b) * 0.5, 1),
321
+ c3: Color3.create((a.c3.r + b.c3.r) * 0.5, (a.c3.g + b.c3.g) * 0.5, (a.c3.b + b.c3.b) * 0.5),
322
+ }
323
+ }
324
+
325
+ function setPlaneMaterial(visual: Entity, normalAxis: Axis, hovered: boolean) {
326
+ const { c4, c3 } = planeColor(normalAxis)
327
+ Material.setPbrMaterial(visual, {
328
+ albedoColor: Color4.create(c4.r, c4.g, c4.b, hovered ? PLANE_ALPHA_HOVER : PLANE_ALPHA),
329
+ emissiveColor: c3,
330
+ emissiveIntensity: hovered ? PLANE_EMISSIVE_HOVER : PLANE_EMISSIVE,
331
+ metallic: 0.5,
332
+ roughness: 0.3,
333
+ transparencyMode: MaterialTransparencyMode.MTM_ALPHA_BLEND,
334
+ })
335
+ }
336
+
337
+ /**
338
+ * Create a plane handle. normalAxis = axis perpendicular to the plane.
339
+ * e.g. normalAxis='y' → XZ plane handle, positioned between X and Z arrows.
340
+ */
341
+ function createPlaneHandle(normalAxis: Axis, root: Entity) {
342
+ // Position: offset along the two in-plane axes
343
+ const axes = getOtherAxes(normalAxis)
344
+ const pos = Vector3.create(
345
+ axes.includes('x') ? PLANE_OFFSET : 0,
346
+ axes.includes('y') ? PLANE_OFFSET : 0,
347
+ axes.includes('z') ? PLANE_OFFSET : 0,
348
+ )
349
+
350
+ // Visual: flat box aligned to the plane
351
+ const visual = engine.addEntity()
352
+ const scale = Vector3.create(
353
+ normalAxis === 'x' ? 0.02 : PLANE_SIZE,
354
+ normalAxis === 'y' ? 0.02 : PLANE_SIZE,
355
+ normalAxis === 'z' ? 0.02 : PLANE_SIZE,
356
+ )
357
+ Transform.create(visual, { position: pos, scale, parent: root })
358
+ MeshRenderer.setBox(visual)
359
+ setPlaneMaterial(visual, normalAxis, false)
360
+ gizmoEntities.push(visual)
361
+ editorEntities.add(visual)
362
+
363
+ // Collider: slightly larger for easier clicking
364
+ const handle = engine.addEntity()
365
+ const colliderScale = Vector3.create(
366
+ normalAxis === 'x' ? 0.08 : PLANE_SIZE * 1.4,
367
+ normalAxis === 'y' ? 0.08 : PLANE_SIZE * 1.4,
368
+ normalAxis === 'z' ? 0.08 : PLANE_SIZE * 1.4,
369
+ )
370
+ Transform.create(handle, { position: pos, scale: colliderScale, parent: root })
371
+ MeshCollider.setBox(handle, ColliderLayer.CL_POINTER)
372
+ gizmoEntities.push(handle)
373
+ editorEntities.add(handle)
374
+
375
+ // Label: show which plane
376
+ const planeLabel = axes.map(a => a.toUpperCase()).join('')
377
+ planeHandleMap.set(handle, { visual, normalAxis })
378
+
379
+ pointerEventsSystem.onPointerDown(
380
+ { entity: handle, opts: { button: InputAction.IA_POINTER, hoverText: `Move ${planeLabel}`, maxDistance: 100 } },
381
+ () => { setGizmoClickConsumed(true); _startPlaneDrag?.(normalAxis) }
382
+ )
383
+
384
+ pointerEventsSystem.onPointerHoverEnter({ entity: handle, opts: { maxDistance: 100 } }, () => {
385
+ setPlaneMaterial(visual, normalAxis, true)
386
+ })
387
+
388
+ pointerEventsSystem.onPointerHoverLeave({ entity: handle, opts: { maxDistance: 100 } }, () => {
389
+ if (state.isDragging && state.dragPlaneMode === normalAxis) return
390
+ setPlaneMaterial(visual, normalAxis, false)
391
+ })
392
+ }
393
+
394
+ // ---- Rotation rings ----
395
+
396
+ const RING_SEGMENTS = 24
397
+ const RING_SEGMENT_THICKNESS = 0.025
398
+ const RING_ALPHA_DEFAULT = 0.85
399
+ const RING_ALPHA_HOVER = 1.0
400
+ const RING_EMISSIVE_DEFAULT = 1.0
401
+ const RING_EMISSIVE_HOVER = 3.0
402
+
403
+ /** Map from handle entity → array of segment visual entities */
404
+ const ringSegmentsMap = new Map<Entity, Entity[]>()
405
+
406
+ export function setDiscMaterial(segmentOrDisc: Entity, axis: Axis, hovered: boolean) {
407
+ const { c4, c3 } = AXIS_COLORS[axis]
408
+ Material.setPbrMaterial(segmentOrDisc, {
409
+ albedoColor: Color4.create(c4.r, c4.g, c4.b, hovered ? RING_ALPHA_HOVER : RING_ALPHA_DEFAULT),
410
+ emissiveColor: c3,
411
+ emissiveIntensity: hovered ? RING_EMISSIVE_HOVER : RING_EMISSIVE_DEFAULT,
412
+ metallic: 0.7,
413
+ roughness: 0.2,
414
+ transparencyMode: MaterialTransparencyMode.MTM_ALPHA_BLEND,
415
+ })
416
+ }
417
+
418
+ export function setRingMaterial(handle: Entity, axis: Axis, hovered: boolean) {
419
+ const segments = ringSegmentsMap.get(handle)
420
+ if (segments) {
421
+ for (const seg of segments) setDiscMaterial(seg, axis, hovered)
422
+ }
423
+ }
424
+
425
+ function createRotationHandle(axis: Axis, root: Entity) {
426
+ const rot = AXIS_ROTATION[axis]
427
+
428
+ const container = engine.addEntity()
429
+ Transform.create(container, { position: Vector3.Zero(), rotation: rot, parent: root })
430
+ gizmoEntities.push(container)
431
+ editorEntities.add(container)
432
+
433
+ // Build ring from segments (thin boxes placed around a circle in the XZ plane)
434
+ const segAngle = (Math.PI * 2) / RING_SEGMENTS
435
+ const segLength = RING_RADIUS * 2 * Math.sin(segAngle / 2) * 1.05 // slight overlap
436
+ const segments: Entity[] = []
437
+
438
+ for (let i = 0; i < RING_SEGMENTS; i++) {
439
+ const angle = i * segAngle
440
+ const x = Math.cos(angle) * RING_RADIUS
441
+ const z = Math.sin(angle) * RING_RADIUS
442
+ // Tangent direction for rotation
443
+ const tangentAngle = angle + Math.PI / 2
444
+
445
+ const seg = engine.addEntity()
446
+ Transform.create(seg, {
447
+ position: Vector3.create(x, 0, z),
448
+ rotation: Quaternion.fromEulerDegrees(0, -tangentAngle * (180 / Math.PI), 0),
449
+ scale: Vector3.create(segLength, RING_SEGMENT_THICKNESS, RING_SEGMENT_THICKNESS),
450
+ parent: container,
451
+ })
452
+ MeshRenderer.setBox(seg)
453
+ setDiscMaterial(seg, axis, false)
454
+ gizmoEntities.push(seg)
455
+ editorEntities.add(seg)
456
+ segments.push(seg)
457
+ }
458
+
459
+ // Invisible collider cylinder for clicking (same as before)
460
+ const handle = engine.addEntity()
461
+ Transform.create(handle, {
462
+ position: Vector3.Zero(),
463
+ scale: Vector3.create(RING_RADIUS * 2, RING_COLLIDER_THICKNESS, RING_RADIUS * 2),
464
+ parent: container,
465
+ })
466
+ MeshCollider.setCylinder(handle, 0.5, 0.5, ColliderLayer.CL_POINTER)
467
+ gizmoEntities.push(handle)
468
+ editorEntities.add(handle)
469
+
470
+ handleAxisMap.set(handle, axis)
471
+ ringSegmentsMap.set(handle, segments)
472
+ // Keep handleDiscMap working by pointing to the first segment (for endDrag reset)
473
+ handleDiscMap.set(handle, handle)
474
+
475
+ pointerEventsSystem.onPointerDown(
476
+ { entity: handle, opts: { button: InputAction.IA_POINTER, hoverText: `Rotate ${axis.toUpperCase()}`, maxDistance: 100 } },
477
+ () => { setGizmoClickConsumed(true); _startDrag?.(axis) }
478
+ )
479
+
480
+ pointerEventsSystem.onPointerHoverEnter({ entity: handle, opts: { maxDistance: 100 } }, () => {
481
+ for (const [h] of ringSegmentsMap) {
482
+ const a = handleAxisMap.get(h)
483
+ if (!a) continue
484
+ setRingMaterial(h, a, h === handle)
485
+ }
486
+ })
487
+
488
+ pointerEventsSystem.onPointerHoverLeave({ entity: handle, opts: { maxDistance: 100 } }, () => {
489
+ if (state.isDragging && state.dragAxis === axis) return
490
+ for (const [h] of ringSegmentsMap) {
491
+ const a = handleAxisMap.get(h)
492
+ if (!a) continue
493
+ setRingMaterial(h, a, false)
494
+ }
495
+ })
496
+ }
@@ -0,0 +1,72 @@
1
+ /** Undo/redo history for transform changes. */
2
+
3
+ import { Entity, Transform } from '@dcl/sdk/ecs'
4
+ import { selectableInfoMap } from './state'
5
+ import { sendEntityUpdate } from './persistence'
6
+ import { applyFlatTransform } from './math-utils'
7
+
8
+ export interface TransformSnapshot {
9
+ px: number; py: number; pz: number
10
+ rx: number; ry: number; rz: number; rw: number
11
+ sx: number; sy: number; sz: number
12
+ }
13
+
14
+ interface HistoryEntry {
15
+ entity: Entity
16
+ before: TransformSnapshot
17
+ after: TransformSnapshot
18
+ }
19
+
20
+ const stack: HistoryEntry[] = []
21
+ let cursor = -1
22
+ const MAX_HISTORY = 50
23
+
24
+ export function captureTransform(entity: Entity): TransformSnapshot {
25
+ const t = Transform.get(entity)
26
+ return {
27
+ px: t.position.x, py: t.position.y, pz: t.position.z,
28
+ rx: t.rotation.x, ry: t.rotation.y, rz: t.rotation.z, rw: t.rotation.w,
29
+ sx: t.scale.x, sy: t.scale.y, sz: t.scale.z,
30
+ }
31
+ }
32
+
33
+ function applySnapshot(entity: Entity, snap: TransformSnapshot) {
34
+ if (!Transform.has(entity)) return
35
+ const t = Transform.getMutable(entity)
36
+ applyFlatTransform(t, snap)
37
+ }
38
+
39
+ export function pushHistory(entity: Entity, before: TransformSnapshot, after: TransformSnapshot) {
40
+ stack.length = cursor + 1
41
+ stack.push({ entity, before, after })
42
+ cursor = stack.length - 1
43
+ if (stack.length > MAX_HISTORY) {
44
+ stack.shift()
45
+ cursor--
46
+ }
47
+ }
48
+
49
+ export function undo(): boolean {
50
+ if (cursor < 0) return false
51
+ const entry = stack[cursor]
52
+ cursor--
53
+ applySnapshot(entry.entity, entry.before)
54
+ sendEntityUpdate(entry.entity)
55
+ const info = selectableInfoMap.get(entry.entity)
56
+ console.log(`[editor] undo ${info?.name ?? 'entity'}`)
57
+ return true
58
+ }
59
+
60
+ export function redo(): boolean {
61
+ if (cursor >= stack.length - 1) return false
62
+ const next = stack[cursor + 1]
63
+ cursor++
64
+ applySnapshot(next.entity, next.after)
65
+ sendEntityUpdate(next.entity)
66
+ const info = selectableInfoMap.get(next.entity)
67
+ console.log(`[editor] redo ${info?.name ?? 'entity'}`)
68
+ return true
69
+ }
70
+
71
+ export function undoCount(): number { return cursor + 1 }
72
+ export function redoCount(): number { return stack.length - 1 - cursor }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Scene Editor — the single entry point.
3
+ *
4
+ * Usage:
5
+ * import { enableEditor } from './__editor'
6
+ * enableEditor()
7
+ *
8
+ * Pure client-side editor. On preview, lets the user toggle the editor on,
9
+ * select entities declared in main-entities.ts, drag them around, and persist
10
+ * changes to the preview server (which writes main-entities.ts on disk).
11
+ *
12
+ * Only available in preview — deployed scenes never show editor UI.
13
+ */
14
+
15
+ import {
16
+ engine,
17
+ Transform,
18
+ MeshCollider,
19
+ pointerEventsSystem,
20
+ InputAction,
21
+ ColliderLayer,
22
+ RealmInfo,
23
+ } from '@dcl/sdk/ecs'
24
+ import { Vector3 } from '@dcl/sdk/math'
25
+ import { state, editorEntities, gizmoClickConsumed, setToggleHandler } from './state'
26
+ import { setupEditorUi } from './ui'
27
+ import { createEditorCamera, createLockCamera, deactivateEditorCamera, editorCameraSystem } from './camera'
28
+ import { SKIP_ENTITIES, discoverySystem, removeAllPointerEvents, restoreAllPointerEvents } from './discovery'
29
+ import { deselectEntity } from './selection'
30
+ import { startDrag, startPlaneDrag, dragSystem } from './drag'
31
+ import { gizmoFollowSystem, setStartDragHandler, setStartPlaneDragHandler } from './gizmo'
32
+ import { modeToggleSystem, resetGizmoClickFlag } from './input'
33
+ import { initPersistence } from './persistence'
34
+
35
+ let initialized = false
36
+
37
+ export function enableEditor() {
38
+ if (initialized) return
39
+ initialized = true
40
+
41
+ // UI + systems wire up immediately; visibility is gated on `state.isPreview`
42
+ // which starts false. A one-shot polling system flips it true once
43
+ // RealmInfo is published and the realm looks editable.
44
+ setupEditorUi()
45
+ setupClientEditor()
46
+ initPersistence()
47
+ engine.addSystem(realmDetectSystem)
48
+ }
49
+
50
+ /**
51
+ * RealmInfo isn't populated synchronously at scene-module-load — the runtime
52
+ * publishes it a few ticks in. Poll until it's available, then decide:
53
+ *
54
+ * - deployed world (baseUrl on worlds-content-server) → never editable,
55
+ * even if some future SDK build flips `isPreview` true there.
56
+ * - `isPreview === true` → CLI `sdk-commands start` preview → editable.
57
+ * - studio realm (`/scenes/<id>/snapshots/<id>` in baseUrl) → editable.
58
+ * opendcl-studio doesn't set isPreview=true even though scenes there are
59
+ * editable; the path pattern is unique to studio's realm handler.
60
+ * - anything else (Genesis City, custom catalysts, etc.) → leave isPreview
61
+ * false so the toolbar/toggle never renders.
62
+ *
63
+ * Removes itself after the first decision — no need to keep polling.
64
+ */
65
+ function realmDetectSystem(_dt: number) {
66
+ const info = RealmInfo.getOrNull(engine.RootEntity)
67
+ if (!info) return
68
+ engine.removeSystem(realmDetectSystem)
69
+ const baseUrl = info.baseUrl ?? ''
70
+ if (baseUrl.startsWith('https://worlds-content-server.decentraland.org/')) {
71
+ console.log('[editor] disabled: deployed world')
72
+ return
73
+ }
74
+ if (info.isPreview) {
75
+ state.isPreview = true
76
+ return
77
+ }
78
+ if (/\/scenes\/[^/]+\/snapshots\/[^/]+/.test(baseUrl)) {
79
+ state.isPreview = true
80
+ return
81
+ }
82
+ console.log('[editor] disabled: not a preview/studio realm')
83
+ }
84
+
85
+ function handleToggle() {
86
+ if (state.editorActive) {
87
+ deselectEntity()
88
+ if (state.editorCamActive) deactivateEditorCamera()
89
+ removeAllPointerEvents()
90
+ state.editorActive = false
91
+ console.log('[editor] editor OFF')
92
+ } else {
93
+ state.editorActive = true
94
+ restoreAllPointerEvents()
95
+ console.log('[editor] editor ON')
96
+ }
97
+ }
98
+
99
+ function setupClientEditor() {
100
+ SKIP_ENTITIES.add(engine.RootEntity)
101
+ SKIP_ENTITIES.add(engine.CameraEntity)
102
+ SKIP_ENTITIES.add(engine.PlayerEntity)
103
+
104
+ setToggleHandler(handleToggle)
105
+ createDeselectGround()
106
+ setStartDragHandler(startDrag)
107
+ setStartPlaneDragHandler(startPlaneDrag)
108
+ createEditorCamera()
109
+ createLockCamera()
110
+
111
+ engine.addSystem(editorCameraSystem, 102)
112
+ engine.addSystem(discoverySystem, 100)
113
+ engine.addSystem(dragSystem)
114
+ engine.addSystem(gizmoFollowSystem)
115
+ engine.addSystem(modeToggleSystem)
116
+ engine.addSystem(resetGizmoClickFlag, Number.MAX_SAFE_INTEGER)
117
+
118
+ console.log('[editor] ready — click to select, E toggle Move/Rotate, F deselect')
119
+ }
120
+
121
+ function createDeselectGround() {
122
+ const ground = engine.addEntity()
123
+ Transform.create(ground, {
124
+ position: Vector3.create(0, -0.05, 0),
125
+ scale: Vector3.create(300, 0.1, 300),
126
+ })
127
+ MeshCollider.setBox(ground, ColliderLayer.CL_POINTER)
128
+ editorEntities.add(ground)
129
+
130
+ pointerEventsSystem.onPointerDown(
131
+ { entity: ground, opts: { button: InputAction.IA_POINTER, maxDistance: 100, showFeedback: false } },
132
+ () => {
133
+ if (!state.editorActive || state.isDragging || gizmoClickConsumed) return
134
+ deselectEntity()
135
+ }
136
+ )
137
+ }