Merge remote-tracking branch 'origin/next' into light-engine

This commit is contained in:
Vitaly Turovsky 2025-03-21 13:54:44 +03:00
commit ec6b2494c8
47 changed files with 1003 additions and 513 deletions

View file

@ -86,7 +86,7 @@
"mojangson": "^2.0.4",
"net-browserify": "github:zardoy/prismarinejs-net-browserify",
"node-gzip": "^1.1.2",
"mcraft-fun-mineflayer": "^0.1.8",
"mcraft-fun-mineflayer": "^0.1.14",
"peerjs": "^1.5.0",
"pixelarticons": "^1.8.1",
"pretty-bytes": "^6.1.1",
@ -151,7 +151,7 @@
"http-server": "^14.1.1",
"https-browserify": "^1.0.0",
"mc-assets": "^0.2.42",
"mineflayer-mouse": "^0.0.8",
"mineflayer-mouse": "^0.1.2",
"minecraft-inventory-gui": "github:zardoy/minecraft-inventory-gui#next",
"mineflayer": "github:zardoy/mineflayer",
"mineflayer-pathfinder": "^2.4.4",

79
pnpm-lock.yaml generated
View file

@ -135,8 +135,8 @@ importers:
specifier: ^4.17.21
version: 4.17.21
mcraft-fun-mineflayer:
specifier: ^0.1.8
version: 0.1.8(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/748163e536abe94f3dc8ada7a542bcd689bbbf49(encoding@0.1.13))
specifier: ^0.1.14
version: 0.1.14(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/06e3050ddf4d9aa655fea6e2bed182937a81705d(encoding@0.1.13))
minecraft-data:
specifier: 3.83.1
version: 3.83.1
@ -363,10 +363,10 @@ importers:
version: 0.0.4
mineflayer:
specifier: github:zardoy/mineflayer
version: https://codeload.github.com/zardoy/mineflayer/tar.gz/748163e536abe94f3dc8ada7a542bcd689bbbf49(encoding@0.1.13)
version: https://codeload.github.com/zardoy/mineflayer/tar.gz/06e3050ddf4d9aa655fea6e2bed182937a81705d(encoding@0.1.13)
mineflayer-mouse:
specifier: ^0.0.8
version: 0.0.8(@types/debug@4.1.12)(@types/node@22.8.1)(terser@5.31.3)(tsx@4.7.0)(yaml@2.4.1)
specifier: ^0.1.2
version: 0.1.2(@types/debug@4.1.12)(@types/node@22.8.1)(terser@5.31.3)(tsx@4.7.0)(yaml@2.4.1)
mineflayer-pathfinder:
specifier: ^2.4.4
version: 2.4.4
@ -4132,6 +4132,10 @@ packages:
resolution: {integrity: sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==}
engines: {node: '>= 0.4'}
call-bound@1.0.4:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
@ -6693,9 +6697,9 @@ packages:
resolution: {integrity: sha512-j2D1RNYtB5Z9gFu9MVjyDBbiALI0mWZ3xW/A3PPefVAHm3HJ2T1vH+1XBov1spBGPl7u+Zo7mRXza3X0egbeOg==}
engines: {node: '>=18.0.0'}
mcraft-fun-mineflayer@0.1.8:
resolution: {integrity: sha512-jyJTihNHfeToBPwVs3QMKBlVcaCABJ25YN2eoIBQEVTRVFzaXh13XRpElphLzTMj1Q5XFYqufHtMoR4tsb08qQ==}
version: 0.1.8
mcraft-fun-mineflayer@0.1.14:
resolution: {integrity: sha512-q/qXQaNbkGJIvXjRvudUT7/k0EsJgphFcvYjrSRWYyGDJeb61MKRVqq1hhMjqx7UK7FMfBKvjfPSxq/QlAP7WQ==}
version: 0.1.14
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
peerDependencies:
'@roamhq/wrtc': '*'
@ -6907,6 +6911,11 @@ packages:
resolution: {integrity: sha512-5p+Dx1SIdQhkKA8Wbm7slN0MR6s7pdnlV2MVSBSmAlR4zW8+FVpsNJfvMQ4XltRqKYyHybNDZEdJocdtdkfhpQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/3bd4dc1b2002cd7badfa5b9cf8dda35cd6cc9ac1:
resolution: {tarball: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/3bd4dc1b2002cd7badfa5b9cf8dda35cd6cc9ac1}
version: 1.54.0
engines: {node: '>=22'}
minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d:
resolution: {tarball: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d}
version: 1.54.0
@ -6923,8 +6932,8 @@ packages:
resolution: {tarball: https://codeload.github.com/zardoy/mineflayer-item-map-downloader/tar.gz/a8d210ecdcf78dd082fa149a96e1612cc9747824}
version: 1.2.0
mineflayer-mouse@0.0.8:
resolution: {integrity: sha512-Y6TfclMjx7ndV+ISznJsQ4SMwzjhYvwvkj8kKBsQXx9/bG6fDtkgfnAroO/f2ppV52WNrtERQlVXYs35gHtIFg==}
mineflayer-mouse@0.1.2:
resolution: {integrity: sha512-QPGEXkF9PurZEpRq0xakKE8SV6sMY/6kCM9cdMeFbtq95IpYeh8ZJdD/twX2A3g3s8MooxlGovfxbpeHdWcOEQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
mineflayer-pathfinder@2.4.4:
@ -6934,8 +6943,8 @@ packages:
resolution: {integrity: sha512-q7cmpZFaSI6sodcMJxc2GkV8IO84HbsUP+xNipGKfGg+FMISKabzdJ838Axb60qRtZrp6ny7LluQE7lesHvvxQ==}
engines: {node: '>=18'}
mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/748163e536abe94f3dc8ada7a542bcd689bbbf49:
resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/748163e536abe94f3dc8ada7a542bcd689bbbf49}
mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/06e3050ddf4d9aa655fea6e2bed182937a81705d:
resolution: {tarball: https://codeload.github.com/zardoy/mineflayer/tar.gz/06e3050ddf4d9aa655fea6e2bed182937a81705d}
version: 4.25.0
engines: {node: '>=18'}
@ -13610,7 +13619,7 @@ snapshots:
flatmap: 0.0.3
long: 5.2.3
minecraft-data: 3.83.1
minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13)
minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/3bd4dc1b2002cd7badfa5b9cf8dda35cd6cc9ac1(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13)
mkdirp: 2.1.6
node-gzip: 1.1.2
node-rsa: 1.1.1
@ -14372,6 +14381,11 @@ snapshots:
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
call-bound@1.0.4:
dependencies:
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
callsites@3.1.0: {}
camel-case@4.1.2:
@ -15431,7 +15445,7 @@ snapshots:
es-iterator-helpers@1.2.1:
dependencies:
call-bind: 1.0.8
call-bound: 1.0.3
call-bound: 1.0.4
define-properties: 1.2.1
es-abstract: 1.23.9
es-errors: 1.3.0
@ -17586,12 +17600,12 @@ snapshots:
maxrects-packer: 2.7.3
zod: 3.24.1
mcraft-fun-mineflayer@0.1.8(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/748163e536abe94f3dc8ada7a542bcd689bbbf49(encoding@0.1.13)):
mcraft-fun-mineflayer@0.1.14(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/06e3050ddf4d9aa655fea6e2bed182937a81705d(encoding@0.1.13)):
dependencies:
'@zardoy/flying-squid': 0.0.49(encoding@0.1.13)
exit-hook: 2.2.1
minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13)
mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/748163e536abe94f3dc8ada7a542bcd689bbbf49(encoding@0.1.13)
minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/3bd4dc1b2002cd7badfa5b9cf8dda35cd6cc9ac1(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13)
mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/06e3050ddf4d9aa655fea6e2bed182937a81705d(encoding@0.1.13)
prismarine-item: 1.16.0
ws: 8.18.0
transitivePeerDependencies:
@ -17903,6 +17917,32 @@ snapshots:
dependencies:
vec3: 0.1.10
minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/3bd4dc1b2002cd7badfa5b9cf8dda35cd6cc9ac1(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13):
dependencies:
'@types/node-rsa': 1.1.4
'@types/readable-stream': 4.0.12
aes-js: 3.1.2
buffer-equal: 1.0.1
debug: 4.4.0(supports-color@8.1.1)
endian-toggle: 0.0.0
lodash.get: 4.4.2
lodash.merge: 4.6.2
minecraft-data: 3.83.1
minecraft-folder-path: 1.2.0
node-fetch: 2.7.0(encoding@0.1.13)
node-rsa: 0.4.2
prismarine-auth: 2.4.2(encoding@0.1.13)
prismarine-chat: 1.10.1
prismarine-nbt: 2.5.0
prismarine-realms: 1.3.2(encoding@0.1.13)
protodef: 1.18.0
readable-stream: 4.5.2
uuid-1345: 1.0.2
yggdrasil: 1.7.0(encoding@0.1.13)
transitivePeerDependencies:
- encoding
- supports-color
minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13):
dependencies:
'@types/node-rsa': 1.1.4
@ -17960,10 +18000,11 @@ snapshots:
- encoding
- supports-color
mineflayer-mouse@0.0.8(@types/debug@4.1.12)(@types/node@22.8.1)(terser@5.31.3)(tsx@4.7.0)(yaml@2.4.1):
mineflayer-mouse@0.1.2(@types/debug@4.1.12)(@types/node@22.8.1)(terser@5.31.3)(tsx@4.7.0)(yaml@2.4.1):
dependencies:
change-case: 5.4.4
debug: 4.4.0(supports-color@8.1.1)
prismarine-item: 1.16.0
prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c
vitest: 3.0.7(@types/debug@4.1.12)(@types/node@22.8.1)(terser@5.31.3)(tsx@4.7.0)(yaml@2.4.1)
transitivePeerDependencies:
@ -18020,7 +18061,7 @@ snapshots:
- encoding
- supports-color
mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/748163e536abe94f3dc8ada7a542bcd689bbbf49(encoding@0.1.13):
mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/06e3050ddf4d9aa655fea6e2bed182937a81705d(encoding@0.1.13):
dependencies:
minecraft-data: 3.83.1
minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13)

View file

@ -155,7 +155,7 @@ const generateItemsGui = async (models: Record<string, BlockModelMcAssets>, isIt
return null
},
getTextureUV (texture) {
return textureAtlas.getTextureUV(texture.toString().slice(1).split('/').slice(1).join('/') as any)
return textureAtlas.getTextureUV(texture.toString().replace('minecraft:', '').replace('block/', '').replace('item/', '').replace('blocks/', '').replace('items/', '') as any)
},
getTextureAtlas () {
return textureAtlas.getTextureAtlas()

View file

@ -3,7 +3,7 @@ const rightOffset = 0
const stats = {}
let lastY = 20
let lastY = 40
export const addNewStat = (id: string, width = 80, x = rightOffset, y = lastY) => {
const pane = document.createElement('div')
pane.style.position = 'fixed'

View file

@ -15,7 +15,7 @@ export const isServerValid = (ip: string) => {
return !isInLocalNetwork && VALID_IP_OR_DOMAIN
}
export async function fetchServerStatus (ip: string, signal?: AbortSignal) {
export async function fetchServerStatus (ip: string, signal?: AbortSignal, versionOverride?: string) {
if (!isServerValid(ip)) return
const response = await fetch(`https://api.mcstatus.io/v2/status/java/${ip}`, { signal })
@ -25,7 +25,7 @@ export async function fetchServerStatus (ip: string, signal?: AbortSignal) {
return {
formattedText: data.motd?.raw ?? '',
textNameRight: data.online ?
`${versionClean} ${data.players?.online ?? '??'}/${data.players?.max ?? '??'}` :
`${versionOverride ?? versionClean} ${data.players?.online ?? '??'}/${data.players?.max ?? '??'}` :
'',
icon: data.icon,
offline: !data.online,

View file

@ -1,6 +1,7 @@
import { disabledSettings, options, qsOptions } from './optionsStorage'
import { miscUiState } from './globalState'
import { setLoadingScreenStatus } from './appStatus'
import { setStorageDataOnAppConfigLoad } from './react/appStorageProvider'
export type AppConfig = {
// defaultHost?: string
@ -42,6 +43,8 @@ export const loadAppConfig = (appConfig: AppConfig) => {
}
}
}
setStorageDataOnAppConfigLoad()
}
export const isBundledConfigUsed = !!process.env.INLINED_APP_CONFIG

View file

@ -1,4 +1,5 @@
import type { AppConfig } from './appConfig'
import { miscUiState } from './globalState'
const qsParams = new URLSearchParams(window.location?.search ?? '')
@ -73,7 +74,7 @@ export const appQueryParams = new Proxy<AppQsParams>({} as AppQsParams, {
}
const qsParam = qsParams.get(property)
if (qsParam) return qsParam
return initialAppConfig.appParams?.[property]
return miscUiState.appConfig?.appParams?.[property]
},
})
@ -84,7 +85,7 @@ export const appQueryParamsArray = new Proxy({} as AppQsParamsArrayTransformed,
}
const qsParam = qsParams.getAll(property)
if (qsParam.length) return qsParam
return initialAppConfig.appParams?.[property] ?? []
return miscUiState.appConfig?.appParams?.[property] ?? []
},
})

View file

@ -15,6 +15,7 @@ import { getFixedFilesize } from './downloadAndOpenFile'
import { packetsReplayState } from './react/state/packetsReplayState'
import { createFullScreenProgressReporter } from './core/progressReporter'
import { showNotification } from './react/NotificationProvider'
import { resetAppStorage } from './react/appStorageProvider'
const { GoogleDriveFileSystem } = require('google-drive-browserfs/src/backends/GoogleDrive')
browserfs.install(window)
@ -620,24 +621,13 @@ export const openWorldZip = async (...args: Parameters<typeof openWorldZipInner>
}
}
export const resetLocalStorageWorld = () => {
for (const key of Object.keys(localStorage)) {
if (/^[\da-fA-F]{8}(?:\b-[\da-fA-F]{4}){3}\b-[\da-fA-F]{12}$/g.test(key) || key === '/') {
localStorage.removeItem(key)
}
}
}
export const resetLocalStorageWithoutWorld = () => {
for (const key of Object.keys(localStorage)) {
if (!/^[\da-fA-F]{8}(?:\b-[\da-fA-F]{4}){3}\b-[\da-fA-F]{12}$/g.test(key) && key !== '/') {
localStorage.removeItem(key)
}
}
export const resetLocalStorage = () => {
resetOptions()
resetAppStorage()
}
window.resetLocalStorageWorld = resetLocalStorageWorld
window.resetLocalStorage = resetLocalStorage
export const openFilePicker = (specificCase?: 'resourcepack') => {
// create and show input picker
let picker: HTMLInputElement = document.body.querySelector('input#file-zip-picker')!

View file

@ -79,8 +79,9 @@ const writeText = (text) => {
displayClientChat(text)
}
const commands: Array<{
export const commands: Array<{
command: string[],
alwaysAvailable?: boolean,
invoke (args: string[]): Promise<void> | void
//@ts-format-ignore-region
}> = [
@ -111,6 +112,7 @@ const commands: Array<{
},
{
command: ['/pos'],
alwaysAvailable: true,
async invoke ([type]) {
let pos: { x: number, y: number, z: number } | undefined
if (type === 'block') {
@ -131,13 +133,12 @@ const commands: Array<{
]
//@ts-format-ignore-endregion
export const getBuiltinCommandsList = () => commands.flatMap(command => command.command)
export const getBuiltinCommandsList = () => commands.filter(command => command.alwaysAvailable || localServer).flatMap(command => command.command)
export const tryHandleBuiltinCommand = (message: string) => {
if (!localServer) return
const [userCommand, ...args] = message.split(' ')
for (const command of commands) {
for (const command of commands.filter(command => command.alwaysAvailable || localServer)) {
if (command.command.includes(userCommand)) {
void command.invoke(args) // ignoring for now
return true

View file

@ -8,6 +8,7 @@ import { ControMax } from 'contro-max/build/controMax'
import { CommandEventArgument, SchemaCommandInput } from 'contro-max/build/types'
import { stringStartsWith } from 'contro-max/build/stringUtils'
import { UserOverrideCommand, UserOverridesConfig } from 'contro-max/build/types/store'
import { GameMode } from 'mineflayer'
import { isGameActive, showModal, gameAdditionalState, activeModalStack, hideCurrentModal, miscUiState, hideModal, hideAllModals } from './globalState'
import { goFullscreen, isInRealGameSession, pointerLock, reloadChunks } from './utils'
import { options } from './optionsStorage'
@ -25,11 +26,13 @@ import { showNotification } from './react/NotificationProvider'
import { lastConnectOptions } from './react/AppStatusProvider'
import { onCameraMove, onControInit } from './cameraRotationControls'
import { createNotificationProgressReporter } from './core/progressReporter'
import { appStorage } from './react/appStorageProvider'
import { switchGameMode } from './packetsReplay/replayPackets'
export const customKeymaps = proxy(JSON.parse(localStorage.keymap || '{}')) as UserOverridesConfig
export const customKeymaps = proxy(appStorage.keybindings)
subscribe(customKeymaps, () => {
localStorage.keymap = JSON.stringify(customKeymaps)
appStorage.keybindings = customKeymaps
})
const controlOptions = {
@ -636,29 +639,35 @@ export const f3Keybinds: Array<{
{
key: 'F4',
async action () {
let nextGameMode: GameMode
switch (bot.game.gameMode) {
case 'creative': {
bot.chat('/gamemode survival')
nextGameMode = 'survival'
break
}
case 'survival': {
bot.chat('/gamemode adventure')
nextGameMode = 'adventure'
break
}
case 'adventure': {
bot.chat('/gamemode spectator')
nextGameMode = 'spectator'
break
}
case 'spectator': {
bot.chat('/gamemode creative')
nextGameMode = 'creative'
break
}
// No default
}
if (lastConnectOptions.value?.worldStateFileContents) {
switchGameMode(nextGameMode)
} else {
bot.chat(`/gamemode ${nextGameMode}`)
}
},
mobileTitle: 'Cycle Game Mode'
},
@ -809,15 +818,16 @@ let allowFlying = false
export const onBotCreate = () => {
let wasSpectatorFlying = false
bot._client.on('abilities', ({ flags }) => {
allowFlying = !!(flags & 4)
if (flags & 2) { // flying
toggleFly(true, false)
} else {
toggleFly(false, false)
}
allowFlying = !!(flags & 4)
})
const gamemodeCheck = () => {
if (bot.game.gameMode === 'spectator') {
allowFlying = true
toggleFly(true, false)
wasSpectatorFlying = true
} else if (wasSpectatorFlying) {

View file

@ -13,13 +13,43 @@ export const getFixedFilesize = (bytes: number) => {
const inner = async () => {
const { replayFileUrl } = appQueryParams
if (replayFileUrl) {
setLoadingScreenStatus('Downloading replay file...')
setLoadingScreenStatus('Downloading replay file')
const response = await fetch(replayFileUrl)
const contentLength = response.headers?.get('Content-Length')
const size = contentLength ? +contentLength : undefined
const filename = replayFileUrl.split('/').pop()
const contents = await response.text()
let downloadedBytes = 0
const buffer = await new Response(new ReadableStream({
async start (controller) {
if (!response.body) throw new Error('Server returned no response!')
const reader = response.body.getReader()
// eslint-disable-next-line no-constant-condition
while (true) {
const { done, value } = await reader.read()
if (done) {
controller.close()
break
}
downloadedBytes += value.byteLength
// Calculate download progress as a percentage
const progress = size ? (downloadedBytes / size) * 100 : undefined
setLoadingScreenStatus(`Download replay file progress: ${progress === undefined ? '?' : Math.floor(progress)}% (${getFixedFilesize(downloadedBytes)} / ${size && getFixedFilesize(size)})`, false, true)
// Pass the received data to the controller
controller.enqueue(value)
}
},
})).arrayBuffer()
// Convert buffer to text, handling any compression automatically
const decoder = new TextDecoder()
const contents = decoder.decode(buffer)
openFile({
contents,
filename,

View file

@ -3,17 +3,12 @@
import { proxy, ref, subscribe } from 'valtio'
import type { WorldWarp } from 'flying-squid/dist/lib/modules/warps'
import type { OptionsGroupType } from './optionsGuiScheme'
import { appQueryParams } from './appParams'
import { options, disabledSettings } from './optionsStorage'
import { AppConfig } from './appConfig'
// todo: refactor structure with support of hideNext=false
const notHideableModalsWithoutForce = new Set(['app-status'])
if (appQueryParams.lockConnect) {
notHideableModalsWithoutForce.add('editServer')
}
export const notHideableModalsWithoutForce = new Set(['app-status'])
type Modal = ({ elem?: HTMLElement & Record<string, any> } & { reactType: string })

2
src/globals.d.ts vendored
View file

@ -26,7 +26,7 @@ declare const customEvents: import('typed-emitter').default<{
mineflayerBotCreated (): void
search (q: string): void
activateItem (item: Item, slot: number, offhand: boolean): void
hurtAnimation (): void
hurtAnimation (yaw?: number): void
}>
declare const beforeRenderFrame: Array<() => void>

View file

@ -363,6 +363,7 @@ export async function connect (connectOptions: ConnectOptions) {
if (miscUiState.gameLoaded) return
setLoadingScreenStatus(`Error encountered. ${err}`, true)
appStatusState.showReconnect = true
onPossibleErrorDisconnect()
destroyAll()
}
@ -712,6 +713,7 @@ export async function connect (connectOptions: ConnectOptions) {
console.log('You were kicked!', kickReason)
const { formatted: kickReasonFormatted, plain: kickReasonString } = parseFormattedMessagePacket(kickReason)
setLoadingScreenStatus(`The Minecraft server kicked you. Kick reason: ${kickReasonString}`, true, undefined, undefined, kickReasonFormatted)
appStatusState.showReconnect = true
destroyAll()
})
@ -779,11 +781,13 @@ export async function connect (connectOptions: ConnectOptions) {
setLoadingScreenStatus('Placing blocks (starting viewer)')
if (!connectOptions.worldStateFileContents || connectOptions.worldStateFileContents.length < 3 * 1024 * 1024) {
localStorage.lastConnectOptions = JSON.stringify(connectOptions)
if (process.env.NODE_ENV === 'development' && !localStorage.lockUrl && !Object.keys(window.debugQueryParams).length) {
lockUrl()
}
} else {
localStorage.removeItem('lastConnectOptions')
}
connectOptions.onSuccessfulPlay?.()
if (process.env.NODE_ENV === 'development' && !localStorage.lockUrl && !Object.keys(window.debugQueryParams).length) {
lockUrl()
}
updateDataAfterJoin()
if (connectOptions.autoLoginPassword) {
bot.chat(`/login ${connectOptions.autoLoginPassword}`)
@ -827,6 +831,7 @@ export async function connect (connectOptions: ConnectOptions) {
if (appStatusState.isError) return
const waitForChunks = async () => {
if (appQueryParams.sp === '1') return //todo
const waitForChunks = options.waitForChunksRender === 'sp-only' ? !!singleplayer : options.waitForChunksRender
if (viewer.world.allChunksFinished || !waitForChunks) {
return
@ -894,8 +899,8 @@ export async function connect (connectOptions: ConnectOptions) {
const reconnectOptions = sessionStorage.getItem('reconnectOptions') ? JSON.parse(sessionStorage.getItem('reconnectOptions')!) : undefined
listenGlobalEvents()
const unsubscribe = watchValue(miscUiState, async s => {
if (s.fsReady && s.appConfig) {
const unsubscribe = subscribe(miscUiState, async () => {
if (miscUiState.fsReady && miscUiState.appConfig) {
unsubscribe()
if (reconnectOptions) {
sessionStorage.removeItem('reconnectOptions')

View file

@ -37,6 +37,10 @@ export const allImagesLoadedState = proxy({
value: false
})
export const jeiCustomCategories = proxy({
value: [] as Array<{ id: string, categoryTitle: string, items: any[] }>
})
export const onGameLoad = (onLoad) => {
allImagesLoadedState.value = false
version = bot.version
@ -254,12 +258,25 @@ const getItemName = (slot: Item | RenderItem | null) => {
return text.join('')
}
let lastMappedSots = [] as any[]
const itemToVisualKey = (slot: RenderItem | Item | null) => {
if (!slot) return null
return slot.name + (slot['metadata'] ?? '-') + (slot.nbt ? JSON.stringify(slot.nbt) : '') + (slot['components'] ? JSON.stringify(slot['components']) : '')
}
const mapSlots = (slots: Array<RenderItem | Item | null>, isJei = false) => {
return slots.map((slot, i) => {
const newSlots = slots.map((slot, i) => {
// todo stateid
if (!slot) return
if (!isJei) {
const oldKey = itemToVisualKey(lastMappedSots[i])
if (oldKey && oldKey === itemToVisualKey(slot)) {
return lastMappedSots[i]
}
}
try {
if (slot.durabilityUsed && slot.maxDurability) slot.durabilityUsed = Math.min(slot.durabilityUsed, slot.maxDurability)
const debugIsQuickbar = !isJei && i === bot.inventory.hotbarStart + bot.quickBarSlot
const modelName = getItemModelName(slot, { 'minecraft:display_context': 'gui', })
const slotCustomProps = renderSlot({ modelName }, debugIsQuickbar)
@ -277,6 +294,8 @@ const mapSlots = (slots: Array<RenderItem | Item | null>, isJei = false) => {
}
return slot
})
lastMappedSots = newSlots
return newSlots
}
export const upInventoryItems = (isInventory: boolean, invWindow = lastWindow) => {
@ -324,9 +343,14 @@ const implementedContainersGuiMap = {
const upJei = (search: string) => {
search = search.toLowerCase()
// todo fix pre flat
const matchedSlots = loadedData.itemsArray.map(x => {
const itemsArray = [
...jeiCustomCategories.value.flatMap(x => x.items).filter(x => x !== null),
...loadedData.itemsArray.filter(x => x.displayName.toLowerCase().includes(search)).map(item => new PrismarineItem(item.id, 1)).filter(x => x !== null)
]
const matchedSlots = itemsArray.map(x => {
x.displayName = getItemName(x) ?? x.displayName
if (!x.displayName.toLowerCase().includes(search)) return null
return new PrismarineItem(x.id, 1)
return x
}).filter(a => a !== null)
lastWindow.pwindow.win.jeiSlotsPage = 0
lastWindow.pwindow.win.jeiSlots = mapSlots(matchedSlots, true)
@ -344,7 +368,7 @@ export const openItemsCanvas = (type, _bot = bot as typeof bot | null) => {
return [...allRecipes ?? [], ...itemDescription ? [
[
'GenericDescription',
mapSlots([item])[0],
mapSlots([item], true)[0],
[],
itemDescription
]
@ -448,26 +472,43 @@ const openWindow = (type: string | undefined) => {
inGameError(`Item for block ${slotItem.name} not found`)
return
}
const item = new PrismarineItem(itemId, isRightclick ? 64 : 1, slotItem.metadata)
const item = PrismarineItem.fromNotch({
...slotItem,
itemId,
itemCount: isRightclick ? 64 : 1,
components: slotItem.components ?? [],
removeComponents: slotItem.removedComponents ?? [],
itemDamage: slotItem.metadata ?? 0,
nbt: slotItem.nbt,
})
if (bot.game.gameMode === 'creative') {
const freeSlot = bot.inventory.firstEmptyInventorySlot()
if (freeSlot === null) return
void bot.creative.setInventorySlot(freeSlot, item)
} else {
inv.canvasManager.children[0].showRecipesOrUsages(!isRightclick, mapSlots([item])[0])
inv.canvasManager.children[0].showRecipesOrUsages(!isRightclick, mapSlots([item], true)[0])
}
}
// if (bot.game.gameMode !== 'spectator') {
lastWindow.pwindow.win.jeiSlotsPage = 0
// todo workaround so inventory opens immediately (though it still lags)
setTimeout(() => {
upJei('')
})
miscUiState.displaySearchInput = true
// } else {
// lastWindow.pwindow.win.jeiSlots = []
// }
const isJeiEnabled = () => {
if (typeof options.jeiEnabled === 'boolean') return options.jeiEnabled
if (Array.isArray(options.jeiEnabled)) {
return options.jeiEnabled.includes(bot.game?.gameMode as any)
}
return false
}
if (isJeiEnabled()) {
lastWindow.pwindow.win.jeiSlotsPage = 0
// todo workaround so inventory opens immediately (though it still lags)
setTimeout(() => {
upJei('')
})
miscUiState.displaySearchInput = true
} else {
lastWindow.pwindow.win.jeiSlots = []
miscUiState.displaySearchInput = false
}
if (type === undefined) {
// player inventory
@ -574,8 +615,8 @@ const getAllItemRecipes = (itemName: string) => {
return results.map(({ result, ingredients, description }) => {
return [
'CraftingTableGuide',
mapSlots([result])[0],
mapSlots(ingredients),
mapSlots([result], true)[0],
mapSlots(ingredients, true),
description
]
})

View file

@ -10,10 +10,10 @@ class CameraShake {
this.rollAngle = 0
}
shakeFromDamage () {
shakeFromDamage (yaw?: number) {
// Add roll animation
const startRoll = this.rollAngle
const targetRoll = startRoll + (Math.random() < 0.5 ? -1 : 1) * this.damageRollAmount
const targetRoll = startRoll + (yaw ?? (Math.random() < 0.5 ? -1 : 1)) * this.damageRollAmount
this.rollAnimation = {
startTime: performance.now(),
@ -85,12 +85,14 @@ customEvents.on('mineflayerBotCreated', () => {
})
}
customEvents.on('hurtAnimation', () => {
cameraShake.shakeFromDamage()
customEvents.on('hurtAnimation', (yaw) => {
cameraShake.shakeFromDamage(yaw)
})
bot._client.on('hurt_animation', () => {
customEvents.emit('hurtAnimation')
bot._client.on('hurt_animation', ({ entityId, yaw }) => {
if (entityId === bot.entity.id) {
customEvents.emit('hurtAnimation', yaw)
}
})
bot.on('entityHurt', ({ id }) => {
if (id === bot.entity.id) {

View file

@ -39,7 +39,7 @@ export const getItemMetadata = (item: GeneralInputItem) => {
const customTextComponent = componentMap.get('custom_name') || componentMap.get('item_name')
if (customTextComponent) {
customText = nbt.simplify(customTextComponent.data)
customText = typeof customTextComponent.data === 'string' ? customTextComponent.data : nbt.simplify(customTextComponent.data)
}
const customModelComponent = componentMap.get('item_model')
if (customModelComponent) {

View file

@ -26,20 +26,39 @@ export const localRelayServerPlugin = (bot: Bot) => {
})
)
bot.downloadCurrentWorldState = () => {
const worldState = bot.webViewer._unstable.createStateCaptureFile()
const downloadFile = (contents: string, filename: string) => {
const a = document.createElement('a')
const textContents = worldState.contents
const blob = new Blob([textContents], { type: 'text/plain' })
const blob = new Blob([contents], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
a.href = url
// add readable timestamp to filename
const timestamp = new Date().toISOString().replaceAll(/[-:Z]/g, '')
a.download = `${bot.username}-world-state-${timestamp}.${WORLD_STATE_FILE_EXTENSION}`
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
bot.downloadCurrentWorldState = () => {
const worldState = bot.webViewer._unstable.createStateCaptureFile()
// add readable timestamp to filename
const timestamp = new Date().toISOString().replaceAll(/[-:Z]/g, '')
downloadFile(worldState.contents, `${bot.username}-world-state-${timestamp}.${WORLD_STATE_FILE_EXTENSION}`)
}
let logger: PacketsLogger | undefined
bot.startPacketsRecording = () => {
bot.webViewer._unstable.startRecording((l) => {
logger = l
})
}
bot.stopPacketsRecording = () => {
if (!logger) return
const packets = logger?.contents
logger = undefined
const timestamp = new Date().toISOString().replaceAll(/[-:Z]/g, '')
downloadFile(packets, `${bot.username}-packets-${timestamp}.${PACKETS_REPLAY_FILE_EXTENSION}`)
bot.webViewer._unstable.stopRecording()
}
circularBuffer = new CircularBuffer(AUTO_CAPTURE_PACKETS_COUNT)
let position = 0
bot._client.on('writePacket' as any, (name, params) => {
@ -87,12 +106,16 @@ subscribe(packetsRecordingState, () => {
declare module 'mineflayer' {
interface Bot {
downloadCurrentWorldState: () => void
startPacketsRecording: () => void
stopPacketsRecording: () => void
}
}
export const getLastAutoCapturedPackets = () => circularBuffer?.size
export const downloadAutoCapturedPackets = () => {
const logger = new PacketsLogger({ minecraftVersion: lastConnectVersion })
logger.relativeTime = false
logger.formattedTime = true
for (const packet of circularBuffer?.getLastElements() ?? []) {
logger.log(packet.isFromServer, { name: packet.name, state: packet.state, time: packet.timestamp }, packet.params)
}

View file

@ -2,20 +2,23 @@ import { useRef, useState } from 'react'
import { useSnapshot } from 'valtio'
import { openURL } from 'renderer/viewer/lib/simpleUtils'
import { noCase } from 'change-case'
import { versionToNumber } from 'mc-assets/dist/utils'
import { gameAdditionalState, miscUiState, openOptionsMenu, showModal } from './globalState'
import { AppOptions, options } from './optionsStorage'
import { AppOptions, getChangedSettings, options, resetOptions } from './optionsStorage'
import Button from './react/Button'
import { OptionMeta, OptionSlider } from './react/OptionsItems'
import Slider from './react/Slider'
import { getScreenRefreshRate } from './utils'
import { setLoadingScreenStatus } from './appStatus'
import { openFilePicker, resetLocalStorageWithoutWorld } from './browserfs'
import { openFilePicker, resetLocalStorage } from './browserfs'
import { completeResourcepackPackInstall, getResourcePackNames, resourcePackState, uninstallResourcePack } from './resourcePack'
import { downloadPacketsReplay, packetsRecordingState } from './packetsReplay/packetsReplayLegacy'
import { showOptionsModal } from './react/SelectOption'
import { showInputsModal, showOptionsModal } from './react/SelectOption'
import supportedVersions from './supportedVersions.mjs'
import { getVersionAutoSelect } from './connect'
import { createNotificationProgressReporter } from './core/progressReporter'
import { customKeymaps } from './controls'
import { appStorage } from './react/appStorageProvider'
export const guiOptionsScheme: {
[t in OptionsGroupType]: Array<{ [K in keyof AppOptions]?: Partial<OptionMeta<AppOptions[K]>> } & { custom? }>
@ -450,9 +453,19 @@ export const guiOptionsScheme: {
return <Button
inScreen
onClick={() => {
if (confirm('Are you sure you want to reset all settings?')) resetLocalStorageWithoutWorld()
if (confirm('Are you sure you want to reset all settings?')) resetOptions()
}}
>Reset all settings</Button>
>Reset settings</Button>
},
},
{
custom () {
return <Button
inScreen
onClick={() => {
if (confirm('Are you sure you want to remove all data (settings, keybindings, servers, username, auth, proxies)?')) resetLocalStorage()
}}
>Remove all data</Button>
},
},
{
@ -460,6 +473,11 @@ export const guiOptionsScheme: {
return <Category>Developer</Category>
},
},
{
custom () {
return <Button label='Export/Import...' onClick={() => openOptionsMenu('export-import')} inScreen />
}
},
{
custom () {
const { active } = useSnapshot(packetsRecordingState)
@ -495,7 +513,7 @@ export const guiOptionsScheme: {
{
custom () {
const { serversAutoVersionSelect } = useSnapshot(options)
const allVersions = [...supportedVersions, 'latest', 'auto']
const allVersions = [...[...supportedVersions].sort((a, b) => versionToNumber(a) - versionToNumber(b)), 'latest', 'auto']
const currentIndex = allVersions.indexOf(serversAutoVersionSelect)
const getDisplayValue = (version: string) => {
@ -508,10 +526,11 @@ export const guiOptionsScheme: {
return <div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Slider
style={{ width: 150 }}
label='Server Version'
label='Prefer Server Version'
value={currentIndex}
min={0}
max={allVersions.length - 1}
unit=''
valueDisplay={getDisplayValue(serversAutoVersionSelect)}
updateValue={(newVal) => {
options.serversAutoVersionSelect = allVersions[newVal]
@ -521,8 +540,94 @@ export const guiOptionsScheme: {
},
},
],
'export-import': [
{
custom () {
return <Category>Export/Import Data</Category>
}
},
{
custom () {
return <Button
inScreen
disabled={true}
onClick={() => {}}
>Import Data</Button>
}
},
{
custom () {
return <Button
inScreen
onClick={async () => {
const data = await showInputsModal('Export Profile', {
profileName: {
type: 'text',
},
exportSettings: {
type: 'checkbox',
defaultValue: true,
},
exportKeybindings: {
type: 'checkbox',
defaultValue: true,
},
exportServers: {
type: 'checkbox',
defaultValue: true,
},
saveUsernameAndProxy: {
type: 'checkbox',
defaultValue: true,
},
})
const fileName = `${data.profileName ? `${data.profileName}-` : ''}web-client-profile.json`
const json = {
_about: 'Minecraft Web Client (mcraft.fun) Profile',
...data.exportSettings ? {
options: getChangedSettings(),
} : {},
...data.exportKeybindings ? {
keybindings: customKeymaps,
} : {},
...data.exportServers ? {
servers: appStorage.serversList,
} : {},
...data.saveUsernameAndProxy ? {
username: appStorage.username,
proxy: appStorage.proxiesData?.selected,
} : {},
}
const blob = new Blob([JSON.stringify(json, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
}}
>Export Data</Button>
}
},
{
custom () {
return <Button
inScreen
disabled
>Export Worlds</Button>
}
},
{
custom () {
return <Button
inScreen
disabled
>Export Resource Pack</Button>
}
}
],
}
export type OptionsGroupType = 'main' | 'render' | 'interface' | 'controls' | 'sound' | 'advanced' | 'VR'
export type OptionsGroupType = 'main' | 'render' | 'interface' | 'controls' | 'sound' | 'advanced' | 'VR' | 'export-import'
const Category = ({ children }) => <div style={{
fontSize: 9,

View file

@ -1,11 +1,9 @@
// todo implement async options storage
import { proxy, subscribe } from 'valtio/vanilla'
// weird webpack configuration bug: it cant import valtio/utils in this file
import { subscribeKey } from 'valtio/utils'
import { omitObj } from '@zardoy/utils'
import { appQueryParamsArray } from './appParams'
import type { AppConfig } from './appConfig'
import { appStorage } from './react/appStorageProvider'
const isDev = process.env.NODE_ENV === 'development'
const initialAppConfig = process.env?.INLINED_APP_CONFIG as AppConfig ?? {}
@ -63,6 +61,7 @@ const defaultOptions = {
preciseMouseInput: false,
// todo ui setting, maybe enable by default?
waitForChunksRender: 'sp-only' as 'sp-only' | boolean,
jeiEnabled: true as boolean | Array<'creative' | 'survival' | 'adventure' | 'spectator'>,
// antiAliasing: false,
@ -163,12 +162,31 @@ const migrateOptions = (options: Partial<AppOptions & Record<string, any>>) => {
export type AppOptions = typeof defaultOptions
// when opening html file locally in browser, localStorage is shared between all ever opened html files, so we try to avoid conflicts
const localStorageKey = process.env.SINGLE_FILE_BUILD ? 'minecraftWebClientOptions' : 'options'
const isDeepEqual = (a: any, b: any): boolean => {
if (a === b) return true
if (typeof a !== typeof b) return false
if (typeof a !== 'object') return false
if (a === null || b === null) return a === b
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false
return a.every((item, index) => isDeepEqual(item, b[index]))
}
const keysA = Object.keys(a)
const keysB = Object.keys(b)
if (keysA.length !== keysB.length) return false
return keysA.every(key => isDeepEqual(a[key], b[key]))
}
export const getChangedSettings = () => {
return Object.fromEntries(
Object.entries(options).filter(([key, value]) => !isDeepEqual(defaultOptions[key], value))
)
}
export const options: AppOptions = proxy({
...defaultOptions,
...initialAppConfig.defaultSettings,
...migrateOptions(JSON.parse(localStorage[localStorageKey] || '{}')),
...migrateOptions(appStorage.options),
...qsOptions
})
@ -180,14 +198,14 @@ export const resetOptions = () => {
Object.defineProperty(window, 'debugChangedOptions', {
get () {
return Object.fromEntries(Object.entries(options).filter(([key, v]) => defaultOptions[key] !== v))
return getChangedSettings()
},
})
subscribe(options, () => {
// Don't save disabled settings to localStorage
const saveOptions = omitObj(options, [...disabledSettings.value] as any)
localStorage[localStorageKey] = JSON.stringify(saveOptions)
appStorage.options = saveOptions
})
type WatchValue = <T extends Record<string, any>>(proxy: T, callback: (p: T, isChanged: boolean) => void) => () => void

View file

@ -3,6 +3,7 @@ import { createServer, ServerClient } from 'minecraft-protocol'
import { ParsedReplayPacket, parseReplayContents } from 'mcraft-fun-mineflayer/build/packetsLogger'
import { PACKETS_REPLAY_FILE_EXTENSION, WORLD_STATE_FILE_EXTENSION } from 'mcraft-fun-mineflayer/build/worldState'
import MinecraftData from 'minecraft-data'
import { GameMode } from 'mineflayer'
import { LocalServer } from '../customServer'
import { UserError } from '../mineflayer/userError'
import { packetsReplayState } from '../react/state/packetsReplayState'
@ -188,6 +189,10 @@ const mainPacketsReplayer = async (client: ServerClient, packets: ParsedReplayPa
}
if (packet.isFromServer) {
if (packet.params === null) {
console.warn('packet.params is null', packet)
continue
}
playServerPacket(packet.name, packet.params)
await new Promise(resolve => {
setTimeout(resolve, packet.diff * packetsReplayState.speed + ADDITIONAL_DELAY * (packetsReplayState.customButtons.packetsSenderDelay.state ? 1 : 0))
@ -216,6 +221,7 @@ const mainPacketsReplayer = async (client: ServerClient, packets: ParsedReplayPa
setTimeout(resolve, 1000)
})] : [])
])
clientsPacketsWaiter.stopWaiting()
clientPackets = []
}
}
@ -226,6 +232,25 @@ const mainPacketsReplayer = async (client: ServerClient, packets: ParsedReplayPa
}
}
export const switchGameMode = (gameMode: GameMode) => {
const gamemodes = {
survival: 0,
creative: 1,
adventure: 2,
spectator: 3
}
if (gameMode === 'spectator') {
bot._client.emit('abilities', {
// can fly + is flying
flags: 6
})
}
bot._client.emit('game_state_change', {
reason: 3,
gameMode: gamemodes[gameMode]
})
}
interface PacketsWaiterOptions {
unexpectedPacketReceived?: (name: string, params: any) => void
expectedPacketReceived?: (name: string, params: any) => void
@ -236,6 +261,7 @@ interface PacketsWaiterOptions {
interface PacketsWaiter {
addPacket(name: string, params: any): void
waitForPackets(packets: string[]): Promise<void>
stopWaiting(): void
}
const createPacketsWaiter = (options: PacketsWaiterOptions = {}): PacketsWaiter => {
@ -296,6 +322,11 @@ const createPacketsWaiter = (options: PacketsWaiterOptions = {}): PacketsWaiter
isWaiting = false
packetHandler = null
}
},
stopWaiting () {
isWaiting = false
packetHandler = null
queuedPackets.length = 0
}
}
}

View file

@ -13,7 +13,7 @@ import { miscUiState } from './globalState'
import { loadMinecraftData } from './connect'
let panoramaCubeMap
let shouldDisplayPanorama = false
let shouldDisplayPanorama = true
const panoramaFiles = [
'panorama_3.png', // right (+x)
@ -34,13 +34,12 @@ export async function addPanoramaCubeMap () {
setTimeout(resolve, 0) // wait for viewer to be initialized
})
viewer.camera.fov = 85
if (!shouldDisplayPanorama) return
if (process.env.SINGLE_FILE_BUILD_MODE) {
void initDemoWorld()
return
}
shouldDisplayPanorama = true
let time = 0
viewer.camera.near = 0.05
viewer.camera.updateProjectionMatrix()
@ -102,12 +101,16 @@ export async function addPanoramaCubeMap () {
panoramaCubeMap = group
}
subscribeKey(miscUiState, 'fsReady', () => {
if (miscUiState.fsReady) {
// don't do it earlier to load fs and display menu faster
void addPanoramaCubeMap()
}
})
if (process.env.SINGLE_FILE_BUILD_MODE) {
subscribeKey(miscUiState, 'fsReady', () => {
if (miscUiState.fsReady) {
// don't do it earlier to load fs and display menu faster
void addPanoramaCubeMap()
}
})
} else {
void addPanoramaCubeMap()
}
export function removePanorama () {
for (const unloadPanoramaCallback of unloadPanoramaCallbacks) {

View file

@ -3,7 +3,7 @@ import { appQueryParams } from '../appParams'
import { fetchServerStatus, isServerValid } from '../api/mcStatusApi'
import { parseServerAddress } from '../parseServerAddress'
import Screen from './Screen'
import Input from './Input'
import Input, { INPUT_LABEL_WIDTH, InputWithLabel } from './Input'
import Button from './Button'
import SelectGameVersion from './SelectGameVersion'
import { usePassesScaledDimensions } from './UIProvider'
@ -32,8 +32,6 @@ interface Props {
allowAutoConnect?: boolean
}
const ELEMENTS_WIDTH = 190
export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQs, onQsConnect, placeholders, accounts, versions, allowAutoConnect }: Props) => {
const isSmallHeight = !usePassesScaledDimensions(null, 350)
const qsParamName = parseQs ? appQueryParams.name : undefined
@ -159,6 +157,7 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
<InputWithLabel
required
label="Server IP"
autoFocus={!lockConnect}
value={serverIp}
disabled={lockConnect && parsedQsIp.host !== null}
onChange={({ target: { value } }) => {
@ -256,20 +255,8 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
const ButtonWrapper = ({ ...props }: React.ComponentProps<typeof Button>) => {
props.style ??= {}
props.style.width = ELEMENTS_WIDTH
props.style.width = INPUT_LABEL_WIDTH
return <Button {...props} />
}
const InputWithLabel = ({ label, span, ...props }: React.ComponentProps<typeof Input> & { label, span? }) => {
return <div style={{
display: 'flex',
flexDirection: 'column',
gridRow: span ? 'span 2 / span 2' : undefined,
}}
>
<label style={{ fontSize: 12, marginBottom: 1, color: 'lightgray' }}>{label}</label>
<Input rootStyles={{ width: ELEMENTS_WIDTH }} {...props} />
</div>
}
const fallbackIfNotFound = (index: number) => (index === -1 ? undefined : index)

View file

@ -69,7 +69,9 @@ export default ({
>
{isError && (
<>
{showReconnect && onReconnect && <Button label="Reconnect" onClick={onReconnect} />}
{showReconnect && onReconnect && <Button onClick={onReconnect}>
<b>Reconnect</b>
</Button>}
{actionsSlot}
<Button
onClick={() => {
@ -79,8 +81,9 @@ export default ({
window.location.reload()
}
}}
label="Reset App (recommended)"
/>
>
<b>Reset App (recommended)</b>
</Button>
{!lockConnect && backAction && <Button label="Back" onClick={backAction} />}
</>
)}

View file

@ -28,9 +28,11 @@ export default () => {
addBossBar(bossBar as BossBarType)
})
bot.on('bossBarUpdated', (bossBar) => {
if (!bossBar) return
addBossBar(bossBar as BossBarType)
})
bot.on('bossBarDeleted', (bossBar) => {
if (!bossBar) return
removeBossBar(bossBar as BossBarType)
})
}, [])

View file

@ -29,6 +29,12 @@ div.chat-wrapper {
gap: 1px;
}
.chat-submit-button {
visibility: hidden;
position: absolute;
pointer-events: none !important;
}
.chat-input-wrapper form {
display: flex;
}
@ -84,11 +90,11 @@ div.chat-wrapper {
::-webkit-scrollbar {
width: 5px;
height: 5px;
background-color: rgb(24, 24, 24);
background-color: #272727;
}
::-webkit-scrollbar-thumb {
background-color: rgb(50, 50, 50);
background-color: #747474;
}
.chat-completions-items>div {
@ -154,20 +160,24 @@ input[type=text],
padding-bottom: 1px;
padding-left: 2px;
padding-right: 2px;
height: 15px;
}
.chat-mobile-hidden {
width: 8px;
height: 0;
.chat-mobile-input-hidden {
position: absolute;
width: 8px;
height: 1px !important;
display: block !important;
opacity: 0;
pointer-events: none;
height: 1px !important;
/* ios: using z-index, pointer-events: none or top below -10px breaks arrows */
}
.chat-mobile-hidden:nth-last-child(1) {
height: 8px;
.chat-mobile-input-hidden-up {
top: -10px;
}
.chat-mobile-input-hidden-down {
top: -5px;
}
#chatinput:focus {

View file

@ -71,6 +71,8 @@ export default ({
}: Props) => {
const sendHistoryRef = useRef(JSON.parse(window.sessionStorage.chatHistory || '[]'))
const [isInputFocused, setIsInputFocused] = useState(false)
// const [spellCheckEnabled, setSpellCheckEnabled] = useState(false)
const spellCheckEnabled = false
const [completePadText, setCompletePadText] = useState('')
const completeRequestValue = useRef('')
@ -107,9 +109,28 @@ export default ({
}, 0)
}
const auxInputFocus = (fireKey: string) => {
const handleArrowUp = () => {
if (chatHistoryPos.current === 0) return
if (chatHistoryPos.current === sendHistoryRef.current.length) { // started navigating history
inputCurrentlyEnteredValue.current = chatInput.current.value
}
chatHistoryPos.current--
updateInputValue(sendHistoryRef.current[chatHistoryPos.current] || '')
}
const handleArrowDown = () => {
if (chatHistoryPos.current === sendHistoryRef.current.length) return
chatHistoryPos.current++
updateInputValue(sendHistoryRef.current[chatHistoryPos.current] || inputCurrentlyEnteredValue.current || '')
}
const auxInputFocus = (direction: 'up' | 'down') => {
chatInput.current.focus()
chatInput.current.dispatchEvent(new KeyboardEvent('keydown', { code: fireKey, bubbles: true }))
if (direction === 'up') {
handleArrowUp()
} else {
handleArrowDown()
}
}
useEffect(() => {
@ -125,6 +146,7 @@ export default ({
if (opened) {
updateInputValue(chatInputValueGlobal.value)
chatInputValueGlobal.value = ''
chatHistoryPos.current = sendHistoryRef.current.length
if (!usingTouch) {
chatInput.current.focus()
}
@ -167,6 +189,8 @@ export default ({
const onMainInputChange = () => {
const completeValue = getCompleteValue()
setCompletePadText(completeValue === '/' ? '' : completeValue)
// not sure if enabling would be useful at all (maybe make as a setting in the future?)
// setSpellCheckEnabled(!chatInput.current.value.startsWith('/'))
if (completeRequestValue.current === completeValue) {
updateFilteredCompleteItems(completionItemsSource)
return
@ -271,20 +295,23 @@ export default ({
{isIos && <input
value=''
type="text"
className="chat-mobile-hidden"
className="chat-mobile-input-hidden chat-mobile-input-hidden-up"
id="chatinput-next-command"
spellCheck={false}
autoComplete="off"
onFocus={() => auxInputFocus('ArrowUp')}
onFocus={() => auxInputFocus('up')}
onChange={() => { }}
/>}
<input
defaultValue=''
// ios doesn't support toggling autoCorrect on the fly so we need to re-create the input
key={spellCheckEnabled ? 'true' : 'false'}
ref={chatInput}
type="text"
className="chat-input"
id="chatinput"
spellCheck={false}
spellCheck={spellCheckEnabled}
autoCorrect={spellCheckEnabled ? 'on' : 'off'}
autoComplete="off"
aria-autocomplete="both"
onChange={onMainInputChange}
@ -294,16 +321,9 @@ export default ({
onBlur={() => setIsInputFocused(false)}
onKeyDown={(e) => {
if (e.code === 'ArrowUp') {
if (chatHistoryPos.current === 0) return
if (chatHistoryPos.current === sendHistoryRef.current.length) { // started navigating history
inputCurrentlyEnteredValue.current = e.currentTarget.value
}
chatHistoryPos.current--
updateInputValue(sendHistoryRef.current[chatHistoryPos.current] || '')
handleArrowUp()
} else if (e.code === 'ArrowDown') {
if (chatHistoryPos.current === sendHistoryRef.current.length) return
chatHistoryPos.current++
updateInputValue(sendHistoryRef.current[chatHistoryPos.current] || inputCurrentlyEnteredValue.current || '')
handleArrowDown()
}
if (e.code === 'Tab') {
if (completionItemsSource.length) {
@ -327,15 +347,15 @@ export default ({
{isIos && <input
value=''
type="text"
className="chat-mobile-hidden"
className="chat-mobile-input-hidden chat-mobile-input-hidden-down"
id="chatinput-prev-command"
spellCheck={false}
autoComplete="off"
onFocus={() => auxInputFocus('ArrowDown')}
onFocus={() => auxInputFocus('down')}
onChange={() => { }}
/>}
{/* for some reason this is needed to make Enter work on android chrome */}
<button type='submit' style={{ visibility: 'hidden' }} />
<button type='submit' className="chat-submit-button" />
</form>
</div>
</div>

View file

@ -88,7 +88,7 @@ export default () => {
// normalize
items = items.map(item => `/${item}`)
}
if (localServer) {
if (items.length) {
items = [...items, ...getBuiltinCommandsList()]
}
}

View file

@ -90,7 +90,7 @@ export default () => {
upArmour()
}, [])
return <div>
return <div className='hud-bars-container'>
<HealthBar
gameMode={gameMode}
isHardcore={isHardcore}

View file

@ -10,7 +10,7 @@ interface Props extends Omit<React.ComponentProps<'input'>, 'width'> {
width?: number
}
export default ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue, width, ...inputProps }: Props) => {
const Input = ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue, width, ...inputProps }: Props) => {
if (width) rootStyles = { ...rootStyles, width }
const ref = useRef<HTMLInputElement>(null!)
@ -51,3 +51,19 @@ export default ({ autoFocus, rootStyles, inputRef, validateInput, defaultValue,
/>
</div>
}
export default Input
export const INPUT_LABEL_WIDTH = 190
export const InputWithLabel = ({ label, span, ...props }: React.ComponentProps<typeof Input> & { label, span? }) => {
return <div style={{
display: 'flex',
flexDirection: 'column',
gridRow: span ? 'span 2 / span 2' : undefined,
}}
>
<label style={{ fontSize: 12, marginBottom: 1, color: 'lightgray' }}>{label}</label>
<Input rootStyles={{ width: INPUT_LABEL_WIDTH }} {...props} />
</div>
}

View file

@ -7,6 +7,7 @@ import Button from './Button'
import ButtonWithTooltip from './ButtonWithTooltip'
import { pixelartIcons } from './PixelartIcon'
import useLongPress from './useLongPress'
import PauseLinkButtons from './PauseLinkButtons'
type Action = (e: React.MouseEvent<HTMLButtonElement>) => void
@ -15,7 +16,6 @@ interface Props {
singleplayerAction?: Action
optionsAction?: Action
githubAction?: Action
linksButton?: JSX.Element
openFileAction?: Action
mapsProvider?: string
versionStatus?: string
@ -35,7 +35,6 @@ export default ({
singleplayerAction,
optionsAction,
githubAction,
linksButton,
openFileAction,
versionText,
onVersionTextClick,
@ -62,8 +61,9 @@ export default ({
const versionLongPress = useLongPress(
() => {
const buildDate = process.env.BUILD_VERSION ? new Date(process.env.BUILD_VERSION) : null
alert(`BUILD INFO:\n${buildDate?.toLocaleString() || 'Development build'}`)
const buildDate = process.env.BUILD_VERSION ? new Date(process.env.BUILD_VERSION + ':00:00.000Z') : null
const hoursAgo = buildDate ? Math.round((Date.now() - buildDate.getTime()) / (1000 * 60 * 60)) : null
alert(`BUILD DATE:\n${buildDate?.toLocaleString() || 'Development build'}${hoursAgo ? `\nBuilt ${hoursAgo} hours ago` : ''}`)
},
() => onVersionTextClick?.(),
)
@ -143,16 +143,7 @@ export default ({
Options
</Button>
<div className={styles['menu-row']}>
<ButtonWithTooltip
initialTooltip={{
content: 'Report bugs or request features!',
}}
style={{ width: '98px' }}
onClick={githubAction}
>
GitHub
</ButtonWithTooltip>
{linksButton}
<PauseLinkButtons />
</div>
</div>

View file

@ -8,7 +8,6 @@ import { setLoadingScreenStatus } from '../appStatus'
import { openFilePicker, copyFilesAsync, mkdirRecursive, openWorldDirectory, removeFileRecursiveAsync } from '../browserfs'
import MainMenu from './MainMenu'
import { DiscordButton } from './DiscordButton'
const isMainMenu = () => {
return activeModalStack.length === 0 && !miscUiState.gameLoaded
@ -123,29 +122,10 @@ export default () => {
singleplayerAvailable={singleplayerAvailable}
connectToServerAction={() => showModal({ reactType: 'serversList' })}
singleplayerAction={async () => {
const oldFormatSave = fs.existsSync('./world/level.dat')
if (oldFormatSave) {
setLoadingScreenStatus('Migrating old save, don\'t close the page')
try {
await mkdirRecursive('/data/worlds/local')
await copyFilesAsync('/world/', '/data/worlds/local')
try {
await removeFileRecursiveAsync('/world/')
} catch (err) {
console.error(err)
}
} catch (err) {
console.warn(err)
alert('Failed to migrate world from localStorage')
} finally {
setLoadingScreenStatus(undefined)
}
}
showModal({ reactType: 'singleplayer' })
}}
githubAction={() => openGithub()}
optionsAction={() => openOptionsMenu('main')}
linksButton={<DiscordButton />}
bottomRightLinks={process.env.MAIN_MENU_LINKS}
openFileAction={e => {
if (!!window.showDirectoryPicker && !e.shiftKey) {

View file

@ -22,7 +22,7 @@ export default function PacketsReplayProvider () {
return (
<ReplayPanel
style={{
transform: 'scale(0.5)',
transform: 'scale(0.4)',
transformOrigin: 'top right'
}}
replayName={state.replayName}

View file

@ -0,0 +1,50 @@
import { useSnapshot } from 'valtio'
import { openURL } from 'renderer/viewer/lib/simpleUtils'
import { ErrorBoundary } from '@zardoy/react-util'
import { miscUiState } from '../globalState'
import { openGithub } from '../utils'
import Button from './Button'
import { DiscordButton } from './DiscordButton'
import styles from './PauseScreen.module.css'
function PauseLinkButtonsInner () {
const { appConfig } = useSnapshot(miscUiState)
const pauseLinksConfig = appConfig?.pauseLinks
if (!pauseLinksConfig) return null
const renderButton = (button: Record<string, any>, style: React.CSSProperties, key: number) => {
if (button.type === 'discord') {
return <DiscordButton key={key} style={style} text={button.text}/>
}
if (button.type === 'github') {
return <Button key={key} className="button" style={style} onClick={() => openGithub()}>{button.text ?? 'GitHub'}</Button>
}
if (button.type === 'url' && button.text) {
return <Button key={key} className="button" style={style} onClick={() => openURL(button.url)}>{button.text}</Button>
}
return null
}
return (
<>
{pauseLinksConfig.map((row, i) => {
const style = { width: (204 / row.length - (row.length > 1 ? 4 : 0)) + 'px' }
return (
<div key={i} className={styles.row}>
{row.map((button, k) => renderButton(button, style, k))}
</div>
)
})}
</>
)
}
export default () => {
return <ErrorBoundary renderError={(error) => {
console.error(error)
return null
}}>
<PauseLinkButtonsInner />
</ErrorBoundary>
}

View file

@ -34,6 +34,8 @@ import { DiscordButton } from './DiscordButton'
import { showNotification } from './NotificationProvider'
import { appStatusState, reconnectReload } from './AppStatusProvider'
import NetworkStatus from './NetworkStatus'
import PauseLinkButtons from './PauseLinkButtons'
import { pixelartIcons } from './PixelartIcon'
const waitForPotentialRender = async () => {
return new Promise<void>(resolve => {
@ -160,7 +162,7 @@ export default () => {
const { singleplayer, wanOpened, wanOpening } = useSnapshot(miscUiState)
const { noConnection } = useSnapshot(gameAdditionalState)
const { active: packetsReplaceActive, hasRecordedPackets: packetsReplaceHasRecordedPackets } = useSnapshot(packetsRecordingState)
const { displayRecordButton } = useSnapshot(options)
const { displayRecordButton: displayPacketsButtons } = useSnapshot(options)
const handlePointerLockChange = () => {
if (!pointerLock.hasPointerLock && activeModalStack.length === 0) {
@ -227,33 +229,13 @@ export default () => {
if (!isModalActive) return null
const pauseLinks: React.ReactNode[] = []
const pauseLinksConfig = miscUiState.appConfig?.pauseLinks
if (pauseLinksConfig) {
for (const [i, row] of pauseLinksConfig.entries()) {
const rowButtons: React.ReactNode[] = []
for (const [k, button] of row.entries()) {
const key = `${i}-${k}`
const style = { width: (204 / row.length - (row.length > 1 ? 4 : 0)) + 'px' }
if (button.type === 'discord') {
rowButtons.push(<DiscordButton key={key} style={style} text={button.text}/>)
} else if (button.type === 'github') {
rowButtons.push(<Button key={key} className="button" style={style} onClick={() => openGithub()}>{button.text ?? 'GitHub'}</Button>)
} else if (button.type === 'url' && button.text) {
rowButtons.push(<Button key={key} className="button" style={style} onClick={() => openURL(button.url)}>{button.text}</Button>)
}
}
pauseLinks.push(<div className={styles.row}>{rowButtons}</div>)
}
}
return <Screen title='Game Menu'>
<div style={{ position: 'fixed', top: '5px', left: 'calc(env(safe-area-inset-left) + 5px)', display: 'flex', flexDirection: 'column', gap: '5px' }}>
<Button
icon="pixelarticons:folder"
onClick={async () => openWorldActions()}
/>
{displayRecordButton && (
{displayPacketsButtons && (
<>
<Button
icon={packetsReplaceActive ? 'pixelarticons:debug-stop' : 'pixelarticons:circle'}
@ -263,10 +245,14 @@ export default () => {
/>
{packetsReplaceHasRecordedPackets && (
<Button
icon="pixelarticons:download"
icon={pixelartIcons['briefcase-download']}
onClick={async () => downloadPacketsReplay()}
/>
)}
<Button
icon={pixelartIcons['download']}
onClick={async () => bot.downloadCurrentWorldState()}
/>
</>
)}
</div>
@ -277,7 +263,7 @@ export default () => {
</ErrorBoundary>
<div className={styles.pause_container}>
<Button className="button" style={{ width: '204px' }} onClick={onReturnPress}>Back to Game</Button>
{pauseLinks}
<PauseLinkButtons />
<Button className="button" style={{ width: '204px' }} onClick={() => openOptionsMenu('main')}>Options</Button>
{singleplayer ? (
<div className={styles.row}>

View file

@ -1,9 +1,11 @@
import { useState, useEffect } from 'react'
import { useSnapshot } from 'valtio'
import { filterPackets } from './packetsFilter'
import { DARK_COLORS } from './components/replay/constants'
import FilterInput from './components/replay/FilterInput'
import PacketList from './components/replay/PacketList'
import ProgressBar from './components/replay/ProgressBar'
import { packetsReplayState } from './state/packetsReplayState'
interface Props {
replayName: string
@ -41,7 +43,7 @@ export default function ReplayPanel ({
style
}: Props) {
const [filter, setFilter] = useState(defaultFilter)
const [isMinimized, setIsMinimized] = useState(false)
const { isMinimized } = useSnapshot(packetsReplayState)
const { filtered: filteredPackets, hiddenCount } = filterPackets(packets.slice(-500), filter)
useEffect(() => {
@ -50,7 +52,7 @@ export default function ReplayPanel ({
const handlePlayPauseClick = () => {
if (isMinimized) {
setIsMinimized(false)
packetsReplayState.isMinimized = false
} else {
onPlayPause?.(!isPlaying)
}
@ -113,7 +115,7 @@ export default function ReplayPanel ({
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ fontSize: '12px', fontWeight: 'bold' }}>{replayName || 'Unnamed Replay'}</div>
<button
onClick={() => setIsMinimized(true)}
onClick={() => { packetsReplayState.isMinimized = true }}
style={{
background: 'none',
border: 'none',

View file

@ -1,10 +1,14 @@
import { proxy, useSnapshot } from 'valtio'
import { useEffect, useRef } from 'react'
import { noCase } from 'change-case'
import { titleCase } from 'title-case'
import { hideCurrentModal, showModal } from '../globalState'
import { parseFormattedMessagePacket } from '../botUtils'
import Screen from './Screen'
import { useIsModalActive } from './utilsApp'
import Button from './Button'
import MessageFormattedString from './MessageFormattedString'
import Input, { InputWithLabel } from './Input'
const state = proxy({
title: '',
@ -12,6 +16,8 @@ const state = proxy({
showCancel: true,
minecraftJsonMessage: null as null | Record<string, any>,
behavior: 'resolve-close' as 'resolve-close' | 'close-resolve',
inputs: {} as Record<string, InputOption>,
inputsConfirmButton: ''
})
let resolve
@ -35,17 +41,63 @@ export const showOptionsModal = async <T extends string> (
title,
options,
showCancel: cancel,
minecraftJsonMessage: minecraftJsonMessageParsed
minecraftJsonMessage: minecraftJsonMessageParsed,
inputs: {},
inputsConfirmButton: ''
})
})
}
type InputOption = {
type: 'text' | 'checkbox'
defaultValue?: string | boolean
label?: string
}
export const showInputsModal = async <T extends Record<string, InputOption>>(
title: string,
inputs: T,
{ cancel = true, minecraftJsonMessage }: { cancel?: boolean, minecraftJsonMessage? } = {}
): Promise<{
[K in keyof T]: T[K] extends { type: 'text' }
? string
: T[K] extends { type: 'checkbox' }
? boolean
: never
}> => {
showModal({ reactType: 'general-select' })
let minecraftJsonMessageParsed
if (minecraftJsonMessage) {
const parseResult = parseFormattedMessagePacket(minecraftJsonMessage)
minecraftJsonMessageParsed = parseResult.formatted
if (parseResult.plain) {
title += ` (${parseResult.plain})`
}
}
return new Promise((_resolve) => {
resolve = _resolve
Object.assign(state, {
title,
inputs,
showCancel: cancel,
minecraftJsonMessage: minecraftJsonMessageParsed,
options: [],
inputsConfirmButton: 'Confirm'
})
})
}
export default () => {
const { title, options, showCancel, minecraftJsonMessage } = useSnapshot(state)
const { title, options, showCancel, minecraftJsonMessage, inputs, inputsConfirmButton } = useSnapshot(state)
const isModalActive = useIsModalActive('general-select')
const inputValues = useRef({})
useEffect(() => {
inputValues.current = Object.fromEntries(Object.entries(inputs).map(([key, input]) => [key, input.defaultValue ?? (input.type === 'checkbox' ? false : '')]))
}, [inputs])
if (!isModalActive) return
const resolveClose = (value: string | undefined) => {
const resolveClose = (value: any) => {
if (state.behavior === 'resolve-close') {
resolve(value)
hideCurrentModal()
@ -59,17 +111,66 @@ export default () => {
{minecraftJsonMessage && <div style={{ textAlign: 'center', }}>
<MessageFormattedString message={minecraftJsonMessage} />
</div>}
{options.map(option => <Button
key={option} onClick={() => {
resolveClose(option)
}}
>{option}
</Button>)}
{showCancel && <Button
style={{ marginTop: 30 }} onClick={() => {
resolveClose(undefined)
}}
>Cancel
</Button>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 9 }}>
{options.length > 0 && <div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
{options.map(option => <Button
key={option} onClick={() => {
resolveClose(option)
}}
>{option}
</Button>)}
</div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{Object.entries(inputs).map(([key, input]) => {
const label = input.label ?? titleCase(noCase(key))
return <div key={key}>
{input.type === 'text' && (
<InputWithLabel
label={label}
autoFocus
type='text'
defaultValue={input.defaultValue as string}
onChange={(e) => {
inputValues.current[key] = e.target.value
}}
/>
)}
{input.type === 'checkbox' && (
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12 }}>
<input
type='checkbox'
style={{ marginBottom: -1, }}
defaultChecked={input.defaultValue as boolean}
onChange={(e) => {
inputValues.current[key] = e.target.checked
}}
/>
{label}
</label>
)}
</div>
})}
</div>
{inputs && inputsConfirmButton && (
<Button
// style={{ marginTop: 30 }}
onClick={() => {
resolveClose(inputValues.current)
}}
>
{inputsConfirmButton}
</Button>
)}
{showCancel && (
<Button
// style={{ marginTop: 30 }}
onClick={() => {
resolveClose(undefined)
}}
>
Cancel
</Button>
)}
</div>
</Screen>
}

View file

@ -1,64 +1,61 @@
import React from 'react'
import React, { useMemo } from 'react'
import { useSnapshot } from 'valtio'
import { miscUiState } from '../globalState'
import { appQueryParams } from '../appParams'
import Singleplayer from './Singleplayer'
import Input from './Input'
import Button from './Button'
import PixelartIcon, { pixelartIcons } from './PixelartIcon'
import Select from './Select'
import { BaseServerInfo } from './AddServerOrConnect'
import { useIsSmallWidth } from './simpleHooks'
import { appStorage, SavedProxiesData, ServerHistoryEntry } from './appStorageProvider'
const getInitialProxies = () => {
const proxies = [] as string[]
if (miscUiState.appConfig?.defaultProxy) {
proxies.push(miscUiState.appConfig.defaultProxy)
}
return proxies
}
export const getCurrentProxy = (): string | undefined => {
return appQueryParams.proxy ?? appStorage.proxiesData?.selected ?? getInitialProxies()[0]
}
export const getCurrentUsername = () => {
return appQueryParams.username ?? appStorage.username
}
interface Props extends React.ComponentProps<typeof Singleplayer> {
joinServer: (info: BaseServerInfo | string, additional: {
shouldSave?: boolean
index?: number
}) => void
initialProxies: SavedProxiesLocalStorage
updateProxies: (proxies: SavedProxiesLocalStorage) => void
username: string
setUsername: (username: string) => void
onProfileClick?: () => void
setQuickConnectIp?: (ip: string) => void
serverHistory?: Array<{
ip: string
versionOverride?: string
numConnects: number
}>
}
export interface SavedProxiesLocalStorage {
proxies: readonly string[]
selected: string
}
type ProxyStatusResult = {
time: number
ping: number
status: 'success' | 'error' | 'unknown'
}
export default ({
initialProxies,
updateProxies: updateProxiesProp,
joinServer,
username,
setUsername,
onProfileClick,
setQuickConnectIp,
serverHistory,
...props
}: Props) => {
const [proxies, setProxies] = React.useState(initialProxies)
const updateProxies = (newData: SavedProxiesLocalStorage) => {
setProxies(newData)
updateProxiesProp(newData)
}
const snap = useSnapshot(appStorage)
const username = useMemo(() => getCurrentUsername(), [appQueryParams.username, appStorage.username])
const [serverIp, setServerIp] = React.useState('')
const [save, setSave] = React.useState(true)
const [activeHighlight, setActiveHighlight] = React.useState(undefined as 'quick-connect' | 'server-list' | undefined)
const updateProxies = (newData: SavedProxiesData) => {
appStorage.proxiesData = newData
}
const setUsername = (username: string) => {
appStorage.username = username
}
const getActiveHighlightStyles = (type: typeof activeHighlight) => {
const styles: React.CSSProperties = {
transition: 'filter 0.2s',
@ -71,6 +68,8 @@ export default ({
const isSmallWidth = useIsSmallWidth()
const initialProxies = getInitialProxies()
const proxiesData = snap.proxiesData ?? { proxies: initialProxies, selected: initialProxies[0] }
return <Singleplayer
{...props}
firstRowChildrenOverride={<form
@ -85,7 +84,6 @@ export default ({
onMouseEnter={() => setActiveHighlight('quick-connect')}
onMouseLeave={() => setActiveHighlight(undefined)}
>
{/* todo history */}
<Input
required
placeholder='Quick Connect IP (:version)'
@ -102,8 +100,8 @@ export default ({
spellCheck="false"
/>
<datalist id="server-history">
{serverHistory?.map((server) => (
<option key={server.ip} value={`${server.ip}${server.versionOverride ? `:${server.versionOverride}` : ''}`} />
{[...(snap.serversHistory ?? [])].sort((a, b) => b.numConnects - a.numConnects).map((server) => (
<option key={server.ip} value={`${server.ip}${server.version ? `:${server.version}` : ''}`} />
))}
</datalist>
<label style={{ fontSize: 10, display: 'flex', alignItems: 'center', gap: 5, height: '100%', marginTop: '-1px' }}>
@ -126,10 +124,10 @@ export default ({
? <PixelartIcon iconName={pixelartIcons.server} styles={{ fontSize: 14, color: 'lightgray', marginLeft: 2 }} onClick={onProfileClick} />
: <span style={{ color: 'lightgray', fontSize: 14 }}>Proxy:</span>}
<Select
initialOptions={proxies.proxies.map(p => { return { value: p, label: p } })}
defaultValue={{ value: proxies.selected, label: proxies.selected }}
initialOptions={proxiesData.proxies.map(p => { return { value: p, label: p } })}
defaultValue={{ value: proxiesData.selected, label: proxiesData.selected }}
updateOptions={(newSel) => {
updateProxies({ proxies: [...proxies.proxies], selected: newSel })
updateProxies({ proxies: [...proxiesData.proxies], selected: newSel })
}}
containerStyle={{
width: isSmallWidth ? 140 : 180,
@ -139,6 +137,7 @@ export default ({
<Input
rootStyles={{ width: 80 }}
value={username}
disabled={appQueryParams.username !== undefined}
onChange={({ target: { value } }) => setUsername(value)}
autoCorrect="off"
autoCapitalize="off"

View file

@ -2,19 +2,24 @@ import { useEffect, useMemo, useState } from 'react'
import { useUtilsEffect } from '@zardoy/react-util'
import { useSnapshot } from 'valtio'
import { ConnectOptions } from '../connect'
import { activeModalStack, hideCurrentModal, miscUiState, showModal } from '../globalState'
import { activeModalStack, hideCurrentModal, miscUiState, notHideableModalsWithoutForce, showModal } from '../globalState'
import supportedVersions from '../supportedVersions.mjs'
import { appQueryParams } from '../appParams'
import { fetchServerStatus, isServerValid } from '../api/mcStatusApi'
import { getServerInfo } from '../mineflayer/mc-protocol'
import { parseServerAddress } from '../parseServerAddress'
import ServersList from './ServersList'
import ServersList, { getCurrentProxy, getCurrentUsername } from './ServersList'
import AddServerOrConnect, { BaseServerInfo } from './AddServerOrConnect'
import { useDidUpdateEffect } from './utils'
import { useIsModalActive } from './utilsApp'
import { showOptionsModal } from './SelectOption'
import { useCopyKeybinding } from './simpleHooks'
import { AuthenticatedAccount, getInitialServersList, getServerConnectionHistory, setNewServersList, StoreServerItem } from './serversStorage'
import { AuthenticatedAccount, getInitialServersList, getServerConnectionHistory, setNewServersList } from './serversStorage'
import { appStorage, StoreServerItem } from './appStorageProvider'
if (appQueryParams.lockConnect) {
notHideableModalsWithoutForce.add('editServer')
}
type AdditionalDisplayData = {
textNameRightGrayed: string
@ -24,21 +29,6 @@ type AdditionalDisplayData = {
offline?: boolean
}
const serversListQs = appQueryParams.serversList
const proxyQs = appQueryParams.proxy
const getInitialProxies = () => {
const proxies = [] as string[]
if (miscUiState.appConfig?.defaultProxy) {
proxies.push(miscUiState.appConfig.defaultProxy)
}
if (localStorage['proxy']) {
proxies.push(localStorage['proxy'])
localStorage.removeItem('proxy')
}
return proxies
}
// todo move to base
const normalizeIp = (ip: string) => ip.replace(/https?:\/\//, '').replace(/\/(:|$)/, '')
@ -46,45 +36,30 @@ const FETCH_DELAY = 100 // ms between each request
const MAX_CONCURRENT_REQUESTS = 10
const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersList?: string[] }) => {
const [proxies, setProxies] = useState<readonly string[]>(localStorage['proxies'] ? JSON.parse(localStorage['proxies']) : getInitialProxies())
const [selectedProxy, setSelectedProxy] = useState(proxyQs ?? localStorage.getItem('selectedProxy') ?? proxies?.[0] ?? '')
const [serverEditScreen, setServerEditScreen] = useState<StoreServerItem | true | null>(null) // true for add
const [defaultUsername, _setDefaultUsername] = useState(localStorage['username'] ?? (`mcrafter${Math.floor(Math.random() * 1000)}`))
const [authenticatedAccounts, _setAuthenticatedAccounts] = useState<AuthenticatedAccount[]>(JSON.parse(localStorage['authenticatedAccounts'] || '[]'))
const { authenticatedAccounts } = useSnapshot(appStorage)
const [quickConnectIp, setQuickConnectIp] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
// Save username to localStorage when component mounts if it doesn't exist
useEffect(() => {
if (!localStorage['username']) {
localStorage.setItem('username', defaultUsername)
}
}, [])
const { serversList: savedServersList } = useSnapshot(appStorage)
const setAuthenticatedAccounts = (newState: typeof authenticatedAccounts) => {
_setAuthenticatedAccounts(newState)
localStorage.setItem('authenticatedAccounts', JSON.stringify(newState))
}
const serversListDisplay = useMemo(() => {
return (
customServersList
? customServersList.map((row): StoreServerItem => {
const [ip, name] = row.split(' ')
const [_ip, _port, version] = ip.split(':')
return {
ip,
versionOverride: version,
name,
}
})
: [...getInitialServersList()]
)
}, [customServersList, savedServersList])
const setDefaultUsername = (newState: typeof defaultUsername) => {
_setDefaultUsername(newState)
localStorage.setItem('username', newState)
}
const saveNewProxy = () => {
if (!selectedProxy || proxyQs) return
localStorage.setItem('selectedProxy', selectedProxy)
}
useEffect(() => {
if (proxies.length) {
localStorage.setItem('proxies', JSON.stringify(proxies))
}
saveNewProxy()
}, [proxies])
const [serversList, setServersList] = useState<StoreServerItem[]>(() => (customServersList ? [] : getInitialServersList()))
const [additionalData, setAdditionalData] = useState<Record<string, AdditionalDisplayData>>({})
const [additionalServerData, setAdditionalServerData] = useState<Record<string, AdditionalDisplayData>>({})
// Add keyboard handler for moving servers
useEffect(() => {
@ -92,49 +67,31 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
if (['input', 'textarea', 'select'].includes((e.target as HTMLElement)?.tagName?.toLowerCase())) return
if (!e.shiftKey || selectedIndex === undefined) return
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
if (customServersList) return
e.preventDefault()
e.stopImmediatePropagation()
const newIndex = e.key === 'ArrowUp'
? Math.max(0, selectedIndex - 1)
: Math.min(serversList.length - 1, selectedIndex + 1)
: Math.min(serversListDisplay.length - 1, selectedIndex + 1)
if (newIndex === selectedIndex) return
// Move server in the list
const newList = [...serversList]
const newList = [...serversListDisplay]
const oldItem = newList[selectedIndex]
newList[selectedIndex] = newList[newIndex]
newList[newIndex] = oldItem
setServersList(newList)
appStorage.serversList = newList
setSelectedIndex(newIndex)
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [selectedIndex, serversList])
}, [selectedIndex, serversListDisplay])
useEffect(() => {
if (customServersList) {
setServersList(customServersList.map(row => {
const [ip, name] = row.split(' ')
const [_ip, _port, version] = ip.split(':')
return {
ip,
versionOverride: version,
name,
}
}))
}
}, [customServersList])
useDidUpdateEffect(() => {
// save data only on user changes
setNewServersList(serversList)
}, [serversList])
const serversListSorted = useMemo(() => serversList.map((server, index) => ({ ...server, index })), [serversList])
const serversListSorted = useMemo(() => serversListDisplay.map((server, index) => ({ ...server, index })), [serversListDisplay])
// by lastJoined
// const serversListSorted = useMemo(() => {
// return serversList.map((server, index) => ({ ...server, index })).sort((a, b) => (b.lastJoined ?? 0) - (a.lastJoined ?? 0))
@ -182,10 +139,10 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
offline: false
}
} else {
data = await fetchServerStatus(server.ip/* , signal */) // DONT ADD SIGNAL IT WILL CRUSH JS RUNTIME
data = await fetchServerStatus(server.ip, /* signal */undefined, server.versionOverride) // DONT ADD SIGNAL IT WILL CRUSH JS RUNTIME
}
if (data) {
setAdditionalData(old => ({
setAdditionalServerData(old => ({
...old,
[server.ip]: data
}))
@ -224,7 +181,7 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
}, [isEditScreenModal])
useCopyKeybinding(() => {
const item = serversList[selectedIndex]
const item = serversListDisplay[selectedIndex]
if (!item) return
let str = `${item.ip}`
if (item.versionOverride) {
@ -236,8 +193,8 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
const editModalJsx = isEditScreenModal ? <AddServerOrConnect
allowAutoConnect={miscUiState.appConfig?.allowAutoConnect}
placeholders={{
proxyOverride: selectedProxy,
usernameOverride: defaultUsername,
proxyOverride: getCurrentProxy(),
usernameOverride: getCurrentUsername(),
}}
parseQs={!serverEditScreen}
onBack={() => {
@ -247,12 +204,13 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
if (!serverEditScreen) return
if (serverEditScreen === true) {
const server: StoreServerItem = { ...info, lastJoined: Date.now() } // so it appears first
setServersList(old => [...old, server])
appStorage.serversList = [server, ...(appStorage.serversList ?? serversListDisplay)]
} else {
const index = serversList.indexOf(serverEditScreen)
const { lastJoined } = serversList[index]
serversList[index] = { ...info, lastJoined }
setServersList([...serversList])
const index = appStorage.serversList?.indexOf(serverEditScreen)
if (index !== undefined) {
const { lastJoined } = appStorage.serversList![index]
appStorage.serversList![index] = { ...info, lastJoined }
}
}
setServerEditScreen(null)
}}
@ -262,9 +220,9 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
} : serverEditScreen}
onQsConnect={(info) => {
const connectOptions: ConnectOptions = {
username: info.usernameOverride || defaultUsername,
username: info.usernameOverride || getCurrentUsername() || '',
server: normalizeIp(info.ip),
proxy: info.proxyOverride || selectedProxy,
proxy: info.proxyOverride || getCurrentProxy(),
botVersion: info.versionOverride,
ignoreQs: true,
}
@ -304,11 +262,11 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
}
const lastJoinedUsername = serversListSorted.find(s => s.usernameOverride)?.usernameOverride
let username = overrides.usernameOverride || defaultUsername
let username = overrides.usernameOverride || getCurrentUsername() || ''
if (!username) {
username = prompt('Username', lastJoinedUsername || '')
if (!username) return
setDefaultUsername(username)
const promptUsername = prompt('Enter username', lastJoinedUsername || '')
if (!promptUsername) return
username = promptUsername
}
let authenticatedAccount: AuthenticatedAccount | true | undefined
if (overrides.authenticatedAccountOverride) {
@ -321,15 +279,15 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
const options = {
username,
server: normalizeIp(ip),
proxy: overrides.proxyOverride || selectedProxy,
proxy: overrides.proxyOverride || getCurrentProxy(),
botVersion: overrides.versionOverride ?? /* legacy */ overrides['version'],
ignoreQs: true,
autoLoginPassword: server?.autoLogin?.[username],
authenticatedAccount,
saveServerToHistory: shouldSave,
onSuccessfulPlay () {
if (shouldSave && !serversList.some(s => s.ip === ip)) {
const newServersList: StoreServerItem[] = [...serversList, {
if (shouldSave && !serversListDisplay.some(s => s.ip === ip)) {
const newServersList: StoreServerItem[] = [...serversListDisplay, {
ip,
lastJoined: Date.now(),
versionOverride: overrides.versionOverride,
@ -341,10 +299,10 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
if (shouldSave === undefined) { // loading saved
// find and update
const server = serversList.find(s => s.ip === ip)
const server = serversListDisplay.find(s => s.ip === ip)
if (server) {
// move to top
const newList = [...serversList]
const newList = [...serversListDisplay]
const index = newList.indexOf(server)
const thisItem = newList[index]
newList.splice(index, 1)
@ -352,40 +310,31 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
server.lastJoined = Date.now()
server.numConnects = (server.numConnects || 0) + 1
setNewServersList(serversList)
setNewServersList(newList)
}
}
// save new selected proxy (if new)
if (!proxies.includes(selectedProxy)) {
// setProxies([...proxies, selectedProxy])
localStorage.setItem('proxies', JSON.stringify([...proxies, selectedProxy]))
}
saveNewProxy()
},
serverIndex: shouldSave ? serversList.length.toString() : indexOrIp // assume last
serverIndex: shouldSave ? serversListDisplay.length.toString() : indexOrIp // assume last
} satisfies ConnectOptions
dispatchEvent(new CustomEvent('connect', { detail: options }))
// qsOptions
}}
lockedEditing={!!customServersList}
username={defaultUsername}
setUsername={setDefaultUsername}
setQuickConnectIp={setQuickConnectIp}
onProfileClick={async () => {
const username = await showOptionsModal('Select authenticated account to remove', authenticatedAccounts.map(a => a.username))
if (!username) return
setAuthenticatedAccounts(authenticatedAccounts.filter(a => a.username !== username))
appStorage.authenticatedAccounts = authenticatedAccounts.filter(a => a.username !== username)
}}
onWorldAction={(action, index) => {
const server = serversList[index]
const server = serversListDisplay[index]
if (!server) return
if (action === 'edit') {
setServerEditScreen(server)
}
if (action === 'delete') {
setServersList(old => old.filter(s => s !== server))
appStorage.serversList = appStorage.serversList!.filter(s => s !== server)
}
}}
onGeneralAction={(action) => {
@ -397,7 +346,7 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
}
}}
worldData={serversListSorted.map(server => {
const additional = additionalData[server.ip]
const additional = additionalServerData[server.ip]
return {
name: server.index.toString(),
title: server.name || server.ip,
@ -407,30 +356,14 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
worldNameRightGrayed: additional?.textNameRightGrayed ?? '',
iconSrc: additional?.icon,
offline: additional?.offline,
group: 'Custom Servers'
group: customServersList ? 'Provided Servers' : 'Saved Servers'
}
})}
initialProxies={{
proxies,
selected: selectedProxy,
}}
updateProxies={({ proxies, selected }) => {
// new proxy is saved in joinServer
setProxies(proxies)
setSelectedProxy(selected)
}}
hidden={hidden}
onRowSelect={(_, i) => {
setSelectedIndex(i)
}}
selectedRow={selectedIndex}
serverHistory={getServerConnectionHistory()
.sort((a, b) => b.numConnects - a.numConnects)
.map(server => ({
ip: server.ip,
versionOverride: server.version,
numConnects: server.numConnects
}))}
/>
return <>
{serversListJsx}
@ -439,6 +372,7 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
}
export default () => {
const serversListQs = appQueryParams.serversList
const [customServersList, setCustomServersList] = useState<string[] | undefined>(serversListQs ? [] : undefined)
useEffect(() => {
@ -455,7 +389,7 @@ export default () => {
setCustomServersList(serversListQs.split(','))
}
}
}, [])
}, [serversListQs])
const modalStack = useSnapshot(activeModalStack)
const hasServersListModal = modalStack.some(x => x.reactType === 'serversList')

View file

@ -34,7 +34,7 @@
.action-bar {
position: fixed;
bottom: 20%;
bottom: calc(var(--safe-area-inset-bottom) + 60px);
left: 0;
right: 0;
font-size: 0.7rem;

View file

@ -0,0 +1,135 @@
import { proxy, ref, subscribe } from 'valtio'
import { UserOverridesConfig } from 'contro-max/build/types/store'
import { subscribeKey } from 'valtio/utils'
import { CustomCommand } from './KeybindingsCustom'
import { AuthenticatedAccount } from './serversStorage'
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:' : ''
export interface SavedProxiesData {
proxies: string[]
selected: string
}
export interface ServerHistoryEntry {
ip: string
version?: string
numConnects: number
lastConnected: number
}
export interface StoreServerItem extends BaseServerInfo {
lastJoined?: number
description?: string
optionsOverride?: Record<string, any>
autoLogin?: Record<string, string>
numConnects?: number // Track number of connections
}
type StorageData = {
customCommands: Record<string, CustomCommand> | undefined
username: string | undefined
keybindings: UserOverridesConfig | undefined
options: any
proxiesData: SavedProxiesData | undefined
serversHistory: ServerHistoryEntry[]
authenticatedAccounts: AuthenticatedAccount[]
serversList: StoreServerItem[] | undefined
}
const oldKeysAliases: Partial<Record<keyof StorageData, string>> = {
serversHistory: 'serverConnectionHistory',
}
const migrateLegacyData = () => {
const proxies = localStorage.getItem('proxies')
const selectedProxy = localStorage.getItem('selectedProxy')
if (proxies && selectedProxy) {
appStorage.proxiesData = {
proxies: JSON.parse(proxies),
selected: selectedProxy,
}
}
const username = localStorage.getItem('username')
if (username && !username.startsWith('"')) {
appStorage.username = username
}
const serversHistoryLegacy = localStorage.getItem('serverConnectionHistory')
if (serversHistoryLegacy) {
appStorage.serversHistory = JSON.parse(serversHistoryLegacy)
}
localStorage.removeItem('proxies')
localStorage.removeItem('selectedProxy')
localStorage.removeItem('serverConnectionHistory')
}
const defaultStorageData: StorageData = {
customCommands: undefined,
username: undefined,
keybindings: undefined,
options: {},
proxiesData: undefined,
serversHistory: [],
authenticatedAccounts: [],
serversList: undefined,
}
export const setStorageDataOnAppConfigLoad = () => {
appStorage.username ??= `mcrafter${Math.floor(Math.random() * 1000)}`
}
export const appStorage = proxy({ ...defaultStorageData })
window.appStorage = appStorage
// Restore data from localStorage
for (const key of Object.keys(defaultStorageData)) {
const prefixedKey = `${localStoragePrefix}${key}`
const aliasedKey = oldKeysAliases[key]
const storedValue = localStorage.getItem(prefixedKey) ?? (aliasedKey ? localStorage.getItem(aliasedKey) : undefined)
if (storedValue) {
try {
const parsed = JSON.parse(storedValue)
// appStorage[key] = parsed && typeof parsed === 'object' ? ref(parsed) : parsed
appStorage[key] = parsed
} catch (e) {
console.error(`Failed to parse stored value for ${key}:`, e)
}
}
}
const saveKey = (key: keyof StorageData) => {
const prefixedKey = `${localStoragePrefix}${key}`
const value = appStorage[key]
if (value === undefined) {
localStorage.removeItem(prefixedKey)
} else {
localStorage.setItem(prefixedKey, JSON.stringify(value))
}
}
subscribe(appStorage, (ops) => {
for (const op of ops) {
const [type, path, value] = op
const key = path[0]
saveKey(key as keyof StorageData)
}
})
// Subscribe to changes and save to localStorage
export const resetAppStorage = () => {
for (const key of Object.keys(appStorage)) {
appStorage[key as keyof StorageData] = defaultStorageData[key as keyof StorageData]
}
for (const key of Object.keys(localStorage)) {
if (key.startsWith(localStoragePrefix)) {
localStorage.removeItem(key)
}
}
}
migrateLegacyData()

View file

@ -1,16 +1,10 @@
import { appQueryParams } from '../appParams'
import { miscUiState } from '../globalState'
import { BaseServerInfo } from './AddServerOrConnect'
import { appStorage, StoreServerItem } from './appStorageProvider'
const serversListQs = appQueryParams.serversList
export interface StoreServerItem extends BaseServerInfo {
lastJoined?: number
description?: string
optionsOverride?: Record<string, any>
autoLogin?: Record<string, string>
numConnects?: number // Track number of connections
}
export interface AuthenticatedAccount {
// type: 'microsoft'
username: string
@ -19,6 +13,7 @@ export interface AuthenticatedAccount {
expiresOn: number
}
}
export interface ServerConnectionHistory {
ip: string
numConnects: number
@ -28,7 +23,7 @@ export interface ServerConnectionHistory {
export function updateServerConnectionHistory (ip: string, version?: string) {
try {
const history: ServerConnectionHistory[] = JSON.parse(localStorage.getItem('serverConnectionHistory') || '[]')
const history = [...(appStorage.serversHistory ?? [])]
const existingServer = history.find(s => s.ip === ip)
if (existingServer) {
existingServer.numConnects++
@ -42,53 +37,36 @@ export function updateServerConnectionHistory (ip: string, version?: string) {
version
})
}
localStorage.setItem('serverConnectionHistory', JSON.stringify(history))
appStorage.serversHistory = history
} catch (err) {
console.error('Failed to update server connection history:', err)
}
}
export const updateLoadedServerData = (callback: (data: StoreServerItem) => StoreServerItem, index = miscUiState.loadedServerIndex) => {
if (!index) index = miscUiState.loadedServerIndex
if (!index) return
// function assumes component is not mounted to avoid sync issues after save
const servers = getInitialServersList()
if (index === undefined) index = miscUiState.loadedServerIndex
if (index === undefined) return
const servers = [...(appStorage.serversList ?? [])]
const server = servers[index]
if (!server) return
servers[index] = callback(server)
setNewServersList(servers)
}
export const setNewServersList = (serversList: StoreServerItem[], force = false) => {
if (serversListQs && !force) return
localStorage['serversList'] = JSON.stringify(serversList)
// cleanup legacy
localStorage.removeItem('serverHistory')
localStorage.removeItem('server')
localStorage.removeItem('password')
localStorage.removeItem('version')
appStorage.serversList = serversList
}
export const getInitialServersList = () => {
if (localStorage['serversList']) return JSON.parse(localStorage['serversList']) as StoreServerItem[]
// If we already have servers in appStorage, use those
if (appStorage.serversList) return appStorage.serversList
const servers = [] as StoreServerItem[]
const legacyServersList = localStorage['serverHistory'] ? JSON.parse(localStorage['serverHistory']) as string[] : null
if (legacyServersList) {
for (const server of legacyServersList) {
if (!server || localStorage['server'] === server) continue
servers.push({ ip: server, lastJoined: Date.now() })
}
}
if (localStorage['server']) {
const legacyLastJoinedServer: StoreServerItem = {
ip: localStorage['server'],
versionOverride: localStorage['version'],
lastJoined: Date.now()
}
servers.push(legacyLastJoinedServer)
}
if (servers.length === 0) { // server list is empty, let's suggest some
if (servers.length === 0) {
// server list is empty, let's suggest some
for (const server of miscUiState.appConfig?.promoteServers ?? []) {
servers.push({
ip: server.ip,
@ -100,16 +78,13 @@ export const getInitialServersList = () => {
return servers
}
export const updateAuthenticatedAccountData = (callback: (data: AuthenticatedAccount[]) => AuthenticatedAccount[]) => {
const accounts = JSON.parse(localStorage['authenticatedAccounts'] || '[]') as AuthenticatedAccount[]
const accounts = appStorage.authenticatedAccounts
const newAccounts = callback(accounts)
localStorage['authenticatedAccounts'] = JSON.stringify(newAccounts)
appStorage.authenticatedAccounts = newAccounts
}
export function getServerConnectionHistory (): ServerConnectionHistory[] {
try {
return JSON.parse(localStorage.getItem('serverConnectionHistory') || '[]')
} catch {
return []
}
return appStorage.serversHistory ?? []
}

View file

@ -5,6 +5,7 @@ import { appQueryParams, updateQsParam } from '../../appParams'
export const packetsReplayState = proxy({
packetsPlayback: [] as PacketData[],
isOpen: false,
isMinimized: false,
replayName: '',
isPlaying: false,
progress: {

View file

@ -1,13 +0,0 @@
import { CustomCommand } from './KeybindingsCustom'
type StorageData = {
customCommands: Record<string, CustomCommand>
// ...
}
export const getStoredValue = <T extends keyof StorageData> (name: T): StorageData[T] | undefined => {
return localStorage[name] ? JSON.parse(localStorage[name]) : undefined
}
export const setStoredValue = <T extends keyof StorageData> (name: T, value: StorageData[T]) => {
localStorage[name] = JSON.stringify(value)
}

View file

@ -128,32 +128,36 @@ const InGameUi = () => {
<RobustPortal to={document.querySelector('#ui-root')}>
{/* apply scaling */}
<div style={{ display: showUI ? 'block' : 'none' }}>
<GameInteractionOverlay zIndex={7} />
{!disabledUiParts.includes('death-screen') && <DeathScreenProvider />}
{!disabledUiParts.includes('debug-overlay') && <DebugOverlay />}
{!disabledUiParts.includes('mobile-top-buttons') && <MobileTopButtons />}
{!disabledUiParts.includes('players-list') && <PlayerListOverlayProvider />}
{!disabledUiParts.includes('chat') && <ChatProvider />}
<SoundMuffler />
{showMinimap !== 'never' && <MinimapProvider adapter={adapter} displayMode='minimapOnly' />}
{!disabledUiParts.includes('title') && <TitleProvider />}
{!disabledUiParts.includes('scoreboard') && <ScoreboardProvider />}
{!disabledUiParts.includes('effects-indicators') && <IndicatorEffectsProvider />}
{!disabledUiParts.includes('crosshair') && <Crosshair />}
{!disabledUiParts.includes('books') && <BookProvider />}
{!disabledUiParts.includes('bossbars') && displayBossBars && <BossBarOverlayProvider />}
<PerComponentErrorBoundary>
<GameInteractionOverlay zIndex={7} />
{!disabledUiParts.includes('death-screen') && <DeathScreenProvider />}
{!disabledUiParts.includes('debug-overlay') && <DebugOverlay />}
{!disabledUiParts.includes('mobile-top-buttons') && <MobileTopButtons />}
{!disabledUiParts.includes('players-list') && <PlayerListOverlayProvider />}
{!disabledUiParts.includes('chat') && <ChatProvider />}
<SoundMuffler />
{showMinimap !== 'never' && <MinimapProvider adapter={adapter} displayMode='minimapOnly' />}
{!disabledUiParts.includes('title') && <TitleProvider />}
{!disabledUiParts.includes('scoreboard') && <ScoreboardProvider />}
{!disabledUiParts.includes('effects-indicators') && <IndicatorEffectsProvider />}
{!disabledUiParts.includes('crosshair') && <Crosshair />}
{!disabledUiParts.includes('books') && <BookProvider />}
{!disabledUiParts.includes('bossbars') && displayBossBars && <BossBarOverlayProvider />}
</PerComponentErrorBoundary>
</div>
<PauseScreen />
<MineflayerPluginHud />
<MineflayerPluginConsole />
{showUI && <TouchInteractionHint />}
<div style={{ display: showUI ? 'block' : 'none' }}>
{!disabledUiParts.includes('xp-bar') && <XPBarProvider />}
{!disabledUiParts.includes('hud-bars') && <HudBarsProvider />}
<BedTime />
</div>
{showUI && !disabledUiParts.includes('hotbar') && <HotbarRenderApp />}
<PerComponentErrorBoundary>
<PauseScreen />
<MineflayerPluginHud />
<MineflayerPluginConsole />
{showUI && <TouchInteractionHint />}
<div style={{ display: showUI ? 'block' : 'none' }}>
{!disabledUiParts.includes('xp-bar') && <XPBarProvider />}
{!disabledUiParts.includes('hud-bars') && <HudBarsProvider />}
<BedTime />
</div>
{showUI && !disabledUiParts.includes('hotbar') && <HotbarRenderApp />}
</PerComponentErrorBoundary>
</RobustPortal>
<PerComponentErrorBoundary>
<SignEditorProvider />

View file

@ -449,26 +449,34 @@ export const onAppLoad = () => {
customEvents.on('mineflayerBotCreated', () => {
// todo also handle resourcePack
const handleResourcePackRequest = async (packet) => {
const start = Date.now()
console.log('Received resource pack request', packet)
if (options.serverResourcePacks === 'never') return
const promptMessagePacket = ('promptMessage' in packet && packet.promptMessage) ? packet.promptMessage : undefined
const promptMessageText = promptMessagePacket ? '' : 'Do you want to use server resource pack?'
// TODO!
const hash = 'hash' in packet ? packet.hash : '-'
const forced = 'forced' in packet ? packet.forced : false
const choice = options.serverResourcePacks === 'always'
? true
: await showOptionsModal(promptMessageText, ['Download & Install (recommended)', 'Pretend Installed (not recommended)'], {
cancel: !forced,
minecraftJsonMessage: promptMessagePacket,
const choice = options.serverResourcePacks === 'never'
? false
: options.serverResourcePacks === 'always'
? true
: await showOptionsModal(promptMessageText, ['Download & Install (recommended)', 'Pretend Installed (not recommended)'], {
cancel: !forced,
minecraftJsonMessage: promptMessagePacket,
})
if (Date.now() - start < 700) { // wait for state protocol switch
await new Promise(resolve => {
setTimeout(resolve, 700)
})
}
if (choice === false) {
bot.acceptResourcePack()
return
}
if (!choice) {
bot.denyResourcePack()
return
}
await new Promise(resolve => {
setTimeout(resolve, 500)
})
console.log('accepting resource pack')
bot.acceptResourcePack()
if (choice === true || choice === 'Download & Install (recommended)') {

View file

@ -66,7 +66,7 @@ export const pointerLock = {
}
export const isInRealGameSession = () => {
return isGameActive(true) && !packetsReplayState.isOpen && !gameAdditionalState.viewerConnection
return isGameActive(true) && (!packetsReplayState.isOpen || packetsReplayState.isMinimized) && !gameAdditionalState.viewerConnection
}
window.getScreenRefreshRate = getScreenRefreshRate