Skip to content

Extending markup with custom elements

James Baicoianu edited this page Jan 19, 2025 · 1 revision

One of the most powerful features of JanusWeb is that any room can extend the markup with its own custom elements. Janus comes with a number of basic building blocks for you to build your experience with - <object>, <image>, <particle>, etc, and using room scripts you can add logic which uses instances of these built-in elements to do all kinds of things. But beyond a certain point, it becomes difficult to manage too many of these objects with room scripts alone.

Custom elements are the solution to this problem. Instead of all of our logic existing at the room level, we can define custom elements which have self-contained logic, where each instance of the object is responsible for controlling itself, and we can create any number of instances of these objects in our room's markup or scripts. This is similar in concept to Unity's prefabs, or Blueprint classes in Unreal Engine.

Defining Our Custom Thing

To define a custom element, we can write some code like this, and put it in a file, let's call it things.js:

room.defineElement('my-custom-thing', {
  create() {
    // initialize object here
  },
  update() {
    // run any per-frame code here
  }
});

We can now create our objects in markup:

<janus-viewer>
  <assets>
    <assetscript src="things.js" />
  </assets>
  <room use_local_asset="room1">
    <my-custom-thing pos="0 0 4" col="purple" />
  </room>
</janus-viewer>

or in another room script:

let mything = room.createObject('my-custom-thing');

image

Adding Custom Behaviors

Of course, this basic object doesn't do anything, it doesn't even have any visible components. Let's extend it a bit, we'll make a cube that jumps in the air and explodes when clicked on.

Pay special attention to the fact that we use this.createObject() and not room.createObject() - this creates objects as children of our custom element, which means they'll follow that object around wherever we place them in the room. Custom elements can of course spawn objects at the room level, eg, a gun would spawn bullets in the room rather than as a child of the gun - but when defining our "prefab" objects, we generally want to be creating children, not orphans.

room.registerElement('my-custom-thing', {
  cubesize: .5,
  jumpspeed: 10,
  explodedelay: 1500,
  explosionsize: 2,
  create() {
    // Create objects as children of our custom element
    this.cube = this.createObject('object', {
      id: 'cube',
      col: this.col, // pass through color from our base object's "col" attribute
      scale: V(this.cubesize),
      collision_id: 'cube'
    });
    this.cube.addEventListener('click', ev => this.jump());
  },
  update() {
  },
  jump() {
    this.vel.y = this.jumpspeed;

    // Add some gravity to the situation
    this.addForce('gravity', V(0, -9.8, 0));

    // Set our timer
    setTimeout(() => this.explode(), this.explodedelay);
  },
  explode() {
    // hide our cube...
    this.cube.visible = false;

    // and spawn a particle system to show some sparks
    this.sparks = this.createObject('particle', {
      vel: V(-this.explosionsize / 2),
      rand_vel: V(this.explosionsize),
      col: this.col,
      count: 100,
      rate: 100,
      duration: 3
    });
  }
});

Peek 2025-01-17 12-32

More!

Great, it works - but the real power of building this way is how easy it is to reuse this. Let's add a bunch more!

<janus-viewer>
  <assets>
    <assetscript src="things.js" />
  </assets>
  <room use_local_asset="room1">
    <my-custom-thing pos="0 0 4" col="purple" />
+    <my-custom-thing pos="-1 0 6" col="yellow" />
+    <my-custom-thing pos="-1 0 3" col="orange" cubesize=".4" />
+    <my-custom-thing pos="2 0 5" col="purple" cubesize="1.2" jumpspeed="15" explodedelay="500" />
+    <my-custom-thing pos="-2 0 4" col="red" />
+    <my-custom-thing pos=".5 0 6" col="green" />
+    <my-custom-thing pos="-.5 0 8" col="blue" />
+    <my-custom-thing pos="0 0 7" col="black" />
  </room>
</janus-viewer>

Peek 2025-01-17 12-37

As you can see, we can now create any number of our custom things, with different parameters, and they all work independently of each other without having to write any additional code to manage them. Each one is responsible for maintaining its own state - this behavior can be as complex as we need it to be for whatever it is we're building.

Custom Events

Another thing we can do with our custom elements is to make them fire custom events when certain things happen. This allows us to build more advanced interactive behavior, where other objects in our room can react when things happen with our custom elements. To do this, we define some new oneventname properties on our object, and then call this.dispatchEvent({type: 'eventname'}) in our code where we want the event to be fired. So extending our existing object, that would look like this:

room.registerElement('my-custom-thing', {
  cubesize: .5,
  jumpspeed: 10,
  explodedelay: 1000,
  explosionsize: 20,

+  onjump: new CustomEvent('jump'),
+  onexplode: new CustomEvent('explode'),

  create() {
    this.mass = 1; // our object needs some mass if gravity is going to affect it
    this.cube = this.createObject('object', {
      id: 'cube',
      col: this.col, // pass through color from our base object's "col" attribute
      scale: V(this.cubesize),
      pos: V(0, this.cubesize / 2, 0), // so we're not stuck in the ground
      collision_id: 'cube'
    });
    this.cube.addEventListener('click', ev => this.jump());
  },
  update() {
  },
  jump() {
    console.log('boing', this);

    // Shoot up into the air
    this.vel = V(0, +this.jumpspeed, 0);

    // Add some gravity to the situation
    this.addForce('gravity', V(0, -9.8, 0));

+    // Fire our "jump" event
+    this.dispatchEvent({type: 'jump'});
+
    // Count down to explosion
    setTimeout(() => this.explode(), this.explodedelay);
  },
  explode() {
    console.log('boom', this);

    // hide our cube...
    this.cube.visible = false;

    // and spawn a particle system to show some sparks
    this.sparks = this.createObject('particle', {
      vel: V(-this.explosionsize / 2).add(this.vel),
      rand_vel: V(this.explosionsize),
      //accel: V(0, -9.8, 0),
      col: this.col,
      scale: V(.1),
      count: 500,
      rate: 5000,
      duration: 10,
      loop: false,
    });
+
+    // Fire our "explode" event
+    this.dispatchEvent({type: 'explode'});
  }
});

We've added two new properties, onjump and onexplode, and two calls to this.dispatchEvent() in the jump() and explode() functions. We can now respond to these events in our markup. We have access to two special variables inside of our event handlers when specified in the markup: this refers to the object which fires the event, and event refers to the event object that was fired.

<janus-viewer>
  <assets>
    <assetscript src="things.js" />
  </assets>
  <room use_local_asset="room1">
    <my-custom-thing pos="0 0 4" col="purple" />
+    <my-custom-thing pos="-1 0 6" col="yellow" onjump="this.angular.y = 10" onexplode="room.createObject('light', { pos: this.pos.clone() })"/>
    <my-custom-thing pos="-1 0 3" col="orange" cubesize=".4" />
    <my-custom-thing pos="2 0 5" col="purple" cubesize="1.2" jumpspeed="15" explodedelay="500" />
    <my-custom-thing pos="-2 0 4" col="red" />
    <my-custom-thing pos=".5 0 6" col="green" />
    <my-custom-thing pos="-.5 0 8" col="blue" />
    <my-custom-thing pos="0 0 7" col="black" />
  </room>
</janus-viewer>

Peek 2025-01-17 13-58 Note that this new behavior only triggers when we click on the yellow object - it's the only one we added event handlers for. The others behave as they did before, while our yellow one starts to spin when it jumps, and spawns a new light in the room when it explodes.

We can also add these events in scripts, to achieve the same result:

let myobject = room.createObject('my-custom-thing', { col: 'lime'});
myobject.addEventListener('jump', ev => myobject.angular.y = 20);
myobject.addEventListener('explode', ev => room.createObject('light', { pos: myobject.pos.clone() }));