Skip to content

Commit

Permalink
feat: Armor Stand and armor model support (#242)
Browse files Browse the repository at this point in the history
Co-authored-by: Vitaly <[email protected]>
  • Loading branch information
Phoenix616 and zardoy authored Dec 19, 2024
1 parent 16fe17e commit cb82963
Show file tree
Hide file tree
Showing 6 changed files with 446 additions and 45 deletions.
226 changes: 207 additions & 19 deletions prismarine-viewer/viewer/lib/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,19 @@ import { PlayerObject, PlayerAnimation } from 'skinview3d'
import { loadSkinToCanvas, loadEarsToCanvasFromSkin, inferModelType, loadCapeToCanvas, loadImage } from 'skinview-utils'
// todo replace with url
import stevePng from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png'
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 { EntityMetadataVersions } from '../../../src/mcDataTypes'
import * as Entity from './entity/EntityMesh'
import { getMesh } from './entity/EntityMesh'
import { WalkingGeneralSwing } from './entity/animations'
import externalTexturesJson from './entity/externalTextures.json'
import { disposeObject } from './threeJsUtils'
import { armorModels } from './entity/objModels'
const { loadTexture } = globalThis.isElectron ? require('./utils.electron.js') : require('./utils')

export const TWEEN_DURATION = 120

Expand Down Expand Up @@ -57,6 +61,26 @@ function toQuaternion (quaternion: any, defaultValue?: THREE.Quaternion) {
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)',
Expand Down Expand Up @@ -369,13 +393,17 @@ export class Entities extends EventEmitter {
return jsonLike.value
}
const parsed = typeof jsonLike === 'string' ? mojangson.simplify(mojangson.parse(jsonLike)) : nbt.simplify(jsonLike)
const text = flat(parsed).map(x => x.text)
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) {
const textureUv = this.getItemUv?.(item.itemId ?? item.blockId)
if (textureUv) {
Expand Down Expand Up @@ -418,14 +446,38 @@ export class Entities extends EventEmitter {
}
}

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: import('prismarine-entity').Entity & { delete?; pos, name }, overrides) {
const isPlayerModel = entity.name === 'player'
if (entity.name === 'zombie' || 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 (!this.entities[entity.id] && !entity.delete) {
// 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()
this.emit('remove', entity)
this.scene.remove(e)
disposeObject(e)
// todo dispose textures as well ?
delete this.entities[entity.id]
return
}

let mesh
if (e === undefined) {
const group = new THREE.Group()
let mesh
if (entity.name === 'item') {
const item = entity.metadata?.find((m: any) => typeof m === 'object' && m?.itemCount)
if (item) {
Expand Down Expand Up @@ -508,7 +560,8 @@ export class Entities extends EventEmitter {
boxHelper.visible = false
this.scene.add(group)

this.entities[entity.id] = group
e = group
this.entities[entity.id] = e

this.emit('add', entity)

Expand All @@ -517,14 +570,24 @@ export class Entities extends EventEmitter {
}
this.setDebugMode(this.debugMode, group)
this.setRendering(this.rendering, group)
} else {
mesh = e.children.find(c => c.name === 'mesh')
}

// check if entity has armor
if (entity.equipment) {
addArmorModel(e, 'feet', entity.equipment[2])
addArmorModel(e, 'legs', entity.equipment[3], 2)
addArmorModel(e, 'chest', entity.equipment[4])
addArmorModel(e, 'head', entity.equipment[5])
}

const meta = getGeneralEntitiesMetadata(entity)

//@ts-expect-error
// set visibility
const isInvisible = entity.metadata?.[0] & 0x20
for (const child of this.entities[entity.id]?.children.find(c => c.name === 'mesh')?.children ?? []) {
for (const child of mesh.children ?? []) {
if (child.name !== 'nametag') {
child.visible = !isInvisible
}
Expand All @@ -547,10 +610,77 @@ export class Entities extends EventEmitter {
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,
this.entities[entity.id].children.find(c => c.name === 'mesh')
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_leftarm':
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_rightarm':
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_leftleg':
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_rightleg':
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
// if (entity.name === 'item_frame' || entity.name === 'glow_item_frame') {
// const example = {
Expand Down Expand Up @@ -578,9 +708,6 @@ export class Entities extends EventEmitter {
// }
// }

// this can be undefined in case where packet entity_destroy was sent twice (so it was already deleted)
const e = this.entities[entity.id]

if (entity.username) {
e.username = entity.username
}
Expand All @@ -592,15 +719,6 @@ export class Entities extends EventEmitter {
playerObject.skin.head.rotation.x = overrides.rotation.head.x ? - overrides.rotation.head.x : 0
}

if (entity.delete && e) {
if (e.additionalCleanup) e.additionalCleanup()
this.emit('remove', entity)
this.scene.remove(e)
disposeObject(e)
// todo dispose textures as well ?
delete this.entities[entity.id]
}

if (entity.pos) {
new TWEEN.Tween(e.position).to({ x: entity.pos.x, y: entity.pos.y, z: entity.pos.z }, TWEEN_DURATION).start()
}
Expand Down Expand Up @@ -645,3 +763,73 @@ function getSpecificEntityMetadata<T extends keyof EntityMetadataVersions> (name
if (entity.name !== name) return
return getGeneralEntitiesMetadata(entity) as any
}

function addArmorModel (entityMesh: THREE.Object3D, slotType: string, item: Item, layer = 1, overlay = false) {

Check warning on line 767 in prismarine-viewer/viewer/lib/entities.ts

View workflow job for this annotation

GitHub Actions / build-and-deploy

Function 'addArmorModel' has too many parameters (5). Maximum allowed is 4
if (!item) {
removeArmorModel(entityMesh, slotType)
return
}
const itemParts = item.name.split('_')
const armorMaterial = itemParts[0]
if (!armorMaterial) {
removeArmorModel(entityMesh, slotType)
return
}
// TODO: Support resource pack
// TODO: Support mirroring on certain parts of the model
const texturePath = armorModels[`${armorMaterial}Layer${layer}${overlay ? 'Overlay' : ''}`]
if (!texturePath || !armorModels.armorModel[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
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(texturePath, armorModels.armorModel[slotType])
mesh.name = meshName
material = mesh.material
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(entityMesh, slotType, item, layer, true)
}
const group = new THREE.Object3D()
group.name = `armor_${slotType}${overlay ? '_overlay' : ''}`
group.add(mesh)

const skeletonHelper = new THREE.SkeletonHelper(mesh)
//@ts-expect-error
skeletonHelper.material.linewidth = 2
skeletonHelper.visible = false
group.add(skeletonHelper)

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()
}
}
}
31 changes: 24 additions & 7 deletions prismarine-viewer/viewer/lib/entity/EntityMesh.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ function dot(a, b) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}

function addCube(attr, boneId, bone, cube, texWidth = 64, texHeight = 64) {
function addCube(attr, boneId, bone, cube, texWidth = 64, texHeight = 64, mirror = false) {

Check warning on line 97 in prismarine-viewer/viewer/lib/entity/EntityMesh.js

View workflow job for this annotation

GitHub Actions / build-and-deploy

Function 'addCube' has too many parameters (7). Maximum allowed is 4
const cubeRotation = new THREE.Euler(0, 0, 0)
if (cube.rotation) {
cubeRotation.x = -cube.rotation[0] * Math.PI / 180
Expand All @@ -104,15 +104,20 @@ function addCube(attr, boneId, bone, cube, texWidth = 64, texHeight = 64) {
for (const { dir, corners, u0, v0, u1, v1 } of Object.values(elemFaces)) {
const ndx = Math.floor(attr.positions.length / 3)

const eastOrWest = dir[0] !== 0
const faceUvs = []
for (const pos of corners) {
const u = (cube.uv[0] + dot(pos[3] ? u1 : u0, cube.size)) / texWidth
const v = (cube.uv[1] + dot(pos[4] ? v1 : v0, cube.size)) / texHeight

const posX = eastOrWest && mirror ? pos[0] ^ 1 : pos[0]
const posY = pos[1]
const posZ = eastOrWest && mirror ? pos[2] ^ 1 : pos[2]
const inflate = cube.inflate ?? 0
let vecPos = new THREE.Vector3(
cube.origin[0] + pos[0] * cube.size[0] + (pos[0] ? inflate : -inflate),
cube.origin[1] + pos[1] * cube.size[1] + (pos[1] ? inflate : -inflate),
cube.origin[2] + pos[2] * cube.size[2] + (pos[2] ? inflate : -inflate)
cube.origin[0] + posX * cube.size[0] + (posX ? inflate : -inflate),
cube.origin[1] + posY * cube.size[1] + (posY ? inflate : -inflate),
cube.origin[2] + posZ * cube.size[2] + (posZ ? inflate : -inflate)
)

vecPos = vecPos.applyEuler(cubeRotation)
Expand All @@ -122,16 +127,28 @@ function addCube(attr, boneId, bone, cube, texWidth = 64, texHeight = 64) {

attr.positions.push(vecPos.x, vecPos.y, vecPos.z)
attr.normals.push(...dir)
attr.uvs.push(u, v)
faceUvs.push(u, v)
attr.skinIndices.push(boneId, 0, 0, 0)
attr.skinWeights.push(1, 0, 0, 0)
}

if (mirror) {
for (let i = 0; i + 1 < corners.length; i += 2) {
const faceIndex = i * 2
const tempFaceUvs = faceUvs.slice(faceIndex, faceIndex + 4)
faceUvs[faceIndex] = tempFaceUvs[2]
faceUvs[faceIndex + 1] = tempFaceUvs[eastOrWest ? 1 : 3]
faceUvs[faceIndex + 2] = tempFaceUvs[0]
faceUvs[faceIndex + 3] = tempFaceUvs[eastOrWest ? 3 : 1]
}
}
attr.uvs.push(...faceUvs)

attr.indices.push(ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3)
}
}

function getMesh(texture, jsonModel, overrides = {}) {
export function getMesh(texture, jsonModel, overrides = {}) {
const bones = {}

const geoData = {
Expand Down Expand Up @@ -169,7 +186,7 @@ function getMesh(texture, jsonModel, overrides = {}) {

if (jsonBone.cubes) {
for (const cube of jsonBone.cubes) {
addCube(geoData, i, bone, cube, jsonModel.texturewidth, jsonModel.textureheight)
addCube(geoData, i, bone, cube, jsonModel.texturewidth, jsonModel.textureheight, jsonBone.mirror)
}
}
i++
Expand Down
Loading

0 comments on commit cb82963

Please sign in to comment.