|
| 1 | +/** |
| 2 | + * ColliderLayer showcase scene |
| 3 | + * |
| 4 | + * Demonstrates how each ColliderLayer value (CL_PLAYER, CL_MAIN_PLAYER, CL_PHYSICS, |
| 5 | + * CL_POINTER, CL_CUSTOM*) behaves across the SDK components that consume collider |
| 6 | + * masks: TriggerArea, Raycast, MeshCollider, and GltfContainer. |
| 7 | + * |
| 8 | + * Parcel: 5,5 |
| 9 | + * |
| 10 | + * Layout (top-down, north = +z): |
| 11 | + * |
| 12 | + * z=14 [T1] [T2] [T3] [T4] ← TriggerArea row |
| 13 | + * |
| 14 | + * TriggerArea boxes (T1–T4): |
| 15 | + * Box 1: CL_PLAYER — fires for remote avatars only |
| 16 | + * Box 2: CL_MAIN_PLAYER — fires for the local player only |
| 17 | + * Box 3: CL_PLAYER|CL_MAIN_PLAYER — fires for both local and remote |
| 18 | + * Box 4: CL_PHYSICS — negative control: never fires for any avatar (CL_PHYSICS targets scene-mesh walls / floors, not the character). |
| 19 | + * z=11 [Gltf] ← GltfContainer with CL_MAIN_PLAYER visible mesh |
| 20 | + * |
| 21 | + * z=8.5 [MC_N] |
| 22 | + * z=7.77 [MC_NW] [MC_NE] |
| 23 | + * z=6 [MC_W] ⊙ [MC_E] ← Raycast emitter (clickable sphere) |
| 24 | + * z=4.23 [MC_SW] [MC_SE] |
| 25 | + * z=3.5 [MC_S] |
| 26 | + * |
| 27 | + * - Click the yellow sphere to rotate its forward direction by +45° around Y. |
| 28 | + * - The thin yellow beam visualises the current ray: full length if no hit, |
| 29 | + * shortened to the hit distance if a collider qualifies. |
| 30 | + * - The mask cycles every 2 s (CL_PLAYER → CL_MAIN_PLAYER → CL_PHYSICS) so |
| 31 | + * you can hold a rotation and watch all three results in turn. |
| 32 | + */ |
| 33 | + |
| 34 | +import { |
| 35 | + Billboard, |
| 36 | + BillboardMode, |
| 37 | + ColliderLayer, |
| 38 | + GltfContainer, |
| 39 | + InputAction, |
| 40 | + Material, |
| 41 | + MeshCollider, |
| 42 | + MeshRenderer, |
| 43 | + Raycast, |
| 44 | + RaycastQueryType, |
| 45 | + RaycastResult, |
| 46 | + TextShape, |
| 47 | + Transform, |
| 48 | + TriggerArea, |
| 49 | + engine, |
| 50 | + pointerEventsSystem, |
| 51 | + triggerAreaEventsSystem, |
| 52 | +} from '@dcl/sdk/ecs' |
| 53 | +import { Color3, Color4, Quaternion, Vector3 } from '@dcl/sdk/math' |
| 54 | + |
| 55 | +// ─── helpers ───────────────────────────────────────────────────────────────── |
| 56 | + |
| 57 | +function label(text: string, pos: Vector3, color: Color4 = Color4.White()): void { |
| 58 | + const e = engine.addEntity() |
| 59 | + Transform.create(e, { position: pos }) |
| 60 | + Billboard.create(e, { billboardMode: BillboardMode.BM_Y }) |
| 61 | + TextShape.create(e, { text, textColor: color, fontSize: 3 }) |
| 62 | +} |
| 63 | + |
| 64 | +// ─── A. TriggerArea row (moved north to give the raycast section open space) ── |
| 65 | + |
| 66 | +function makeTriggerBox( |
| 67 | + x: number, |
| 68 | + maskLabel: string, |
| 69 | + initialColor: Color4, |
| 70 | + mask: ColliderLayer | number |
| 71 | +): void { |
| 72 | + const z = 14 |
| 73 | + const pos = Vector3.create(x, 1, z) |
| 74 | + const e = engine.addEntity() |
| 75 | + Transform.create(e, { position: pos, scale: Vector3.create(1.5, 2, 1.5) }) |
| 76 | + MeshRenderer.setBox(e) |
| 77 | + Material.setPbrMaterial(e, { albedoColor: { ...initialColor, a: 0.5 } }) |
| 78 | + TriggerArea.setBox(e, mask as ColliderLayer) |
| 79 | + label(maskLabel, Vector3.create(x, pos.y + 1.8, z)) |
| 80 | + |
| 81 | + triggerAreaEventsSystem.onTriggerEnter(e, (result) => { |
| 82 | + const trigEntity = result.trigger?.entity ?? -1 |
| 83 | + console.log(`TriggerArea [${maskLabel}] ENTER — triggering entity: ${trigEntity}`) |
| 84 | + Material.setPbrMaterial(e, { albedoColor: Color4.create(0, 1, 0.4, 0.7) }) |
| 85 | + }) |
| 86 | + |
| 87 | + triggerAreaEventsSystem.onTriggerExit(e, () => { |
| 88 | + Material.setPbrMaterial(e, { albedoColor: { ...initialColor, a: 0.5 } }) |
| 89 | + }) |
| 90 | +} |
| 91 | + |
| 92 | +makeTriggerBox(3, 'CL_PLAYER', Color4.create(0.2, 0.5, 1, 0.5), ColliderLayer.CL_PLAYER) |
| 93 | +makeTriggerBox(5, 'CL_MAIN_PLAYER', Color4.create(1, 0.5, 0, 0.5), ColliderLayer.CL_MAIN_PLAYER) |
| 94 | +makeTriggerBox( |
| 95 | + 7, |
| 96 | + 'CL_PLAYER\n| CL_MAIN_PLAYER', |
| 97 | + Color4.create(0.8, 0.2, 1, 0.5), |
| 98 | + (ColliderLayer.CL_PLAYER | ColliderLayer.CL_MAIN_PLAYER) as unknown as ColliderLayer |
| 99 | +) |
| 100 | +makeTriggerBox(9, 'CL_PHYSICS (no hit)', Color4.create(0.5, 0.5, 0.5, 0.5), ColliderLayer.CL_PHYSICS) |
| 101 | + |
| 102 | +// ─── B. Raycast emitter — clickable sphere + ring of MeshCollider targets ──── |
| 103 | + |
| 104 | +const SPHERE_POS = Vector3.create(8, 1, 6) |
| 105 | +const SPHERE_SCALE = 0.3 |
| 106 | +const SPHERE_RADIUS_WORLD = SPHERE_SCALE * 0.5 // built-in sphere is unit-diameter |
| 107 | + |
| 108 | +const RAY_MAX_DISTANCE = 3 |
| 109 | +// Push the ray origin just outside the sphere collider so we never self-hit. |
| 110 | +const RAY_ORIGIN_OFFSET = SPHERE_RADIUS_WORLD + 0.05 |
| 111 | + |
| 112 | +const RAYCAST_MASKS: Array<{ name: string; mask: number }> = [ |
| 113 | + { name: 'CL_PLAYER', mask: ColliderLayer.CL_PLAYER }, |
| 114 | + { name: 'CL_MAIN_PLAYER', mask: ColliderLayer.CL_MAIN_PLAYER }, |
| 115 | + { name: 'CL_PHYSICS', mask: ColliderLayer.CL_PHYSICS }, |
| 116 | + { name: 'CL_CUSTOM1', mask: ColliderLayer.CL_CUSTOM1 }, |
| 117 | +] |
| 118 | + |
| 119 | +const raycastEntity = engine.addEntity() |
| 120 | +Transform.create(raycastEntity, { |
| 121 | + position: SPHERE_POS, |
| 122 | + scale: Vector3.create(SPHERE_SCALE, SPHERE_SCALE, SPHERE_SCALE), |
| 123 | +}) |
| 124 | +MeshRenderer.setSphere(raycastEntity) |
| 125 | +Material.setPbrMaterial(raycastEntity, { |
| 126 | + albedoColor: Color4.create(1, 1, 0, 1), |
| 127 | + emissiveColor: Color3.create(0.5, 0.5, 0), |
| 128 | + emissiveIntensity: 1, |
| 129 | +}) |
| 130 | +// CL_POINTER lets the player's cursor click the sphere. The originOffset above |
| 131 | +// guarantees the cycling raycast never self-hits this pointer collider. |
| 132 | +MeshCollider.setSphere(raycastEntity, ColliderLayer.CL_POINTER) |
| 133 | + |
| 134 | +// Hover hint via the pointer-events component (clickable). |
| 135 | +pointerEventsSystem.onPointerDown( |
| 136 | + { |
| 137 | + entity: raycastEntity, |
| 138 | + opts: { button: InputAction.IA_POINTER, hoverText: 'Rotate ray +45°' }, |
| 139 | + }, |
| 140 | + () => { |
| 141 | + rotateBy45() |
| 142 | + } |
| 143 | +) |
| 144 | + |
| 145 | +// Label above the sphere — keep it as a separate (un-parented) entity so the |
| 146 | +// sphere's 0.3 scale doesn't shrink the text. The text colour signals whether |
| 147 | +// the latest raycast qualified a hit (green) or not (yellow). |
| 148 | +const LABEL_COLOR_HIT = Color4.create(0.2, 1, 0.4, 1) |
| 149 | +const LABEL_COLOR_NO_HIT = Color4.create(1, 0.95, 0.2, 1) |
| 150 | + |
| 151 | +const raycastLabelEntity = engine.addEntity() |
| 152 | +Transform.create(raycastLabelEntity, { position: Vector3.create(SPHERE_POS.x, SPHERE_POS.y + 1.2, SPHERE_POS.z) }) |
| 153 | +Billboard.create(raycastLabelEntity, { billboardMode: BillboardMode.BM_Y }) |
| 154 | +TextShape.create(raycastLabelEntity, { |
| 155 | + text: 'Raycast: CL_PLAYER\n(click sphere to rotate)', |
| 156 | + fontSize: 3, |
| 157 | + textColor: LABEL_COLOR_NO_HIT, |
| 158 | +}) |
| 159 | + |
| 160 | +// Ray visualiser — thin elongated box updated each cycle/click. |
| 161 | +const visualizerEntity = engine.addEntity() |
| 162 | +Transform.create(visualizerEntity, { |
| 163 | + position: SPHERE_POS, |
| 164 | + scale: Vector3.create(0.04, 0.04, RAY_MAX_DISTANCE - RAY_ORIGIN_OFFSET), |
| 165 | +}) |
| 166 | +MeshRenderer.setBox(visualizerEntity) |
| 167 | +Material.setPbrMaterial(visualizerEntity, { |
| 168 | + albedoColor: Color4.create(1, 1, 0, 0.85), |
| 169 | + emissiveColor: Color3.create(0.9, 0.9, 0.1), |
| 170 | + emissiveIntensity: 0.6, |
| 171 | +}) |
| 172 | + |
| 173 | +// ─── B.1 cycling state + rotation ───────────────────────────────────────────── |
| 174 | + |
| 175 | +let raycastMaskIndex = 0 |
| 176 | +let raycastTimer = 0 |
| 177 | +let raycastTimestamp = 0 |
| 178 | +let displayedMaskName = RAYCAST_MASKS[0].name |
| 179 | +let currentYDeg = 0 |
| 180 | + |
| 181 | +function currentDirectionXZ(): { x: number; z: number } { |
| 182 | + const rad = (currentYDeg * Math.PI) / 180 |
| 183 | + return { x: Math.sin(rad), z: Math.cos(rad) } |
| 184 | +} |
| 185 | + |
| 186 | +function fireRaycast(mask: number): void { |
| 187 | + raycastTimestamp++ |
| 188 | + const dir = currentDirectionXZ() |
| 189 | + Raycast.createOrReplace(raycastEntity, { |
| 190 | + // originOffset is added in world space (NOT rotated by the entity's |
| 191 | + // rotation) per the Explorer's PBRaycast handling, so we compute the |
| 192 | + // rotated offset ourselves. |
| 193 | + originOffset: Vector3.create(dir.x * RAY_ORIGIN_OFFSET, 0, dir.z * RAY_ORIGIN_OFFSET), |
| 194 | + direction: { $case: 'localDirection', localDirection: Vector3.Forward() }, |
| 195 | + maxDistance: RAY_MAX_DISTANCE, |
| 196 | + queryType: RaycastQueryType.RQT_HIT_FIRST, |
| 197 | + continuous: false, |
| 198 | + collisionMask: mask, |
| 199 | + timestamp: raycastTimestamp, |
| 200 | + }) |
| 201 | +} |
| 202 | + |
| 203 | +function updateVisualizer(hitDistance: number | null): void { |
| 204 | + const dir = currentDirectionXZ() |
| 205 | + // hitDistance is measured from the ray origin (already past RAY_ORIGIN_OFFSET). |
| 206 | + const length = hitDistance ?? RAY_MAX_DISTANCE |
| 207 | + // Visualiser starts at the ray origin (sphere edge) and extends `length` along the ray. |
| 208 | + const centerDistance = RAY_ORIGIN_OFFSET + length / 2 |
| 209 | + |
| 210 | + const t = Transform.getMutable(visualizerEntity) |
| 211 | + t.position = Vector3.create( |
| 212 | + SPHERE_POS.x + dir.x * centerDistance, |
| 213 | + SPHERE_POS.y, |
| 214 | + SPHERE_POS.z + dir.z * centerDistance |
| 215 | + ) |
| 216 | + t.rotation = Quaternion.fromEulerDegrees(0, currentYDeg, 0) |
| 217 | + t.scale = Vector3.create(0.04, 0.04, length) |
| 218 | +} |
| 219 | + |
| 220 | +function rotateBy45(): void { |
| 221 | + currentYDeg = (currentYDeg + 45) % 360 |
| 222 | + Transform.getMutable(raycastEntity).rotation = Quaternion.fromEulerDegrees(0, currentYDeg, 0) |
| 223 | + // Re-fire so the visualiser updates without waiting for the cycle timer. |
| 224 | + fireRaycast(RAYCAST_MASKS[raycastMaskIndex].mask) |
| 225 | +} |
| 226 | + |
| 227 | +// Seed the first raycast & visualiser. |
| 228 | +fireRaycast(RAYCAST_MASKS[0].mask) |
| 229 | +updateVisualizer(null) |
| 230 | + |
| 231 | +engine.addSystem((dt: number) => { |
| 232 | + raycastTimer += dt |
| 233 | + if (raycastTimer < 2) return |
| 234 | + raycastTimer = 0 |
| 235 | + |
| 236 | + // 1. Read result of last raycast and refresh the label + visualiser. |
| 237 | + const result = RaycastResult.getOrNull(raycastEntity) |
| 238 | + const didHit = !!(result && result.hits.length > 0) |
| 239 | + let hitInfo = 'no hit' |
| 240 | + let hitDistance: number | null = null |
| 241 | + if (didHit) { |
| 242 | + const h = result!.hits[0] |
| 243 | + hitDistance = h.length ?? null |
| 244 | + hitInfo = `entity:${h.entityId ?? '?'} d:${hitDistance?.toFixed(2) ?? '?'}` |
| 245 | + } |
| 246 | + const lbl = TextShape.getMutable(raycastLabelEntity) |
| 247 | + lbl.text = `Raycast: ${displayedMaskName}\n${hitInfo}` |
| 248 | + lbl.textColor = didHit ? LABEL_COLOR_HIT : LABEL_COLOR_NO_HIT |
| 249 | + updateVisualizer(hitDistance) |
| 250 | + |
| 251 | + // 2. Cycle mask and fire next ray. |
| 252 | + raycastMaskIndex = (raycastMaskIndex + 1) % RAYCAST_MASKS.length |
| 253 | + const next = RAYCAST_MASKS[raycastMaskIndex] |
| 254 | + fireRaycast(next.mask) |
| 255 | + displayedMaskName = next.name |
| 256 | +}) |
| 257 | + |
| 258 | +// ─── B.2 MeshCollider ring around the sphere ───────────────────────────────── |
| 259 | + |
| 260 | +interface RingCube { |
| 261 | + angle: number // degrees from +Z (forward), clockwise as you look down |
| 262 | + mask: ColliderLayer | number |
| 263 | + name: string |
| 264 | + color: Color4 |
| 265 | +} |
| 266 | + |
| 267 | +const RING_RADIUS = 2.5 |
| 268 | +const CUBE_SCALE = 0.8 |
| 269 | + |
| 270 | +const RING_CUBES: RingCube[] = [ |
| 271 | + { angle: 0, mask: ColliderLayer.CL_PHYSICS, name: 'CL_PHYSICS', color: Color4.create(0.5, 0.5, 0.5, 1) }, |
| 272 | + { angle: 45, mask: ColliderLayer.CL_POINTER, name: 'CL_POINTER', color: Color4.create(0.8, 0.3, 0.9, 1) }, |
| 273 | + { angle: 90, mask: (ColliderLayer.CL_PHYSICS | ColliderLayer.CL_POINTER), name: 'CL_PHYSICS|POINTER', color: Color4.create(0.95, 0.95, 0.95, 1) }, |
| 274 | + { angle: 135, mask: ColliderLayer.CL_PLAYER, name: 'CL_PLAYER', color: Color4.create(0.2, 0.5, 1, 1) }, |
| 275 | + { angle: 180, mask: ColliderLayer.CL_MAIN_PLAYER, name: 'CL_MAIN_PLAYER', color: Color4.create(1, 0.55, 0.1, 1) }, |
| 276 | + { angle: 225, mask: (ColliderLayer.CL_PLAYER | ColliderLayer.CL_MAIN_PLAYER), name: 'CL_PLAYER|CL_MAIN', color: Color4.create(0.9, 0.3, 0.7, 1) }, |
| 277 | + { angle: 270, mask: ColliderLayer.CL_CUSTOM1, name: 'CL_CUSTOM1', color: Color4.create(0.3, 0.85, 0.4, 1) }, |
| 278 | + { angle: 315, mask: (ColliderLayer.CL_PHYSICS | ColliderLayer.CL_PLAYER), name: 'CL_PHYSICS|CL_PLAYER',color: Color4.create(0.2, 0.85, 0.95, 1) }, |
| 279 | +] |
| 280 | + |
| 281 | +for (const cube of RING_CUBES) { |
| 282 | + const rad = (cube.angle * Math.PI) / 180 |
| 283 | + const x = SPHERE_POS.x + Math.sin(rad) * RING_RADIUS |
| 284 | + const z = SPHERE_POS.z + Math.cos(rad) * RING_RADIUS |
| 285 | + |
| 286 | + const e = engine.addEntity() |
| 287 | + Transform.create(e, { |
| 288 | + position: Vector3.create(x, SPHERE_POS.y, z), |
| 289 | + scale: Vector3.create(CUBE_SCALE, CUBE_SCALE, CUBE_SCALE), |
| 290 | + }) |
| 291 | + MeshRenderer.setBox(e) |
| 292 | + Material.setPbrMaterial(e, { albedoColor: cube.color }) |
| 293 | + MeshCollider.setBox(e, cube.mask as ColliderLayer) |
| 294 | + label(cube.name, Vector3.create(x, SPHERE_POS.y + 0.9, z)) |
| 295 | +} |
| 296 | + |
| 297 | +// ─── C. GltfContainer with CL_MAIN_PLAYER visible collider ─────────────────── |
| 298 | + |
| 299 | +label('GltfContainer: visible CL_MAIN_PLAYER', Vector3.create(13, 3, 11)) |
| 300 | + |
| 301 | +const gltfEntity = engine.addEntity() |
| 302 | +Transform.create(gltfEntity, { position: Vector3.create(13, 1, 11) }) |
| 303 | +GltfContainer.create(gltfEntity, { |
| 304 | + src: 'assets/starCoin.glb', |
| 305 | + visibleMeshesCollisionMask: ColliderLayer.CL_MAIN_PLAYER, |
| 306 | + invisibleMeshesCollisionMask: ColliderLayer.CL_NONE, |
| 307 | +}) |
| 308 | + |
0 commit comments