New release with important fixes (#41)

This commit is contained in:
Vitaly 2023-11-11 22:23:20 +03:00 committed by GitHub
commit bc2a994cb6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 581 additions and 113 deletions

View file

@ -53,11 +53,14 @@ There is world renderer playground ([link](https://mcon.vercel.app/playground.ht
However, there are many things that can be done in online version. You can access some global variables in the console and useful examples:
- `localStorage.debug = '*'` - Enables all debug messages!
- `localStorage.debug = '*'` - Enables all debug messages! Warning: this will start all packets spam.
Instead I recommend setting `options.debugLogNotFrequentPackets`. Also you can use `debugTopPackets` (with JSON.stringify) to see what packets were received/sent by name
- `bot` - Mineflayer bot instance. See Mineflayer documentation for more.
- `viewer` - Three.js viewer instance, basically does all the rendering.
- `viewer.world.sectionObjects` - Object with all active chunk sections (geometries) in the world. Each chunk section is a Three.js mesh or group.
- `debugSceneChunks` - The same as above, but relative to current bot position (e.g. 0,0 is the current chunk).
- `debugChangedOptions` - See what options are changed. Don't change options here.
- `localServer` - Only for singleplayer mode/host. Flying Squid server instance, see it's documentation for more.
- `localServer.overworld.storageProvider.regions` - See ALL LOADED region files with all raw data.
@ -70,7 +73,7 @@ You can also drag and drop any .dat file into the browser window to see it's con
- `F3` - Toggle debug overlay
- `F3 + A` - Reload all chunks (these that are loaded from the server)
<!-- <!-- - `F3 + N` - Restart local server (basically resets the world!) -->
- `F3 + G` - Toggle chunk sections (geometries) border visibility (aka Three.js geometry helpers) - most probably need to reload chunks after toggling
- `F3 + G` - Toggle chunk sections (geometries) border visibility (aka Three.js geometry helpers)
### Notable Things that Power this Project

View file

@ -6,8 +6,9 @@
"start": "node scripts/build.js copyFilesDev && node scripts/prepareData.mjs && node esbuild.mjs --watch",
"start-watch-script": "nodemon -w esbuild.mjs esbuild.mjs",
"build": "node scripts/build.js copyFiles && node scripts/prepareData.mjs -f && node esbuild.mjs --minify --prod",
"check-build": "tsc && pnpm build",
"check-build": "tsc && pnpm test-unit && pnpm build",
"test:cypress": "cypress run",
"test-unit": "vitest",
"test:e2e": "start-test http-get://localhost:8080 test:cypress",
"prod-start": "node server.js",
"postinstall": "node scripts/gen-texturepack-files.mjs && tsx scripts/optimizeBlockCollisions.ts",

21
pnpm-lock.yaml generated
View file

@ -73,7 +73,7 @@ importers:
version: 4.18.2
flying-squid:
specifier: github:zardoy/space-squid#everything
version: github.com/zardoy/space-squid/a639714c26e2252b34be833f64f23d9f45f136de
version: github.com/zardoy/space-squid/9d72f865da99bcc55db2c5071754f61a5d935c73
fs-extra:
specifier: ^11.1.1
version: 11.1.1
@ -4604,7 +4604,7 @@ packages:
/@types/connect@3.4.36:
resolution: {integrity: sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==}
dependencies:
'@types/node': 20.8.0
'@types/node': 20.8.10
dev: true
/@types/cookie@0.4.1:
@ -4620,7 +4620,7 @@ packages:
/@types/cross-spawn@6.0.3:
resolution: {integrity: sha512-BDAkU7WHHRHnvBf5z89lcvACsvkz/n7Tv+HyD/uW76O29HoH1Tk/W6iQrepaZVbisvlEek4ygwT8IW7ow9XLAA==}
dependencies:
'@types/node': 20.8.0
'@types/node': 20.8.10
dev: true
/@types/detect-port@1.3.3:
@ -4691,7 +4691,7 @@ packages:
/@types/graceful-fs@4.1.7:
resolution: {integrity: sha512-MhzcwU8aUygZroVwL2jeYk6JisJrPl/oov/gsgGCue9mkgl9wjGbzReYQClxiUgFDnib9FuHqTndccKeZKxTRw==}
dependencies:
'@types/node': 20.8.0
'@types/node': 20.8.10
dev: true
/@types/http-cache-semantics@4.0.2:
@ -4844,7 +4844,7 @@ packages:
/@types/resolve@1.17.1:
resolution: {integrity: sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==}
dependencies:
'@types/node': 20.8.0
'@types/node': 20.8.10
dev: false
/@types/sat@0.0.31:
@ -4862,7 +4862,7 @@ packages:
resolution: {integrity: sha512-aAG6yRf6r0wQ29bkS+x97BIs64ZLxeE/ARwyS6wrldMm3C1MdKwCcnnEwMC1slI8wuxJOpiUH9MioC0A0i+GJw==}
dependencies:
'@types/mime': 1.3.3
'@types/node': 20.8.0
'@types/node': 20.8.10
dev: true
/@types/serve-static@1.15.3:
@ -9606,7 +9606,7 @@ packages:
resolution: {integrity: sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==}
engines: {node: '>= 10.13.0'}
dependencies:
'@types/node': 20.8.0
'@types/node': 20.8.10
merge-stream: 2.0.0
supports-color: 7.2.0
dev: false
@ -9615,7 +9615,7 @@ packages:
resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@types/node': 20.8.0
'@types/node': 20.8.10
jest-util: 29.7.0
merge-stream: 2.0.0
supports-color: 8.1.1
@ -14750,13 +14750,12 @@ packages:
- utf-8-validate
dev: false
github.com/zardoy/space-squid/a639714c26e2252b34be833f64f23d9f45f136de:
resolution: {tarball: https://codeload.github.com/zardoy/space-squid/tar.gz/a639714c26e2252b34be833f64f23d9f45f136de}
github.com/zardoy/space-squid/9d72f865da99bcc55db2c5071754f61a5d935c73:
resolution: {tarball: https://codeload.github.com/zardoy/space-squid/tar.gz/9d72f865da99bcc55db2c5071754f61a5d935c73}
name: flying-squid
version: 0.0.0-dev
engines: {node: '>=8'}
hasBin: true
prepare: true
requiresBuild: true
dependencies:
change-case: 4.1.2

View file

@ -90,19 +90,18 @@ async function main () {
// const schem = await Schematic.read(Buffer.from(data), version)
const viewDistance = 0
const center = new Vec3(0, 90, 0)
const targetPos = new Vec3(2, 90, 2)
const World = WorldLoader(version)
// const diamondSquare = require('diamond-square')({ version, seed: Math.floor(Math.random() * Math.pow(2, 31)) })
const targetPos = center
//@ts-ignore
const chunk1 = new Chunk()
//@ts-ignore
const chunk2 = new Chunk()
chunk1.setBlockStateId(center, 34)
chunk2.setBlockStateId(center.offset(1, 0, 0), 34)
chunk1.setBlockStateId(targetPos, 34)
chunk2.setBlockStateId(targetPos.offset(1, 0, 0), 34)
const world = new World((chunkX, chunkZ) => {
// if (chunkX === 0 && chunkZ === 0) return chunk1
// if (chunkX === 1 && chunkZ === 0) return chunk2
@ -113,7 +112,7 @@ async function main () {
// await schem.paste(world, new Vec3(0, 60, 0))
const worldView = new WorldDataEmitter(world, viewDistance, center)
const worldView = new WorldDataEmitter(world, viewDistance, targetPos)
// Create three.js context, add to page
const renderer = new THREE.WebGLRenderer()
@ -127,20 +126,20 @@ async function main () {
viewer.listen(worldView)
// Load chunks
await worldView.init(center)
await worldView.init(targetPos)
window['worldView'] = worldView
window['viewer'] = viewer
//@ts-ignore
const controls = new globalThis.THREE.OrbitControls(viewer.camera, renderer.domElement)
controls.target.set(center.x + 0.5, center.y + 0.5, center.z + 0.5)
controls.target.set(targetPos.x + 0.5, targetPos.y + 0.5, targetPos.z + 0.5)
const cameraPos = center.offset(2, 2, 2)
const cameraPos = targetPos.offset(2, 2, 2)
const pitch = THREE.MathUtils.degToRad(-45)
const yaw = THREE.MathUtils.degToRad(45)
viewer.camera.rotation.set(pitch, yaw, 0, 'ZYX')
viewer.camera.lookAt(center.x + 0.5, center.y + 0.5, center.z + 0.5)
viewer.camera.lookAt(targetPos.x + 0.5, targetPos.y + 0.5, targetPos.z + 0.5)
viewer.camera.position.set(cameraPos.x + 0.5, cameraPos.y + 0.5, cameraPos.z + 0.5)
controls.update()
@ -158,30 +157,30 @@ async function main () {
const { states } = mcData.blocksByStateId[getBlock()?.minStateId] ?? {}
folder = gui.addFolder('metadata')
if (states) {
for (const state of states) {
let defaultValue
switch (state.type) {
case 'enum':
defaultValue = state.values[0]
break
case 'bool':
defaultValue = false
break
case 'int':
defaultValue = 0
break
case 'direction':
defaultValue = 'north'
break
for (const state of states) {
let defaultValue
switch (state.type) {
case 'enum':
defaultValue = state.values[0]
break
case 'bool':
defaultValue = false
break
case 'int':
defaultValue = 0
break
case 'direction':
defaultValue = 'north'
break
default:
continue
}
blockProps[state.name] = defaultValue
if (state.type === 'enum') {
folder.add(blockProps, state.name, state.values)
} else {
folder.add(blockProps, state.name)
default:
continue
}
blockProps[state.name] = defaultValue
if (state.type === 'enum') {
folder.add(blockProps, state.name, state.values)
} else {
folder.add(blockProps, state.name)
}
}
} else {
@ -214,8 +213,8 @@ async function main () {
}
} else {
try {
//@ts-ignore
block = Block.fromProperties(blockId ?? -1, blockProps, 0)
//@ts-ignore
block = Block.fromProperties(blockId ?? -1, blockProps, 0)
} catch (err) {
console.error(err)
block = Block.fromStateId(0, 0)

View file

@ -38,6 +38,7 @@ function getEntityMesh (entity, scene, options) {
tex.needsUpdate = true
const spriteMat = new THREE.SpriteMaterial({ map: tex })
const sprite = new THREE.Sprite(spriteMat)
sprite.renderOrder = 1000
sprite.scale.set(canvas.width * 0.005, canvas.height * 0.005, 1)
sprite.position.y += entity.height + 0.6

View file

@ -20,6 +20,7 @@ export class Viewer {
playerHeight: number
isSneaking: boolean
version: string
cameraObjectOverride?: THREE.Object3D // for xr
constructor (public renderer: THREE.WebGLRenderer, numWorkers?: number) {
this.scene = new THREE.Scene()
@ -81,12 +82,13 @@ export class Viewer {
}
setFirstPersonCamera (pos: Vec3 | null, yaw: number, pitch: number, roll = 0) {
const cam = this.cameraObjectOverride || this.camera
if (pos) {
let y = pos.y + this.playerHeight
if (this.isSneaking) y -= 0.3
new tweenJs.Tween(this.camera.position).to({ x: pos.x, y, z: pos.z }, 50).start()
new tweenJs.Tween(cam.position).to({ x: pos.x, y, z: pos.z }, 50).start()
}
this.camera.rotation.set(pitch, yaw, roll, 'ZYX')
cam.rotation.set(pitch, yaw, roll, 'ZYX')
}
// todo type
@ -116,6 +118,10 @@ export class Viewer {
this.setBlockStateId(new Vec3(pos.x, pos.y, pos.z), stateId)
})
emitter.on('chunkPosUpdate', ({ pos }) => {
this.world.updateViewerPosition(pos)
})
emitter.emit('listening')
this.domElement.addEventListener('pointerdown', (evt) => {

View file

@ -18,7 +18,7 @@ export class WorldDataEmitter extends EventEmitter {
private eventListeners: Record<string, any> = {};
private emitter: WorldDataEmitter
constructor (public world: import('prismarine-world').world.World | typeof __type_bot['world'], public viewDistance: number, position: Vec3 = new Vec3(0, 0, 0)) {
constructor(public world: import('prismarine-world').world.World | typeof __type_bot['world'], public viewDistance: number, position: Vec3 = new Vec3(0, 0, 0)) {
super()
this.loadedChunks = {}
this.lastPos = new Vec3(0, 0, 0).update(position)
@ -91,6 +91,7 @@ export class WorldDataEmitter extends EventEmitter {
}
async init (pos: Vec3) {
this.emitter.emit('chunkPosUpdate', { pos })
const [botX, botZ] = chunkPos(pos)
const positions = generateSpiralMatrix(this.viewDistance).map(([x, z]) => new Vec3((botX + x) * 16, 0, (botZ + z) * 16))
@ -138,6 +139,7 @@ export class WorldDataEmitter extends EventEmitter {
const [lastX, lastZ] = chunkPos(this.lastPos)
const [botX, botZ] = chunkPos(pos)
if (lastX !== botX || lastZ !== botZ || force) {
this.emitter.emit('chunkPosUpdate', { pos })
const newView = new ViewRect(botX, botZ, this.viewDistance)
const chunksToUnload: Vec3[] = []
for (const coords of Object.keys(this.loadedChunks)) {

View file

@ -9,6 +9,7 @@ import { dispose3 } from './dispose'
import { toMajor } from './version.js'
import PrismarineChatLoader from 'prismarine-chat'
import { renderSign } from '../sign-renderer/'
import { chunkPos } from './simpleUtils'
function mod (x, n) {
return ((x % n) + n) % n
@ -31,8 +32,10 @@ export class WorldRenderer {
downloadedBlockStatesData = undefined as any
downloadedTextureImage = undefined as any
workers: any[] = []
viewerPosition?: Vec3
texturesVersion?: string
constructor (public scene: THREE.Scene, numWorkers = 4) {
// init workers
for (let i = 0; i < numWorkers; i++) {
@ -67,13 +70,16 @@ export class WorldRenderer {
const mesh = new THREE.Mesh(geometry, this.material)
mesh.position.set(data.geometry.sx, data.geometry.sy, data.geometry.sz)
mesh.name = 'mesh'
object = new THREE.Group()
object.add(mesh)
if (this.showChunkBorders) {
const boxHelper = new THREE.BoxHelper(mesh, 0xffff00)
object.add(boxHelper)
const boxHelper = new THREE.BoxHelper(mesh, 0xffff00)
boxHelper.name = 'helper'
object.add(boxHelper)
if (!this.showChunkBorders) {
boxHelper.visible = false
}
// should not it compute once
// should not compute it once
if (Object.keys(data.geometry.signs).length) {
for (const [posKey, { isWall, rotation }] of Object.entries(data.geometry.signs)) {
const [x, y, z] = posKey.split(',')
@ -83,6 +89,7 @@ export class WorldRenderer {
}
}
this.sectionObjects[data.key] = object
this.updatePosDataChunk(data.key)
this.scene.add(object)
} else if (data.type === 'sectionFinished') {
this.sectionsOutstanding.delete(data.key)
@ -94,6 +101,26 @@ export class WorldRenderer {
}
}
/**
* Optionally update data that are depedendent on the viewer position
*/
updatePosDataChunk (key: string) {
if (!this.viewerPosition) return
const [x, y, z] = key.split(',').map(x => Math.floor(+x / 16))
const [xPlayer, yPlayer, zPlayer] = this.viewerPosition.toArray().map(x => Math.floor(x / 16))
// sum of distances: x + y + z
const chunkDistance = Math.abs(x - xPlayer) + Math.abs(y - yPlayer) + Math.abs(z - zPlayer)
const section = this.sectionObjects[key].children.find(child => child.name === 'mesh')!
section.renderOrder = 500 - chunkDistance
}
updateViewerPosition (pos: Vec3) {
this.viewerPosition = pos
for (const key of Object.keys(this.sectionObjects)) {
this.updatePosDataChunk(key)
}
}
renderSign (position: Vec3, rotation: number, isWall: boolean, blockEntity) {
const PrismarineChat = PrismarineChatLoader(this.version!)
const canvas = renderSign(blockEntity, PrismarineChat)
@ -124,6 +151,17 @@ export class WorldRenderer {
return group
}
updateShowChunksBorder (value: boolean) {
this.showChunkBorders = value
for (const object of Object.values(this.sectionObjects)) {
for (const child of object.children) {
if (child.name === 'helper') {
child.visible = value;
}
}
}
}
resetWorld () {
this.active = false
for (const mesh of Object.values(this.sectionObjects)) {
@ -182,6 +220,15 @@ export class WorldRenderer {
})
}
getLoadedChunksRelative (pos: Vec3) {
const [currentX, currentZ] = chunkPos(pos)
return Object.fromEntries(Object.entries(this.sectionObjects).map(([key, o]) => {
const [xRaw, yRaw, zRaw] = key.split(',').map(Number)
const [x, z] = chunkPos({x: xRaw, z: zRaw})
return [`${x - currentX},${z - currentZ}`, o]
}))
}
addColumn (x, z, chunk) {
this.loadedChunks[`${x},${z}`] = true
for (const worker of this.workers) {

View file

@ -57,7 +57,8 @@ export const renderSign = (blockEntity: SignBlockEntity, PrismarineChat: typeof
const defaultColor = ('front_text' in blockEntity ? blockEntity.front_text.color : blockEntity.Color) || 'black'
for (let [lineNum, text] of texts.slice(0, 4).entries()) {
// todo: in pre flatenning it seems the format was not json
const parsed = text?.startsWith('{') ? parseSafe(text ?? '""', 'sign text') : text
if (text === 'null') continue
const parsed = text?.startsWith('{') || text?.startsWith('"') ? parseSafe(text ?? '""', 'sign text') : text
if (!parsed || (typeof parsed !== 'object' && typeof parsed !== 'string')) continue
// todo fix type
const message = typeof parsed === 'string' ? fromFormattedString(parsed) : new PrismarineChat(parsed) as never

View file

@ -0,0 +1,85 @@
import { test, expect } from 'vitest'
import { renderSign } from '.'
import PrismarineChatLoader from 'prismarine-chat'
const PrismarineChat = PrismarineChatLoader({ language: {} } as any)
let ctxTexts = [] as any[]
global.document = {
createElement () {
return {
getContext () {
return {
fillText (text, x, y) {
ctxTexts.push({ text, x, y })
},
measureText () { return 0 }
}
}
}
}
} as any
const render = (entity) => {
ctxTexts = []
renderSign(entity, PrismarineChat)
return ctxTexts.map(({ text, y }) => [y / 80, text])
}
test('sign renderer', () => {
let blockEntity = {
"GlowingText": 0,
"Color": "black",
"Text4": "{\"text\":\"\"}",
"Text3": "{\"text\":\"\"}",
"Text2": "{\"text\":\"\"}",
"Text1": "{\"extra\":[{\"color\":\"dark_green\",\"text\":\"Minecraft \"},{\"text\":\"Tools\"}],\"text\":\"\"}"
} as any
expect(render(blockEntity)).toMatchInlineSnapshot(`
[
[
1,
"",
],
[
1,
"Minecraft ",
],
[
1,
"Tools",
],
[
2,
"",
],
[
3,
"",
],
[
4,
"",
],
]
`)
blockEntity = { // pre flatenning
"Text1": "Welcome to",
"Text2": "",
"Text3": "null",
"Text4": "\"Version 2.1\"",
} as const
expect(render(blockEntity)).toMatchInlineSnapshot(`
[
[
1,
"Welcome to",
],
[
4,
"Version 2.1",
],
]
`)
})

View file

@ -17,6 +17,10 @@ export async function loadSound (path: string) {
}
export async function playSound (path) {
const volume = options.volume / 100
if (!volume) return
audioContext ??= new window.AudioContext()
for (const [soundName, sound] of Object.entries(sounds)) {
@ -25,8 +29,6 @@ export async function playSound (path) {
convertedSounds.push(soundName)
}
const volume = options.volume / 100
const soundBuffer = sounds[path]
if (!soundBuffer) {
console.warn(`Sound ${path} not loaded`)

View file

@ -226,12 +226,10 @@ contro.on('release', ({ command }) => {
// hard-coded keybindings
const hardcodedPressedKeys = new Set<string>()
document.addEventListener('keydown', (e) => {
if (!isGameActive(false)) return
if (hardcodedPressedKeys.has('F3')) {
// reload chunks
if (e.code === 'KeyA') {
export const f3Keybinds = [
{
key: 'KeyA',
action () {
//@ts-expect-error
const loadedChunks = Object.entries(worldView.loadedChunks).filter(([, v]) => v).map(([key]) => key.split(',').map(Number))
for (const [x, z] of loadedChunks) {
@ -242,12 +240,25 @@ document.addEventListener('keydown', (e) => {
localServer.players[0].world.columns = {}
}
void reloadChunks()
}
if (e.code === 'KeyG') {
// todo make it work without reload
},
mobileTitle: 'Reload chunks',
},
{
key: 'KeyG',
action () {
options.showChunkBorders = !options.showChunkBorders
void reloadChunks()
}
viewer.world.updateShowChunksBorder(options.showChunkBorders)
},
mobileTitle: 'Toggle chunk borders',
}
]
const hardcodedPressedKeys = new Set<string>()
document.addEventListener('keydown', (e) => {
if (!isGameActive(false)) return
if (hardcodedPressedKeys.has('F3')) {
const keybind = f3Keybinds.find((v) => v.key === e.code)
if (keybind) keybind.action()
return
}
@ -388,6 +399,7 @@ const selectItem = async () => {
}
addEventListener('mousedown', async (e) => {
if ((e.target as HTMLElement).matches?.('#VRButton')) return
void pointerLock.requestPointerLock()
if (!bot) return
// wheel click

View file

@ -62,6 +62,7 @@ class CustomChannelClient extends EventEmitter {
debug(params)
}
this.emit('writePacket', name, params)
customCommunication.sendData.call(this, { name, params, state: this.state })
}

View file

@ -3,7 +3,7 @@ import { openWorldZip } from './browserfs'
import { getResourcePackName, installTexturePack, resourcePackState, updateTexturePackInstalledState } from './texturePack'
import { setLoadingScreenStatus } from './utils'
const getFixedFilesize = (bytes: number) => {
export const getFixedFilesize = (bytes: number) => {
return prettyBytes(bytes, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}

View file

@ -105,6 +105,11 @@ document.body.appendChild(renderer.domElement)
// Create viewer
const viewer: import('prismarine-viewer/viewer/lib/viewer').Viewer = new Viewer(renderer, options.numWorkers)
window.viewer = viewer
Object.defineProperty(window, 'debugSceneChunks', {
get () {
return viewer.world.getLoadedChunksRelative(bot.entity.position)
},
})
viewer.entities.entitiesOptions = {
fontFamily: 'mojangles'
}
@ -124,7 +129,7 @@ let previousWindowHeight = window.innerHeight
const renderFrame = (time: DOMHighResTimeStamp) => {
if (window.stopLoop) return
window.requestAnimationFrame(renderFrame)
if (window.stopRender) return
if (window.stopRender || renderer.xr.isPresenting) return
if (renderInterval) {
delta += time - lastTime
lastTime = time
@ -200,8 +205,8 @@ function hideCurrentScreens () {
insertActiveModalStack('', [])
}
const loadSingleplayer = (serverOverrides = {}) => {
void connect({ singleplayer: true, username: options.localUsername, password: '', serverOverrides })
const loadSingleplayer = (serverOverrides = {}, flattenedServerOverrides = {}) => {
void connect({ singleplayer: true, username: options.localUsername, password: '', serverOverrides, serverOverridesFlat: flattenedServerOverrides })
}
function listenGlobalEvents () {
const menu = document.getElementById('play-screen')
@ -242,7 +247,7 @@ const cleanConnectIp = (host: string | undefined, defaultPort: string | undefine
}
async function connect (connectOptions: {
server?: string; singleplayer?: any; username: string; password?: any; proxy?: any; botVersion?: any; serverOverrides?; peerId?: string
server?: string; singleplayer?: any; username: string; password?: any; proxy?: any; botVersion?: any; serverOverrides?; serverOverridesFlat?; peerId?: string
}) {
document.getElementById('play-screen').style = 'display: none;'
removePanorama()
@ -335,6 +340,7 @@ async function connect (connectOptions: {
let localServer
try {
const serverOptions = _.defaultsDeep({}, connectOptions.serverOverrides ?? {}, options.localServerOptions, defaultServerOptions)
Object.assign(serverOptions, connectOptions.serverOverridesFlat ?? {})
const downloadMcData = async (version: string) => {
setLoadingScreenStatus(`Downloading data for ${version}`)
await loadScript(`./mc-data/${toMajorVersion(version)}.js`)
@ -535,7 +541,7 @@ async function connect (connectOptions: {
window.debugMenu = debugMenu
void initVR(bot, renderer, viewer)
void initVR()
postRenderFrameFn = () => {
viewer.setFirstPersonCamera(null, bot.entity.yaw, bot.entity.pitch)
@ -577,6 +583,7 @@ async function connect (connectOptions: {
function changeCallback () {
notification.show = false
if (renderer.xr.isPresenting) return // todo
if (!pointerLock.hasPointerLock && activeModalStack.length === 0) {
showModal(pauseMenu)
}
@ -693,7 +700,7 @@ watchValue(miscUiState, async s => {
if (s.appLoaded) { // fs ready
const qs = new URLSearchParams(window.location.search)
if (qs.get('singleplayer') === '1') {
loadSingleplayer({
loadSingleplayer({}, {
worldFolder: undefined
})
}
@ -711,19 +718,33 @@ watchValue(miscUiState, async s => {
})
// #region fire click event on touch as we disable default behaviors
let activeTouch: { touch: Touch, elem: HTMLElement } | undefined
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
activeTouch.elem.click()
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) => {
if (!isGameActive(true)) return
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: e.composedPath()[0] as HTMLElement
elem: firstClickable,
start: Date.now(),
}
}, { passive: false })
// #endregion

View file

@ -22,6 +22,9 @@ let peerInstance: Peer | undefined
export const getJoinLink = () => {
if (!peerInstance) return
const url = new URL(window.location.href)
for (const key of url.searchParams.keys()) {
url.searchParams.delete(key)
}
url.searchParams.set('connectPeer', peerInstance.id)
url.searchParams.set('peerVersion', localServer!.options.version)
return url.toString()

View file

@ -1,4 +1,8 @@
const { LitElement, html, css } = require('lit')
const { subscribeKey } = require('valtio/utils')
const { miscUiState } = require('../../globalState')
const { options } = require('../../optionsStorage')
const { getFixedFilesize } = require('../../downloadAndOpenFile')
class DebugOverlay extends LitElement {
static get styles () {
@ -55,7 +59,8 @@ class DebugOverlay extends LitElement {
cursorBlock: { type: Object },
rendererDevice: { type: String },
bot: { type: Object },
customEntries: { type: Object }
customEntries: { type: Object },
packetsString: { type: String }
}
}
@ -63,6 +68,7 @@ class DebugOverlay extends LitElement {
super()
this.showOverlay = false
this.customEntries = {}
this.packetsString = ''
}
firstUpdated () {
@ -72,6 +78,120 @@ class DebugOverlay extends LitElement {
e.preventDefault()
}
})
let receivedTotal = 0
let received = {
count: 0,
size: 0
}
let sent = {
count: 0,
size: 0
}
const packetsCountByNamePerSec = {
received: {},
sent: {}
}
const hardcodedListOfDebugPacketsToIgnore = {
received: [
'entity_velocity',
'sound_effect',
'rel_entity_move',
'entity_head_rotation',
'entity_metadata',
'entity_move_look',
'teams',
'entity_teleport',
'entity_look',
'ping',
'entity_update_attributes',
'player_info',
'update_time',
'animation',
'entity_equipment',
'entity_destroy',
'named_entity_spawn',
'update_light',
'set_slot',
'block_break_animation',
'map_chunk',
'spawn_entity',
'world_particles',
'keep_alive',
'chat',
'playerlist_header',
'scoreboard_objective',
'scoreboard_score'
],
sent: [
'pong',
'position',
'look',
'keep_alive',
'position_look'
]
} // todo cleanup?
const ignoredPackets = new Set('')
Object.defineProperty(window, 'debugTopPackets', {
get () {
return Object.fromEntries(Object.entries(packetsCountByName).map(([s, packets]) => [s, Object.fromEntries(Object.entries(packets).sort(([, n1], [, n2]) => {
return n2 - n1
}))]))
}
})
setInterval(() => {
this.packetsString = `${received.count} (${(received.size / 1024).toFixed(2)} KB/s, ${getFixedFilesize(receivedTotal)}) ↑ ${sent.count}`
received = {
count: 0,
size: 0
}
sent = {
count: 0,
size: 0
}
packetsCountByNamePerSec.received = {}
packetsCountByNamePerSec.sent = {}
}, 1000)
const packetsCountByName = {
received: {},
sent: {}
}
const managePackets = (type, name, data) => {
packetsCountByName[type][name] ??= 0
packetsCountByName[type][name]++
if (options.debugLogNotFrequentPackets && !ignoredPackets.has(name) && !hardcodedListOfDebugPacketsToIgnore[type].includes(name)) {
packetsCountByNamePerSec[type][name] ??= 0
packetsCountByNamePerSec[type][name]++
if (packetsCountByNamePerSec[type][name] > 5 || packetsCountByName[type][name] > 100) { // todo think of tracking the count within 10s
console.info(`[packet ${name} was ${type} too frequent] Ignoring...`)
ignoredPackets.add(name)
} else {
console.info(`[packet ${type}] ${name}`, /* ${JSON.stringify(data, null, 2)}` */ data)
}
}
}
subscribeKey(miscUiState, 'gameLoaded', () => {
if (!miscUiState.gameLoaded) return
packetsCountByName.received = {}
packetsCountByName.sent = {}
const readPacket = (data, { name }, _buf, fullBuffer) => {
if (fullBuffer) {
const size = fullBuffer.byteLength
receivedTotal += size
received.size += size
}
received.count++
managePackets('received', name, data)
}
bot._client.on('packet', readPacket)
bot._client.on('packet_name', (name, data) => readPacket(data, { name })) // custom client
bot._client.on('writePacket', (name, data) => {
sent.count++
managePackets('sent', name, data)
})
})
}
updated (changedProperties) {
@ -128,6 +248,7 @@ class DebugOverlay extends LitElement {
<div class="empty"></div>
<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>Packets: ${this.packetsString}</p>
<p>Facing (viewer): ${rot[0].toFixed(3)} ${rot[1].toFixed(3)}</p>
<p>Facing (minecraft): ${quadsDescription[minecraftQuad]} (${minecraftYaw.toFixed(1)} ${(rot[1] * -180 / Math.PI).toFixed(1)})</p>
<p>Light: ${skyL} (${skyL} sky)</p>

View file

@ -1,3 +1,6 @@
import { f3Keybinds } from '../controls'
import { showOptionsModal } from '../react/SelectOption'
const { LitElement, html, css, unsafeCSS } = require('lit')
const { showModal, miscUiState } = require('../globalState')
const { options, watchValue } = require('../optionsStorage')
@ -219,7 +222,12 @@ class Hud extends LitElement {
<div class="debug-btn" @pointerdown=${(e) => {
window.dispatchEvent(new MouseEvent('mousedown', { button: 1 }))
}}>S</div>
<div class="debug-btn" @pointerdown=${(e) => {
<div class="debug-btn" @longtouch=${async () => {
const select = await showOptionsModal('', f3Keybinds.filter(f3Keybind => f3Keybind.mobileTitle).map(f3Keybind => f3Keybind.mobileTitle))
if (!select) return
const f3Keybind = f3Keybinds.find(f3Keybind => f3Keybind.mobileTitle === select)
f3Keybind.action()
}} @pointerdown=${(e) => {
this.shadowRoot.getElementById('debug-overlay').showOverlay = !this.shadowRoot.getElementById('debug-overlay').showOverlay
}}>F3</div>
<div class="chat-btn" @pointerdown=${(e) => {

View file

@ -123,6 +123,11 @@ export const guiOptionsScheme: {
custom () {
return <Button label='Advanced...' onClick={() => openOptionsMenu('advanced')} inScreen />
},
},
{
custom () {
return <Button label='VR...' onClick={() => openOptionsMenu('VR')} inScreen />
},
}
],
interface: [
@ -178,6 +183,16 @@ export const guiOptionsScheme: {
{ volume: {} },
// { ignoreSilentSwitch: {} },
],
VR: [
{
custom () {
return <>
<span style={{ fontSize: 9, display: 'flex', justifyContent: 'center', alignItems: 'center' }}>VR currently has basic support</span>
<div />
</>
},
}
],
advanced: [
{
custom () {
@ -188,4 +203,4 @@ export const guiOptionsScheme: {
}
],
}
export type OptionsGroupType = 'main' | 'render' | 'interface' | 'controls' | 'sound' | 'advanced'
export type OptionsGroupType = 'main' | 'render' | 'interface' | 'controls' | 'sound' | 'advanced' | 'VR'

View file

@ -4,8 +4,6 @@ import { proxy, subscribe } from 'valtio/vanilla'
// weird webpack configuration bug: it cant import valtio/utils in this file
import { subscribeKey } from 'valtio/utils'
const mergeAny: <T>(arg1: T, arg2: any) => T = Object.assign
const defaultOptions = {
renderDistance: 2,
multiplayerRenderDistance: 2,
@ -31,6 +29,8 @@ const defaultOptions = {
highPerformanceGpu: false,
/** @unstable */
disableAssets: false,
/** @unstable */
debugLogNotFrequentPackets: false,
unimplementedContainers: false,
dayCycleAndLighting: true,
@ -53,9 +53,10 @@ const defaultOptions = {
export type AppOptions = typeof defaultOptions
export const options = proxy(
mergeAny(defaultOptions, JSON.parse(localStorage.options || '{}'))
)
export const options: AppOptions = proxy({
...defaultOptions,
...JSON.parse(localStorage.options || '{}')
})
window.options = window.settings = options
@ -63,6 +64,12 @@ export const resetOptions = () => {
Object.assign(options, defaultOptions)
}
Object.defineProperty(window, 'debugChangedOptions', {
get () {
return Object.fromEntries(Object.entries(options).filter(([key, v]) => defaultOptions[key] !== v))
},
})
subscribe(options, () => {
localStorage.options = JSON.stringify(options)
})

View file

@ -117,10 +117,10 @@ export async function addPanoramaCubeMap () {
}
export function removePanorama () {
shouldDisplayPanorama = false
if (!panoramaCubeMap) return
viewer.camera = new THREE.PerspectiveCamera(options.fov, window.innerWidth / window.innerHeight, 0.1, 1000)
viewer.camera.updateProjectionMatrix()
viewer.scene.remove(panoramaCubeMap)
panoramaCubeMap = null
shouldDisplayPanorama = false
}

View file

@ -30,8 +30,8 @@ export default () => {
console.error(err)
}
})
bot.on('death', () => {
if (dieReasonProxy.value) return
bot.on('health', () => { // bot.isAlive can be already false so can't use death event (respawn packet)
if (dieReasonProxy.value || bot.health > 0) return
dieReasonProxy.value = []
})

View file

@ -0,0 +1,28 @@
import { useUsingTouch } from '@dimaka/interface'
import { useEffect, useState } from 'react'
import Button from './Button'
export default () => {
const [fullScreen, setFullScreen] = useState(false)
useEffect(() => {
document.documentElement.addEventListener('fullscreenchange', () => {
setFullScreen(!!document.fullscreenElement)
})
}, [])
const usingTouch = useUsingTouch()
if (!usingTouch || !document.documentElement.requestFullscreen || fullScreen) return null
return <Button
icon='pixelarticons:scale'
style={{
position: 'fixed',
top: 5,
left: 5,
width: 22,
}}
onClick={() => {
void document.documentElement.requestFullscreen()
}}
/>
}

View file

@ -22,7 +22,7 @@ const Slider: React.FC<Props> = ({
width,
value: valueProp,
valueDisplay,
min = 1,
min = 0,
max = 100,
disabledReason,

View file

@ -16,6 +16,7 @@ import SingleplayerProvider from './react/SingleplayerProvider'
import CreateWorldProvider from './react/CreateWorldProvider'
import AppStatusProvider from './react/AppStatusProvider'
import SelectOption from './react/SelectOption'
import EnterFullscreenButton from './react/EnterFullscreenButton'
// todo
useInterfaceState.setState({
@ -135,6 +136,7 @@ const InGameUi = () => {
const App = () => {
return <div>
<EnterFullscreenButton />
<InGameUi />
<Portal to={document.querySelector('#ui-root')}>
<SingleplayerProvider />

View file

@ -29,6 +29,8 @@
display: flex;
flex-direction: column;
margin-top: 35px;
/* todo remove it but without it in chrome android the screen is not scrollable */
overflow: auto;
/* todo I'm not sure about it */
/* margin-top: calc(100% / 6 - 16px); */
width: 310px;

View file

@ -64,6 +64,12 @@ body {
text-shadow: 1px 1px #222;
}
#VRButton {
background: rgba(0, 0, 0, 0.3) !important;
opacity: 0.7 !important;
position: fixed !important;
}
.dirt-bg {
position: absolute;
top: 0;

View file

@ -155,8 +155,9 @@ export const setLoadingScreenStatus = function (status: string | undefined | nul
export const disconnect = async () => {
if (localServer) {
await saveServer()
localServer.quit()
void localServer.quit() // todo investigate we should await
}
window.history.replaceState({}, '', `${window.location.pathname}`) // remove qs
bot.end('You left the server')
}

127
src/vr.js
View file

@ -1,13 +1,13 @@
/* global THREE */
const { VRButton } = require('three/examples/jsm/webxr/VRButton.js')
const { GLTFLoader } = require('three/examples/jsm/loaders/GLTFLoader.js')
const { XRControllerModelFactory } = require('three/examples/jsm/webxr/XRControllerModelFactory.js')
const TWEEN = require('@tweenjs/tween.js')
const { buttonMap: standardButtonsMap } = require('contro-max/build/gamepad')
const { activeModalStack, hideModal } = require('./globalState')
async function initVR (bot, renderer, viewer) {
async function initVR () {
const { renderer } = viewer
if (!('xr' in navigator)) return
const isSupported = await navigator.xr.isSessionSupported('immersive-vr')
const isSupported = await navigator.xr.isSessionSupported('immersive-vr') && !!XRSession.prototype.updateRenderState // e.g. android webview doesn't support updateRenderState
if (!isSupported) return
// VR
@ -21,32 +21,70 @@ async function initVR (bot, renderer, viewer) {
const controllerModelFactory = new XRControllerModelFactory(new GLTFLoader())
const controller1 = renderer.xr.getControllerGrip(0)
const controller2 = renderer.xr.getControllerGrip(1)
// todo the logic written here can be hard to understand as it was designed to work in gamepad api emulation mode, will be refactored once there is a contro-max rewrite is done
const virtualGamepadIndex = 4
let connectedVirtualGamepad
const manageXrInputSource = ({ gamepad, handedness = defaultHandedness }, defaultHandedness, removeAction = false) => {
if (handedness === 'right') {
const event = new Event(removeAction ? 'gamepaddisconnected' : 'gamepadconnected') // todo need to expose and use external gamepads api in contro-max instead
event.gamepad = removeAction ? connectedVirtualGamepad : { ...gamepad, mapping: 'standard', index: virtualGamepadIndex }
connectedVirtualGamepad = event.gamepad
window.dispatchEvent(event)
}
}
let hand1 = controllerModelFactory.createControllerModel(controller1)
controller1.addEventListener('connected', (event) => {
hand1.xrInputSource = event.data
manageXrInputSource(event.data, 'left')
user.add(controller1)
})
controller1.add(hand1)
let hand2 = controllerModelFactory.createControllerModel(controller2)
controller2.addEventListener('connected', (event) => {
hand2.xrInputSource = event.data
manageXrInputSource(event.data, 'right')
user.add(controller2)
})
controller2.add(hand2)
viewer.setFirstPersonCamera = function (pos, yaw, pitch) {
if (pos) new TWEEN.Tween(user.position).to({ x: pos.x, y: pos.y, z: pos.z }, 50).start()
user.rotation.set(pitch, yaw, 0, 'ZYX')
controller1.addEventListener('disconnected', () => {
// don't handle removal of gamepads for now as is don't affect contro-max
hand1.xrInputSource = undefined
manageXrInputSource(hand1.xrInputSource, 'left', true)
})
controller2.addEventListener('disconnected', () => {
hand2.xrInputSource = undefined
manageXrInputSource(hand1.xrInputSource, 'right', true)
})
const originalGetGamepads = navigator.getGamepads.bind(navigator)
navigator.getGamepads = () => {
const originalGamepads = originalGetGamepads()
if (!hand1.xrInputSource || !hand2.xrInputSource) return originalGamepads
return [
...originalGamepads,
{
axes: remapAxes(hand2.xrInputSource.gamepad.axes, hand1.xrInputSource.gamepad.axes),
buttons: remapButtons(hand2.xrInputSource.gamepad.buttons, hand1.xrInputSource.gamepad.buttons),
connected: true,
mapping: 'standard',
id: '',
index: virtualGamepadIndex
}
]
}
let rotSnapReset = true
let yawOffset = 0
renderer.setAnimationLoop(() => {
if (!renderer.xr.isPresenting) return
if (hand1.xrInputSource && hand2.xrInputSource) {
hand1.xAxis = hand1.xrInputSource.gamepad.axes[2]
hand1.yAxis = hand1.xrInputSource.gamepad.axes[3]
hand2.xAxis = hand2.xrInputSource.gamepad.axes[2]
hand2.yAxis = hand2.xrInputSource.gamepad.axes[3]
// hand2 should be right
if (hand1.xrInputSource.handedness === 'right') {
const tmp = hand2
hand2 = hand1
@ -63,23 +101,70 @@ async function initVR (bot, renderer, viewer) {
rotSnapReset = true
}
viewer.setFirstPersonCamera(null, yawOffset, 0)
// viewer.setFirstPersonCamera(null, yawOffset, 0)
viewer.setFirstPersonCamera(null, bot.entity.yaw, bot.entity.pitch)
const xrCamera = renderer.xr.getCamera(viewer.camera)
const d = xrCamera.getWorldDirection()
bot.entity.yaw = Math.atan2(-d.x, -d.z)
bot.entity.pitch = Math.asin(d.y)
// todo restore this logic (need to preserve ability to move camera)
// const xrCamera = renderer.xr.getCamera(viewer.camera)
// const d = xrCamera.getWorldDirection() // todo target
// bot.entity.yaw = Math.atan2(-d.x, -d.z)
// bot.entity.pitch = Math.asin(d.y)
bot.physics.stepHeight = 1
bot.setControlState('forward', hand2.yAxis < -0.5)
bot.setControlState('back', hand2.yAxis > 0.5)
bot.setControlState('right', hand2.xAxis < -0.5)
bot.setControlState('left', hand2.xAxis > 0.5)
// todo ?
// bot.physics.stepHeight = 1
TWEEN.update()
viewer.update()
renderer.render(viewer.scene, viewer.camera)
viewer.render()
})
renderer.xr.addEventListener('sessionstart', () => {
viewer.cameraObjectOverride = user
// close all modals to be in game
for (const _modal of activeModalStack) {
hideModal(undefined, {}, { force: true })
}
})
renderer.xr.addEventListener('sessionend', () => {
viewer.cameraObjectOverride = undefined
})
}
module.exports = { initVR }
module.exports.initVR = initVR
const xrStandardRightButtonsMap = [
[0 /* trigger */, 'Right Trigger'],
[1 /* squeeze */, 'Right Bumper'],
// need to think of a way to support touchpad input
[3 /* Thumbstick Press */, 'Right Stick'],
[4 /* A */, 'A'],
[5 /* B */, 'B'],
]
const xrStandardLeftButtonsMap = [
[0 /* trigger */, 'Left Trigger'],
[1 /* squeeze */, 'Left Bumper'],
// need to think of a way to support touchpad input
[3 /* Thumbstick Press */, 'Left Stick'],
[4 /* A */, 'X'],
[5 /* B */, 'Y'],
]
const remapButtons = (rightButtons, leftButtons) => {
// return remapped buttons
const remapped = []
const remapWithMap = (buttons, map) => {
for (const [index, standardName] of map) {
const standardMappingIndex = standardButtonsMap.findIndex((aliases) => aliases.find(alias => standardName === alias))
remapped[standardMappingIndex] = buttons[index]
}
}
remapWithMap(rightButtons, xrStandardRightButtonsMap)
remapWithMap(leftButtons, xrStandardLeftButtonsMap)
return remapped
}
const remapAxes = (axesRight, axesLeft) => {
// 0, 1 are reserved for touch
return [
axesLeft[2],
axesLeft[3],
axesRight[2],
axesRight[3]
]
}

10
vitest.config.ts Normal file
View file

@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
root: 'prismarine-viewer/viewer',
test: {
include: [
'**/*.test.ts'
],
},
})