Panorama
@@ -329,7 +356,8 @@
input.setCustomValidity("");
} else {
skinViewer.loadSkin(url, {
- model: document.getElementById("skin_model").value
+ model: document.getElementById("skin_model").value,
+ ears: document.getElementById("ears_source").value === "current_skin"
})
.then(() => input.setCustomValidity(""))
.catch(e => {
@@ -356,6 +384,46 @@
}
}
+ function reloadEars(skipSkinReload = false) {
+ const sourceType = document.getElementById("ears_source").value;
+ let hideInput = true;
+ if (sourceType === "none") {
+ skinViewer.loadEars(null);
+ } else if (sourceType === "current_skin") {
+ if (!skipSkinReload){
+ reloadSkin();
+ }
+ } else {
+ hideInput = false;
+ document.querySelectorAll("#default_ears option[data-texture-type]").forEach(opt => {
+ opt.disabled = opt.dataset.textureType !== sourceType;
+ });
+
+ const input = document.getElementById("ears_url");
+ const url = obtainTextureUrl("ears_url");
+ if (url === "") {
+ skinViewer.loadEars(null);
+ input.setCustomValidity("");
+ } else {
+ skinViewer.loadEars(url, { textureType: sourceType })
+ .then(() => input.setCustomValidity(""))
+ .catch(e => {
+ input.setCustomValidity("Image can't be loaded.");
+ console.error(e);
+ });
+ }
+ }
+
+ const el = document.getElementById("ears_texture_input");
+ if (hideInput) {
+ if (!(el.classList.contains("hidden"))){
+ el.classList.add("hidden");
+ }
+ } else {
+ el.classList.remove("hidden");
+ }
+ }
+
function reloadPanorama() {
const input = document.getElementById("panorama_url");
const url = obtainTextureUrl("panorama_url");
@@ -442,11 +510,14 @@
};
initializeUploadButton("skin_url", reloadSkin);
initializeUploadButton("cape_url", reloadCape);
+ initializeUploadButton("ears_url", reloadEars);
initializeUploadButton("panorama_url", reloadPanorama);
document.getElementById("skin_url").addEventListener("change", () => reloadSkin());
document.getElementById("skin_model").addEventListener("change", () => reloadSkin());
document.getElementById("cape_url").addEventListener("change", () => reloadCape());
+ document.getElementById("ears_source").addEventListener("change", () => reloadEars());
+ document.getElementById("ears_url").addEventListener("change", () => reloadEars());
document.getElementById("panorama_url").addEventListener("change", () => reloadPanorama());
for (const el of document.querySelectorAll('input[type="radio"][name="back_equipment"]')) {
@@ -502,6 +573,7 @@
}
reloadSkin();
reloadCape();
+ reloadEars(true);
reloadPanorama();
}
diff --git a/package-lock.json b/package-lock.json
index 1f8e5d5..c5751f2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,7 @@
"license": "MIT",
"dependencies": {
"@types/three": "^0.136.1",
- "skinview-utils": "^0.6.2",
+ "skinview-utils": "^0.7.0",
"three": "^0.136.0"
},
"devDependencies": {
@@ -3647,9 +3647,9 @@
"dev": true
},
"node_modules/skinview-utils": {
- "version": "0.6.2",
- "resolved": "https://registry.npmjs.org/skinview-utils/-/skinview-utils-0.6.2.tgz",
- "integrity": "sha512-UdjWwXCVZobtG+dc7ilvMRbtXYSqPJtWKPFdgWc44Gs4aoOmZML2lErr77h7uussXo9zrcR+fPizGshGpdETvQ==",
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/skinview-utils/-/skinview-utils-0.7.0.tgz",
+ "integrity": "sha512-ecbbUp0AuvZX5fCIOOwbNPxr/JIbrAGhCcdLcww0Ov9PbvAbeYjNDSIu1ebCJBe4CWc6ZYYn8MFNp68DDta0iQ==",
"dependencies": {
"@types/offscreencanvas": "^2019.6.4"
}
@@ -7038,9 +7038,9 @@
"dev": true
},
"skinview-utils": {
- "version": "0.6.2",
- "resolved": "https://registry.npmjs.org/skinview-utils/-/skinview-utils-0.6.2.tgz",
- "integrity": "sha512-UdjWwXCVZobtG+dc7ilvMRbtXYSqPJtWKPFdgWc44Gs4aoOmZML2lErr77h7uussXo9zrcR+fPizGshGpdETvQ==",
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/skinview-utils/-/skinview-utils-0.7.0.tgz",
+ "integrity": "sha512-ecbbUp0AuvZX5fCIOOwbNPxr/JIbrAGhCcdLcww0Ov9PbvAbeYjNDSIu1ebCJBe4CWc6ZYYn8MFNp68DDta0iQ==",
"requires": {
"@types/offscreencanvas": "^2019.6.4"
}
diff --git a/package.json b/package.json
index 673da62..a5a023e 100644
--- a/package.json
+++ b/package.json
@@ -39,7 +39,7 @@
],
"dependencies": {
"@types/three": "^0.136.1",
- "skinview-utils": "^0.6.2",
+ "skinview-utils": "^0.7.0",
"three": "^0.136.0"
},
"devDependencies": {
diff --git a/src/model.ts b/src/model.ts
index 84fa735..f09666a 100644
--- a/src/model.ts
+++ b/src/model.ts
@@ -332,6 +332,33 @@ export class ElytraObject extends Group {
}
}
+export class EarsObject extends Group {
+
+ readonly rightEar: Mesh;
+ readonly leftEar: Mesh;
+
+ constructor(texture: Texture) {
+ super();
+
+ const material = new MeshStandardMaterial({
+ map: texture,
+ side: FrontSide
+ });
+ const earBox = new BoxGeometry(8, 8, 4 / 3);
+ setUVs(earBox, 0, 0, 6, 6, 1, 14, 7);
+
+ this.rightEar = new Mesh(earBox, material);
+ this.rightEar.name = "rightEar";
+ this.rightEar.position.x = -6;
+ this.add(this.rightEar);
+
+ this.leftEar = new Mesh(earBox, material);
+ this.leftEar.name = "leftEar";
+ this.leftEar.position.x = 6;
+ this.add(this.leftEar);
+ }
+}
+
export type BackEquipment = "cape" | "elytra";
export class PlayerObject extends Group {
@@ -339,8 +366,9 @@ export class PlayerObject extends Group {
readonly skin: SkinObject;
readonly cape: CapeObject;
readonly elytra: ElytraObject;
+ readonly ears: EarsObject;
- constructor(skinTexture: Texture, capeTexture: Texture) {
+ constructor(skinTexture: Texture, capeTexture: Texture, earsTexture: Texture) {
super();
this.skin = new SkinObject(skinTexture);
@@ -362,6 +390,13 @@ export class PlayerObject extends Group {
this.elytra.position.z = -2;
this.elytra.visible = false;
this.add(this.elytra);
+
+ this.ears = new EarsObject(earsTexture);
+ 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);
}
get backEquipment(): BackEquipment | null {
diff --git a/src/viewer.ts b/src/viewer.ts
index 46b9578..fe0e2e0 100644
--- a/src/viewer.ts
+++ b/src/viewer.ts
@@ -1,4 +1,4 @@
-import { inferModelType, isTextureSource, loadCapeToCanvas, loadImage, loadSkinToCanvas, ModelType, RemoteImage, TextureSource } from "skinview-utils";
+import { inferModelType, isTextureSource, loadCapeToCanvas, loadEarsToCanvas, loadEarsToCanvasFromSkin, loadImage, loadSkinToCanvas, ModelType, RemoteImage, TextureSource } from "skinview-utils";
import { Color, ColorRepresentation, PointLight, EquirectangularReflectionMapping, Group, NearestFilter, PerspectiveCamera, Scene, Texture, Vector2, WebGLRenderer, AmbientLight, Mapping } from "three";
import { RootAnimation } from "./animation.js";
import { BackEquipment, PlayerObject } from "./model.js";
@@ -15,6 +15,14 @@ export interface SkinLoadOptions extends LoadOptions {
* The model type of skin. Default is "auto-detect".
*/
model?: ModelType | "auto-detect";
+
+ /**
+ * true: Loads the ears drawn on the skin texture, and show it.
+ * "load-only": Loads the ears drawn on the skin texture, but do not make it visible.
+ * false: Do not load ears from the skin texture.
+ * Default is false.
+ */
+ ears?: boolean | "load-only";
}
export interface CapeLoadOptions extends LoadOptions {
@@ -25,6 +33,15 @@ export interface CapeLoadOptions extends LoadOptions {
backEquipment?: BackEquipment;
}
+export interface EarsLoadOptions extends LoadOptions {
+ /**
+ * "standalone": The texture is a 14x7 image that only contains the ears;
+ * "skin": The texture is a skin that contains ears, and we only show its ear part.
+ * Default is "standalone".
+ */
+ textureType?: "standalone" | "skin";
+}
+
export interface SkinViewerOptions {
width?: number;
height?: number;
@@ -32,6 +49,17 @@ export interface SkinViewerOptions {
model?: ModelType | "auto-detect";
cape?: RemoteImage | TextureSource;
+ /**
+ * If you want to show the ears drawn on the current skin, set this to "current-skin".
+ * To show ears that come from a separate texture, you have to specify 'textureType' ("standalone" or "skin") and 'source'.
+ * "standalone" means the provided texture is a 14x7 image that only contains the ears.
+ * "skin" means the provided texture is a skin that contains ears, and we only show its ear part.
+ */
+ ears?: "current-skin" | {
+ textureType: "standalone" | "skin",
+ source: RemoteImage | TextureSource
+ }
+
/**
* Whether the canvas contains an alpha buffer. Default is true.
* This option can be turned off if you use an opaque background.
@@ -92,8 +120,10 @@ export class SkinViewer {
readonly skinCanvas: HTMLCanvasElement;
readonly capeCanvas: HTMLCanvasElement;
+ readonly earsCanvas: HTMLCanvasElement;
private readonly skinTexture: Texture;
private readonly capeTexture: Texture;
+ private readonly earsTexture: Texture;
private backgroundTexture: Texture | null = null;
private _disposed: boolean = false;
@@ -118,6 +148,11 @@ export class SkinViewer {
this.capeTexture.magFilter = NearestFilter;
this.capeTexture.minFilter = NearestFilter;
+ this.earsCanvas = document.createElement("canvas");
+ this.earsTexture = new Texture(this.earsCanvas);
+ this.earsTexture.magFilter = NearestFilter;
+ this.earsTexture.minFilter = NearestFilter;
+
this.scene = new Scene();
this.camera = new PerspectiveCamera();
@@ -133,7 +168,7 @@ export class SkinViewer {
});
this.renderer.setPixelRatio(window.devicePixelRatio);
- this.playerObject = new PlayerObject(this.skinTexture, this.capeTexture);
+ this.playerObject = new PlayerObject(this.skinTexture, this.capeTexture, this.earsTexture);
this.playerObject.name = "player";
this.playerObject.skin.visible = false;
this.playerObject.cape.visible = false;
@@ -143,12 +178,18 @@ export class SkinViewer {
if (options.skin !== undefined) {
this.loadSkin(options.skin, {
- model: options.model
+ model: options.model,
+ ears: options.ears === "current-skin"
});
}
if (options.cape !== undefined) {
this.loadCape(options.cape);
}
+ if (options.ears !== undefined && options.ears !== "current-skin") {
+ this.loadEars(options.ears.source, {
+ textureType: options.ears.textureType
+ });
+ }
if (options.width !== undefined) {
this.width = options.width;
}
@@ -217,6 +258,14 @@ export class SkinViewer {
this.playerObject.skin.visible = true;
}
+ if (options.ears === true || options.ears == "load-only") {
+ loadEarsToCanvasFromSkin(this.earsCanvas, source);
+ this.earsTexture.needsUpdate = true;
+ if (options.ears === true) {
+ this.playerObject.ears.visible = true;
+ }
+ }
+
} else {
return loadImage(source).then(image => this.loadSkin(image, options));
}
@@ -238,12 +287,15 @@ export class SkinViewer {
): void | Promise {
if (source === null) {
this.resetCape();
+
} else if (isTextureSource(source)) {
loadCapeToCanvas(this.capeCanvas, source);
this.capeTexture.needsUpdate = true;
+
if (options.makeVisible !== false) {
this.playerObject.backEquipment = options.backEquipment === undefined ? "cape" : options.backEquipment;
}
+
} else {
return loadImage(source).then(image => this.loadCape(image, options));
}
@@ -253,6 +305,40 @@ export class SkinViewer {
this.playerObject.backEquipment = null;
}
+ loadEars(empty: null): void;
+ loadEars(
+ source: S,
+ options?: EarsLoadOptions
+ ): S extends TextureSource ? void : Promise;
+
+ loadEars(
+ source: TextureSource | RemoteImage | null,
+ options: EarsLoadOptions = {}
+ ): void | Promise {
+ if (source === null) {
+ this.resetEars();
+
+ } else if (isTextureSource(source)) {
+ if (options.textureType === "skin") {
+ loadEarsToCanvasFromSkin(this.earsCanvas, source);
+ } else {
+ loadEarsToCanvas(this.earsCanvas, source);
+ }
+ this.earsTexture.needsUpdate = true;
+
+ if (options.makeVisible !== false) {
+ this.playerObject.ears.visible = true;
+ }
+
+ } else {
+ return loadImage(source).then(image => this.loadEars(image, options));
+ }
+ }
+
+ resetEars(): void {
+ this.playerObject.ears.visible = false;
+ }
+
loadPanorama(
source: S
): S extends TextureSource ? void : Promise {