diff --git a/config.json b/config.json index 57b1c207..940fb738 100644 --- a/config.json +++ b/config.json @@ -16,6 +16,10 @@ { "ip": "wss://play2.mcraft.fun" }, + { + "ip": "wss://play-creative.mcraft.fun", + "description": "Might be available soon, stay tuned!" + }, { "ip": "kaboom.pw", "version": "1.20.3", diff --git a/config.mcraft-only.json b/config.mcraft-only.json index 51b6eee6..7d9a7b59 100644 --- a/config.mcraft-only.json +++ b/config.mcraft-only.json @@ -1,3 +1,4 @@ { - "alwaysReconnectButton": true + "alwaysReconnectButton": true, + "reportBugButtonWithReconnect": true } diff --git a/experiments/three.ts b/experiments/three.ts index 9b158dec..21142b5f 100644 --- a/experiments/three.ts +++ b/experiments/three.ts @@ -1,5 +1,4 @@ import * as THREE from 'three' -import { loadThreeJsTextureFromBitmap } from '../renderer/viewer/lib/utils/skins' // Create scene, camera and renderer const scene = new THREE.Scene() diff --git a/package.json b/package.json index a09b92a3..fe4adb16 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev-proxy": "node server.js", "start": "run-p dev-proxy dev-rsbuild watch-mesher", "start2": "run-p dev-rsbuild watch-mesher", + "start-metrics": "ENABLE_METRICS=true rsbuild dev", "build": "pnpm build-other-workers && rsbuild build", "build-analyze": "BUNDLE_ANALYZE=true rsbuild build && pnpm build-other-workers", "build-single-file": "SINGLE_FILE_BUILD=true rsbuild build", @@ -32,7 +33,8 @@ "run-all": "run-p start run-playground", "build-playground": "rsbuild build --config renderer/rsbuild.config.ts", "watch-playground": "rsbuild dev --config renderer/rsbuild.config.ts", - "update-git-deps": "tsx scripts/updateGitDeps.ts" + "update-git-deps": "tsx scripts/updateGitDeps.ts", + "request-data": "tsx scripts/requestData.ts" }, "keywords": [ "prismarine", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 291cec39..c37e0510 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,7 +136,7 @@ importers: version: 4.17.21 mcraft-fun-mineflayer: specifier: ^0.1.23 - version: 0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/2cd1e3da257a0c0415e9dc5524fb14bd5844f0d8(encoding@0.1.13)) + version: 0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/3daf1f4bdc6afad0dedd87b879875f3dbb7b0980(encoding@0.1.13)) minecraft-data: specifier: 3.92.0 version: 3.92.0 @@ -341,7 +341,7 @@ importers: version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/89c33d396f3fde4804c71f4be3c203ade1833b41(@types/react@18.3.18)(react@18.3.1) mineflayer: specifier: github:zardoy/mineflayer#gen-the-master - version: https://codeload.github.com/zardoy/mineflayer/tar.gz/2cd1e3da257a0c0415e9dc5524fb14bd5844f0d8(encoding@0.1.13) + version: https://codeload.github.com/zardoy/mineflayer/tar.gz/3daf1f4bdc6afad0dedd87b879875f3dbb7b0980(encoding@0.1.13) mineflayer-mouse: specifier: ^0.1.11 version: 0.1.11 @@ -6678,8 +6678,8 @@ packages: resolution: {integrity: sha512-GtW4hkijyZbSu5LKYYD89xZu+XY7OoP7IkrCnNEn6EdPm0+vr2THoJgFGKrlze9/81+T+P3E4qvJXNFiU/zeJg==} engines: {node: '>=22'} - mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/2cd1e3da257a0c0415e9dc5524fb14bd5844f0d8: - resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/2cd1e3da257a0c0415e9dc5524fb14bd5844f0d8} + mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/3daf1f4bdc6afad0dedd87b879875f3dbb7b0980: + resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/3daf1f4bdc6afad0dedd87b879875f3dbb7b0980} version: 4.30.0 engines: {node: '>=22'} @@ -16956,12 +16956,12 @@ snapshots: maxrects-packer: '@zardoy/maxrects-packer@2.7.4' zod: 3.24.2 - mcraft-fun-mineflayer@0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/2cd1e3da257a0c0415e9dc5524fb14bd5844f0d8(encoding@0.1.13)): + mcraft-fun-mineflayer@0.1.23(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/3daf1f4bdc6afad0dedd87b879875f3dbb7b0980(encoding@0.1.13)): dependencies: '@zardoy/flying-squid': 0.0.49(encoding@0.1.13) exit-hook: 2.2.1 minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/6c2204a813690ead420e2b8c7f0ef32ca357d176(patch_hash=a8726e6981ddc3486262d981d1e2030f379901c055ac9c4bf3036b4149e860e0)(encoding@0.1.13) - mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/2cd1e3da257a0c0415e9dc5524fb14bd5844f0d8(encoding@0.1.13) + mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/3daf1f4bdc6afad0dedd87b879875f3dbb7b0980(encoding@0.1.13) prismarine-item: 1.16.0 ws: 8.18.1 transitivePeerDependencies: @@ -17379,7 +17379,7 @@ snapshots: - encoding - supports-color - mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/2cd1e3da257a0c0415e9dc5524fb14bd5844f0d8(encoding@0.1.13): + mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/3daf1f4bdc6afad0dedd87b879875f3dbb7b0980(encoding@0.1.13): dependencies: '@nxg-org/mineflayer-physics-util': 1.8.10 minecraft-data: 3.92.0 diff --git a/renderer/viewer/lib/mesher/models.ts b/renderer/viewer/lib/mesher/models.ts index a69d3e9a..aca47e15 100644 --- a/renderer/viewer/lib/mesher/models.ts +++ b/renderer/viewer/lib/mesher/models.ts @@ -542,7 +542,6 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W heads: {}, signs: {}, // isFull: true, - highestBlocks: new Map(), hadErrors: false, blocksCount: 0 } @@ -552,12 +551,6 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W for (cursor.z = sz; cursor.z < sz + 16; cursor.z++) { for (cursor.x = sx; cursor.x < sx + 16; cursor.x++) { let block = world.getBlock(cursor, blockProvider, attr)! - if (!INVISIBLE_BLOCKS.has(block.name)) { - const highest = attr.highestBlocks.get(`${cursor.x},${cursor.z}`) - if (!highest || highest.y < cursor.y) { - attr.highestBlocks.set(`${cursor.x},${cursor.z}`, { y: cursor.y, stateId: block.stateId, biomeId: block.biome.id }) - } - } if (INVISIBLE_BLOCKS.has(block.name)) continue if ((block.name.includes('_sign') || block.name === 'sign') && !world.config.disableSignsMapsSupport) { const key = `${cursor.x},${cursor.y},${cursor.z}` diff --git a/renderer/viewer/lib/mesher/shared.ts b/renderer/viewer/lib/mesher/shared.ts index 53e0c534..230db6b9 100644 --- a/renderer/viewer/lib/mesher/shared.ts +++ b/renderer/viewer/lib/mesher/shared.ts @@ -42,7 +42,6 @@ export type MesherGeometryOutput = { heads: Record, signs: Record, // isFull: boolean - highestBlocks: Map hadErrors: boolean blocksCount: number customBlockModels?: CustomBlockModels diff --git a/renderer/viewer/lib/renderUtils.js b/renderer/viewer/lib/renderUtils.js deleted file mode 100644 index 14176561..00000000 --- a/renderer/viewer/lib/renderUtils.js +++ /dev/null @@ -1,11 +0,0 @@ -import { fromFormattedString } from '@xmcl/text-component' - -export const formattedStringToSimpleString = (str) => { - const result = fromFormattedString(str) - str = result.text - // todo recursive - for (const extra of result.extra) { - str += extra.text - } - return str -} diff --git a/renderer/viewer/lib/utils.ts b/renderer/viewer/lib/utils.ts index 5c9c00f1..f471aa9d 100644 --- a/renderer/viewer/lib/utils.ts +++ b/renderer/viewer/lib/utils.ts @@ -1,30 +1,3 @@ -import * as THREE from 'three' -import { loadThreeJsTextureFromUrl, loadThreeJsTextureFromUrlSync } from './utils/skins' - -let textureCache: Record = {} -let imagesPromises: Record> = {} - -export async function loadTexture (texture: string, cb: (texture: THREE.Texture) => void, onLoad?: () => void): Promise { - const cached = textureCache[texture] - if (!cached) { - const { promise, resolve } = Promise.withResolvers() - const t = loadThreeJsTextureFromUrlSync(texture) - textureCache[texture] = t.texture - void t.promise.then(resolve) - imagesPromises[texture] = promise - } - - cb(textureCache[texture]) - void imagesPromises[texture].then(() => { - onLoad?.() - }) -} - -export const clearTextureCache = () => { - textureCache = {} - imagesPromises = {} -} - export const loadScript = async function (scriptSrc: string, highPriority = true): Promise { const existingScript = document.querySelector(`script[src="${scriptSrc}"]`) if (existingScript) { @@ -52,3 +25,33 @@ export const loadScript = async function (scriptSrc: string, highPriority = true document.head.appendChild(scriptElement) }) } + +const detectFullOffscreenCanvasSupport = () => { + if (typeof OffscreenCanvas === 'undefined') return false + try { + const canvas = new OffscreenCanvas(1, 1) + // Try to get a WebGL context - this will fail on iOS where only 2D is supported (iOS 16) + const gl = canvas.getContext('webgl2') || canvas.getContext('webgl') + return gl !== null + } catch (e) { + return false + } +} + +const hasFullOffscreenCanvasSupport = detectFullOffscreenCanvasSupport() + +export const createCanvas = (width: number, height: number): OffscreenCanvas => { + if (hasFullOffscreenCanvasSupport) { + return new OffscreenCanvas(width, height) + } + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + return canvas as unknown as OffscreenCanvas // todo-low +} + +export async function loadImageFromUrl (imageUrl: string): Promise { + const response = await fetch(imageUrl) + const blob = await response.blob() + return createImageBitmap(blob) +} diff --git a/renderer/viewer/lib/utils/skins.ts b/renderer/viewer/lib/utils/skins.ts index 9aa97340..3163702c 100644 --- a/renderer/viewer/lib/utils/skins.ts +++ b/renderer/viewer/lib/utils/skins.ts @@ -1,68 +1,7 @@ import { loadSkinToCanvas } from 'skinview-utils' -import * as THREE from 'three' -import stevePng from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png' -import { getLoadedImage } from 'mc-assets/dist/utils' +import { createCanvas, loadImageFromUrl } from '../utils' -const detectFullOffscreenCanvasSupport = () => { - if (typeof OffscreenCanvas === 'undefined') return false - try { - const canvas = new OffscreenCanvas(1, 1) - // Try to get a WebGL context - this will fail on iOS where only 2D is supported (iOS 16) - const gl = canvas.getContext('webgl2') || canvas.getContext('webgl') - return gl !== null - } catch (e) { - return false - } -} - -const hasFullOffscreenCanvasSupport = detectFullOffscreenCanvasSupport() - -export const loadThreeJsTextureFromUrlSync = (imageUrl: string) => { - const texture = new THREE.Texture() - const promise = getLoadedImage(imageUrl).then(image => { - texture.image = image - texture.needsUpdate = true - return texture - }) - return { - texture, - promise - } -} - -export const createCanvas = (width: number, height: number): OffscreenCanvas => { - if (hasFullOffscreenCanvasSupport) { - return new OffscreenCanvas(width, height) - } - const canvas = document.createElement('canvas') - canvas.width = width - canvas.height = height - return canvas as unknown as OffscreenCanvas // todo-low -} - -export const loadThreeJsTextureFromUrl = async (imageUrl: string) => { - const loaded = new THREE.TextureLoader().loadAsync(imageUrl) - return loaded -} -export const loadThreeJsTextureFromBitmap = (image: ImageBitmap) => { - const canvas = createCanvas(image.width, image.height) - const ctx = canvas.getContext('2d')! - ctx.drawImage(image, 0, 0) - const texture = new THREE.Texture(canvas) - texture.magFilter = THREE.NearestFilter - texture.minFilter = THREE.NearestFilter - return texture -} - -export const stevePngUrl = stevePng -export const steveTexture = loadThreeJsTextureFromUrl(stevePngUrl) - - -export async function loadImageFromUrl (imageUrl: string): Promise { - const response = await fetch(imageUrl) - const blob = await response.blob() - return createImageBitmap(blob) -} +export { default as stevePngUrl } from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png' const config = { apiEnabled: true, diff --git a/renderer/viewer/lib/worldrendererCommon.ts b/renderer/viewer/lib/worldrendererCommon.ts index 7501b279..5c4fd6b5 100644 --- a/renderer/viewer/lib/worldrendererCommon.ts +++ b/renderer/viewer/lib/worldrendererCommon.ts @@ -123,7 +123,6 @@ export abstract class WorldRendererCommon handleResize = () => { } highestBlocksByChunks = new Map() - highestBlocksBySections = new Map() blockEntities = {} workersProcessAverageTime = 0 @@ -389,8 +388,6 @@ export abstract class WorldRendererCommon this.logWorkerWork(() => `-> ${data.workerIndex} geometry ${data.key} ${JSON.stringify({ dataSize: JSON.stringify(data).length })}`) this.geometryReceiveCount[data.workerIndex] ??= 0 this.geometryReceiveCount[data.workerIndex]++ - const { geometry } = data - this.highestBlocksBySections[data.key] = geometry.highestBlocks const chunkCoords = data.key.split(',').map(Number) this.lastChunkDistance = Math.max(...this.getDistance(new Vec3(chunkCoords[0], 0, chunkCoords[2]))) } @@ -688,7 +685,6 @@ export abstract class WorldRendererCommon for (let y = this.worldSizeParams.minY; y < this.worldSizeParams.worldHeight; y += 16) { this.setSectionDirty(new Vec3(x, y, z), false) delete this.finishedSections[`${x},${y},${z}`] - this.highestBlocksBySections.delete(`${x},${y},${z}`) } this.highestBlocksByChunks.delete(`${x},${z}`) diff --git a/renderer/viewer/sign-renderer/index.ts b/renderer/viewer/sign-renderer/index.ts index a1e4331f..f14b9b4c 100644 --- a/renderer/viewer/sign-renderer/index.ts +++ b/renderer/viewer/sign-renderer/index.ts @@ -1,5 +1,5 @@ -import { fromFormattedString, render, RenderNode, TextComponent } from '@xmcl/text-component' import type { ChatMessage } from 'prismarine-chat' +import { createCanvas } from '../lib/utils' type SignBlockEntity = { Color?: string @@ -32,29 +32,40 @@ const parseSafe = (text: string, task: string) => { } } -export const renderSign = (blockEntity: SignBlockEntity, PrismarineChat: typeof ChatMessage, ctxHook = (ctx) => { }) => { +const LEGACY_COLORS = { + black: '#000000', + dark_blue: '#0000AA', + dark_green: '#00AA00', + dark_aqua: '#00AAAA', + dark_red: '#AA0000', + dark_purple: '#AA00AA', + gold: '#FFAA00', + gray: '#AAAAAA', + dark_gray: '#555555', + blue: '#5555FF', + green: '#55FF55', + aqua: '#55FFFF', + red: '#FF5555', + light_purple: '#FF55FF', + yellow: '#FFFF55', + white: '#FFFFFF', +} + +export const renderSign = ( + blockEntity: SignBlockEntity, + isHanging: boolean, + PrismarineChat: typeof ChatMessage, + ctxHook = (ctx) => { }, + canvasCreator = (width, height): OffscreenCanvas => { return createCanvas(width, height) } +) => { // todo don't use texture rendering, investigate the font rendering when possible // or increase factor when needed const factor = 40 + const fontSize = 1.6 * factor const signboardY = [16, 9] const heightOffset = signboardY[0] - signboardY[1] const heightScalar = heightOffset / 16 - - let canvas: HTMLCanvasElement | undefined - let _ctx: CanvasRenderingContext2D | null = null - const getCtx = () => { - if (_ctx) return _ctx - canvas = document.createElement('canvas') - - canvas.width = 16 * factor - canvas.height = heightOffset * factor - - _ctx = canvas.getContext('2d')! - _ctx.imageSmoothingEnabled = false - - ctxHook(_ctx) - return _ctx - } + // todo the text should be clipped based on it's render width (needs investigate) const texts = 'front_text' in blockEntity ? /* > 1.20 */ blockEntity.front_text.messages : [ blockEntity.Text1, @@ -62,78 +73,144 @@ export const renderSign = (blockEntity: SignBlockEntity, PrismarineChat: typeof blockEntity.Text3, blockEntity.Text4 ] + + if (!texts.some((text) => text !== 'null')) { + return undefined + } + + const canvas = canvasCreator(16 * factor, heightOffset * factor) + + const _ctx = canvas.getContext('2d')! + + ctxHook(_ctx) const defaultColor = ('front_text' in blockEntity ? blockEntity.front_text.color : blockEntity.Color) || 'black' for (const [lineNum, text] of texts.slice(0, 4).entries()) { - // todo: in pre flatenning it seems the format was not json if (text === 'null') continue - const parsed = text?.startsWith('{') || text?.startsWith('"') ? parseSafe(text ?? '""', 'sign text') : text - if (!parsed || (typeof parsed !== 'object' && typeof parsed !== 'string')) continue - // todo fix type - const message = typeof parsed === 'string' ? fromFormattedString(parsed) : new PrismarineChat(parsed) as never - const patchExtra = ({ extra }: TextComponent) => { - if (!extra) return - for (const child of extra) { - if (child.color) { - child.color = child.color === 'dark_green' ? child.color.toUpperCase() : child.color.toLowerCase() - } - patchExtra(child) - } - } - patchExtra(message) - const rendered = render(message) - - const toRenderCanvas: Array<{ - fontStyle: string - fillStyle: string - underlineStyle: boolean - strikeStyle: boolean - text: string - }> = [] - let plainText = '' - // todo the text should be clipped based on it's render width (needs investigate) - const MAX_LENGTH = 50 // avoid abusing the signboard - const renderText = (node: RenderNode) => { - const { component } = node - let { text } = component - if (plainText.length + text.length > MAX_LENGTH) { - text = text.slice(0, MAX_LENGTH - plainText.length) - if (!text) return false - } - plainText += text - toRenderCanvas.push({ - fontStyle: `${component.bold ? 'bold' : ''} ${component.italic ? 'italic' : ''}`, - fillStyle: node.style['color'] || defaultColor, - underlineStyle: component.underlined ?? false, - strikeStyle: component.strikethrough ?? false, - text - }) - for (const child of node.children) { - const stop = renderText(child) === false - if (stop) return false - } - } - - renderText(rendered) - - // skip rendering empty lines (and possible signs) - if (!plainText.trim()) continue - - const ctx = getCtx() - const fontSize = 1.6 * factor - ctx.font = `${fontSize}px mojangles` - const textWidth = ctx.measureText(plainText).width - - let renderedWidth = 0 - for (const { fillStyle, fontStyle, strikeStyle, text, underlineStyle } of toRenderCanvas) { - // todo strikeStyle, underlineStyle - ctx.fillStyle = fillStyle - ctx.font = `${fontStyle} ${fontSize}px mojangles` - ctx.fillText(text, (canvas!.width - textWidth) / 2 + renderedWidth, fontSize * (lineNum + 1)) - renderedWidth += ctx.measureText(text).width // todo isn't the font is monospace? - } + renderComponent(text, PrismarineChat, canvas, fontSize, defaultColor, fontSize * (lineNum + 1) + (isHanging ? 0 : -8)) } - // ctx.fillStyle = 'red' - // ctx.fillRect(0, 0, canvas.width, canvas.height) - return canvas } + +export const renderComponent = ( + text: JsonEncodedType | string | undefined, + PrismarineChat: typeof ChatMessage, + canvas: OffscreenCanvas, + fontSize: number, + defaultColor: string, + offset = 0 +) => { + // todo: in pre flatenning it seems the format was not json + const parsed = typeof text === 'string' && (text?.startsWith('{') || text?.startsWith('"')) ? parseSafe(text ?? '""', 'sign text') : text + if (!parsed || (typeof parsed !== 'object' && typeof parsed !== 'string')) return + // todo fix type + + const ctx = canvas.getContext('2d')! + if (!ctx) throw new Error('Could not get 2d context') + ctx.imageSmoothingEnabled = false + ctx.font = `${fontSize}px mojangles` + + type Formatting = { + color: string | undefined + underlined: boolean | undefined + strikethrough: boolean | undefined + bold: boolean | undefined + italic: boolean | undefined + } + + type Message = ChatMessage & Formatting & { text: string } + + const message = new PrismarineChat(parsed) as Message + + const toRenderCanvas: Array<{ + fontStyle: string + fillStyle: string + underlineStyle: boolean + strikeStyle: boolean + offset: number + text: string + }> = [] + let visibleFormatting = false + let plainText = '' + let textOffset = offset + const textWidths: number[] = [] + + const renderText = (component: Message, parentFormatting?: Formatting | undefined) => { + const { text } = component + const formatting = { + color: component.color ?? parentFormatting?.color, + underlined: component.underlined ?? parentFormatting?.underlined, + strikethrough: component.strikethrough ?? parentFormatting?.strikethrough, + bold: component.bold ?? parentFormatting?.bold, + italic: component.italic ?? parentFormatting?.italic + } + visibleFormatting = visibleFormatting || formatting.underlined || formatting.strikethrough || false + if (text?.includes('\n')) { + for (const line of text.split('\n')) { + addTextPart(line, formatting) + textOffset += fontSize + plainText = '' + } + } else if (text) { + addTextPart(text, formatting) + } + if (component.extra) { + for (const child of component.extra) { + renderText(child as Message, formatting) + } + } + } + + const addTextPart = (text: string, formatting: Formatting) => { + plainText += text + textWidths[textOffset] = ctx.measureText(plainText).width + let color = formatting.color ?? defaultColor + if (!color.startsWith('#')) { + color = LEGACY_COLORS[color.toLowerCase()] || color + } + toRenderCanvas.push({ + fontStyle: `${formatting.bold ? 'bold' : ''} ${formatting.italic ? 'italic' : ''}`, + fillStyle: color, + underlineStyle: formatting.underlined ?? false, + strikeStyle: formatting.strikethrough ?? false, + offset: textOffset, + text + }) + } + + renderText(message) + + // skip rendering empty lines + if (!visibleFormatting && !message.toString().trim()) return + + let renderedWidth = 0 + let previousOffsetY = 0 + for (const { fillStyle, fontStyle, underlineStyle, strikeStyle, offset: offsetY, text } of toRenderCanvas) { + if (previousOffsetY !== offsetY) { + renderedWidth = 0 + } + previousOffsetY = offsetY + ctx.fillStyle = fillStyle + ctx.textRendering = 'optimizeLegibility' + ctx.font = `${fontStyle} ${fontSize}px mojangles` + const textWidth = textWidths[offsetY] ?? ctx.measureText(text).width + const offsetX = (canvas.width - textWidth) / 2 + renderedWidth + ctx.fillText(text, offsetX, offsetY) + if (strikeStyle) { + ctx.lineWidth = fontSize / 8 + ctx.strokeStyle = fillStyle + ctx.beginPath() + ctx.moveTo(offsetX, offsetY - ctx.lineWidth * 2.5) + ctx.lineTo(offsetX + ctx.measureText(text).width, offsetY - ctx.lineWidth * 2.5) + ctx.stroke() + } + if (underlineStyle) { + ctx.lineWidth = fontSize / 8 + ctx.strokeStyle = fillStyle + ctx.beginPath() + ctx.moveTo(offsetX, offsetY + ctx.lineWidth) + ctx.lineTo(offsetX + ctx.measureText(text).width, offsetY + ctx.lineWidth) + ctx.stroke() + } + renderedWidth += ctx.measureText(text).width + } +} diff --git a/renderer/viewer/sign-renderer/playground.ts b/renderer/viewer/sign-renderer/playground.ts index 92ff5d03..a7438092 100644 --- a/renderer/viewer/sign-renderer/playground.ts +++ b/renderer/viewer/sign-renderer/playground.ts @@ -21,9 +21,14 @@ const blockEntity = { await document.fonts.load('1em mojangles') -const canvas = renderSign(blockEntity, PrismarineChat, (ctx) => { +const canvas = renderSign(blockEntity, false, PrismarineChat, (ctx) => { ctx.drawImage(img, 0, 0, ctx.canvas.width, ctx.canvas.height) -}) +}, (width, height) => { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + return canvas as unknown as OffscreenCanvas +}) as unknown as HTMLCanvasElement if (canvas) { canvas.style.imageRendering = 'pixelated' diff --git a/renderer/viewer/sign-renderer/tests.test.ts b/renderer/viewer/sign-renderer/tests.test.ts index b8fc94fc..ab268849 100644 --- a/renderer/viewer/sign-renderer/tests.test.ts +++ b/renderer/viewer/sign-renderer/tests.test.ts @@ -22,7 +22,7 @@ global.document = { const render = (entity) => { ctxTexts = [] - renderSign(entity, PrismarineChat) + renderSign(entity, true, PrismarineChat) return ctxTexts.map(({ text, y }) => [y / 64, text]) } @@ -37,10 +37,6 @@ test('sign renderer', () => { } as any expect(render(blockEntity)).toMatchInlineSnapshot(` [ - [ - 1, - "", - ], [ 1, "Minecraft ", diff --git a/renderer/viewer/three/entities.ts b/renderer/viewer/three/entities.ts index 414b0279..51292d2f 100644 --- a/renderer/viewer/three/entities.ts +++ b/renderer/viewer/three/entities.ts @@ -1,11 +1,10 @@ //@ts-check -import EventEmitter from 'events' import { UnionToIntersection } from 'type-fest' import nbt from 'prismarine-nbt' import * as TWEEN from '@tweenjs/tween.js' import * as THREE from 'three' -import { PlayerObject, PlayerAnimation } from 'skinview3d' -import { loadSkinToCanvas, loadEarsToCanvasFromSkin, inferModelType, loadCapeToCanvas, loadImage } from 'skinview-utils' +import { PlayerAnimation, PlayerObject } from 'skinview3d' +import { inferModelType, loadCapeToCanvas, loadEarsToCanvasFromSkin } from 'skinview-utils' // todo replace with url import { degreesToRadians } from '@nxg-org/mineflayer-tracker/lib/mathUtils' import { NameTagObject } from 'skinview3d/libs/nametag' @@ -13,22 +12,24 @@ import { flat, fromFormattedString } from '@xmcl/text-component' import mojangson from 'mojangson' import { snakeCase } from 'change-case' import { Item } from 'prismarine-item' -import { BlockModel } from 'mc-assets' import { isEntityAttackable } from 'mineflayer-mouse/dist/attackableEntity' -import { Vec3 } from 'vec3' import { Team } from 'mineflayer' +import PrismarineChatLoader from 'prismarine-chat' import { EntityMetadataVersions } from '../../../src/mcDataTypes' import { ItemSpecificContextProperties } from '../lib/basePlayerState' -import { loadSkinImage, loadSkinFromUsername, stevePngUrl, steveTexture, createCanvas } from '../lib/utils/skins' -import { loadTexture } from '../lib/utils' +import { loadSkinFromUsername, loadSkinImage, stevePngUrl } from '../lib/utils/skins' +import { renderComponent } from '../sign-renderer' +import { createCanvas } from '../lib/utils' import { getBlockMeshFromModel } from './holdingBlock' import * as Entity from './entity/EntityMesh' import { getMesh } from './entity/EntityMesh' import { WalkingGeneralSwing } from './entity/animations' -import { disposeObject } from './threeJsUtils' -import { armorModel, elytraTexture, armorTextures } from './entity/armorModels' +import { disposeObject, loadTexture, loadThreeJsTextureFromUrl } from './threeJsUtils' +import { armorModel, armorTextures, elytraTexture } from './entity/armorModels' import { WorldRendererThree } from './worldrendererThree' +export const steveTexture = loadThreeJsTextureFromUrl(stevePngUrl) + export const TWEEN_DURATION = 120 type PlayerObjectType = PlayerObject & { @@ -96,8 +97,11 @@ function getUsernameTexture ({ username, nameTagBackgroundColor = 'rgba(0, 0, 0, 0.3)', nameTagTextOpacity = 255 -}: any, { fontFamily = 'sans-serif' }: any) { +}: any, { fontFamily = 'mojangles' }: any, version: string) { const canvas = createCanvas(64, 64) + + const PrismarineChat = PrismarineChatLoader(version) + const ctx = canvas.getContext('2d') if (!ctx) throw new Error('Could not get 2d context') @@ -105,35 +109,36 @@ function getUsernameTexture ({ const padding = 5 ctx.font = `${fontSize}px ${fontFamily}` - const lines = String(username).split('\n') - + const plainLines = String(typeof username === 'string' ? username : new PrismarineChat(username).toString()).split('\n') let textWidth = 0 - for (const line of lines) { + for (const line of plainLines) { const width = ctx.measureText(line).width + padding * 2 if (width > textWidth) textWidth = width } canvas.width = textWidth - canvas.height = (fontSize + padding) * lines.length + canvas.height = (fontSize + padding) * plainLines.length ctx.fillStyle = nameTagBackgroundColor ctx.fillRect(0, 0, canvas.width, canvas.height) - ctx.font = `${fontSize}px ${fontFamily}` - ctx.fillStyle = `rgba(255, 255, 255, ${nameTagTextOpacity / 255})` - let i = 0 - for (const line of lines) { - i++ - ctx.fillText(line, (textWidth - ctx.measureText(line).width) / 2, -padding + fontSize * i) - } + ctx.globalAlpha = nameTagTextOpacity / 255 + + renderComponent(username, PrismarineChat, canvas, fontSize, 'white', -padding + fontSize) + + ctx.globalAlpha = 1 return canvas } -const addNametag = (entity, options, mesh) => { +const addNametag = (entity, options: { fontFamily: string }, mesh, version: string) => { + for (const c of mesh.children) { + if (c.name === 'nametag') { + c.removeFromParent() + } + } if (entity.username !== undefined) { - if (mesh.children.some(c => c.name === 'nametag')) return // todo update - const canvas = getUsernameTexture(entity, options) + const canvas = getUsernameTexture(entity, options, version) const tex = new THREE.Texture(canvas) tex.needsUpdate = true let nameTag @@ -174,7 +179,7 @@ const nametags = {} const isFirstUpperCase = (str) => str.charAt(0) === str.charAt(0).toUpperCase() -function getEntityMesh (entity: import('prismarine-entity').Entity & { delete?: any; pos?: any; name?: any }, world: WorldRendererThree | undefined, options: { fontFamily: string }, overrides) { +function getEntityMesh (entity: import('prismarine-entity').Entity & { delete?: any; pos?: any; name?: any }, world: WorldRendererThree, options: { fontFamily: string }, overrides) { if (entity.name) { try { // https://github.com/PrismarineJS/prismarine-viewer/pull/410 @@ -182,7 +187,7 @@ function getEntityMesh (entity: import('prismarine-entity').Entity & { delete?: const e = new Entity.EntityMesh('1.16.4', entityName, world, overrides) if (e.mesh) { - addNametag(entity, options, e.mesh) + addNametag(entity, options, e.mesh, world.version) return e.mesh } } catch (err) { @@ -200,7 +205,7 @@ function getEntityMesh (entity: import('prismarine-entity').Entity & { delete?: addNametag({ username: entity.name, height: entity.height, - }, options, cube) + }, options, cube, world.version) } return cube } @@ -486,6 +491,7 @@ export class Entities { .some(channel => channel !== 0) } + // todo true/undefined doesnt reset the skin to the default one // eslint-disable-next-line max-params async updatePlayerSkin (entityId: string | number, username: string | undefined, uuidCache: string | undefined, skinUrl: string | true, capeUrl: string | true | undefined = undefined) { if (uuidCache) { @@ -966,21 +972,22 @@ export class Entities { // entity specific meta const textDisplayMeta = getSpecificEntityMetadata('text_display', entity) const displayTextRaw = textDisplayMeta?.text || meta.custom_name_visible && meta.custom_name - const displayText = this.parseEntityLabel(displayTextRaw) - if (entity.name !== 'player' && displayText) { + if (entity.name !== 'player' && displayTextRaw) { const nameTagFixed = textDisplayMeta && (textDisplayMeta.billboard_render_constraints === 'fixed' || !textDisplayMeta.billboard_render_constraints) - const nameTagBackgroundColor = textDisplayMeta && toRgba(textDisplayMeta.background_color) + const nameTagBackgroundColor = (textDisplayMeta && (parseInt(textDisplayMeta.style_flags, 10) & 0x04) === 0) ? toRgba(textDisplayMeta.background_color) : undefined let nameTagTextOpacity: any if (textDisplayMeta?.text_opacity) { const rawOpacity = parseInt(textDisplayMeta?.text_opacity, 10) nameTagTextOpacity = rawOpacity > 0 ? rawOpacity : 256 - rawOpacity } addNametag( - { ...entity, username: displayText, nameTagBackgroundColor, nameTagTextOpacity, nameTagFixed, + { ...entity, username: typeof displayTextRaw === 'string' ? mojangson.simplify(mojangson.parse(displayTextRaw)) : nbt.simplify(displayTextRaw), + nameTagBackgroundColor, nameTagTextOpacity, nameTagFixed, nameTagScale: textDisplayMeta?.scale, nameTagTranslation: textDisplayMeta && (textDisplayMeta.translation || new THREE.Vector3(0, 0, 0)), nameTagRotationLeft: toQuaternion(textDisplayMeta?.left_rotation), nameTagRotationRight: toQuaternion(textDisplayMeta?.right_rotation) }, this.entitiesOptions, - mesh + mesh, + this.worldRenderer.version ) } diff --git a/renderer/viewer/three/entity/EntityMesh.ts b/renderer/viewer/three/entity/EntityMesh.ts index 454bf35c..229da6d5 100644 --- a/renderer/viewer/three/entity/EntityMesh.ts +++ b/renderer/viewer/three/entity/EntityMesh.ts @@ -6,7 +6,7 @@ import ocelotPng from '../../../../node_modules/mc-assets/dist/other-textures/la import arrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/arrow.png' import spectralArrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/spectral_arrow.png' import tippedArrowTexture from '../../../../node_modules/mc-assets/dist/other-textures/1.21.2/entity/projectiles/tipped_arrow.png' -import { loadTexture } from '../../lib/utils' +import { loadTexture } from '../threeJsUtils' import { WorldRendererThree } from '../worldrendererThree' import entities from './entities.json' import { externalModels } from './objModels' diff --git a/renderer/viewer/lib/hand.ts b/renderer/viewer/three/hand.ts similarity index 95% rename from renderer/viewer/lib/hand.ts rename to renderer/viewer/three/hand.ts index 8bfa051b..2bd3832b 100644 --- a/renderer/viewer/lib/hand.ts +++ b/renderer/viewer/three/hand.ts @@ -1,6 +1,7 @@ import * as THREE from 'three' -import { loadSkinToCanvas } from 'skinview-utils' -import { loadSkinFromUsername, loadSkinImage, steveTexture } from './utils/skins' +import { loadSkinFromUsername, loadSkinImage } from '../lib/utils/skins' +import { steveTexture } from './entities' + export const getMyHand = async (image?: string, userName?: string) => { let newMap: THREE.Texture diff --git a/renderer/viewer/three/holdingBlock.ts b/renderer/viewer/three/holdingBlock.ts index af355f2e..c8a56386 100644 --- a/renderer/viewer/three/holdingBlock.ts +++ b/renderer/viewer/three/holdingBlock.ts @@ -4,12 +4,12 @@ import PrismarineItem from 'prismarine-item' import worldBlockProvider, { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider' import { BlockModel } from 'mc-assets' import { getThreeBlockModelGroup, renderBlockThree, setBlockPosition } from '../lib/mesher/standaloneRenderer' -import { getMyHand } from '../lib/hand' import { MovementState, PlayerStateRenderer } from '../lib/basePlayerState' import { DebugGui } from '../lib/DebugGui' import { SmoothSwitcher } from '../lib/smoothSwitcher' import { watchProperty } from '../lib/utils/proxy' import { WorldRendererConfig } from '../lib/worldrendererCommon' +import { getMyHand } from './hand' import { WorldRendererThree } from './worldrendererThree' import { disposeObject } from './threeJsUtils' diff --git a/renderer/viewer/three/panorama.ts b/renderer/viewer/three/panorama.ts index 7e975d4b..76aa285b 100644 --- a/renderer/viewer/three/panorama.ts +++ b/renderer/viewer/three/panorama.ts @@ -7,9 +7,9 @@ import type { GraphicsInitOptions } from '../../../src/appViewer' import { WorldDataEmitter } from '../lib/worldDataEmitter' import { defaultWorldRendererConfig, WorldRendererCommon } from '../lib/worldrendererCommon' import { getDefaultRendererState } from '../baseGraphicsBackend' -import { loadThreeJsTextureFromUrl, loadThreeJsTextureFromUrlSync } from '../lib/utils/skins' import { ResourcesManager } from '../../../src/resourcesManager' import { getInitialPlayerStateRenderer } from '../lib/basePlayerState' +import { loadThreeJsTextureFromUrl, loadThreeJsTextureFromUrlSync } from './threeJsUtils' import { WorldRendererThree } from './worldrendererThree' import { EntityMesh } from './entity/EntityMesh' import { DocumentRenderer } from './documentRenderer' diff --git a/renderer/viewer/three/threeJsUtils.ts b/renderer/viewer/three/threeJsUtils.ts index 5ae3b24f..cbef9065 100644 --- a/renderer/viewer/three/threeJsUtils.ts +++ b/renderer/viewer/three/threeJsUtils.ts @@ -1,4 +1,6 @@ import * as THREE from 'three' +import { getLoadedImage } from 'mc-assets/dist/utils' +import { createCanvas } from '../lib/utils' export const disposeObject = (obj: THREE.Object3D, cleanTextures = false) => { // not cleaning texture there as it might be used by other objects, but would be good to also do that @@ -16,3 +18,56 @@ export const disposeObject = (obj: THREE.Object3D, cleanTextures = false) => { } } } + +let textureCache: Record = {} +let imagesPromises: Record> = {} + +export const loadThreeJsTextureFromUrlSync = (imageUrl: string) => { + const texture = new THREE.Texture() + const promise = getLoadedImage(imageUrl).then(image => { + texture.image = image + texture.needsUpdate = true + return texture + }) + return { + texture, + promise + } +} + +export const loadThreeJsTextureFromUrl = async (imageUrl: string) => { + const loaded = new THREE.TextureLoader().loadAsync(imageUrl) + return loaded +} + +export const loadThreeJsTextureFromBitmap = (image: ImageBitmap) => { + const canvas = createCanvas(image.width, image.height) + const ctx = canvas.getContext('2d')! + ctx.drawImage(image, 0, 0) + const texture = new THREE.Texture(canvas) + texture.magFilter = THREE.NearestFilter + texture.minFilter = THREE.NearestFilter + return texture +} + +export async function loadTexture (texture: string, cb: (texture: THREE.Texture) => void, onLoad?: () => void): Promise { + const cached = textureCache[texture] + if (!cached) { + const { promise, resolve } = Promise.withResolvers() + const t = loadThreeJsTextureFromUrlSync(texture) + textureCache[texture] = t.texture + void t.promise.then(resolve) + imagesPromises[texture] = promise + } + + cb(textureCache[texture]) + void imagesPromises[texture].then(() => { + onLoad?.() + }) +} + +export const clearTextureCache = () => { + textureCache = {} + imagesPromises = {} +} + diff --git a/renderer/viewer/three/world/cursorBlock.ts b/renderer/viewer/three/world/cursorBlock.ts index b3be2ca2..b71c1b8d 100644 --- a/renderer/viewer/three/world/cursorBlock.ts +++ b/renderer/viewer/three/world/cursorBlock.ts @@ -1,10 +1,9 @@ import * as THREE from 'three' import { LineMaterial, LineSegmentsGeometry, Wireframe } from 'three-stdlib' import { Vec3 } from 'vec3' -import { subscribeKey } from 'valtio/utils' -import { Block } from 'prismarine-block' import { BlockShape, BlocksShapes } from 'renderer/viewer/lib/basePlayerState' import { WorldRendererThree } from '../worldrendererThree' +import { loadThreeJsTextureFromUrl } from '../threeJsUtils' import destroyStage0 from '../../../../assets/destroy_stage_0.png' import destroyStage1 from '../../../../assets/destroy_stage_1.png' import destroyStage2 from '../../../../assets/destroy_stage_2.png' @@ -15,7 +14,6 @@ import destroyStage6 from '../../../../assets/destroy_stage_6.png' import destroyStage7 from '../../../../assets/destroy_stage_7.png' import destroyStage8 from '../../../../assets/destroy_stage_8.png' import destroyStage9 from '../../../../assets/destroy_stage_9.png' -import { loadThreeJsTextureFromUrl } from '../../lib/utils/skins' export class CursorBlock { _cursorLinesHidden = false diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index e5b93f88..bc95f06b 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -10,13 +10,12 @@ import { WorldRendererCommon } from '../lib/worldrendererCommon' import { addNewStat } from '../lib/ui/newStats' import { MesherGeometryOutput } from '../lib/mesher/shared' import { ItemSpecificContextProperties } from '../lib/basePlayerState' -import { getMyHand } from '../lib/hand' import { setBlockPosition } from '../lib/mesher/standaloneRenderer' -import { loadThreeJsTextureFromBitmap } from '../lib/utils/skins' +import { getMyHand } from './hand' import HoldingBlock from './holdingBlock' import { getMesh } from './entity/EntityMesh' import { armorModel } from './entity/armorModels' -import { disposeObject } from './threeJsUtils' +import { disposeObject, loadThreeJsTextureFromBitmap } from './threeJsUtils' import { CursorBlock } from './world/cursorBlock' import { getItemUv } from './appShared' import { Entities } from './entities' @@ -426,7 +425,7 @@ export class WorldRendererThree extends WorldRendererCommon { this.scene.add(object) } - getSignTexture (position: Vec3, blockEntity, backSide = false) { + getSignTexture (position: Vec3, blockEntity, isHanging, backSide = false) { const chunk = chunkPos(position) let textures = this.chunkTextures.get(`${chunk[0]},${chunk[1]}`) if (!textures) { @@ -438,7 +437,7 @@ export class WorldRendererThree extends WorldRendererCommon { if (textures[texturekey]) return textures[texturekey] const PrismarineChat = PrismarineChatLoader(this.version) - const canvas = renderSign(blockEntity, PrismarineChat) + const canvas = renderSign(blockEntity, isHanging, PrismarineChat) if (!canvas) return const tex = new THREE.Texture(canvas) tex.magFilter = THREE.NearestFilter @@ -784,7 +783,7 @@ export class WorldRendererThree extends WorldRendererCommon { } renderSign (position: Vec3, rotation: number, isWall: boolean, isHanging: boolean, blockEntity) { - const tex = this.getSignTexture(position, blockEntity) + const tex = this.getSignTexture(position, blockEntity, isHanging) if (!tex) return diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 219fe57e..e264f6b7 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -15,6 +15,7 @@ import { appAndRendererSharedConfig } from './renderer/rsbuildSharedConfig' import { genLargeDataAliases } from './scripts/genLargeDataAliases' import sharp from 'sharp' import supportedVersions from './src/supportedVersions.mjs' +import { startWsServer } from './scripts/wsServer' const SINGLE_FILE_BUILD = process.env.SINGLE_FILE_BUILD === 'true' @@ -59,6 +60,8 @@ const configSource = (SINGLE_FILE_BUILD ? 'BUNDLED' : (process.env.CONFIG_JSON_S const faviconPath = 'favicon.png' +const enableMetrics = process.env.ENABLE_METRICS === 'true' + // base options are in ./renderer/rsbuildSharedConfig.ts const appConfig = defineConfig({ html: { @@ -159,6 +162,7 @@ const appConfig = defineConfig({ 'process.env.INLINED_APP_CONFIG': JSON.stringify(configSource === 'BUNDLED' ? configJson : null), 'process.env.ENABLE_COOKIE_STORAGE': JSON.stringify(process.env.ENABLE_COOKIE_STORAGE || true), 'process.env.COOKIE_STORAGE_PREFIX': JSON.stringify(process.env.COOKIE_STORAGE_PREFIX || ''), + 'process.env.WS_PORT': JSON.stringify(enableMetrics ? 8081 : false), }, }, server: { @@ -216,6 +220,12 @@ const appConfig = defineConfig({ await execAsync('pnpm run build-mesher') } fs.writeFileSync('./dist/version.txt', buildingVersion, 'utf-8') + + // Start WebSocket server in development + if (dev && enableMetrics) { + await startWsServer(8081, false) + } + console.timeEnd('total-prep') } if (!dev) { diff --git a/scripts/requestData.ts b/scripts/requestData.ts new file mode 100644 index 00000000..dc866a1b --- /dev/null +++ b/scripts/requestData.ts @@ -0,0 +1,42 @@ +import WebSocket from 'ws' + +function formatBytes(bytes: number) { + return `${(bytes).toFixed(2)} MB` +} + +function formatTime(ms: number) { + return `${(ms / 1000).toFixed(2)}s` +} + +const ws = new WebSocket('ws://localhost:8081') + +ws.on('open', () => { + console.log('Connected to metrics server, waiting for metrics...') +}) + +ws.on('message', (data) => { + try { + const metrics = JSON.parse(data.toString()) + console.log('\nPerformance Metrics:') + console.log('------------------') + console.log(`Load Time: ${formatTime(metrics.loadTime)}`) + console.log(`Memory Usage: ${formatBytes(metrics.memoryUsage)}`) + console.log(`Timestamp: ${new Date(metrics.timestamp).toLocaleString()}`) + if (!process.argv.includes('-f')) { // follow mode + process.exit(0) + } + } catch (error) { + console.error('Error parsing metrics:', error) + } +}) + +ws.on('error', (error) => { + console.error('WebSocket error:', error) + process.exit(1) +}) + +// Exit if no metrics received after 5 seconds +setTimeout(() => { + console.error('Timeout waiting for metrics') + process.exit(1) +}, 5000) diff --git a/scripts/wsServer.ts b/scripts/wsServer.ts new file mode 100644 index 00000000..43035f52 --- /dev/null +++ b/scripts/wsServer.ts @@ -0,0 +1,45 @@ +import {WebSocketServer} from 'ws' + +export function startWsServer(port: number = 8081, tryOtherPort: boolean = true): Promise { + return new Promise((resolve, reject) => { + const tryPort = (currentPort: number) => { + const wss = new WebSocketServer({ port: currentPort }) + .on('listening', () => { + console.log(`WebSocket server started on port ${currentPort}`) + resolve(currentPort) + }) + .on('error', (err: any) => { + if (err.code === 'EADDRINUSE' && tryOtherPort) { + console.log(`Port ${currentPort} in use, trying ${currentPort + 1}`) + wss.close() + tryPort(currentPort + 1) + } else { + reject(err) + } + }) + + wss.on('connection', (ws) => { + console.log('Client connected') + + ws.on('message', (message) => { + try { + // Simply relay the message to all connected clients except sender + wss.clients.forEach(client => { + if (client !== ws && client.readyState === WebSocket.OPEN) { + client.send(message.toString()) + } + }) + } catch (error) { + console.error('Error processing message:', error) + } + }) + + ws.on('close', () => { + console.log('Client disconnected') + }) + }) + } + + tryPort(port) + }) +} diff --git a/src/appConfig.ts b/src/appConfig.ts index b8c1e219..92fde21a 100644 --- a/src/appConfig.ts +++ b/src/appConfig.ts @@ -24,6 +24,7 @@ export type MobileButtonConfig = { readonly icon?: string readonly action?: ActionType readonly actionHold?: ActionType | ActionHoldConfig + readonly iconStyle?: React.CSSProperties } export type AppConfig = { @@ -56,9 +57,12 @@ export type AppConfig = { defaultUsername?: string skinTexturesProxy?: string alwaysReconnectButton?: boolean + reportBugButtonWithReconnect?: boolean + disabledCommands?: string[] // Array of command IDs to disable (e.g. ['general.jump', 'general.chat']) } export const loadAppConfig = (appConfig: AppConfig) => { + if (miscUiState.appConfig) { Object.assign(miscUiState.appConfig, appConfig) } else { @@ -70,7 +74,7 @@ export const loadAppConfig = (appConfig: AppConfig) => { if (value) { disabledSettings.value.add(key) // since the setting is forced, we need to set it to that value - if (appConfig.defaultSettings?.[key] && !qsOptions[key]) { + if (appConfig.defaultSettings && key in appConfig.defaultSettings && !qsOptions[key]) { options[key] = appConfig.defaultSettings[key] } } else { @@ -78,6 +82,7 @@ export const loadAppConfig = (appConfig: AppConfig) => { } } } + // todo apply defaultSettings to defaults even if not forced in case of remote config if (appConfig.keybindings) { Object.assign(customKeymaps, defaultsDeep(appConfig.keybindings, customKeymaps)) diff --git a/src/controls.ts b/src/controls.ts index 44de8f11..9430f9c1 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -116,6 +116,10 @@ export const contro = new ControMax({ window.controMax = contro export type Command = CommandEventArgument['command'] +export const isCommandDisabled = (command: Command) => { + return miscUiState.appConfig?.disabledCommands?.includes(command) +} + onControInit() updateBinds(customKeymaps) @@ -544,6 +548,8 @@ const customCommandsHandler = ({ command }) => { contro.on('trigger', customCommandsHandler) contro.on('trigger', ({ command }) => { + if (isCommandDisabled(command)) return + const willContinue = !isGameActive(true) alwaysPressedHandledCommand(command) if (willContinue) return @@ -677,6 +683,8 @@ contro.on('trigger', ({ command }) => { }) contro.on('release', ({ command }) => { + if (isCommandDisabled(command)) return + inModalCommand(command, false) onTriggerOrReleased(command, false) }) @@ -996,9 +1004,16 @@ export const handleMobileButtonCustomAction = (action: CustomAction) => { } } +export const triggerCommand = (command: Command, isDown: boolean) => { + handleMobileButtonActionCommand(command, isDown) +} + export const handleMobileButtonActionCommand = (command: ActionType | ActionHoldConfig, isDown: boolean) => { const commandValue = typeof command === 'string' ? command : 'command' in command ? command.command : command + // Check if command is disabled before proceeding + if (typeof commandValue === 'string' && isCommandDisabled(commandValue as Command)) return + if (typeof commandValue === 'string' && !stringStartsWith(commandValue, 'custom')) { const event: CommandEventArgument = { command: commandValue as Command, diff --git a/src/devtools.ts b/src/devtools.ts index b9267127..8890fdea 100644 --- a/src/devtools.ts +++ b/src/devtools.ts @@ -209,3 +209,106 @@ setInterval(() => { }, 1000) // --- + +// Add type declaration for performance.memory +declare global { + interface Performance { + memory?: { + usedJSHeapSize: number + totalJSHeapSize: number + jsHeapSizeLimit: number + } + } +} + +// Performance metrics WebSocket client +let ws: WebSocket | null = null +let wsReconnectTimeout: NodeJS.Timeout | null = null +let metricsInterval: NodeJS.Timeout | null = null + +// Start collecting metrics immediately +const startTime = performance.now() + +function collectAndSendMetrics () { + if (!ws || ws.readyState !== WebSocket.OPEN) return + + const metrics = { + loadTime: performance.now() - startTime, + memoryUsage: (performance.memory?.usedJSHeapSize ?? 0) / 1024 / 1024, + timestamp: Date.now() + } + + ws.send(JSON.stringify(metrics)) +} + +function getWebSocketUrl () { + const wsPort = process.env.WS_PORT + if (!wsPort) return null + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const { hostname } = window.location + return `${protocol}//${hostname}:${wsPort}` +} + +function connectWebSocket () { + if (ws) return + + const wsUrl = getWebSocketUrl() + if (!wsUrl) { + console.log('WebSocket server not configured') + return + } + + ws = new WebSocket(wsUrl) + + ws.onopen = () => { + console.log('Connected to metrics server') + if (wsReconnectTimeout) { + clearTimeout(wsReconnectTimeout) + wsReconnectTimeout = null + } + + // Start sending metrics immediately after connection + collectAndSendMetrics() + + // Clear existing interval if any + if (metricsInterval) { + clearInterval(metricsInterval) + } + + // Set new interval + metricsInterval = setInterval(collectAndSendMetrics, 500) + } + + ws.onclose = () => { + console.log('Disconnected from metrics server') + ws = null + + // Clear metrics interval + if (metricsInterval) { + clearInterval(metricsInterval) + metricsInterval = null + } + + // Try to reconnect after 3 seconds + wsReconnectTimeout = setTimeout(connectWebSocket, 3000) + } + + ws.onerror = (error) => { + console.error('WebSocket error:', error) + } +} + +// Connect immediately +connectWebSocket() + +// Add command to request current metrics +window.requestMetrics = () => { + const metrics = { + loadTime: performance.now() - startTime, + memoryUsage: (performance.memory?.usedJSHeapSize ?? 0) / 1024 / 1024, + timestamp: Date.now() + } + console.log('Current metrics:', metrics) + return metrics +} diff --git a/src/entities.ts b/src/entities.ts index 3dac51ce..79602aa5 100644 --- a/src/entities.ts +++ b/src/entities.ts @@ -198,46 +198,40 @@ customEvents.on('gameLoaded', () => { } }) - // Texture override from packet properties - bot._client.on('player_info', (packet) => { - const applySkinTexturesProxy = (url: string) => { - const { appConfig } = miscUiState - if (appConfig?.skinTexturesProxy) { - return url?.replace('http://textures.minecraft.net/', appConfig.skinTexturesProxy) - .replace('https://textures.minecraft.net/', appConfig.skinTexturesProxy) - } - return url + const applySkinTexturesProxy = (url: string | undefined) => { + const { appConfig } = miscUiState + if (appConfig?.skinTexturesProxy) { + return url?.replace('http://textures.minecraft.net/', appConfig.skinTexturesProxy) + .replace('https://textures.minecraft.net/', appConfig.skinTexturesProxy) } + return url + } - for (const playerEntry of packet.data) { - if (!playerEntry.player && !playerEntry.properties) continue - let textureProperty = playerEntry.properties?.find(prop => prop?.name === 'textures') - if (!textureProperty) { - textureProperty = playerEntry.player?.properties?.find(prop => prop?.key === 'textures') - } - if (textureProperty) { - try { - const textureData = JSON.parse(Buffer.from(textureProperty.value, 'base64').toString()) - const skinUrl = applySkinTexturesProxy(textureData.textures?.SKIN?.url) - const capeUrl = applySkinTexturesProxy(textureData.textures?.CAPE?.url) + // Texture override from packet properties + const updateSkin = (player: import('mineflayer').Player) => { + if (!player.uuid || !player.username || !player.skinData) return - // Find entity with matching UUID and update skin - let entityId = '' - for (const [entId, entity] of Object.entries(bot.entities)) { - if (entity.uuid === playerEntry.uuid) { - entityId = entId - break - } - } - // even if not found, still record to cache - void getThreeJsRendererMethods()?.updatePlayerSkin(entityId, playerEntry.player?.name, playerEntry.uuid, skinUrl, capeUrl) - } catch (err) { - console.error('Error decoding player texture:', err) + try { + const skinUrl = applySkinTexturesProxy(player.skinData.url) + const capeUrl = applySkinTexturesProxy((player.skinData as any).capeUrl) + + // Find entity with matching UUID and update skin + let entityId = '' + for (const [entId, entity] of Object.entries(bot.entities)) { + if (entity.uuid === player.uuid) { + entityId = entId + break } } + // even if not found, still record to cache + void getThreeJsRendererMethods()?.updatePlayerSkin(entityId, player.username, player.uuid, skinUrl ?? true, capeUrl) + } catch (err) { + console.error('Error decoding player texture:', err) } + } - }) + bot.on('playerJoined', updateSkin) + bot.on('playerUpdated', updateSkin) bot.on('teamUpdated', (team: Team) => { for (const entity of Object.values(bot.entities)) { diff --git a/src/env.d.ts b/src/env.d.ts index 4cb5bafd..9b3e9774 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -3,6 +3,7 @@ declare namespace NodeJS { // Build configuration NODE_ENV: 'development' | 'production' SINGLE_FILE_BUILD?: string + WS_PORT?: string DISABLE_SERVICE_WORKER?: string CONFIG_JSON_SOURCE?: 'BUNDLED' | 'REMOTE' LOCAL_CONFIG_FILE?: string diff --git a/src/mineflayer/items.ts b/src/mineflayer/items.ts index 495c4f39..48d0dfe0 100644 --- a/src/mineflayer/items.ts +++ b/src/mineflayer/items.ts @@ -58,9 +58,17 @@ export const getItemMetadata = (item: GeneralInputItem, resourcesManager: Resour } if (customModelDataDefinitions) { const customModelDataComponent: any = componentMap.get('custom_model_data') - if (customModelDataComponent?.data && typeof customModelDataComponent.data === 'number') { - const customModelData = customModelDataComponent.data - if (customModelDataDefinitions[customModelData]) { + if (customModelDataComponent?.data) { + let customModelData: number | undefined + if (typeof customModelDataComponent.data === 'number') { + customModelData = customModelDataComponent.data + } else if (typeof customModelDataComponent.data === 'object' + && 'floats' in customModelDataComponent.data + && Array.isArray(customModelDataComponent.data.floats) + && customModelDataComponent.data.floats.length > 0) { + customModelData = customModelDataComponent.data.floats[0] + } + if (customModelData && customModelDataDefinitions[customModelData]) { customModel = customModelDataDefinitions[customModelData] } } diff --git a/src/react/ArmorBar.css b/src/react/ArmorBar.css index ca579a72..9718e553 100644 --- a/src/react/ArmorBar.css +++ b/src/react/ArmorBar.css @@ -8,7 +8,6 @@ --offset: calc(-1 * 25px); --bg-x: calc(-1 * 16px); --bg-y: calc(-1 * 9px); - pointer-events: none; image-rendering: pixelated; } diff --git a/src/react/BreathBar.css b/src/react/BreathBar.css index e98cf7c2..bcf93303 100644 --- a/src/react/BreathBar.css +++ b/src/react/BreathBar.css @@ -9,7 +9,6 @@ --offset: calc(-1 * 16px); --bg-x: calc(-1 * 16px); --bg-y: calc(-1 * 18px); - pointer-events: none; image-rendering: pixelated; } diff --git a/src/react/Chat.css b/src/react/Chat.css index 246f17b3..ff9f60b1 100644 --- a/src/react/Chat.css +++ b/src/react/Chat.css @@ -9,7 +9,7 @@ div.chat-wrapper { /* Only apply overflow hidden when not in mobile mode */ div.chat-wrapper:not(.display-mobile):not(.input-mobile) { - overflow: hidden; + /* overflow: hidden; */ } .chat-messages-wrapper { diff --git a/src/react/FoodBar.css b/src/react/FoodBar.css index ca3629cd..83da3c25 100644 --- a/src/react/FoodBar.css +++ b/src/react/FoodBar.css @@ -11,7 +11,6 @@ --offset: calc(-1 * (52px + (9px * (4 * var(--kind) + var(--lightened) * 2)))); --bg-x: calc(-1 * (16px + 9px * var(--lightened))); --bg-y: calc(-1 * 27px); - pointer-events: none; image-rendering: pixelated; } diff --git a/src/react/HealthBar.css b/src/react/HealthBar.css index a6fefe09..72f5a1fe 100644 --- a/src/react/HealthBar.css +++ b/src/react/HealthBar.css @@ -11,7 +11,6 @@ --offset: calc(-1 * (52px + (9px * (4 * var(--kind) + var(--lightened) * 2)) )); --bg-x: calc(-1 * (16px + 9px * var(--lightened))); --bg-y: calc(-1 * var(--hardcore) * 45px); - pointer-events: none; image-rendering: pixelated; } diff --git a/src/react/HotbarRenderApp.tsx b/src/react/HotbarRenderApp.tsx index 80f0a789..ed1f42e6 100644 --- a/src/react/HotbarRenderApp.tsx +++ b/src/react/HotbarRenderApp.tsx @@ -2,15 +2,15 @@ import { useEffect, useRef, useState } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { createPortal } from 'react-dom' import { subscribe, useSnapshot } from 'valtio' -import { openItemsCanvas, openPlayerInventory, upInventoryItems } from '../inventoryWindows' +import { openItemsCanvas, upInventoryItems } from '../inventoryWindows' import { activeModalStack, isGameActive, miscUiState } from '../globalState' import { currentScaling } from '../scaleInterface' import { watchUnloadForCleanup } from '../gameUnload' import { getItemNameRaw } from '../mineflayer/items' import { isInRealGameSession } from '../utils' +import { triggerCommand } from '../controls' import MessageFormattedString from './MessageFormattedString' import SharedHudVars from './SharedHudVars' -import { packetsReplayState } from './state/packetsReplayState' const ItemName = ({ itemKey }: { itemKey: string }) => { @@ -75,6 +75,8 @@ const HotbarInner = () => { const container = useRef(null!) const [itemKey, setItemKey] = useState('') const hasModals = useSnapshot(activeModalStack).length + const { currentTouch, appConfig } = useSnapshot(miscUiState) + const mobileOpenInventory = currentTouch && !appConfig?.disabledCommands?.includes('general.inventory') useEffect(() => { const controller = new AbortController() @@ -105,7 +107,7 @@ const HotbarInner = () => { canvasManager.setScale(currentScaling.scale) canvasManager.windowHeight = 25 * canvasManager.scale - canvasManager.windowWidth = (210 - (inv.inventory.supportsOffhand ? 0 : 25) + (miscUiState.currentTouch ? 28 : 0)) * canvasManager.scale + canvasManager.windowWidth = (210 - (inv.inventory.supportsOffhand ? 0 : 25) + (mobileOpenInventory ? 28 : 0)) * canvasManager.scale } setSize() watchUnloadForCleanup(subscribe(currentScaling, setSize)) @@ -119,8 +121,9 @@ const HotbarInner = () => { canvasManager.canvas.onclick = (e) => { if (!isGameActive(true)) return const pos = inv.canvasManager.getMousePos(inv.canvas, e) - if (canvasManager.canvas.width - pos.x < 35 * inv.canvasManager.scale) { - openPlayerInventory() + if (canvasManager.canvas.width - pos.x < 35 * inv.canvasManager.scale && mobileOpenInventory) { + triggerCommand('general.inventory', true) + triggerCommand('general.inventory', false) } } @@ -180,17 +183,8 @@ const HotbarInner = () => { }) document.addEventListener('touchend', (e) => { if (touchStart && (e.target as HTMLElement).closest('.hotbar') && Date.now() - touchStart > 700) { - // drop item - bot._client.write('block_dig', { - 'status': 4, - 'location': { - 'x': 0, - 'z': 0, - 'y': 0 - }, - 'face': 0, - sequence: 0 - }) + triggerCommand('general.dropStack', true) + triggerCommand('general.dropStack', false) } touchStart = 0 }) diff --git a/src/react/HudBarsProvider.tsx b/src/react/HudBarsProvider.tsx index db061500..42fa8378 100644 --- a/src/react/HudBarsProvider.tsx +++ b/src/react/HudBarsProvider.tsx @@ -1,5 +1,7 @@ import { useRef, useState, useMemo } from 'react' import { GameMode } from 'mineflayer' +import { useSnapshot } from 'valtio' +import { options } from '../optionsStorage' import { armor } from './armorValues' import HealthBar from './HealthBar' import FoodBar from './FoodBar' @@ -8,6 +10,8 @@ import BreathBar from './BreathBar' import './HealthBar.css' export default () => { + const { disabledUiParts } = useSnapshot(options) + const [damaged, setDamaged] = useState(false) const [healthValue, setHealthValue] = useState(bot.health) const [food, setFood] = useState(bot.food) @@ -91,7 +95,7 @@ export default () => { }, []) return
- { setEffectToAdd(null) setEffectToRemove(null) }} - /> - } + {!disabledUiParts.includes('armor-bar') && - } + {!disabledUiParts.includes('food-bar') && { setEffectToAdd(null) setEffectToRemove(null) }} - /> - } + {!disabledUiParts.includes('breath-bar') && + />}
} diff --git a/src/react/PauseScreen.tsx b/src/react/PauseScreen.tsx index 4bdd2974..3036aa4b 100644 --- a/src/react/PauseScreen.tsx +++ b/src/react/PauseScreen.tsx @@ -32,7 +32,7 @@ import Screen from './Screen' import styles from './PauseScreen.module.css' import { DiscordButton } from './DiscordButton' import { showNotification } from './NotificationProvider' -import { appStatusState, reconnectReload } from './AppStatusProvider' +import { appStatusState, lastConnectOptions, reconnectReload } from './AppStatusProvider' import NetworkStatus from './NetworkStatus' import PauseLinkButtons from './PauseLinkButtons' import { pixelartIcons } from './PixelartIcon' @@ -265,7 +265,7 @@ export default () => {
- + {singleplayer ? (
} {(noConnection || appConfig?.alwaysReconnectButton) && ( - +
+ + {appConfig?.reportBugButtonWithReconnect && ( +
)}
diff --git a/src/utils.ts b/src/utils.ts index d48fbbc3..3ccc7fc4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -163,7 +163,7 @@ export const reloadChunks = async () => { } export const openGithub = (addUrl = '') => { - window.open(`${process.env.GITHUB_URL}${addUrl}`, '_blank') + window.open(`${process.env.GITHUB_URL?.replace(/\/$/, '')}${addUrl}`, '_blank') } export const resolveTimeout = async (promise, timeout = 10_000) => {