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
});
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:

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"
});

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;
}
});

Note that we pass enemy.scale
into the scale
option for applySettings
.
This is because the game scales enemies dynamically, and the recorder will record the scale the game has provided and store it in enemy.scale
.
We can then apply this scale to our model to better reflect the scale in-game.
Custom Model
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:

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!
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;
}
});

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):

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);
}
};

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:
Mesh
- An object used for rendering polygonsMeshPhongMaterial
- A built in materialVector3
- Vector3 mathEuler
- Euler angle rotation math
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);
}
};

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;
}
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:

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:

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