Merge branch 'next' into nextgen-physics

This commit is contained in:
Vitaly Turovsky 2025-02-03 10:25:08 +03:00
commit 2d7ec12a75
57 changed files with 2063 additions and 680 deletions

View file

@ -40,25 +40,25 @@ jobs:
# if: ${{ github.event.pull_request.base.ref == 'release' }}
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
dedupe-check:
runs-on: ubuntu-latest
if: github.event.pull_request.head.ref == 'next'
steps:
- name: Checkout repository
uses: actions/checkout@v2
# dedupe-check:
# runs-on: ubuntu-latest
# if: github.event.pull_request.head.ref == 'next'
# steps:
# - name: Checkout repository
# uses: actions/checkout@v2
- name: Install pnpm
run: npm install -g pnpm@9.0.4
- name: Run pnpm dedupe
run: pnpm dedupe
# - name: Install pnpm
# run: npm install -g pnpm@9.0.4
- name: Check for changes
run: |
if ! git diff --exit-code --quiet pnpm-lock.yaml; then
echo "pnpm dedupe introduced changes:"
git diff --color=always pnpm-lock.yaml
exit 1
else
echo "No changes detected after pnpm dedupe in pnpm-lock.yaml"
fi
# - name: Run pnpm dedupe
# run: pnpm dedupe
# - name: Check for changes
# run: |
# if ! git diff --exit-code --quiet pnpm-lock.yaml; then
# echo "pnpm dedupe introduced changes:"
# git diff --color=always pnpm-lock.yaml
# exit 1
# else
# echo "No changes detected after pnpm dedupe in pnpm-lock.yaml"
# fi

View file

@ -6,9 +6,14 @@ WORKDIR /app
COPY . /app
# install pnpm
RUN npm i -g pnpm@9.0.4
# Build arguments
ARG DOWNLOAD_SOUNDS=false
ARG DISABLE_SERVICE_WORKER=false
# TODO need flat --no-root-optional
RUN node ./scripts/dockerPrepare.mjs
RUN pnpm i
# Download sounds if flag is enabled
RUN if [ "$DOWNLOAD_SOUNDS" = "true" ] ; then node scripts/downloadSoundsMap.mjs ; fi
# TODO for development
# EXPOSE 9090
@ -17,7 +22,9 @@ RUN pnpm i
# ENTRYPOINT ["pnpm", "run", "run-all"]
# only for prod
RUN GITHUB_REPOSITORY=zardoy/minecraft-web-client pnpm run build
RUN GITHUB_REPOSITORY=zardoy/minecraft-web-client \
DISABLE_SERVICE_WORKER=$DISABLE_SERVICE_WORKER \
pnpm run build
# ---- Run Stage ----
FROM node:18-alpine
@ -31,5 +38,5 @@ RUN npm i -g pnpm@9.0.4
RUN npm init -yp
RUN pnpm i express github:zardoy/prismarinejs-net-browserify compression cors
EXPOSE 8080
VOLUME /app/dist
VOLUME /app/public
ENTRYPOINT ["node", "server.js", "--prod"]

18
pnpm-lock.yaml generated
View file

@ -147,7 +147,7 @@ importers:
version: 2.0.4
net-browserify:
specifier: github:zardoy/prismarinejs-net-browserify
version: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/ab3721ca833308a0be099d14ea0053fbd8459ace
version: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/624cc67c16f5e8b23b772e7eaabae16ba84b8590
node-gzip:
specifier: ^1.1.2
version: 1.1.2
@ -162,7 +162,7 @@ importers:
version: 6.1.1
prismarine-provider-anvil:
specifier: github:zardoy/prismarine-provider-anvil#everything
version: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/a3a7d031069373cc3e0cd05e54512dd9461ca34b(minecraft-data@3.83.1)
version: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.83.1)
prosemirror-example-setup:
specifier: ^1.2.2
version: 1.2.2
@ -6633,8 +6633,8 @@ packages:
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/ab3721ca833308a0be099d14ea0053fbd8459ace:
resolution: {tarball: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/ab3721ca833308a0be099d14ea0053fbd8459ace}
net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/624cc67c16f5e8b23b772e7eaabae16ba84b8590:
resolution: {tarball: https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/624cc67c16f5e8b23b772e7eaabae16ba84b8590}
version: 0.2.4
nice-try@1.0.5:
@ -7188,8 +7188,8 @@ packages:
resolution: {tarball: https://codeload.github.com/zardoy/prismarine-physics/tar.gz/353e25b800149393f40539ec381218be44cbb03b}
version: 1.9.0
prismarine-provider-anvil@https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/a3a7d031069373cc3e0cd05e54512dd9461ca34b:
resolution: {tarball: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/a3a7d031069373cc3e0cd05e54512dd9461ca34b}
prismarine-provider-anvil@https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7:
resolution: {tarball: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7}
version: 2.8.0
prismarine-realms@1.3.2:
@ -12791,7 +12791,7 @@ snapshots:
prismarine-entity: 2.3.1
prismarine-item: 1.16.0
prismarine-nbt: 2.5.0
prismarine-provider-anvil: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/a3a7d031069373cc3e0cd05e54512dd9461ca34b(minecraft-data@3.83.1)
prismarine-provider-anvil: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.83.1)
prismarine-windows: 2.9.0
prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c
rambda: 9.2.0
@ -17224,7 +17224,7 @@ snapshots:
neo-async@2.6.2: {}
net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/ab3721ca833308a0be099d14ea0053fbd8459ace:
net-browserify@https://codeload.github.com/zardoy/prismarinejs-net-browserify/tar.gz/624cc67c16f5e8b23b772e7eaabae16ba84b8590:
dependencies:
body-parser: 1.20.2
express: 4.18.2
@ -17869,7 +17869,7 @@ snapshots:
prismarine-nbt: 2.5.0
vec3: 0.1.8
prismarine-provider-anvil@https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/a3a7d031069373cc3e0cd05e54512dd9461ca34b(minecraft-data@3.83.1):
prismarine-provider-anvil@https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.83.1):
dependencies:
prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0)
prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/e68e9a423b5b1907535878fb636f12c28a1a9374(minecraft-data@3.83.1)

View file

@ -20,6 +20,7 @@ import { getMesh } from './entity/EntityMesh'
import { WalkingGeneralSwing } from './entity/animations'
import { disposeObject } from './threeJsUtils'
import { armorModels } from './entity/objModels'
import { Viewer } from './viewer'
const { loadTexture } = globalThis.isElectron ? require('./utils.electron.js') : require('./utils')
export const TWEEN_DURATION = 120
@ -163,12 +164,12 @@ const nametags = {}
const isFirstUpperCase = (str) => str.charAt(0) === str.charAt(0).toUpperCase()
function getEntityMesh (entity, scene, options, overrides) {
function getEntityMesh (entity, world, options, overrides) {
if (entity.name) {
try {
// https://github.com/PrismarineJS/prismarine-viewer/pull/410
const entityName = (isFirstUpperCase(entity.name) ? snakeCase(entity.name) : entity.name).toLowerCase()
const e = new Entity.EntityMesh('1.16.4', entityName, scene, overrides)
const e = new Entity.EntityMesh('1.16.4', entityName, world, overrides)
if (e.mesh) {
addNametag(entity, options, e.mesh)
@ -211,6 +212,8 @@ export class Entities extends EventEmitter {
clock = new THREE.Clock()
rendering = true
itemsTexture: THREE.Texture | null = null
cachedMapsImages = {} as Record<number, string>
itemFrameMaps = {} as Record<number, Array<THREE.Mesh<THREE.PlaneGeometry, THREE.MeshLambertMaterial>>>
getItemUv: undefined | ((idOrName: number | string) => {
texture: THREE.Texture;
u: number;
@ -220,7 +223,7 @@ export class Entities extends EventEmitter {
size?: number;
})
constructor (public scene: THREE.Scene) {
constructor (public viewer: Viewer) {
super()
this.entitiesOptions = {}
this.debugMode = 'none'
@ -229,7 +232,7 @@ export class Entities extends EventEmitter {
clear () {
for (const mesh of Object.values(this.entities)) {
this.scene.remove(mesh)
this.viewer.scene.remove(mesh)
disposeObject(mesh)
}
this.entities = {}
@ -251,9 +254,9 @@ export class Entities extends EventEmitter {
this.rendering = rendering
for (const ent of entity ? [entity] : Object.values(this.entities)) {
if (rendering) {
if (!this.scene.children.includes(ent)) this.scene.add(ent)
if (!this.viewer.scene.children.includes(ent)) this.viewer.scene.add(ent)
} else {
this.scene.remove(ent)
this.viewer.scene.remove(ent)
}
}
}
@ -417,6 +420,7 @@ export class Entities extends EventEmitter {
}
getItemMesh (item) {
// TODO: Render proper model (especially for blocks) instead of flat texture
const textureUv = this.getItemUv?.(item.itemId ?? item.blockId)
if (textureUv) {
// todo use geometry buffer uv instead!
@ -470,9 +474,13 @@ export class Entities extends EventEmitter {
update (entity: import('prismarine-entity').Entity & { delete?; pos, name }, overrides) {
const isPlayerModel = entity.name === 'player'
if (entity.name === 'zombie' || entity.name === 'zombie_villager' || entity.name === 'husk') {
if (entity.name === 'zombie_villager' || entity.name === 'husk') {
overrides.texture = `textures/1.16.4/entity/${entity.name === 'zombie_villager' ? 'zombie_villager/zombie_villager.png' : `zombie/${entity.name}.png`}`
}
if (entity.name === 'glow_item_frame') {
if (!overrides.textures) overrides.textures = []
overrides.textures['background'] = 'block:glow_item_frame'
}
// this can be undefined in case where packet entity_destroy was sent twice (so it was already deleted)
let e = this.entities[entity.id]
@ -480,7 +488,7 @@ export class Entities extends EventEmitter {
if (!e) return
if (e.additionalCleanup) e.additionalCleanup()
this.emit('remove', entity)
this.scene.remove(e)
this.viewer.scene.remove(e)
disposeObject(e)
// todo dispose textures as well ?
delete this.entities[entity.id]
@ -551,7 +559,7 @@ export class Entities extends EventEmitter {
//@ts-expect-error
playerObject.animation.isMoving = false
} else {
mesh = getEntityMesh(entity, this.scene, this.entitiesOptions, overrides)
mesh = getEntityMesh(entity, this.viewer.world, this.entitiesOptions, overrides)
}
if (!mesh) return
mesh.name = 'mesh'
@ -570,7 +578,7 @@ export class Entities extends EventEmitter {
group.add(mesh)
group.add(boxHelper)
boxHelper.visible = false
this.scene.add(group)
this.viewer.scene.add(group)
e = group
this.entities[entity.id] = e
@ -694,31 +702,51 @@ export class Entities extends EventEmitter {
}
// todo handle map, map_chunks events
// if (entity.name === 'item_frame' || entity.name === 'glow_item_frame') {
// const example = {
// "present": true,
// "itemId": 847,
// "itemCount": 1,
// "nbtData": {
// "type": "compound",
// "name": "",
// "value": {
// "map": {
// "type": "int",
// "value": 2146483444
// },
// "interactiveboard": {
// "type": "byte",
// "value": 1
// }
// }
// }
// }
// const item = entity.metadata?.[8]
// if (item.nbtData) {
// const nbt = nbt.simplify(item.nbtData)
// }
// }
let itemFrameMeta = getSpecificEntityMetadata('item_frame', entity)
if (!itemFrameMeta) {
itemFrameMeta = getSpecificEntityMetadata('glow_item_frame', entity)
}
if (itemFrameMeta) {
// TODO: fix type
// todo! fix errors in mc-data (no entities data prior 1.18.2)
const item = (itemFrameMeta?.item ?? entity.metadata?.[8]) as any as { itemId, blockId, components, nbtData: { value: { map: { value: number } } } }
mesh.scale.set(1, 1, 1)
e.rotation.x = -entity.pitch
e.children.find(c => {
if (c.name.startsWith('map_')) {
disposeObject(c)
const existingMapNumber = parseInt(c.name.split('_')[1], 10)
this.itemFrameMaps[existingMapNumber] = this.itemFrameMaps[existingMapNumber]?.filter(mesh => mesh !== c)
if (c instanceof THREE.Mesh) {
c.material?.map?.dispose()
}
return true
} else if (c.name === 'item') {
disposeObject(c)
return true
}
return false
})?.removeFromParent()
if (item && (item.itemId ?? item.blockId ?? 0) !== 0) {
const rotation = (itemFrameMeta.rotation as any as number) ?? 0
const mapNumber = item.nbtData?.value?.map?.value ?? item.components?.find(x => x.type === 'map_id')?.data
if (mapNumber) {
// TODO: Use proper larger item frame model when a map exists
mesh.scale.set(16 / 12, 16 / 12, 1)
this.addMapModel(e, mapNumber, rotation)
} else {
const itemMesh = this.getItemMesh(item)
if (itemMesh) {
itemMesh.mesh.position.set(0, 0, 0.43)
itemMesh.mesh.scale.set(0.5, 0.5, 0.5)
itemMesh.mesh.rotateY(Math.PI)
itemMesh.mesh.rotateZ(rotation * Math.PI / 4)
itemMesh.mesh.name = 'item'
e.add(itemMesh.mesh)
}
}
}
}
if (entity.username) {
e.username = entity.username
@ -741,6 +769,74 @@ export class Entities extends EventEmitter {
}
}
updateMap (mapNumber: string | number, data: string) {
this.cachedMapsImages[mapNumber] = data
let itemFrameMeshes = this.itemFrameMaps[mapNumber]
if (!itemFrameMeshes) return
itemFrameMeshes = itemFrameMeshes.filter(mesh => mesh.parent)
this.itemFrameMaps[mapNumber] = itemFrameMeshes
if (itemFrameMeshes) {
for (const mesh of itemFrameMeshes) {
mesh.material.map = this.loadMap(data)
mesh.material.needsUpdate = true
mesh.visible = true
}
}
}
addMapModel (entityMesh: THREE.Object3D, mapNumber: number, rotation: number) {
const imageData = this.cachedMapsImages?.[mapNumber]
let texture: THREE.Texture | null = null
if (imageData) {
texture = this.loadMap(imageData)
}
const parameters = {
transparent: true,
alphaTest: 0.1,
}
if (texture) {
parameters['map'] = texture
}
const material = new THREE.MeshLambertMaterial(parameters)
const mapMesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material)
mapMesh.rotation.set(0, Math.PI, 0)
entityMesh.add(mapMesh)
let isInvisible = false
entityMesh.traverseVisible(c => {
if (c.name === 'geometry_frame') {
isInvisible = false
}
})
if (isInvisible) {
mapMesh.position.set(0, 0, 0.499)
} else {
mapMesh.position.set(0, 0, 0.437)
}
mapMesh.rotateZ(Math.PI * 2 - rotation * Math.PI / 2)
mapMesh.name = `map_${mapNumber}`
if (!texture) {
mapMesh.visible = false
}
if (!this.itemFrameMaps[mapNumber]) {
this.itemFrameMaps[mapNumber] = []
}
this.itemFrameMaps[mapNumber].push(mapMesh)
}
loadMap (data: any) {
const texture = new THREE.TextureLoader().load(data)
if (texture) {
texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
texture.needsUpdate = true
}
return texture
}
handleDamageEvent (entityId, damageAmount) {
const entityMesh = this.entities[entityId]?.children.find(c => c.name === 'mesh')
if (entityMesh) {
@ -808,7 +904,7 @@ function addArmorModel (entityMesh: THREE.Object3D, slotType: string, item: Item
material.map = texture
})
} else {
mesh = getMesh(texturePath, armorModels.armorModel[slotType])
mesh = getMesh(viewer.world, texturePath, armorModels.armorModel[slotType])
mesh.name = meshName
material = mesh.material
material.side = THREE.DoubleSide

View file

@ -94,7 +94,7 @@ function dot(a, b) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
function addCube(attr, boneId, bone, cube, texWidth = 64, texHeight = 64, mirror = false) {
function addCube(attr, boneId, bone, cube, sameTextureForAllFaces = false, texWidth = 64, texHeight = 64, mirror = false) {
const cubeRotation = new THREE.Euler(0, 0, 0)
if (cube.rotation) {
cubeRotation.x = -cube.rotation[0] * Math.PI / 180
@ -107,8 +107,15 @@ function addCube(attr, boneId, bone, cube, texWidth = 64, texHeight = 64, mirror
const eastOrWest = dir[0] !== 0
const faceUvs = []
for (const pos of corners) {
const u = (cube.uv[0] + dot(pos[3] ? u1 : u0, cube.size)) / texWidth
const v = (cube.uv[1] + dot(pos[4] ? v1 : v0, cube.size)) / texHeight
let u
let v
if (sameTextureForAllFaces) {
u = (cube.uv[0] + pos[3] * cube.size[0]) / texWidth
v = (cube.uv[1] + pos[4] * cube.size[1]) / texHeight
} else {
u = (cube.uv[0] + dot(pos[3] ? u1 : u0, cube.size)) / texWidth
v = (cube.uv[1] + dot(pos[4] ? v1 : v0, cube.size)) / texHeight
}
const posX = eastOrWest && mirror ? pos[0] ^ 1 : pos[0]
const posY = pos[1]
@ -148,7 +155,23 @@ function addCube(attr, boneId, bone, cube, texWidth = 64, texHeight = 64, mirror
}
}
export function getMesh(texture, jsonModel, overrides = {}) {
export function getMesh(worldRenderer, texture, jsonModel, overrides = {}) {
let textureWidth = jsonModel.texturewidth ?? 64
let textureHeight = jsonModel.textureheight ?? 64
let textureOffset
const useBlockTexture = texture.startsWith('block:')
if (useBlockTexture) {
const blockName = texture.slice(6)
const textureInfo = worldRenderer.blocksAtlasParser.getTextureInfo(blockName)
if (textureInfo) {
textureWidth = worldRenderer.material.map.image.width
textureHeight = worldRenderer.material.map.image.height
textureOffset = [textureInfo.u, textureInfo.v]
} else {
console.error(`Unknown block ${blockName}`)
}
}
const bones = {}
const geoData = {
@ -186,7 +209,7 @@ export function getMesh(texture, jsonModel, overrides = {}) {
if (jsonBone.cubes) {
for (const cube of jsonBone.cubes) {
addCube(geoData, i, bone, cube, jsonModel.texturewidth, jsonModel.textureheight, jsonBone.mirror)
addCube(geoData, i, bone, cube, useBlockTexture, textureWidth, textureHeight, jsonBone.mirror)
}
}
i++
@ -215,18 +238,25 @@ export function getMesh(texture, jsonModel, overrides = {}) {
mesh.bind(skeleton)
mesh.scale.set(1 / 16, 1 / 16, 1 / 16)
loadTexture(texture, texture => {
if (material.map) {
// texture is already loaded
return
}
texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
texture.flipY = false
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping
if (textureOffset) {
texture = worldRenderer.material.map.clone()
texture.offset.set(textureOffset[0], textureOffset[1])
texture.needsUpdate = true
material.map = texture
})
} else {
loadTexture(texture.endsWith('.png') || texture.startsWith('data:image/') ? texture : texture + '.png', texture => {
if (material.map) {
// texture is already loaded
return
}
texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
texture.flipY = false
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping
material.map = texture
})
}
return mesh
}
@ -252,6 +282,7 @@ export const temporaryMap = {
'hopper_minecart': 'minecart',
'command_block_minecart': 'minecart',
'tnt_minecart': 'minecart',
'glow_item_frame': 'item_frame',
'glow_squid': 'squid',
'trader_llama': 'llama',
'chest_boat': 'boat',
@ -321,7 +352,7 @@ const offsetEntity = {
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export class EntityMesh {
constructor(version, type, scene, /** @type {{textures?, rotation?: Record<string, {x,y,z}>}} */overrides = {}) {
constructor(version, type, worldRenderer, /** @type {{textures?, rotation?: Record<string, {x,y,z}>}} */overrides = {}) {
const originalType = type
const mappedValue = temporaryMap[type]
if (mappedValue) type = mappedValue
@ -388,7 +419,7 @@ export class EntityMesh {
const texture = overrides.textures?.[name] ?? e.textures[name]
if (!texture) continue
// console.log(JSON.stringify(jsonModel, null, 2))
const mesh = getMesh(texture + '.png', jsonModel, overrides)
const mesh = getMesh(worldRenderer, texture, jsonModel, overrides)
mesh.name = `geometry_${name}`
this.mesh.add(mesh)

View file

@ -7838,6 +7838,53 @@
}
}
},
"item_frame": {
"identifier": "minecraft:item_frame",
"materials": {"default": "item_frame"},
"textures": {
"background": "block:item_frame",
"frame": "block:oak_planks"
},
"geometry": {
"background": {
"bones": [
{
"name": "base"
},
{
"name": "background",
"parent": "base",
"rotation": [0, 180, 0],
"pivot": [0, 0, 0],
"cubes": [
{"origin": [-5, -5, -8], "size": [10, 10, 0.5], "uv": [3, 3]}
]
}
],
"texturewidth": 16,
"textureheight": 16
},
"frame": {
"bones": [
{
"name": "frame",
"parent": "base",
"rotation": [0, 180, 0],
"pivot": [0, 0, 0],
"cubes": [
{"origin": [-6, -6, -8], "size": [12, 1, 1], "uv": [2, 2]},
{"origin": [-6, 5, -8], "size": [12, 1, 1], "uv": [2, 13]},
{"origin": [-6, -5, -8], "size": [1, 10, 1], "uv": [2, 3]},
{"origin": [5, -5, -8], "size": [1, 10, 1], "uv": [13, 3]}
]
}
],
"texturewidth": 16,
"textureheight": 16
}
},
"render_controllers": ["controller.render.item_frame"]
},
"leash_knot": {
"identifier": "minecraft:leash_knot",
"materials": {"default": "leash_knot"},
@ -7847,7 +7894,8 @@
"bones": [
{
"name": "knot",
"cubes": [{"origin": [-3, 2, -3], "size": [6, 8, 6]}]
"rotation": [0, 180, 0],
"cubes": [{"origin": [5, 6, 5], "size": [6, 8, 6], "uv": [0, 0]}]
}
],
"texturewidth": 32,

View file

@ -48,7 +48,7 @@ export class Viewer {
this.threeJsWorld = new WorldRendererThree(this.scene, this.renderer, worldConfig)
this.setWorld()
this.resetScene()
this.entities = new Entities(this.scene)
this.entities = new Entities(this)
// this.primitives = new Primitives(this.scene, this.camera)
this.domElement = renderer.domElement

View file

@ -75,6 +75,10 @@ export class WorldDataEmitter extends EventEmitter {
this.eventListeners = {
// 'move': botPosition,
entitySpawn (e: any) {
if (e.name === 'item_frame' || e.name === 'glow_item_frame') {
// Item frames use block positions in the protocol, not their center. Fix that.
e.position.translate(0.5, 0.5, 0.5)
}
emitEntity(e)
},
entityUpdate (e: any) {

View file

@ -20,6 +20,7 @@ const execAsync = promisify(childProcess.exec)
const buildingVersion = new Date().toISOString().split(':')[0]
const dev = process.env.NODE_ENV === 'development'
const disableServiceWorker = process.env.DISABLE_SERVICE_WORKER === 'true'
let releaseTag
let releaseChangelog
@ -59,6 +60,7 @@ const appConfig = defineConfig({
'process.env.DEPS_VERSIONS': JSON.stringify({}),
'process.env.RELEASE_TAG': JSON.stringify(releaseTag),
'process.env.RELEASE_CHANGELOG': JSON.stringify(releaseChangelog),
'process.env.DISABLE_SERVICE_WORKER': JSON.stringify(disableServiceWorker),
},
},
server: {
@ -103,6 +105,9 @@ const appConfig = defineConfig({
configJson.defaultProxy = ':8080'
}
fs.writeFileSync('./dist/config.json', JSON.stringify({ ...configJson, ...configLocalJson }), 'utf8')
if (fs.existsSync('./generated/sounds.js')) {
fs.copyFileSync('./generated/sounds.js', './dist/sounds.js')
}
// childProcess.execSync('./scripts/prepareSounds.mjs', { stdio: 'inherit' })
// childProcess.execSync('tsx ./scripts/genMcDataTypes.ts', { stdio: 'inherit' })
// childProcess.execSync('tsx ./scripts/genPixelartTypes.ts', { stdio: 'inherit' })
@ -121,15 +126,17 @@ const appConfig = defineConfig({
prep()
})
build.onAfterBuild(async () => {
const { count, size, warnings } = await generateSW({
// dontCacheBustURLsMatching: [new RegExp('...')],
globDirectory: 'dist',
skipWaiting: true,
clientsClaim: true,
additionalManifestEntries: getSwAdditionalEntries(),
globPatterns: [],
swDest: './dist/service-worker.js',
})
if (!disableServiceWorker) {
const { count, size, warnings } = await generateSW({
// dontCacheBustURLsMatching: [new RegExp('...')],
globDirectory: 'dist',
skipWaiting: true,
clientsClaim: true,
additionalManifestEntries: getSwAdditionalEntries(),
globPatterns: [],
swDest: './dist/service-worker.js',
})
}
})
}
build.onBeforeStartDevServer(() => prep())

View file

@ -1,9 +1,12 @@
import fs from 'fs'
const url = 'https://github.com/zardoy/minecraft-web-client/raw/sounds-generated/sounds.js'
const savePath = 'dist/sounds.js'
const url = 'https://github.com/zardoy/minecraft-web-client/raw/sounds-generated/sounds-v2.js'
fetch(url).then(res => res.text()).then(data => {
fs.writeFileSync(savePath, data, 'utf8')
if (fs.existsSync('./dist')) {
fs.writeFileSync('./dist/sounds.js', data, 'utf8')
}
fs.mkdirSync('./generated', { recursive: true })
fs.writeFileSync('./generated/sounds.js', data, 'utf8')
if (fs.existsSync('.vercel/output/static/')) {
fs.writeFileSync('.vercel/output/static/sounds.js', data, 'utf8')
}

View file

@ -10,26 +10,31 @@ import { build } from 'esbuild'
const __dirname = path.dirname(fileURLToPath(new URL(import.meta.url)))
const targetedVersions = ['1.20.1', '1.19.2', '1.18.2', '1.17.1', '1.16.5', '1.15.2', '1.14.4', '1.13.2', '1.12.2', '1.11.2', '1.10.2', '1.9.4', '1.8.9']
const targetedVersions = ['1.21.1', '1.20.6', '1.20.1', '1.19.2', '1.18.2', '1.17.1', '1.16.5', '1.15.2', '1.14.4', '1.13.2', '1.12.2', '1.11.2', '1.10.2', '1.9.4', '1.8.9']
/** @type {{name, size, hash}[]} */
let prevSounds = null
const burgerDataUrl = (version) => `https://raw.githubusercontent.com/Pokechu22/Burger/gh-pages/${version}.json`
const burgerDataPath = './generated/burger.json'
const EXISTING_CACHE_PATH = './generated/existing-sounds-cache.json'
// const perVersionData: Record<string, { removed: string[],
const soundsPathVersionsRemap = {}
const downloadAllSounds = async () => {
const downloadAllSoundsAndCreateMap = async () => {
let existingSoundsCache = {}
try {
existingSoundsCache = JSON.parse(await fs.promises.readFile(EXISTING_CACHE_PATH, 'utf8'))
} catch (err) {}
const { versions } = await getVersionList()
const lastVersion = versions.filter(version => !version.id.includes('w'))[0]
// if (lastVersion.id !== targetedVersions[0]) throw new Error('last version is not the same as targetedVersions[0], update')
for (const targetedVersion of targetedVersions) {
const versionData = versions.find(x => x.id === targetedVersion)
if (!versionData) throw new Error('no version data for ' + targetedVersion)
console.log('Getting assets for version', targetedVersion)
for (const version of targetedVersions) {
const versionData = versions.find(x => x.id === version)
if (!versionData) throw new Error('no version data for ' + version)
console.log('Getting assets for version', version)
const { assetIndex } = await fetch(versionData.url).then((r) => r.json())
/** @type {{objects: {[a: string]: { size, hash }}}} */
const index = await fetch(assetIndex.url).then((r) => r.json())
@ -45,26 +50,30 @@ const downloadAllSounds = async () => {
const changedSize = soundAssets.filter(x => prevSoundNames.has(x.name) && prevSounds.find(y => y.name === x.name).size !== x.size)
console.log('changed size', changedSize.map(x => ({ name: x.name, prev: prevSounds.find(y => y.name === x.name).size, curr: x.size })))
if (addedSounds.length || changedSize.length) {
soundsPathVersionsRemap[targetedVersion] = [...addedSounds, ...changedSize].map(x => x.name.replace('minecraft/sounds/', '').replace('.ogg', ''))
soundsPathVersionsRemap[version] = [...addedSounds, ...changedSize].map(x => x.name.replace('minecraft/sounds/', '').replace('.ogg', ''))
}
if (addedSounds.length) {
console.log('downloading new sounds for version', targetedVersion)
downloadSounds(addedSounds, targetedVersion + '/')
console.log('downloading new sounds for version', version)
downloadSounds(version, addedSounds, version + '/')
}
if (changedSize.length) {
console.log('downloading changed sounds for version', targetedVersion)
downloadSounds(changedSize, targetedVersion + '/')
console.log('downloading changed sounds for version', version)
downloadSounds(version, changedSize, version + '/')
}
} else {
console.log('downloading sounds for version', targetedVersion)
downloadSounds(soundAssets)
console.log('downloading sounds for version', version)
downloadSounds(version, soundAssets)
}
prevSounds = soundAssets
}
async function downloadSound({ name, hash, size }, namePath, log) {
const cached =
!!namePath.replace('.ogg', '.mp3').split('/').reduce((acc, cur) => acc?.[cur], existingSoundsCache.sounds) ||
!!namePath.replace('.ogg', '.ogg').split('/').reduce((acc, cur) => acc?.[cur], existingSoundsCache.sounds)
const savePath = path.resolve(`generated/sounds/${namePath}`)
if (fs.existsSync(savePath)) {
if (cached || fs.existsSync(savePath)) {
// console.log('skipped', name)
existingSoundsCache.sounds[namePath] = true
return
}
log()
@ -86,7 +95,12 @@ const downloadAllSounds = async () => {
}
writer.close()
}
async function downloadSounds(assets, addPath = '') {
async function downloadSounds(version, assets, addPath = '') {
if (addPath && existingSoundsCache.sounds[version]) {
console.log('using existing sounds for version', version)
return
}
console.log(version, 'have to download', assets.length, 'sounds')
for (let i = 0; i < assets.length; i += 5) {
await Promise.all(assets.slice(i, i + 5).map((asset, j) => downloadSound(asset, `${addPath}${asset.name}`, () => {
console.log('downloading', addPath, asset.name, i + j, '/', assets.length)
@ -95,6 +109,7 @@ const downloadAllSounds = async () => {
}
fs.writeFileSync('./generated/soundsPathVersionsRemap.json', JSON.stringify(soundsPathVersionsRemap), 'utf8')
fs.writeFileSync(EXISTING_CACHE_PATH, JSON.stringify(existingSoundsCache), 'utf8')
}
const lightpackOverrideSounds = {
@ -106,7 +121,8 @@ const lightpackOverrideSounds = {
// this is not done yet, will be used to select only sounds for bundle (most important ones)
const isSoundWhitelisted = (name) => name.startsWith('random/') || name.startsWith('note/') || name.endsWith('/say1') || name.endsWith('/death') || (name.startsWith('mob/') && name.endsWith('/step1')) || name.endsWith('/swoop1') || /* name.endsWith('/break1') || */ name.endsWith('dig/stone1')
const ffmpeg = 'C:/Users/Vitaly/Documents/LosslessCut-win-x64/resources/ffmpeg.exe' // will be ffmpeg-static
// const ffmpeg = 'C:/Users/Vitaly/Documents/LosslessCut-win-x64/resources/ffmpeg.exe' // can be ffmpeg-static
const ffmpegExec = 'ffmpeg'
const maintainBitrate = true
const scanFilesDeep = async (root, onOggFile) => {
@ -127,7 +143,7 @@ const convertSounds = async () => {
})
const convertSound = async (i) => {
const proc = promisify(exec)(`${ffmpeg} -i "${toConvert[i]}" -y -codec:a libmp3lame ${maintainBitrate ? '-qscale:a 2' : ''} "${toConvert[i].replace('.ogg', '.mp3')}"`)
const proc = promisify(exec)(`${ffmpegExec} -i "${toConvert[i]}" -y -codec:a libmp3lame ${maintainBitrate ? '-qscale:a 2' : ''} "${toConvert[i].replace('.ogg', '.mp3')}"`)
// pipe stdout to the console
proc.child.stdout.pipe(process.stdout)
await proc
@ -147,8 +163,8 @@ const getSoundsMap = (burgerData) => {
}
const writeSoundsMap = async () => {
// const burgerData = await fetch(burgerDataUrl(targetedVersions[0])).then((r) => r.json())
// fs.writeFileSync(burgerDataPath, JSON.stringify(burgerData[0].sounds), 'utf8')
const burgerData = await fetch(burgerDataUrl(targetedVersions[0])).then((r) => r.json())
fs.writeFileSync(burgerDataPath, JSON.stringify(burgerData[0].sounds), 'utf8')
const allSoundsMapOutput = {}
let prevMap
@ -174,16 +190,22 @@ const writeSoundsMap = async () => {
// const includeSound = isSoundWhitelisted(firstName)
// if (!includeSound) continue
const mostUsedSound = sounds.sort((a, b) => b.weight - a.weight)[0]
const targetSound = sounds[0]
// outputMap[id] = { subtitle, sounds: mostUsedSound }
// outputMap[id] = { subtitle, sounds }
const soundFilePath = `generated/sounds/minecraft/sounds/${targetSound.name}.mp3`
// const soundFilePath = `generated/sounds/minecraft/sounds/${targetSound.name}.mp3`
// if (!fs.existsSync(soundFilePath)) {
// console.warn('no sound file', targetSound.name)
// continue
// }
let outputUseSoundLine = []
const minWeight = sounds.reduce((acc, cur) => cur.weight ? Math.min(acc, cur.weight) : acc, sounds[0].weight ?? 1)
if (isNaN(minWeight)) debugger
for (const sound of sounds) {
if (sound.weight && isNaN(sound.weight)) debugger
outputUseSoundLine.push(`${sound.volume ?? 1};${sound.name};${sound.weight ?? minWeight}`)
}
const key = `${id};${name}`
outputIdMap[key] = `${targetSound.volume ?? 1};${targetSound.name}`
outputIdMap[key] = outputUseSoundLine.join(',')
if (prevMap && prevMap[key]) {
keysStats.same++
} else {
@ -221,7 +243,7 @@ const makeSoundsBundle = async () => {
const allSoundsMeta = {
format: 'mp3',
baseUrl: 'https://raw.githubusercontent.com/zardoy/minecraft-web-client/sounds-generated/sounds/'
baseUrl: `https://raw.githubusercontent.com/${process.env.REPO_SLUG}/sounds/sounds/`
}
await build({
@ -235,9 +257,25 @@ const makeSoundsBundle = async () => {
},
metafile: true,
})
// copy also to generated/sounds.js
fs.copyFileSync('./dist/sounds.js', './generated/sounds.js')
}
// downloadAllSounds()
// convertSounds()
// writeSoundsMap()
// makeSoundsBundle()
const action = process.argv[2]
if (action) {
const execFn = {
download: downloadAllSoundsAndCreateMap,
convert: convertSounds,
write: writeSoundsMap,
bundle: makeSoundsBundle,
}[action]
if (execFn) {
execFn()
}
} else {
// downloadAllSoundsAndCreateMap()
// convertSounds()
// writeSoundsMap()
makeSoundsBundle()
}

109
scripts/uploadSoundFiles.ts Normal file
View file

@ -0,0 +1,109 @@
import fetch from 'node-fetch';
import * as fs from 'fs';
import * as path from 'path';
import { glob } from 'glob';
// Git details
const REPO_SLUG = process.env.REPO_SLUG;
const owner = REPO_SLUG.split('/')[0];
const repo = REPO_SLUG.split('/')[1];
const branch = "sounds";
// GitHub token for authentication
const token = process.env.GITHUB_TOKEN;
// GitHub API endpoint
const baseUrl = `https://api.github.com/repos/${owner}/${repo}/contents`;
const headers = {
Authorization: `token ${token}`,
'Content-Type': 'application/json'
};
async function getShaForExistingFile(repoFilePath: string): Promise<string | null> {
const url = `${baseUrl}/${repoFilePath}?ref=${branch}`;
const response = await fetch(url, { headers });
if (response.status === 404) {
return null; // File does not exist
}
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
}
const data = await response.json();
return data.sha;
}
async function uploadFiles() {
const commitMessage = "Upload multiple files via script";
const committer = {
name: "GitHub",
email: "noreply@github.com"
};
const filesToUpload = glob.sync("generated/sounds/**/*.mp3").map(localPath => {
const repoPath = localPath.replace(/^generated\//, '');
return { localPath, repoPath };
});
const files = await Promise.all(filesToUpload.map(async file => {
const content = fs.readFileSync(file.localPath, 'base64');
const sha = await getShaForExistingFile(file.repoPath);
return {
path: file.repoPath,
mode: "100644",
type: "blob",
sha: sha || undefined,
content: content
};
}));
const treeResponse = await fetch(`${baseUrl}/git/trees`, {
method: 'POST',
headers: headers,
body: JSON.stringify({
base_tree: null,
tree: files
})
});
if (!treeResponse.ok) {
throw new Error(`Failed to create tree: ${treeResponse.statusText}`);
}
const treeData = await treeResponse.json();
const commitResponse = await fetch(`${baseUrl}/git/commits`, {
method: 'POST',
headers: headers,
body: JSON.stringify({
message: commitMessage,
tree: treeData.sha,
parents: [branch],
committer: committer
})
});
if (!commitResponse.ok) {
throw new Error(`Failed to create commit: ${commitResponse.statusText}`);
}
const commitData = await commitResponse.json();
const updateRefResponse = await fetch(`${baseUrl}/git/refs/heads/${branch}`, {
method: 'PATCH',
headers: headers,
body: JSON.stringify({
sha: commitData.sha
})
});
if (!updateRefResponse.ok) {
throw new Error(`Failed to update ref: ${updateRefResponse.statusText}`);
}
console.log("Files uploaded successfully");
}
uploadFiles().catch(error => {
console.error("Error uploading files:", error);
});

67
scripts/uploadSounds.ts Normal file
View file

@ -0,0 +1,67 @@
import fs from 'fs'
// GitHub details
const owner = "zardoy";
const repo = "minecraft-web-client";
const branch = "sounds-generated";
const filePath = "dist/sounds.js"; // Local file path
const repoFilePath = "sounds-v2.js"; // Path in the repo
// GitHub token for authentication
const token = process.env.GITHUB_TOKEN;
// GitHub API endpoint
const baseUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${repoFilePath}`;
const headers = {
Authorization: `token ${token}`,
'Content-Type': 'application/json'
};
async function getShaForExistingFile(): Promise<string | null> {
const url = `${baseUrl}?ref=${branch}`;
const response = await fetch(url, { headers });
if (response.status === 404) {
return null; // File does not exist
}
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
}
const data = await response.json();
return data.sha;
}
async function uploadFile() {
const content = fs.readFileSync(filePath, 'utf8');
const base64Content = Buffer.from(content).toString('base64');
const sha = await getShaForExistingFile();
console.log('got sha')
const body = {
message: "Update sounds.js",
content: base64Content,
branch: branch,
committer: {
name: "GitHub",
email: "noreply@github.com"
},
sha: sha || undefined
};
const response = await fetch(baseUrl, {
method: 'PUT',
headers: headers,
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(`Failed to upload file: ${response.statusText}`);
}
const responseData = await response.json();
console.log("File uploaded successfully:", responseData);
}
uploadFile().catch(error => {
console.error("Error uploading file:", error);
});

View file

@ -26,6 +26,7 @@ if (!isProd) {
app.get('/config.json', (req, res, next) => {
// read original file config
let config = {}
let publicConfig = {}
try {
config = require('./config.json')
} catch {
@ -33,9 +34,13 @@ app.get('/config.json', (req, res, next) => {
config = require('./dist/config.json')
} catch { }
}
try {
publicConfig = require('./public/config.json')
} catch { }
res.json({
...config,
'defaultProxy': '', // use current url (this server)
...publicConfig,
})
})
if (isProd) {
@ -45,6 +50,11 @@ if (isProd) {
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp')
next()
})
// First serve from the override directory (volume mount)
app.use(express.static(path.join(__dirname, './public')))
// Then fallback to the original dist directory
app.use(express.static(path.join(__dirname, './dist')))
}

54
src/api/mcStatusApi.ts Normal file
View file

@ -0,0 +1,54 @@
export const isServerValid = (ip: string) => {
const isInLocalNetwork = ip.startsWith('192.168.') ||
ip.startsWith('10.') ||
ip.startsWith('172.') ||
ip.startsWith('127.') ||
ip.startsWith('localhost') ||
ip.startsWith(':')
const VALID_IP_OR_DOMAIN = ip.includes('.')
return !isInLocalNetwork && VALID_IP_OR_DOMAIN
}
export async function fetchServerStatus (ip: string, signal?: AbortSignal) {
if (!isServerValid(ip)) return
const response = await fetch(`https://api.mcstatus.io/v2/status/java/${ip}`, { signal })
const data: ServerResponse = await response.json()
const versionClean = data.version?.name_raw.replace(/^[^\d.]+/, '')
return {
formattedText: data.motd?.raw ?? '',
textNameRight: data.online ?
`${versionClean} ${data.players?.online ?? '??'}/${data.players?.max ?? '??'}` :
'',
icon: data.icon,
offline: !data.online,
raw: data
}
}
export type ServerResponse = {
online: boolean
version?: {
name_raw: string
}
// display tooltip
players?: {
online: number
max: number
list: Array<{
name_raw: string
name_clean: string
}>
}
icon?: string
motd?: {
raw: string
}
// todo circle error icon
mods?: Array<{ name: string, version: string }>
// todo display via hammer icon
software?: string
plugins?: Array<{ name, version }>
}

78
src/appParams.ts Normal file
View file

@ -0,0 +1,78 @@
const qsParams = new URLSearchParams(window.location.search)
export type AppQsParams = {
// AddServerOrConnect.tsx params
ip?: string
name?: string
version?: string
proxy?: string
username?: string
lockConnect?: string
autoConnect?: string
// googledrive.ts params
state?: string
// ServersListProvider.tsx params
serversList?: string
// Map and texture params
texturepack?: string
map?: string
mapDirBaseUrl?: string
mapDirGuess?: string
// Singleplayer params
singleplayer?: string
sp?: string
loadSave?: string
// Server params
reconnect?: string
server?: string
// Peer connection params
connectPeer?: string
peerVersion?: string
// UI params
modal?: string
viewerConnect?: string
// Map version param
mapVersion?: string
// Command params
command?: string
// Misc params
suggest_save?: string
scene?: string
}
export type AppQsParamsArray = {
mapDir?: string[]
setting?: string[]
serverSetting?: string[]
command?: string[]
}
type AppQsParamsArrayTransformed = {
[k in keyof AppQsParamsArray]: string[]
}
export const appQueryParams = new Proxy<AppQsParams>({} as AppQsParams, {
get (target, property) {
if (typeof property !== 'string') {
return null
}
return qsParams.get(property)
},
})
export const appQueryParamsArray = new Proxy({} as AppQsParamsArrayTransformed, {
get (target, property) {
if (typeof property !== 'string') {
return null
}
return qsParams.getAll(property)
},
})
// Helper function to check if a specific query parameter exists
export const hasQueryParam = (param: keyof AppQsParams) => qsParams.has(param)
// Helper function to get all query parameters as a URLSearchParams object
export const getRawQueryParams = () => qsParams;
(globalThis as any).debugQueryParams = Object.fromEntries(qsParams.entries())

View file

@ -1,3 +1,4 @@
import { subscribeKey } from 'valtio/utils'
import { options } from './optionsStorage'
import { isCypress } from './standaloneUtils'
import { reportWarningOnce } from './utils'
@ -5,9 +6,14 @@ import { reportWarningOnce } from './utils'
let audioContext: AudioContext
const sounds: Record<string, any> = {}
// Track currently playing sounds and their gain nodes
const activeSounds: Array<{ source: AudioBufferSourceNode; gainNode: GainNode; volumeMultiplier: number }> = []
window.activeSounds = activeSounds
// load as many resources on page load as possible instead on demand as user can disable internet connection after he thinks the page is loaded
const loadingSounds = [] as string[]
const convertedSounds = [] as string[]
export async function loadSound (path: string, contents = path) {
if (loadingSounds.includes(path)) return true
loadingSounds.push(path)
@ -24,15 +30,15 @@ export async function loadSound (path: string, contents = path) {
loadingSounds.splice(loadingSounds.indexOf(path), 1)
}
export const loadOrPlaySound = async (url, soundVolume = 1) => {
export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = 500) => {
const soundBuffer = sounds[url]
if (!soundBuffer) {
const start = Date.now()
const cancelled = await loadSound(url)
if (cancelled || Date.now() - start > 500) return
if (cancelled || Date.now() - start > loadTimeout) return
}
await playSound(url)
return playSound(url, soundVolume)
}
export async function playSound (url, soundVolume = 1) {
@ -49,6 +55,7 @@ export async function playSound (url, soundVolume = 1) {
for (const [soundName, sound] of Object.entries(sounds)) {
if (convertedSounds.includes(soundName)) continue
// eslint-disable-next-line no-await-in-loop
sounds[soundName] = await audioContext.decodeAudioData(sound)
convertedSounds.push(soundName)
}
@ -66,4 +73,51 @@ export async function playSound (url, soundVolume = 1) {
gainNode.connect(audioContext.destination)
gainNode.gain.value = volume
source.start(0)
// Add to active sounds
activeSounds.push({ source, gainNode, volumeMultiplier: soundVolume })
const callbacks = [] as Array<() => void>
source.onended = () => {
// Remove from active sounds when finished
const index = activeSounds.findIndex(s => s.source === source)
if (index !== -1) activeSounds.splice(index, 1)
for (const callback of callbacks) {
callback()
}
callbacks.length = 0
}
return {
onEnded (callback: () => void) {
callbacks.push(callback)
},
}
}
export function stopAllSounds () {
for (const { source } of activeSounds) {
try {
source.stop()
} catch (err) {
console.warn('Failed to stop sound:', err)
}
}
activeSounds.length = 0
}
export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number) {
const normalizedVolume = newVolume / 100
for (const { gainNode, volumeMultiplier } of activeSounds) {
try {
gainNode.gain.value = normalizedVolume * volumeMultiplier
} catch (err) {
console.warn('Failed to change sound volume:', err)
}
}
}
subscribeKey(options, 'volume', () => {
changeVolumeOfCurrentlyPlayingSounds(options.volume)
})

View file

@ -0,0 +1,80 @@
import { contro } from './controls'
import { activeModalStack, isGameActive, miscUiState, showModal } from './globalState'
import { options } from './optionsStorage'
import { hideNotification, notificationProxy } from './react/NotificationProvider'
import { pointerLock } from './utils'
import worldInteractions from './worldInteractions'
let lastMouseMove: number
export const updateCursor = () => {
worldInteractions.update()
}
export type CameraMoveEvent = {
movementX: number
movementY: number
type: string
stopPropagation?: () => void
}
export function onCameraMove (e: MouseEvent | CameraMoveEvent) {
if (!isGameActive(true)) return
if (e.type === 'mousemove' && !document.pointerLockElement) 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
moveCameraRawHandler({
x: e.movementX * mouseSensX * 0.0001,
y: e.movementY * mouseSensY * 0.0001
})
updateCursor()
}
export const moveCameraRawHandler = ({ x, y }: { x: number; y: number }) => {
const maxPitch = 0.5 * Math.PI
const minPitch = -0.5 * Math.PI
viewer.world.lastCamUpdate = Date.now()
if (!bot?.entity) return
const pitch = bot.entity.pitch - y
void bot.look(bot.entity.yaw - x, Math.max(minPitch, Math.min(maxPitch, pitch)), true)
}
window.addEventListener('mousemove', (e: MouseEvent) => {
onCameraMove(e)
}, { capture: true })
export const onControInit = () => {
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: 'stickMovement',
stopPropagation () {}
} as CameraMoveEvent)
miscUiState.usingGamepadInput = true
})
}
function pointerLockChangeCallback () {
if (notificationProxy.id === 'pointerlockchange') {
hideNotification()
}
if (viewer.renderer.xr.isPresenting) return // todo
if (!pointerLock.hasPointerLock && activeModalStack.length === 0) {
showModal({ reactType: 'pause-screen' })
}
}
document.addEventListener('pointerlockchange', pointerLockChangeCallback, false)

View file

@ -2,7 +2,7 @@ import { versionsByMinecraftVersion } from 'minecraft-data'
import minecraftInitialDataJson from '../generated/minecraft-initial-data.json'
import { AuthenticatedAccount } from './react/ServersListProvider'
import { setLoadingScreenStatus } from './utils'
import { downloadSoundsIfNeeded } from './soundSystem'
import { downloadSoundsIfNeeded } from './sounds/botSoundSystem'
import { miscUiState } from './globalState'
export type ConnectOptions = {

View file

@ -7,7 +7,7 @@ import { ControMax } from 'contro-max/build/controMax'
import { CommandEventArgument, SchemaCommandInput } from 'contro-max/build/types'
import { stringStartsWith } from 'contro-max/build/stringUtils'
import { UserOverrideCommand, UserOverridesConfig } from 'contro-max/build/types/store'
import { isGameActive, showModal, gameAdditionalState, activeModalStack, hideCurrentModal, miscUiState, loadedGameState, hideModal } from './globalState'
import { isGameActive, showModal, gameAdditionalState, activeModalStack, hideCurrentModal, miscUiState, hideModal, hideAllModals } from './globalState'
import { goFullscreen, pointerLock, reloadChunks } from './utils'
import { options } from './optionsStorage'
import { openPlayerInventory } from './inventoryWindows'
@ -19,9 +19,10 @@ import { showOptionsModal } from './react/SelectOption'
import widgets from './react/widgets'
import { getItemFromBlock } from './chatUtils'
import { gamepadUiCursorState, moveGamepadCursorByPx } from './react/GamepadUiCursor'
import { completeTexturePackInstall, resourcePackState } from './resourcePack'
import { completeTexturePackInstall, copyServerResourcePackToRegular, resourcePackState } from './resourcePack'
import { showNotification } from './react/NotificationProvider'
import { lastConnectOptions } from './react/AppStatusProvider'
import { onCameraMove, onControInit } from './cameraRotationControls'
export const customKeymaps = proxy(JSON.parse(localStorage.keymap || '{}')) as UserOverridesConfig
@ -50,7 +51,11 @@ export const contro = new ControMax({
command: ['Slash'],
swapHands: ['KeyF'],
zoom: ['KeyC'],
selectItem: ['KeyH'] // default will be removed
selectItem: ['KeyH'], // default will be removed
rotateCameraLeft: ['ArrowLeft'],
rotateCameraRight: ['ArrowRight'],
rotateCameraUp: ['ArrowUp'],
rotateCameraDown: ['ArrowDown']
},
ui: {
toggleFullscreen: ['F11'],
@ -92,6 +97,8 @@ export const contro = new ControMax({
window.controMax = contro
export type Command = CommandEventArgument<typeof contro['_commandsRaw']>['command']
onControInit()
updateBinds(customKeymaps)
const updateDoPreventDefault = () => {
@ -245,6 +252,73 @@ const inModalCommand = (command: Command, pressed: boolean) => {
}
}
// Camera rotation controls
const cameraRotationControls = {
activeDirections: new Set<'left' | 'right' | 'up' | 'down'>(),
interval: null as ReturnType<typeof setInterval> | null,
config: {
speed: 1, // movement per interval
interval: 5 // ms between movements
},
movements: {
left: { movementX: -0.5, movementY: 0 },
right: { movementX: 0.5, movementY: 0 },
up: { movementX: 0, movementY: -0.5 },
down: { movementX: 0, movementY: 0.5 }
},
updateMovement () {
if (cameraRotationControls.activeDirections.size === 0) {
if (cameraRotationControls.interval) {
clearInterval(cameraRotationControls.interval)
cameraRotationControls.interval = null
}
return
}
if (!cameraRotationControls.interval) {
cameraRotationControls.interval = setInterval(() => {
// Combine all active movements
const movement = { movementX: 0, movementY: 0 }
for (const direction of cameraRotationControls.activeDirections) {
movement.movementX += cameraRotationControls.movements[direction].movementX
movement.movementY += cameraRotationControls.movements[direction].movementY
}
onCameraMove({
...movement,
type: 'keyboardRotation',
stopPropagation () {}
})
}, cameraRotationControls.config.interval)
}
},
start (direction: 'left' | 'right' | 'up' | 'down') {
cameraRotationControls.activeDirections.add(direction)
cameraRotationControls.updateMovement()
},
stop (direction: 'left' | 'right' | 'up' | 'down') {
cameraRotationControls.activeDirections.delete(direction)
cameraRotationControls.updateMovement()
},
handleCommand (command: string, pressed: boolean) {
const directionMap = {
'general.rotateCameraLeft': 'left',
'general.rotateCameraRight': 'right',
'general.rotateCameraUp': 'up',
'general.rotateCameraDown': 'down'
} as const
const direction = directionMap[command]
if (direction) {
if (pressed) cameraRotationControls.start(direction)
else cameraRotationControls.stop(direction)
return true
}
return false
}
}
window.cameraRotationControls = cameraRotationControls
const setSneaking = (state: boolean) => {
gameAdditionalState.isSneaking = state
bot.setControlState('sneak', state)
@ -275,7 +349,6 @@ const onTriggerOrReleased = (command: Command, pressed: boolean) => {
} else if (pressed) {
setSneaking(!gameAdditionalState.isSneaking)
}
break
case 'general.attackDestroy':
document.dispatchEvent(new MouseEvent(pressed ? 'mousedown' : 'mouseup', { button: 0 }))
@ -286,6 +359,12 @@ const onTriggerOrReleased = (command: Command, pressed: boolean) => {
case 'general.zoom':
gameAdditionalState.isZooming = pressed
break
case 'general.rotateCameraLeft':
case 'general.rotateCameraRight':
case 'general.rotateCameraUp':
case 'general.rotateCameraDown':
cameraRotationControls.handleCommand(command, pressed)
break
}
}
}
@ -370,6 +449,12 @@ contro.on('trigger', ({ command }) => {
case 'general.toggleSneakOrDown':
case 'general.sprint':
case 'general.attackDestroy':
case 'general.rotateCameraLeft':
case 'general.rotateCameraRight':
case 'general.rotateCameraUp':
case 'general.rotateCameraDown':
// no-op
break
case 'general.swapHands': {
bot._client.write('entity_action', {
entityId: bot.entity.id,
@ -450,7 +535,12 @@ contro.on('release', ({ command }) => {
// hard-coded keybindings
export const f3Keybinds = [
export const f3Keybinds: Array<{
key?: string,
action: () => void,
mobileTitle: string
enabled?: () => boolean
}> = [
{
key: 'KeyA',
action () {
@ -496,9 +586,9 @@ export const f3Keybinds = [
key: 'KeyT',
async action () {
// TODO!
if (resourcePackState.resourcePackInstalled || loadedGameState.usingServerResourcePack) {
if (resourcePackState.resourcePackInstalled || gameAdditionalState.usingServerResourcePack) {
showNotification('Reloading textures...')
await completeTexturePackInstall('default', 'default', loadedGameState.usingServerResourcePack)
await completeTexturePackInstall('default', 'default', gameAdditionalState.usingServerResourcePack)
}
},
mobileTitle: 'Reload Textures'
@ -539,7 +629,15 @@ export const f3Keybinds = [
const proxyPing = await bot['pingProxy']()
void showOptionsModal(`${username}: last known total latency (ping): ${playerPing}. Connected to ${lastConnectOptions.value?.proxy} with current ping ${proxyPing}. Player UUID: ${uuid}`, [])
},
mobileTitle: 'Show Proxy & Ping Details'
mobileTitle: 'Show Proxy & Ping Details',
enabled: () => !!lastConnectOptions.value?.proxy
},
{
action () {
void copyServerResourcePackToRegular()
},
mobileTitle: 'Copy Server Resource Pack',
enabled: () => !!gameAdditionalState.usingServerResourcePack
}
]
@ -548,7 +646,7 @@ document.addEventListener('keydown', (e) => {
if (!isGameActive(false)) return
if (hardcodedPressedKeys.has('F3')) {
const keybind = f3Keybinds.find((v) => v.key === e.code)
if (keybind) {
if (keybind && (keybind.enabled?.() ?? true)) {
keybind.action()
e.stopPropagation()
}
@ -740,19 +838,12 @@ window.addEventListener('keydown', (e) => {
if (activeModalStack.length) {
const hideAll = e.ctrlKey || e.metaKey
if (hideAll) {
while (activeModalStack.length > 0) {
hideCurrentModal(undefined, () => {
if (!activeModalStack.length) {
pointerLock.justHitEscape = true
}
})
}
hideAllModals()
} else {
hideCurrentModal(undefined, () => {
if (!activeModalStack.length) {
pointerLock.justHitEscape = true
}
})
hideCurrentModal()
}
if (activeModalStack.length === 0) {
pointerLock.justHitEscape = true
}
} else if (pointerLock.hasPointerLock) {
document.exitPointerLock?.()

View file

@ -2,16 +2,16 @@ import prettyBytes from 'pretty-bytes'
import { openWorldFromHttpDir, openWorldZip } from './browserfs'
import { getResourcePackNames, installTexturePack, resourcePackState, updateTexturePackInstalledState } from './resourcePack'
import { setLoadingScreenStatus } from './utils'
import { appQueryParams, appQueryParamsArray } from './appParams'
export const getFixedFilesize = (bytes: number) => {
return prettyBytes(bytes, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const inner = async () => {
const qs = new URLSearchParams(window.location.search)
const mapUrlDir = qs.getAll('mapDir')
const mapUrlDirGuess = qs.get('mapDirGuess')
const mapUrlDirBaseUrl = qs.get('mapDirBaseUrl')
const mapUrlDir = appQueryParamsArray.mapDir ?? []
const mapUrlDirGuess = appQueryParams.mapDirGuess
const mapUrlDirBaseUrl = appQueryParams.mapDirBaseUrl
if (mapUrlDir.length) {
await openWorldFromHttpDir(mapUrlDir, mapUrlDirBaseUrl ?? undefined)
return true
@ -20,8 +20,8 @@ const inner = async () => {
// await openWorldFromHttpDir(undefined, mapUrlDirGuess)
return true
}
let mapUrl = qs.get('map')
const texturepack = qs.get('texturepack')
let mapUrl = appQueryParams.map
const { texturepack } = appQueryParams
// fixme
if (texturepack) mapUrl = texturepack
if (!mapUrl) return false

View file

@ -86,10 +86,21 @@ export const hideCurrentModal = (_data?, onHide?: () => void) => {
}
}
export const hideAllModals = () => {
while (activeModalStack.length > 0) {
if (!hideModal()) break
}
return activeModalStack.length === 0
}
export const openOptionsMenu = (group: OptionsGroupType) => {
showModal({ reactType: `options-${group}` })
}
subscribe(activeModalStack, () => {
document.body.style.setProperty('--has-modals-z', activeModalStack.length ? '-1' : null)
})
// ---
export const currentContextMenu = proxy({ items: [] as ContextMenuItem[] | null, x: 0, y: 0 })
@ -139,12 +150,6 @@ export const miscUiState = proxy({
displaySearchInput: false,
})
export const loadedGameState = proxy({
username: '',
serverIp: '' as string | null,
usingServerResourcePack: false,
})
export const isGameActive = (foregroundCheck: boolean) => {
if (foregroundCheck && activeModalStack.length) return false
return miscUiState.gameLoaded
@ -158,7 +163,9 @@ export const gameAdditionalState = proxy({
isSprinting: false,
isSneaking: false,
isZooming: false,
warps: [] as WorldWarp[]
warps: [] as WorldWarp[],
usingServerResourcePack: false,
})
window.gameAdditionalState = gameAdditionalState

View file

@ -6,6 +6,7 @@ import { loadGoogleDriveApi, loadInMemorySave } from './react/SingleplayerProvid
import { setLoadingScreenStatus } from './utils'
import { mountGoogleDriveFolder } from './browserfs'
import { showOptionsModal } from './react/SelectOption'
import { appQueryParams } from './appParams'
const CLIENT_ID = '137156026346-igv2gkjsj2hlid92rs3q7cjjnc77s132.apps.googleusercontent.com'
// const CLIENT_ID = process.env.GOOGLE_CLIENT_ID
@ -45,7 +46,7 @@ export const useGoogleLogIn = () => {
}
export const possiblyHandleStateVariable = async () => {
const stateParam = new URLSearchParams(window.location.search).get('state')
const stateParam = appQueryParams.state
if (!stateParam) return
setLoadingScreenStatus('Opening world in read only mode, waiting for login...')
await loadGoogleDriveApi()

View file

@ -7,12 +7,10 @@ import './entities'
import './globalDomListeners'
import './mineflayer/maps'
import './mineflayer/cameraShake'
import initCollisionShapes from './getCollisionInteractionShapes'
import { onGameLoad } from './inventoryWindows'
import { supportedVersions } from 'minecraft-protocol'
import initCollisionShapes from './getCollisionInteractionShapes'
import protocolMicrosoftAuth from 'minecraft-protocol/src/client/microsoftAuth'
import microsoftAuthflow from './microsoftAuthflow'
import nbt from 'prismarine-nbt'
import 'core-js/features/array/at'
import 'core-js/features/promise/with-resolvers'
@ -24,7 +22,7 @@ import PrismarineItem from 'prismarine-item'
import { options, watchValue } from './optionsStorage'
import './reactUi'
import { contro, lockUrl, onBotCreate } from './controls'
import { lockUrl, onBotCreate } from './controls'
import './dragndrop'
import { possiblyCleanHandle, resetStateAfterDisconnect } from './browserfs'
import { watchOptionsAfterViewerInit, watchOptionsAfterWorldViewInit } from './watchOptions'
@ -40,7 +38,7 @@ import { Vec3 } from 'vec3'
import worldInteractions from './worldInteractions'
import * as THREE from 'three'
import MinecraftData, { versionsByMinecraftVersion } from 'minecraft-data'
import MinecraftData from 'minecraft-data'
import debug from 'debug'
import { defaultsDeep } from 'lodash-es'
import initializePacketsReplay from './packetsReplay'
@ -53,16 +51,12 @@ import {
hideModal,
insertActiveModalStack,
isGameActive,
loadedGameState,
miscUiState,
showModal
} from './globalState'
import {
pointerLock,
toMajorVersion,
setLoadingScreenStatus
pointerLock, setLoadingScreenStatus
} from './utils'
import { isCypress } from './standaloneUtils'
@ -77,7 +71,6 @@ import dayCycle from './dayCycle'
import { onAppLoad, resourcepackReload } from './resourcePack'
import { ConnectPeerOptions, 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'
@ -85,9 +78,7 @@ import { fsState } from './loadSave'
import { watchFov } from './rendererUtils'
import { loadInMemorySave } from './react/SingleplayerProvider'
import { downloadSoundsIfNeeded } 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'
@ -106,6 +97,8 @@ import { ItemsRenderer } from 'mc-assets/dist/itemsRenderer'
import './mobileShim'
import { parseFormattedMessagePacket } from './botUtils'
import { getViewerVersionData, getWsProtocolStream } from './viewerConnector'
import { appQueryParams, appQueryParamsArray } from './appParams'
import { updateCursor } from './cameraRotationControls'
window.debug = debug
window.THREE = THREE
@ -201,44 +194,13 @@ viewer.entities.entitiesOptions = {
}
watchOptionsAfterViewerInit()
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 = {}) => {
const serverSettingsQsRaw = new URLSearchParams(window.location.search).getAll('serverSetting')
const serverSettingsQsRaw = appQueryParamsArray.serverSetting ?? []
const serverSettingsQs = serverSettingsQsRaw.map(x => x.split(':')).reduce<Record<string, string>>((acc, [key, value]) => {
acc[key] = JSON.parse(value)
return acc
@ -286,7 +248,7 @@ const cleanConnectIp = (host: string | undefined, defaultPort: string | undefine
}
}
async function connect (connectOptions: ConnectOptions) {
export async function connect (connectOptions: ConnectOptions) {
if (miscUiState.gameLoaded) return
miscUiState.hasErrors = false
lastConnectOptions.value = connectOptions
@ -301,6 +263,10 @@ async function connect (connectOptions: ConnectOptions) {
if (connectOptions.proxy?.startsWith(':')) {
connectOptions.proxy = `${location.protocol}//${location.hostname}${connectOptions.proxy}`
}
if (connectOptions.proxy && location.port !== '80' && location.port !== '443' && !/:\d+$/.test(connectOptions.proxy)) {
const https = connectOptions.proxy.startsWith('https://') || location.protocol === 'https:'
connectOptions.proxy = `${connectOptions.proxy}:${https ? 443 : 80}`
}
const proxy = cleanConnectIp(connectOptions.proxy, undefined)
let { username } = connectOptions
@ -702,7 +668,7 @@ async function connect (connectOptions: ConnectOptions) {
setLoadingScreenStatus('Placing blocks (starting viewer)')
localStorage.lastConnectOptions = JSON.stringify(connectOptions)
connectOptions.onSuccessfulPlay?.()
if (process.env.NODE_ENV === 'development' && !localStorage.lockUrl && new URLSearchParams(location.search).size === 0) {
if (process.env.NODE_ENV === 'development' && !localStorage.lockUrl && !Object.keys(window.debugQueryParams).length) {
lockUrl()
}
updateDataAfterJoin()
@ -746,154 +712,11 @@ async function connect (connectOptions: ConnectOptions) {
setLoadingScreenStatus('Setting callbacks')
const maxPitch = 0.5 * Math.PI
const minPitch = -0.5 * Math.PI
mouseMovePostHandle = ({ x, y }) => {
viewer.world.lastCamUpdate = Date.now()
const pitch = bot.entity.pitch - y
void bot.look(bot.entity.yaw - x, Math.max(minPitch, Math.min(maxPitch, pitch)), true)
}
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 () => {
loadedGameState.serverIp = server.host ?? null
loadedGameState.username = username
})
onGameLoad(() => {})
if (appStatusState.isError) return
setTimeout(() => {
// todo
const qs = new URLSearchParams(window.location.search)
if (qs.get('suggest_save')) {
if (appQueryParams.suggest_save) {
showNotification('Suggestion', 'Save the world to keep your progress!', false, undefined, async () => {
const savePath = await saveToBrowserMemory()
if (!savePath) return
@ -912,7 +735,7 @@ async function connect (connectOptions: ConnectOptions) {
// 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')
console.log('All chunks done and ready! Time from renderer open to ready', (Date.now() - start) / 1000, 's')
viewer.render() // ensure the last state is rendered
document.dispatchEvent(new Event('cypress-world-ready'))
})
@ -925,8 +748,8 @@ async function connect (connectOptions: ConnectOptions) {
if (!connectOptions.ignoreQs) {
// todo cleanup
customEvents.on('gameLoaded', () => {
const qs = new URLSearchParams(window.location.search)
for (let command of qs.getAll('command')) {
const commands = appQueryParamsArray.command ?? []
for (let command of commands) {
if (!command.startsWith('/')) command = `/${command}`
bot.chat(command)
}
@ -937,17 +760,14 @@ async function connect (connectOptions: ConnectOptions) {
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' || qs.get('sp') === '1') {
if (appQueryParams.singleplayer === '1' || appQueryParams.sp === '1') {
loadSingleplayer({}, {
worldFolder: undefined,
...moreServerOptions
...appQueryParams.version ? { version: appQueryParams.version } : {}
})
}
if (qs.get('loadSave')) {
const savePath = `/data/worlds/${qs.get('loadSave')}`
if (appQueryParams.loadSave) {
const savePath = `/data/worlds/${appQueryParams.loadSave}`
try {
await fs.promises.stat(savePath)
} catch (err) {
@ -1003,22 +823,19 @@ void window.fetch('config.json').then(async res => res.json()).then(c => c, (err
// 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')
if (appQueryParams.reconnect && process.env.NODE_ENV === 'development') {
const lastConnect = JSON.parse(localStorage.lastConnectOptions ?? {})
void connect({
botVersion: qs.get('version') ?? undefined,
...lastConnect, // todo mixing is not good idea
ip: ip || undefined
botVersion: appQueryParams.version ?? undefined,
...lastConnect,
ip: appQueryParams.ip || undefined
})
return
}
if (qs.get('ip') || qs.get('proxy')) {
const waitAppConfigLoad = !qs.get('proxy')
if (appQueryParams.ip || appQueryParams.proxy) {
const waitAppConfigLoad = !appQueryParams.proxy
const openServerEditor = () => {
hideModal()
// show server editor for connect or save
showModal({ reactType: 'editServer' })
}
showModal({ reactType: 'empty' })
@ -1040,12 +857,12 @@ downloadAndOpenFile().then((downloadAction) => {
void Promise.resolve().then(() => {
// try to connect to peer
const peerId = qs.get('connectPeer')
const peerId = appQueryParams.connectPeer
const peerOptions = {} as ConnectPeerOptions
if (qs.get('server')) {
peerOptions.server = qs.get('server')!
if (appQueryParams.server) {
peerOptions.server = appQueryParams.server
}
const version = qs.get('peerVersion')
const version = appQueryParams.peerVersion
if (peerId) {
let username: string | null = options.guestUsername
if (options.askGuestName) username = prompt('Enter your username', username)
@ -1060,11 +877,11 @@ downloadAndOpenFile().then((downloadAction) => {
}
})
if (qs.get('serversList')) {
if (appQueryParams.serversList) {
showModal({ reactType: 'serversList' })
}
const viewerWsConnect = qs.get('viewerConnect')
const viewerWsConnect = appQueryParams.viewerConnect
if (viewerWsConnect) {
void connect({
username: `viewer-${Math.random().toString(36).slice(2, 10)}`,
@ -1072,8 +889,8 @@ downloadAndOpenFile().then((downloadAction) => {
})
}
if (qs.get('modal')) {
const modals = qs.get('modal')!.split(',')
if (appQueryParams.modal) {
const modals = appQueryParams.modal.split(',')
for (const modal of modals) {
showModal({ reactType: modal })
}

View file

@ -11,6 +11,7 @@ import { isMajorVersionGreater } from './utils'
import { activeModalStacks, insertActiveModalStack, miscUiState } from './globalState'
import supportedVersions from './supportedVersions.mjs'
import { appQueryParams } from './appParams'
// todo include name of opened handle (zip)!
// additional fs metadata
@ -84,8 +85,7 @@ export const loadSave = async (root = '/world') => {
let version: string | undefined | null
let isFlat = false
if (levelDat) {
const qs = new URLSearchParams(window.location.search)
version = qs.get('mapVersion') ?? levelDat.Version?.Name
version = appQueryParams.mapVersion ?? levelDat.Version?.Name
if (!version) {
// const newVersion = disablePrompts ? '1.8.8' : prompt(`In 1.8 and before world save doesn't contain version info, please enter version you want to use to load the world.\nSupported versions ${supportedVersions.join(', ')}`, '1.8.8')
// if (!newVersion) return

View file

@ -16,5 +16,8 @@ setImageConverter((buf: Uint8Array) => {
customEvents.on('mineflayerBotCreated', () => {
bot.on('login', () => {
bot.loadPlugin(mapDownloader)
bot.mapDownloader.on('new_map', ({ png, id }) => {
viewer.entities.updateMap(id, png)
})
})
})

View file

@ -2,7 +2,7 @@ import { useRef, useState } from 'react'
import { useSnapshot } from 'valtio'
import { openURL } from 'prismarine-viewer/viewer/lib/simpleUtils'
import { noCase } from 'change-case'
import { loadedGameState, miscUiState, openOptionsMenu, showModal } from './globalState'
import { gameAdditionalState, miscUiState, openOptionsMenu, showModal } from './globalState'
import { AppOptions, options } from './optionsStorage'
import Button from './react/Button'
import { OptionMeta, OptionSlider } from './react/OptionsItems'
@ -157,7 +157,7 @@ export const guiOptionsScheme: {
{
custom () {
const { resourcePackInstalled } = useSnapshot(resourcePackState)
const { usingServerResourcePack } = useSnapshot(loadedGameState)
const { usingServerResourcePack } = useSnapshot(gameAdditionalState)
const { enabledResourcepack } = useSnapshot(options)
return <Button
label={`Resource Pack: ${usingServerResourcePack ? 'SERVER ON' : resourcePackInstalled ? enabledResourcepack ? 'ON' : 'OFF' : 'NO'}`} inScreen onClick={async () => {
@ -233,6 +233,19 @@ export const guiOptionsScheme: {
chatSelect: {
},
},
{
custom () {
return <Category>World</Category>
},
highlightBlockColor: {
text: 'Block Highlight Color',
values: [
['auto', 'Auto'],
['blue', 'Blue'],
['classic', 'Classic']
],
},
},
{
custom () {
return <Category>Sign Editor</Category>
@ -342,33 +355,38 @@ export const guiOptionsScheme: {
touchButtonsSize: {
min: 40,
disableIf: [
'touchControlsType',
'joystick-buttons'
'touchMovementType',
'modern'
],
},
touchButtonsOpacity: {
min: 10,
max: 90,
disableIf: [
'touchControlsType',
'joystick-buttons'
'touchMovementType',
'modern'
],
},
touchButtonsPosition: {
max: 80,
disableIf: [
'touchControlsType',
'joystick-buttons'
'touchMovementType',
'modern'
],
},
touchControlsType: {
values: [['classic', 'Classic'], ['joystick-buttons', 'New']],
touchMovementType: {
text: 'Movement Controls',
values: [['modern', 'Modern'], ['classic', 'Classic']],
},
touchInteractionType: {
text: 'Interaction Controls',
values: [['classic', 'Classic'], ['buttons', 'Buttons']],
},
},
{
custom () {
const { touchControlsType } = useSnapshot(options)
return <Button label='Setup Touch Buttons' onClick={() => showModal({ reactType: 'touch-buttons-setup' })} inScreen disabled={touchControlsType !== 'joystick-buttons'} />
const { touchInteractionType, touchMovementType } = useSnapshot(options)
return <Button label='Setup Touch Buttons' onClick={() => showModal({ reactType: 'touch-buttons-setup' })} inScreen disabled={touchInteractionType === 'classic' && touchMovementType === 'classic'} />
},
},
{

View file

@ -4,6 +4,7 @@ import { proxy, subscribe } from 'valtio/vanilla'
// weird webpack configuration bug: it cant import valtio/utils in this file
import { subscribeKey } from 'valtio/utils'
import { omitObj } from '@zardoy/utils'
import { appQueryParamsArray } from './appParams'
const isDev = process.env.NODE_ENV === 'development'
const defaultOptions = {
@ -25,6 +26,7 @@ const defaultOptions = {
chatOpacityOpened: 100,
messagesLimit: 200,
volume: 50,
enableMusic: false,
// fov: 70,
fov: 75,
guiScale: 3,
@ -33,7 +35,8 @@ const defaultOptions = {
touchButtonsOpacity: 80,
touchButtonsPosition: 12,
touchControlsPositions: getDefaultTouchControlsPositions(),
touchControlsType: 'classic' as 'classic' | 'joystick-buttons',
touchMovementType: 'modern' as 'modern' | 'classic',
touchInteractionType: 'classic' as 'classic' | 'buttons',
gpuPreference: 'default' as 'default' | 'high-performance' | 'low-power',
backgroundRendering: '20fps' as 'full' | '20fps' | '5fps',
/** @unstable */
@ -95,6 +98,7 @@ const defaultOptions = {
displayBossBars: false, // boss bar overlay was removed for some reason, enable safely
disabledUiParts: [] as string[],
neighborChunkUpdates: true,
highlightBlockColor: 'auto' as 'auto' | 'blue' | 'classic',
}
function getDefaultTouchControlsPositions () {
@ -118,7 +122,8 @@ function getDefaultTouchControlsPositions () {
} as Record<string, [number, number]>
}
const qsOptionsRaw = new URLSearchParams(location.search).getAll('setting')
// const qsOptionsRaw = new URLSearchParams(location.search).getAll('setting')
const qsOptionsRaw = appQueryParamsArray.setting ?? []
export const qsOptions = Object.fromEntries(qsOptionsRaw.map(o => {
const [key, value] = o.split(':')
return [key, JSON.parse(value)]
@ -135,6 +140,9 @@ const migrateOptions = (options: Partial<AppOptions & Record<string, any>>) => {
if (options.touchControlsPositions?.jump === undefined) {
options.touchControlsPositions!.jump = defaultOptions.touchControlsPositions.jump
}
if (options.touchControlsType === 'joystick-buttons') {
options.touchInteractionType = 'buttons'
}
return options
}

View file

@ -1,9 +1,11 @@
import React, { useEffect } from 'react'
import { appQueryParams } from '../appParams'
import { fetchServerStatus, isServerValid } from '../api/mcStatusApi'
import Screen from './Screen'
import Input from './Input'
import Button from './Button'
import SelectGameVersion from './SelectGameVersion'
import { useIsSmallWidth } from './simpleHooks'
import { useIsSmallWidth, usePassesWindowDimensions } from './simpleHooks'
export interface BaseServerInfo {
ip: string
@ -32,13 +34,13 @@ interface Props {
const ELEMENTS_WIDTH = 190
export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQs, onQsConnect, placeholders, accounts, versions, allowAutoConnect }: Props) => {
const qsParams = parseQs ? new URLSearchParams(window.location.search) : undefined
const qsParamName = qsParams?.get('name')
const qsParamIp = qsParams?.get('ip')
const qsParamVersion = qsParams?.get('version')
const qsParamProxy = qsParams?.get('proxy')
const qsParamUsername = qsParams?.get('username')
const qsParamLockConnect = qsParams?.get('lockConnect')
const isSmallHeight = !usePassesWindowDimensions(null, 350)
const qsParamName = parseQs ? appQueryParams.name : undefined
const qsParamIp = parseQs ? appQueryParams.ip : undefined
const qsParamVersion = parseQs ? appQueryParams.version : undefined
const qsParamProxy = parseQs ? appQueryParams.proxy : undefined
const qsParamUsername = parseQs ? appQueryParams.username : undefined
const qsParamLockConnect = parseQs ? appQueryParams.lockConnect : undefined
const qsIpParts = qsParamIp?.split(':')
const ipParts = initialData?.ip ? initialData?.ip.split(':') : undefined
@ -70,8 +72,55 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
authenticatedAccountOverride,
}
const [fetchedServerInfoIp, setFetchedServerInfoIp] = React.useState<string | undefined>(undefined)
const [serverOnline, setServerOnline] = React.useState(null as boolean | null)
const [onlinePlayersList, setOnlinePlayersList] = React.useState<string[]>([])
useEffect(() => {
if (qsParams?.get('autoConnect') === 'true' && qsParams?.get('ip') && allowAutoConnect) {
const controller = new AbortController()
const checkServer = async () => {
if (!qsParamIp || !isServerValid(qsParamIp)) return
try {
const status = await fetchServerStatus(qsParamIp)
if (!status) return
setServerOnline(status.raw.online)
setOnlinePlayersList(status.raw.players?.list.map(p => p.name_raw) ?? [])
setFetchedServerInfoIp(qsParamIp)
} catch (err) {
console.error('Failed to fetch server status:', err)
}
}
void checkServer()
return () => controller.abort()
}, [qsParamIp])
const validateUsername = (username: string) => {
if (!username) return undefined
if (onlinePlayersList.includes(username)) {
return { border: 'red solid 1px' }
}
const MINECRAFT_USERNAME_REGEX = /^\w{3,16}$/
if (!MINECRAFT_USERNAME_REGEX.test(username)) {
return { border: 'red solid 1px' }
}
return undefined
}
const validateServerIp = () => {
if (!serverIp) return undefined
if (serverOnline) {
return { border: 'lightgreen solid 1px' }
} else {
return { border: 'red solid 1px' }
}
}
useEffect(() => {
if (qsParamIp && qsParamVersion && allowAutoConnect) {
onQsConnect?.(commonUseOptions)
}
}, [])
@ -99,9 +148,19 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
<InputWithLabel label="Server Name" value={serverName} onChange={({ target: { value } }) => setServerName(value)} placeholder='Defaults to IP' />
</div>
</>}
<InputWithLabel required label="Server IP" value={serverIp} disabled={lockConnect && qsIpParts?.[0] !== null} onChange={({ target: { value } }) => setServerIp(value)} />
<InputWithLabel
required
label="Server IP"
value={serverIp}
disabled={lockConnect && qsIpParts?.[0] !== null}
onChange={({ target: { value } }) => {
setServerIp(value)
setServerOnline(false)
}}
validateInput={serverOnline === null || fetchedServerInfoIp !== serverIp ? undefined : validateServerIp}
/>
<InputWithLabel label="Server Port" value={serverPort} disabled={lockConnect && qsIpParts?.[1] !== null} onChange={({ target: { value } }) => setServerPort(value)} placeholder='25565' />
<div style={{ gridColumn: smallWidth ? '' : 'span 2' }}>Overrides:</div>
{isSmallHeight ? <div style={{ gridColumn: 'span 2', marginTop: 10, }} /> : <div style={{ gridColumn: smallWidth ? '' : 'span 2' }}>Overrides:</div>}
<div style={{
display: 'flex',
flexDirection: 'column',
@ -114,12 +173,19 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
setVersionOverride(value)
}}
placeholder="Optional, but recommended to specify"
disabled={lockConnect && qsParamVersion !== null}
disabled={lockConnect}
/>
</div>
<InputWithLabel label="Proxy Override" value={proxyOverride} disabled={lockConnect && qsParamProxy !== null} onChange={({ target: { value } }) => setProxyOverride(value)} placeholder={placeholders?.proxyOverride} />
<InputWithLabel label="Username Override" value={usernameOverride} disabled={!noAccountSelected || lockConnect && qsParamUsername !== null} onChange={({ target: { value } }) => setUsernameOverride(value)} placeholder={placeholders?.usernameOverride} />
<InputWithLabel label="Proxy Override" value={proxyOverride} disabled={lockConnect && (qsParamProxy !== null || !!placeholders?.proxyOverride)} onChange={({ target: { value } }) => setProxyOverride(value)} placeholder={placeholders?.proxyOverride} />
<InputWithLabel
label="Username Override"
value={usernameOverride}
disabled={!noAccountSelected || (lockConnect && qsParamUsername !== null)}
onChange={({ target: { value } }) => setUsernameOverride(value)}
placeholder={placeholders?.usernameOverride}
validateInput={!serverOnline || fetchedServerInfoIp !== serverIp ? undefined : validateUsername}
/>
<label style={{
display: 'flex',
flexDirection: 'column',
@ -135,6 +201,7 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
fontSize: 13,
}}
defaultValue={initialAccount === true ? -2 : initialAccount === undefined ? -1 : (fallbackIfNotFound((accounts ?? []).indexOf(initialAccount)) ?? -2)}
disabled={lockConnect && qsParamUsername !== null}
>
<option value={-1}>Offline Account (Username)</option>
{accounts?.map((account, i) => <option key={i} value={i}>{account} (Logged In)</option>)}

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { appQueryParams } from '../appParams'
import styles from './appStatus.module.css'
import Button from './Button'
import Screen from './Screen'
@ -15,6 +16,7 @@ export default ({
children
}) => {
const [loadingDotIndex, setLoadingDotIndex] = useState(0)
const lockConnect = appQueryParams.lockConnect === 'true'
useEffect(() => {
const statusRunner = async () => {
@ -65,7 +67,7 @@ export default ({
>
{isError && (
<>
{backAction && <Button label="Back" onClick={backAction} />}
{!lockConnect && backAction && <Button label="Back" onClick={backAction} />}
{actionsSlot}
<Button onClick={() => window.location.reload()} label="Reset App (recommended)" />
</>

View file

@ -13,6 +13,7 @@ interface Props extends React.ComponentProps<'button'> {
children?: React.ReactNode
inScreen?: boolean
rootRef?: Ref<HTMLButtonElement>
overlayColor?: string
}
const ButtonContext = createContext({
@ -23,7 +24,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, ...args }) => {
export default (({ label, icon, children, inScreen, rootRef, type = 'button', postLabel, overlayColor, ...args }) => {
const ctx = useContext(ButtonContext)
const onClick = (e) => {
@ -45,6 +46,13 @@ export default (({ label, icon, children, inScreen, rootRef, type = 'button', po
{label}
{postLabel}
{children}
{overlayColor && <div style={{
position: 'absolute',
inset: 0,
backgroundColor: overlayColor,
opacity: 0.5,
pointerEvents: 'none'
}} />}
</button>
</SharedHudVars>
}) satisfies FC<Props>

View file

@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { useSnapshot } from 'valtio'
import { formatMessage } from '../chatUtils'
import { getBuiltinCommandsList, tryHandleBuiltinCommand } from '../builtinCommands'
import { hideCurrentModal, loadedGameState, miscUiState } from '../globalState'
import { hideCurrentModal, miscUiState } from '../globalState'
import { options } from '../optionsStorage'
import Chat, { Message, fadeMessage } from './Chat'
import { useIsModalActive } from './utilsApp'
@ -54,7 +54,7 @@ export default () => {
updateLoadedServerData((server) => {
server.autoLogin ??= {}
const password = message.split(' ')[1]
server.autoLogin[loadedGameState.username] = password
server.autoLogin[bot.player.username] = password
return server
})
hideNotification()

View file

@ -0,0 +1,187 @@
import { useRef } from 'react'
import { useSnapshot } from 'valtio'
import { useUtilsEffect } from '@zardoy/react-util'
import { options } from '../optionsStorage'
import { activeModalStack, isGameActive, miscUiState } from '../globalState'
import worldInteractions from '../worldInteractions'
import { onCameraMove, CameraMoveEvent } from '../cameraRotationControls'
import { handleMovementStickDelta, joystickPointer } from './TouchAreasControls'
/** after what time of holding the finger start breaking the block */
const touchStartBreakingBlockMs = 500
function GameInteractionOverlayInner ({ zIndex }: { zIndex: number }) {
const overlayRef = useRef<HTMLDivElement>(null)
useUtilsEffect(({ signal }) => {
if (!overlayRef.current) return
const cameraControlEl = overlayRef.current
let virtualClickActive = false
let virtualClickTimeout: NodeJS.Timeout | undefined
let screenTouches = 0
let capturedPointer: {
id: number;
x: number;
y: number;
sourceX: number;
sourceY: number;
activateCameraMove: boolean;
time: number
} | undefined
const pointerDownHandler = (e: PointerEvent) => {
const clickedEl = e.composedPath()[0]
if (!isGameActive(true) || clickedEl !== cameraControlEl || e.pointerId === undefined) {
return
}
screenTouches++
if (screenTouches === 3) {
// todo maybe mouse wheel click?
}
const usingModernMovement = options.touchMovementType === 'modern'
if (usingModernMovement) {
if (!joystickPointer.pointer && e.clientX < window.innerWidth / 2) {
cameraControlEl.setPointerCapture(e.pointerId)
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.touchInteractionType === 'classic') {
virtualClickTimeout ??= setTimeout(() => {
virtualClickActive = true
document.dispatchEvent(new MouseEvent('mousedown', { button: 0 }))
}, touchStartBreakingBlockMs)
}
}
const pointerMoveHandler = (e: PointerEvent) => {
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)
}
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',
stopPropagation: () => e.stopPropagation()
} as CameraMoveEvent)
capturedPointer.x = e.pageX
capturedPointer.y = e.pageY
}
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 (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--
}
const contextMenuHandler = (e: Event) => {
e.preventDefault()
}
const blurHandler = () => {
bot.clearControlStates()
}
cameraControlEl.addEventListener('pointerdown', pointerDownHandler, { signal })
cameraControlEl.addEventListener('pointermove', pointerMoveHandler, { signal })
cameraControlEl.addEventListener('pointerup', pointerUpHandler, { signal })
cameraControlEl.addEventListener('pointercancel', pointerUpHandler, { signal })
cameraControlEl.addEventListener('lostpointercapture', pointerUpHandler, { signal })
cameraControlEl.addEventListener('contextmenu', contextMenuHandler, { signal })
window.addEventListener('blur', blurHandler, { signal })
}, [])
return (
<OverlayElement divRef={overlayRef} zIndex={zIndex} />
)
}
const OverlayElement = ({ divRef, zIndex }: { divRef: React.RefObject<HTMLDivElement>, zIndex: number }) => {
return <div
className='game-interaction-overlay'
ref={divRef}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex,
touchAction: 'none',
userSelect: 'none'
}}
/>
}
export default function GameInteractionOverlay ({ zIndex }: { zIndex: number }) {
const modalStack = useSnapshot(activeModalStack)
const { currentTouch } = useSnapshot(miscUiState)
if (modalStack.length > 0 || !currentTouch) return null
return <GameInteractionOverlayInner zIndex={zIndex} />
}

View file

@ -19,7 +19,7 @@ export default () => {
updateHeldMap()
})
bot.on('new_map', () => {
bot.on('new_map', ({ id }) => {
// total maps: Object.keys(bot.mapDownloader.maps).length
updateHeldMap()
})

View file

@ -1,6 +1,6 @@
.effectsScreen-container {
position: fixed;
top: 6%;
top: max(6%, 30px);
left: 0px;
z-index: -2;
pointer-events: none;

View file

@ -1,4 +1,4 @@
import React, { CSSProperties, useEffect, useRef, useState } from 'react'
import React, { CSSProperties, useEffect, useMemo, useRef, useState } from 'react'
import { isMobile } from 'prismarine-viewer/viewer/lib/simpleUtils'
import styles from './input.module.css'
@ -28,6 +28,10 @@ export default ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue,
}, [])
useEffect(() => {
setValidationStyle(validateInput?.(value as any) ?? {})
}, [value, validateInput])
return <div id='input-container' className={styles.container} style={rootStyles}>
<input
ref={ref}
@ -41,7 +45,6 @@ export default ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue,
{...inputProps}
value={value}
onChange={(e) => {
setValidationStyle(validateInput?.(e.target.value) ?? {})
setValue(e.target.value)
inputProps.onChange?.(e)
}}

View file

@ -15,11 +15,12 @@ import { useSnapshot } from 'valtio'
import BlockData from '../../prismarine-viewer/viewer/lib/moreBlockDataGenerated.json'
import preflatMap from '../preflatMap.json'
import { contro } from '../controls'
import { gameAdditionalState, showModal, hideModal, miscUiState, loadedGameState, activeModalStack } from '../globalState'
import { gameAdditionalState, showModal, hideModal, miscUiState, activeModalStack } from '../globalState'
import { options } from '../optionsStorage'
import Minimap, { DisplayMode } from './Minimap'
import { ChunkInfo, DrawerAdapter, MapUpdates, MinimapDrawer } from './MinimapDrawer'
import { useIsModalActive } from './utilsApp'
import { lastConnectOptions } from './AppStatusProvider'
const getBlockKey = (x: number, z: number) => {
return `${x},${z}`
@ -167,9 +168,9 @@ export class DrawerAdapterImpl extends TypedEventEmitter<MapUpdates> implements
// type suppressed until server is updated. It works fine
void (localServer as any).setWarp(warp, remove)
} else if (remove) {
localStorage.removeItem(`warps: ${loadedGameState.username} ${loadedGameState.serverIp}`)
localStorage.removeItem(`warps: ${bot.player.username} ${lastConnectOptions.value!.server}`)
} else {
localStorage.setItem(`warps: ${loadedGameState.username} ${loadedGameState.serverIp}`, JSON.stringify(this.warps))
localStorage.setItem(`warps: ${bot.player.username} ${lastConnectOptions.value!.server}`, JSON.stringify(this.warps))
}
this.emit('updateWarps')
}

View file

@ -6,7 +6,7 @@
left: 50%;
transform: translate(-50%);
gap: 0 5px;
z-index: -1;
z-index: var(--has-modals-z, 7);
}
.pause-btn,

View file

@ -21,7 +21,7 @@ export default () => {
}, [])
const onLongPress = async () => {
const select = await showOptionsModal('', f3Keybinds.filter(f3Keybind => f3Keybind.mobileTitle).map(f3Keybind => f3Keybind.mobileTitle))
const select = await showOptionsModal('', f3Keybinds.filter(f3Keybind => f3Keybind.mobileTitle && (f3Keybind.enabled?.() ?? true)).map(f3Keybind => f3Keybind.mobileTitle))
if (!select) return
const f3Keybind = f3Keybinds.find(f3Keybind => f3Keybind.mobileTitle === select)
if (f3Keybind) f3Keybind.action()

View file

@ -3,11 +3,12 @@ import { noCase } from 'change-case'
import { titleCase } from 'title-case'
import { useMemo } from 'react'
import { options, qsOptions } from '../optionsStorage'
import { miscUiState } from '../globalState'
import { hideAllModals, miscUiState } from '../globalState'
import Button from './Button'
import Slider from './Slider'
import Screen from './Screen'
import { showOptionsModal } from './SelectOption'
import PixelartIcon, { pixelartIcons } from './PixelartIcon'
type GeneralItem<T extends string | number | boolean> = {
id?: string
@ -188,10 +189,18 @@ interface Props {
}
export default ({ items, title, backButtonAction }: Props) => {
const { currentTouch } = useSnapshot(miscUiState)
return <Screen
title={title}
>
<div className='screen-items'>
{currentTouch && (
<div style={{ position: 'fixed', marginLeft: '-30px', display: 'flex', flexDirection: 'column', gap: 1, }}>
<Button icon={pixelartIcons['close']} onClick={hideAllModals} style={{ color: '#ff5d5d', }} />
<Button icon={pixelartIcons['chevron-left']} onClick={backButtonAction} style={{ color: 'yellow', }} />
</div>
)}
{items.map((element, i) => {
// make sure its unique!
return <RenderOption key={element.id ?? `${title}-${i}`} item={element} />

View file

@ -19,6 +19,7 @@ import { disconnect } from '../flyingSquidUtils'
import { pointerLock, setLoadingScreenStatus } from '../utils'
import { closeWan, openToWanAndCopyJoinLink, getJoinLink } from '../localServerMultiplayer'
import { collectFilesToCopy, fileExistsAsyncOptimized, mkdirRecursive, uniqueFileNameFromWorldName } from '../browserfs'
import { appQueryParams } from '../appParams'
import { useIsModalActive } from './utilsApp'
import { showOptionsModal } from './SelectOption'
import Button from './Button'
@ -86,7 +87,7 @@ export const saveToBrowserMemory = async () => {
const srcPath = join(worldFolder, copyPath)
const savePath = join(saveRootPath, copyPath)
await mkdirRecursive(savePath)
await fs.promises.writeFile(savePath, await fs.promises.readFile(srcPath))
await fs.promises.writeFile(savePath, await fs.promises.readFile(srcPath) as any)
upProgress(totalSIze)
if (isRegionFiles) {
const regionFile = copyPath.split('/').at(-1)!
@ -146,8 +147,7 @@ const splitByCopySize = (files: string[], copySize = 15) => {
}
export default () => {
const qsParams = new URLSearchParams(window.location.search)
const lockConnect = qsParams?.get('lockConnect') === 'true'
const lockConnect = appQueryParams.lockConnect === 'true'
const isModalActive = useIsModalActive('pause-screen')
const fsStateSnap = useSnapshot(fsState)
const activeModalStackSnap = useSnapshot(activeModalStack)

View file

@ -1,15 +1,16 @@
import { useSnapshot } from 'valtio'
import { useState, useEffect, useMemo } from 'react'
import { isGameActive, loadedGameState } from '../globalState'
import { isGameActive } from '../globalState'
import PlayerListOverlay from './PlayerListOverlay'
import './PlayerListOverlay.css'
import { lastConnectOptions } from './AppStatusProvider'
const MAX_ROWS_PER_COL = 10
type Players = typeof bot.players
export default () => {
const { serverIp } = useSnapshot(loadedGameState)
const serverIp = lastConnectOptions.value?.server
const [clientId, setClientId] = useState('')
const [players, setPlayers] = useState<Players>({})
const [isOpen, setIsOpen] = useState(false)

View file

@ -4,6 +4,8 @@ import { useSnapshot } from 'valtio'
import { ConnectOptions } from '../connect'
import { activeModalStack, hideCurrentModal, miscUiState, showModal } from '../globalState'
import supportedVersions from '../supportedVersions.mjs'
import { appQueryParams } from '../appParams'
import { fetchServerStatus, isServerValid } from '../api/mcStatusApi'
import ServersList from './ServersList'
import AddServerOrConnect, { BaseServerInfo } from './AddServerOrConnect'
import { useDidUpdateEffect } from './utils'
@ -11,42 +13,18 @@ import { useIsModalActive } from './utilsApp'
import { showOptionsModal } from './SelectOption'
import { useCopyKeybinding } from './simpleHooks'
interface StoreServerItem extends BaseServerInfo {
export interface StoreServerItem extends BaseServerInfo {
lastJoined?: number
description?: string
optionsOverride?: Record<string, any>
autoLogin?: Record<string, string>
}
type ServerResponse = {
online: boolean
version?: {
name_raw: string
}
// display tooltip
players?: {
online: number
max: number
list: Array<{
name_raw: string
name_clean: string
}>
}
icon?: string
motd?: {
raw: string
}
// todo circle error icon
mods?: Array<{ name, version }>
// todo display via hammer icon
software?: string
plugins?: Array<{ name, version }>
}
type AdditionalDisplayData = {
formattedText: string
textNameRight: string
icon?: string
offline?: boolean
}
export interface AuthenticatedAccount {
@ -93,8 +71,8 @@ const getInitialServersList = () => {
return servers
}
const serversListQs = new URLSearchParams(window.location.search).get('serversList')
const proxyQs = new URLSearchParams(window.location.search).get('proxy')
const serversListQs = appQueryParams.serversList
const proxyQs = appQueryParams.proxy
const setNewServersList = (serversList: StoreServerItem[], force = false) => {
if (serversListQs && !force) return
@ -138,6 +116,9 @@ export const updateAuthenticatedAccountData = (callback: (data: AuthenticatedAcc
// todo move to base
const normalizeIp = (ip: string) => ip.replace(/https?:\/\//, '').replace(/\/(:|$)/, '')
const FETCH_DELAY = 100 // ms between each request
const MAX_CONCURRENT_REQUESTS = 10
const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersList?: string[] }) => {
const [proxies, setProxies] = useState<readonly string[]>(localStorage['proxies'] ? JSON.parse(localStorage['proxies']) : getInitialProxies())
const [selectedProxy, setSelectedProxy] = useState(proxyQs ?? localStorage.getItem('selectedProxy') ?? proxies?.[0] ?? '')
@ -196,36 +177,59 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
return serversList.map((server, index) => ({ ...server, index })).sort((a, b) => (b.lastJoined ?? 0) - (a.lastJoined ?? 0))
}, [serversList])
useUtilsEffect(({ signal }) => {
const update = async () => {
for (const server of serversListSorted) {
const isInLocalNetwork = server.ip.startsWith('192.168.') || server.ip.startsWith('10.') || server.ip.startsWith('172.') || server.ip.startsWith('127.') || server.ip.startsWith('localhost')
if (isInLocalNetwork || signal.aborted) continue
// eslint-disable-next-line no-await-in-loop
await fetch(`https://api.mcstatus.io/v2/status/java/${server.ip}`, {
// TODO: bounty for this who fix it
// signal
}).then(async r => r.json()).then((data: ServerResponse) => {
const versionClean = data.version?.name_raw.replace(/^[^\d.]+/, '')
if (!versionClean) return
setAdditionalData(old => {
return ({
...old,
[server.ip]: {
formattedText: data.motd?.raw ?? '',
textNameRight: `${versionClean} ${data.players?.online ?? '??'}/${data.players?.max ?? '??'}`,
icon: data.icon,
}
})
})
})
}
}
void update().catch((err) => {})
}, [serversListSorted])
const isEditScreenModal = useIsModalActive('editServer')
useUtilsEffect(({ signal }) => {
if (isEditScreenModal) return
const update = async () => {
const queue = serversListSorted
.map(server => {
if (!isServerValid(server.ip) || signal.aborted) return null
return server
})
.filter(x => x !== null)
const activeRequests = new Set<Promise<void>>()
let lastRequestStart = 0
for (const server of queue) {
// Wait if at concurrency limit
if (activeRequests.size >= MAX_CONCURRENT_REQUESTS) {
// eslint-disable-next-line no-await-in-loop
await Promise.race(activeRequests)
}
// Create and track new request
// eslint-disable-next-line @typescript-eslint/no-loop-func
const request = new Promise<void>(resolve => {
setTimeout(async () => {
try {
lastRequestStart = Date.now()
if (signal.aborted) return
const data = await fetchServerStatus(server.ip/* , signal */) // DONT ADD SIGNAL IT WILL CRUSH JS RUNTIME
if (data) {
setAdditionalData(old => ({
...old,
[server.ip]: data
}))
}
} finally {
activeRequests.delete(request)
resolve()
}
}, lastRequestStart ? Math.max(0, FETCH_DELAY - (Date.now() - lastRequestStart)) : 0)
})
activeRequests.add(request)
}
await Promise.all(activeRequests)
}
void update()
}, [serversListSorted, isEditScreenModal])
useDidUpdateEffect(() => {
if (serverEditScreen && !isEditScreenModal) {
showModal({ reactType: 'editServer' })
@ -394,10 +398,10 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
name: server.index.toString(),
title: server.name || server.ip,
detail: (server.versionOverride ?? '') + ' ' + (server.usernameOverride ?? ''),
// lastPlayed: server.lastJoined,
formattedTextOverride: additional?.formattedText,
worldNameRight: additional?.textNameRight ?? '',
iconSrc: additional?.icon,
offline: additional?.offline
}
})}
initialProxies={{

View file

@ -12,6 +12,7 @@ import Button from './Button'
import Tabs from './Tabs'
import MessageFormattedString from './MessageFormattedString'
import { useIsSmallWidth } from './simpleHooks'
import PixelartIcon from './PixelartIcon'
export interface WorldProps {
name: string
@ -26,9 +27,10 @@ export interface WorldProps {
onFocus?: (name: string) => void
onInteraction?(interaction: 'enter' | 'space')
elemRef?: React.Ref<HTMLDivElement>
offline?: boolean
}
const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction, iconSrc, formattedTextOverride, worldNameRight, elemRef }: WorldProps & { ref?: React.Ref<HTMLDivElement> }) => {
const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction, iconSrc, formattedTextOverride, worldNameRight, elemRef, offline }: WorldProps & { ref?: React.Ref<HTMLDivElement> }) => {
const timeRelativeFormatted = useMemo(() => {
if (!lastPlayed) return ''
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
@ -60,7 +62,14 @@ const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus,
<div className={styles.world_info}>
<div className={styles.world_title}>
<div>{title}</div>
<div className={styles.world_title_right}>{worldNameRight}</div>
<div className={styles.world_title_right}>
{offline ? (
<span style={{ color: 'red', display: 'flex', alignItems: 'center', gap: 4 }}>
<PixelartIcon iconName="signal-off" width={12} />
Offline
</span>
) : worldNameRight}
</div>
</div>
{formattedTextOverride ? <div className={styles.world_info_formatted}>
<MessageFormattedString message={formattedTextOverride} />

View file

@ -1,7 +1,7 @@
import { useState } from 'react'
import { useSnapshot } from 'valtio'
import { hideCurrentModal } from '../globalState'
import { lastPlayedSounds } from '../soundSystem'
import { lastPlayedSounds } from '../sounds/botSoundSystem'
import { options } from '../optionsStorage'
import Button from './Button'
import Screen from './Screen'

View file

@ -2,6 +2,7 @@ import { CSSProperties, PointerEvent, useEffect, useRef } from 'react'
import { proxy, ref, useSnapshot } from 'valtio'
import { contro } from '../controls'
import worldInteractions from '../worldInteractions'
import { options } from '../optionsStorage'
import PixelartIcon from './PixelartIcon'
import Button from './Button'
@ -9,13 +10,6 @@ export type ButtonName = 'action' | 'sneak' | 'break' | 'jump'
type ButtonsPositions = Record<ButtonName, [number, number]>
interface Props {
touchActive: boolean
setupActive: boolean
buttonsPositions: ButtonsPositions
closeButtonsSetup: (newPositions?: ButtonsPositions) => void
}
const getCurrentAppScaling = () => {
// body has css property --guiScale
const guiScale = getComputedStyle(document.body).getPropertyValue('--guiScale')
@ -51,15 +45,23 @@ export const handleMovementStickDelta = (e?: { clientX, clientY }) => {
})
}
export default ({ touchActive, setupActive, buttonsPositions, closeButtonsSetup }: Props) => {
type Props = {
setupActive: boolean
closeButtonsSetup: (newPositions?: ButtonsPositions) => void
foregroundGameActive: boolean
}
const Z_INDEX_INTERACTIBLE = 8
export default ({ setupActive, closeButtonsSetup, foregroundGameActive }: Props) => {
const bot = window.bot as typeof __type_bot | undefined
if (setupActive) touchActive = true
const { touchControlsPositions, touchMovementType, touchInteractionType } = useSnapshot(options)
const buttonsPositions = touchControlsPositions as ButtonsPositions
const joystickOuter = useRef<HTMLDivElement>(null)
const joystickInner = useRef<HTMLDivElement>(null)
const { pointer } = useSnapshot(joystickPointer)
// const { isFlying, isSneaking } = useSnapshot(gameAdditionalState)
const newButtonPositions = { ...buttonsPositions }
const buttonProps = (name: ButtonName) => {
@ -146,6 +148,7 @@ export default ({ touchActive, setupActive, buttonsPositions, closeButtonsSetup
justifyContent: 'center',
alignItems: 'center',
transition: 'background 0.1s',
zIndex: Z_INDEX_INTERACTIBLE,
} satisfies CSSProperties,
onPointerDown (e: PType) {
const elem = e.currentTarget as HTMLElement
@ -162,8 +165,8 @@ export default ({ touchActive, setupActive, buttonsPositions, closeButtonsSetup
const elem = e.currentTarget as HTMLElement
const size = 32
const scale = getCurrentAppScaling()
const xPerc = (e.clientX - size / 4 / scale) / window.innerWidth * 100
const yPerc = (e.clientY - size / 4 / scale) / window.innerHeight * 100
const xPerc = (e.clientX - (size * scale) / 2) / window.innerWidth * 100
const yPerc = (e.clientY - (size * scale) / 2) / window.innerHeight * 100
elem.style.left = `${xPerc}%`
elem.style.top = `${yPerc}%`
newButtonPositions[name] = [xPerc, yPerc]
@ -178,55 +181,65 @@ export default ({ touchActive, setupActive, buttonsPositions, closeButtonsSetup
useEffect(() => {
joystickPointer.joystickInner = joystickInner.current && ref(joystickInner.current)
// todo antipattern
}, [touchActive])
}, [foregroundGameActive])
if (!touchActive) return null
if (!foregroundGameActive && !setupActive) return null
return <div>
<div
className='movement_joystick_outer'
ref={joystickOuter}
style={{
display: pointer ? 'flex' : 'none',
borderRadius: '50%',
width: 50,
height: 50,
border: '2px solid rgba(0, 0, 0, 0.5)',
backgroundColor: 'rgba(255, 255, div, 0.5)',
position: 'fixed',
justifyContent: 'center',
alignItems: 'center',
translate: '-50% -50%',
...pointer ? {
left: `${pointer.x / window.innerWidth * 100}%`,
top: `${pointer.y / window.innerHeight * 100}%`
} : {}
}}
>
{touchMovementType === 'modern' && (
<div
className='movement_joystick_inner'
className='movement_joystick_outer'
ref={joystickOuter}
style={{
display: pointer ? 'flex' : 'none',
borderRadius: '50%',
width: 20,
height: 20,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
position: 'absolute',
width: 50,
height: 50,
border: '2px solid rgba(0, 0, 0, 0.5)',
backgroundColor: 'rgba(255, 255, div, 0.5)',
position: 'fixed',
justifyContent: 'center',
alignItems: 'center',
translate: '-50% -50%',
...pointer ? {
left: `${pointer.x / window.innerWidth * 100}%`,
top: `${pointer.y / window.innerHeight * 100}%`
} : {}
}}
ref={joystickInner}
/>
</div>
<div {...buttonProps('action')}>
<PixelartIcon iconName='circle' />
</div>
<div {...buttonProps('sneak')}>
<PixelartIcon iconName='arrow-down' />
</div>
<div {...buttonProps('jump')}>
<PixelartIcon iconName='arrow-up' />
</div>
<div {...buttonProps('break')}>
<MineIcon />
</div>
>
<div
className='movement_joystick_inner'
style={{
borderRadius: '50%',
width: 20,
height: 20,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
position: 'absolute',
}}
ref={joystickInner}
/>
</div>
)}
{touchMovementType === 'modern' && (
<>
<div {...buttonProps('sneak')}>
<PixelartIcon iconName='arrow-down' />
</div>
<div {...buttonProps('jump')}>
<PixelartIcon iconName='arrow-up' />
</div>
</>
)}
{touchInteractionType === 'buttons' && (
<>
<div {...buttonProps('action')}>
<PixelartIcon iconName='circle' />
</div>
<div {...buttonProps('break')}>
<MineIcon />
</div>
</>
)}
{setupActive && <div style={{
position: 'fixed',
bottom: 0,

View file

@ -8,12 +8,10 @@ export default () => {
const usingTouch = useUsingTouch()
const hasModals = useSnapshot(activeModalStack).length !== 0
const setupActive = useIsModalActive('touch-buttons-setup')
const { touchControlsPositions, touchControlsType } = useSnapshot(options)
return <TouchAreasControls
touchActive={!!bot && !!usingTouch && !hasModals && touchControlsType === 'joystick-buttons'}
foregroundGameActive={!!bot && !!usingTouch && !hasModals}
setupActive={setupActive}
buttonsPositions={touchControlsPositions as any}
closeButtonsSetup={(newPositions) => {
if (newPositions) {
options.touchControlsPositions = newPositions
@ -21,5 +19,4 @@ export default () => {
hideModal()
}}
/>
}

View file

@ -49,9 +49,9 @@ export default () => {
const usingTouch = useUsingTouch()
const { usingGamepadInput } = useSnapshot(miscUiState)
const modals = useSnapshot(activeModalStack)
const { touchControlsType } = useSnapshot(options)
const { touchMovementType } = useSnapshot(options)
if (!usingTouch || usingGamepadInput || touchControlsType !== 'classic') return null
if (!usingTouch || usingGamepadInput || touchMovementType !== 'classic') return null
return (
<div
style={{ zIndex: modals.length ? 7 : 8 }}

View file

@ -7,6 +7,18 @@ export const useIsSmallWidth = () => {
return useMedia(SMALL_SCREEN_MEDIA.replace('@media ', ''))
}
export const usePassesWindowDimensions = (minWidth: number | null = null, minHeight: number | null = null) => {
let media = '('
if (minWidth !== null) {
media += `min-width: ${minWidth}px, `
}
if (minHeight !== null) {
media += `min-height: ${minHeight}px, `
}
media += ')'
return useMedia(media)
}
export const useCopyKeybinding = (getCopyText: () => string | undefined) => {
useUtilsEffect(({ signal }) => {
addEventListener('keydown', (e) => {

View file

@ -47,6 +47,7 @@ import { options } from './optionsStorage'
import BossBarOverlayProvider from './react/BossBarOverlayProvider'
import DebugEdges from './react/DebugEdges'
import LibraryVersions from './react/components/LibraryVersions'
import GameInteractionOverlay from './react/GameInteractionOverlay'
const RobustPortal = ({ children, to }) => {
return createPortal(<PerComponentErrorBoundary>{children}</PerComponentErrorBoundary>, to)
@ -117,6 +118,7 @@ const InGameUi = () => {
<RobustPortal to={document.querySelector('#ui-root')}>
{/* apply scaling */}
<div style={{ display: showUI ? 'block' : 'none' }}>
<GameInteractionOverlay zIndex={7} />
{!disabledUiParts.includes('death-screen') && <DeathScreenProvider />}
{!disabledUiParts.includes('debug-overlay') && <DebugOverlay />}
{!disabledUiParts.includes('mobile-top-buttons') && <MobileTopButtons />}

View file

@ -4,14 +4,14 @@ import fs from 'fs'
import JSZip from 'jszip'
import { proxy, subscribe } from 'valtio'
import { WorldRendererThree } from 'prismarine-viewer/viewer/lib/worldrendererThree'
import { mkdirRecursive, removeFileRecursiveAsync } from './browserfs'
import { collectFilesToCopy, copyFilesAsyncWithProgress, mkdirRecursive, removeFileRecursiveAsync } from './browserfs'
import { setLoadingScreenStatus } from './utils'
import { showNotification } from './react/NotificationProvider'
import { options } from './optionsStorage'
import { showOptionsModal } from './react/SelectOption'
import { appStatusState } from './react/AppStatusProvider'
import { appReplacableResources, resourcesContentOriginal } from './generated/resources'
import { loadedGameState, miscUiState } from './globalState'
import { gameAdditionalState, miscUiState } from './globalState'
import { watchUnloadForCleanup } from './gameUnload'
export const resourcePackState = proxy({
@ -32,7 +32,7 @@ const texturePackBasePath = '/data/resourcePacks/'
export const uninstallTexturePack = async (name = 'default') => {
if (await existsAsync('/resourcepack/pack.mcmeta')) {
await removeFileRecursiveAsync('/resourcepack')
loadedGameState.usingServerResourcePack = false
gameAdditionalState.usingServerResourcePack = false
}
const basePath = texturePackBasePath + name
if (!(await existsAsync(basePath))) return
@ -113,7 +113,7 @@ export const installTexturePack = async (file: File | ArrayBuffer, displayName =
done++
upStatus()
}))
console.log('done')
console.log('resource pack install done')
await completeTexturePackInstall(displayName, name, isServer)
}
@ -129,7 +129,7 @@ export const completeTexturePackInstall = async (displayName: string | undefined
showNotification('Texturepack installed & enabled')
await updateTexturePackInstalledState()
if (isServer) {
loadedGameState.usingServerResourcePack = true
gameAdditionalState.usingServerResourcePack = true
} else {
options.enabledResourcepack = name
}
@ -157,7 +157,7 @@ const getSizeFromImage = async (filePath: string) => {
return probeImg.width
}
export const getActiveTexturepackBasePath = async () => {
export const getActiveResourcepackBasePath = async () => {
if (await existsAsync('/resourcepack/pack.mcmeta')) {
return '/resourcepack'
}
@ -198,7 +198,7 @@ const getFilesMapFromDir = async (dir: string) => {
}
export const getResourcepackTiles = async (type: 'blocks' | 'items', existingTextures: string[]) => {
const basePath = await getActiveTexturepackBasePath()
const basePath = await getActiveResourcepackBasePath()
if (!basePath) return
let firstTextureSize: number | undefined
const namespaces = await fs.promises.readdir(join(basePath, 'assets'))
@ -282,7 +282,7 @@ const prepareBlockstatesAndModels = async () => {
viewer.world.customBlockStates = {}
viewer.world.customModels = {}
const usedTextures = new Set<string>()
const basePath = await getActiveTexturepackBasePath()
const basePath = await getActiveResourcepackBasePath()
if (!basePath) return
if (appStatusState.status) {
setLoadingScreenStatus('Reading resource pack blockstates and models')
@ -361,6 +361,7 @@ export const onAppLoad = () => {
customEvents.on('mineflayerBotCreated', () => {
// todo also handle resourcePack
const handleResourcePackRequest = async (packet) => {
console.log('Received resource pack request', packet)
if (options.serverResourcePacks === 'never') return
const promptMessagePacket = ('promptMessage' in packet && packet.promptMessage) ? packet.promptMessage : undefined
const promptMessageText = promptMessagePacket ? '' : 'Do you want to use server resource pack?'
@ -397,7 +398,7 @@ export const onAppLoad = () => {
}
const updateAllReplacableTextures = async () => {
const basePath = await getActiveTexturepackBasePath()
const basePath = await getActiveResourcepackBasePath()
const setCustomCss = async (path: string | null, varName: string, repeat = 1) => {
if (path && await existsAsync(path)) {
const contents = await fs.promises.readFile(path, 'base64')
@ -462,3 +463,29 @@ const updateTextures = async () => {
export const resourcepackReload = async (version) => {
await updateTextures()
}
export const copyServerResourcePackToRegular = async (name = 'default') => {
// Check if server resource pack exists
if (!(await existsAsync('/resourcepack/pack.mcmeta'))) {
throw new Error('No server resource pack is currently installed')
}
// Get display name from server resource pack if available
let displayName
try {
displayName = await fs.promises.readFile('/resourcepack/name.txt', 'utf8')
} catch {
displayName = 'Server Resource Pack'
}
// Copy all files from server resource pack to regular location
const destPath = texturePackBasePath + name
await mkdirRecursive(destPath)
setLoadingScreenStatus('Copying server resource pack to regular location')
await copyFilesAsyncWithProgress('/resourcepack', destPath, true, ' (server -> regular)')
// Complete the installation
await completeTexturePackInstall(displayName, name, false)
showNotification('Server resource pack copied to regular location')
}

View file

@ -2,6 +2,7 @@ import { isCypress } from './standaloneUtils'
// might not resolve at all
export const registerServiceWorker = async () => {
if (process.env.DISABLE_SERVICE_WORKER) return
if (!('serviceWorker' in navigator)) return
if (!isCypress() && process.env.NODE_ENV !== 'development') {
return new Promise<void>(resolve => {

View file

@ -1,50 +1,51 @@
import { subscribeKey } from 'valtio/utils'
import { Vec3 } from 'vec3'
import { versionToMajor, versionToNumber, versionsMapToMajor } from 'prismarine-viewer/viewer/prepare/utils'
import { versionToNumber } from 'prismarine-viewer/viewer/prepare/utils'
import { loadScript } from 'prismarine-viewer/viewer/lib/utils'
import type { Block } from 'prismarine-block'
import { miscUiState } from './globalState'
import { options } from './optionsStorage'
import { loadOrPlaySound } from './basicSounds'
import { showNotification } from './react/NotificationProvider'
import { subscribeKey } from 'valtio/utils'
import { miscUiState } from '../globalState'
import { options } from '../optionsStorage'
import { loadOrPlaySound } from '../basicSounds'
import { getActiveResourcepackBasePath, resourcePackState } from '../resourcePack'
import { createSoundMap, SoundMap } from './soundsMap'
import { musicSystem } from './musicSystem'
const globalObject = window as {
allSoundsMap?: Record<string, Record<string, string>>,
allSoundsVersionedMap?: Record<string, string[]>,
let soundMap: SoundMap | undefined
const updateResourcePack = async () => {
if (!soundMap) return
soundMap.activeResourcePackBasePath = await getActiveResourcepackBasePath() ?? undefined
}
let musicInterval: ReturnType<typeof setInterval> | null = null
subscribeKey(miscUiState, 'gameLoaded', async () => {
if (!miscUiState.gameLoaded) return
const soundsLegacyMap = window.allSoundsVersionedMap as Record<string, string[]>
const { allSoundsMap } = globalObject
const allSoundsMeta = window.allSoundsMeta as { format: string, baseUrl: string }
if (!allSoundsMap) {
if (!miscUiState.gameLoaded || !loadedData.sounds) {
stopMusicSystem()
soundMap?.quit()
return
}
const allSoundsMajor = versionsMapToMajor(allSoundsMap)
const soundsMap = allSoundsMajor[versionToMajor(bot.version)] ?? Object.values(allSoundsMajor)[0]
if (!soundsMap || !miscUiState.gameLoaded || !loadedData.sounds) {
return
}
// const soundsPerId = Object.fromEntries(Object.entries(soundsMap).map(([id, sound]) => [+id.split(';')[0], sound]))
const soundsPerName = Object.fromEntries(Object.entries(soundsMap).map(([id, sound]) => [id.split(';')[1], sound]))
console.log(`Loading sounds for version ${bot.version}. Resourcepack state: ${JSON.stringify(resourcePackState)}`)
soundMap = createSoundMap(bot.version) ?? undefined
if (!soundMap) return
void updateResourcePack()
startMusicSystem()
const playGeneralSound = async (soundKey: string, position?: Vec3, volume = 1, pitch?: number) => {
if (!options.volume) return
const soundStaticData = soundsPerName[soundKey]?.split(';')
if (!soundStaticData) return
const soundVolume = +soundStaticData[0]!
const soundPath = soundStaticData[1]!
const versionedSound = getVersionedSound(bot.version, soundPath, Object.entries(soundsLegacyMap))
// todo test versionedSound
const url = allSoundsMeta.baseUrl.replace(/\/$/, '') + (versionedSound ? `/${versionedSound}` : '') + '/minecraft/sounds/' + soundPath + '.' + allSoundsMeta.format
const isMuted = options.mutedSounds.includes(soundKey) || options.mutedSounds.includes(soundPath) || options.volume === 0
if (!options.volume || !soundMap) return
const soundData = await soundMap.getSoundUrl(soundKey, volume)
if (!soundData) return
const isMuted = options.mutedSounds.includes(soundKey) || options.volume === 0
if (position) {
if (!isMuted) {
viewer.playSound(position, url, soundVolume * Math.max(Math.min(volume, 1), 0) * (options.volume / 100), Math.max(Math.min(pitch ?? 1, 2), 0.5))
viewer.playSound(
position,
soundData.url,
soundData.volume * (options.volume / 100),
Math.max(Math.min(pitch ?? 1, 2), 0.5)
)
}
if (getDistance(bot.entity.position, position) < 4 * 16) {
lastPlayedSounds.lastServerPlayed[soundKey] ??= { count: 0, last: 0 }
@ -53,7 +54,7 @@ subscribeKey(miscUiState, 'gameLoaded', async () => {
}
} else {
if (!isMuted) {
await loadOrPlaySound(url, volume)
await loadOrPlaySound(soundData.url, volume)
}
lastPlayedSounds.lastClientPlayed.push(soundKey)
if (lastPlayedSounds.lastClientPlayed.length > 10) {
@ -61,84 +62,72 @@ subscribeKey(miscUiState, 'gameLoaded', async () => {
}
}
}
const musicStartCheck = async (force = false) => {
if (!soundMap) return
// 20% chance to start music
if (Math.random() > 0.2 && !force && !options.enableMusic) return
const musicKeys = ['music.game']
if (bot.game.gameMode === 'creative') {
musicKeys.push('music.creative')
}
const randomMusicKey = musicKeys[Math.floor(Math.random() * musicKeys.length)]
const soundData = await soundMap.getSoundUrl(randomMusicKey)
if (!soundData) return
await musicSystem.playMusic(soundData.url, soundData.volume)
}
function startMusicSystem () {
if (musicInterval) return
musicInterval = setInterval(() => {
void musicStartCheck()
}, 10_000)
}
window.forceStartMusic = () => {
void musicStartCheck(true)
}
function stopMusicSystem () {
if (musicInterval) {
clearInterval(musicInterval)
musicInterval = null
}
}
const playHardcodedSound = async (soundKey: string, position?: Vec3, volume = 1, pitch?: number) => {
await playGeneralSound(soundKey, position, volume, pitch)
}
bot.on('soundEffectHeard', async (soundId, position, volume, pitch) => {
await playHardcodedSound(soundId, position, volume, pitch)
})
bot.on('hardcodedSoundEffectHeard', async (soundIdNum, soundCategory, position, volume, pitch) => {
const fixOffset = versionToNumber('1.20.4') === versionToNumber(bot.version) ? -1 : 0
const soundKey = loadedData.sounds[soundIdNum + fixOffset]?.name
if (soundKey === undefined) return
await playGeneralSound(soundKey, position, volume, pitch)
})
// workaround as mineflayer doesn't support soundEvent
bot._client.on('sound_effect', async (packet) => {
const soundResource = packet['soundEvent']?.resource as string | undefined
if (packet.soundId !== 0 || !soundResource) return
const pos = new Vec3(packet.x / 8, packet.y / 8, packet.z / 8)
await playHardcodedSound(soundResource.replace('minecraft:', ''), pos, packet.volume, packet.pitch)
})
bot.on('entityHurt', async (entity) => {
if (entity.id === bot.entity.id) {
await playHardcodedSound('entity.player.hurt')
}
})
const useBlockSound = (blockName: string, category: string, fallback: string) => {
blockName = {
// todo somehow generated, not full
grass_block: 'grass',
tall_grass: 'grass',
fern: 'grass',
large_fern: 'grass',
dead_bush: 'grass',
seagrass: 'grass',
tall_seagrass: 'grass',
kelp: 'grass',
kelp_plant: 'grass',
sugar_cane: 'grass',
bamboo: 'grass',
vine: 'grass',
nether_sprouts: 'grass',
nether_wart: 'grass',
twisting_vines: 'grass',
weeping_vines: 'grass',
cobblestone: 'stone',
stone_bricks: 'stone',
mossy_stone_bricks: 'stone',
cracked_stone_bricks: 'stone',
chiseled_stone_bricks: 'stone',
stone_brick_slab: 'stone',
stone_brick_stairs: 'stone',
stone_brick_wall: 'stone',
polished_granite: 'stone',
}[blockName] ?? blockName
const key = 'block.' + blockName + '.' + category
return soundsPerName[key] ? key : fallback
}
const getStepSound = (blockUnder: Block) => {
// const soundsMap = globalObject.allSoundsMap?.[bot.version]
// if (!soundsMap) return
// let soundResult = 'block.stone.step'
// for (const x of Object.keys(soundsMap).map(n => n.split(';')[1])) {
// const match = /block\.(.+)\.step/.exec(x)
// const block = match?.[1]
// if (!block) continue
// if (loadedData.blocksByName[block]?.name === blockUnder.name) {
// soundResult = x
// break
// }
// }
return useBlockSound(blockUnder.name, 'step', 'block.stone.step')
}
let lastStepSound = 0
const movementHappening = async () => {
if (!bot.player) return // no info yet
if (!bot.player || !soundMap) return // no info yet
const VELOCITY_THRESHOLD = 0.1
const { x, z, y } = bot.player.entity.velocity
if (bot.entity.onGround && Math.abs(x) < VELOCITY_THRESHOLD && (Math.abs(z) > VELOCITY_THRESHOLD || Math.abs(y) > VELOCITY_THRESHOLD)) {
@ -146,9 +135,9 @@ subscribeKey(miscUiState, 'gameLoaded', async () => {
if (Date.now() - lastStepSound > 300) {
const blockUnder = bot.world.getBlock(bot.entity.position.offset(0, -1, 0))
if (blockUnder) {
const stepSound = getStepSound(blockUnder)
const stepSound = soundMap.getStepSound(blockUnder.name)
if (stepSound) {
await playHardcodedSound(stepSound, undefined, 0.6)// todo not sure why 0.6
await playHardcodedSound(stepSound, undefined, 0.6)
lastStepSound = Date.now()
}
}
@ -157,8 +146,8 @@ subscribeKey(miscUiState, 'gameLoaded', async () => {
}
const playBlockBreak = async (blockName: string, position?: Vec3) => {
const sound = useBlockSound(blockName, 'break', 'block.stone.break')
if (!soundMap) return
const sound = soundMap.getBreakSound(blockName)
await playHardcodedSound(sound, position, 0.6, 1)
}
@ -200,8 +189,8 @@ subscribeKey(miscUiState, 'gameLoaded', async () => {
if (effectId === 1010) {
console.log('play record', data)
}
// todo add support for all current world events
})
let diggingBlock: Block | null = null
customEvents.on('digStart', () => {
diggingBlock = bot.blockAtCursor(5)
@ -214,40 +203,14 @@ subscribeKey(miscUiState, 'gameLoaded', async () => {
}
registerEvents()
// 1.20+ soundEffectHeard is broken atm
// bot._client.on('packet', (data, { name }, buffer) => {
// if (name === 'sound_effect') {
// console.log(data, buffer)
// }
// })
})
// todo
// const music = {
// activated: false,
// playing: '',
// activate () {
// const gameMusic = Object.entries(globalObject.allSoundsMap?.[bot.version] ?? {}).find(([id, sound]) => sound.includes('music.game'))
// if (!gameMusic) return
// const soundPath = gameMusic[0].split(';')[1]
// const next = () => {}
// }
// }
const getVersionedSound = (version: string, item: string, itemsMapSortedEntries: Array<[string, string[]]>) => {
const verNumber = versionToNumber(version)
for (const [itemsVer, items] of itemsMapSortedEntries) {
// 1.18 < 1.18.1
// 1.13 < 1.13.2
if (items.includes(item) && verNumber <= versionToNumber(itemsVer)) {
return itemsVer
}
}
}
subscribeKey(resourcePackState, 'resourcePackInstalled', async () => {
await updateResourcePack()
})
export const downloadSoundsIfNeeded = async () => {
if (!globalObject.allSoundsMap) {
if (!window.allSoundsMap) {
try {
await loadScript('./sounds.js')
} catch (err) {

33
src/sounds/musicSystem.ts Normal file
View file

@ -0,0 +1,33 @@
import { loadOrPlaySound } from '../basicSounds'
import { options } from '../optionsStorage'
class MusicSystem {
private currentMusic: string | null = null
async playMusic (url: string, musicVolume = 1) {
if (!options.enableMusic || this.currentMusic) return
try {
const { onEnded } = await loadOrPlaySound(url, 0.5 * musicVolume, 5000) ?? {}
if (!onEnded) return
this.currentMusic = url
onEnded(() => {
this.currentMusic = null
})
} catch (err) {
console.warn('Failed to play music:', err)
this.currentMusic = null
}
}
stopMusic () {
if (this.currentMusic) {
this.currentMusic = null
}
}
}
export const musicSystem = new MusicSystem()

347
src/sounds/soundsMap.ts Normal file
View file

@ -0,0 +1,347 @@
import fs from 'fs'
import path from 'path'
import { versionsMapToMajor, versionToMajor, versionToNumber } from 'prismarine-viewer/viewer/prepare/utils'
import { stopAllSounds } from '../basicSounds'
import { musicSystem } from './musicSystem'
interface SoundMeta {
format: string
baseUrl: string
}
interface SoundData {
volume: number
path: string
}
interface SoundMapData {
allSoundsMap: Record<string, Record<string, string>>
soundsLegacyMap: Record<string, string[]>
soundsMeta: SoundMeta
}
interface BlockSoundMap {
[blockName: string]: string
}
interface SoundEntry {
file: string
weight: number
volume: number
}
export class SoundMap {
private readonly soundsPerName: Record<string, SoundEntry[]>
private readonly existingResourcePackPaths: Set<string>
public activeResourcePackBasePath: string | undefined
constructor (
private readonly soundData: SoundMapData,
private readonly version: string
) {
const allSoundsMajor = versionsMapToMajor(soundData.allSoundsMap)
const soundsMap = allSoundsMajor[versionToMajor(version)] ?? Object.values(allSoundsMajor)[0]
this.soundsPerName = Object.fromEntries(
Object.entries(soundsMap).map(([id, soundsStr]) => {
const sounds = soundsStr.split(',').map(s => {
const [volume, name, weight] = s.split(';')
if (isNaN(Number(volume))) throw new Error('volume is not a number')
if (isNaN(Number(weight))) {
// debugger
throw new TypeError('weight is not a number')
}
return {
file: name,
weight: Number(weight),
volume: Number(volume)
}
})
return [id.split(';')[1], sounds]
})
)
}
async updateExistingResourcePackPaths () {
if (!this.activeResourcePackBasePath) return
// todo support sounds.js from resource pack
const soundsBasePath = path.join(this.activeResourcePackBasePath, 'assets/minecraft/sounds')
// scan recursively for sounds files
const scan = async (dir: string) => {
const entries = await fs.promises.readdir(dir, { withFileTypes: true })
for (const entry of entries) {
const entryPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
await scan(entryPath)
} else if (entry.isFile() && entry.name.endsWith('.ogg')) {
const relativePath = path.relative(soundsBasePath, entryPath)
this.existingResourcePackPaths.add(relativePath)
}
}
}
await scan(soundsBasePath)
}
async getSoundUrl (soundKey: string, volume = 1): Promise<{ url: string; volume: number } | undefined> {
const sounds = this.soundsPerName[soundKey]
if (!sounds?.length) return undefined
// Pick a random sound based on weights
const totalWeight = sounds.reduce((sum, s) => sum + s.weight, 0)
let random = Math.random() * totalWeight
const sound = sounds.find(s => {
random -= s.weight
return random <= 0
}) ?? sounds[0]
const versionedSound = this.getVersionedSound(sound.file)
let url = this.soundData.soundsMeta.baseUrl.replace(/\/$/, '') +
(versionedSound ? `/${versionedSound}` : '') +
'/minecraft/sounds/' +
sound.file +
'.' +
this.soundData.soundsMeta.format
// Try loading from resource pack file first
if (this.activeResourcePackBasePath) {
const tryFormat = async (format: string) => {
try {
const resourcePackPath = path.join(this.activeResourcePackBasePath!, `/assets/minecraft/sounds/${sound.file}.${format}`)
const fileData = await fs.promises.readFile(resourcePackPath)
url = `data:audio/${format};base64,${fileData.toString('base64')}`
return true
} catch (err) {
}
}
const success = await tryFormat(this.soundData.soundsMeta.format)
if (!success && this.soundData.soundsMeta.format !== 'ogg') {
await tryFormat('ogg')
}
}
return {
url,
volume: sound.volume * Math.max(Math.min(volume, 1), 0)
}
}
private getVersionedSound (item: string): string | undefined {
const verNumber = versionToNumber(this.version)
const entries = Object.entries(this.soundData.soundsLegacyMap)
for (const [itemsVer, items] of entries) {
if (items.includes(item) && verNumber <= versionToNumber(itemsVer)) {
return itemsVer
}
}
return undefined
}
getBlockSound (blockName: string, category: string, fallback: string): string {
const mappedName = blockSoundAliases[blockName] ?? blockName
const key = `block.${mappedName}.${category}`
return this.soundsPerName[key] ? key : fallback
}
getStepSound (blockName: string): string {
return this.getBlockSound(blockName, 'step', 'block.stone.step')
}
getBreakSound (blockName: string): string {
return this.getBlockSound(blockName, 'break', 'block.stone.break')
}
quit () {
musicSystem.stopMusic()
stopAllSounds()
}
}
export function createSoundMap (version: string): SoundMap | null {
const globalObject = window as {
allSoundsMap?: Record<string, Record<string, string>>,
allSoundsVersionedMap?: Record<string, string[]>,
allSoundsMeta?: { format: string, baseUrl: string }
}
if (!globalObject.allSoundsMap) return null
return new SoundMap({
allSoundsMap: globalObject.allSoundsMap,
soundsLegacyMap: globalObject.allSoundsVersionedMap ?? {},
soundsMeta: globalObject.allSoundsMeta!
}, version)
}
// Block name mappings for sound effects
const blockSoundAliases: BlockSoundMap = {
// Grass-like blocks
grass_block: 'grass',
tall_grass: 'grass',
fern: 'grass',
large_fern: 'grass',
dead_bush: 'grass',
seagrass: 'grass',
tall_seagrass: 'grass',
kelp: 'grass',
kelp_plant: 'grass',
sugar_cane: 'grass',
bamboo: 'grass',
vine: 'grass',
nether_sprouts: 'grass',
nether_wart: 'grass',
twisting_vines: 'grass',
weeping_vines: 'grass',
sweet_berry_bush: 'grass',
glow_lichen: 'grass',
moss_carpet: 'grass',
moss_block: 'grass',
hanging_roots: 'grass',
spore_blossom: 'grass',
small_dripleaf: 'grass',
big_dripleaf: 'grass',
flowering_azalea: 'grass',
azalea: 'grass',
azalea_leaves: 'grass',
flowering_azalea_leaves: 'grass',
// Stone-like blocks
cobblestone: 'stone',
stone_bricks: 'stone',
mossy_stone_bricks: 'stone',
cracked_stone_bricks: 'stone',
chiseled_stone_bricks: 'stone',
stone_brick_slab: 'stone',
stone_brick_stairs: 'stone',
stone_brick_wall: 'stone',
polished_granite: 'stone',
granite: 'stone',
andesite: 'stone',
diorite: 'stone',
polished_andesite: 'stone',
polished_diorite: 'stone',
deepslate: 'deepslate',
cobbled_deepslate: 'deepslate',
polished_deepslate: 'deepslate',
deepslate_bricks: 'deepslate_bricks',
deepslate_tiles: 'deepslate_tiles',
calcite: 'stone',
tuff: 'stone',
smooth_stone: 'stone',
smooth_sandstone: 'stone',
smooth_quartz: 'stone',
smooth_red_sandstone: 'stone',
// Wood-like blocks
oak_planks: 'wood',
spruce_planks: 'wood',
birch_planks: 'wood',
jungle_planks: 'wood',
acacia_planks: 'wood',
dark_oak_planks: 'wood',
crimson_planks: 'wood',
warped_planks: 'wood',
oak_log: 'wood',
spruce_log: 'wood',
birch_log: 'wood',
jungle_log: 'wood',
acacia_log: 'wood',
dark_oak_log: 'wood',
crimson_stem: 'stem',
warped_stem: 'stem',
// Metal blocks
iron_block: 'metal',
gold_block: 'metal',
copper_block: 'copper',
exposed_copper: 'copper',
weathered_copper: 'copper',
oxidized_copper: 'copper',
netherite_block: 'netherite_block',
ancient_debris: 'ancient_debris',
lodestone: 'lodestone',
chain: 'chain',
anvil: 'anvil',
chipped_anvil: 'anvil',
damaged_anvil: 'anvil',
// Glass blocks
glass: 'glass',
glass_pane: 'glass',
white_stained_glass: 'glass',
orange_stained_glass: 'glass',
magenta_stained_glass: 'glass',
light_blue_stained_glass: 'glass',
yellow_stained_glass: 'glass',
lime_stained_glass: 'glass',
pink_stained_glass: 'glass',
gray_stained_glass: 'glass',
light_gray_stained_glass: 'glass',
cyan_stained_glass: 'glass',
purple_stained_glass: 'glass',
blue_stained_glass: 'glass',
brown_stained_glass: 'glass',
green_stained_glass: 'glass',
red_stained_glass: 'glass',
black_stained_glass: 'glass',
tinted_glass: 'glass',
// Wool blocks
white_wool: 'wool',
orange_wool: 'wool',
magenta_wool: 'wool',
light_blue_wool: 'wool',
yellow_wool: 'wool',
lime_wool: 'wool',
pink_wool: 'wool',
gray_wool: 'wool',
light_gray_wool: 'wool',
cyan_wool: 'wool',
purple_wool: 'wool',
blue_wool: 'wool',
brown_wool: 'wool',
green_wool: 'wool',
red_wool: 'wool',
black_wool: 'wool',
// Nether blocks
netherrack: 'netherrack',
nether_bricks: 'nether_bricks',
red_nether_bricks: 'nether_bricks',
nether_wart_block: 'wart_block',
warped_wart_block: 'wart_block',
soul_sand: 'soul_sand',
soul_soil: 'soul_soil',
basalt: 'basalt',
polished_basalt: 'basalt',
blackstone: 'gilded_blackstone',
gilded_blackstone: 'gilded_blackstone',
// Amethyst blocks
amethyst_block: 'amethyst_block',
amethyst_cluster: 'amethyst_cluster',
large_amethyst_bud: 'large_amethyst_bud',
medium_amethyst_bud: 'medium_amethyst_bud',
small_amethyst_bud: 'small_amethyst_bud',
// Miscellaneous
sand: 'sand',
red_sand: 'sand',
gravel: 'gravel',
snow: 'snow',
snow_block: 'snow',
powder_snow: 'powder_snow',
ice: 'glass',
packed_ice: 'glass',
blue_ice: 'glass',
slime_block: 'slime_block',
honey_block: 'honey_block',
scaffolding: 'scaffolding',
ladder: 'ladder',
lantern: 'lantern',
soul_lantern: 'lantern',
pointed_dripstone: 'pointed_dripstone',
dripstone_block: 'dripstone_block',
rooted_dirt: 'rooted_dirt',
sculk_sensor: 'sculk_sensor',
shroomlight: 'shroomlight'
}

8
src/sounds/testSounds.ts Normal file
View file

@ -0,0 +1,8 @@
import { createSoundMap } from './soundsMap'
//@ts-expect-error
globalThis.window = {}
require('../../generated/sounds.js')
const soundMap = createSoundMap('1.20.1')
console.log(soundMap?.getSoundUrl('ambient.cave'))

View file

@ -6,6 +6,7 @@ import * as THREE from 'three'
import { Vec3 } from 'vec3'
import { LineMaterial } from 'three-stdlib'
import { Entity } from 'prismarine-entity'
import { subscribeKey } from 'valtio/utils'
import destroyStage0 from '../assets/destroy_stage_0.png'
import destroyStage1 from '../assets/destroy_stage_1.png'
import destroyStage2 from '../assets/destroy_stage_2.png'
@ -149,7 +150,16 @@ class WorldInteraction {
const inCreative = bot.game.gameMode === 'creative'
const pixelRatio = viewer.renderer.getPixelRatio()
viewer.world.threejsCursorLineMaterial = new LineMaterial({
color: inCreative ? 0x40_80_ff : 0x00_00_00,
color: (() => {
switch (options.highlightBlockColor) {
case 'blue':
return 0x40_80_ff
case 'classic':
return 0x00_00_00
default:
return inCreative ? 0x40_80_ff : 0x00_00_00
}
})(),
linewidth: Math.max(pixelRatio * 0.7, 1) * 2,
// dashed: true,
// dashSize: 5,
@ -158,6 +168,8 @@ class WorldInteraction {
upLineMaterial()
// todo use gamemode update only
bot.on('game', upLineMaterial)
// Update material when highlight color setting changes
subscribeKey(options, 'highlightBlockColor', upLineMaterial)
}
activateEntity (entity: Entity) {
@ -189,9 +201,18 @@ class WorldInteraction {
}
}
beforeUpdateChecks () {
if (!document.hasFocus()) {
// deactive all buttson
this.buttons.fill(false)
}
}
// todo this shouldnt be done in the render loop, migrate the code to dom events to avoid delays on lags
update () {
this.beforeUpdateChecks()
const inSpectator = bot.game.gameMode === 'spectator'
const inAdventure = bot.game.gameMode === 'adventure'
const entity = getEntityCursor()
let cursorBlock = inSpectator && !options.showCursorBlockInSpectator ? null : bot.blockAtCursor(5)
if (entity) {
@ -199,7 +220,7 @@ class WorldInteraction {
}
let cursorBlockDiggable = cursorBlock
if (cursorBlock && !bot.canDigBlock(cursorBlock) && bot.game.gameMode !== 'creative') cursorBlockDiggable = null
if (cursorBlock && (!bot.canDigBlock(cursorBlock) || inAdventure) && bot.game.gameMode !== 'creative') cursorBlockDiggable = null
const cursorChanged = cursorBlock && viewer.world.cursorBlock ? !viewer.world.cursorBlock.equals(cursorBlock.position) : viewer.world.cursorBlock !== cursorBlock
@ -393,6 +414,43 @@ const getDataFromShape = (shape) => {
return { position, width, height, depth }
}
// Blocks that can be interacted with in adventure mode
const activatableBlockPatterns = [
// Containers
/^(chest|barrel|hopper|dispenser|dropper)$/,
/^.*shulker_box$/,
/^.*(furnace|smoker)$/,
/^(brewing_stand|beacon)$/,
// Crafting
/^.*table$/,
/^(grindstone|stonecutter|loom)$/,
/^.*anvil$/,
// Redstone
/^(lever|repeater|comparator|daylight_detector|observer|note_block|jukebox|bell)$/,
// Buttons
/^.*button$/,
// Doors and trapdoors
/^.*door$/,
/^.*trapdoor$/,
// Functional blocks
/^(enchanting_table|lectern|composter|respawn_anchor|lodestone|conduit)$/,
/^.*bee.*$/,
// Beds
/^.*bed$/,
// Misc
/^(cake|decorated_pot|crafter|trial_spawner|vault)$/
]
function isBlockActivatable (blockName: string) {
return activatableBlockPatterns.some(pattern => pattern.test(blockName))
}
function isLookingAtActivatableBlock () {
const cursorBlock = bot.blockAtCursor(5)
if (!cursorBlock) return false
return isBlockActivatable(cursorBlock.name)
}
export const getEntityCursor = () => {
const entity = bot.nearestEntity((e) => {
if (e.position.distanceTo(bot.entity.position) <= (bot.game.gameMode === 'creative' ? 5 : 3)) {