This commit is contained in:
Vitaly 2024-11-20 10:04:59 +01:00 committed by GitHub
commit ce601caf2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 354 additions and 163 deletions

View file

@ -197,7 +197,8 @@
"no-async-promise-executor": "off",
"no-bitwise": "off",
"unicorn/filename-case": "off",
"max-depth": "off"
"max-depth": "off",
"unicorn/no-typeof-undefined": "off"
},
"overrides": [
{

View file

@ -17,7 +17,7 @@ RUN pnpm i
# ENTRYPOINT ["pnpm", "run", "run-all"]
# only for prod
RUN pnpm run build
RUN GITHUB_REPOSITORY=zardoy/minecraft-web-client pnpm run build
# ---- Run Stage ----
FROM node:18-alpine

View file

@ -38,11 +38,13 @@ Whatever offline mode you used (zip, folder, just single player), you can always
![docs-assets/singleplayer-future-city-1-10-2.jpg](./docs-assets/singleplayer-future-city-1-10-2.jpg)
### Servers
### Servers & Proxy
You can play almost on any Java server, vanilla servers are fully supported.
See the [Mineflayer](https://github.com/PrismarineJS/mineflayer) repo for the list of supported versions (should support majority of versions).
There is a builtin proxy, but you can also host your one! Just clone the repo, run `pnpm i` (following CONTRIBUTING.MD) and run `pnpm prod-start`, then you can specify `http://localhost:8080` in the proxy field.
There is a builtin proxy, but you can also host your one! Just clone the repo, run `pnpm i` (following CONTRIBUTING.MD) and run `pnpm prod-start`, then you can specify `http://localhost:8080` in the proxy field. Or you can deploy it to the cloud service:
[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](https://app.koyeb.com/deploy?name=minecraft-web-client&type=git&repository=zardoy%2Fminecraft-web-client&branch=next&builder=dockerfile&env%5B%5D=&ports=8080%3Bhttp%3B%2F)
Proxy servers are used to connect to Minecraft servers which use TCP protocol. When you connect connect to a server with a proxy, websocket connection is created between you (browser client) and the proxy server located in Europe, then the proxy connects to the Minecraft server and sends the data to the client (you) without any packet deserialization to avoid any additional delays. That said all the Minecraft protocol packets are processed by the client, right in your browser.
@ -139,7 +141,7 @@ Server specific:
Single player specific:
- `?loadSave=<save_name>` - Load the save on load with the specified folder name (not title)
- `?singleplayer=1` - Create empty world on load. Nothing will be saved
- `?singleplayer=1` or `?sp=1` - Create empty world on load. Nothing will be saved
- `?version=<version>` - Set the version for the singleplayer world (when used with `?singleplayer=1`)
- `?noSave=true` - Disable auto save on unload / disconnect / export whenever a world is loaded. Only manual save with `/save` command will work.
- `?serverSetting=<key>:<value>` - Set local server [options](https://github.com/zardoy/space-squid/tree/everything/src/modules.ts#L51). For example `?serverSetting=noInitialChunksSend:true` will disable initial chunks loading on the loading screen.

View file

@ -6,7 +6,7 @@ Minecraft UI components for React extracted from [mcraft.fun](https://mcraft.fun
pnpm i minecraft-react
```
![demo](https://github-production-user-asset-6210df.s3.amazonaws.com/46503702/346295584-80f3ed4a-cab6-45d2-8896-5e20233cc9b1.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAVCODYLSA53PQK4ZA%2F20240706%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20240706T195400Z&X-Amz-Expires=300&X-Amz-Signature=5b063823a57057c4042c15edd1db3edd107e00940fd0e66a2ba1df4e564a2809&X-Amz-SignedHeaders=host&actor_id=46503702&key_id=0&repo_id=432411890)
![demo](./docs-assets/npm-banner.jpeg)
## Usage

BIN
docs-assets/npm-banner.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View file

@ -29,17 +29,27 @@ function getUsernameTexture (username: string, { fontFamily = 'sans-serif' }: an
const padding = 5
ctx.font = `${fontSize}px ${fontFamily}`
const textWidth = ctx.measureText(username).width + padding * 2
const lines = String(username).split('\n')
let textWidth = 0
for (const line of lines) {
const width = ctx.measureText(line).width + padding * 2
if (width > textWidth) textWidth = width
}
canvas.width = textWidth
canvas.height = fontSize + padding * 2
canvas.height = (fontSize + padding * 2) * lines.length
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.font = `${fontSize}px ${fontFamily}`
ctx.fillStyle = 'white'
ctx.fillText(username, padding, fontSize)
let i = 0
for (const line of lines) {
i++
ctx.fillText(line, padding + (textWidth - ctx.measureText(line).width) / 2, fontSize * i)
}
return canvas
}
@ -454,6 +464,7 @@ export class Entities extends EventEmitter {
// ---
// not player
const displayText = entity.metadata?.[3] && this.parseEntityLabel(entity.metadata[2])
|| entity.metadata?.[23] && this.parseEntityLabel(entity.metadata[23]) // text displays
if (entity.name !== 'player' && displayText) {
addNametag({ ...entity, username: displayText }, this.entitiesOptions, this.entities[entity.id].children.find(c => c.name === 'mesh'))
}

View file

@ -224,8 +224,8 @@ export const knownNotHandled = [
'item_display', 'item_frame',
'lightning_bolt', 'marker',
'painting', 'spawner_minecart',
'spectral_arrow', 'text_display',
'tnt', 'trader_llama', 'zombie_horse'
'spectral_arrow', 'tnt',
'trader_llama', 'zombie_horse'
]
export const temporaryMap = {

View file

@ -16390,6 +16390,10 @@
},
"render_controllers": ["controller.render.strider"]
},
"text_display": {
"identifier": "minecraft:text_display",
"geometry": {}
},
"trident": {
"identifier": "minecraft:thrown_trident",
"textures": {

View file

@ -484,7 +484,7 @@ export function getSectionGeometry (sx, sy, sz, world: World) {
}
}
if (invisibleBlocks.has(block.name)) continue
if (block.name.includes('_sign') || block.name === 'sign') {
if ((block.name.includes('_sign') || block.name === 'sign') && !world.config.disableSignsMapsSupport) {
const key = `${cursor.x},${cursor.y},${cursor.z}`
const props: any = block.getProperties()
const facingRotationMap = {

View file

@ -7,7 +7,9 @@ export const defaultMesherConfig = {
smoothLighting: true,
outputFormat: 'threeJs' as 'threeJs' | 'webgpu',
textureSize: 1024, // for testing
debugModelVariant: undefined as undefined | number[]
debugModelVariant: undefined as undefined | number[],
clipWorldBelowY: undefined as undefined | number,
disableSignsMapsSupport: false
}
export type MesherConfig = typeof defaultMesherConfig

View file

@ -99,7 +99,7 @@ export class Viewer {
setBlockStateId (pos: Vec3, stateId: number) {
if (!this.world.loadedChunks[`${Math.floor(pos.x / 16) * 16},${Math.floor(pos.z / 16) * 16}`]) {
console.warn('[should be unreachable] setBlockStateId called for unloaded chunk', pos)
console.debug('[should be unreachable] setBlockStateId called for unloaded chunk', pos)
}
this.world.setBlockStateId(pos, stateId)
}

View file

@ -321,6 +321,10 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
console.log('texture loaded')
}
get worldMinYRender () {
return Math.floor(Math.max(this.worldConfig.minY, this.mesherConfig.clipWorldBelowY ?? -Infinity) / 16) * 16
}
addColumn (x: number, z: number, chunk: any, isLightUpdate: boolean) {
if (!this.active) return
if (this.workers.length === 0) throw new Error('workers not initialized yet')
@ -330,7 +334,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
// todo optimize
worker.postMessage({ type: 'chunk', x, z, chunk })
}
for (let y = this.worldConfig.minY; y < this.worldConfig.worldHeight; y += 16) {
for (let y = this.worldMinYRender; y < this.worldConfig.worldHeight; y += 16) {
const loc = new Vec3(x, y, z)
this.setSectionDirty(loc)
if (this.neighborChunkUpdates && (!isLightUpdate || this.mesherConfig.smoothLighting)) {

View file

@ -52,6 +52,7 @@ exports.getSwAdditionalEntries = () => {
'*.png',
'*.woff',
'mesher.js',
'manifest.json',
'worldSaveWorker.js',
`textures/entity/squid/squid.png`,
// everything but not .map

View file

@ -2,6 +2,11 @@
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { execSync } from 'child_process'
// write release tag
const commitShort = execSync('git rev-parse --short HEAD').toString().trim()
fs.writeFileSync('./assets/release.json', JSON.stringify({ latestTag: `${commitShort} (docker)` }), 'utf8')
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
delete packageJson.optionalDependencies

View file

@ -1,4 +1,5 @@
import { versionToNumber } from 'prismarine-viewer/viewer/prepare/utils'
import * as nbt from 'prismarine-nbt'
export const displayClientChat = (text: string) => {
const message = {
@ -18,3 +19,22 @@ export const displayClientChat = (text: string) => {
sender: 'minecraft:chat'
})
}
export const parseFormattedMessagePacket = (arg) => {
if (typeof arg === 'object') {
try {
return {
formatted: nbt.simplify(arg),
plain: ''
}
} catch (err) {
console.warn('Failed to parse formatted message', arg, err)
return {
plain: JSON.stringify(arg)
}
}
}
return {
plain: String(arg)
}
}

View file

@ -52,6 +52,7 @@ export const contro = new ControMax({
selectItem: ['KeyH'] // default will be removed
},
ui: {
toggleFullscreen: ['F11'],
back: [null/* 'Escape' */, 'B'],
leftClick: [null, 'A'],
rightClick: [null, 'Y'],
@ -419,6 +420,10 @@ contro.on('trigger', ({ command }) => {
if (command === 'ui.pauseMenu') {
showModal({ reactType: 'pause-screen' })
}
if (command === 'ui.toggleFullscreen') {
void goFullscreen(true)
}
})
contro.on('release', ({ command }) => {
@ -742,10 +747,6 @@ window.addEventListener('keydown', (e) => {
// #region experimental debug things
window.addEventListener('keydown', (e) => {
if (e.code === 'F11') {
e.preventDefault()
void goFullscreen(true)
}
if (e.code === 'KeyL' && e.altKey) {
console.clear()
}

View file

@ -1,4 +1,7 @@
import { saveServer } from './flyingSquidUtils'
import { watchUnloadForCleanup } from './gameUnload'
import { showModal } from './globalState'
import { options } from './optionsStorage'
import { chatInputValueGlobal } from './react/Chat'
import { showNotification } from './react/NotificationProvider'
@ -10,4 +13,15 @@ export default () => {
showModal({ reactType: 'chat' })
})
})
if (options.singleplayerAutoSave) {
const autoSaveInterval = setInterval(() => {
if (options.singleplayerAutoSave) {
void saveServer(true)
}
}, 2000)
watchUnloadForCleanup(() => {
clearInterval(autoSaveInterval)
})
}
}

View file

@ -102,6 +102,7 @@ import packetsPatcher from './packetsPatcher'
import { mainMenuState } from './react/MainMenuRenderApp'
import { ItemsRenderer } from 'mc-assets/dist/itemsRenderer'
import './mobileShim'
import { parseFormattedMessagePacket } from './botUtils'
window.debug = debug
window.THREE = THREE
@ -402,7 +403,9 @@ async function connect (connectOptions: ConnectOptions) {
setLoadingScreenStatus(`Loading data for ${version}`)
if (!document.fonts.check('1em mojangles')) {
// todo instead re-render signs on load
await document.fonts.load('1em mojangles').catch(() => { })
await document.fonts.load('1em mojangles').catch(() => {
console.error('Failed to load font, signs wont be rendered correctly')
})
}
await window._MC_DATA_RESOLVER.promise // ensure data is loaded
await downloadSoundsIfNeeded()
@ -457,7 +460,7 @@ async function connect (connectOptions: ConnectOptions) {
flyingSquidEvents()
}
if (connectOptions.authenticatedAccount) username = 'not-used'
if (connectOptions.authenticatedAccount) username = 'you'
let initialLoadingText: string
if (singleplayer) {
initialLoadingText = 'Local server is still starting'
@ -637,14 +640,7 @@ async function connect (connectOptions: ConnectOptions) {
bot.on('kicked', (kickReason) => {
console.log('You were kicked!', kickReason)
let kickReasonString = typeof kickReason === 'string' ? kickReason : JSON.stringify(kickReason)
let kickReasonFormatted = undefined as undefined | Record<string, any>
if (typeof kickReason === 'object') {
try {
kickReasonFormatted = nbt.simplify(kickReason)
kickReasonString = ''
} catch {}
}
const { formatted: kickReasonFormatted, plain: kickReasonString } = parseFormattedMessagePacket(kickReason)
setLoadingScreenStatus(`The Minecraft server kicked you. Kick reason: ${kickReasonString}`, true, undefined, undefined, kickReasonFormatted)
destroyAll()
})
@ -932,7 +928,7 @@ watchValue(miscUiState, async s => {
const qs = new URLSearchParams(window.location.search)
const moreServerOptions = {} as Record<string, any>
if (qs.has('version')) moreServerOptions.version = qs.get('version')
if (qs.get('singleplayer') === '1') {
if (qs.get('singleplayer') === '1' || qs.get('sp') === '1') {
loadSingleplayer({}, {
worldFolder: undefined,
...moreServerOptions

View file

@ -31,6 +31,7 @@ export default async ({ tokenCaches, proxyBaseUrl, setProgressText = (text) => {
const authFlow = {
async getMinecraftJavaToken () {
setProgressText('Authenticating with Microsoft account')
if (!window.crypto && !isPageSecure()) throw new Error('Crypto API is available only in secure contexts. Be sure to use https!')
let result = null
await fetch(authEndpoint, {
method: 'POST',
@ -43,22 +44,29 @@ export default async ({ tokenCaches, proxyBaseUrl, setProgressText = (text) => {
connectingServer,
connectingServerVersion: connectingVersion
}),
}).then(async response => {
if (!response.ok) {
throw new Error(`Auth server error (${response.status}): ${await response.text()}`)
}
const reader = response.body!.getReader()
const decoder = new TextDecoder('utf8')
const processText = ({ done, value = undefined as Uint8Array | undefined }) => {
if (done) {
return
})
.catch(e => {
throw new Error(`Failed to connect to auth server (network error): ${e.message}`)
})
.then(async response => {
if (!response.ok) {
throw new Error(`Auth server error (${response.status}): ${await response.text()}`)
}
const processChunk = (chunkStr) => {
try {
const json = JSON.parse(chunkStr)
const reader = response.body!.getReader()
const decoder = new TextDecoder('utf8')
const processText = ({ done, value = undefined as Uint8Array | undefined }) => {
if (done) {
return
}
const processChunk = (chunkStr) => {
let json: any
try {
json = JSON.parse(chunkStr)
} catch (err) {}
if (!json) return
if (json.user_code) {
onMsaCodeCallback(json)
// this.codeCallback(json)
@ -66,26 +74,22 @@ export default async ({ tokenCaches, proxyBaseUrl, setProgressText = (text) => {
if (json.error) throw new Error(json.error)
if (json.token) result = json
if (json.newCache) setCacheResult(json.newCache)
} catch (err) {
}
const strings = decoder.decode(value)
for (const chunk of strings.split('\n\n')) {
processChunk(chunk)
}
return reader.read().then(processText)
}
const strings = decoder.decode(value)
for (const chunk of strings.split('\n\n')) {
processChunk(chunk)
}
return reader.read().then(processText)
}
return reader.read().then(processText)
})
if (!window.crypto && !isPageSecure()) throw new Error('Crypto API is available only in secure contexts. Be sure to use https!')
})
const restoredData = await restoreData(result)
if (!restoredData?.certificates?.profileKeys?.privatePEM) {
throw new Error(`Authentication server issue: it didn't return auth data. Most probably because the auth request was rejected by the end authority and retrying won't help until the issue is resolved.`)
if (restoredData?.certificates?.profileKeys?.privatePEM) {
restoredData.certificates.profileKeys.private = restoredData.certificates.profileKeys.privatePEM
}
restoredData.certificates.profileKeys.private = restoredData.certificates.profileKeys.privatePEM
return restoredData
}
}

View file

@ -74,7 +74,7 @@ export const guiOptionsScheme: {
},
smoothLighting: {},
newVersionsLighting: {
text: 'Lighting in newer versions',
text: 'Lighting in Newer Versions',
},
lowMemoryMode: {
text: 'Low Memory Mode',
@ -98,6 +98,19 @@ export const guiOptionsScheme: {
],
},
},
{
custom () {
return <Category>Resource Packs</Category>
},
serverResourcePacks: {
text: 'Download From Server',
values: [
'prompt',
'always',
'never'
],
}
}
],
main: [
{

View file

@ -52,6 +52,9 @@ const defaultOptions = {
// antiAliasing: false,
clipWorldBelowY: undefined as undefined | number, // will be removed
disableSignsMapsSupport: false,
singleplayerAutoSave: false,
showChunkBorders: false, // todo rename option
frameLimit: false as number | false,
alwaysBackupWorldBeforeLoading: undefined as boolean | undefined | null,
@ -157,7 +160,7 @@ subscribe(options, () => {
localStorage.options = JSON.stringify(saveOptions)
})
type WatchValue = <T extends Record<string, any>>(proxy: T, callback: (p: T) => void) => void
type WatchValue = <T extends Record<string, any>>(proxy: T, callback: (p: T, isChanged: boolean) => void) => void
export const watchValue: WatchValue = (proxy, callback) => {
const watchedProps = new Set<string>()
@ -166,10 +169,10 @@ export const watchValue: WatchValue = (proxy, callback) => {
watchedProps.add(p.toString())
return Reflect.get(target, p, receiver)
},
}))
}), false)
for (const prop of watchedProps) {
subscribeKey(proxy, prop, () => {
callback(proxy)
callback(proxy, true)
})
}
}

View file

@ -22,7 +22,7 @@ interface Props {
initialData?: BaseServerInfo
parseQs?: boolean
onQsConnect?: (server: BaseServerInfo) => void
defaults?: Pick<BaseServerInfo, 'proxyOverride' | 'usernameOverride'>
placeholders?: Pick<BaseServerInfo, 'proxyOverride' | 'usernameOverride'>
accounts?: string[]
authenticatedAccounts?: number
versions?: string[]
@ -30,7 +30,7 @@ interface Props {
const ELEMENTS_WIDTH = 190
export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQs, onQsConnect, defaults, accounts, versions, authenticatedAccounts }: Props) => {
export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQs, onQsConnect, placeholders, accounts, versions, authenticatedAccounts }: Props) => {
const qsParams = parseQs ? new URLSearchParams(window.location.search) : undefined
const qsParamName = qsParams?.get('name')
const qsParamIp = qsParams?.get('ip')
@ -111,8 +111,8 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
/>
</div>
<InputWithLabel label="Proxy Override" value={proxyOverride} disabled={lockConnect && qsParamProxy !== null} onChange={({ target: { value } }) => setProxyOverride(value)} placeholder={defaults?.proxyOverride} />
<InputWithLabel label="Username Override" value={usernameOverride} disabled={!noAccountSelected || lockConnect && qsParamUsername !== null} onChange={({ target: { value } }) => setUsernameOverride(value)} placeholder={defaults?.usernameOverride} />
<InputWithLabel label="Proxy Override" value={proxyOverride} disabled={lockConnect && qsParamProxy !== null} onChange={({ target: { value } }) => setProxyOverride(value)} placeholder={placeholders?.proxyOverride} />
<InputWithLabel label="Username Override" value={usernameOverride} disabled={!noAccountSelected || lockConnect && qsParamUsername !== null} onChange={({ target: { value } }) => setUsernameOverride(value)} placeholder={placeholders?.usernameOverride} />
<label style={{
display: 'flex',
flexDirection: 'column',

View file

@ -4,6 +4,7 @@ import triangle from './ps_icons/playstation_triangle_console_controller_gamepad
import square from './ps_icons/playstation_square_console_controller_gamepad_icon.svg'
import circle from './ps_icons/circle_playstation_console_controller_gamepad_icon.svg'
import cross from './ps_icons/cross_playstation_console_controller_gamepad_icon.svg'
import { parseBindingName } from './parseKeybindingName'
type Props = {
@ -30,24 +31,6 @@ export default ({ type, val, isPS }: Props) => {
</>
}
async function parseBindingName (binding: string) {
if (!binding) return ''
const { keyboard } = navigator
const layoutMap = await keyboard?.getLayoutMap?.() ?? new Map()
const mapKey = key => layoutMap.get(key) || key
const cut = binding.replaceAll(/(Numpad|Digit|Key)/g, '')
const parts = cut.includes('+') ? cut.split('+') : [cut]
for (let i = 0; i < parts.length; i++) {
parts[i] = mapKey(parts[i]).split(/(?=[A-Z\d])/).reverse().join(' ')
}
return parts.join(' + ')
}
const buttonsMap = {
'A': cross,
'B': circle,

View file

@ -1,49 +1,73 @@
import { proxy, useSnapshot } from 'valtio'
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'
const state = proxy({
title: '',
options: [] as string[],
showCancel: true,
minecraftJsonMessage: null as null | Record<string, any>,
behavior: 'resolve-close' as 'resolve-close' | 'close-resolve',
})
let resolve
export const showOptionsModal = async <T extends string> (
title: string,
options: T[],
{ cancel = true }: { cancel?: boolean } = {}
{ cancel = true, minecraftJsonMessage }: { cancel?: boolean, minecraftJsonMessage? } = {}
): Promise<T | undefined> => {
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,
options,
showCancel: cancel,
minecraftJsonMessage: minecraftJsonMessageParsed
})
})
}
export default () => {
const { title, options, showCancel } = useSnapshot(state)
const { title, options, showCancel, minecraftJsonMessage } = useSnapshot(state)
const isModalActive = useIsModalActive('general-select')
if (!isModalActive) return
const resolveClose = (value: string | undefined) => {
if (state.behavior === 'resolve-close') {
resolve(value)
hideCurrentModal()
} else {
hideCurrentModal()
resolve(value)
}
}
return <Screen title={title} backdrop>
{minecraftJsonMessage && <div style={{ textAlign: 'center', }}>
<MessageFormattedString message={minecraftJsonMessage} />
</div>}
{options.map(option => <Button
key={option} onClick={() => {
hideCurrentModal()
resolve(option)
resolveClose(option)
}}
>{option}
</Button>)}
{showCancel && <Button
style={{ marginTop: 30 }} onClick={() => {
hideCurrentModal()
resolve(undefined)
resolveClose(undefined)
}}
>Cancel
</Button>}

View file

@ -16,6 +16,7 @@ interface Props extends React.ComponentProps<typeof Singleplayer> {
username: string
setUsername: (username: string) => void
onProfileClick?: () => void
setQuickConnectIp?: (ip: string) => void
}
export interface SavedProxiesLocalStorage {
@ -29,7 +30,7 @@ type ProxyStatusResult = {
status: 'success' | 'error' | 'unknown'
}
export default ({ initialProxies, updateProxies: updateProxiesProp, joinServer, username, setUsername, onProfileClick, ...props }: Props) => {
export default ({ initialProxies, updateProxies: updateProxiesProp, joinServer, username, setUsername, onProfileClick, setQuickConnectIp, ...props }: Props) => {
const [proxies, setProxies] = React.useState(initialProxies)
const updateProxies = (newData: SavedProxiesLocalStorage) => {
@ -39,6 +40,17 @@ export default ({ initialProxies, updateProxies: updateProxiesProp, joinServer,
const [serverIp, setServerIp] = React.useState('')
const [save, setSave] = React.useState(true)
const [activeHighlight, setActiveHighlight] = React.useState(undefined as 'quick-connect' | 'server-list' | undefined)
const getActiveHighlightStyles = (type: typeof activeHighlight) => {
const styles: React.CSSProperties = {
transition: 'filter 0.2s',
}
if (activeHighlight && activeHighlight !== type) {
styles.filter = 'brightness(0.7)'
}
return styles
}
return <Singleplayer
{...props}
@ -66,9 +78,22 @@ export default ({ initialProxies, updateProxies: updateProxiesProp, joinServer,
})
}}
>
<div style={{ display: 'flex', gap: 5, alignItems: 'center' }}>
<div
style={{ display: 'flex', gap: 5, alignItems: 'center', ...getActiveHighlightStyles('quick-connect') }}
className='quick-connect-row'
onMouseEnter={() => setActiveHighlight('quick-connect')}
onMouseLeave={() => setActiveHighlight(undefined)}
>
{/* todo history */}
<Input required placeholder='Quick Connect IP (:version)' value={serverIp} onChange={({ target: { value } }) => setServerIp(value)} />
<Input
required
placeholder='Quick Connect IP (:version)'
value={serverIp}
onChange={({ target: { value } }) => {
setQuickConnectIp?.(value)
setServerIp(value)
}}
/>
<label style={{ fontSize: 10, display: 'flex', alignItems: 'center', gap: 5, height: '100%', marginTop: '-1px' }}>
<input
type='checkbox' checked={save}
@ -108,6 +133,10 @@ export default ({ initialProxies, updateProxies: updateProxiesProp, joinServer,
}
props.onWorldAction?.(action, serverName)
}}
setListHovered={(hovered) => {
setActiveHighlight(hovered ? 'server-list' : undefined)
}}
listStyle={getActiveHighlightStyles('server-list')}
secondRowStyles={getActiveHighlightStyles('server-list')}
/>
}

View file

@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react'
import { useUtilsEffect } from '@zardoy/react-util'
import { useSnapshot } from 'valtio'
import { ConnectOptions } from '../connect'
import { hideCurrentModal, miscUiState, showModal } from '../globalState'
import { activeModalStack, hideCurrentModal, miscUiState, showModal } from '../globalState'
import supportedVersions from '../supportedVersions.mjs'
import ServersList from './ServersList'
import AddServerOrConnect, { BaseServerInfo } from './AddServerOrConnect'
@ -132,29 +133,31 @@ export const updateAuthenticatedAccountData = (callback: (data: AuthenticatedAcc
// todo move to base
const normalizeIp = (ip: string) => ip.replace(/https?:\/\//, '').replace(/\/(:|$)/, '')
const Inner = () => {
const Inner = ({ hidden }: { hidden?: boolean }) => {
const [proxies, setProxies] = useState<readonly string[]>(localStorage['proxies'] ? JSON.parse(localStorage['proxies']) : getInitialProxies())
const [selectedProxy, setSelectedProxy] = useState(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 [defaultUsername, _setDefaultUsername] = useState(localStorage['username'] ?? (`mcrafter${Math.floor(Math.random() * 1000)}`))
const [authenticatedAccounts, _setAuthenticatedAccounts] = useState<AuthenticatedAccount[]>(JSON.parse(localStorage['authenticatedAccounts'] || '[]'))
const [quickConnectIp, setQuickConnectIp] = useState('')
const setAuthenticatedAccounts = (newState: typeof authenticatedAccounts) => {
_setAuthenticatedAccounts(newState)
localStorage.setItem('authenticatedAccounts', JSON.stringify(newState))
}
const setDefaultUsername = (newState: typeof defaultUsername) => {
_setDefaultUsername(newState)
localStorage.setItem('username', newState)
}
useEffect(() => {
localStorage.setItem('authenticatedAccounts', JSON.stringify(authenticatedAccounts))
}, [authenticatedAccounts])
useEffect(() => {
localStorage.setItem('username', defaultUsername)
}, [defaultUsername])
useEffect(() => {
// TODO! do not unmount on connecting screen
// if (proxies.length) {
// localStorage.setItem('proxies', JSON.stringify(proxies))
// }
// if (selectedProxy) {
// localStorage.setItem('selectedProxy', selectedProxy)
// }
if (proxies.length) {
localStorage.setItem('proxies', JSON.stringify(proxies))
}
if (selectedProxy) {
localStorage.setItem('selectedProxy', selectedProxy)
}
}, [proxies])
const [serversList, setServersList] = useState<StoreServerItem[]>(() => getInitialServersList())
@ -195,7 +198,7 @@ const Inner = () => {
})
}
}
void update()
void update().catch((err) => {})
}, [serversListSorted])
const isEditScreenModal = useIsModalActive('editServer')
@ -215,46 +218,46 @@ const Inner = () => {
}
}, [isEditScreenModal])
if (isEditScreenModal) {
return <AddServerOrConnect
defaults={{
proxyOverride: selectedProxy,
usernameOverride: defaultUsername,
}}
parseQs={!serverEditScreen}
onBack={() => {
hideCurrentModal()
}}
onConfirm={(info) => {
if (!serverEditScreen) return
if (serverEditScreen === true) {
const server: StoreServerItem = { ...info, lastJoined: Date.now() } // so it appears first
setServersList(old => [...old, server])
} else {
const index = serversList.indexOf(serverEditScreen)
const { lastJoined } = serversList[index]
serversList[index] = { ...info, lastJoined }
setServersList([...serversList])
}
setServerEditScreen(null)
}}
accounts={authenticatedAccounts.map(a => a.username)}
initialData={!serverEditScreen || serverEditScreen === true ? undefined : serverEditScreen}
onQsConnect={(info) => {
const connectOptions: ConnectOptions = {
username: info.usernameOverride || defaultUsername,
server: normalizeIp(info.ip),
proxy: info.proxyOverride || selectedProxy,
botVersion: info.versionOverride,
ignoreQs: true,
}
dispatchEvent(new CustomEvent('connect', { detail: connectOptions }))
}}
versions={supportedVersions}
/>
}
const editModalJsx = isEditScreenModal ? <AddServerOrConnect
placeholders={{
proxyOverride: selectedProxy,
usernameOverride: defaultUsername,
}}
parseQs={!serverEditScreen}
onBack={() => {
hideCurrentModal()
}}
onConfirm={(info) => {
if (!serverEditScreen) return
if (serverEditScreen === true) {
const server: StoreServerItem = { ...info, lastJoined: Date.now() } // so it appears first
setServersList(old => [...old, server])
} else {
const index = serversList.indexOf(serverEditScreen)
const { lastJoined } = serversList[index]
serversList[index] = { ...info, lastJoined }
setServersList([...serversList])
}
setServerEditScreen(null)
}}
accounts={authenticatedAccounts.map(a => a.username)}
initialData={!serverEditScreen || serverEditScreen === true ? {
ip: quickConnectIp
} : serverEditScreen}
onQsConnect={(info) => {
const connectOptions: ConnectOptions = {
username: info.usernameOverride || defaultUsername,
server: normalizeIp(info.ip),
proxy: info.proxyOverride || selectedProxy,
botVersion: info.versionOverride,
ignoreQs: true,
}
dispatchEvent(new CustomEvent('connect', { detail: connectOptions }))
}}
versions={supportedVersions}
/> : null
return <ServersList
const serversListJsx = <ServersList
joinServer={(overrides, { shouldSave }) => {
const indexOrIp = overrides.ip
let ip = indexOrIp
@ -327,10 +330,11 @@ const Inner = () => {
}}
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(old => old.filter(a => a.username !== username))
setAuthenticatedAccounts(authenticatedAccounts.filter(a => a.username !== username))
}}
onWorldAction={(action, index) => {
const server = serversList[index]
@ -372,12 +376,21 @@ const Inner = () => {
setProxies(proxies)
setSelectedProxy(selected)
}}
hidden={hidden}
/>
return <>
{serversListJsx}
{editModalJsx}
</>
}
export default () => {
const modalStack = useSnapshot(activeModalStack)
const hasServersListModal = modalStack.some(x => x.reactType === 'serversList')
const editServerModalActive = useIsModalActive('editServer')
const isServersListModalActive = useIsModalActive('serversList')
const eitherModal = isServersListModalActive || editServerModalActive
return eitherModal ? <Inner /> : null
const render = eitherModal || hasServersListModal
return render ? <Inner hidden={!isServersListModalActive} /> : null
}

View file

@ -85,9 +85,15 @@ interface Props {
warning?: string
warningAction?: () => void
warningActionLabel?: string
hidden?: boolean
onWorldAction (action: 'load' | 'export' | 'delete' | 'edit', worldName: string): void
onGeneralAction (action: 'cancel' | 'create'): void
onRowSelect? (name: string, index: number): void
defaultSelectedRow?: number
listStyle?: React.CSSProperties
setListHovered?: (hovered: boolean) => void
secondRowStyles?: React.CSSProperties
}
export default ({
@ -104,7 +110,13 @@ export default ({
disabledProviders,
error,
isReadonly,
warning, warningAction, warningActionLabel
warning, warningAction, warningActionLabel,
hidden,
onRowSelect,
defaultSelectedRow,
listStyle,
setListHovered,
secondRowStyles
}: Props) => {
const containerRef = useRef<any>()
const firstButton = useRef<HTMLButtonElement>(null)
@ -122,13 +134,17 @@ export default ({
})
const [search, setSearch] = useState('')
const [focusedWorld, setFocusedWorld] = useState('')
const [focusedWorld, setFocusedWorld] = useState(defaultSelectedRow ? worldData?.[defaultSelectedRow]?.name ?? '' : '')
useEffect(() => {
setFocusedWorld('')
}, [activeProvider])
return <div ref={containerRef}>
const onRowSelectHandler = (name: string, index: number) => {
onRowSelect?.(name, index)
setFocusedWorld(name)
}
return <div ref={containerRef} hidden={hidden}>
<div className="dirt-bg" />
<div className={classNames('fullscreen', styles.root)}>
<span className={classNames('screen-title', styles.title)}>{serversLayout ? 'Join Java Servers' : 'Select Saved World'}</span>
@ -141,9 +157,13 @@ export default ({
setActiveProvider?.(tab as any)
}} fullSize
/>
<div style={{
marginTop: 3,
}}
<div
style={{
marginTop: 3,
...listStyle
}}
onMouseEnter={() => setListHovered?.(true)}
onMouseLeave={() => setListHovered?.(false)}
>
{
providerActions && <div style={{
@ -159,9 +179,9 @@ export default ({
}
{
worldData
? worldData.filter(data => data.title.toLowerCase().includes(search.toLowerCase())).map(({ name, size, detail, ...rest }) => (
? worldData.filter(data => data.title.toLowerCase().includes(search.toLowerCase())).map(({ name, size, detail, ...rest }, index) => (
<World
{...rest} size={size} name={name} onFocus={setFocusedWorld} isFocused={focusedWorld === name} key={name} onInteraction={(interaction) => {
{...rest} size={size} name={name} onFocus={row => onRowSelectHandler(row, index)} isFocused={focusedWorld === name} key={name} onInteraction={(interaction) => {
if (interaction === 'enter') onWorldAction('load', name)
else if (interaction === 'space') firstButton.current?.focus()
}}
@ -192,7 +212,7 @@ export default ({
<Button rootRef={firstButton} disabled={!focusedWorld} onClick={() => onWorldAction('load', focusedWorld)}>Load World</Button>
<Button onClick={() => onGeneralAction('create')} disabled={isReadonly}>Create New World</Button>
</div>}
<div>
<div style={{ ...secondRowStyles }}>
{serversLayout ? <Button style={{ width: 100 }} disabled={!focusedWorld} onClick={() => onWorldAction('edit', focusedWorld)}>Edit</Button> : <Button style={{ width: 100 }} disabled={!focusedWorld} onClick={() => onWorldAction('export', focusedWorld)}>Export</Button>}
<Button style={{ width: 100 }} disabled={!focusedWorld} onClick={() => onWorldAction('delete', focusedWorld)}>Delete</Button>
{serversLayout ?

View file

@ -0,0 +1,14 @@
import { test, expect } from 'vitest'
import { parseBindingName } from './parseKeybindingName'
test('display keybinding correctly', async () => {
expect(await parseBindingName('unknown')).toMatchInlineSnapshot('"unknown"')
expect(await parseBindingName('KeyT')).toMatchInlineSnapshot('"T"')
expect(await parseBindingName('Digit1')).toMatchInlineSnapshot('"1"')
expect(await parseBindingName('Numpad1')).toMatchInlineSnapshot('"Numpad 1"')
expect(await parseBindingName('MetaLeft')).toMatchInlineSnapshot('"Left Meta"')
expect(await parseBindingName('Space')).toMatchInlineSnapshot('"Space"')
expect(await parseBindingName('Escape')).toMatchInlineSnapshot('"Escape"')
expect(await parseBindingName('F11')).toMatchInlineSnapshot('"F11"')
expect(await parseBindingName('Mouse 55')).toMatchInlineSnapshot('"Mouse 55"')
})

View file

@ -0,0 +1,17 @@
export async function parseBindingName (binding: string) {
if (!binding) return ''
const { keyboard } = (typeof navigator === 'undefined' ? undefined : navigator) ?? {}
const layoutMap = await keyboard?.getLayoutMap?.() ?? new Map<string, string>()
const mapKey = (key: string) => layoutMap.get(key) || key
const cut = binding.replaceAll(/(Digit|Key)/g, '')
const parts = cut.includes('+') ? cut.split('+') : [cut]
for (let i = 0; i < parts.length; i++) {
parts[i] = mapKey(parts[i]).split(/(?<=[a-z])(?=\d)/).join(' ').split(/(?=[A-Z])/).reverse().join(' ')
}
return parts.join(' + ')
}

View file

@ -295,14 +295,16 @@ export const onAppLoad = () => {
// todo also handle resourcePack
const handleResourcePackRequest = async (packet) => {
if (options.serverResourcePacks === 'never') return
const promptMessage = ('promptMessage' in packet && packet.promptMessage) ? JSON.stringify(packet.promptMessage) : 'Do you want to use server resource pack?'
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(promptMessage, ['Download & Install (recommended)', 'Pretend Installed (not recommended)'], {
cancel: !forced
: await showOptionsModal(promptMessageText, ['Download & Install (recommended)', 'Pretend Installed (not recommended)'], {
cancel: !forced,
minecraftJsonMessage: promptMessagePacket,
})
if (!choice) return
bot.acceptResourcePack()

View file

@ -58,6 +58,13 @@ export const watchOptionsAfterViewerInit = () => {
watchValue(options, o => {
viewer.world.displayStats = o.renderDebug === 'advanced'
})
watchValue(options, (o, isChanged) => {
viewer.world.mesherConfig.clipWorldBelowY = o.clipWorldBelowY
viewer.world.mesherConfig.disableSignsMapsSupport = o.disableSignsMapsSupport
if (isChanged) {
(viewer.world as WorldRendererThree).rerenderAllChunks()
}
})
viewer.world.mesherConfig.smoothLighting = options.smoothLighting
subscribeKey(options, 'smoothLighting', () => {

View file

@ -6,6 +6,7 @@ export default defineConfig({
include: [
'../../src/botUtils.test.ts',
'../../src/markdownToFormattedText.test.ts',
'../../src/react/parseKeybindingName.test.ts',
'lib/mesher/test/tests.test.ts',
'sign-renderer/tests.test.ts'
],