Skip to content

Commit 271981d

Browse files
authored
chore: sdk collider layers test scene (#71)
1 parent 2ca6d66 commit 271981d

9 files changed

Lines changed: 386 additions & 1 deletion

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ ipfs
1313
yarn.lock
1414
**/tsconfig.tsbuildinfo
1515
static
16-
dclcontext
16+
dclcontext
17+
.idea

dcl-workspace.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@
8181
{
8282
"path": "scenes/5,90-scene-bounds-check"
8383
},
84+
{
85+
"path": "scenes/5,5-collider-layers"
86+
},
8487
{
8588
"path": "scenes/4,22-avatar-shape-scene-emotes"
8689
},
15.7 KB
Binary file not shown.
83.6 KB
Loading

scenes/5,5-collider-layers/main.crdt

Whitespace-only changes.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "collider-layers",
3+
"version": "1.0.0",
4+
"description": "Showcase scene for the ColliderLayer enum — demonstrates how each layer (CL_PLAYER, CL_MAIN_PLAYER, CL_PHYSICS, CL_POINTER, CL_CUSTOM*) behaves across TriggerArea, Raycast, MeshCollider, and GltfContainer.",
5+
"scripts": {
6+
"build": "sdk-commands build --skip-install",
7+
"start": "sdk-commands start",
8+
"deploy": "sdk-commands deploy"
9+
},
10+
"dependencies": {
11+
"@dcl/js-runtime": "7.24.1",
12+
"@dcl/sdk": "latest",
13+
"@dcl/sdk-commands": "latest"
14+
},
15+
"engines": {
16+
"node": ">=16.0.0",
17+
"npm": ">=6.0.0"
18+
}
19+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"ecs7": true,
3+
"runtimeVersion": "7",
4+
"display": {
5+
"title": "ColliderLayer Showcase",
6+
"description": "Demonstrates the different ColliderLayer values (CL_PLAYER, CL_MAIN_PLAYER, CL_PHYSICS, CL_POINTER, CL_CUSTOM*) and how they behave across TriggerArea, Raycast, MeshCollider, and GltfContainer.",
7+
"navmapThumbnail": "images/scene-thumbnail.png",
8+
"favicon": "favicon_asset"
9+
},
10+
"owner": "",
11+
"contact": {
12+
"name": "SDK",
13+
"email": ""
14+
},
15+
"main": "bin/index.js",
16+
"tags": [],
17+
"scene": {
18+
"parcels": [
19+
"5,5"
20+
],
21+
"base": "5,5"
22+
},
23+
"spawnPoints": [
24+
{
25+
"name": "spawn1",
26+
"default": true,
27+
"position": {
28+
"x": 8,
29+
"y": 0,
30+
"z": 8
31+
},
32+
"cameraTarget": {
33+
"x": 8,
34+
"y": 1,
35+
"z": 12
36+
}
37+
}
38+
],
39+
"featureToggles": {},
40+
"worldConfiguration": {
41+
"name": "sdk7testscenes.dcl.eth"
42+
}
43+
}
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
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

Comments
 (0)