//@ts-check import EventEmitter from 'events' import { UnionToIntersection } from 'type-fest' import nbt from 'prismarine-nbt' import * as TWEEN from '@tweenjs/tween.js' import * as THREE from 'three' import { PlayerObject, PlayerAnimation } from 'skinview3d' import { loadSkinToCanvas, loadEarsToCanvasFromSkin, inferModelType, loadCapeToCanvas, loadImage } from 'skinview-utils' // todo replace with url import { degreesToRadians } from '@nxg-org/mineflayer-tracker/lib/mathUtils' import { NameTagObject } from 'skinview3d/libs/nametag' import { flat, fromFormattedString } from '@xmcl/text-component' import mojangson from 'mojangson' import { snakeCase } from 'change-case' import { Item } from 'prismarine-item' import { BlockModel } from 'mc-assets' import { isEntityAttackable } from 'mineflayer-mouse/dist/attackableEntity' import { Vec3 } from 'vec3' import { EntityMetadataVersions } from '../../../src/mcDataTypes' import { ItemSpecificContextProperties } from '../lib/basePlayerState' import { loadSkinImage, loadSkinFromUsername, stevePngUrl, steveTexture, createCanvas } from '../lib/utils/skins' import { loadTexture } from '../lib/utils' import { getBlockMeshFromModel } from './holdingBlock' import * as Entity from './entity/EntityMesh' import { getMesh } from './entity/EntityMesh' import { WalkingGeneralSwing } from './entity/animations' import { disposeObject } from './threeJsUtils' import { armorModel, elytraTexture, armorTextures } from './entity/armorModels' import { WorldRendererThree } from './worldrendererThree' export const TWEEN_DURATION = 120 type PlayerObjectType = PlayerObject & { animation?: PlayerAnimation realPlayerUuid: string realUsername: string } function convert2sComplementToHex (complement: number) { if (complement < 0) { complement = (0xFF_FF_FF_FF + complement + 1) >>> 0 } return complement.toString(16) } function toRgba (color: string | undefined) { if (color === undefined) { return undefined } if (parseInt(color, 10) === 0) { return 'rgba(0, 0, 0, 0)' } const hex = convert2sComplementToHex(parseInt(color, 10)) if (hex.length === 8) { return `#${hex.slice(2, 8)}${hex.slice(0, 2)}` } else { return `#${hex}` } } function toQuaternion (quaternion: any, defaultValue?: THREE.Quaternion) { if (quaternion === undefined) { return defaultValue } if (quaternion instanceof THREE.Quaternion) { return quaternion } if (Array.isArray(quaternion)) { return new THREE.Quaternion(quaternion[0], quaternion[1], quaternion[2], quaternion[3]) } return new THREE.Quaternion(quaternion.x, quaternion.y, quaternion.z, quaternion.w) } function poseToEuler (pose: any, defaultValue?: THREE.Euler) { if (pose === undefined) { return defaultValue ?? new THREE.Euler() } if (pose instanceof THREE.Euler) { return pose } if (pose['yaw'] !== undefined && pose['pitch'] !== undefined && pose['roll'] !== undefined) { // Convert Minecraft pitch, yaw, roll definitions to our angle system return new THREE.Euler(-degreesToRadians(pose.pitch), -degreesToRadians(pose.yaw), degreesToRadians(pose.roll), 'ZYX') } if (pose['x'] !== undefined && pose['y'] !== undefined && pose['z'] !== undefined) { return new THREE.Euler(pose.z, pose.y, pose.x, 'ZYX') } if (Array.isArray(pose)) { return new THREE.Euler(pose[0], pose[1], pose[2]) } return defaultValue ?? new THREE.Euler() } function getUsernameTexture ({ username, nameTagBackgroundColor = 'rgba(0, 0, 0, 0.3)', nameTagTextOpacity = 255 }: any, { fontFamily = 'sans-serif' }: any) { const canvas = createCanvas(64, 64) const ctx = canvas.getContext('2d') if (!ctx) throw new Error('Could not get 2d context') const fontSize = 48 const padding = 5 ctx.font = `${fontSize}px ${fontFamily}` const lines = String(username).split('\n') let textWidth = 0 for (const line of lines) { const width = ctx.measureText(line).width + padding * 2 if (width > textWidth) textWidth = width } canvas.width = textWidth canvas.height = (fontSize + padding) * lines.length ctx.fillStyle = nameTagBackgroundColor ctx.fillRect(0, 0, canvas.width, canvas.height) ctx.font = `${fontSize}px ${fontFamily}` ctx.fillStyle = `rgba(255, 255, 255, ${nameTagTextOpacity / 255})` let i = 0 for (const line of lines) { i++ ctx.fillText(line, (textWidth - ctx.measureText(line).width) / 2, -padding + fontSize * i) } return canvas } const addNametag = (entity, options, mesh) => { if (entity.username !== undefined) { if (mesh.children.some(c => c.name === 'nametag')) return // todo update const canvas = getUsernameTexture(entity, options) const tex = new THREE.Texture(canvas) tex.needsUpdate = true let nameTag if (entity.nameTagFixed) { const geometry = new THREE.PlaneGeometry() const material = new THREE.MeshBasicMaterial({ map: tex }) material.transparent = true nameTag = new THREE.Mesh(geometry, material) nameTag.rotation.set(entity.pitch, THREE.MathUtils.degToRad(entity.yaw + 180), 0) nameTag.position.y += entity.height + 0.3 } else { const spriteMat = new THREE.SpriteMaterial({ map: tex }) nameTag = new THREE.Sprite(spriteMat) nameTag.position.y += entity.height + 0.6 } nameTag.renderOrder = 1000 nameTag.scale.set(canvas.width * 0.005, canvas.height * 0.005, 1) if (entity.nameTagRotationRight) { nameTag.applyQuaternion(entity.nameTagRotationRight) } if (entity.nameTagScale) { nameTag.scale.multiply(entity.nameTagScale) } if (entity.nameTagRotationLeft) { nameTag.applyQuaternion(entity.nameTagRotationLeft) } if (entity.nameTagTranslation) { nameTag.position.add(entity.nameTagTranslation) } nameTag.name = 'nametag' mesh.add(nameTag) } } // todo cleanup const nametags = {} const isFirstUpperCase = (str) => str.charAt(0) === str.charAt(0).toUpperCase() function getEntityMesh (entity: import('prismarine-entity').Entity & { delete?: any; pos?: any; name?: any }, world: WorldRendererThree | undefined, options: { fontFamily: string }, overrides) { if (entity.name) { try { // https://github.com/PrismarineJS/prismarine-viewer/pull/410 const entityName = (isFirstUpperCase(entity.name) ? snakeCase(entity.name) : entity.name).toLowerCase() const e = new Entity.EntityMesh('1.16.4', entityName, world, overrides) if (e.mesh) { addNametag(entity, options, e.mesh) return e.mesh } } catch (err) { reportError?.(err) } } if (!isEntityAttackable(loadedData, entity)) return const geometry = new THREE.BoxGeometry(entity.width, entity.height, entity.width) geometry.translate(0, entity.height / 2, 0) const material = new THREE.MeshBasicMaterial({ color: 0xff_00_ff }) const cube = new THREE.Mesh(geometry, material) const nametagCount = (nametags[entity.name] = (nametags[entity.name] || 0) + 1) if (nametagCount < 6) { addNametag({ username: entity.name, height: entity.height, }, options, cube) } return cube } export type SceneEntity = THREE.Object3D & { playerObject?: PlayerObjectType username?: string uuid?: string additionalCleanup?: () => void originalEntity: import('prismarine-entity').Entity & { delete?; pos?, name } } export class Entities { entities = {} as Record playerEntity: SceneEntity | null = null // Special entity for the player in third person entitiesOptions = { fontFamily: 'mojangles' } debugMode: string onSkinUpdate: () => void clock = new THREE.Clock() currentlyRendering = true cachedMapsImages = {} as Record itemFrameMaps = {} as Record>> get entitiesByName (): Record { const byName: Record = {} for (const entity of Object.values(this.entities)) { if (!entity['realName']) continue byName[entity['realName']] = byName[entity['realName']] || [] byName[entity['realName']].push(entity) } return byName } get entitiesRenderingCount (): number { return Object.values(this.entities).filter(entity => entity.visible).length } getDebugString (): string { const totalEntities = Object.keys(this.entities).length const visibleEntities = this.entitiesRenderingCount const playerEntities = Object.values(this.entities).filter(entity => entity.playerObject) const visiblePlayerEntities = playerEntities.filter(entity => entity.visible) return `${visibleEntities}/${totalEntities} ${visiblePlayerEntities.length}/${playerEntities.length}` } constructor (public worldRenderer: WorldRendererThree) { this.debugMode = 'none' this.onSkinUpdate = () => { } this.watchResourcesUpdates() } handlePlayerEntity (playerData: SceneEntity['originalEntity']) { // Create player entity if it doesn't exist if (!this.playerEntity) { // Create the player entity similar to how normal entities are created const group = new THREE.Group() as unknown as SceneEntity group.originalEntity = { ...playerData, name: 'player' } as SceneEntity['originalEntity'] const wrapper = new THREE.Group() const playerObject = this.setupPlayerObject(playerData, wrapper, {}) group.playerObject = playerObject group.add(wrapper) group.name = 'player_entity' this.playerEntity = group this.worldRenderer.scene.add(group) void this.updatePlayerSkin(playerData.id, playerData.username, playerData.uuid ?? undefined, stevePngUrl) } // Update position and rotation if (playerData.position) { this.playerEntity.position.set(playerData.position.x, playerData.position.y, playerData.position.z) } if (playerData.yaw !== undefined) { this.playerEntity.rotation.y = playerData.yaw } this.updateEntityEquipment(this.playerEntity, playerData) } clear () { for (const mesh of Object.values(this.entities)) { this.worldRenderer.scene.remove(mesh) disposeObject(mesh) } this.entities = {} // Clean up player entity if (this.playerEntity) { this.worldRenderer.scene.remove(this.playerEntity) disposeObject(this.playerEntity) this.playerEntity = null } } reloadEntities () { for (const entity of Object.values(this.entities)) { // update all entities textures like held items, armour, etc // todo update entity textures itself this.update({ ...entity.originalEntity, delete: true, } as SceneEntity['originalEntity'], {}) this.update(entity.originalEntity, {}) } } watchResourcesUpdates () { this.worldRenderer.resourcesManager.on('assetsTexturesUpdated', () => this.reloadEntities()) this.worldRenderer.resourcesManager.on('assetsInventoryReady', () => this.reloadEntities()) } setDebugMode (mode: string, entity: THREE.Object3D | null = null) { this.debugMode = mode for (const mesh of entity ? [entity] : Object.values(this.entities)) { const boxHelper = mesh.children.find(c => c.name === 'debug')! boxHelper.visible = false if (this.debugMode === 'basic') { boxHelper.visible = true } // todo advanced } } setRendering (rendering: boolean, entity: THREE.Object3D | null = null) { this.currentlyRendering = rendering for (const ent of entity ? [entity] : Object.values(this.entities)) { if (rendering) { if (!this.worldRenderer.scene.children.includes(ent)) this.worldRenderer.scene.add(ent) } else { this.worldRenderer.scene.remove(ent) } } } render () { const renderEntitiesConfig = this.worldRenderer.worldRendererConfig.renderEntities if (renderEntitiesConfig !== this.currentlyRendering) { this.setRendering(renderEntitiesConfig) } const dt = this.clock.getDelta() const botPos = this.worldRenderer.viewerChunkPosition const VISIBLE_DISTANCE = 10 * 10 // Update regular entities for (const [entityId, entity] of [...Object.entries(this.entities), ['player_entity', this.playerEntity] as [string, SceneEntity | null]]) { if (!entity) continue const { playerObject } = entity // Update animations if (playerObject?.animation) { playerObject.animation.update(playerObject, dt) } // Update visibility based on distance and chunk load status if (botPos && entity.position) { const dx = entity.position.x - botPos.x const dy = entity.position.y - botPos.y const dz = entity.position.z - botPos.z const distanceSquared = dx * dx + dy * dy + dz * dz // Entity is visible if within 20 blocks OR in a finished chunk entity.visible = !!(distanceSquared < VISIBLE_DISTANCE || this.worldRenderer.shouldObjectVisible(entity)) this.maybeRenderPlayerSkin(entityId) } if (entity.visible) { // Update armor positions this.syncArmorPositions(entity) } if (entityId === 'player_entity') { entity.visible = this.worldRenderer.playerStateUtils.isThirdPerson() if (entity.visible) { // sync const yOffset = this.worldRenderer.playerStateReactive.eyeHeight const pos = this.worldRenderer.cameraObject.position.clone().add(new THREE.Vector3(0, -yOffset, 0)) entity.position.set(pos.x, pos.y, pos.z) const rotation = this.worldRenderer.cameraShake.getBaseRotation() entity.rotation.set(0, rotation.yaw, 0) // Sync head rotation entity.traverse((c) => { if (c.name === 'head') { c.rotation.set(-rotation.pitch, 0, 0) } }) } } } } private syncArmorPositions (entity: SceneEntity) { if (!entity.playerObject) return // todo-low use property access for less loop iterations (small performance gain) entity.traverse((armor) => { if (!armor.name.startsWith('geometry_armor_')) return const { skin } = entity.playerObject! switch (armor.name) { case 'geometry_armor_head': // Head armor sync if (armor.children[0]?.children[0]) { armor.children[0].children[0].rotation.set( -skin.head.rotation.x, skin.head.rotation.y, skin.head.rotation.z, skin.head.rotation.order ) } break case 'geometry_armor_legs': // Legs armor sync if (armor.children[0]) { // Left leg if (armor.children[0].children[2]) { armor.children[0].children[2].rotation.set( -skin.leftLeg.rotation.x, skin.leftLeg.rotation.y, skin.leftLeg.rotation.z, skin.leftLeg.rotation.order ) } // Right leg if (armor.children[0].children[1]) { armor.children[0].children[1].rotation.set( -skin.rightLeg.rotation.x, skin.rightLeg.rotation.y, skin.rightLeg.rotation.z, skin.rightLeg.rotation.order ) } } break case 'geometry_armor_feet': // Boots armor sync if (armor.children[0]) { // Right boot if (armor.children[0].children[0]) { armor.children[0].children[0].rotation.set( -skin.rightLeg.rotation.x, skin.rightLeg.rotation.y, skin.rightLeg.rotation.z, skin.rightLeg.rotation.order ) } // Left boot (reversed Z rotation) if (armor.children[0].children[1]) { armor.children[0].children[1].rotation.set( -skin.leftLeg.rotation.x, skin.leftLeg.rotation.y, -skin.leftLeg.rotation.z, skin.leftLeg.rotation.order ) } } break } }) } getPlayerObject (entityId: string | number) { if (this.playerEntity?.originalEntity.id === entityId) return this.playerEntity?.playerObject const playerObject = this.entities[entityId]?.playerObject return playerObject } uuidPerSkinUrlsCache = {} as Record private isCanvasBlank (canvas: HTMLCanvasElement): boolean { return !canvas.getContext('2d') ?.getImageData(0, 0, canvas.width, canvas.height).data .some(channel => channel !== 0) } // eslint-disable-next-line max-params async updatePlayerSkin (entityId: string | number, username: string | undefined, uuidCache: string | undefined, skinUrl: string | true, capeUrl: string | true | undefined = undefined) { if (uuidCache) { if (typeof skinUrl === 'string' || typeof capeUrl === 'string') this.uuidPerSkinUrlsCache[uuidCache] = {} if (typeof skinUrl === 'string') this.uuidPerSkinUrlsCache[uuidCache].skinUrl = skinUrl if (typeof capeUrl === 'string') this.uuidPerSkinUrlsCache[uuidCache].capeUrl = capeUrl if (skinUrl === true) { skinUrl = this.uuidPerSkinUrlsCache[uuidCache]?.skinUrl ?? skinUrl } capeUrl ??= this.uuidPerSkinUrlsCache[uuidCache]?.capeUrl } const playerObject = this.getPlayerObject(entityId) if (!playerObject) return if (skinUrl === true) { if (!username) return const newSkinUrl = await loadSkinFromUsername(username, 'skin') if (!this.getPlayerObject(entityId)) return if (!newSkinUrl) return skinUrl = newSkinUrl } if (typeof skinUrl !== 'string') throw new Error('Invalid skin url') const renderEars = this.worldRenderer.worldRendererConfig.renderEars || username === 'deadmau5' void this.loadAndApplySkin(entityId, skinUrl, renderEars).then(async () => { if (capeUrl) { if (capeUrl === true && username) { const newCapeUrl = await loadSkinFromUsername(username, 'cape') if (!this.getPlayerObject(entityId)) return if (!newCapeUrl) return capeUrl = newCapeUrl } if (typeof capeUrl === 'string') { void this.loadAndApplyCape(entityId, capeUrl) } } }) playerObject.cape.visible = false if (!capeUrl) { playerObject.backEquipment = null playerObject.elytra.map = null if (playerObject.cape.map) { playerObject.cape.map.dispose() } playerObject.cape.map = null } } private async loadAndApplySkin (entityId: string | number, skinUrl: string, renderEars: boolean) { let playerObject = this.getPlayerObject(entityId) if (!playerObject) return try { let playerCustomSkinImage: ImageBitmap | undefined playerObject = this.getPlayerObject(entityId) if (!playerObject) return let skinTexture: THREE.Texture let skinCanvas: OffscreenCanvas if (skinUrl === stevePngUrl) { skinTexture = await steveTexture const canvas = createCanvas(64, 64) const ctx = canvas.getContext('2d') if (!ctx) throw new Error('Failed to get context') ctx.drawImage(skinTexture.image, 0, 0) skinCanvas = canvas } else { const { canvas, image } = await loadSkinImage(skinUrl) playerCustomSkinImage = image skinTexture = new THREE.CanvasTexture(canvas) skinCanvas = canvas } skinTexture.magFilter = THREE.NearestFilter skinTexture.minFilter = THREE.NearestFilter skinTexture.needsUpdate = true playerObject.skin.map = skinTexture as any playerObject.skin.modelType = inferModelType(skinCanvas) let earsCanvas: HTMLCanvasElement | undefined if (!playerCustomSkinImage) { renderEars = false } else if (renderEars) { earsCanvas = document.createElement('canvas') loadEarsToCanvasFromSkin(earsCanvas, playerCustomSkinImage) renderEars = !this.isCanvasBlank(earsCanvas) } if (renderEars) { const earsTexture = new THREE.CanvasTexture(earsCanvas!) earsTexture.magFilter = THREE.NearestFilter earsTexture.minFilter = THREE.NearestFilter earsTexture.needsUpdate = true //@ts-expect-error playerObject.ears.map = earsTexture playerObject.ears.visible = true } else { playerObject.ears.map = null playerObject.ears.visible = false } this.onSkinUpdate?.() } catch (error) { console.error('Error loading skin:', error) } } private async loadAndApplyCape (entityId: string | number, capeUrl: string) { let playerObject = this.getPlayerObject(entityId) if (!playerObject) return try { const { canvas: capeCanvas, image: capeImage } = await loadSkinImage(capeUrl) playerObject = this.getPlayerObject(entityId) if (!playerObject) return loadCapeToCanvas(capeCanvas, capeImage) const capeTexture = new THREE.CanvasTexture(capeCanvas) capeTexture.magFilter = THREE.NearestFilter capeTexture.minFilter = THREE.NearestFilter capeTexture.needsUpdate = true //@ts-expect-error playerObject.cape.map = capeTexture playerObject.cape.visible = true //@ts-expect-error playerObject.elytra.map = capeTexture this.onSkinUpdate?.() if (!playerObject.backEquipment) { playerObject.backEquipment = 'cape' } } catch (error) { console.error('Error loading cape:', error) } } debugSwingArm () { const playerObject = Object.values(this.entities).find(entity => entity.playerObject?.animation instanceof WalkingGeneralSwing) if (!playerObject) return (playerObject.playerObject!.animation as WalkingGeneralSwing).swingArm() } playAnimation (entityPlayerId, animation: 'walking' | 'running' | 'oneSwing' | 'idle' | 'crouch' | 'crouchWalking') { // TODO CLEANUP! // Handle special player entity ID for bot entity in third person if (entityPlayerId === 'player_entity' && this.playerEntity?.playerObject) { const { playerObject } = this.playerEntity if (animation === 'oneSwing') { if (!(playerObject.animation instanceof WalkingGeneralSwing)) throw new Error('Expected WalkingGeneralSwing') playerObject.animation.swingArm() return } if (playerObject.animation instanceof WalkingGeneralSwing) { playerObject.animation.switchAnimationCallback = () => { if (!(playerObject.animation instanceof WalkingGeneralSwing)) throw new Error('Expected WalkingGeneralSwing') playerObject.animation.isMoving = animation === 'walking' || animation === 'running' || animation === 'crouchWalking' playerObject.animation.isRunning = animation === 'running' playerObject.animation.isCrouched = animation === 'crouch' || animation === 'crouchWalking' } } return } // Handle regular entities const playerObject = this.getPlayerObject(entityPlayerId) if (playerObject) { if (animation === 'oneSwing') { if (!(playerObject.animation instanceof WalkingGeneralSwing)) throw new Error('Expected WalkingGeneralSwing') playerObject.animation.swingArm() return } if (playerObject.animation instanceof WalkingGeneralSwing) { playerObject.animation.switchAnimationCallback = () => { if (!(playerObject.animation instanceof WalkingGeneralSwing)) throw new Error('Expected WalkingGeneralSwing') playerObject.animation.isMoving = animation === 'walking' || animation === 'running' || animation === 'crouchWalking' playerObject.animation.isRunning = animation === 'running' playerObject.animation.isCrouched = animation === 'crouch' || animation === 'crouchWalking' } } return } // Handle player entity (for third person view) - fallback for backwards compatibility if (this.playerEntity?.playerObject) { const { playerObject: playerEntityObject } = this.playerEntity if (animation === 'oneSwing') { if (!(playerEntityObject.animation instanceof WalkingGeneralSwing)) throw new Error('Expected WalkingGeneralSwing') playerEntityObject.animation.swingArm() return } if (playerEntityObject.animation instanceof WalkingGeneralSwing) { playerEntityObject.animation.switchAnimationCallback = () => { if (!(playerEntityObject.animation instanceof WalkingGeneralSwing)) throw new Error('Expected WalkingGeneralSwing') playerEntityObject.animation.isMoving = animation === 'walking' || animation === 'running' || animation === 'crouchWalking' playerEntityObject.animation.isRunning = animation === 'running' playerEntityObject.animation.isCrouched = animation === 'crouch' || animation === 'crouchWalking' } } } } parseEntityLabel (jsonLike) { if (!jsonLike) return try { if (jsonLike.type === 'string') { return jsonLike.value } const parsed = typeof jsonLike === 'string' ? mojangson.simplify(mojangson.parse(jsonLike)) : nbt.simplify(jsonLike) const text = flat(parsed).map(this.textFromComponent) return text.join('') } catch (err) { return jsonLike } } private textFromComponent (component) { return typeof component === 'string' ? component : component.text ?? '' } getItemMesh (item, specificProps: ItemSpecificContextProperties, previousModel?: string) { if (!item.nbt && item.nbtData) item.nbt = item.nbtData const textureUv = this.worldRenderer.getItemRenderData(item, specificProps) if (previousModel && previousModel === textureUv?.modelName) return undefined if (textureUv && 'resolvedModel' in textureUv) { const mesh = getBlockMeshFromModel(this.worldRenderer.material, textureUv.resolvedModel, textureUv.modelName, this.worldRenderer.resourcesManager.currentResources.worldBlockProvider!) let SCALE = 1 if (specificProps['minecraft:display_context'] === 'ground') { SCALE = 0.5 } else if (specificProps['minecraft:display_context'] === 'thirdperson') { SCALE = 6 } mesh.scale.set(SCALE, SCALE, SCALE) const outerGroup = new THREE.Group() outerGroup.add(mesh) return { mesh: outerGroup, isBlock: true, itemsTexture: null, itemsTextureFlipped: null, modelName: textureUv.modelName, } } // TODO: Render proper model (especially for blocks) instead of flat texture if (textureUv) { const textureThree = textureUv.renderInfo?.texture === 'blocks' ? this.worldRenderer.material.map! : this.worldRenderer.itemsTexture // todo use geometry buffer uv instead! const { u, v, su, sv } = textureUv const size = undefined const itemsTexture = textureThree.clone() itemsTexture.flipY = true const sizeY = (sv ?? size)! const sizeX = (su ?? size)! itemsTexture.offset.set(u, 1 - v - sizeY) itemsTexture.repeat.set(sizeX, sizeY) itemsTexture.needsUpdate = true itemsTexture.magFilter = THREE.NearestFilter itemsTexture.minFilter = THREE.NearestFilter const itemsTextureFlipped = itemsTexture.clone() itemsTextureFlipped.repeat.x *= -1 itemsTextureFlipped.needsUpdate = true itemsTextureFlipped.offset.set(u + (sizeX), 1 - v - sizeY) const material = new THREE.MeshStandardMaterial({ map: itemsTexture, transparent: true, alphaTest: 0.1, }) const materialFlipped = new THREE.MeshStandardMaterial({ map: itemsTextureFlipped, transparent: true, alphaTest: 0.1, }) const mesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 0), [ // top left and right bottom are black box materials others are transparent new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), new THREE.MeshBasicMaterial({ color: 0x00_00_00 }), material, materialFlipped, ]) let SCALE = 1 if (specificProps['minecraft:display_context'] === 'ground') { SCALE = 0.5 } else if (specificProps['minecraft:display_context'] === 'thirdperson') { SCALE = 6 } mesh.scale.set(SCALE, SCALE, SCALE) return { mesh, isBlock: false, itemsTexture, itemsTextureFlipped, modelName: textureUv.modelName, } } } setVisible (mesh: THREE.Object3D, visible: boolean) { //mesh.visible = visible //TODO: Fix workaround for visibility setting if (visible) { mesh.scale.set(1, 1, 1) } else { mesh.scale.set(0, 0, 0) } } update (entity: SceneEntity['originalEntity'], overrides) { const justAdded = !this.entities[entity.id] const isPlayerModel = entity.name === 'player' if (entity.name === 'zombie_villager' || entity.name === 'husk') { overrides.texture = `textures/1.16.4/entity/${entity.name === 'zombie_villager' ? 'zombie_villager/zombie_villager.png' : `zombie/${entity.name}.png`}` } if (entity.name === 'glow_item_frame') { if (!overrides.textures) overrides.textures = [] overrides.textures['background'] = 'block:glow_item_frame' } // this can be undefined in case where packet entity_destroy was sent twice (so it was already deleted) let e = this.entities[entity.id] if (entity.delete) { if (!e) return if (e.additionalCleanup) e.additionalCleanup() e.traverse(c => { if (c['additionalCleanup']) c['additionalCleanup']() }) this.onRemoveEntity(entity) this.worldRenderer.scene.remove(e) disposeObject(e) // todo dispose textures as well ? delete this.entities[entity.id] return } let mesh: THREE.Object3D | undefined if (e === undefined) { const group = new THREE.Group() as unknown as SceneEntity group.originalEntity = entity if (entity.name === 'item' || entity.name === 'tnt' || entity.name === 'falling_block') { const item = entity.name === 'tnt' ? { name: 'tnt' } : entity.name === 'falling_block' ? { blockState: entity['objectData'] } : entity.metadata?.find((m: any) => typeof m === 'object' && m?.itemCount) if (item) { const object = this.getItemMesh(item, { 'minecraft:display_context': 'ground', }) if (object) { mesh = object.mesh if (entity.name === 'item') { mesh.scale.set(0.5, 0.5, 0.5) mesh.position.set(0, 0.2, 0) } else { mesh.scale.set(2, 2, 2) mesh.position.set(0, 0.5, 0) } // set faces // mesh.position.set(targetPos.x + 0.5 + 2, targetPos.y + 0.5, targetPos.z + 0.5) // viewer.scene.add(mesh) const clock = new THREE.Clock() if (entity.name === 'item') { mesh.onBeforeRender = () => { const delta = clock.getDelta() mesh!.rotation.y += delta } } // TNT blinking // if (entity.name === 'tnt') { // let lastBlink = 0 // const blinkInterval = 500 // ms between blinks // mesh.onBeforeRender = () => { // const now = Date.now() // if (now - lastBlink > blinkInterval) { // lastBlink = now // mesh.traverse((child) => { // if (child instanceof THREE.Mesh) { // const material = child.material as THREE.MeshLambertMaterial // material.color.set(material.color?.equals(new THREE.Color(0xff_ff_ff)) // ? new THREE.Color(0xff_00_00) // : new THREE.Color(0xff_ff_ff)) // } // }) // } // } // } group.additionalCleanup = () => { // important: avoid texture memory leak and gpu slowdown object.itemsTexture?.dispose() object.itemsTextureFlipped?.dispose() } } } } else if (isPlayerModel) { const wrapper = new THREE.Group() const playerObject = this.setupPlayerObject(entity, wrapper, overrides) group.playerObject = playerObject mesh = wrapper if (entity.username) { // todo proper colors const nameTag = new NameTagObject(fromFormattedString(entity.username).text, { font: `48px ${this.entitiesOptions.fontFamily}`, }) nameTag.position.y = playerObject.position.y + playerObject.scale.y * 16 + 3 nameTag.renderOrder = 1000 //@ts-expect-error wrapper.add(nameTag) } } else { mesh = getEntityMesh(entity, this.worldRenderer, this.entitiesOptions, overrides) } if (!mesh) return mesh.name = 'mesh' // set initial position so there are no weird jumps update after const pos = entity.pos ?? entity.position group.position.set(pos.x, pos.y, pos.z) // todo use width and height instead const boxHelper = new THREE.BoxHelper( mesh, entity.type === 'hostile' ? 0xff_00_00 : entity.type === 'mob' ? 0x00_ff_00 : entity.type === 'player' ? 0x00_00_ff : 0xff_a5_00, ) boxHelper.name = 'debug' group.add(mesh) group.add(boxHelper) boxHelper.visible = false this.worldRenderer.scene.add(group) e = group e.name = 'entity' e['realName'] = entity.name this.entities[entity.id] = e this.onAddEntity(entity) if (isPlayerModel) { void this.updatePlayerSkin(entity.id, entity.username, overrides?.texture ? entity.uuid : undefined, overrides?.texture || stevePngUrl) } this.setDebugMode(this.debugMode, group) this.setRendering(this.currentlyRendering, group) } else { mesh = e.children.find(c => c.name === 'mesh') } // Update equipment this.updateEntityEquipment(e, entity) const meta = getGeneralEntitiesMetadata(entity) const isInvisible = ((entity.metadata?.[0] ?? 0) as unknown as number) & 0x20 || (this.worldRenderer.playerStateReactive.cameraSpectatingEntity === entity.id && this.worldRenderer.playerStateUtils.isSpectator()) for (const child of mesh!.children ?? []) { if (child.name !== 'nametag') { child.visible = !isInvisible } } // --- // set baby size if (meta.baby) { e.scale.set(0.5, 0.5, 0.5) } else { e.scale.set(1, 1, 1) } // entity specific meta const textDisplayMeta = getSpecificEntityMetadata('text_display', entity) const displayTextRaw = textDisplayMeta?.text || meta.custom_name_visible && meta.custom_name const displayText = this.parseEntityLabel(displayTextRaw) if (entity.name !== 'player' && displayText) { const nameTagFixed = textDisplayMeta && (textDisplayMeta.billboard_render_constraints === 'fixed' || !textDisplayMeta.billboard_render_constraints) const nameTagBackgroundColor = textDisplayMeta && toRgba(textDisplayMeta.background_color) let nameTagTextOpacity: any if (textDisplayMeta?.text_opacity) { const rawOpacity = parseInt(textDisplayMeta?.text_opacity, 10) nameTagTextOpacity = rawOpacity > 0 ? rawOpacity : 256 - rawOpacity } addNametag( { ...entity, username: displayText, nameTagBackgroundColor, nameTagTextOpacity, nameTagFixed, nameTagScale: textDisplayMeta?.scale, nameTagTranslation: textDisplayMeta && (textDisplayMeta.translation || new THREE.Vector3(0, 0, 0)), nameTagRotationLeft: toQuaternion(textDisplayMeta?.left_rotation), nameTagRotationRight: toQuaternion(textDisplayMeta?.right_rotation) }, this.entitiesOptions, mesh ) } const armorStandMeta = getSpecificEntityMetadata('armor_stand', entity) if (armorStandMeta) { const isSmall = (parseInt(armorStandMeta.client_flags, 10) & 0x01) !== 0 const hasArms = (parseInt(armorStandMeta.client_flags, 10) & 0x04) !== 0 const hasBasePlate = (parseInt(armorStandMeta.client_flags, 10) & 0x08) === 0 const isMarker = (parseInt(armorStandMeta.client_flags, 10) & 0x10) !== 0 mesh!.castShadow = !isMarker mesh!.receiveShadow = !isMarker if (isSmall) { e.scale.set(0.5, 0.5, 0.5) } else { e.scale.set(1, 1, 1) } e.traverse(c => { switch (c.name) { case 'bone_baseplate': this.setVisible(c, hasBasePlate) c.rotation.y = -e.rotation.y break case 'bone_head': if (armorStandMeta.head_pose) { c.setRotationFromEuler(poseToEuler(armorStandMeta.head_pose)) } break case 'bone_body': if (armorStandMeta.body_pose) { c.setRotationFromEuler(poseToEuler(armorStandMeta.body_pose)) } break case 'bone_rightarm': if (c.parent?.name !== 'bone_armor') { this.setVisible(c, hasArms) } if (armorStandMeta.left_arm_pose) { c.setRotationFromEuler(poseToEuler(armorStandMeta.left_arm_pose)) } else { c.setRotationFromEuler(poseToEuler({ 'yaw': -10, 'pitch': -10, 'roll': 0 })) } break case 'bone_leftarm': if (c.parent?.name !== 'bone_armor') { this.setVisible(c, hasArms) } if (armorStandMeta.right_arm_pose) { c.setRotationFromEuler(poseToEuler(armorStandMeta.right_arm_pose)) } else { c.setRotationFromEuler(poseToEuler({ 'yaw': 10, 'pitch': -10, 'roll': 0 })) } break case 'bone_rightleg': if (armorStandMeta.left_leg_pose) { c.setRotationFromEuler(poseToEuler(armorStandMeta.left_leg_pose)) } else { c.setRotationFromEuler(poseToEuler({ 'yaw': -1, 'pitch': -1, 'roll': 0 })) } break case 'bone_leftleg': if (armorStandMeta.right_leg_pose) { c.setRotationFromEuler(poseToEuler(armorStandMeta.right_leg_pose)) } else { c.setRotationFromEuler(poseToEuler({ 'yaw': 1, 'pitch': 1, 'roll': 0 })) } break } }) } // todo handle map, map_chunks events let itemFrameMeta = getSpecificEntityMetadata('item_frame', entity) if (!itemFrameMeta) { itemFrameMeta = getSpecificEntityMetadata('glow_item_frame', entity) } if (itemFrameMeta) { // TODO: fix type // todo! fix errors in mc-data (no entities data prior 1.18.2) const item = (itemFrameMeta?.item ?? entity.metadata?.[8]) as any as { itemId, blockId, components, nbtData: { value: { map: { value: number } } } } mesh!.scale.set(1, 1, 1) mesh!.position.set(0, 0, -0.5) e.rotation.x = -entity.pitch e.children.find(c => { if (c.name.startsWith('map_')) { disposeObject(c) const existingMapNumber = parseInt(c.name.split('_')[1], 10) this.itemFrameMaps[existingMapNumber] = this.itemFrameMaps[existingMapNumber]?.filter(mesh => mesh !== c) if (c instanceof THREE.Mesh) { c.material?.map?.dispose() } return true } else if (c.name === 'item') { disposeObject(c) return true } return false })?.removeFromParent() if (item && (item.itemId ?? item.blockId ?? 0) !== 0) { // Get rotation from metadata, default to 0 if not present // Rotation is stored in 45° increments (0-7) for items, 90° increments (0-3) for maps const rotation = (itemFrameMeta.rotation as any as number) ?? 0 const mapNumber = item.nbtData?.value?.map?.value ?? item.components?.find(x => x.type === 'map_id')?.data if (mapNumber) { // TODO: Use proper larger item frame model when a map exists mesh!.scale.set(16 / 12, 16 / 12, 1) // Handle map rotation (4 possibilities, 90° increments) this.addMapModel(e, mapNumber, rotation) } else { // Handle regular item rotation (8 possibilities, 45° increments) const itemMesh = this.getItemMesh(item, { 'minecraft:display_context': 'fixed', }) if (itemMesh) { itemMesh.mesh.position.set(0, 0, -0.05) // itemMesh.mesh.position.set(0, 0, 0.43) if (itemMesh.isBlock) { itemMesh.mesh.scale.set(0.25, 0.25, 0.25) } else { itemMesh.mesh.scale.set(0.5, 0.5, 0.5) } // Rotate 180° around Y axis first itemMesh.mesh.rotateY(Math.PI) // Then apply the 45° increment rotation itemMesh.mesh.rotateZ(-rotation * Math.PI / 4) itemMesh.mesh.name = 'item' e.add(itemMesh.mesh) } } } } if (entity.username) { e.username = entity.username } this.updateEntityPosition(entity, justAdded, overrides) } updateEntityPosition (entity: import('prismarine-entity').Entity, justAdded: boolean, overrides: { rotation?: { head?: { y: number, x: number } } }) { const e = this.entities[entity.id] if (!e) return const ANIMATION_DURATION = justAdded ? 0 : TWEEN_DURATION if (entity.position) { new TWEEN.Tween(e.position).to({ x: entity.position.x, y: entity.position.y, z: entity.position.z }, ANIMATION_DURATION).start() } if (entity.yaw) { const da = (entity.yaw - e.rotation.y) % (Math.PI * 2) const dy = 2 * da % (Math.PI * 2) - da new TWEEN.Tween(e.rotation).to({ y: e.rotation.y + dy }, ANIMATION_DURATION).start() } if (e?.playerObject && overrides?.rotation?.head) { const { playerObject } = e const headRotationDiff = overrides.rotation.head.y ? overrides.rotation.head.y - entity.yaw : 0 playerObject.skin.head.rotation.y = -headRotationDiff playerObject.skin.head.rotation.x = overrides.rotation.head.x ? - overrides.rotation.head.x : 0 } } onAddEntity (entity: import('prismarine-entity').Entity) { } loadedSkinEntityIds = new Set() maybeRenderPlayerSkin (entityId: string) { let mesh = this.entities[entityId] if (entityId === 'player_entity') { mesh = this.playerEntity! entityId = this.playerEntity?.originalEntity.id as any } if (!mesh) return if (!mesh.playerObject) return if (!mesh.visible) return const MAX_DISTANCE_SKIN_LOAD = 128 const cameraPos = this.worldRenderer.cameraObject.position const distance = mesh.position.distanceTo(cameraPos) if (distance < MAX_DISTANCE_SKIN_LOAD && distance < (this.worldRenderer.viewDistance * 16)) { if (this.loadedSkinEntityIds.has(entityId)) return this.loadedSkinEntityIds.add(entityId) void this.updatePlayerSkin(entityId, mesh.playerObject.realUsername, mesh.playerObject.realPlayerUuid, true, true) } } playerPerAnimation = {} as Record onRemoveEntity (entity: import('prismarine-entity').Entity) { this.loadedSkinEntityIds.delete(entity.id.toString()) } updateMap (mapNumber: string | number, data: string) { this.cachedMapsImages[mapNumber] = data let itemFrameMeshes = this.itemFrameMaps[mapNumber] if (!itemFrameMeshes) return itemFrameMeshes = itemFrameMeshes.filter(mesh => mesh.parent) this.itemFrameMaps[mapNumber] = itemFrameMeshes if (itemFrameMeshes) { for (const mesh of itemFrameMeshes) { mesh.material.map = this.loadMap(data) mesh.material.needsUpdate = true mesh.visible = true } } } addMapModel (entityMesh: THREE.Object3D, mapNumber: number, rotation: number) { const imageData = this.cachedMapsImages?.[mapNumber] let texture: THREE.Texture | null = null if (imageData) { texture = this.loadMap(imageData) } const parameters = { transparent: true, alphaTest: 0.1, } if (texture) { parameters['map'] = texture } const material = new THREE.MeshLambertMaterial(parameters) const mapMesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material) mapMesh.rotation.set(0, Math.PI, 0) entityMesh.add(mapMesh) let isInvisible = true entityMesh.traverseVisible(c => { if (c.name === 'geometry_frame') { isInvisible = false } }) if (isInvisible) { mapMesh.position.set(0, 0, 0.499) } else { mapMesh.position.set(0, 0, 0.437) } // Apply 90° increment rotation for maps (0-3) mapMesh.rotateZ(Math.PI * 2 - rotation * Math.PI / 2) mapMesh.name = `map_${mapNumber}` if (!texture) { mapMesh.visible = false } if (!this.itemFrameMaps[mapNumber]) { this.itemFrameMaps[mapNumber] = [] } this.itemFrameMaps[mapNumber].push(mapMesh) } loadMap (data: any) { const texture = new THREE.TextureLoader().load(data) if (texture) { texture.magFilter = THREE.NearestFilter texture.minFilter = THREE.NearestFilter texture.needsUpdate = true } return texture } addItemModel (entityMesh: SceneEntity, hand: 'left' | 'right', item: Item, isPlayer = false) { const bedrockParentName = `bone_${hand}item` const itemName = `custom_item_${hand}` // remove existing item entityMesh.traverse(c => { if (c.name === itemName) { c.removeFromParent() if (c['additionalCleanup']) c['additionalCleanup']() } }) if (!item) return const itemObject = this.getItemMesh(item, { 'minecraft:display_context': 'thirdperson', }) if (itemObject?.mesh) { entityMesh.traverse(c => { if (c.name.toLowerCase() === bedrockParentName || c.name === `${hand}Arm`) { const group = new THREE.Object3D() group['additionalCleanup'] = () => { // important: avoid texture memory leak and gpu slowdown itemObject.itemsTexture?.dispose() itemObject.itemsTextureFlipped?.dispose() } const itemMesh = itemObject.mesh group.rotation.z = -Math.PI / 16 if (itemObject.isBlock) { group.rotation.y = Math.PI / 4 } else { itemMesh.rotation.z = -Math.PI / 4 group.rotation.y = Math.PI / 2 group.scale.multiplyScalar(2) } // if player, move item below and forward a bit if (isPlayer) { group.position.y = -8 group.position.z = 5 group.position.x = hand === 'left' ? 1 : -1 group.rotation.x = Math.PI } group.add(itemMesh) group.name = itemName c.add(group) } }) } } handleDamageEvent (entityId, damageAmount) { const entityMesh = this.entities[entityId]?.children.find(c => c.name === 'mesh') if (entityMesh) { entityMesh.traverse((child) => { if (child instanceof THREE.Mesh && child.material.clone) { const clonedMaterial = child.material.clone() clonedMaterial.dispose() child.material = child.material.clone() const originalColor = child.material.color.clone() child.material.color.set(0xff_00_00) new TWEEN.Tween(child.material.color) .to(originalColor, 500) .start() } }) } } raycastSceneDebug () { // return any object from scene. raycast from camera const raycaster = new THREE.Raycaster() raycaster.setFromCamera(new THREE.Vector2(0, 0), this.worldRenderer.camera) const intersects = raycaster.intersectObjects(this.worldRenderer.scene.children) return intersects[0]?.object } private setupPlayerObject (entity: SceneEntity['originalEntity'], wrapper: THREE.Group, overrides: { texture?: string }): PlayerObjectType { const playerObject = new PlayerObject() as PlayerObjectType playerObject.realPlayerUuid = entity.uuid ?? '' playerObject.realUsername = entity.username ?? '' playerObject.position.set(0, 16, 0) // fix issues with starfield playerObject.traverse((obj) => { if (obj instanceof THREE.Mesh && obj.material instanceof THREE.MeshStandardMaterial) { obj.material.transparent = true } }) wrapper.add(playerObject as any) const scale = 1 / 16 wrapper.scale.set(scale, scale, scale) wrapper.rotation.set(0, Math.PI, 0) // Set up animation playerObject.animation = new WalkingGeneralSwing() //@ts-expect-error playerObject.animation.isMoving = false return playerObject } private updateEntityEquipment (entityMesh: SceneEntity, entity: SceneEntity['originalEntity']) { if (!entityMesh || !entity.equipment) return const isPlayer = entity.type === 'player' this.addItemModel(entityMesh, isPlayer ? 'right' : 'left', entity.equipment[0], isPlayer) this.addItemModel(entityMesh, isPlayer ? 'left' : 'right', entity.equipment[1], isPlayer) addArmorModel(this.worldRenderer, entityMesh, 'feet', entity.equipment[2]) addArmorModel(this.worldRenderer, entityMesh, 'legs', entity.equipment[3], 2) addArmorModel(this.worldRenderer, entityMesh, 'chest', entity.equipment[4]) addArmorModel(this.worldRenderer, entityMesh, 'head', entity.equipment[5]) // Update player-specific equipment if (isPlayer && entityMesh.playerObject) { const { playerObject } = entityMesh playerObject.backEquipment = entity.equipment.some((item) => item?.name === 'elytra') ? 'elytra' : 'cape' if (playerObject.backEquipment === 'elytra') { void this.loadAndApplyCape(entity.id, elytraTexture) } if (playerObject.cape.map === null) { playerObject.cape.visible = false } } } } function getGeneralEntitiesMetadata (entity: { name; metadata }): Partial> { const entityData = loadedData.entitiesByName[entity.name] return new Proxy({}, { get (target, p, receiver) { if (typeof p !== 'string' || !entityData) return const index = entityData.metadataKeys?.indexOf(p) return entity.metadata?.[index ?? -1] }, }) } function getSpecificEntityMetadata (name: T, entity): EntityMetadataVersions[T] | undefined { if (entity.name !== name) return return getGeneralEntitiesMetadata(entity) as any } function addArmorModel (worldRenderer: WorldRendererThree, entityMesh: THREE.Object3D, slotType: string, item: Item, layer = 1, overlay = false) { if (!item) { removeArmorModel(entityMesh, slotType) return } const itemParts = item.name.split('_') let texturePath const isPlayerHead = slotType === 'head' && item.name === 'player_head' if (isPlayerHead) { removeArmorModel(entityMesh, slotType) if (item.nbt) { const itemNbt = nbt.simplify(item.nbt) try { let textureData if (itemNbt.SkullOwner) { textureData = itemNbt.SkullOwner.Properties.textures[0]?.Value } else { textureData = itemNbt['minecraft:profile']?.Properties?.find(p => p.name === 'textures')?.value } if (textureData) { const decodedData = JSON.parse(Buffer.from(textureData, 'base64').toString()) texturePath = decodedData.textures?.SKIN?.url const { skinTexturesProxy } = this.worldRenderer.worldRendererConfig if (skinTexturesProxy) { texturePath = texturePath?.replace('http://textures.minecraft.net/', skinTexturesProxy) .replace('https://textures.minecraft.net/', skinTexturesProxy) } } } catch (err) { console.error('Error decoding player head texture:', err) } } else { texturePath = stevePngUrl } } const armorMaterial = itemParts[0] if (!texturePath) { // TODO: Support mirroring on certain parts of the model const armorTextureName = `${armorMaterial}_layer_${layer}${overlay ? '_overlay' : ''}` texturePath = worldRenderer.resourcesManager.currentResources.customTextures.armor?.textures[armorTextureName]?.src ?? armorTextures[armorTextureName] } if (!texturePath || !armorModel[slotType]) { removeArmorModel(entityMesh, slotType) return } const meshName = `geometry_armor_${slotType}${overlay ? '_overlay' : ''}` let mesh = entityMesh.children.findLast(c => c.name === meshName) as THREE.Mesh let material if (mesh) { material = mesh.material void loadTexture(texturePath, texture => { texture.magFilter = THREE.NearestFilter texture.minFilter = THREE.NearestFilter texture.flipY = false texture.wrapS = THREE.MirroredRepeatWrapping texture.wrapT = THREE.MirroredRepeatWrapping material.map = texture }) } else { mesh = getMesh(worldRenderer, texturePath, armorModel[slotType]) // // enable debug mode to see the mesh // mesh.traverse(c => { // if (c instanceof THREE.Mesh) { // c.material.wireframe = true // } // }) if (slotType === 'head') { // avoid z-fighting with the head mesh.children[0].position.y += 0.01 } mesh.name = meshName material = mesh.material if (!isPlayerHead) { material.side = THREE.DoubleSide } } if (armorMaterial === 'leather' && !overlay) { const color = (item.nbt?.value as any)?.display?.value?.color?.value if (color) { const r = color >> 16 & 0xff const g = color >> 8 & 0xff const b = color & 0xff material.color.setRGB(r / 255, g / 255, b / 255) } else { material.color.setHex(0xB5_6D_51) // default brown color } addArmorModel(worldRenderer, entityMesh, slotType, item, layer, true) } else { material.color.setHex(0xFF_FF_FF) } const group = new THREE.Object3D() group.name = `armor_${slotType}${overlay ? '_overlay' : ''}` group.add(mesh) entityMesh.add(mesh) } function removeArmorModel (entityMesh: THREE.Object3D, slotType: string) { for (const c of entityMesh.children) { if (c.name === `geometry_armor_${slotType}` || c.name === `geometry_armor_${slotType}_overlay`) { c.removeFromParent() } } }