b3d-trigger
Invisible proximity zone that fires callbacks and dispatches events when a target (the active camera or a named mesh) enters or exits a spherical region. Useful for mission waypoints, area-of-effect zones, and cutscene triggers.
Set onEnter and onExit callback properties from JavaScript, or listen
for 'enter' / 'exit' CustomEvents on the element.
Demo
import { b3d, b3dTrigger, b3dSphere, b3dLight, b3dSkybox, b3dBiped, b3dGround, emptyInput } from 'tosijs-3d'
import { tosi, elements } from 'tosijs'
const { div, span, p } = elements
const { demo } = tosi({ demo: { status: 'walking…', dist: '' } })
// A wandering goal (a glowing marker) with a proximity trigger around it.
let goal = { x: 6, z: 5 }
const marker = b3dSphere({ meshName: 'goal', diameter: 0.7, y: 0.35, x: goal.x, z: goal.z, color: '#ffcc00' })
// Radius comfortably exceeds STOP (below) and the trigger sits low (near the
// biped's root at the feet, y≈0) — otherwise the 0.5m vertical gap eats the
// margin and a biped stopping at ~1.2m horizontal lands just outside.
const trigger = b3dTrigger({ x: goal.x, y: 0.3, z: goal.z, radius: 2, debug: true })
// The NPC: a NON-player biped driven by a tiny "walk to the goal" AI. A biped
// polls whatever is on `.inputProvider` every frame, so an AI is just an
// InputProvider emitting the same ControlInput (forward / turn) a player would.
const walker = b3dBiped({ url: '/omnidude.glb', x: -6, z: -6, initialState: 'idle' })
const STOP = 1.2 // how close counts as "arrived"
walker.inputProvider = {
poll() {
const m = walker.mesh
if (!m) return emptyInput()
const p = m.getAbsolutePosition()
const dx = goal.x - p.x
const dz = goal.z - p.z
const dist = Math.hypot(dx, dz)
// Live readout of the trigger's own view of the world (its debugState) — so
// we can see WHY it does/doesn't fire: what target it's watching, whether that
// target resolves, and its measured distance vs the AI's.
const ds = trigger.debugState
demo.dist.value =
`AI ${dist.toFixed(1)}m · target=${ds.target} · resolved=${ds.targetResolved}` +
` · trigDist=${ds.distance ?? '—'} · inside=${ds.inside}`
// Turn toward the goal (biped forward is +Z) and walk until we're there.
const f = m.forward
let turn = Math.atan2(dx, dz) - Math.atan2(f.x, f.z)
while (turn > Math.PI) turn -= 2 * Math.PI
while (turn < -Math.PI) turn += 2 * Math.PI
return {
...emptyInput(),
forward: dist > STOP ? 1 : 0,
turn: Math.max(-1, Math.min(1, turn * 2)),
}
},
}
// Watch the biped (not the camera): point the trigger at its mesh once loaded.
// Give the root a KNOWN name first — a GLB root is often '__root__' or even '',
// which the trigger's getMeshByName/getTransformNodeByName lookup can't resolve.
const wire = setInterval(() => {
if (walker.mesh) {
walker.mesh.name = 'walker'
// Set BOTH ways: the property, and the attribute (drives the reactive value
// via attributeChangedCallback) — belt-and-suspenders so `target` reliably
// becomes 'walker' instead of staying the default 'camera'.
trigger.target = 'walker'
trigger.setAttribute('target', 'walker')
clearInterval(wire)
}
}, 100)
// On arrival: pause, then teleport the goal (marker + trigger) to a random spot
// on the ground. The NPC notices the trigger it's now outside of and walks to the
// new position. Repeat forever — trigger + simple AI in a loop.
trigger.onEnter = () => {
demo.status.value = 'reached it — relocating…'
setTimeout(() => {
// Pick a spot well AWAY from the biped — otherwise the goal can land on top
// of it, so it's already inside the relocated trigger and never exits/re-enters
// (the loop stalls).
const p = walker.mesh ? walker.mesh.getAbsolutePosition() : { x: 0, z: 0 }
do {
goal = { x: (Math.random() - 0.5) * 16, z: (Math.random() - 0.5) * 16 }
} while (Math.hypot(goal.x - p.x, goal.z - p.z) < 5)
marker.x = goal.x; marker.z = goal.z
trigger.x = goal.x; trigger.z = goal.z
demo.status.value = 'walking…'
}, 1000)
}
preview.append(
b3d(
{
sceneCreated(el, BABYLON) {
const camera = new BABYLON.ArcRotateCamera(
'cam', -Math.PI / 2, Math.PI / 3.2, 22,
new BABYLON.Vector3(0, 0, 0), el.scene
)
// Keep the camera >=5deg above the horizon and out of the scene: a bare
// ArcRotateCamera tilts under the ground and zooms through everything.
camera.lowerBetaLimit = (20 * Math.PI) / 180
camera.upperBetaLimit = (85 * Math.PI) / 180
camera.lowerRadiusLimit = 8
camera.upperRadiusLimit = 60
camera.attachControl(el.querySelector('canvas'), true)
el.setActiveCamera(camera)
},
},
b3dLight({ y: 1, intensity: 0.8 }),
b3dSkybox({ timeOfDay: 12 }),
b3dGround({ size: 20, color: '#556644' }),
marker,
trigger,
walker,
),
div(
{ style: 'position:absolute; top:8px; left:8px; background:rgba(0,0,0,0.6); color:white; padding:8px 12px; border-radius:6px; font:14px monospace' },
p('An NPC walks to the marker → it relocates → repeat'),
span({ bindText: demo.status }),
p({ style: 'opacity:0.7' }, span({ bindText: demo.dist })),
)
)
Attributes
| Attribute | Default | Description |
|---|---|---|
x |
0 |
Center X |
y |
0 |
Center Y |
z |
0 |
Center Z |
radius |
5 |
Trigger sphere radius |
disabled |
false |
Disable the trigger (default: active) |
target |
'camera' |
'camera' or a mesh name to watch |
debug |
false |
Show wireframe sphere |
once |
false |
Fire onEnter once then deactivate |