Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Armor Stand and armor model support #242

Merged
merged 2 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 { 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 @@
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 @@
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 @@
}
}

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 @@
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 @@
}
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 @@
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) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we have to split this huge update function into other functions like updateThing at some point

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I have been thinking about how to better handle this too. Having a section for each entity would really bloat it, maybe it even needs to be in a separate file for each entity to properly support all of the functionality.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yes thats a good idea!

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
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i like it! 👏

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 @@
// }
// }

// 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 @@
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 @@
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 @@
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 @@
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 @@

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 @@

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
Loading