@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.
- package/README.md +5 -3
- package/context/sdk7-cheat-sheet.md +4 -0
- package/dist/index.js +0 -12
- package/dist/index.js.map +1 -1
- package/extensions/dcl-init.ts +58 -6
- package/package.json +3 -3
- package/prompts/system.md +71 -41
- package/skills/add-3d-models/SKILL.md +120 -70
- package/skills/add-interactivity/SKILL.md +74 -2
- package/skills/advanced-input/SKILL.md +34 -1
- package/skills/advanced-rendering/SKILL.md +82 -9
- package/skills/animations-tweens/SKILL.md +203 -98
- package/skills/audio-analysis/SKILL.md +164 -0
- package/skills/audio-video/SKILL.md +184 -83
- package/skills/build-ui/SKILL.md +25 -2
- package/skills/camera-control/SKILL.md +78 -7
- package/skills/create-scene/SKILL.md +56 -13
- package/skills/deploy-scene/SKILL.md +12 -0
- package/skills/deploy-worlds/SKILL.md +35 -0
- package/skills/editor-gizmo/.gitignore +11 -0
- package/skills/editor-gizmo/SKILL.md +222 -0
- package/skills/editor-gizmo/src/__editor/camera.ts +277 -0
- package/skills/editor-gizmo/src/__editor/discovery.ts +186 -0
- package/skills/editor-gizmo/src/__editor/drag.ts +265 -0
- package/skills/editor-gizmo/src/__editor/gizmo.ts +496 -0
- package/skills/editor-gizmo/src/__editor/history.ts +72 -0
- package/skills/editor-gizmo/src/__editor/index.ts +137 -0
- package/skills/editor-gizmo/src/__editor/input.ts +55 -0
- package/skills/editor-gizmo/src/__editor/math-utils.ts +114 -0
- package/skills/editor-gizmo/src/__editor/persistence.ts +113 -0
- package/skills/editor-gizmo/src/__editor/selection.ts +157 -0
- package/skills/editor-gizmo/src/__editor/state.ts +117 -0
- package/skills/editor-gizmo/src/__editor/ui.tsx +697 -0
- package/skills/game-design/SKILL.md +1 -2
- package/skills/lighting-environment/SKILL.md +103 -56
- package/skills/multiplayer-sync/SKILL.md +31 -2
- package/skills/nft-blockchain/SKILL.md +45 -40
- package/skills/npcs/SKILL.md +180 -0
- package/skills/optimize-scene/SKILL.md +7 -2
- package/skills/particle-system/SKILL.md +222 -0
- package/skills/player-avatar/SKILL.md +133 -7
- package/skills/player-physics/SKILL.md +93 -0
- package/skills/scene-runtime/SKILL.md +9 -5
- package/skills/visual-feedback/SKILL.md +1 -0
- package/extensions/dcl-setup-ollama.ts +0 -312
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: editor-gizmo
|
|
3
|
+
description: Enable the visual editor in a Decentraland scene with translate/rotate gizmos. Adds click-to-select, drag-to-move arrows, drag-to-rotate rings, plane handles, wireframe selection box, and UI overlay. Auto-discovers all entities declared in main-entities.ts. Use when user wants to enable the editor, add gizmos, edit the scene interactively, or tweak object positions and rotations in preview.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Visual Editor Gizmo
|
|
7
|
+
|
|
8
|
+
Add an in-scene visual editor that lets users click objects to select them, then drag arrow/disc handles to move or rotate them. The editor only edits entities declared in `main-entities.ts` — runtime-spawned entities are hidden from the hierarchy.
|
|
9
|
+
|
|
10
|
+
## How It Works
|
|
11
|
+
|
|
12
|
+
- **Preview only**: the editor only activates in `/preview`. Deployed scenes never show editor UI.
|
|
13
|
+
- **Editor toggle**: a pencil button in the bottom-right corner toggles the editor on/off (starts OFF).
|
|
14
|
+
- **Auto-discovery**: finds all entities with `Transform` + `MeshRenderer` or `GltfContainer`. Hierarchy filters to entities whose `Name` is declared in `main-entities.ts`.
|
|
15
|
+
- **Click to select**: shows a wireframe bounding box and spawns the gizmo.
|
|
16
|
+
- **Translate mode**: 3 colored arrows (R/G/B = X/Y/Z) — drag to move along a world axis. 3 plane handles (XZ/XY/YZ) for 2-axis constrained movement.
|
|
17
|
+
- **Rotate mode**: 3 colored ring outlines — drag to rotate around a world axis.
|
|
18
|
+
- **World-aligned gizmos**: arrows and rings always point along world X/Y/Z, regardless of entity or parent rotation. Drag deltas are converted to local space for child entities.
|
|
19
|
+
- **E key**: toggle between Move and Rotate.
|
|
20
|
+
- **F key** or **click ground**: deselect.
|
|
21
|
+
- **Undo/redo**: key 4 = undo, Shift+4 = redo.
|
|
22
|
+
- **Auto-save**: changes POST to `${realm.baseUrl}/editor/changes` on every drag end. The preview server merges them into `main-entities.ts` and synchronously regenerates `main.crdt`.
|
|
23
|
+
|
|
24
|
+
## Setup Steps
|
|
25
|
+
|
|
26
|
+
### Step 0: Check if editor is already installed (and up-to-date)
|
|
27
|
+
|
|
28
|
+
If `src/__editor/state.ts` exists, read the first line and look for `EDITOR_VERSION`. Compare it with the version in `{baseDir}/src/__editor/state.ts`. If the versions match, skip Step 1 — the files are current. If they differ (or `src/__editor/` doesn't exist), proceed with Step 1 to install or update.
|
|
29
|
+
|
|
30
|
+
### Step 1: Copy editor files into the scene
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
mkdir -p src/__editor && cp -rf {baseDir}/src/__editor/* src/__editor/
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
This creates a self-contained editor directory:
|
|
37
|
+
```
|
|
38
|
+
src/__editor/
|
|
39
|
+
├── index.ts — Entry point + enableEditor() export
|
|
40
|
+
├── state.ts — Shared state and types
|
|
41
|
+
├── persistence.ts — HTTP POST/GET to {baseUrl}/editor/changes
|
|
42
|
+
├── selection.ts — Select/deselect + highlight
|
|
43
|
+
├── discovery.ts — Auto-discover scene entities
|
|
44
|
+
├── gizmo.ts — Translate/rotate gizmo handles
|
|
45
|
+
├── drag.ts — Drag system (ray-plane intersection)
|
|
46
|
+
├── camera.ts — Editor camera (orbit, WASD pan)
|
|
47
|
+
├── input.ts — Key bindings (E, F, 1-4)
|
|
48
|
+
├── history.ts — Undo/redo stack
|
|
49
|
+
├── math-utils.ts — Vector/quaternion helpers
|
|
50
|
+
└── ui.tsx — Toolbar + hierarchy + properties panel
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Step 2: Add `enableEditor()` to the scene's main function
|
|
54
|
+
|
|
55
|
+
In `src/index.ts`:
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
import { enableEditor } from './__editor'
|
|
59
|
+
|
|
60
|
+
export function main() {
|
|
61
|
+
// ... your scene code ...
|
|
62
|
+
enableEditor()
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`enableEditor()` is a no-op outside preview mode, so it's safe to leave in deployed scenes.
|
|
67
|
+
|
|
68
|
+
### Step 3: Make sure the scene has a `main-entities.ts`
|
|
69
|
+
|
|
70
|
+
If the scene doesn't have one yet, create `main-entities.ts` at the scene root with at least one entity (see "Authoring Model" below). Without it, the editor's hierarchy panel falls back to permissive mode and shows every named entity, including runtime ones.
|
|
71
|
+
|
|
72
|
+
### Step 4: Update tsconfig.json to include `main-entities.ts`
|
|
73
|
+
|
|
74
|
+
The default scene `tsconfig.json` only checks `src/**/*`. Widen the include so the typed `Scene` shape is validated:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"extends": "@dcl/sdk/types/tsconfig.ecs7.json",
|
|
79
|
+
"include": ["src/**/*.ts", "src/**/*.tsx", "main-entities.ts"]
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Step 5: Verify
|
|
84
|
+
|
|
85
|
+
Run `/preview`. You should see a pencil button bottom-right. Click it to toggle the editor. Click any entity declared in `main-entities.ts` to select it; drag arrows or rings to move or rotate.
|
|
86
|
+
|
|
87
|
+
## Scene Authoring Model
|
|
88
|
+
|
|
89
|
+
The editor only edits **declared** entities — those that exist in `main-entities.ts`. Dynamic entities created at runtime via `engine.addEntity()` are hidden from the hierarchy and not draggable.
|
|
90
|
+
|
|
91
|
+
### `main-entities.ts` (canonical entity declarations)
|
|
92
|
+
|
|
93
|
+
`main-entities.ts` lives at the scene root, exports a typed `scene` constant, and is bundled into `main.crdt` at build time. The `satisfies Scene` clause keeps the literal keys typed (so code can reference entity names safely) while still validating the shape against the schema.
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
import type { Scene } from '@dcl/sdk/scene-types'
|
|
97
|
+
|
|
98
|
+
export const scene = {
|
|
99
|
+
"barrel_1": {
|
|
100
|
+
"components": {
|
|
101
|
+
"Transform": {
|
|
102
|
+
"position": { "x": 5, "y": 0, "z": 8 },
|
|
103
|
+
"rotation": { "x": 0, "y": 0, "z": 0, "w": 1 },
|
|
104
|
+
"scale": { "x": 1, "y": 1, "z": 1 }
|
|
105
|
+
},
|
|
106
|
+
"GltfContainer": { "src": "models/Barrel.glb" }
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
"lamp_1": {
|
|
110
|
+
"components": {
|
|
111
|
+
"Transform": {
|
|
112
|
+
"position": { "x": 0, "y": 1.5, "z": 0 },
|
|
113
|
+
"rotation": { "x": 0, "y": 0, "z": 0, "w": 1 },
|
|
114
|
+
"scale": { "x": 1, "y": 1, "z": 1 },
|
|
115
|
+
"parent": "barrel_1"
|
|
116
|
+
},
|
|
117
|
+
"GltfContainer": { "src": "models/Lamp.glb" }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} satisfies Scene
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Rules:**
|
|
124
|
+
- **Names are unique** within `scene` — they're the stable ID the editor and code use to reference an entity.
|
|
125
|
+
- **Parents are referenced by Name**, not entity ID (e.g. `"parent": "barrel_1"`). The build resolves names to IDs.
|
|
126
|
+
- **`Transform.position` is required.** `rotation` defaults to identity, `scale` defaults to `(1,1,1)`, but you must provide all three keys when authoring.
|
|
127
|
+
- **Literal-only constraint:** values inside `scene` must be plain JSON-compatible literals — no function calls (`Vector3.create(...)`), no spread, no computed expressions, no comments inside the literal. The build parses the AST and the editor save handler rewrites the whole literal as JSON, so anything outside this discipline gets stripped or breaks.
|
|
128
|
+
|
|
129
|
+
### Behavior in `src/index.ts`
|
|
130
|
+
|
|
131
|
+
Code references entities by Name and attaches behavior. Use a type-only import of `scene` so the bundle stays small, and derive `EntityName` for typo-safe lookups:
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
import { engine, pointerEventsSystem, InputAction } from '@dcl/sdk/ecs'
|
|
135
|
+
import { enableEditor } from './__editor'
|
|
136
|
+
import type { scene } from '../main-entities'
|
|
137
|
+
|
|
138
|
+
type EntityName = keyof typeof scene
|
|
139
|
+
|
|
140
|
+
export function main() {
|
|
141
|
+
const barrel = engine.getEntityOrNullByName<EntityName>('barrel_1')
|
|
142
|
+
if (barrel === null) return
|
|
143
|
+
|
|
144
|
+
pointerEventsSystem.onPointerDown(
|
|
145
|
+
{ entity: barrel, opts: { button: InputAction.IA_POINTER, hoverText: 'Open' } },
|
|
146
|
+
() => console.log('clicked barrel')
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
enableEditor()
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
- `engine.getEntityOrNullByName<EntityName>(name)` looks up an entity by its `Name` component, populated by the `main.crdt` preload (built from `main-entities.ts` at bundle time).
|
|
154
|
+
- The `<EntityName>` type parameter makes typos a compile error: passing `'barrl_1'` (typo) fails type-checking.
|
|
155
|
+
- Renaming an entity in `main-entities.ts` immediately surfaces every stale reference in code as a compile error.
|
|
156
|
+
- Use `getEntityOrNullByName` rather than `getEntityByName` — the SDK's typed `getEntityByName` requires awkward generics and silently returns `undefined` cast as `Entity`, so null-handling is cleaner.
|
|
157
|
+
|
|
158
|
+
### Dynamic entities
|
|
159
|
+
|
|
160
|
+
Anything that needs to spawn at runtime (effects, projectiles, dynamic UI markers) still uses `engine.addEntity()` directly:
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
const explosion = engine.addEntity()
|
|
164
|
+
Transform.create(explosion, { position: Vector3.create(8, 1, 8) })
|
|
165
|
+
GltfContainer.create(explosion, { src: 'models/Explosion.glb' })
|
|
166
|
+
// Don't give dynamic entities a Name — they don't go in main-entities.ts.
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
**Rule**: only declarative, editable entities go in `main-entities.ts`. Dynamic runtime entities use `engine.addEntity` and don't get `Name` components.
|
|
170
|
+
|
|
171
|
+
## Persistence
|
|
172
|
+
|
|
173
|
+
When the user drags an entity in preview:
|
|
174
|
+
|
|
175
|
+
1. The scene applies the new Transform client-side (instant).
|
|
176
|
+
2. The editor POSTs `${realm.baseUrl}/editor/changes` with the entity's new Transform, keyed by Name.
|
|
177
|
+
3. The preview server merges the change into `main-entities.ts` on disk by parsing the AST, mutating the scene object, and splicing the new JSON back into the source — preserving everything outside the `scene` literal (imports, `satisfies` clause, comments above the export).
|
|
178
|
+
4. The same handler synchronously regenerates `main.crdt` so the next reload preloads the updated state.
|
|
179
|
+
5. Both `main-entities.ts` and `main.crdt` are excluded from the file watcher, so editor saves do not trigger a scene reload.
|
|
180
|
+
|
|
181
|
+
There is no manual "save" step — every drag persists.
|
|
182
|
+
|
|
183
|
+
If the AI / a human edits `main-entities.ts` directly (without going through the editor), a dedicated watcher on `main-entities.ts` regenerates `main.crdt` out-of-band as well, with an mtime check that skips redundant work when the editor's POST handler already produced a fresh CRDT.
|
|
184
|
+
|
|
185
|
+
## Removing the Editor
|
|
186
|
+
|
|
187
|
+
To remove:
|
|
188
|
+
1. Delete `src/__editor/`
|
|
189
|
+
2. Remove the `import { enableEditor } from './__editor'` line and the `enableEditor()` call
|
|
190
|
+
|
|
191
|
+
`main-entities.ts` is unaffected — it remains the source of truth for declared entities, regardless of whether the editor is installed.
|
|
192
|
+
|
|
193
|
+
## Adding New Entities
|
|
194
|
+
|
|
195
|
+
When the user asks to add a new entity (e.g., "add a barrel"):
|
|
196
|
+
|
|
197
|
+
1. **Add the entry to `main-entities.ts`** with a unique Name and the components needed to render it (`Transform`, `GltfContainer` or `MeshRenderer` + `Material`, etc.). The TS compiler will validate the shape against `Scene`.
|
|
198
|
+
2. **Reference it in code** if you need to attach behavior:
|
|
199
|
+
```typescript
|
|
200
|
+
const barrel = engine.getEntityOrNullByName<EntityName>('barrel_1')
|
|
201
|
+
```
|
|
202
|
+
3. Run `/preview` — the entity will appear in the scene at the position declared in `main-entities.ts`, and will be draggable in the editor.
|
|
203
|
+
|
|
204
|
+
## Components supported in `main-entities.ts`
|
|
205
|
+
|
|
206
|
+
All ECS data components that the client renders/uses:
|
|
207
|
+
|
|
208
|
+
- `Transform` (required for every entity)
|
|
209
|
+
- `GltfContainer`, `MeshRenderer`, `MeshCollider`, `Material`
|
|
210
|
+
- `VisibilityComponent`, `Billboard`
|
|
211
|
+
- `AudioSource`, `VideoPlayer`, `TextShape`, `NftShape`
|
|
212
|
+
- `Animator` (state-machine config; runtime control via code)
|
|
213
|
+
|
|
214
|
+
Behavior — pointer event callbacks, systems, tweens, conditional logic — stays in `src/`.
|
|
215
|
+
|
|
216
|
+
## Common Pitfalls
|
|
217
|
+
|
|
218
|
+
- **Component shapes mirror the SDK protobuf.** `MeshRenderer` is `{ mesh: { $case: 'box', box: { uvs: [] } } }`, not `MeshRenderer.setBox()`. The TS compiler validates against the protobuf-derived types and will flag mismatches.
|
|
219
|
+
- **Don't use `Vector3.create(...)` inside `main-entities.ts`.** Use plain `{ x, y, z }` objects. The literal-only constraint means function calls and identifier references will break the build/save pipeline.
|
|
220
|
+
- **`box` mesh requires `uvs: []`.** Same for `plane`. Sphere and cylinder default to `{}`. The TS type forces this — pay attention to red squiggles.
|
|
221
|
+
- **Renaming an entity** breaks every code reference until you update them. That's the type system working as intended; let TS guide you to the broken references.
|
|
222
|
+
- **Comments inside the `scene` literal get wiped on the first editor save.** Keep comments outside the literal (above the import, before the `export const scene =` line).
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editor orbit camera + drag lock camera.
|
|
3
|
+
*
|
|
4
|
+
* Orbit model: target + yaw + pitch + distance.
|
|
5
|
+
* Lock camera: freezes view during gizmo drag (non-editor-cam mode only).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
engine,
|
|
10
|
+
Entity,
|
|
11
|
+
Transform,
|
|
12
|
+
VirtualCamera,
|
|
13
|
+
MainCamera,
|
|
14
|
+
InputModifier,
|
|
15
|
+
inputSystem,
|
|
16
|
+
InputAction,
|
|
17
|
+
PrimaryPointerInfo,
|
|
18
|
+
} from '@dcl/sdk/ecs'
|
|
19
|
+
import { Vector3, Quaternion } from '@dcl/sdk/math'
|
|
20
|
+
import { getSceneInformation } from '~system/Runtime'
|
|
21
|
+
import { state, editorEntities, selectableInfoMap, gizmoClickConsumed } from './state'
|
|
22
|
+
import { copyVec3, copyQuat } from './math-utils'
|
|
23
|
+
|
|
24
|
+
// ── Scene bounds (computed once) ────────────────────────
|
|
25
|
+
let sceneCenter = Vector3.create(8, 0, 8)
|
|
26
|
+
let sceneBoundsMin = Vector3.create(0, 0, 0)
|
|
27
|
+
let sceneBoundsMax = Vector3.create(16, 20, 16)
|
|
28
|
+
|
|
29
|
+
async function getSceneInformationAsync() {
|
|
30
|
+
try {
|
|
31
|
+
const info = await getSceneInformation({})
|
|
32
|
+
const metadata = JSON.parse(info.metadataJson)
|
|
33
|
+
const parcels: string[] = metadata?.scene?.parcels ?? ['0,0']
|
|
34
|
+
let minX = Infinity, minZ = Infinity, maxX = -Infinity, maxZ = -Infinity
|
|
35
|
+
for (const p of parcels) {
|
|
36
|
+
const [px, pz] = p.split(',').map(Number)
|
|
37
|
+
if (px < minX) minX = px
|
|
38
|
+
if (pz < minZ) minZ = pz
|
|
39
|
+
if (px > maxX) maxX = px
|
|
40
|
+
if (pz > maxZ) maxZ = pz
|
|
41
|
+
}
|
|
42
|
+
const base = parcels[0].split(',').map(Number)
|
|
43
|
+
const offX = -base[0] * 16
|
|
44
|
+
const offZ = -base[1] * 16
|
|
45
|
+
sceneBoundsMin = Vector3.create((minX * 16) + offX, 0, (minZ * 16) + offZ)
|
|
46
|
+
sceneBoundsMax = Vector3.create(((maxX + 1) * 16) + offX, 20, ((maxZ + 1) * 16) + offZ)
|
|
47
|
+
sceneCenter = Vector3.create(
|
|
48
|
+
(sceneBoundsMin.x + sceneBoundsMax.x) / 2,
|
|
49
|
+
0,
|
|
50
|
+
(sceneBoundsMin.z + sceneBoundsMax.z) / 2,
|
|
51
|
+
)
|
|
52
|
+
} catch (e) {
|
|
53
|
+
console.log('[editor] failed to read scene bounds, using defaults', e)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
void getSceneInformationAsync()
|
|
57
|
+
|
|
58
|
+
// ============================================================
|
|
59
|
+
// Editor Camera
|
|
60
|
+
// ============================================================
|
|
61
|
+
|
|
62
|
+
let editorCamEntity: Entity | undefined
|
|
63
|
+
|
|
64
|
+
const editorCam = {
|
|
65
|
+
target: Vector3.create(16, 2, 16),
|
|
66
|
+
yaw: -45,
|
|
67
|
+
pitch: 35,
|
|
68
|
+
distance: 25,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Tuning
|
|
72
|
+
const PAN_SPEED = 12
|
|
73
|
+
const VERTICAL_SPEED = 8
|
|
74
|
+
const ORBIT_SENSITIVITY = 0.15
|
|
75
|
+
const ZOOM_SPEED = 15
|
|
76
|
+
const MIN_DISTANCE = 3
|
|
77
|
+
const MAX_DISTANCE = 80
|
|
78
|
+
const MIN_PITCH = 5
|
|
79
|
+
const MAX_PITCH = 89
|
|
80
|
+
const FOCUS_DISTANCE = 8
|
|
81
|
+
|
|
82
|
+
export function createEditorCamera() {
|
|
83
|
+
editorCamEntity = engine.addEntity()
|
|
84
|
+
Transform.create(editorCamEntity, {
|
|
85
|
+
position: Vector3.Zero(),
|
|
86
|
+
rotation: Quaternion.Identity(),
|
|
87
|
+
})
|
|
88
|
+
VirtualCamera.create(editorCamEntity, {
|
|
89
|
+
defaultTransition: { transitionMode: VirtualCamera.Transition.Time(0) },
|
|
90
|
+
})
|
|
91
|
+
editorEntities.add(editorCamEntity)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function updateEditorCamera() {
|
|
95
|
+
if (editorCamEntity === undefined) return
|
|
96
|
+
|
|
97
|
+
const pitchRad = editorCam.pitch * (Math.PI / 180)
|
|
98
|
+
const yawRad = editorCam.yaw * (Math.PI / 180)
|
|
99
|
+
|
|
100
|
+
const cosP = Math.cos(pitchRad)
|
|
101
|
+
const offset = Vector3.create(
|
|
102
|
+
-Math.sin(yawRad) * cosP * editorCam.distance,
|
|
103
|
+
Math.sin(pitchRad) * editorCam.distance,
|
|
104
|
+
-Math.cos(yawRad) * cosP * editorCam.distance,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
const camPos = Vector3.add(editorCam.target, offset)
|
|
108
|
+
const forward = Vector3.normalize(Vector3.subtract(editorCam.target, camPos))
|
|
109
|
+
const camRot = Quaternion.lookRotation(forward)
|
|
110
|
+
|
|
111
|
+
const t = Transform.getMutable(editorCamEntity)
|
|
112
|
+
copyVec3(t.position, camPos)
|
|
113
|
+
copyQuat(t.rotation, camRot)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function activateEditorCamera() {
|
|
117
|
+
if (state.editorCamActive || state.isDragging) return
|
|
118
|
+
state.editorCamActive = true
|
|
119
|
+
|
|
120
|
+
// Center on scene
|
|
121
|
+
editorCam.target = Vector3.create(sceneCenter.x, 0, sceneCenter.z)
|
|
122
|
+
|
|
123
|
+
updateEditorCamera()
|
|
124
|
+
MainCamera.getMutable(engine.CameraEntity).virtualCameraEntity = editorCamEntity
|
|
125
|
+
|
|
126
|
+
InputModifier.createOrReplace(engine.PlayerEntity, {
|
|
127
|
+
mode: InputModifier.Mode.Standard({ disableAll: true }),
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
console.log('[editor] editor camera ON — WASD pan, Space/Shift up/down, 2/3 zoom, drag to orbit, F focus')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function deactivateEditorCamera() {
|
|
134
|
+
if (!state.editorCamActive) return
|
|
135
|
+
state.editorCamActive = false
|
|
136
|
+
|
|
137
|
+
MainCamera.getMutable(engine.CameraEntity).virtualCameraEntity = undefined
|
|
138
|
+
|
|
139
|
+
if (InputModifier.has(engine.PlayerEntity)) {
|
|
140
|
+
InputModifier.deleteFrom(engine.PlayerEntity)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
console.log('[editor] editor camera OFF')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function toggleEditorCamera() {
|
|
147
|
+
if (state.editorCamActive) deactivateEditorCamera()
|
|
148
|
+
else activateEditorCamera()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function focusSelectedEntity() {
|
|
152
|
+
if (state.selectedEntity === undefined || !Transform.has(state.selectedEntity)) return
|
|
153
|
+
const info = selectableInfoMap.get(state.selectedEntity)
|
|
154
|
+
const entityPos = Transform.get(state.selectedEntity).position
|
|
155
|
+
const offset = info?.centerOffset ?? Vector3.Zero()
|
|
156
|
+
|
|
157
|
+
editorCam.target = Vector3.create(
|
|
158
|
+
entityPos.x + offset.x,
|
|
159
|
+
entityPos.y + offset.y,
|
|
160
|
+
entityPos.z + offset.z,
|
|
161
|
+
)
|
|
162
|
+
editorCam.distance = FOCUS_DISTANCE
|
|
163
|
+
|
|
164
|
+
updateEditorCamera()
|
|
165
|
+
console.log(`[editor] focused on ${info?.name ?? 'entity'}`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function getCamRight(): Vector3 {
|
|
169
|
+
const yawRad = editorCam.yaw * (Math.PI / 180)
|
|
170
|
+
return Vector3.create(Math.cos(yawRad), 0, -Math.sin(yawRad))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function getCamForward(): Vector3 {
|
|
174
|
+
const yawRad = editorCam.yaw * (Math.PI / 180)
|
|
175
|
+
return Vector3.create(Math.sin(yawRad), 0, Math.cos(yawRad))
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function editorCameraSystem(dt: number) {
|
|
179
|
+
if (!state.editorActive || !state.editorCamActive) return
|
|
180
|
+
|
|
181
|
+
let changed = false
|
|
182
|
+
const right = getCamRight()
|
|
183
|
+
const forward = getCamForward()
|
|
184
|
+
|
|
185
|
+
if (inputSystem.isPressed(InputAction.IA_FORWARD)) {
|
|
186
|
+
editorCam.target = Vector3.add(editorCam.target, Vector3.scale(forward, PAN_SPEED * dt))
|
|
187
|
+
changed = true
|
|
188
|
+
}
|
|
189
|
+
if (inputSystem.isPressed(InputAction.IA_BACKWARD)) {
|
|
190
|
+
editorCam.target = Vector3.add(editorCam.target, Vector3.scale(forward, -PAN_SPEED * dt))
|
|
191
|
+
changed = true
|
|
192
|
+
}
|
|
193
|
+
if (inputSystem.isPressed(InputAction.IA_RIGHT)) {
|
|
194
|
+
editorCam.target = Vector3.add(editorCam.target, Vector3.scale(right, PAN_SPEED * dt))
|
|
195
|
+
changed = true
|
|
196
|
+
}
|
|
197
|
+
if (inputSystem.isPressed(InputAction.IA_LEFT)) {
|
|
198
|
+
editorCam.target = Vector3.add(editorCam.target, Vector3.scale(right, -PAN_SPEED * dt))
|
|
199
|
+
changed = true
|
|
200
|
+
}
|
|
201
|
+
if (inputSystem.isPressed(InputAction.IA_JUMP)) {
|
|
202
|
+
editorCam.target.y += VERTICAL_SPEED * dt
|
|
203
|
+
changed = true
|
|
204
|
+
}
|
|
205
|
+
if (inputSystem.isPressed(InputAction.IA_WALK)) {
|
|
206
|
+
editorCam.target.y -= VERTICAL_SPEED * dt
|
|
207
|
+
changed = true
|
|
208
|
+
}
|
|
209
|
+
if (inputSystem.isPressed(InputAction.IA_ACTION_4)) {
|
|
210
|
+
editorCam.distance = Math.max(MIN_DISTANCE, editorCam.distance - ZOOM_SPEED * dt)
|
|
211
|
+
changed = true
|
|
212
|
+
}
|
|
213
|
+
if (inputSystem.isPressed(InputAction.IA_ACTION_5)) {
|
|
214
|
+
editorCam.distance = Math.min(MAX_DISTANCE, editorCam.distance + ZOOM_SPEED * dt)
|
|
215
|
+
changed = true
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (inputSystem.isPressed(InputAction.IA_POINTER) && !state.isDragging && !gizmoClickConsumed) {
|
|
219
|
+
const pointer = PrimaryPointerInfo.getOrNull(engine.RootEntity)
|
|
220
|
+
if (pointer && pointer.screenDelta) {
|
|
221
|
+
const dx = pointer.screenDelta.x ?? 0
|
|
222
|
+
const dy = pointer.screenDelta.y ?? 0
|
|
223
|
+
if (Math.abs(dx) > 0.001 || Math.abs(dy) > 0.001) {
|
|
224
|
+
editorCam.yaw += dx * ORBIT_SENSITIVITY
|
|
225
|
+
editorCam.pitch = Math.max(MIN_PITCH, Math.min(MAX_PITCH, editorCam.pitch + dy * ORBIT_SENSITIVITY))
|
|
226
|
+
changed = true
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (changed) updateEditorCamera()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ============================================================
|
|
235
|
+
// Drag Lock Camera
|
|
236
|
+
// ============================================================
|
|
237
|
+
|
|
238
|
+
let lockCamEntity: Entity | undefined
|
|
239
|
+
|
|
240
|
+
export function createLockCamera() {
|
|
241
|
+
lockCamEntity = engine.addEntity()
|
|
242
|
+
Transform.create(lockCamEntity, {
|
|
243
|
+
position: Vector3.Zero(),
|
|
244
|
+
rotation: Quaternion.Identity(),
|
|
245
|
+
})
|
|
246
|
+
VirtualCamera.create(lockCamEntity, {
|
|
247
|
+
defaultTransition: { transitionMode: VirtualCamera.Transition.Time(0) },
|
|
248
|
+
})
|
|
249
|
+
editorEntities.add(lockCamEntity)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function lockCamera() {
|
|
253
|
+
if (state.editorCamActive || lockCamEntity === undefined) return
|
|
254
|
+
if (!Transform.has(engine.CameraEntity)) return
|
|
255
|
+
const camT = Transform.get(engine.CameraEntity)
|
|
256
|
+
const lockT = Transform.getMutable(lockCamEntity)
|
|
257
|
+
copyVec3(lockT.position, camT.position)
|
|
258
|
+
copyQuat(lockT.rotation, camT.rotation)
|
|
259
|
+
MainCamera.getMutable(engine.CameraEntity).virtualCameraEntity = lockCamEntity
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function unlockCamera() {
|
|
263
|
+
if (state.editorCamActive || lockCamEntity === undefined) return
|
|
264
|
+
MainCamera.getMutable(engine.CameraEntity).virtualCameraEntity = undefined
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ============================================================
|
|
268
|
+
// Active Camera Helper
|
|
269
|
+
// ============================================================
|
|
270
|
+
|
|
271
|
+
/** Returns the active camera transform -- editor cam when active, otherwise player camera. */
|
|
272
|
+
export function getActiveCameraTransform() {
|
|
273
|
+
if (state.editorCamActive && editorCamEntity !== undefined && Transform.has(editorCamEntity)) {
|
|
274
|
+
return Transform.get(editorCamEntity)
|
|
275
|
+
}
|
|
276
|
+
return Transform.get(engine.CameraEntity)
|
|
277
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/** Auto-discovery of scene entities. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
engine,
|
|
5
|
+
Entity,
|
|
6
|
+
Transform,
|
|
7
|
+
MeshRenderer,
|
|
8
|
+
MeshCollider,
|
|
9
|
+
Material,
|
|
10
|
+
GltfContainer,
|
|
11
|
+
Name,
|
|
12
|
+
pointerEventsSystem,
|
|
13
|
+
InputAction,
|
|
14
|
+
ColliderLayer,
|
|
15
|
+
} from '@dcl/sdk/ecs'
|
|
16
|
+
import { Vector3 } from '@dcl/sdk/math'
|
|
17
|
+
import {
|
|
18
|
+
SelectableInfo,
|
|
19
|
+
state,
|
|
20
|
+
editorEntities,
|
|
21
|
+
selectableInfoMap,
|
|
22
|
+
originalMaterials,
|
|
23
|
+
gizmoClickConsumed,
|
|
24
|
+
} from './state'
|
|
25
|
+
import { selectEntity } from './selection'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
/** Built-in entities to never register */
|
|
29
|
+
export const SKIP_ENTITIES = new Set<Entity>()
|
|
30
|
+
|
|
31
|
+
/** Entity names to skip (case-insensitive). Add names here to prevent selection. */
|
|
32
|
+
const SKIP_NAMES = new Set(['ground', 'floor'])
|
|
33
|
+
|
|
34
|
+
function getEntityName(entity: Entity): string {
|
|
35
|
+
if (Name.has(entity)) {
|
|
36
|
+
return Name.get(entity).value
|
|
37
|
+
}
|
|
38
|
+
if (GltfContainer.has(entity)) {
|
|
39
|
+
const src = GltfContainer.get(entity).src
|
|
40
|
+
const filename = src.split('/').pop() ?? src
|
|
41
|
+
return filename.replace(/\.(glb|gltf)$/i, '')
|
|
42
|
+
}
|
|
43
|
+
if (MeshRenderer.has(entity)) {
|
|
44
|
+
const mr = MeshRenderer.get(entity) as any
|
|
45
|
+
const meshCase = mr?.mesh?.$case ?? 'mesh'
|
|
46
|
+
return `${meshCase} #${entity}`
|
|
47
|
+
}
|
|
48
|
+
return `entity #${entity}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function detectMeshType(entity: Entity): 'box' | 'sphere' | 'cylinder' {
|
|
52
|
+
if (!MeshRenderer.has(entity)) return 'box'
|
|
53
|
+
try {
|
|
54
|
+
const mr = MeshRenderer.get(entity) as any
|
|
55
|
+
const c = mr?.mesh?.$case
|
|
56
|
+
if (c === 'sphere') return 'sphere'
|
|
57
|
+
if (c === 'cylinder') return 'cylinder'
|
|
58
|
+
} catch {}
|
|
59
|
+
return 'box'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function estimateBounds(entity: Entity): { centerOffset: Vector3; boundsSize: Vector3; isModel: boolean } {
|
|
63
|
+
const t = Transform.get(entity)
|
|
64
|
+
const s = t.scale ?? Vector3.One()
|
|
65
|
+
|
|
66
|
+
if (GltfContainer.has(entity)) {
|
|
67
|
+
return {
|
|
68
|
+
centerOffset: Vector3.create(0, Math.max(s.y * 0.5, 0.3), 0),
|
|
69
|
+
boundsSize: Vector3.create(Math.max(s.x, 0.5), Math.max(s.y, 0.5), Math.max(s.z, 0.5)),
|
|
70
|
+
isModel: true,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
centerOffset: Vector3.Zero(),
|
|
76
|
+
boundsSize: Vector3.create(s.x, s.y, s.z),
|
|
77
|
+
isModel: false,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function registerEntity(entity: Entity) {
|
|
82
|
+
if (selectableInfoMap.has(entity)) return
|
|
83
|
+
if (editorEntities.has(entity)) return
|
|
84
|
+
if (SKIP_ENTITIES.has(entity)) return
|
|
85
|
+
|
|
86
|
+
// Only declared entities (those with a Name) are editable. Dynamic
|
|
87
|
+
// entities created at runtime via engine.addEntity() are intentionally
|
|
88
|
+
// skipped — by convention they have no Name component.
|
|
89
|
+
if (!Name.has(entity)) return
|
|
90
|
+
|
|
91
|
+
const n = Name.get(entity).value.toLowerCase()
|
|
92
|
+
if (SKIP_NAMES.has(n)) return
|
|
93
|
+
|
|
94
|
+
const { centerOffset, boundsSize, isModel } = estimateBounds(entity)
|
|
95
|
+
const name = getEntityName(entity)
|
|
96
|
+
const colliderShape = isModel ? 'box' : detectMeshType(entity)
|
|
97
|
+
|
|
98
|
+
const hadMeshCollider = MeshCollider.has(entity)
|
|
99
|
+
if (!hadMeshCollider) {
|
|
100
|
+
MeshCollider.setBox(entity, ColliderLayer.CL_POINTER)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let originalVisibleMask: number | undefined
|
|
104
|
+
let originalInvisibleMask: number | undefined
|
|
105
|
+
if (GltfContainer.has(entity)) {
|
|
106
|
+
const gltf = GltfContainer.get(entity)
|
|
107
|
+
originalVisibleMask = gltf.visibleMeshesCollisionMask
|
|
108
|
+
originalInvisibleMask = gltf.invisibleMeshesCollisionMask
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!isModel && Material.has(entity)) {
|
|
112
|
+
try {
|
|
113
|
+
const mat = Material.get(entity) as any
|
|
114
|
+
const pbr = mat?.pbr ?? mat?.material?.pbr
|
|
115
|
+
if (pbr?.albedoColor) {
|
|
116
|
+
const c = pbr.albedoColor
|
|
117
|
+
originalMaterials.set(entity, { r: c.r ?? 0, g: c.g ?? 0, b: c.b ?? 0, a: c.a ?? 1 })
|
|
118
|
+
}
|
|
119
|
+
} catch {}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Read parent entity from Transform
|
|
123
|
+
let parentEntity: number | undefined
|
|
124
|
+
const t = Transform.get(entity)
|
|
125
|
+
const rawParent = t.parent
|
|
126
|
+
if (rawParent !== undefined && rawParent !== 0 && rawParent !== (entity as number)) {
|
|
127
|
+
parentEntity = rawParent as number
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const info: SelectableInfo = {
|
|
131
|
+
name,
|
|
132
|
+
centerOffset,
|
|
133
|
+
boundsSize,
|
|
134
|
+
isModel,
|
|
135
|
+
colliderShape,
|
|
136
|
+
originalVisibleMask,
|
|
137
|
+
originalInvisibleMask,
|
|
138
|
+
src: GltfContainer.has(entity) ? GltfContainer.get(entity).src : undefined,
|
|
139
|
+
meshType: !isModel ? colliderShape : undefined,
|
|
140
|
+
parentEntity,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
selectableInfoMap.set(entity, info)
|
|
144
|
+
|
|
145
|
+
pointerEventsSystem.onPointerDown(
|
|
146
|
+
{
|
|
147
|
+
entity,
|
|
148
|
+
opts: { button: InputAction.IA_POINTER, hoverText: `Select ${name}`, maxDistance: 100 },
|
|
149
|
+
},
|
|
150
|
+
() => {
|
|
151
|
+
if (!state.editorActive || state.isDragging || gizmoClickConsumed) return
|
|
152
|
+
selectEntity(entity)
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function discoverySystem() {
|
|
158
|
+
if (!state.editorActive) return
|
|
159
|
+
|
|
160
|
+
for (const [entity] of engine.getEntitiesWith(Transform, MeshRenderer)) {
|
|
161
|
+
registerEntity(entity)
|
|
162
|
+
}
|
|
163
|
+
for (const [entity] of engine.getEntitiesWith(Transform, GltfContainer)) {
|
|
164
|
+
registerEntity(entity)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Remove pointer events from all discovered entities (hides hover text). */
|
|
169
|
+
export function removeAllPointerEvents() {
|
|
170
|
+
for (const [entity] of selectableInfoMap) {
|
|
171
|
+
pointerEventsSystem.removeOnPointerDown(entity)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Re-add pointer events on all discovered entities. */
|
|
176
|
+
export function restoreAllPointerEvents() {
|
|
177
|
+
for (const [entity, info] of selectableInfoMap) {
|
|
178
|
+
pointerEventsSystem.onPointerDown(
|
|
179
|
+
{ entity, opts: { button: InputAction.IA_POINTER, hoverText: `Select ${info.name}`, maxDistance: 100 } },
|
|
180
|
+
() => {
|
|
181
|
+
if (!state.editorActive || state.isDragging || gizmoClickConsumed) return
|
|
182
|
+
selectEntity(entity)
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
}
|