diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index a7778e331..384e198ba 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -16,6 +16,8 @@ jobs: fetch-depth: 0 - name: Install Global Dependencies run: npm install --global vercel pnpm + - run: pnpm install + - run: pnpm run build - uses: amondnet/vercel-action@v25 with: vercel-token: ${{ secrets.VERCEL_TOKEN }} diff --git a/package.json b/package.json index 47d295623..240293947 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,6 @@ ], "author": "PrismarineJS", "license": "MIT", - "release": { - "initialVersion": { - "version": "0.1.0", - "releaseNotes": "The start of something new...", - "releaseNotesWithExisting": "The start of something new..." - } - }, "dependencies": { "@dimaka/interface": "0.0.1", "@emotion/css": "^11.11.2", @@ -51,8 +44,10 @@ "lit": "^2.8.0", "minecraft-data": "^3.0.0", "net-browserify": "github:PrismarineJS/net-browserify", + "peerjs": "^1.5.0", "pretty-bytes": "^6.1.1", "prismarine-world": "^3.6.2", + "qrcode.react": "^3.1.0", "querystring": "^0.2.1", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -77,6 +72,7 @@ "cypress": "^9.5.4", "cypress-esbuild-preprocessor": "^1.0.2", "events": "^3.3.0", + "filesize": "^10.0.12", "html-webpack-plugin": "^5.5.3", "http-browserify": "^1.7.0", "http-server": "^14.1.1", @@ -97,7 +93,6 @@ "three": "0.128.0", "timers-browserify": "^2.0.12", "url-loader": "^4.1.1", - "filesize": "^10.0.12", "use-typed-event-listener": "^4.0.2", "vite": "^4.4.9", "webpack": "^5.88.2", diff --git a/src/builtinCommands.ts b/src/builtinCommands.ts index d3cf1a0b5..5190d8d05 100644 --- a/src/builtinCommands.ts +++ b/src/builtinCommands.ts @@ -2,6 +2,7 @@ import JSZip from 'jszip' import fs from 'fs' import { join } from 'path' import { fsState } from './loadSave' +import { closeWan, openToWanAndCopyJoinLink } from './localServerMultiplayer' const notImplemented = () => { return 'Not implemented yet' @@ -50,15 +51,30 @@ const exportWorld = async () => { window.exportWorld = exportWorld +const writeText = (text) => { + bot._client.emit('chat', { + message: JSON.stringify({ text }) + }) +} + const commands = [ { command: ['/download', '/export'], invoke: exportWorld }, { - command: ['/publish'], - // todo - invoke: notImplemented + command: ['/publish', '/share'], + invoke: async () => { + const text = await openToWanAndCopyJoinLink(writeText) + if (text) writeText(text) + } + }, + { + command: ['/close'], + invoke: () => { + const text = closeWan() + if (text) writeText(text) + } }, { command: '/reset-world -y', diff --git a/src/customServer.ts b/src/customServer.ts index c2a6c8229..39941d6a1 100644 --- a/src/customServer.ts +++ b/src/customServer.ts @@ -2,6 +2,7 @@ import EventEmitter from 'events' import Client from 'minecraft-protocol/src/client' +window.serverDataChannel ??= {} export const customCommunication = { sendData(data) { //@ts-ignore diff --git a/src/defaultLocalServerOptions.js b/src/defaultLocalServerOptions.js index fbd8bf22c..2a1e90564 100644 --- a/src/defaultLocalServerOptions.js +++ b/src/defaultLocalServerOptions.js @@ -29,6 +29,7 @@ module.exports = { 'header': 'Flying squid', 'footer': 'Test server' }, + keepAlive: false, 'everybody-op': true, 'max-entities': 100, 'version': '1.14.4', diff --git a/src/downloadAndOpenWorld.ts b/src/downloadAndOpenWorld.ts index 54c80599e..fb0846c5d 100644 --- a/src/downloadAndOpenWorld.ts +++ b/src/downloadAndOpenWorld.ts @@ -6,6 +6,12 @@ const getConstantFilesize = (bytes: number) => { return prettyBytes(bytes, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } +export const hasMapUrl = () => { + const qs = new URLSearchParams(window.location.search) + const mapUrl = qs.get('map') + return !!mapUrl +} + export default async () => { const qs = new URLSearchParams(window.location.search) const mapUrl = qs.get('map') diff --git a/src/globalState.ts b/src/globalState.ts index 076cddbb2..df754b4cf 100644 --- a/src/globalState.ts +++ b/src/globalState.ts @@ -103,8 +103,11 @@ export const showContextmenu = (items: ContextMenuItem[], { clientX, clientY }) // --- export const miscUiState = proxy({ + currentDisplayQr: null as string | null, currentTouch: null as boolean | null, singleplayer: false, + flyingSquid: false, + wanOpened: false, gameLoaded: false, resourcePackInstalled: false, }) @@ -148,6 +151,7 @@ window.addEventListener('beforeunload', (event) => { // todo-low maybe exclude chat? if (!isGameActive(true) && activeModalStack.at(-1)?.elem.id !== 'chat') return if (sessionStorage.lastReload && options.preventDevReloadWhilePlaying === false) return + if (options.closeConfirmation === false) return // For major browsers doning only this is enough event.preventDefault() diff --git a/src/index.ts b/src/index.ts index 344c218ea..da0dfe4e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,7 +31,7 @@ import './controls' import './dragndrop' import './browserfs' import './eruda' -import downloadAndOpenWorld from './downloadAndOpenWorld' +import downloadAndOpenWorld, { hasMapUrl } from './downloadAndOpenWorld' import net from 'net' import Stats from 'stats.js' @@ -63,7 +63,8 @@ import { isCypress, loadScript, toMajorVersion, - setLoadingScreenStatus + setLoadingScreenStatus, + resolveTimeout } from './utils' import { @@ -74,13 +75,14 @@ import { import { startLocalServer, unsupportedLocalServerFeatures } from './createLocalServer' import serverOptions from './defaultLocalServerOptions' -import { customCommunication } from './customServer' +import { clientDuplex, customCommunication } from './customServer' import updateTime from './updateTime' import { options } from './optionsStorage' import { subscribeKey } from 'valtio/utils' import _ from 'lodash' import { contro } from './controls' import { genTexturePackTextures, watchTexturepackInViewer } from './texturePack' +import { connectToPeer } from './localServerMultiplayer' //@ts-ignore window.THREE = THREE @@ -263,16 +265,17 @@ const removeAllListeners = () => { disposables = [] } -/** - * @param {{ server: any; port?: string; singleplayer: any; username: any; password: any; proxy: any; botVersion?: any; serverOverrides? }} connectOptions - */ -async function connect(connectOptions) { +async function connect(connectOptions: { + server: any; port?: string; singleplayer?: any; username: any; password: any; proxy: any; botVersion?: any; serverOverrides?; peerId?: string +}) { const menu = document.getElementById('play-screen') menu.style = 'display: none;' removePanorama() const singeplayer = connectOptions.singleplayer + const p2pMultiplayer = !!connectOptions.peerId miscUiState.singleplayer = singeplayer + miscUiState.flyingSquid = singeplayer || p2pMultiplayer const oldSetInterval = window.setInterval // @ts-ignore window.setInterval = (callback, ms) => { @@ -403,9 +406,9 @@ async function connect(connectOptions) { await loadScript(`./mc-data/${toMajorVersion(version)}.js`) } - const version = connectOptions.botVersion ?? serverOptions.version - if (version) { - await downloadMcData(version) + const downloadVersion = connectOptions.botVersion || singeplayer ? serverOptions.version : undefined + if (downloadVersion) { + await downloadMcData(downloadVersion) } if (singeplayer) { @@ -421,7 +424,6 @@ async function connect(connectOptions) { // flying-squid: 'login' -> player.login -> now sends 'login' event to the client (handled in many plugins in mineflayer) -> then 'update_health' is sent which emits 'spawn' in mineflayer setLoadingScreenStatus('Starting local server') - window.serverDataChannel ??= {} localServer = window.localServer = startLocalServer() // todo need just to call quit if started // loadingScreen.maybeRecoverable = false @@ -433,16 +435,23 @@ async function connect(connectOptions) { } } + const usingCustomCommunication = true + + const botDuplex = !p2pMultiplayer ? undefined/* clientDuplex */ : await connectToPeer(connectOptions.peerId); + setLoadingScreenStatus('Creating mineflayer bot') bot = mineflayer.createBot({ host, port, - version: connectOptions.botVersion === '' ? false : connectOptions.botVersion, + version: !connectOptions.botVersion ? false : connectOptions.botVersion, + ...singeplayer || p2pMultiplayer ? { + keepAlive: false, + stream: botDuplex, + } : {}, ...singeplayer ? { version: serverOptions.version, connect() { }, - keepAlive: false, - customCommunication + customCommunication: usingCustomCommunication ? customCommunication : undefined, } : {}, username, password, @@ -454,7 +463,8 @@ async function connect(connectOptions) { await downloadMcData(client.version) } }) - if (singeplayer) { + if (singeplayer || p2pMultiplayer) { + // p2pMultiplayer still uses the same flying-squid server const _supportFeature = bot.supportFeature bot.supportFeature = (feature) => { if (unsupportedLocalServerFeatures.includes(feature)) { @@ -463,13 +473,16 @@ async function connect(connectOptions) { return _supportFeature(feature) } - bot.emit('inject_allowed') - bot._client.emit('connect') + if (usingCustomCommunication) { + bot.emit('inject_allowed') + bot._client.emit('connect') + } } } catch (err) { handleError(err) } if (!bot) return + let p2pConnectTimeout = p2pMultiplayer ? setTimeout(() => { throw new Error('Spawn timeout. There might be error on other side, check console.') }, 20_000) : undefined hud.preload(bot) // bot.on('inject_allowed', () => { @@ -500,6 +513,7 @@ async function connect(connectOptions) { }) bot.once('spawn', () => { + if (p2pConnectTimeout) clearTimeout(p2pConnectTimeout) // todo display notification if not critical const mcData = require('minecraft-data')(bot.version) @@ -702,6 +716,7 @@ async function connect(connectOptions) { errorAbortController.abort() if (loadingScreen.hasError) return // remove loading screen, wait a second to make sure a frame has properly rendered + setLoadingScreenStatus(undefined) hideCurrentScreens() }, singeplayer ? 0 : 2500) }) @@ -744,4 +759,24 @@ window.addEventListener('keydown', (e) => { addPanoramaCubeMap() showModal(document.getElementById('title-screen')) main() -downloadAndOpenWorld() +if (hasMapUrl()) { + downloadAndOpenWorld() +} else { + window.addEventListener('hud-ready', (e) => { + // try to connect to peer + const qs = new URLSearchParams(window.location.search) + const peerId = qs.get('connectPeer') + const version = qs.get('peerVersion') + if (peerId) { + let username = options.guestUsername + if (!options.askGuestName) username = prompt('Enter your username', username) + options.guestUsername = username + connect({ + server: '', port: '', proxy: '', password: '', + username, + botVersion: version || undefined, + peerId + }) + } + }) +} diff --git a/src/localServerMultiplayer.ts b/src/localServerMultiplayer.ts new file mode 100644 index 000000000..1268b55f5 --- /dev/null +++ b/src/localServerMultiplayer.ts @@ -0,0 +1,150 @@ +import { Duplex } from 'stream' +import Peer, { DataConnection } from 'peerjs' +import Client from 'minecraft-protocol/src/client' +import { resolveTimeout, setLoadingScreenStatus } from './utils' +import { miscUiState } from './globalState' + +class CustomDuplex extends Duplex { + constructor(options, public writeAction) { + super(options) + } + + _read() { } + + _write(chunk, encoding, callback) { + this.writeAction(chunk) + callback() + } +} + +let peerInstance: Peer | undefined + +export const getJoinLink = () => { + if (!peerInstance) return + const url = new URL(window.location.href) + url.searchParams.set('connectPeer', peerInstance.id) + url.searchParams.set('peerVersion', localServer.options.version) + return url.toString() +} + +const copyJoinLink = async () => { + miscUiState.wanOpened = true + const joinLink = getJoinLink() + if (navigator.clipboard) { + await navigator.clipboard.writeText(joinLink) + } else { + window.prompt('Copy to clipboard: Ctrl+C, Enter', joinLink) + } +} + +export const openToWanAndCopyJoinLink = async (writeText: (text) => void, doCopy = true) => { + if (!localServer) return + if (peerInstance) { + if (doCopy) await copyJoinLink() + return 'Already opened to wan. Join link copied' + } + const peer = new Peer({ + debug: 3, + }) + peerInstance = peer + peer.on('connection', (connection) => { + console.log('connection') + const serverDuplex = new CustomDuplex({}, (data) => connection.send(data)) + const client = new Client(true, localServer.options.version, undefined, false, undefined, /* true */); + client.setSocket(serverDuplex) + localServer._server.emit('connection', client) + + connection.on('data', (data: any) => { + serverDuplex.push(Buffer.from(data)) + }) + // our side disconnect + const endConnection = () => { + console.log('connection.close') + serverDuplex.end() + connection.close() + }; + serverDuplex.on('end', endConnection) + serverDuplex.on('force-close', endConnection) + client.on('end', endConnection) + + const disconnected = () => { + serverDuplex.end() + client.end() + } + connection.on('iceStateChanged', (state) => { + console.log('iceStateChanged', state) + if (state === 'disconnected') { + disconnected() + } + }) + connection.on('close', disconnected) + connection.on('error', disconnected) + }) + peer.on('error', (error) => { + console.error(error) + writeText(error.message) + }) + return await new Promise(resolve => { + peer.on('open', async () => { + await copyJoinLink() + resolve('Copied join link to clipboard') + }) + setTimeout(() => { + resolve('Failed to open to wan (timeout)') + }, 5000) + }) +} + +export const closeWan = () => { + if (!peerInstance) return + peerInstance.destroy() + peerInstance = undefined + miscUiState.wanOpened = false + return 'Closed to wan' +} + +export const connectToPeer = async (peerId: string) => { + setLoadingScreenStatus('Connecting to peer server') + // todo destroy connection on error + const peer = new Peer({ + debug: 3, + }) + await resolveTimeout(new Promise(resolve => { + peer.once('open', resolve) + })) + setLoadingScreenStatus('Connecting to the peer') + const connection = peer.connect(peerId, { + serialization: 'raw', + }) + await resolveTimeout(new Promise((resolve, reject) => { + connection.once('error', (error) => { + console.log(error.type, error.name) + console.log(error) + return reject(error.message); + }) + connection.once('open', resolve) + })) + + const clientDuplex = new CustomDuplex({}, (data) => { + // todo rm debug + console.debug('sending', data.toString()) + connection.send(data); + }) + connection.on('data', (data: any) => { + console.debug('received', Buffer.from(data).toString()) + clientDuplex.push(Buffer.from(data)) + }) + connection.on('close', () => { + console.log('connection closed') + clientDuplex.end() + // bot._client.end() + // bot.end() + bot.emit('end', 'Disconnected.') + }) + connection.on('error', (error) => { + console.error(error) + clientDuplex.end() + }) + + return clientDuplex +} diff --git a/src/menus/hud.js b/src/menus/hud.js index 42dbb6f36..7437d4e17 100644 --- a/src/menus/hud.js +++ b/src/menus/hud.js @@ -9,6 +9,10 @@ export const guiIcons1_17_1 = require('minecraft-assets/minecraft-assets/data/1. export const guiIcons1_16_4 = require('minecraft-assets/minecraft-assets/data/1.16.4/gui/icons.png') class Hud extends LitElement { + firstUpdated () { + window.dispatchEvent(new CustomEvent('hud-ready', { detail: this })) + } + static get styles () { return css` :host { diff --git a/src/menus/pause_screen.js b/src/menus/pause_screen.js index 97f8706bd..5d1a6f9d0 100644 --- a/src/menus/pause_screen.js +++ b/src/menus/pause_screen.js @@ -7,6 +7,8 @@ const { subscribe } = require('valtio') const { saveWorld } = require('../builtinCommands') const { notification } = require('./notification') const { disconnect } = require('../utils') +const { subscribeKey } = require('valtio/utils') +const { closeWan, openToWanAndCopyJoinLink, getJoinLink } = require('../localServerMultiplayer') class PauseScreen extends LitElement { static get styles () { @@ -56,9 +58,14 @@ class PauseScreen extends LitElement { subscribe(fsState, () => { this.requestUpdate() }) + subscribeKey(miscUiState, 'singleplayer', () => this.requestUpdate()) + subscribeKey(miscUiState, 'wanOpened', () => this.requestUpdate()) } render () { + const joinButton = miscUiState.singleplayer + const isOpenedToWan = miscUiState.wanOpened + return html`
@@ -71,6 +78,12 @@ class PauseScreen extends LitElement { openURL('https://discord.gg/4Ucm684Fq3')}> showModal(document.getElementById('options-screen'))}> + + + ${joinButton ? html`
+ this.clickJoinLinkButton()}> + this.clickJoinLinkButton(true)}> +
` : ''} { disconnect() }}> @@ -78,6 +91,21 @@ class PauseScreen extends LitElement { ` } + async clickJoinLinkButton (qr = false) { + if (!qr && miscUiState.wanOpened) { + closeWan() + return + } + if (!miscUiState.wanOpened || !qr) { + await openToWanAndCopyJoinLink(() => { }, !qr) + } + if (qr) { + const joinLink = getJoinLink() + miscUiState.currentDisplayQr = joinLink + return + } + } + show () { this.focus() // todo? diff --git a/src/optionsStorage.ts b/src/optionsStorage.ts index f344f485b..c6715093c 100644 --- a/src/optionsStorage.ts +++ b/src/optionsStorage.ts @@ -13,6 +13,7 @@ const defaultOptions = { maxMultiplayerRenderDistance: 6, excludeCommunicationDebugEvents: [], preventDevReloadWhilePlaying: false, + closeConfirmation: true, autoFullScreen: false, mouseRawInput: false, autoExitFullscreen: false, @@ -20,7 +21,9 @@ const defaultOptions = { localServerOptions: {}, localUsername: 'wanderer', preferLoadReadonly: false, - disableLoadPrompts: false + disableLoadPrompts: false, + guestUsername: 'guest', + askGuestName: true } export const options = proxy( diff --git a/src/reactUi.jsx b/src/reactUi.jsx index e479fb092..63af67759 100644 --- a/src/reactUi.jsx +++ b/src/reactUi.jsx @@ -3,11 +3,13 @@ import { renderToDom } from '@zardoy/react-util' import { LeftTouchArea, RightTouchArea, useUsingTouch, useInterfaceState } from '@dimaka/interface' import { css } from '@emotion/css' -import { activeModalStack, isGameActive } from './globalState' +import { activeModalStack, isGameActive, miscUiState } from './globalState' import { isProbablyIphone } from './menus/components/common' // import DeathScreen from './react/DeathScreen' import { useSnapshot } from 'valtio' import { contro } from './controls' +import { QRCodeSVG } from 'qrcode.react' +import { createPortal } from 'react-dom' // todo useInterfaceState.setState({ @@ -73,11 +75,38 @@ function useIsBotAvailable() { return isGameActive(false) } +const DisplayQr = () => { + const { currentDisplayQr } = useSnapshot(miscUiState) + + if (!currentDisplayQr) return null + + return createPortal(
{ + miscUiState.currentDisplayQr = null + }} + > + +
, document.body) + +} + const App = () => { const isBotAvailable = useIsBotAvailable() if (!isBotAvailable) return null return
+
} diff --git a/src/utils.ts b/src/utils.ts index d0d093ae6..889564bea 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -138,6 +138,7 @@ export const setLoadingScreenStatus = function (status: string | undefined, isEr const loadingScreen = document.getElementById('loading-error-screen') if (status === undefined) { + loadingScreen.status = '' hideModal({ elem: loadingScreen, }, null, { force: true }) return } @@ -150,7 +151,7 @@ export const setLoadingScreenStatus = function (status: string | undefined, isEr } loadingScreen.hideDots = hideDots loadingScreen.hasError = isError - loadingScreen.status = status + loadingScreen.status = isError && loadingScreen.status ? status + `\nLast status: ${loadingScreen.status}` : status } @@ -233,3 +234,12 @@ export const openFilePicker = (specificCase?: 'resourcepack') => { picker.click() } + +export const resolveTimeout = (promise, timeout = 10000) => { + return new Promise((resolve, reject) => { + promise.then(resolve, reject) + setTimeout(() => { + reject(new Error('timeout')) + }, timeout) + }) +}