Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

release #228

Open
wants to merge 9 commits into
base: release
Choose a base branch
from
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.NPM.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Binary file added docs-assets/npm-banner.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion prismarine-viewer/viewer/lib/mesher/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@
}))
}

function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, type: number, biome: string, water: boolean, attr: Record<string, any>) {

Check warning on line 129 in prismarine-viewer/viewer/lib/mesher/models.ts

View workflow job for this annotation

GitHub Actions / build-and-deploy

Function 'renderLiquid' has too many parameters (7). Maximum allowed is 4
const heights: number[] = []
for (let z = -1; z <= 1; z++) {
for (let x = -1; x <= 1; x++) {
Expand Down Expand Up @@ -238,7 +238,7 @@

let needSectionRecomputeOnChange = false

function renderElement (world: World, cursor: Vec3, element: BlockElement, doAO: boolean, attr: MesherGeometryOutput, globalMatrix: any, globalShift: any, block: Block, biome: string) {

Check warning on line 241 in prismarine-viewer/viewer/lib/mesher/models.ts

View workflow job for this annotation

GitHub Actions / build-and-deploy

Function 'renderElement' has too many parameters (9). Maximum allowed is 4
const position = cursor
// const key = `${position.x},${position.y},${position.z}`
// if (!globalThis.allowedBlocks.includes(key)) return
Expand Down Expand Up @@ -484,7 +484,7 @@
}
}
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 = {
Expand Down
4 changes: 3 additions & 1 deletion prismarine-viewer/viewer/lib/mesher/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion prismarine-viewer/viewer/lib/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
6 changes: 5 additions & 1 deletion prismarine-viewer/viewer/lib/worldrendererCommon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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)) {
Expand Down
1 change: 1 addition & 0 deletions scripts/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ exports.getSwAdditionalEntries = () => {
'*.png',
'*.woff',
'mesher.js',
'manifest.json',
'worldSaveWorker.js',
`textures/entity/squid/squid.png`,
// everything but not .map
Expand Down
5 changes: 5 additions & 0 deletions scripts/dockerPrepare.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions src/botUtils.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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)
}
}
14 changes: 14 additions & 0 deletions src/flyingSquidEvents.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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)
})
}
}
18 changes: 7 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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()
})
Expand Down Expand Up @@ -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
Expand Down
56 changes: 30 additions & 26 deletions src/microsoftAuthflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -43,49 +44,52 @@ 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()}`)
}
})
.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 reader = response.body!.getReader()
const decoder = new TextDecoder('utf8')
const reader = response.body!.getReader()
const decoder = new TextDecoder('utf8')

const processText = ({ done, value = undefined as Uint8Array | undefined }) => {
if (done) {
return
}
const processText = ({ done, value = undefined as Uint8Array | undefined }) => {
if (done) {
return
}

const processChunk = (chunkStr) => {
try {
const json = JSON.parse(chunkStr)
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)
}
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)
const strings = decoder.decode(value)

for (const chunk of strings.split('\n\n')) {
processChunk(chunk)
}
for (const chunk of strings.split('\n\n')) {
processChunk(chunk)
}

return reader.read().then(processText)
}
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
}
}
Expand Down
15 changes: 14 additions & 1 deletion src/optionsGuiScheme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export const guiOptionsScheme: {
},
smoothLighting: {},
newVersionsLighting: {
text: 'Lighting in newer versions',
text: 'Lighting in Newer Versions',
},
lowMemoryMode: {
text: 'Low Memory Mode',
Expand All @@ -98,6 +98,19 @@ export const guiOptionsScheme: {
],
},
},
{
custom () {
return <Category>Resource Packs</Category>
},
serverResourcePacks: {
text: 'Download From Server',
values: [
'prompt',
'always',
'never'
],
}
}
],
main: [
{
Expand Down
9 changes: 6 additions & 3 deletions src/optionsStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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>()
Expand All @@ -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)
})
}
}
Expand Down
8 changes: 4 additions & 4 deletions src/react/AddServerOrConnect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,15 @@ 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[]
}

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')
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading