Creating Custom Enemies

The Basics

Once you have created a custom profile, you can now customise the enemies as you wish.

Open the datablock file path_to_your_profile/datablocks/enemy/enemy.js and remove all enemy definitions after "Unknown" such that you are left with just the following:

const { EnemyDatablock } = await require("../../../vanilla/datablocks/enemy/enemy.js", "asl");
const { Identifier } = await require("../../../vanilla/parser/identifier.js", "asl");
const { BigFlyerModel } = await require("../../../vanilla/renderer/enemy/models/bigflyer.js", "asl");
const { FlyerModel } = await require("../../../vanilla/renderer/enemy/models/flyer.js", "asl");
const { HumanoidEnemyModel } = await require("../../../vanilla/renderer/enemy/models/humanoid.js", "asl");
const { SquidModel } = await require("../../../vanilla/renderer/enemy/models/squid.js", "asl");
EnemyDatablock.clear();
const shooterScale = 0.8;
EnemyDatablock.set(Identifier.create("Enemy", 0), {
  name: "Unknown",
  maxHealth: Infinity
});

shooterScale is provided as shooters in-game are slightly smaller than strikers. This is a useful constant to use when scaling shooter models.

To create your first custom enemy, open a replay of the rundown you are making the profile for and enable "Show Enemy Info" in the viewer settings.

Locate an enemy that you wish to add to the datablocks, enemy info should show its ID:

Enemy with ID of 24

Back in our script we can add this enemy using the following:

// Set the ID in the Identifier to 24
EnemyDatablock.set(Identifier.create("Enemy", 24), {
  name: "Bob"
});

You do not need to provide a maxHealth value, this is there in the vanilla version for backwards compatability with old replays. Newer versions store the health value of an enemy within the replay.

Enemy after defining in DB

The EnemyDatablock provides additional customisation for the model of the enemy as well via the model property. This accepts a function which should return the model that the enemy should use, providing the enemy spawn information.

In the basic case of a stickfigure, you do not need to worry about any of this and can simply use the built-in HumanoidEnemyModel class:

EnemyDatablock.set(Identifier.create("Enemy", 24), {
  name: "Bob",
  model: (wrapper, enemy) => {
    // wrapper - THREE JS wrapper used to encapsulte your model and
    //           render things such as Bio Tracker Pings and Enemy Info.
    //           It also provides information such as the current animHandle
    //           and enemy DB information. Many model implementations will use
    //           it, but it is not necessary.
    // enemy - Spawn information about the enemy such as type, hp etc...
    const model = new HumanoidEnemyModel(wrapper);
    return model;
  }
});

The HumanoidEnemyModel class provides a applySettings method which lets you alter how the stick figure looks:

EnemyDatablock.set(Identifier.create("Enemy", 24), {
  name: "Bob",
  model: (wrapper, enemy) => {
    const model = new HumanoidEnemyModel(wrapper);
    model.applySettings({
      scale: enemy.scale * shooterScale,
      armScale: {
        x: 0.2,
        y: 0.2,
        z: 0.2
      },
      headScale: {
        x: 0,
        y: 0,
        z: 0
      },
      legScale: {
        x: 1.1,
        y: 1.1,
        z: 1.1
      }
    });
    return model;
  }
});

For more information on what can be customised, refer to the docs here.

Custom Model

This requires good knowledge of JavaScript and how ASL works, I recommend you read the prerequisite knowledge page before continuing.

Sometimes you might want to stray away from just stick figures and instead provide your own model.

This section will provide an example in the context of creating a model for the "Bloody Mass" enemies from Duo Trials:

Bloody Mass from Duo Trials

This can be achieved by creating a custom enemy model class. First lets create a new file called bloodymass.js in the root of your profile:

Extending the Existing Stick Figures

Lets open this file in your favourite text editor to start creating our custom model. I want this model to extend the existing stick figure model since I wish to use the underlying bone structure and animations, however you can choose to make the model completely from scratch as well.

First lets import HumanoidEnemyModel from the vanilla profile:

const { HumanoidEnemyModel } = await require("../vanilla/renderer/enemy/models/humanoid.js", "asl");

We can then export a new class extending it:

const { HumanoidEnemyModel } = await require("../vanilla/renderer/enemy/models/humanoid.js", "asl");

exports.BloodMassModel = class extends HumanoidEnemyModel {
}

When an enemy model is generated, they are all passed an optional EnemyModelWrapper object. This wrapper manages the model and contains information such as EnemyDatablock data for the type of enemy and its animation handle.

Furthermore, you can assign a target object as the spot where the bio tracker ping icon will render. HumanoidEnemyModel uses this to place the bio ping on the hip bone of the enemy:

// @Class HumanoidEnemyModel
constructor(wrapper) {
    super();  
    this.wrapper = wrapper;
    this.wrapper.tagTarget = this.visual.joints.spine1;
    // ...
}

For this reason, we need to pass the wrapper object to it in our class inheritance:

const { HumanoidEnemyModel } = await require("../vanilla/renderer/enemy/models/humanoid.js", "asl");

exports.BloodMassModel = class extends HumanoidEnemyModel {
    constructor(wrapper) {
        super(wrapper);
    }
}

We now have our extended model, lets use it for our enemy!

If you have not setup a custom datablock for an enemy, please do so now here.

Back in our enemy datablock definition, we can now import our new model and use it:

// ** path_to_profile/datablocks/enemy.js **

// Import our new model
const { BloodMassModel } = await require("../../bloodymass.js", "asl");

// Assign the model to our datablock
EnemyDatablock.set(Identifier.create("Enemy", 24), {
  name: "Blood Mass",
  model: (wrapper, enemy) => {
    const model = new BloodMassModel(wrapper);
    return model;
  }
});
Result in Viewer

Creating our own renderer

Great! We have now successfully extended the humanoid model, it currently will act and behave the same but we can change that.

Back in our bloodymass.js script we can override the render function for the model:

const { HumanoidEnemyModel } = await require("../vanilla/renderer/enemy/models/humanoid.js", "asl");

exports.BloodMassModel = class extends HumanoidEnemyModel {
    constructor(wrapper) {
        super(wrapper);
    }
	
    render(dt, time, enemy, anim) {
    	
    }
};

Back in the viewer, your enemy should now dissappear as we are no longer rendering it (our render function does nothing)

Instead you should notice that all your enemies our at spawn (position 0,0):

Note that due to culling, you need to keep the original enemy locations in camera view to see this.

Since we are not updating any of the objects during rendering, the root parent object also does not move. We can change this using the following:

const { HumanoidEnemyModel } = await require("../vanilla/renderer/enemy/models/humanoid.js", "asl");

exports.BloodMassModel = class extends HumanoidEnemyModel {
    constructor(wrapper) {
    	super(wrapper);
    }
	
    render(dt, time, enemy, anim) {
        this.root.position.copy(enemy.position);
    }
};
By updating the root, the enemy now renders in the right location

Instead of doing this, however, we are going to use the animations provided by HumanoidEnemyModel . We can do this by calling its animate function:

const { HumanoidEnemyModel } = await require("../vanilla/renderer/enemy/models/humanoid.js", "asl");

exports.BloodMassModel = class extends HumanoidEnemyModel {
    constructor(wrapper) {
    	super(wrapper);
    }
	
    render(dt, time, enemy, anim) {
        this.animate(dt, time, enemy, anim);
    }
};

The animate function does more than just move the root of the model, it also animates the model's bones for the enemy limbs.

Next lets add an early return to skip rendering if the enemy is no longer visible (This accounts for culling):

const { HumanoidEnemyModel } = await require("../vanilla/renderer/enemy/models/humanoid.js", "asl");

exports.BloodMassModel = class extends HumanoidEnemyModel {
    constructor(wrapper) {
    	super(wrapper);
    }
	
    render(dt, time, enemy, anim) {
        if (!this.isVisible()) return;
    
        this.animate(dt, time, enemy, anim);
    }
};

Adding the Spikes!

Now lets start creating those spikes from the original model. To do so, I'm going to reuse the tentacle models provided which were originally used for flyer tendrils.

First lets import the required dependencies:

const { Mesh, MeshPhongMaterial, Vector3, Euler } = await require("three", "esm");
const { loadGLTFGeometry } = await require("../vanilla/library/modelloader.js", "asl");

const { HumanoidEnemyModel } = await require("../vanilla/renderer/enemy/models/humanoid.js", "asl");

exports.BloodMassModel = class extends HumanoidEnemyModel {
    // ...
};

From three we need:

We also need the loadGLTFGeometry function from vanilla which is used to load BufferGeometries of models from GLTF files.

Now we can load the spike model and attach it to a bone on our animated model:

const { Mesh, MeshPhongMaterial, Vector3, Euler } = await require("three", "esm");
const { loadGLTFGeometry } = await require("../vanilla/library/modelloader.js", "asl");
const { HumanoidEnemyModel } = await require("../vanilla/renderer/enemy/models/humanoid.js", "asl");
exports.BloodMassModel = class extends HumanoidEnemyModel {
    constructor(wrapper) {
        super(wrapper);
        
        // Create our material
        const material = new MeshPhongMaterial({ color: 0xff0000 });
        
        // Load our model
        loadGLTFGeometry("../js3party/models/spike.glb").then(model => {
        
            // Once the model is loaded, create a mesh for it, assigning our material
            this.tendril = new Mesh(model, material);
            
            // Add the tendril to the `hip` joint of the visual skeleton
            this.visual.joints.hip.add(this.tendril);
        
        // Catch any errors during the load process
        }).catch(error => { throw module.error(error, "Failed to load spike model.") });
    }

    render(dt, time, enemy, anim) {
        if (!this.isVisible()) return;
        
        this.animate(dt, time, enemy, anim);
    }
};
Rendered Spike

We have now successfully added a spike to the hip bone of our stick figure!

Animating the Spikes

Now lets say we want to have the spike sway a little. We can achieve this in our render function by applying a sin and cos wave to our spikes rotation.

In our render function, we are provided with a time variable which represents the current time in the replay in milliseconds. Animations are much easier to handle in seconds, so we will first convert the time into seconds:

render(dt, time, enemy, anim) {
    if (!this.isVisible()) return;
    
    this.animate(dt, time, enemy, anim);
    
    // Convert from milliseconds to seconds
    time = time / 1000;
}

Note that we do the conversion after calling animate as that function expects the time to still be in milliseconds.

We can now set the rotation of the tendril based on a sin or cos wave in respect to time:

render(dt, time, enemy, anim) {
    if (!this.isVisible()) return;
    
    this.animate(dt, time, enemy, anim);

    // Convert from milliseconds to seconds
    time = time / 1000;

    // If our tendril does not exist then it still has not loaded yet - early return
    if (this.tendril === undefined) return;

    // Apply sin and cos rotations to our spike
    // - angles are in radians!
    this.tendril.rotation.set(
        Math.sin(time), 
        Math.cos(time), 
        Math.sin(time)
    );
}

With this, we should be able to see our spike sway accordingly:

Spike swaying

We can adjust the math equations to produce the sort of animation we want - Feel free to mess around with it.

By repeating the above to produce more spikes we can generate our bloody mass model:

Full bloody mass model

The full source code can be found below:

const { Mesh, MeshPhongMaterial, Vector3, Euler } = await require("three", "esm");
const { loadGLTFGeometry } = await require("../vanilla/library/modelloader.js", "asl");
const { HumanoidEnemyModel } = await require("../vanilla/renderer/enemy/models/humanoid.js", "asl");

exports.BloodMassModel = class extends HumanoidEnemyModel {
  constructor(wrapper) {
    super(wrapper);
    this.tendrilMaterial = new MeshPhongMaterial({ color: 0xff0000 });

    this.tendrils = [];
    this.originalRotations = [];
    this.randomOffsets = [];
    
    loadGLTFGeometry("../js3party/models/spike.glb").then(model => {
      const tendrilsPerPart = 8;

      const partsToAttachTo = ["hip", "spine1", "spine2"];
      for (let j = 0; j < partsToAttachTo.length; j++) {
        const jointname = partsToAttachTo[j];
        const randOffset = Math.random();
        for (let i = 0; i < tendrilsPerPart; i++) {
          const rotAngle = i * (360/tendrilsPerPart);
          const tendril = new Mesh(model, this.tendrilMaterial);
          this.visual.joints[jointname].add(tendril);
          tendril.scale.set(0.1, 0.3, 0.1);
          tendril.rotation.set(Math.deg2rad * 60, Math.deg2rad * rotAngle + randOffset, 0, "YXZ");
          const fwd = new Vector3(0, 0.08, 0);
          tendril.position.add(fwd.applyQuaternion(tendril.quaternion));
          tendril.position.add(new Vector3(0, (j * 0.15), 0));

          this.tendrils.push(tendril);
          this.originalRotations.push(tendril.rotation.clone());
          this.randomOffsets.push(Math.random());
        }

        // to have them point downwards
        for (let i = 0; i < tendrilsPerPart; i++) {
          const rotAngle = i * (360/tendrilsPerPart);
          const tendril = new Mesh(model, this.tendrilMaterial);
          this.visual.joints[jointname].add(tendril);
          tendril.scale.set(0.1, 0.3, 0.1);
          tendril.rotation.set(Math.deg2rad * 110, Math.deg2rad * rotAngle + randOffset, 0, "YXZ");
          const fwd = new Vector3(0, 0.08, 0);
          tendril.position.add(fwd.applyQuaternion(tendril.quaternion));
          tendril.position.add(new Vector3(0, (j * 0.15), 0));

          this.tendrils.push(tendril);
          this.originalRotations.push(tendril.rotation.clone());
          this.randomOffsets.push(Math.random());
        }

        const upspikeTendril = new Mesh(model, this.tendrilMaterial);
        this.visual.joints[jointname].add(upspikeTendril);
        upspikeTendril.scale.set(0.1, 0.3, 0.1);
        upspikeTendril.position.add(new Vector3(0, (j * 0.15) + 0.08, 0));
        this.tendrils.push(upspikeTendril);
        this.originalRotations.push(upspikeTendril.rotation.clone());
        this.randomOffsets.push(Math.random());

        const downspikeTendril = new Mesh(model, this.tendrilMaterial);
        this.visual.joints[jointname].add(downspikeTendril);
        downspikeTendril.scale.set(0.1, 0.3, 0.1);
        downspikeTendril.position.add(new Vector3(0, (j * 0.15) + 0.08, 0));
        downspikeTendril.rotation.set(Math.deg2rad * 180, 0, 0)
        this.tendrils.push(downspikeTendril);
        this.originalRotations.push(downspikeTendril.rotation.clone());
        this.randomOffsets.push(Math.random());
      }

    });

    // for gc prevention
    this.temp = new Euler();

  }

  applySettings(settings) {
    super.applySettings(settings);
	
    if (this.settings?.color !== undefined) this.tendrilMaterial.color.set(this.settings.color);
  }

  render(dt, time, enemy, anim) {
    if (!this.isVisible()) return;
    
    // speed up the animation while enemy is asleep
    if (anim.state === "Hibernate") time *= 0.2;
    
    this.animate(dt, time, enemy, anim); // animate the underlying stick figure
    
    // small amounts of wriggling to the tendrils
    for (let i = 0; i < this.tendrils.length; ++i) {
      const tendril = this.tendrils[i];
      const rot = this.originalRotations[i];
      const offset = this.randomOffsets[i];
      this.temp.copy(rot);
      this.temp.x += 5 * Math.sin((time * offset + offset * 2000) / 1000) * Math.deg2rad;
      this.temp.y += 10 * Math.sin((time * offset + offset * 4000) / 1000) * Math.deg2rad;
      this.temp.z += 20 * Math.sin((time * offset + offset * 3000) / 1000) * Math.deg2rad;
      tendril.rotation.copy(this.temp);
    }

  }
};

Last updated