diff --git a/.eslintrc.json b/.eslintrc.json index f454100a..8b2225b5 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -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": [ { diff --git a/Dockerfile b/Dockerfile index 2e43ad8a..a5f6ac06 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.MD b/README.MD index 9b07bdb5..77f68032 100644 --- a/README.MD +++ b/README.MD @@ -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=` - 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=` - 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=:` - 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. diff --git a/README.NPM.MD b/README.NPM.MD index 9c4bf17f..dc2c7c72 100644 --- a/README.NPM.MD +++ b/README.NPM.MD @@ -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 diff --git a/docs-assets/npm-banner.jpeg b/docs-assets/npm-banner.jpeg new file mode 100644 index 00000000..95de07b8 Binary files /dev/null and b/docs-assets/npm-banner.jpeg differ diff --git a/prismarine-viewer/viewer/lib/entities.ts b/prismarine-viewer/viewer/lib/entities.ts index 6afd1c34..ef73faf7 100644 --- a/prismarine-viewer/viewer/lib/entities.ts +++ b/prismarine-viewer/viewer/lib/entities.ts @@ -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')) } diff --git a/prismarine-viewer/viewer/lib/entity/EntityMesh.js b/prismarine-viewer/viewer/lib/entity/EntityMesh.js index a518171f..1350dcf0 100644 --- a/prismarine-viewer/viewer/lib/entity/EntityMesh.js +++ b/prismarine-viewer/viewer/lib/entity/EntityMesh.js @@ -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 = { diff --git a/prismarine-viewer/viewer/lib/entity/entities.json b/prismarine-viewer/viewer/lib/entity/entities.json index f005f5d1..e084a5cd 100644 --- a/prismarine-viewer/viewer/lib/entity/entities.json +++ b/prismarine-viewer/viewer/lib/entity/entities.json @@ -16390,6 +16390,10 @@ }, "render_controllers": ["controller.render.strider"] }, + "text_display": { + "identifier": "minecraft:text_display", + "geometry": {} + }, "trident": { "identifier": "minecraft:thrown_trident", "textures": { diff --git a/prismarine-viewer/viewer/lib/mesher/models.ts b/prismarine-viewer/viewer/lib/mesher/models.ts index 555efd80..91c0cf40 100644 --- a/prismarine-viewer/viewer/lib/mesher/models.ts +++ b/prismarine-viewer/viewer/lib/mesher/models.ts @@ -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 = { diff --git a/prismarine-viewer/viewer/lib/mesher/shared.ts b/prismarine-viewer/viewer/lib/mesher/shared.ts index 07b66747..b08a2bd7 100644 --- a/prismarine-viewer/viewer/lib/mesher/shared.ts +++ b/prismarine-viewer/viewer/lib/mesher/shared.ts @@ -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 diff --git a/prismarine-viewer/viewer/lib/viewer.ts b/prismarine-viewer/viewer/lib/viewer.ts index 2a396222..d16dbe81 100644 --- a/prismarine-viewer/viewer/lib/viewer.ts +++ b/prismarine-viewer/viewer/lib/viewer.ts @@ -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) } diff --git a/prismarine-viewer/viewer/lib/worldrendererCommon.ts b/prismarine-viewer/viewer/lib/worldrendererCommon.ts index ea90da37..65546de4 100644 --- a/prismarine-viewer/viewer/lib/worldrendererCommon.ts +++ b/prismarine-viewer/viewer/lib/worldrendererCommon.ts @@ -321,6 +321,10 @@ export abstract class WorldRendererCommon 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 // 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)) { diff --git a/scripts/build.js b/scripts/build.js index 9b8a85a5..d3703eea 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -52,6 +52,7 @@ exports.getSwAdditionalEntries = () => { '*.png', '*.woff', 'mesher.js', + 'manifest.json', 'worldSaveWorker.js', `textures/entity/squid/squid.png`, // everything but not .map diff --git a/scripts/dockerPrepare.mjs b/scripts/dockerPrepare.mjs index fd3680b4..ff009168 100644 --- a/scripts/dockerPrepare.mjs +++ b/scripts/dockerPrepare.mjs @@ -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 diff --git a/src/botUtils.ts b/src/botUtils.ts index b84223b2..4dadb529 100644 --- a/src/botUtils.ts +++ b/src/botUtils.ts @@ -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) + } +} diff --git a/src/controls.ts b/src/controls.ts index 6ec4ee9b..602ea81d 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -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() } diff --git a/src/flyingSquidEvents.ts b/src/flyingSquidEvents.ts index fb5b00e3..7231dd27 100644 --- a/src/flyingSquidEvents.ts +++ b/src/flyingSquidEvents.ts @@ -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) + }) + } } diff --git a/src/index.ts b/src/index.ts index 637e0655..1b65bb00 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 - 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 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 diff --git a/src/microsoftAuthflow.ts b/src/microsoftAuthflow.ts index cdfcfc77..00f4e675 100644 --- a/src/microsoftAuthflow.ts +++ b/src/microsoftAuthflow.ts @@ -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 } } diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index 87ad3882..434ad484 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -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 Resource Packs + }, + serverResourcePacks: { + text: 'Download From Server', + values: [ + 'prompt', + 'always', + 'never' + ], + } + } ], main: [ { diff --git a/src/optionsStorage.ts b/src/optionsStorage.ts index 01610cf2..53deb927 100644 --- a/src/optionsStorage.ts +++ b/src/optionsStorage.ts @@ -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 = >(proxy: T, callback: (p: T) => void) => void +type WatchValue = >(proxy: T, callback: (p: T, isChanged: boolean) => void) => void export const watchValue: WatchValue = (proxy, callback) => { const watchedProps = new Set() @@ -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) }) } } diff --git a/src/react/AddServerOrConnect.tsx b/src/react/AddServerOrConnect.tsx index 06f4fcd7..d98a74b8 100644 --- a/src/react/AddServerOrConnect.tsx +++ b/src/react/AddServerOrConnect.tsx @@ -22,7 +22,7 @@ interface Props { initialData?: BaseServerInfo parseQs?: boolean onQsConnect?: (server: BaseServerInfo) => void - defaults?: Pick + placeholders?: Pick 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 /> - setProxyOverride(value)} placeholder={defaults?.proxyOverride} /> - setUsernameOverride(value)} placeholder={defaults?.usernameOverride} /> + setProxyOverride(value)} placeholder={placeholders?.proxyOverride} /> + setUsernameOverride(value)} placeholder={placeholders?.usernameOverride} />