Release (#332)
This commit is contained in:
commit
25bd72cd31
68 changed files with 2519 additions and 227 deletions
4
.github/workflows/next-deploy.yml
vendored
4
.github/workflows/next-deploy.yml
vendored
|
|
@ -30,6 +30,8 @@ jobs:
|
|||
- name: Write Release Info
|
||||
run: |
|
||||
echo "{\"latestTag\": \"$(git rev-parse --short $GITHUB_SHA)\", \"isCommit\": true}" > assets/release.json
|
||||
- name: Download Generated Sounds map
|
||||
run: node scripts/downloadSoundsMap.mjs
|
||||
- name: Build Project Artifacts
|
||||
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
|
||||
env:
|
||||
|
|
@ -40,8 +42,6 @@ jobs:
|
|||
mkdir -p .vercel/output/static/playground
|
||||
pnpm build-playground
|
||||
cp -r renderer/dist/* .vercel/output/static/playground/
|
||||
- name: Download Generated Sounds map
|
||||
run: node scripts/downloadSoundsMap.mjs
|
||||
- name: Deploy Project Artifacts to Vercel
|
||||
uses: mathiasvr/command-output@v2.0.0
|
||||
with:
|
||||
|
|
|
|||
4
.github/workflows/preview.yml
vendored
4
.github/workflows/preview.yml
vendored
|
|
@ -72,6 +72,8 @@ jobs:
|
|||
- name: Write Release Info
|
||||
run: |
|
||||
echo "{\"latestTag\": \"$(git rev-parse --short ${{ github.event.pull_request.head.sha }})\", \"isCommit\": true}" > assets/release.json
|
||||
- name: Download Generated Sounds map
|
||||
run: node scripts/downloadSoundsMap.mjs
|
||||
- name: Build Project Artifacts
|
||||
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
|
||||
env:
|
||||
|
|
@ -90,8 +92,6 @@ jobs:
|
|||
run: |
|
||||
mkdir -p .vercel/output/static/commit
|
||||
echo "<meta http-equiv='refresh' content='0;url=https://github.com/${{ github.repository }}/pull/${{ github.event.issue.number || github.event.pull_request.number }}/commits/${{ github.event.pull_request.head.sha }}'>" > .vercel/output/static/commit/index.html
|
||||
- name: Download Generated Sounds map
|
||||
run: node scripts/downloadSoundsMap.mjs
|
||||
- name: Deploy Project Artifacts to Vercel
|
||||
uses: mathiasvr/command-output@v2.0.0
|
||||
with:
|
||||
|
|
|
|||
4
.github/workflows/publish.yml
vendored
4
.github/workflows/publish.yml
vendored
|
|
@ -29,6 +29,8 @@ jobs:
|
|||
run: pnpx zardoy-release empty --skip-github --output-file assets/release.json
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Download Generated Sounds map
|
||||
run: node scripts/downloadSoundsMap.mjs
|
||||
- run: vercel build --token=${{ secrets.VERCEL_TOKEN }} --prod
|
||||
env:
|
||||
CONFIG_JSON_SOURCE: BUNDLED
|
||||
|
|
@ -38,8 +40,6 @@ jobs:
|
|||
mkdir -p .vercel/output/static/playground
|
||||
pnpm build-playground
|
||||
cp -r renderer/dist/* .vercel/output/static/playground/
|
||||
- name: Download Generated Sounds map
|
||||
run: node scripts/downloadSoundsMap.mjs
|
||||
- name: Deploy Project to Vercel
|
||||
uses: mathiasvr/command-output@v2.0.0
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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",
|
||||
|
|
@ -195,7 +195,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",
|
||||
|
|
|
|||
24
pnpm-lock.yaml
generated
24
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
|
||||
|
|
@ -2025,8 +2025,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==}
|
||||
|
|
@ -6459,9 +6459,9 @@ packages:
|
|||
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': '*'
|
||||
|
|
@ -11353,10 +11353,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
|
||||
|
||||
|
|
@ -16993,7 +16993,7 @@ snapshots:
|
|||
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
|
||||
|
|
@ -17437,7 +17437,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) {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export const clearTextureCache = () => {
|
|||
imagesPromises = {}
|
||||
}
|
||||
|
||||
export const loadScript = async function (scriptSrc: string): Promise<HTMLScriptElement> {
|
||||
export const loadScript = async function (scriptSrc: string, highPriority = true): Promise<HTMLScriptElement> {
|
||||
const existingScript = document.querySelector<HTMLScriptElement>(`script[src="${scriptSrc}"]`)
|
||||
if (existingScript) {
|
||||
return existingScript
|
||||
|
|
@ -31,6 +31,10 @@ export const loadScript = async function (scriptSrc: string): Promise<HTMLScript
|
|||
return new Promise((resolve, reject) => {
|
||||
const scriptElement = document.createElement('script')
|
||||
scriptElement.src = scriptSrc
|
||||
|
||||
if (highPriority) {
|
||||
scriptElement.fetchPriority = 'high'
|
||||
}
|
||||
scriptElement.async = true
|
||||
|
||||
scriptElement.addEventListener('load', () => {
|
||||
|
|
|
|||
|
|
@ -252,7 +252,6 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
|
|||
|
||||
async loadChunk (pos: ChunkPos, isLightUpdate = false, reason = 'spiral') {
|
||||
const [botX, botZ] = chunkPos(this.lastPos)
|
||||
console.log('loadChunk', botX - pos.x / 16, botZ - pos.z / 16)
|
||||
|
||||
const dx = Math.abs(botX - Math.floor(pos.x / 16))
|
||||
const dz = Math.abs(botZ - Math.floor(pos.z / 16))
|
||||
|
|
@ -280,7 +279,7 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter<Wo
|
|||
this.debugChunksInfo[`${pos.x},${pos.z}`].loads.push({
|
||||
dataLength: chunk.length,
|
||||
reason,
|
||||
time: Math.floor(performance.now()),
|
||||
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 })
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { chunkPos } from './simpleUtils'
|
|||
import { addNewStat, removeAllStats, removeStat, updatePanesVisibility, updateStatText } from './ui/newStats'
|
||||
import { WorldDataEmitter } from './worldDataEmitter'
|
||||
import { IPlayerState } from './basePlayerState'
|
||||
import { MesherLogReader } from './mesherlogReader'
|
||||
|
||||
function mod (x, n) {
|
||||
return ((x % n) + n) % n
|
||||
|
|
@ -53,6 +54,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 }
|
||||
|
||||
|
|
@ -136,12 +139,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'
|
||||
}
|
||||
|
|
@ -152,6 +158,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
mainThreadRendering = true
|
||||
backendInfoReport = '-'
|
||||
chunksFullInfo = '-'
|
||||
workerCustomHandleTime = 0
|
||||
|
||||
get version () {
|
||||
return this.displayOptions.version
|
||||
|
|
@ -167,7 +174,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})`)
|
||||
|
|
@ -200,22 +207,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 () { }
|
||||
|
|
@ -295,7 +313,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)
|
||||
|
|
@ -308,12 +326,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 () => {
|
||||
|
|
@ -331,8 +352,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 })}`)
|
||||
|
|
@ -344,7 +368,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) {
|
||||
|
|
@ -407,7 +431,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()
|
||||
}
|
||||
|
|
@ -474,8 +498,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
|
||||
|
|
@ -488,11 +511,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 {
|
||||
|
|
@ -594,7 +613,8 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
|
|||
customBlockModels: customBlockModels || undefined
|
||||
})
|
||||
}
|
||||
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)
|
||||
|
|
@ -636,8 +656,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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -848,6 +869,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)
|
||||
|
|
@ -859,19 +882,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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ const appConfig = defineConfig({
|
|||
tag: 'link',
|
||||
attrs: {
|
||||
rel: 'manifest',
|
||||
crossorigin: 'use-credentials',
|
||||
crossorigin: 'anonymous',
|
||||
href: 'manifest.json'
|
||||
},
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ exports.getSwAdditionalEntries = () => {
|
|||
'manifest.json',
|
||||
'worldSaveWorker.js',
|
||||
`textures/entity/squid/squid.png`,
|
||||
'sounds.js',
|
||||
// everything but not .map
|
||||
'static/**/!(*.map)',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -23,6 +25,11 @@ export type AppConfig = {
|
|||
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) => {
|
||||
|
|
@ -46,6 +53,11 @@ export const loadAppConfig = (appConfig: AppConfig) => {
|
|||
}
|
||||
}
|
||||
|
||||
if (appConfig.keybindings) {
|
||||
Object.assign(customKeymaps, defaultsDeep(appConfig.keybindings, customKeymaps))
|
||||
updateBinds(customKeymaps)
|
||||
}
|
||||
|
||||
setStorageDataOnAppConfigLoad()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
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])
|
||||
}
|
||||
|
|
@ -70,8 +70,12 @@ export const loadMinecraftData = async (version: string) => {
|
|||
miscUiState.loadedDataVersion = version
|
||||
}
|
||||
|
||||
export const downloadAllMinecraftData = async () => {
|
||||
export type AssetDownloadReporter = (asset: string, isDone: boolean) => void
|
||||
|
||||
export const downloadAllMinecraftData = async (reporter?: AssetDownloadReporter) => {
|
||||
reporter?.('mc-data', false)
|
||||
await window._LOAD_MC_DATA()
|
||||
reporter?.('mc-data', true)
|
||||
}
|
||||
|
||||
const loadFonts = async () => {
|
||||
|
|
@ -84,6 +88,12 @@ const loadFonts = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
export const downloadOtherGameData = async () => {
|
||||
await Promise.all([loadFonts(), downloadSoundsIfNeeded()])
|
||||
export const downloadOtherGameData = async (reporter?: AssetDownloadReporter) => {
|
||||
reporter?.('fonts', false)
|
||||
reporter?.('sounds', false)
|
||||
|
||||
await Promise.all([
|
||||
loadFonts().then(() => reporter?.('fonts', true)),
|
||||
downloadSoundsIfNeeded().then(() => reporter?.('sounds', true))
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -170,7 +171,7 @@ export const createNotificationProgressReporter = (endMessage?: string): Progres
|
|||
},
|
||||
end () {
|
||||
if (endMessage) {
|
||||
showNotification(endMessage, '', false, '', undefined, true)
|
||||
showNotification(endMessage, '', false, pixelartIcons.check, undefined, true)
|
||||
} else {
|
||||
hideNotification(id)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
72
src/index.ts
72
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,30 +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 = []
|
||||
|
|
@ -110,7 +109,6 @@ void registerServiceWorker().then(() => {
|
|||
watchFov()
|
||||
initCollisionShapes()
|
||||
initializePacketsReplay()
|
||||
packetsPatcher()
|
||||
onAppLoad()
|
||||
customChannels()
|
||||
|
||||
|
|
@ -168,6 +166,8 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
})
|
||||
}
|
||||
|
||||
loadingTimerState.loading = true
|
||||
loadingTimerState.start = Date.now()
|
||||
miscUiState.hasErrors = false
|
||||
lastConnectOptions.value = connectOptions
|
||||
|
||||
|
|
@ -211,6 +211,7 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
let bot!: typeof __type_bot
|
||||
const destroyAll = (wasKicked = false) => {
|
||||
if (ended) return
|
||||
loadingTimerState.loading = false
|
||||
const hadConnected = !!bot
|
||||
if (!wasKicked && miscUiState.appConfig?.allowAutoConnect && appQueryParams.autoConnect && hadConnected) {
|
||||
location.reload()
|
||||
|
|
@ -301,11 +302,24 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
const serverOptions = defaultsDeep({}, connectOptions.serverOverrides ?? {}, options.localServerOptions, defaultServerOptions)
|
||||
Object.assign(serverOptions, connectOptions.serverOverridesFlat ?? {})
|
||||
|
||||
await progress.executeWithMessage('Downloading minecraft data', 'download-mcdata', async () => {
|
||||
await progress.executeWithMessage('Downloading Minecraft data', 'download-mcdata', async () => {
|
||||
loadingTimerState.networkOnlyStart = Date.now()
|
||||
|
||||
let downloadingAssets = [] as string[]
|
||||
const reportAssetDownload = (asset: string, isDone: boolean) => {
|
||||
if (isDone) {
|
||||
downloadingAssets = downloadingAssets.filter(a => a !== asset)
|
||||
} else {
|
||||
downloadingAssets.push(asset)
|
||||
}
|
||||
progress.setSubStage('download-mcdata', `(${downloadingAssets.join(', ')})`)
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
downloadAllMinecraftData(),
|
||||
downloadOtherGameData()
|
||||
downloadAllMinecraftData(reportAssetDownload),
|
||||
downloadOtherGameData(reportAssetDownload)
|
||||
])
|
||||
loadingTimerState.networkOnlyStart = 0
|
||||
})
|
||||
|
||||
let dataDownloaded = false
|
||||
|
|
@ -315,7 +329,7 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
appViewer.resourcesManager.currentConfig = { version, texturesVersion: options.useVersionsTextures || undefined }
|
||||
|
||||
await progress.executeWithMessage(
|
||||
'Loading minecraft data',
|
||||
'Processing downloaded Minecraft data',
|
||||
async () => {
|
||||
await appViewer.resourcesManager.loadSourceData(version)
|
||||
}
|
||||
|
|
@ -368,6 +382,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
|
||||
|
|
@ -401,8 +425,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}`
|
||||
|
|
@ -416,6 +442,7 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
setLoadingScreenStatus(initialLoadingText)
|
||||
|
||||
if (parsedServer.isWebSocket) {
|
||||
loadingTimerState.networkOnlyStart = Date.now()
|
||||
clientDataStream = (await getWebsocketStream(server.host)).mineflayerStream
|
||||
}
|
||||
|
||||
|
|
@ -459,6 +486,7 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
|
||||
if (finalVersion) {
|
||||
// ensure data is downloaded
|
||||
loadingTimerState.networkOnlyStart ??= Date.now()
|
||||
await downloadMcData(finalVersion)
|
||||
}
|
||||
|
||||
|
|
@ -542,7 +570,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) {
|
||||
|
|
@ -612,14 +640,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', () => {
|
||||
|
|
@ -671,7 +691,8 @@ export async function connect (connectOptions: ConnectOptions) {
|
|||
onBotCreate()
|
||||
|
||||
bot.once('login', () => {
|
||||
setLoadingScreenStatus('Loading world')
|
||||
loadingTimerState.networkOnlyStart = 0
|
||||
progress.setMessage('Loading world')
|
||||
})
|
||||
|
||||
let worldWasReady = false
|
||||
|
|
@ -986,4 +1007,5 @@ if (initialLoader) {
|
|||
window.pageLoaded = true
|
||||
|
||||
void possiblyHandleStateVariable()
|
||||
appViewer.waitBackendLoadPromises.push(appStartup())
|
||||
registerOpenBenchmarkListener()
|
||||
|
|
|
|||
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: [
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { omitObj } from '@zardoy/utils'
|
|||
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,11 +60,15 @@ 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,
|
||||
|
|
@ -293,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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -182,7 +186,7 @@ export default () => {
|
|||
<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>
|
||||
|
|
@ -267,14 +271,17 @@ const hardcodedListOfDebugPacketsToIgnore = {
|
|||
'playerlist_header',
|
||||
'scoreboard_objective',
|
||||
'scoreboard_score',
|
||||
'entity_status'
|
||||
'entity_status',
|
||||
'set_ticking_state',
|
||||
'ping_response'
|
||||
],
|
||||
sent: [
|
||||
'pong',
|
||||
'position',
|
||||
'look',
|
||||
'keep_alive',
|
||||
'position_look'
|
||||
'position_look',
|
||||
'ping_request'
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -226,7 +227,6 @@ const App = () => {
|
|||
<CreateWorldProvider />
|
||||
<AppStatusProvider />
|
||||
<KeybindingsScreenProvider />
|
||||
<SelectOption />
|
||||
<ServersListProvider />
|
||||
<OptionsRenderApp />
|
||||
<MainMenuRenderApp />
|
||||
|
|
@ -235,6 +235,9 @@ const App = () => {
|
|||
<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)
|
||||
|
||||
|
|
|
|||
|
|
@ -44,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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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