Next month release (#240)

This commit is contained in:
Vitaly 2024-12-21 00:29:57 +03:00 committed by GitHub
commit 0896b61df0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 1081 additions and 214 deletions

View file

@ -2,23 +2,27 @@
![banner](./docs-assets/banner.jpg)
A true Minecraft client running in your browser! A port of the original game to the web, written in JavaScript using the best modern web technologies.
Minecraft **clone** rewritten in TypeScript using the best modern web technologies. Minecraft vanilla-compatible client and integrated server packaged into a single web app.
You can try this out at [mcraft.fun](https://mcraft.fun/), [pcm.gg](https://pcm.gg) (short link) [mcon.vercel.app](https://mcon.vercel.app/) or the GitHub pages deploy. Every commit from the `develop` (default) branch is deployed to [s.mcraft.fun](https://s.mcraft.fun/) - so it's usually newer, but might be less stable.
You can try this out at [mcraft.fun](https://mcraft.fun/), [pcm.gg](https://pcm.gg) (short link), [mcon.vercel.app](https://mcon.vercel.app/) or the GitHub pages deploy. Every commit from the default (`develop`) branch is deployed to [s.mcraft.fun](https://s.mcraft.fun/) and [s.pcm.gg](https://s.pcm.gg/) - so it's usually newer, but might be less stable.
For building the project yourself / contributing, see [Development, Debugging & Contributing](#development-debugging--contributing). If you encounter any bugs or usability issues, please report them!
Don't confuse with [Eaglercraft](https://git.eaglercraft.rip/eaglercraft/eaglercraft-1.8) which is a REAL vanilla Minecraft Java edition port to the web (but with its own limitations). Eaglercraft is a fully playable solution, but this project is more in position of a "technical demo" to show how it's possible to build games for web at scale entirely with the JS ecosystem. Have fun!
For building the project yourself / contributing, see [Development, Debugging & Contributing](#development-debugging--contributing). For reference at what and how web technologies / frameworks are used, see [TECH.md](./TECH.md).
### Big Features
- Open any zip world file or even folder in read-write mode!
- Connect to Java servers running in both offline (cracked) and online mode* (it's possible because of proxy servers, see below)
- Integrated JS server capable of opening Java world saves in any way (folders, zip, web streaming, etc)
- Singleplayer mode with simple world generations!
- Google Drive support for reading / saving worlds
- Works offline
- Play with friends over internet! (P2P is powered by Peer.js discovery servers)
- First-class touch (mobile) & controller support
- First-class keybindings configuration
- Basic Resource pack support: Custom GUI, all textures. Server resource packs are not supported yet.
- Builtin JEI with recipes & guides for every item (also replaces creative inventory)
- Builtin JEI with recipes & descriptions for every item (JEI is creative inventory replacement)
- Play with friends over internet! (P2P is powered by Peer.js discovery servers)
- ~~Google Drive support for reading / saving worlds back to the cloud~~
- even even more!
All components that are in [Storybook](https://mcraft.fun/storybook) are published as npm module and can be used in other projects: [`minecraft-react`](https://npmjs.com/minecraft-react)
@ -131,6 +135,7 @@ There are some parameters you can set in the url to archive some specific behavi
General:
- **`?setting=<setting_name>:<setting_value>`** - Set and lock the setting on load. You can set multiple settings by separating them with `&` e.g. `?setting=autoParkour:true&setting=renderDistance:4`
- `?modal=<modal>` - Open specific modal on page load eg `keybindings`. Very useful on UI changes testing during dev. For path use `,` as separator. To get currently opened modal type this in the console: `activeModalStack.at(-1).reactType`
Server specific:
@ -140,7 +145,6 @@ Server specific:
- `?proxy=<proxy_address>` - Set the proxy server address to use for the server
- `?username=<username>` - Set the username for the server
- `?lockConnect=true` - Only works then `ip` parameter is set. Disables cancel/save buttons and all inputs in the connect screen already set as parameters. Useful for integrates iframes.
- `?reconnect=true` - Reconnect to the server on page reloads. Available in **dev mode only** and very useful on server testing.
- `?serversList=<list_or_url>` - `<list_or_url>` can be a list of servers in the format `ip:version,ip` or a url to a json file with the same format (array) or a txt file with line-delimited list of server IPs.
Single player specific:
@ -174,6 +178,10 @@ In this case you must use `?mapDirBaseUrl` to specify the base URL to fetch the
- `?mapDirBaseUrl` - See above.
Only during development:
- `?reconnect=true` - Reconnect to the server on page reloads. Very useful on server testing.
<!-- - `?mapDirGuess=<base_url>` - Load the map from the provided URL and paths will be guessed with a few additional fetch requests. -->
### Notable Things that Power this Project

57
TECH.md Normal file
View file

@ -0,0 +1,57 @@
### Eaglercraft Comparison
This project uses proxies so you can connect to almost any vanilla server. Though proxies have some limitations such as increased latency and servers will complain about using VPN (though we have a workaround for that, but ping will be much higher).
This client generally has better performance but some features reproduction might be inaccurate eg its less stable and more buggy in some cases.
| Feature | This project | Eaglercraft | Description |
| --------------------------------- | ------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| General | | | |
| Mobile Support (touch) | ✅(+) | ✅ | |
| Gamepad Support | ✅ | ❌ | |
| A11Y | ✅ | ❌ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page (but maybe it's not needed) |
| Game Features | | | |
| Servers Support (quality) | ❌ | ✅ | Eaglercraft is vanilla Minecraft, while this project tries to emulate original game behavior at protocol level (Mineflayer is used) |
| Servers Support (any version, ip) | ✅ | ❌ | We support almost all Minecraft versions, only important if you connect to a server where you need new content like blocks or if you play with friends. And you can connect to almost any server using proxy servers! |
| Singleplayer Survival Features | ❌ | ✅ | Just like Eaglercraft this project can generate and save worlds, but generator is simple and only a few survival features are supported (look here for [supported features list](https://github.com/zardoy/space-squid)) |
| Singleplayer Maps | ✅ | ✅ | We support any version, but adventure maps won't work, but simple parkour and build maps might be interesting to explore... |
| Singleplayer Maps World Streaming | ✅ | ❌ | Thanks to Browserfs, saves can be loaded to local singleplayer server using multiple ways: from local folder, server directory (not zip), dropbox or other cloud *backend* etc... |
| P2P Multiplayer | ✅ | ✅ | A way to connect to other browser running the project. But it's almost useless here since many survival features are not implemented. Maybe only to build / explore maps together... |
| Voice Chat | ❌ | ✅ | Eaglercraft has custom WebRTC voice chat implementation, though it could also be easily implemented there |
| Online Servers | ✅ | ❌ | We have custom implementation (including integration on proxy side) for joining to servers |
| Plugin Features | ✅ | ❌ | We have Mineflayer plugins support, like Auto Jump & Auto Parkour was added here that way |
| Direct Connection | ❌ | ✅ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page |
| Mods | ❌(roadmap) | ❌ | This project will support mods for singleplayer. In theory its possible to implement support for modded servers on protocol level (including all needed mods) |
| Video Recording | ❌ | ✅ | Don't feel needed |
| Metaverse Features | ❌(roadmap) | ❌ | Iframes, video streams inside of game world (custom protocol channel) |
| Sounds | ✅ | ✅ | |
| Resource Packs | ✅(--) | ✅ | This project has very limited support for them (only textures images are loadable for now) |
| Assets Compressing & Splitting | ✅ | ❌ | We have advanced Minecraft data processing and good code chunk splitting so the web app will open much faster and use less memory |
| Graphics | | | |
| Fancy Graphics | ❌ | ✅ | While Eaglercraft has top-level shaders we don't even support lighting |
| Fast & Efficient Graphics | ❌(+) | ❌ | Feels like no one needs to have 64 rendering distance work smoothly |
| VR | ✅ | ❌ | Feels like not needed feature. UI is missing in this project since DOM can't be rendered in VR so Eaglercraft could be better in that aspect |
| AR | ❌ | ❌ | Would be the most useless feature |
| Minimap & Waypoints | ✅(-) | ❌ | We have buggy minimap, which can be enabled in settings and full map is opened by pressing `M` key |
Features available to only this project:
- CSS & JS Customization
- JS Real Time Debugging & Console Scripting (eg Devtools)
### Tech Stack
Bundler: Rsbuild!
UI: powered by React and css modules. Storybook helps with UI development.
### Rare WEB Features
There are a number of web features that are not commonly used but you might be interested in them if you decide to build your own game in the web.
TODO
| API | Usage & Description |
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `Crypto` API | Used to make chat features work when joining online servers with authentication. |
| `requestPointerLock({ unadjustedMovement: true })` API | Required for games. Disables system mouse acceleration (important for Mac users). Aka mouse raw input |
| `navigator.keyboard.lock()` | (only in Chromium browsers) When entering fullscreen it allows to use any key combination like ctrl+w in the game |
| `navigator.keyboard.getLayoutMap()` | (only in Chromium browsers) To display the right keyboard symbol for the key keybinding on different keyboard layouts (e.g. QWERTY vs AZERTY) |

View file

@ -68,7 +68,7 @@
"esbuild-plugin-polyfill-node": "^0.3.0",
"express": "^4.18.2",
"filesize": "^10.0.12",
"flying-squid": "npm:@zardoy/flying-squid@^0.0.49",
"flying-squid": "npm:@zardoy/flying-squid@^0.0.51",
"fs-extra": "^11.1.1",
"google-drive-browserfs": "github:zardoy/browserfs#google-drive",
"jszip": "^3.10.1",
@ -142,7 +142,7 @@
"http-browserify": "^1.7.0",
"http-server": "^14.1.1",
"https-browserify": "^1.0.0",
"mc-assets": "^0.2.23",
"mc-assets": "^0.2.26",
"minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next",
"mineflayer": "github:zardoy/mineflayer",
"mineflayer-pathfinder": "^2.4.4",

20
pnpm-lock.yaml generated
View file

@ -119,8 +119,8 @@ importers:
specifier: ^10.0.12
version: 10.0.12
flying-squid:
specifier: npm:@zardoy/flying-squid@^0.0.49
version: '@zardoy/flying-squid@0.0.49(encoding@0.1.13)'
specifier: npm:@zardoy/flying-squid@^0.0.51
version: '@zardoy/flying-squid@0.0.51(encoding@0.1.13)'
fs-extra:
specifier: ^11.1.1
version: 11.1.1
@ -346,8 +346,8 @@ importers:
specifier: ^1.0.0
version: 1.0.0
mc-assets:
specifier: ^0.2.23
version: 0.2.23
specifier: ^0.2.26
version: 0.2.26
minecraft-inventory-gui:
specifier: github:zardoy/minecraft-inventory-gui#next
version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/75e940a4cd50d89e0ba03db3733d5d704917a3c8(@types/react@18.2.20)(react@18.2.0)
@ -3401,8 +3401,8 @@ packages:
resolution: {integrity: sha512-6xm38yGVIa6mKm/DUCF2zFFJhERh/QWp1ufm4cNUvxsONBmfPg8uZ9pZBdOmF6qFGr/HlT6ABBkCSx/dlEtvWg==}
engines: {node: '>=12 <14 || 14.2 - 14.9 || >14.10.0'}
'@zardoy/flying-squid@0.0.49':
resolution: {integrity: sha512-Kt4wr5/R+44tcLU9gjuNG2an9weWeKEpIoKXfsgJN2GGQqdnbd5nBpxfGDdgZ9aMdFugsVW8BsyPZNhj9vbMXA==}
'@zardoy/flying-squid@0.0.51':
resolution: {integrity: sha512-HHZ79H9NkS44lL9vk6gVEuJDJqj88gpiBt9Ihh5p4rHXTVbRid95riiNK5dD0kHI94P5/DXdtNalvmJDPU86oQ==}
engines: {node: '>=8'}
hasBin: true
@ -6582,8 +6582,8 @@ packages:
peerDependencies:
react: ^18.2.0
mc-assets@0.2.23:
resolution: {integrity: sha512-sLbPhsSOYdW8nYllIyPZbVPnLu7V3bZTgIO4mI4nlG525q17NIbUNEjItHKtdi60u0vI6qLgHKjf0CoNRqa/Nw==}
mc-assets@0.2.26:
resolution: {integrity: sha512-BDrdD/kAMuVvD18nnvukE9StddL1VokParxSlFSRQdAAQmqTuYZlC19rho/SjYb+dBGZSVxwC+e0hZnSuyP9hA==}
engines: {node: '>=18.0.0'}
md5-file@4.0.0:
@ -13431,7 +13431,7 @@ snapshots:
'@types/emscripten': 1.39.8
tslib: 1.14.1
'@zardoy/flying-squid@0.0.49(encoding@0.1.13)':
'@zardoy/flying-squid@0.0.51(encoding@0.1.13)':
dependencies:
'@tootallnate/once': 2.0.0
chalk: 5.3.0
@ -17453,7 +17453,7 @@ snapshots:
dependencies:
react: 18.2.0
mc-assets@0.2.23: {}
mc-assets@0.2.26: {}
md5-file@4.0.0: {}

View file

@ -24,6 +24,7 @@ window.THREE = THREE
export class BasePlaygroundScene {
continuousRender = false
stopRender = false
guiParams = {}
viewDistance = 0
targetPos = new Vec3(2, 90, 2)
@ -49,6 +50,15 @@ export class BasePlaygroundScene {
windowHidden = false
world: ReturnType<typeof getSyncWorld>
_worldConfig = defaultWorldRendererConfig
get worldConfig () {
return this._worldConfig
}
set worldConfig (value) {
this._worldConfig = value
viewer.world.config = value
}
constructor () {
void this.initData().then(() => {
this.addKeyboardShortcuts()
@ -56,16 +66,19 @@ export class BasePlaygroundScene {
}
onParamsUpdate (paramName: string, object: any) {}
updateQs () {
updateQs (paramName: string, valueSet: any) {
if (this.skipUpdateQs) return
const oldQs = new URLSearchParams(window.location.search)
const newQs = new URLSearchParams()
if (oldQs.get('scene')) {
newQs.set('scene', oldQs.get('scene')!)
}
for (const [key, value] of Object.entries(this.params)) {
if (!value || typeof value === 'function' || this.params.skipQs?.includes(key) || this.alwaysIgnoreQs.includes(key)) continue
newQs.set(key, value)
const newQs = new URLSearchParams(window.location.search)
// if (oldQs.get('scene')) {
// newQs.set('scene', oldQs.get('scene')!)
// }
for (const [key, value] of Object.entries({ [paramName]: valueSet })) {
if (typeof value === 'function' || this.params.skipQs?.includes(key) || this.alwaysIgnoreQs.includes(key)) continue
if (value) {
newQs.set(key, value)
} else {
newQs.delete(key)
}
}
window.history.replaceState({}, '', `${window.location.pathname}?${newQs.toString()}`)
}
@ -89,7 +102,9 @@ export class BasePlaygroundScene {
if (option?.hide) continue
this.gui.add(this.params, param, option?.options ?? option?.min, option?.max)
}
this.gui.open(false)
if (window.innerHeight < 700) {
this.gui.open(false)
}
this.gui.onChange(({ property, object }) => {
if (object === this.params) {
@ -101,16 +116,18 @@ export class BasePlaygroundScene {
window.location.reload()
})
}
this.updateQs(property, value)
} else {
this.onParamsUpdate(property, object)
}
this.updateQs()
})
}
// mainChunk: import('prismarine-chunk/types/index').PCChunk
// overridables
setupWorld () { }
sceneReset () {}
// eslint-disable-next-line max-params
addWorldBlock (xOffset: number, yOffset: number, zOffset: number, blockName: BlockNames, properties?: Record<string, any>) {
@ -159,8 +176,9 @@ export class BasePlaygroundScene {
renderer.setSize(window.innerWidth, window.innerHeight)
// Create viewer
const viewer = new Viewer(renderer, { ...defaultWorldRendererConfig, numWorkers: 6 })
const viewer = new Viewer(renderer, this.worldConfig)
window.viewer = viewer
window.world = window.viewer.world
const isWebgpu = false
const promises = [] as Array<Promise<void>>
if (isWebgpu) {
@ -269,12 +287,14 @@ export class BasePlaygroundScene {
loop () {
if (this.continuousRender && !this.windowHidden) {
this.render()
this.render(true)
requestAnimationFrame(() => this.loop())
}
}
render () {
render (fromLoop = false) {
if (!fromLoop && this.continuousRender) return
if (this.stopRender) return
statsStart()
viewer.render()
statsEnd()
@ -287,8 +307,13 @@ export class BasePlaygroundScene {
this.controls?.reset()
this.resetCamera()
}
if (e.code === 'KeyE') {
worldView?.setBlockStateId(this.targetPos, this.world.getBlockStateId(this.targetPos))
if (e.code === 'KeyE') { // refresh block (main)
worldView!.setBlockStateId(this.targetPos, this.world.getBlockStateId(this.targetPos))
}
if (e.code === 'KeyF') { // reload all chunks
this.sceneReset()
worldView!.unloadAllChunks()
void worldView!.init(this.targetPos)
}
}
})

View file

@ -1,3 +1,4 @@
import { Vec3 } from 'vec3'
import { BasePlaygroundScene } from '../baseScene'
export default class extends BasePlaygroundScene {
@ -6,69 +7,125 @@ export default class extends BasePlaygroundScene {
override initGui (): void {
this.params = {
squareSize: 50
testActive: false,
testUpdatesPerSecond: 10,
testInitialUpdate: false,
stopGeometryUpdate: false,
manualTest: () => {
this.updateBlock()
},
testNeighborUpdates: () => {
this.testNeighborUpdates()
}
}
super.initGui()
}
lastUpdatedOffset = 0
lastUpdatedId = 2
updateBlock () {
const x = this.lastUpdatedOffset % 16
const z = Math.floor(this.lastUpdatedOffset / 16)
const y = 90
worldView!.setBlockStateId(new Vec3(x, y, z), this.lastUpdatedId++)
this.lastUpdatedOffset++
if (this.lastUpdatedOffset > 16 * 16) this.lastUpdatedOffset = 0
if (this.lastUpdatedId > 500) this.lastUpdatedId = 1
}
testNeighborUpdates () {
viewer.world.setBlockStateId(new Vec3(15, 95, 15), 1)
viewer.world.setBlockStateId(new Vec3(0, 95, 15), 1)
viewer.world.setBlockStateId(new Vec3(15, 95, 0), 1)
viewer.world.setBlockStateId(new Vec3(0, 95, 0), 1)
viewer.world.setBlockStateId(new Vec3(16, 95, 15), 1)
viewer.world.setBlockStateId(new Vec3(-1, 95, 15), 1)
viewer.world.setBlockStateId(new Vec3(15, 95, -1), 1)
viewer.world.setBlockStateId(new Vec3(-1, 95, 0), 1)
setTimeout(() => {
viewer.world.setBlockStateId(new Vec3(16, 96, 16), 1)
viewer.world.setBlockStateId(new Vec3(-1, 96, 16), 1)
viewer.world.setBlockStateId(new Vec3(16, 96, -1), 1)
viewer.world.setBlockStateId(new Vec3(-1, 96, -1), 1)
}, 3000)
}
setupTimer () {
// this.stopRender = true
let lastTime = 0
const tick = () => {
viewer.world.debugStopGeometryUpdate = this.params.stopGeometryUpdate
const updateEach = 1000 / this.params.testUpdatesPerSecond
requestAnimationFrame(tick)
if (!this.params.testActive) return
const updateCount = Math.floor(performance.now() - lastTime) / updateEach
for (let i = 0; i < updateCount; i++) {
this.updateBlock()
}
lastTime = performance.now()
}
requestAnimationFrame(tick)
// const limit = 1000
// const limit = 100
const limit = 1
const updatedChunks = new Set<string>()
const updatedBlocks = new Set<string>()
let lastSecond = 0
setInterval(() => {
const second = Math.floor(performance.now() / 1000)
if (lastSecond !== second) {
lastSecond = second
updatedChunks.clear()
updatedBlocks.clear()
}
const isEven = second % 2 === 0
if (updatedBlocks.size > limit) {
return
}
const changeBlock = (x, z) => {
const chunkKey = `${Math.floor(x / 16)},${Math.floor(z / 16)}`
const key = `${x},${z}`
if (updatedBlocks.has(chunkKey)) return
// const limit = 1
// const updatedChunks = new Set<string>()
// const updatedBlocks = new Set<string>()
// let lastSecond = 0
// setInterval(() => {
// const second = Math.floor(performance.now() / 1000)
// if (lastSecond !== second) {
// lastSecond = second
// updatedChunks.clear()
// updatedBlocks.clear()
// }
// const isEven = second % 2 === 0
// if (updatedBlocks.size > limit) {
// return
// }
// const changeBlock = (x, z) => {
// const chunkKey = `${Math.floor(x / 16)},${Math.floor(z / 16)}`
// const key = `${x},${z}`
// if (updatedBlocks.has(chunkKey)) return
updatedChunks.add(chunkKey)
worldView!.world.setBlock(this.targetPos.offset(x, 0, z), this.Block.fromStateId(isEven ? 2 : 3, 0))
updatedBlocks.add(key)
}
const { squareSize } = this.params
const xStart = -squareSize
const zStart = -squareSize
const xEnd = squareSize
const zEnd = squareSize
for (let x = xStart; x <= xEnd; x += 16) {
for (let z = zStart; z <= zEnd; z += 16) {
const key = `${x},${z}`
if (updatedChunks.has(key)) continue
changeBlock(x, z)
return
}
}
// for (let x = xStart; x <= xEnd; x += 16) {
// for (let z = zStart; z <= zEnd; z += 16) {
// const key = `${x},${z}`
// if (updatedChunks.has(key)) continue
// changeBlock(x, z)
// return
// }
// }
}, 1)
// updatedChunks.add(chunkKey)
// worldView!.world.setBlock(this.targetPos.offset(x, 0, z), this.Block.fromStateId(isEven ? 2 : 3, 0))
// updatedBlocks.add(key)
// }
// const { squareSize } = this.params
// const xStart = -squareSize
// const zStart = -squareSize
// const xEnd = squareSize
// const zEnd = squareSize
// for (let x = xStart; x <= xEnd; x += 16) {
// for (let z = zStart; z <= zEnd; z += 16) {
// const key = `${x},${z}`
// if (updatedChunks.has(key)) continue
// changeBlock(x, z)
// return
// }
// }
// for (let x = xStart; x <= xEnd; x += 16) {
// for (let z = zStart; z <= zEnd; z += 16) {
// const key = `${x},${z}`
// if (updatedChunks.has(key)) continue
// changeBlock(x, z)
// return
// }
// }
// }, 1)
}
setupWorld () {
this.params.squareSize ??= 30
const { squareSize } = this.params
const maxSquareSize = this.viewDistance * 16 * 2
if (squareSize > maxSquareSize) throw new Error(`Square size too big, max is ${maxSquareSize}`)
this.worldConfig.showChunkBorders = true
const maxSquareRadius = this.viewDistance * 16
// const fullBlocks = loadedData.blocksArray.map(x => x.name)
const squareSize = maxSquareRadius
for (let x = -squareSize; x <= squareSize; x++) {
for (let z = -squareSize; z <= squareSize; z++) {
const i = Math.abs(x + z) * squareSize
@ -81,5 +138,10 @@ export default class extends BasePlaygroundScene {
done = true
this.setupTimer()
})
setTimeout(() => {
if (this.params.testInitialUpdate) {
this.updateBlock()
}
})
}
}

View file

@ -8,15 +8,19 @@ import { PlayerObject, PlayerAnimation } from 'skinview3d'
import { loadSkinToCanvas, loadEarsToCanvasFromSkin, inferModelType, loadCapeToCanvas, loadImage } from 'skinview-utils'
// todo replace with url
import stevePng from 'mc-assets/dist/other-textures/latest/entity/player/wide/steve.png'
import { degreesToRadians } from '@nxg-org/mineflayer-tracker/lib/mathUtils'
import { NameTagObject } from 'skinview3d/libs/nametag'
import { flat, fromFormattedString } from '@xmcl/text-component'
import mojangson from 'mojangson'
import { snakeCase } from 'change-case'
import { Item } from 'prismarine-item'
import { EntityMetadataVersions } from '../../../src/mcDataTypes'
import * as Entity from './entity/EntityMesh'
import { getMesh } from './entity/EntityMesh'
import { WalkingGeneralSwing } from './entity/animations'
import externalTexturesJson from './entity/externalTextures.json'
import { disposeObject } from './threeJsUtils'
import { armorModels } from './entity/objModels'
const { loadTexture } = globalThis.isElectron ? require('./utils.electron.js') : require('./utils')
export const TWEEN_DURATION = 120
@ -57,6 +61,26 @@ function toQuaternion (quaternion: any, defaultValue?: THREE.Quaternion) {
return new THREE.Quaternion(quaternion.x, quaternion.y, quaternion.z, quaternion.w)
}
function poseToEuler (pose: any, defaultValue?: THREE.Euler) {
if (pose === undefined) {
return defaultValue ?? new THREE.Euler()
}
if (pose instanceof THREE.Euler) {
return pose
}
if (pose['yaw'] !== undefined && pose['pitch'] !== undefined && pose['roll'] !== undefined) {
// Convert Minecraft pitch, yaw, roll definitions to our angle system
return new THREE.Euler(-degreesToRadians(pose.pitch), -degreesToRadians(pose.yaw), degreesToRadians(pose.roll), 'ZYX')
}
if (pose['x'] !== undefined && pose['y'] !== undefined && pose['z'] !== undefined) {
return new THREE.Euler(pose.z, pose.y, pose.x, 'ZYX')
}
if (Array.isArray(pose)) {
return new THREE.Euler(pose[0], pose[1], pose[2])
}
return defaultValue ?? new THREE.Euler()
}
function getUsernameTexture ({
username,
nameTagBackgroundColor = 'rgba(0, 0, 0, 0.3)',
@ -369,13 +393,17 @@ export class Entities extends EventEmitter {
return jsonLike.value
}
const parsed = typeof jsonLike === 'string' ? mojangson.simplify(mojangson.parse(jsonLike)) : nbt.simplify(jsonLike)
const text = flat(parsed).map(x => x.text)
const text = flat(parsed).map(this.textFromComponent)
return text.join('')
} catch (err) {
return jsonLike
}
}
private textFromComponent (component) {
return typeof component === 'string' ? component : component.text ?? ''
}
getItemMesh (item) {
const textureUv = this.getItemUv?.(item.itemId ?? item.blockId)
if (textureUv) {
@ -418,14 +446,38 @@ export class Entities extends EventEmitter {
}
}
setVisible (mesh: THREE.Object3D, visible: boolean) {
//mesh.visible = visible
//TODO: Fix workaround for visibility setting
if (visible) {
mesh.scale.set(1, 1, 1)
} else {
mesh.scale.set(0, 0, 0)
}
}
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') {
overrides.texture = `textures/1.16.4/entity/${entity.name === 'zombie_villager' ? 'zombie_villager/zombie_villager.png' : `zombie/${entity.name}.png`}`
}
if (!this.entities[entity.id] && !entity.delete) {
// this can be undefined in case where packet entity_destroy was sent twice (so it was already deleted)
let e = this.entities[entity.id]
if (entity.delete) {
if (!e) return
if (e.additionalCleanup) e.additionalCleanup()
this.emit('remove', entity)
this.scene.remove(e)
disposeObject(e)
// todo dispose textures as well ?
delete this.entities[entity.id]
return
}
let mesh
if (e === undefined) {
const group = new THREE.Group()
let mesh
if (entity.name === 'item') {
const item = entity.metadata?.find((m: any) => typeof m === 'object' && m?.itemCount)
if (item) {
@ -508,7 +560,8 @@ export class Entities extends EventEmitter {
boxHelper.visible = false
this.scene.add(group)
this.entities[entity.id] = group
e = group
this.entities[entity.id] = e
this.emit('add', entity)
@ -517,6 +570,16 @@ export class Entities extends EventEmitter {
}
this.setDebugMode(this.debugMode, group)
this.setRendering(this.rendering, group)
} else {
mesh = e.children.find(c => c.name === 'mesh')
}
// check if entity has armor
if (entity.equipment) {
addArmorModel(e, 'feet', entity.equipment[2])
addArmorModel(e, 'legs', entity.equipment[3], 2)
addArmorModel(e, 'chest', entity.equipment[4])
addArmorModel(e, 'head', entity.equipment[5])
}
const meta = getGeneralEntitiesMetadata(entity)
@ -524,7 +587,7 @@ export class Entities extends EventEmitter {
//@ts-expect-error
// set visibility
const isInvisible = entity.metadata?.[0] & 0x20
for (const child of this.entities[entity.id]?.children.find(c => c.name === 'mesh')?.children ?? []) {
for (const child of mesh.children ?? []) {
if (child.name !== 'nametag') {
child.visible = !isInvisible
}
@ -547,10 +610,77 @@ export class Entities extends EventEmitter {
nameTagScale: textDisplayMeta?.scale, nameTagTranslation: textDisplayMeta && (textDisplayMeta.translation || new THREE.Vector3(0, 0, 0)),
nameTagRotationLeft: toQuaternion(textDisplayMeta?.left_rotation), nameTagRotationRight: toQuaternion(textDisplayMeta?.right_rotation) },
this.entitiesOptions,
this.entities[entity.id].children.find(c => c.name === 'mesh')
mesh
)
}
const armorStandMeta = getSpecificEntityMetadata('armor_stand', entity)
if (armorStandMeta) {
const isSmall = (parseInt(armorStandMeta.client_flags, 10) & 0x01) !== 0
const hasArms = (parseInt(armorStandMeta.client_flags, 10) & 0x04) !== 0
const hasBasePlate = (parseInt(armorStandMeta.client_flags, 10) & 0x08) === 0
const isMarker = (parseInt(armorStandMeta.client_flags, 10) & 0x10) !== 0
mesh.castShadow = !isMarker
mesh.receiveShadow = !isMarker
if (isSmall) {
e.scale.set(0.5, 0.5, 0.5)
} else {
e.scale.set(1, 1, 1)
}
e.traverse(c => {
switch (c.name) {
case 'bone_baseplate':
this.setVisible(c, hasBasePlate)
c.rotation.y = -e.rotation.y
break
case 'bone_head':
if (armorStandMeta.head_pose) {
c.setRotationFromEuler(poseToEuler(armorStandMeta.head_pose))
}
break
case 'bone_body':
if (armorStandMeta.body_pose) {
c.setRotationFromEuler(poseToEuler(armorStandMeta.body_pose))
}
break
case 'bone_leftarm':
if (c.parent?.name !== 'bone_armor') {
this.setVisible(c, hasArms)
}
if (armorStandMeta.left_arm_pose) {
c.setRotationFromEuler(poseToEuler(armorStandMeta.left_arm_pose))
} else {
c.setRotationFromEuler(poseToEuler({ 'yaw': -10, 'pitch': -10, 'roll': 0 }))
}
break
case 'bone_rightarm':
if (c.parent?.name !== 'bone_armor') {
this.setVisible(c, hasArms)
}
if (armorStandMeta.right_arm_pose) {
c.setRotationFromEuler(poseToEuler(armorStandMeta.right_arm_pose))
} else {
c.setRotationFromEuler(poseToEuler({ 'yaw': 10, 'pitch': -10, 'roll': 0 }))
}
break
case 'bone_leftleg':
if (armorStandMeta.left_leg_pose) {
c.setRotationFromEuler(poseToEuler(armorStandMeta.left_leg_pose))
} else {
c.setRotationFromEuler(poseToEuler({ 'yaw': -1, 'pitch': -1, 'roll': 0 }))
}
break
case 'bone_rightleg':
if (armorStandMeta.right_leg_pose) {
c.setRotationFromEuler(poseToEuler(armorStandMeta.right_leg_pose))
} else {
c.setRotationFromEuler(poseToEuler({ 'yaw': 1, 'pitch': 1, 'roll': 0 }))
}
break
}
})
}
// todo handle map, map_chunks events
// if (entity.name === 'item_frame' || entity.name === 'glow_item_frame') {
// const example = {
@ -578,9 +708,6 @@ export class Entities extends EventEmitter {
// }
// }
// this can be undefined in case where packet entity_destroy was sent twice (so it was already deleted)
const e = this.entities[entity.id]
if (entity.username) {
e.username = entity.username
}
@ -592,15 +719,6 @@ export class Entities extends EventEmitter {
playerObject.skin.head.rotation.x = overrides.rotation.head.x ? - overrides.rotation.head.x : 0
}
if (entity.delete && e) {
if (e.additionalCleanup) e.additionalCleanup()
this.emit('remove', entity)
this.scene.remove(e)
disposeObject(e)
// todo dispose textures as well ?
delete this.entities[entity.id]
}
if (entity.pos) {
new TWEEN.Tween(e.position).to({ x: entity.pos.x, y: entity.pos.y, z: entity.pos.z }, TWEEN_DURATION).start()
}
@ -645,3 +763,73 @@ function getSpecificEntityMetadata<T extends keyof EntityMetadataVersions> (name
if (entity.name !== name) return
return getGeneralEntitiesMetadata(entity) as any
}
function addArmorModel (entityMesh: THREE.Object3D, slotType: string, item: Item, layer = 1, overlay = false) {
if (!item) {
removeArmorModel(entityMesh, slotType)
return
}
const itemParts = item.name.split('_')
const armorMaterial = itemParts[0]
if (!armorMaterial) {
removeArmorModel(entityMesh, slotType)
return
}
// TODO: Support resource pack
// TODO: Support mirroring on certain parts of the model
const texturePath = armorModels[`${armorMaterial}Layer${layer}${overlay ? 'Overlay' : ''}`]
if (!texturePath || !armorModels.armorModel[slotType]) {
return
}
const meshName = `geometry_armor_${slotType}${overlay ? '_overlay' : ''}`
let mesh = entityMesh.children.findLast(c => c.name === meshName) as THREE.Mesh
let material
if (mesh) {
material = mesh.material
loadTexture(texturePath, texture => {
texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
texture.flipY = false
texture.wrapS = THREE.MirroredRepeatWrapping
texture.wrapT = THREE.MirroredRepeatWrapping
material.map = texture
})
} else {
mesh = getMesh(texturePath, armorModels.armorModel[slotType])
mesh.name = meshName
material = mesh.material
material.side = THREE.DoubleSide
}
if (armorMaterial === 'leather' && !overlay) {
const color = (item.nbt?.value as any)?.display?.value?.color?.value
if (color) {
const r = color >> 16 & 0xff
const g = color >> 8 & 0xff
const b = color & 0xff
material.color.setRGB(r / 255, g / 255, b / 255)
} else {
material.color.setHex(0xB5_6D_51) // default brown color
}
addArmorModel(entityMesh, slotType, item, layer, true)
}
const group = new THREE.Object3D()
group.name = `armor_${slotType}${overlay ? '_overlay' : ''}`
group.add(mesh)
const skeletonHelper = new THREE.SkeletonHelper(mesh)
//@ts-expect-error
skeletonHelper.material.linewidth = 2
skeletonHelper.visible = false
group.add(skeletonHelper)
entityMesh.add(mesh)
}
function removeArmorModel (entityMesh: THREE.Object3D, slotType: string) {
for (const c of entityMesh.children) {
if (c.name === `geometry_armor_${slotType}` || c.name === `geometry_armor_${slotType}_overlay`) {
c.removeFromParent()
}
}
}

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) {
function addCube(attr, boneId, bone, cube, 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
@ -104,15 +104,20 @@ function addCube(attr, boneId, bone, cube, texWidth = 64, texHeight = 64) {
for (const { dir, corners, u0, v0, u1, v1 } of Object.values(elemFaces)) {
const ndx = Math.floor(attr.positions.length / 3)
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
const posX = eastOrWest && mirror ? pos[0] ^ 1 : pos[0]
const posY = pos[1]
const posZ = eastOrWest && mirror ? pos[2] ^ 1 : pos[2]
const inflate = cube.inflate ?? 0
let vecPos = new THREE.Vector3(
cube.origin[0] + pos[0] * cube.size[0] + (pos[0] ? inflate : -inflate),
cube.origin[1] + pos[1] * cube.size[1] + (pos[1] ? inflate : -inflate),
cube.origin[2] + pos[2] * cube.size[2] + (pos[2] ? inflate : -inflate)
cube.origin[0] + posX * cube.size[0] + (posX ? inflate : -inflate),
cube.origin[1] + posY * cube.size[1] + (posY ? inflate : -inflate),
cube.origin[2] + posZ * cube.size[2] + (posZ ? inflate : -inflate)
)
vecPos = vecPos.applyEuler(cubeRotation)
@ -122,16 +127,28 @@ function addCube(attr, boneId, bone, cube, texWidth = 64, texHeight = 64) {
attr.positions.push(vecPos.x, vecPos.y, vecPos.z)
attr.normals.push(...dir)
attr.uvs.push(u, v)
faceUvs.push(u, v)
attr.skinIndices.push(boneId, 0, 0, 0)
attr.skinWeights.push(1, 0, 0, 0)
}
if (mirror) {
for (let i = 0; i + 1 < corners.length; i += 2) {
const faceIndex = i * 2
const tempFaceUvs = faceUvs.slice(faceIndex, faceIndex + 4)
faceUvs[faceIndex] = tempFaceUvs[2]
faceUvs[faceIndex + 1] = tempFaceUvs[eastOrWest ? 1 : 3]
faceUvs[faceIndex + 2] = tempFaceUvs[0]
faceUvs[faceIndex + 3] = tempFaceUvs[eastOrWest ? 3 : 1]
}
}
attr.uvs.push(...faceUvs)
attr.indices.push(ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3)
}
}
function getMesh(texture, jsonModel, overrides = {}) {
export function getMesh(texture, jsonModel, overrides = {}) {
const bones = {}
const geoData = {
@ -169,7 +186,7 @@ function getMesh(texture, jsonModel, overrides = {}) {
if (jsonBone.cubes) {
for (const cube of jsonBone.cubes) {
addCube(geoData, i, bone, cube, jsonModel.texturewidth, jsonModel.textureheight)
addCube(geoData, i, bone, cube, jsonModel.texturewidth, jsonModel.textureheight, jsonBone.mirror)
}
}
i++

View file

@ -0,0 +1,158 @@
{
"head": {
"bones": [
{"name": "armor", "pivot": [0, 12, 0]},
{
"name": "head",
"parent": "armor",
"pivot": [0, 12, 0],
"cubes": [
{
"origin": [-4, 23, -4],
"size": [8, 8, 8],
"uv": [0, 0],
"inflate": 1
}
]
}
],
"visible_bounds_width": 1.5,
"visible_bounds_offset": [0, 0.5, 0],
"texturewidth": 64,
"textureheight": 32
},
"chest": {
"bones": [
{"name": "armor", "pivot": [0, 12, 0]},
{
"name": "body",
"parent": "armor",
"pivot": [0, 13, 0],
"cubes": [
{
"origin": [-4, 12, -2],
"size": [8, 12, 4],
"uv": [16, 16],
"inflate": 1
}
]
},
{
"name": "rightarm",
"parent": "armor",
"pivot": [5, 10, 0],
"cubes": [
{
"origin": [4, 12, -2],
"size": [4, 12, 4],
"uv": [40, 16],
"inflate": 0.75
}
]
},
{
"name": "leftarm",
"parent": "armor",
"pivot": [-5, 10, 0],
"cubes": [
{
"origin": [-8, 12, -2],
"size": [4, 12, 4],
"uv": [40, 16],
"inflate": 0.75
}
],
"mirror": true
}
],
"visible_bounds_width": 1.5,
"visible_bounds_offset": [0, 0.5, 0],
"texturewidth": 64,
"textureheight": 32
},
"legs": {
"bones": [
{"name": "armor", "pivot": [0, 12, 0]},
{
"name": "body",
"parent": "armor",
"pivot": [0, 13, 0],
"cubes": [
{
"origin": [-4, 12, -2],
"size": [8, 12, 4],
"uv": [16, 16],
"inflate": 0.75
}
]
},
{
"name": "rightleg",
"parent": "armor",
"pivot": [1.9, 1, 0],
"cubes": [
{
"origin": [-0.1, 0, -2],
"size": [4, 12, 4],
"uv": [0, 16],
"inflate": 0.5
}
]
},
{
"name": "leftleg",
"parent": "armor",
"pivot": [-1.9, 1, 0],
"cubes": [
{
"origin": [-3.9, 0, -2],
"size": [4, 12, 4],
"uv": [0, 16],
"inflate": 0.5
}
],
"mirror": true
}
],
"visible_bounds_width": 1.5,
"visible_bounds_offset": [0, 0.5, 0],
"texturewidth": 64,
"textureheight": 32
},
"feet": {
"bones": [
{"name": "armor", "pivot": [0, 12, 0]},
{
"name": "rightleg",
"parent": "armor",
"pivot": [1.9, 1, 0],
"cubes": [
{
"origin": [-0.1, 0, -2],
"size": [4, 12, 4],
"uv": [0, 16],
"inflate": 0.75
}
]
},
{
"name": "leftleg",
"parent": "armor",
"pivot": [-1.9, 1, 0],
"cubes": [
{
"origin": [-3.9, 0, -2],
"size": [4, 12, 4],
"uv": [0, 16],
"inflate": 0.75
}
],
"mirror": true
}
],
"visible_bounds_width": 1.5,
"visible_bounds_offset": [0, 0.5, 0],
"texturewidth": 64,
"textureheight": 32
}
}

View file

@ -0,0 +1,36 @@
/*
* prismarine-web-client - prismarine-web-client
* Copyright (C) 2024 Max Lee aka Phoenix616 (mail@moep.tv)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// TODO: replace with load from resource pack
export { default as chainmailLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/chainmail_layer_1.png'
export { default as chainmailLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/chainmail_layer_2.png'
export { default as diamondLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/diamond_layer_1.png'
export { default as diamondLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/diamond_layer_2.png'
export { default as goldenLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/gold_layer_1.png'
export { default as goldenLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/gold_layer_2.png'
export { default as ironLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/iron_layer_1.png'
export { default as ironLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/iron_layer_2.png'
export { default as leatherLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/leather_layer_1.png'
export { default as leatherLayer1Overlay } from 'mc-assets/dist/other-textures/latest/models/armor/leather_layer_1_overlay.png'
export { default as leatherLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/leather_layer_2.png'
export { default as leatherLayer2Overlay } from 'mc-assets/dist/other-textures/latest/models/armor/leather_layer_2_overlay.png'
export { default as netheriteLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/netherite_layer_1.png'
export { default as netheriteLayer2 } from 'mc-assets/dist/other-textures/latest/models/armor/netherite_layer_2.png'
export { default as turtleLayer1 } from 'mc-assets/dist/other-textures/latest/models/armor/turtle_layer_1.png'
export { default as armorModel } from './armorModels.json'

View file

@ -186,15 +186,16 @@
"bones": [
{
"name": "baseplate",
"parent": "waist",
"cubes": [
{"origin": [-6, 0, -6], "size": [12, 1, 12], "uv": [0, 32]}
]
},
{"name": "waist", "parent": "baseplate", "pivot": [0, 12, 0]},
{"name": "waist", "pivot": [0, 12, 0]},
{
"name": "body",
"parent": "waist",
"pivot": [0, 24, 0],
"pivot": [0, 13, 0],
"cubes": [
{"origin": [-6, 21, -1.5], "size": [12, 3, 3], "uv": [0, 26]},
{"origin": [-3, 14, -1], "size": [2, 7, 2], "uv": [16, 0]},
@ -204,50 +205,50 @@
},
{
"name": "head",
"parent": "body",
"pivot": [0, 24, 0],
"parent": "waist",
"pivot": [0, 12, 0],
"cubes": [{"origin": [-1, 24, -1], "size": [2, 7, 2], "uv": [0, 0]}]
},
{
"name": "hat",
"parent": "head",
"pivot": [0, 24, 0],
"pivot": [0, 12, 0],
"cubes": [
{"origin": [-4, 24, -4], "size": [8, 8, 8], "uv": [32, 0]}
]
},
{
"name": "leftarm",
"parent": "body",
"name": "rightarm",
"parent": "waist",
"mirror": true,
"pivot": [5, 22, 0],
"pivot": [5, 10, 0],
"cubes": [
{"origin": [5, 12, -1], "size": [2, 12, 2], "uv": [32, 16]}
]
},
{"name": "leftitem", "parent": "leftarm", "pivot": [6, 15, 1]},
{"name": "rightitem", "parent": "leftarm", "pivot": [6, 15, 1]},
{
"name": "leftleg",
"parent": "body",
"name": "rightleg",
"parent": "waist",
"mirror": true,
"pivot": [1.9, 12, 0],
"pivot": [1.9, 1, 0],
"cubes": [
{"origin": [0.9, 1, -1], "size": [2, 11, 2], "uv": [40, 16]}
]
},
{
"name": "rightarm",
"parent": "body",
"pivot": [-5, 22, 0],
"name": "leftarm",
"parent": "waist",
"pivot": [-5, 10, 0],
"cubes": [
{"origin": [-7, 12, -1], "size": [2, 12, 2], "uv": [24, 0]}
]
},
{"name": "rightitem", "parent": "rightarm", "pivot": [-6, 15, 1]},
{"name": "leftitem", "parent": "rightarm", "pivot": [-6, 15, 1]},
{
"name": "rightleg",
"parent": "body",
"pivot": [-1.9, 12, 0],
"name": "leftleg",
"parent": "waist",
"pivot": [-1.9, 1, 0],
"cubes": [
{"origin": [-2.9, 1, -1], "size": [2, 11, 2], "uv": [8, 0]}
]

View file

@ -1 +1,2 @@
export * as externalModels from './exportedModels'
export * as armorModels from './armorModels'

View file

@ -123,7 +123,7 @@ const isCube = (block: Block) => {
if (block.isCube) return true
if (!block.models?.length || block.models.length !== 1) return false
// all variants
return block.models[0].every(v => v.elements!.every(e => {
return block.models[0].every(v => v.elements.every(e => {
return e.from[0] === 0 && e.from[1] === 0 && e.from[2] === 0 && e.to[0] === 16 && e.to[1] === 16 && e.to[2] === 16
}))
}

View file

@ -6,6 +6,7 @@ import { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider'
import moreBlockDataGeneratedJson from '../moreBlockDataGenerated.json'
import legacyJson from '../../../../src/preflatMap.json'
import { defaultMesherConfig } from './shared'
import { INVISIBLE_BLOCKS } from './worldConstants'
const ignoreAoBlocks = Object.keys(moreBlockDataGeneratedJson.noOcclusions)
@ -178,7 +179,9 @@ export class World {
properties: props,
}, this.preflat)! // fixme! this is a hack (also need a setting for all versions)
if (!block.models!.length) {
console.debug('[mesher] block to render not found', block.name, props)
if (block.name !== 'water' && block.name !== 'lava' && !INVISIBLE_BLOCKS.has(block.name)) {
console.debug('[mesher] block to render not found', block.name, props)
}
block.models = null
}
} catch (err) {

View file

@ -6,7 +6,6 @@ const stats = {}
let lastY = 20
export const addNewStat = (id: string, width = 80, x = rightOffset, y = lastY) => {
const pane = document.createElement('div')
pane.id = 'fps-counter'
pane.style.position = 'fixed'
pane.style.top = `${y}px`
pane.style.right = `${x}px`
@ -27,6 +26,7 @@ export const addNewStat = (id: string, width = 80, x = rightOffset, y = lastY) =
return {
updateText (text: string) {
if (pane.innerText === text) return
pane.innerText = text
},
setVisibility (visible: boolean) {

View file

@ -107,7 +107,11 @@ export class Viewer {
const sectionX = Math.floor(pos.x / 16) * 16
const sectionZ = Math.floor(pos.z / 16) * 16
if (this.world.queuedChunks.has(`${sectionX},${sectionZ}`)) {
await this.world.waitForChunkToLoad(pos)
await new Promise<void>(resolve => {
this.world.queuedFunctions.push(() => {
resolve()
})
})
}
if (!this.world.loadedChunks[`${sectionX},${sectionZ}`]) {
console.debug('[should be unreachable] setBlockStateId called for unloaded chunk', pos)
@ -222,6 +226,10 @@ export class Viewer {
this.world.queuedChunks.delete(`${args[0]},${args[1]}`)
this.addColumn(...args as Parameters<typeof this.addColumn>)
}
for (const fn of this.world.queuedFunctions) {
fn()
}
this.world.queuedFunctions = []
currentLoadChunkBatch = null
}, this.addChunksBatchWaitTime)
}

View file

@ -12,6 +12,7 @@ import itemsAtlasLegacy from 'mc-assets/dist/itemsAtlasLegacy.png'
import { AtlasParser } from 'mc-assets'
import TypedEmitter from 'typed-emitter'
import { LineMaterial } from 'three-stdlib'
import christmasPack from 'mc-assets/dist/textureReplacements/christmas'
import { dynamicMcDataFiles } from '../../buildMesherConfig.mjs'
import { toMajorVersion } from '../../../src/utils'
import { buildCleanupDecorator } from './cleanupDecorator'
@ -30,6 +31,7 @@ export const worldCleanup = buildCleanupDecorator('resetWorld')
export const defaultWorldRendererConfig = {
showChunkBorders: false,
numWorkers: 4,
isPlayground: false,
// game renderer setting actually
displayHand: false
}
@ -47,7 +49,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
threejsCursorLineMaterial: LineMaterial
@worldCleanup()
cursorBlock = null as Vec3 | null
isPlayground = false
displayStats = true
@worldCleanup()
worldConfig = { minY: 0, worldHeight: 256 }
@ -58,18 +59,24 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
active = false
version = undefined as string | undefined
// #region CHUNK & SECTIONS TRACKING
@worldCleanup()
loadedChunks = {} as Record<string, boolean> // data is added for these chunks and they might be still processing
@worldCleanup()
finishedChunks = {} as Record<string, boolean> // these chunks are fully loaded into the world (scene)
@worldCleanup()
finishedSections = {} as Record<string, boolean> // these sections are fully loaded into the world (scene)
@worldCleanup()
// loading sections (chunks)
sectionsWaiting = new Map<string, number>()
@worldCleanup()
queuedChunks = new Set<string>()
queuedFunctions = [] as Array<() => void>
// #endregion
@worldCleanup()
renderUpdateEmitter = new EventEmitter() as unknown as TypedEmitter<{
@ -129,6 +136,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
}
neighborChunkUpdates = true
lastChunkDistance = 0
debugStopGeometryUpdate = false
abstract outputFormat: 'threeJs' | 'webgpu'
@ -157,7 +165,9 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
const worker: any = new Worker(src)
const handleMessage = (data) => {
if (!this.active) return
this.handleWorkerMessage(data)
if (data.type !== 'geometry' || !this.debugStopGeometryUpdate) {
this.handleWorkerMessage(data)
}
if (data.type === 'geometry') {
this.geometryReceiveCount[data.workerIndex] ??= 0
this.geometryReceiveCount[data.workerIndex]++
@ -174,7 +184,10 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
if (data.type === 'sectionFinished') { // on after load & unload section
if (!this.sectionsWaiting.get(data.key)) throw new Error(`sectionFinished event for non-outstanding section ${data.key}`)
this.sectionsWaiting.set(data.key, this.sectionsWaiting.get(data.key)! - 1)
if (this.sectionsWaiting.get(data.key) === 0) this.sectionsWaiting.delete(data.key)
if (this.sectionsWaiting.get(data.key) === 0) {
this.sectionsWaiting.delete(data.key)
this.finishedSections[data.key] = true
}
const chunkCoords = data.key.split(',').map(Number)
if (this.loadedChunks[`${chunkCoords[0]},${chunkCoords[2]}`]) { // ensure chunk data was added, not a neighbor chunk update
@ -215,6 +228,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
if (allFinished) {
this.allChunksLoaded?.()
this.allChunksFinished = true
this.allLoadedIn ??= Date.now() - this.initialChunkLoadWasStartedIn!
}
}
}
@ -300,14 +314,21 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
}
}
async updateTexturesData (resourcePackUpdate = false) {
async updateTexturesData (resourcePackUpdate = false, prioritizeBlockTextures?: string[]) {
const blocksAssetsParser = new AtlasParser(this.blocksAtlases, blocksAtlasLatest, blocksAtlasLegacy)
const itemsAssetsParser = new AtlasParser(this.itemsAtlases, itemsAtlasLatest, itemsAtlasLegacy)
const blockTexturesChanges = {} as Record<string, string>
const date = new Date()
if ((date.getMonth() === 11 && date.getDate() >= 24) || (date.getMonth() === 0 && date.getDate() <= 6)) {
Object.assign(blockTexturesChanges, christmasPack)
}
const customBlockTextures = Object.keys(this.customTextures.blocks?.textures ?? {}).filter(x => x.includes('/'))
const { atlas: blocksAtlas, canvas: blocksCanvas } = await blocksAssetsParser.makeNewAtlas(this.texturesVersion ?? this.version ?? 'latest', (textureName) => {
const texture = this.customTextures?.blocks?.textures[textureName]
if (!texture) return
return texture
}, this.customTextures?.blocks?.tileSize)
return blockTexturesChanges[textureName] ?? texture
}, /* this.customTextures?.blocks?.tileSize */undefined, prioritizeBlockTextures, customBlockTextures)
const { atlas: itemsAtlas, canvas: itemsCanvas } = await itemsAssetsParser.makeNewAtlas(this.texturesVersion ?? this.version ?? 'latest', (textureName) => {
const texture = this.customTextures?.items?.textures[textureName]
if (!texture) return
@ -397,9 +418,15 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
}
delete this.finishedChunks[`${x},${z}`]
this.allChunksFinished = Object.keys(this.finishedChunks).length === this.chunksLength
if (!this.allChunksFinished) {
this.allLoadedIn = undefined
this.initialChunkLoadWasStartedIn = undefined
}
for (let y = this.worldConfig.minY; y < this.worldConfig.worldHeight; y += 16) {
this.setSectionDirty(new Vec3(x, y, z), false)
delete this.finishedSections[`${x},${y},${z}`]
}
// remove from highestBlocks
const startX = Math.floor(x / 16) * 16
const startZ = Math.floor(z / 16) * 16
@ -413,26 +440,54 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
}
setBlockStateId (pos: Vec3, stateId: number) {
const key = `${Math.floor(pos.x / 16) * 16},${Math.floor(pos.y / 16) * 16},${Math.floor(pos.z / 16) * 16}`
const useChangeWorker = !this.sectionsWaiting[key]
const needAoRecalculation = true
for (const worker of this.workers) {
worker.postMessage({ type: 'blockUpdate', pos, stateId })
}
this.setSectionDirty(pos, true, useChangeWorker)
this.setSectionDirty(pos, true, true)
if (this.neighborChunkUpdates) {
if ((pos.x & 15) === 0) this.setSectionDirty(pos.offset(-16, 0, 0), true, useChangeWorker)
if ((pos.x & 15) === 15) this.setSectionDirty(pos.offset(16, 0, 0), true, useChangeWorker)
if ((pos.y & 15) === 0) this.setSectionDirty(pos.offset(0, -16, 0), true, useChangeWorker)
if ((pos.y & 15) === 15) this.setSectionDirty(pos.offset(0, 16, 0), true, useChangeWorker)
if ((pos.z & 15) === 0) this.setSectionDirty(pos.offset(0, 0, -16), true, useChangeWorker)
if ((pos.z & 15) === 15) this.setSectionDirty(pos.offset(0, 0, 16), true, useChangeWorker)
if ((pos.x & 15) === 0) this.setSectionDirty(pos.offset(-16, 0, 0), true, true)
if ((pos.x & 15) === 15) this.setSectionDirty(pos.offset(16, 0, 0), true, true)
if ((pos.y & 15) === 0) this.setSectionDirty(pos.offset(0, -16, 0), true, true)
if ((pos.y & 15) === 15) this.setSectionDirty(pos.offset(0, 16, 0), true, true)
if ((pos.z & 15) === 0) this.setSectionDirty(pos.offset(0, 0, -16), true, true)
if ((pos.z & 15) === 15) this.setSectionDirty(pos.offset(0, 0, 16), true, true)
if (needAoRecalculation) {
// top view neighbors
if ((pos.x & 15) === 0 && (pos.z & 15) === 0) this.setSectionDirty(pos.offset(-16, 0, -16), true, true)
if ((pos.x & 15) === 15 && (pos.z & 15) === 0) this.setSectionDirty(pos.offset(16, 0, -16), true, true)
if ((pos.x & 15) === 0 && (pos.z & 15) === 15) this.setSectionDirty(pos.offset(-16, 0, 16), true, true)
if ((pos.x & 15) === 15 && (pos.z & 15) === 15) this.setSectionDirty(pos.offset(16, 0, 16), true, true)
// side view neighbors (but ignore updates above)
// z view neighbors
if ((pos.x & 15) === 0 && (pos.y & 15) === 0) this.setSectionDirty(pos.offset(-16, -16, 0), true, true)
if ((pos.x & 15) === 15 && (pos.y & 15) === 0) this.setSectionDirty(pos.offset(16, -16, 0), true, true)
// x view neighbors
if ((pos.z & 15) === 0 && (pos.y & 15) === 0) this.setSectionDirty(pos.offset(0, -16, -16), true, true)
if ((pos.z & 15) === 15 && (pos.y & 15) === 0) this.setSectionDirty(pos.offset(0, -16, 16), true, true)
// x & z neighbors
if ((pos.y & 15) === 0 && (pos.x & 15) === 0 && (pos.z & 15) === 0) this.setSectionDirty(pos.offset(-16, -16, -16), true, true)
if ((pos.y & 15) === 0 && (pos.x & 15) === 15 && (pos.z & 15) === 0) this.setSectionDirty(pos.offset(16, -16, -16), true, true)
if ((pos.y & 15) === 0 && (pos.x & 15) === 0 && (pos.z & 15) === 15) this.setSectionDirty(pos.offset(-16, -16, 16), true, true)
if ((pos.y & 15) === 0 && (pos.x & 15) === 15 && (pos.z & 15) === 15) this.setSectionDirty(pos.offset(16, -16, 16), true, true)
}
}
}
queueAwaited = false
messagesQueue = {} as { [workerIndex: string]: any[] }
getWorkerNumber (pos: Vec3) {
getWorkerNumber (pos: Vec3, updateAction = false) {
if (updateAction) {
const key = `${Math.floor(pos.x / 16) * 16},${Math.floor(pos.y / 16) * 16},${Math.floor(pos.z / 16) * 16}`
const cantUseChangeWorker = this.sectionsWaiting.get(key) && !this.finishedSections[key]
if (!cantUseChangeWorker) return 0
}
const hash = mod(Math.floor(pos.x / 16) + Math.floor(pos.y / 16) + Math.floor(pos.z / 16), this.workers.length - 1)
return hash + 1
}
@ -448,7 +503,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
// Dispatch sections to workers based on position
// This guarantees uniformity accross workers and that a given section
// is always dispatched to the same worker
const hash = useChangeWorker ? 0 : this.getWorkerNumber(pos)
const hash = this.getWorkerNumber(pos, useChangeWorker)
this.sectionsWaiting.set(key, (this.sectionsWaiting.get(key) ?? 0) + 1)
this.messagesQueue[hash] ??= []
this.messagesQueue[hash].push({

View file

@ -36,7 +36,7 @@ export class WorldRendererThree extends WorldRendererCommon {
constructor (public scene: THREE.Scene, public renderer: THREE.WebGLRenderer, public config: WorldRendererConfig) {
super(config)
this.rendererDevice = String(WorldRendererThree.getRendererInfo(this.renderer))
this.rendererDevice = `${WorldRendererThree.getRendererInfo(this.renderer)} powered by three.js r${THREE.REVISION}`
this.starField = new StarField(scene)
this.holdingBlock = new HoldingBlock()
this.holdingBlockLeft = new HoldingBlock()
@ -422,7 +422,7 @@ export class WorldRendererThree extends WorldRendererCommon {
static getRendererInfo (renderer: THREE.WebGLRenderer) {
try {
const gl = renderer.getContext()
return `${gl.getParameter(gl.getExtension('WEBGL_debug_renderer_info')!.UNMASKED_RENDERER_WEBGL)} powered by three.js r{THREE.REVISION}`
return `${gl.getParameter(gl.getExtension('WEBGL_debug_renderer_info')!.UNMASKED_RENDERER_WEBGL)}`
} catch (err) {
console.warn('Failed to get renderer info', err)
}

View file

@ -1,4 +1,9 @@
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 { miscUiState } from './globalState'
export type ConnectOptions = {
server?: string
@ -16,4 +21,24 @@ export type ConnectOptions = {
/** If true, will show a UI to authenticate with a new account */
authenticatedAccount?: AuthenticatedAccount | true
peerOptions?: any
viewerWsConnect?: string
}
export const downloadNeededDataOnConnect = async (version: string) => {
// todo expose cache
const initialDataVersion = Object.keys(minecraftInitialDataJson)[0]!
if (version === initialDataVersion) {
// ignore cache hit
versionsByMinecraftVersion.pc[initialDataVersion]!.dataVersion!++
}
setLoadingScreenStatus(`Loading data for ${version}`)
if (!document.fonts.check('1em mojangles')) {
// todo instead re-render signs on load
await document.fonts.load('1em mojangles').catch(() => {
console.error('Failed to load font, signs wont be rendered correctly')
})
}
await window._MC_DATA_RESOLVER.promise // ensure data is loaded
await downloadSoundsIfNeeded()
miscUiState.loadedDataVersion = version
}

View file

@ -49,6 +49,7 @@ export const contro = new ControMax({
chat: [['KeyT', 'Enter']],
command: ['Slash'],
swapHands: ['KeyF'],
zoom: ['KeyC'],
selectItem: ['KeyH'] // default will be removed
},
ui: {
@ -282,6 +283,9 @@ const onTriggerOrReleased = (command: Command, pressed: boolean) => {
case 'general.interactPlace':
document.dispatchEvent(new MouseEvent(pressed ? 'mousedown' : 'mouseup', { button: 2 }))
break
case 'general.zoom':
gameAdditionalState.isZooming = pressed
break
}
}
}
@ -415,6 +419,8 @@ contro.on('trigger', ({ command }) => {
case 'general.prevHotbarSlot':
cycleHotbarSlot(-1)
break
case 'general.zoom':
break
}
}

View file

@ -43,8 +43,8 @@ customEvents.on('gameLoaded', () => {
})
})
window.inspectPacket = (packetName, full = false) => {
const listener = (...args) => console.log('packet', packetName, full ? args : args[0])
window.inspectPacket = (packetName, fullOrListener: boolean | ((...args) => void) = false) => {
const listener = typeof fullOrListener === 'function' ? fullOrListener : (...args) => console.log('packet', packetName, fullOrListener ? args : args[0])
const attach = () => {
bot?._client.prependListener(packetName, listener)
}

View file

@ -154,6 +154,7 @@ export const gameAdditionalState = proxy({
isFlying: false,
isSprinting: false,
isSneaking: false,
isZooming: false,
warps: [] as WorldWarp[]
})

View file

@ -93,7 +93,7 @@ import { saveToBrowserMemory } from './react/PauseScreen'
import { ViewerWrapper } from 'prismarine-viewer/viewer/lib/viewerWrapper'
import './devReload'
import './water'
import { ConnectOptions } from './connect'
import { ConnectOptions, downloadNeededDataOnConnect } from './connect'
import { ref, subscribe } from 'valtio'
import { signInMessageState } from './react/SignInMessageProvider'
import { updateAuthenticatedAccountData, updateLoadedServerData } from './react/ServersListProvider'
@ -103,6 +103,7 @@ import { mainMenuState } from './react/MainMenuRenderApp'
import { ItemsRenderer } from 'mc-assets/dist/itemsRenderer'
import './mobileShim'
import { parseFormattedMessagePacket } from './botUtils'
import { getViewerVersionData, getWsProtocolStream } from './viewerConnector'
window.debug = debug
window.THREE = THREE
@ -376,7 +377,7 @@ async function connect (connectOptions: ConnectOptions) {
signal: errorAbortController.signal
})
if (proxy) {
if (proxy && !connectOptions.viewerWsConnect) {
console.log(`using proxy ${proxy.host}:${proxy.port || location.port}`)
net['setProxy']({ hostname: proxy.host, port: proxy.port })
@ -395,22 +396,7 @@ async function connect (connectOptions: ConnectOptions) {
throw new Error('Microsoft authentication is only supported on 1.19.4 - 1.20.6 (at least for now)')
}
// todo expose cache
const lastVersion = supportedVersions.at(-1)
if (version === lastVersion) {
// ignore cache hit
versionsByMinecraftVersion.pc[lastVersion]!['dataVersion']!++
}
setLoadingScreenStatus(`Loading data for ${version}`)
if (!document.fonts.check('1em mojangles')) {
// todo instead re-render signs on load
await document.fonts.load('1em mojangles').catch(() => {
console.error('Failed to load font, signs wont be rendered correctly')
})
}
await window._MC_DATA_RESOLVER.promise // ensure data is loaded
await downloadSoundsIfNeeded()
miscUiState.loadedDataVersion = version
await downloadNeededDataOnConnect(version)
try {
await resourcepackReload(version)
} catch (err) {
@ -486,12 +472,26 @@ async function connect (connectOptions: ConnectOptions) {
connectingServer: server.host
}) : undefined
let clientDataStream
if (p2pMultiplayer) {
clientDataStream = await connectToPeer(connectOptions.peerId!, connectOptions.peerOptions)
}
if (connectOptions.viewerWsConnect) {
const { version, time } = await getViewerVersionData(connectOptions.viewerWsConnect)
console.log('Latency:', Date.now() - time, 'ms')
// const version = '1.21.1'
connectOptions.botVersion = version
await downloadMcData(version)
setLoadingScreenStatus(`Connecting to WebSocket server ${connectOptions.viewerWsConnect}`)
clientDataStream = await getWsProtocolStream(connectOptions.viewerWsConnect)
}
bot = mineflayer.createBot({
host: server.host,
port: server.port ? +server.port : undefined,
version: connectOptions.botVersion || false,
...p2pMultiplayer ? {
stream: await connectToPeer(connectOptions.peerId!, connectOptions.peerOptions),
...clientDataStream ? {
stream: clientDataStream,
} : {},
...singleplayer || p2pMultiplayer ? {
keepAlive: false,
@ -576,10 +576,13 @@ async function connect (connectOptions: ConnectOptions) {
bot.emit('inject_allowed')
bot._client.emit('connect')
} else if (connectOptions.viewerWsConnect) {
// bot.emit('inject_allowed')
bot._client.emit('connect')
} else {
const setupConnectHandlers = () => {
bot._client.socket.on('connect', () => {
console.log('WebSocket connection established')
console.log('Proxy WebSocket connection established')
//@ts-expect-error
bot._client.socket._ws.addEventListener('close', () => {
console.log('WebSocket connection closed')
@ -620,6 +623,7 @@ async function connect (connectOptions: ConnectOptions) {
} else {
const originalSetSocket = bot._client.setSocket.bind(bot._client)
bot._client.setSocket = (socket) => {
if (!bot) return
originalSetSocket(socket)
setupConnectHandlers()
}
@ -1054,6 +1058,21 @@ downloadAndOpenFile().then((downloadAction) => {
if (qs.get('serversList')) {
showModal({ reactType: 'serversList' })
}
const viewerWsConnect = qs.get('viewerConnect')
if (viewerWsConnect) {
void connect({
username: `viewer-${Math.random().toString(36).slice(2, 10)}`,
viewerWsConnect,
})
}
if (qs.get('modal')) {
const modals = qs.get('modal')!.split(',')
for (const modal of modals) {
showModal({ reactType: modal })
}
}
}, (err) => {
console.error(err)
alert(`Failed to download file: ${err}`)

55
src/react/DebugEdges.tsx Normal file
View file

@ -0,0 +1,55 @@
import { useState } from 'react'
import { useIsHashActive } from './simpleHooks'
export default () => {
const MODES_COUNT = 4
const [mode, setMode] = useState(0)
const isHashActive = useIsHashActive('#edges')
if (!isHashActive) return null
const styles: React.CSSProperties = {
display: 'flex',
fontSize: 18,
zIndex: 10_000,
background: 'rgba(0, 0, 255, 0.5)',
border: '2px solid red',
whiteSpace: 'pre',
}
let text = ''
if (mode === 0) {
styles.position = 'fixed'
styles.inset = 0
styles.height = '100%'
text = 'inset 0 fixed 100% height'
}
if (mode === 1) {
styles.position = 'fixed'
styles.inset = 0
text = 'inset 0 fixed'
}
if (mode === 2) {
styles.position = 'absolute'
styles.inset = 0
text = 'inset 0 absolute'
}
if (mode === 3) {
styles.position = 'fixed'
styles.top = 0
styles.left = 0
styles.right = 0
styles.height = '100dvh'
text = 'top 0 fixed 100dvh'
}
return <div
style={styles}
onClick={() => {
setMode((mode + 1) % MODES_COUNT)
}}
>
{mode}: {text}{'\n'}
inner: {window.innerWidth}x{window.innerHeight}{'\n'}
outer: {window.outerWidth}x{window.outerHeight}{'\n'}
</div>
}

View file

@ -17,7 +17,6 @@ function InnerSearch () {
>
<Input
autoFocus={currentTouch === false}
width={50}
placeholder='Search...'
onChange={({ target: { value } }) => {
customEvents.emit('search', value)

View file

@ -2,14 +2,17 @@ import React, { CSSProperties, useEffect, useRef, useState } from 'react'
import { isMobile } from 'prismarine-viewer/viewer/lib/simpleUtils'
import styles from './input.module.css'
interface Props extends React.ComponentProps<'input'> {
interface Props extends Omit<React.ComponentProps<'input'>, 'width'> {
rootStyles?: React.CSSProperties
autoFocus?: boolean
inputRef?: React.RefObject<HTMLInputElement>
validateInput?: (value: string) => CSSProperties | undefined
width?: number
}
export default ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue, ...inputProps }: Props) => {
export default ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue, width, ...inputProps }: Props) => {
if (width) rootStyles = { ...rootStyles, width }
const ref = useRef<HTMLInputElement>(null!)
const [validationStyle, setValidationStyle] = useState<CSSProperties>({})
const [value, setValue] = useState(defaultValue ?? '')

View file

@ -71,7 +71,7 @@ export default ({
</ButtonWithTooltip>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<ButtonWithTooltip
style={{ width: 170 }}
style={{ width: 150 }}
onClick={singleplayerAction}
data-test-id='singleplayer-button'
initialTooltip={{
@ -83,6 +83,14 @@ export default ({
Singleplayer
</ButtonWithTooltip>
<ButtonWithTooltip
disabled={!mapsProvider}
// className={styles['maps-provider']}
icon={pixelartIcons.map}
initialTooltip={{ content: 'Explore maps to play from provider!', placement: 'top-start' }}
onClick={() => mapsProvider && openURL(httpsRegex.test(mapsProvider) ? mapsProvider : 'https://' + mapsProvider, false)}
/>
<ButtonWithTooltip
data-test-id='select-file-folder'
icon={pixelartIcons.folder}
@ -145,14 +153,6 @@ export default ({
<span>A Minecraft client in the browser!</span>
</span>
</div>
{mapsProvider &&
<ButtonWithTooltip
className={styles['maps-provider']}
icon={pixelartIcons.map}
initialTooltip={{ content: 'Explore maps to play from provider!', placement: 'right' }}
onClick={() => openURL(httpsRegex.test(mapsProvider) ? mapsProvider : 'https://' + mapsProvider, false)}
/>}
</div>
)
}

View file

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

View file

@ -2,9 +2,10 @@ import React from 'react'
import Singleplayer from './Singleplayer'
import Input from './Input'
import Button from './Button'
import PixelartIcon from './PixelartIcon'
import PixelartIcon, { pixelartIcons } from './PixelartIcon'
import Select from './Select'
import { BaseServerInfo } from './AddServerOrConnect'
import { useIsSmallWidth } from './simpleHooks'
interface Props extends React.ComponentProps<typeof Singleplayer> {
joinServer: (info: BaseServerInfo, additional: {
@ -52,6 +53,8 @@ export default ({ initialProxies, updateProxies: updateProxiesProp, joinServer,
return styles
}
const isSmallWidth = useIsSmallWidth()
return <Singleplayer
{...props}
firstRowChildrenOverride={<form
@ -93,6 +96,7 @@ export default ({ initialProxies, updateProxies: updateProxiesProp, joinServer,
setQuickConnectIp?.(value)
setServerIp(value)
}}
width={isSmallWidth ? 120 : 180}
/>
<label style={{ fontSize: 10, display: 'flex', alignItems: 'center', gap: 5, height: '100%', marginTop: '-1px' }}>
<input
@ -101,7 +105,7 @@ export default ({ initialProxies, updateProxies: updateProxiesProp, joinServer,
onChange={({ target: { checked } }) => setSave(checked)}
/> Save
</label>
<Button style={{ width: 90 }} type='submit'>Join Server</Button>
<Button style={{ width: 90 }} type='submit'>Connect</Button>
</div>
</form>}
searchRowChildrenOverride={
@ -110,14 +114,18 @@ export default ({ initialProxies, updateProxies: updateProxiesProp, joinServer,
}}
>
<div style={{ display: 'flex', gap: 3, alignItems: 'center' }}>
<span style={{ color: 'lightgray', fontSize: 14 }}>Proxy:</span>
{isSmallWidth
? <PixelartIcon iconName={pixelartIcons.server} styles={{ fontSize: 14, color: 'lightgray', marginLeft: 2 }} onClick={onProfileClick} />
: <span style={{ color: 'lightgray', fontSize: 14 }}>Proxy:</span>}
<Select
initialOptions={proxies.proxies.map(p => { return { value: p, label: p } })}
defaultValue={{ value: proxies.selected, label: proxies.selected }}
updateOptions={(newSel) => {
updateProxies({ proxies: [...proxies.proxies], selected: newSel })
}}
containerStyle={{
width: isSmallWidth ? 140 : 180,
}}
/>
<PixelartIcon iconName='user' styles={{ fontSize: 14, color: 'lightgray', marginLeft: 2 }} onClick={onProfileClick} />
<Input rootStyles={{ width: 80 }} value={username} onChange={({ target: { value } }) => setUsername(value)} />

View file

@ -1,6 +1,7 @@
import { useState } from 'react'
import { useUtilsEffect } from '@zardoy/react-util'
import PixelartIcon from './PixelartIcon'
import { QRCodeSVG } from 'qrcode.react'
import PixelartIcon, { pixelartIcons } from './PixelartIcon'
import Screen from './Screen'
import Button from './Button'
@ -8,7 +9,6 @@ export default ({
code = 'ABCD-EFGH-IJKL-MNOP',
loginLink = 'https://aka.ms/devicelogin',
connectingServer = 'mc.example.comsdlfjsklfjsfjdskfjsj',
warningText = true,
expiresEnd = Date.now() + 1000 * 60 * 5,
setSaveToken = (() => { }) as ((state: boolean) => void) | undefined,
defaultSaveToken = true,
@ -28,7 +28,7 @@ export default ({
})
}, [])
return <Screen title='Microsoft Account Authentication'>
return <Screen title='Microsoft Account Authentication' titleMarginTop={5}>
<div style={{
background: 'white',
padding: '20px 18px',
@ -83,19 +83,25 @@ export default ({
fontWeight: 600,
}}
target='_blank'
>{loginLink}
>{loginLink.replace(/(https?:\/\/)?(www\.)?/, '')}
</a>
{' '}
and enter the code above.
</div>
{warningText && <div style={{
fontSize: 12,
<div style={{
fontSize: 11,
marginTop: 5,
color: 'gray'
color: 'gray',
display: 'flex',
gap: 2
}}
>
<PixelartIcon iconName='alert' /> Join only <b>vanilla servers</b>! This client is detectable and may result in a ban by anti-cheat plugins.
</div>}
<div>
<PixelartIcon iconName={pixelartIcons.alert} styles={{ display: 'inline-block', }} />
Join only <b>vanilla servers</b>! This client is detectable and may result in a ban by anti-cheat plugins.
</div>
<QRCodeSVG size={40} value={directLink} style={{ display: 'block', flexShrink: 0 }} color='gray' />
</div>
{setSaveToken && <label style={{
fontSize: 12,
display: 'flex',

View file

@ -1,6 +1,7 @@
import { proxy, ref, useSnapshot } from 'valtio'
import SignInMessage from './SignInMessage'
import { lastConnectOptions } from './AppStatusProvider'
import { useIsModalActive } from './utilsApp'
export const signInMessageState = proxy({
code: '',
@ -12,8 +13,9 @@ export const signInMessageState = proxy({
export default () => {
const { code, expiresOn, link, shouldSaveToken } = useSnapshot(signInMessageState)
const signInTestModal = useIsModalActive('sign-in-test')
if (!code) return null
if (!code && !signInTestModal) return null
return <SignInMessage
code={code}

View file

@ -11,6 +11,7 @@ import Input from './Input'
import Button from './Button'
import Tabs from './Tabs'
import MessageFormattedString from './MessageFormattedString'
import { useIsSmallWidth } from './simpleHooks'
export interface WorldProps {
name: string
@ -146,6 +147,8 @@ export default ({
onRowSelect?.(name, index)
setFocusedWorld(name)
}
const isSmallWidth = useIsSmallWidth()
return <div ref={containerRef} hidden={hidden}>
<div className="dirt-bg" />
<div className={classNames('fullscreen', styles.root)}>
@ -209,12 +212,15 @@ export default ({
}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 400, paddingBottom: 3 }}>
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 400, paddingBottom: 3, alignItems: 'center', }}>
{firstRowChildrenOverride || <div>
<Button rootRef={firstButton} disabled={!focusedWorld} onClick={() => onWorldAction('load', focusedWorld)}>Load World</Button>
<Button onClick={() => onGeneralAction('create')} disabled={isReadonly}>Create New World</Button>
</div>}
<div style={{ ...secondRowStyles }}>
<div style={{
...secondRowStyles,
...isSmallWidth ? { display: 'grid', gridTemplateColumns: '1fr 1fr' } : {}
}}>
{serversLayout ? <Button style={{ width: 100 }} disabled={!focusedWorld || lockedEditing} onClick={() => onWorldAction('edit', focusedWorld)}>Edit</Button> : <Button style={{ width: 100 }} disabled={!focusedWorld} onClick={() => onWorldAction('export', focusedWorld)}>Export</Button>}
<Button style={{ width: 100 }} disabled={!focusedWorld || lockedEditing} onClick={() => onWorldAction('delete', focusedWorld)}>Delete</Button>
{serversLayout ?

View file

@ -57,8 +57,8 @@ export default () => {
style={{ zIndex: modals.length ? 7 : 8 }}
className={css`
position: fixed;
inset: 0;
height: 100%;
bottom: 0;
/* height: 100%; */
display: flex;
width: 100%;
justify-content: space-between;

View file

@ -100,10 +100,10 @@
color: white;
text-shadow: 1px 1px #222;
font-size: 10px;
padding-left: calc(env(safe-area-inset-left) / 2);
}
.product-info {
padding-left: calc(env(safe-area-inset-left) / 2);
}
.product-description {

View file

@ -14,10 +14,8 @@ interface CssExports {
minec: string;
minecraft: string;
'product-description': string;
'product-info': string;
'product-link': string;
productDescription: string;
productInfo: string;
productLink: string;
raft: string;
root: string;

View file

@ -1,4 +1,5 @@
import { useUtilsEffect } from '@zardoy/react-util'
import { useEffect, useState } from 'react'
import { useMedia } from 'react-use'
const SMALL_SCREEN_MEDIA = '@media (max-width: 440px)'
@ -25,3 +26,19 @@ export const useCopyKeybinding = (getCopyText: () => string | undefined) => {
}, { signal })
}, [getCopyText])
}
export const useIsHashActive = (hash: `#${string}`) => {
const [isActive, setIsActive] = useState(false)
useEffect(() => {
const checkHash = () => {
setIsActive(location.hash === hash)
}
checkHash()
addEventListener('hashchange', checkHash)
return () => {
removeEventListener('hashchange', checkHash)
}
}, [])
return isActive
}

View file

@ -45,6 +45,7 @@ import SignInMessageProvider from './react/SignInMessageProvider'
import BookProvider from './react/BookProvider'
import { options } from './optionsStorage'
import BossBarOverlayProvider from './react/BossBarOverlayProvider'
import DebugEdges from './react/DebugEdges'
const RobustPortal = ({ children, to }) => {
return createPortal(<PerComponentErrorBoundary>{children}</PerComponentErrorBoundary>, to)
@ -198,6 +199,7 @@ const App = () => {
<GamepadUiCursor />
</div>
<div />
<DebugEdges />
</RobustPortal>
</ButtonAppProvider>
</div>

View file

@ -5,7 +5,7 @@ import { options } from './optionsStorage'
export const watchFov = () => {
const updateFov = () => {
if (!bot) return
let fovSetting = options.fov
let fovSetting = gameAdditionalState.isZooming ? 30 : options.fov
// todo check values and add transition
if (bot.controlState.sprint && !bot.controlState.sneak) {
fovSetting += 5
@ -20,6 +20,7 @@ export const watchFov = () => {
subscribeKey(options, 'fov', updateFov)
subscribeKey(gameAdditionalState, 'isFlying', updateFov)
subscribeKey(gameAdditionalState, 'isSprinting', updateFov)
subscribeKey(gameAdditionalState, 'isZooming', updateFov)
subscribeKey(gameAdditionalState, 'isSneaking', () => {
viewer.isSneaking = gameAdditionalState.isSneaking
viewer.setFirstPersonCamera(bot.entity.position, bot.entity.yaw, bot.entity.pitch)

View file

@ -37,6 +37,8 @@
/* todo I'm not sure about it */
/* margin-top: calc(100% / 6 - 16px); */
align-items: center;
/* apply safe area padding */
padding: 0 calc(env(safe-area-inset-left) / 2) 0 calc(env(safe-area-inset-right) / 2);
gap: 10px;
}

View file

@ -114,14 +114,16 @@ const updateStatsPerSecAvg = () => {
window.statsPerSec = {}
let statsPerSecCurrent = {}
let lastReset = performance.now()
window.addStatPerSec = (name) => {
statsPerSecCurrent[name] ??= 0
statsPerSecCurrent[name]++
}
window.statsPerSecCurrent = statsPerSecCurrent
setInterval(() => {
window.statsPerSec = statsPerSecCurrent
window.statsPerSec = { duration: Math.floor(performance.now() - lastReset), ...statsPerSecCurrent, }
statsPerSecCurrent = {}
window.statsPerSecCurrent = statsPerSecCurrent
updateStatsPerSecAvg()
lastReset = performance.now()
}, 1000)

95
src/viewerConnector.ts Normal file
View file

@ -0,0 +1,95 @@
import { EventEmitter } from 'events'
import { Duplex } from 'stream'
import states from 'minecraft-protocol/src/states'
import { createClient } from 'minecraft-protocol'
class CustomDuplex extends Duplex {
constructor (options, public writeAction) {
super(options)
}
override _read () {}
override _write (chunk, encoding, callback) {
this.writeAction(chunk)
callback()
}
}
export const getViewerVersionData = async (url: string) => {
const ws = await openWebsocket(url)
ws.send('version')
return new Promise<{
version: string
time: number,
clientIgnoredPackets?: string[]
}>((resolve, reject) => {
ws.addEventListener('message', async (message) => {
const { data } = message
const parsed = JSON.parse(data.toString())
resolve(parsed)
ws.close()
// todo
customEvents.on('mineflayerBotCreated', () => {
const client = bot._client as any
const oldWrite = client.write.bind(client)
client.write = (...args) => {
const [name] = args
if (parsed?.clientIgnoredPackets?.includes(name)) {
return
}
oldWrite(...args)
}
})
})
})
}
const openWebsocket = async (url: string) => {
if (url.startsWith(':')) url = `ws://localhost${url}`
if (!url.startsWith('ws')) url = `ws://${url}`
const ws = new WebSocket(url)
await new Promise<void>((resolve, reject) => {
ws.onopen = () => resolve()
ws.onerror = (err) => reject(new Error(`[websocket] Failed to connect to ${url}`))
ws.onclose = (ev) => reject(ev.reason)
})
return ws
}
export const getWsProtocolStream = async (url: string) => {
const ws = await openWebsocket(url)
const clientDuplex = new CustomDuplex(undefined, data => {
// console.log('send', Buffer.from(data).toString('hex'))
ws.send(data)
})
// todo use keep alive instead?
let lastMessageTime = performance.now()
ws.addEventListener('message', async (message) => {
let { data } = message
if (data instanceof Blob) {
data = await data.arrayBuffer()
}
clientDuplex.push(Buffer.from(data))
lastMessageTime = performance.now()
})
setInterval(() => {
// if (clientDuplex.destroyed) return
// if (performance.now() - lastMessageTime > 10_000) {
// console.log('no packats received in 10s!')
// clientDuplex.end()
// }
}, 5000)
ws.addEventListener('close', () => {
console.log('ws closed')
clientDuplex.end()
// bot.emit('end', 'Disconnected.')
})
ws.addEventListener('error', err => {
console.log('ws error', err)
})
return clientDuplex
}