Merge branch 'next' into light-engine
This commit is contained in:
commit
3cd1ac3666
80 changed files with 3081 additions and 267 deletions
|
|
@ -12,6 +12,7 @@ For building the project yourself / contributing, see [Development, Debugging &
|
|||
|
||||
### Big Features
|
||||
|
||||
- Official Mineflayer [plugin integration](https://github.com/zardoy/mcraft-fun-mineflayer-plugin)! View / Control your bot remotely.
|
||||
- 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 clone capable of opening Java world saves in any way (folders, zip, web chunks streaming, etc)
|
||||
|
|
|
|||
39
assets/config.html
Normal file
39
assets/config.html
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Configure client</title>
|
||||
<script>
|
||||
function removeSettings() {
|
||||
if (confirm('Are you sure you want to RESET ALL SETTINGS?')) {
|
||||
localStorage.setItem('options', '{}');
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
function removeAllData() {
|
||||
localStorage.removeItem('serversList')
|
||||
localStorage.removeItem('serversHistory')
|
||||
localStorage.removeItem('authenticatedAccounts')
|
||||
localStorage.removeItem('modsAutoUpdateLastCheck')
|
||||
localStorage.removeItem('firstModsPageVisit')
|
||||
localStorage.removeItem('proxiesData')
|
||||
localStorage.removeItem('keybindings')
|
||||
localStorage.removeItem('username')
|
||||
localStorage.removeItem('customCommands')
|
||||
localStorage.removeItem('options')
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div style="display: flex;gap: 10px;">
|
||||
<button onclick="removeSettings()">Reset all settings</button>
|
||||
<button onclick="removeAllData()">Remove all user data (but not mods or worlds)</button>
|
||||
<!-- <button>Remove all user data (worlds, resourcepacks)</button> -->
|
||||
<!-- <button>Remove all mods</button> -->
|
||||
<!-- <button>Remove all mod repositories</button> -->
|
||||
</div>
|
||||
<input />
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -25,6 +25,8 @@
|
|||
"description": "Very nice a polite server. Must try for everyone!"
|
||||
}
|
||||
],
|
||||
"rightSideText": "A Minecraft client clone in the browser!",
|
||||
"splashText": "Gen is cooking!",
|
||||
"pauseLinks": [
|
||||
[
|
||||
{
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@
|
|||
"google-drive-browserfs": "github:zardoy/browserfs#google-drive",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mcraft-fun-mineflayer": "^0.1.14",
|
||||
"mcraft-fun-mineflayer": "^0.1.21",
|
||||
"minecraft-data": "3.83.1",
|
||||
"minecraft-protocol": "github:PrismarineJS/node-minecraft-protocol#master",
|
||||
"mineflayer-item-map-downloader": "github:zardoy/mineflayer-item-map-downloader",
|
||||
|
|
@ -151,7 +151,7 @@
|
|||
"http-browserify": "^1.7.0",
|
||||
"http-server": "^14.1.1",
|
||||
"https-browserify": "^1.0.0",
|
||||
"mc-assets": "^0.2.52",
|
||||
"mc-assets": "^0.2.53",
|
||||
"minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next",
|
||||
"mineflayer": "github:GenerelSchwerz/mineflayer",
|
||||
"mineflayer-mouse": "^0.1.7",
|
||||
|
|
@ -196,7 +196,7 @@
|
|||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@nxg-org/mineflayer-physics-util": "1.8.6",
|
||||
"@nxg-org/mineflayer-physics-util": "1.8.7",
|
||||
"buffer": "^6.0.3",
|
||||
"vec3": "0.1.10",
|
||||
"three": "0.154.0",
|
||||
|
|
|
|||
42
pnpm-lock.yaml
generated
42
pnpm-lock.yaml
generated
|
|
@ -5,7 +5,7 @@ settings:
|
|||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
'@nxg-org/mineflayer-physics-util': 1.8.6
|
||||
'@nxg-org/mineflayer-physics-util': 1.8.7
|
||||
buffer: ^6.0.3
|
||||
vec3: 0.1.10
|
||||
three: 0.154.0
|
||||
|
|
@ -132,8 +132,8 @@ importers:
|
|||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
mcraft-fun-mineflayer:
|
||||
specifier: ^0.1.14
|
||||
version: 0.1.14(encoding@0.1.13)(mineflayer@https://codeload.github.com/GenerelSchwerz/mineflayer/tar.gz/d459d2ed76a997af1a7c94718ed7d5dee4478b8a(encoding@0.1.13))
|
||||
specifier: ^0.1.21
|
||||
version: 0.1.21(encoding@0.1.13)(mineflayer@https://codeload.github.com/GenerelSchwerz/mineflayer/tar.gz/d459d2ed76a997af1a7c94718ed7d5dee4478b8a(encoding@0.1.13))
|
||||
minecraft-data:
|
||||
specifier: 3.83.1
|
||||
version: 3.83.1
|
||||
|
|
@ -337,11 +337,11 @@ importers:
|
|||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
mc-assets:
|
||||
specifier: ^0.2.52
|
||||
version: 0.2.52
|
||||
specifier: ^0.2.53
|
||||
version: 0.2.53
|
||||
minecraft-inventory-gui:
|
||||
specifier: github:zardoy/minecraft-inventory-gui#next
|
||||
version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f57dd78ca8e3b7cdd724d4272d8cbf6743b0cf00(@types/react@18.3.18)(react@18.3.1)
|
||||
version: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/98bc5bb8ee6da8b4b771c05b404cee796318ccd4(@types/react@18.3.18)(react@18.3.1)
|
||||
minecraft-lighting:
|
||||
specifier: file:../minecraft-lighting
|
||||
version: file:../minecraft-lighting
|
||||
|
|
@ -2028,8 +2028,8 @@ packages:
|
|||
'@nxg-org/mineflayer-auto-jump@0.7.12':
|
||||
resolution: {integrity: sha512-F5vX/lerlWx/5HVlkDNbvrtQ19PL6iG8i4ItPTIRtjGiFzusDefP7DI226zSFR8Wlaw45qHv0jn814p/4/qVdQ==}
|
||||
|
||||
'@nxg-org/mineflayer-physics-util@1.8.6':
|
||||
resolution: {integrity: sha512-eRn9e9OMvl1+kEfwPPshAl1A5MX0eDWaI7WVRf7ht9qo9N3fKiw+mM/AGPuhVjEr16zUls77P6Sn9cVZJuUdlw==}
|
||||
'@nxg-org/mineflayer-physics-util@1.8.7':
|
||||
resolution: {integrity: sha512-wtLYvHqoEFr/j0ny2lyogwjbMvwpFuG2aWI8sI14+EAiGFRpL5+cog2ujSDsnRTZruO7tUXMTiPc1kebjXwfJg==}
|
||||
|
||||
'@nxg-org/mineflayer-tracker@1.2.1':
|
||||
resolution: {integrity: sha512-SI1ffF8zvg3/ZNE021Ja2W0FZPN+WbQDZf8yFqOcXtPRXAtM9W6HvoACdzXep8BZid7WYgYLIgjKpB+9RqvCNQ==}
|
||||
|
|
@ -6458,13 +6458,13 @@ packages:
|
|||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
mc-assets@0.2.52:
|
||||
resolution: {integrity: sha512-6mUI63fcUIjB0Ghjls7bLMnse2XUgvhPajsFkRQf10PcXYbfS/OAnX51X8sNx2pzfoHSlA81U7v+v906YwoAUw==}
|
||||
mc-assets@0.2.53:
|
||||
resolution: {integrity: sha512-Ucsu2pDLr/cs8bxbxU9KTszdf/vPTLphYgEHUEWxuYlMkPQUCpsQwkn3YgyykJ7RXaca7zZGlZXaTPXBAqJT6A==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
mcraft-fun-mineflayer@0.1.14:
|
||||
resolution: {integrity: sha512-q/qXQaNbkGJIvXjRvudUT7/k0EsJgphFcvYjrSRWYyGDJeb61MKRVqq1hhMjqx7UK7FMfBKvjfPSxq/QlAP7WQ==}
|
||||
version: 0.1.14
|
||||
mcraft-fun-mineflayer@0.1.21:
|
||||
resolution: {integrity: sha512-FtzebYMvLvunApQy9ilF1RGqiX01DJn8y7q4xAONiIhBrIT7BrHK3O63IA50YgklldvdgVxn7s3m4QANvsH2JA==}
|
||||
version: 0.1.21
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
peerDependencies:
|
||||
'@roamhq/wrtc': '*'
|
||||
|
|
@ -6676,8 +6676,8 @@ packages:
|
|||
minecraft-folder-path@1.2.0:
|
||||
resolution: {integrity: sha512-qaUSbKWoOsH9brn0JQuBhxNAzTDMwrOXorwuRxdJKKKDYvZhtml+6GVCUrY5HRiEsieBEjCUnhVpDuQiKsiFaw==}
|
||||
|
||||
minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f57dd78ca8e3b7cdd724d4272d8cbf6743b0cf00:
|
||||
resolution: {tarball: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f57dd78ca8e3b7cdd724d4272d8cbf6743b0cf00}
|
||||
minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/98bc5bb8ee6da8b4b771c05b404cee796318ccd4:
|
||||
resolution: {tarball: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/98bc5bb8ee6da8b4b771c05b404cee796318ccd4}
|
||||
version: 1.0.1
|
||||
|
||||
minecraft-lighting@file:../minecraft-lighting:
|
||||
|
|
@ -11359,10 +11359,10 @@ snapshots:
|
|||
|
||||
'@nxg-org/mineflayer-auto-jump@0.7.12':
|
||||
dependencies:
|
||||
'@nxg-org/mineflayer-physics-util': 1.8.6
|
||||
'@nxg-org/mineflayer-physics-util': 1.8.7
|
||||
strict-event-emitter-types: 2.0.0
|
||||
|
||||
'@nxg-org/mineflayer-physics-util@1.8.6':
|
||||
'@nxg-org/mineflayer-physics-util@1.8.7':
|
||||
dependencies:
|
||||
'@nxg-org/mineflayer-util-plugin': 1.8.4
|
||||
|
||||
|
|
@ -16994,12 +16994,12 @@ snapshots:
|
|||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mc-assets@0.2.52:
|
||||
mc-assets@0.2.53:
|
||||
dependencies:
|
||||
maxrects-packer: '@zardoy/maxrects-packer@2.7.4'
|
||||
zod: 3.24.2
|
||||
|
||||
mcraft-fun-mineflayer@0.1.14(encoding@0.1.13)(mineflayer@https://codeload.github.com/GenerelSchwerz/mineflayer/tar.gz/d459d2ed76a997af1a7c94718ed7d5dee4478b8a(encoding@0.1.13)):
|
||||
mcraft-fun-mineflayer@0.1.21(encoding@0.1.13)(mineflayer@https://codeload.github.com/GenerelSchwerz/mineflayer/tar.gz/d459d2ed76a997af1a7c94718ed7d5dee4478b8a(encoding@0.1.13)):
|
||||
dependencies:
|
||||
'@zardoy/flying-squid': 0.0.49(encoding@0.1.13)
|
||||
exit-hook: 2.2.1
|
||||
|
|
@ -17315,7 +17315,7 @@ snapshots:
|
|||
|
||||
minecraft-folder-path@1.2.0: {}
|
||||
|
||||
minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/f57dd78ca8e3b7cdd724d4272d8cbf6743b0cf00(@types/react@18.3.18)(react@18.3.1):
|
||||
minecraft-inventory-gui@https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/98bc5bb8ee6da8b4b771c05b404cee796318ccd4(@types/react@18.3.18)(react@18.3.1):
|
||||
dependencies:
|
||||
valtio: 1.13.2(@types/react@18.3.18)(react@18.3.1)
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -17447,7 +17447,7 @@ snapshots:
|
|||
|
||||
mineflayer@https://codeload.github.com/GenerelSchwerz/mineflayer/tar.gz/d459d2ed76a997af1a7c94718ed7d5dee4478b8a(encoding@0.1.13):
|
||||
dependencies:
|
||||
'@nxg-org/mineflayer-physics-util': 1.8.6
|
||||
'@nxg-org/mineflayer-physics-util': 1.8.7
|
||||
minecraft-data: 3.83.1
|
||||
minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/9e116c3dd4682b17c4e2c80249a2447a093d9284(patch_hash=3a55a278c417cc34ff3172cd1de8e22852935cba0586875cbd0635f1ffdaa5ab)(encoding@0.1.13)
|
||||
prismarine-biome: 1.3.0(minecraft-data@3.83.1)(prismarine-registry@1.11.0)
|
||||
|
|
|
|||
|
|
@ -108,6 +108,10 @@ export const rspackViewerConfig = (config, { appendPlugins, addRules, rspack }:
|
|||
{
|
||||
test: /\.txt$/,
|
||||
type: 'asset/source',
|
||||
},
|
||||
{
|
||||
test: /\.log$/,
|
||||
type: 'asset/source',
|
||||
}
|
||||
])
|
||||
config.ignoreWarnings = [
|
||||
|
|
|
|||
|
|
@ -423,13 +423,19 @@ function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO:
|
|||
|
||||
if (!needTiles) {
|
||||
if (doAO && aos[0] + aos[3] >= aos[1] + aos[2]) {
|
||||
attr.indices.push(
|
||||
ndx, ndx + 3, ndx + 2, ndx, ndx + 1, ndx + 3
|
||||
)
|
||||
attr.indices[attr.indicesCount++] = ndx
|
||||
attr.indices[attr.indicesCount++] = ndx + 3
|
||||
attr.indices[attr.indicesCount++] = ndx + 2
|
||||
attr.indices[attr.indicesCount++] = ndx
|
||||
attr.indices[attr.indicesCount++] = ndx + 1
|
||||
attr.indices[attr.indicesCount++] = ndx + 3
|
||||
} else {
|
||||
attr.indices.push(
|
||||
ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3
|
||||
)
|
||||
attr.indices[attr.indicesCount++] = ndx
|
||||
attr.indices[attr.indicesCount++] = ndx + 1
|
||||
attr.indices[attr.indicesCount++] = ndx + 2
|
||||
attr.indices[attr.indicesCount++] = ndx + 2
|
||||
attr.indices[attr.indicesCount++] = ndx + 1
|
||||
attr.indices[attr.indicesCount++] = ndx + 3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -463,6 +469,8 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
|
|||
t_colors: [],
|
||||
t_uvs: [],
|
||||
indices: [],
|
||||
indicesCount: 0, // Track current index position
|
||||
using32Array: true,
|
||||
tiles: {},
|
||||
// todo this can be removed here
|
||||
heads: {},
|
||||
|
|
@ -605,12 +613,19 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
|
|||
|
||||
let ndx = attr.positions.length / 3
|
||||
for (let i = 0; i < attr.t_positions!.length / 12; i++) {
|
||||
attr.indices.push(
|
||||
ndx, ndx + 1, ndx + 2, ndx + 2, ndx + 1, ndx + 3,
|
||||
// eslint-disable-next-line @stylistic/function-call-argument-newline
|
||||
// back face
|
||||
ndx, ndx + 2, ndx + 1, ndx + 2, ndx + 3, ndx + 1
|
||||
)
|
||||
attr.indices[attr.indicesCount++] = ndx
|
||||
attr.indices[attr.indicesCount++] = ndx + 1
|
||||
attr.indices[attr.indicesCount++] = ndx + 2
|
||||
attr.indices[attr.indicesCount++] = ndx + 2
|
||||
attr.indices[attr.indicesCount++] = ndx + 1
|
||||
attr.indices[attr.indicesCount++] = ndx + 3
|
||||
// back face
|
||||
attr.indices[attr.indicesCount++] = ndx
|
||||
attr.indices[attr.indicesCount++] = ndx + 2
|
||||
attr.indices[attr.indicesCount++] = ndx + 1
|
||||
attr.indices[attr.indicesCount++] = ndx + 2
|
||||
attr.indices[attr.indicesCount++] = ndx + 3
|
||||
attr.indices[attr.indicesCount++] = ndx + 1
|
||||
ndx += 4
|
||||
}
|
||||
|
||||
|
|
@ -628,6 +643,12 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
|
|||
attr.normals = new Float32Array(attr.normals) as any
|
||||
attr.colors = new Float32Array(attr.colors) as any
|
||||
attr.uvs = new Float32Array(attr.uvs) as any
|
||||
attr.using32Array = arrayNeedsUint32(attr.indices)
|
||||
if (attr.using32Array) {
|
||||
attr.indices = new Uint32Array(attr.indices)
|
||||
} else {
|
||||
attr.indices = new Uint16Array(attr.indices)
|
||||
}
|
||||
|
||||
if (needTiles) {
|
||||
delete attr.positions
|
||||
|
|
@ -639,6 +660,21 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
|
|||
return attr
|
||||
}
|
||||
|
||||
// copied from three.js
|
||||
function arrayNeedsUint32 (array) {
|
||||
|
||||
// assumes larger values usually on last
|
||||
|
||||
for (let i = array.length - 1; i >= 0; -- i) {
|
||||
|
||||
if (array[i] >= 65_535) return true // account for PRIMITIVE_RESTART_FIXED_INDEX, #24565
|
||||
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
}
|
||||
|
||||
export const setBlockStatesData = (blockstatesModels, blocksAtlas: any, _needTiles = false, useUnknownBlockModel = true, version = 'latest') => {
|
||||
blockProvider = worldBlockProvider(blockstatesModels, blocksAtlas, version)
|
||||
globalThis.blockProvider = blockProvider
|
||||
|
|
|
|||
|
|
@ -33,7 +33,9 @@ export type MesherGeometryOutput = {
|
|||
t_colors?: number[],
|
||||
t_uvs?: number[],
|
||||
|
||||
indices: number[],
|
||||
indices: Uint32Array | Uint16Array | number[],
|
||||
indicesCount: number,
|
||||
using32Array: boolean,
|
||||
tiles: Record<string, BlockType>,
|
||||
heads: Record<string, any>,
|
||||
signs: Record<string, any>,
|
||||
|
|
|
|||
131
renderer/viewer/lib/mesherlogReader.ts
Normal file
131
renderer/viewer/lib/mesherlogReader.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/* eslint-disable no-await-in-loop */
|
||||
import { Vec3 } from 'vec3'
|
||||
|
||||
// import log from '../../../../../Downloads/mesher (2).log'
|
||||
import { WorldRendererCommon } from './worldrendererCommon'
|
||||
const log = ''
|
||||
|
||||
|
||||
export class MesherLogReader {
|
||||
chunksToReceive: Array<{
|
||||
x: number
|
||||
z: number
|
||||
chunkLength: number
|
||||
}> = []
|
||||
messagesQueue: Array<{
|
||||
fromWorker: boolean
|
||||
workerIndex: number
|
||||
message: any
|
||||
}> = []
|
||||
|
||||
sectionFinishedToReceive = null as {
|
||||
messagesLeft: string[]
|
||||
resolve: () => void
|
||||
} | null
|
||||
replayStarted = false
|
||||
|
||||
constructor (private readonly worldRenderer: WorldRendererCommon) {
|
||||
this.parseMesherLog()
|
||||
}
|
||||
|
||||
chunkReceived (x: number, z: number, chunkLength: number) {
|
||||
// remove existing chunks with same x and z
|
||||
const existingChunkIndex = this.chunksToReceive.findIndex(chunk => chunk.x === x && chunk.z === z)
|
||||
if (existingChunkIndex === -1) {
|
||||
// console.error('Chunk not found', x, z)
|
||||
} else {
|
||||
// warn if chunkLength is different
|
||||
if (this.chunksToReceive[existingChunkIndex].chunkLength !== chunkLength) {
|
||||
// console.warn('Chunk length mismatch', x, z, this.chunksToReceive[existingChunkIndex].chunkLength, chunkLength)
|
||||
}
|
||||
// remove chunk
|
||||
this.chunksToReceive = this.chunksToReceive.filter((chunk, index) => chunk.x !== x || chunk.z !== z)
|
||||
}
|
||||
this.maybeStartReplay()
|
||||
}
|
||||
|
||||
async maybeStartReplay () {
|
||||
if (this.chunksToReceive.length !== 0 || this.replayStarted) return
|
||||
const lines = log.split('\n')
|
||||
console.log('starting replay')
|
||||
this.replayStarted = true
|
||||
const waitForWorkersMessages = async () => {
|
||||
if (!this.sectionFinishedToReceive) return
|
||||
await new Promise<void>(resolve => {
|
||||
this.sectionFinishedToReceive!.resolve = resolve
|
||||
})
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('dispatchMessages dirty')) {
|
||||
await waitForWorkersMessages()
|
||||
this.worldRenderer.stopMesherMessagesProcessing = true
|
||||
const message = JSON.parse(line.slice(line.indexOf('{'), line.lastIndexOf('}') + 1))
|
||||
if (!message.value) continue
|
||||
const index = line.split(' ')[1]
|
||||
const type = line.split(' ')[3]
|
||||
// console.log('sending message', message.x, message.y, message.z)
|
||||
this.worldRenderer.forceCallFromMesherReplayer = true
|
||||
this.worldRenderer.setSectionDirty(new Vec3(message.x, message.y, message.z), message.value)
|
||||
this.worldRenderer.forceCallFromMesherReplayer = false
|
||||
}
|
||||
if (line.includes('-> blockUpdate')) {
|
||||
await waitForWorkersMessages()
|
||||
this.worldRenderer.stopMesherMessagesProcessing = true
|
||||
const message = JSON.parse(line.slice(line.indexOf('{'), line.lastIndexOf('}') + 1))
|
||||
this.worldRenderer.forceCallFromMesherReplayer = true
|
||||
this.worldRenderer.setBlockStateIdInner(new Vec3(message.pos.x, message.pos.y, message.pos.z), message.stateId)
|
||||
this.worldRenderer.forceCallFromMesherReplayer = false
|
||||
}
|
||||
|
||||
if (line.includes(' sectionFinished ')) {
|
||||
if (!this.sectionFinishedToReceive) {
|
||||
console.log('starting worker message processing validating')
|
||||
this.worldRenderer.stopMesherMessagesProcessing = false
|
||||
this.sectionFinishedToReceive = {
|
||||
messagesLeft: [],
|
||||
resolve: () => {
|
||||
this.sectionFinishedToReceive = null
|
||||
}
|
||||
}
|
||||
}
|
||||
const parts = line.split(' ')
|
||||
const coordsPart = parts.find(part => part.split(',').length === 3)
|
||||
if (!coordsPart) throw new Error(`no coords part found ${line}`)
|
||||
const [x, y, z] = coordsPart.split(',').map(Number)
|
||||
this.sectionFinishedToReceive.messagesLeft.push(`${x},${y},${z}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
workerMessageReceived (type: string, message: any) {
|
||||
if (type === 'sectionFinished') {
|
||||
const { key } = message
|
||||
if (!this.sectionFinishedToReceive) {
|
||||
console.warn(`received sectionFinished message but no sectionFinishedToReceive ${key}`)
|
||||
return
|
||||
}
|
||||
|
||||
const idx = this.sectionFinishedToReceive.messagesLeft.indexOf(key)
|
||||
if (idx === -1) {
|
||||
console.warn(`received sectionFinished message for non-outstanding section ${key}`)
|
||||
return
|
||||
}
|
||||
this.sectionFinishedToReceive.messagesLeft.splice(idx, 1)
|
||||
if (this.sectionFinishedToReceive.messagesLeft.length === 0) {
|
||||
this.sectionFinishedToReceive.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parseMesherLog () {
|
||||
const lines = log.split('\n')
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('-> chunk')) {
|
||||
const chunk = JSON.parse(line.slice('-> chunk'.length))
|
||||
this.chunksToReceive.push(chunk)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ export const addNewStat = (id: string, width = 80, x = rightOffset, y = lastY) =
|
|||
|
||||
return {
|
||||
updateText (text: string) {
|
||||
if (pane.innerText === text || pane.style.display === 'none') return
|
||||
if (pane.innerText === text) return
|
||||
pane.innerText = text
|
||||
},
|
||||
setVisibility (visible: boolean) {
|
||||
|
|
|
|||
|
|
@ -36,10 +36,18 @@ export type WorldDataEmitterEvents = {
|
|||
* It's up to the consumer to serialize the data if needed
|
||||
*/
|
||||
export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<WorldDataEmitterEvents>) {
|
||||
private loadedChunks: Record<ChunkPosKey, boolean>
|
||||
private readonly lastPos: Vec3
|
||||
loadedChunks: Record<ChunkPosKey, boolean>
|
||||
readonly lastPos: Vec3
|
||||
private eventListeners: Record<string, any> = {}
|
||||
private readonly emitter: WorldDataEmitter
|
||||
debugChunksInfo: Record<ChunkPosKey, {
|
||||
loads: Array<{
|
||||
dataLength: number
|
||||
reason: string
|
||||
time: number
|
||||
}>
|
||||
// blockUpdates: number
|
||||
}> = {}
|
||||
|
||||
waitingSpiralChunksLoad = {} as Record<ChunkPosKey, (value: boolean) => void>
|
||||
|
||||
|
|
@ -65,12 +73,12 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
|
|||
setBlockStateId (position: Vec3, stateId: number) {
|
||||
const val = this.world.setBlockStateId(position, stateId) as Promise<void> | void
|
||||
if (val) throw new Error('setBlockStateId returned promise (not supported)')
|
||||
const chunkX = Math.floor(position.x / 16)
|
||||
const chunkZ = Math.floor(position.z / 16)
|
||||
if (!this.loadedChunks[`${chunkX},${chunkZ}`] && !this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`]) {
|
||||
void this.loadChunk({ x: chunkX, z: chunkZ })
|
||||
return
|
||||
}
|
||||
// const chunkX = Math.floor(position.x / 16)
|
||||
// const chunkZ = Math.floor(position.z / 16)
|
||||
// if (!this.loadedChunks[`${chunkX},${chunkZ}`] && !this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`]) {
|
||||
// void this.loadChunk({ x: chunkX, z: chunkZ })
|
||||
// return
|
||||
// }
|
||||
|
||||
updateBlockLight(position.x, position.y, position.z, stateId)
|
||||
this.emit('blockUpdate', { pos: position, stateId })
|
||||
|
|
@ -117,6 +125,8 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
|
|||
if (this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`]) {
|
||||
this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`](true)
|
||||
delete this.waitingSpiralChunksLoad[`${pos.x},${pos.z}`]
|
||||
} else if (this.loadedChunks[`${pos.x},${pos.z}`]) {
|
||||
void this.loadChunk(pos, false, 'Received another chunkColumnLoad event while already loaded')
|
||||
}
|
||||
},
|
||||
chunkColumnUnload: (pos: Vec3) => {
|
||||
|
|
@ -140,8 +150,8 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
|
|||
|
||||
bot._client.on('update_light', ({ chunkX, chunkZ }) => {
|
||||
const chunkPos = new Vec3(chunkX * 16, 0, chunkZ * 16)
|
||||
if (!this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`]) {
|
||||
void this.loadChunk(chunkPos, true)
|
||||
if (!this.waitingSpiralChunksLoad[`${chunkX},${chunkZ}`] && this.loadedChunks[`${chunkX},${chunkZ}`]) {
|
||||
void this.loadChunk(chunkPos, true, 'update_light')
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -240,10 +250,11 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
|
|||
// debugGotChunkLatency = [] as number[]
|
||||
// lastTime = 0
|
||||
|
||||
async loadChunk (pos: ChunkPos, isLightUpdate = false) {
|
||||
async loadChunk (pos: ChunkPos, isLightUpdate = false, reason = 'spiral') {
|
||||
const [botX, botZ] = chunkPos(this.lastPos)
|
||||
const chunkX = Math.floor(pos.x / 16)
|
||||
const chunkZ = Math.floor(pos.z / 16)
|
||||
|
||||
const dx = Math.abs(botX - chunkX)
|
||||
const dz = Math.abs(botZ - chunkZ)
|
||||
if (dx <= this.viewDistance && dz <= this.viewDistance) {
|
||||
|
|
@ -271,6 +282,15 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
|
|||
//@ts-expect-error
|
||||
this.emitter.emit('loadChunk', { x: pos.x, z: pos.z, chunk, blockEntities: column.blockEntities, worldConfig, isLightUpdate })
|
||||
this.loadedChunks[`${pos.x},${pos.z}`] = true
|
||||
|
||||
this.debugChunksInfo[`${pos.x},${pos.z}`] ??= {
|
||||
loads: []
|
||||
}
|
||||
this.debugChunksInfo[`${pos.x},${pos.z}`].loads.push({
|
||||
dataLength: chunk.length,
|
||||
reason,
|
||||
time: Date.now(),
|
||||
})
|
||||
} else if (this.isPlayground) { // don't allow in real worlds pre-flag chunks as loaded to avoid race condition when the chunk might still be loading. In playground it's assumed we always pre-load all chunks first
|
||||
this.emitter.emit('markAsLoaded', { x: pos.x, z: pos.z })
|
||||
}
|
||||
|
|
@ -289,6 +309,7 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
|
|||
unloadChunk (pos: ChunkPos) {
|
||||
this.emitter.emit('unloadChunk', { x: pos.x, z: pos.z })
|
||||
delete this.loadedChunks[`${pos.x},${pos.z}`]
|
||||
delete this.debugChunksInfo[`${pos.x},${pos.z}`]
|
||||
}
|
||||
|
||||
async updatePosition (pos: Vec3, force = false) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { addNewStat, removeAllStats, removeStat, updatePanesVisibility, updateSt
|
|||
import { WorldDataEmitter } from './worldDataEmitter'
|
||||
import { IPlayerState } from './basePlayerState'
|
||||
import { createLightEngine, getLightEngine, getLightEngineSafe } from './lightEngine'
|
||||
import { MesherLogReader } from './mesherlogReader'
|
||||
|
||||
function mod (x, n) {
|
||||
return ((x % n) + n) % n
|
||||
|
|
@ -54,6 +55,8 @@ export const defaultWorldRendererConfig = {
|
|||
export type WorldRendererConfig = typeof defaultWorldRendererConfig
|
||||
|
||||
export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any> {
|
||||
worldReadyResolvers = Promise.withResolvers<void>()
|
||||
worldReadyPromise = this.worldReadyResolvers.promise
|
||||
timeOfTheDay = 0
|
||||
worldSizeParams = { minY: 0, worldHeight: 256 }
|
||||
|
||||
|
|
@ -137,12 +140,15 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
worldRendererConfig: WorldRendererConfig
|
||||
playerState: IPlayerState
|
||||
reactiveState: RendererReactiveState
|
||||
mesherLogReader: MesherLogReader | undefined
|
||||
forceCallFromMesherReplayer = false
|
||||
stopMesherMessagesProcessing = false
|
||||
|
||||
abortController = new AbortController()
|
||||
lastRendered = 0
|
||||
renderingActive = true
|
||||
geometryReceiveCountPerSec = 0
|
||||
workerLogger = {
|
||||
mesherLogger = {
|
||||
contents: [] as string[],
|
||||
active: new URL(location.href).searchParams.get('mesherlog') === 'true'
|
||||
}
|
||||
|
|
@ -153,6 +159,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
mainThreadRendering = true
|
||||
backendInfoReport = '-'
|
||||
chunksFullInfo = '-'
|
||||
workerCustomHandleTime = 0
|
||||
|
||||
get version () {
|
||||
return this.displayOptions.version
|
||||
|
|
@ -170,7 +177,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
this.worldRendererConfig = displayOptions.inWorldRenderingConfig
|
||||
this.playerState = displayOptions.playerState
|
||||
this.reactiveState = displayOptions.rendererState
|
||||
|
||||
// this.mesherLogReader = new MesherLogReader(this)
|
||||
this.renderUpdateEmitter.on('update', () => {
|
||||
const loadedChunks = Object.keys(this.finishedChunks).length
|
||||
updateStatText('loaded-chunks', `${loadedChunks}/${this.chunksLength} chunks (${this.lastChunkDistance}/${this.viewDistance})`)
|
||||
|
|
@ -203,22 +210,33 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
}
|
||||
|
||||
logWorkerWork (message: string | (() => string)) {
|
||||
if (!this.workerLogger.active) return
|
||||
this.workerLogger.contents.push(typeof message === 'function' ? message() : message)
|
||||
if (!this.mesherLogger.active) return
|
||||
this.mesherLogger.contents.push(typeof message === 'function' ? message() : message)
|
||||
}
|
||||
|
||||
init () {
|
||||
async init () {
|
||||
if (this.active) throw new Error('WorldRendererCommon is already initialized')
|
||||
this.watchReactivePlayerState()
|
||||
void this.setVersion(this.version).then(() => {
|
||||
this.resourcesManager.on('assetsTexturesUpdated', () => {
|
||||
if (!this.active) return
|
||||
void this.updateAssetsData()
|
||||
})
|
||||
if (this.resourcesManager.currentResources) {
|
||||
void this.updateAssetsData()
|
||||
}
|
||||
await this.resourcesManager.loadMcData(this.version)
|
||||
if (!this.resourcesManager.currentResources) {
|
||||
await this.resourcesManager.updateAssetsData({ })
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
this.resetWorkers(),
|
||||
(async () => {
|
||||
if (this.resourcesManager.currentResources) {
|
||||
await this.updateAssetsData()
|
||||
}
|
||||
})()
|
||||
])
|
||||
|
||||
this.resourcesManager.on('assetsTexturesUpdated', async () => {
|
||||
if (!this.active) return
|
||||
await this.updateAssetsData()
|
||||
})
|
||||
|
||||
this.watchReactivePlayerState()
|
||||
this.worldReadyResolvers.resolve()
|
||||
}
|
||||
|
||||
snapshotInitialValues () { }
|
||||
|
|
@ -298,7 +316,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
async processMessageQueue (source: string) {
|
||||
if (this.isProcessingQueue || this.messageQueue.length === 0) return
|
||||
this.logWorkerWork(`# ${source} processing queue`)
|
||||
if (this.lastRendered && performance.now() - this.lastRendered > 30 && this.worldRendererConfig._experimentalSmoothChunkLoading && this.renderingActive) {
|
||||
if (this.lastRendered && performance.now() - this.lastRendered > this.ONMESSAGE_TIME_LIMIT && this.worldRendererConfig._experimentalSmoothChunkLoading && this.renderingActive) {
|
||||
const start = performance.now()
|
||||
await new Promise(resolve => {
|
||||
requestAnimationFrame(resolve)
|
||||
|
|
@ -311,12 +329,15 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
let processedCount = 0
|
||||
|
||||
while (this.messageQueue.length > 0) {
|
||||
const data = this.messageQueue.shift()!
|
||||
this.handleMessage(data)
|
||||
processedCount++
|
||||
const processingStopped = this.stopMesherMessagesProcessing
|
||||
if (!processingStopped) {
|
||||
const data = this.messageQueue.shift()!
|
||||
this.handleMessage(data)
|
||||
processedCount++
|
||||
}
|
||||
|
||||
// Check if we've exceeded the time limit
|
||||
if (performance.now() - startTime > this.ONMESSAGE_TIME_LIMIT && this.renderingActive && this.worldRendererConfig._experimentalSmoothChunkLoading) {
|
||||
if (processingStopped || (performance.now() - startTime > this.ONMESSAGE_TIME_LIMIT && this.renderingActive && this.worldRendererConfig._experimentalSmoothChunkLoading)) {
|
||||
// If we have more messages and exceeded time limit, schedule next batch
|
||||
if (this.messageQueue.length > 0) {
|
||||
requestAnimationFrame(async () => {
|
||||
|
|
@ -334,8 +355,11 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
|
||||
handleMessage (data) {
|
||||
if (!this.active) return
|
||||
this.mesherLogReader?.workerMessageReceived(data.type, data)
|
||||
if (data.type !== 'geometry' || !this.debugStopGeometryUpdate) {
|
||||
const start = performance.now()
|
||||
this.handleWorkerMessage(data)
|
||||
this.workerCustomHandleTime += performance.now() - start
|
||||
}
|
||||
if (data.type === 'geometry') {
|
||||
this.logWorkerWork(() => `-> ${data.workerIndex} geometry ${data.key} ${JSON.stringify({ dataSize: JSON.stringify(data).length })}`)
|
||||
|
|
@ -347,7 +371,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
this.lastChunkDistance = Math.max(...this.getDistance(new Vec3(chunkCoords[0], 0, chunkCoords[2])))
|
||||
}
|
||||
if (data.type === 'sectionFinished') { // on after load & unload section
|
||||
this.logWorkerWork(`-> ${data.workerIndex} sectionFinished ${data.key} ${JSON.stringify({ processTime: data.processTime })}`)
|
||||
this.logWorkerWork(`<- ${data.workerIndex} sectionFinished ${data.key} ${JSON.stringify({ processTime: data.processTime })}`)
|
||||
if (!this.sectionsWaiting.has(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) {
|
||||
|
|
@ -410,7 +434,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
|
||||
downloadMesherLog () {
|
||||
const a = document.createElement('a')
|
||||
a.href = 'data:text/plain;charset=utf-8,' + encodeURIComponent(this.workerLogger.contents.join('\n'))
|
||||
a.href = 'data:text/plain;charset=utf-8,' + encodeURIComponent(this.mesherLogger.contents.join('\n'))
|
||||
a.download = 'mesher.log'
|
||||
a.click()
|
||||
}
|
||||
|
|
@ -477,8 +501,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
this.workers = []
|
||||
}
|
||||
|
||||
// new game load happens here
|
||||
async setVersion (version: string) {
|
||||
async resetWorkers () {
|
||||
this.resetWorld()
|
||||
|
||||
// for workers in single file build
|
||||
|
|
@ -491,11 +514,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
this.initWorkers()
|
||||
this.active = true
|
||||
|
||||
await this.resourcesManager.loadMcData(version)
|
||||
this.sendMesherMcData()
|
||||
if (!this.resourcesManager.currentResources) {
|
||||
await this.resourcesManager.updateAssetsData({ })
|
||||
}
|
||||
}
|
||||
|
||||
getMesherConfig (): MesherConfig {
|
||||
|
|
@ -598,7 +617,8 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
lightData: getLightEngineSafe()?.worldLightHolder.dumpChunk(x, z)
|
||||
})
|
||||
}
|
||||
this.logWorkerWork(`-> chunk ${JSON.stringify({ x, z, chunkLength: chunk.length, customBlockModelsLength: customBlockModels ? Object.keys(customBlockModels).length : 0 })}`)
|
||||
this.logWorkerWork(() => `-> chunk ${JSON.stringify({ x, z, chunkLength: chunk.length, customBlockModelsLength: customBlockModels ? Object.keys(customBlockModels).length : 0 })}`)
|
||||
this.mesherLogReader?.chunkReceived(x, z, chunk.length)
|
||||
for (let y = this.worldMinYRender; y < this.worldSizeParams.worldHeight; y += 16) {
|
||||
const loc = new Vec3(x, y, z)
|
||||
this.setSectionDirty(loc)
|
||||
|
|
@ -640,8 +660,9 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
this.updateChunksStats()
|
||||
|
||||
if (Object.keys(this.loadedChunks).length === 0) {
|
||||
this.workerLogger.contents = []
|
||||
this.mesherLogger.contents = []
|
||||
this.logWorkerWork('# all chunks unloaded. New log started')
|
||||
void this.mesherLogReader?.maybeStartReplay()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -852,6 +873,8 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
}
|
||||
|
||||
setSectionDirty (pos: Vec3, value = true, useChangeWorker = false) { // value false is used for unloading chunks
|
||||
if (!this.forceCallFromMesherReplayer && this.mesherLogReader) return
|
||||
|
||||
if (this.viewDistance === -1) throw new Error('viewDistance not set')
|
||||
this.reactiveState.world.mesherWork = true
|
||||
const distance = this.getDistance(pos)
|
||||
|
|
@ -863,19 +886,30 @@ 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 = this.getWorkerNumber(pos, useChangeWorker)
|
||||
const hash = this.getWorkerNumber(pos, useChangeWorker && this.mesherLogger.active)
|
||||
this.sectionsWaiting.set(key, (this.sectionsWaiting.get(key) ?? 0) + 1)
|
||||
this.toWorkerMessagesQueue[hash] ??= []
|
||||
this.toWorkerMessagesQueue[hash].push({
|
||||
// this.workers[hash].postMessage({
|
||||
type: 'dirty',
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
z: pos.z,
|
||||
value,
|
||||
config: this.getMesherConfig(),
|
||||
})
|
||||
this.dispatchMessages()
|
||||
if (this.forceCallFromMesherReplayer) {
|
||||
this.workers[hash].postMessage({
|
||||
type: 'dirty',
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
z: pos.z,
|
||||
value,
|
||||
config: this.getMesherConfig(),
|
||||
})
|
||||
} else {
|
||||
this.toWorkerMessagesQueue[hash] ??= []
|
||||
this.toWorkerMessagesQueue[hash].push({
|
||||
// this.workers[hash].postMessage({
|
||||
type: 'dirty',
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
z: pos.z,
|
||||
value,
|
||||
config: this.getMesherConfig(),
|
||||
})
|
||||
this.dispatchMessages()
|
||||
}
|
||||
}
|
||||
|
||||
dispatchMessages () {
|
||||
|
|
|
|||
|
|
@ -120,7 +120,9 @@ export class DocumentRenderer {
|
|||
this.preRender()
|
||||
this.stats.markStart()
|
||||
tween.update()
|
||||
this.render(sizeChanged)
|
||||
if (!window.freezeRender) {
|
||||
this.render(sizeChanged)
|
||||
}
|
||||
for (const fn of this.onRender) {
|
||||
fn(sizeChanged)
|
||||
}
|
||||
|
|
@ -189,6 +191,10 @@ class TopRightStats {
|
|||
const hasRamPanel = this.stats2.dom.children.length === 3
|
||||
|
||||
this.addStat(this.stats.dom)
|
||||
if (process.env.NODE_ENV === 'development' && document.exitPointerLock) {
|
||||
this.stats.dom.style.top = ''
|
||||
this.stats.dom.style.bottom = '0'
|
||||
}
|
||||
if (hasRamPanel) {
|
||||
this.addStat(this.stats2.dom)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -551,3 +551,4 @@ export class EntityMesh {
|
|||
}
|
||||
}
|
||||
}
|
||||
window.EntityMesh = EntityMesh
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import * as THREE from 'three'
|
||||
import { Vec3 } from 'vec3'
|
||||
import { proxy } from 'valtio'
|
||||
import { GraphicsBackendLoader, GraphicsBackend, GraphicsInitOptions, DisplayWorldOptions, RendererReactiveState } from '../../../src/appViewer'
|
||||
import { GraphicsBackendLoader, GraphicsBackend, GraphicsInitOptions, DisplayWorldOptions } from '../../../src/appViewer'
|
||||
import { ProgressReporter } from '../../../src/core/progressReporter'
|
||||
import { showNotification } from '../../../src/react/NotificationProvider'
|
||||
import { WorldRendererThree } from './worldrendererThree'
|
||||
import { DocumentRenderer } from './documentRenderer'
|
||||
import { PanoramaRenderer } from './panorama'
|
||||
|
|
@ -53,12 +53,14 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
|
|||
let panoramaRenderer: PanoramaRenderer | null = null
|
||||
let worldRenderer: WorldRendererThree | null = null
|
||||
|
||||
const startPanorama = () => {
|
||||
const startPanorama = async () => {
|
||||
if (worldRenderer) return
|
||||
if (!panoramaRenderer) {
|
||||
panoramaRenderer = new PanoramaRenderer(documentRenderer, initOptions, !!process.env.SINGLE_FILE_BUILD_MODE)
|
||||
void panoramaRenderer.start()
|
||||
window.panoramaRenderer = panoramaRenderer
|
||||
callModsMethod('panoramaCreated', panoramaRenderer)
|
||||
await panoramaRenderer.start()
|
||||
callModsMethod('panoramaReady', panoramaRenderer)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -68,16 +70,18 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
|
|||
await initOptions.resourcesManager.updateAssetsData({ })
|
||||
}
|
||||
|
||||
const startWorld = (displayOptions: DisplayWorldOptions) => {
|
||||
const startWorld = async (displayOptions: DisplayWorldOptions) => {
|
||||
if (panoramaRenderer) {
|
||||
panoramaRenderer.dispose()
|
||||
panoramaRenderer = null
|
||||
}
|
||||
worldRenderer = new WorldRendererThree(documentRenderer.renderer, initOptions, displayOptions)
|
||||
await worldRenderer.worldReadyPromise
|
||||
documentRenderer.render = (sizeChanged: boolean) => {
|
||||
worldRenderer?.render(sizeChanged)
|
||||
}
|
||||
window.world = worldRenderer
|
||||
callModsMethod('worldReady', worldRenderer)
|
||||
}
|
||||
|
||||
const disconnect = () => {
|
||||
|
|
@ -119,8 +123,24 @@ const createGraphicsBackend: GraphicsBackendLoader = (initOptions: GraphicsInitO
|
|||
}
|
||||
}
|
||||
|
||||
globalThis.threeJsBackend = backend
|
||||
globalThis.resourcesManager = initOptions.resourcesManager
|
||||
callModsMethod('default', backend)
|
||||
|
||||
return backend
|
||||
}
|
||||
|
||||
const callModsMethod = (method: string, ...args: any[]) => {
|
||||
for (const mod of Object.values((window.loadedMods ?? {}) as Record<string, any>)) {
|
||||
try {
|
||||
mod.threeJsBackendModule?.[method]?.(...args)
|
||||
} catch (err) {
|
||||
const errorMessage = `[mod three.js] Error calling ${method} on ${mod.name}: ${err}`
|
||||
showNotification(errorMessage, 'error')
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createGraphicsBackend.id = 'threejs'
|
||||
export default createGraphicsBackend
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ export class ThreeJsMedia {
|
|||
this.worldRenderer.onWorldSwitched.push(() => {
|
||||
this.onWorldGone()
|
||||
})
|
||||
|
||||
this.worldRenderer.onRender.push(() => {
|
||||
this.render()
|
||||
})
|
||||
}
|
||||
|
||||
onWorldGone () {
|
||||
|
|
@ -304,6 +308,18 @@ export class ThreeJsMedia {
|
|||
return id
|
||||
}
|
||||
|
||||
render () {
|
||||
for (const [id, videoData] of this.customMedia.entries()) {
|
||||
const chunkX = Math.floor(videoData.props.position.x / 16) * 16
|
||||
const chunkZ = Math.floor(videoData.props.position.z / 16) * 16
|
||||
const sectionY = Math.floor(videoData.props.position.y / 16) * 16
|
||||
|
||||
const chunkKey = `${chunkX},${chunkZ}`
|
||||
const sectionKey = `${chunkX},${sectionY},${chunkZ}`
|
||||
videoData.mesh.visible = !!this.worldRenderer.sectionObjects[sectionKey] || !!this.worldRenderer.finishedChunks[chunkKey]
|
||||
}
|
||||
}
|
||||
|
||||
setVideoPlaying (id: string, playing: boolean) {
|
||||
const videoData = this.customMedia.get(id)
|
||||
if (videoData?.video) {
|
||||
|
|
@ -531,9 +547,13 @@ export class ThreeJsMedia {
|
|||
console.log('Exact test mesh added with dimensions:', width, height, 'and rotation:', rotation)
|
||||
}
|
||||
|
||||
lastCheck = 0
|
||||
THROTTLE_TIME = 100
|
||||
tryIntersectMedia () {
|
||||
// hack: need to optimize this by pulling only in distance of interaction instead (or throttle)!
|
||||
// hack: need to optimize this by pulling only in distance of interaction instead and throttle
|
||||
if (this.customMedia.size === 0) return
|
||||
if (Date.now() - this.lastCheck < this.THROTTLE_TIME) return
|
||||
this.lastCheck = Date.now()
|
||||
|
||||
const { camera, scene } = this.worldRenderer
|
||||
const raycaster = new THREE.Raycaster()
|
||||
|
|
|
|||
160
renderer/viewer/three/threeJsParticles.ts
Normal file
160
renderer/viewer/three/threeJsParticles.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import * as THREE from 'three'
|
||||
|
||||
interface ParticleMesh extends THREE.Mesh {
|
||||
velocity: THREE.Vector3;
|
||||
}
|
||||
|
||||
interface ParticleConfig {
|
||||
fountainHeight: number;
|
||||
resetHeight: number;
|
||||
xVelocityRange: number;
|
||||
zVelocityRange: number;
|
||||
particleCount: number;
|
||||
particleRadiusRange: { min: number; max: number };
|
||||
yVelocityRange: { min: number; max: number };
|
||||
}
|
||||
|
||||
export interface FountainOptions {
|
||||
position?: { x: number, y: number, z: number }
|
||||
particleConfig?: Partial<ParticleConfig>;
|
||||
}
|
||||
|
||||
export class Fountain {
|
||||
private readonly particles: ParticleMesh[] = []
|
||||
private readonly config: { particleConfig: ParticleConfig }
|
||||
private readonly position: THREE.Vector3
|
||||
container: THREE.Object3D | undefined
|
||||
|
||||
constructor (public sectionId: string, options: FountainOptions = {}) {
|
||||
this.position = options.position ? new THREE.Vector3(options.position.x, options.position.y, options.position.z) : new THREE.Vector3(0, 0, 0)
|
||||
this.config = this.createConfig(options.particleConfig)
|
||||
}
|
||||
|
||||
private createConfig (
|
||||
particleConfigOverride?: Partial<ParticleConfig>
|
||||
): { particleConfig: ParticleConfig } {
|
||||
const particleConfig: ParticleConfig = {
|
||||
fountainHeight: 10,
|
||||
resetHeight: 0,
|
||||
xVelocityRange: 0.4,
|
||||
zVelocityRange: 0.4,
|
||||
particleCount: 400,
|
||||
particleRadiusRange: { min: 0.1, max: 0.6 },
|
||||
yVelocityRange: { min: 0.1, max: 2 },
|
||||
...particleConfigOverride
|
||||
}
|
||||
|
||||
return { particleConfig }
|
||||
}
|
||||
|
||||
|
||||
createParticles (container: THREE.Object3D): void {
|
||||
this.container = container
|
||||
const colorStart = new THREE.Color(0xff_ff_00)
|
||||
const colorEnd = new THREE.Color(0xff_a5_00)
|
||||
|
||||
for (let i = 0; i < this.config.particleConfig.particleCount; i++) {
|
||||
const radius = Math.random() *
|
||||
(this.config.particleConfig.particleRadiusRange.max - this.config.particleConfig.particleRadiusRange.min) +
|
||||
this.config.particleConfig.particleRadiusRange.min
|
||||
const geometry = new THREE.SphereGeometry(radius)
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color: colorStart.clone().lerp(colorEnd, Math.random())
|
||||
})
|
||||
const mesh = new THREE.Mesh(geometry, material)
|
||||
const particle = mesh as unknown as ParticleMesh
|
||||
|
||||
particle.position.set(
|
||||
this.position.x + (Math.random() - 0.5) * this.config.particleConfig.xVelocityRange * 2,
|
||||
this.position.y + this.config.particleConfig.fountainHeight,
|
||||
this.position.z + (Math.random() - 0.5) * this.config.particleConfig.zVelocityRange * 2
|
||||
)
|
||||
|
||||
particle.velocity = new THREE.Vector3(
|
||||
(Math.random() - 0.5) * this.config.particleConfig.xVelocityRange,
|
||||
-Math.random() * this.config.particleConfig.yVelocityRange.max,
|
||||
(Math.random() - 0.5) * this.config.particleConfig.zVelocityRange
|
||||
)
|
||||
|
||||
this.particles.push(particle)
|
||||
this.container.add(particle)
|
||||
|
||||
// this.container.onBeforeRender = () => {
|
||||
// this.render()
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
render (): void {
|
||||
for (const particle of this.particles) {
|
||||
particle.velocity.y -= 0.01 + Math.random() * 0.1
|
||||
particle.position.add(particle.velocity)
|
||||
|
||||
if (particle.position.y < this.position.y + this.config.particleConfig.resetHeight) {
|
||||
particle.position.set(
|
||||
this.position.x + (Math.random() - 0.5) * this.config.particleConfig.xVelocityRange * 2,
|
||||
this.position.y + this.config.particleConfig.fountainHeight,
|
||||
this.position.z + (Math.random() - 0.5) * this.config.particleConfig.zVelocityRange * 2
|
||||
)
|
||||
particle.velocity.set(
|
||||
(Math.random() - 0.5) * this.config.particleConfig.xVelocityRange,
|
||||
-Math.random() * this.config.particleConfig.yVelocityRange.max,
|
||||
(Math.random() - 0.5) * this.config.particleConfig.zVelocityRange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateParticleCount (newCount: number): void {
|
||||
if (newCount !== this.config.particleConfig.particleCount) {
|
||||
this.config.particleConfig.particleCount = newCount
|
||||
const currentCount = this.particles.length
|
||||
|
||||
if (newCount > currentCount) {
|
||||
this.addParticles(newCount - currentCount)
|
||||
} else if (newCount < currentCount) {
|
||||
this.removeParticles(currentCount - newCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addParticles (count: number): void {
|
||||
const geometry = new THREE.SphereGeometry(0.1)
|
||||
const material = new THREE.MeshBasicMaterial({ color: 0x00_ff_00 })
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const mesh = new THREE.Mesh(geometry, material)
|
||||
const particle = mesh as unknown as ParticleMesh
|
||||
particle.position.copy(this.position)
|
||||
particle.velocity = new THREE.Vector3(
|
||||
Math.random() * this.config.particleConfig.xVelocityRange -
|
||||
this.config.particleConfig.xVelocityRange / 2,
|
||||
Math.random() * 2,
|
||||
Math.random() * this.config.particleConfig.zVelocityRange -
|
||||
this.config.particleConfig.zVelocityRange / 2
|
||||
)
|
||||
this.particles.push(particle)
|
||||
this.container!.add(particle)
|
||||
}
|
||||
}
|
||||
|
||||
private removeParticles (count: number): void {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const particle = this.particles.pop()
|
||||
if (particle) {
|
||||
this.container!.remove(particle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public dispose (): void {
|
||||
for (const particle of this.particles) {
|
||||
particle.geometry.dispose()
|
||||
if (Array.isArray(particle.material)) {
|
||||
for (const material of particle.material) material.dispose()
|
||||
} else {
|
||||
particle.material.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -25,12 +25,13 @@ import { Entities } from './entities'
|
|||
import { ThreeJsSound } from './threeJsSound'
|
||||
import { CameraShake } from './cameraShake'
|
||||
import { ThreeJsMedia } from './threeJsMedia'
|
||||
import { Fountain } from './threeJsParticles'
|
||||
|
||||
type SectionKey = string
|
||||
|
||||
export class WorldRendererThree extends WorldRendererCommon {
|
||||
outputFormat = 'threeJs' as const
|
||||
sectionObjects: Record<string, THREE.Object3D> = {}
|
||||
sectionObjects: Record<string, THREE.Object3D & { foutain?: boolean }> = {}
|
||||
chunkTextures = new Map<string, { [pos: string]: THREE.Texture }>()
|
||||
signsCache = new Map<string, any>()
|
||||
starField: StarField
|
||||
|
|
@ -68,6 +69,7 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|||
limitZ?: number,
|
||||
}
|
||||
}
|
||||
fountains: Fountain[] = []
|
||||
|
||||
get tilesRendered () {
|
||||
return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj as any).tilesCount, 0)
|
||||
|
|
@ -88,12 +90,15 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|||
|
||||
this.addDebugOverlay()
|
||||
this.resetScene()
|
||||
this.init()
|
||||
void this.init()
|
||||
void initVR(this)
|
||||
|
||||
this.soundSystem = new ThreeJsSound(this)
|
||||
this.cameraShake = new CameraShake(this.camera, this.onRender)
|
||||
this.media = new ThreeJsMedia(this)
|
||||
// this.fountain = new Fountain(this.scene, this.scene, {
|
||||
// position: new THREE.Vector3(0, 10, 0),
|
||||
// })
|
||||
|
||||
this.renderUpdateEmitter.on('chunkFinished', (chunkKey: string) => {
|
||||
this.finishChunk(chunkKey)
|
||||
|
|
@ -333,7 +338,7 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|||
geometry.setAttribute('normal', new THREE.BufferAttribute(data.geometry.normals, 3))
|
||||
geometry.setAttribute('color', new THREE.BufferAttribute(data.geometry.colors, 3))
|
||||
geometry.setAttribute('uv', new THREE.BufferAttribute(data.geometry.uvs, 2))
|
||||
geometry.setIndex(data.geometry.indices)
|
||||
geometry.index = new THREE.BufferAttribute(data.geometry.indices as Uint32Array | Uint16Array, 1)
|
||||
|
||||
const mesh = new THREE.Mesh(geometry, this.material)
|
||||
mesh.position.set(data.geometry.sx, data.geometry.sy, data.geometry.sz)
|
||||
|
|
@ -443,7 +448,6 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|||
const start = performance.now()
|
||||
this.lastRendered = performance.now()
|
||||
this.cursorBlock.render()
|
||||
|
||||
this.updateSectionOffsets()
|
||||
|
||||
const sizeOrFovChanged = sizeChanged || this.displayOptions.inWorldRenderingConfig.fov !== this.camera.fov
|
||||
|
|
@ -464,6 +468,14 @@ export class WorldRendererThree extends WorldRendererCommon {
|
|||
this.holdingBlockLeft.render(this.camera, this.renderer, this.ambientLight, this.directionalLight)
|
||||
}
|
||||
|
||||
for (const fountain of this.fountains) {
|
||||
if (this.sectionObjects[fountain.sectionId] && !this.sectionObjects[fountain.sectionId].foutain) {
|
||||
fountain.createParticles(this.sectionObjects[fountain.sectionId])
|
||||
this.sectionObjects[fountain.sectionId].foutain = true
|
||||
}
|
||||
fountain.render()
|
||||
}
|
||||
|
||||
for (const onRender of this.onRender) {
|
||||
onRender()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ const appConfig = defineConfig({
|
|||
fs.copyFileSync('./assets/favicon.png', './dist/favicon.png')
|
||||
fs.copyFileSync('./assets/playground.html', './dist/playground.html')
|
||||
fs.copyFileSync('./assets/manifest.json', './dist/manifest.json')
|
||||
fs.copyFileSync('./assets/config.html', './dist/config.html')
|
||||
fs.copyFileSync('./assets/loading-bg.jpg', './dist/loading-bg.jpg')
|
||||
if (fs.existsSync('./assets/release.json')) {
|
||||
fs.copyFileSync('./assets/release.json', './dist/release.json')
|
||||
|
|
|
|||
|
|
@ -11,7 +11,12 @@ import supportedVersions from '../src/supportedVersions.mjs'
|
|||
|
||||
const __dirname = path.dirname(fileURLToPath(new URL(import.meta.url)))
|
||||
|
||||
const targetedVersions = supportedVersions.reverse()
|
||||
export const versionToNumber = (ver) => {
|
||||
const [x, y = '0', z = '0'] = ver.split('.')
|
||||
return +`${x.padStart(2, '0')}${y.padStart(2, '0')}${z.padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const targetedVersions = [...supportedVersions].sort((a, b) => versionToNumber(b) - versionToNumber(a))
|
||||
|
||||
/** @type {{name, size, hash}[]} */
|
||||
let prevSounds = null
|
||||
|
|
@ -173,13 +178,36 @@ const writeSoundsMap = async () => {
|
|||
|
||||
// todo REMAP ONLY IDS. Do diffs, as mostly only ids are changed between versions
|
||||
// const localTargetedVersions = targetedVersions.slice(0, 2)
|
||||
let lastMappingsJson
|
||||
const localTargetedVersions = targetedVersions
|
||||
for (const targetedVersion of localTargetedVersions) {
|
||||
for (const targetedVersion of [...localTargetedVersions].reverse()) {
|
||||
console.log('Processing version', targetedVersion)
|
||||
|
||||
const burgerData = await fetch(burgerDataUrl(targetedVersion)).then((r) => r.json()).catch((err) => {
|
||||
console.error('error fetching burger data', targetedVersion, err)
|
||||
// console.error('error fetching burger data', targetedVersion, err)
|
||||
return null
|
||||
})
|
||||
if (!burgerData) continue
|
||||
/** @type {{sounds: string[]}} */
|
||||
const mappingJson = await fetch(`https://raw.githubusercontent.com/ViaVersion/Mappings/7a45c1f9dbc1f1fdadacfecdb205ba84e55766fc/mappings/mapping-${targetedVersion}.json`).then(async (r) => {
|
||||
return r.json()
|
||||
// lastMappingsJson = r.status === 404 ? lastMappingsJson : (await r.json())
|
||||
// if (r.status === 404) {
|
||||
// console.warn('using prev mappings json for ' + targetedVersion)
|
||||
// }
|
||||
// return lastMappingsJson
|
||||
}).catch((err) => {
|
||||
// console.error('error fetching mapping json', targetedVersion, err)
|
||||
return null
|
||||
})
|
||||
// if (!mappingJson) throw new Error('no initial mapping json for ' + targetedVersion)
|
||||
if (burgerData && !mappingJson) {
|
||||
console.warn('has burger but no mapping json for ' + targetedVersion)
|
||||
continue
|
||||
}
|
||||
if (!mappingJson || !burgerData) {
|
||||
console.warn('no mapping json or burger data for ' + targetedVersion)
|
||||
continue
|
||||
}
|
||||
const allSoundsMap = getSoundsMap(burgerData)
|
||||
// console.log(Object.keys(sounds).length, 'ids')
|
||||
const outputIdMap = {}
|
||||
|
|
@ -190,7 +218,7 @@ const writeSoundsMap = async () => {
|
|||
new: 0,
|
||||
same: 0
|
||||
}
|
||||
for (const { id, subtitle, sounds, name } of Object.values(allSoundsMap)) {
|
||||
for (const { _id, subtitle, sounds, name } of Object.values(allSoundsMap)) {
|
||||
if (!sounds?.length /* && !subtitle */) continue
|
||||
const firstName = sounds[0].name
|
||||
// const includeSound = isSoundWhitelisted(firstName)
|
||||
|
|
@ -210,6 +238,11 @@ const writeSoundsMap = async () => {
|
|||
if (sound.weight && isNaN(sound.weight)) debugger
|
||||
outputUseSoundLine.push(`${sound.volume ?? 1};${sound.name};${sound.weight ?? minWeight}`)
|
||||
}
|
||||
const id = mappingJson.sounds.findIndex(x => x === name)
|
||||
if (id === -1) {
|
||||
console.warn('no id for sound', name, targetedVersion)
|
||||
continue
|
||||
}
|
||||
const key = `${id};${name}`
|
||||
outputIdMap[key] = outputUseSoundLine.join(',')
|
||||
if (prevMap && prevMap[key]) {
|
||||
|
|
@ -283,6 +316,6 @@ if (action) {
|
|||
} else {
|
||||
// downloadAllSoundsAndCreateMap()
|
||||
// convertSounds()
|
||||
// writeSoundsMap()
|
||||
makeSoundsBundle()
|
||||
writeSoundsMap()
|
||||
// makeSoundsBundle()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { defaultsDeep } from 'lodash'
|
||||
import { disabledSettings, options, qsOptions } from './optionsStorage'
|
||||
import { miscUiState } from './globalState'
|
||||
import { setLoadingScreenStatus } from './appStatus'
|
||||
import { setStorageDataOnAppConfigLoad } from './react/appStorageProvider'
|
||||
import { customKeymaps, updateBinds } from './controls'
|
||||
|
||||
export type AppConfig = {
|
||||
// defaultHost?: string
|
||||
|
|
@ -15,12 +17,19 @@ export type AppConfig = {
|
|||
mapsProvider?: string
|
||||
|
||||
appParams?: Record<string, any> // query string params
|
||||
rightSideText?: string
|
||||
|
||||
defaultSettings?: Record<string, any>
|
||||
forceSettings?: Record<string, boolean>
|
||||
// hideSettings?: Record<string, boolean>
|
||||
allowAutoConnect?: boolean
|
||||
splashText?: string
|
||||
pauseLinks?: Array<Array<Record<string, any>>>
|
||||
keybindings?: Record<string, any>
|
||||
defaultLanguage?: string
|
||||
displayLanguageSelector?: boolean
|
||||
supportedLanguages?: string[]
|
||||
showModsButton?: boolean
|
||||
}
|
||||
|
||||
export const loadAppConfig = (appConfig: AppConfig) => {
|
||||
|
|
@ -44,6 +53,11 @@ export const loadAppConfig = (appConfig: AppConfig) => {
|
|||
}
|
||||
}
|
||||
|
||||
if (appConfig.keybindings) {
|
||||
Object.assign(customKeymaps, defaultsDeep(appConfig.keybindings, customKeymaps))
|
||||
updateBinds(customKeymaps)
|
||||
}
|
||||
|
||||
setStorageDataOnAppConfigLoad()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ export type AppQsParams = {
|
|||
suggest_save?: string
|
||||
noPacketsValidation?: string
|
||||
testCrashApp?: string
|
||||
onlyConnect?: string
|
||||
connectText?: string
|
||||
freezeSettings?: string
|
||||
|
||||
// Replay params
|
||||
replayFilter?: string
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ export interface GraphicsBackend {
|
|||
}
|
||||
|
||||
export class AppViewer {
|
||||
waitBackendLoadPromises = [] as Array<Promise<void>>
|
||||
|
||||
resourcesManager = new ResourcesManager()
|
||||
worldView: WorldDataEmitter | undefined
|
||||
readonly config: GraphicsBackendConfig = {
|
||||
|
|
@ -114,11 +116,14 @@ export class AppViewer {
|
|||
this.disconnectBackend()
|
||||
}
|
||||
|
||||
loadBackend (loader: GraphicsBackendLoader) {
|
||||
async loadBackend (loader: GraphicsBackendLoader) {
|
||||
if (this.backend) {
|
||||
this.disconnectBackend()
|
||||
}
|
||||
|
||||
await Promise.all(this.waitBackendLoadPromises)
|
||||
this.waitBackendLoadPromises = []
|
||||
|
||||
this.backendLoader = loader
|
||||
const rendererSpecificSettings = {} as Record<string, any>
|
||||
const rendererSettingsKey = `renderer.${this.backendLoader?.id}`
|
||||
|
|
|
|||
|
|
@ -9,25 +9,27 @@ import { showNotification } from './react/NotificationProvider'
|
|||
const backends = [
|
||||
createGraphicsBackend,
|
||||
]
|
||||
const loadBackend = () => {
|
||||
const loadBackend = async () => {
|
||||
let backend = backends.find(backend => backend.id === options.activeRenderer)
|
||||
if (!backend) {
|
||||
showNotification(`No backend found for renderer ${options.activeRenderer}`, `Falling back to ${backends[0].id}`, true)
|
||||
backend = backends[0]
|
||||
}
|
||||
appViewer.loadBackend(backend)
|
||||
await appViewer.loadBackend(backend)
|
||||
}
|
||||
window.loadBackend = loadBackend
|
||||
if (process.env.SINGLE_FILE_BUILD_MODE) {
|
||||
const unsub = subscribeKey(miscUiState, 'fsReady', () => {
|
||||
if (miscUiState.fsReady) {
|
||||
// don't do it earlier to load fs and display menu faster
|
||||
loadBackend()
|
||||
void loadBackend()
|
||||
unsub()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
loadBackend()
|
||||
setTimeout(() => {
|
||||
void loadBackend()
|
||||
})
|
||||
}
|
||||
|
||||
const animLoop = () => {
|
||||
|
|
@ -40,10 +42,10 @@ watchOptionsAfterViewerInit()
|
|||
|
||||
// reset backend when renderer changes
|
||||
|
||||
subscribeKey(options, 'activeRenderer', () => {
|
||||
subscribeKey(options, 'activeRenderer', async () => {
|
||||
if (appViewer.currentDisplay === 'world' && bot) {
|
||||
appViewer.resetBackend(true)
|
||||
loadBackend()
|
||||
await loadBackend()
|
||||
void appViewer.startWithBot()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -74,9 +74,8 @@ export const onControInit = () => {
|
|||
}
|
||||
|
||||
function pointerLockChangeCallback () {
|
||||
if (notificationProxy.id === 'pointerlockchange') {
|
||||
hideNotification()
|
||||
}
|
||||
hideNotification('pointerlockchange')
|
||||
|
||||
if (appViewer.rendererState.preventEscapeMenu) return
|
||||
if (!pointerLock.hasPointerLock && activeModalStack.length === 0 && miscUiState.gameLoaded) {
|
||||
showModal({ reactType: 'pause-screen' })
|
||||
|
|
|
|||
582
src/clientMods.ts
Normal file
582
src/clientMods.ts
Normal file
|
|
@ -0,0 +1,582 @@
|
|||
/* eslint-disable no-await-in-loop */
|
||||
import { openDB } from 'idb'
|
||||
import * as React from 'react'
|
||||
import * as valtio from 'valtio'
|
||||
import * as valtioUtils from 'valtio/utils'
|
||||
import { gt } from 'semver'
|
||||
import { proxy } from 'valtio'
|
||||
import { options } from './optionsStorage'
|
||||
import { appStorage } from './react/appStorageProvider'
|
||||
import { showInputsModal, showOptionsModal } from './react/SelectOption'
|
||||
import { ProgressReporter } from './core/progressReporter'
|
||||
|
||||
let sillyProtection = false
|
||||
const protectRuntime = () => {
|
||||
if (sillyProtection) return
|
||||
sillyProtection = true
|
||||
const sensetiveKeys = new Set(['authenticatedAccounts', 'serversList', 'username'])
|
||||
const proxy = new Proxy(window.localStorage, {
|
||||
get (target, prop) {
|
||||
if (typeof prop === 'string') {
|
||||
if (sensetiveKeys.has(prop)) {
|
||||
console.warn(`Access to sensitive key "${prop}" was blocked`)
|
||||
return null
|
||||
}
|
||||
if (prop === 'getItem') {
|
||||
return (key: string) => {
|
||||
if (sensetiveKeys.has(key)) {
|
||||
console.warn(`Access to sensitive key "${key}" via getItem was blocked`)
|
||||
return null
|
||||
}
|
||||
return target.getItem(key)
|
||||
}
|
||||
}
|
||||
if (prop === 'setItem') {
|
||||
return (key: string, value: string) => {
|
||||
if (sensetiveKeys.has(key)) {
|
||||
console.warn(`Attempt to set sensitive key "${key}" via setItem was blocked`)
|
||||
return
|
||||
}
|
||||
target.setItem(key, value)
|
||||
}
|
||||
}
|
||||
if (prop === 'removeItem') {
|
||||
return (key: string) => {
|
||||
if (sensetiveKeys.has(key)) {
|
||||
console.warn(`Attempt to delete sensitive key "${key}" via removeItem was blocked`)
|
||||
return
|
||||
}
|
||||
target.removeItem(key)
|
||||
}
|
||||
}
|
||||
if (prop === 'clear') {
|
||||
console.warn('Attempt to clear localStorage was blocked')
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
return Reflect.get(target, prop)
|
||||
},
|
||||
set (target, prop, value) {
|
||||
if (typeof prop === 'string' && sensetiveKeys.has(prop)) {
|
||||
console.warn(`Attempt to set sensitive key "${prop}" was blocked`)
|
||||
return false
|
||||
}
|
||||
return Reflect.set(target, prop, value)
|
||||
},
|
||||
deleteProperty (target, prop) {
|
||||
if (typeof prop === 'string' && sensetiveKeys.has(prop)) {
|
||||
console.warn(`Attempt to delete sensitive key "${prop}" was blocked`)
|
||||
return false
|
||||
}
|
||||
return Reflect.deleteProperty(target, prop)
|
||||
}
|
||||
})
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: proxy,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
})
|
||||
}
|
||||
|
||||
// #region Database
|
||||
const dbPromise = openDB('mods-db', 1, {
|
||||
upgrade (db) {
|
||||
db.createObjectStore('mods', {
|
||||
keyPath: 'name',
|
||||
})
|
||||
db.createObjectStore('repositories', {
|
||||
keyPath: 'url',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// mcraft-repo.json
|
||||
export interface McraftRepoFile {
|
||||
packages: ClientModDefinition[]
|
||||
/** @default true */
|
||||
prefix?: string | boolean
|
||||
name?: string // display name
|
||||
description?: string
|
||||
mirrorUrls?: string[]
|
||||
autoUpdateOverride?: boolean
|
||||
lastUpdated?: number
|
||||
}
|
||||
export interface Repository extends McraftRepoFile {
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface ClientMod {
|
||||
name: string; // unique identifier like owner.name
|
||||
version: string
|
||||
enabled?: boolean
|
||||
|
||||
scriptMainUnstable?: string;
|
||||
serverPlugin?: string
|
||||
// serverPlugins?: string[]
|
||||
// mesherThread?: string
|
||||
stylesGlobal?: string
|
||||
threeJsBackend?: string // three.js
|
||||
// stylesLocal?: string
|
||||
|
||||
requiresNetwork?: boolean
|
||||
fullyOffline?: boolean
|
||||
description?: string
|
||||
author?: string
|
||||
section?: string
|
||||
autoUpdateOverride?: boolean
|
||||
lastUpdated?: number
|
||||
wasModifiedLocally?: boolean
|
||||
// todo depends, hashsum
|
||||
}
|
||||
|
||||
const cleanupFetchedModData = (mod: ClientModDefinition | Record<string, any>) => {
|
||||
delete mod['enabled']
|
||||
delete mod['repo']
|
||||
delete mod['autoUpdateOverride']
|
||||
delete mod['lastUpdated']
|
||||
delete mod['wasModifiedLocally']
|
||||
return mod
|
||||
}
|
||||
|
||||
export type ClientModDefinition = Omit<ClientMod, 'enabled' | 'wasModifiedLocally'> & {
|
||||
scriptMainUnstable?: boolean
|
||||
stylesGlobal?: boolean
|
||||
serverPlugin?: boolean
|
||||
threeJsBackend?: boolean
|
||||
}
|
||||
|
||||
export async function saveClientModData (data: ClientMod) {
|
||||
const db = await dbPromise
|
||||
data.lastUpdated = Date.now()
|
||||
await db.put('mods', data)
|
||||
modsReactiveUpdater.counter++
|
||||
}
|
||||
|
||||
async function getPlugin (name: string) {
|
||||
const db = await dbPromise
|
||||
return db.get('mods', name) as Promise<ClientMod | undefined>
|
||||
}
|
||||
|
||||
async function getAllMods () {
|
||||
const db = await dbPromise
|
||||
return db.getAll('mods') as Promise<ClientMod[]>
|
||||
}
|
||||
|
||||
async function deletePlugin (name) {
|
||||
const db = await dbPromise
|
||||
await db.delete('mods', name)
|
||||
modsReactiveUpdater.counter++
|
||||
}
|
||||
|
||||
async function removeAllMods () {
|
||||
const db = await dbPromise
|
||||
await db.clear('mods')
|
||||
modsReactiveUpdater.counter++
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
async function saveRepository (data: Repository) {
|
||||
const db = await dbPromise
|
||||
data.lastUpdated = Date.now()
|
||||
await db.put('repositories', data)
|
||||
}
|
||||
|
||||
async function getRepository (url: string) {
|
||||
const db = await dbPromise
|
||||
return db.get('repositories', url) as Promise<Repository | undefined>
|
||||
}
|
||||
|
||||
async function getAllRepositories () {
|
||||
const db = await dbPromise
|
||||
return db.getAll('repositories') as Promise<Repository[]>
|
||||
}
|
||||
window.getAllRepositories = getAllRepositories
|
||||
|
||||
async function deleteRepository (url) {
|
||||
const db = await dbPromise
|
||||
await db.delete('repositories', url)
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
// #endregion
|
||||
|
||||
window.mcraft = {
|
||||
version: process.env.RELEASE_TAG,
|
||||
build: process.env.BUILD_VERSION,
|
||||
ui: {},
|
||||
React,
|
||||
valtio: {
|
||||
...valtio,
|
||||
...valtioUtils,
|
||||
},
|
||||
// openDB
|
||||
}
|
||||
|
||||
const activateMod = async (mod: ClientMod, reason: string) => {
|
||||
if (mod.enabled === false) return false
|
||||
protectRuntime()
|
||||
console.debug(`Activating mod ${mod.name} (${reason})...`)
|
||||
window.loadedMods ??= {}
|
||||
if (window.loadedMods[mod.name]) {
|
||||
console.warn(`Mod is ${mod.name} already loaded, skipping activation...`)
|
||||
return false
|
||||
}
|
||||
if (mod.stylesGlobal) {
|
||||
const style = document.createElement('style')
|
||||
style.textContent = mod.stylesGlobal
|
||||
style.id = `mod-${mod.name}`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
if (mod.scriptMainUnstable) {
|
||||
const blob = new Blob([mod.scriptMainUnstable], { type: 'text/javascript' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
// eslint-disable-next-line no-useless-catch
|
||||
try {
|
||||
const module = await import(/* webpackIgnore: true */ url)
|
||||
module.default?.(structuredClone(mod))
|
||||
window.loadedMods[mod.name] ??= {}
|
||||
window.loadedMods[mod.name].mainUnstableModule = module
|
||||
} catch (e) {
|
||||
throw e
|
||||
}
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
if (mod.threeJsBackend) {
|
||||
const blob = new Blob([mod.threeJsBackend], { type: 'text/javascript' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
// eslint-disable-next-line no-useless-catch
|
||||
try {
|
||||
const module = await import(/* webpackIgnore: true */ url)
|
||||
// todo
|
||||
window.loadedMods[mod.name] ??= {}
|
||||
// for accessing global world var
|
||||
window.loadedMods[mod.name].threeJsBackendModule = module
|
||||
} catch (e) {
|
||||
throw e
|
||||
}
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
mod.enabled = true
|
||||
return true
|
||||
}
|
||||
|
||||
export const appStartup = async () => {
|
||||
void checkModsUpdates()
|
||||
|
||||
const mods = await getAllMods()
|
||||
for (const mod of mods) {
|
||||
await activateMod(mod, 'autostart').catch(e => {
|
||||
modsErrors[mod.name] ??= []
|
||||
modsErrors[mod.name].push(`startup: ${String(e)}`)
|
||||
console.error(`Error activating mod on startup ${mod.name}:`, e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const modsUpdateStatus = proxy({} as Record<string, [string, string]>)
|
||||
export const modsWaitingReloadStatus = proxy({} as Record<string, boolean>)
|
||||
export const modsErrors = proxy({} as Record<string, string[]>)
|
||||
|
||||
const normalizeRepoUrl = (url: string) => {
|
||||
if (url.startsWith('https://')) return url
|
||||
if (url.startsWith('http://')) return url
|
||||
if (url.startsWith('//')) return `https:${url}`
|
||||
return `https://raw.githubusercontent.com/${url}/master`
|
||||
}
|
||||
|
||||
const installOrUpdateMod = async (repo: Repository, mod: ClientModDefinition, activate = true, progress?: ProgressReporter) => {
|
||||
// eslint-disable-next-line no-useless-catch
|
||||
try {
|
||||
const fetchData = async (urls: string[]) => {
|
||||
const errored = [] as string[]
|
||||
// eslint-disable-next-line no-unreachable-loop
|
||||
for (const urlTemplate of urls) {
|
||||
const modNameOnly = mod.name.split('.').pop()
|
||||
const modFolder = repo.prefix === false ? modNameOnly : typeof repo.prefix === 'string' ? `${repo.prefix}/${modNameOnly}` : mod.name
|
||||
const url = new URL(`${modFolder}/${urlTemplate}`, normalizeRepoUrl(repo.url).replace(/\/$/, '') + '/').href
|
||||
// eslint-disable-next-line no-useless-catch
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`)
|
||||
return await response.text()
|
||||
} catch (e) {
|
||||
// errored.push(String(e))
|
||||
throw e
|
||||
}
|
||||
}
|
||||
console.warn(`[${mod.name}] Error installing component of ${urls[0]}: ${errored.join(', ')}`)
|
||||
return undefined
|
||||
}
|
||||
if (mod.stylesGlobal) {
|
||||
await progress?.executeWithMessage(
|
||||
`Downloading ${mod.name} styles`,
|
||||
async () => {
|
||||
mod.stylesGlobal = await fetchData(['global.css']) as any
|
||||
}
|
||||
)
|
||||
}
|
||||
if (mod.scriptMainUnstable) {
|
||||
await progress?.executeWithMessage(
|
||||
`Downloading ${mod.name} script`,
|
||||
async () => {
|
||||
mod.scriptMainUnstable = await fetchData(['mainUnstable.js']) as any
|
||||
}
|
||||
)
|
||||
}
|
||||
if (mod.threeJsBackend) {
|
||||
await progress?.executeWithMessage(
|
||||
`Downloading ${mod.name} three.js backend`,
|
||||
async () => {
|
||||
mod.threeJsBackend = await fetchData(['three.js']) as any
|
||||
}
|
||||
)
|
||||
}
|
||||
if (mod.serverPlugin) {
|
||||
if (mod.name.endsWith('.disabled')) throw new Error(`Mod name ${mod.name} can't end with .disabled`)
|
||||
await progress?.executeWithMessage(
|
||||
`Downloading ${mod.name} server plugin`,
|
||||
async () => {
|
||||
mod.serverPlugin = await fetchData(['serverPlugin.js']) as any
|
||||
}
|
||||
)
|
||||
}
|
||||
if (activate) {
|
||||
// todo try to de-activate mod if it's already loaded
|
||||
if (window.loadedMods?.[mod.name]) {
|
||||
modsWaitingReloadStatus[mod.name] = true
|
||||
} else {
|
||||
await activateMod(mod as ClientMod, 'install')
|
||||
}
|
||||
}
|
||||
await saveClientModData(mod as ClientMod)
|
||||
delete modsUpdateStatus[mod.name]
|
||||
} catch (e) {
|
||||
// console.error(`Error installing mod ${mod.name}:`, e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
const checkRepositoryUpdates = async (repo: Repository) => {
|
||||
for (const mod of repo.packages) {
|
||||
|
||||
const modExisting = await getPlugin(mod.name)
|
||||
if (modExisting?.version && gt(mod.version, modExisting.version)) {
|
||||
modsUpdateStatus[mod.name] = [modExisting.version, mod.version]
|
||||
if (options.modsAutoUpdate === 'always' && (!repo.autoUpdateOverride && !modExisting.autoUpdateOverride)) {
|
||||
void installOrUpdateMod(repo, mod).catch(e => {
|
||||
console.error(`Error updating mod ${mod.name}:`, e)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const fetchRepository = async (urlOriginal: string, url: string, hasMirrors = false) => {
|
||||
const fetchUrl = normalizeRepoUrl(url).replace(/\/$/, '') + '/mcraft-repo.json'
|
||||
try {
|
||||
const response = await fetch(fetchUrl).then(async res => res.json())
|
||||
if (!response.packages) throw new Error(`No packages field in the response json of the repository: ${fetchUrl}`)
|
||||
response.autoUpdateOverride = (await getRepository(urlOriginal))?.autoUpdateOverride
|
||||
response.url = urlOriginal
|
||||
void saveRepository(response)
|
||||
modsReactiveUpdater.counter++
|
||||
return true
|
||||
} catch (e) {
|
||||
console.warn(`Error fetching repository (trying other mirrors) ${url}:`, e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const fetchAllRepositories = async () => {
|
||||
const repositories = await getAllRepositories()
|
||||
await Promise.all(repositories.map(async (repo) => {
|
||||
const allUrls = [repo.url, ...(repo.mirrorUrls || [])]
|
||||
for (const [i, url] of allUrls.entries()) {
|
||||
const isLast = i === allUrls.length - 1
|
||||
|
||||
if (await fetchRepository(repo.url, url, !isLast)) break
|
||||
}
|
||||
}))
|
||||
appStorage.modsAutoUpdateLastCheck = Date.now()
|
||||
}
|
||||
|
||||
const checkModsUpdates = async () => {
|
||||
await autoRefreshModRepositories()
|
||||
for (const repo of await getAllRepositories()) {
|
||||
|
||||
await checkRepositoryUpdates(repo)
|
||||
}
|
||||
}
|
||||
|
||||
const autoRefreshModRepositories = async () => {
|
||||
if (options.modsAutoUpdate === 'never') return
|
||||
const lastCheck = appStorage.modsAutoUpdateLastCheck
|
||||
if (lastCheck && Date.now() - lastCheck < 1000 * 60 * 60 * options.modsUpdatePeriodCheck) return
|
||||
await fetchAllRepositories()
|
||||
// todo think of not updating check timestamp on offline access
|
||||
}
|
||||
|
||||
export const installModByName = async (repoUrl: string, name: string, progress?: ProgressReporter) => {
|
||||
progress?.beginStage('main', `Installing ${name}`)
|
||||
const repo = await getRepository(repoUrl)
|
||||
if (!repo) throw new Error(`Repository ${repoUrl} not found`)
|
||||
const mod = repo.packages.find(m => m.name === name)
|
||||
if (!mod) throw new Error(`Mod ${name} not found in repository ${repoUrl}`)
|
||||
await installOrUpdateMod(repo, mod, undefined, progress)
|
||||
progress?.endStage('main')
|
||||
}
|
||||
|
||||
export const uninstallModAction = async (name: string) => {
|
||||
const choice = await showOptionsModal(`Uninstall mod ${name}?`, ['Yes'])
|
||||
if (!choice) return
|
||||
await deletePlugin(name)
|
||||
window.loadedMods ??= {}
|
||||
if (window.loadedMods[name]) {
|
||||
// window.loadedMods[name].default?.(null)
|
||||
delete window.loadedMods[name]
|
||||
modsWaitingReloadStatus[name] = true
|
||||
}
|
||||
// Clear any errors associated with the mod
|
||||
delete modsErrors[name]
|
||||
}
|
||||
|
||||
export const setEnabledModAction = async (name: string, newEnabled: boolean) => {
|
||||
const mod = await getPlugin(name)
|
||||
if (!mod) throw new Error(`Mod ${name} not found`)
|
||||
if (newEnabled) {
|
||||
mod.enabled = true
|
||||
if (!window.loadedMods?.[mod.name]) {
|
||||
await activateMod(mod, 'manual')
|
||||
}
|
||||
} else {
|
||||
// todo deactivate mod
|
||||
mod.enabled = false
|
||||
if (window.loadedMods?.[mod.name]) {
|
||||
if (window.loadedMods[mod.name]?.threeJsBackendModule) {
|
||||
window.loadedMods[mod.name].threeJsBackendModule.deactivate()
|
||||
delete window.loadedMods[mod.name].threeJsBackendModule
|
||||
}
|
||||
if (window.loadedMods[mod.name]?.mainUnstableModule) {
|
||||
window.loadedMods[mod.name].mainUnstableModule.deactivate()
|
||||
delete window.loadedMods[mod.name].mainUnstableModule
|
||||
}
|
||||
|
||||
if (Object.keys(window.loadedMods[mod.name]).length === 0) {
|
||||
delete window.loadedMods[mod.name]
|
||||
}
|
||||
}
|
||||
}
|
||||
await saveClientModData(mod)
|
||||
}
|
||||
|
||||
export const modsReactiveUpdater = proxy({
|
||||
counter: 0
|
||||
})
|
||||
|
||||
export const getAllModsDisplayList = async () => {
|
||||
const repos = await getAllRepositories()
|
||||
const installedMods = await getAllMods()
|
||||
const modsWithoutRepos = installedMods.filter(mod => !repos.some(repo => repo.packages.some(m => m.name === mod.name)))
|
||||
const mapMods = (mapMods: ClientMod[]) => mapMods.map(mod => ({
|
||||
...mod,
|
||||
installed: installedMods.find(m => m.name === mod.name),
|
||||
activated: !!window.loadedMods?.[mod.name],
|
||||
installedVersion: installedMods.find(m => m.name === mod.name)?.version,
|
||||
canBeActivated: mod.scriptMainUnstable || mod.stylesGlobal,
|
||||
}))
|
||||
return {
|
||||
repos: repos.map(repo => ({
|
||||
...repo,
|
||||
packages: mapMods(repo.packages as ClientMod[]),
|
||||
})),
|
||||
modsWithoutRepos: mapMods(modsWithoutRepos),
|
||||
}
|
||||
}
|
||||
|
||||
export const removeRepositoryAction = async (url: string) => {
|
||||
// todo remove mods
|
||||
const choice = await showOptionsModal('Remove repository? Installed mods wont be automatically removed.', ['Yes'])
|
||||
if (!choice) return
|
||||
await deleteRepository(url)
|
||||
modsReactiveUpdater.counter++
|
||||
}
|
||||
|
||||
export const selectAndRemoveRepository = async () => {
|
||||
const repos = await getAllRepositories()
|
||||
const choice = await showOptionsModal('Select repository to remove', repos.map(repo => repo.url))
|
||||
if (!choice) return
|
||||
await removeRepositoryAction(choice)
|
||||
}
|
||||
|
||||
export const addRepositoryAction = async () => {
|
||||
const { url } = await showInputsModal('Add repository', {
|
||||
url: {
|
||||
type: 'text',
|
||||
label: 'Repository URL or slug',
|
||||
placeholder: 'github-owner/repo-name',
|
||||
},
|
||||
})
|
||||
if (!url) return
|
||||
await fetchRepository(url, url)
|
||||
}
|
||||
|
||||
export const getServerPlugin = async (plugin: string) => {
|
||||
const mod = await getPlugin(plugin)
|
||||
if (!mod) return null
|
||||
if (mod.serverPlugin) {
|
||||
return {
|
||||
content: mod.serverPlugin,
|
||||
version: mod.version
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const getAvailableServerPlugins = async () => {
|
||||
const mods = await getAllMods()
|
||||
return mods.filter(mod => mod.serverPlugin)
|
||||
}
|
||||
|
||||
window.inspectInstalledMods = getAllMods
|
||||
|
||||
type ModifiableField = {
|
||||
field: string
|
||||
label: string
|
||||
language: string
|
||||
getContent?: () => string
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
export const getAllModsModifiableFields = () => {
|
||||
const fields: ModifiableField[] = [
|
||||
{
|
||||
field: 'scriptMainUnstable',
|
||||
label: 'Main Thread Script (unstable)',
|
||||
language: 'js'
|
||||
},
|
||||
{
|
||||
field: 'stylesGlobal',
|
||||
label: 'Global CSS Styles',
|
||||
language: 'css'
|
||||
},
|
||||
{
|
||||
field: 'threeJsBackend',
|
||||
label: 'Three.js Renderer Backend Thread',
|
||||
language: 'js'
|
||||
},
|
||||
{
|
||||
field: 'serverPlugin',
|
||||
label: 'Built-in server plugin',
|
||||
language: 'js'
|
||||
}
|
||||
]
|
||||
return fields
|
||||
}
|
||||
|
||||
export const getModModifiableFields = (mod: ClientMod): ModifiableField[] => {
|
||||
return getAllModsModifiableFields().filter(field => mod[field.field])
|
||||
}
|
||||
|
|
@ -41,36 +41,43 @@ const controlOptions = {
|
|||
export const contro = new ControMax({
|
||||
commands: {
|
||||
general: {
|
||||
// movement
|
||||
jump: ['Space', 'A'],
|
||||
inventory: ['KeyE', 'X'],
|
||||
drop: ['KeyQ', 'B'],
|
||||
sneak: ['ShiftLeft'],
|
||||
toggleSneakOrDown: [null, 'Right Stick'],
|
||||
sprint: ['ControlLeft', 'Left Stick'],
|
||||
// game interactions
|
||||
nextHotbarSlot: [null, 'Right Bumper'],
|
||||
prevHotbarSlot: [null, 'Left Bumper'],
|
||||
attackDestroy: [null, 'Right Trigger'],
|
||||
interactPlace: [null, 'Left Trigger'],
|
||||
chat: [['KeyT', 'Enter']],
|
||||
command: ['Slash'],
|
||||
swapHands: ['KeyF'],
|
||||
zoom: ['KeyC'],
|
||||
selectItem: ['KeyH'], // default will be removed
|
||||
rotateCameraLeft: [null],
|
||||
rotateCameraRight: [null],
|
||||
rotateCameraUp: [null],
|
||||
rotateCameraDown: [null],
|
||||
viewerConsole: ['Backquote']
|
||||
// ui?
|
||||
chat: [['KeyT', 'Enter']],
|
||||
command: ['Slash'],
|
||||
// client side
|
||||
zoom: ['KeyC'],
|
||||
viewerConsole: ['Backquote'],
|
||||
},
|
||||
ui: {
|
||||
toggleFullscreen: ['F11'],
|
||||
back: [null/* 'Escape' */, 'B'],
|
||||
toggleMap: ['KeyM'],
|
||||
toggleMap: ['KeyJ'],
|
||||
leftClick: [null, 'A'],
|
||||
rightClick: [null, 'Y'],
|
||||
speedupCursor: [null, 'Left Stick'],
|
||||
pauseMenu: [null, 'Start']
|
||||
},
|
||||
communication: {
|
||||
toggleMicrophone: ['KeyK'],
|
||||
},
|
||||
advanced: {
|
||||
lockUrl: ['KeyY'],
|
||||
},
|
||||
|
|
@ -177,6 +184,7 @@ contro.on('movementUpdate', ({ vector, soleVector, gamepadIndex }) => {
|
|||
if (action) {
|
||||
void contro.emit('trigger', { command: 'general.forward' } as any)
|
||||
} else {
|
||||
void contro.emit('release', { command: 'general.forward' } as any)
|
||||
setSprinting(false)
|
||||
}
|
||||
}
|
||||
|
|
@ -549,6 +557,10 @@ contro.on('trigger', ({ command }) => {
|
|||
}
|
||||
}
|
||||
|
||||
if (command === 'communication.toggleMicrophone') {
|
||||
// toggleMicrophoneMuted()
|
||||
}
|
||||
|
||||
if (command === 'ui.pauseMenu') {
|
||||
showModal({ reactType: 'pause-screen' })
|
||||
}
|
||||
|
|
@ -613,6 +625,13 @@ export const f3Keybinds: Array<{
|
|||
},
|
||||
mobileTitle: 'Toggle chunk borders',
|
||||
},
|
||||
{
|
||||
key: 'KeyH',
|
||||
action () {
|
||||
showModal({ reactType: 'chunks-debug' })
|
||||
},
|
||||
mobileTitle: 'Show Chunks Debug',
|
||||
},
|
||||
{
|
||||
key: 'KeyY',
|
||||
async action () {
|
||||
|
|
@ -761,6 +780,11 @@ const selectItem = async () => {
|
|||
}
|
||||
|
||||
addEventListener('mousedown', async (e) => {
|
||||
// always prevent default for side buttons (back / forward navigation)
|
||||
if (e.button === 3 || e.button === 4) {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
if ((e.target as HTMLElement).matches?.('#VRButton')) return
|
||||
if (!isInRealGameSession() && !(e.target as HTMLElement).id.includes('ui-root')) return
|
||||
void pointerLock.requestPointerLock()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { setLoadingScreenStatus } from '../appStatus'
|
||||
import { appStatusState } from '../react/AppStatusProvider'
|
||||
import { hideNotification, showNotification } from '../react/NotificationProvider'
|
||||
import { pixelartIcons } from '../react/PixelartIcon'
|
||||
|
||||
export interface ProgressReporter {
|
||||
currentMessage: string | undefined
|
||||
|
|
@ -163,15 +164,16 @@ export const createFullScreenProgressReporter = (): ProgressReporter => {
|
|||
}
|
||||
|
||||
export const createNotificationProgressReporter = (endMessage?: string): ProgressReporter => {
|
||||
const id = `progress-reporter-${Math.random().toString(36).slice(2)}`
|
||||
return createProgressReporter({
|
||||
setMessage (message: string) {
|
||||
showNotification(`${message}...`, '', false, '', undefined, true)
|
||||
showNotification(`${message}...`, '', false, '', undefined, true, id)
|
||||
},
|
||||
end () {
|
||||
if (endMessage) {
|
||||
showNotification(endMessage, '', false, '', undefined, true)
|
||||
showNotification(endMessage, '', false, pixelartIcons.check, undefined, true)
|
||||
} else {
|
||||
hideNotification()
|
||||
hideNotification(id)
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -218,10 +218,6 @@ const registerMediaChannels = () => {
|
|||
{ name: 'z', type: 'f32' },
|
||||
{ name: 'width', type: 'f32' },
|
||||
{ name: 'height', type: 'f32' },
|
||||
// N, 0
|
||||
// W, 3
|
||||
// S, 2
|
||||
// E, 1
|
||||
{ name: 'rotation', type: 'i16' }, // 0: 0° - towards positive z, 1: 90° - positive x, 2: 180° - negative z, 3: 270° - negative x (3-6 is same but double side)
|
||||
{ name: 'source', type: ['pstring', { countType: 'i16' }] },
|
||||
{ name: 'loop', type: 'bool' },
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ module.exports = {
|
|||
'gameMode': 0,
|
||||
'difficulty': 0,
|
||||
'worldFolder': 'world',
|
||||
'pluginsFolder': true,
|
||||
// todo set sid, disable entities auto-spawn
|
||||
'generation': {
|
||||
// grass_field
|
||||
|
|
@ -33,6 +34,6 @@ module.exports = {
|
|||
keepAlive: false,
|
||||
'everybody-op': true,
|
||||
'max-entities': 100,
|
||||
'version': '1.18.2',
|
||||
'version': '1.18',
|
||||
versionMajor: '1.18'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { subscribeKey } from 'valtio/utils'
|
|||
import { getThreeJsRendererMethods } from 'renderer/viewer/three/threeJsMethods'
|
||||
import { options, watchValue } from './optionsStorage'
|
||||
import { miscUiState } from './globalState'
|
||||
import { EntityStatus } from './mineflayer/entityStatus'
|
||||
|
||||
|
||||
const updateAutoJump = () => {
|
||||
|
|
@ -85,7 +86,7 @@ customEvents.on('gameLoaded', () => {
|
|||
bot._client.on('entity_status', (data) => {
|
||||
if (versionToNumber(bot.version) >= versionToNumber('1.19.4')) return
|
||||
const { entityId, entityStatus } = data
|
||||
if (entityStatus === 2) {
|
||||
if (entityStatus === EntityStatus.HURT) {
|
||||
getThreeJsRendererMethods()?.damageEntity(entityId, entityStatus)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ import { AppConfig } from './appConfig'
|
|||
|
||||
// todo: refactor structure with support of hideNext=false
|
||||
|
||||
export const notHideableModalsWithoutForce = new Set(['app-status'])
|
||||
export const notHideableModalsWithoutForce = new Set([
|
||||
'app-status',
|
||||
'divkit:nonclosable',
|
||||
'only-connect-server',
|
||||
])
|
||||
|
||||
type Modal = ({ elem?: HTMLElement & Record<string, any> } & { reactType: string })
|
||||
|
||||
|
|
@ -35,10 +39,10 @@ const showModalInner = (modal: Modal) => {
|
|||
return true
|
||||
}
|
||||
|
||||
export const showModal = (elem: /* (HTMLElement & Record<string, any>) | */{ reactType: string }) => {
|
||||
const resolved = elem
|
||||
export const showModal = (elem: /* (HTMLElement & Record<string, any>) | */{ reactType: string } | string) => {
|
||||
const resolved = typeof elem === 'string' ? { reactType: elem } : elem
|
||||
const curModal = activeModalStack.at(-1)
|
||||
if (/* elem === curModal?.elem || */(elem.reactType && elem.reactType === curModal?.reactType) || !showModalInner(resolved)) return
|
||||
if ((resolved.reactType && resolved.reactType === curModal?.reactType) || !showModalInner(resolved)) return
|
||||
activeModalStack.push(resolved)
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +53,7 @@ export const showModal = (elem: /* (HTMLElement & Record<string, any>) | */{ re
|
|||
export const hideModal = (modal = activeModalStack.at(-1), data: any = undefined, options: { force?: boolean; restorePrevious?: boolean } = {}) => {
|
||||
const { force = false, restorePrevious = true } = options
|
||||
if (!modal) return
|
||||
let cancel = notHideableModalsWithoutForce.has(modal.reactType) ? !force : undefined
|
||||
let cancel = [...notHideableModalsWithoutForce].some(m => modal.reactType.startsWith(m)) ? !force : undefined
|
||||
if (force) {
|
||||
cancel = undefined
|
||||
}
|
||||
|
|
|
|||
87
src/index.ts
87
src/index.ts
|
|
@ -14,6 +14,7 @@ import './mineflayer/java-tester/index'
|
|||
import './external'
|
||||
import './appConfig'
|
||||
import './mineflayer/timers'
|
||||
import './mineflayer/plugins'
|
||||
import { getServerInfo } from './mineflayer/mc-protocol'
|
||||
import { onGameLoad } from './inventoryWindows'
|
||||
import initCollisionShapes from './getCollisionInteractionShapes'
|
||||
|
|
@ -27,8 +28,7 @@ import { options } from './optionsStorage'
|
|||
import './reactUi'
|
||||
import { lockUrl, onBotCreate } from './controls'
|
||||
import './dragndrop'
|
||||
import { possiblyCleanHandle, resetStateAfterDisconnect } from './browserfs'
|
||||
import { watchOptionsAfterViewerInit, watchOptionsAfterWorldViewInit } from './watchOptions'
|
||||
import { possiblyCleanHandle } from './browserfs'
|
||||
import downloadAndOpenFile from './downloadAndOpenFile'
|
||||
|
||||
import fs from 'fs'
|
||||
|
|
@ -74,29 +74,29 @@ import { showNotification } from './react/NotificationProvider'
|
|||
import { saveToBrowserMemory } from './react/PauseScreen'
|
||||
import './devReload'
|
||||
import './water'
|
||||
import { ConnectOptions, loadMinecraftData, getVersionAutoSelect, downloadOtherGameData, downloadAllMinecraftData } from './connect'
|
||||
import { ConnectOptions, getVersionAutoSelect, downloadOtherGameData, downloadAllMinecraftData } from './connect'
|
||||
import { ref, subscribe } from 'valtio'
|
||||
import { signInMessageState } from './react/SignInMessageProvider'
|
||||
import { updateAuthenticatedAccountData, updateLoadedServerData, updateServerConnectionHistory } from './react/serversStorage'
|
||||
import packetsPatcher from './mineflayer/plugins/packetsPatcher'
|
||||
import { mainMenuState } from './react/MainMenuRenderApp'
|
||||
import './mobileShim'
|
||||
import { parseFormattedMessagePacket } from './botUtils'
|
||||
import { getViewerVersionData, getWsProtocolStream, handleCustomChannel } from './viewerConnector'
|
||||
import { appStartup } from './clientMods'
|
||||
import { getViewerVersionData, getWsProtocolStream, onBotCreatedViewerHandler } from './viewerConnector'
|
||||
import { getWebsocketStream } from './mineflayer/websocket-core'
|
||||
import { appQueryParams, appQueryParamsArray } from './appParams'
|
||||
import { playerState } from './mineflayer/playerState'
|
||||
import { states } from 'minecraft-protocol'
|
||||
import { initMotionTracking } from './react/uiMotion'
|
||||
import { UserError } from './mineflayer/userError'
|
||||
import ping from './mineflayer/plugins/ping'
|
||||
import mouse from './mineflayer/plugins/mouse'
|
||||
import { startLocalReplayServer } from './packetsReplay/replayPackets'
|
||||
import { localRelayServerPlugin } from './mineflayer/plugins/packetsRecording'
|
||||
import { createConsoleLogProgressReporter, createFullScreenProgressReporter, ProgressReporter } from './core/progressReporter'
|
||||
import { createFullScreenProgressReporter, createWrappedProgressReporter, ProgressReporter } from './core/progressReporter'
|
||||
import { appViewer } from './appViewer'
|
||||
import './appViewerLoad'
|
||||
import { registerOpenBenchmarkListener } from './benchmark'
|
||||
import { tryHandleBuiltinCommand } from './builtinCommands'
|
||||
import { loadingTimerState } from './react/LoadingTimer'
|
||||
import { loadPluginsIntoWorld } from './react/CreateWorldProvider'
|
||||
|
||||
window.debug = debug
|
||||
window.beforeRenderFrame = []
|
||||
|
|
@ -109,7 +109,6 @@ void registerServiceWorker().then(() => {
|
|||
watchFov()
|
||||
initCollisionShapes()
|
||||
initializePacketsReplay()
|
||||
packetsPatcher()
|
||||
onAppLoad()
|
||||
customChannels()
|
||||
|
||||
|
|
@ -167,6 +166,8 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
})
|
||||
}
|
||||
|
||||
loadingTimerState.loading = true
|
||||
loadingTimerState.start = Date.now()
|
||||
miscUiState.hasErrors = false
|
||||
lastConnectOptions.value = connectOptions
|
||||
|
||||
|
|
@ -208,8 +209,13 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
|
||||
let ended = false
|
||||
let bot!: typeof __type_bot
|
||||
const destroyAll = () => {
|
||||
const destroyAll = (wasKicked = false) => {
|
||||
if (ended) return
|
||||
loadingTimerState.loading = false
|
||||
const hadConnected = !!bot
|
||||
if (!wasKicked && miscUiState.appConfig?.allowAutoConnect && appQueryParams.autoConnect && hadConnected) {
|
||||
location.reload()
|
||||
}
|
||||
errorAbortController.abort()
|
||||
ended = true
|
||||
progress.end()
|
||||
|
|
@ -251,6 +257,10 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
if (isCypress()) throw err
|
||||
miscUiState.hasErrors = true
|
||||
if (miscUiState.gameLoaded) return
|
||||
// close all modals
|
||||
for (const modal of activeModalStack) {
|
||||
hideModal(modal)
|
||||
}
|
||||
|
||||
setLoadingScreenStatus(`Error encountered. ${err}`, true)
|
||||
appStatusState.showReconnect = true
|
||||
|
|
@ -280,7 +290,7 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
|
||||
if (connectOptions.server && !connectOptions.viewerWsConnect && !parsedServer.isWebSocket) {
|
||||
console.log(`using proxy ${proxy.host}:${proxy.port || location.port}`)
|
||||
net['setProxy']({ hostname: proxy.host, port: proxy.port })
|
||||
net['setProxy']({ hostname: proxy.host, port: proxy.port, headers: { Authorization: `Bearer ${new URLSearchParams(location.search).get('token') ?? ''}` } })
|
||||
}
|
||||
|
||||
const renderDistance = singleplayer ? renderDistanceSingleplayer : multiplayerRenderDistance
|
||||
|
|
@ -293,10 +303,12 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
Object.assign(serverOptions, connectOptions.serverOverridesFlat ?? {})
|
||||
|
||||
await progress.executeWithMessage('Downloading minecraft data', 'download-mcdata', async () => {
|
||||
loadingTimerState.networkOnlyStart = Date.now()
|
||||
await Promise.all([
|
||||
downloadAllMinecraftData(),
|
||||
downloadOtherGameData()
|
||||
])
|
||||
loadingTimerState.networkOnlyStart = 0
|
||||
})
|
||||
|
||||
let dataDownloaded = false
|
||||
|
|
@ -359,6 +371,16 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
// Client (class) of flying-squid (in server/login.js of mc-protocol): onLogin handler: skip most logic & go to loginClient() which assigns uuid and sends 'success' back to client (onLogin handler) and emits 'login' on the server (login.js in flying-squid handler)
|
||||
// flying-squid: 'login' -> player.login -> now sends 'login' event to the client (handled in many plugins in mineflayer) -> then 'update_health' is sent which emits 'spawn' in mineflayer
|
||||
|
||||
const serverPlugins = new URLSearchParams(location.search).getAll('serverPlugin')
|
||||
if (serverPlugins.length > 0 && !serverOptions.worldFolder) {
|
||||
console.log('Placing server plugins', serverPlugins)
|
||||
|
||||
serverOptions.worldFolder ??= '/temp'
|
||||
await loadPluginsIntoWorld('/temp', serverPlugins)
|
||||
|
||||
console.log('Server plugins placed')
|
||||
}
|
||||
|
||||
localServer = window.localServer = window.server = startLocalServer(serverOptions)
|
||||
connectOptions?.connectEvents?.serverCreated?.()
|
||||
// todo need just to call quit if started
|
||||
|
|
@ -392,8 +414,10 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
} else if (connectOptions.server) {
|
||||
if (!finalVersion) {
|
||||
const versionAutoSelect = getVersionAutoSelect()
|
||||
setLoadingScreenStatus(`Fetching server version. Preffered: ${versionAutoSelect}`)
|
||||
const wrapped = createWrappedProgressReporter(progress, `Fetching server version. Preffered: ${versionAutoSelect}`)
|
||||
loadingTimerState.networkOnlyStart = Date.now()
|
||||
const autoVersionSelect = await getServerInfo(server.host, server.port ? Number(server.port) : undefined, versionAutoSelect)
|
||||
wrapped.end()
|
||||
finalVersion = autoVersionSelect.version
|
||||
}
|
||||
initialLoadingText = `Connecting to server ${server.host}:${server.port ?? 25_565} with version ${finalVersion}`
|
||||
|
|
@ -407,6 +431,7 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
setLoadingScreenStatus(initialLoadingText)
|
||||
|
||||
if (parsedServer.isWebSocket) {
|
||||
loadingTimerState.networkOnlyStart = Date.now()
|
||||
clientDataStream = (await getWebsocketStream(server.host)).mineflayerStream
|
||||
}
|
||||
|
||||
|
|
@ -450,6 +475,7 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
|
||||
if (finalVersion) {
|
||||
// ensure data is downloaded
|
||||
loadingTimerState.networkOnlyStart ??= Date.now()
|
||||
await downloadMcData(finalVersion)
|
||||
}
|
||||
|
||||
|
|
@ -533,7 +559,7 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
}) as unknown as typeof __type_bot
|
||||
window.bot = bot
|
||||
if (connectOptions.viewerWsConnect) {
|
||||
void handleCustomChannel()
|
||||
void onBotCreatedViewerHandler()
|
||||
}
|
||||
customEvents.emit('mineflayerBotCreated')
|
||||
if (singleplayer || p2pMultiplayer || localReplaySession) {
|
||||
|
|
@ -603,14 +629,6 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
}
|
||||
if (!bot) return
|
||||
|
||||
if (connectOptions.server) {
|
||||
bot.loadPlugin(ping)
|
||||
}
|
||||
bot.loadPlugin(mouse)
|
||||
if (!localReplaySession) {
|
||||
bot.loadPlugin(localRelayServerPlugin)
|
||||
}
|
||||
|
||||
const p2pConnectTimeout = p2pMultiplayer ? setTimeout(() => { throw new UserError('Spawn timeout. There might be error on the other side, check console.') }, 20_000) : undefined
|
||||
|
||||
// bot.on('inject_allowed', () => {
|
||||
|
|
@ -622,9 +640,13 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
bot.on('kicked', (kickReason) => {
|
||||
console.log('You were kicked!', kickReason)
|
||||
const { formatted: kickReasonFormatted, plain: kickReasonString } = parseFormattedMessagePacket(kickReason)
|
||||
// close all modals
|
||||
for (const modal of activeModalStack) {
|
||||
hideModal(modal)
|
||||
}
|
||||
setLoadingScreenStatus(`The Minecraft server kicked you. Kick reason: ${kickReasonString}`, true, undefined, undefined, kickReasonFormatted)
|
||||
appStatusState.showReconnect = true
|
||||
destroyAll()
|
||||
destroyAll(true)
|
||||
})
|
||||
|
||||
const packetBeforePlay = (_, __, ___, fullBuffer) => {
|
||||
|
|
@ -644,6 +666,10 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
if (endReason === 'socketClosed') {
|
||||
endReason = lastKnownKickReason ?? 'Connection with proxy server lost'
|
||||
}
|
||||
// close all modals
|
||||
for (const modal of activeModalStack) {
|
||||
hideModal(modal)
|
||||
}
|
||||
setLoadingScreenStatus(`You have been disconnected from the server. End reason:\n${endReason}`, true)
|
||||
appStatusState.showReconnect = true
|
||||
onPossibleErrorDisconnect()
|
||||
|
|
@ -654,7 +680,8 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
onBotCreate()
|
||||
|
||||
bot.once('login', () => {
|
||||
setLoadingScreenStatus('Loading world')
|
||||
loadingTimerState.networkOnlyStart = 0
|
||||
progress.setMessage('Loading world')
|
||||
})
|
||||
|
||||
let worldWasReady = false
|
||||
|
|
@ -799,7 +826,10 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
const commands = appQueryParamsArray.command ?? []
|
||||
for (let command of commands) {
|
||||
if (!command.startsWith('/')) command = `/${command}`
|
||||
bot.chat(command)
|
||||
const builtinHandled = tryHandleBuiltinCommand(command)
|
||||
if (!builtinHandled) {
|
||||
bot.chat(command)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -888,7 +918,11 @@ if (!reconnectOptions) {
|
|||
const waitAppConfigLoad = !appQueryParams.proxy
|
||||
const openServerEditor = () => {
|
||||
hideModal()
|
||||
showModal({ reactType: 'editServer' })
|
||||
if (appQueryParams.onlyConnect) {
|
||||
showModal({ reactType: 'only-connect-server' })
|
||||
} else {
|
||||
showModal({ reactType: 'editServer' })
|
||||
}
|
||||
}
|
||||
showModal({ reactType: 'empty' })
|
||||
if (waitAppConfigLoad) {
|
||||
|
|
@ -962,4 +996,5 @@ if (initialLoader) {
|
|||
window.pageLoaded = true
|
||||
|
||||
void possiblyHandleStateVariable()
|
||||
appViewer.waitBackendLoadPromises.push(appStartup())
|
||||
registerOpenBenchmarkListener()
|
||||
|
|
|
|||
|
|
@ -216,6 +216,8 @@ export const renderSlot = (model: ResolvedItemModelRender, debugIsQuickbar = fal
|
|||
}
|
||||
}
|
||||
|
||||
const blockToTopTexture = (r) => r.top ?? r
|
||||
|
||||
try {
|
||||
assertDefined(appViewer.resourcesManager.currentResources?.itemsRenderer)
|
||||
itemTexture =
|
||||
|
|
@ -224,9 +226,11 @@ export const renderSlot = (model: ResolvedItemModelRender, debugIsQuickbar = fal
|
|||
?? appViewer.resourcesManager.currentResources.itemsRenderer.getItemTexture('item/missing_texture')!
|
||||
} catch (err) {
|
||||
inGameError(`Failed to render item ${itemModelName} (original: ${originalItemName}) on ${bot.version} (resourcepack: ${options.enabledResourcepack}): ${err.stack}`)
|
||||
itemTexture = appViewer.resourcesManager.currentResources!.itemsRenderer.getItemTexture('block/errored')!
|
||||
itemTexture = blockToTopTexture(appViewer.resourcesManager.currentResources!.itemsRenderer.getItemTexture('errored')!)
|
||||
}
|
||||
|
||||
itemTexture ??= blockToTopTexture(appViewer.resourcesManager.currentResources!.itemsRenderer.getItemTexture('unknown')!)
|
||||
|
||||
|
||||
if ('type' in itemTexture) {
|
||||
// is item
|
||||
|
|
|
|||
70
src/mineflayer/entityStatus.ts
Normal file
70
src/mineflayer/entityStatus.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
export const EntityStatus = {
|
||||
JUMP: 1,
|
||||
HURT: 2, // legacy
|
||||
DEATH: 3,
|
||||
START_ATTACKING: 4,
|
||||
STOP_ATTACKING: 5,
|
||||
TAMING_FAILED: 6,
|
||||
TAMING_SUCCEEDED: 7,
|
||||
SHAKE_WETNESS: 8,
|
||||
USE_ITEM_COMPLETE: 9,
|
||||
EAT_GRASS: 10,
|
||||
OFFER_FLOWER: 11,
|
||||
LOVE_HEARTS: 12,
|
||||
VILLAGER_ANGRY: 13,
|
||||
VILLAGER_HAPPY: 14,
|
||||
WITCH_HAT_MAGIC: 15,
|
||||
ZOMBIE_CONVERTING: 16,
|
||||
FIREWORKS_EXPLODE: 17,
|
||||
IN_LOVE_HEARTS: 18,
|
||||
SQUID_ANIM_SYNCH: 19,
|
||||
SILVERFISH_MERGE_ANIM: 20,
|
||||
GUARDIAN_ATTACK_SOUND: 21,
|
||||
REDUCED_DEBUG_INFO: 22,
|
||||
FULL_DEBUG_INFO: 23,
|
||||
PERMISSION_LEVEL_ALL: 24,
|
||||
PERMISSION_LEVEL_MODERATORS: 25,
|
||||
PERMISSION_LEVEL_GAMEMASTERS: 26,
|
||||
PERMISSION_LEVEL_ADMINS: 27,
|
||||
PERMISSION_LEVEL_OWNERS: 28,
|
||||
ATTACK_BLOCKED: 29,
|
||||
SHIELD_DISABLED: 30,
|
||||
FISHING_ROD_REEL_IN: 31,
|
||||
ARMORSTAND_WOBBLE: 32,
|
||||
THORNED: 33, // legacy
|
||||
STOP_OFFER_FLOWER: 34,
|
||||
TALISMAN_ACTIVATE: 35, // legacy
|
||||
DROWNED: 36, // legacy
|
||||
BURNED: 37, // legacy
|
||||
DOLPHIN_LOOKING_FOR_TREASURE: 38,
|
||||
RAVAGER_STUNNED: 39,
|
||||
TRUSTING_FAILED: 40,
|
||||
TRUSTING_SUCCEEDED: 41,
|
||||
VILLAGER_SWEAT: 42,
|
||||
BAD_OMEN_TRIGGERED: 43, // legacy
|
||||
POKED: 44, // legacy
|
||||
FOX_EAT: 45,
|
||||
TELEPORT: 46,
|
||||
MAINHAND_BREAK: 47,
|
||||
OFFHAND_BREAK: 48,
|
||||
HEAD_BREAK: 49,
|
||||
CHEST_BREAK: 50,
|
||||
LEGS_BREAK: 51,
|
||||
FEET_BREAK: 52,
|
||||
HONEY_SLIDE: 53,
|
||||
HONEY_JUMP: 54,
|
||||
SWAP_HANDS: 55,
|
||||
CANCEL_SHAKE_WETNESS: 56,
|
||||
FROZEN: 57, // legacy
|
||||
START_RAM: 58,
|
||||
END_RAM: 59,
|
||||
POOF: 60,
|
||||
TENDRILS_SHIVER: 61,
|
||||
SONIC_CHARGE: 62,
|
||||
SNIFFER_DIGGING_SOUND: 63,
|
||||
ARMADILLO_PEEK: 64,
|
||||
BODY_BREAK: 65,
|
||||
SHAKE: 66
|
||||
} as const
|
||||
|
||||
export type EntityStatusName = keyof typeof EntityStatus
|
||||
|
|
@ -2,6 +2,7 @@ import { Client } from 'minecraft-protocol'
|
|||
import { appQueryParams } from '../appParams'
|
||||
import { downloadAllMinecraftData, getVersionAutoSelect } from '../connect'
|
||||
import { gameAdditionalState } from '../globalState'
|
||||
import { ProgressReporter } from '../core/progressReporter'
|
||||
import { pingServerVersion, validatePacket } from './minecraft-protocol-extra'
|
||||
import { getWebsocketStream } from './websocket-core'
|
||||
|
||||
|
|
@ -34,16 +35,27 @@ setInterval(() => {
|
|||
}, 1000)
|
||||
|
||||
|
||||
export const getServerInfo = async (ip: string, port?: number, preferredVersion = getVersionAutoSelect(), ping = false) => {
|
||||
export const getServerInfo = async (ip: string, port?: number, preferredVersion = getVersionAutoSelect(), ping = false, progressReporter?: ProgressReporter) => {
|
||||
await downloadAllMinecraftData()
|
||||
const isWebSocket = ip.startsWith('ws://') || ip.startsWith('wss://')
|
||||
let stream
|
||||
if (isWebSocket) {
|
||||
progressReporter?.setMessage('Connecting to WebSocket server')
|
||||
stream = (await getWebsocketStream(ip)).mineflayerStream
|
||||
progressReporter?.setMessage('WebSocket connected. Ping packet sent, waiting for response')
|
||||
}
|
||||
window.setLoadingMessage = (message?: string) => {
|
||||
if (message === undefined) {
|
||||
progressReporter?.endStage('dns')
|
||||
} else {
|
||||
progressReporter?.beginStage('dns', message)
|
||||
}
|
||||
}
|
||||
return pingServerVersion(ip, port, {
|
||||
...(stream ? { stream } : {}),
|
||||
...(ping ? { noPongTimeout: 3000 } : {}),
|
||||
...(preferredVersion ? { version: preferredVersion } : {}),
|
||||
}).finally(() => {
|
||||
window.setLoadingMessage = undefined
|
||||
})
|
||||
}
|
||||
|
|
|
|||
21
src/mineflayer/plugins/index.ts
Normal file
21
src/mineflayer/plugins/index.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { lastConnectOptions } from '../../react/AppStatusProvider'
|
||||
import mouse from './mouse'
|
||||
import packetsPatcher from './packetsPatcher'
|
||||
import { localRelayServerPlugin } from './packetsRecording'
|
||||
import ping from './ping'
|
||||
import webFeatures from './webFeatures'
|
||||
|
||||
// register
|
||||
webFeatures()
|
||||
packetsPatcher()
|
||||
|
||||
|
||||
customEvents.on('mineflayerBotCreated', () => {
|
||||
if (lastConnectOptions.value!.server) {
|
||||
bot.loadPlugin(ping)
|
||||
}
|
||||
bot.loadPlugin(mouse)
|
||||
if (!lastConnectOptions.value!.worldStateFileContents) {
|
||||
bot.loadPlugin(localRelayServerPlugin)
|
||||
}
|
||||
})
|
||||
|
|
@ -72,6 +72,7 @@ export const localRelayServerPlugin = (bot: Bot) => {
|
|||
position: position++,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
packetsReplayState.progress.current++
|
||||
}
|
||||
})
|
||||
bot._client.on('packet', (data, { name }) => {
|
||||
|
|
@ -86,8 +87,22 @@ export const localRelayServerPlugin = (bot: Bot) => {
|
|||
position: position++,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
packetsReplayState.progress.total++
|
||||
}
|
||||
})
|
||||
const oldWriteChannel = bot._client.writeChannel.bind(bot._client)
|
||||
bot._client.writeChannel = (channel, params) => {
|
||||
packetsReplayState.packetsPlayback.push({
|
||||
name: channel,
|
||||
data: params,
|
||||
isFromClient: true,
|
||||
isUpcoming: false,
|
||||
position: position++,
|
||||
timestamp: Date.now(),
|
||||
isCustomChannel: true,
|
||||
})
|
||||
oldWriteChannel(channel, params)
|
||||
}
|
||||
|
||||
upPacketsReplayPanel()
|
||||
}
|
||||
|
|
@ -95,6 +110,8 @@ export const localRelayServerPlugin = (bot: Bot) => {
|
|||
const upPacketsReplayPanel = () => {
|
||||
if (packetsRecordingState.active && bot) {
|
||||
packetsReplayState.isOpen = true
|
||||
packetsReplayState.isMinimized = true
|
||||
packetsReplayState.isRecording = true
|
||||
packetsReplayState.replayName = 'Recording all packets for ' + bot.username
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
src/mineflayer/plugins/webFeatures.ts
Normal file
12
src/mineflayer/plugins/webFeatures.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Bot } from 'mineflayer'
|
||||
import { getAppLanguage } from '../../optionsStorage'
|
||||
|
||||
export default () => {
|
||||
customEvents.on('mineflayerBotCreated', () => {
|
||||
bot.loadPlugin(plugin)
|
||||
})
|
||||
}
|
||||
|
||||
const plugin = (bot: Bot) => {
|
||||
bot.settings['locale'] = getAppLanguage()
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import { openFilePicker, resetLocalStorage } from './browserfs'
|
|||
import { completeResourcepackPackInstall, getResourcePackNames, resourcepackReload, resourcePackState, uninstallResourcePack } from './resourcePack'
|
||||
import { downloadPacketsReplay, packetsRecordingState } from './packetsReplay/packetsReplayLegacy'
|
||||
import { showInputsModal, showOptionsModal } from './react/SelectOption'
|
||||
import { modsUpdateStatus } from './clientMods'
|
||||
import supportedVersions from './supportedVersions.mjs'
|
||||
import { getVersionAutoSelect } from './connect'
|
||||
import { createNotificationProgressReporter } from './core/progressReporter'
|
||||
|
|
@ -227,10 +228,31 @@ export const guiOptionsScheme: {
|
|||
return <Button label='Advanced...' onClick={() => openOptionsMenu('advanced')} inScreen />
|
||||
},
|
||||
},
|
||||
{
|
||||
custom () {
|
||||
const { appConfig } = useSnapshot(miscUiState)
|
||||
const modsUpdateSnapshot = useSnapshot(modsUpdateStatus)
|
||||
|
||||
if (appConfig?.showModsButton === false) return null
|
||||
return <Button label={`Client Mods: ${Object.keys(window.loadedMods ?? {}).length} (${Object.keys(modsUpdateSnapshot).length})`} onClick={() => showModal({ reactType: 'mods' })} inScreen />
|
||||
},
|
||||
},
|
||||
{
|
||||
custom () {
|
||||
return <Button label='VR...' onClick={() => openOptionsMenu('VR')} inScreen />
|
||||
},
|
||||
},
|
||||
{
|
||||
custom () {
|
||||
const { appConfig } = useSnapshot(miscUiState)
|
||||
if (!appConfig?.displayLanguageSelector) return null
|
||||
return <Button
|
||||
label='Language...' onClick={async () => {
|
||||
const newLang = await showOptionsModal('Set Language', (appConfig.supportedLanguages ?? []) as string[])
|
||||
if (!newLang) return
|
||||
options.language = newLang.split(' - ')[0]
|
||||
}} inScreen />
|
||||
},
|
||||
}
|
||||
],
|
||||
interface: [
|
||||
|
|
@ -569,6 +591,11 @@ export const guiOptionsScheme: {
|
|||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
debugContro: {
|
||||
text: 'Debug Controls',
|
||||
},
|
||||
}
|
||||
],
|
||||
'export-import': [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { proxy, subscribe } from 'valtio/vanilla'
|
||||
import { subscribeKey } from 'valtio/utils'
|
||||
import { omitObj } from '@zardoy/utils'
|
||||
import { appQueryParamsArray } from './appParams'
|
||||
import { appQueryParams, appQueryParamsArray } from './appParams'
|
||||
import type { AppConfig } from './appConfig'
|
||||
import { appStorage } from './react/appStorageProvider'
|
||||
import { miscUiState } from './globalState'
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
const initialAppConfig = process.env?.INLINED_APP_CONFIG as AppConfig ?? {}
|
||||
|
|
@ -59,13 +60,18 @@ const defaultOptions = {
|
|||
serversAutoVersionSelect: 'auto' as 'auto' | 'latest' | '1.20.4' | string,
|
||||
customChannels: false,
|
||||
remoteContentNotSameOrigin: false as boolean | string[],
|
||||
packetsReplayAutoStart: false,
|
||||
packetsRecordingAutoStart: false,
|
||||
language: 'auto',
|
||||
preciseMouseInput: false,
|
||||
// todo ui setting, maybe enable by default?
|
||||
waitForChunksRender: 'sp-only' as 'sp-only' | boolean,
|
||||
waitForChunksRender: false as 'sp-only' | boolean,
|
||||
jeiEnabled: true as boolean | Array<'creative' | 'survival' | 'adventure' | 'spectator'>,
|
||||
modsSupport: false,
|
||||
modsAutoUpdate: 'check' as 'check' | 'never' | 'always',
|
||||
modsUpdatePeriodCheck: 24, // hours
|
||||
preventBackgroundTimeoutKick: false,
|
||||
preventSleep: false,
|
||||
debugContro: false,
|
||||
|
||||
// antiAliasing: false,
|
||||
|
||||
|
|
@ -235,6 +241,7 @@ Object.defineProperty(window, 'debugChangedOptions', {
|
|||
})
|
||||
|
||||
subscribe(options, (ops) => {
|
||||
if (appQueryParams.freezeSettings === 'true') return
|
||||
for (const op of ops) {
|
||||
const [type, path, value] = op
|
||||
// let patch
|
||||
|
|
@ -291,3 +298,10 @@ export const useOptionValue = (setting, valueCallback) => {
|
|||
valueCallback(setting)
|
||||
subscribe(setting, valueCallback)
|
||||
}
|
||||
|
||||
export const getAppLanguage = () => {
|
||||
if (options.language === 'auto') {
|
||||
return miscUiState.appConfig?.defaultLanguage ?? navigator.language
|
||||
}
|
||||
return options.language
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { PacketsLogger } from 'mcraft-fun-mineflayer/build/packetsLogger'
|
|||
import { options } from '../optionsStorage'
|
||||
|
||||
export const packetsRecordingState = proxy({
|
||||
active: options.packetsReplayAutoStart,
|
||||
active: options.packetsRecordingAutoStart,
|
||||
hasRecordedPackets: false
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import styles from './appStatus.module.css'
|
|||
import Button from './Button'
|
||||
import Screen from './Screen'
|
||||
import LoadingChunks from './LoadingChunks'
|
||||
import LoadingTimer from './LoadingTimer'
|
||||
|
||||
export default ({
|
||||
status,
|
||||
|
|
@ -37,57 +38,61 @@ export default ({
|
|||
void statusRunner()
|
||||
}, [])
|
||||
|
||||
const lockConnect = appQueryParams.lockConnect === 'true'
|
||||
|
||||
return (
|
||||
<Screen
|
||||
className='small-content'
|
||||
titleSelectable={isError}
|
||||
title={
|
||||
<>
|
||||
<span style={{
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
<div style={{ display: 'inline-flex', gap: '1px', }} hidden={hideDots || isError}>
|
||||
{
|
||||
[...'...'].map((dot, i) => {
|
||||
return <span
|
||||
key={i} style={{
|
||||
visibility: loadingDotIndex <= i ? 'hidden' : 'visible',
|
||||
}}>{dot}</span>
|
||||
})
|
||||
}
|
||||
</div>
|
||||
<p className={styles.description}>{description}</p>
|
||||
<p className={styles['last-status']}>{lastStatus ? `Last status: ${lastStatus}` : lastStatus}</p>
|
||||
</>
|
||||
}
|
||||
backdrop='dirt'
|
||||
>
|
||||
{isError && (
|
||||
<>
|
||||
{showReconnect && onReconnect && <Button onClick={onReconnect}>
|
||||
<b>Reconnect</b>
|
||||
</Button>}
|
||||
{actionsSlot}
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (location.search) {
|
||||
location.search = ''
|
||||
} else {
|
||||
window.location.reload()
|
||||
}
|
||||
<div className=''>
|
||||
<Screen
|
||||
className='small-content'
|
||||
titleSelectable={isError}
|
||||
title={
|
||||
<>
|
||||
<span style={{
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
<b>Reset App (recommended)</b>
|
||||
</Button>
|
||||
{backAction && <Button label="Back" onClick={backAction} />}
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</Screen>
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
<div style={{ display: 'inline-flex', gap: '1px', }} hidden={hideDots || isError}>
|
||||
{
|
||||
[...'...'].map((dot, i) => {
|
||||
return <span
|
||||
key={i} style={{
|
||||
visibility: loadingDotIndex <= i ? 'hidden' : 'visible',
|
||||
}}>{dot}</span>
|
||||
})
|
||||
}
|
||||
</div>
|
||||
<p className={styles.description}>{description}</p>
|
||||
<p className={styles['last-status']}>{lastStatus ? `Last status: ${lastStatus}` : lastStatus}</p>
|
||||
</>
|
||||
}
|
||||
backdrop='dirt'
|
||||
>
|
||||
{isError && (
|
||||
<>
|
||||
{showReconnect && onReconnect && <Button onClick={onReconnect}>
|
||||
<b>Reconnect</b>
|
||||
</Button>}
|
||||
{actionsSlot}
|
||||
{!lockConnect && <Button
|
||||
onClick={() => {
|
||||
if (location.search) {
|
||||
location.search = ''
|
||||
} else {
|
||||
window.location.reload()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<b>Reset App (recommended)</b>
|
||||
</Button>}
|
||||
{backAction && <Button label="Back" onClick={backAction} />}
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
<LoadingTimer />
|
||||
</Screen>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,7 +127,8 @@ export default () => {
|
|||
}, [])
|
||||
|
||||
const displayAuthButton = status.includes('This server appears to be an online server and you are providing no authentication.')
|
||||
const displayVpnButton = status.includes('VPN') || status.includes('Proxy')
|
||||
const hasVpnText = (text: string) => text.includes('VPN') || text.includes('Proxy')
|
||||
const displayVpnButton = hasVpnText(status) || (minecraftJsonMessage && hasVpnText(JSON.stringify(minecraftJsonMessage)))
|
||||
const authReconnectAction = async () => {
|
||||
let accounts = [] as AuthenticatedAccount[]
|
||||
updateAuthenticatedAccountData(oldAccounts => {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ interface Props extends React.ComponentProps<'button'> {
|
|||
inScreen?: boolean
|
||||
rootRef?: Ref<HTMLButtonElement>
|
||||
overlayColor?: string
|
||||
noTranslate?: boolean
|
||||
}
|
||||
|
||||
const ButtonContext = createContext({
|
||||
|
|
@ -24,7 +25,7 @@ export const ButtonProvider: FC<{ children, onClick }> = ({ children, onClick })
|
|||
return <ButtonContext.Provider value={{ onClick }}>{children}</ButtonContext.Provider>
|
||||
}
|
||||
|
||||
export default (({ label, icon, children, inScreen, rootRef, type = 'button', postLabel, overlayColor, ...args }) => {
|
||||
export default (({ label, icon, children, inScreen, rootRef, type = 'button', postLabel, overlayColor, noTranslate, ...args }) => {
|
||||
const ctx = useContext(ButtonContext)
|
||||
|
||||
const onClick = (e) => {
|
||||
|
|
@ -40,12 +41,23 @@ export default (({ label, icon, children, inScreen, rootRef, type = 'button', po
|
|||
args.style.width = 20
|
||||
}
|
||||
|
||||
const tryToTranslate = (maybeText: any) => {
|
||||
if (noTranslate) return maybeText
|
||||
if (typeof maybeText === 'string') {
|
||||
return window.translateText?.(maybeText) ?? maybeText
|
||||
}
|
||||
if (Array.isArray(maybeText)) {
|
||||
return maybeText.map(tryToTranslate)
|
||||
}
|
||||
return maybeText
|
||||
}
|
||||
|
||||
return <SharedHudVars>
|
||||
<button ref={rootRef} {...args} className={classNames(buttonCss.button, args.className)} onClick={onClick} type={type}>
|
||||
{icon && <PixelartIcon className={buttonCss.icon} iconName={icon} />}
|
||||
{label}
|
||||
{tryToTranslate(label)}
|
||||
{postLabel}
|
||||
{children}
|
||||
{tryToTranslate(children)}
|
||||
{overlayColor && <div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
|
|
|
|||
|
|
@ -283,10 +283,8 @@ export default ({
|
|||
const message = chatInput.current.value
|
||||
if (message) {
|
||||
setSendHistory([...sendHistoryRef.current, message])
|
||||
const result = sendMessage?.(message)
|
||||
if (result !== false) {
|
||||
onClose?.()
|
||||
}
|
||||
onClose?.()
|
||||
sendMessage?.(message)
|
||||
// Always scroll to bottom after sending a message
|
||||
scrollToBottom()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { options } from '../optionsStorage'
|
|||
import { viewerVersionState } from '../viewerConnector'
|
||||
import Chat, { Message, fadeMessage } from './Chat'
|
||||
import { useIsModalActive } from './utilsApp'
|
||||
import { hideNotification, showNotification } from './NotificationProvider'
|
||||
import { hideNotification, notificationProxy, showNotification } from './NotificationProvider'
|
||||
import { updateLoadedServerData } from './serversStorage'
|
||||
import { lastConnectOptions } from './AppStatusProvider'
|
||||
|
||||
|
|
@ -65,6 +65,7 @@ export default () => {
|
|||
})
|
||||
hideNotification()
|
||||
})
|
||||
notificationProxy.id = 'auto-login'
|
||||
const listener = () => {
|
||||
hideNotification()
|
||||
}
|
||||
|
|
|
|||
130
src/react/ChunksDebug.tsx
Normal file
130
src/react/ChunksDebug.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import './LoadingChunks.css'
|
||||
|
||||
export interface ChunkDebug {
|
||||
x: number // like -32
|
||||
z: number // like -32
|
||||
lines: string[]
|
||||
sidebarLines: string[]
|
||||
state: 'server-waiting' | 'order-queued' | 'client-waiting' | 'client-processing' | 'done-empty' | 'done'
|
||||
}
|
||||
|
||||
interface ProcessedChunk extends ChunkDebug {
|
||||
relX: number
|
||||
relZ: number
|
||||
displayLines: string[]
|
||||
}
|
||||
|
||||
const stateColors: Record<ChunkDebug['state'], string> = {
|
||||
'server-waiting': 'gray',
|
||||
'order-queued': 'darkorange',
|
||||
'client-waiting': 'yellow',
|
||||
'client-processing': 'yellow',
|
||||
'done-empty': 'darkgreen',
|
||||
'done': 'limegreen',
|
||||
}
|
||||
|
||||
export default ({
|
||||
chunks,
|
||||
playerChunk,
|
||||
maxDistance,
|
||||
tileSize = 16,
|
||||
fontSize = 5,
|
||||
}: {
|
||||
chunks: ChunkDebug[]
|
||||
playerChunk: { x: number, z: number }
|
||||
maxDistance: number,
|
||||
tileSize?: number
|
||||
fontSize?: number
|
||||
}) => {
|
||||
const [selectedChunk, setSelectedChunk] = useState<ProcessedChunk | null>(null)
|
||||
const [showSidebar, setShowSidebar] = useState(false)
|
||||
|
||||
// Calculate grid dimensions based on maxDistance
|
||||
const gridSize = maxDistance * 2 + 1
|
||||
const centerIndex = maxDistance
|
||||
|
||||
// Process chunks to get only the last one for each position and within maxDistance
|
||||
const processedChunks = chunks.reduce<Record<string, ProcessedChunk>>((acc, chunk) => {
|
||||
const relX = Math.floor((chunk.x - playerChunk.x) / 16)
|
||||
const relZ = Math.floor((chunk.z - playerChunk.z) / 16)
|
||||
|
||||
// Skip chunks outside maxDistance
|
||||
if (Math.abs(relX) > maxDistance || Math.abs(relZ) > maxDistance) return acc
|
||||
|
||||
const key = `${chunk.x},${chunk.z}`
|
||||
acc[key] = {
|
||||
...chunk,
|
||||
relX,
|
||||
relZ,
|
||||
displayLines: [`${relX},${relZ} (${chunk.x},${chunk.z})`, ...chunk.lines]
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${gridSize}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${gridSize}, 1fr)`,
|
||||
gap: 1,
|
||||
// width: `${tileSize * gridSize}px`,
|
||||
// height: `${tileSize * gridSize}px`,
|
||||
}}>
|
||||
{Array.from({ length: gridSize * gridSize }).map((_, i) => {
|
||||
const relX = -maxDistance + (i % gridSize)
|
||||
const relZ = -maxDistance + Math.floor(i / gridSize)
|
||||
const x = playerChunk.x + relX * 16
|
||||
const z = playerChunk.z + relZ * 16
|
||||
const chunk = processedChunks[`${x},${z}`]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${x},${z}`}
|
||||
onClick={() => {
|
||||
if (chunk) {
|
||||
setSelectedChunk(chunk)
|
||||
setShowSidebar(true)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
background: chunk ? stateColors[chunk.state] : 'black',
|
||||
color: 'white',
|
||||
fontSize: `${fontSize}px`,
|
||||
cursor: chunk ? 'pointer' : 'default',
|
||||
position: 'relative',
|
||||
width: `${tileSize}px`,
|
||||
height: `${tileSize}px`,
|
||||
// pre-wrap
|
||||
whiteSpace: 'pre',
|
||||
}}
|
||||
>
|
||||
{relX}, {relZ}{'\n'}
|
||||
{chunk?.lines.join('\n')}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showSidebar && selectedChunk && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }} className='text-select'>
|
||||
{selectedChunk.displayLines.map((line, i) => (
|
||||
<div key={i} style={{ fontSize: '10px', wordBreak: 'break-word' }}>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ marginTop: '10px', fontSize: '10px', whiteSpace: 'pre', maxWidth: 100, }}>
|
||||
<div>Sidebar Info:</div>
|
||||
{selectedChunk.sidebarLines.map((line, i) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
84
src/react/ChunksDebugScreen.tsx
Normal file
84
src/react/ChunksDebugScreen.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useUtilsEffect } from '@zardoy/react-util'
|
||||
import { WorldRendererCommon } from 'renderer/viewer/lib/worldrendererCommon'
|
||||
import { WorldRendererThree } from 'renderer/viewer/three/worldrendererThree'
|
||||
import Screen from './Screen'
|
||||
import ChunksDebug, { ChunkDebug } from './ChunksDebug'
|
||||
import { useIsModalActive } from './utilsApp'
|
||||
|
||||
const Inner = () => {
|
||||
const [playerX, setPlayerX] = useState(Math.floor(worldView!.lastPos.x / 16) * 16)
|
||||
const [playerZ, setPlayerZ] = useState(Math.floor(worldView!.lastPos.z / 16) * 16)
|
||||
const [update, setUpdate] = useState(0)
|
||||
|
||||
useUtilsEffect(({ interval }) => {
|
||||
interval(
|
||||
500,
|
||||
() => {
|
||||
setPlayerX(Math.floor(worldView!.lastPos.x / 16) * 16)
|
||||
setPlayerZ(Math.floor(worldView!.lastPos.z / 16) * 16)
|
||||
setUpdate(u => u + 1)
|
||||
}
|
||||
)
|
||||
}, [])
|
||||
|
||||
const mapChunk = (key: string, state: ChunkDebug['state']): ChunkDebug => {
|
||||
const chunk = worldView!.debugChunksInfo[key]
|
||||
return {
|
||||
x: Number(key.split(',')[0]),
|
||||
z: Number(key.split(',')[1]),
|
||||
state,
|
||||
lines: [String(chunk?.loads.length ?? 0)],
|
||||
sidebarLines: [
|
||||
`loads: ${chunk.loads.map(l => `${l.reason} ${l.dataLength} ${l.time}`).join('\n')}`,
|
||||
// `blockUpdates: ${chunk.blockUpdates}`,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const chunksWaitingServer = Object.keys(worldView!.waitingSpiralChunksLoad).map(key => mapChunk(key, 'server-waiting'))
|
||||
|
||||
const world = globalThis.world as WorldRendererThree
|
||||
|
||||
const loadedSectionsChunks = Object.fromEntries(Object.keys(world.sectionObjects).map(sectionPos => {
|
||||
const [x, y, z] = sectionPos.split(',').map(Number)
|
||||
return [`${x},${z}`, true]
|
||||
}))
|
||||
|
||||
const chunksWaitingClient = Object.keys(worldView!.loadedChunks).map(key => mapChunk(key, 'client-waiting'))
|
||||
|
||||
const clientProcessingChunks = Object.keys(world.loadedChunks).map(key => mapChunk(key, 'client-processing'))
|
||||
|
||||
const chunksDoneEmpty = Object.keys(world.finishedChunks)
|
||||
.filter(chunkPos => !loadedSectionsChunks[chunkPos])
|
||||
.map(key => mapChunk(key, 'done-empty'))
|
||||
|
||||
const chunksDone = Object.keys(world.finishedChunks).map(key => mapChunk(key, 'done'))
|
||||
|
||||
const allChunks = [
|
||||
...chunksWaitingServer,
|
||||
...chunksWaitingClient,
|
||||
...clientProcessingChunks,
|
||||
...chunksDone,
|
||||
...chunksDoneEmpty,
|
||||
]
|
||||
return <Screen title="Chunks Debug">
|
||||
<ChunksDebug
|
||||
chunks={allChunks}
|
||||
playerChunk={{
|
||||
x: playerX,
|
||||
z: playerZ
|
||||
}}
|
||||
maxDistance={worldView!.viewDistance}
|
||||
tileSize={32}
|
||||
fontSize={8}
|
||||
/>
|
||||
</Screen>
|
||||
}
|
||||
|
||||
export default () => {
|
||||
const isActive = useIsModalActive('chunks-debug')
|
||||
if (!isActive) return null
|
||||
|
||||
return <Inner />
|
||||
}
|
||||
80
src/react/ConnectOnlyServerUi.tsx
Normal file
80
src/react/ConnectOnlyServerUi.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { appQueryParams } from '../appParams'
|
||||
import { ConnectOptions } from '../connect'
|
||||
import { lastConnectOptions } from './AppStatusProvider'
|
||||
import PixelartIcon, { pixelartIcons } from './PixelartIcon'
|
||||
import { useIsModalActive } from './utilsApp'
|
||||
import Button from './Button'
|
||||
|
||||
const VERTICAL_LAYOUT = false
|
||||
|
||||
export default () => {
|
||||
const { ip, version, proxy, username, connectText } = appQueryParams
|
||||
const isModalActive = useIsModalActive('only-connect-server')
|
||||
|
||||
if (!isModalActive) return null
|
||||
|
||||
const handleConnect = () => {
|
||||
const connectOptions: ConnectOptions = {
|
||||
username: username || '',
|
||||
server: ip,
|
||||
proxy,
|
||||
botVersion: version,
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('connect', { detail: connectOptions }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'rgba(0, 0, 0, 0.25)',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '40px',
|
||||
alignItems: 'center',
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
padding: VERTICAL_LAYOUT ? '15px 40px' : '25px',
|
||||
// paddingRight: VERTICAL_LAYOUT ? '0' : '40px',
|
||||
borderRadius: '5px',
|
||||
flexDirection: VERTICAL_LAYOUT ? 'column' : 'row',
|
||||
}}>
|
||||
<div style={{
|
||||
color: 'white',
|
||||
fontSize: '13px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '10px' }}>
|
||||
<PixelartIcon iconName={pixelartIcons.server} width={16} />
|
||||
<span style={{ marginLeft: '8px' }}>{ip}</span>
|
||||
{proxy && <span style={{ marginLeft: '8px', color: 'lightgray' }}>({proxy})</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<PixelartIcon iconName={pixelartIcons.user} width={16} />
|
||||
<span style={{ marginLeft: '8px' }}>{username}</span>
|
||||
{version && <span style={{ marginLeft: '8px', color: 'lightgray' }}>({version})</span>}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleConnect}
|
||||
style={{
|
||||
width: 'auto',
|
||||
padding: '0 12px',
|
||||
transform: 'scale(1.4)',
|
||||
transformOrigin: 'center'
|
||||
}}
|
||||
>
|
||||
{connectText || 'Connect'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
src/react/ControDebug.tsx
Normal file
71
src/react/ControDebug.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { options } from '../optionsStorage'
|
||||
import { contro } from '../controls'
|
||||
|
||||
export default () => {
|
||||
const [pressedKeys, setPressedKeys] = useState<Set<string>>(new Set())
|
||||
const [actions, setActions] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
setPressedKeys(prev => new Set([...prev, e.code]))
|
||||
}
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
setPressedKeys(prev => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(e.code)
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
setPressedKeys(new Set())
|
||||
}
|
||||
|
||||
const handleControTrigger = ({ command }) => {
|
||||
setActions(prev => [...prev, command])
|
||||
}
|
||||
|
||||
const handleControReleased = ({ command }) => {
|
||||
setActions(prev => prev.filter(action => action !== command))
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
window.addEventListener('blur', handleBlur)
|
||||
|
||||
contro.on('trigger', handleControTrigger)
|
||||
contro.on('release', handleControReleased)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
window.removeEventListener('blur', handleBlur)
|
||||
contro.off('trigger', handleControTrigger)
|
||||
contro.off('released', handleControReleased)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!options.debugContro) return null
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
right: 0,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
padding: '8px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '8px',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px'
|
||||
}}>
|
||||
<div>Keys: {[...pressedKeys].join(', ')}</div>
|
||||
<div style={{ color: 'limegreen' }}>Actions: {actions.join(', ')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { proxy, useSnapshot } from 'valtio'
|
||||
import { filesize } from 'filesize'
|
||||
import { getAvailableServerPlugins } from '../clientMods'
|
||||
import { showModal } from '../globalState'
|
||||
import Input from './Input'
|
||||
import Screen from './Screen'
|
||||
import Button from './Button'
|
||||
import SelectGameVersion from './SelectGameVersion'
|
||||
import styles from './createWorld.module.css'
|
||||
import { InputOption, showInputsModal, showOptionsModal } from './SelectOption'
|
||||
|
||||
// const worldTypes = ['default', 'flat', 'largeBiomes', 'amplified', 'customized', 'buffet', 'debug_all_block_states']
|
||||
const worldTypes = ['default', 'flat'/* , 'void' */]
|
||||
|
|
@ -15,13 +18,14 @@ export const creatingWorldState = proxy({
|
|||
title: '',
|
||||
type: worldTypes[0],
|
||||
gameMode: gameModes[0],
|
||||
version: ''
|
||||
version: '',
|
||||
plugins: [] as string[]
|
||||
})
|
||||
|
||||
export default ({ cancelClick, createClick, customizeClick, versions, defaultVersion }) => {
|
||||
const [quota, setQuota] = useState('')
|
||||
|
||||
const { title, type, version, gameMode } = useSnapshot(creatingWorldState)
|
||||
const { title, type, version, gameMode, plugins } = useSnapshot(creatingWorldState)
|
||||
useEffect(() => {
|
||||
creatingWorldState.version = defaultVersion
|
||||
void navigator.storage?.estimate?.().then(({ quota, usage }) => {
|
||||
|
|
@ -69,7 +73,38 @@ export default ({ cancelClick, createClick, customizeClick, versions, defaultVer
|
|||
creatingWorldState.gameMode = gameModes[index === gameModes.length - 1 ? 0 : index + 1]
|
||||
}}
|
||||
>
|
||||
Gamemode: {gameMode}
|
||||
Game Mode: {gameMode}
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<Button onClick={async () => {
|
||||
const availableServerPlugins = await getAvailableServerPlugins()
|
||||
const availableModNames = availableServerPlugins.map(mod => mod.name)
|
||||
const choices: Record<string, InputOption> = Object.fromEntries(availableServerPlugins.map(mod => [mod.name, {
|
||||
type: 'checkbox' as const,
|
||||
defaultValue: creatingWorldState.plugins.includes(mod.name),
|
||||
label: mod.name
|
||||
}]))
|
||||
choices.installMore = {
|
||||
type: 'button' as const,
|
||||
onButtonClick () {
|
||||
showModal({ reactType: 'mods' })
|
||||
}
|
||||
}
|
||||
const choice = await showInputsModal('Select server plugins from mods to install:', choices)
|
||||
if (!choice) return
|
||||
creatingWorldState.plugins = availableModNames.filter(modName => choice[modName])
|
||||
}}
|
||||
>Use Mods ({plugins.length})
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const index = gameModes.indexOf(gameMode)
|
||||
creatingWorldState.gameMode = gameModes[index === gameModes.length - 1 ? 0 : index + 1]
|
||||
}}
|
||||
disabled
|
||||
>
|
||||
Save Type: Java
|
||||
</Button>
|
||||
</div>
|
||||
<div className='muted' style={{ fontSize: 8 }}>Default and other world types are WIP</div>
|
||||
|
|
@ -80,7 +115,11 @@ export default ({ cancelClick, createClick, customizeClick, versions, defaultVer
|
|||
}}
|
||||
>Cancel
|
||||
</Button>
|
||||
<Button disabled={!title} onClick={createClick}>Create</Button>
|
||||
<Button disabled={!title} onClick={createClick}>
|
||||
<b>
|
||||
Create
|
||||
</b>
|
||||
</Button>
|
||||
</div>
|
||||
<div className='muted' style={{ fontSize: 9 }}>Note: save important worlds in folders on your hard drive!</div>
|
||||
<div className='muted' style={{ fontSize: 9 }}>{quota}</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { hideCurrentModal, showModal } from '../globalState'
|
||||
import defaultLocalServerOptions from '../defaultLocalServerOptions'
|
||||
import { mkdirRecursive, uniqueFileNameFromWorldName } from '../browserfs'
|
||||
import supportedVersions from '../supportedVersions.mjs'
|
||||
import { getServerPlugin } from '../clientMods'
|
||||
import CreateWorld, { WorldCustomize, creatingWorldState } from './CreateWorld'
|
||||
import { getWorldsPath } from './SingleplayerProvider'
|
||||
import { useIsModalActive } from './utilsApp'
|
||||
|
|
@ -14,7 +17,7 @@ export default () => {
|
|||
const versions = Object.values(versionsPerMinor).map(x => {
|
||||
return {
|
||||
version: x,
|
||||
label: x === defaultLocalServerOptions.version ? `${x} (available offline)` : x
|
||||
label: x === defaultLocalServerOptions.version ? `${x} (default)` : x
|
||||
}
|
||||
})
|
||||
return <CreateWorld
|
||||
|
|
@ -24,10 +27,11 @@ export default () => {
|
|||
}}
|
||||
createClick={async () => {
|
||||
// create new world
|
||||
const { title, type, version, gameMode } = creatingWorldState
|
||||
const { title, type, version, gameMode, plugins } = creatingWorldState
|
||||
// todo display path in ui + disable if exist
|
||||
const savePath = await uniqueFileNameFromWorldName(title, getWorldsPath())
|
||||
await mkdirRecursive(savePath)
|
||||
await loadPluginsIntoWorld(savePath, plugins)
|
||||
let generation
|
||||
if (type === 'flat') {
|
||||
generation = {
|
||||
|
|
@ -68,3 +72,16 @@ export default () => {
|
|||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const loadPluginsIntoWorld = async (worldPath: string, plugins: string[]) => {
|
||||
for (const plugin of plugins) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const { content, version } = await getServerPlugin(plugin) ?? {}
|
||||
if (content) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await mkdirRecursive(path.join(worldPath, 'plugins'))
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await fs.promises.writeFile(path.join(worldPath, 'plugins', `${plugin}-${version}.js`), content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export default () => {
|
|||
const [cursorBlock, setCursorBlock] = useState<Block | null>(null)
|
||||
const [blockInfo, setBlockInfo] = useState<{ customBlockName?: string, modelInfo?: BlockStateModelInfo } | null>(null)
|
||||
const [clientTps, setClientTps] = useState(0)
|
||||
const [serverTps, setServerTps] = useState(null as null | { value: number, frozen: boolean })
|
||||
const minecraftYaw = useRef(0)
|
||||
const minecraftQuad = useRef(0)
|
||||
const rendererDevice = appViewer.rendererState.renderer ?? 'No render backend'
|
||||
|
|
@ -155,6 +156,9 @@ export default () => {
|
|||
sent.current.count++
|
||||
managePackets('sent', name, data)
|
||||
})
|
||||
bot._client.on('set_ticking_state' as any, (data) => {
|
||||
setServerTps({ value: data.tick_rate, frozen: data.is_frozen })
|
||||
})
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleF3)
|
||||
|
|
@ -180,8 +184,9 @@ export default () => {
|
|||
<div className={styles.empty} />
|
||||
<p>XYZ: {pos.x.toFixed(3)} / {pos.y.toFixed(3)} / {pos.z.toFixed(3)}</p>
|
||||
<p>Chunk: {Math.floor(pos.x % 16)} ~ {Math.floor(pos.z % 16)} in {Math.floor(pos.x / 16)} ~ {Math.floor(pos.z / 16)}</p>
|
||||
<p>Section: {Math.floor(pos.x / 16) * 16}, {Math.floor(pos.y / 16) * 16}, {Math.floor(pos.z / 16) * 16}</p>
|
||||
<p>Packets: {packetsString}</p>
|
||||
<p>Client TPS: {clientTps}</p>
|
||||
<p>Client TPS: {clientTps} {serverTps ? `Server TPS: ${serverTps.value} ${serverTps.frozen ? '(frozen)' : ''}` : ''}</p>
|
||||
<p>Facing (viewer): {bot.entity.yaw.toFixed(3)} {bot.entity.pitch.toFixed(3)}</p>
|
||||
<p>Facing (minecraft): {quadsDescription[minecraftQuad.current]} ({minecraftYaw.current.toFixed(1)} {(bot.entity.pitch * -180 / Math.PI).toFixed(1)})</p>
|
||||
<p>Light: {blockL} ({skyL} sky)</p>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ const Input = ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue, w
|
|||
return <div id='input-container' className={styles.container} style={rootStyles}>
|
||||
<input
|
||||
ref={ref}
|
||||
className={styles.input}
|
||||
autoComplete='off'
|
||||
autoCapitalize='off'
|
||||
autoCorrect='off'
|
||||
|
|
@ -43,6 +42,7 @@ const Input = ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue, w
|
|||
spellCheck='false'
|
||||
style={{ ...validationStyle }}
|
||||
{...inputProps}
|
||||
className={styles.input + ' ' + (inputProps.className ?? '')}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value)
|
||||
|
|
|
|||
50
src/react/LoadingTimer.tsx
Normal file
50
src/react/LoadingTimer.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { proxy, useSnapshot } from 'valtio'
|
||||
|
||||
export const loadingTimerState = proxy({
|
||||
start: 0,
|
||||
loading: false,
|
||||
total: '0.00',
|
||||
|
||||
networkOnlyStart: 0,
|
||||
networkTimeTotal: '0.0'
|
||||
})
|
||||
|
||||
customEvents.on('gameLoaded', () => {
|
||||
loadingTimerState.loading = false
|
||||
})
|
||||
|
||||
export default () => {
|
||||
// const time = useSnapshot(timerState).start
|
||||
const { networkTimeTotal, total } = useSnapshot(loadingTimerState)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (!loadingTimerState.loading) return
|
||||
if (loadingTimerState.networkOnlyStart) {
|
||||
loadingTimerState.networkTimeTotal = ((Date.now() - loadingTimerState.networkOnlyStart) / 1000).toFixed(2)
|
||||
} else {
|
||||
loadingTimerState.total = ((Date.now() - loadingTimerState.start) / 1000).toFixed(2)
|
||||
}
|
||||
}, 100)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return <Portal to={document.getElementById('ui-root')!}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
color: 'gray',
|
||||
fontSize: '0.6em',
|
||||
paddingLeft: 'calc(env(safe-area-inset-left) / 2)'
|
||||
}}>
|
||||
{total}/{networkTimeTotal}
|
||||
</div>
|
||||
</Portal>
|
||||
}
|
||||
|
||||
const Portal = ({ children, to = document.body }) => {
|
||||
return createPortal(children, to)
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import React from 'react'
|
||||
import { openURL } from 'renderer/viewer/lib/simpleUtils'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { haveDirectoryPicker } from '../utils'
|
||||
import { ConnectOptions } from '../connect'
|
||||
import { miscUiState } from '../globalState'
|
||||
import styles from './mainMenu.module.css'
|
||||
import Button from './Button'
|
||||
import ButtonWithTooltip from './ButtonWithTooltip'
|
||||
|
|
@ -44,6 +46,8 @@ export default ({
|
|||
bottomRightLinks,
|
||||
singleplayerAvailable = true
|
||||
}: Props) => {
|
||||
const { appConfig } = useSnapshot(miscUiState)
|
||||
|
||||
if (!bottomRightLinks?.trim()) bottomRightLinks = undefined
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
const linksParsed = bottomRightLinks?.split(/;|\n/g).map(l => {
|
||||
|
|
@ -89,7 +93,7 @@ export default ({
|
|||
<div className={styles['game-title']}>
|
||||
<div className={styles.minecraft}>
|
||||
<div className={styles.edition} />
|
||||
<span className={styles.splash}>Prismarine is a beautiful block</span>
|
||||
<span className={styles.splash}>{appConfig?.splashText}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -177,7 +181,7 @@ export default ({
|
|||
</div>
|
||||
})}
|
||||
</div>
|
||||
<span>A Minecraft client clone in the browser!</span>
|
||||
<span>{appConfig?.rightSideText}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export const mineflayerConsoleState = proxy({
|
|||
messages: [] as ConsoleMessage[],
|
||||
replEnabled: false,
|
||||
consoleEnabled: false,
|
||||
takeoverMode: false
|
||||
})
|
||||
|
||||
const MessageLine = ({ message }: { message: ConsoleMessage }) => {
|
||||
|
|
|
|||
483
src/react/ModsPage.tsx
Normal file
483
src/react/ModsPage.tsx
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
import { useEffect, useState, useMemo, useRef } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { openURL } from 'renderer/viewer/lib/simpleUtils'
|
||||
import { addRepositoryAction, setEnabledModAction, getAllModsDisplayList, installModByName, selectAndRemoveRepository, uninstallModAction, fetchAllRepositories, modsReactiveUpdater, modsErrors, fetchRepository, getModModifiableFields, saveClientModData, getAllModsModifiableFields } from '../clientMods'
|
||||
import { createNotificationProgressReporter, ProgressReporter } from '../core/progressReporter'
|
||||
import { hideModal } from '../globalState'
|
||||
import { useIsModalActive } from './utilsApp'
|
||||
import Input from './Input'
|
||||
import Button from './Button'
|
||||
import styles from './mods.module.css'
|
||||
import { showOptionsModal, showInputsModal } from './SelectOption'
|
||||
import Screen from './Screen'
|
||||
import PixelartIcon, { pixelartIcons } from './PixelartIcon'
|
||||
import { showNotification } from './NotificationProvider'
|
||||
import { usePassesScaledDimensions } from './UIProvider'
|
||||
import { appStorage } from './appStorageProvider'
|
||||
|
||||
type ModsData = Awaited<ReturnType<typeof getAllModsDisplayList>>
|
||||
|
||||
const ModListItem = ({
|
||||
mod,
|
||||
onClick,
|
||||
hasError
|
||||
}: {
|
||||
mod: ModsData['repos'][0]['packages'][0],
|
||||
onClick: () => void,
|
||||
hasError: boolean
|
||||
}) => (
|
||||
<div
|
||||
className={styles.modRow}
|
||||
onClick={onClick}
|
||||
data-enabled={mod.installed ? '' : mod.activated}
|
||||
data-has-error={hasError}
|
||||
>
|
||||
<div className={styles.modRowTitle}>
|
||||
{mod.name}
|
||||
{mod.installedVersion && mod.installedVersion !== mod.version && (
|
||||
<PixelartIcon
|
||||
iconName={pixelartIcons['arrow-up-box']}
|
||||
styles={{ fontSize: 14, marginLeft: 3 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.modRowInfo}>
|
||||
{mod.description}
|
||||
{mod.author && ` • By ${mod.author}`}
|
||||
{mod.version && ` • v${mod.version}`}
|
||||
{mod.serverPlugin && ` • World plugin`}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ModSidebar = ({ mod }: { mod: (ModsData['repos'][0]['packages'][0] & { repo?: string }) | null }) => {
|
||||
const errors = useSnapshot(modsErrors)
|
||||
const [editingField, setEditingField] = useState<{ name: string, content: string, language: string } | null>(null)
|
||||
|
||||
const handleAction = async (action: () => Promise<void>, errorMessage: string, progress?: ProgressReporter) => {
|
||||
try {
|
||||
await action()
|
||||
progress?.end()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
progress?.end()
|
||||
showNotification(errorMessage, error.message, true)
|
||||
}
|
||||
}
|
||||
|
||||
if (!mod) {
|
||||
return <div className={styles.modInfoText}>Select a mod to view details</div>
|
||||
}
|
||||
|
||||
const modifiableFields = mod.installed ? getModModifiableFields(mod.installed) : []
|
||||
|
||||
const handleSaveField = async (newContents: string) => {
|
||||
if (!editingField) return
|
||||
try {
|
||||
mod[editingField.name] = newContents
|
||||
mod.wasModifiedLocally = true
|
||||
await saveClientModData(mod)
|
||||
setEditingField(null)
|
||||
showNotification('Success', 'Contents saved successfully')
|
||||
} catch (error) {
|
||||
showNotification('Error', 'Failed to save contents: ' + error.message, true)
|
||||
}
|
||||
}
|
||||
|
||||
if (editingField) {
|
||||
return (
|
||||
<EditingCodeWindow
|
||||
contents={editingField.content}
|
||||
language={editingField.language}
|
||||
onClose={newContents => {
|
||||
if (newContents === undefined) {
|
||||
setEditingField(null)
|
||||
return
|
||||
}
|
||||
void handleSaveField(newContents)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.modInfo}>
|
||||
<div className={styles.modInfoTitle}>
|
||||
{mod.name} {mod.installed?.wasModifiedLocally ? '(modified)' : ''}
|
||||
</div>
|
||||
<div className={styles.modInfoText}>
|
||||
{mod.description}
|
||||
</div>
|
||||
<div className={styles.modInfoText}>
|
||||
{mod.author && `Author: ${mod.author}\n`}
|
||||
{mod.version && `Version: ${mod.version}\n`}
|
||||
{mod.installedVersion && mod.installedVersion !== mod.version && `Installed version: ${mod.installedVersion}\n`}
|
||||
{mod.section && `Section: ${mod.section}\n`}
|
||||
</div>
|
||||
{errors[mod.name]?.length > 0 && (
|
||||
<div className={styles.modErrorList}>
|
||||
<ul>
|
||||
{errors[mod.name].map((error, i) => (
|
||||
<li key={i}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.modActions}>
|
||||
{mod.installed ? (
|
||||
<>
|
||||
{mod.activated ? (
|
||||
<Button
|
||||
onClick={async () => handleAction(
|
||||
async () => setEnabledModAction(mod.name, false),
|
||||
'Failed to disable mod:'
|
||||
)}
|
||||
icon={pixelartIcons['remove-box']}
|
||||
title="Disable"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
onClick={async () => handleAction(
|
||||
async () => setEnabledModAction(mod.name, true),
|
||||
'Failed to enable mod:'
|
||||
)}
|
||||
icon={pixelartIcons['add-box']}
|
||||
title="Enable"
|
||||
disabled={!mod.canBeActivated}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
onClick={async () => handleAction(
|
||||
async () => uninstallModAction(mod.name),
|
||||
'Failed to uninstall mod:'
|
||||
)}
|
||||
icon={pixelartIcons.trash}
|
||||
title="Delete"
|
||||
/>
|
||||
{mod.installedVersion && mod.installedVersion !== mod.version && (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!mod.repo) return
|
||||
const progress = createNotificationProgressReporter(`${mod.name} updated and activated`)
|
||||
await handleAction(
|
||||
async () => {
|
||||
await installModByName(mod.repo!, mod.name, progress)
|
||||
},
|
||||
'Failed to update mod:',
|
||||
progress
|
||||
)
|
||||
}}
|
||||
icon={pixelartIcons['arrow-up-box']}
|
||||
title="Update"
|
||||
/>
|
||||
)}
|
||||
{mod.serverPlugin && (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('sp', '1')
|
||||
url.searchParams.set('serverPlugin', mod.name)
|
||||
openURL(url.toString())
|
||||
}}
|
||||
icon={pixelartIcons.play}
|
||||
title="Try in blank world"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!mod.repo) return
|
||||
const progress = createNotificationProgressReporter(`${mod.name} installed and enabled`)
|
||||
await handleAction(
|
||||
async () => {
|
||||
await installModByName(mod.repo!, mod.name, progress)
|
||||
},
|
||||
'Failed to install & activate mod:',
|
||||
progress
|
||||
)
|
||||
}}
|
||||
icon={pixelartIcons.download}
|
||||
title="Install"
|
||||
/>
|
||||
)}
|
||||
{modifiableFields.length > 0 && (
|
||||
<Button
|
||||
onClick={async (e) => {
|
||||
const fields = e.shiftKey ? getAllModsModifiableFields() : modifiableFields
|
||||
const result = await showInputsModal('Edit Mod Field', Object.fromEntries(fields.map(field => {
|
||||
return [field.field, {
|
||||
type: 'button' as const,
|
||||
label: field.label,
|
||||
onButtonClick () {
|
||||
setEditingField({
|
||||
name: field.field,
|
||||
content: field.getContent?.() || mod.installed![field.field] || '',
|
||||
language: field.language
|
||||
})
|
||||
}
|
||||
}]
|
||||
})), {
|
||||
showConfirm: false
|
||||
})
|
||||
}}
|
||||
icon={pixelartIcons['edit']}
|
||||
title="Edit Mod"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const EditingCodeWindow = ({
|
||||
contents,
|
||||
language,
|
||||
onClose
|
||||
}: {
|
||||
contents: string,
|
||||
language: string,
|
||||
onClose: (newContents?: string) => void
|
||||
}) => {
|
||||
const ref = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true })
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true })
|
||||
}, [])
|
||||
|
||||
return <Screen title="Editing code">
|
||||
<div className="">
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={styles.fieldEditorTextarea}
|
||||
defaultValue={contents}
|
||||
/>
|
||||
<Button
|
||||
style={{ position: 'absolute', bottom: 10, left: 10, backgroundColor: 'red' }}
|
||||
onClick={() => onClose(undefined)}
|
||||
icon={pixelartIcons.close}
|
||||
title="Cancel"
|
||||
/>
|
||||
<Button
|
||||
style={{ position: 'absolute', bottom: 10, right: 10, backgroundColor: '#4CAF50' }}
|
||||
onClick={() => onClose(ref.current?.value)}
|
||||
icon={pixelartIcons.check}
|
||||
title="Save"
|
||||
/>
|
||||
</div>
|
||||
</Screen>
|
||||
}
|
||||
|
||||
export default () => {
|
||||
const isModalActive = useIsModalActive('mods', true)
|
||||
const [modsData, setModsData] = useState<ModsData | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [showOnlyInstalled, setShowOnlyInstalled] = useState(false)
|
||||
const [showOnlyEnabled, setShowOnlyEnabled] = useState(false)
|
||||
const [selectedModIndex, setSelectedModIndex] = useState<number | null>(null)
|
||||
const [expandedRepos, setExpandedRepos] = useState<Record<string, boolean>>({})
|
||||
const useHorizontalLayout = usePassesScaledDimensions(400)
|
||||
const { counter } = useSnapshot(modsReactiveUpdater)
|
||||
const errors = useSnapshot(modsErrors)
|
||||
|
||||
const allModsArray = useMemo(() => {
|
||||
if (!modsData) return []
|
||||
return [
|
||||
...modsData.repos.flatMap(repo => repo.packages.map(mod => ({ ...mod, repo: repo.url }))),
|
||||
...modsData.modsWithoutRepos
|
||||
]
|
||||
}, [modsData])
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalActive) {
|
||||
if (appStorage.firstModsPageVisit) {
|
||||
appStorage.firstModsPageVisit = false
|
||||
const defaultRepo = 'zardoy/mcraft-client-mods'
|
||||
void fetchRepository(defaultRepo, defaultRepo)
|
||||
}
|
||||
void getAllModsDisplayList().then(mods => {
|
||||
setModsData(mods)
|
||||
// Update selected mod index if needed
|
||||
if (selectedModIndex !== null && selectedModIndex < allModsArray.length) {
|
||||
setSelectedModIndex(selectedModIndex)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [isModalActive, counter])
|
||||
|
||||
if (!isModalActive) return null
|
||||
|
||||
const toggleRepo = (repoUrl: string) => {
|
||||
setExpandedRepos(prev => ({
|
||||
...prev,
|
||||
[repoUrl]: !prev[repoUrl]
|
||||
}))
|
||||
}
|
||||
|
||||
const modFilter = (mod: ModsData['repos'][0]['packages'][0]) => {
|
||||
const matchesSearch = mod.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
mod.description?.toLowerCase().includes(search.toLowerCase())
|
||||
const matchesInstalledFilter = !showOnlyInstalled || mod.installed
|
||||
const matchesEnabledFilter = !showOnlyEnabled || mod.activated
|
||||
return matchesSearch && matchesInstalledFilter && matchesEnabledFilter
|
||||
}
|
||||
|
||||
const filteredMods = modsData ? {
|
||||
repos: modsData.repos.map(repo => ({
|
||||
...repo,
|
||||
packages: repo.packages.filter(modFilter)
|
||||
})),
|
||||
modsWithoutRepos: modsData.modsWithoutRepos.filter(modFilter)
|
||||
} : null
|
||||
|
||||
const filteredModsCount = filteredMods ?
|
||||
filteredMods.repos.reduce((acc, repo) => acc + repo.packages.length, 0) + filteredMods.modsWithoutRepos.length : 0
|
||||
|
||||
const totalRepos = modsData?.repos.length ?? 0
|
||||
|
||||
const getStatsText = () => {
|
||||
if (!filteredMods) return 'Loading...'
|
||||
if (showOnlyEnabled) {
|
||||
return `Showing ${filteredMods.repos.reduce((acc, repo) => acc + repo.packages.filter(mod => mod.activated).length, 0)} enabled mods in ${totalRepos} repos`
|
||||
} else if (showOnlyInstalled) {
|
||||
return `Showing ${filteredMods.repos.reduce((acc, repo) => acc + repo.packages.filter(mod => mod.installed).length, 0)} installed mods in ${totalRepos} repos`
|
||||
}
|
||||
return `Showing all ${totalRepos} repos with ${filteredModsCount} mods`
|
||||
}
|
||||
|
||||
const selectedMod = selectedModIndex === null ? null : allModsArray[selectedModIndex]
|
||||
|
||||
return <Screen backdrop="dirt" title="Client Mods (Preview)" titleMarginTop={0} contentStyle={{ paddingTop: 15, height: '100%', width: '100%' }}>
|
||||
<Button
|
||||
icon={pixelartIcons['close']}
|
||||
onClick={() => {
|
||||
hideModal()
|
||||
}}
|
||||
style={{
|
||||
color: '#ff5d5d',
|
||||
position: 'fixed',
|
||||
top: 10,
|
||||
left: 20
|
||||
}}
|
||||
/>
|
||||
<div className={styles.root}>
|
||||
<div className={styles.header}>
|
||||
<Button
|
||||
style={{}}
|
||||
icon={pixelartIcons['sliders']}
|
||||
onClick={() => {
|
||||
if (showOnlyEnabled) {
|
||||
setShowOnlyEnabled(false)
|
||||
} else if (showOnlyInstalled) {
|
||||
setShowOnlyInstalled(false)
|
||||
setShowOnlyEnabled(true)
|
||||
} else {
|
||||
setShowOnlyInstalled(true)
|
||||
}
|
||||
}}
|
||||
title={showOnlyEnabled ? 'Show all mods' : showOnlyInstalled ? 'Show enabled mods' : 'Show installed mods'}
|
||||
/>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
// const refreshButton = `Refresh repositories (last update)`
|
||||
const refreshButton = `Refresh repositories`
|
||||
const choice = await showOptionsModal(`Manage repositories (${modsData?.repos.length ?? '-'} repos)`, ['Add repository', 'Remove repository', refreshButton])
|
||||
switch (choice) {
|
||||
case 'Add repository': {
|
||||
await addRepositoryAction()
|
||||
break
|
||||
}
|
||||
case 'Remove repository': {
|
||||
await selectAndRemoveRepository()
|
||||
break
|
||||
}
|
||||
case refreshButton: {
|
||||
await fetchAllRepositories()
|
||||
break
|
||||
}
|
||||
case undefined:
|
||||
break
|
||||
}
|
||||
}}
|
||||
icon={pixelartIcons['list-box']}
|
||||
title="Manage repositories"
|
||||
/>
|
||||
<Input
|
||||
className={styles.searchBar}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search mods in added repositories..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.statsRow}>
|
||||
{getStatsText()}
|
||||
</div>
|
||||
<div className={`${styles.content} ${useHorizontalLayout ? '' : styles.verticalContent}`}>
|
||||
<div className={styles.modList}>
|
||||
{filteredMods ? (
|
||||
<>
|
||||
{filteredMods.repos.map(repo => (
|
||||
<div key={repo.url}>
|
||||
<div
|
||||
className={styles.repoHeader}
|
||||
onClick={() => toggleRepo(repo.url)}
|
||||
>
|
||||
<span>{expandedRepos[repo.url] ? '▼' : '▶'}</span>
|
||||
<span>{repo.name || repo.url}</span>
|
||||
<span>({repo.packages.length})</span>
|
||||
</div>
|
||||
{expandedRepos[repo.url] && (
|
||||
<div className={styles.repoContent}>
|
||||
{repo.packages.map((mod) => (
|
||||
<ModListItem
|
||||
key={mod.name}
|
||||
mod={mod}
|
||||
onClick={() => setSelectedModIndex(allModsArray.findIndex(m => m.name === mod.name))}
|
||||
hasError={errors[mod.name]?.length > 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{filteredMods.modsWithoutRepos.length > 0 && (
|
||||
<div>
|
||||
<div className={styles.repoHeader}>
|
||||
<span>▼</span>
|
||||
<span>Other Mods</span>
|
||||
<span>({filteredMods.modsWithoutRepos.length})</span>
|
||||
</div>
|
||||
<div className={styles.repoContent}>
|
||||
{filteredMods.modsWithoutRepos.map(mod => (
|
||||
<ModListItem
|
||||
key={mod.name}
|
||||
mod={mod}
|
||||
onClick={() => setSelectedModIndex(allModsArray.findIndex(m => m.name === mod.name))}
|
||||
hasError={errors[mod.name]?.length > 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.modRowInfo}>Loading mods...</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.sidebar}>
|
||||
<ModSidebar mod={selectedMod} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Screen>
|
||||
}
|
||||
|
|
@ -25,7 +25,8 @@ export const showNotification = (
|
|||
isError = false,
|
||||
icon = '',
|
||||
action = undefined as (() => void) | undefined,
|
||||
autoHide = true
|
||||
autoHide = true,
|
||||
id = ''
|
||||
) => {
|
||||
notificationProxy.message = message
|
||||
notificationProxy.subMessage = subMessage
|
||||
|
|
@ -34,11 +35,14 @@ export const showNotification = (
|
|||
notificationProxy.open = true
|
||||
notificationProxy.autoHide = autoHide
|
||||
notificationProxy.action = action
|
||||
notificationProxy.id = id
|
||||
}
|
||||
globalThis.showNotification = showNotification
|
||||
export const hideNotification = () => {
|
||||
// openNotification('') // reset
|
||||
notificationProxy.open = false
|
||||
export const hideNotification = (id?: string) => {
|
||||
if (id === undefined || notificationProxy.id === id) {
|
||||
// openNotification('') // reset
|
||||
notificationProxy.open = false
|
||||
}
|
||||
}
|
||||
|
||||
export default () => {
|
||||
|
|
@ -47,7 +51,7 @@ export default () => {
|
|||
useEffect(() => {
|
||||
if (autoHide && open) {
|
||||
setTimeout(() => {
|
||||
hideNotification()
|
||||
hideNotification(notificationProxy.id)
|
||||
}, 7000)
|
||||
}
|
||||
}, [autoHide, open])
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { appStatusState, reconnectReload } from './AppStatusProvider'
|
|||
import NetworkStatus from './NetworkStatus'
|
||||
import PauseLinkButtons from './PauseLinkButtons'
|
||||
import { pixelartIcons } from './PixelartIcon'
|
||||
import LoadingTimer from './LoadingTimer'
|
||||
|
||||
const waitForPotentialRender = async () => {
|
||||
return new Promise<void>(resolve => {
|
||||
|
|
@ -298,5 +299,6 @@ export default () => {
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<LoadingTimer />
|
||||
</Screen>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export default function ReplayPanel ({
|
|||
style
|
||||
}: Props) {
|
||||
const [filter, setFilter] = useState(defaultFilter)
|
||||
const { isMinimized } = useSnapshot(packetsReplayState)
|
||||
const { isMinimized, isRecording } = useSnapshot(packetsReplayState)
|
||||
const { filtered: filteredPackets, hiddenCount } = filterPackets(packets.slice(-500), filter)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -70,7 +70,9 @@ export default function ReplayPanel ({
|
|||
}}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
{isPlaying ? (
|
||||
{isRecording ? (
|
||||
<circle cx="12" cy="12" r="8" fill="red" />
|
||||
) : isPlaying ? (
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||
) : (
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
|
|
@ -137,7 +139,9 @@ export default function ReplayPanel ({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '8px', color: '#888888', marginTop: '-8px' }}>Integrated server emulation. Testing client...</div>
|
||||
<div style={{ fontSize: '8px', color: '#888888', marginTop: '-8px' }}>
|
||||
{isRecording ? 'Recording packets...' : 'Integrated server emulation. Testing client...'}
|
||||
</div>
|
||||
|
||||
<FilterInput
|
||||
value={filter}
|
||||
|
|
@ -155,7 +159,14 @@ export default function ReplayPanel ({
|
|||
maxHeight={300}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
// grayscale if recording
|
||||
filter: isRecording ? 'grayscale(100%)' : 'none',
|
||||
cursor: isRecording ? 'not-allowed' : 'default'
|
||||
}}>
|
||||
{playPauseButton}
|
||||
<ProgressBar current={progress.current} total={progress.total} />
|
||||
</div>
|
||||
|
|
@ -228,4 +239,5 @@ export interface PacketData {
|
|||
actualVersion?: any
|
||||
position: number
|
||||
timestamp: number
|
||||
isCustomChannel?: boolean
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,14 +6,15 @@ interface Props {
|
|||
className?: string
|
||||
titleSelectable?: boolean
|
||||
titleMarginTop?: number
|
||||
contentStyle?: React.CSSProperties
|
||||
}
|
||||
|
||||
export default ({ title, children, backdrop = true, style, className = '', titleSelectable, titleMarginTop }: Props) => {
|
||||
export default ({ title, children, backdrop = true, style, className = '', titleSelectable, titleMarginTop, contentStyle }: 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" style={titleMarginTop === undefined ? {} : { marginTop: titleMarginTop }}>
|
||||
<div className="screen-content" style={{ ...contentStyle, ...(titleMarginTop === undefined ? {} : { marginTop: titleMarginTop }) }}>
|
||||
<div className={`screen-title ${titleSelectable ? 'text-select' : ''}`}>{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -48,21 +48,33 @@ export const showOptionsModal = async <T extends string> (
|
|||
})
|
||||
}
|
||||
|
||||
type InputOption = {
|
||||
type: 'text' | 'checkbox'
|
||||
export type InputOption = {
|
||||
type: 'text' | 'checkbox' | 'button'
|
||||
defaultValue?: string | boolean
|
||||
label?: string
|
||||
placeholder?: string
|
||||
onButtonClick?: () => void
|
||||
}
|
||||
export const showInputsModal = async <T extends Record<string, InputOption>>(
|
||||
title: string,
|
||||
inputs: T,
|
||||
{ cancel = true, minecraftJsonMessage }: { cancel?: boolean, minecraftJsonMessage? } = {}
|
||||
{
|
||||
cancel = true,
|
||||
minecraftJsonMessage,
|
||||
showConfirm = true
|
||||
}: {
|
||||
cancel?: boolean,
|
||||
minecraftJsonMessage?
|
||||
showConfirm?: boolean
|
||||
} = {}
|
||||
): Promise<{
|
||||
[K in keyof T]: T[K] extends { type: 'text' }
|
||||
? string
|
||||
: T[K] extends { type: 'checkbox' }
|
||||
? boolean
|
||||
: never
|
||||
: T[K] extends { type: 'button' }
|
||||
? string
|
||||
: never
|
||||
}> => {
|
||||
showModal({ reactType: 'general-select' })
|
||||
let minecraftJsonMessageParsed
|
||||
|
|
@ -81,7 +93,7 @@ export const showInputsModal = async <T extends Record<string, InputOption>>(
|
|||
showCancel: cancel,
|
||||
minecraftJsonMessage: minecraftJsonMessageParsed,
|
||||
options: [],
|
||||
inputsConfirmButton: 'Confirm'
|
||||
inputsConfirmButton: showConfirm ? 'Confirm' : ''
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
@ -130,6 +142,7 @@ export default () => {
|
|||
autoFocus
|
||||
type='text'
|
||||
defaultValue={input.defaultValue as string}
|
||||
placeholder={input.placeholder}
|
||||
onChange={(e) => {
|
||||
inputValues.current[key] = e.target.value
|
||||
}}
|
||||
|
|
@ -148,6 +161,15 @@ export default () => {
|
|||
{label}
|
||||
</label>
|
||||
)}
|
||||
{input.type === 'button' && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
resolveClose(inputValues.current)
|
||||
input.onButtonClick?.()
|
||||
}}
|
||||
>{label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ import { showOptionsModal } from './SelectOption'
|
|||
import { useCopyKeybinding } from './simpleHooks'
|
||||
import { AuthenticatedAccount, getInitialServersList, getServerConnectionHistory, setNewServersList } from './serversStorage'
|
||||
import { appStorage, StoreServerItem } from './appStorageProvider'
|
||||
import Button from './Button'
|
||||
import { pixelartIcons } from './PixelartIcon'
|
||||
import { showNotification } from './NotificationProvider'
|
||||
|
||||
const EXPLICIT_SHARE_SERVER_MODE = false
|
||||
|
||||
if (appQueryParams.lockConnect) {
|
||||
notHideableModalsWithoutForce.add('editServer')
|
||||
|
|
@ -350,6 +355,27 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
|
|||
}}
|
||||
worldData={serversListSorted.map(server => {
|
||||
const additional = additionalServerData[server.ip]
|
||||
const handleShare = async () => {
|
||||
try {
|
||||
const qs = new URLSearchParams()
|
||||
qs.set('ip', server.ip)
|
||||
if (server.proxyOverride) qs.set('proxy', server.proxyOverride)
|
||||
if (server.versionOverride) qs.set('version', server.versionOverride)
|
||||
qs.set('username', server.usernameOverride ?? '')
|
||||
const shareUrl = `${window.location.origin}${window.location.pathname}?${qs.toString()}`
|
||||
await navigator.clipboard.writeText(shareUrl)
|
||||
const MESSAGE = 'Server link copied to clipboard'
|
||||
if (EXPLICIT_SHARE_SERVER_MODE) {
|
||||
await showOptionsModal(MESSAGE, [])
|
||||
} else {
|
||||
showNotification(MESSAGE)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
showNotification('Failed to copy server link to clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: server.index.toString(),
|
||||
title: server.name || server.ip,
|
||||
|
|
@ -359,6 +385,16 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
|
|||
worldNameRightGrayed: additional?.textNameRightGrayed ?? '',
|
||||
iconSrc: additional?.icon,
|
||||
offline: additional?.offline,
|
||||
afterTitleUi: (
|
||||
<Button
|
||||
icon="external-link"
|
||||
style={{ marginRight: 8, width: 20, height: 20 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void handleShare()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
group: customServersList ? 'Provided Servers' : 'Saved Servers'
|
||||
}
|
||||
})}
|
||||
|
|
@ -397,6 +433,8 @@ export default () => {
|
|||
const modalStack = useSnapshot(activeModalStack)
|
||||
const hasServersListModal = modalStack.some(x => x.reactType === 'serversList')
|
||||
const editServerModalActive = useIsModalActive('editServer')
|
||||
const generalSelectActive = useIsModalActive('general-select')
|
||||
// const isServersListModalActive = useIsModalActive('serversList') || (modalStack.some(x => x.reactType === 'serversList') && generalSelectActive)
|
||||
const isServersListModalActive = useIsModalActive('serversList')
|
||||
|
||||
const eitherModal = isServersListModalActive || editServerModalActive
|
||||
|
|
|
|||
77
src/react/VoiceMicrophone.tsx
Normal file
77
src/react/VoiceMicrophone.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { proxy, useSnapshot } from 'valtio'
|
||||
import PixelartIcon, { pixelartIcons } from './PixelartIcon'
|
||||
|
||||
export const voiceChatStatus = proxy({
|
||||
active: false,
|
||||
muted: false,
|
||||
hasInputVoice: false,
|
||||
isErrored: false,
|
||||
isConnected: false,
|
||||
isAlone: false,
|
||||
|
||||
isSharingScreen: false,
|
||||
})
|
||||
|
||||
window.voiceChatStatus = voiceChatStatus
|
||||
|
||||
const Icon = () => {
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M9 14H8v-2h3v-1H8V9h3V8H8V6h3V5H8V3h1V2h1V1h4v1h1v1h1v2h-3v1h3v2h-3v1h3v2h-3v1h3v2h-1v1h-1v1h-4v-1H9z" />
|
||||
<path fill="currentColor" d="M19 12v3h-1v2h-1v1h-2v1h-2v2h3v2H8v-2h3v-2H9v-1H7v-1H6v-2H5v-3h1v2h1v2h1v1h2v1h4v-1h2v-1h1v-2h1v-2z" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
export default () => {
|
||||
const SIZE = 48
|
||||
const { active, muted, hasInputVoice, isSharingScreen, isConnected, isErrored, isAlone } = useSnapshot(voiceChatStatus)
|
||||
if (!active) return null
|
||||
|
||||
const getRingColor = () => {
|
||||
if (isErrored) return 'rgba(214, 4, 4, 0.5)' // red with opacity
|
||||
if (isConnected) {
|
||||
if (isAlone) return 'rgba(183, 255, 0, 0.5)' // lime yellow
|
||||
return 'rgba(50, 205, 50, 0.5)' // green with opacity
|
||||
}
|
||||
return 'rgba(128, 128, 128, 0.5)' // gray with opacity
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='voice-chat-microphone'
|
||||
onClick={() => {
|
||||
// toggleMicrophoneMuted()
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
zIndex: 10,
|
||||
left: '20px',
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: muted ? 'rgb(214 4 4)' : hasInputVoice ? '#32cd32' : '#ffffff',
|
||||
border: `2px solid ${getRingColor()}`,
|
||||
borderRadius: '50%',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.2)',
|
||||
transition: 'background-color 0.2s ease',
|
||||
}}>
|
||||
<Icon />
|
||||
</div>
|
||||
{/* stop sharing screen */}
|
||||
{isSharingScreen && <div style={{
|
||||
position: 'fixed',
|
||||
bottom: '20px',
|
||||
left: '20px',
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<PixelartIcon iconName={pixelartIcons.cast} /> Stop Sharing Screen
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import type { BaseServerInfo } from './AddServerOrConnect'
|
|||
|
||||
// when opening html file locally in browser, localStorage is shared between all ever opened html files, so we try to avoid conflicts
|
||||
const localStoragePrefix = process.env?.SINGLE_FILE_BUILD ? 'minecraft-web-client:' : ''
|
||||
const { localStorage } = window
|
||||
|
||||
export interface SavedProxiesData {
|
||||
proxies: string[]
|
||||
|
|
@ -39,6 +40,8 @@ type StorageData = {
|
|||
serversHistory: ServerHistoryEntry[]
|
||||
authenticatedAccounts: AuthenticatedAccount[]
|
||||
serversList: StoreServerItem[] | undefined
|
||||
modsAutoUpdateLastCheck: number | undefined
|
||||
firstModsPageVisit: boolean
|
||||
}
|
||||
|
||||
const oldKeysAliases: Partial<Record<keyof StorageData, string>> = {
|
||||
|
|
@ -79,6 +82,8 @@ const defaultStorageData: StorageData = {
|
|||
serversHistory: [],
|
||||
authenticatedAccounts: [],
|
||||
serversList: undefined,
|
||||
modsAutoUpdateLastCheck: undefined,
|
||||
firstModsPageVisit: true,
|
||||
}
|
||||
|
||||
export const setStorageDataOnAppConfigLoad = () => {
|
||||
|
|
@ -86,7 +91,6 @@ export const setStorageDataOnAppConfigLoad = () => {
|
|||
}
|
||||
|
||||
export const appStorage = proxy({ ...defaultStorageData })
|
||||
window.appStorage = appStorage
|
||||
|
||||
// Restore data from localStorage
|
||||
for (const key of Object.keys(defaultStorageData)) {
|
||||
|
|
|
|||
|
|
@ -16,11 +16,12 @@ const formatters: Record<string, (data: any) => string> = {
|
|||
default: (data) => processPacketDataForLogging(data)
|
||||
}
|
||||
|
||||
const getPacketIcon = (name: string): string => {
|
||||
if (name.includes('position')) return '📍'
|
||||
if (name.includes('chat')) return '💬'
|
||||
if (name.includes('block') || name.includes('chunk') || name.includes('light')) return '📦'
|
||||
if (name.includes('entity') || name.includes('player') || name.includes('passenger')) return '🎯'
|
||||
const getPacketIcon = (packet: PacketData): string => {
|
||||
if (packet.isCustomChannel) return '🔗'
|
||||
if (packet.name.includes('position')) return '📍'
|
||||
if (packet.name.includes('chat')) return '💬'
|
||||
if (packet.name.includes('block') || packet.name.includes('chunk') || packet.name.includes('light')) return '📦'
|
||||
if (packet.name.includes('entity') || packet.name.includes('player') || packet.name.includes('passenger')) return '🎯'
|
||||
return '📄'
|
||||
}
|
||||
|
||||
|
|
@ -102,7 +103,7 @@ export default function PacketList ({ packets, filter, maxHeight = 300 }: Props)
|
|||
opacity: packet.isUpcoming ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
<span>{getPacketIcon(packet.name)}</span>
|
||||
<span>{getPacketIcon(packet)}</span>
|
||||
<span style={{ color: DARK_COLORS.textDim }}>
|
||||
#{packet.position}
|
||||
{timeDiff && <span style={{ marginLeft: '4px' }}>{timeDiff}</span>}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,13 @@ export const useScrollBehavior = (
|
|||
const scrollToBottom = () => {
|
||||
if (elementRef.current) {
|
||||
elementRef.current.scrollTop = elementRef.current.scrollHeight
|
||||
setTimeout(() => {
|
||||
if (!elementRef.current) return
|
||||
elementRef.current.scrollTo({
|
||||
top: elementRef.current.scrollHeight,
|
||||
behavior: 'instant'
|
||||
})
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
193
src/react/mods.module.css
Normal file
193
src/react/mods.module.css
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
padding-top: 0;
|
||||
box-sizing: border-box;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.statsRow {
|
||||
color: #999;
|
||||
font-size: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.statsRow {
|
||||
color: #999;
|
||||
font-size: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.searchBar {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
min-height: 0; /* Important for Firefox */
|
||||
}
|
||||
|
||||
.verticalContent {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.verticalContent .modList {
|
||||
height: 50%;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.verticalContent .sidebar {
|
||||
height: 50%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
padding: 5px;
|
||||
min-height: 0; /* Important for Firefox */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.modInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.modInfoTitle {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modInfoText {
|
||||
font-size: 10px;
|
||||
white-space: pre-wrap;
|
||||
color: #bcbcbc;
|
||||
}
|
||||
|
||||
.modActions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.modRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modRow:hover {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modRowTitle {
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modRowInfo {
|
||||
font-size: 10px;
|
||||
color: #bcbcbc;
|
||||
}
|
||||
|
||||
.repoHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #bcbcbc;
|
||||
font-size: 8px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.repoHeader:hover {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.repoContent {
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* Mod state styles */
|
||||
.modRow[data-enabled="false"] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.modRow[data-enabled="true"] {
|
||||
color: lime;
|
||||
}
|
||||
|
||||
.modRow[data-enabled="true"] .modRowTitle {
|
||||
color: lime;
|
||||
}
|
||||
|
||||
/* Error state styles */
|
||||
.modRow[data-has-error="true"] {
|
||||
background: rgba(255, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.modRow[data-has-error="true"] .modRowTitle {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.modErrorList {
|
||||
font-size: 8px;
|
||||
color: #ff6b6b;
|
||||
margin-top: 5px;
|
||||
padding-left: 10px;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.fieldEditorTextarea {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 7px;
|
||||
}
|
||||
|
||||
.fieldEditorTextarea {
|
||||
position: absolute;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
25
src/react/mods.module.css.d.ts
vendored
Normal file
25
src/react/mods.module.css.d.ts
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
content: string;
|
||||
fieldEditorTextarea: string;
|
||||
header: string;
|
||||
modActions: string;
|
||||
modErrorList: string;
|
||||
modInfo: string;
|
||||
modInfoText: string;
|
||||
modInfoTitle: string;
|
||||
modList: string;
|
||||
modRow: string;
|
||||
modRowInfo: string;
|
||||
modRowTitle: string;
|
||||
repoContent: string;
|
||||
repoHeader: string;
|
||||
root: string;
|
||||
searchBar: string;
|
||||
sidebar: string;
|
||||
statsRow: string;
|
||||
verticalContent: string;
|
||||
}
|
||||
declare const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
|
@ -6,6 +6,7 @@ export const packetsReplayState = proxy({
|
|||
packetsPlayback: [] as PacketData[],
|
||||
isOpen: false,
|
||||
isMinimized: false,
|
||||
isRecording: false,
|
||||
replayName: '',
|
||||
isPlaying: false,
|
||||
progress: {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import SignInMessageProvider from './react/SignInMessageProvider'
|
|||
import BookProvider from './react/BookProvider'
|
||||
import { options } from './optionsStorage'
|
||||
import BossBarOverlayProvider from './react/BossBarOverlayProvider'
|
||||
import ModsPage from './react/ModsPage'
|
||||
import DebugEdges from './react/DebugEdges'
|
||||
import GameInteractionOverlay from './react/GameInteractionOverlay'
|
||||
import MineflayerPluginHud from './react/MineflayerPluginHud'
|
||||
|
|
@ -54,6 +55,11 @@ import { useAppScale } from './scaleInterface'
|
|||
import PacketsReplayProvider from './react/PacketsReplayProvider'
|
||||
import TouchInteractionHint from './react/TouchInteractionHint'
|
||||
import { ua } from './react/utils'
|
||||
import VoiceMicrophone from './react/VoiceMicrophone'
|
||||
import ConnectOnlyServerUi from './react/ConnectOnlyServerUi'
|
||||
import ControDebug from './react/ControDebug'
|
||||
import ChunksDebug from './react/ChunksDebug'
|
||||
import ChunksDebugScreen from './react/ChunksDebugScreen'
|
||||
|
||||
const isFirefox = ua.getBrowser().name === 'Firefox'
|
||||
if (isFirefox) {
|
||||
|
|
@ -156,6 +162,8 @@ const InGameUi = () => {
|
|||
{!disabledUiParts.includes('crosshair') && <Crosshair />}
|
||||
{!disabledUiParts.includes('books') && <BookProvider />}
|
||||
{!disabledUiParts.includes('bossbars') && displayBossBars && <BossBarOverlayProvider />}
|
||||
<VoiceMicrophone />
|
||||
<ChunksDebugScreen />
|
||||
</PerComponentErrorBoundary>
|
||||
</div>
|
||||
|
||||
|
|
@ -208,6 +216,7 @@ const App = () => {
|
|||
<HeldMapUi />
|
||||
</InGameComponent>
|
||||
</div>
|
||||
<ControDebug />
|
||||
<div />
|
||||
</RobustPortal>
|
||||
<EnterFullscreenButton />
|
||||
|
|
@ -218,14 +227,17 @@ const App = () => {
|
|||
<CreateWorldProvider />
|
||||
<AppStatusProvider />
|
||||
<KeybindingsScreenProvider />
|
||||
<SelectOption />
|
||||
<ServersListProvider />
|
||||
<OptionsRenderApp />
|
||||
<MainMenuRenderApp />
|
||||
<ConnectOnlyServerUi />
|
||||
<TouchAreasControlsProvider />
|
||||
<SignInMessageProvider />
|
||||
<PacketsReplayProvider />
|
||||
<NotificationProvider />
|
||||
<ModsPage />
|
||||
|
||||
<SelectOption />
|
||||
|
||||
<NoModalFoundProvider />
|
||||
</RobustPortal>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ setScale()
|
|||
subscribeKey(options, 'guiScale', setScale)
|
||||
watchValue(currentScaling, (c) => {
|
||||
document.documentElement.style.setProperty('--guiScale', String(c.scale))
|
||||
document.documentElement.style.setProperty('--scale', String(c.scale))
|
||||
})
|
||||
window.addEventListener('resize', setScale)
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
inset: 0;
|
||||
height: 100dvh;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
.fullscreen {
|
||||
|
|
@ -43,9 +44,25 @@
|
|||
gap: 10px;
|
||||
}
|
||||
|
||||
.scaled-content-container {
|
||||
font-size: calc(16px * var(--scale));
|
||||
}
|
||||
|
||||
.scaled-content-container .screen-title {
|
||||
font-size: calc(10px * var(--scale));
|
||||
margin-top: calc(35px * var(--scale));
|
||||
}
|
||||
|
||||
.scaled-content-container .dirt-bg {
|
||||
image-rendering: pixelated;
|
||||
background-size: calc(32px * var(--scale));
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media screen and (max-height: 426px) {
|
||||
.fullscreen:not(.small-content) {
|
||||
.screen-content {
|
||||
/* margin-top: calc(14px * var(--scale)); */
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,14 @@
|
|||
|
||||
// Custom DNS resolver made by SiebeDW. Powered by google dns.
|
||||
// Supported: SRV (not all errors support)
|
||||
module.exports.resolveSrv = function (hostname, callback) {
|
||||
module.exports.resolveSrv = function (hostname, _callback) {
|
||||
globalThis.setLoadingMessage?.(`Getting SRV using Google DNS`)
|
||||
|
||||
const callback = (err, result) => {
|
||||
globalThis.setLoadingMessage?.(undefined)
|
||||
_callback(err, result)
|
||||
}
|
||||
|
||||
const Http = new XMLHttpRequest()
|
||||
const url = `https://dns.google.com/resolve?name=${hostname}&type=SRV`
|
||||
Http.open('GET', url)
|
||||
|
|
@ -14,6 +21,7 @@ module.exports.resolveSrv = function (hostname, callback) {
|
|||
Http.onerror = async function () {
|
||||
try {
|
||||
if (!globalThis.resolveDnsFallback) return
|
||||
globalThis.setLoadingMessage?.('Resolving SRV using fallback')
|
||||
const result = await globalThis.resolveDnsFallback(minecraftServerHostname)
|
||||
callback(null, result ? [{
|
||||
priority: 0,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { EventEmitter } from 'events'
|
||||
|
||||
EventEmitter.defaultMaxListeners = 200
|
||||
|
||||
const oldEmit = EventEmitter.prototype.emit
|
||||
EventEmitter.prototype.emit = function (...args) {
|
||||
if (args[0] === 'error' && !this._events.error) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { options } from '../optionsStorage'
|
|||
import { loadOrPlaySound } from '../basicSounds'
|
||||
import { getActiveResourcepackBasePath, resourcePackState } from '../resourcePack'
|
||||
import { showNotification } from '../react/NotificationProvider'
|
||||
import { pixelartIcons } from '../react/PixelartIcon'
|
||||
import { createSoundMap, SoundMap } from './soundsMap'
|
||||
import { musicSystem } from './musicSystem'
|
||||
|
||||
|
|
@ -33,7 +34,7 @@ subscribeKey(miscUiState, 'gameLoaded', async () => {
|
|||
globalThis.soundMap = soundMap
|
||||
if (!soundMap) return
|
||||
if (soundMap.noVersionIdMapping) {
|
||||
showNotification('No sound ID mapping for this version', undefined, true)
|
||||
showNotification('No exact sound ID mappings for this version', undefined, false, pixelartIcons['warning-box'])
|
||||
}
|
||||
void updateResourcePack()
|
||||
startMusicSystem()
|
||||
|
|
@ -115,7 +116,7 @@ subscribeKey(miscUiState, 'gameLoaded', async () => {
|
|||
const soundResource = packet['soundEvent']?.resource as string | undefined
|
||||
const pos = new Vec3(packet.x / 8, packet.y / 8, packet.z / 8)
|
||||
if (packet.soundId !== 0 || !soundResource) {
|
||||
const soundKey = soundMap!.soundsIdToName[packet.soundId]
|
||||
const soundKey = soundMap!.soundsIdToName[packet.soundId - 1]
|
||||
if (soundKey === undefined) return
|
||||
await playGeneralSound(soundKey, pos, packet.volume, packet.pitch)
|
||||
return
|
||||
|
|
@ -134,10 +135,12 @@ subscribeKey(miscUiState, 'gameLoaded', async () => {
|
|||
const movementHappening = async () => {
|
||||
if (!bot.entity || !soundMap) return // no info yet
|
||||
const VELOCITY_THRESHOLD = 0.1
|
||||
const RUN_THRESHOLD = 0.15
|
||||
const { x, z, y } = bot.entity.velocity
|
||||
if (bot.entity.onGround && Math.abs(x) < VELOCITY_THRESHOLD && (Math.abs(z) > VELOCITY_THRESHOLD || Math.abs(y) > VELOCITY_THRESHOLD)) {
|
||||
if (bot.entity.onGround && (Math.abs(x) > VELOCITY_THRESHOLD || Math.abs(z) > VELOCITY_THRESHOLD)) {
|
||||
const isRunning = (Math.abs(x) > RUN_THRESHOLD || Math.abs(z) > RUN_THRESHOLD)
|
||||
// movement happening
|
||||
if (Date.now() - lastStepSound > 300) {
|
||||
if (Date.now() - lastStepSound > (isRunning ? 100 : 300)) {
|
||||
const blockUnder = bot.world.getBlock(bot.entity.position.offset(0, -1, 0))
|
||||
if (blockUnder) {
|
||||
const stepSound = soundMap.getStepSound(blockUnder.name)
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export const pointerLock = {
|
|||
void goFullscreen()
|
||||
}
|
||||
const displayBrowserProblem = () => {
|
||||
if (notificationProxy.id === 'auto-login') return // prevent notification hide
|
||||
showNotification('Browser Delay Limitation', navigator['keyboard'] ? 'Click on screen, enable Auto Fullscreen or F11' : 'Click on screen or use fullscreen in Chrome')
|
||||
notificationProxy.id = 'pointerlockchange'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { CustomChannelPacketFromClient, CustomChannelPacketFromServer, UIDefinit
|
|||
import { activeModalStack } from './globalState'
|
||||
import { mineflayerPluginHudState } from './react/MineflayerPluginHud'
|
||||
import { mineflayerConsoleState } from './react/MineflayerPluginConsole'
|
||||
import { showNotification } from './react/NotificationProvider'
|
||||
|
||||
export const viewerVersionState = proxy({
|
||||
forwardChat: true,
|
||||
|
|
@ -43,6 +44,7 @@ export const getViewerVersionData = async (url: string) => {
|
|||
requiresPass: boolean,
|
||||
forwardChat: boolean,
|
||||
clientIgnoredPackets?: string[]
|
||||
takeoverMode?: boolean
|
||||
}>((resolve, reject) => {
|
||||
ws.addEventListener('message', async (message) => {
|
||||
const { data } = message
|
||||
|
|
@ -67,6 +69,7 @@ export const getViewerVersionData = async (url: string) => {
|
|||
})
|
||||
mineflayerConsoleState.consoleEnabled = result.consoleEnabled
|
||||
mineflayerConsoleState.replEnabled = result.replEnabled
|
||||
mineflayerConsoleState.takeoverMode = result.takeoverMode ?? false
|
||||
return result
|
||||
}
|
||||
|
||||
|
|
@ -125,7 +128,7 @@ export const getWsProtocolStream = async (url: string) => {
|
|||
|
||||
const CHANNEL_NAME = 'minecraft-web-client:data'
|
||||
|
||||
export const handleCustomChannel = async () => {
|
||||
const handleCustomChannel = () => {
|
||||
bot._client.registerChannel(CHANNEL_NAME, ['string', []])
|
||||
const toCleanup = [] as Array<() => void>
|
||||
subscribe(activeModalStack, () => {
|
||||
|
|
@ -288,4 +291,35 @@ export const handleCustomChannel = async () => {
|
|||
// No default
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
send
|
||||
}
|
||||
}
|
||||
|
||||
export const onBotCreatedViewerHandler = async () => {
|
||||
const { send } = handleCustomChannel()
|
||||
bot.physicsEnabled = false
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
bot.once('inject_allowed', resolve)
|
||||
})
|
||||
|
||||
const originalSetControlState = bot.setControlState.bind(bot)
|
||||
bot.setControlState = (control, state) => {
|
||||
if (bot.controlState[control] === state) {
|
||||
return
|
||||
}
|
||||
if (!mineflayerConsoleState.takeoverMode) {
|
||||
showNotification('Remote control is not enabled', 'Enable takeoverMode in bot plugin settings first')
|
||||
return
|
||||
}
|
||||
// send command to viewer
|
||||
send({
|
||||
type: 'setControlState',
|
||||
control,
|
||||
value: state
|
||||
})
|
||||
originalSetControlState(control, state)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue