skin3d
Version:
Skin3d is a lightweight JS library that renders any Minecraft skin as a smooth, interactive 3D model you can drop straight into a webpage.
1,396 lines (1,392 loc) • 70.1 kB
JavaScript
import { Group, MeshStandardMaterial, FrontSide, DoubleSide, BoxGeometry, Mesh, Vector2, Sprite, SpriteMaterial, CanvasTexture, NearestFilter, AmbientLight, PointLight, Scene, PerspectiveCamera, ColorManagement, WebGLRenderer, WebGLRenderTarget, DepthTexture, FloatType, Clock, EquirectangularReflectionMapping, Texture, Color, Object3D } from 'three';
import { isTextureSource, loadSkinToCanvas, inferModelType, loadEarsToCanvasFromSkin, loadImage, loadCapeToCanvas, loadEarsToCanvas } from 'skinview-utils';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import 'three/examples/jsm/postprocessing/Pass.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
import { FXAAShader } from 'three/examples/jsm/shaders/FXAAShader.js';
/**
* @file Model.ts
* @description This file defines the 3D model classes for Minecraft-style player models, including skin, cape, elytra, and ears.
* @author Cosmic-fi
* @license MIT
*/
/**
* Set the UV mapping for a box geometry.
* @param box The box geometry to set UVs for.
* @param u The U coordinate of the top-left corner of the texture region.
* @param v The V coordinate of the top-left corner of the texture region.
* @param width The width of the texture region.
* @param height The height of the texture region.
* @param depth The depth of the texture region.
* @param textureWidth The total width of the texture.
* @param textureHeight The total height of the texture.
*/
function setUVs(box, u, v, width, height, depth, textureWidth, textureHeight) {
const toFaceVertices = (x1, y1, x2, y2) => [
new Vector2(x1 / textureWidth, 1.0 - y2 / textureHeight),
new Vector2(x2 / textureWidth, 1.0 - y2 / textureHeight),
new Vector2(x2 / textureWidth, 1.0 - y1 / textureHeight),
new Vector2(x1 / textureWidth, 1.0 - y1 / textureHeight),
];
const top = toFaceVertices(u + depth, v, u + width + depth, v + depth);
const bottom = toFaceVertices(u + width + depth, v, u + width * 2 + depth, v + depth);
const left = toFaceVertices(u, v + depth, u + depth, v + depth + height);
const front = toFaceVertices(u + depth, v + depth, u + width + depth, v + depth + height);
const right = toFaceVertices(u + width + depth, v + depth, u + width + depth * 2, v + height + depth);
const back = toFaceVertices(u + width + depth * 2, v + depth, u + width * 2 + depth * 2, v + height + depth);
const uvAttr = box.attributes.uv;
const uvRight = [right[3], right[2], right[0], right[1]];
const uvLeft = [left[3], left[2], left[0], left[1]];
const uvTop = [top[3], top[2], top[0], top[1]];
const uvBottom = [bottom[0], bottom[1], bottom[3], bottom[2]];
const uvFront = [front[3], front[2], front[0], front[1]];
const uvBack = [back[3], back[2], back[0], back[1]];
const newUVData = [];
for (const uvArray of [uvRight, uvLeft, uvTop, uvBottom, uvFront, uvBack]) {
for (const uv of uvArray) {
newUVData.push(uv.x, uv.y);
}
}
uvAttr.set(new Float32Array(newUVData));
uvAttr.needsUpdate = true;
}
/**
* Set UVs for a skin box (64x64 texture).
* @param box The box geometry to set UVs for.
* @param u The U coordinate of the top-left corner of the texture region.
* @param v The V coordinate of the top-left corner of the texture region.
* @param width The width of the texture region.
* @param height The height of the texture region.
* @param depth The depth of the texture region.
*/
function setSkinUVs(box, u, v, width, height, depth) {
setUVs(box, u, v, width, height, depth, 64, 64);
}
/** Set UVs for a cape box (64x32 texture).
* @param box The box geometry to set UVs for.
* @param u The U coordinate of the top-left corner of the texture region.
* @param v The V coordinate of the top-left corner of the texture region.
* @param width The width of the texture region.
* @param height The height of the texture region.
* @param depth The depth of the texture region.
*/
function setCapeUVs(box, u, v, width, height, depth) {
setUVs(box, u, v, width, height, depth, 64, 32);
}
/**
* Represents a body part with an inner and outer layer.
* For example, the head with the hat layer.
*/
class BodyPart extends Group {
constructor(innerLayer, outerLayer) {
super();
Object.defineProperty(this, "innerLayer", {
enumerable: true,
configurable: true,
writable: true,
value: innerLayer
});
Object.defineProperty(this, "outerLayer", {
enumerable: true,
configurable: true,
writable: true,
value: outerLayer
});
innerLayer.name = "inner";
outerLayer.name = "outer";
}
}
/**
* Represents the player's skin model, including all body parts.
* Supports "default" and "slim" model types.
*/
class SkinObject extends Group {
constructor() {
super();
Object.defineProperty(this, "head", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "body", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "rightArm", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "leftArm", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "rightLeg", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "leftLeg", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "modelListeners", {
enumerable: true,
configurable: true,
writable: true,
value: []
});
Object.defineProperty(this, "slim", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "_map", {
enumerable: true,
configurable: true,
writable: true,
value: null
});
Object.defineProperty(this, "layer1Material", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "layer1MaterialBiased", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "layer2Material", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "layer2MaterialBiased", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/**
* Material for the inner layer (layer 1) of the skin.
*/
this.layer1Material = new MeshStandardMaterial({ side: FrontSide });
this.layer2Material = new MeshStandardMaterial({ side: DoubleSide, transparent: true, alphaTest: 1e-5 });
this.layer1MaterialBiased = this.layer1Material.clone();
this.layer1MaterialBiased.polygonOffset = true;
this.layer1MaterialBiased.polygonOffsetFactor = 1.0;
this.layer1MaterialBiased.polygonOffsetUnits = 1.0;
this.layer2MaterialBiased = this.layer2Material.clone();
this.layer2MaterialBiased.polygonOffset = true;
this.layer2MaterialBiased.polygonOffsetFactor = 1.0;
this.layer2MaterialBiased.polygonOffsetUnits = 1.0;
/**
* Head part of the skin model.
*/
const headBox = new BoxGeometry(8, 8, 8);
setSkinUVs(headBox, 0, 0, 8, 8, 8);
const headMesh = new Mesh(headBox, this.layer1Material);
const head2Box = new BoxGeometry(9, 9, 9);
setSkinUVs(head2Box, 32, 0, 8, 8, 8);
const head2Mesh = new Mesh(head2Box, this.layer2Material);
this.head = new BodyPart(headMesh, head2Mesh);
this.head.name = "head";
this.head.add(headMesh, head2Mesh);
headMesh.position.y = 4;
head2Mesh.position.y = 4;
this.add(this.head);
/**
* Body part of the skin model.
*/
const bodyBox = new BoxGeometry(8, 12, 4);
setSkinUVs(bodyBox, 16, 16, 8, 12, 4);
const bodyMesh = new Mesh(bodyBox, this.layer1Material);
const body2Box = new BoxGeometry(8.5, 12.5, 4.5);
setSkinUVs(body2Box, 16, 32, 8, 12, 4);
const body2Mesh = new Mesh(body2Box, this.layer2Material);
this.body = new BodyPart(bodyMesh, body2Mesh);
this.body.name = "body";
this.body.add(bodyMesh, body2Mesh);
this.body.position.y = -6;
this.add(this.body);
/**
* Right Arm part of the skin model.
*/
const rightArmBox = new BoxGeometry();
const rightArmMesh = new Mesh(rightArmBox, this.layer1MaterialBiased);
this.modelListeners.push(() => {
rightArmMesh.scale.x = this.slim ? 3 : 4;
rightArmMesh.scale.y = 12;
rightArmMesh.scale.z = 4;
setSkinUVs(rightArmBox, 40, 16, this.slim ? 3 : 4, 12, 4);
});
const rightArm2Box = new BoxGeometry();
const rightArm2Mesh = new Mesh(rightArm2Box, this.layer2MaterialBiased);
this.modelListeners.push(() => {
rightArm2Mesh.scale.x = this.slim ? 3.5 : 4.5;
rightArm2Mesh.scale.y = 12.5;
rightArm2Mesh.scale.z = 4.5;
setSkinUVs(rightArm2Box, 40, 32, this.slim ? 3 : 4, 12, 4);
});
const rightArmPivot = new Group();
rightArmPivot.add(rightArmMesh, rightArm2Mesh);
this.modelListeners.push(() => {
rightArmPivot.position.x = this.slim ? -0.5 : -1;
});
rightArmPivot.position.y = -4;
this.rightArm = new BodyPart(rightArmMesh, rightArm2Mesh);
this.rightArm.name = "rightArm";
this.rightArm.add(rightArmPivot);
this.rightArm.position.x = -5;
this.rightArm.position.y = -2;
this.add(this.rightArm);
/**
* Left Arm part of the skin model.
*/
const leftArmBox = new BoxGeometry();
const leftArmMesh = new Mesh(leftArmBox, this.layer1MaterialBiased);
this.modelListeners.push(() => {
leftArmMesh.scale.x = this.slim ? 3 : 4;
leftArmMesh.scale.y = 12;
leftArmMesh.scale.z = 4;
setSkinUVs(leftArmBox, 32, 48, this.slim ? 3 : 4, 12, 4);
});
const leftArm2Box = new BoxGeometry();
const leftArm2Mesh = new Mesh(leftArm2Box, this.layer2MaterialBiased);
this.modelListeners.push(() => {
leftArm2Mesh.scale.x = this.slim ? 3.5 : 4.5;
leftArm2Mesh.scale.y = 12.5;
leftArm2Mesh.scale.z = 4.5;
setSkinUVs(leftArm2Box, 48, 48, this.slim ? 3 : 4, 12, 4);
});
const leftArmPivot = new Group();
leftArmPivot.add(leftArmMesh, leftArm2Mesh);
this.modelListeners.push(() => {
leftArmPivot.position.x = this.slim ? 0.5 : 1;
});
leftArmPivot.position.y = -4;
this.leftArm = new BodyPart(leftArmMesh, leftArm2Mesh);
this.leftArm.name = "leftArm";
this.leftArm.add(leftArmPivot);
this.leftArm.position.x = 5;
this.leftArm.position.y = -2;
this.add(this.leftArm);
/***
* Right Leg part of the skin model.
*/
const rightLegBox = new BoxGeometry(4, 12, 4);
setSkinUVs(rightLegBox, 0, 16, 4, 12, 4);
const rightLegMesh = new Mesh(rightLegBox, this.layer1MaterialBiased);
const rightLeg2Box = new BoxGeometry(4.5, 12.5, 4.5);
setSkinUVs(rightLeg2Box, 0, 32, 4, 12, 4);
const rightLeg2Mesh = new Mesh(rightLeg2Box, this.layer2MaterialBiased);
const rightLegPivot = new Group();
rightLegPivot.add(rightLegMesh, rightLeg2Mesh);
rightLegPivot.position.y = -6;
this.rightLeg = new BodyPart(rightLegMesh, rightLeg2Mesh);
this.rightLeg.name = "rightLeg";
this.rightLeg.add(rightLegPivot);
this.rightLeg.position.x = -1.9;
this.rightLeg.position.y = -12;
this.rightLeg.position.z = -0.1;
this.add(this.rightLeg);
/**
* Left Leg part of the skin model.
*/
const leftLegBox = new BoxGeometry(4, 12, 4);
setSkinUVs(leftLegBox, 16, 48, 4, 12, 4);
const leftLegMesh = new Mesh(leftLegBox, this.layer1MaterialBiased);
const leftLeg2Box = new BoxGeometry(4.5, 12.5, 4.5);
setSkinUVs(leftLeg2Box, 0, 48, 4, 12, 4);
const leftLeg2Mesh = new Mesh(leftLeg2Box, this.layer2MaterialBiased);
const leftLegPivot = new Group();
leftLegPivot.add(leftLegMesh, leftLeg2Mesh);
leftLegPivot.position.y = -6;
this.leftLeg = new BodyPart(leftLegMesh, leftLeg2Mesh);
this.leftLeg.name = "leftLeg";
this.leftLeg.add(leftLegPivot);
this.leftLeg.position.x = 1.9;
this.leftLeg.position.y = -12;
this.leftLeg.position.z = -0.1;
this.add(this.leftLeg);
this.modelType = "default";
}
/**
* The texture map for the skin.
* @return The texture map for the skin.
*/
get map() {
return this._map;
}
/**
* Set the texture map for the skin.
* @param newMap The new texture map.
*/
set map(newMap) {
this._map = newMap;
this.layer1Material.map = newMap;
this.layer1Material.needsUpdate = true;
this.layer1MaterialBiased.map = newMap;
this.layer1MaterialBiased.needsUpdate = true;
this.layer2Material.map = newMap;
this.layer2Material.needsUpdate = true;
this.layer2MaterialBiased.map = newMap;
this.layer2MaterialBiased.needsUpdate = true;
}
/**
* The model type ("default" or "slim").
* @return The model type.
*/
get modelType() {
return this.slim ? "slim" : "default";
}
/**
* Set the model type.
* @param value The new model type.
*/
set modelType(value) {
this.slim = value === "slim";
this.modelListeners.forEach(listener => listener());
}
/**
* Get all body parts in this skin.
* @return An array of all body parts.
*/
getBodyParts() {
return this.children.filter(it => it instanceof BodyPart);
}
/**
* Show or hide the inner layer of all body parts.
* @param value Whether to show the inner layer.
*/
setInnerLayerVisible(value) {
this.getBodyParts().forEach(part => (part.innerLayer.visible = value));
}
/**
* Show or hide the outer layer of all body parts.
* @param value Whether to show the outer layer.
*/
setOuterLayerVisible(value) {
this.getBodyParts().forEach(part => (part.outerLayer.visible = value));
}
/**
* Reset all joint rotations and positions to default.
*/
resetJoints() {
this.head.rotation.set(0, 0, 0);
this.leftArm.rotation.set(0, 0, 0);
this.rightArm.rotation.set(0, 0, 0);
this.leftLeg.rotation.set(0, 0, 0);
this.rightLeg.rotation.set(0, 0, 0);
this.body.rotation.set(0, 0, 0);
this.head.position.y = 0;
this.body.position.y = -6;
this.body.position.z = 0;
this.rightArm.position.x = -5;
this.rightArm.position.y = -2;
this.rightArm.position.z = 0;
this.leftArm.position.x = 5;
this.leftArm.position.y = -2;
this.leftArm.position.z = 0;
this.rightLeg.position.x = -1.9;
this.rightLeg.position.y = -12;
this.rightLeg.position.z = -0.1;
this.leftLeg.position.x = 1.9;
this.leftLeg.position.y = -12;
this.leftLeg.position.z = -0.1;
}
}
/**
* Represents a Minecraft-style cape.
*/
class CapeObject extends Group {
constructor() {
super();
Object.defineProperty(this, "cape", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "material", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
this.material = new MeshStandardMaterial({
side: DoubleSide,
transparent: true,
alphaTest: 1e-5,
});
const capeBox = new BoxGeometry(10, 16, 1);
setCapeUVs(capeBox, 0, 0, 10, 16, 1);
this.cape = new Mesh(capeBox, this.material);
this.cape.position.y = -8;
this.cape.position.z = 0.5;
this.add(this.cape);
}
/**
* The texture map for the cape.
* @return The texture map for the cape.
*/
get map() {
return this.material.map;
}
/**
* Set the texture map for the cape.
* @param newMap The new texture map.
*/
set map(newMap) {
this.material.map = newMap;
this.material.needsUpdate = true;
}
}
/**
* Represents a Minecraft-style elytra (wings).
*/
class ElytraObject extends Group {
constructor() {
super();
Object.defineProperty(this, "leftWing", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "rightWing", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "material", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
this.material = new MeshStandardMaterial({
side: DoubleSide,
transparent: true,
alphaTest: 1e-5,
});
const leftWingBox = new BoxGeometry(12, 22, 4);
setCapeUVs(leftWingBox, 22, 0, 10, 20, 2);
const leftWingMesh = new Mesh(leftWingBox, this.material);
leftWingMesh.position.x = -5;
leftWingMesh.position.y = -10;
leftWingMesh.position.z = -1;
this.leftWing = new Group();
this.leftWing.add(leftWingMesh);
this.add(this.leftWing);
const rightWingBox = new BoxGeometry(12, 22, 4);
setCapeUVs(rightWingBox, 22, 0, 10, 20, 2);
const rightWingMesh = new Mesh(rightWingBox, this.material);
rightWingMesh.scale.x = -1;
rightWingMesh.position.x = 5;
rightWingMesh.position.y = -10;
rightWingMesh.position.z = -1;
this.rightWing = new Group();
this.rightWing.add(rightWingMesh);
this.add(this.rightWing);
this.leftWing.position.x = 5;
this.leftWing.rotation.x = 0.2617994;
this.resetJoints();
}
/**
* Reset wing rotations to default.
*/
resetJoints() {
this.leftWing.rotation.y = 0.01; // avoid z-fighting
this.leftWing.rotation.z = 0.2617994;
this.updateRightWing();
}
/**
* Mirror the left wing's position and rotation to the right wing.
*/
updateRightWing() {
this.rightWing.position.x = -this.leftWing.position.x;
this.rightWing.position.y = this.leftWing.position.y;
this.rightWing.rotation.x = this.leftWing.rotation.x;
this.rightWing.rotation.y = -this.leftWing.rotation.y;
this.rightWing.rotation.z = -this.leftWing.rotation.z;
}
/**
* The texture map for the elytra.
* @return The texture map for the elytra.
*/
get map() {
return this.material.map;
}
/**
* Set the texture map for the elytra.
* @param newMap The new texture map.
*/
set map(newMap) {
this.material.map = newMap;
this.material.needsUpdate = true;
}
}
/**
* Represents a pair of ears (for skin with ears).
*/
class EarsObject extends Group {
constructor() {
super();
Object.defineProperty(this, "rightEar", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "leftEar", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "material", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
this.material = new MeshStandardMaterial({ side: FrontSide });
const earBox = new BoxGeometry(8, 8, 4 / 3);
setUVs(earBox, 0, 0, 6, 6, 1, 14, 7);
this.rightEar = new Mesh(earBox, this.material);
this.rightEar.name = "rightEar";
this.rightEar.position.x = -6;
this.add(this.rightEar);
this.leftEar = new Mesh(earBox, this.material);
this.leftEar.name = "leftEar";
this.leftEar.position.x = 6;
this.add(this.leftEar);
}
/**
* The texture map for the ears.
* @return The texture map for the ears.
*/
get map() {
return this.material.map;
}
/**
* Set the texture map for the ears.
* @param newMap The new texture map.
*/
set map(newMap) {
this.material.map = newMap;
this.material.needsUpdate = true;
}
}
const CapeDefaultAngle = (10.8 * Math.PI) / 180;
/**
* Represents a full player model, including skin, cape, elytra, and ears.
*/
class PlayerObject extends Group {
constructor() {
super();
Object.defineProperty(this, "skin", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "cape", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "elytra", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "ears", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
this.skin = new SkinObject();
this.skin.name = "skin";
this.skin.position.y = 8;
this.add(this.skin);
this.cape = new CapeObject();
this.cape.name = "cape";
this.cape.position.y = 8;
this.cape.position.z = -2;
this.cape.rotation.x = CapeDefaultAngle;
this.cape.rotation.y = Math.PI;
this.add(this.cape);
this.elytra = new ElytraObject();
this.elytra.name = "elytra";
this.elytra.position.y = 8;
this.elytra.position.z = -2;
this.elytra.visible = false;
this.add(this.elytra);
this.ears = new EarsObject();
this.ears.name = "ears";
this.ears.position.y = 10;
this.ears.position.z = 2 / 3;
this.ears.visible = false;
this.skin.head.add(this.ears);
}
/**
* Which back equipment is visible ("cape", "elytra", or null).
* @return The currently visible back equipment.
*/
get backEquipment() {
if (this.cape.visible)
return "cape";
if (this.elytra.visible)
return "elytra";
return null;
}
/**
* Set which back equipment is visible.
* @param value The back equipment to show, or null to hide both.
*/
set backEquipment(value) {
this.cape.visible = value === "cape";
this.elytra.visible = value === "elytra";
}
/**
* Reset all joints and positions to default.
*/
resetJoints() {
this.skin.resetJoints();
this.cape.rotation.x = CapeDefaultAngle;
this.cape.position.y = 8;
this.cape.position.z = -2;
this.elytra.position.y = 8;
this.elytra.position.z = -2;
this.elytra.rotation.x = 0;
this.elytra.resetJoints();
}
}
/**
* @file Animation.ts
* @description This file defines the PlayerAnimation class and its subclasses for animating PlayerObject instances.
* @author Cosmic-fi
* @license MIT
*/
/**
* Abstract base class for animations that can be played on a PlayerObject.
*/
class PlayerAnimation {
constructor() {
/**
* Animation speed multiplier.
* @defaultValue 1.0
*/
Object.defineProperty(this, "speed", {
enumerable: true,
configurable: true,
writable: true,
value: 1.0
});
/**
* Whether the animation is paused.
* @defaultValue false
*/
Object.defineProperty(this, "paused", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
/** Current animation progress. */
Object.defineProperty(this, "progress", {
enumerable: true,
configurable: true,
writable: true,
value: 0
});
/** Internal id counter for animations. */
Object.defineProperty(this, "currentId", {
enumerable: true,
configurable: true,
writable: true,
value: 0
});
Object.defineProperty(this, "progress0", {
enumerable: true,
configurable: true,
writable: true,
value: new Map()
});
Object.defineProperty(this, "animationObjects", {
enumerable: true,
configurable: true,
writable: true,
value: new Map()
});
}
/**
* Update the animation state.
* @param player - The player object.
* @param deltaTime - Time elapsed since last call.
*/
update(player, deltaTime) {
if (this.paused)
return;
const delta = deltaTime * this.speed;
this.animate(player, delta);
this.animationObjects.forEach((animation, id) => {
const progress0 = this.progress0.get(id);
animation(player, this.progress - progress0, id);
});
this.progress += delta;
}
/**
* Add a new animation function and return its id.
* @param fn - Animation function (player, progress, id).
* @returns The id of the newly added animation.
*/
addAnimation(fn) {
const id = this.currentId++;
this.progress0.set(id, this.progress);
this.animationObjects.set(id, fn);
return id;
}
/**
* Remove an animation by its id.
* @param id - The id of the animation to remove.
*/
removeAnimation(id) {
if (id !== undefined) {
this.animationObjects.delete(id);
this.progress0.delete(id);
}
}
}
/**
* Animation from a function.
*/
class FunctionAnimation extends PlayerAnimation {
constructor(fn) {
super();
Object.defineProperty(this, "fn", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
this.fn = fn;
}
/** @inheritdoc */
animate(player, delta) {
this.fn(player, this.progress, delta);
}
}
/**
* Idle animation (arms and cape sway gently).
*/
class IdleAnimation extends PlayerAnimation {
/** @inheritdoc */
animate(player) {
const t = this.progress * 2;
const basicArmRotationZ = Math.PI * 0.02;
player.skin.leftArm.rotation.z = Math.cos(t) * 0.03 + basicArmRotationZ;
player.skin.rightArm.rotation.z = Math.cos(t + Math.PI) * 0.03 - basicArmRotationZ;
const basicCapeRotationX = Math.PI * 0.06;
player.cape.rotation.x = Math.sin(t) * 0.01 + basicCapeRotationX;
}
}
/**
* Walking animation (arms and legs swing, head bobs).
*/
class WalkingAnimation extends PlayerAnimation {
constructor() {
super(...arguments);
/**
* Whether to shake head when walking.
* @defaultValue true
*/
Object.defineProperty(this, "headBobbing", {
enumerable: true,
configurable: true,
writable: true,
value: true
});
}
/** @inheritdoc */
animate(player) {
const t = this.progress * 8;
player.skin.leftLeg.rotation.x = Math.sin(t) * 0.5;
player.skin.rightLeg.rotation.x = Math.sin(t + Math.PI) * 0.5;
player.skin.leftArm.rotation.x = Math.sin(t + Math.PI) * 0.5;
player.skin.rightArm.rotation.x = Math.sin(t) * 0.5;
const basicArmRotationZ = Math.PI * 0.02;
player.skin.leftArm.rotation.z = Math.cos(t) * 0.03 + basicArmRotationZ;
player.skin.rightArm.rotation.z = Math.cos(t + Math.PI) * 0.03 - basicArmRotationZ;
if (this.headBobbing) {
player.skin.head.rotation.y = Math.sin(t / 4) * 0.2;
player.skin.head.rotation.x = Math.sin(t / 5) * 0.1;
}
else {
player.skin.head.rotation.y = 0;
player.skin.head.rotation.x = 0;
}
const basicCapeRotationX = Math.PI * 0.06;
player.cape.rotation.x = Math.sin(t / 1.5) * 0.06 + basicCapeRotationX;
}
}
/**
* Running animation (faster, more exaggerated swing).
*/
class RunningAnimation extends PlayerAnimation {
/** @inheritdoc */
animate(player) {
const t = this.progress * 15 + Math.PI * 0.5;
player.skin.leftLeg.rotation.x = Math.cos(t + Math.PI) * 1.3;
player.skin.rightLeg.rotation.x = Math.cos(t) * 1.3;
player.skin.leftArm.rotation.x = Math.cos(t) * 1.5;
player.skin.rightArm.rotation.x = Math.cos(t + Math.PI) * 1.5;
const basicArmRotationZ = Math.PI * 0.1;
player.skin.leftArm.rotation.z = Math.cos(t) * 0.1 + basicArmRotationZ;
player.skin.rightArm.rotation.z = Math.cos(t + Math.PI) * 0.1 - basicArmRotationZ;
player.position.y = Math.cos(t * 2);
player.position.x = Math.cos(t) * 0.15;
player.rotation.z = Math.cos(t + Math.PI) * 0.01;
const basicCapeRotationX = Math.PI * 0.3;
player.cape.rotation.x = Math.sin(t * 2) * 0.1 + basicCapeRotationX;
}
}
/**
* Clamps a number between a minimum and maximum value.
* @param num - The number to clamp.
* @param min - The minimum value.
* @param max - The maximum value.
* @returns The clamped number.
*/
function clamp(num, min, max) {
return num <= min ? min : num >= max ? max : num;
}
/**
* Flying animation (body rotates, elytra wings expand).
*/
class FlyingAnimation extends PlayerAnimation {
/** @inheritdoc */
animate(player) {
const t = this.progress > 0 ? this.progress * 20 : 0;
const startProgress = clamp((t * t) / 100, 0, 1);
player.rotation.x = (startProgress * Math.PI) / 2;
player.skin.head.rotation.x = startProgress > 0.5 ? Math.PI / 4 - player.rotation.x : 0;
const basicArmRotationZ = Math.PI * 0.25 * startProgress;
player.skin.leftArm.rotation.z = basicArmRotationZ;
player.skin.rightArm.rotation.z = -basicArmRotationZ;
const elytraRotationX = 0.34906584;
const elytraRotationZ = Math.PI / 2;
const interpolation = Math.pow(0.9, t);
player.elytra.leftWing.rotation.x = elytraRotationX + interpolation * (0.2617994 - elytraRotationX);
player.elytra.leftWing.rotation.z = elytraRotationZ + interpolation * (0.2617994 - elytraRotationZ);
player.elytra.updateRightWing();
}
}
/**
* Waving animation (one arm waves).
*/
class WaveAnimation extends PlayerAnimation {
constructor(whichArm = "left") {
super();
/**
* Which arm to wave.
* defaultValue "left"
*/
Object.defineProperty(this, "whichArm", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
this.whichArm = whichArm;
}
/** @inheritdoc */
animate(player) {
const t = this.progress * Math.PI;
const targetArm = this.whichArm === "left" ? player.skin.leftArm : player.skin.rightArm;
targetArm.rotation.x = 180;
targetArm.rotation.z = Math.sin(t) * 0.5;
}
}
/**
* Crouch animation (body and limbs move to crouch pose).
*/
class CrouchAnimation extends PlayerAnimation {
constructor() {
super(...arguments);
/**
* Show progress of animation.
* @defaultValue false
*/
Object.defineProperty(this, "showProgress", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
/**
* Run this animation once.
* @defaultValue false
*/
Object.defineProperty(this, "runOnce", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "isRunningHitAnimation", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "hitAnimationSpeed", {
enumerable: true,
configurable: true,
writable: true,
value: 1
});
Object.defineProperty(this, "erp", {
enumerable: true,
configurable: true,
writable: true,
value: 0
}); // Elytra rotate progress
Object.defineProperty(this, "isCrouched", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
}
/**
* Add the hit animation.
* @param speed - Speed of hit animation (default: same as crouch speed).
*/
addHitAnimation(speed = this.speed) {
this.isRunningHitAnimation = true;
this.hitAnimationSpeed = speed;
}
/** @inheritdoc */
animate(player) {
let pr = this.progress * 8;
if (pr === 0)
this.isCrouched = undefined;
if (this.runOnce)
pr = clamp(pr, -1, 1);
if (!this.showProgress)
pr = Math.floor(pr);
const sinVal = Math.abs(Math.sin((pr * Math.PI) / 2));
player.skin.body.rotation.x = 0.4537860552 * sinVal;
player.skin.body.position.z = 1.3256181 * sinVal - 3.4500310377 * sinVal;
player.skin.body.position.y = -6 - 2.103677462 * sinVal;
player.cape.position.y = 8 - 1.851236166577372 * sinVal;
player.cape.rotation.x = (10.8 * Math.PI) / 180 + 0.294220265771 * sinVal;
player.cape.position.z = -2 + 3.786619432 * sinVal - 3.4500310377 * sinVal;
player.elytra.position.x = player.cape.position.x;
player.elytra.position.y = player.cape.position.y;
player.elytra.position.z = player.cape.position.z;
player.elytra.rotation.x = player.cape.rotation.x - (10.8 * Math.PI) / 180;
const pr1 = this.progress / this.speed;
if (sinVal === 1) {
this.erp = !this.isCrouched ? pr1 : this.erp;
this.isCrouched = true;
player.elytra.leftWing.rotation.z =
0.26179944 + 0.4582006 * Math.abs(Math.sin((Math.min(pr1 - this.erp, 1) * Math.PI) / 2));
player.elytra.updateRightWing();
}
else if (this.isCrouched !== undefined) {
this.erp = this.isCrouched ? pr1 : this.erp;
player.elytra.leftWing.rotation.z =
0.72 - 0.4582006 * Math.abs(Math.sin((Math.min(pr1 - this.erp, 1) * Math.PI) / 2));
player.elytra.updateRightWing();
this.isCrouched = false;
}
player.skin.head.position.y = -3.618325234674 * sinVal;
player.skin.leftArm.position.z = 3.618325234674 * sinVal - 3.4500310377 * sinVal;
player.skin.rightArm.position.z = player.skin.leftArm.position.z;
player.skin.leftArm.rotation.x = 0.410367746202 * sinVal;
player.skin.rightArm.rotation.x = player.skin.leftArm.rotation.x;
player.skin.leftArm.rotation.z = 0.1;
player.skin.rightArm.rotation.z = -player.skin.leftArm.rotation.z;
player.skin.leftArm.position.y = -2 - 2.53943318 * sinVal;
player.skin.rightArm.position.y = player.skin.leftArm.position.y;
player.skin.rightLeg.position.z = -3.4500310377 * sinVal;
player.skin.leftLeg.position.z = player.skin.rightLeg.position.z;
if (this.isRunningHitAnimation) {
const pr2 = this.progress;
let t = (this.progress * 18 * this.hitAnimationSpeed) / this.speed;
if (this.speed === 0)
t = 0;
const isCrouching = Math.abs(Math.sin((pr2 * Math.PI) / 2)) === 1;
player.skin.rightArm.rotation.x =
-0.4537860552 + 2 * Math.sin(t + Math.PI) * 0.3 - (isCrouching ? 0.4537860552 : 0);
const basicArmRotationZ = 0.01 * Math.PI + 0.06;
player.skin.rightArm.rotation.z = -Math.cos(t) * 0.403 + basicArmRotationZ;
player.skin.body.rotation.y = -Math.cos(t) * 0.06;
player.skin.leftArm.rotation.x = Math.sin(t + Math.PI) * 0.077 + (isCrouching ? 0.47 : 0);
player.skin.leftArm.rotation.z = -Math.cos(t) * 0.015 + 0.13 - (!isCrouching ? 0.05 : 0);
if (!isCrouching) {
player.skin.leftArm.position.z = Math.cos(t) * 0.3;
player.skin.leftArm.position.x = 5 - Math.cos(t) * 0.05;
}
}
}
}
/**
* Hit animation (right arm swings).
*/
class HitAnimation extends PlayerAnimation {
/** @inheritdoc */
animate(player) {
const t = this.progress * 18;
player.skin.rightArm.rotation.x = -0.4537860552 * 2 + 2 * Math.sin(t + Math.PI) * 0.3;
const basicArmRotationZ = 0.01 * Math.PI + 0.06;
player.skin.rightArm.rotation.z = -Math.cos(t) * 0.403 + basicArmRotationZ;
player.skin.body.rotation.y = -Math.cos(t) * 0.06;
player.skin.leftArm.rotation.x = Math.sin(t + Math.PI) * 0.077;
player.skin.leftArm.rotation.z = -Math.cos(t) * 0.015 + 0.13 - 0.05;
player.skin.leftArm.position.z = Math.cos(t) * 0.3;
player.skin.leftArm.position.x = 5 - Math.cos(t) * 0.05;
}
}
/**
* @file Nametag.ts
* @description This file defines the NameTagObject class for creating Minecraft-style name tags.
* @author Cosmic-fi
* @license MIT
*/
/**
* A Minecraft name tag, i.e. a text label with background.
*/
class NameTagObject extends Sprite {
constructor(text = "", options = {}) {
const material = new SpriteMaterial({
transparent: true,
alphaTest: 1e-5,
});
super(material);
/**
* A promise that is resolved after the name tag is fully painted.
*
* This will be a resolved promise, if
* {@link NameTagOptions.repaintAfterLoaded} is `false`, or
* the desired font is available when the `NameTagObject` is created.
*
* If {@link NameTagOptions.repaintAfterLoaded} is `true`, and
* the desired font hasn't been loaded when the `NameTagObject` is created,
* the name tag will be painted with the fallback font first, and then
* repainted with the desired font after it's loaded. This promise is
* resolved after repainting is done.
*/
Object.defineProperty(this, "painted", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "text", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "font", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "margin", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "textStyle", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "backgroundStyle", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "height", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "textMaterial", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
this.textMaterial = material;
this.text = text;
this.font = options.font === undefined ? "48px Minecraft" : options.font;
this.margin = options.margin === undefined ? [5, 10, 5, 10] : options.margin;
this.textStyle = options.textStyle === undefined ? "white" : options.textStyle;
this.backgroundStyle = options.backgroundStyle === undefined ? "rgba(0,0,0,.25)" : options.backgroundStyle;
this.height = options.height === undefined ? 4.0 : options.height;
const repaintAfterLoaded = options.repaintAfterLoaded === undefined ? true : options.repaintAfterLoaded;
if (repaintAfterLoaded && !document.fonts.check(this.font, this.text)) {
this.paint();
this.painted = this.loadAndPaint();
}
else {
this.paint();
this.painted = Promise.resolve();
}
}
/**
* Set the text of the name tag.
* @param newText The new text.
*/
async loadAndPaint() {
await document.fonts.load(this.font, this.text);
this.paint();
}
/**
* Paint the name tag.
* This method creates a canvas, draws the text and background,
* and applies it as a texture to the sprite.
* @private
*/
paint() {
const canvas = document.createElement("canvas");
/**
* Measure the text size
*
* @remarks
* We need to create the canvas and get the context first,
* because some browsers (e.g., Safari) require a canvas to measure text.
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Advanced_text_metrics}
*/
let ctx = canvas.getContext("2d");
ctx.font = this.font;
const metrics = ctx.measureText(this.text);
/**
* Resize the canvas to fit the text with margins
*/
canvas.width = this.margin[3] + metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight + this.margin[1];
canvas.height =
this.margin[0] + metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent + this.margin[2];
/**
* Draw the background and text
* @remarks
* We need to get the context again after resizing the canvas,
* because resizing clears the canvas and resets the context.
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas#resizing_the_canvas
*/
ctx = canvas.getContext("2d");
ctx.font = this.font;
/**
* Draw the background
*/
ctx.fillStyle = this.backgroundStyle;
ctx.fillRect(0, 0, canvas.width, canvas.height);
/**
* Draw the text
*/
ctx.fillStyle = this.textStyle;
ctx.fillText(this.text, this.margin[3] + metrics.actualBoundingBoxLeft, this.margin[0] + metrics.actualBoundingBoxAscent);
/**
* Create the texture and apply it to the sprite
*/
const texture = new CanvasTexture(canvas);
texture.magFilter = NearestFilter;
texture.minFilter = NearestFilter;
this.textMaterial.map = texture;
this.textMaterial.needsUpdate = true;
/**
* Adjust the scale of the sprite to maintain aspect ratio
*/
this.scale.x = (canvas.width / canvas.height) * this.height;
this.scale.y = this.height;
}
}
/**
* @file Render.ts
* @description This file defines the Render class that renders a 3D player model on a canvas.
* @author Cosmic-fi
* @license MIT
*/
/**
* The {Render} renders the player on a canvas.
*/
class Render {
constructor(options = {}) {
/** The canvas where the renderer draws its output. */
Object.defineProperty(this, "canvas", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "scene", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "camera", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "renderer", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/** Mouse control component (OrbitControls). */
Object.defineProperty(this, "controls", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/** The player object (skin, cape, elytra, ears). */
Object.defineProperty(this, "playerObject", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
/** Group that wraps the player object for centering. */
Object.defineProperty(this, "playerWrapper", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "globalLight", {
enumerable: true,
configurable: true,
writable: true,
value: new AmbientLight(0xffffff, 3)
});
Object.defineProperty(this, "cameraLight", {
enumerable: true,
configurable: true,
writable: true,
value: new PointLight(0xffffff, 0.6)
});
Object.defineProperty(this, "composer", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "renderPass", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "fxaaPass", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "skinCanvas", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "capeCanvas", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "earsCanvas", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "skinTexture", {
enumerable: true,
configurable: true,
writable: true,
value: null
});
Object.defineProperty(this, "capeTexture", {
enumerable: true,
configurable: true,
writable: true,
value: null
});
Object.defineProperty(this, "earsTexture", {
enumerable: true,
configurable: true,
writable: true,
value: null
});
Object.defineProperty(this, "backgroundTexture", {
enumerable: true,
configurable: true,
writable: true,
value: null
});
Object.defineProperty(this, "_disposed", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "_renderPaused", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
Object.defineProperty(this, "_zoom", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "isUserRotating", {
enumerable: true,
configurable: true,
writable: true,
value: false
});
/**
* Whether to rotate the player along the y axis.
*
* @defaultValue `fa