Next month release (#240)
This commit is contained in:
commit
0896b61df0
41 changed files with 1081 additions and 214 deletions
22
README.MD
22
README.MD
|
|
@ -2,23 +2,27 @@
|
|||
|
||||

|
||||
|
||||
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
57
TECH.md
Normal 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) |
|
||||
|
|
@ -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
20
pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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++
|
||||
|
|
|
|||
158
prismarine-viewer/viewer/lib/entity/armorModels.json
Normal file
158
prismarine-viewer/viewer/lib/entity/armorModels.json
Normal 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
|
||||
}
|
||||
}
|
||||
36
prismarine-viewer/viewer/lib/entity/armorModels.ts
Normal file
36
prismarine-viewer/viewer/lib/entity/armorModels.ts
Normal 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'
|
||||
|
|
@ -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]}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export * as externalModels from './exportedModels'
|
||||
export * as armorModels from './armorModels'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ export const gameAdditionalState = proxy({
|
|||
isFlying: false,
|
||||
isSprinting: false,
|
||||
isSneaking: false,
|
||||
isZooming: false,
|
||||
warps: [] as WorldWarp[]
|
||||
})
|
||||
|
||||
|
|
|
|||
61
src/index.ts
61
src/index.ts
|
|
@ -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
55
src/react/DebugEdges.tsx
Normal 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>
|
||||
}
|
||||
|
|
@ -17,7 +17,6 @@ function InnerSearch () {
|
|||
>
|
||||
<Input
|
||||
autoFocus={currentTouch === false}
|
||||
width={50}
|
||||
placeholder='Search...'
|
||||
onChange={({ target: { value } }) => {
|
||||
customEvents.emit('search', value)
|
||||
|
|
|
|||
|
|
@ -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 ?? '')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 ?
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
2
src/react/mainMenu.module.css.d.ts
vendored
2
src/react/mainMenu.module.css.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
95
src/viewerConnector.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue