This commit is contained in:
Vitaly 2025-07-12 08:03:05 +03:00 committed by GitHub
commit cedb077cce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 652 additions and 328 deletions

View file

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

View file

@ -1,3 +1,4 @@
{
"alwaysReconnectButton": true
"alwaysReconnectButton": true,
"reportBugButtonWithReconnect": true
}

View file

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

View file

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

14
pnpm-lock.yaml generated
View file

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

View file

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

View file

@ -42,7 +42,6 @@ export type MesherGeometryOutput = {
heads: Record<string, any>,
signs: Record<string, any>,
// isFull: boolean
highestBlocks: Map<string, HighestBlockInfo>
hadErrors: boolean
blocksCount: number
customBlockModels?: CustomBlockModels

View file

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

View file

@ -1,30 +1,3 @@
import * as THREE from 'three'
import { loadThreeJsTextureFromUrl, loadThreeJsTextureFromUrlSync } from './utils/skins'
let textureCache: Record<string, THREE.Texture> = {}
let imagesPromises: Record<string, Promise<THREE.Texture>> = {}
export async function loadTexture (texture: string, cb: (texture: THREE.Texture) => void, onLoad?: () => void): Promise<void> {
const cached = textureCache[texture]
if (!cached) {
const { promise, resolve } = Promise.withResolvers<THREE.Texture>()
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<HTMLScriptElement> {
const existingScript = document.querySelector<HTMLScriptElement>(`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<ImageBitmap> {
const response = await fetch(imageUrl)
const blob = await response.blob()
return createImageBitmap(blob)
}

View file

@ -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<ImageBitmap> {
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,

View file

@ -123,7 +123,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
handleResize = () => { }
highestBlocksByChunks = new Map<string, { [chunkKey: string]: HighestBlockInfo }>()
highestBlocksBySections = new Map<string, { [sectionKey: string]: HighestBlockInfo }>()
blockEntities = {}
workersProcessAverageTime = 0
@ -389,8 +388,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
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<WorkerSend = any, WorkerReceive = any>
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}`)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, THREE.Texture> = {}
let imagesPromises: Record<string, Promise<THREE.Texture>> = {}
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<void> {
const cached = textureCache[texture]
if (!cached) {
const { promise, resolve } = Promise.withResolvers<THREE.Texture>()
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 = {}
}

View file

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

View file

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

View file

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

42
scripts/requestData.ts Normal file
View file

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

45
scripts/wsServer.ts Normal file
View file

@ -0,0 +1,45 @@
import {WebSocketServer} from 'ws'
export function startWsServer(port: number = 8081, tryOtherPort: boolean = true): Promise<number> {
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)
})
}

View file

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

View file

@ -116,6 +116,10 @@ export const contro = new ControMax({
window.controMax = contro
export type Command = CommandEventArgument<typeof contro['_commandsRaw']>['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<typeof contro['_commandsRaw']> = {
command: commandValue as Command,

View file

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

View file

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

1
src/env.d.ts vendored
View file

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

View file

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

View file

@ -8,7 +8,6 @@
--offset: calc(-1 * 25px);
--bg-x: calc(-1 * 16px);
--bg-y: calc(-1 * 9px);
pointer-events: none;
image-rendering: pixelated;
}

View file

@ -9,7 +9,6 @@
--offset: calc(-1 * 16px);
--bg-x: calc(-1 * 16px);
--bg-y: calc(-1 * 18px);
pointer-events: none;
image-rendering: pixelated;
}

View file

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

View file

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

View file

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

View file

@ -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<HTMLDivElement>(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
})

View file

@ -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 <div className='hud-bars-container'>
<HealthBar
{!disabledUiParts.includes('health-bar') && <HealthBar
gameMode={gameMode}
isHardcore={isHardcore}
damaged={damaged}
@ -102,12 +106,12 @@ export default () => {
setEffectToAdd(null)
setEffectToRemove(null)
}}
/>
<ArmorBar
/>}
{!disabledUiParts.includes('armor-bar') && <ArmorBar
armorValue={armorValue}
style={gameMode !== 'survival' && gameMode !== 'adventure' ? { display: 'none' } : { display: 'flex' }}
/>
<FoodBar
/>}
{!disabledUiParts.includes('food-bar') && <FoodBar
gameMode={gameMode}
food={food}
effectToAdd={effectToAdd}
@ -116,9 +120,9 @@ export default () => {
setEffectToAdd(null)
setEffectToRemove(null)
}}
/>
<BreathBar
/>}
{!disabledUiParts.includes('breath-bar') && <BreathBar
oxygen={gameMode !== 'survival' && gameMode !== 'adventure' ? 0 : oxygen}
/>
/>}
</div>
}

View file

@ -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 () => {
<div className={styles.pause_container}>
<Button className="button" style={{ width: '204px' }} onClick={onReturnPress}>Back to Game</Button>
<PauseLinkButtons />
<Button className="button" style={{ width: '204px' }} onClick={() => openOptionsMenu('main')}>Options</Button>
<Button className="button" style={{ width: '204px' }} onClick={() => openOptionsMenu('main')}>Options...</Button>
{singleplayer ? (
<div className={styles.row}>
<Button className="button" style={{ width: '170px' }} onClick={async () => clickJoinLinkButton()}>
@ -295,9 +295,53 @@ export default () => {
</Button>
</>}
{(noConnection || appConfig?.alwaysReconnectButton) && (
<Button className="button" style={{ width: '204px' }} onClick={reconnectReload}>
Reconnect
</Button>
<div className={styles.row}>
<Button className="button" style={{ width: appConfig?.reportBugButtonWithReconnect ? '98px' : '204px' }} onClick={reconnectReload}>
Reconnect
</Button>
{appConfig?.reportBugButtonWithReconnect && (
<Button
label="Report Problem"
className="button"
style={{ width: '98px' }}
onClick={async () => {
const platform = (navigator as any).userAgentData?.platform ?? navigator.platform
const body = `Version: ${window.location.hostname}\nServer: ${lastConnectOptions.value?.server ?? '<not a server>'}\nPlatform: ${platform}\nWebsite: ${window.location.href}`
const currentHost = window.location.hostname
const options = [
'GitHub (please use it if you can)',
'Email',
...((currentHost === 'mcraft.fun' || currentHost === 'ru.mcraft.fun') ? ['Try Beta Version'] : []),
// 'Use previous versions of client'
]
const action = await showOptionsModal('Report client issue', options)
if (!action) return
switch (action) {
case 'GitHub (please use it if you can)':
openGithub(`/issues/new?body=${encodeURIComponent(body)}&title=${encodeURIComponent('[Bug Report] <describe your issue here>')}&labels=bug`)
break
case 'Email': {
window.location.href = `mailto:support@mcraft.fun?body=${encodeURIComponent(body)}`
break
}
case 'Try Beta Version': {
if (currentHost === 'mcraft.fun') {
window.location.href = 'https://s.mcraft.fun'
} else if (currentHost === 'ru.mcraft.fun') {
window.location.href = 'https://s.pcm.gg'
}
break
}
case 'Use previous versions of client':
// TODO: Implement versions screen
void showOptionsModal('Previous versions', [])
break
}
}}
/>
)}
</div>
)}
</div>
<LoadingTimer />

View file

@ -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) => {