From c7830940685e6138655559c66a3db8df3aff1822 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 13 Dec 2024 20:56:20 +0300 Subject: [PATCH 01/65] suppress test error --- prismarine-viewer/viewer/lib/mesher/test/tests.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/prismarine-viewer/viewer/lib/mesher/test/tests.test.ts b/prismarine-viewer/viewer/lib/mesher/test/tests.test.ts index 4e322bcf6..78374b6bd 100644 --- a/prismarine-viewer/viewer/lib/mesher/test/tests.test.ts +++ b/prismarine-viewer/viewer/lib/mesher/test/tests.test.ts @@ -43,11 +43,14 @@ test('Known blocks are not rendered', () => { } } } + console.log('Checking blocks of version', lastVersion) console.log('Average time', time / times) // should be fixed, but to avoid regressions & for visibility + // TODO resolve creaking_heart issue (1.21.3) expect(missingBlocks).toMatchInlineSnapshot(` { "bubble_column": true, + "creaking_heart": true, "end_gateway": true, "end_portal": true, "structure_void": true, From 4d411fe561282541a20cf7148418b951290d72c4 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Fri, 13 Dec 2024 21:04:45 +0300 Subject: [PATCH 02/65] feat: true hand display behind the setting (#217) --- prismarine-viewer/viewer/lib/hand.ts | 96 ++++++ prismarine-viewer/viewer/lib/holdingBlock.ts | 297 +++++++++++++++--- prismarine-viewer/viewer/lib/viewer.ts | 15 +- .../viewer/lib/worldDataEmitter.ts | 40 ++- .../viewer/lib/worldrendererCommon.ts | 4 +- .../viewer/lib/worldrendererThree.ts | 33 +- src/devtools.ts | 2 +- src/flyingSquidUtils.ts | 2 + src/optionsStorage.ts | 1 + src/topRightStats.ts | 37 ++- src/worldInteractions.ts | 8 +- 11 files changed, 444 insertions(+), 91 deletions(-) create mode 100644 prismarine-viewer/viewer/lib/hand.ts diff --git a/prismarine-viewer/viewer/lib/hand.ts b/prismarine-viewer/viewer/lib/hand.ts new file mode 100644 index 000000000..6c9aacc53 --- /dev/null +++ b/prismarine-viewer/viewer/lib/hand.ts @@ -0,0 +1,96 @@ +import * as THREE from 'three' +import { loadSkinToCanvas } from 'skinview-utils' +import stevePng from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png' + +let steveTexture: THREE.Texture +export const getMyHand = async (image?: string) => { + let newMap: THREE.Texture + if (!image && steveTexture) { + newMap = steveTexture + } else { + image ??= stevePng + const skinCanvas = document.createElement('canvas') + const img = new Image() + img.src = image + await new Promise(resolve => { + img.onload = () => { + resolve() + } + }) + loadSkinToCanvas(skinCanvas, img) + newMap = new THREE.CanvasTexture(skinCanvas) + // newMap.flipY = false + newMap.magFilter = THREE.NearestFilter + newMap.minFilter = THREE.NearestFilter + if (!image) { + steveTexture = newMap + } + } + + // right arm + const box = new THREE.BoxGeometry() + const material = new THREE.MeshStandardMaterial() + const slim = false + const mesh = new THREE.Mesh(box, material) + mesh.scale.x = slim ? 3 : 4 + mesh.scale.y = 12 + mesh.scale.z = 4 + setSkinUVs(box, 40, 16, slim ? 3 : 4, 12, 4) + material.map = newMap + material.needsUpdate = true + const group = new THREE.Group() + group.add(mesh) + group.scale.set(0.1, 0.1, 0.1) + mesh.rotation.z = Math.PI + return group +} + +function setUVs ( + box: THREE.BoxGeometry, + u: number, + v: number, + width: number, + height: number, + depth: number, + textureWidth: number, + textureHeight: number +): void { + const toFaceVertices = (x1: number, y1: number, x2: number, y2: number) => [ + new THREE.Vector2(x1 / textureWidth, 1 - y2 / textureHeight), + new THREE.Vector2(x2 / textureWidth, 1 - y2 / textureHeight), + new THREE.Vector2(x2 / textureWidth, 1 - y1 / textureHeight), + new THREE.Vector2(x1 / textureWidth, 1 - y1 / textureHeight), + ] + + const top = toFaceVertices(u + depth, v, u + width + depth, v + depth) + const bottom = toFaceVertices(u + width + depth, v, u + width * 2 + depth, v + depth) + const left = toFaceVertices(u, v + depth, u + depth, v + depth + height) + const front = toFaceVertices(u + depth, v + depth, u + width + depth, v + depth + height) + const right = toFaceVertices(u + width + depth, v + depth, u + width + depth * 2, v + height + depth) + const back = toFaceVertices(u + width + depth * 2, v + depth, u + width * 2 + depth * 2, v + height + depth) + + const uvAttr = box.attributes.uv as THREE.BufferAttribute + const uvRight = [right[3], right[2], right[0], right[1]] + const uvLeft = [left[3], left[2], left[0], left[1]] + const uvTop = [top[3], top[2], top[0], top[1]] + const uvBottom = [bottom[0], bottom[1], bottom[3], bottom[2]] + const uvFront = [front[3], front[2], front[0], front[1]] + const uvBack = [back[3], back[2], back[0], back[1]] + + // Create a new array to hold the modified UV data + const newUVData = [] as number[] + + // Iterate over the arrays and copy the data to uvData + for (const uvArray of [uvRight, uvLeft, uvTop, uvBottom, uvFront, uvBack]) { + for (const uv of uvArray) { + newUVData.push(uv.x, uv.y) + } + } + + uvAttr.set(new Float32Array(newUVData)) + uvAttr.needsUpdate = true +} + +function setSkinUVs (box: THREE.BoxGeometry, u: number, v: number, width: number, height: number, depth: number): void { + setUVs(box, u, v, width, height, depth, 64, 64) +} diff --git a/prismarine-viewer/viewer/lib/holdingBlock.ts b/prismarine-viewer/viewer/lib/holdingBlock.ts index f5a0ca799..5fc3e03c0 100644 --- a/prismarine-viewer/viewer/lib/holdingBlock.ts +++ b/prismarine-viewer/viewer/lib/holdingBlock.ts @@ -1,14 +1,19 @@ import * as THREE from 'three' import * as tweenJs from '@tweenjs/tween.js' import worldBlockProvider from 'mc-assets/dist/worldBlockProvider' +import { GUI } from 'lil-gui' import { getThreeBlockModelGroup, renderBlockThree, setBlockPosition } from './mesher/standaloneRenderer' +import { getMyHand } from './hand' export type HandItemBlock = { - name - properties + name? + properties? + type: 'block' | 'item' | 'hand' + id?: number } export default class HoldingBlock { + // TODO refactor with the tree builder for better visual understanding holdingBlock: THREE.Object3D | undefined = undefined swingAnimation: tweenJs.Group | undefined = undefined blockSwapAnimation: { @@ -16,22 +21,25 @@ export default class HoldingBlock { hidden: boolean } | undefined = undefined cameraGroup = new THREE.Mesh() - objectOuterGroup = new THREE.Group() - objectInnerGroup = new THREE.Group() - camera: THREE.Group | THREE.PerspectiveCamera + objectOuterGroup = new THREE.Group() // 3 + objectInnerGroup = new THREE.Group() // 4 + holdingBlockInnerGroup = new THREE.Group() // 5 + camera = new THREE.PerspectiveCamera(75, 1, 0.1, 100) stopUpdate = false lastHeldItem: HandItemBlock | undefined toBeRenderedItem: HandItemBlock | undefined isSwinging = false nextIterStopCallbacks: Array<() => void> | undefined + rightSide = true - constructor (public scene: THREE.Scene) { + debug = {} as Record + + constructor () { this.initCameraGroup() } initCameraGroup () { this.cameraGroup = new THREE.Mesh() - this.scene.add(this.cameraGroup) } startSwing () { @@ -44,17 +52,18 @@ export default class HoldingBlock { // const DURATION = 1000 * 0.35 / 2 const DURATION = 1000 * 0.35 / 3 // const DURATION = 1000 + const { position, rotation, object } = this.getFinalSwingPositionRotation() const initialPos = { - x: this.objectInnerGroup.position.x, - y: this.objectInnerGroup.position.y, - z: this.objectInnerGroup.position.z + x: object.position.x, + y: object.position.y, + z: object.position.z } const initialRot = { - x: this.objectInnerGroup.rotation.x, - y: this.objectInnerGroup.rotation.y, - z: this.objectInnerGroup.rotation.z + x: object.rotation.x, + y: object.rotation.y, + z: object.rotation.z } - const mainAnim = new tweenJs.Tween(this.objectInnerGroup.position, this.swingAnimation).to({ y: this.objectInnerGroup.position.y - this.objectInnerGroup.scale.y / 2 }, DURATION).yoyo(true).repeat(Infinity).start() + const mainAnim = new tweenJs.Tween(object.position, this.swingAnimation).to(position, DURATION).yoyo(true).repeat(Infinity).start() let i = 0 mainAnim.onRepeat(() => { i++ @@ -67,14 +76,66 @@ export default class HoldingBlock { this.swingAnimation!.removeAll() this.swingAnimation = undefined // todo refactor to be more generic for animations - this.objectInnerGroup.position.set(initialPos.x, initialPos.y, initialPos.z) - // this.objectInnerGroup.rotation.set(initialRot.x, initialRot.y, initialRot.z) - Object.assign(this.objectInnerGroup.rotation, initialRot) + object.position.set(initialPos.x, initialPos.y, initialPos.z) + // object.rotation.set(initialRot.x, initialRot.y, initialRot.z) + Object.assign(object.rotation, initialRot) } }) - new tweenJs.Tween(this.objectInnerGroup.rotation, this.swingAnimation).to({ z: THREE.MathUtils.degToRad(90) }, DURATION).yoyo(true).repeat(Infinity).start() - new tweenJs.Tween(this.objectInnerGroup.rotation, this.swingAnimation).to({ x: -THREE.MathUtils.degToRad(90) }, DURATION).yoyo(true).repeat(Infinity).start() + new tweenJs.Tween(object.rotation, this.swingAnimation).to(rotation, DURATION).yoyo(true).repeat(Infinity).start() + } + } + + getFinalSwingPositionRotation (origPosition?: THREE.Vector3) { + const object = this.objectInnerGroup + if (this.lastHeldItem?.type === 'block') { + origPosition ??= object.position + return { + position: { y: origPosition.y - this.objectInnerGroup.scale.y / 2 }, + rotation: { z: THREE.MathUtils.degToRad(90), x: -THREE.MathUtils.degToRad(90) }, + object + } + } + if (this.lastHeldItem?.type === 'item') { + const object = this.holdingBlockInnerGroup + origPosition ??= object.position + return { + position: { + y: origPosition.y - object.scale.y * 2, + // z: origPosition.z - window.zFinal, + // x: origPosition.x - window.xFinal, + }, + // rotation: { z: THREE.MathUtils.degToRad(90), x: -THREE.MathUtils.degToRad(90) } + rotation: { + // z: THREE.MathUtils.degToRad(window.zRotationFinal ?? 0), + // x: THREE.MathUtils.degToRad(window.xRotationFinal ?? 0), + // y: THREE.MathUtils.degToRad(window.yRotationFinal ?? 0), + x: THREE.MathUtils.degToRad(-120) + }, + object + } + } + if (this.lastHeldItem?.type === 'hand') { + const object = this.holdingBlockInnerGroup + origPosition ??= object.position + return { + position: { + y: origPosition.y - (window.yFinal ?? 0.15), + z: origPosition.z - window.zFinal, + x: origPosition.x - window.xFinal, + }, + rotation: { + x: THREE.MathUtils.degToRad(window.xRotationFinal || -14.7), + y: THREE.MathUtils.degToRad(window.yRotationFinal || 33.95), + z: THREE.MathUtils.degToRad(window.zRotationFinal || -28), + }, + object + } + } + return { + position: {}, + rotation: {}, + object } } @@ -89,11 +150,35 @@ export default class HoldingBlock { }) } - update (camera: typeof this.camera) { - this.camera = camera + render (originalCamera: THREE.PerspectiveCamera, renderer: THREE.WebGLRenderer, ambientLight: THREE.AmbientLight, directionalLight: THREE.DirectionalLight) { + if (!this.lastHeldItem) return this.swingAnimation?.update() this.blockSwapAnimation?.tween.update() + + const scene = new THREE.Scene() + scene.add(this.cameraGroup) + // if (this.camera.aspect !== originalCamera.aspect) { + // this.camera.aspect = originalCamera.aspect + // this.camera.updateProjectionMatrix() + // } this.updateCameraGroup() + scene.add(ambientLight.clone()) + scene.add(directionalLight.clone()) + + const viewerSize = renderer.getSize(new THREE.Vector2()) + const minSize = Math.min(viewerSize.width, viewerSize.height) + + renderer.autoClear = false + renderer.clearDepth() + if (this.rightSide) { + const x = viewerSize.width - minSize + // if (x) x -= x / 4 + renderer.setViewport(x, 0, minSize, minSize) + } else { + renderer.setViewport(0, 0, minSize, minSize) + } + renderer.render(scene, this.camera) + renderer.setViewport(0, 0, viewerSize.width, viewerSize.height) } // worldTest () { @@ -142,23 +227,36 @@ export default class HoldingBlock { this.cameraGroup.position.copy(camera.position) this.cameraGroup.rotation.copy(camera.rotation) - const viewerSize = viewer.renderer.getSize(new THREE.Vector2()) - // const x = window.x ?? 0.25 * viewerSize.width / viewerSize.height - // const x = 0 * viewerSize.width / viewerSize.height - const x = 0.2 * viewerSize.width / viewerSize.height - this.objectOuterGroup.position.set(x, -0.3, -0.45) + // const viewerSize = viewer.renderer.getSize(new THREE.Vector2()) + // const aspect = viewerSize.width / viewerSize.height + const aspect = 1 + + + // Adjust the position based on the aspect ratio + const { position, scale: scaleData } = this.getHandHeld3d() + const distance = -position.z + const side = this.rightSide ? 1 : -1 + this.objectOuterGroup.position.set( + distance * position.x * aspect * side, + distance * position.y, + -distance + ) + + // const scale = Math.min(0.8, Math.max(1, 1 * aspect)) + const scale = scaleData * 2.22 * 0.2 + this.objectOuterGroup.scale.set(scale, scale, scale) } - async initHandObject (material: THREE.Material, blockstatesModels: any, blocksAtlases: any, block?: HandItemBlock) { + async initHandObject (material: THREE.Material, blockstatesModels: any, blocksAtlases: any, handItem?: HandItemBlock) { let animatingCurrent = false - if (!this.swingAnimation && !this.blockSwapAnimation && this.isDifferentItem(block)) { + if (!this.swingAnimation && !this.blockSwapAnimation && this.isDifferentItem(handItem)) { animatingCurrent = true await this.playBlockSwapAnimation() this.holdingBlock?.removeFromParent() this.holdingBlock = undefined } - this.lastHeldItem = block - if (!block) { + this.lastHeldItem = handItem + if (!handItem) { this.holdingBlock?.removeFromParent() this.holdingBlock = undefined this.swingAnimation = undefined @@ -166,16 +264,28 @@ export default class HoldingBlock { return } const blockProvider = worldBlockProvider(blockstatesModels, blocksAtlases, 'latest') - const models = blockProvider.getAllResolvedModels0_1(block, true) - const blockInner = getThreeBlockModelGroup(material, models, undefined, 'plains', loadedData) - // const { mesh: itemMesh } = viewer.entities.getItemMesh({ - // itemId: 541, - // })! - // itemMesh.position.set(0.5, 0.5, 0.5) - // const blockInner = itemMesh + let blockInner + if (handItem.type === 'block') { + const models = blockProvider.getAllResolvedModels0_1({ + name: handItem.name, + properties: handItem.properties ?? {} + }, true) + blockInner = getThreeBlockModelGroup(material, models, undefined, 'plains', loadedData) + } else if (handItem.type === 'item') { + const { mesh: itemMesh } = viewer.entities.getItemMesh({ + itemId: handItem.id, + })! + itemMesh.position.set(0.5, 0.5, 0.5) + blockInner = itemMesh + } else { + blockInner = await getMyHand() + } blockInner.name = 'holdingBlock' const blockOuterGroup = new THREE.Group() - blockOuterGroup.add(blockInner) + this.holdingBlockInnerGroup.removeFromParent() + this.holdingBlockInnerGroup = new THREE.Group() + this.holdingBlockInnerGroup.add(blockInner) + blockOuterGroup.add(this.holdingBlockInnerGroup) this.holdingBlock = blockInner this.objectInnerGroup = new THREE.Group() this.objectInnerGroup.add(blockOuterGroup) @@ -190,18 +300,113 @@ export default class HoldingBlock { this.objectOuterGroup.add(this.objectInnerGroup) this.cameraGroup.add(this.objectOuterGroup) - const rotation = -45 + -90 - // const rotation = -45 // should be for item - this.holdingBlock.rotation.set(0, THREE.MathUtils.degToRad(rotation), 0, 'ZYX') + const rotationDeg = this.getHandHeld3d().rotation + let origPosition + const setRotation = () => { + const final = this.getFinalSwingPositionRotation(origPosition) + origPosition ??= final.object.position.clone() + if (this.debug.displayFinal) { + Object.assign(final.object.position, final.position) + Object.assign(final.object.rotation, final.rotation) + } else if (this.debug.displayFinal === false) { + final.object.rotation.set(0, 0, 0) + } - // const scale = window.scale ?? 0.2 - const scale = 0.2 - this.objectOuterGroup.scale.set(scale, scale, scale) - // this.objectOuterGroup.position.set(x, window.y ?? -0.41, window.z ?? -0.45) - // this.objectOuterGroup.position.set(x, 0, -0.45) + this.holdingBlock!.rotation.x = THREE.MathUtils.degToRad(rotationDeg.x) + this.holdingBlock!.rotation.y = THREE.MathUtils.degToRad(rotationDeg.y) + this.holdingBlock!.rotation.z = THREE.MathUtils.degToRad(rotationDeg.z) + this.objectOuterGroup.rotation.y = THREE.MathUtils.degToRad(rotationDeg.yOuter) + } + // const gui = new GUI() + // gui.add(rotationDeg, 'x', -180, 180, 0.1) + // gui.add(rotationDeg, 'y', -180, 180, 0.1) + // gui.add(rotationDeg, 'z', -180, 180, 0.1) + // gui.add(rotationDeg, 'yOuter', -180, 180, 0.1) + // Object.assign(window, { xFinal: 0, yFinal: 0, zFinal: 0, xRotationFinal: 0, yRotationFinal: 0, zRotationFinal: 0, displayFinal: true }) + // gui.add(window, 'xFinal', -10, 10, 0.05) + // gui.add(window, 'yFinal', -10, 10, 0.05) + // gui.add(window, 'zFinal', -10, 10, 0.05) + // gui.add(window, 'xRotationFinal', -180, 180, 0.05) + // gui.add(window, 'yRotationFinal', -180, 180, 0.05) + // gui.add(window, 'zRotationFinal', -180, 180, 0.05) + // gui.add(window, 'displayFinal') + // gui.onChange(setRotation) + setRotation() if (animatingCurrent) { await this.playBlockSwapAnimation() } } + + getHandHeld3d () { + const type = this.lastHeldItem?.type ?? 'hand' + const { debug } = this + + let scale = type === 'item' ? 0.68 : 0.45 + + const position = { + x: debug.x ?? 0.4, + y: debug.y ?? -0.7, + z: -0.45 + } + + if (type === 'item') { + position.x = -0.05 + // position.y -= 3.2 / 10 + // position.z += 1.13 / 10 + } + + if (type === 'hand') { + // position.x = viewer.camera.aspect > 1 ? 0.7 : 1.1 + position.y = -0.8 + scale = 0.8 + } + + const rotations = { + block: { + x: 0, + y: -45 + 90, + z: 0, + yOuter: 0 + }, + // hand: { + // x: 166.7, + // // y: -180, + // y: -165.2, + // // z: -156.3, + // z: -134.2, + // yOuter: -81.1 + // }, + hand: { + x: -32.4, + // y: 25.1 + y: 42.8, + z: -41.3, + yOuter: 0 + }, + // item: { + // x: -174, + // y: 47.3, + // z: -134.2, + // yOuter: -41.2 + // } + item: { + // x: -174, + // y: 47.3, + // z: -134.2, + // yOuter: -41.2 + x: 0, + // y: -90, // todo thats the correct one but we don't make it look too cheap because of no depth + y: -70, + z: window.z ?? 25, + yOuter: 0 + } + } + + return { + rotation: rotations[type], + position, + scale + } + } } diff --git a/prismarine-viewer/viewer/lib/viewer.ts b/prismarine-viewer/viewer/lib/viewer.ts index 73f29fb2d..20f10284e 100644 --- a/prismarine-viewer/viewer/lib/viewer.ts +++ b/prismarine-viewer/viewer/lib/viewer.ts @@ -3,12 +3,14 @@ import * as THREE from 'three' import { Vec3 } from 'vec3' import { generateSpiralMatrix } from 'flying-squid/dist/utils' import worldBlockProvider from 'mc-assets/dist/worldBlockProvider' +import stevePng from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png' import { Entities } from './entities' import { Primitives } from './primitives' import { WorldRendererThree } from './worldrendererThree' import { WorldRendererCommon, WorldRendererConfig, defaultWorldRendererConfig } from './worldrendererCommon' import { getThreeBlockModelGroup, renderBlockThree, setBlockPosition } from './mesher/standaloneRenderer' import { addNewStat } from './ui/newStats' +import { getMyHand } from './hand' export class Viewer { scene: THREE.Scene @@ -88,6 +90,7 @@ export class Viewer { return new THREE.TextureLoader().loadAsync(this.world.itemsAtlasParser!.latestImage) }).then((texture) => { this.entities.itemsTexture = texture + this.world.renderUpdateEmitter.emit('itemsTextureDownloaded') }) } @@ -114,18 +117,12 @@ export class Viewer { void set() } - demoModel () { + async demoModel () { //@ts-expect-error const pos = cursorBlockRel(0, 1, 0).position const blockProvider = worldBlockProvider(this.world.blockstatesModels, this.world.blocksAtlases, 'latest') - const models = blockProvider.getAllResolvedModels0_1({ - name: 'item_frame', - properties: { - // map: false - } - }, true) - const { material } = this.world - const mesh = getThreeBlockModelGroup(material, models, undefined, 'plains', loadedData) + + const mesh = await getMyHand() // mesh.rotation.y = THREE.MathUtils.degToRad(90) setBlockPosition(mesh, pos) const helper = new THREE.BoxHelper(mesh, 0xff_ff_00) diff --git a/prismarine-viewer/viewer/lib/worldDataEmitter.ts b/prismarine-viewer/viewer/lib/worldDataEmitter.ts index 0223b855b..371ef8f58 100644 --- a/prismarine-viewer/viewer/lib/worldDataEmitter.ts +++ b/prismarine-viewer/viewer/lib/worldDataEmitter.ts @@ -107,23 +107,33 @@ export class WorldDataEmitter extends EventEmitter { time: () => { this.emitter.emit('time', bot.time.timeOfDay) }, - heldItemChanged: () => { - if (!this.handDisplay) { - viewer.world.onHandItemSwitch(undefined) - return - } - const newItem = bot.heldItem - if (!newItem) { - viewer.world.onHandItemSwitch(undefined) - return - } - const block = loadedData.blocksByName[newItem.name] - // todo clean types - const blockProperties = block ? new window.PrismarineBlock(block.id, 'void', newItem.metadata).getProperties() : {} - viewer.world.onHandItemSwitch({ name: newItem.name, properties: blockProperties }) + heldItemChanged () { + handChanged(false) }, } satisfies Partial - this.eventListeners.heldItemChanged() + const handChanged = (isLeftHand: boolean) => { + if (!this.handDisplay) { + viewer.world.onHandItemSwitch(undefined, isLeftHand) + return + } + const newItem = isLeftHand ? bot.inventory.slots[45] : bot.heldItem + if (!newItem) { + viewer.world.onHandItemSwitch(undefined, isLeftHand) + return + } + const block = loadedData.blocksByName[newItem.name] + // todo clean types + const blockProperties = block ? new window.PrismarineBlock(block.id, 'void', newItem.metadata).getProperties() : {} + // todo item props + viewer.world.onHandItemSwitch({ name: newItem.name, properties: blockProperties, id: newItem.type, type: block ? 'block' : 'item', }, isLeftHand) + } + bot.inventory.on('updateSlot', (index) => { + if (index === 45) { + handChanged(true) + } + }) + handChanged(false) + handChanged(true) bot._client.on('update_light', ({ chunkX, chunkZ }) => { diff --git a/prismarine-viewer/viewer/lib/worldrendererCommon.ts b/prismarine-viewer/viewer/lib/worldrendererCommon.ts index 71c094b4f..a7c3972c1 100644 --- a/prismarine-viewer/viewer/lib/worldrendererCommon.ts +++ b/prismarine-viewer/viewer/lib/worldrendererCommon.ts @@ -217,8 +217,8 @@ export abstract class WorldRendererCommon } } - onHandItemSwitch (item: HandItemBlock | undefined): void { } - changeHandSwingingState (isAnimationPlaying: boolean): void { } + onHandItemSwitch (item: HandItemBlock | undefined, isLeftHand: boolean): void { } + changeHandSwingingState (isAnimationPlaying: boolean, isLeftHand: boolean): void { } abstract handleWorkerMessage (data: WorkerReceive): void diff --git a/prismarine-viewer/viewer/lib/worldrendererThree.ts b/prismarine-viewer/viewer/lib/worldrendererThree.ts index 8671bfaf0..de1116908 100644 --- a/prismarine-viewer/viewer/lib/worldrendererThree.ts +++ b/prismarine-viewer/viewer/lib/worldrendererThree.ts @@ -23,6 +23,7 @@ export class WorldRendererThree extends WorldRendererCommon { starField: StarField cameraSectionPos: Vec3 = new Vec3(0, 0, 0) holdingBlock: HoldingBlock + holdingBlockLeft: HoldingBlock rendererDevice = '...' get tilesRendered () { @@ -37,31 +38,44 @@ export class WorldRendererThree extends WorldRendererCommon { super(config) this.rendererDevice = String(WorldRendererThree.getRendererInfo(this.renderer)) this.starField = new StarField(scene) - this.holdingBlock = new HoldingBlock(this.scene) + this.holdingBlock = new HoldingBlock() + this.holdingBlockLeft = new HoldingBlock() + this.holdingBlockLeft.rightSide = false - this.renderUpdateEmitter.on('textureDownloaded', () => { + this.renderUpdateEmitter.on('itemsTextureDownloaded', () => { if (this.holdingBlock.toBeRenderedItem) { this.onHandItemSwitch(this.holdingBlock.toBeRenderedItem) this.holdingBlock.toBeRenderedItem = undefined } + if (this.holdingBlockLeft.toBeRenderedItem) { + this.onHandItemSwitch(this.holdingBlock.toBeRenderedItem, true) + this.holdingBlockLeft.toBeRenderedItem = undefined + } }) this.addDebugOverlay() } - onHandItemSwitch (item: HandItemBlock | undefined) { + onHandItemSwitch (item: HandItemBlock | undefined, isLeft = false) { + if (!isLeft) { + item ??= { + type: 'hand', + } + } + const holdingBlock = isLeft ? this.holdingBlockLeft : this.holdingBlock if (!this.currentTextureImage) { - this.holdingBlock.toBeRenderedItem = item + holdingBlock.toBeRenderedItem = item return } - void this.holdingBlock.initHandObject(this.material, this.blockstatesModels, this.blocksAtlases, item) + void holdingBlock.initHandObject(this.material, this.blockstatesModels, this.blocksAtlases, item) } - changeHandSwingingState (isAnimationPlaying: boolean) { + changeHandSwingingState (isAnimationPlaying: boolean, isLeft = false) { + const holdingBlock = isLeft ? this.holdingBlockLeft : this.holdingBlock if (isAnimationPlaying) { - this.holdingBlock.startSwing() + holdingBlock.startSwing() } else { - void this.holdingBlock.stopSwing() + void holdingBlock.stopSwing() } } @@ -223,10 +237,11 @@ export class WorldRendererThree extends WorldRendererCommon { render () { tweenJs.update() - this.holdingBlock.update(this.camera) // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style const cam = this.camera instanceof THREE.Group ? this.camera.children.find(child => child instanceof THREE.PerspectiveCamera) as THREE.PerspectiveCamera : this.camera this.renderer.render(this.scene, cam) + this.holdingBlock.render(this.camera, this.renderer, viewer.ambientLight, viewer.directionalLight) + this.holdingBlockLeft.render(this.camera, this.renderer, viewer.ambientLight, viewer.directionalLight) } renderSign (position: Vec3, rotation: number, isWall: boolean, isHanging: boolean, blockEntity) { diff --git a/src/devtools.ts b/src/devtools.ts index 16337c1d7..e87b6d333 100644 --- a/src/devtools.ts +++ b/src/devtools.ts @@ -46,7 +46,7 @@ customEvents.on('gameLoaded', () => { window.inspectPacket = (packetName, full = false) => { const listener = (...args) => console.log('packet', packetName, full ? args : args[0]) const attach = () => { - bot?._client.on(packetName, listener) + bot?._client.prependListener(packetName, listener) } attach() customEvents.on('mineflayerBotCreated', attach) diff --git a/src/flyingSquidUtils.ts b/src/flyingSquidUtils.ts index 78254cf23..012830d9f 100644 --- a/src/flyingSquidUtils.ts +++ b/src/flyingSquidUtils.ts @@ -27,8 +27,10 @@ export async function savePlayers (autoSave: boolean) { export const saveServer = async (autoSave = true) => { if (!localServer || fsState.isReadonly) return // todo + console.time('save server') const worlds = [(localServer as any).overworld] as Array await Promise.all([localServer.writeLevelDat(), savePlayers(autoSave), ...worlds.map(async world => world.saveNow())]) + console.timeEnd('save server') } export const disconnect = async () => { if (localServer) { diff --git a/src/optionsStorage.ts b/src/optionsStorage.ts index 618d99e88..a242107c6 100644 --- a/src/optionsStorage.ts +++ b/src/optionsStorage.ts @@ -80,6 +80,7 @@ const defaultOptions = { autoParkour: false, vrSupport: true, // doesn't directly affect the VR mode, should only disable the button which is annoying to android users renderDebug: (isDev ? 'advanced' : 'basic') as 'none' | 'advanced' | 'basic', + autoVersionSelect: '1.20.4', // advanced bot options autoRespawn: false, diff --git a/src/topRightStats.ts b/src/topRightStats.ts index 4bcd7264e..fd717ef48 100644 --- a/src/topRightStats.ts +++ b/src/topRightStats.ts @@ -88,13 +88,40 @@ export const statsEnd = () => { // for advanced debugging, use with watch expression +window.statsPerSecAvg = {} +let currentStatsPerSec = {} as Record +const waitingStatsPerSec = {} +window.markStart = (label) => { + waitingStatsPerSec[label] ??= [] + waitingStatsPerSec[label][0] = performance.now() +} +window.markEnd = (label) => { + if (!waitingStatsPerSec[label]?.[0]) return + currentStatsPerSec[label] ??= [] + currentStatsPerSec[label].push(performance.now() - waitingStatsPerSec[label][0]) + delete waitingStatsPerSec[label] +} +const updateStatsPerSecAvg = () => { + window.statsPerSecAvg = Object.fromEntries(Object.entries(currentStatsPerSec).map(([key, value]) => { + return [key, { + avg: value.reduce((a, b) => a + b, 0) / value.length, + count: value.length + }] + })) + currentStatsPerSec = {} +} + + window.statsPerSec = {} -let statsPerSec = {} +let statsPerSecCurrent = {} window.addStatPerSec = (name) => { - statsPerSec[name] ??= 0 - statsPerSec[name]++ + statsPerSecCurrent[name] ??= 0 + statsPerSecCurrent[name]++ } +window.statsPerSecCurrent = statsPerSecCurrent setInterval(() => { - window.statsPerSec = statsPerSec - statsPerSec = {} + window.statsPerSec = statsPerSecCurrent + statsPerSecCurrent = {} + window.statsPerSecCurrent = statsPerSecCurrent + updateStatsPerSecAvg() }, 1000) diff --git a/src/worldInteractions.ts b/src/worldInteractions.ts index d6674ce31..1134d7f89 100644 --- a/src/worldInteractions.ts +++ b/src/worldInteractions.ts @@ -257,8 +257,8 @@ class WorldInteraction { bot.lookAt = oldLookAt }).catch(console.warn) } - viewer.world.changeHandSwingingState(true) - viewer.world.changeHandSwingingState(false) + viewer.world.changeHandSwingingState(true, false) + viewer.world.changeHandSwingingState(false, false) } else if (!stop) { const offhand = activate ? false : activatableItems(bot.inventory.slots[45]?.name ?? '') bot.activateItem(offhand) // todo offhand @@ -317,14 +317,14 @@ class WorldInteraction { }) customEvents.emit('digStart') this.lastDigged = Date.now() - viewer.world.changeHandSwingingState(true) + viewer.world.changeHandSwingingState(true, false) } else if (performance.now() - this.lastSwing > 200) { bot.swingArm('right') this.lastSwing = performance.now() } } if (!this.buttons[0] && this.lastButtons[0]) { - viewer.world.changeHandSwingingState(false) + viewer.world.changeHandSwingingState(false, false) } this.prevOnGround = onGround From b10c6809ff64effeea87bb7d87044f2b8b2bdb01 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sat, 14 Dec 2024 08:08:30 +0300 Subject: [PATCH 03/65] fix: fix signs could not be rendered properly because font was not in the build --- src/styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles.css b/src/styles.css index 817fae8ca..1e5407f60 100644 --- a/src/styles.css +++ b/src/styles.css @@ -119,7 +119,7 @@ body { @font-face { font-family: mojangles; - src: url(../assets/mojangles.ttf); + src: url(../assets/mojangles.ttf?inline); } #ui-root { From 9b5155d3fe83f3bbadea2583fc48de6711e45477 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sat, 14 Dec 2024 08:08:58 +0300 Subject: [PATCH 04/65] up singleplayer version --- src/defaultLocalServerOptions.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/defaultLocalServerOptions.js b/src/defaultLocalServerOptions.js index 130726e0f..cd9495673 100644 --- a/src/defaultLocalServerOptions.js +++ b/src/defaultLocalServerOptions.js @@ -33,6 +33,6 @@ module.exports = { keepAlive: false, 'everybody-op': true, 'max-entities': 100, - 'version': '1.14.4', - versionMajor: '1.14' + 'version': '1.18.2', + versionMajor: '1.18' } From ad0502dcb93d9cf04d0005b2c0b4e5d2182603a1 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sat, 14 Dec 2024 08:09:09 +0300 Subject: [PATCH 05/65] recenter edition in ui --- src/react/mainMenu.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/react/mainMenu.module.css b/src/react/mainMenu.module.css index db072ce1a..dcec47c3e 100644 --- a/src/react/mainMenu.module.css +++ b/src/react/mainMenu.module.css @@ -45,7 +45,7 @@ display: block; position: absolute; top: 37px; - left: calc(88px + 5px); + left: calc((512px / 2 - 176px / 2) / 2); background-image: url('../../assets/edition.png'); background-size: 128px; width: 88px; From 725f6ec3643eb3a7c8fde399a3b88cc0cf910aea Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sat, 14 Dec 2024 08:17:01 +0300 Subject: [PATCH 06/65] restore hand display setting --- prismarine-viewer/viewer/lib/worldDataEmitter.ts | 12 ------------ prismarine-viewer/viewer/lib/worldrendererCommon.ts | 4 +++- prismarine-viewer/viewer/lib/worldrendererThree.ts | 6 ++++-- src/watchOptions.ts | 2 +- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/prismarine-viewer/viewer/lib/worldDataEmitter.ts b/prismarine-viewer/viewer/lib/worldDataEmitter.ts index 371ef8f58..61d5a503a 100644 --- a/prismarine-viewer/viewer/lib/worldDataEmitter.ts +++ b/prismarine-viewer/viewer/lib/worldDataEmitter.ts @@ -23,15 +23,7 @@ export class WorldDataEmitter extends EventEmitter { private readonly emitter: WorldDataEmitter keepChunksDistance = 0 addWaitTime = 1 - _handDisplay = false isPlayground = false - get handDisplay () { - return this._handDisplay - } - set handDisplay (newVal) { - this._handDisplay = newVal - this.eventListeners.heldItemChanged?.() - } constructor (public world: typeof __type_bot['world'], public viewDistance: number, position: Vec3 = new Vec3(0, 0, 0)) { super() @@ -112,10 +104,6 @@ export class WorldDataEmitter extends EventEmitter { }, } satisfies Partial const handChanged = (isLeftHand: boolean) => { - if (!this.handDisplay) { - viewer.world.onHandItemSwitch(undefined, isLeftHand) - return - } const newItem = isLeftHand ? bot.inventory.slots[45] : bot.heldItem if (!newItem) { viewer.world.onHandItemSwitch(undefined, isLeftHand) diff --git a/prismarine-viewer/viewer/lib/worldrendererCommon.ts b/prismarine-viewer/viewer/lib/worldrendererCommon.ts index a7c3972c1..ec649a14f 100644 --- a/prismarine-viewer/viewer/lib/worldrendererCommon.ts +++ b/prismarine-viewer/viewer/lib/worldrendererCommon.ts @@ -29,7 +29,9 @@ export const worldCleanup = buildCleanupDecorator('resetWorld') export const defaultWorldRendererConfig = { showChunkBorders: false, - numWorkers: 4 + numWorkers: 4, + // game renderer setting actually + displayHand: false } export type WorldRendererConfig = typeof defaultWorldRendererConfig diff --git a/prismarine-viewer/viewer/lib/worldrendererThree.ts b/prismarine-viewer/viewer/lib/worldrendererThree.ts index de1116908..4d317354e 100644 --- a/prismarine-viewer/viewer/lib/worldrendererThree.ts +++ b/prismarine-viewer/viewer/lib/worldrendererThree.ts @@ -240,8 +240,10 @@ export class WorldRendererThree extends WorldRendererCommon { // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style const cam = this.camera instanceof THREE.Group ? this.camera.children.find(child => child instanceof THREE.PerspectiveCamera) as THREE.PerspectiveCamera : this.camera this.renderer.render(this.scene, cam) - this.holdingBlock.render(this.camera, this.renderer, viewer.ambientLight, viewer.directionalLight) - this.holdingBlockLeft.render(this.camera, this.renderer, viewer.ambientLight, viewer.directionalLight) + if (this.config.displayHand) { + this.holdingBlock.render(this.camera, this.renderer, viewer.ambientLight, viewer.directionalLight) + this.holdingBlockLeft.render(this.camera, this.renderer, viewer.ambientLight, viewer.directionalLight) + } } renderSign (position: Vec3, rotation: number, isWall: boolean, isHanging: boolean, blockEntity) { diff --git a/src/watchOptions.ts b/src/watchOptions.ts index 4b69726c9..84092de27 100644 --- a/src/watchOptions.ts +++ b/src/watchOptions.ts @@ -96,6 +96,6 @@ export const watchOptionsAfterWorldViewInit = () => { watchValue(options, o => { if (!worldView) return worldView.keepChunksDistance = o.keepChunksDistance - worldView.handDisplay = o.handDisplay + viewer.world.config.displayHand = o.handDisplay }) } From 372583be7d9155e7c68f3aa2a18ec55dd50da178 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sat, 14 Dec 2024 10:31:12 +0300 Subject: [PATCH 07/65] fix ci build type checks --- prismarine-viewer/examples/baseScene.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/prismarine-viewer/examples/baseScene.ts b/prismarine-viewer/examples/baseScene.ts index 1db68eb82..4b01abcd2 100644 --- a/prismarine-viewer/examples/baseScene.ts +++ b/prismarine-viewer/examples/baseScene.ts @@ -17,6 +17,7 @@ import { WorldDataEmitter } from '../viewer' import { Viewer } from '../viewer/lib/viewer' import { BlockNames } from '../../src/mcDataTypes' import { initWithRenderer, statsEnd, statsStart } from '../../src/topRightStats' +import { defaultWorldRendererConfig } from '../viewer/lib/worldrendererCommon' import { getSyncWorld } from './shared' window.THREE = THREE @@ -158,7 +159,7 @@ export class BasePlaygroundScene { renderer.setSize(window.innerWidth, window.innerHeight) // Create viewer - const viewer = new Viewer(renderer, { numWorkers: 6, showChunkBorders: false, }) + const viewer = new Viewer(renderer, { ...defaultWorldRendererConfig, numWorkers: 6 }) window.viewer = viewer const isWebgpu = false const promises = [] as Array> From 5f87385486eed08571fb7e15da92d76919f27b60 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sat, 14 Dec 2024 10:34:28 +0300 Subject: [PATCH 08/65] make title selectable --- src/react/AppStatus.tsx | 2 +- src/react/Screen.tsx | 5 +++-- src/styles.css | 4 ++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/react/AppStatus.tsx b/src/react/AppStatus.tsx index f9e5217ed..2c24b1539 100644 --- a/src/react/AppStatus.tsx +++ b/src/react/AppStatus.tsx @@ -38,10 +38,10 @@ export default ({ return ( diff --git a/src/react/Screen.tsx b/src/react/Screen.tsx index c80b880ac..6654e4ddf 100644 --- a/src/react/Screen.tsx +++ b/src/react/Screen.tsx @@ -4,15 +4,16 @@ interface Props { backdrop?: boolean | 'dirt' style?: React.CSSProperties className?: string + titleSelectable?: boolean } -export default ({ title, children, backdrop = true, style, className }: Props) => { +export default ({ title, children, backdrop = true, style, className, titleSelectable }: Props) => { return ( <> {backdrop === 'dirt' ?
: backdrop ?
: null}
-
{title}
+
{title}
{children}
diff --git a/src/styles.css b/src/styles.css index 1e5407f60..2c645ae92 100644 --- a/src/styles.css +++ b/src/styles.css @@ -181,6 +181,10 @@ body::xr-overlay #viewer-canvas { color: #999; } +.text-select { + user-select: text; +} + @media screen and (min-width: 430px) { .span-2 { grid-column: span 2; From 963a769db326eb621468a05d194c42e5ce3a8d87 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sun, 15 Dec 2024 17:00:59 +0300 Subject: [PATCH 09/65] docs: change description to avoid confustion with eaglercraft project --- README.MD | 10 ++++++---- TECH.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 TECH.md diff --git a/README.MD b/README.MD index ceba1c6c9..b2220a748 100644 --- a/README.MD +++ b/README.MD @@ -2,18 +2,20 @@ ![banner](./docs-assets/banner.jpg) -A true Minecraft client running in your browser! A port of the original game to the web, written in JavaScript using the best modern web technologies. +Minecraft **clone** rewritten in TypeScript using the best modern web technologies. Minecraft vanilla-compatible client and integrated server packaged into a single web app. -You can try this out at [mcraft.fun](https://mcraft.fun/), [pcm.gg](https://pcm.gg) (short link) [mcon.vercel.app](https://mcon.vercel.app/) or the GitHub pages deploy. Every commit from the `develop` (default) branch is deployed to [s.mcraft.fun](https://s.mcraft.fun/) - so it's usually newer, but might be less stable. +You can try this out at [mcraft.fun](https://mcraft.fun/), [pcm.gg](https://pcm.gg) (short link), [mcon.vercel.app](https://mcon.vercel.app/) or the GitHub pages deploy. Every commit from the default (`develop`) branch is deployed to [s.mcraft.fun](https://s.mcraft.fun/) and [s.pcm.gg](https://s.pcm.gg/) - so it's usually newer, but might be less stable. -For building the project yourself / contributing, see [Development, Debugging & Contributing](#development-debugging--contributing). If you encounter any bugs or usability issues, please report them! +Don't confuse with [Eaglercraft](https://git.eaglercraft.rip/eaglercraft/eaglercraft-1.8) that is a REAL vanilla Minecraft Java edition port to the web. Eaglercraft is a fully playable solution, but this project is more in position of a "technical demo" to show how it's possible to build games for web at scale entirely in JS ecosystem. + +For building the project yourself / contributing, see [Development, Debugging & Contributing](#development-debugging--contributing). For reference at what and how web technologies / frameworks are used, see [TECH.md](./TECH.md). ### Big Features - Open any zip world file or even folder in read-write mode! - Connect to Java servers running in both offline (cracked) and online mode* (it's possible because of proxy servers, see below) - Singleplayer mode with simple world generations! -- Google Drive support for reading / saving worlds +- Google Drive support for reading / saving worlds back to the cloud - Works offline - Play with friends over internet! (P2P is powered by Peer.js discovery servers) - First-class touch (mobile) & controller support diff --git a/TECH.md b/TECH.md new file mode 100644 index 000000000..eca13dcee --- /dev/null +++ b/TECH.md @@ -0,0 +1,47 @@ +### Eaglercraft Comparison + +This project uses proxies, Eaglercraft uses relays to connect to vanilla servers from the browser, these serve the same purpose but have different implementations. Though they have the same limitations such as increased latency and servers will complain about using VPN. + +| Feature | This project | Eaglercraft | Description | +| --------------------------------- | ------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| General | | | | +| Mobile Support (touch) | ✅(+) | ✅ | | +| Gamepad Support | ✅ | ❌ | | +| Game Features | | | | +| Servers Support (quality) | ❌ | ✅ | Eaglercraft is vanilla Minecraft, while this project tries to emulate original game behavior at protocol level (Mineflayer is used) | +| Servers Support (any version) | ✅ | ❌ | We support almost all Minecraft versions, only important if you connect to a server where you need new content like blocks or if you play with friends | +| Singleplayer Survival Features | ❌ | ✅ | Just like Eaglercraft this project can generate and save worlds, but generator is simple and only a few survival features are supported (look here for [supported features list](https://github.com/zardoy/space-squid)) | +| Singleplayer Maps | ✅ | ✅ | We support any version, but adventure maps won't work, but simple parkour and build maps might be interesting to explore... | +| Singleplayer Maps World Streaming | ✅ | ❌ | Thanks to Browserfs, saves can be loaded to local singleplayer server using multiple ways: from local folder, server directory (not zip), dropbox or other cloud *backend* etc... | +| P2P Multiplayer | ✅ | ✅ | A way to connect to other browser running the project. But it's almost useless here since many survival features are not implemented. Maybe only to build / explore maps together... | +| Voice Chat | ❌ | ✅ | Eaglercraft has custom WebRTC voice chat implementation, though it could also be easily implemented there | +| Online Servers | ✅ | ❌ | We have custom implementation (including integration on proxy side) for joining to servers | +| Graphics | | | | +| Fancy Graphics | ❌ | ✅ | While Eaglercraft has top-level shaders we don't even support lighting | +| Fast & Efficient Graphics | ❌(+) | ❌ | Feels like no one needs to have 64 rendering distance work smoothly | +| VR | ✅ | ❌ | Feels like not needed feature. UI is missing in this project since DOM can't be rendered in VR so Eaglercraft could be better in that aspect | +| Minimap | ✅(-) | ❌ | We have buggy minimap, which can be enabled in settings and full map is opened by pressing `M` key | +| Plugin Features | ✅ | ❌ | We have Mineflayer plugins support, like Auto Jump & Auto Parkour was added here that way | + +Features available to only this project: + +- CSS & JS Customization +- JS Real Time Debugging & Console Scripting (eg Devtools) + +### Tech Stack + +Bundler: Rsbuild! +UI: powered by React and css modules. Storybook helps with UI development. + +### Rare WEB Features + +There are a number of web features that are not commonly used but you might be interested in them if you decide to build your own game in the web. + +TODO + +| API | Usage & Description | +| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `Crypto` API | Used to make chat features work when joining online servers with authentication. | +| `requestPointerLock({ unadjustedMovement: true })` API | Required for games. Disables system mouse acceleration (important for Mac users) | +| `navigator.keyboard.lock()` | (only in Chromium browsers) When entering fullscreen it allows to use any key combination like ctrl+w in the game | +| `navigator.keyboard.getLayoutMap()` | (only in Chromium browsers) To display the right keyboard symbol for the key keybinding on different keyboard layouts (e.g. QWERTY vs AZERTY) | From 652120c71b63f5c2b979b4b94c20f7ecb0fc090e Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sun, 15 Dec 2024 17:48:05 +0300 Subject: [PATCH 10/65] docs: more eaglercraft comparison --- TECH.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/TECH.md b/TECH.md index eca13dcee..b1d149085 100644 --- a/TECH.md +++ b/TECH.md @@ -7,6 +7,7 @@ This project uses proxies, Eaglercraft uses relays to connect to vanilla servers | General | | | | | Mobile Support (touch) | ✅(+) | ✅ | | | Gamepad Support | ✅ | ❌ | | +| A11Y | ✅ | ❌ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page (but maybe it's not needed) | | Game Features | | | | | Servers Support (quality) | ❌ | ✅ | Eaglercraft is vanilla Minecraft, while this project tries to emulate original game behavior at protocol level (Mineflayer is used) | | Servers Support (any version) | ✅ | ❌ | We support almost all Minecraft versions, only important if you connect to a server where you need new content like blocks or if you play with friends | @@ -16,12 +17,17 @@ This project uses proxies, Eaglercraft uses relays to connect to vanilla servers | P2P Multiplayer | ✅ | ✅ | A way to connect to other browser running the project. But it's almost useless here since many survival features are not implemented. Maybe only to build / explore maps together... | | Voice Chat | ❌ | ✅ | Eaglercraft has custom WebRTC voice chat implementation, though it could also be easily implemented there | | Online Servers | ✅ | ❌ | We have custom implementation (including integration on proxy side) for joining to servers | +| Plugin Features | ✅ | ❌ | We have Mineflayer plugins support, like Auto Jump & Auto Parkour was added here that way | +| Direct Connection | ❌ | ✅ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page | +| Mods | ❌(roadmap) | ❌ | This project will support mods for singleplayer. In theory its possible to implement support for modded servers on protocol level (including all needed mods) | +| Video Recording | ❌ | ✅ | Don't feel needed | +| Metaverse Features | ❌(roadmap) | ❌ | Iframes, video streams inside of game world (custom protocol channel) | | Graphics | | | | | Fancy Graphics | ❌ | ✅ | While Eaglercraft has top-level shaders we don't even support lighting | | Fast & Efficient Graphics | ❌(+) | ❌ | Feels like no one needs to have 64 rendering distance work smoothly | | VR | ✅ | ❌ | Feels like not needed feature. UI is missing in this project since DOM can't be rendered in VR so Eaglercraft could be better in that aspect | -| Minimap | ✅(-) | ❌ | We have buggy minimap, which can be enabled in settings and full map is opened by pressing `M` key | -| Plugin Features | ✅ | ❌ | We have Mineflayer plugins support, like Auto Jump & Auto Parkour was added here that way | +| AR | ❌ | ❌ | Would be the most useless feature | +| Minimap & Waypoints | ✅(-) | ❌ | We have buggy minimap, which can be enabled in settings and full map is opened by pressing `M` key | Features available to only this project: From 45b8ae6f7ca5da96c3980111b602a6aafe54d266 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sun, 15 Dec 2024 17:54:22 +0300 Subject: [PATCH 11/65] docs: more comparison eagler --- TECH.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/TECH.md b/TECH.md index b1d149085..c557e1e2a 100644 --- a/TECH.md +++ b/TECH.md @@ -22,6 +22,9 @@ This project uses proxies, Eaglercraft uses relays to connect to vanilla servers | Mods | ❌(roadmap) | ❌ | This project will support mods for singleplayer. In theory its possible to implement support for modded servers on protocol level (including all needed mods) | | Video Recording | ❌ | ✅ | Don't feel needed | | Metaverse Features | ❌(roadmap) | ❌ | Iframes, video streams inside of game world (custom protocol channel) | +| Sounds | ✅ | ✅(-) | Eaglercraft has reduced sound quality, but better general support for them | +| Resource Packs | ✅(--) | ✅(-) | Eaglercraft obviously don't support server resource pack, but this project has very limited support for them (only textures images are loadable for now) | +| Assets Compressing | ✅ | ✅❌ | We have advanced Minecraft data processing and good code chunk splitting so the web app will open faster and use less memory | | Graphics | | | | | Fancy Graphics | ❌ | ✅ | While Eaglercraft has top-level shaders we don't even support lighting | | Fast & Efficient Graphics | ❌(+) | ❌ | Feels like no one needs to have 64 rendering distance work smoothly | From ee257d7916ba00532febed635ce2c50e0bdcc632 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Mon, 16 Dec 2024 19:16:58 +0300 Subject: [PATCH 12/65] Docs eaglercraft change (#239) A lot misleading information removed --- README.MD | 2 +- TECH.md | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/README.MD b/README.MD index b2220a748..6b8bdc97d 100644 --- a/README.MD +++ b/README.MD @@ -6,7 +6,7 @@ Minecraft **clone** rewritten in TypeScript using the best modern web technologi You can try this out at [mcraft.fun](https://mcraft.fun/), [pcm.gg](https://pcm.gg) (short link), [mcon.vercel.app](https://mcon.vercel.app/) or the GitHub pages deploy. Every commit from the default (`develop`) branch is deployed to [s.mcraft.fun](https://s.mcraft.fun/) and [s.pcm.gg](https://s.pcm.gg/) - so it's usually newer, but might be less stable. -Don't confuse with [Eaglercraft](https://git.eaglercraft.rip/eaglercraft/eaglercraft-1.8) that is a REAL vanilla Minecraft Java edition port to the web. Eaglercraft is a fully playable solution, but this project is more in position of a "technical demo" to show how it's possible to build games for web at scale entirely in JS ecosystem. +Don't confuse with [Eaglercraft](https://git.eaglercraft.rip/eaglercraft/eaglercraft-1.8) which is a REAL vanilla Minecraft Java edition port to the web (but with its own limitations). Eaglercraft is a fully playable solution, but this project is more in position of a "technical demo" to show how it's possible to build games for web at scale entirely with the JS ecosystem. Have fun! For building the project yourself / contributing, see [Development, Debugging & Contributing](#development-debugging--contributing). For reference at what and how web technologies / frameworks are used, see [TECH.md](./TECH.md). diff --git a/TECH.md b/TECH.md index c557e1e2a..3ea76719e 100644 --- a/TECH.md +++ b/TECH.md @@ -1,6 +1,7 @@ ### Eaglercraft Comparison -This project uses proxies, Eaglercraft uses relays to connect to vanilla servers from the browser, these serve the same purpose but have different implementations. Though they have the same limitations such as increased latency and servers will complain about using VPN. +This project uses proxies so you can connect to almost any vanilla server. Though proxies have some limitations such as increased latency and servers will complain about using VPN (though we have a workaround for that, but ping will be much higher). +This client generally has better performance but some features reproduction might be inaccurate eg its less stable and more buggy in some cases. | Feature | This project | Eaglercraft | Description | | --------------------------------- | ------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -10,7 +11,7 @@ This project uses proxies, Eaglercraft uses relays to connect to vanilla servers | A11Y | ✅ | ❌ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page (but maybe it's not needed) | | Game Features | | | | | Servers Support (quality) | ❌ | ✅ | Eaglercraft is vanilla Minecraft, while this project tries to emulate original game behavior at protocol level (Mineflayer is used) | -| Servers Support (any version) | ✅ | ❌ | We support almost all Minecraft versions, only important if you connect to a server where you need new content like blocks or if you play with friends | +| Servers Support (any version, ip) | ✅ | ❌ | We support almost all Minecraft versions, only important if you connect to a server where you need new content like blocks or if you play with friends. And you can connect to almost any server using proxy servers! | | Singleplayer Survival Features | ❌ | ✅ | Just like Eaglercraft this project can generate and save worlds, but generator is simple and only a few survival features are supported (look here for [supported features list](https://github.com/zardoy/space-squid)) | | Singleplayer Maps | ✅ | ✅ | We support any version, but adventure maps won't work, but simple parkour and build maps might be interesting to explore... | | Singleplayer Maps World Streaming | ✅ | ❌ | Thanks to Browserfs, saves can be loaded to local singleplayer server using multiple ways: from local folder, server directory (not zip), dropbox or other cloud *backend* etc... | @@ -22,9 +23,9 @@ This project uses proxies, Eaglercraft uses relays to connect to vanilla servers | Mods | ❌(roadmap) | ❌ | This project will support mods for singleplayer. In theory its possible to implement support for modded servers on protocol level (including all needed mods) | | Video Recording | ❌ | ✅ | Don't feel needed | | Metaverse Features | ❌(roadmap) | ❌ | Iframes, video streams inside of game world (custom protocol channel) | -| Sounds | ✅ | ✅(-) | Eaglercraft has reduced sound quality, but better general support for them | -| Resource Packs | ✅(--) | ✅(-) | Eaglercraft obviously don't support server resource pack, but this project has very limited support for them (only textures images are loadable for now) | -| Assets Compressing | ✅ | ✅❌ | We have advanced Minecraft data processing and good code chunk splitting so the web app will open faster and use less memory | +| Sounds | ✅ | ✅ | | +| Resource Packs | ✅(--) | ✅ | This project has very limited support for them (only textures images are loadable for now) | +| Assets Compressing & Splitting | ✅ | ❌ | We have advanced Minecraft data processing and good code chunk splitting so the web app will open much faster and use less memory | | Graphics | | | | | Fancy Graphics | ❌ | ✅ | While Eaglercraft has top-level shaders we don't even support lighting | | Fast & Efficient Graphics | ❌(+) | ❌ | Feels like no one needs to have 64 rendering distance work smoothly | @@ -51,6 +52,6 @@ TODO | API | Usage & Description | | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | | `Crypto` API | Used to make chat features work when joining online servers with authentication. | -| `requestPointerLock({ unadjustedMovement: true })` API | Required for games. Disables system mouse acceleration (important for Mac users) | +| `requestPointerLock({ unadjustedMovement: true })` API | Required for games. Disables system mouse acceleration (important for Mac users). Aka mouse raw input | | `navigator.keyboard.lock()` | (only in Chromium browsers) When entering fullscreen it allows to use any key combination like ctrl+w in the game | | `navigator.keyboard.getLayoutMap()` | (only in Chromium browsers) To display the right keyboard symbol for the key keybinding on different keyboard layouts (e.g. QWERTY vs AZERTY) | From 7b0ead559547c32be9035e45b110456136381bb5 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Tue, 17 Dec 2024 11:00:40 +0300 Subject: [PATCH 13/65] fix renderer string --- prismarine-viewer/viewer/lib/worldrendererThree.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/prismarine-viewer/viewer/lib/worldrendererThree.ts b/prismarine-viewer/viewer/lib/worldrendererThree.ts index 4d317354e..36008797c 100644 --- a/prismarine-viewer/viewer/lib/worldrendererThree.ts +++ b/prismarine-viewer/viewer/lib/worldrendererThree.ts @@ -36,7 +36,7 @@ export class WorldRendererThree extends WorldRendererCommon { constructor (public scene: THREE.Scene, public renderer: THREE.WebGLRenderer, public config: WorldRendererConfig) { super(config) - this.rendererDevice = String(WorldRendererThree.getRendererInfo(this.renderer)) + this.rendererDevice = `${WorldRendererThree.getRendererInfo(this.renderer)} powered by three.js r${THREE.REVISION}` this.starField = new StarField(scene) this.holdingBlock = new HoldingBlock() this.holdingBlockLeft = new HoldingBlock() @@ -422,7 +422,7 @@ export class WorldRendererThree extends WorldRendererCommon { static getRendererInfo (renderer: THREE.WebGLRenderer) { try { const gl = renderer.getContext() - return `${gl.getParameter(gl.getExtension('WEBGL_debug_renderer_info')!.UNMASKED_RENDERER_WEBGL)} powered by three.js r{THREE.REVISION}` + return `${gl.getParameter(gl.getExtension('WEBGL_debug_renderer_info')!.UNMASKED_RENDERER_WEBGL)}` } catch (err) { console.warn('Failed to get renderer info', err) } From 064d70480df479d74d3ccc375be7b1720698851b Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 18 Dec 2024 09:06:31 +0300 Subject: [PATCH 14/65] fix: a lot of edge case world block updates fixes & improvements. Fix all known visual incosistencies after chunk edge block updates, make them faster --- prismarine-viewer/examples/baseScene.ts | 57 ++++-- .../examples/scenes/frequentUpdates.ts | 162 ++++++++++++------ prismarine-viewer/viewer/lib/viewer.ts | 10 +- .../viewer/lib/worldrendererCommon.ts | 80 +++++++-- 4 files changed, 226 insertions(+), 83 deletions(-) diff --git a/prismarine-viewer/examples/baseScene.ts b/prismarine-viewer/examples/baseScene.ts index 4b01abcd2..daa56fcce 100644 --- a/prismarine-viewer/examples/baseScene.ts +++ b/prismarine-viewer/examples/baseScene.ts @@ -24,6 +24,7 @@ window.THREE = THREE export class BasePlaygroundScene { continuousRender = false + stopRender = false guiParams = {} viewDistance = 0 targetPos = new Vec3(2, 90, 2) @@ -49,6 +50,15 @@ export class BasePlaygroundScene { windowHidden = false world: ReturnType + _worldConfig = defaultWorldRendererConfig + get worldConfig () { + return this._worldConfig + } + set worldConfig (value) { + this._worldConfig = value + viewer.world.config = value + } + constructor () { void this.initData().then(() => { this.addKeyboardShortcuts() @@ -56,16 +66,19 @@ export class BasePlaygroundScene { } onParamsUpdate (paramName: string, object: any) {} - updateQs () { + updateQs (paramName: string, valueSet: any) { if (this.skipUpdateQs) return - const oldQs = new URLSearchParams(window.location.search) - const newQs = new URLSearchParams() - if (oldQs.get('scene')) { - newQs.set('scene', oldQs.get('scene')!) - } - for (const [key, value] of Object.entries(this.params)) { - if (!value || typeof value === 'function' || this.params.skipQs?.includes(key) || this.alwaysIgnoreQs.includes(key)) continue - newQs.set(key, value) + const newQs = new URLSearchParams(window.location.search) + // if (oldQs.get('scene')) { + // newQs.set('scene', oldQs.get('scene')!) + // } + for (const [key, value] of Object.entries({ [paramName]: valueSet })) { + if (typeof value === 'function' || this.params.skipQs?.includes(key) || this.alwaysIgnoreQs.includes(key)) continue + if (value) { + newQs.set(key, value) + } else { + newQs.delete(key) + } } window.history.replaceState({}, '', `${window.location.pathname}?${newQs.toString()}`) } @@ -89,7 +102,9 @@ export class BasePlaygroundScene { if (option?.hide) continue this.gui.add(this.params, param, option?.options ?? option?.min, option?.max) } - this.gui.open(false) + if (window.innerHeight < 700) { + this.gui.open(false) + } this.gui.onChange(({ property, object }) => { if (object === this.params) { @@ -101,16 +116,18 @@ export class BasePlaygroundScene { window.location.reload() }) } + this.updateQs(property, value) } else { this.onParamsUpdate(property, object) } - this.updateQs() }) } // mainChunk: import('prismarine-chunk/types/index').PCChunk + // overridables setupWorld () { } + sceneReset () {} // eslint-disable-next-line max-params addWorldBlock (xOffset: number, yOffset: number, zOffset: number, blockName: BlockNames, properties?: Record) { @@ -159,8 +176,9 @@ export class BasePlaygroundScene { renderer.setSize(window.innerWidth, window.innerHeight) // Create viewer - const viewer = new Viewer(renderer, { ...defaultWorldRendererConfig, numWorkers: 6 }) + const viewer = new Viewer(renderer, this.worldConfig) window.viewer = viewer + window.world = window.viewer.world const isWebgpu = false const promises = [] as Array> if (isWebgpu) { @@ -269,12 +287,14 @@ export class BasePlaygroundScene { loop () { if (this.continuousRender && !this.windowHidden) { - this.render() + this.render(true) requestAnimationFrame(() => this.loop()) } } - render () { + render (fromLoop = false) { + if (!fromLoop && this.continuousRender) return + if (this.stopRender) return statsStart() viewer.render() statsEnd() @@ -287,8 +307,13 @@ export class BasePlaygroundScene { this.controls?.reset() this.resetCamera() } - if (e.code === 'KeyE') { - worldView?.setBlockStateId(this.targetPos, this.world.getBlockStateId(this.targetPos)) + if (e.code === 'KeyE') { // refresh block (main) + worldView!.setBlockStateId(this.targetPos, this.world.getBlockStateId(this.targetPos)) + } + if (e.code === 'KeyF') { // reload all chunks + this.sceneReset() + worldView!.unloadAllChunks() + void worldView!.init(this.targetPos) } } }) diff --git a/prismarine-viewer/examples/scenes/frequentUpdates.ts b/prismarine-viewer/examples/scenes/frequentUpdates.ts index 6b0718f49..bc4012557 100644 --- a/prismarine-viewer/examples/scenes/frequentUpdates.ts +++ b/prismarine-viewer/examples/scenes/frequentUpdates.ts @@ -1,3 +1,4 @@ +import { Vec3 } from 'vec3' import { BasePlaygroundScene } from '../baseScene' export default class extends BasePlaygroundScene { @@ -6,69 +7,125 @@ export default class extends BasePlaygroundScene { override initGui (): void { this.params = { - squareSize: 50 + testActive: false, + testUpdatesPerSecond: 10, + testInitialUpdate: false, + stopGeometryUpdate: false, + manualTest: () => { + this.updateBlock() + }, + testNeighborUpdates: () => { + this.testNeighborUpdates() + } } super.initGui() } + lastUpdatedOffset = 0 + lastUpdatedId = 2 + updateBlock () { + const x = this.lastUpdatedOffset % 16 + const z = Math.floor(this.lastUpdatedOffset / 16) + const y = 90 + worldView!.setBlockStateId(new Vec3(x, y, z), this.lastUpdatedId++) + this.lastUpdatedOffset++ + if (this.lastUpdatedOffset > 16 * 16) this.lastUpdatedOffset = 0 + if (this.lastUpdatedId > 500) this.lastUpdatedId = 1 + } + + testNeighborUpdates () { + viewer.world.setBlockStateId(new Vec3(15, 95, 15), 1) + viewer.world.setBlockStateId(new Vec3(0, 95, 15), 1) + viewer.world.setBlockStateId(new Vec3(15, 95, 0), 1) + viewer.world.setBlockStateId(new Vec3(0, 95, 0), 1) + + viewer.world.setBlockStateId(new Vec3(16, 95, 15), 1) + viewer.world.setBlockStateId(new Vec3(-1, 95, 15), 1) + viewer.world.setBlockStateId(new Vec3(15, 95, -1), 1) + viewer.world.setBlockStateId(new Vec3(-1, 95, 0), 1) + setTimeout(() => { + viewer.world.setBlockStateId(new Vec3(16, 96, 16), 1) + viewer.world.setBlockStateId(new Vec3(-1, 96, 16), 1) + viewer.world.setBlockStateId(new Vec3(16, 96, -1), 1) + viewer.world.setBlockStateId(new Vec3(-1, 96, -1), 1) + }, 3000) + } + setupTimer () { + // this.stopRender = true + + let lastTime = 0 + const tick = () => { + viewer.world.debugStopGeometryUpdate = this.params.stopGeometryUpdate + const updateEach = 1000 / this.params.testUpdatesPerSecond + requestAnimationFrame(tick) + if (!this.params.testActive) return + const updateCount = Math.floor(performance.now() - lastTime) / updateEach + for (let i = 0; i < updateCount; i++) { + this.updateBlock() + } + lastTime = performance.now() + } + + requestAnimationFrame(tick) + // const limit = 1000 // const limit = 100 - const limit = 1 - const updatedChunks = new Set() - const updatedBlocks = new Set() - let lastSecond = 0 - setInterval(() => { - const second = Math.floor(performance.now() / 1000) - if (lastSecond !== second) { - lastSecond = second - updatedChunks.clear() - updatedBlocks.clear() - } - const isEven = second % 2 === 0 - if (updatedBlocks.size > limit) { - return - } - const changeBlock = (x, z) => { - const chunkKey = `${Math.floor(x / 16)},${Math.floor(z / 16)}` - const key = `${x},${z}` - if (updatedBlocks.has(chunkKey)) return + // const limit = 1 + // const updatedChunks = new Set() + // const updatedBlocks = new Set() + // let lastSecond = 0 + // setInterval(() => { + // const second = Math.floor(performance.now() / 1000) + // if (lastSecond !== second) { + // lastSecond = second + // updatedChunks.clear() + // updatedBlocks.clear() + // } + // const isEven = second % 2 === 0 + // if (updatedBlocks.size > limit) { + // return + // } + // const changeBlock = (x, z) => { + // const chunkKey = `${Math.floor(x / 16)},${Math.floor(z / 16)}` + // const key = `${x},${z}` + // if (updatedBlocks.has(chunkKey)) return - updatedChunks.add(chunkKey) - worldView!.world.setBlock(this.targetPos.offset(x, 0, z), this.Block.fromStateId(isEven ? 2 : 3, 0)) - updatedBlocks.add(key) - } - const { squareSize } = this.params - const xStart = -squareSize - const zStart = -squareSize - const xEnd = squareSize - const zEnd = squareSize - for (let x = xStart; x <= xEnd; x += 16) { - for (let z = zStart; z <= zEnd; z += 16) { - const key = `${x},${z}` - if (updatedChunks.has(key)) continue - changeBlock(x, z) - return - } - } - // for (let x = xStart; x <= xEnd; x += 16) { - // for (let z = zStart; z <= zEnd; z += 16) { - // const key = `${x},${z}` - // if (updatedChunks.has(key)) continue - // changeBlock(x, z) - // return - // } - // } - }, 1) + // updatedChunks.add(chunkKey) + // worldView!.world.setBlock(this.targetPos.offset(x, 0, z), this.Block.fromStateId(isEven ? 2 : 3, 0)) + // updatedBlocks.add(key) + // } + // const { squareSize } = this.params + // const xStart = -squareSize + // const zStart = -squareSize + // const xEnd = squareSize + // const zEnd = squareSize + // for (let x = xStart; x <= xEnd; x += 16) { + // for (let z = zStart; z <= zEnd; z += 16) { + // const key = `${x},${z}` + // if (updatedChunks.has(key)) continue + // changeBlock(x, z) + // return + // } + // } + // for (let x = xStart; x <= xEnd; x += 16) { + // for (let z = zStart; z <= zEnd; z += 16) { + // const key = `${x},${z}` + // if (updatedChunks.has(key)) continue + // changeBlock(x, z) + // return + // } + // } + // }, 1) } setupWorld () { - this.params.squareSize ??= 30 - const { squareSize } = this.params - const maxSquareSize = this.viewDistance * 16 * 2 - if (squareSize > maxSquareSize) throw new Error(`Square size too big, max is ${maxSquareSize}`) + this.worldConfig.showChunkBorders = true + + const maxSquareRadius = this.viewDistance * 16 // const fullBlocks = loadedData.blocksArray.map(x => x.name) + const squareSize = maxSquareRadius for (let x = -squareSize; x <= squareSize; x++) { for (let z = -squareSize; z <= squareSize; z++) { const i = Math.abs(x + z) * squareSize @@ -81,5 +138,10 @@ export default class extends BasePlaygroundScene { done = true this.setupTimer() }) + setTimeout(() => { + if (this.params.testInitialUpdate) { + this.updateBlock() + } + }) } } diff --git a/prismarine-viewer/viewer/lib/viewer.ts b/prismarine-viewer/viewer/lib/viewer.ts index 20f10284e..c90bc811f 100644 --- a/prismarine-viewer/viewer/lib/viewer.ts +++ b/prismarine-viewer/viewer/lib/viewer.ts @@ -107,7 +107,11 @@ export class Viewer { const sectionX = Math.floor(pos.x / 16) * 16 const sectionZ = Math.floor(pos.z / 16) * 16 if (this.world.queuedChunks.has(`${sectionX},${sectionZ}`)) { - await this.world.waitForChunkToLoad(pos) + await new Promise(resolve => { + this.world.queuedFunctions.push(() => { + resolve() + }) + }) } if (!this.world.loadedChunks[`${sectionX},${sectionZ}`]) { console.debug('[should be unreachable] setBlockStateId called for unloaded chunk', pos) @@ -222,6 +226,10 @@ export class Viewer { this.world.queuedChunks.delete(`${args[0]},${args[1]}`) this.addColumn(...args as Parameters) } + for (const fn of this.world.queuedFunctions) { + fn() + } + this.world.queuedFunctions = [] currentLoadChunkBatch = null }, this.addChunksBatchWaitTime) } diff --git a/prismarine-viewer/viewer/lib/worldrendererCommon.ts b/prismarine-viewer/viewer/lib/worldrendererCommon.ts index ec649a14f..4dc1ff90d 100644 --- a/prismarine-viewer/viewer/lib/worldrendererCommon.ts +++ b/prismarine-viewer/viewer/lib/worldrendererCommon.ts @@ -30,6 +30,7 @@ export const worldCleanup = buildCleanupDecorator('resetWorld') export const defaultWorldRendererConfig = { showChunkBorders: false, numWorkers: 4, + isPlayground: false, // game renderer setting actually displayHand: false } @@ -47,7 +48,6 @@ export abstract class WorldRendererCommon threejsCursorLineMaterial: LineMaterial @worldCleanup() cursorBlock = null as Vec3 | null - isPlayground = false displayStats = true @worldCleanup() worldConfig = { minY: 0, worldHeight: 256 } @@ -58,18 +58,24 @@ export abstract class WorldRendererCommon active = false version = undefined as string | undefined + // #region CHUNK & SECTIONS TRACKING @worldCleanup() loadedChunks = {} as Record // data is added for these chunks and they might be still processing @worldCleanup() finishedChunks = {} as Record // these chunks are fully loaded into the world (scene) + @worldCleanup() + finishedSections = {} as Record // these sections are fully loaded into the world (scene) + @worldCleanup() // loading sections (chunks) sectionsWaiting = new Map() @worldCleanup() queuedChunks = new Set() + queuedFunctions = [] as Array<() => void> + // #endregion @worldCleanup() renderUpdateEmitter = new EventEmitter() as unknown as TypedEmitter<{ @@ -129,6 +135,7 @@ export abstract class WorldRendererCommon } neighborChunkUpdates = true lastChunkDistance = 0 + debugStopGeometryUpdate = false abstract outputFormat: 'threeJs' | 'webgpu' @@ -157,7 +164,9 @@ export abstract class WorldRendererCommon const worker: any = new Worker(src) const handleMessage = (data) => { if (!this.active) return - this.handleWorkerMessage(data) + if (data.type !== 'geometry' || !this.debugStopGeometryUpdate) { + this.handleWorkerMessage(data) + } if (data.type === 'geometry') { this.geometryReceiveCount[data.workerIndex] ??= 0 this.geometryReceiveCount[data.workerIndex]++ @@ -174,7 +183,10 @@ export abstract class WorldRendererCommon if (data.type === 'sectionFinished') { // on after load & unload section if (!this.sectionsWaiting.get(data.key)) throw new Error(`sectionFinished event for non-outstanding section ${data.key}`) this.sectionsWaiting.set(data.key, this.sectionsWaiting.get(data.key)! - 1) - if (this.sectionsWaiting.get(data.key) === 0) this.sectionsWaiting.delete(data.key) + if (this.sectionsWaiting.get(data.key) === 0) { + this.sectionsWaiting.delete(data.key) + this.finishedSections[data.key] = true + } const chunkCoords = data.key.split(',').map(Number) if (this.loadedChunks[`${chunkCoords[0]},${chunkCoords[2]}`]) { // ensure chunk data was added, not a neighbor chunk update @@ -215,6 +227,7 @@ export abstract class WorldRendererCommon if (allFinished) { this.allChunksLoaded?.() this.allChunksFinished = true + this.allLoadedIn ??= Date.now() - this.initialChunkLoadWasStartedIn! } } } @@ -300,14 +313,15 @@ export abstract class WorldRendererCommon } } - async updateTexturesData (resourcePackUpdate = false) { + async updateTexturesData (resourcePackUpdate = false, prioritizeBlockTextures?: string[]) { const blocksAssetsParser = new AtlasParser(this.blocksAtlases, blocksAtlasLatest, blocksAtlasLegacy) const itemsAssetsParser = new AtlasParser(this.itemsAtlases, itemsAtlasLatest, itemsAtlasLegacy) + const customBlockTextures = Object.keys(this.customTextures.blocks?.textures ?? {}).filter(x => x.includes('/')) const { atlas: blocksAtlas, canvas: blocksCanvas } = await blocksAssetsParser.makeNewAtlas(this.texturesVersion ?? this.version ?? 'latest', (textureName) => { const texture = this.customTextures?.blocks?.textures[textureName] if (!texture) return return texture - }, this.customTextures?.blocks?.tileSize) + }, /* this.customTextures?.blocks?.tileSize */undefined, prioritizeBlockTextures, customBlockTextures) const { atlas: itemsAtlas, canvas: itemsCanvas } = await itemsAssetsParser.makeNewAtlas(this.texturesVersion ?? this.version ?? 'latest', (textureName) => { const texture = this.customTextures?.items?.textures[textureName] if (!texture) return @@ -397,9 +411,15 @@ export abstract class WorldRendererCommon } delete this.finishedChunks[`${x},${z}`] this.allChunksFinished = Object.keys(this.finishedChunks).length === this.chunksLength + if (!this.allChunksFinished) { + this.allLoadedIn = undefined + this.initialChunkLoadWasStartedIn = undefined + } for (let y = this.worldConfig.minY; y < this.worldConfig.worldHeight; y += 16) { this.setSectionDirty(new Vec3(x, y, z), false) + delete this.finishedSections[`${x},${y},${z}`] } + // remove from highestBlocks const startX = Math.floor(x / 16) * 16 const startZ = Math.floor(z / 16) * 16 @@ -413,26 +433,54 @@ export abstract class WorldRendererCommon } setBlockStateId (pos: Vec3, stateId: number) { - const key = `${Math.floor(pos.x / 16) * 16},${Math.floor(pos.y / 16) * 16},${Math.floor(pos.z / 16) * 16}` - const useChangeWorker = !this.sectionsWaiting[key] + const needAoRecalculation = true for (const worker of this.workers) { worker.postMessage({ type: 'blockUpdate', pos, stateId }) } - this.setSectionDirty(pos, true, useChangeWorker) + this.setSectionDirty(pos, true, true) if (this.neighborChunkUpdates) { - if ((pos.x & 15) === 0) this.setSectionDirty(pos.offset(-16, 0, 0), true, useChangeWorker) - if ((pos.x & 15) === 15) this.setSectionDirty(pos.offset(16, 0, 0), true, useChangeWorker) - if ((pos.y & 15) === 0) this.setSectionDirty(pos.offset(0, -16, 0), true, useChangeWorker) - if ((pos.y & 15) === 15) this.setSectionDirty(pos.offset(0, 16, 0), true, useChangeWorker) - if ((pos.z & 15) === 0) this.setSectionDirty(pos.offset(0, 0, -16), true, useChangeWorker) - if ((pos.z & 15) === 15) this.setSectionDirty(pos.offset(0, 0, 16), true, useChangeWorker) + if ((pos.x & 15) === 0) this.setSectionDirty(pos.offset(-16, 0, 0), true, true) + if ((pos.x & 15) === 15) this.setSectionDirty(pos.offset(16, 0, 0), true, true) + if ((pos.y & 15) === 0) this.setSectionDirty(pos.offset(0, -16, 0), true, true) + if ((pos.y & 15) === 15) this.setSectionDirty(pos.offset(0, 16, 0), true, true) + if ((pos.z & 15) === 0) this.setSectionDirty(pos.offset(0, 0, -16), true, true) + if ((pos.z & 15) === 15) this.setSectionDirty(pos.offset(0, 0, 16), true, true) + + if (needAoRecalculation) { + // top view neighbors + if ((pos.x & 15) === 0 && (pos.z & 15) === 0) this.setSectionDirty(pos.offset(-16, 0, -16), true, true) + if ((pos.x & 15) === 15 && (pos.z & 15) === 0) this.setSectionDirty(pos.offset(16, 0, -16), true, true) + if ((pos.x & 15) === 0 && (pos.z & 15) === 15) this.setSectionDirty(pos.offset(-16, 0, 16), true, true) + if ((pos.x & 15) === 15 && (pos.z & 15) === 15) this.setSectionDirty(pos.offset(16, 0, 16), true, true) + + // side view neighbors (but ignore updates above) + // z view neighbors + if ((pos.x & 15) === 0 && (pos.y & 15) === 0) this.setSectionDirty(pos.offset(-16, -16, 0), true, true) + if ((pos.x & 15) === 15 && (pos.y & 15) === 0) this.setSectionDirty(pos.offset(16, -16, 0), true, true) + + // x view neighbors + if ((pos.z & 15) === 0 && (pos.y & 15) === 0) this.setSectionDirty(pos.offset(0, -16, -16), true, true) + if ((pos.z & 15) === 15 && (pos.y & 15) === 0) this.setSectionDirty(pos.offset(0, -16, 16), true, true) + + // x & z neighbors + if ((pos.y & 15) === 0 && (pos.x & 15) === 0 && (pos.z & 15) === 0) this.setSectionDirty(pos.offset(-16, -16, -16), true, true) + if ((pos.y & 15) === 0 && (pos.x & 15) === 15 && (pos.z & 15) === 0) this.setSectionDirty(pos.offset(16, -16, -16), true, true) + if ((pos.y & 15) === 0 && (pos.x & 15) === 0 && (pos.z & 15) === 15) this.setSectionDirty(pos.offset(-16, -16, 16), true, true) + if ((pos.y & 15) === 0 && (pos.x & 15) === 15 && (pos.z & 15) === 15) this.setSectionDirty(pos.offset(16, -16, 16), true, true) + } } } queueAwaited = false messagesQueue = {} as { [workerIndex: string]: any[] } - getWorkerNumber (pos: Vec3) { + getWorkerNumber (pos: Vec3, updateAction = false) { + if (updateAction) { + const key = `${Math.floor(pos.x / 16) * 16},${Math.floor(pos.y / 16) * 16},${Math.floor(pos.z / 16) * 16}` + const cantUseChangeWorker = this.sectionsWaiting.get(key) && !this.finishedSections[key] + if (!cantUseChangeWorker) return 0 + } + const hash = mod(Math.floor(pos.x / 16) + Math.floor(pos.y / 16) + Math.floor(pos.z / 16), this.workers.length - 1) return hash + 1 } @@ -448,7 +496,7 @@ export abstract class WorldRendererCommon // Dispatch sections to workers based on position // This guarantees uniformity accross workers and that a given section // is always dispatched to the same worker - const hash = useChangeWorker ? 0 : this.getWorkerNumber(pos) + const hash = this.getWorkerNumber(pos, useChangeWorker) this.sectionsWaiting.set(key, (this.sectionsWaiting.get(key) ?? 0) + 1) this.messagesQueue[hash] ??= [] this.messagesQueue[hash].push({ From 51c1346456102ef660ca7e107e04c03667d45b47 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 18 Dec 2024 09:20:59 +0300 Subject: [PATCH 15/65] print debug duration --- src/topRightStats.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/topRightStats.ts b/src/topRightStats.ts index fd717ef48..529aa9647 100644 --- a/src/topRightStats.ts +++ b/src/topRightStats.ts @@ -114,14 +114,16 @@ const updateStatsPerSecAvg = () => { window.statsPerSec = {} let statsPerSecCurrent = {} +let lastReset = performance.now() window.addStatPerSec = (name) => { statsPerSecCurrent[name] ??= 0 statsPerSecCurrent[name]++ } window.statsPerSecCurrent = statsPerSecCurrent setInterval(() => { - window.statsPerSec = statsPerSecCurrent + window.statsPerSec = { duration: Math.floor(performance.now() - lastReset), ...statsPerSecCurrent, } statsPerSecCurrent = {} window.statsPerSecCurrent = statsPerSecCurrent updateStatsPerSecAvg() + lastReset = performance.now() }, 1000) From 2c0b99ffdbd6f6edcff84bdb6d0176f2b41efc4e Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 18 Dec 2024 10:23:58 +0300 Subject: [PATCH 16/65] fix wrong cache hit on local dev --- src/connect.ts | 24 ++++++++++++++++++++++++ src/index.ts | 19 ++----------------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/connect.ts b/src/connect.ts index 40a476693..09ee15426 100644 --- a/src/connect.ts +++ b/src/connect.ts @@ -1,4 +1,9 @@ +import { versionsByMinecraftVersion } from 'minecraft-data' +import minecraftInitialDataJson from '../generated/minecraft-initial-data.json' import { AuthenticatedAccount } from './react/ServersListProvider' +import { setLoadingScreenStatus } from './utils' +import { downloadSoundsIfNeeded } from './soundSystem' +import { miscUiState } from './globalState' export type ConnectOptions = { server?: string @@ -17,3 +22,22 @@ export type ConnectOptions = { authenticatedAccount?: AuthenticatedAccount | true peerOptions?: any } + +export const downloadNeededDataOnConnect = async (version: string) => { + // todo expose cache + const initialDataVersion = Object.keys(minecraftInitialDataJson)[0]! + if (version === initialDataVersion) { + // ignore cache hit + versionsByMinecraftVersion.pc[initialDataVersion]!.dataVersion!++ + } + setLoadingScreenStatus(`Loading data for ${version}`) + if (!document.fonts.check('1em mojangles')) { + // todo instead re-render signs on load + await document.fonts.load('1em mojangles').catch(() => { + console.error('Failed to load font, signs wont be rendered correctly') + }) + } + await window._MC_DATA_RESOLVER.promise // ensure data is loaded + await downloadSoundsIfNeeded() + miscUiState.loadedDataVersion = version +} diff --git a/src/index.ts b/src/index.ts index 93bbe6b90..a1fa8e3b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -93,7 +93,7 @@ import { saveToBrowserMemory } from './react/PauseScreen' import { ViewerWrapper } from 'prismarine-viewer/viewer/lib/viewerWrapper' import './devReload' import './water' -import { ConnectOptions } from './connect' +import { ConnectOptions, downloadNeededDataOnConnect } from './connect' import { ref, subscribe } from 'valtio' import { signInMessageState } from './react/SignInMessageProvider' import { updateAuthenticatedAccountData, updateLoadedServerData } from './react/ServersListProvider' @@ -395,22 +395,7 @@ async function connect (connectOptions: ConnectOptions) { throw new Error('Microsoft authentication is only supported on 1.19.4 - 1.20.6 (at least for now)') } - // todo expose cache - const lastVersion = supportedVersions.at(-1) - if (version === lastVersion) { - // ignore cache hit - versionsByMinecraftVersion.pc[lastVersion]!['dataVersion']!++ - } - setLoadingScreenStatus(`Loading data for ${version}`) - if (!document.fonts.check('1em mojangles')) { - // todo instead re-render signs on load - await document.fonts.load('1em mojangles').catch(() => { - console.error('Failed to load font, signs wont be rendered correctly') - }) - } - await window._MC_DATA_RESOLVER.promise // ensure data is loaded - await downloadSoundsIfNeeded() - miscUiState.loadedDataVersion = version + await downloadNeededDataOnConnect(version) try { await resourcepackReload(version) } catch (err) { From 961cf01a0e3c5090fc752694e57d12e0dc72dab0 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 18 Dec 2024 11:01:50 +0300 Subject: [PATCH 17/65] allow inspectFn to be function eg for debugger statement --- src/devtools.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/devtools.ts b/src/devtools.ts index e87b6d333..9aec8b773 100644 --- a/src/devtools.ts +++ b/src/devtools.ts @@ -43,8 +43,8 @@ customEvents.on('gameLoaded', () => { }) }) -window.inspectPacket = (packetName, full = false) => { - const listener = (...args) => console.log('packet', packetName, full ? args : args[0]) +window.inspectPacket = (packetName, fullOrListener: boolean | ((...args) => void) = false) => { + const listener = typeof fullOrListener === 'function' ? fullOrListener : (...args) => console.log('packet', packetName, fullOrListener ? args : args[0]) const attach = () => { bot?._client.prependListener(packetName, listener) } From 10ee4c00aefaff663b534239ea839378b1378d6a Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 18 Dec 2024 12:53:27 +0300 Subject: [PATCH 18/65] feat: initial support for websocket (direct connection) servers. mcraft-fun-mineflayer plugin --- src/connect.ts | 1 + src/index.ts | 27 ++++++++++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/connect.ts b/src/connect.ts index 09ee15426..b7023880f 100644 --- a/src/connect.ts +++ b/src/connect.ts @@ -21,6 +21,7 @@ export type ConnectOptions = { /** If true, will show a UI to authenticate with a new account */ authenticatedAccount?: AuthenticatedAccount | true peerOptions?: any + viewerWsConnect?: string } export const downloadNeededDataOnConnect = async (version: string) => { diff --git a/src/index.ts b/src/index.ts index a1fa8e3b0..0e481cd8b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -103,6 +103,7 @@ import { mainMenuState } from './react/MainMenuRenderApp' import { ItemsRenderer } from 'mc-assets/dist/itemsRenderer' import './mobileShim' import { parseFormattedMessagePacket } from './botUtils' +import { getWsProtocolStream } from './viewerConnector' window.debug = debug window.THREE = THREE @@ -471,12 +472,20 @@ async function connect (connectOptions: ConnectOptions) { connectingServer: server.host }) : undefined + let clientDataStream + if (p2pMultiplayer) { + clientDataStream = await connectToPeer(connectOptions.peerId!, connectOptions.peerOptions) + } + if (connectOptions.viewerWsConnect) { + clientDataStream = await getWsProtocolStream(connectOptions.viewerWsConnect) + } + bot = mineflayer.createBot({ host: server.host, port: server.port ? +server.port : undefined, version: connectOptions.botVersion || false, - ...p2pMultiplayer ? { - stream: await connectToPeer(connectOptions.peerId!, connectOptions.peerOptions), + ...clientDataStream ? { + stream: clientDataStream, } : {}, ...singleplayer || p2pMultiplayer ? { keepAlive: false, @@ -559,12 +568,15 @@ async function connect (connectOptions: ConnectOptions) { return _supportFeature(feature) }) as typeof bot.supportFeature + bot.emit('inject_allowed') + bot._client.emit('connect') + } else if (connectOptions.viewerWsConnect) { bot.emit('inject_allowed') bot._client.emit('connect') } else { const setupConnectHandlers = () => { bot._client.socket.on('connect', () => { - console.log('WebSocket connection established') + console.log('Proxy WebSocket connection established') //@ts-expect-error bot._client.socket._ws.addEventListener('close', () => { console.log('WebSocket connection closed') @@ -1039,6 +1051,15 @@ downloadAndOpenFile().then((downloadAction) => { if (qs.get('serversList')) { showModal({ reactType: 'serversList' }) } + + const viewerWsConnect = qs.get('viewerConnect') + if (viewerWsConnect) { + void connect({ + username: `viewer-${Math.random().toString(36).slice(2, 10)}`, + botVersion: '1.21.1', + viewerWsConnect, + }) + } }, (err) => { console.error(err) alert(`Failed to download file: ${err}`) From 10e14bd675f7fdc6502d3d6a337f8c96a8bd0ec7 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 18 Dec 2024 12:53:54 +0300 Subject: [PATCH 19/65] add viewer connector impl --- src/viewerConnector.ts | 60 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/viewerConnector.ts diff --git a/src/viewerConnector.ts b/src/viewerConnector.ts new file mode 100644 index 000000000..5d4cc4faf --- /dev/null +++ b/src/viewerConnector.ts @@ -0,0 +1,60 @@ +import { EventEmitter } from 'events' +import { Duplex } from 'stream' +import states from 'minecraft-protocol/src/states' +import { createClient } from 'minecraft-protocol' + +class CustomDuplex extends Duplex { + constructor (options, public writeAction) { + super(options) + } + + override _read () {} + + override _write (chunk, encoding, callback) { + this.writeAction(chunk) + callback() + } +} + +export const getWsProtocolStream = async (url: string) => { + if (url.startsWith(':')) url = `ws://localhost${url}` + if (!url.startsWith('ws')) url = `ws://${url}` + const ws = new WebSocket(url) + await new Promise((resolve, reject) => { + ws.onopen = () => resolve() + ws.onerror = reject + ws.onclose = reject + }) + const clientDuplex = new CustomDuplex(undefined, data => { + // console.log('send', Buffer.from(data).toString('hex')) + ws.send(data) + }) + // todo use keep alive instead? + let lastMessageTime = performance.now() + ws.addEventListener('message', async (message) => { + let { data } = message + if (data instanceof Blob) { + data = await data.arrayBuffer() + } + clientDuplex.push(Buffer.from(data)) + lastMessageTime = performance.now() + }) + setInterval(() => { + if (performance.now() - lastMessageTime > 10_000) { + console.log('no packats received in 10s!') + clientDuplex.end() + } + }, 5000) + + ws.addEventListener('close', () => { + console.log('ws closed') + clientDuplex.end() + // bot.emit('end', 'Disconnected.') + }) + + ws.addEventListener('error', err => { + console.log('ws error', err) + }) + + return clientDuplex +} From c289283e7fb7899fca127802e5344aea463edd13 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 18 Dec 2024 12:57:43 +0300 Subject: [PATCH 20/65] meaningful errors --- src/viewerConnector.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/viewerConnector.ts b/src/viewerConnector.ts index 5d4cc4faf..f4984fea3 100644 --- a/src/viewerConnector.ts +++ b/src/viewerConnector.ts @@ -22,8 +22,8 @@ export const getWsProtocolStream = async (url: string) => { const ws = new WebSocket(url) await new Promise((resolve, reject) => { ws.onopen = () => resolve() - ws.onerror = reject - ws.onclose = reject + ws.onerror = (err) => reject(new Error(`Failed to connect to websocket ${url}`)) + ws.onclose = (ev) => reject(ev.reason) }) const clientDuplex = new CustomDuplex(undefined, data => { // console.log('send', Buffer.from(data).toString('hex')) From b13c8df46918a99a8ac5c1eb3b1c8aefbae96b89 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 18 Dec 2024 13:59:55 +0300 Subject: [PATCH 21/65] implement auto version configuration for viewer --- src/index.ts | 13 ++++++++---- src/viewerConnector.ts | 47 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0e481cd8b..36eda63cc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -103,7 +103,7 @@ import { mainMenuState } from './react/MainMenuRenderApp' import { ItemsRenderer } from 'mc-assets/dist/itemsRenderer' import './mobileShim' import { parseFormattedMessagePacket } from './botUtils' -import { getWsProtocolStream } from './viewerConnector' +import { getViewerVersionData, getWsProtocolStream } from './viewerConnector' window.debug = debug window.THREE = THREE @@ -377,7 +377,7 @@ async function connect (connectOptions: ConnectOptions) { signal: errorAbortController.signal }) - if (proxy) { + if (proxy && !connectOptions.viewerWsConnect) { console.log(`using proxy ${proxy.host}:${proxy.port || location.port}`) net['setProxy']({ hostname: proxy.host, port: proxy.port }) @@ -477,6 +477,12 @@ async function connect (connectOptions: ConnectOptions) { clientDataStream = await connectToPeer(connectOptions.peerId!, connectOptions.peerOptions) } if (connectOptions.viewerWsConnect) { + const { version, time } = await getViewerVersionData(connectOptions.viewerWsConnect) + console.log('Latency:', Date.now() - time, 'ms') + // const version = '1.21.1' + connectOptions.botVersion = version + await downloadMcData(version) + setLoadingScreenStatus(`Connecting to WebSocket server ${connectOptions.viewerWsConnect}`) clientDataStream = await getWsProtocolStream(connectOptions.viewerWsConnect) } @@ -571,7 +577,7 @@ async function connect (connectOptions: ConnectOptions) { bot.emit('inject_allowed') bot._client.emit('connect') } else if (connectOptions.viewerWsConnect) { - bot.emit('inject_allowed') + // bot.emit('inject_allowed') bot._client.emit('connect') } else { const setupConnectHandlers = () => { @@ -1056,7 +1062,6 @@ downloadAndOpenFile().then((downloadAction) => { if (viewerWsConnect) { void connect({ username: `viewer-${Math.random().toString(36).slice(2, 10)}`, - botVersion: '1.21.1', viewerWsConnect, }) } diff --git a/src/viewerConnector.ts b/src/viewerConnector.ts index f4984fea3..45d85bb02 100644 --- a/src/viewerConnector.ts +++ b/src/viewerConnector.ts @@ -16,15 +16,49 @@ class CustomDuplex extends Duplex { } } -export const getWsProtocolStream = async (url: string) => { +export const getViewerVersionData = async (url: string) => { + const ws = await openWebsocket(url) + ws.send('version') + return new Promise<{ + version: string + time: number, + clientIgnoredPackets?: string[] + }>((resolve, reject) => { + ws.addEventListener('message', async (message) => { + const { data } = message + const parsed = JSON.parse(data.toString()) + resolve(parsed) + ws.close() + // todo + customEvents.on('mineflayerBotCreated', () => { + const client = bot._client as any + const oldWrite = client.write.bind(client) + client.write = (...args) => { + const [name] = args + if (parsed?.clientIgnoredPackets?.includes(name)) { + return + } + oldWrite(...args) + } + }) + }) + }) +} + +const openWebsocket = async (url: string) => { if (url.startsWith(':')) url = `ws://localhost${url}` if (!url.startsWith('ws')) url = `ws://${url}` const ws = new WebSocket(url) await new Promise((resolve, reject) => { ws.onopen = () => resolve() - ws.onerror = (err) => reject(new Error(`Failed to connect to websocket ${url}`)) + ws.onerror = (err) => reject(new Error(`[websocket] Failed to connect to ${url}`)) ws.onclose = (ev) => reject(ev.reason) }) + return ws +} + +export const getWsProtocolStream = async (url: string) => { + const ws = await openWebsocket(url) const clientDuplex = new CustomDuplex(undefined, data => { // console.log('send', Buffer.from(data).toString('hex')) ws.send(data) @@ -40,10 +74,11 @@ export const getWsProtocolStream = async (url: string) => { lastMessageTime = performance.now() }) setInterval(() => { - if (performance.now() - lastMessageTime > 10_000) { - console.log('no packats received in 10s!') - clientDuplex.end() - } + // if (clientDuplex.destroyed) return + // if (performance.now() - lastMessageTime > 10_000) { + // console.log('no packats received in 10s!') + // clientDuplex.end() + // } }, 5000) ws.addEventListener('close', () => { From bf676cdf529f5a6c3a02ecce837ea0e54d2054d4 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 18 Dec 2024 14:12:09 +0300 Subject: [PATCH 22/65] up mc-assets --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- prismarine-viewer/viewer/lib/mesher/world.ts | 5 ++++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 4ed5a4494..15b27b15e 100644 --- a/package.json +++ b/package.json @@ -142,7 +142,7 @@ "http-browserify": "^1.7.0", "http-server": "^14.1.1", "https-browserify": "^1.0.0", - "mc-assets": "^0.2.23", + "mc-assets": "^0.2.25", "minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next", "mineflayer": "github:zardoy/mineflayer", "mineflayer-pathfinder": "^2.4.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a89eb6df..c5ad3115f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -346,8 +346,8 @@ importers: specifier: ^1.0.0 version: 1.0.0 mc-assets: - specifier: ^0.2.23 - version: 0.2.23 + specifier: ^0.2.25 + version: 0.2.25 minecraft-inventory-gui: specifier: github:zardoy/minecraft-inventory-gui#next version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/75e940a4cd50d89e0ba03db3733d5d704917a3c8(@types/react@18.2.20)(react@18.2.0) @@ -6582,8 +6582,8 @@ packages: peerDependencies: react: ^18.2.0 - mc-assets@0.2.23: - resolution: {integrity: sha512-sLbPhsSOYdW8nYllIyPZbVPnLu7V3bZTgIO4mI4nlG525q17NIbUNEjItHKtdi60u0vI6qLgHKjf0CoNRqa/Nw==} + mc-assets@0.2.25: + resolution: {integrity: sha512-MdtncPBC6kwIkYXsBsSEJGP+q2e+7Q4Wnb4j3FjS7gmafz50Vjp4E/S3MsM7H8R3FoDrjVIx6qR24l/rneW/Lw==} engines: {node: '>=18.0.0'} md5-file@4.0.0: @@ -17453,7 +17453,7 @@ snapshots: dependencies: react: 18.2.0 - mc-assets@0.2.23: {} + mc-assets@0.2.25: {} md5-file@4.0.0: {} diff --git a/prismarine-viewer/viewer/lib/mesher/world.ts b/prismarine-viewer/viewer/lib/mesher/world.ts index c69da7513..be7646816 100644 --- a/prismarine-viewer/viewer/lib/mesher/world.ts +++ b/prismarine-viewer/viewer/lib/mesher/world.ts @@ -6,6 +6,7 @@ import { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider' import moreBlockDataGeneratedJson from '../moreBlockDataGenerated.json' import legacyJson from '../../../../src/preflatMap.json' import { defaultMesherConfig } from './shared' +import { INVISIBLE_BLOCKS } from './worldConstants' const ignoreAoBlocks = Object.keys(moreBlockDataGeneratedJson.noOcclusions) @@ -178,7 +179,9 @@ export class World { properties: props, }, this.preflat)! // fixme! this is a hack (also need a setting for all versions) if (!block.models!.length) { - console.debug('[mesher] block to render not found', block.name, props) + if (block.name !== 'water' && block.name !== 'lava' && !INVISIBLE_BLOCKS.has(block.name)) { + console.debug('[mesher] block to render not found', block.name, props) + } block.models = null } } catch (err) { From db7a9b9dd2c3f2dd825df34697a530caf12671aa Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 18 Dec 2024 22:22:11 +0300 Subject: [PATCH 23/65] feat: add zoom keybinding (Key - C) --- src/controls.ts | 6 ++++++ src/globalState.ts | 1 + src/rendererUtils.ts | 3 ++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/controls.ts b/src/controls.ts index cb1c132bc..4a6d1f6ff 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -49,6 +49,7 @@ export const contro = new ControMax({ chat: [['KeyT', 'Enter']], command: ['Slash'], swapHands: ['KeyF'], + zoom: ['KeyC'], selectItem: ['KeyH'] // default will be removed }, ui: { @@ -282,6 +283,9 @@ const onTriggerOrReleased = (command: Command, pressed: boolean) => { case 'general.interactPlace': document.dispatchEvent(new MouseEvent(pressed ? 'mousedown' : 'mouseup', { button: 2 })) break + case 'general.zoom': + gameAdditionalState.isZooming = pressed + break } } } @@ -415,6 +419,8 @@ contro.on('trigger', ({ command }) => { case 'general.prevHotbarSlot': cycleHotbarSlot(-1) break + case 'general.zoom': + break } } diff --git a/src/globalState.ts b/src/globalState.ts index 90e3e359c..54c5fc356 100644 --- a/src/globalState.ts +++ b/src/globalState.ts @@ -154,6 +154,7 @@ export const gameAdditionalState = proxy({ isFlying: false, isSprinting: false, isSneaking: false, + isZooming: false, warps: [] as WorldWarp[] }) diff --git a/src/rendererUtils.ts b/src/rendererUtils.ts index 6b83fd011..0a43ee1b3 100644 --- a/src/rendererUtils.ts +++ b/src/rendererUtils.ts @@ -5,7 +5,7 @@ import { options } from './optionsStorage' export const watchFov = () => { const updateFov = () => { if (!bot) return - let fovSetting = options.fov + let fovSetting = gameAdditionalState.isZooming ? 30 : options.fov // todo check values and add transition if (bot.controlState.sprint && !bot.controlState.sneak) { fovSetting += 5 @@ -20,6 +20,7 @@ export const watchFov = () => { subscribeKey(options, 'fov', updateFov) subscribeKey(gameAdditionalState, 'isFlying', updateFov) subscribeKey(gameAdditionalState, 'isSprinting', updateFov) + subscribeKey(gameAdditionalState, 'isZooming', updateFov) subscribeKey(gameAdditionalState, 'isSneaking', () => { viewer.isSneaking = gameAdditionalState.isSneaking viewer.setFirstPersonCamera(bot.entity.position, bot.entity.yaw, bot.entity.pitch) From 16fe17edf50ffa32e3f9a3856cd59ae3cca04bfe Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 18 Dec 2024 22:49:17 +0300 Subject: [PATCH 24/65] fix lint --- prismarine-viewer/viewer/lib/mesher/models.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prismarine-viewer/viewer/lib/mesher/models.ts b/prismarine-viewer/viewer/lib/mesher/models.ts index 51a00a448..84c591a1a 100644 --- a/prismarine-viewer/viewer/lib/mesher/models.ts +++ b/prismarine-viewer/viewer/lib/mesher/models.ts @@ -123,7 +123,7 @@ const isCube = (block: Block) => { if (block.isCube) return true if (!block.models?.length || block.models.length !== 1) return false // all variants - return block.models[0].every(v => v.elements!.every(e => { + return block.models[0].every(v => v.elements.every(e => { return e.from[0] === 0 && e.from[1] === 0 && e.from[2] === 0 && e.to[0] === 16 && e.to[1] === 16 && e.to[2] === 16 })) } From cb82963b86cd3e1540c49903cd3b8b19922cadf7 Mon Sep 17 00:00:00 2001 From: Max Lee Date: Thu, 19 Dec 2024 13:24:39 +0100 Subject: [PATCH 25/65] feat: Armor Stand and armor model support (#242) Co-authored-by: Vitaly --- prismarine-viewer/viewer/lib/entities.ts | 226 ++++++++++++++++-- .../viewer/lib/entity/EntityMesh.js | 31 ++- .../viewer/lib/entity/armorModels.json | 158 ++++++++++++ .../viewer/lib/entity/armorModels.ts | 36 +++ .../viewer/lib/entity/entities.json | 39 +-- .../viewer/lib/entity/objModels.js | 1 + 6 files changed, 446 insertions(+), 45 deletions(-) create mode 100644 prismarine-viewer/viewer/lib/entity/armorModels.json create mode 100644 prismarine-viewer/viewer/lib/entity/armorModels.ts diff --git a/prismarine-viewer/viewer/lib/entities.ts b/prismarine-viewer/viewer/lib/entities.ts index 47a0303ef..efc80b5de 100644 --- a/prismarine-viewer/viewer/lib/entities.ts +++ b/prismarine-viewer/viewer/lib/entities.ts @@ -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 @@ -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)', @@ -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) { @@ -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) { @@ -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) @@ -517,6 +570,16 @@ 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) @@ -524,7 +587,7 @@ export class Entities extends EventEmitter { //@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 } @@ -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 = { @@ -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 } @@ -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() } @@ -645,3 +763,73 @@ function getSpecificEntityMetadata (name if (entity.name !== name) return return getGeneralEntitiesMetadata(entity) as any } + +function addArmorModel (entityMesh: THREE.Object3D, slotType: string, item: Item, layer = 1, overlay = false) { + 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() + } + } +} diff --git a/prismarine-viewer/viewer/lib/entity/EntityMesh.js b/prismarine-viewer/viewer/lib/entity/EntityMesh.js index 1350dcf0d..69dd95d65 100644 --- a/prismarine-viewer/viewer/lib/entity/EntityMesh.js +++ b/prismarine-viewer/viewer/lib/entity/EntityMesh.js @@ -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) { const cubeRotation = new THREE.Euler(0, 0, 0) if (cube.rotation) { cubeRotation.x = -cube.rotation[0] * Math.PI / 180 @@ -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) @@ -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 = { @@ -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++ diff --git a/prismarine-viewer/viewer/lib/entity/armorModels.json b/prismarine-viewer/viewer/lib/entity/armorModels.json new file mode 100644 index 000000000..3632bad19 --- /dev/null +++ b/prismarine-viewer/viewer/lib/entity/armorModels.json @@ -0,0 +1,158 @@ +{ + "head": { + "bones": [ + {"name": "armor", "pivot": [0, 12, 0]}, + { + "name": "head", + "parent": "armor", + "pivot": [0, 12, 0], + "cubes": [ + { + "origin": [-4, 23, -4], + "size": [8, 8, 8], + "uv": [0, 0], + "inflate": 1 + } + ] + } + ], + "visible_bounds_width": 1.5, + "visible_bounds_offset": [0, 0.5, 0], + "texturewidth": 64, + "textureheight": 32 + }, + "chest": { + "bones": [ + {"name": "armor", "pivot": [0, 12, 0]}, + { + "name": "body", + "parent": "armor", + "pivot": [0, 13, 0], + "cubes": [ + { + "origin": [-4, 12, -2], + "size": [8, 12, 4], + "uv": [16, 16], + "inflate": 1 + } + ] + }, + { + "name": "rightarm", + "parent": "armor", + "pivot": [5, 10, 0], + "cubes": [ + { + "origin": [4, 12, -2], + "size": [4, 12, 4], + "uv": [40, 16], + "inflate": 0.75 + } + ] + }, + { + "name": "leftarm", + "parent": "armor", + "pivot": [-5, 10, 0], + "cubes": [ + { + "origin": [-8, 12, -2], + "size": [4, 12, 4], + "uv": [40, 16], + "inflate": 0.75 + } + ], + "mirror": true + } + ], + "visible_bounds_width": 1.5, + "visible_bounds_offset": [0, 0.5, 0], + "texturewidth": 64, + "textureheight": 32 + }, + "legs": { + "bones": [ + {"name": "armor", "pivot": [0, 12, 0]}, + { + "name": "body", + "parent": "armor", + "pivot": [0, 13, 0], + "cubes": [ + { + "origin": [-4, 12, -2], + "size": [8, 12, 4], + "uv": [16, 16], + "inflate": 0.75 + } + ] + }, + { + "name": "rightleg", + "parent": "armor", + "pivot": [1.9, 1, 0], + "cubes": [ + { + "origin": [-0.1, 0, -2], + "size": [4, 12, 4], + "uv": [0, 16], + "inflate": 0.5 + } + ] + }, + { + "name": "leftleg", + "parent": "armor", + "pivot": [-1.9, 1, 0], + "cubes": [ + { + "origin": [-3.9, 0, -2], + "size": [4, 12, 4], + "uv": [0, 16], + "inflate": 0.5 + } + ], + "mirror": true + } + ], + "visible_bounds_width": 1.5, + "visible_bounds_offset": [0, 0.5, 0], + "texturewidth": 64, + "textureheight": 32 + }, + "feet": { + "bones": [ + {"name": "armor", "pivot": [0, 12, 0]}, + { + "name": "rightleg", + "parent": "armor", + "pivot": [1.9, 1, 0], + "cubes": [ + { + "origin": [-0.1, 0, -2], + "size": [4, 12, 4], + "uv": [0, 16], + "inflate": 0.75 + } + ] + }, + { + "name": "leftleg", + "parent": "armor", + "pivot": [-1.9, 1, 0], + "cubes": [ + { + "origin": [-3.9, 0, -2], + "size": [4, 12, 4], + "uv": [0, 16], + "inflate": 0.75 + } + ], + "mirror": true + } + ], + "visible_bounds_width": 1.5, + "visible_bounds_offset": [0, 0.5, 0], + "texturewidth": 64, + "textureheight": 32 + } +} \ No newline at end of file diff --git a/prismarine-viewer/viewer/lib/entity/armorModels.ts b/prismarine-viewer/viewer/lib/entity/armorModels.ts new file mode 100644 index 000000000..e1d3f87bb --- /dev/null +++ b/prismarine-viewer/viewer/lib/entity/armorModels.ts @@ -0,0 +1,36 @@ +/* + * prismarine-web-client - prismarine-web-client + * Copyright (C) 2024 Max Lee aka Phoenix616 (mail@moep.tv) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +// TODO: replace with load from resource pack +export { default as chainmailLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/chainmail_layer_1.png' +export { default as chainmailLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/chainmail_layer_2.png' +export { default as diamondLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/diamond_layer_1.png' +export { default as diamondLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/diamond_layer_2.png' +export { default as goldenLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/gold_layer_1.png' +export { default as goldenLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/gold_layer_2.png' +export { default as ironLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/iron_layer_1.png' +export { default as ironLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/iron_layer_2.png' +export { default as leatherLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/leather_layer_1.png' +export { default as leatherLayer1Overlay } from 'mc-assets/dist/other-textures/latest/models/armor/leather_layer_1_overlay.png' +export { default as leatherLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/leather_layer_2.png' +export { default as leatherLayer2Overlay } from 'mc-assets/dist/other-textures/latest/models/armor/leather_layer_2_overlay.png' +export { default as netheriteLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/netherite_layer_1.png' +export { default as netheriteLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/netherite_layer_2.png' +export { default as turtleLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/turtle_layer_1.png' + +export { default as armorModel } from './armorModels.json' diff --git a/prismarine-viewer/viewer/lib/entity/entities.json b/prismarine-viewer/viewer/lib/entity/entities.json index e084a5cd3..9824d4182 100644 --- a/prismarine-viewer/viewer/lib/entity/entities.json +++ b/prismarine-viewer/viewer/lib/entity/entities.json @@ -186,15 +186,16 @@ "bones": [ { "name": "baseplate", + "parent": "waist", "cubes": [ {"origin": [-6, 0, -6], "size": [12, 1, 12], "uv": [0, 32]} ] }, - {"name": "waist", "parent": "baseplate", "pivot": [0, 12, 0]}, + {"name": "waist", "pivot": [0, 12, 0]}, { "name": "body", "parent": "waist", - "pivot": [0, 24, 0], + "pivot": [0, 13, 0], "cubes": [ {"origin": [-6, 21, -1.5], "size": [12, 3, 3], "uv": [0, 26]}, {"origin": [-3, 14, -1], "size": [2, 7, 2], "uv": [16, 0]}, @@ -204,50 +205,50 @@ }, { "name": "head", - "parent": "body", - "pivot": [0, 24, 0], + "parent": "waist", + "pivot": [0, 12, 0], "cubes": [{"origin": [-1, 24, -1], "size": [2, 7, 2], "uv": [0, 0]}] }, { "name": "hat", "parent": "head", - "pivot": [0, 24, 0], + "pivot": [0, 12, 0], "cubes": [ {"origin": [-4, 24, -4], "size": [8, 8, 8], "uv": [32, 0]} ] }, { - "name": "leftarm", - "parent": "body", + "name": "rightarm", + "parent": "waist", "mirror": true, - "pivot": [5, 22, 0], + "pivot": [5, 10, 0], "cubes": [ {"origin": [5, 12, -1], "size": [2, 12, 2], "uv": [32, 16]} ] }, - {"name": "leftitem", "parent": "leftarm", "pivot": [6, 15, 1]}, + {"name": "rightitem", "parent": "leftarm", "pivot": [6, 15, 1]}, { - "name": "leftleg", - "parent": "body", + "name": "rightleg", + "parent": "waist", "mirror": true, - "pivot": [1.9, 12, 0], + "pivot": [1.9, 1, 0], "cubes": [ {"origin": [0.9, 1, -1], "size": [2, 11, 2], "uv": [40, 16]} ] }, { - "name": "rightarm", - "parent": "body", - "pivot": [-5, 22, 0], + "name": "leftarm", + "parent": "waist", + "pivot": [-5, 10, 0], "cubes": [ {"origin": [-7, 12, -1], "size": [2, 12, 2], "uv": [24, 0]} ] }, - {"name": "rightitem", "parent": "rightarm", "pivot": [-6, 15, 1]}, + {"name": "leftitem", "parent": "rightarm", "pivot": [-6, 15, 1]}, { - "name": "rightleg", - "parent": "body", - "pivot": [-1.9, 12, 0], + "name": "leftleg", + "parent": "waist", + "pivot": [-1.9, 1, 0], "cubes": [ {"origin": [-2.9, 1, -1], "size": [2, 11, 2], "uv": [8, 0]} ] diff --git a/prismarine-viewer/viewer/lib/entity/objModels.js b/prismarine-viewer/viewer/lib/entity/objModels.js index edff440b9..5af296067 100644 --- a/prismarine-viewer/viewer/lib/entity/objModels.js +++ b/prismarine-viewer/viewer/lib/entity/objModels.js @@ -1 +1,2 @@ export * as externalModels from './exportedModels' +export * as armorModels from './armorModels' From dd7c9c172ea38796078c7d6e1d9c1f236ba8f2be Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Thu, 19 Dec 2024 20:44:04 +0300 Subject: [PATCH 26/65] feat: add modal query parameter for page load fix: improve servers list UI for small width screens --- README.MD | 6 +++++- src/index.ts | 7 +++++++ src/react/Input.tsx | 7 +++++-- src/react/ServersList.tsx | 16 ++++++++++++---- src/react/Singleplayer.tsx | 10 ++++++++-- 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/README.MD b/README.MD index 6b8bdc97d..34f9f2720 100644 --- a/README.MD +++ b/README.MD @@ -133,6 +133,7 @@ There are some parameters you can set in the url to archive some specific behavi General: - **`?setting=:`** - Set and lock the setting on load. You can set multiple settings by separating them with `&` e.g. `?setting=autoParkour:true&setting=renderDistance:4` +- `?modal=` - Open specific modal on page load eg `keybindings`. Very useful on UI changes testing during dev. For path use `,` as separator. To get currently opened modal type this in the console: `activeModalStack.at(-1).reactType` Server specific: @@ -142,7 +143,6 @@ Server specific: - `?proxy=` - Set the proxy server address to use for the server - `?username=` - Set the username for the server - `?lockConnect=true` - Only works then `ip` parameter is set. Disables cancel/save buttons and all inputs in the connect screen already set as parameters. Useful for integrates iframes. -- `?reconnect=true` - Reconnect to the server on page reloads. Available in **dev mode only** and very useful on server testing. - `?serversList=` - `` can be a list of servers in the format `ip:version,ip` or a url to a json file with the same format (array) or a txt file with line-delimited list of server IPs. Single player specific: @@ -176,6 +176,10 @@ In this case you must use `?mapDirBaseUrl` to specify the base URL to fetch the - `?mapDirBaseUrl` - See above. +Only during development: + +- `?reconnect=true` - Reconnect to the server on page reloads. Very useful on server testing. + ### Notable Things that Power this Project diff --git a/src/index.ts b/src/index.ts index 36eda63cc..81e0d78d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1065,6 +1065,13 @@ downloadAndOpenFile().then((downloadAction) => { viewerWsConnect, }) } + + if (qs.get('modal')) { + const modals = qs.get('modal')!.split(',') + for (const modal of modals) { + showModal({ reactType: modal }) + } + } }, (err) => { console.error(err) alert(`Failed to download file: ${err}`) diff --git a/src/react/Input.tsx b/src/react/Input.tsx index 1da36cc3f..72baef53e 100644 --- a/src/react/Input.tsx +++ b/src/react/Input.tsx @@ -2,14 +2,17 @@ import React, { CSSProperties, useEffect, useRef, useState } from 'react' import { isMobile } from 'prismarine-viewer/viewer/lib/simpleUtils' import styles from './input.module.css' -interface Props extends React.ComponentProps<'input'> { +interface Props extends Omit, 'width'> { rootStyles?: React.CSSProperties autoFocus?: boolean inputRef?: React.RefObject validateInput?: (value: string) => CSSProperties | undefined + width?: number } -export default ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue, ...inputProps }: Props) => { +export default ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue, width, ...inputProps }: Props) => { + if (width) rootStyles = { ...rootStyles, width } + const ref = useRef(null!) const [validationStyle, setValidationStyle] = useState({}) const [value, setValue] = useState(defaultValue ?? '') diff --git a/src/react/ServersList.tsx b/src/react/ServersList.tsx index 57316d222..cc75e0370 100644 --- a/src/react/ServersList.tsx +++ b/src/react/ServersList.tsx @@ -2,9 +2,10 @@ import React from 'react' import Singleplayer from './Singleplayer' import Input from './Input' import Button from './Button' -import PixelartIcon from './PixelartIcon' +import PixelartIcon, { pixelartIcons } from './PixelartIcon' import Select from './Select' import { BaseServerInfo } from './AddServerOrConnect' +import { useIsSmallWidth } from './simpleHooks' interface Props extends React.ComponentProps { joinServer: (info: BaseServerInfo, additional: { @@ -52,6 +53,8 @@ export default ({ initialProxies, updateProxies: updateProxiesProp, joinServer, return styles } + const isSmallWidth = useIsSmallWidth() + return - +
} searchRowChildrenOverride={ @@ -110,14 +114,18 @@ export default ({ initialProxies, updateProxies: updateProxiesProp, joinServer, }} >
- Proxy: + {isSmallWidth + ? + : Proxy:} setUsername(value)} /> diff --git a/src/react/Singleplayer.tsx b/src/react/Singleplayer.tsx index 024f77640..e8fa8224c 100644 --- a/src/react/Singleplayer.tsx +++ b/src/react/Singleplayer.tsx @@ -11,6 +11,7 @@ import Input from './Input' import Button from './Button' import Tabs from './Tabs' import MessageFormattedString from './MessageFormattedString' +import { useIsSmallWidth } from './simpleHooks' export interface WorldProps { name: string @@ -146,6 +147,8 @@ export default ({ onRowSelect?.(name, index) setFocusedWorld(name) } + const isSmallWidth = useIsSmallWidth() + return