Merge branch 'next' into light-engine

This commit is contained in:
Vitaly Turovsky 2025-04-24 05:49:45 +03:00
commit 3cd1ac3666
80 changed files with 3081 additions and 267 deletions

View file

@ -12,6 +12,7 @@ For building the project yourself / contributing, see [Development, Debugging &
### Big Features
- Official Mineflayer [plugin integration](https://github.com/zardoy/mcraft-fun-mineflayer-plugin)! View / Control your bot remotely.
- Open any zip world file or even folder in read-write mode!
- Connect to Java servers running in both offline (cracked) and online mode* (it's possible because of proxy servers, see below)
- Integrated JS server clone capable of opening Java world saves in any way (folders, zip, web chunks streaming, etc)

39
assets/config.html Normal file
View file

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Configure client</title>
<script>
function removeSettings() {
if (confirm('Are you sure you want to RESET ALL SETTINGS?')) {
localStorage.setItem('options', '{}');
location.reload();
}
}
function removeAllData() {
localStorage.removeItem('serversList')
localStorage.removeItem('serversHistory')
localStorage.removeItem('authenticatedAccounts')
localStorage.removeItem('modsAutoUpdateLastCheck')
localStorage.removeItem('firstModsPageVisit')
localStorage.removeItem('proxiesData')
localStorage.removeItem('keybindings')
localStorage.removeItem('username')
localStorage.removeItem('customCommands')
localStorage.removeItem('options')
}
</script>
</head>
<body>
<div style="display: flex;gap: 10px;">
<button onclick="removeSettings()">Reset all settings</button>
<button onclick="removeAllData()">Remove all user data (but not mods or worlds)</button>
<!-- <button>Remove all user data (worlds, resourcepacks)</button> -->
<!-- <button>Remove all mods</button> -->
<!-- <button>Remove all mod repositories</button> -->
</div>
<input />
</body>
</html>

View file

@ -25,6 +25,8 @@
"description": "Very nice a polite server. Must try for everyone!"
}
],
"rightSideText": "A Minecraft client clone in the browser!",
"splashText": "Gen is cooking!",
"pauseLinks": [
[
{

View file

@ -81,7 +81,7 @@
"google-drive-browserfs": "github:zardoy/browserfs#google-drive",
"jszip": "^3.10.1",
"lodash-es": "^4.17.21",
"mcraft-fun-mineflayer": "^0.1.14",
"mcraft-fun-mineflayer": "^0.1.21",
"minecraft-data": "3.83.1",
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
"mineflayer-item-map-downloader": "github:zardoy/mineflayer-item-map-downloader",
@ -151,7 +151,7 @@
"http-browserify": "^1.7.0",
"http-server": "^14.1.1",
"https-browserify": "^1.0.0",
"mc-assets": "^0.2.52",
"mc-assets": "^0.2.53",
"minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next",
"mineflayer": "github:GenerelSchwerz/mineflayer",
"mineflayer-mouse": "^0.1.7",
@ -196,7 +196,7 @@
},
"pnpm": {
"overrides": {
"@nxg-org/mineflayer-physics-util": "1.8.6",
"@nxg-org/mineflayer-physics-util": "1.8.7",
"buffer": "^6.0.3",
"vec3": "0.1.10",
"three": "0.154.0",

42
pnpm-lock.yaml generated
View file

@ -5,7 +5,7 @@ settings:
excludeLinksFromLockfile: false
overrides:
'@nxg-org/mineflayer-physics-util': 1.8.6
'@nxg-org/mineflayer-physics-util': 1.8.7
buffer: ^6.0.3
vec3: 0.1.10
three: 0.154.0
@ -132,8 +132,8 @@ importers:
specifier: ^4.17.21
version: 4.17.21
mcraft-fun-mineflayer:
specifier: ^0.1.14
version: 0.1.14(encoding@0.1.13)(mineflayer@https://codeload.github.com/GenerelSchwerz/mineflayer/tar.gz/d459d2ed76a997af1a7c94718ed7d5dee4478b8a(encoding@0.1.13))
specifier: ^0.1.21
version: 0.1.21(encoding@0.1.13)(mineflayer@https://codeload.github.com/GenerelSchwerz/mineflayer/tar.gz/d459d2ed76a997af1a7c94718ed7d5dee4478b8a(encoding@0.1.13))
minecraft-data:
specifier: 3.83.1
version: 3.83.1
@ -337,11 +337,11 @@ importers:
specifier: ^1.0.0
version: 1.0.0
mc-assets:
specifier: ^0.2.52
version: 0.2.52
specifier: ^0.2.53
version: 0.2.53
minecraft-inventory-gui:
specifier: github:zardoy/minecraft-inventory-gui#next
version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f57dd78ca8e3b7cdd724d4272d8cbf6743b0cf00(@types/react@18.3.18)(react@18.3.1)
version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/98bc5bb8ee6da8b4b771c05b404cee796318ccd4(@types/react@18.3.18)(react@18.3.1)
minecraft-lighting:
specifier: file:../minecraft-lighting
version: file:../minecraft-lighting
@ -2028,8 +2028,8 @@ packages:
'@nxg-org/mineflayer-auto-jump@0.7.12':
resolution: {integrity: sha512-F5vX/lerlWx/5HVlkDNbvrtQ19PL6iG8i4ItPTIRtjGiFzusDefP7DI226zSFR8Wlaw45qHv0jn814p/4/qVdQ==}
'@nxg-org/mineflayer-physics-util@1.8.6':
resolution: {integrity: sha512-eRn9e9OMvl1+kEfwPPshAl1A5MX0eDWaI7WVRf7ht9qo9N3fKiw+mM/AGPuhVjEr16zUls77P6Sn9cVZJuUdlw==}
'@nxg-org/mineflayer-physics-util@1.8.7':
resolution: {integrity: sha512-wtLYvHqoEFr/j0ny2lyogwjbMvwpFuG2aWI8sI14+EAiGFRpL5+cog2ujSDsnRTZruO7tUXMTiPc1kebjXwfJg==}
'@nxg-org/mineflayer-tracker@1.2.1':
resolution: {integrity: sha512-SI1ffF8zvg3/ZNE021Ja2W0FZPN+WbQDZf8yFqOcXtPRXAtM9W6HvoACdzXep8BZid7WYgYLIgjKpB+9RqvCNQ==}
@ -6458,13 +6458,13 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mc-assets@0.2.52:
resolution: {integrity: sha512-6mUI63fcUIjB0Ghjls7bLMnse2XUgvhPajsFkRQf10PcXYbfS/OAnX51X8sNx2pzfoHSlA81U7v+v906YwoAUw==}
mc-assets@0.2.53:
resolution: {integrity: sha512-Ucsu2pDLr/cs8bxbxU9KTszdf/vPTLphYgEHUEWxuYlMkPQUCpsQwkn3YgyykJ7RXaca7zZGlZXaTPXBAqJT6A==}
engines: {node: '>=18.0.0'}
mcraft-fun-mineflayer@0.1.14:
resolution: {integrity: sha512-q/qXQaNbkGJIvXjRvudUT7/k0EsJgphFcvYjrSRWYyGDJeb61MKRVqq1hhMjqx7UK7FMfBKvjfPSxq/QlAP7WQ==}
version: 0.1.14
mcraft-fun-mineflayer@0.1.21:
resolution: {integrity: sha512-FtzebYMvLvunApQy9ilF1RGqiX01DJn8y7q4xAONiIhBrIT7BrHK3O63IA50YgklldvdgVxn7s3m4QANvsH2JA==}
version: 0.1.21
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
peerDependencies:
'@roamhq/wrtc': '*'
@ -6676,8 +6676,8 @@ packages:
minecraft-folder-path@1.2.0:
resolution: {integrity: sha512-qaUSbKWoOsH9brn0JQuBhxNAzTDMwrOXorwuRxdJKKKDYvZhtml+6GVCUrY5HRiEsieBEjCUnhVpDuQiKsiFaw==}
minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f57dd78ca8e3b7cdd724d4272d8cbf6743b0cf00:
resolution: {tarball: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f57dd78ca8e3b7cdd724d4272d8cbf6743b0cf00}
minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/98bc5bb8ee6da8b4b771c05b404cee796318ccd4:
resolution: {tarball: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/98bc5bb8ee6da8b4b771c05b404cee796318ccd4}
version: 1.0.1
minecraft-lighting@file:../minecraft-lighting:
@ -11359,10 +11359,10 @@ snapshots:
'@nxg-org/mineflayer-auto-jump@0.7.12':
dependencies:
'@nxg-org/mineflayer-physics-util': 1.8.6
'@nxg-org/mineflayer-physics-util': 1.8.7
strict-event-emitter-types: 2.0.0
'@nxg-org/mineflayer-physics-util@1.8.6':
'@nxg-org/mineflayer-physics-util@1.8.7':
dependencies:
'@nxg-org/mineflayer-util-plugin': 1.8.4
@ -16994,12 +16994,12 @@ snapshots:
math-intrinsics@1.1.0: {}
mc-assets@0.2.52:
mc-assets@0.2.53:
dependencies:
maxrects-packer: '@zardoy/maxrects-packer@2.7.4'
zod: 3.24.2
mcraft-fun-mineflayer@0.1.14(encoding@0.1.13)(mineflayer@https://codeload.github.com/GenerelSchwerz/mineflayer/tar.gz/d459d2ed76a997af1a7c94718ed7d5dee4478b8a(encoding@0.1.13)):
mcraft-fun-mineflayer@0.1.21(encoding@0.1.13)(mineflayer@https://codeload.github.com/GenerelSchwerz/mineflayer/tar.gz/d459d2ed76a997af1a7c94718ed7d5dee4478b8a(encoding@0.1.13)):
dependencies:
'@zardoy/flying-squid': 0.0.49(encoding@0.1.13)
exit-hook: 2.2.1
@ -17315,7 +17315,7 @@ snapshots:
minecraft-folder-path@1.2.0: {}
minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f57dd78ca8e3b7cdd724d4272d8cbf6743b0cf00(@types/react@18.3.18)(react@18.3.1):
minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/98bc5bb8ee6da8b4b771c05b404cee796318ccd4(@types/react@18.3.18)(react@18.3.1):
dependencies:
valtio: 1.13.2(@types/react@18.3.18)(react@18.3.1)
transitivePeerDependencies:
@ -17447,7 +17447,7 @@ snapshots:
mineflayer@https://codeload.github.com/GenerelSchwerz/mineflayer/tar.gz/d459d2ed76a997af1a7c94718ed7d5dee4478b8a(encoding@0.1.13):
dependencies:
'@nxg-org/mineflayer-physics-util': 1.8.6
'@nxg-org/mineflayer-physics-util': 1.8.7
minecraft-data: 3.83.1
minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284(patch_hash=3a55a278c417cc34ff3172cd1de8e22852935cba0586875cbd0635f1ffdaa5ab)(encoding@0.1.13)
prismarine-biome: 1.3.0(minecraft-data@3.83.1)(prismarine-registry@1.11.0)

View file

@ -108,6 +108,10 @@ export const rspackViewerConfig = (config, { appendPlugins, addRules, rspack }:
{
test: /\.txt$/,
type: 'asset/source',
},
{
test: /\.log$/,
type: 'asset/source',
}
])
config.ignoreWarnings = [

View file

@ -423,13 +423,19 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
if (!needTiles) {
if (doAO && aos[0] + aos[3] >= aos[1] + aos[2]) {
attr.indices.push(
ndx, ndx + 3, ndx + 2, ndx, ndx + 1, ndx + 3
)
attr.indices[attr.indicesCount++] = ndx
attr.indices[attr.indicesCount++] = ndx + 3
attr.indices[attr.indicesCount++] = ndx + 2
attr.indices[attr.indicesCount++] = ndx
attr.indices[attr.indicesCount++] = ndx + 1
attr.indices[attr.indicesCount++] = ndx + 3
} else {
attr.indices.push(
ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3
)
attr.indices[attr.indicesCount++] = ndx
attr.indices[attr.indicesCount++] = ndx + 1
attr.indices[attr.indicesCount++] = ndx + 2
attr.indices[attr.indicesCount++] = ndx + 2
attr.indices[attr.indicesCount++] = ndx + 1
attr.indices[attr.indicesCount++] = ndx + 3
}
}
}
@ -463,6 +469,8 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
t_colors: [],
t_uvs: [],
indices: [],
indicesCount: 0, // Track current index position
using32Array: true,
tiles: {},
// todo this can be removed here
heads: {},
@ -605,12 +613,19 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
let ndx = attr.positions.length / 3
for (let i = 0; i < attr.t_positions!.length / 12; i++) {
attr.indices.push(
ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3,
// eslint-disable-next-line @stylistic/function-call-argument-newline
// back face
ndx, ndx + 2, ndx + 1, ndx + 2, ndx + 3, ndx + 1
)
attr.indices[attr.indicesCount++] = ndx
attr.indices[attr.indicesCount++] = ndx + 1
attr.indices[attr.indicesCount++] = ndx + 2
attr.indices[attr.indicesCount++] = ndx + 2
attr.indices[attr.indicesCount++] = ndx + 1
attr.indices[attr.indicesCount++] = ndx + 3
// back face
attr.indices[attr.indicesCount++] = ndx
attr.indices[attr.indicesCount++] = ndx + 2
attr.indices[attr.indicesCount++] = ndx + 1
attr.indices[attr.indicesCount++] = ndx + 2
attr.indices[attr.indicesCount++] = ndx + 3
attr.indices[attr.indicesCount++] = ndx + 1
ndx += 4
}
@ -628,6 +643,12 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
attr.normals = new Float32Array(attr.normals) as any
attr.colors = new Float32Array(attr.colors) as any
attr.uvs = new Float32Array(attr.uvs) as any
attr.using32Array = arrayNeedsUint32(attr.indices)
if (attr.using32Array) {
attr.indices = new Uint32Array(attr.indices)
} else {
attr.indices = new Uint16Array(attr.indices)
}
if (needTiles) {
delete attr.positions
@ -639,6 +660,21 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
return attr
}
// copied from three.js
function arrayNeedsUint32 (array) {
// assumes larger values usually on last
for (let i = array.length - 1; i >= 0; -- i) {
if (array[i] >= 65_535) return true // account for PRIMITIVE_RESTART_FIXED_INDEX, #24565
}
return false
}
export const setBlockStatesData = (blockstatesModels, blocksAtlas: any, _needTiles = false, useUnknownBlockModel = true, version = 'latest') => {
blockProvider = worldBlockProvider(blockstatesModels, blocksAtlas, version)
globalThis.blockProvider = blockProvider

View file

@ -33,7 +33,9 @@ export type MesherGeometryOutput = {
t_colors?: number[],
t_uvs?: number[],
indices: number[],
indices: Uint32Array | Uint16Array | number[],
indicesCount: number,
using32Array: boolean,
tiles: Record<string, BlockType>,
heads: Record<string, any>,
signs: Record<string, any>,

View file

@ -0,0 +1,131 @@
/* eslint-disable no-await-in-loop */
import { Vec3 } from 'vec3'
// import log from '../../../../../Downloads/mesher (2).log'
import { WorldRendererCommon } from './worldrendererCommon'
const log = ''
export class MesherLogReader {
chunksToReceive: Array<{
x: number
z: number
chunkLength: number
}> = []
messagesQueue: Array<{
fromWorker: boolean
workerIndex: number
message: any
}> = []
sectionFinishedToReceive = null as {
messagesLeft: string[]
resolve: () => void
} | null
replayStarted = false
constructor (private readonly worldRenderer: WorldRendererCommon) {
this.parseMesherLog()
}
chunkReceived (x: number, z: number, chunkLength: number) {
// remove existing chunks with same x and z
const existingChunkIndex = this.chunksToReceive.findIndex(chunk => chunk.x === x && chunk.z === z)
if (existingChunkIndex === -1) {
// console.error('Chunk not found', x, z)
} else {
// warn if chunkLength is different
if (this.chunksToReceive[existingChunkIndex].chunkLength !== chunkLength) {
// console.warn('Chunk length mismatch', x, z, this.chunksToReceive[existingChunkIndex].chunkLength, chunkLength)
}
// remove chunk
this.chunksToReceive = this.chunksToReceive.filter((chunk, index) => chunk.x !== x || chunk.z !== z)
}
this.maybeStartReplay()
}
async maybeStartReplay () {
if (this.chunksToReceive.length !== 0 || this.replayStarted) return
const lines = log.split('\n')
console.log('starting replay')
this.replayStarted = true
const waitForWorkersMessages = async () => {
if (!this.sectionFinishedToReceive) return
await new Promise<void>(resolve => {
this.sectionFinishedToReceive!.resolve = resolve
})
}
for (const line of lines) {
if (line.includes('dispatchMessages dirty')) {
await waitForWorkersMessages()
this.worldRenderer.stopMesherMessagesProcessing = true
const message = JSON.parse(line.slice(line.indexOf('{'), line.lastIndexOf('}') + 1))
if (!message.value) continue
const index = line.split(' ')[1]
const type = line.split(' ')[3]
// console.log('sending message', message.x, message.y, message.z)
this.worldRenderer.forceCallFromMesherReplayer = true
this.worldRenderer.setSectionDirty(new Vec3(message.x, message.y, message.z), message.value)
this.worldRenderer.forceCallFromMesherReplayer = false
}
if (line.includes('-> blockUpdate')) {
await waitForWorkersMessages()
this.worldRenderer.stopMesherMessagesProcessing = true
const message = JSON.parse(line.slice(line.indexOf('{'), line.lastIndexOf('}') + 1))
this.worldRenderer.forceCallFromMesherReplayer = true
this.worldRenderer.setBlockStateIdInner(new Vec3(message.pos.x, message.pos.y, message.pos.z), message.stateId)
this.worldRenderer.forceCallFromMesherReplayer = false
}
if (line.includes(' sectionFinished ')) {
if (!this.sectionFinishedToReceive) {
console.log('starting worker message processing validating')
this.worldRenderer.stopMesherMessagesProcessing = false
this.sectionFinishedToReceive = {
messagesLeft: [],
resolve: () => {
this.sectionFinishedToReceive = null
}
}
}
const parts = line.split(' ')
const coordsPart = parts.find(part => part.split(',').length === 3)
if (!coordsPart) throw new Error(`no coords part found ${line}`)
const [x, y, z] = coordsPart.split(',').map(Number)
this.sectionFinishedToReceive.messagesLeft.push(`${x},${y},${z}`)
}
}
}
workerMessageReceived (type: string, message: any) {
if (type === 'sectionFinished') {
const { key } = message
if (!this.sectionFinishedToReceive) {
console.warn(`received sectionFinished message but no sectionFinishedToReceive ${key}`)
return
}
const idx = this.sectionFinishedToReceive.messagesLeft.indexOf(key)
if (idx === -1) {
console.warn(`received sectionFinished message for non-outstanding section ${key}`)
return
}
this.sectionFinishedToReceive.messagesLeft.splice(idx, 1)
if (this.sectionFinishedToReceive.messagesLeft.length === 0) {
this.sectionFinishedToReceive.resolve()
}
}
}
parseMesherLog () {
const lines = log.split('\n')
for (const line of lines) {
if (line.startsWith('-> chunk')) {
const chunk = JSON.parse(line.slice('-> chunk'.length))
this.chunksToReceive.push(chunk)
continue
}
}
}
}

View file

@ -26,7 +26,7 @@ export const addNewStat = (id: string, width = 80, x = rightOffset, y = lastY) =
return {
updateText (text: string) {
if (pane.innerText === text || pane.style.display === 'none') return
if (pane.innerText === text) return
pane.innerText = text
},
setVisibility (visible: boolean) {

View file

@ -36,10 +36,18 @@ export type WorldDataEmitterEvents = {
* It's up to the consumer to serialize the data if needed
*/
export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<WorldDataEmitterEvents>) {
private loadedChunks: Record<ChunkPosKey, boolean>
private readonly lastPos: Vec3
loadedChunks: Record<ChunkPosKey, boolean>
readonly lastPos: Vec3
private eventListeners: Record<string, any> = {}
private readonly emitter: WorldDataEmitter
debugChunksInfo: Record<ChunkPosKey, {
loads: Array<{
dataLength: number
reason: string
time: number
}>
// blockUpdates: number
}> = {}
waitingSpiralChunksLoad = {} as Record<ChunkPosKey, (value: boolean) => void>
@ -65,12 +73,12 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
setBlockStateId (position: Vec3, stateId: number) {
const val = this.world.setBlockStateId(position, stateId) as Promise<void> | void
if (val) throw new Error('setBlockStateId returned promise (not supported)')
const chunkX = Math.floor(position.x / 16)
const chunkZ = Math.floor(position.z / 16)
if (!this.loadedChunks[`${chunkX},${chunkZ}`] && !this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`]) {
void this.loadChunk({ x: chunkX, z: chunkZ })
return
}
// const chunkX = Math.floor(position.x / 16)
// const chunkZ = Math.floor(position.z / 16)
// if (!this.loadedChunks[`${chunkX},${chunkZ}`] && !this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`]) {
// void this.loadChunk({ x: chunkX, z: chunkZ })
// return
// }
updateBlockLight(position.x, position.y, position.z, stateId)
this.emit('blockUpdate', { pos: position, stateId })
@ -117,6 +125,8 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
if (this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`]) {
this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`](true)
delete this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`]
} else if (this.loadedChunks[`${pos.x},${pos.z}`]) {
void this.loadChunk(pos, false, 'Received another chunkColumnLoad event while already loaded')
}
},
chunkColumnUnload: (pos: Vec3) => {
@ -140,8 +150,8 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
bot._client.on('update_light', ({ chunkX, chunkZ }) => {
const chunkPos = new Vec3(chunkX * 16, 0, chunkZ * 16)
if (!this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`]) {
void this.loadChunk(chunkPos, true)
if (!this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`] && this.loadedChunks[`${chunkX},${chunkZ}`]) {
void this.loadChunk(chunkPos, true, 'update_light')
}
})
@ -240,10 +250,11 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
// debugGotChunkLatency = [] as number[]
// lastTime = 0
async loadChunk (pos: ChunkPos, isLightUpdate = false) {
async loadChunk (pos: ChunkPos, isLightUpdate = false, reason = 'spiral') {
const [botX, botZ] = chunkPos(this.lastPos)
const chunkX = Math.floor(pos.x / 16)
const chunkZ = Math.floor(pos.z / 16)
const dx = Math.abs(botX - chunkX)
const dz = Math.abs(botZ - chunkZ)
if (dx <= this.viewDistance && dz <= this.viewDistance) {
@ -271,6 +282,15 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
//@ts-expect-error
this.emitter.emit('loadChunk', { x: pos.x, z: pos.z, chunk, blockEntities: column.blockEntities, worldConfig, isLightUpdate })
this.loadedChunks[`${pos.x},${pos.z}`] = true
this.debugChunksInfo[`${pos.x},${pos.z}`] ??= {
loads: []
}
this.debugChunksInfo[`${pos.x},${pos.z}`].loads.push({
dataLength: chunk.length,
reason,
time: Date.now(),
})
} else if (this.isPlayground) { // don't allow in real worlds pre-flag chunks as loaded to avoid race condition when the chunk might still be loading. In playground it's assumed we always pre-load all chunks first
this.emitter.emit('markAsLoaded', { x: pos.x, z: pos.z })
}
@ -289,6 +309,7 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
unloadChunk (pos: ChunkPos) {
this.emitter.emit('unloadChunk', { x: pos.x, z: pos.z })
delete this.loadedChunks[`${pos.x},${pos.z}`]
delete this.debugChunksInfo[`${pos.x},${pos.z}`]
}
async updatePosition (pos: Vec3, force = false) {

View file

@ -20,6 +20,7 @@ import { addNewStat, removeAllStats, removeStat, updatePanesVisibility, updateSt
import { WorldDataEmitter } from './worldDataEmitter'
import { IPlayerState } from './basePlayerState'
import { createLightEngine, getLightEngine, getLightEngineSafe } from './lightEngine'
import { MesherLogReader } from './mesherlogReader'
function mod (x, n) {
return ((x % n) + n) % n
@ -54,6 +55,8 @@ export const defaultWorldRendererConfig = {
export type WorldRendererConfig = typeof defaultWorldRendererConfig
export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any> {
worldReadyResolvers = Promise.withResolvers<void>()
worldReadyPromise = this.worldReadyResolvers.promise
timeOfTheDay = 0
worldSizeParams = { minY: 0, worldHeight: 256 }
@ -137,12 +140,15 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
worldRendererConfig: WorldRendererConfig
playerState: IPlayerState
reactiveState: RendererReactiveState
mesherLogReader: MesherLogReader | undefined
forceCallFromMesherReplayer = false
stopMesherMessagesProcessing = false
abortController = new AbortController()
lastRendered = 0
renderingActive = true
geometryReceiveCountPerSec = 0
workerLogger = {
mesherLogger = {
contents: [] as string[],
active: new URL(location.href).searchParams.get('mesherlog') === 'true'
}
@ -153,6 +159,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
mainThreadRendering = true
backendInfoReport = '-'
chunksFullInfo = '-'
workerCustomHandleTime = 0
get version () {
return this.displayOptions.version
@ -170,7 +177,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.worldRendererConfig = displayOptions.inWorldRenderingConfig
this.playerState = displayOptions.playerState
this.reactiveState = displayOptions.rendererState
// this.mesherLogReader = new MesherLogReader(this)
this.renderUpdateEmitter.on('update', () => {
const loadedChunks = Object.keys(this.finishedChunks).length
updateStatText('loaded-chunks', `${loadedChunks}/${this.chunksLength} chunks (${this.lastChunkDistance}/${this.viewDistance})`)
@ -203,22 +210,33 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
}
logWorkerWork (message: string | (() => string)) {
if (!this.workerLogger.active) return
this.workerLogger.contents.push(typeof message === 'function' ? message() : message)
if (!this.mesherLogger.active) return
this.mesherLogger.contents.push(typeof message === 'function' ? message() : message)
}
init () {
async init () {
if (this.active) throw new Error('WorldRendererCommon is already initialized')
this.watchReactivePlayerState()
void this.setVersion(this.version).then(() => {
this.resourcesManager.on('assetsTexturesUpdated', () => {
if (!this.active) return
void this.updateAssetsData()
})
if (this.resourcesManager.currentResources) {
void this.updateAssetsData()
}
await this.resourcesManager.loadMcData(this.version)
if (!this.resourcesManager.currentResources) {
await this.resourcesManager.updateAssetsData({ })
}
await Promise.all([
this.resetWorkers(),
(async () => {
if (this.resourcesManager.currentResources) {
await this.updateAssetsData()
}
})()
])
this.resourcesManager.on('assetsTexturesUpdated', async () => {
if (!this.active) return
await this.updateAssetsData()
})
this.watchReactivePlayerState()
this.worldReadyResolvers.resolve()
}
snapshotInitialValues () { }
@ -298,7 +316,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
async processMessageQueue (source: string) {
if (this.isProcessingQueue || this.messageQueue.length === 0) return
this.logWorkerWork(`# ${source} processing queue`)
if (this.lastRendered && performance.now() - this.lastRendered > 30 && this.worldRendererConfig._experimentalSmoothChunkLoading && this.renderingActive) {
if (this.lastRendered && performance.now() - this.lastRendered > this.ONMESSAGE_TIME_LIMIT && this.worldRendererConfig._experimentalSmoothChunkLoading && this.renderingActive) {
const start = performance.now()
await new Promise(resolve => {
requestAnimationFrame(resolve)
@ -311,12 +329,15 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
let processedCount = 0
while (this.messageQueue.length > 0) {
const data = this.messageQueue.shift()!
this.handleMessage(data)
processedCount++
const processingStopped = this.stopMesherMessagesProcessing
if (!processingStopped) {
const data = this.messageQueue.shift()!
this.handleMessage(data)
processedCount++
}
// Check if we've exceeded the time limit
if (performance.now() - startTime > this.ONMESSAGE_TIME_LIMIT && this.renderingActive && this.worldRendererConfig._experimentalSmoothChunkLoading) {
if (processingStopped || (performance.now() - startTime > this.ONMESSAGE_TIME_LIMIT && this.renderingActive && this.worldRendererConfig._experimentalSmoothChunkLoading)) {
// If we have more messages and exceeded time limit, schedule next batch
if (this.messageQueue.length > 0) {
requestAnimationFrame(async () => {
@ -334,8 +355,11 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
handleMessage (data) {
if (!this.active) return
this.mesherLogReader?.workerMessageReceived(data.type, data)
if (data.type !== 'geometry' || !this.debugStopGeometryUpdate) {
const start = performance.now()
this.handleWorkerMessage(data)
this.workerCustomHandleTime += performance.now() - start
}
if (data.type === 'geometry') {
this.logWorkerWork(() => `-> ${data.workerIndex} geometry ${data.key} ${JSON.stringify({ dataSize: JSON.stringify(data).length })}`)
@ -347,7 +371,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.lastChunkDistance = Math.max(...this.getDistance(new Vec3(chunkCoords[0], 0, chunkCoords[2])))
}
if (data.type === 'sectionFinished') { // on after load & unload section
this.logWorkerWork(`-> ${data.workerIndex} sectionFinished ${data.key} ${JSON.stringify({ processTime: data.processTime })}`)
this.logWorkerWork(`<- ${data.workerIndex} sectionFinished ${data.key} ${JSON.stringify({ processTime: data.processTime })}`)
if (!this.sectionsWaiting.has(data.key)) throw new Error(`sectionFinished event for non-outstanding section ${data.key}`)
this.sectionsWaiting.set(data.key, this.sectionsWaiting.get(data.key)! - 1)
if (this.sectionsWaiting.get(data.key) === 0) {
@ -410,7 +434,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
downloadMesherLog () {
const a = document.createElement('a')
a.href = 'data:text/plain;charset=utf-8,' + encodeURIComponent(this.workerLogger.contents.join('\n'))
a.href = 'data:text/plain;charset=utf-8,' + encodeURIComponent(this.mesherLogger.contents.join('\n'))
a.download = 'mesher.log'
a.click()
}
@ -477,8 +501,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.workers = []
}
// new game load happens here
async setVersion (version: string) {
async resetWorkers () {
this.resetWorld()
// for workers in single file build
@ -491,11 +514,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.initWorkers()
this.active = true
await this.resourcesManager.loadMcData(version)
this.sendMesherMcData()
if (!this.resourcesManager.currentResources) {
await this.resourcesManager.updateAssetsData({ })
}
}
getMesherConfig (): MesherConfig {
@ -598,7 +617,8 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
lightData: getLightEngineSafe()?.worldLightHolder.dumpChunk(x, z)
})
}
this.logWorkerWork(`-> chunk ${JSON.stringify({ x, z, chunkLength: chunk.length, customBlockModelsLength: customBlockModels ? Object.keys(customBlockModels).length : 0 })}`)
this.logWorkerWork(() => `-> chunk ${JSON.stringify({ x, z, chunkLength: chunk.length, customBlockModelsLength: customBlockModels ? Object.keys(customBlockModels).length : 0 })}`)
this.mesherLogReader?.chunkReceived(x, z, chunk.length)
for (let y = this.worldMinYRender; y < this.worldSizeParams.worldHeight; y += 16) {
const loc = new Vec3(x, y, z)
this.setSectionDirty(loc)
@ -640,8 +660,9 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
this.updateChunksStats()
if (Object.keys(this.loadedChunks).length === 0) {
this.workerLogger.contents = []
this.mesherLogger.contents = []
this.logWorkerWork('# all chunks unloaded. New log started')
void this.mesherLogReader?.maybeStartReplay()
}
}
@ -852,6 +873,8 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
}
setSectionDirty (pos: Vec3, value = true, useChangeWorker = false) { // value false is used for unloading chunks
if (!this.forceCallFromMesherReplayer && this.mesherLogReader) return
if (this.viewDistance === -1) throw new Error('viewDistance not set')
this.reactiveState.world.mesherWork = true
const distance = this.getDistance(pos)
@ -863,19 +886,30 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
// Dispatch sections to workers based on position
// This guarantees uniformity accross workers and that a given section
// is always dispatched to the same worker
const hash = this.getWorkerNumber(pos, useChangeWorker)
const hash = this.getWorkerNumber(pos, useChangeWorker && this.mesherLogger.active)
this.sectionsWaiting.set(key, (this.sectionsWaiting.get(key) ?? 0) + 1)
this.toWorkerMessagesQueue[hash] ??= []
this.toWorkerMessagesQueue[hash].push({
// this.workers[hash].postMessage({
type: 'dirty',
x: pos.x,
y: pos.y,
z: pos.z,
value,
config: this.getMesherConfig(),
})
this.dispatchMessages()
if (this.forceCallFromMesherReplayer) {
this.workers[hash].postMessage({
type: 'dirty',
x: pos.x,
y: pos.y,
z: pos.z,
value,
config: this.getMesherConfig(),
})
} else {
this.toWorkerMessagesQueue[hash] ??= []
this.toWorkerMessagesQueue[hash].push({
// this.workers[hash].postMessage({
type: 'dirty',
x: pos.x,
y: pos.y,
z: pos.z,
value,
config: this.getMesherConfig(),
})
this.dispatchMessages()
}
}
dispatchMessages () {

View file

@ -120,7 +120,9 @@ export class DocumentRenderer {
this.preRender()
this.stats.markStart()
tween.update()
this.render(sizeChanged)
if (!window.freezeRender) {
this.render(sizeChanged)
}
for (const fn of this.onRender) {
fn(sizeChanged)
}
@ -189,6 +191,10 @@ class TopRightStats {
const hasRamPanel = this.stats2.dom.children.length === 3
this.addStat(this.stats.dom)
if (process.env.NODE_ENV === 'development' && document.exitPointerLock) {
this.stats.dom.style.top = ''
this.stats.dom.style.bottom = '0'
}
if (hasRamPanel) {
this.addStat(this.stats2.dom)
}

View file

@ -551,3 +551,4 @@ export class EntityMesh {
}
}
}
window.EntityMesh = EntityMesh

View file

@ -1,8 +1,8 @@
import * as THREE from 'three'
import { Vec3 } from 'vec3'
import { proxy } from 'valtio'
import { GraphicsBackendLoader, GraphicsBackend, GraphicsInitOptions, DisplayWorldOptions, RendererReactiveState } from '../../../src/appViewer'
import { GraphicsBackendLoader, GraphicsBackend, GraphicsInitOptions, DisplayWorldOptions } from '../../../src/appViewer'
import { ProgressReporter } from '../../../src/core/progressReporter'
import { showNotification } from '../../../src/react/NotificationProvider'
import { WorldRendererThree } from './worldrendererThree'
import { DocumentRenderer } from './documentRenderer'
import { PanoramaRenderer } from './panorama'
@ -53,12 +53,14 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
let panoramaRenderer: PanoramaRenderer | null = null
let worldRenderer: WorldRendererThree | null = null
const startPanorama = () => {
const startPanorama = async () => {
if (worldRenderer) return
if (!panoramaRenderer) {
panoramaRenderer = new PanoramaRenderer(documentRenderer, initOptions, !!process.env.SINGLE_FILE_BUILD_MODE)
void panoramaRenderer.start()
window.panoramaRenderer = panoramaRenderer
callModsMethod('panoramaCreated', panoramaRenderer)
await panoramaRenderer.start()
callModsMethod('panoramaReady', panoramaRenderer)
}
}
@ -68,16 +70,18 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
await initOptions.resourcesManager.updateAssetsData({ })
}
const startWorld = (displayOptions: DisplayWorldOptions) => {
const startWorld = async (displayOptions: DisplayWorldOptions) => {
if (panoramaRenderer) {
panoramaRenderer.dispose()
panoramaRenderer = null
}
worldRenderer = new WorldRendererThree(documentRenderer.renderer, initOptions, displayOptions)
await worldRenderer.worldReadyPromise
documentRenderer.render = (sizeChanged: boolean) => {
worldRenderer?.render(sizeChanged)
}
window.world = worldRenderer
callModsMethod('worldReady', worldRenderer)
}
const disconnect = () => {
@ -119,8 +123,24 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
}
}
globalThis.threeJsBackend = backend
globalThis.resourcesManager = initOptions.resourcesManager
callModsMethod('default', backend)
return backend
}
const callModsMethod = (method: string, ...args: any[]) => {
for (const mod of Object.values((window.loadedMods ?? {}) as Record<string, any>)) {
try {
mod.threeJsBackendModule?.[method]?.(...args)
} catch (err) {
const errorMessage = `[mod three.js] Error calling ${method} on ${mod.name}: ${err}`
showNotification(errorMessage, 'error')
throw new Error(errorMessage)
}
}
}
createGraphicsBackend.id = 'threejs'
export default createGraphicsBackend

View file

@ -35,6 +35,10 @@ export class ThreeJsMedia {
this.worldRenderer.onWorldSwitched.push(() => {
this.onWorldGone()
})
this.worldRenderer.onRender.push(() => {
this.render()
})
}
onWorldGone () {
@ -304,6 +308,18 @@ export class ThreeJsMedia {
return id
}
render () {
for (const [id, videoData] of this.customMedia.entries()) {
const chunkX = Math.floor(videoData.props.position.x / 16) * 16
const chunkZ = Math.floor(videoData.props.position.z / 16) * 16
const sectionY = Math.floor(videoData.props.position.y / 16) * 16
const chunkKey = `${chunkX},${chunkZ}`
const sectionKey = `${chunkX},${sectionY},${chunkZ}`
videoData.mesh.visible = !!this.worldRenderer.sectionObjects[sectionKey] || !!this.worldRenderer.finishedChunks[chunkKey]
}
}
setVideoPlaying (id: string, playing: boolean) {
const videoData = this.customMedia.get(id)
if (videoData?.video) {
@ -531,9 +547,13 @@ export class ThreeJsMedia {
console.log('Exact test mesh added with dimensions:', width, height, 'and rotation:', rotation)
}
lastCheck = 0
THROTTLE_TIME = 100
tryIntersectMedia () {
// hack: need to optimize this by pulling only in distance of interaction instead (or throttle)!
// hack: need to optimize this by pulling only in distance of interaction instead and throttle
if (this.customMedia.size === 0) return
if (Date.now() - this.lastCheck < this.THROTTLE_TIME) return
this.lastCheck = Date.now()
const { camera, scene } = this.worldRenderer
const raycaster = new THREE.Raycaster()

View file

@ -0,0 +1,160 @@
import * as THREE from 'three'
interface ParticleMesh extends THREE.Mesh {
velocity: THREE.Vector3;
}
interface ParticleConfig {
fountainHeight: number;
resetHeight: number;
xVelocityRange: number;
zVelocityRange: number;
particleCount: number;
particleRadiusRange: { min: number; max: number };
yVelocityRange: { min: number; max: number };
}
export interface FountainOptions {
position?: { x: number, y: number, z: number }
particleConfig?: Partial<ParticleConfig>;
}
export class Fountain {
private readonly particles: ParticleMesh[] = []
private readonly config: { particleConfig: ParticleConfig }
private readonly position: THREE.Vector3
container: THREE.Object3D | undefined
constructor (public sectionId: string, options: FountainOptions = {}) {
this.position = options.position ? new THREE.Vector3(options.position.x, options.position.y, options.position.z) : new THREE.Vector3(0, 0, 0)
this.config = this.createConfig(options.particleConfig)
}
private createConfig (
particleConfigOverride?: Partial<ParticleConfig>
): { particleConfig: ParticleConfig } {
const particleConfig: ParticleConfig = {
fountainHeight: 10,
resetHeight: 0,
xVelocityRange: 0.4,
zVelocityRange: 0.4,
particleCount: 400,
particleRadiusRange: { min: 0.1, max: 0.6 },
yVelocityRange: { min: 0.1, max: 2 },
...particleConfigOverride
}
return { particleConfig }
}
createParticles (container: THREE.Object3D): void {
this.container = container
const colorStart = new THREE.Color(0xff_ff_00)
const colorEnd = new THREE.Color(0xff_a5_00)
for (let i = 0; i < this.config.particleConfig.particleCount; i++) {
const radius = Math.random() *
(this.config.particleConfig.particleRadiusRange.max - this.config.particleConfig.particleRadiusRange.min) +
this.config.particleConfig.particleRadiusRange.min
const geometry = new THREE.SphereGeometry(radius)
const material = new THREE.MeshBasicMaterial({
color: colorStart.clone().lerp(colorEnd, Math.random())
})
const mesh = new THREE.Mesh(geometry, material)
const particle = mesh as unknown as ParticleMesh
particle.position.set(
this.position.x + (Math.random() - 0.5) * this.config.particleConfig.xVelocityRange * 2,
this.position.y + this.config.particleConfig.fountainHeight,
this.position.z + (Math.random() - 0.5) * this.config.particleConfig.zVelocityRange * 2
)
particle.velocity = new THREE.Vector3(
(Math.random() - 0.5) * this.config.particleConfig.xVelocityRange,
-Math.random() * this.config.particleConfig.yVelocityRange.max,
(Math.random() - 0.5) * this.config.particleConfig.zVelocityRange
)
this.particles.push(particle)
this.container.add(particle)
// this.container.onBeforeRender = () => {
// this.render()
// }
}
}
render (): void {
for (const particle of this.particles) {
particle.velocity.y -= 0.01 + Math.random() * 0.1
particle.position.add(particle.velocity)
if (particle.position.y < this.position.y + this.config.particleConfig.resetHeight) {
particle.position.set(
this.position.x + (Math.random() - 0.5) * this.config.particleConfig.xVelocityRange * 2,
this.position.y + this.config.particleConfig.fountainHeight,
this.position.z + (Math.random() - 0.5) * this.config.particleConfig.zVelocityRange * 2
)
particle.velocity.set(
(Math.random() - 0.5) * this.config.particleConfig.xVelocityRange,
-Math.random() * this.config.particleConfig.yVelocityRange.max,
(Math.random() - 0.5) * this.config.particleConfig.zVelocityRange
)
}
}
}
private updateParticleCount (newCount: number): void {
if (newCount !== this.config.particleConfig.particleCount) {
this.config.particleConfig.particleCount = newCount
const currentCount = this.particles.length
if (newCount > currentCount) {
this.addParticles(newCount - currentCount)
} else if (newCount < currentCount) {
this.removeParticles(currentCount - newCount)
}
}
}
private addParticles (count: number): void {
const geometry = new THREE.SphereGeometry(0.1)
const material = new THREE.MeshBasicMaterial({ color: 0x00_ff_00 })
for (let i = 0; i < count; i++) {
const mesh = new THREE.Mesh(geometry, material)
const particle = mesh as unknown as ParticleMesh
particle.position.copy(this.position)
particle.velocity = new THREE.Vector3(
Math.random() * this.config.particleConfig.xVelocityRange -
this.config.particleConfig.xVelocityRange / 2,
Math.random() * 2,
Math.random() * this.config.particleConfig.zVelocityRange -
this.config.particleConfig.zVelocityRange / 2
)
this.particles.push(particle)
this.container!.add(particle)
}
}
private removeParticles (count: number): void {
for (let i = 0; i < count; i++) {
const particle = this.particles.pop()
if (particle) {
this.container!.remove(particle)
}
}
}
public dispose (): void {
for (const particle of this.particles) {
particle.geometry.dispose()
if (Array.isArray(particle.material)) {
for (const material of particle.material) material.dispose()
} else {
particle.material.dispose()
}
}
}
}

View file

@ -25,12 +25,13 @@ import { Entities } from './entities'
import { ThreeJsSound } from './threeJsSound'
import { CameraShake } from './cameraShake'
import { ThreeJsMedia } from './threeJsMedia'
import { Fountain } from './threeJsParticles'
type SectionKey = string
export class WorldRendererThree extends WorldRendererCommon {
outputFormat = 'threeJs' as const
sectionObjects: Record<string, THREE.Object3D> = {}
sectionObjects: Record<string, THREE.Object3D & { foutain?: boolean }> = {}
chunkTextures = new Map<string, { [pos: string]: THREE.Texture }>()
signsCache = new Map<string, any>()
starField: StarField
@ -68,6 +69,7 @@ export class WorldRendererThree extends WorldRendererCommon {
limitZ?: number,
}
}
fountains: Fountain[] = []
get tilesRendered () {
return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj as any).tilesCount, 0)
@ -88,12 +90,15 @@ export class WorldRendererThree extends WorldRendererCommon {
this.addDebugOverlay()
this.resetScene()
this.init()
void this.init()
void initVR(this)
this.soundSystem = new ThreeJsSound(this)
this.cameraShake = new CameraShake(this.camera, this.onRender)
this.media = new ThreeJsMedia(this)
// this.fountain = new Fountain(this.scene, this.scene, {
// position: new THREE.Vector3(0, 10, 0),
// })
this.renderUpdateEmitter.on('chunkFinished', (chunkKey: string) => {
this.finishChunk(chunkKey)
@ -333,7 +338,7 @@ export class WorldRendererThree extends WorldRendererCommon {
geometry.setAttribute('normal', new THREE.BufferAttribute(data.geometry.normals, 3))
geometry.setAttribute('color', new THREE.BufferAttribute(data.geometry.colors, 3))
geometry.setAttribute('uv', new THREE.BufferAttribute(data.geometry.uvs, 2))
geometry.setIndex(data.geometry.indices)
geometry.index = new THREE.BufferAttribute(data.geometry.indices as Uint32Array | Uint16Array, 1)
const mesh = new THREE.Mesh(geometry, this.material)
mesh.position.set(data.geometry.sx, data.geometry.sy, data.geometry.sz)
@ -443,7 +448,6 @@ export class WorldRendererThree extends WorldRendererCommon {
const start = performance.now()
this.lastRendered = performance.now()
this.cursorBlock.render()
this.updateSectionOffsets()
const sizeOrFovChanged = sizeChanged || this.displayOptions.inWorldRenderingConfig.fov !== this.camera.fov
@ -464,6 +468,14 @@ export class WorldRendererThree extends WorldRendererCommon {
this.holdingBlockLeft.render(this.camera, this.renderer, this.ambientLight, this.directionalLight)
}
for (const fountain of this.fountains) {
if (this.sectionObjects[fountain.sectionId] && !this.sectionObjects[fountain.sectionId].foutain) {
fountain.createParticles(this.sectionObjects[fountain.sectionId])
this.sectionObjects[fountain.sectionId].foutain = true
}
fountain.render()
}
for (const onRender of this.onRender) {
onRender()
}

View file

@ -173,6 +173,7 @@ const appConfig = defineConfig({
fs.copyFileSync('./assets/favicon.png', './dist/favicon.png')
fs.copyFileSync('./assets/playground.html', './dist/playground.html')
fs.copyFileSync('./assets/manifest.json', './dist/manifest.json')
fs.copyFileSync('./assets/config.html', './dist/config.html')
fs.copyFileSync('./assets/loading-bg.jpg', './dist/loading-bg.jpg')
if (fs.existsSync('./assets/release.json')) {
fs.copyFileSync('./assets/release.json', './dist/release.json')

View file

@ -11,7 +11,12 @@ import supportedVersions from '../src/supportedVersions.mjs'
const __dirname = path.dirname(fileURLToPath(new URL(import.meta.url)))
const targetedVersions = supportedVersions.reverse()
export const versionToNumber = (ver) => {
const [x, y = '0', z = '0'] = ver.split('.')
return +`${x.padStart(2, '0')}${y.padStart(2, '0')}${z.padStart(2, '0')}`
}
const targetedVersions = [...supportedVersions].sort((a, b) => versionToNumber(b) - versionToNumber(a))
/** @type {{name, size, hash}[]} */
let prevSounds = null
@ -173,13 +178,36 @@ const writeSoundsMap = async () => {
// todo REMAP ONLY IDS. Do diffs, as mostly only ids are changed between versions
// const localTargetedVersions = targetedVersions.slice(0, 2)
let lastMappingsJson
const localTargetedVersions = targetedVersions
for (const targetedVersion of localTargetedVersions) {
for (const targetedVersion of [...localTargetedVersions].reverse()) {
console.log('Processing version', targetedVersion)
const burgerData = await fetch(burgerDataUrl(targetedVersion)).then((r) => r.json()).catch((err) => {
console.error('error fetching burger data', targetedVersion, err)
// console.error('error fetching burger data', targetedVersion, err)
return null
})
if (!burgerData) continue
/** @type {{sounds: string[]}} */
const mappingJson = await fetch(`https://raw.githubusercontent.com/ViaVersion/Mappings/7a45c1f9dbc1f1fdadacfecdb205ba84e55766fc/mappings/mapping-${targetedVersion}.json`).then(async (r) => {
return r.json()
// lastMappingsJson = r.status === 404 ? lastMappingsJson : (await r.json())
// if (r.status === 404) {
// console.warn('using prev mappings json for ' + targetedVersion)
// }
// return lastMappingsJson
}).catch((err) => {
// console.error('error fetching mapping json', targetedVersion, err)
return null
})
// if (!mappingJson) throw new Error('no initial mapping json for ' + targetedVersion)
if (burgerData && !mappingJson) {
console.warn('has burger but no mapping json for ' + targetedVersion)
continue
}
if (!mappingJson || !burgerData) {
console.warn('no mapping json or burger data for ' + targetedVersion)
continue
}
const allSoundsMap = getSoundsMap(burgerData)
// console.log(Object.keys(sounds).length, 'ids')
const outputIdMap = {}
@ -190,7 +218,7 @@ const writeSoundsMap = async () => {
new: 0,
same: 0
}
for (const { id, subtitle, sounds, name } of Object.values(allSoundsMap)) {
for (const { _id, subtitle, sounds, name } of Object.values(allSoundsMap)) {
if (!sounds?.length /* && !subtitle */) continue
const firstName = sounds[0].name
// const includeSound = isSoundWhitelisted(firstName)
@ -210,6 +238,11 @@ const writeSoundsMap = async () => {
if (sound.weight && isNaN(sound.weight)) debugger
outputUseSoundLine.push(`${sound.volume ?? 1};${sound.name};${sound.weight ?? minWeight}`)
}
const id = mappingJson.sounds.findIndex(x => x === name)
if (id === -1) {
console.warn('no id for sound', name, targetedVersion)
continue
}
const key = `${id};${name}`
outputIdMap[key] = outputUseSoundLine.join(',')
if (prevMap && prevMap[key]) {
@ -283,6 +316,6 @@ if (action) {
} else {
// downloadAllSoundsAndCreateMap()
// convertSounds()
// writeSoundsMap()
makeSoundsBundle()
writeSoundsMap()
// makeSoundsBundle()
}

View file

@ -1,7 +1,9 @@
import { defaultsDeep } from 'lodash'
import { disabledSettings, options, qsOptions } from './optionsStorage'
import { miscUiState } from './globalState'
import { setLoadingScreenStatus } from './appStatus'
import { setStorageDataOnAppConfigLoad } from './react/appStorageProvider'
import { customKeymaps, updateBinds } from './controls'
export type AppConfig = {
// defaultHost?: string
@ -15,12 +17,19 @@ export type AppConfig = {
mapsProvider?: string
appParams?: Record<string, any> // query string params
rightSideText?: string
defaultSettings?: Record<string, any>
forceSettings?: Record<string, boolean>
// hideSettings?: Record<string, boolean>
allowAutoConnect?: boolean
splashText?: string
pauseLinks?: Array<Array<Record<string, any>>>
keybindings?: Record<string, any>
defaultLanguage?: string
displayLanguageSelector?: boolean
supportedLanguages?: string[]
showModsButton?: boolean
}
export const loadAppConfig = (appConfig: AppConfig) => {
@ -44,6 +53,11 @@ export const loadAppConfig = (appConfig: AppConfig) => {
}
}
if (appConfig.keybindings) {
Object.assign(customKeymaps, defaultsDeep(appConfig.keybindings, customKeymaps))
updateBinds(customKeymaps)
}
setStorageDataOnAppConfigLoad()
}

View file

@ -42,6 +42,9 @@ export type AppQsParams = {
suggest_save?: string
noPacketsValidation?: string
testCrashApp?: string
onlyConnect?: string
connectText?: string
freezeSettings?: string
// Replay params
replayFilter?: string

View file

@ -89,6 +89,8 @@ export interface GraphicsBackend {
}
export class AppViewer {
waitBackendLoadPromises = [] as Array<Promise<void>>
resourcesManager = new ResourcesManager()
worldView: WorldDataEmitter | undefined
readonly config: GraphicsBackendConfig = {
@ -114,11 +116,14 @@ export class AppViewer {
this.disconnectBackend()
}
loadBackend (loader: GraphicsBackendLoader) {
async loadBackend (loader: GraphicsBackendLoader) {
if (this.backend) {
this.disconnectBackend()
}
await Promise.all(this.waitBackendLoadPromises)
this.waitBackendLoadPromises = []
this.backendLoader = loader
const rendererSpecificSettings = {} as Record<string, any>
const rendererSettingsKey = `renderer.${this.backendLoader?.id}`

View file

@ -9,25 +9,27 @@ import { showNotification } from './react/NotificationProvider'
const backends = [
createGraphicsBackend,
]
const loadBackend = () => {
const loadBackend = async () => {
let backend = backends.find(backend => backend.id === options.activeRenderer)
if (!backend) {
showNotification(`No backend found for renderer ${options.activeRenderer}`, `Falling back to ${backends[0].id}`, true)
backend = backends[0]
}
appViewer.loadBackend(backend)
await appViewer.loadBackend(backend)
}
window.loadBackend = loadBackend
if (process.env.SINGLE_FILE_BUILD_MODE) {
const unsub = subscribeKey(miscUiState, 'fsReady', () => {
if (miscUiState.fsReady) {
// don't do it earlier to load fs and display menu faster
loadBackend()
void loadBackend()
unsub()
}
})
} else {
loadBackend()
setTimeout(() => {
void loadBackend()
})
}
const animLoop = () => {
@ -40,10 +42,10 @@ watchOptionsAfterViewerInit()
// reset backend when renderer changes
subscribeKey(options, 'activeRenderer', () => {
subscribeKey(options, 'activeRenderer', async () => {
if (appViewer.currentDisplay === 'world' && bot) {
appViewer.resetBackend(true)
loadBackend()
await loadBackend()
void appViewer.startWithBot()
}
})

View file

@ -74,9 +74,8 @@ export const onControInit = () => {
}
function pointerLockChangeCallback () {
if (notificationProxy.id === 'pointerlockchange') {
hideNotification()
}
hideNotification('pointerlockchange')
if (appViewer.rendererState.preventEscapeMenu) return
if (!pointerLock.hasPointerLock && activeModalStack.length === 0 && miscUiState.gameLoaded) {
showModal({ reactType: 'pause-screen' })

582
src/clientMods.ts Normal file
View file

@ -0,0 +1,582 @@
/* eslint-disable no-await-in-loop */
import { openDB } from 'idb'
import * as React from 'react'
import * as valtio from 'valtio'
import * as valtioUtils from 'valtio/utils'
import { gt } from 'semver'
import { proxy } from 'valtio'
import { options } from './optionsStorage'
import { appStorage } from './react/appStorageProvider'
import { showInputsModal, showOptionsModal } from './react/SelectOption'
import { ProgressReporter } from './core/progressReporter'
let sillyProtection = false
const protectRuntime = () => {
if (sillyProtection) return
sillyProtection = true
const sensetiveKeys = new Set(['authenticatedAccounts', 'serversList', 'username'])
const proxy = new Proxy(window.localStorage, {
get (target, prop) {
if (typeof prop === 'string') {
if (sensetiveKeys.has(prop)) {
console.warn(`Access to sensitive key "${prop}" was blocked`)
return null
}
if (prop === 'getItem') {
return (key: string) => {
if (sensetiveKeys.has(key)) {
console.warn(`Access to sensitive key "${key}" via getItem was blocked`)
return null
}
return target.getItem(key)
}
}
if (prop === 'setItem') {
return (key: string, value: string) => {
if (sensetiveKeys.has(key)) {
console.warn(`Attempt to set sensitive key "${key}" via setItem was blocked`)
return
}
target.setItem(key, value)
}
}
if (prop === 'removeItem') {
return (key: string) => {
if (sensetiveKeys.has(key)) {
console.warn(`Attempt to delete sensitive key "${key}" via removeItem was blocked`)
return
}
target.removeItem(key)
}
}
if (prop === 'clear') {
console.warn('Attempt to clear localStorage was blocked')
return () => {}
}
}
return Reflect.get(target, prop)
},
set (target, prop, value) {
if (typeof prop === 'string' && sensetiveKeys.has(prop)) {
console.warn(`Attempt to set sensitive key "${prop}" was blocked`)
return false
}
return Reflect.set(target, prop, value)
},
deleteProperty (target, prop) {
if (typeof prop === 'string' && sensetiveKeys.has(prop)) {
console.warn(`Attempt to delete sensitive key "${prop}" was blocked`)
return false
}
return Reflect.deleteProperty(target, prop)
}
})
Object.defineProperty(window, 'localStorage', {
value: proxy,
writable: false,
configurable: false,
})
}
// #region Database
const dbPromise = openDB('mods-db', 1, {
upgrade (db) {
db.createObjectStore('mods', {
keyPath: 'name',
})
db.createObjectStore('repositories', {
keyPath: 'url',
})
},
})
// mcraft-repo.json
export interface McraftRepoFile {
packages: ClientModDefinition[]
/** @default true */
prefix?: string | boolean
name?: string // display name
description?: string
mirrorUrls?: string[]
autoUpdateOverride?: boolean
lastUpdated?: number
}
export interface Repository extends McraftRepoFile {
url: string
}
export interface ClientMod {
name: string; // unique identifier like owner.name
version: string
enabled?: boolean
scriptMainUnstable?: string;
serverPlugin?: string
// serverPlugins?: string[]
// mesherThread?: string
stylesGlobal?: string
threeJsBackend?: string // three.js
// stylesLocal?: string
requiresNetwork?: boolean
fullyOffline?: boolean
description?: string
author?: string
section?: string
autoUpdateOverride?: boolean
lastUpdated?: number
wasModifiedLocally?: boolean
// todo depends, hashsum
}
const cleanupFetchedModData = (mod: ClientModDefinition | Record<string, any>) => {
delete mod['enabled']
delete mod['repo']
delete mod['autoUpdateOverride']
delete mod['lastUpdated']
delete mod['wasModifiedLocally']
return mod
}
export type ClientModDefinition = Omit<ClientMod, 'enabled' | 'wasModifiedLocally'> & {
scriptMainUnstable?: boolean
stylesGlobal?: boolean
serverPlugin?: boolean
threeJsBackend?: boolean
}
export async function saveClientModData (data: ClientMod) {
const db = await dbPromise
data.lastUpdated = Date.now()
await db.put('mods', data)
modsReactiveUpdater.counter++
}
async function getPlugin (name: string) {
const db = await dbPromise
return db.get('mods', name) as Promise<ClientMod | undefined>
}
async function getAllMods () {
const db = await dbPromise
return db.getAll('mods') as Promise<ClientMod[]>
}
async function deletePlugin (name) {
const db = await dbPromise
await db.delete('mods', name)
modsReactiveUpdater.counter++
}
async function removeAllMods () {
const db = await dbPromise
await db.clear('mods')
modsReactiveUpdater.counter++
}
// ---
async function saveRepository (data: Repository) {
const db = await dbPromise
data.lastUpdated = Date.now()
await db.put('repositories', data)
}
async function getRepository (url: string) {
const db = await dbPromise
return db.get('repositories', url) as Promise<Repository | undefined>
}
async function getAllRepositories () {
const db = await dbPromise
return db.getAll('repositories') as Promise<Repository[]>
}
window.getAllRepositories = getAllRepositories
async function deleteRepository (url) {
const db = await dbPromise
await db.delete('repositories', url)
}
// ---
// #endregion
window.mcraft = {
version: process.env.RELEASE_TAG,
build: process.env.BUILD_VERSION,
ui: {},
React,
valtio: {
...valtio,
...valtioUtils,
},
// openDB
}
const activateMod = async (mod: ClientMod, reason: string) => {
if (mod.enabled === false) return false
protectRuntime()
console.debug(`Activating mod ${mod.name} (${reason})...`)
window.loadedMods ??= {}
if (window.loadedMods[mod.name]) {
console.warn(`Mod is ${mod.name} already loaded, skipping activation...`)
return false
}
if (mod.stylesGlobal) {
const style = document.createElement('style')
style.textContent = mod.stylesGlobal
style.id = `mod-${mod.name}`
document.head.appendChild(style)
}
if (mod.scriptMainUnstable) {
const blob = new Blob([mod.scriptMainUnstable], { type: 'text/javascript' })
const url = URL.createObjectURL(blob)
// eslint-disable-next-line no-useless-catch
try {
const module = await import(/* webpackIgnore: true */ url)
module.default?.(structuredClone(mod))
window.loadedMods[mod.name] ??= {}
window.loadedMods[mod.name].mainUnstableModule = module
} catch (e) {
throw e
}
URL.revokeObjectURL(url)
}
if (mod.threeJsBackend) {
const blob = new Blob([mod.threeJsBackend], { type: 'text/javascript' })
const url = URL.createObjectURL(blob)
// eslint-disable-next-line no-useless-catch
try {
const module = await import(/* webpackIgnore: true */ url)
// todo
window.loadedMods[mod.name] ??= {}
// for accessing global world var
window.loadedMods[mod.name].threeJsBackendModule = module
} catch (e) {
throw e
}
URL.revokeObjectURL(url)
}
mod.enabled = true
return true
}
export const appStartup = async () => {
void checkModsUpdates()
const mods = await getAllMods()
for (const mod of mods) {
await activateMod(mod, 'autostart').catch(e => {
modsErrors[mod.name] ??= []
modsErrors[mod.name].push(`startup: ${String(e)}`)
console.error(`Error activating mod on startup ${mod.name}:`, e)
})
}
}
export const modsUpdateStatus = proxy({} as Record<string, [string, string]>)
export const modsWaitingReloadStatus = proxy({} as Record<string, boolean>)
export const modsErrors = proxy({} as Record<string, string[]>)
const normalizeRepoUrl = (url: string) => {
if (url.startsWith('https://')) return url
if (url.startsWith('http://')) return url
if (url.startsWith('//')) return `https:${url}`
return `https://raw.githubusercontent.com/${url}/master`
}
const installOrUpdateMod = async (repo: Repository, mod: ClientModDefinition, activate = true, progress?: ProgressReporter) => {
// eslint-disable-next-line no-useless-catch
try {
const fetchData = async (urls: string[]) => {
const errored = [] as string[]
// eslint-disable-next-line no-unreachable-loop
for (const urlTemplate of urls) {
const modNameOnly = mod.name.split('.').pop()
const modFolder = repo.prefix === false ? modNameOnly : typeof repo.prefix === 'string' ? `${repo.prefix}/${modNameOnly}` : mod.name
const url = new URL(`${modFolder}/${urlTemplate}`, normalizeRepoUrl(repo.url).replace(/\/$/, '') + '/').href
// eslint-disable-next-line no-useless-catch
try {
const response = await fetch(url)
if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`)
return await response.text()
} catch (e) {
// errored.push(String(e))
throw e
}
}
console.warn(`[${mod.name}] Error installing component of ${urls[0]}: ${errored.join(', ')}`)
return undefined
}
if (mod.stylesGlobal) {
await progress?.executeWithMessage(
`Downloading ${mod.name} styles`,
async () => {
mod.stylesGlobal = await fetchData(['global.css']) as any
}
)
}
if (mod.scriptMainUnstable) {
await progress?.executeWithMessage(
`Downloading ${mod.name} script`,
async () => {
mod.scriptMainUnstable = await fetchData(['mainUnstable.js']) as any
}
)
}
if (mod.threeJsBackend) {
await progress?.executeWithMessage(
`Downloading ${mod.name} three.js backend`,
async () => {
mod.threeJsBackend = await fetchData(['three.js']) as any
}
)
}
if (mod.serverPlugin) {
if (mod.name.endsWith('.disabled')) throw new Error(`Mod name ${mod.name} can't end with .disabled`)
await progress?.executeWithMessage(
`Downloading ${mod.name} server plugin`,
async () => {
mod.serverPlugin = await fetchData(['serverPlugin.js']) as any
}
)
}
if (activate) {
// todo try to de-activate mod if it's already loaded
if (window.loadedMods?.[mod.name]) {
modsWaitingReloadStatus[mod.name] = true
} else {
await activateMod(mod as ClientMod, 'install')
}
}
await saveClientModData(mod as ClientMod)
delete modsUpdateStatus[mod.name]
} catch (e) {
// console.error(`Error installing mod ${mod.name}:`, e)
throw e
}
}
const checkRepositoryUpdates = async (repo: Repository) => {
for (const mod of repo.packages) {
const modExisting = await getPlugin(mod.name)
if (modExisting?.version && gt(mod.version, modExisting.version)) {
modsUpdateStatus[mod.name] = [modExisting.version, mod.version]
if (options.modsAutoUpdate === 'always' && (!repo.autoUpdateOverride && !modExisting.autoUpdateOverride)) {
void installOrUpdateMod(repo, mod).catch(e => {
console.error(`Error updating mod ${mod.name}:`, e)
})
}
}
}
}
export const fetchRepository = async (urlOriginal: string, url: string, hasMirrors = false) => {
const fetchUrl = normalizeRepoUrl(url).replace(/\/$/, '') + '/mcraft-repo.json'
try {
const response = await fetch(fetchUrl).then(async res => res.json())
if (!response.packages) throw new Error(`No packages field in the response json of the repository: ${fetchUrl}`)
response.autoUpdateOverride = (await getRepository(urlOriginal))?.autoUpdateOverride
response.url = urlOriginal
void saveRepository(response)
modsReactiveUpdater.counter++
return true
} catch (e) {
console.warn(`Error fetching repository (trying other mirrors) ${url}:`, e)
return false
}
}
export const fetchAllRepositories = async () => {
const repositories = await getAllRepositories()
await Promise.all(repositories.map(async (repo) => {
const allUrls = [repo.url, ...(repo.mirrorUrls || [])]
for (const [i, url] of allUrls.entries()) {
const isLast = i === allUrls.length - 1
if (await fetchRepository(repo.url, url, !isLast)) break
}
}))
appStorage.modsAutoUpdateLastCheck = Date.now()
}
const checkModsUpdates = async () => {
await autoRefreshModRepositories()
for (const repo of await getAllRepositories()) {
await checkRepositoryUpdates(repo)
}
}
const autoRefreshModRepositories = async () => {
if (options.modsAutoUpdate === 'never') return
const lastCheck = appStorage.modsAutoUpdateLastCheck
if (lastCheck && Date.now() - lastCheck < 1000 * 60 * 60 * options.modsUpdatePeriodCheck) return
await fetchAllRepositories()
// todo think of not updating check timestamp on offline access
}
export const installModByName = async (repoUrl: string, name: string, progress?: ProgressReporter) => {
progress?.beginStage('main', `Installing ${name}`)
const repo = await getRepository(repoUrl)
if (!repo) throw new Error(`Repository ${repoUrl} not found`)
const mod = repo.packages.find(m => m.name === name)
if (!mod) throw new Error(`Mod ${name} not found in repository ${repoUrl}`)
await installOrUpdateMod(repo, mod, undefined, progress)
progress?.endStage('main')
}
export const uninstallModAction = async (name: string) => {
const choice = await showOptionsModal(`Uninstall mod ${name}?`, ['Yes'])
if (!choice) return
await deletePlugin(name)
window.loadedMods ??= {}
if (window.loadedMods[name]) {
// window.loadedMods[name].default?.(null)
delete window.loadedMods[name]
modsWaitingReloadStatus[name] = true
}
// Clear any errors associated with the mod
delete modsErrors[name]
}
export const setEnabledModAction = async (name: string, newEnabled: boolean) => {
const mod = await getPlugin(name)
if (!mod) throw new Error(`Mod ${name} not found`)
if (newEnabled) {
mod.enabled = true
if (!window.loadedMods?.[mod.name]) {
await activateMod(mod, 'manual')
}
} else {
// todo deactivate mod
mod.enabled = false
if (window.loadedMods?.[mod.name]) {
if (window.loadedMods[mod.name]?.threeJsBackendModule) {
window.loadedMods[mod.name].threeJsBackendModule.deactivate()
delete window.loadedMods[mod.name].threeJsBackendModule
}
if (window.loadedMods[mod.name]?.mainUnstableModule) {
window.loadedMods[mod.name].mainUnstableModule.deactivate()
delete window.loadedMods[mod.name].mainUnstableModule
}
if (Object.keys(window.loadedMods[mod.name]).length === 0) {
delete window.loadedMods[mod.name]
}
}
}
await saveClientModData(mod)
}
export const modsReactiveUpdater = proxy({
counter: 0
})
export const getAllModsDisplayList = async () => {
const repos = await getAllRepositories()
const installedMods = await getAllMods()
const modsWithoutRepos = installedMods.filter(mod => !repos.some(repo => repo.packages.some(m => m.name === mod.name)))
const mapMods = (mapMods: ClientMod[]) => mapMods.map(mod => ({
...mod,
installed: installedMods.find(m => m.name === mod.name),
activated: !!window.loadedMods?.[mod.name],
installedVersion: installedMods.find(m => m.name === mod.name)?.version,
canBeActivated: mod.scriptMainUnstable || mod.stylesGlobal,
}))
return {
repos: repos.map(repo => ({
...repo,
packages: mapMods(repo.packages as ClientMod[]),
})),
modsWithoutRepos: mapMods(modsWithoutRepos),
}
}
export const removeRepositoryAction = async (url: string) => {
// todo remove mods
const choice = await showOptionsModal('Remove repository? Installed mods wont be automatically removed.', ['Yes'])
if (!choice) return
await deleteRepository(url)
modsReactiveUpdater.counter++
}
export const selectAndRemoveRepository = async () => {
const repos = await getAllRepositories()
const choice = await showOptionsModal('Select repository to remove', repos.map(repo => repo.url))
if (!choice) return
await removeRepositoryAction(choice)
}
export const addRepositoryAction = async () => {
const { url } = await showInputsModal('Add repository', {
url: {
type: 'text',
label: 'Repository URL or slug',
placeholder: 'github-owner/repo-name',
},
})
if (!url) return
await fetchRepository(url, url)
}
export const getServerPlugin = async (plugin: string) => {
const mod = await getPlugin(plugin)
if (!mod) return null
if (mod.serverPlugin) {
return {
content: mod.serverPlugin,
version: mod.version
}
}
return null
}
export const getAvailableServerPlugins = async () => {
const mods = await getAllMods()
return mods.filter(mod => mod.serverPlugin)
}
window.inspectInstalledMods = getAllMods
type ModifiableField = {
field: string
label: string
language: string
getContent?: () => string
}
// ---
export const getAllModsModifiableFields = () => {
const fields: ModifiableField[] = [
{
field: 'scriptMainUnstable',
label: 'Main Thread Script (unstable)',
language: 'js'
},
{
field: 'stylesGlobal',
label: 'Global CSS Styles',
language: 'css'
},
{
field: 'threeJsBackend',
label: 'Three.js Renderer Backend Thread',
language: 'js'
},
{
field: 'serverPlugin',
label: 'Built-in server plugin',
language: 'js'
}
]
return fields
}
export const getModModifiableFields = (mod: ClientMod): ModifiableField[] => {
return getAllModsModifiableFields().filter(field => mod[field.field])
}

View file

@ -41,36 +41,43 @@ const controlOptions = {
export const contro = new ControMax({
commands: {
general: {
// movement
jump: ['Space', 'A'],
inventory: ['KeyE', 'X'],
drop: ['KeyQ', 'B'],
sneak: ['ShiftLeft'],
toggleSneakOrDown: [null, 'Right Stick'],
sprint: ['ControlLeft', 'Left Stick'],
// game interactions
nextHotbarSlot: [null, 'Right Bumper'],
prevHotbarSlot: [null, 'Left Bumper'],
attackDestroy: [null, 'Right Trigger'],
interactPlace: [null, 'Left Trigger'],
chat: [['KeyT', 'Enter']],
command: ['Slash'],
swapHands: ['KeyF'],
zoom: ['KeyC'],
selectItem: ['KeyH'], // default will be removed
rotateCameraLeft: [null],
rotateCameraRight: [null],
rotateCameraUp: [null],
rotateCameraDown: [null],
viewerConsole: ['Backquote']
// ui?
chat: [['KeyT', 'Enter']],
command: ['Slash'],
// client side
zoom: ['KeyC'],
viewerConsole: ['Backquote'],
},
ui: {
toggleFullscreen: ['F11'],
back: [null/* 'Escape' */, 'B'],
toggleMap: ['KeyM'],
toggleMap: ['KeyJ'],
leftClick: [null, 'A'],
rightClick: [null, 'Y'],
speedupCursor: [null, 'Left Stick'],
pauseMenu: [null, 'Start']
},
communication: {
toggleMicrophone: ['KeyK'],
},
advanced: {
lockUrl: ['KeyY'],
},
@ -177,6 +184,7 @@ contro.on('movementUpdate', ({ vector, soleVector, gamepadIndex }) => {
if (action) {
void contro.emit('trigger', { command: 'general.forward' } as any)
} else {
void contro.emit('release', { command: 'general.forward' } as any)
setSprinting(false)
}
}
@ -549,6 +557,10 @@ contro.on('trigger', ({ command }) => {
}
}
if (command === 'communication.toggleMicrophone') {
// toggleMicrophoneMuted()
}
if (command === 'ui.pauseMenu') {
showModal({ reactType: 'pause-screen' })
}
@ -613,6 +625,13 @@ export const f3Keybinds: Array<{
},
mobileTitle: 'Toggle chunk borders',
},
{
key: 'KeyH',
action () {
showModal({ reactType: 'chunks-debug' })
},
mobileTitle: 'Show Chunks Debug',
},
{
key: 'KeyY',
async action () {
@ -761,6 +780,11 @@ const selectItem = async () => {
}
addEventListener('mousedown', async (e) => {
// always prevent default for side buttons (back / forward navigation)
if (e.button === 3 || e.button === 4) {
e.preventDefault()
}
if ((e.target as HTMLElement).matches?.('#VRButton')) return
if (!isInRealGameSession() && !(e.target as HTMLElement).id.includes('ui-root')) return
void pointerLock.requestPointerLock()

View file

@ -1,6 +1,7 @@
import { setLoadingScreenStatus } from '../appStatus'
import { appStatusState } from '../react/AppStatusProvider'
import { hideNotification, showNotification } from '../react/NotificationProvider'
import { pixelartIcons } from '../react/PixelartIcon'
export interface ProgressReporter {
currentMessage: string | undefined
@ -163,15 +164,16 @@ export const createFullScreenProgressReporter = (): ProgressReporter => {
}
export const createNotificationProgressReporter = (endMessage?: string): ProgressReporter => {
const id = `progress-reporter-${Math.random().toString(36).slice(2)}`
return createProgressReporter({
setMessage (message: string) {
showNotification(`${message}...`, '', false, '', undefined, true)
showNotification(`${message}...`, '', false, '', undefined, true, id)
},
end () {
if (endMessage) {
showNotification(endMessage, '', false, '', undefined, true)
showNotification(endMessage, '', false, pixelartIcons.check, undefined, true)
} else {
hideNotification()
hideNotification(id)
}
},

View file

@ -218,10 +218,6 @@ const registerMediaChannels = () => {
{ name: 'z', type: 'f32' },
{ name: 'width', type: 'f32' },
{ name: 'height', type: 'f32' },
// N, 0
// W, 3
// S, 2
// E, 1
{ name: 'rotation', type: 'i16' }, // 0: 0° - towards positive z, 1: 90° - positive x, 2: 180° - negative z, 3: 270° - negative x (3-6 is same but double side)
{ name: 'source', type: ['pstring', { countType: 'i16' }] },
{ name: 'loop', type: 'bool' },

View file

@ -8,6 +8,7 @@ module.exports = {
'gameMode': 0,
'difficulty': 0,
'worldFolder': 'world',
'pluginsFolder': true,
// todo set sid, disable entities auto-spawn
'generation': {
// grass_field
@ -33,6 +34,6 @@ module.exports = {
keepAlive: false,
'everybody-op': true,
'max-entities': 100,
'version': '1.18.2',
'version': '1.18',
versionMajor: '1.18'
}

View file

@ -6,6 +6,7 @@ import { subscribeKey } from 'valtio/utils'
import { getThreeJsRendererMethods } from 'renderer/viewer/three/threeJsMethods'
import { options, watchValue } from './optionsStorage'
import { miscUiState } from './globalState'
import { EntityStatus } from './mineflayer/entityStatus'
const updateAutoJump = () => {
@ -85,7 +86,7 @@ customEvents.on('gameLoaded', () => {
bot._client.on('entity_status', (data) => {
if (versionToNumber(bot.version) >= versionToNumber('1.19.4')) return
const { entityId, entityStatus } = data
if (entityStatus === 2) {
if (entityStatus === EntityStatus.HURT) {
getThreeJsRendererMethods()?.damageEntity(entityId, entityStatus)
}
})

View file

@ -8,7 +8,11 @@ import { AppConfig } from './appConfig'
// todo: refactor structure with support of hideNext=false
export const notHideableModalsWithoutForce = new Set(['app-status'])
export const notHideableModalsWithoutForce = new Set([
'app-status',
'divkit:nonclosable',
'only-connect-server',
])
type Modal = ({ elem?: HTMLElement & Record<string, any> } & { reactType: string })
@ -35,10 +39,10 @@ const showModalInner = (modal: Modal) => {
return true
}
export const showModal = (elem: /* (HTMLElement & Record<string, any>) | */{ reactType: string }) => {
const resolved = elem
export const showModal = (elem: /* (HTMLElement & Record<string, any>) | */{ reactType: string } | string) => {
const resolved = typeof elem === 'string' ? { reactType: elem } : elem
const curModal = activeModalStack.at(-1)
if (/* elem === curModal?.elem || */(elem.reactType && elem.reactType === curModal?.reactType) || !showModalInner(resolved)) return
if ((resolved.reactType && resolved.reactType === curModal?.reactType) || !showModalInner(resolved)) return
activeModalStack.push(resolved)
}
@ -49,7 +53,7 @@ export const showModal = (elem: /* (HTMLElement & Record<string, any>) | */{ re
export const hideModal = (modal = activeModalStack.at(-1), data: any = undefined, options: { force?: boolean; restorePrevious?: boolean } = {}) => {
const { force = false, restorePrevious = true } = options
if (!modal) return
let cancel = notHideableModalsWithoutForce.has(modal.reactType) ? !force : undefined
let cancel = [...notHideableModalsWithoutForce].some(m => modal.reactType.startsWith(m)) ? !force : undefined
if (force) {
cancel = undefined
}

View file

@ -14,6 +14,7 @@ import './mineflayer/java-tester/index'
import './external'
import './appConfig'
import './mineflayer/timers'
import './mineflayer/plugins'
import { getServerInfo } from './mineflayer/mc-protocol'
import { onGameLoad } from './inventoryWindows'
import initCollisionShapes from './getCollisionInteractionShapes'
@ -27,8 +28,7 @@ import { options } from './optionsStorage'
import './reactUi'
import { lockUrl, onBotCreate } from './controls'
import './dragndrop'
import { possiblyCleanHandle, resetStateAfterDisconnect } from './browserfs'
import { watchOptionsAfterViewerInit, watchOptionsAfterWorldViewInit } from './watchOptions'
import { possiblyCleanHandle } from './browserfs'
import downloadAndOpenFile from './downloadAndOpenFile'
import fs from 'fs'
@ -74,29 +74,29 @@ import { showNotification } from './react/NotificationProvider'
import { saveToBrowserMemory } from './react/PauseScreen'
import './devReload'
import './water'
import { ConnectOptions, loadMinecraftData, getVersionAutoSelect, downloadOtherGameData, downloadAllMinecraftData } from './connect'
import { ConnectOptions, getVersionAutoSelect, downloadOtherGameData, downloadAllMinecraftData } from './connect'
import { ref, subscribe } from 'valtio'
import { signInMessageState } from './react/SignInMessageProvider'
import { updateAuthenticatedAccountData, updateLoadedServerData, updateServerConnectionHistory } from './react/serversStorage'
import packetsPatcher from './mineflayer/plugins/packetsPatcher'
import { mainMenuState } from './react/MainMenuRenderApp'
import './mobileShim'
import { parseFormattedMessagePacket } from './botUtils'
import { getViewerVersionData, getWsProtocolStream, handleCustomChannel } from './viewerConnector'
import { appStartup } from './clientMods'
import { getViewerVersionData, getWsProtocolStream, onBotCreatedViewerHandler } from './viewerConnector'
import { getWebsocketStream } from './mineflayer/websocket-core'
import { appQueryParams, appQueryParamsArray } from './appParams'
import { playerState } from './mineflayer/playerState'
import { states } from 'minecraft-protocol'
import { initMotionTracking } from './react/uiMotion'
import { UserError } from './mineflayer/userError'
import ping from './mineflayer/plugins/ping'
import mouse from './mineflayer/plugins/mouse'
import { startLocalReplayServer } from './packetsReplay/replayPackets'
import { localRelayServerPlugin } from './mineflayer/plugins/packetsRecording'
import { createConsoleLogProgressReporter, createFullScreenProgressReporter, ProgressReporter } from './core/progressReporter'
import { createFullScreenProgressReporter, createWrappedProgressReporter, ProgressReporter } from './core/progressReporter'
import { appViewer } from './appViewer'
import './appViewerLoad'
import { registerOpenBenchmarkListener } from './benchmark'
import { tryHandleBuiltinCommand } from './builtinCommands'
import { loadingTimerState } from './react/LoadingTimer'
import { loadPluginsIntoWorld } from './react/CreateWorldProvider'
window.debug = debug
window.beforeRenderFrame = []
@ -109,7 +109,6 @@ void registerServiceWorker().then(() => {
watchFov()
initCollisionShapes()
initializePacketsReplay()
packetsPatcher()
onAppLoad()
customChannels()
@ -167,6 +166,8 @@ export async function connect (connectOptions: ConnectOptions) {
})
}
loadingTimerState.loading = true
loadingTimerState.start = Date.now()
miscUiState.hasErrors = false
lastConnectOptions.value = connectOptions
@ -208,8 +209,13 @@ export async function connect (connectOptions: ConnectOptions) {
let ended = false
let bot!: typeof __type_bot
const destroyAll = () => {
const destroyAll = (wasKicked = false) => {
if (ended) return
loadingTimerState.loading = false
const hadConnected = !!bot
if (!wasKicked && miscUiState.appConfig?.allowAutoConnect && appQueryParams.autoConnect && hadConnected) {
location.reload()
}
errorAbortController.abort()
ended = true
progress.end()
@ -251,6 +257,10 @@ export async function connect (connectOptions: ConnectOptions) {
if (isCypress()) throw err
miscUiState.hasErrors = true
if (miscUiState.gameLoaded) return
// close all modals
for (const modal of activeModalStack) {
hideModal(modal)
}
setLoadingScreenStatus(`Error encountered. ${err}`, true)
appStatusState.showReconnect = true
@ -280,7 +290,7 @@ export async function connect (connectOptions: ConnectOptions) {
if (connectOptions.server && !connectOptions.viewerWsConnect && !parsedServer.isWebSocket) {
console.log(`using proxy ${proxy.host}:${proxy.port || location.port}`)
net['setProxy']({ hostname: proxy.host, port: proxy.port })
net['setProxy']({ hostname: proxy.host, port: proxy.port, headers: { Authorization: `Bearer ${new URLSearchParams(location.search).get('token') ?? ''}` } })
}
const renderDistance = singleplayer ? renderDistanceSingleplayer : multiplayerRenderDistance
@ -293,10 +303,12 @@ export async function connect (connectOptions: ConnectOptions) {
Object.assign(serverOptions, connectOptions.serverOverridesFlat ?? {})
await progress.executeWithMessage('Downloading minecraft data', 'download-mcdata', async () => {
loadingTimerState.networkOnlyStart = Date.now()
await Promise.all([
downloadAllMinecraftData(),
downloadOtherGameData()
])
loadingTimerState.networkOnlyStart = 0
})
let dataDownloaded = false
@ -359,6 +371,16 @@ export async function connect (connectOptions: ConnectOptions) {
// Client (class) of flying-squid (in server/login.js of mc-protocol): onLogin handler: skip most logic & go to loginClient() which assigns uuid and sends 'success' back to client (onLogin handler) and emits 'login' on the server (login.js in flying-squid handler)
// flying-squid: 'login' -> player.login -> now sends 'login' event to the client (handled in many plugins in mineflayer) -> then 'update_health' is sent which emits 'spawn' in mineflayer
const serverPlugins = new URLSearchParams(location.search).getAll('serverPlugin')
if (serverPlugins.length > 0 && !serverOptions.worldFolder) {
console.log('Placing server plugins', serverPlugins)
serverOptions.worldFolder ??= '/temp'
await loadPluginsIntoWorld('/temp', serverPlugins)
console.log('Server plugins placed')
}
localServer = window.localServer = window.server = startLocalServer(serverOptions)
connectOptions?.connectEvents?.serverCreated?.()
// todo need just to call quit if started
@ -392,8 +414,10 @@ export async function connect (connectOptions: ConnectOptions) {
} else if (connectOptions.server) {
if (!finalVersion) {
const versionAutoSelect = getVersionAutoSelect()
setLoadingScreenStatus(`Fetching server version. Preffered: ${versionAutoSelect}`)
const wrapped = createWrappedProgressReporter(progress, `Fetching server version. Preffered: ${versionAutoSelect}`)
loadingTimerState.networkOnlyStart = Date.now()
const autoVersionSelect = await getServerInfo(server.host, server.port ? Number(server.port) : undefined, versionAutoSelect)
wrapped.end()
finalVersion = autoVersionSelect.version
}
initialLoadingText = `Connecting to server ${server.host}:${server.port ?? 25_565} with version ${finalVersion}`
@ -407,6 +431,7 @@ export async function connect (connectOptions: ConnectOptions) {
setLoadingScreenStatus(initialLoadingText)
if (parsedServer.isWebSocket) {
loadingTimerState.networkOnlyStart = Date.now()
clientDataStream = (await getWebsocketStream(server.host)).mineflayerStream
}
@ -450,6 +475,7 @@ export async function connect (connectOptions: ConnectOptions) {
if (finalVersion) {
// ensure data is downloaded
loadingTimerState.networkOnlyStart ??= Date.now()
await downloadMcData(finalVersion)
}
@ -533,7 +559,7 @@ export async function connect (connectOptions: ConnectOptions) {
}) as unknown as typeof __type_bot
window.bot = bot
if (connectOptions.viewerWsConnect) {
void handleCustomChannel()
void onBotCreatedViewerHandler()
}
customEvents.emit('mineflayerBotCreated')
if (singleplayer || p2pMultiplayer || localReplaySession) {
@ -603,14 +629,6 @@ export async function connect (connectOptions: ConnectOptions) {
}
if (!bot) return
if (connectOptions.server) {
bot.loadPlugin(ping)
}
bot.loadPlugin(mouse)
if (!localReplaySession) {
bot.loadPlugin(localRelayServerPlugin)
}
const p2pConnectTimeout = p2pMultiplayer ? setTimeout(() => { throw new UserError('Spawn timeout. There might be error on the other side, check console.') }, 20_000) : undefined
// bot.on('inject_allowed', () => {
@ -622,9 +640,13 @@ export async function connect (connectOptions: ConnectOptions) {
bot.on('kicked', (kickReason) => {
console.log('You were kicked!', kickReason)
const { formatted: kickReasonFormatted, plain: kickReasonString } = parseFormattedMessagePacket(kickReason)
// close all modals
for (const modal of activeModalStack) {
hideModal(modal)
}
setLoadingScreenStatus(`The Minecraft server kicked you. Kick reason: ${kickReasonString}`, true, undefined, undefined, kickReasonFormatted)
appStatusState.showReconnect = true
destroyAll()
destroyAll(true)
})
const packetBeforePlay = (_, __, ___, fullBuffer) => {
@ -644,6 +666,10 @@ export async function connect (connectOptions: ConnectOptions) {
if (endReason === 'socketClosed') {
endReason = lastKnownKickReason ?? 'Connection with proxy server lost'
}
// close all modals
for (const modal of activeModalStack) {
hideModal(modal)
}
setLoadingScreenStatus(`You have been disconnected from the server. End reason:\n${endReason}`, true)
appStatusState.showReconnect = true
onPossibleErrorDisconnect()
@ -654,7 +680,8 @@ export async function connect (connectOptions: ConnectOptions) {
onBotCreate()
bot.once('login', () => {
setLoadingScreenStatus('Loading world')
loadingTimerState.networkOnlyStart = 0
progress.setMessage('Loading world')
})
let worldWasReady = false
@ -799,7 +826,10 @@ export async function connect (connectOptions: ConnectOptions) {
const commands = appQueryParamsArray.command ?? []
for (let command of commands) {
if (!command.startsWith('/')) command = `/${command}`
bot.chat(command)
const builtinHandled = tryHandleBuiltinCommand(command)
if (!builtinHandled) {
bot.chat(command)
}
}
})
}
@ -888,7 +918,11 @@ if (!reconnectOptions) {
const waitAppConfigLoad = !appQueryParams.proxy
const openServerEditor = () => {
hideModal()
showModal({ reactType: 'editServer' })
if (appQueryParams.onlyConnect) {
showModal({ reactType: 'only-connect-server' })
} else {
showModal({ reactType: 'editServer' })
}
}
showModal({ reactType: 'empty' })
if (waitAppConfigLoad) {
@ -962,4 +996,5 @@ if (initialLoader) {
window.pageLoaded = true
void possiblyHandleStateVariable()
appViewer.waitBackendLoadPromises.push(appStartup())
registerOpenBenchmarkListener()

View file

@ -216,6 +216,8 @@ export const renderSlot = (model: ResolvedItemModelRender, debugIsQuickbar = fal
}
}
const blockToTopTexture = (r) => r.top ?? r
try {
assertDefined(appViewer.resourcesManager.currentResources?.itemsRenderer)
itemTexture =
@ -224,9 +226,11 @@ export const renderSlot = (model: ResolvedItemModelRender, debugIsQuickbar = fal
?? appViewer.resourcesManager.currentResources.itemsRenderer.getItemTexture('item/missing_texture')!
} catch (err) {
inGameError(`Failed to render item ${itemModelName} (original: ${originalItemName}) on ${bot.version} (resourcepack: ${options.enabledResourcepack}): ${err.stack}`)
itemTexture = appViewer.resourcesManager.currentResources!.itemsRenderer.getItemTexture('block/errored')!
itemTexture = blockToTopTexture(appViewer.resourcesManager.currentResources!.itemsRenderer.getItemTexture('errored')!)
}
itemTexture ??= blockToTopTexture(appViewer.resourcesManager.currentResources!.itemsRenderer.getItemTexture('unknown')!)
if ('type' in itemTexture) {
// is item

View file

@ -0,0 +1,70 @@
export const EntityStatus = {
JUMP: 1,
HURT: 2, // legacy
DEATH: 3,
START_ATTACKING: 4,
STOP_ATTACKING: 5,
TAMING_FAILED: 6,
TAMING_SUCCEEDED: 7,
SHAKE_WETNESS: 8,
USE_ITEM_COMPLETE: 9,
EAT_GRASS: 10,
OFFER_FLOWER: 11,
LOVE_HEARTS: 12,
VILLAGER_ANGRY: 13,
VILLAGER_HAPPY: 14,
WITCH_HAT_MAGIC: 15,
ZOMBIE_CONVERTING: 16,
FIREWORKS_EXPLODE: 17,
IN_LOVE_HEARTS: 18,
SQUID_ANIM_SYNCH: 19,
SILVERFISH_MERGE_ANIM: 20,
GUARDIAN_ATTACK_SOUND: 21,
REDUCED_DEBUG_INFO: 22,
FULL_DEBUG_INFO: 23,
PERMISSION_LEVEL_ALL: 24,
PERMISSION_LEVEL_MODERATORS: 25,
PERMISSION_LEVEL_GAMEMASTERS: 26,
PERMISSION_LEVEL_ADMINS: 27,
PERMISSION_LEVEL_OWNERS: 28,
ATTACK_BLOCKED: 29,
SHIELD_DISABLED: 30,
FISHING_ROD_REEL_IN: 31,
ARMORSTAND_WOBBLE: 32,
THORNED: 33, // legacy
STOP_OFFER_FLOWER: 34,
TALISMAN_ACTIVATE: 35, // legacy
DROWNED: 36, // legacy
BURNED: 37, // legacy
DOLPHIN_LOOKING_FOR_TREASURE: 38,
RAVAGER_STUNNED: 39,
TRUSTING_FAILED: 40,
TRUSTING_SUCCEEDED: 41,
VILLAGER_SWEAT: 42,
BAD_OMEN_TRIGGERED: 43, // legacy
POKED: 44, // legacy
FOX_EAT: 45,
TELEPORT: 46,
MAINHAND_BREAK: 47,
OFFHAND_BREAK: 48,
HEAD_BREAK: 49,
CHEST_BREAK: 50,
LEGS_BREAK: 51,
FEET_BREAK: 52,
HONEY_SLIDE: 53,
HONEY_JUMP: 54,
SWAP_HANDS: 55,
CANCEL_SHAKE_WETNESS: 56,
FROZEN: 57, // legacy
START_RAM: 58,
END_RAM: 59,
POOF: 60,
TENDRILS_SHIVER: 61,
SONIC_CHARGE: 62,
SNIFFER_DIGGING_SOUND: 63,
ARMADILLO_PEEK: 64,
BODY_BREAK: 65,
SHAKE: 66
} as const
export type EntityStatusName = keyof typeof EntityStatus

View file

@ -2,6 +2,7 @@ import { Client } from 'minecraft-protocol'
import { appQueryParams } from '../appParams'
import { downloadAllMinecraftData, getVersionAutoSelect } from '../connect'
import { gameAdditionalState } from '../globalState'
import { ProgressReporter } from '../core/progressReporter'
import { pingServerVersion, validatePacket } from './minecraft-protocol-extra'
import { getWebsocketStream } from './websocket-core'
@ -34,16 +35,27 @@ setInterval(() => {
}, 1000)
export const getServerInfo = async (ip: string, port?: number, preferredVersion = getVersionAutoSelect(), ping = false) => {
export const getServerInfo = async (ip: string, port?: number, preferredVersion = getVersionAutoSelect(), ping = false, progressReporter?: ProgressReporter) => {
await downloadAllMinecraftData()
const isWebSocket = ip.startsWith('ws://') || ip.startsWith('wss://')
let stream
if (isWebSocket) {
progressReporter?.setMessage('Connecting to WebSocket server')
stream = (await getWebsocketStream(ip)).mineflayerStream
progressReporter?.setMessage('WebSocket connected. Ping packet sent, waiting for response')
}
window.setLoadingMessage = (message?: string) => {
if (message === undefined) {
progressReporter?.endStage('dns')
} else {
progressReporter?.beginStage('dns', message)
}
}
return pingServerVersion(ip, port, {
...(stream ? { stream } : {}),
...(ping ? { noPongTimeout: 3000 } : {}),
...(preferredVersion ? { version: preferredVersion } : {}),
}).finally(() => {
window.setLoadingMessage = undefined
})
}

View file

@ -0,0 +1,21 @@
import { lastConnectOptions } from '../../react/AppStatusProvider'
import mouse from './mouse'
import packetsPatcher from './packetsPatcher'
import { localRelayServerPlugin } from './packetsRecording'
import ping from './ping'
import webFeatures from './webFeatures'
// register
webFeatures()
packetsPatcher()
customEvents.on('mineflayerBotCreated', () => {
if (lastConnectOptions.value!.server) {
bot.loadPlugin(ping)
}
bot.loadPlugin(mouse)
if (!lastConnectOptions.value!.worldStateFileContents) {
bot.loadPlugin(localRelayServerPlugin)
}
})

View file

@ -72,6 +72,7 @@ export const localRelayServerPlugin = (bot: Bot) => {
position: position++,
timestamp: Date.now(),
})
packetsReplayState.progress.current++
}
})
bot._client.on('packet', (data, { name }) => {
@ -86,8 +87,22 @@ export const localRelayServerPlugin = (bot: Bot) => {
position: position++,
timestamp: Date.now(),
})
packetsReplayState.progress.total++
}
})
const oldWriteChannel = bot._client.writeChannel.bind(bot._client)
bot._client.writeChannel = (channel, params) => {
packetsReplayState.packetsPlayback.push({
name: channel,
data: params,
isFromClient: true,
isUpcoming: false,
position: position++,
timestamp: Date.now(),
isCustomChannel: true,
})
oldWriteChannel(channel, params)
}
upPacketsReplayPanel()
}
@ -95,6 +110,8 @@ export const localRelayServerPlugin = (bot: Bot) => {
const upPacketsReplayPanel = () => {
if (packetsRecordingState.active && bot) {
packetsReplayState.isOpen = true
packetsReplayState.isMinimized = true
packetsReplayState.isRecording = true
packetsReplayState.replayName = 'Recording all packets for ' + bot.username
}
}

View file

@ -0,0 +1,12 @@
import { Bot } from 'mineflayer'
import { getAppLanguage } from '../../optionsStorage'
export default () => {
customEvents.on('mineflayerBotCreated', () => {
bot.loadPlugin(plugin)
})
}
const plugin = (bot: Bot) => {
bot.settings['locale'] = getAppLanguage()
}

View file

@ -14,6 +14,7 @@ import { openFilePicker, resetLocalStorage } from './browserfs'
import { completeResourcepackPackInstall, getResourcePackNames, resourcepackReload, resourcePackState, uninstallResourcePack } from './resourcePack'
import { downloadPacketsReplay, packetsRecordingState } from './packetsReplay/packetsReplayLegacy'
import { showInputsModal, showOptionsModal } from './react/SelectOption'
import { modsUpdateStatus } from './clientMods'
import supportedVersions from './supportedVersions.mjs'
import { getVersionAutoSelect } from './connect'
import { createNotificationProgressReporter } from './core/progressReporter'
@ -227,10 +228,31 @@ export const guiOptionsScheme: {
return <Button label='Advanced...' onClick={() => openOptionsMenu('advanced')} inScreen />
},
},
{
custom () {
const { appConfig } = useSnapshot(miscUiState)
const modsUpdateSnapshot = useSnapshot(modsUpdateStatus)
if (appConfig?.showModsButton === false) return null
return <Button label={`Client Mods: ${Object.keys(window.loadedMods ?? {}).length} (${Object.keys(modsUpdateSnapshot).length})`} onClick={() => showModal({ reactType: 'mods' })} inScreen />
},
},
{
custom () {
return <Button label='VR...' onClick={() => openOptionsMenu('VR')} inScreen />
},
},
{
custom () {
const { appConfig } = useSnapshot(miscUiState)
if (!appConfig?.displayLanguageSelector) return null
return <Button
label='Language...' onClick={async () => {
const newLang = await showOptionsModal('Set Language', (appConfig.supportedLanguages ?? []) as string[])
if (!newLang) return
options.language = newLang.split(' - ')[0]
}} inScreen />
},
}
],
interface: [
@ -569,6 +591,11 @@ export const guiOptionsScheme: {
],
},
},
{
debugContro: {
text: 'Debug Controls',
},
}
],
'export-import': [
{

View file

@ -1,9 +1,10 @@
import { proxy, subscribe } from 'valtio/vanilla'
import { subscribeKey } from 'valtio/utils'
import { omitObj } from '@zardoy/utils'
import { appQueryParamsArray } from './appParams'
import { appQueryParams, appQueryParamsArray } from './appParams'
import type { AppConfig } from './appConfig'
import { appStorage } from './react/appStorageProvider'
import { miscUiState } from './globalState'
const isDev = process.env.NODE_ENV === 'development'
const initialAppConfig = process.env?.INLINED_APP_CONFIG as AppConfig ?? {}
@ -59,13 +60,18 @@ const defaultOptions = {
serversAutoVersionSelect: 'auto' as 'auto' | 'latest' | '1.20.4' | string,
customChannels: false,
remoteContentNotSameOrigin: false as boolean | string[],
packetsReplayAutoStart: false,
packetsRecordingAutoStart: false,
language: 'auto',
preciseMouseInput: false,
// todo ui setting, maybe enable by default?
waitForChunksRender: 'sp-only' as 'sp-only' | boolean,
waitForChunksRender: false as 'sp-only' | boolean,
jeiEnabled: true as boolean | Array<'creative' | 'survival' | 'adventure' | 'spectator'>,
modsSupport: false,
modsAutoUpdate: 'check' as 'check' | 'never' | 'always',
modsUpdatePeriodCheck: 24, // hours
preventBackgroundTimeoutKick: false,
preventSleep: false,
debugContro: false,
// antiAliasing: false,
@ -235,6 +241,7 @@ Object.defineProperty(window, 'debugChangedOptions', {
})
subscribe(options, (ops) => {
if (appQueryParams.freezeSettings === 'true') return
for (const op of ops) {
const [type, path, value] = op
// let patch
@ -291,3 +298,10 @@ export const useOptionValue = (setting, valueCallback) => {
valueCallback(setting)
subscribe(setting, valueCallback)
}
export const getAppLanguage = () => {
if (options.language === 'auto') {
return miscUiState.appConfig?.defaultLanguage ?? navigator.language
}
return options.language
}

View file

@ -3,7 +3,7 @@ import { PacketsLogger } from 'mcraft-fun-mineflayer/build/packetsLogger'
import { options } from '../optionsStorage'
export const packetsRecordingState = proxy({
active: options.packetsReplayAutoStart,
active: options.packetsRecordingAutoStart,
hasRecordedPackets: false
})

View file

@ -4,6 +4,7 @@ import styles from './appStatus.module.css'
import Button from './Button'
import Screen from './Screen'
import LoadingChunks from './LoadingChunks'
import LoadingTimer from './LoadingTimer'
export default ({
status,
@ -37,57 +38,61 @@ export default ({
void statusRunner()
}, [])
const lockConnect = appQueryParams.lockConnect === 'true'
return (
<Screen
className='small-content'
titleSelectable={isError}
title={
<>
<span style={{
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
}}
>
{status}
</span>
<div style={{ display: 'inline-flex', gap: '1px', }} hidden={hideDots || isError}>
{
[...'...'].map((dot, i) => {
return <span
key={i} style={{
visibility: loadingDotIndex <= i ? 'hidden' : 'visible',
}}>{dot}</span>
})
}
</div>
<p className={styles.description}>{description}</p>
<p className={styles['last-status']}>{lastStatus ? `Last status: ${lastStatus}` : lastStatus}</p>
</>
}
backdrop='dirt'
>
{isError && (
<>
{showReconnect && onReconnect && <Button onClick={onReconnect}>
<b>Reconnect</b>
</Button>}
{actionsSlot}
<Button
onClick={() => {
if (location.search) {
location.search = ''
} else {
window.location.reload()
}
<div className=''>
<Screen
className='small-content'
titleSelectable={isError}
title={
<>
<span style={{
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
}}
>
<b>Reset App (recommended)</b>
</Button>
{backAction && <Button label="Back" onClick={backAction} />}
</>
)}
{children}
</Screen>
>
{status}
</span>
<div style={{ display: 'inline-flex', gap: '1px', }} hidden={hideDots || isError}>
{
[...'...'].map((dot, i) => {
return <span
key={i} style={{
visibility: loadingDotIndex <= i ? 'hidden' : 'visible',
}}>{dot}</span>
})
}
</div>
<p className={styles.description}>{description}</p>
<p className={styles['last-status']}>{lastStatus ? `Last status: ${lastStatus}` : lastStatus}</p>
</>
}
backdrop='dirt'
>
{isError && (
<>
{showReconnect && onReconnect && <Button onClick={onReconnect}>
<b>Reconnect</b>
</Button>}
{actionsSlot}
{!lockConnect && <Button
onClick={() => {
if (location.search) {
location.search = ''
} else {
window.location.reload()
}
}}
>
<b>Reset App (recommended)</b>
</Button>}
{backAction && <Button label="Back" onClick={backAction} />}
</>
)}
{children}
<LoadingTimer />
</Screen>
</div>
)
}

View file

@ -127,7 +127,8 @@ export default () => {
}, [])
const displayAuthButton = status.includes('This server appears to be an online server and you are providing no authentication.')
const displayVpnButton = status.includes('VPN') || status.includes('Proxy')
const hasVpnText = (text: string) => text.includes('VPN') || text.includes('Proxy')
const displayVpnButton = hasVpnText(status) || (minecraftJsonMessage && hasVpnText(JSON.stringify(minecraftJsonMessage)))
const authReconnectAction = async () => {
let accounts = [] as AuthenticatedAccount[]
updateAuthenticatedAccountData(oldAccounts => {

View file

@ -14,6 +14,7 @@ interface Props extends React.ComponentProps<'button'> {
inScreen?: boolean
rootRef?: Ref<HTMLButtonElement>
overlayColor?: string
noTranslate?: boolean
}
const ButtonContext = createContext({
@ -24,7 +25,7 @@ export const ButtonProvider: FC<{ children, onClick }> = ({ children, onClick })
return <ButtonContext.Provider value={{ onClick }}>{children}</ButtonContext.Provider>
}
export default (({ label, icon, children, inScreen, rootRef, type = 'button', postLabel, overlayColor, ...args }) => {
export default (({ label, icon, children, inScreen, rootRef, type = 'button', postLabel, overlayColor, noTranslate, ...args }) => {
const ctx = useContext(ButtonContext)
const onClick = (e) => {
@ -40,12 +41,23 @@ export default (({ label, icon, children, inScreen, rootRef, type = 'button', po
args.style.width = 20
}
const tryToTranslate = (maybeText: any) => {
if (noTranslate) return maybeText
if (typeof maybeText === 'string') {
return window.translateText?.(maybeText) ?? maybeText
}
if (Array.isArray(maybeText)) {
return maybeText.map(tryToTranslate)
}
return maybeText
}
return <SharedHudVars>
<button ref={rootRef} {...args} className={classNames(buttonCss.button, args.className)} onClick={onClick} type={type}>
{icon && <PixelartIcon className={buttonCss.icon} iconName={icon} />}
{label}
{tryToTranslate(label)}
{postLabel}
{children}
{tryToTranslate(children)}
{overlayColor && <div style={{
position: 'absolute',
inset: 0,

View file

@ -283,10 +283,8 @@ export default ({
const message = chatInput.current.value
if (message) {
setSendHistory([...sendHistoryRef.current, message])
const result = sendMessage?.(message)
if (result !== false) {
onClose?.()
}
onClose?.()
sendMessage?.(message)
// Always scroll to bottom after sending a message
scrollToBottom()
}

View file

@ -7,7 +7,7 @@ import { options } from '../optionsStorage'
import { viewerVersionState } from '../viewerConnector'
import Chat, { Message, fadeMessage } from './Chat'
import { useIsModalActive } from './utilsApp'
import { hideNotification, showNotification } from './NotificationProvider'
import { hideNotification, notificationProxy, showNotification } from './NotificationProvider'
import { updateLoadedServerData } from './serversStorage'
import { lastConnectOptions } from './AppStatusProvider'
@ -65,6 +65,7 @@ export default () => {
})
hideNotification()
})
notificationProxy.id = 'auto-login'
const listener = () => {
hideNotification()
}

130
src/react/ChunksDebug.tsx Normal file
View file

@ -0,0 +1,130 @@
import { useEffect, useRef, useState } from 'react'
import './LoadingChunks.css'
export interface ChunkDebug {
x: number // like -32
z: number // like -32
lines: string[]
sidebarLines: string[]
state: 'server-waiting' | 'order-queued' | 'client-waiting' | 'client-processing' | 'done-empty' | 'done'
}
interface ProcessedChunk extends ChunkDebug {
relX: number
relZ: number
displayLines: string[]
}
const stateColors: Record<ChunkDebug['state'], string> = {
'server-waiting': 'gray',
'order-queued': 'darkorange',
'client-waiting': 'yellow',
'client-processing': 'yellow',
'done-empty': 'darkgreen',
'done': 'limegreen',
}
export default ({
chunks,
playerChunk,
maxDistance,
tileSize = 16,
fontSize = 5,
}: {
chunks: ChunkDebug[]
playerChunk: { x: number, z: number }
maxDistance: number,
tileSize?: number
fontSize?: number
}) => {
const [selectedChunk, setSelectedChunk] = useState<ProcessedChunk | null>(null)
const [showSidebar, setShowSidebar] = useState(false)
// Calculate grid dimensions based on maxDistance
const gridSize = maxDistance * 2 + 1
const centerIndex = maxDistance
// Process chunks to get only the last one for each position and within maxDistance
const processedChunks = chunks.reduce<Record<string, ProcessedChunk>>((acc, chunk) => {
const relX = Math.floor((chunk.x - playerChunk.x) / 16)
const relZ = Math.floor((chunk.z - playerChunk.z) / 16)
// Skip chunks outside maxDistance
if (Math.abs(relX) > maxDistance || Math.abs(relZ) > maxDistance) return acc
const key = `${chunk.x},${chunk.z}`
acc[key] = {
...chunk,
relX,
relZ,
displayLines: [`${relX},${relZ} (${chunk.x},${chunk.z})`, ...chunk.lines]
}
return acc
}, {})
return (
<div style={{ display: 'flex', gap: '10px' }}>
<div style={{
display: 'grid',
gridTemplateColumns: `repeat(${gridSize}, 1fr)`,
gridTemplateRows: `repeat(${gridSize}, 1fr)`,
gap: 1,
// width: `${tileSize * gridSize}px`,
// height: `${tileSize * gridSize}px`,
}}>
{Array.from({ length: gridSize * gridSize }).map((_, i) => {
const relX = -maxDistance + (i % gridSize)
const relZ = -maxDistance + Math.floor(i / gridSize)
const x = playerChunk.x + relX * 16
const z = playerChunk.z + relZ * 16
const chunk = processedChunks[`${x},${z}`]
return (
<div
key={`${x},${z}`}
onClick={() => {
if (chunk) {
setSelectedChunk(chunk)
setShowSidebar(true)
}
}}
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: chunk ? stateColors[chunk.state] : 'black',
color: 'white',
fontSize: `${fontSize}px`,
cursor: chunk ? 'pointer' : 'default',
position: 'relative',
width: `${tileSize}px`,
height: `${tileSize}px`,
// pre-wrap
whiteSpace: 'pre',
}}
>
{relX}, {relZ}{'\n'}
{chunk?.lines.join('\n')}
</div>
)
})}
</div>
{showSidebar && selectedChunk && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }} className='text-select'>
{selectedChunk.displayLines.map((line, i) => (
<div key={i} style={{ fontSize: '10px', wordBreak: 'break-word' }}>
{line}
</div>
))}
<div style={{ marginTop: '10px', fontSize: '10px', whiteSpace: 'pre', maxWidth: 100, }}>
<div>Sidebar Info:</div>
{selectedChunk.sidebarLines.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,84 @@
import { useEffect, useState } from 'react'
import { useUtilsEffect } from '@zardoy/react-util'
import { WorldRendererCommon } from 'renderer/viewer/lib/worldrendererCommon'
import { WorldRendererThree } from 'renderer/viewer/three/worldrendererThree'
import Screen from './Screen'
import ChunksDebug, { ChunkDebug } from './ChunksDebug'
import { useIsModalActive } from './utilsApp'
const Inner = () => {
const [playerX, setPlayerX] = useState(Math.floor(worldView!.lastPos.x / 16) * 16)
const [playerZ, setPlayerZ] = useState(Math.floor(worldView!.lastPos.z / 16) * 16)
const [update, setUpdate] = useState(0)
useUtilsEffect(({ interval }) => {
interval(
500,
() => {
setPlayerX(Math.floor(worldView!.lastPos.x / 16) * 16)
setPlayerZ(Math.floor(worldView!.lastPos.z / 16) * 16)
setUpdate(u => u + 1)
}
)
}, [])
const mapChunk = (key: string, state: ChunkDebug['state']): ChunkDebug => {
const chunk = worldView!.debugChunksInfo[key]
return {
x: Number(key.split(',')[0]),
z: Number(key.split(',')[1]),
state,
lines: [String(chunk?.loads.length ?? 0)],
sidebarLines: [
`loads: ${chunk.loads.map(l => `${l.reason} ${l.dataLength} ${l.time}`).join('\n')}`,
// `blockUpdates: ${chunk.blockUpdates}`,
],
}
}
const chunksWaitingServer = Object.keys(worldView!.waitingSpiralChunksLoad).map(key => mapChunk(key, 'server-waiting'))
const world = globalThis.world as WorldRendererThree
const loadedSectionsChunks = Object.fromEntries(Object.keys(world.sectionObjects).map(sectionPos => {
const [x, y, z] = sectionPos.split(',').map(Number)
return [`${x},${z}`, true]
}))
const chunksWaitingClient = Object.keys(worldView!.loadedChunks).map(key => mapChunk(key, 'client-waiting'))
const clientProcessingChunks = Object.keys(world.loadedChunks).map(key => mapChunk(key, 'client-processing'))
const chunksDoneEmpty = Object.keys(world.finishedChunks)
.filter(chunkPos => !loadedSectionsChunks[chunkPos])
.map(key => mapChunk(key, 'done-empty'))
const chunksDone = Object.keys(world.finishedChunks).map(key => mapChunk(key, 'done'))
const allChunks = [
...chunksWaitingServer,
...chunksWaitingClient,
...clientProcessingChunks,
...chunksDone,
...chunksDoneEmpty,
]
return <Screen title="Chunks Debug">
<ChunksDebug
chunks={allChunks}
playerChunk={{
x: playerX,
z: playerZ
}}
maxDistance={worldView!.viewDistance}
tileSize={32}
fontSize={8}
/>
</Screen>
}
export default () => {
const isActive = useIsModalActive('chunks-debug')
if (!isActive) return null
return <Inner />
}

View file

@ -0,0 +1,80 @@
import { useMemo } from 'react'
import { useSnapshot } from 'valtio'
import { appQueryParams } from '../appParams'
import { ConnectOptions } from '../connect'
import { lastConnectOptions } from './AppStatusProvider'
import PixelartIcon, { pixelartIcons } from './PixelartIcon'
import { useIsModalActive } from './utilsApp'
import Button from './Button'
const VERTICAL_LAYOUT = false
export default () => {
const { ip, version, proxy, username, connectText } = appQueryParams
const isModalActive = useIsModalActive('only-connect-server')
if (!isModalActive) return null
const handleConnect = () => {
const connectOptions: ConnectOptions = {
username: username || '',
server: ip,
proxy,
botVersion: version,
}
window.dispatchEvent(new CustomEvent('connect', { detail: connectOptions }))
}
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'rgba(0, 0, 0, 0.25)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
display: 'flex',
gap: '40px',
alignItems: 'center',
background: 'rgba(0, 0, 0, 0.5)',
padding: VERTICAL_LAYOUT ? '15px 40px' : '25px',
// paddingRight: VERTICAL_LAYOUT ? '0' : '40px',
borderRadius: '5px',
flexDirection: VERTICAL_LAYOUT ? 'column' : 'row',
}}>
<div style={{
color: 'white',
fontSize: '13px'
}}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '10px' }}>
<PixelartIcon iconName={pixelartIcons.server} width={16} />
<span style={{ marginLeft: '8px' }}>{ip}</span>
{proxy && <span style={{ marginLeft: '8px', color: 'lightgray' }}>({proxy})</span>}
</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
<PixelartIcon iconName={pixelartIcons.user} width={16} />
<span style={{ marginLeft: '8px' }}>{username}</span>
{version && <span style={{ marginLeft: '8px', color: 'lightgray' }}>({version})</span>}
</div>
</div>
<Button
onClick={handleConnect}
style={{
width: 'auto',
padding: '0 12px',
transform: 'scale(1.4)',
transformOrigin: 'center'
}}
>
{connectText || 'Connect'}
</Button>
</div>
</div>
)
}

71
src/react/ControDebug.tsx Normal file
View file

@ -0,0 +1,71 @@
import { useEffect, useState } from 'react'
import { options } from '../optionsStorage'
import { contro } from '../controls'
export default () => {
const [pressedKeys, setPressedKeys] = useState<Set<string>>(new Set())
const [actions, setActions] = useState<string[]>([])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
setPressedKeys(prev => new Set([...prev, e.code]))
}
const handleKeyUp = (e: KeyboardEvent) => {
setPressedKeys(prev => {
const newSet = new Set(prev)
newSet.delete(e.code)
return newSet
})
}
const handleBlur = () => {
setPressedKeys(new Set())
}
const handleControTrigger = ({ command }) => {
setActions(prev => [...prev, command])
}
const handleControReleased = ({ command }) => {
setActions(prev => prev.filter(action => action !== command))
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
window.addEventListener('blur', handleBlur)
contro.on('trigger', handleControTrigger)
contro.on('release', handleControReleased)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
window.removeEventListener('blur', handleBlur)
contro.off('trigger', handleControTrigger)
contro.off('released', handleControReleased)
}
}, [])
if (!options.debugContro) return null
return (
<div style={{
position: 'fixed',
right: 0,
top: '50%',
transform: 'translateY(-50%)',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
padding: '8px',
fontFamily: 'monospace',
fontSize: '8px',
color: 'white',
display: 'flex',
flexDirection: 'column',
gap: '4px'
}}>
<div>Keys: {[...pressedKeys].join(', ')}</div>
<div style={{ color: 'limegreen' }}>Actions: {actions.join(', ')}</div>
</div>
)
}

View file

@ -1,11 +1,14 @@
import { useEffect, useState } from 'react'
import { proxy, useSnapshot } from 'valtio'
import { filesize } from 'filesize'
import { getAvailableServerPlugins } from '../clientMods'
import { showModal } from '../globalState'
import Input from './Input'
import Screen from './Screen'
import Button from './Button'
import SelectGameVersion from './SelectGameVersion'
import styles from './createWorld.module.css'
import { InputOption, showInputsModal, showOptionsModal } from './SelectOption'
// const worldTypes = ['default', 'flat', 'largeBiomes', 'amplified', 'customized', 'buffet', 'debug_all_block_states']
const worldTypes = ['default', 'flat'/* , 'void' */]
@ -15,13 +18,14 @@ export const creatingWorldState = proxy({
title: '',
type: worldTypes[0],
gameMode: gameModes[0],
version: ''
version: '',
plugins: [] as string[]
})
export default ({ cancelClick, createClick, customizeClick, versions, defaultVersion }) => {
const [quota, setQuota] = useState('')
const { title, type, version, gameMode } = useSnapshot(creatingWorldState)
const { title, type, version, gameMode, plugins } = useSnapshot(creatingWorldState)
useEffect(() => {
creatingWorldState.version = defaultVersion
void navigator.storage?.estimate?.().then(({ quota, usage }) => {
@ -69,7 +73,38 @@ export default ({ cancelClick, createClick, customizeClick, versions, defaultVer
creatingWorldState.gameMode = gameModes[index === gameModes.length - 1 ? 0 : index + 1]
}}
>
Gamemode: {gameMode}
Game Mode: {gameMode}
</Button>
</div>
<div style={{ display: 'flex' }}>
<Button onClick={async () => {
const availableServerPlugins = await getAvailableServerPlugins()
const availableModNames = availableServerPlugins.map(mod => mod.name)
const choices: Record<string, InputOption> = Object.fromEntries(availableServerPlugins.map(mod => [mod.name, {
type: 'checkbox' as const,
defaultValue: creatingWorldState.plugins.includes(mod.name),
label: mod.name
}]))
choices.installMore = {
type: 'button' as const,
onButtonClick () {
showModal({ reactType: 'mods' })
}
}
const choice = await showInputsModal('Select server plugins from mods to install:', choices)
if (!choice) return
creatingWorldState.plugins = availableModNames.filter(modName => choice[modName])
}}
>Use Mods ({plugins.length})
</Button>
<Button
onClick={() => {
const index = gameModes.indexOf(gameMode)
creatingWorldState.gameMode = gameModes[index === gameModes.length - 1 ? 0 : index + 1]
}}
disabled
>
Save Type: Java
</Button>
</div>
<div className='muted' style={{ fontSize: 8 }}>Default and other world types are WIP</div>
@ -80,7 +115,11 @@ export default ({ cancelClick, createClick, customizeClick, versions, defaultVer
}}
>Cancel
</Button>
<Button disabled={!title} onClick={createClick}>Create</Button>
<Button disabled={!title} onClick={createClick}>
<b>
Create
</b>
</Button>
</div>
<div className='muted' style={{ fontSize: 9 }}>Note: save important worlds in folders on your hard drive!</div>
<div className='muted' style={{ fontSize: 9 }}>{quota}</div>

View file

@ -1,7 +1,10 @@
import fs from 'fs'
import path from 'path'
import { hideCurrentModal, showModal } from '../globalState'
import defaultLocalServerOptions from '../defaultLocalServerOptions'
import { mkdirRecursive, uniqueFileNameFromWorldName } from '../browserfs'
import supportedVersions from '../supportedVersions.mjs'
import { getServerPlugin } from '../clientMods'
import CreateWorld, { WorldCustomize, creatingWorldState } from './CreateWorld'
import { getWorldsPath } from './SingleplayerProvider'
import { useIsModalActive } from './utilsApp'
@ -14,7 +17,7 @@ export default () => {
const versions = Object.values(versionsPerMinor).map(x => {
return {
version: x,
label: x === defaultLocalServerOptions.version ? `${x} (available offline)` : x
label: x === defaultLocalServerOptions.version ? `${x} (default)` : x
}
})
return <CreateWorld
@ -24,10 +27,11 @@ export default () => {
}}
createClick={async () => {
// create new world
const { title, type, version, gameMode } = creatingWorldState
const { title, type, version, gameMode, plugins } = creatingWorldState
// todo display path in ui + disable if exist
const savePath = await uniqueFileNameFromWorldName(title, getWorldsPath())
await mkdirRecursive(savePath)
await loadPluginsIntoWorld(savePath, plugins)
let generation
if (type === 'flat') {
generation = {
@ -68,3 +72,16 @@ export default () => {
}
return null
}
export const loadPluginsIntoWorld = async (worldPath: string, plugins: string[]) => {
for (const plugin of plugins) {
// eslint-disable-next-line no-await-in-loop
const { content, version } = await getServerPlugin(plugin) ?? {}
if (content) {
// eslint-disable-next-line no-await-in-loop
await mkdirRecursive(path.join(worldPath, 'plugins'))
// eslint-disable-next-line no-await-in-loop
await fs.promises.writeFile(path.join(worldPath, 'plugins', `${plugin}-${version}.js`), content)
}
}
}

View file

@ -39,6 +39,7 @@ export default () => {
const [cursorBlock, setCursorBlock] = useState<Block | null>(null)
const [blockInfo, setBlockInfo] = useState<{ customBlockName?: string, modelInfo?: BlockStateModelInfo } | null>(null)
const [clientTps, setClientTps] = useState(0)
const [serverTps, setServerTps] = useState(null as null | { value: number, frozen: boolean })
const minecraftYaw = useRef(0)
const minecraftQuad = useRef(0)
const rendererDevice = appViewer.rendererState.renderer ?? 'No render backend'
@ -155,6 +156,9 @@ export default () => {
sent.current.count++
managePackets('sent', name, data)
})
bot._client.on('set_ticking_state' as any, (data) => {
setServerTps({ value: data.tick_rate, frozen: data.is_frozen })
})
return () => {
document.removeEventListener('keydown', handleF3)
@ -180,8 +184,9 @@ export default () => {
<div className={styles.empty} />
<p>XYZ: {pos.x.toFixed(3)} / {pos.y.toFixed(3)} / {pos.z.toFixed(3)}</p>
<p>Chunk: {Math.floor(pos.x % 16)} ~ {Math.floor(pos.z % 16)} in {Math.floor(pos.x / 16)} ~ {Math.floor(pos.z / 16)}</p>
<p>Section: {Math.floor(pos.x / 16) * 16}, {Math.floor(pos.y / 16) * 16}, {Math.floor(pos.z / 16) * 16}</p>
<p>Packets: {packetsString}</p>
<p>Client TPS: {clientTps}</p>
<p>Client TPS: {clientTps} {serverTps ? `Server TPS: ${serverTps.value} ${serverTps.frozen ? '(frozen)' : ''}` : ''}</p>
<p>Facing (viewer): {bot.entity.yaw.toFixed(3)} {bot.entity.pitch.toFixed(3)}</p>
<p>Facing (minecraft): {quadsDescription[minecraftQuad.current]} ({minecraftYaw.current.toFixed(1)} {(bot.entity.pitch * -180 / Math.PI).toFixed(1)})</p>
<p>Light: {blockL} ({skyL} sky)</p>

View file

@ -35,7 +35,6 @@ const Input = ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue, w
return <div id='input-container' className={styles.container} style={rootStyles}>
<input
ref={ref}
className={styles.input}
autoComplete='off'
autoCapitalize='off'
autoCorrect='off'
@ -43,6 +42,7 @@ const Input = ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue, w
spellCheck='false'
style={{ ...validationStyle }}
{...inputProps}
className={styles.input + ' ' + (inputProps.className ?? '')}
value={value}
onChange={(e) => {
setValue(e.target.value)

View file

@ -0,0 +1,50 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { proxy, useSnapshot } from 'valtio'
export const loadingTimerState = proxy({
start: 0,
loading: false,
total: '0.00',
networkOnlyStart: 0,
networkTimeTotal: '0.0'
})
customEvents.on('gameLoaded', () => {
loadingTimerState.loading = false
})
export default () => {
// const time = useSnapshot(timerState).start
const { networkTimeTotal, total } = useSnapshot(loadingTimerState)
useEffect(() => {
const interval = setInterval(() => {
if (!loadingTimerState.loading) return
if (loadingTimerState.networkOnlyStart) {
loadingTimerState.networkTimeTotal = ((Date.now() - loadingTimerState.networkOnlyStart) / 1000).toFixed(2)
} else {
loadingTimerState.total = ((Date.now() - loadingTimerState.start) / 1000).toFixed(2)
}
}, 100)
return () => clearInterval(interval)
}, [])
return <Portal to={document.getElementById('ui-root')!}>
<div style={{
position: 'absolute',
left: 0,
bottom: 0,
color: 'gray',
fontSize: '0.6em',
paddingLeft: 'calc(env(safe-area-inset-left) / 2)'
}}>
{total}/{networkTimeTotal}
</div>
</Portal>
}
const Portal = ({ children, to = document.body }) => {
return createPortal(children, to)
}

View file

@ -1,7 +1,9 @@
import React from 'react'
import { openURL } from 'renderer/viewer/lib/simpleUtils'
import { useSnapshot } from 'valtio'
import { haveDirectoryPicker } from '../utils'
import { ConnectOptions } from '../connect'
import { miscUiState } from '../globalState'
import styles from './mainMenu.module.css'
import Button from './Button'
import ButtonWithTooltip from './ButtonWithTooltip'
@ -44,6 +46,8 @@ export default ({
bottomRightLinks,
singleplayerAvailable = true
}: Props) => {
const { appConfig } = useSnapshot(miscUiState)
if (!bottomRightLinks?.trim()) bottomRightLinks = undefined
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const linksParsed = bottomRightLinks?.split(/;|\n/g).map(l => {
@ -89,7 +93,7 @@ export default ({
<div className={styles['game-title']}>
<div className={styles.minecraft}>
<div className={styles.edition} />
<span className={styles.splash}>Prismarine is a beautiful block</span>
<span className={styles.splash}>{appConfig?.splashText}</span>
</div>
</div>
@ -177,7 +181,7 @@ export default ({
</div>
})}
</div>
<span>A Minecraft client clone in the browser!</span>
<span>{appConfig?.rightSideText}</span>
</span>
</div>
</div>

View file

@ -17,6 +17,7 @@ export const mineflayerConsoleState = proxy({
messages: [] as ConsoleMessage[],
replEnabled: false,
consoleEnabled: false,
takeoverMode: false
})
const MessageLine = ({ message }: { message: ConsoleMessage }) => {

483
src/react/ModsPage.tsx Normal file
View file

@ -0,0 +1,483 @@
import { useEffect, useState, useMemo, useRef } from 'react'
import { useSnapshot } from 'valtio'
import { openURL } from 'renderer/viewer/lib/simpleUtils'
import { addRepositoryAction, setEnabledModAction, getAllModsDisplayList, installModByName, selectAndRemoveRepository, uninstallModAction, fetchAllRepositories, modsReactiveUpdater, modsErrors, fetchRepository, getModModifiableFields, saveClientModData, getAllModsModifiableFields } from '../clientMods'
import { createNotificationProgressReporter, ProgressReporter } from '../core/progressReporter'
import { hideModal } from '../globalState'
import { useIsModalActive } from './utilsApp'
import Input from './Input'
import Button from './Button'
import styles from './mods.module.css'
import { showOptionsModal, showInputsModal } from './SelectOption'
import Screen from './Screen'
import PixelartIcon, { pixelartIcons } from './PixelartIcon'
import { showNotification } from './NotificationProvider'
import { usePassesScaledDimensions } from './UIProvider'
import { appStorage } from './appStorageProvider'
type ModsData = Awaited<ReturnType<typeof getAllModsDisplayList>>
const ModListItem = ({
mod,
onClick,
hasError
}: {
mod: ModsData['repos'][0]['packages'][0],
onClick: () => void,
hasError: boolean
}) => (
<div
className={styles.modRow}
onClick={onClick}
data-enabled={mod.installed ? '' : mod.activated}
data-has-error={hasError}
>
<div className={styles.modRowTitle}>
{mod.name}
{mod.installedVersion && mod.installedVersion !== mod.version && (
<PixelartIcon
iconName={pixelartIcons['arrow-up-box']}
styles={{ fontSize: 14, marginLeft: 3 }}
/>
)}
</div>
<div className={styles.modRowInfo}>
{mod.description}
{mod.author && ` • By ${mod.author}`}
{mod.version && ` • v${mod.version}`}
{mod.serverPlugin && ` • World plugin`}
</div>
</div>
)
const ModSidebar = ({ mod }: { mod: (ModsData['repos'][0]['packages'][0] & { repo?: string }) | null }) => {
const errors = useSnapshot(modsErrors)
const [editingField, setEditingField] = useState<{ name: string, content: string, language: string } | null>(null)
const handleAction = async (action: () => Promise<void>, errorMessage: string, progress?: ProgressReporter) => {
try {
await action()
progress?.end()
} catch (error) {
console.error(error)
progress?.end()
showNotification(errorMessage, error.message, true)
}
}
if (!mod) {
return <div className={styles.modInfoText}>Select a mod to view details</div>
}
const modifiableFields = mod.installed ? getModModifiableFields(mod.installed) : []
const handleSaveField = async (newContents: string) => {
if (!editingField) return
try {
mod[editingField.name] = newContents
mod.wasModifiedLocally = true
await saveClientModData(mod)
setEditingField(null)
showNotification('Success', 'Contents saved successfully')
} catch (error) {
showNotification('Error', 'Failed to save contents: ' + error.message, true)
}
}
if (editingField) {
return (
<EditingCodeWindow
contents={editingField.content}
language={editingField.language}
onClose={newContents => {
if (newContents === undefined) {
setEditingField(null)
return
}
void handleSaveField(newContents)
}}
/>
)
}
return (
<>
<div className={styles.modInfo}>
<div className={styles.modInfoTitle}>
{mod.name} {mod.installed?.wasModifiedLocally ? '(modified)' : ''}
</div>
<div className={styles.modInfoText}>
{mod.description}
</div>
<div className={styles.modInfoText}>
{mod.author && `Author: ${mod.author}\n`}
{mod.version && `Version: ${mod.version}\n`}
{mod.installedVersion && mod.installedVersion !== mod.version && `Installed version: ${mod.installedVersion}\n`}
{mod.section && `Section: ${mod.section}\n`}
</div>
{errors[mod.name]?.length > 0 && (
<div className={styles.modErrorList}>
<ul>
{errors[mod.name].map((error, i) => (
<li key={i}>{error}</li>
))}
</ul>
</div>
)}
</div>
<div className={styles.modActions}>
{mod.installed ? (
<>
{mod.activated ? (
<Button
onClick={async () => handleAction(
async () => setEnabledModAction(mod.name, false),
'Failed to disable mod:'
)}
icon={pixelartIcons['remove-box']}
title="Disable"
/>
) : (
<Button
onClick={async () => handleAction(
async () => setEnabledModAction(mod.name, true),
'Failed to enable mod:'
)}
icon={pixelartIcons['add-box']}
title="Enable"
disabled={!mod.canBeActivated}
/>
)}
<Button
onClick={async () => handleAction(
async () => uninstallModAction(mod.name),
'Failed to uninstall mod:'
)}
icon={pixelartIcons.trash}
title="Delete"
/>
{mod.installedVersion && mod.installedVersion !== mod.version && (
<Button
onClick={async () => {
if (!mod.repo) return
const progress = createNotificationProgressReporter(`${mod.name} updated and activated`)
await handleAction(
async () => {
await installModByName(mod.repo!, mod.name, progress)
},
'Failed to update mod:',
progress
)
}}
icon={pixelartIcons['arrow-up-box']}
title="Update"
/>
)}
{mod.serverPlugin && (
<Button
onClick={async () => {
const url = new URL(window.location.href)
url.searchParams.set('sp', '1')
url.searchParams.set('serverPlugin', mod.name)
openURL(url.toString())
}}
icon={pixelartIcons.play}
title="Try in blank world"
/>
)}
</>
) : (
<Button
onClick={async () => {
if (!mod.repo) return
const progress = createNotificationProgressReporter(`${mod.name} installed and enabled`)
await handleAction(
async () => {
await installModByName(mod.repo!, mod.name, progress)
},
'Failed to install & activate mod:',
progress
)
}}
icon={pixelartIcons.download}
title="Install"
/>
)}
{modifiableFields.length > 0 && (
<Button
onClick={async (e) => {
const fields = e.shiftKey ? getAllModsModifiableFields() : modifiableFields
const result = await showInputsModal('Edit Mod Field', Object.fromEntries(fields.map(field => {
return [field.field, {
type: 'button' as const,
label: field.label,
onButtonClick () {
setEditingField({
name: field.field,
content: field.getContent?.() || mod.installed![field.field] || '',
language: field.language
})
}
}]
})), {
showConfirm: false
})
}}
icon={pixelartIcons['edit']}
title="Edit Mod"
/>
)}
</div>
</>
)
}
const EditingCodeWindow = ({
contents,
language,
onClose
}: {
contents: string,
language: string,
onClose: (newContents?: string) => void
}) => {
const ref = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
e.stopImmediatePropagation()
}
}
window.addEventListener('keydown', handleKeyDown, { capture: true })
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
}, [])
return <Screen title="Editing code">
<div className="">
<textarea
ref={ref}
className={styles.fieldEditorTextarea}
defaultValue={contents}
/>
<Button
style={{ position: 'absolute', bottom: 10, left: 10, backgroundColor: 'red' }}
onClick={() => onClose(undefined)}
icon={pixelartIcons.close}
title="Cancel"
/>
<Button
style={{ position: 'absolute', bottom: 10, right: 10, backgroundColor: '#4CAF50' }}
onClick={() => onClose(ref.current?.value)}
icon={pixelartIcons.check}
title="Save"
/>
</div>
</Screen>
}
export default () => {
const isModalActive = useIsModalActive('mods', true)
const [modsData, setModsData] = useState<ModsData | null>(null)
const [search, setSearch] = useState('')
const [showOnlyInstalled, setShowOnlyInstalled] = useState(false)
const [showOnlyEnabled, setShowOnlyEnabled] = useState(false)
const [selectedModIndex, setSelectedModIndex] = useState<number | null>(null)
const [expandedRepos, setExpandedRepos] = useState<Record<string, boolean>>({})
const useHorizontalLayout = usePassesScaledDimensions(400)
const { counter } = useSnapshot(modsReactiveUpdater)
const errors = useSnapshot(modsErrors)
const allModsArray = useMemo(() => {
if (!modsData) return []
return [
...modsData.repos.flatMap(repo => repo.packages.map(mod => ({ ...mod, repo: repo.url }))),
...modsData.modsWithoutRepos
]
}, [modsData])
useEffect(() => {
if (isModalActive) {
if (appStorage.firstModsPageVisit) {
appStorage.firstModsPageVisit = false
const defaultRepo = 'zardoy/mcraft-client-mods'
void fetchRepository(defaultRepo, defaultRepo)
}
void getAllModsDisplayList().then(mods => {
setModsData(mods)
// Update selected mod index if needed
if (selectedModIndex !== null && selectedModIndex < allModsArray.length) {
setSelectedModIndex(selectedModIndex)
}
})
}
}, [isModalActive, counter])
if (!isModalActive) return null
const toggleRepo = (repoUrl: string) => {
setExpandedRepos(prev => ({
...prev,
[repoUrl]: !prev[repoUrl]
}))
}
const modFilter = (mod: ModsData['repos'][0]['packages'][0]) => {
const matchesSearch = mod.name.toLowerCase().includes(search.toLowerCase()) ||
mod.description?.toLowerCase().includes(search.toLowerCase())
const matchesInstalledFilter = !showOnlyInstalled || mod.installed
const matchesEnabledFilter = !showOnlyEnabled || mod.activated
return matchesSearch && matchesInstalledFilter && matchesEnabledFilter
}
const filteredMods = modsData ? {
repos: modsData.repos.map(repo => ({
...repo,
packages: repo.packages.filter(modFilter)
})),
modsWithoutRepos: modsData.modsWithoutRepos.filter(modFilter)
} : null
const filteredModsCount = filteredMods ?
filteredMods.repos.reduce((acc, repo) => acc + repo.packages.length, 0) + filteredMods.modsWithoutRepos.length : 0
const totalRepos = modsData?.repos.length ?? 0
const getStatsText = () => {
if (!filteredMods) return 'Loading...'
if (showOnlyEnabled) {
return `Showing ${filteredMods.repos.reduce((acc, repo) => acc + repo.packages.filter(mod => mod.activated).length, 0)} enabled mods in ${totalRepos} repos`
} else if (showOnlyInstalled) {
return `Showing ${filteredMods.repos.reduce((acc, repo) => acc + repo.packages.filter(mod => mod.installed).length, 0)} installed mods in ${totalRepos} repos`
}
return `Showing all ${totalRepos} repos with ${filteredModsCount} mods`
}
const selectedMod = selectedModIndex === null ? null : allModsArray[selectedModIndex]
return <Screen backdrop="dirt" title="Client Mods (Preview)" titleMarginTop={0} contentStyle={{ paddingTop: 15, height: '100%', width: '100%' }}>
<Button
icon={pixelartIcons['close']}
onClick={() => {
hideModal()
}}
style={{
color: '#ff5d5d',
position: 'fixed',
top: 10,
left: 20
}}
/>
<div className={styles.root}>
<div className={styles.header}>
<Button
style={{}}
icon={pixelartIcons['sliders']}
onClick={() => {
if (showOnlyEnabled) {
setShowOnlyEnabled(false)
} else if (showOnlyInstalled) {
setShowOnlyInstalled(false)
setShowOnlyEnabled(true)
} else {
setShowOnlyInstalled(true)
}
}}
title={showOnlyEnabled ? 'Show all mods' : showOnlyInstalled ? 'Show enabled mods' : 'Show installed mods'}
/>
<Button
onClick={async () => {
// const refreshButton = `Refresh repositories (last update)`
const refreshButton = `Refresh repositories`
const choice = await showOptionsModal(`Manage repositories (${modsData?.repos.length ?? '-'} repos)`, ['Add repository', 'Remove repository', refreshButton])
switch (choice) {
case 'Add repository': {
await addRepositoryAction()
break
}
case 'Remove repository': {
await selectAndRemoveRepository()
break
}
case refreshButton: {
await fetchAllRepositories()
break
}
case undefined:
break
}
}}
icon={pixelartIcons['list-box']}
title="Manage repositories"
/>
<Input
className={styles.searchBar}
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search mods in added repositories..."
autoFocus
/>
</div>
<div className={styles.statsRow}>
{getStatsText()}
</div>
<div className={`${styles.content} ${useHorizontalLayout ? '' : styles.verticalContent}`}>
<div className={styles.modList}>
{filteredMods ? (
<>
{filteredMods.repos.map(repo => (
<div key={repo.url}>
<div
className={styles.repoHeader}
onClick={() => toggleRepo(repo.url)}
>
<span>{expandedRepos[repo.url] ? '▼' : '▶'}</span>
<span>{repo.name || repo.url}</span>
<span>({repo.packages.length})</span>
</div>
{expandedRepos[repo.url] && (
<div className={styles.repoContent}>
{repo.packages.map((mod) => (
<ModListItem
key={mod.name}
mod={mod}
onClick={() => setSelectedModIndex(allModsArray.findIndex(m => m.name === mod.name))}
hasError={errors[mod.name]?.length > 0}
/>
))}
</div>
)}
</div>
))}
{filteredMods.modsWithoutRepos.length > 0 && (
<div>
<div className={styles.repoHeader}>
<span></span>
<span>Other Mods</span>
<span>({filteredMods.modsWithoutRepos.length})</span>
</div>
<div className={styles.repoContent}>
{filteredMods.modsWithoutRepos.map(mod => (
<ModListItem
key={mod.name}
mod={mod}
onClick={() => setSelectedModIndex(allModsArray.findIndex(m => m.name === mod.name))}
hasError={errors[mod.name]?.length > 0}
/>
))}
</div>
</div>
)}
</>
) : (
<div className={styles.modRowInfo}>Loading mods...</div>
)}
</div>
<div className={styles.sidebar}>
<ModSidebar mod={selectedMod} />
</div>
</div>
</div>
</Screen>
}

View file

@ -25,7 +25,8 @@ export const showNotification = (
isError = false,
icon = '',
action = undefined as (() => void) | undefined,
autoHide = true
autoHide = true,
id = ''
) => {
notificationProxy.message = message
notificationProxy.subMessage = subMessage
@ -34,11 +35,14 @@ export const showNotification = (
notificationProxy.open = true
notificationProxy.autoHide = autoHide
notificationProxy.action = action
notificationProxy.id = id
}
globalThis.showNotification = showNotification
export const hideNotification = () => {
// openNotification('') // reset
notificationProxy.open = false
export const hideNotification = (id?: string) => {
if (id === undefined || notificationProxy.id === id) {
// openNotification('') // reset
notificationProxy.open = false
}
}
export default () => {
@ -47,7 +51,7 @@ export default () => {
useEffect(() => {
if (autoHide && open) {
setTimeout(() => {
hideNotification()
hideNotification(notificationProxy.id)
}, 7000)
}
}, [autoHide, open])

View file

@ -36,6 +36,7 @@ import { appStatusState, reconnectReload } from './AppStatusProvider'
import NetworkStatus from './NetworkStatus'
import PauseLinkButtons from './PauseLinkButtons'
import { pixelartIcons } from './PixelartIcon'
import LoadingTimer from './LoadingTimer'
const waitForPotentialRender = async () => {
return new Promise<void>(resolve => {
@ -298,5 +299,6 @@ export default () => {
</Button>
)}
</div>
<LoadingTimer />
</Screen>
}

View file

@ -43,7 +43,7 @@ export default function ReplayPanel ({
style
}: Props) {
const [filter, setFilter] = useState(defaultFilter)
const { isMinimized } = useSnapshot(packetsReplayState)
const { isMinimized, isRecording } = useSnapshot(packetsReplayState)
const { filtered: filteredPackets, hiddenCount } = filterPackets(packets.slice(-500), filter)
useEffect(() => {
@ -70,7 +70,9 @@ export default function ReplayPanel ({
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
{isPlaying ? (
{isRecording ? (
<circle cx="12" cy="12" r="8" fill="red" />
) : isPlaying ? (
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
) : (
<path d="M8 5v14l11-7z"/>
@ -137,7 +139,9 @@ export default function ReplayPanel ({
</button>
</div>
<div style={{ fontSize: '8px', color: '#888888', marginTop: '-8px' }}>Integrated server emulation. Testing client...</div>
<div style={{ fontSize: '8px', color: '#888888', marginTop: '-8px' }}>
{isRecording ? 'Recording packets...' : 'Integrated server emulation. Testing client...'}
</div>
<FilterInput
value={filter}
@ -155,7 +159,14 @@ export default function ReplayPanel ({
maxHeight={300}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
// grayscale if recording
filter: isRecording ? 'grayscale(100%)' : 'none',
cursor: isRecording ? 'not-allowed' : 'default'
}}>
{playPauseButton}
<ProgressBar current={progress.current} total={progress.total} />
</div>
@ -228,4 +239,5 @@ export interface PacketData {
actualVersion?: any
position: number
timestamp: number
isCustomChannel?: boolean
}

View file

@ -6,14 +6,15 @@ interface Props {
className?: string
titleSelectable?: boolean
titleMarginTop?: number
contentStyle?: React.CSSProperties
}
export default ({ title, children, backdrop = true, style, className = '', titleSelectable, titleMarginTop }: Props) => {
export default ({ title, children, backdrop = true, style, className = '', titleSelectable, titleMarginTop, contentStyle }: Props) => {
return (
<>
{backdrop === 'dirt' ? <div className='dirt-bg' /> : backdrop ? <div className="backdrop" /> : null}
<div className={`fullscreen ${className}`} style={{ overflow: 'auto', ...style }}>
<div className="screen-content" style={titleMarginTop === undefined ? {} : { marginTop: titleMarginTop }}>
<div className="screen-content" style={{ ...contentStyle, ...(titleMarginTop === undefined ? {} : { marginTop: titleMarginTop }) }}>
<div className={`screen-title ${titleSelectable ? 'text-select' : ''}`}>{title}</div>
{children}
</div>

View file

@ -48,21 +48,33 @@ export const showOptionsModal = async <T extends string> (
})
}
type InputOption = {
type: 'text' | 'checkbox'
export type InputOption = {
type: 'text' | 'checkbox' | 'button'
defaultValue?: string | boolean
label?: string
placeholder?: string
onButtonClick?: () => void
}
export const showInputsModal = async <T extends Record<string, InputOption>>(
title: string,
inputs: T,
{ cancel = true, minecraftJsonMessage }: { cancel?: boolean, minecraftJsonMessage? } = {}
{
cancel = true,
minecraftJsonMessage,
showConfirm = true
}: {
cancel?: boolean,
minecraftJsonMessage?
showConfirm?: boolean
} = {}
): Promise<{
[K in keyof T]: T[K] extends { type: 'text' }
? string
: T[K] extends { type: 'checkbox' }
? boolean
: never
: T[K] extends { type: 'button' }
? string
: never
}> => {
showModal({ reactType: 'general-select' })
let minecraftJsonMessageParsed
@ -81,7 +93,7 @@ export const showInputsModal = async <T extends Record<string, InputOption>>(
showCancel: cancel,
minecraftJsonMessage: minecraftJsonMessageParsed,
options: [],
inputsConfirmButton: 'Confirm'
inputsConfirmButton: showConfirm ? 'Confirm' : ''
})
})
}
@ -130,6 +142,7 @@ export default () => {
autoFocus
type='text'
defaultValue={input.defaultValue as string}
placeholder={input.placeholder}
onChange={(e) => {
inputValues.current[key] = e.target.value
}}
@ -148,6 +161,15 @@ export default () => {
{label}
</label>
)}
{input.type === 'button' && (
<Button
onClick={() => {
resolveClose(inputValues.current)
input.onButtonClick?.()
}}
>{label}
</Button>
)}
</div>
})}
</div>

View file

@ -16,6 +16,11 @@ import { showOptionsModal } from './SelectOption'
import { useCopyKeybinding } from './simpleHooks'
import { AuthenticatedAccount, getInitialServersList, getServerConnectionHistory, setNewServersList } from './serversStorage'
import { appStorage, StoreServerItem } from './appStorageProvider'
import Button from './Button'
import { pixelartIcons } from './PixelartIcon'
import { showNotification } from './NotificationProvider'
const EXPLICIT_SHARE_SERVER_MODE = false
if (appQueryParams.lockConnect) {
notHideableModalsWithoutForce.add('editServer')
@ -350,6 +355,27 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
}}
worldData={serversListSorted.map(server => {
const additional = additionalServerData[server.ip]
const handleShare = async () => {
try {
const qs = new URLSearchParams()
qs.set('ip', server.ip)
if (server.proxyOverride) qs.set('proxy', server.proxyOverride)
if (server.versionOverride) qs.set('version', server.versionOverride)
qs.set('username', server.usernameOverride ?? '')
const shareUrl = `${window.location.origin}${window.location.pathname}?${qs.toString()}`
await navigator.clipboard.writeText(shareUrl)
const MESSAGE = 'Server link copied to clipboard'
if (EXPLICIT_SHARE_SERVER_MODE) {
await showOptionsModal(MESSAGE, [])
} else {
showNotification(MESSAGE)
}
} catch (err) {
console.error(err)
showNotification('Failed to copy server link to clipboard')
}
}
return {
name: server.index.toString(),
title: server.name || server.ip,
@ -359,6 +385,16 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
worldNameRightGrayed: additional?.textNameRightGrayed ?? '',
iconSrc: additional?.icon,
offline: additional?.offline,
afterTitleUi: (
<Button
icon="external-link"
style={{ marginRight: 8, width: 20, height: 20 }}
onClick={(e) => {
e.stopPropagation()
void handleShare()
}}
/>
),
group: customServersList ? 'Provided Servers' : 'Saved Servers'
}
})}
@ -397,6 +433,8 @@ export default () => {
const modalStack = useSnapshot(activeModalStack)
const hasServersListModal = modalStack.some(x => x.reactType === 'serversList')
const editServerModalActive = useIsModalActive('editServer')
const generalSelectActive = useIsModalActive('general-select')
// const isServersListModalActive = useIsModalActive('serversList') || (modalStack.some(x => x.reactType === 'serversList') && generalSelectActive)
const isServersListModalActive = useIsModalActive('serversList')
const eitherModal = isServersListModalActive || editServerModalActive

View file

@ -0,0 +1,77 @@
import { proxy, useSnapshot } from 'valtio'
import PixelartIcon, { pixelartIcons } from './PixelartIcon'
export const voiceChatStatus = proxy({
active: false,
muted: false,
hasInputVoice: false,
isErrored: false,
isConnected: false,
isAlone: false,
isSharingScreen: false,
})
window.voiceChatStatus = voiceChatStatus
const Icon = () => {
return <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path fill="currentColor" d="M9 14H8v-2h3v-1H8V9h3V8H8V6h3V5H8V3h1V2h1V1h4v1h1v1h1v2h-3v1h3v2h-3v1h3v2h-3v1h3v2h-1v1h-1v1h-4v-1H9z" />
<path fill="currentColor" d="M19 12v3h-1v2h-1v1h-2v1h-2v2h3v2H8v-2h3v-2H9v-1H7v-1H6v-2H5v-3h1v2h1v2h1v1h2v1h4v-1h2v-1h1v-2h1v-2z" />
</svg>
}
export default () => {
const SIZE = 48
const { active, muted, hasInputVoice, isSharingScreen, isConnected, isErrored, isAlone } = useSnapshot(voiceChatStatus)
if (!active) return null
const getRingColor = () => {
if (isErrored) return 'rgba(214, 4, 4, 0.5)' // red with opacity
if (isConnected) {
if (isAlone) return 'rgba(183, 255, 0, 0.5)' // lime yellow
return 'rgba(50, 205, 50, 0.5)' // green with opacity
}
return 'rgba(128, 128, 128, 0.5)' // gray with opacity
}
return (
<div
className='voice-chat-microphone'
onClick={() => {
// toggleMicrophoneMuted()
}}
>
<div style={{
position: 'fixed',
bottom: '20px',
zIndex: 10,
left: '20px',
width: SIZE,
height: SIZE,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: muted ? 'rgb(214 4 4)' : hasInputVoice ? '#32cd32' : '#ffffff',
border: `2px solid ${getRingColor()}`,
borderRadius: '50%',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.2)',
transition: 'background-color 0.2s ease',
}}>
<Icon />
</div>
{/* stop sharing screen */}
{isSharingScreen && <div style={{
position: 'fixed',
bottom: '20px',
left: '20px',
width: SIZE,
height: SIZE,
display: 'flex',
alignItems: 'center',
}}>
<PixelartIcon iconName={pixelartIcons.cast} /> Stop Sharing Screen
</div>}
</div>
)
}

View file

@ -7,6 +7,7 @@ import type { BaseServerInfo } from './AddServerOrConnect'
// when opening html file locally in browser, localStorage is shared between all ever opened html files, so we try to avoid conflicts
const localStoragePrefix = process.env?.SINGLE_FILE_BUILD ? 'minecraft-web-client:' : ''
const { localStorage } = window
export interface SavedProxiesData {
proxies: string[]
@ -39,6 +40,8 @@ type StorageData = {
serversHistory: ServerHistoryEntry[]
authenticatedAccounts: AuthenticatedAccount[]
serversList: StoreServerItem[] | undefined
modsAutoUpdateLastCheck: number | undefined
firstModsPageVisit: boolean
}
const oldKeysAliases: Partial<Record<keyof StorageData, string>> = {
@ -79,6 +82,8 @@ const defaultStorageData: StorageData = {
serversHistory: [],
authenticatedAccounts: [],
serversList: undefined,
modsAutoUpdateLastCheck: undefined,
firstModsPageVisit: true,
}
export const setStorageDataOnAppConfigLoad = () => {
@ -86,7 +91,6 @@ export const setStorageDataOnAppConfigLoad = () => {
}
export const appStorage = proxy({ ...defaultStorageData })
window.appStorage = appStorage
// Restore data from localStorage
for (const key of Object.keys(defaultStorageData)) {

View file

@ -16,11 +16,12 @@ const formatters: Record<string, (data: any) => string> = {
default: (data) => processPacketDataForLogging(data)
}
const getPacketIcon = (name: string): string => {
if (name.includes('position')) return '📍'
if (name.includes('chat')) return '💬'
if (name.includes('block') || name.includes('chunk') || name.includes('light')) return '📦'
if (name.includes('entity') || name.includes('player') || name.includes('passenger')) return '🎯'
const getPacketIcon = (packet: PacketData): string => {
if (packet.isCustomChannel) return '🔗'
if (packet.name.includes('position')) return '📍'
if (packet.name.includes('chat')) return '💬'
if (packet.name.includes('block') || packet.name.includes('chunk') || packet.name.includes('light')) return '📦'
if (packet.name.includes('entity') || packet.name.includes('player') || packet.name.includes('passenger')) return '🎯'
return '📄'
}
@ -102,7 +103,7 @@ export default function PacketList ({ packets, filter, maxHeight = 300 }: Props)
opacity: packet.isUpcoming ? 0.5 : 1
}}
>
<span>{getPacketIcon(packet.name)}</span>
<span>{getPacketIcon(packet)}</span>
<span style={{ color: DARK_COLORS.textDim }}>
#{packet.position}
{timeDiff && <span style={{ marginLeft: '4px' }}>{timeDiff}</span>}

View file

@ -23,6 +23,13 @@ export const useScrollBehavior = (
const scrollToBottom = () => {
if (elementRef.current) {
elementRef.current.scrollTop = elementRef.current.scrollHeight
setTimeout(() => {
if (!elementRef.current) return
elementRef.current.scrollTo({
top: elementRef.current.scrollHeight,
behavior: 'instant'
})
}, 0)
}
}

193
src/react/mods.module.css Normal file
View file

@ -0,0 +1,193 @@
.root {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
padding: 10px;
padding-top: 0;
box-sizing: border-box;
gap: 10px;
}
.header {
display: flex;
gap: 5px;
}
.statsRow {
color: #999;
font-size: 10px;
margin-bottom: 8px;
}
.statsRow {
color: #999;
font-size: 10px;
margin-bottom: 8px;
}
.searchBar {
flex: 1;
}
.content {
display: flex;
flex: 1;
gap: 10px;
overflow: hidden;
min-height: 0; /* Important for Firefox */
}
.verticalContent {
flex-direction: column;
}
.verticalContent .modList {
height: 50%;
min-height: 200px;
}
.verticalContent .sidebar {
height: 50%;
width: 100%;
}
.modList {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 5px;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
padding: 5px;
min-height: 0; /* Important for Firefox */
height: 100%;
}
.sidebar {
width: 200px;
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px;
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
flex-shrink: 0;
height: 100%;
}
.modInfo {
display: flex;
flex-direction: column;
gap: 5px;
}
.modInfoTitle {
font-size: 12px;
font-weight: bold;
color: white;
}
.modInfoText {
font-size: 10px;
white-space: pre-wrap;
color: #bcbcbc;
}
.modActions {
display: flex;
gap: 5px;
}
.modRow {
display: flex;
flex-direction: column;
padding: 8px;
border-radius: 4px;
cursor: pointer;
}
.modRow:hover {
background: rgba(0, 0, 0, 0.2);
}
.modRowTitle {
font-size: 12px;
color: white;
margin-bottom: 4px;
display: flex;
}
.modRowInfo {
font-size: 10px;
color: #bcbcbc;
}
.repoHeader {
display: flex;
align-items: center;
gap: 4px;
color: #bcbcbc;
font-size: 8px;
cursor: pointer;
padding: 4px;
border-radius: 4px;
}
.repoHeader:hover {
background: rgba(0, 0, 0, 0.2);
}
.repoContent {
margin-left: 10px;
display: flex;
flex-direction: column;
gap: 5px;
}
/* Mod state styles */
.modRow[data-enabled="false"] {
opacity: 0.5;
}
.modRow[data-enabled="true"] {
color: lime;
}
.modRow[data-enabled="true"] .modRowTitle {
color: lime;
}
/* Error state styles */
.modRow[data-has-error="true"] {
background: rgba(255, 0, 0, 0.1);
}
.modRow[data-has-error="true"] .modRowTitle {
color: #ff6b6b;
}
.modErrorList {
font-size: 8px;
color: #ff6b6b;
margin-top: 5px;
padding-left: 10px;
list-style-type: disc;
}
.fieldEditorTextarea {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
font-size: 7px;
}
.fieldEditorTextarea {
position: absolute;
width: 100%;
height: 100%;
padding: 10px;
}

25
src/react/mods.module.css.d.ts vendored Normal file
View file

@ -0,0 +1,25 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
content: string;
fieldEditorTextarea: string;
header: string;
modActions: string;
modErrorList: string;
modInfo: string;
modInfoText: string;
modInfoTitle: string;
modList: string;
modRow: string;
modRowInfo: string;
modRowTitle: string;
repoContent: string;
repoHeader: string;
root: string;
searchBar: string;
sidebar: string;
statsRow: string;
verticalContent: string;
}
declare const cssExports: CssExports;
export default cssExports;

View file

@ -6,6 +6,7 @@ export const packetsReplayState = proxy({
packetsPlayback: [] as PacketData[],
isOpen: false,
isMinimized: false,
isRecording: false,
replayName: '',
isPlaying: false,
progress: {

View file

@ -45,6 +45,7 @@ import SignInMessageProvider from './react/SignInMessageProvider'
import BookProvider from './react/BookProvider'
import { options } from './optionsStorage'
import BossBarOverlayProvider from './react/BossBarOverlayProvider'
import ModsPage from './react/ModsPage'
import DebugEdges from './react/DebugEdges'
import GameInteractionOverlay from './react/GameInteractionOverlay'
import MineflayerPluginHud from './react/MineflayerPluginHud'
@ -54,6 +55,11 @@ import { useAppScale } from './scaleInterface'
import PacketsReplayProvider from './react/PacketsReplayProvider'
import TouchInteractionHint from './react/TouchInteractionHint'
import { ua } from './react/utils'
import VoiceMicrophone from './react/VoiceMicrophone'
import ConnectOnlyServerUi from './react/ConnectOnlyServerUi'
import ControDebug from './react/ControDebug'
import ChunksDebug from './react/ChunksDebug'
import ChunksDebugScreen from './react/ChunksDebugScreen'
const isFirefox = ua.getBrowser().name === 'Firefox'
if (isFirefox) {
@ -156,6 +162,8 @@ const InGameUi = () => {
{!disabledUiParts.includes('crosshair') && <Crosshair />}
{!disabledUiParts.includes('books') && <BookProvider />}
{!disabledUiParts.includes('bossbars') && displayBossBars && <BossBarOverlayProvider />}
<VoiceMicrophone />
<ChunksDebugScreen />
</PerComponentErrorBoundary>
</div>
@ -208,6 +216,7 @@ const App = () => {
<HeldMapUi />
</InGameComponent>
</div>
<ControDebug />
<div />
</RobustPortal>
<EnterFullscreenButton />
@ -218,14 +227,17 @@ const App = () => {
<CreateWorldProvider />
<AppStatusProvider />
<KeybindingsScreenProvider />
<SelectOption />
<ServersListProvider />
<OptionsRenderApp />
<MainMenuRenderApp />
<ConnectOnlyServerUi />
<TouchAreasControlsProvider />
<SignInMessageProvider />
<PacketsReplayProvider />
<NotificationProvider />
<ModsPage />
<SelectOption />
<NoModalFoundProvider />
</RobustPortal>

View file

@ -35,6 +35,7 @@ setScale()
subscribeKey(options, 'guiScale', setScale)
watchValue(currentScaling, (c) => {
document.documentElement.style.setProperty('--guiScale', String(c.scale))
document.documentElement.style.setProperty('--scale', String(c.scale))
})
window.addEventListener('resize', setScale)

View file

@ -17,6 +17,7 @@
inset: 0;
height: 100dvh;
background: rgba(0, 0, 0, 0.75);
z-index: 12;
}
.fullscreen {
@ -43,9 +44,25 @@
gap: 10px;
}
.scaled-content-container {
font-size: calc(16px * var(--scale));
}
.scaled-content-container .screen-title {
font-size: calc(10px * var(--scale));
margin-top: calc(35px * var(--scale));
}
.scaled-content-container .dirt-bg {
image-rendering: pixelated;
background-size: calc(32px * var(--scale));
transform: none;
}
@media screen and (max-height: 426px) {
.fullscreen:not(.small-content) {
.screen-content {
/* margin-top: calc(14px * var(--scale)); */
margin-top: 14px;
}

View file

@ -2,7 +2,14 @@
// Custom DNS resolver made by SiebeDW. Powered by google dns.
// Supported: SRV (not all errors support)
module.exports.resolveSrv = function (hostname, callback) {
module.exports.resolveSrv = function (hostname, _callback) {
globalThis.setLoadingMessage?.(`Getting SRV using Google DNS`)
const callback = (err, result) => {
globalThis.setLoadingMessage?.(undefined)
_callback(err, result)
}
const Http = new XMLHttpRequest()
const url = `https://dns.google.com/resolve?name=${hostname}&type=SRV`
Http.open('GET', url)
@ -14,6 +21,7 @@ module.exports.resolveSrv = function (hostname, callback) {
Http.onerror = async function () {
try {
if (!globalThis.resolveDnsFallback) return
globalThis.setLoadingMessage?.('Resolving SRV using fallback')
const result = await globalThis.resolveDnsFallback(minecraftServerHostname)
callback(null, result ? [{
priority: 0,

View file

@ -1,5 +1,7 @@
import { EventEmitter } from 'events'
EventEmitter.defaultMaxListeners = 200
const oldEmit = EventEmitter.prototype.emit
EventEmitter.prototype.emit = function (...args) {
if (args[0] === 'error' && !this._events.error) {

View file

@ -8,6 +8,7 @@ import { options } from '../optionsStorage'
import { loadOrPlaySound } from '../basicSounds'
import { getActiveResourcepackBasePath, resourcePackState } from '../resourcePack'
import { showNotification } from '../react/NotificationProvider'
import { pixelartIcons } from '../react/PixelartIcon'
import { createSoundMap, SoundMap } from './soundsMap'
import { musicSystem } from './musicSystem'
@ -33,7 +34,7 @@ subscribeKey(miscUiState, 'gameLoaded', async () => {
globalThis.soundMap = soundMap
if (!soundMap) return
if (soundMap.noVersionIdMapping) {
showNotification('No sound ID mapping for this version', undefined, true)
showNotification('No exact sound ID mappings for this version', undefined, false, pixelartIcons['warning-box'])
}
void updateResourcePack()
startMusicSystem()
@ -115,7 +116,7 @@ subscribeKey(miscUiState, 'gameLoaded', async () => {
const soundResource = packet['soundEvent']?.resource as string | undefined
const pos = new Vec3(packet.x / 8, packet.y / 8, packet.z / 8)
if (packet.soundId !== 0 || !soundResource) {
const soundKey = soundMap!.soundsIdToName[packet.soundId]
const soundKey = soundMap!.soundsIdToName[packet.soundId - 1]
if (soundKey === undefined) return
await playGeneralSound(soundKey, pos, packet.volume, packet.pitch)
return
@ -134,10 +135,12 @@ subscribeKey(miscUiState, 'gameLoaded', async () => {
const movementHappening = async () => {
if (!bot.entity || !soundMap) return // no info yet
const VELOCITY_THRESHOLD = 0.1
const RUN_THRESHOLD = 0.15
const { x, z, y } = bot.entity.velocity
if (bot.entity.onGround && Math.abs(x) < VELOCITY_THRESHOLD && (Math.abs(z) > VELOCITY_THRESHOLD || Math.abs(y) > VELOCITY_THRESHOLD)) {
if (bot.entity.onGround && (Math.abs(x) > VELOCITY_THRESHOLD || Math.abs(z) > VELOCITY_THRESHOLD)) {
const isRunning = (Math.abs(x) > RUN_THRESHOLD || Math.abs(z) > RUN_THRESHOLD)
// movement happening
if (Date.now() - lastStepSound > 300) {
if (Date.now() - lastStepSound > (isRunning ? 100 : 300)) {
const blockUnder = bot.world.getBlock(bot.entity.position.offset(0, -1, 0))
if (blockUnder) {
const stepSound = soundMap.getStepSound(blockUnder.name)

View file

@ -39,6 +39,7 @@ export const pointerLock = {
void goFullscreen()
}
const displayBrowserProblem = () => {
if (notificationProxy.id === 'auto-login') return // prevent notification hide
showNotification('Browser Delay Limitation', navigator['keyboard'] ? 'Click on screen, enable Auto Fullscreen or F11' : 'Click on screen or use fullscreen in Chrome')
notificationProxy.id = 'pointerlockchange'
}

View file

@ -8,6 +8,7 @@ import { CustomChannelPacketFromClient, CustomChannelPacketFromServer, UIDefinit
import { activeModalStack } from './globalState'
import { mineflayerPluginHudState } from './react/MineflayerPluginHud'
import { mineflayerConsoleState } from './react/MineflayerPluginConsole'
import { showNotification } from './react/NotificationProvider'
export const viewerVersionState = proxy({
forwardChat: true,
@ -43,6 +44,7 @@ export const getViewerVersionData = async (url: string) => {
requiresPass: boolean,
forwardChat: boolean,
clientIgnoredPackets?: string[]
takeoverMode?: boolean
}>((resolve, reject) => {
ws.addEventListener('message', async (message) => {
const { data } = message
@ -67,6 +69,7 @@ export const getViewerVersionData = async (url: string) => {
})
mineflayerConsoleState.consoleEnabled = result.consoleEnabled
mineflayerConsoleState.replEnabled = result.replEnabled
mineflayerConsoleState.takeoverMode = result.takeoverMode ?? false
return result
}
@ -125,7 +128,7 @@ export const getWsProtocolStream = async (url: string) => {
const CHANNEL_NAME = 'minecraft-web-client:data'
export const handleCustomChannel = async () => {
const handleCustomChannel = () => {
bot._client.registerChannel(CHANNEL_NAME, ['string', []])
const toCleanup = [] as Array<() => void>
subscribe(activeModalStack, () => {
@ -288,4 +291,35 @@ export const handleCustomChannel = async () => {
// No default
}
})
return {
send
}
}
export const onBotCreatedViewerHandler = async () => {
const { send } = handleCustomChannel()
bot.physicsEnabled = false
await new Promise<void>(resolve => {
bot.once('inject_allowed', resolve)
})
const originalSetControlState = bot.setControlState.bind(bot)
bot.setControlState = (control, state) => {
if (bot.controlState[control] === state) {
return
}
if (!mineflayerConsoleState.takeoverMode) {
showNotification('Remote control is not enabled', 'Enable takeoverMode in bot plugin settings first')
return
}
// send command to viewer
send({
type: 'setControlState',
control,
value: state
})
originalSetControlState(control, state)
}
}