958 lines
32 KiB
TypeScript
958 lines
32 KiB
TypeScript
/* eslint-disable import/order */
|
|
import './importsWorkaround'
|
|
import './styles.css'
|
|
import './globals'
|
|
import 'iconify-icon'
|
|
import './devtools'
|
|
import './entities'
|
|
import './globalDomListeners'
|
|
import initCollisionShapes from './getCollisionShapes'
|
|
import { itemsAtlases, onGameLoad } from './inventoryWindows'
|
|
import { supportedVersions } from 'minecraft-protocol'
|
|
|
|
import 'core-js/features/array/at'
|
|
import 'core-js/features/promise/with-resolvers'
|
|
|
|
import './scaleInterface'
|
|
import itemsPng from 'prismarine-viewer/public/textures/items.png'
|
|
import { initWithRenderer } from './topRightStats'
|
|
import PrismarineBlock from 'prismarine-block'
|
|
import PrismarineItem from 'prismarine-item'
|
|
|
|
import { options, watchValue } from './optionsStorage'
|
|
import './reactUi.jsx'
|
|
import { contro, onBotCreate } from './controls'
|
|
import './dragndrop'
|
|
import { possiblyCleanHandle, resetStateAfterDisconnect } from './browserfs'
|
|
import { watchOptionsAfterViewerInit } from './watchOptions'
|
|
import downloadAndOpenFile from './downloadAndOpenFile'
|
|
|
|
import fs from 'fs'
|
|
import net from 'net'
|
|
import mineflayer from 'mineflayer'
|
|
import { WorldDataEmitter, Viewer } from 'prismarine-viewer/viewer'
|
|
import pathfinder from 'mineflayer-pathfinder'
|
|
import { Vec3 } from 'vec3'
|
|
|
|
import worldInteractions from './worldInteractions'
|
|
|
|
import * as THREE from 'three'
|
|
import MinecraftData, { versionsByMinecraftVersion } from 'minecraft-data'
|
|
import debug from 'debug'
|
|
import { defaultsDeep } from 'lodash-es'
|
|
|
|
import { initVR } from './vr'
|
|
import {
|
|
AppConfig,
|
|
activeModalStack,
|
|
activeModalStacks,
|
|
hideModal,
|
|
insertActiveModalStack,
|
|
isGameActive,
|
|
miscUiState,
|
|
showModal
|
|
} from './globalState'
|
|
|
|
|
|
import {
|
|
pointerLock,
|
|
toMajorVersion,
|
|
setLoadingScreenStatus
|
|
} from './utils'
|
|
import { isCypress } from './standaloneUtils'
|
|
|
|
import {
|
|
removePanorama
|
|
} from './panorama'
|
|
|
|
import { startLocalServer, unsupportedLocalServerFeatures } from './createLocalServer'
|
|
import defaultServerOptions from './defaultLocalServerOptions'
|
|
import dayCycle from './dayCycle'
|
|
|
|
import { genTexturePackTextures, watchTexturepackInViewer } from './texturePack'
|
|
import { connectToPeer } from './localServerMultiplayer'
|
|
import CustomChannelClient from './customClient'
|
|
import { loadScript } from 'prismarine-viewer/viewer/lib/utils'
|
|
import { registerServiceWorker } from './serviceWorker'
|
|
import { appStatusState, lastConnectOptions } from './react/AppStatusProvider'
|
|
|
|
import { fsState } from './loadSave'
|
|
import { watchFov } from './rendererUtils'
|
|
import { loadInMemorySave } from './react/SingleplayerProvider'
|
|
|
|
import { downloadSoundsIfNeeded, earlyCheck as earlySoundsMapCheck } from './soundSystem'
|
|
import { ua } from './react/utils'
|
|
import { handleMovementStickDelta, joystickPointer } from './react/TouchAreasControls'
|
|
import { possiblyHandleStateVariable } from './googledrive'
|
|
import flyingSquidEvents from './flyingSquidEvents'
|
|
import { hideNotification, notificationProxy, showNotification } from './react/NotificationProvider'
|
|
import { saveToBrowserMemory } from './react/PauseScreen'
|
|
import { ViewerWrapper } from 'prismarine-viewer/viewer/lib/viewerWrapper'
|
|
import './devReload'
|
|
import './water'
|
|
import { ConnectOptions } from './connect'
|
|
import { subscribe } from 'valtio'
|
|
|
|
window.debug = debug
|
|
window.THREE = THREE
|
|
window.worldInteractions = worldInteractions
|
|
window.beforeRenderFrame = []
|
|
|
|
// ACTUAL CODE
|
|
|
|
void registerServiceWorker()
|
|
watchFov()
|
|
initCollisionShapes()
|
|
|
|
// Create three.js context, add to page
|
|
let renderer: THREE.WebGLRenderer
|
|
try {
|
|
renderer = new THREE.WebGLRenderer({
|
|
powerPreference: options.gpuPreference,
|
|
preserveDrawingBuffer: true,
|
|
logarithmicDepthBuffer: true,
|
|
})
|
|
} catch (err) {
|
|
console.error(err)
|
|
throw new Error(`Failed to create WebGL context, not possible to render (restart browser): ${err.message}`)
|
|
}
|
|
|
|
// renderer.localClippingEnabled = true
|
|
initWithRenderer(renderer.domElement)
|
|
const renderWrapper = new ViewerWrapper(renderer.domElement, renderer)
|
|
// renderWrapper.addToPage()
|
|
watchValue(options, (o) => {
|
|
renderWrapper.renderInterval = o.frameLimit ? 1000 / o.frameLimit : 0
|
|
renderWrapper.renderIntervalUnfocused = o.backgroundRendering === '5fps' ? 1000 / 5 : o.backgroundRendering === '20fps' ? 1000 / 20 : undefined
|
|
})
|
|
|
|
const isFirefox = ua.getBrowser().name === 'Firefox'
|
|
if (isFirefox) {
|
|
// set custom property
|
|
document.body.style.setProperty('--thin-if-firefox', 'thin')
|
|
}
|
|
|
|
const isIphone = ua.getDevice().model === 'iPhone' // todo ipad?
|
|
|
|
if (isIphone) {
|
|
document.documentElement.style.setProperty('--hud-bottom-max', '21px') // env-safe-aria-inset-bottom
|
|
}
|
|
|
|
// Create viewer
|
|
const viewer: import('prismarine-viewer/viewer/lib/viewer').Viewer = new Viewer(renderer)
|
|
window.viewer = viewer
|
|
new THREE.TextureLoader().load(itemsPng, (texture) => {
|
|
viewer.entities.itemsTexture = texture
|
|
// todo unify
|
|
viewer.entities.getItemUv = (id) => {
|
|
try {
|
|
const name = loadedData.items[id]?.name
|
|
const uv = itemsAtlases.latest.textures[name]
|
|
if (!uv) {
|
|
const variant = viewer.world.downloadedBlockStatesData[name]?.variants?.['']
|
|
if (!variant) return
|
|
const faces = (Array.isArray(variant) ? variant[0] : variant).model?.elements?.[0]?.faces
|
|
const uvBlock = faces?.north?.texture ?? faces?.up?.texture ?? faces?.down?.texture ?? faces?.west?.texture ?? faces?.east?.texture ?? faces?.south?.texture
|
|
if (!uvBlock) return
|
|
return {
|
|
...uvBlock,
|
|
size: Math.abs(uvBlock.su),
|
|
texture: viewer.world.material.map
|
|
}
|
|
}
|
|
return {
|
|
...uv,
|
|
size: itemsAtlases.latest.size,
|
|
texture: viewer.entities.itemsTexture
|
|
}
|
|
} catch (err) {
|
|
reportError?.(err)
|
|
return {
|
|
u: 0,
|
|
v: 0,
|
|
size: 16 / viewer.world.material.map!.image.width,
|
|
texture: viewer.world.material.map
|
|
}
|
|
}
|
|
}
|
|
})
|
|
viewer.entities.entitiesOptions = {
|
|
fontFamily: 'mojangles'
|
|
}
|
|
watchOptionsAfterViewerInit()
|
|
watchTexturepackInViewer(viewer)
|
|
|
|
let mouseMovePostHandle = (e) => { }
|
|
let lastMouseMove: number
|
|
const updateCursor = () => {
|
|
worldInteractions.update()
|
|
}
|
|
function onCameraMove (e) {
|
|
if (e.type !== 'touchmove' && !pointerLock.hasPointerLock) return
|
|
e.stopPropagation?.()
|
|
const now = performance.now()
|
|
// todo: limit camera movement for now to avoid unexpected jumps
|
|
if (now - lastMouseMove < 4) return
|
|
lastMouseMove = now
|
|
let { mouseSensX, mouseSensY } = options
|
|
if (mouseSensY === -1) mouseSensY = mouseSensX
|
|
mouseMovePostHandle({
|
|
x: e.movementX * mouseSensX * 0.0001,
|
|
y: e.movementY * mouseSensY * 0.0001
|
|
})
|
|
updateCursor()
|
|
}
|
|
window.addEventListener('mousemove', onCameraMove, { capture: true })
|
|
contro.on('stickMovement', ({ stick, vector }) => {
|
|
if (!isGameActive(true)) return
|
|
if (stick !== 'right') return
|
|
let { x, z } = vector
|
|
if (Math.abs(x) < 0.18) x = 0
|
|
if (Math.abs(z) < 0.18) z = 0
|
|
onCameraMove({ movementX: x * 10, movementY: z * 10, type: 'touchmove' })
|
|
miscUiState.usingGamepadInput = true
|
|
})
|
|
|
|
function hideCurrentScreens () {
|
|
activeModalStacks['main-menu'] = [...activeModalStack]
|
|
insertActiveModalStack('', [])
|
|
}
|
|
|
|
const loadSingleplayer = (serverOverrides = {}, flattenedServerOverrides = {}) => {
|
|
void connect({ singleplayer: true, username: options.localUsername, password: '', serverOverrides, serverOverridesFlat: flattenedServerOverrides })
|
|
}
|
|
function listenGlobalEvents () {
|
|
window.addEventListener('connect', e => {
|
|
const options = (e as CustomEvent).detail
|
|
void connect(options)
|
|
})
|
|
window.addEventListener('singleplayer', (e) => {
|
|
loadSingleplayer((e as CustomEvent).detail)
|
|
})
|
|
}
|
|
|
|
let listeners = [] as Array<{ target, event, callback }>
|
|
let cleanupFunctions = [] as Array<() => void>
|
|
// only for dom listeners (no removeAllListeners)
|
|
// todo refactor them out of connect fn instead
|
|
const registerListener: import('./utilsTs').RegisterListener = (target, event, callback) => {
|
|
target.addEventListener(event, callback)
|
|
listeners.push({ target, event, callback })
|
|
}
|
|
const removeAllListeners = () => {
|
|
for (const { target, event, callback } of listeners) {
|
|
target.removeEventListener(event, callback)
|
|
}
|
|
for (const cleanupFunction of cleanupFunctions) {
|
|
cleanupFunction()
|
|
}
|
|
cleanupFunctions = []
|
|
listeners = []
|
|
}
|
|
|
|
const cleanConnectIp = (host: string | undefined, defaultPort: string | undefined) => {
|
|
const hostPort = host && /:\d+$/.exec(host)
|
|
if (hostPort) {
|
|
return {
|
|
host: host.slice(0, -hostPort[0].length),
|
|
port: hostPort[0].slice(1)
|
|
}
|
|
} else {
|
|
return { host, port: defaultPort }
|
|
}
|
|
}
|
|
|
|
async function connect (connectOptions: ConnectOptions) {
|
|
if (miscUiState.gameLoaded) return
|
|
miscUiState.hasErrors = false
|
|
lastConnectOptions.value = connectOptions
|
|
removePanorama()
|
|
|
|
const { singleplayer } = connectOptions
|
|
const p2pMultiplayer = !!connectOptions.peerId
|
|
miscUiState.singleplayer = singleplayer
|
|
miscUiState.flyingSquid = singleplayer || p2pMultiplayer
|
|
const { renderDistance: renderDistanceSingleplayer, multiplayerRenderDistance } = options
|
|
const server = cleanConnectIp(connectOptions.server, '25565')
|
|
const proxy = cleanConnectIp(connectOptions.proxy, undefined)
|
|
const { username, password } = connectOptions
|
|
|
|
console.log(`connecting to ${server.host}:${server.port} with ${username}`)
|
|
|
|
hideCurrentScreens()
|
|
setLoadingScreenStatus('Logging in')
|
|
|
|
let ended = false
|
|
let bot!: typeof __type_bot
|
|
const destroyAll = () => {
|
|
if (ended) return
|
|
ended = true
|
|
viewer.resetAll()
|
|
localServer = window.localServer = window.server = undefined
|
|
|
|
renderWrapper.postRender = () => { }
|
|
if (bot) {
|
|
bot.end()
|
|
// ensure mineflayer plugins receive this event for cleanup
|
|
bot.emit('end', '')
|
|
bot.removeAllListeners()
|
|
bot._client.removeAllListeners()
|
|
//@ts-expect-error TODO?
|
|
bot._client = undefined
|
|
//@ts-expect-error
|
|
window.bot = bot = undefined
|
|
}
|
|
resetStateAfterDisconnect()
|
|
cleanFs()
|
|
removeAllListeners()
|
|
}
|
|
const cleanFs = () => {
|
|
if (singleplayer && !fsState.inMemorySave) {
|
|
possiblyCleanHandle(() => {
|
|
// todo: this is not enough, we need to wait for all async operations to finish
|
|
})
|
|
}
|
|
}
|
|
let lastPacket = undefined as string | undefined
|
|
const onPossibleErrorDisconnect = () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
|
|
if (lastPacket && bot?._client && bot._client.state !== 'play') {
|
|
appStatusState.descriptionHint = `Last Server Packet: ${lastPacket}`
|
|
}
|
|
}
|
|
const handleError = (err) => {
|
|
console.error(err)
|
|
errorAbortController.abort()
|
|
if (isCypress()) throw err
|
|
miscUiState.hasErrors = true
|
|
if (miscUiState.gameLoaded) return
|
|
|
|
setLoadingScreenStatus(`Error encountered. ${err}`, true)
|
|
onPossibleErrorDisconnect()
|
|
destroyAll()
|
|
}
|
|
|
|
const errorAbortController = new AbortController()
|
|
window.addEventListener('unhandledrejection', (e) => {
|
|
if (e.reason.name === 'ServerPluginLoadFailure') {
|
|
if (confirm(`Failed to load server plugin ${e.reason.pluginName} (invoking ${e.reason.pluginMethod}). Continue?`)) {
|
|
return
|
|
}
|
|
}
|
|
handleError(e.reason)
|
|
}, {
|
|
signal: errorAbortController.signal
|
|
})
|
|
window.addEventListener('error', (e) => {
|
|
handleError(e.message)
|
|
}, {
|
|
signal: errorAbortController.signal
|
|
})
|
|
|
|
if (proxy) {
|
|
console.log(`using proxy ${proxy.host}:${proxy.port || location.port}`)
|
|
|
|
net['setProxy']({ hostname: proxy.host, port: proxy.port })
|
|
}
|
|
|
|
const renderDistance = singleplayer ? renderDistanceSingleplayer : multiplayerRenderDistance
|
|
let localServer
|
|
try {
|
|
const serverOptions = defaultsDeep({}, connectOptions.serverOverrides ?? {}, options.localServerOptions, defaultServerOptions)
|
|
Object.assign(serverOptions, connectOptions.serverOverridesFlat ?? {})
|
|
const downloadMcData = async (version: string) => {
|
|
// todo expose cache
|
|
const lastVersion = supportedVersions.at(-1)
|
|
if (version === lastVersion) {
|
|
// ignore cache hit
|
|
versionsByMinecraftVersion.pc[lastVersion]!['dataVersion']!++
|
|
}
|
|
if (!document.fonts.check('1em mojangles')) {
|
|
// todo instead re-render signs on load
|
|
await document.fonts.load('1em mojangles').catch(() => { })
|
|
}
|
|
setLoadingScreenStatus(`Downloading data for ${version}`)
|
|
await downloadSoundsIfNeeded()
|
|
await loadScript(`./mc-data/${toMajorVersion(version)}.js`)
|
|
miscUiState.loadedDataVersion = version
|
|
try {
|
|
await genTexturePackTextures(version)
|
|
} catch (err) {
|
|
console.error(err)
|
|
const doContinue = confirm('Failed to apply texture pack. See errors in the console. Continue?')
|
|
if (!doContinue) {
|
|
throw err
|
|
}
|
|
}
|
|
viewer.setVersion(version)
|
|
}
|
|
|
|
const downloadVersion = connectOptions.botVersion || (singleplayer ? serverOptions.version : undefined)
|
|
if (downloadVersion) {
|
|
await downloadMcData(downloadVersion)
|
|
}
|
|
|
|
if (singleplayer) {
|
|
// SINGLEPLAYER EXPLAINER:
|
|
// Note 1: here we have custom sync communication between server Client (flying-squid) and game client (mineflayer)
|
|
// Note 2: custom Server class is used which simplifies communication & Client creation on it's side
|
|
// local server started
|
|
// mineflayer.createBot (see source def)
|
|
// bot._client = bot._client ?? mc.createClient(options) <-- mc-protocol package
|
|
// tcpDns() skipped since we define connect option
|
|
// in setProtocol: we emit 'connect' here below so in that file we send set_protocol and login_start (onLogin handler)
|
|
// 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
|
|
|
|
setLoadingScreenStatus('Starting local server')
|
|
localServer = window.localServer = window.server = startLocalServer(serverOptions)
|
|
// todo need just to call quit if started
|
|
// loadingScreen.maybeRecoverable = false
|
|
// init world, todo: do it for any async plugins
|
|
if (!localServer.pluginsReady) {
|
|
await new Promise(resolve => {
|
|
localServer.once('pluginsReady', resolve)
|
|
})
|
|
}
|
|
|
|
localServer.on('newPlayer', (player) => {
|
|
// it's you!
|
|
player.on('loadingStatus', (newStatus) => {
|
|
setLoadingScreenStatus(newStatus, false, false, true)
|
|
})
|
|
})
|
|
flyingSquidEvents()
|
|
}
|
|
|
|
let initialLoadingText: string
|
|
if (singleplayer) {
|
|
initialLoadingText = 'Local server is still starting'
|
|
} else if (p2pMultiplayer) {
|
|
initialLoadingText = 'Connecting to peer'
|
|
} else {
|
|
initialLoadingText = 'Connecting to server'
|
|
}
|
|
setLoadingScreenStatus(initialLoadingText)
|
|
bot = mineflayer.createBot({
|
|
host: server.host,
|
|
port: server.port ? +server.port : undefined,
|
|
version: connectOptions.botVersion || false,
|
|
...p2pMultiplayer ? {
|
|
stream: await connectToPeer(connectOptions.peerId!),
|
|
} : {},
|
|
...singleplayer || p2pMultiplayer ? {
|
|
keepAlive: false,
|
|
} : {},
|
|
...singleplayer ? {
|
|
version: serverOptions.version,
|
|
connect () { },
|
|
Client: CustomChannelClient as any,
|
|
} : {},
|
|
username,
|
|
password,
|
|
viewDistance: renderDistance,
|
|
checkTimeoutInterval: 240 * 1000,
|
|
// noPongTimeout: 240 * 1000,
|
|
closeTimeout: 240 * 1000,
|
|
respawn: options.autoRespawn,
|
|
maxCatchupTicks: 0,
|
|
async versionSelectedHook (client) {
|
|
await downloadMcData(client.version)
|
|
setLoadingScreenStatus(initialLoadingText)
|
|
},
|
|
'mapDownloader-saveToFile': false,
|
|
// "mapDownloader-saveInternal": false, // do not save into memory, todo must be implemeneted as we do really care of ram
|
|
}) as unknown as typeof __type_bot
|
|
window.bot = bot
|
|
earlySoundsMapCheck()
|
|
customEvents.emit('mineflayerBotCreated')
|
|
if (singleplayer || p2pMultiplayer) {
|
|
// in case of p2pMultiplayer there is still flying-squid on the host side
|
|
const _supportFeature = bot.supportFeature
|
|
bot.supportFeature = ((feature) => {
|
|
if (unsupportedLocalServerFeatures.includes(feature)) {
|
|
return false
|
|
}
|
|
return _supportFeature(feature)
|
|
}) as typeof bot.supportFeature
|
|
|
|
bot.emit('inject_allowed')
|
|
bot._client.emit('connect')
|
|
} else {
|
|
const setupConnectHandlers = () => {
|
|
bot._client.socket.on('connect', () => {
|
|
console.log('WebSocket connection established')
|
|
//@ts-expect-error
|
|
bot._client.socket._ws.addEventListener('close', () => {
|
|
console.log('WebSocket connection closed')
|
|
setTimeout(() => {
|
|
if (bot) {
|
|
bot.emit('end', 'WebSocket connection closed with unknown reason')
|
|
}
|
|
}, 1000)
|
|
})
|
|
bot._client.socket.on('close', () => {
|
|
setTimeout(() => {
|
|
if (bot) {
|
|
bot.emit('end', 'WebSocket connection closed with unknown reason')
|
|
}
|
|
})
|
|
})
|
|
})
|
|
let i = 0
|
|
//@ts-expect-error
|
|
bot.pingProxy = async () => {
|
|
const curI = ++i
|
|
return new Promise(resolve => {
|
|
//@ts-expect-error
|
|
bot._client.socket._ws.send(`ping:${curI}`)
|
|
const date = Date.now()
|
|
const onPong = (received) => {
|
|
if (received !== curI.toString()) return
|
|
bot._client.socket.off('pong' as any, onPong)
|
|
resolve(Date.now() - date)
|
|
}
|
|
bot._client.socket.on('pong' as any, onPong)
|
|
})
|
|
}
|
|
}
|
|
// socket setup actually can be delayed because of dns lookup
|
|
if (bot._client.socket) {
|
|
setupConnectHandlers()
|
|
} else {
|
|
const originalSetSocket = bot._client.setSocket.bind(bot._client)
|
|
bot._client.setSocket = (socket) => {
|
|
originalSetSocket(socket)
|
|
setupConnectHandlers()
|
|
}
|
|
}
|
|
|
|
}
|
|
} catch (err) {
|
|
handleError(err)
|
|
}
|
|
if (!bot) return
|
|
|
|
const p2pConnectTimeout = p2pMultiplayer ? setTimeout(() => { throw new Error('Spawn timeout. There might be error on the other side, check console.') }, 20_000) : undefined
|
|
|
|
// bot.on('inject_allowed', () => {
|
|
// loadingScreen.maybeRecoverable = false
|
|
// })
|
|
|
|
bot.on('error', handleError)
|
|
|
|
bot.on('kicked', (kickReason) => {
|
|
console.log('User was kicked!', kickReason)
|
|
setLoadingScreenStatus(`The Minecraft server kicked you. Kick reason: ${typeof kickReason === 'object' ? JSON.stringify(kickReason) : kickReason}`, true)
|
|
destroyAll()
|
|
})
|
|
|
|
const packetBeforePlay = (_, __, ___, fullBuffer) => {
|
|
lastPacket = fullBuffer.toString()
|
|
}
|
|
bot._client.on('packet', packetBeforePlay as any)
|
|
const playStateSwitch = (newState) => {
|
|
if (newState === 'play') {
|
|
bot._client.removeListener('packet', packetBeforePlay)
|
|
}
|
|
}
|
|
bot._client.on('state', playStateSwitch)
|
|
|
|
bot.on('end', (endReason) => {
|
|
if (ended) return
|
|
console.log('disconnected for', endReason)
|
|
setLoadingScreenStatus(`You have been disconnected from the server. End reason: ${endReason}`, true)
|
|
onPossibleErrorDisconnect()
|
|
destroyAll()
|
|
if (isCypress()) throw new Error(`disconnected: ${endReason}`)
|
|
})
|
|
|
|
onBotCreate()
|
|
|
|
bot.once('login', () => {
|
|
worldInteractions.initBot()
|
|
|
|
setLoadingScreenStatus('Loading world')
|
|
})
|
|
|
|
const spawnEarlier = !singleplayer && !p2pMultiplayer
|
|
// don't use spawn event, player can be dead
|
|
bot.once(spawnEarlier ? 'forcedMove' : 'health', () => {
|
|
errorAbortController.abort()
|
|
const mcData = MinecraftData(bot.version)
|
|
window.PrismarineBlock = PrismarineBlock(mcData.version.minecraftVersion!)
|
|
window.PrismarineItem = PrismarineItem(mcData.version.minecraftVersion!)
|
|
window.loadedData = mcData
|
|
window.Vec3 = Vec3
|
|
window.pathfinder = pathfinder
|
|
|
|
miscUiState.gameLoaded = true
|
|
miscUiState.loadedServerIndex = connectOptions.serverIndex ?? ''
|
|
customEvents.emit('gameLoaded')
|
|
if (p2pConnectTimeout) clearTimeout(p2pConnectTimeout)
|
|
|
|
setLoadingScreenStatus('Placing blocks (starting viewer)')
|
|
localStorage.lastConnectOptions = JSON.stringify(connectOptions)
|
|
connectOptions.onSuccessfulPlay?.()
|
|
if (connectOptions.autoLoginPassword) {
|
|
bot.chat(`/login ${connectOptions.autoLoginPassword}`)
|
|
}
|
|
|
|
console.log('bot spawned - starting viewer')
|
|
|
|
const center = bot.entity.position
|
|
|
|
const worldView = window.worldView = new WorldDataEmitter(bot.world, renderDistance, center)
|
|
|
|
bot.on('physicsTick', () => updateCursor())
|
|
|
|
|
|
void initVR()
|
|
|
|
renderWrapper.postRender = () => {
|
|
viewer.setFirstPersonCamera(null, bot.entity.yaw, bot.entity.pitch)
|
|
}
|
|
|
|
|
|
// Link WorldDataEmitter and Viewer
|
|
viewer.listen(worldView)
|
|
worldView.listenToBot(bot)
|
|
void worldView.init(bot.entity.position)
|
|
|
|
dayCycle()
|
|
|
|
// Bot position callback
|
|
function botPosition () {
|
|
viewer.world.lastCamUpdate = Date.now()
|
|
// this might cause lag, but not sure
|
|
viewer.setFirstPersonCamera(bot.entity.position, bot.entity.yaw, bot.entity.pitch)
|
|
void worldView.updatePosition(bot.entity.position)
|
|
}
|
|
bot.on('move', botPosition)
|
|
botPosition()
|
|
|
|
setLoadingScreenStatus('Setting callbacks')
|
|
|
|
const maxPitch = 0.5 * Math.PI
|
|
const minPitch = -0.5 * Math.PI
|
|
mouseMovePostHandle = ({ x, y }) => {
|
|
viewer.world.lastCamUpdate = Date.now()
|
|
bot.entity.pitch -= y
|
|
bot.entity.pitch = Math.max(minPitch, Math.min(maxPitch, bot.entity.pitch))
|
|
bot.entity.yaw -= x
|
|
}
|
|
|
|
function changeCallback () {
|
|
if (notificationProxy.id === 'pointerlockchange') {
|
|
hideNotification()
|
|
}
|
|
if (renderer.xr.isPresenting) return // todo
|
|
if (!pointerLock.hasPointerLock && activeModalStack.length === 0) {
|
|
showModal({ reactType: 'pause-screen' })
|
|
}
|
|
}
|
|
|
|
registerListener(document, 'pointerlockchange', changeCallback, false)
|
|
|
|
const cameraControlEl = document.querySelector('#ui-root')
|
|
|
|
/** after what time of holding the finger start breaking the block */
|
|
const touchStartBreakingBlockMs = 500
|
|
let virtualClickActive = false
|
|
let virtualClickTimeout
|
|
let screenTouches = 0
|
|
let capturedPointer: { id; x; y; sourceX; sourceY; activateCameraMove; time } | undefined
|
|
registerListener(document, 'pointerdown', (e) => {
|
|
const usingJoystick = options.touchControlsType === 'joystick-buttons'
|
|
const clickedEl = e.composedPath()[0]
|
|
if (!isGameActive(true) || !miscUiState.currentTouch || clickedEl !== cameraControlEl || e.pointerId === undefined) {
|
|
return
|
|
}
|
|
screenTouches++
|
|
if (screenTouches === 3) {
|
|
// todo needs fixing!
|
|
// window.dispatchEvent(new MouseEvent('mousedown', { button: 1 }))
|
|
}
|
|
if (usingJoystick) {
|
|
if (!joystickPointer.pointer && e.clientX < window.innerWidth / 2) {
|
|
joystickPointer.pointer = {
|
|
pointerId: e.pointerId,
|
|
x: e.clientX,
|
|
y: e.clientY
|
|
}
|
|
return
|
|
}
|
|
}
|
|
if (capturedPointer) {
|
|
return
|
|
}
|
|
cameraControlEl.setPointerCapture(e.pointerId)
|
|
capturedPointer = {
|
|
id: e.pointerId,
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
sourceX: e.clientX,
|
|
sourceY: e.clientY,
|
|
activateCameraMove: false,
|
|
time: Date.now()
|
|
}
|
|
if (options.touchControlsType !== 'joystick-buttons') {
|
|
virtualClickTimeout ??= setTimeout(() => {
|
|
virtualClickActive = true
|
|
document.dispatchEvent(new MouseEvent('mousedown', { button: 0 }))
|
|
}, touchStartBreakingBlockMs)
|
|
}
|
|
})
|
|
registerListener(document, 'pointermove', (e) => {
|
|
if (e.pointerId === undefined) return
|
|
const supportsPressure = (e as any).pressure !== undefined && (e as any).pressure !== 0 && (e as any).pressure !== 0.5 && (e as any).pressure !== 1 && (e.pointerType === 'touch' || e.pointerType === 'pen')
|
|
if (e.pointerId === joystickPointer.pointer?.pointerId) {
|
|
handleMovementStickDelta(e)
|
|
if (supportsPressure && (e as any).pressure > 0.5) {
|
|
bot.setControlState('sprint', true)
|
|
// todo
|
|
}
|
|
return
|
|
}
|
|
if (e.pointerId !== capturedPointer?.id) return
|
|
window.scrollTo(0, 0)
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
|
|
const allowedJitter = 1.1
|
|
if (supportsPressure) {
|
|
bot.setControlState('jump', (e as any).pressure > 0.5)
|
|
}
|
|
const xDiff = Math.abs(e.pageX - capturedPointer.sourceX) > allowedJitter
|
|
const yDiff = Math.abs(e.pageY - capturedPointer.sourceY) > allowedJitter
|
|
if (!capturedPointer.activateCameraMove && (xDiff || yDiff)) capturedPointer.activateCameraMove = true
|
|
if (capturedPointer.activateCameraMove) {
|
|
clearTimeout(virtualClickTimeout)
|
|
}
|
|
onCameraMove({ movementX: e.pageX - capturedPointer.x, movementY: e.pageY - capturedPointer.y, type: 'touchmove' })
|
|
capturedPointer.x = e.pageX
|
|
capturedPointer.y = e.pageY
|
|
}, { passive: false })
|
|
|
|
const pointerUpHandler = (e: PointerEvent) => {
|
|
if (e.pointerId === undefined) return
|
|
if (e.pointerId === joystickPointer.pointer?.pointerId) {
|
|
handleMovementStickDelta()
|
|
joystickPointer.pointer = null
|
|
return
|
|
}
|
|
if (e.pointerId !== capturedPointer?.id) return
|
|
clearTimeout(virtualClickTimeout)
|
|
virtualClickTimeout = undefined
|
|
|
|
if (options.touchControlsType !== 'joystick-buttons') {
|
|
if (virtualClickActive) {
|
|
// button 0 is left click
|
|
document.dispatchEvent(new MouseEvent('mouseup', { button: 0 }))
|
|
virtualClickActive = false
|
|
} else if (!capturedPointer.activateCameraMove && (Date.now() - capturedPointer.time < touchStartBreakingBlockMs)) {
|
|
document.dispatchEvent(new MouseEvent('mousedown', { button: 2 }))
|
|
worldInteractions.update()
|
|
document.dispatchEvent(new MouseEvent('mouseup', { button: 2 }))
|
|
}
|
|
}
|
|
capturedPointer = undefined
|
|
screenTouches--
|
|
}
|
|
registerListener(document, 'pointerup', pointerUpHandler)
|
|
registerListener(document, 'pointercancel', pointerUpHandler)
|
|
registerListener(document, 'lostpointercapture', pointerUpHandler)
|
|
|
|
registerListener(document, 'contextmenu', (e) => e.preventDefault(), false)
|
|
|
|
registerListener(document, 'blur', (e) => {
|
|
bot.clearControlStates()
|
|
}, false)
|
|
|
|
console.log('Done!')
|
|
|
|
// todo
|
|
onGameLoad(async () => {
|
|
if (!viewer.world.downloadedBlockStatesData && !viewer.world.customBlockStatesData) {
|
|
await new Promise<void>(resolve => {
|
|
viewer.world.renderUpdateEmitter.once('blockStatesDownloaded', () => resolve())
|
|
})
|
|
}
|
|
miscUiState.serverIp = server.host as string | null
|
|
miscUiState.username = username
|
|
})
|
|
|
|
if (appStatusState.isError) return
|
|
setTimeout(() => {
|
|
// todo
|
|
const qs = new URLSearchParams(window.location.search)
|
|
if (qs.get('suggest_save')) {
|
|
showNotification('Suggestion', 'Save the world to keep your progress!', false, undefined, async () => {
|
|
const savePath = await saveToBrowserMemory()
|
|
if (!savePath) return
|
|
const saveName = savePath.split('/').pop()
|
|
bot.end()
|
|
// todo hot reload
|
|
location.search = `loadSave=${saveName}`
|
|
})
|
|
}
|
|
}, 600)
|
|
|
|
setLoadingScreenStatus(undefined)
|
|
const start = Date.now()
|
|
let done = false
|
|
void viewer.world.renderUpdateEmitter.on('update', () => {
|
|
// todo might not emit as servers simply don't send chunk if it's empty
|
|
if (!viewer.world.allChunksFinished || done) return
|
|
done = true
|
|
console.log('All done and ready! In', (Date.now() - start) / 1000, 's')
|
|
viewer.render() // ensure the last state is rendered
|
|
document.dispatchEvent(new Event('cypress-world-ready'))
|
|
})
|
|
})
|
|
|
|
if (!connectOptions.ignoreQs) {
|
|
// todo cleanup
|
|
customEvents.on('gameLoaded', () => {
|
|
const qs = new URLSearchParams(window.location.search)
|
|
for (let command of qs.getAll('command')) {
|
|
if (!command.startsWith('/')) command = `/${command}`
|
|
bot.chat(command)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
listenGlobalEvents()
|
|
watchValue(miscUiState, async s => {
|
|
if (s.appLoaded) { // fs ready
|
|
const qs = new URLSearchParams(window.location.search)
|
|
const moreServerOptions = {} as Record<string, any>
|
|
if (qs.has('version')) moreServerOptions.version = qs.get('version')
|
|
if (qs.get('singleplayer') === '1') {
|
|
loadSingleplayer({}, {
|
|
worldFolder: undefined,
|
|
...moreServerOptions
|
|
})
|
|
}
|
|
if (qs.get('loadSave')) {
|
|
const savePath = `/data/worlds/${qs.get('loadSave')}`
|
|
try {
|
|
await fs.promises.stat(savePath)
|
|
} catch (err) {
|
|
alert(`Save ${savePath} not found`)
|
|
return
|
|
}
|
|
await loadInMemorySave(savePath)
|
|
}
|
|
}
|
|
})
|
|
|
|
// #region fire click event on touch as we disable default behaviors
|
|
let activeTouch: { touch: Touch, elem: HTMLElement, start: number } | undefined
|
|
document.body.addEventListener('touchend', (e) => {
|
|
if (!isGameActive(true)) return
|
|
if (activeTouch?.touch.identifier !== e.changedTouches[0].identifier) return
|
|
if (Date.now() - activeTouch.start > 500) {
|
|
activeTouch.elem.dispatchEvent(new Event('longtouch', { bubbles: true }))
|
|
} else {
|
|
activeTouch.elem.click()
|
|
}
|
|
activeTouch = undefined
|
|
})
|
|
document.body.addEventListener('touchstart', (e) => {
|
|
const ignoreElem = (e.target as HTMLElement).matches('vercel-live-feedback') || (e.target as HTMLElement).closest('.hotbar')
|
|
if (!isGameActive(true) || ignoreElem) return
|
|
// we always prevent default behavior to disable magnifier on ios, but by doing so we also disable click events
|
|
e.preventDefault()
|
|
let firstClickable // todo remove composedPath and this workaround when lit-element is fully dropped
|
|
const path = e.composedPath() as Array<{ click?: () => void }>
|
|
for (const elem of path) {
|
|
if (elem.click) {
|
|
firstClickable = elem
|
|
break
|
|
}
|
|
}
|
|
if (!firstClickable) return
|
|
activeTouch = {
|
|
touch: e.touches[0],
|
|
elem: firstClickable,
|
|
start: Date.now(),
|
|
}
|
|
}, { passive: false })
|
|
// #endregion
|
|
|
|
void window.fetch('config.json').then(async res => res.json()).then(c => c, (error) => {
|
|
console.warn('Failed to load optional app config.json', error)
|
|
return {}
|
|
}).then((config: AppConfig | {}) => {
|
|
miscUiState.appConfig = config
|
|
})
|
|
|
|
// qs open actions
|
|
downloadAndOpenFile().then((downloadAction) => {
|
|
if (downloadAction) return
|
|
const qs = new URLSearchParams(window.location.search)
|
|
if (qs.get('reconnect') && process.env.NODE_ENV === 'development') {
|
|
const ip = qs.get('ip')
|
|
const lastConnect = JSON.parse(localStorage.lastConnectOptions ?? {})
|
|
void connect({
|
|
...lastConnect, // todo mixing is not good idea
|
|
ip: ip || undefined
|
|
})
|
|
return
|
|
}
|
|
if (qs.get('ip') || qs.get('proxy')) {
|
|
const waitAppConfigLoad = !qs.get('proxy')
|
|
const openServerEditor = () => {
|
|
hideModal()
|
|
// show server editor for connect or save
|
|
showModal({ reactType: 'editServer' })
|
|
}
|
|
showModal({ reactType: 'empty' })
|
|
if (waitAppConfigLoad) {
|
|
const unsubscribe = subscribe(miscUiState, checkCanDisplay)
|
|
checkCanDisplay()
|
|
// eslint-disable-next-line no-inner-declarations
|
|
function checkCanDisplay () {
|
|
if (miscUiState.appConfig) {
|
|
unsubscribe()
|
|
openServerEditor()
|
|
return true
|
|
}
|
|
}
|
|
} else {
|
|
openServerEditor()
|
|
}
|
|
}
|
|
|
|
void Promise.resolve().then(() => {
|
|
// try to connect to peer
|
|
const peerId = qs.get('connectPeer')
|
|
const version = qs.get('peerVersion')
|
|
if (peerId) {
|
|
let username: string | null = options.guestUsername
|
|
if (options.askGuestName) username = prompt('Enter your username', username)
|
|
if (!username) return
|
|
options.guestUsername = username
|
|
void connect({
|
|
username,
|
|
botVersion: version || undefined,
|
|
peerId
|
|
})
|
|
}
|
|
})
|
|
}, (err) => {
|
|
console.error(err)
|
|
alert(`Failed to download file: ${err}`)
|
|
})
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
const initialLoader = document.querySelector('.initial-loader') as HTMLElement | null
|
|
if (initialLoader) {
|
|
initialLoader.style.opacity = '0'
|
|
initialLoader.style.pointerEvents = 'none'
|
|
}
|
|
window.pageLoaded = true
|
|
|
|
void possiblyHandleStateVariable()
|