diff --git a/README.md b/README.md index c5b0a65e..4bfc2616 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,20 @@ Three.js powered Minecraft skin viewer. // Load an elytra (from a cape texture) skinViewer.loadCape("img/cape.png", { backEquipment: "elytra" }); - // Unload(hide) the cape / elytra + // Unload (hide) the cape / elytra skinViewer.loadCape(null); + // Load armors + skinViewer.loadArmors( + "img/turtle_layer_1.png", // helmet (main) + "img/diamond_layer_1.png", // chestplate (main) + "img/gold_layer_2.png", // leggings (legs) + "img/iron_layer_1.png" // boots (main) + ); + + // Unload (hide) the armors + skinViewer.loadArmors(null); + // Set the background color skinViewer.background = 0x5a76f3; @@ -93,6 +104,84 @@ skinViewer.globalLight.intensity = 3; Setting `globalLight.intensity` to `3.0` and `cameraLight.intensity` to `0.0` will completely disable shadows. + +## Armors + +skinview3d supports loading armor textures for the player. Armor textures can be specified as an object with the following optional properties: + +- `helmet`, `chestplate`, `leggings`, `boots`: textures for the corresponding pieces. +- `main`: a texture that will be used for helmet, chestplate, and boots if their specific textures are not provided. +- `legs`: a texture that will be used for leggings if `leggings` is not provided. + +Each texture can be a `RemoteImage` (URL string), a `TextureSource` (HTML image element or canvas), or `null` to hide that piece. + +### Loading Armors + +You can load armors using the `loadArmors` method. It accepts an object conforming to the structure above, or `null` to hide all armor. + +Examples: + +```js +// Hide all armor +skinViewer.loadArmors(null); + +// Equip only helmet, chestplate, and boots using the same "main" texture +skinViewer.loadArmors({ main: "img/diamond_layer_1.png" }); + +// Equip only leggings using a "legs" texture +skinViewer.loadArmors({ legs: "img/diamond_layer_2.png" }); + +// Equip both main and legs textures +skinViewer.loadArmors({ + main: "img/diamond_layer_1.png", + legs: "img/diamond_layer_2.png" +}); + +// Equip all four pieces individually (helmet, chestplate, leggings, boots) +skinViewer.loadArmors({ + helmet: "img/turtle_layer_1.png", + chestplate: "img/diamond_layer_1.png", + leggings: "img/gold_layer_2.png", + boots: "img/iron_layer_1.png" +}); + +// Mix specific pieces with fallback textures +skinViewer.loadArmors({ + helmet: "img/turtle_layer_1.png", + main: "img/diamond_layer_1.png", // used for chestplate and boots + leggings: "img/gold_layer_2.png" +}); +``` + +### Using Armors in the Constructor + +You can specify armors directly in the `SkinViewer` options via the `armors` property. It accepts the same object format as `loadArmors`. + +```js +new skinview3d.SkinViewer({ + skin: "img/skin.png", + armors: { + helmet: "img/turtle_layer_1.png", + chestplate: "img/diamond_layer_1.png", + leggings: "img/gold_layer_2.png", + boots: "img/iron_layer_1.png" + } +}); +``` + +### Loading Armors Together with a Skin + +The `loadSkin` method also accepts an `armors` option, which behaves identically to the constructor option. + +```js +skinViewer.loadSkin("img/skin.png", { + armors: { + main: "img/diamond_layer_1.png", + legs: "img/diamond_layer_2.png" + } +}); +``` + ## Ears skinview3d supports two types of ear texture: * `standalone`: 14x7 image that contains the ear ([example](https://github.com/bs-community/skinview3d/blob/master/examples/public/img/ears.png)) diff --git a/examples/index.html b/examples/index.html index 46ff0d32..aaacf9c8 100644 --- a/examples/index.html +++ b/examples/index.html @@ -288,6 +288,80 @@

Cape

+
+

Armors

+
+ + + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + + + + + + + + +
+
+

Ears

diff --git a/examples/main.ts b/examples/main.ts index c52cbad4..02750493 100644 --- a/examples/main.ts +++ b/examples/main.ts @@ -17,7 +17,6 @@ const availableAnimations = { }; let skinViewer: skinview3d.SkinViewer; - function obtainTextureUrl(id: string): string { const urlInput = document.getElementById(id) as HTMLInputElement; const fileInput = document.getElementById(`${id}_upload`) as HTMLInputElement; @@ -42,6 +41,7 @@ function obtainTextureUrl(id: string): string { } function reloadSkin(): void { + window.skinViewer = skinViewer; //Set window object to debug in console. const input = document.getElementById("skin_url") as HTMLInputElement; const url = obtainTextureUrl("skin_url"); if (url === "") { @@ -50,7 +50,6 @@ function reloadSkin(): void { } else { const skinModel = document.getElementById("skin_model") as HTMLSelectElement; const earsSource = document.getElementById("ears_source") as HTMLSelectElement; - skinViewer .loadSkin(url, { model: skinModel?.value as ModelType, @@ -84,6 +83,41 @@ function reloadCape(): void { } } +function reloadArmors(): void { + const input1 = document.getElementById("helmet_url") as HTMLInputElement; + const url1 = obtainTextureUrl("helmet_url"); + const input2 = document.getElementById("chestplate_url") as HTMLInputElement; + const url2 = obtainTextureUrl("chestplate_url"); + const input3 = document.getElementById("leggings_url") as HTMLInputElement; + const url3 = obtainTextureUrl("leggings_url"); + const input4 = document.getElementById("boots_url") as HTMLInputElement; + const url4 = obtainTextureUrl("boots_url"); + const textures = { + helmet: url1, + chestplate: url2, + leggings: url3, + boots: url4, + }; + const inputs = [input1.input2, input3, input4]; + Object.keys(textures).forEach((key, index) => { + if (textures[key] === "") { + inputs[index]?.setCustomValidity(""); + textures[key] = null; + } + }); + skinViewer + .loadArmors(textures) + ?.then(() => { + inputs.forEach(input => { + input?.setCustomValidity(""); + }); + }) + ?.catch(e => { + input4?.setCustomValidity("One of the 4 images can't be loaded."); + console.error(e); + }); +} + function reloadEars(skipSkinReload = false): void { const earsSource = document.getElementById("ears_source") as HTMLSelectElement; const sourceType = earsSource?.value; @@ -398,12 +432,20 @@ function initializeControls(): void { initializeUploadButton("skin_url", reloadSkin); initializeUploadButton("cape_url", reloadCape); + initializeUploadButton("helmet_url", reloadArmors); + initializeUploadButton("chestplate_url", reloadArmors); + initializeUploadButton("leggings_url", reloadArmors); + initializeUploadButton("boots_url", reloadArmors); initializeUploadButton("ears_url", reloadEars); initializeUploadButton("panorama_url", reloadPanorama); const skinUrl = document.getElementById("skin_url") as HTMLInputElement; const skinModel = document.getElementById("skin_model") as HTMLSelectElement; const capeUrl = document.getElementById("cape_url") as HTMLInputElement; + const helmetUrl = document.getElementById("helmet_url") as HTMLInputElement; + const chestplateUrl = document.getElementById("chestplate_url") as HTMLInputElement; + const leggingsUrl = document.getElementById("leggings_url") as HTMLInputElement; + const bootsUrl = document.getElementById("boots_url") as HTMLInputElement; const earsSource = document.getElementById("ears_source") as HTMLSelectElement; const earsUrl = document.getElementById("ears_url") as HTMLInputElement; const panoramaUrl = document.getElementById("panorama_url") as HTMLInputElement; @@ -411,6 +453,10 @@ function initializeControls(): void { skinUrl?.addEventListener("change", reloadSkin); skinModel?.addEventListener("change", reloadSkin); capeUrl?.addEventListener("change", reloadCape); + helmetUrl?.addEventListener("change", reloadArmors); + chestplateUrl?.addEventListener("change", reloadArmors); + leggingsUrl?.addEventListener("change", reloadArmors); + bootsUrl?.addEventListener("change", reloadArmors); earsSource?.addEventListener("change", () => reloadEars()); earsUrl?.addEventListener("change", () => reloadEars()); panoramaUrl?.addEventListener("change", reloadPanorama); @@ -511,6 +557,7 @@ function initializeViewer(): void { reloadSkin(); reloadCape(); + reloadArmors(); reloadEars(true); reloadPanorama(); reloadNameTag(); diff --git a/examples/public/img/chainmail_layer_1.png b/examples/public/img/chainmail_layer_1.png new file mode 100644 index 00000000..cd9ed23e Binary files /dev/null and b/examples/public/img/chainmail_layer_1.png differ diff --git a/examples/public/img/chainmail_layer_2.png b/examples/public/img/chainmail_layer_2.png new file mode 100644 index 00000000..0d47920d Binary files /dev/null and b/examples/public/img/chainmail_layer_2.png differ diff --git a/examples/public/img/copper_layer_1.png b/examples/public/img/copper_layer_1.png new file mode 100644 index 00000000..70272f3c Binary files /dev/null and b/examples/public/img/copper_layer_1.png differ diff --git a/examples/public/img/copper_layer_2.png b/examples/public/img/copper_layer_2.png new file mode 100644 index 00000000..3cd59c1e Binary files /dev/null and b/examples/public/img/copper_layer_2.png differ diff --git a/examples/public/img/diamond_layer_1.png b/examples/public/img/diamond_layer_1.png new file mode 100644 index 00000000..750b61d9 Binary files /dev/null and b/examples/public/img/diamond_layer_1.png differ diff --git a/examples/public/img/diamond_layer_2.png b/examples/public/img/diamond_layer_2.png new file mode 100644 index 00000000..a9d69e8b Binary files /dev/null and b/examples/public/img/diamond_layer_2.png differ diff --git a/examples/public/img/gold_layer_1.png b/examples/public/img/gold_layer_1.png new file mode 100644 index 00000000..bbd30114 Binary files /dev/null and b/examples/public/img/gold_layer_1.png differ diff --git a/examples/public/img/gold_layer_2.png b/examples/public/img/gold_layer_2.png new file mode 100644 index 00000000..0d1032e9 Binary files /dev/null and b/examples/public/img/gold_layer_2.png differ diff --git a/examples/public/img/iron_layer_1.png b/examples/public/img/iron_layer_1.png new file mode 100644 index 00000000..9c54e7e2 Binary files /dev/null and b/examples/public/img/iron_layer_1.png differ diff --git a/examples/public/img/iron_layer_2.png b/examples/public/img/iron_layer_2.png new file mode 100644 index 00000000..f45fb536 Binary files /dev/null and b/examples/public/img/iron_layer_2.png differ diff --git a/examples/public/img/netherite_layer_1.png b/examples/public/img/netherite_layer_1.png new file mode 100644 index 00000000..11c961e8 Binary files /dev/null and b/examples/public/img/netherite_layer_1.png differ diff --git a/examples/public/img/netherite_layer_2.png b/examples/public/img/netherite_layer_2.png new file mode 100644 index 00000000..526c7be1 Binary files /dev/null and b/examples/public/img/netherite_layer_2.png differ diff --git a/examples/public/img/turtle_layer_1.png b/examples/public/img/turtle_layer_1.png new file mode 100644 index 00000000..1ad88308 Binary files /dev/null and b/examples/public/img/turtle_layer_1.png differ diff --git a/src/model.ts b/src/model.ts index c103fbd3..851424bc 100644 --- a/src/model.ts +++ b/src/model.ts @@ -57,6 +57,43 @@ function setUVs( uvAttr.set(new Float32Array(newUVData)); uvAttr.needsUpdate = true; } +function mirrorUVs(geometry: BoxGeometry) { + const uvAttr = geometry.attributes.uv; + const uvArray = uvAttr.array; // Float32Array + const floatsPerFace = 8; // 4 vertices * 2 + const faceCount = 6; + const mirroredFaces = []; + for (let faceIdx = 0; faceIdx < faceCount; faceIdx++) { + const start = faceIdx * floatsPerFace; + const uValues = []; + for (let i = 0; i < 4; i++) { + uValues.push(uvArray[start + i * 2]); + } + const minU = Math.min(...uValues); + const maxU = Math.max(...uValues); + const faceUVs = []; + for (let i = 0; i < 4; i++) { + const oldU = uvArray[start + i * 2]; + const oldV = uvArray[start + i * 2 + 1]; + const newU = minU + maxU - oldU; + faceUVs.push(newU, oldV); + } + mirroredFaces.push(faceUVs); + } + + [mirroredFaces[0], mirroredFaces[1]] = [mirroredFaces[1], mirroredFaces[0]]; + + for (let faceIdx = 0; faceIdx < faceCount; faceIdx++) { + const start = faceIdx * floatsPerFace; + const faceData = mirroredFaces[faceIdx]; + for (let i = 0; i < 4; i++) { + uvArray[start + i * 2] = faceData[i * 2]; + uvArray[start + i * 2 + 1] = faceData[i * 2 + 1]; + } + } + + uvAttr.needsUpdate = true; +} function setSkinUVs(box: BoxGeometry, u: number, v: number, width: number, height: number, depth: number): void { setUVs(box, u, v, width, height, depth, 64, 64); @@ -467,6 +504,131 @@ export class EarsObject extends Group { } } +export class ArmorsObject extends Group { + readonly headArmor: Mesh; + readonly leftArmArmor: Mesh; + readonly rightArmArmor: Mesh; + readonly bodyArmor: Mesh; + readonly bodyArmor2: Mesh; + readonly leftLegArmor: Mesh; + readonly leftLegArmor2: Mesh; + readonly rightLegArmor: Mesh; + readonly rightLegArmor2: Mesh; + private armorHelmetMaterial: MeshStandardMaterial; + private armorChestplateMaterial: MeshStandardMaterial; + private armorLeggingsMaterial: MeshStandardMaterial; + private armorBootsMaterial: MeshStandardMaterial; + constructor() { + super(); + this.armorHelmetMaterial = new MeshStandardMaterial({ + side: DoubleSide, + transparent: true, + alphaTest: 1e-5, + }); + this.armorChestplateMaterial = new MeshStandardMaterial({ + side: DoubleSide, + transparent: true, + alphaTest: 1e-5, + }); + this.armorLeggingsMaterial = new MeshStandardMaterial({ + side: DoubleSide, + transparent: true, + alphaTest: 1e-5, + }); + this.armorBootsMaterial = new MeshStandardMaterial({ + side: DoubleSide, + transparent: true, + alphaTest: 1e-5, + }); + const headArmorBox = new BoxGeometry(10, 10, 10); + setSkinUVs(headArmorBox, 0, 0, 8, 8, 8); + this.headArmor = new Mesh(headArmorBox, this.armorHelmetMaterial); + this.headArmor.name = "headArmor"; + this.headArmor.position.y = 4; + + const bodyArmorBox = new BoxGeometry(10, 14, 6); + setSkinUVs(bodyArmorBox, 16, 16, 8, 12, 4); + this.bodyArmor = new Mesh(bodyArmorBox, this.armorChestplateMaterial); + const bodyArmor2Box = new BoxGeometry(9.3, 13.3, 5.3); + setSkinUVs(bodyArmor2Box, 16, 16, 8, 12, 4); + this.bodyArmor2 = new Mesh(bodyArmor2Box, this.armorLeggingsMaterial); + this.bodyArmor.name = "bodyArmor"; + this.bodyArmor2.name = "bodyArmor2"; + + const rightArmArmorBox = new BoxGeometry(6.5, 14.5, 6.5); + this.rightArmArmor = new Mesh(rightArmArmorBox, this.armorChestplateMaterial); + setSkinUVs(rightArmArmorBox, 40, 16, 4, 12, 4); + this.rightArmArmor.name = "rightArmArmor"; + this.rightArmArmor.position.x = -1; + this.rightArmArmor.position.y = -4; + + const leftArmArmorBox = new BoxGeometry(6.5, 14.5, 6.5); + this.leftArmArmor = new Mesh(leftArmArmorBox, this.armorChestplateMaterial); + setSkinUVs(leftArmArmorBox, 40, 16, 4, 12, 4); + mirrorUVs(leftArmArmorBox); + this.leftArmArmor.name = "leftArmArmor"; + this.leftArmArmor.position.x = 1; + this.leftArmArmor.position.y = -4; + + const rightLegArmorBox = new BoxGeometry(5, 13, 5); + setSkinUVs(rightLegArmorBox, 0, 16, 4, 12, 4); + this.rightLegArmor = new Mesh(rightLegArmorBox, this.armorLeggingsMaterial); + + const rightLegArmor2Box = new BoxGeometry(5.5, 14.5, 6.5); + setSkinUVs(rightLegArmor2Box, 0, 16, 4, 12, 4); + this.rightLegArmor2 = new Mesh(rightLegArmor2Box, this.armorBootsMaterial); + + this.rightLegArmor.name = "rightLegArmor"; + this.rightLegArmor2.name = "rightLegArmor2"; + this.rightLegArmor.position.y = -6; + this.rightLegArmor2.position.y = -6; + + const leftLegArmorBox = new BoxGeometry(5, 13, 5); + setSkinUVs(leftLegArmorBox, 0, 16, 4, 12, 4); + this.leftLegArmor = new Mesh(leftLegArmorBox, this.armorLeggingsMaterial); + mirrorUVs(leftLegArmorBox); + + const leftLegArmor2Box = new BoxGeometry(5.5, 14.5, 6.5); + setSkinUVs(leftLegArmor2Box, 0, 16, 4, 12, 4); + this.leftLegArmor2 = new Mesh(leftLegArmor2Box, this.armorBootsMaterial); + mirrorUVs(leftLegArmor2Box); + this.leftLegArmor.name = "leftLegArmor"; + this.leftLegArmor2.name = "leftLegArmor2"; + this.leftLegArmor.position.y = -6; + this.leftLegArmor2.position.y = -6; + + this.add( + this.headArmor, + this.bodyArmor, + this.bodyArmor2, + this.rightArmArmor, + this.leftArmArmor, + this.rightLegArmor, + this.rightLegArmor2, + this.leftLegArmor, + this.leftLegArmor2 + ); + } + public setArmorMaps(map1: Texture | null, map2: Texture | null, map3: Texture | null, map4: Texture | null): void { + this.armorHelmetMaterial.map = map1; + this.armorHelmetMaterial.needsUpdate = true; + this.armorChestplateMaterial.map = map2; + this.armorChestplateMaterial.needsUpdate = true; + this.armorLeggingsMaterial.map = map3; + this.armorLeggingsMaterial.needsUpdate = true; + this.armorBootsMaterial.map = map4; + this.armorBootsMaterial.needsUpdate = true; + } + get maps(): (Texture | null)[] { + return [ + this.armorHelmetMaterial.map, + this.armorChestplateMaterial.map, + this.armorLeggingsMaterial.map, + this.armorBootsMaterial.map, + ]; + } +} + export type BackEquipment = "cape" | "elytra"; const CapeDefaultAngle = (10.8 * Math.PI) / 180; @@ -476,6 +638,7 @@ export class PlayerObject extends Group { readonly cape: CapeObject; readonly elytra: ElytraObject; readonly ears: EarsObject; + readonly armors: ArmorsObject; constructor() { super(); @@ -506,6 +669,27 @@ export class PlayerObject extends Group { this.ears.position.z = 2 / 3; this.ears.visible = false; this.skin.head.add(this.ears); + + this.armors = new ArmorsObject(); + this.armors.name = "armors"; + this.armors.headArmor.visible = false; + this.armors.bodyArmor.visible = false; + this.armors.bodyArmor2.visible = false; + this.armors.leftArmArmor.visible = false; + this.armors.rightArmArmor.visible = false; + this.armors.rightLegArmor.visible = false; + this.armors.rightLegArmor2.visible = false; + this.armors.leftLegArmor.visible = false; + this.armors.leftLegArmor2.visible = false; + this.skin.head.add(this.armors.headArmor); + this.skin.body.add(this.armors.bodyArmor); + this.skin.body.add(this.armors.bodyArmor2); + this.skin.leftArm.add(this.armors.leftArmArmor); + this.skin.rightArm.add(this.armors.rightArmArmor); + this.skin.rightLeg.add(this.armors.rightLegArmor); + this.skin.rightLeg.add(this.armors.rightLegArmor2); + this.skin.leftLeg.add(this.armors.leftLegArmor); + this.skin.leftLeg.add(this.armors.leftLegArmor2); } get backEquipment(): BackEquipment | null { diff --git a/src/viewer.ts b/src/viewer.ts index eb3af670..2e61f30a 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -32,6 +32,7 @@ import { Clock, Object3D, ColorManagement, + MeshStandardMaterial, } from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js"; @@ -73,6 +74,20 @@ export interface SkinLoadOptions extends LoadOptions { * @defaultValue `false` */ ears?: boolean | "load-only"; + + /** + * The armor textures to load along with the skin. + * + * This option behaves exactly the same as the `armors` option in {@link SkinViewerOptions}. + * You can specify an object containing textures for specific armor pieces. + * The object may include any of the following optional properties: + * - `helmet`, `chestplate`, `leggings`, `boots`: direct textures for each piece. + * - `main`: a texture that will be used for helmet, chestplate, and boots if their specific textures are not provided. + * - `legs`: a texture that will be used for leggings if not provided. + * + * If the option is omitted, all armors will be removed. + */ + armors?: ArmorTexture; } export interface CapeLoadOptions extends LoadOptions { @@ -97,7 +112,20 @@ export interface EarsLoadOptions extends LoadOptions { */ textureType?: "standalone" | "skin"; } - +type ArmorTexture = Partial<{ + helmet: TextureSource | RemoteImage | null; + chestplate: TextureSource | RemoteImage | null; + leggings: TextureSource | RemoteImage | null; + boots: TextureSource | RemoteImage | null; + main: TextureSource | RemoteImage | null; + legs: TextureSource | RemoteImage | null; +}>; +type NormalizedArmor = { + helmet: TextureSource | RemoteImage | null; + chestplate: TextureSource | RemoteImage | null; + leggings: TextureSource | RemoteImage | null; + boots: TextureSource | RemoteImage | null; +}; export interface SkinViewerOptions { /** * The canvas where the renderer draws its output. @@ -169,6 +197,24 @@ export interface SkinViewerOptions { textureType: "standalone" | "skin"; source: RemoteImage | TextureSource; }; + /** + * The armor textures of the player. + * + * You can specify an object containing textures for different armor pieces. + * The object may include any of the following optional properties: + * - `helmet`, `chestplate`, `leggings`, `boots`: textures for individual pieces. + * - `main`: a texture that will be applied to helmet, chestplate, and boots when their specific properties are absent. + * - `legs`: a texture that will be applied to leggings when `leggings` is absent. + * + * Each texture can be a `RemoteImage` (URL string), a `TextureSource` (HTML image element), or `null` to hide that piece. + * + * If a property is omitted, it defaults to `null` (no texture for that piece), but may be overridden by `main` or `legs` as described above. + * + * If the option is omitted, set to `null`, or an empty object, all armors will be removed. + * + * @defaultValue `undefined` (no armor) + */ + armors?: ArmorTexture; /** * Whether to preserve the buffers until manually cleared or overwritten. @@ -290,9 +336,20 @@ export class SkinViewer { readonly fxaaPass: ShaderPass; readonly skinCanvas: HTMLCanvasElement; + + readonly armorHelmetCanvas: HTMLCanvasElement; + readonly armorChestplateCanvas: HTMLCanvasElement; + readonly armorLeggingsCanvas: HTMLCanvasElement; + readonly armorBootsCanvas: HTMLCanvasElement; + private contexts: (CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D)[] = []; + readonly capeCanvas: HTMLCanvasElement; readonly earsCanvas: HTMLCanvasElement; private skinTexture: Texture | null = null; + private armorHelmetTexture: Texture | null = null; + private armorChestplateTexture: Texture | null = null; + private armorLeggingsTexture: Texture | null = null; + private armorBootsTexture: Texture | null = null; private capeTexture: Texture | null = null; private earsTexture: Texture | null = null; private backgroundTexture: Texture | null = null; @@ -335,6 +392,10 @@ export class SkinViewer { this.canvas = options.canvas === undefined ? document.createElement("canvas") : options.canvas; this.skinCanvas = document.createElement("canvas"); + this.armorHelmetCanvas = document.createElement("canvas"); + this.armorChestplateCanvas = document.createElement("canvas"); + this.armorLeggingsCanvas = document.createElement("canvas"); + this.armorBootsCanvas = document.createElement("canvas"); this.capeCanvas = document.createElement("canvas"); this.earsCanvas = document.createElement("canvas"); @@ -390,6 +451,7 @@ export class SkinViewer { this.playerObject.name = "player"; this.playerObject.skin.visible = false; this.playerObject.cape.visible = false; + this.playerObject.armors.visible = false; this.playerWrapper = new Group(); this.playerWrapper.add(this.playerObject); this.scene.add(this.playerWrapper); @@ -409,6 +471,9 @@ export class SkinViewer { ears: options.ears === "current-skin", }); } + if (options.armors !== undefined) { + this.loadArmors(options.armors); + } if (options.cape !== undefined) { this.loadCape(options.cape); } @@ -505,6 +570,34 @@ export class SkinViewer { this.fxaaPass.material.uniforms["resolution"].value.y = 1 / (this.height * pixelRatio); } + private recreateArmorTexture(): void { + [this.armorHelmetTexture, this.armorChestplateTexture, this.armorLeggingsTexture, this.armorBootsTexture].forEach( + texture => { + if (texture !== null) { + texture.dispose(); + } + } + ); + this.armorHelmetTexture = new CanvasTexture(this.armorHelmetCanvas); + this.armorHelmetTexture.magFilter = NearestFilter; + this.armorHelmetTexture.minFilter = NearestFilter; + this.armorChestplateTexture = new CanvasTexture(this.armorChestplateCanvas); + this.armorChestplateTexture.magFilter = NearestFilter; + this.armorChestplateTexture.minFilter = NearestFilter; + this.armorLeggingsTexture = new CanvasTexture(this.armorLeggingsCanvas); + this.armorLeggingsTexture.magFilter = NearestFilter; + this.armorLeggingsTexture.minFilter = NearestFilter; + this.armorBootsTexture = new CanvasTexture(this.armorBootsCanvas); + this.armorBootsTexture.magFilter = NearestFilter; + this.armorBootsTexture.minFilter = NearestFilter; + this.playerObject.armors.setArmorMaps( + this.armorHelmetTexture, + this.armorChestplateTexture, + this.armorLeggingsTexture, + this.armorBootsTexture + ); + } + private recreateSkinTexture(): void { if (this.skinTexture !== null) { this.skinTexture.dispose(); @@ -535,6 +628,147 @@ export class SkinViewer { this.earsTexture.minFilter = NearestFilter; this.playerObject.ears.map = this.earsTexture; } + loadArmors(empty: null): void | Promise; + loadArmors(textures: ArmorTexture | null): void | Promise; + loadArmors(textures: ArmorTexture | null) { + if (!textures) { + this.resetArmors(); + return; + } + + let texturesCopy: NormalizedArmor = { + helmet: textures.helmet ?? textures.main ?? null, + chestplate: textures.chestplate ?? textures.main ?? null, + leggings: textures.leggings ?? textures.legs ?? null, + boots: textures.boots ?? textures.main ?? null, + }; + const syncImages: ArmorTexture = {}; + const asyncPromises: Promise[] = []; + + (Object.keys(texturesCopy) as Array).forEach(key => { + if (texturesCopy[key] === null) { + syncImages[key] = null; + } else if (isTextureSource(texturesCopy[key])) { + syncImages[key] = texturesCopy[key] as TextureSource; + } else { + asyncPromises.push( + loadImage(texturesCopy[key] as RemoteImage).then(img => { + syncImages[key] = img; + }) + ); + } + }); + + if (asyncPromises.length > 0) { + return Promise.all(asyncPromises).then(() => { + this._applyArmorTextures(syncImages as NormalizedArmor); + }); + } else { + this._applyArmorTextures(syncImages as NormalizedArmor); + } + } + + private inferArmorType(texture: TextureSource): "legs" | "main" { + const canvas = document.createElement("canvas"); + canvas.width = texture.width; + canvas.height = texture.height; + const ctx = canvas.getContext("2d", { willReadFrequently: true }) as CanvasRenderingContext2D; + ctx.drawImage(texture, 0, 0, texture.width, texture.height); + + const scale = canvas.width / 64; + const x = 0; + const y = 0; + const w = Math.floor(32 * scale); + const h = Math.floor(16 * scale); + + const imgData = ctx.getImageData(x, y, w, h); + const data = imgData.data; + const pixelCount = w * h; + + let allTransparent = true; + let allBlack = true; + let allWhite = true; + + for (let i = 0; i < pixelCount; i++) { + const offset = i * 4; + const r = data[offset]; + const g = data[offset + 1]; + const b = data[offset + 2]; + const a = data[offset + 3]; + + if (a !== 0) allTransparent = false; + if (!(r === 0 && g === 0 && b === 0 && a === 255)) allBlack = false; + if (!(r === 255 && g === 255 && b === 255 && a === 255)) allWhite = false; + + if (!allTransparent && !allBlack && !allWhite) { + return "main"; + } + } + + return allTransparent || allBlack || allWhite ? "legs" : "main"; + } + + private _applyArmorTextures(textures: NormalizedArmor): void { + const helmet = textures.helmet as TextureSource; + const chestplate = textures.chestplate as TextureSource; + const leggings = textures.leggings as TextureSource; + const boots = textures.boots as TextureSource; + const inferArmorType = this.inferArmorType; + const armorTypeMap: Record = { + helmet: "main", + chestplate: "main", + leggings: "legs", + boots: "main", + }; + + const armorNames = ["helmet", "chestplate", "leggings", "boots"]; + function setInvalidTextureWarn(type: keyof NormalizedArmor) { + if (textures[type] && inferArmorType(textures[type] as TextureSource) != armorTypeMap[type]) { + console.warn(`Invalid texture , from ${type}`); + } + } + armorNames.forEach(key => { + setInvalidTextureWarn(key as keyof NormalizedArmor); + }); + if (this.contexts.length === 0) { + this.contexts = [ + this.armorHelmetCanvas.getContext("2d", { willReadFrequently: true }) as CanvasRenderingContext2D, + this.armorChestplateCanvas.getContext("2d", { willReadFrequently: true }) as CanvasRenderingContext2D, + this.armorLeggingsCanvas.getContext("2d", { willReadFrequently: true }) as CanvasRenderingContext2D, + this.armorBootsCanvas.getContext("2d", { willReadFrequently: true }) as CanvasRenderingContext2D, + ]; + } + + [this.armorHelmetCanvas, this.armorChestplateCanvas, this.armorLeggingsCanvas, this.armorBootsCanvas].forEach( + (canvas, index) => { + if (textures[armorNames[index] as keyof NormalizedArmor]) { + const sideLength = (textures[armorNames[index] as keyof NormalizedArmor] as TextureSource).width; + canvas.width = sideLength; + canvas.height = sideLength; + } + (canvas.getContext("2d") as CanvasRenderingContext2D).clearRect(0, 0, canvas.width, canvas.height); + } + ); + + if (helmet) this.contexts[0].drawImage(helmet as CanvasImageSource, 0, 0, helmet.width, helmet.width / 2); + if (chestplate) + this.contexts[1].drawImage(chestplate as CanvasImageSource, 0, 0, chestplate.width, chestplate.width / 2); + if (leggings) this.contexts[2].drawImage(leggings as CanvasImageSource, 0, 0, leggings.width, leggings.width / 2); + if (boots) this.contexts[3].drawImage(boots as CanvasImageSource, 0, 0, boots.width, boots.width / 2); + + this.recreateArmorTexture(); + this.playerObject.armors.visible = true; + this.playerObject.armors.headArmor && (this.playerObject.armors.headArmor.visible = !!helmet); + this.playerObject.armors.bodyArmor && (this.playerObject.armors.bodyArmor.visible = !!chestplate || !!leggings); + this.playerObject.armors.leftArmArmor && (this.playerObject.armors.leftArmArmor.visible = !!chestplate); + this.playerObject.armors.rightArmArmor && (this.playerObject.armors.rightArmArmor.visible = !!chestplate); + this.playerObject.armors.leftLegArmor && (this.playerObject.armors.leftLegArmor.visible = !!leggings || !!boots); + this.playerObject.armors.rightLegArmor && (this.playerObject.armors.rightLegArmor.visible = !!leggings || !!boots); + this.playerObject.armors.bodyArmor2 && (this.playerObject.armors.bodyArmor2.visible = !!chestplate || !!leggings); + this.playerObject.armors.leftLegArmor2 && (this.playerObject.armors.leftLegArmor2.visible = !!leggings || !!boots); + this.playerObject.armors.rightLegArmor2 && + (this.playerObject.armors.rightLegArmor2.visible = !!leggings || !!boots); + } loadSkin(empty: null): void; loadSkin( @@ -554,7 +788,9 @@ export class SkinViewer { } else { this.playerObject.skin.modelType = options.model; } - + if (options.armors !== undefined) { + this.loadArmors(options.armors); + } if (options.makeVisible !== false) { this.playerObject.skin.visible = true; } @@ -574,10 +810,48 @@ export class SkinViewer { return loadImage(source).then(image => this.loadSkin(image, options)); } } - + resetArmors(): void { + this.playerObject.armors.visible = false; + this.playerObject.armors.headArmor && (this.playerObject.armors.headArmor.visible = false); + this.playerObject.armors.bodyArmor && (this.playerObject.armors.bodyArmor.visible = false); + this.playerObject.armors.leftArmArmor && (this.playerObject.armors.leftArmArmor.visible = false); + this.playerObject.armors.rightArmArmor && (this.playerObject.armors.rightArmArmor.visible = false); + this.playerObject.armors.leftLegArmor && (this.playerObject.armors.leftLegArmor.visible = false); + this.playerObject.armors.rightLegArmor && (this.playerObject.armors.rightLegArmor.visible = false); + this.playerObject.armors.bodyArmor2 && (this.playerObject.armors.bodyArmor2.visible = false); + this.playerObject.armors.leftLegArmor2 && (this.playerObject.armors.leftLegArmor2.visible = false); + this.playerObject.armors.rightLegArmor2 && (this.playerObject.armors.rightLegArmor2.visible = false); + this.playerObject.armors.headArmor && + ((this.playerObject.armors.headArmor.material as MeshStandardMaterial).map = null); + this.playerObject.armors.bodyArmor && + ((this.playerObject.armors.bodyArmor.material as MeshStandardMaterial).map = null); + this.playerObject.armors.leftArmArmor && + ((this.playerObject.armors.leftArmArmor.material as MeshStandardMaterial).map = null); + this.playerObject.armors.rightArmArmor && + ((this.playerObject.armors.rightArmArmor.material as MeshStandardMaterial).map = null); + this.playerObject.armors.leftLegArmor && + ((this.playerObject.armors.leftLegArmor.material as MeshStandardMaterial).map = null); + this.playerObject.armors.rightLegArmor && + ((this.playerObject.armors.rightLegArmor.material as MeshStandardMaterial).map = null); + this.playerObject.armors.bodyArmor2 && + ((this.playerObject.armors.bodyArmor2.material as MeshStandardMaterial).map = null); + this.playerObject.armors.leftLegArmor2 && + ((this.playerObject.armors.leftLegArmor2.material as MeshStandardMaterial).map = null); + this.playerObject.armors.rightLegArmor2 && + ((this.playerObject.armors.rightLegArmor2.material as MeshStandardMaterial).map = null); + [this.armorHelmetTexture, this.armorChestplateTexture, this.armorLeggingsTexture, this.armorBootsTexture].forEach( + texture => { + if (texture !== null) { + texture.dispose(); + texture = null; + } + } + ); + } resetSkin(): void { this.playerObject.skin.visible = false; this.playerObject.skin.map = null; + this.resetArmors(); if (this.skinTexture !== null) { this.skinTexture.dispose(); this.skinTexture = null;