From 41684bc028f79c847606dd706d902484bcca1ef4 Mon Sep 17 00:00:00 2001 From: Max Lee Date: Tue, 28 Jan 2025 17:42:06 +0100 Subject: [PATCH 01/39] fix: title would not get rendered and rendering times be wrong (#254) --- src/react/ChatProvider.tsx | 1 + src/react/Title.stories.tsx | 6 +++--- src/react/Title.tsx | 19 ++++++++++--------- src/react/TitleProvider.tsx | 25 +++++++++++++++++++++---- 4 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/react/ChatProvider.tsx b/src/react/ChatProvider.tsx index 892360ce..66d7673f 100644 --- a/src/react/ChatProvider.tsx +++ b/src/react/ChatProvider.tsx @@ -21,6 +21,7 @@ export default () => { useEffect(() => { bot.addListener('message', (jsonMsg, position) => { + if (position === 'game_info') return // ignore action bar messages, they are handled by the TitleProvider const parts = formatMessage(jsonMsg) setMessages(m => { diff --git a/src/react/Title.stories.tsx b/src/react/Title.stories.tsx index 0bfedae4..5e30d7a2 100644 --- a/src/react/Title.stories.tsx +++ b/src/react/Title.stories.tsx @@ -23,9 +23,9 @@ export const Primary: Story = { text: 'Action bar text' }, transitionTimes: { - fadeIn: 2500, - stay: 17_500, - fadeOut: 5000 + fadeIn: 500, + stay: 3500, + fadeOut: 1000 } } } diff --git a/src/react/Title.tsx b/src/react/Title.tsx index d8971915..fd53e1c0 100644 --- a/src/react/Title.tsx +++ b/src/react/Title.tsx @@ -29,7 +29,8 @@ const Title = ({ const [mounted, setMounted] = useState(false) const [useEnterTransition, setUseEnterTransition] = useState(true) - const defaultDuration = 500 + const defaultFadeIn = 500 + const defaultFadeOut = 1000 const startStyle = { opacity: 1, transition: `${transitionTimes.fadeIn}ms ease-in-out all` } @@ -54,10 +55,10 @@ const Title = ({
= { 'text': '' } -const defaultTimings: AnimationTimes = { fadeIn: 400, stay: 3800, fadeOut: 800 } +const defaultTimings: AnimationTimes = { fadeIn: 500, stay: 3500, fadeOut: 1000 } const ticksToMs = (ticks: AnimationTimes) => { ticks.fadeIn *= 50 @@ -14,6 +16,20 @@ const ticksToMs = (ticks: AnimationTimes) => { return ticks } +const getComponent = (input: string | any) => { + if (typeof input === 'string') { + // raw json is sent + return mojangson.simplify(mojangson.parse(input)) + } else if (input.type === 'string') { + // this is used for simple chat components without any special properties + return { 'text': input.value } + } else if (input.type === 'compound') { + // this is used for complex chat components with special properties + return nbt.simplify(input) + } + return input +} + export default () => { const [title, setTitle] = useState>(defaultText) const [subtitle, setSubtitle] = useState>(defaultText) @@ -25,14 +41,14 @@ export default () => { useMemo(() => { // todo move to mineflayer bot._client.on('set_title_text', (packet) => { - setTitle(JSON.parse(packet.text)) + setTitle(getComponent(packet.text)) setOpenTitle(true) }) bot._client.on('set_title_subtitle', (packet) => { - setSubtitle(JSON.parse(packet.text)) + setSubtitle(getComponent(packet.text)) }) bot._client.on('action_bar', (packet) => { - setActionBar(JSON.parse(packet.text)) + setActionBar(getComponent(packet.text)) setOpenActionBar(true) }) bot._client.on('set_title_time', (packet) => { @@ -51,6 +67,7 @@ export default () => { bot.on('actionBar', (packet) => { + setAnimTimes({ fadeIn: 0, stay: 2000, fadeOut: 1000 }) setActionBar(packet) setOpenActionBar(true) }) From 062115c42b297639d741e0dff0cb2abc14fb2682 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Tue, 28 Jan 2025 21:29:53 +0300 Subject: [PATCH 02/39] feat: try to bring tab attention or focus it when you are finally joined the server --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index b68f3d2d..2f412954 100644 --- a/src/index.ts +++ b/src/index.ts @@ -685,6 +685,7 @@ async function connect (connectOptions: ConnectOptions) { const spawnEarlier = !singleplayer && !p2pMultiplayer // don't use spawn event, player can be dead bot.once(spawnEarlier ? 'forcedMove' : 'health', () => { + window.focus?.() errorAbortController.abort() const mcData = MinecraftData(bot.version) window.PrismarineBlock = PrismarineBlock(mcData.version.minecraftVersion!) From 3de1089a1c3c9df3c8a6218af4c29cfde23d4a62 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Wed, 29 Jan 2025 03:01:08 +0300 Subject: [PATCH 03/39] fix: disable block breaking in adventure (#252) --- src/worldInteractions.ts | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/worldInteractions.ts b/src/worldInteractions.ts index 1134d7f8..9b370613 100644 --- a/src/worldInteractions.ts +++ b/src/worldInteractions.ts @@ -192,6 +192,7 @@ class WorldInteraction { // todo this shouldnt be done in the render loop, migrate the code to dom events to avoid delays on lags update () { const inSpectator = bot.game.gameMode === 'spectator' + const inAdventure = bot.game.gameMode === 'adventure' const entity = getEntityCursor() let cursorBlock = inSpectator && !options.showCursorBlockInSpectator ? null : bot.blockAtCursor(5) if (entity) { @@ -199,7 +200,7 @@ class WorldInteraction { } let cursorBlockDiggable = cursorBlock - if (cursorBlock && !bot.canDigBlock(cursorBlock) && bot.game.gameMode !== 'creative') cursorBlockDiggable = null + if (cursorBlock && (!bot.canDigBlock(cursorBlock) || inAdventure) && bot.game.gameMode !== 'creative') cursorBlockDiggable = null const cursorChanged = cursorBlock && viewer.world.cursorBlock ? !viewer.world.cursorBlock.equals(cursorBlock.position) : viewer.world.cursorBlock !== cursorBlock @@ -393,6 +394,43 @@ const getDataFromShape = (shape) => { return { position, width, height, depth } } +// Blocks that can be interacted with in adventure mode +const activatableBlockPatterns = [ + // Containers + /^(chest|barrel|hopper|dispenser|dropper)$/, + /^.*shulker_box$/, + /^.*(furnace|smoker)$/, + /^(brewing_stand|beacon)$/, + // Crafting + /^.*table$/, + /^(grindstone|stonecutter|loom)$/, + /^.*anvil$/, + // Redstone + /^(lever|repeater|comparator|daylight_detector|observer|note_block|jukebox|bell)$/, + // Buttons + /^.*button$/, + // Doors and trapdoors + /^.*door$/, + /^.*trapdoor$/, + // Functional blocks + /^(enchanting_table|lectern|composter|respawn_anchor|lodestone|conduit)$/, + /^.*bee.*$/, + // Beds + /^.*bed$/, + // Misc + /^(cake|decorated_pot|crafter|trial_spawner|vault)$/ +] + +function isBlockActivatable (blockName: string) { + return activatableBlockPatterns.some(pattern => pattern.test(blockName)) +} + +function isLookingAtActivatableBlock () { + const cursorBlock = bot.blockAtCursor(5) + if (!cursorBlock) return false + return isBlockActivatable(cursorBlock.name) +} + export const getEntityCursor = () => { const entity = bot.nearestEntity((e) => { if (e.position.distanceTo(bot.entity.position) <= (bot.game.gameMode === 'creative' ? 5 : 3)) { From 1f5404be9dc1d9a46ae2bf7684da55434733ff15 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 29 Jan 2025 04:02:21 +0300 Subject: [PATCH 04/39] DOWNLOAD_SOUNDS flag --- Dockerfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Dockerfile b/Dockerfile index 484d158e..330445f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,9 +6,13 @@ WORKDIR /app COPY . /app # install pnpm RUN npm i -g pnpm@9.0.4 +# Build arguments +ARG DOWNLOAD_SOUNDS=false # TODO need flat --no-root-optional RUN node ./scripts/dockerPrepare.mjs RUN pnpm i +# Download sounds if flag is enabled +RUN if [ "$DOWNLOAD_SOUNDS" = "true" ] ; then node scripts/downloadSoundsMap.mjs ; fi # TODO for development # EXPOSE 9090 From a628f64d375157dfadb24fc86f96b12adc5c815f Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 29 Jan 2025 04:03:04 +0300 Subject: [PATCH 05/39] fix: deactivate mouse buttons when window loses focus (edge case) --- src/worldInteractions.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/worldInteractions.ts b/src/worldInteractions.ts index 9b370613..3c7e4fa9 100644 --- a/src/worldInteractions.ts +++ b/src/worldInteractions.ts @@ -189,8 +189,16 @@ class WorldInteraction { } } + beforeUpdateChecks () { + if (!document.hasFocus()) { + // deactive all buttson + this.buttons.fill(false) + } + } + // todo this shouldnt be done in the render loop, migrate the code to dom events to avoid delays on lags update () { + this.beforeUpdateChecks() const inSpectator = bot.game.gameMode === 'spectator' const inAdventure = bot.game.gameMode === 'adventure' const entity = getEntityCursor() From df442338f8d581511d0a9aeb0f3e45df72c4a6eb Mon Sep 17 00:00:00 2001 From: Vitaly Date: Wed, 29 Jan 2025 04:54:51 +0300 Subject: [PATCH 06/39] feat(sounds): Add sound variants and resource pack support! (#258) feat: add in-game music support! Enable it with `options.enableMusic = true` --- rsbuild.config.ts | 3 + scripts/downloadSoundsMap.mjs | 2 +- scripts/prepareSounds.mjs | 92 +++-- scripts/uploadSoundFiles.ts | 109 ++++++ scripts/uploadSounds.ts | 67 ++++ src/basicSounds.ts | 60 ++- src/connect.ts | 2 +- src/index.ts | 2 +- src/optionsStorage.ts | 1 + src/react/SoundMuffler.tsx | 2 +- src/resourcePack.ts | 9 +- .../botSoundSystem.ts} | 205 +++++------ src/sounds/musicSystem.ts | 33 ++ src/sounds/soundsMap.ts | 347 ++++++++++++++++++ src/sounds/testSounds.ts | 8 + 15 files changed, 783 insertions(+), 159 deletions(-) create mode 100644 scripts/uploadSoundFiles.ts create mode 100644 scripts/uploadSounds.ts rename src/{soundSystem.ts => sounds/botSoundSystem.ts} (51%) create mode 100644 src/sounds/musicSystem.ts create mode 100644 src/sounds/soundsMap.ts create mode 100644 src/sounds/testSounds.ts diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 933a6d02..cec3d901 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -103,6 +103,9 @@ const appConfig = defineConfig({ configJson.defaultProxy = ':8080' } fs.writeFileSync('./dist/config.json', JSON.stringify({ ...configJson, ...configLocalJson }), 'utf8') + if (fs.existsSync('./generated/sounds.js')) { + fs.copyFileSync('./generated/sounds.js', './dist/sounds.js') + } // childProcess.execSync('./scripts/prepareSounds.mjs', { stdio: 'inherit' }) // childProcess.execSync('tsx ./scripts/genMcDataTypes.ts', { stdio: 'inherit' }) // childProcess.execSync('tsx ./scripts/genPixelartTypes.ts', { stdio: 'inherit' }) diff --git a/scripts/downloadSoundsMap.mjs b/scripts/downloadSoundsMap.mjs index 3c335f8f..1e56131c 100644 --- a/scripts/downloadSoundsMap.mjs +++ b/scripts/downloadSoundsMap.mjs @@ -1,6 +1,6 @@ import fs from 'fs' -const url = 'https://github.com/zardoy/minecraft-web-client/raw/sounds-generated/sounds.js' +const url = 'https://github.com/zardoy/minecraft-web-client/raw/sounds-generated/sounds-v2.js' const savePath = 'dist/sounds.js' fetch(url).then(res => res.text()).then(data => { fs.writeFileSync(savePath, data, 'utf8') diff --git a/scripts/prepareSounds.mjs b/scripts/prepareSounds.mjs index 8f3e5bef..f9b8cd60 100644 --- a/scripts/prepareSounds.mjs +++ b/scripts/prepareSounds.mjs @@ -10,26 +10,31 @@ import { build } from 'esbuild' const __dirname = path.dirname(fileURLToPath(new URL(import.meta.url))) -const targetedVersions = ['1.20.1', '1.19.2', '1.18.2', '1.17.1', '1.16.5', '1.15.2', '1.14.4', '1.13.2', '1.12.2', '1.11.2', '1.10.2', '1.9.4', '1.8.9'] +const targetedVersions = ['1.21.1', '1.20.6', '1.20.1', '1.19.2', '1.18.2', '1.17.1', '1.16.5', '1.15.2', '1.14.4', '1.13.2', '1.12.2', '1.11.2', '1.10.2', '1.9.4', '1.8.9'] /** @type {{name, size, hash}[]} */ let prevSounds = null const burgerDataUrl = (version) => `https://raw.githubusercontent.com/Pokechu22/Burger/gh-pages/${version}.json` const burgerDataPath = './generated/burger.json' +const EXISTING_CACHE_PATH = './generated/existing-sounds-cache.json' // const perVersionData: Record { +const downloadAllSoundsAndCreateMap = async () => { + let existingSoundsCache = {} + try { + existingSoundsCache = JSON.parse(await fs.promises.readFile(EXISTING_CACHE_PATH, 'utf8')) + } catch (err) {} const { versions } = await getVersionList() const lastVersion = versions.filter(version => !version.id.includes('w'))[0] // if (lastVersion.id !== targetedVersions[0]) throw new Error('last version is not the same as targetedVersions[0], update') - for (const targetedVersion of targetedVersions) { - const versionData = versions.find(x => x.id === targetedVersion) - if (!versionData) throw new Error('no version data for ' + targetedVersion) - console.log('Getting assets for version', targetedVersion) + for (const version of targetedVersions) { + const versionData = versions.find(x => x.id === version) + if (!versionData) throw new Error('no version data for ' + version) + console.log('Getting assets for version', version) const { assetIndex } = await fetch(versionData.url).then((r) => r.json()) /** @type {{objects: {[a: string]: { size, hash }}}} */ const index = await fetch(assetIndex.url).then((r) => r.json()) @@ -45,26 +50,30 @@ const downloadAllSounds = async () => { const changedSize = soundAssets.filter(x => prevSoundNames.has(x.name) && prevSounds.find(y => y.name === x.name).size !== x.size) console.log('changed size', changedSize.map(x => ({ name: x.name, prev: prevSounds.find(y => y.name === x.name).size, curr: x.size }))) if (addedSounds.length || changedSize.length) { - soundsPathVersionsRemap[targetedVersion] = [...addedSounds, ...changedSize].map(x => x.name.replace('minecraft/sounds/', '').replace('.ogg', '')) + soundsPathVersionsRemap[version] = [...addedSounds, ...changedSize].map(x => x.name.replace('minecraft/sounds/', '').replace('.ogg', '')) } if (addedSounds.length) { - console.log('downloading new sounds for version', targetedVersion) - downloadSounds(addedSounds, targetedVersion + '/') + console.log('downloading new sounds for version', version) + downloadSounds(version, addedSounds, version + '/') } if (changedSize.length) { - console.log('downloading changed sounds for version', targetedVersion) - downloadSounds(changedSize, targetedVersion + '/') + console.log('downloading changed sounds for version', version) + downloadSounds(version, changedSize, version + '/') } } else { - console.log('downloading sounds for version', targetedVersion) - downloadSounds(soundAssets) + console.log('downloading sounds for version', version) + downloadSounds(version, soundAssets) } prevSounds = soundAssets } async function downloadSound({ name, hash, size }, namePath, log) { + const cached = + !!namePath.replace('.ogg', '.mp3').split('/').reduce((acc, cur) => acc?.[cur], existingSoundsCache.sounds) || + !!namePath.replace('.ogg', '.ogg').split('/').reduce((acc, cur) => acc?.[cur], existingSoundsCache.sounds) const savePath = path.resolve(`generated/sounds/${namePath}`) - if (fs.existsSync(savePath)) { + if (cached || fs.existsSync(savePath)) { // console.log('skipped', name) + existingSoundsCache.sounds[namePath] = true return } log() @@ -86,7 +95,12 @@ const downloadAllSounds = async () => { } writer.close() } - async function downloadSounds(assets, addPath = '') { + async function downloadSounds(version, assets, addPath = '') { + if (addPath && existingSoundsCache.sounds[version]) { + console.log('using existing sounds for version', version) + return + } + console.log(version, 'have to download', assets.length, 'sounds') for (let i = 0; i < assets.length; i += 5) { await Promise.all(assets.slice(i, i + 5).map((asset, j) => downloadSound(asset, `${addPath}${asset.name}`, () => { console.log('downloading', addPath, asset.name, i + j, '/', assets.length) @@ -95,6 +109,7 @@ const downloadAllSounds = async () => { } fs.writeFileSync('./generated/soundsPathVersionsRemap.json', JSON.stringify(soundsPathVersionsRemap), 'utf8') + fs.writeFileSync(EXISTING_CACHE_PATH, JSON.stringify(existingSoundsCache), 'utf8') } const lightpackOverrideSounds = { @@ -106,7 +121,8 @@ const lightpackOverrideSounds = { // this is not done yet, will be used to select only sounds for bundle (most important ones) const isSoundWhitelisted = (name) => name.startsWith('random/') || name.startsWith('note/') || name.endsWith('/say1') || name.endsWith('/death') || (name.startsWith('mob/') && name.endsWith('/step1')) || name.endsWith('/swoop1') || /* name.endsWith('/break1') || */ name.endsWith('dig/stone1') -const ffmpeg = 'C:/Users/Vitaly/Documents/LosslessCut-win-x64/resources/ffmpeg.exe' // will be ffmpeg-static +// const ffmpeg = 'C:/Users/Vitaly/Documents/LosslessCut-win-x64/resources/ffmpeg.exe' // can be ffmpeg-static +const ffmpegExec = 'ffmpeg' const maintainBitrate = true const scanFilesDeep = async (root, onOggFile) => { @@ -127,7 +143,7 @@ const convertSounds = async () => { }) const convertSound = async (i) => { - const proc = promisify(exec)(`${ffmpeg} -i "${toConvert[i]}" -y -codec:a libmp3lame ${maintainBitrate ? '-qscale:a 2' : ''} "${toConvert[i].replace('.ogg', '.mp3')}"`) + const proc = promisify(exec)(`${ffmpegExec} -i "${toConvert[i]}" -y -codec:a libmp3lame ${maintainBitrate ? '-qscale:a 2' : ''} "${toConvert[i].replace('.ogg', '.mp3')}"`) // pipe stdout to the console proc.child.stdout.pipe(process.stdout) await proc @@ -147,8 +163,8 @@ const getSoundsMap = (burgerData) => { } const writeSoundsMap = async () => { - // const burgerData = await fetch(burgerDataUrl(targetedVersions[0])).then((r) => r.json()) - // fs.writeFileSync(burgerDataPath, JSON.stringify(burgerData[0].sounds), 'utf8') + const burgerData = await fetch(burgerDataUrl(targetedVersions[0])).then((r) => r.json()) + fs.writeFileSync(burgerDataPath, JSON.stringify(burgerData[0].sounds), 'utf8') const allSoundsMapOutput = {} let prevMap @@ -174,16 +190,22 @@ const writeSoundsMap = async () => { // const includeSound = isSoundWhitelisted(firstName) // if (!includeSound) continue const mostUsedSound = sounds.sort((a, b) => b.weight - a.weight)[0] - const targetSound = sounds[0] // outputMap[id] = { subtitle, sounds: mostUsedSound } // outputMap[id] = { subtitle, sounds } - const soundFilePath = `generated/sounds/minecraft/sounds/${targetSound.name}.mp3` + // const soundFilePath = `generated/sounds/minecraft/sounds/${targetSound.name}.mp3` // if (!fs.existsSync(soundFilePath)) { // console.warn('no sound file', targetSound.name) // continue // } + let outputUseSoundLine = [] + const minWeight = sounds.reduce((acc, cur) => cur.weight ? Math.min(acc, cur.weight) : acc, sounds[0].weight ?? 1) + if (isNaN(minWeight)) debugger + for (const sound of sounds) { + if (sound.weight && isNaN(sound.weight)) debugger + outputUseSoundLine.push(`${sound.volume ?? 1};${sound.name};${sound.weight ?? minWeight}`) + } const key = `${id};${name}` - outputIdMap[key] = `${targetSound.volume ?? 1};${targetSound.name}` + outputIdMap[key] = outputUseSoundLine.join(',') if (prevMap && prevMap[key]) { keysStats.same++ } else { @@ -221,7 +243,7 @@ const makeSoundsBundle = async () => { const allSoundsMeta = { format: 'mp3', - baseUrl: 'https://raw.githubusercontent.com/zardoy/minecraft-web-client/sounds-generated/sounds/' + baseUrl: `https://raw.githubusercontent.com/${process.env.REPO_SLUG}/sounds/sounds/` } await build({ @@ -235,9 +257,25 @@ const makeSoundsBundle = async () => { }, metafile: true, }) + // copy also to generated/sounds.js + fs.copyFileSync('./dist/sounds.js', './generated/sounds.js') } -// downloadAllSounds() -// convertSounds() -// writeSoundsMap() -// makeSoundsBundle() +const action = process.argv[2] +if (action) { + const execFn = { + download: downloadAllSoundsAndCreateMap, + convert: convertSounds, + write: writeSoundsMap, + bundle: makeSoundsBundle, + }[action] + + if (execFn) { + execFn() + } +} else { + // downloadAllSoundsAndCreateMap() + // convertSounds() + // writeSoundsMap() + makeSoundsBundle() +} diff --git a/scripts/uploadSoundFiles.ts b/scripts/uploadSoundFiles.ts new file mode 100644 index 00000000..e8677c87 --- /dev/null +++ b/scripts/uploadSoundFiles.ts @@ -0,0 +1,109 @@ +import fetch from 'node-fetch'; +import * as fs from 'fs'; +import * as path from 'path'; +import { glob } from 'glob'; + +// Git details +const REPO_SLUG = process.env.REPO_SLUG; +const owner = REPO_SLUG.split('/')[0]; +const repo = REPO_SLUG.split('/')[1]; +const branch = "sounds"; + +// GitHub token for authentication +const token = process.env.GITHUB_TOKEN; + +// GitHub API endpoint +const baseUrl = `https://api.github.com/repos/${owner}/${repo}/contents`; + +const headers = { + Authorization: `token ${token}`, + 'Content-Type': 'application/json' +}; + +async function getShaForExistingFile(repoFilePath: string): Promise { + const url = `${baseUrl}/${repoFilePath}?ref=${branch}`; + const response = await fetch(url, { headers }); + if (response.status === 404) { + return null; // File does not exist + } + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.statusText}`); + } + const data = await response.json(); + return data.sha; +} + +async function uploadFiles() { + const commitMessage = "Upload multiple files via script"; + const committer = { + name: "GitHub", + email: "noreply@github.com" + }; + + const filesToUpload = glob.sync("generated/sounds/**/*.mp3").map(localPath => { + const repoPath = localPath.replace(/^generated\//, ''); + return { localPath, repoPath }; + }); + + const files = await Promise.all(filesToUpload.map(async file => { + const content = fs.readFileSync(file.localPath, 'base64'); + const sha = await getShaForExistingFile(file.repoPath); + return { + path: file.repoPath, + mode: "100644", + type: "blob", + sha: sha || undefined, + content: content + }; + })); + + const treeResponse = await fetch(`${baseUrl}/git/trees`, { + method: 'POST', + headers: headers, + body: JSON.stringify({ + base_tree: null, + tree: files + }) + }); + + if (!treeResponse.ok) { + throw new Error(`Failed to create tree: ${treeResponse.statusText}`); + } + + const treeData = await treeResponse.json(); + + const commitResponse = await fetch(`${baseUrl}/git/commits`, { + method: 'POST', + headers: headers, + body: JSON.stringify({ + message: commitMessage, + tree: treeData.sha, + parents: [branch], + committer: committer + }) + }); + + if (!commitResponse.ok) { + throw new Error(`Failed to create commit: ${commitResponse.statusText}`); + } + + const commitData = await commitResponse.json(); + + const updateRefResponse = await fetch(`${baseUrl}/git/refs/heads/${branch}`, { + method: 'PATCH', + headers: headers, + body: JSON.stringify({ + sha: commitData.sha + }) + }); + + if (!updateRefResponse.ok) { + throw new Error(`Failed to update ref: ${updateRefResponse.statusText}`); + } + + console.log("Files uploaded successfully"); +} + +uploadFiles().catch(error => { + console.error("Error uploading files:", error); +}); diff --git a/scripts/uploadSounds.ts b/scripts/uploadSounds.ts new file mode 100644 index 00000000..b0e9ecd7 --- /dev/null +++ b/scripts/uploadSounds.ts @@ -0,0 +1,67 @@ +import fs from 'fs' + +// GitHub details +const owner = "zardoy"; +const repo = "minecraft-web-client"; +const branch = "sounds-generated"; +const filePath = "dist/sounds.js"; // Local file path +const repoFilePath = "sounds-v2.js"; // Path in the repo + +// GitHub token for authentication +const token = process.env.GITHUB_TOKEN; + +// GitHub API endpoint +const baseUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${repoFilePath}`; + +const headers = { + Authorization: `token ${token}`, + 'Content-Type': 'application/json' +}; + +async function getShaForExistingFile(): Promise { + const url = `${baseUrl}?ref=${branch}`; + const response = await fetch(url, { headers }); + if (response.status === 404) { + return null; // File does not exist + } + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.statusText}`); + } + const data = await response.json(); + return data.sha; +} + +async function uploadFile() { + const content = fs.readFileSync(filePath, 'utf8'); + const base64Content = Buffer.from(content).toString('base64'); + const sha = await getShaForExistingFile(); + console.log('got sha') + + const body = { + message: "Update sounds.js", + content: base64Content, + branch: branch, + committer: { + name: "GitHub", + email: "noreply@github.com" + }, + sha: sha || undefined + }; + + const response = await fetch(baseUrl, { + method: 'PUT', + headers: headers, + body: JSON.stringify(body) + }); + + if (!response.ok) { + throw new Error(`Failed to upload file: ${response.statusText}`); + } + + const responseData = await response.json(); + console.log("File uploaded successfully:", responseData); +} + +uploadFile().catch(error => { + console.error("Error uploading file:", error); +}); diff --git a/src/basicSounds.ts b/src/basicSounds.ts index 48bdcac6..6c2b5f4f 100644 --- a/src/basicSounds.ts +++ b/src/basicSounds.ts @@ -1,3 +1,4 @@ +import { subscribeKey } from 'valtio/utils' import { options } from './optionsStorage' import { isCypress } from './standaloneUtils' import { reportWarningOnce } from './utils' @@ -5,9 +6,14 @@ import { reportWarningOnce } from './utils' let audioContext: AudioContext const sounds: Record = {} +// Track currently playing sounds and their gain nodes +const activeSounds: Array<{ source: AudioBufferSourceNode; gainNode: GainNode; volumeMultiplier: number }> = [] +window.activeSounds = activeSounds + // load as many resources on page load as possible instead on demand as user can disable internet connection after he thinks the page is loaded const loadingSounds = [] as string[] const convertedSounds = [] as string[] + export async function loadSound (path: string, contents = path) { if (loadingSounds.includes(path)) return true loadingSounds.push(path) @@ -24,15 +30,15 @@ export async function loadSound (path: string, contents = path) { loadingSounds.splice(loadingSounds.indexOf(path), 1) } -export const loadOrPlaySound = async (url, soundVolume = 1) => { +export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = 500) => { const soundBuffer = sounds[url] if (!soundBuffer) { const start = Date.now() const cancelled = await loadSound(url) - if (cancelled || Date.now() - start > 500) return + if (cancelled || Date.now() - start > loadTimeout) return } - await playSound(url) + return playSound(url, soundVolume) } export async function playSound (url, soundVolume = 1) { @@ -49,6 +55,7 @@ export async function playSound (url, soundVolume = 1) { for (const [soundName, sound] of Object.entries(sounds)) { if (convertedSounds.includes(soundName)) continue + // eslint-disable-next-line no-await-in-loop sounds[soundName] = await audioContext.decodeAudioData(sound) convertedSounds.push(soundName) } @@ -66,4 +73,51 @@ export async function playSound (url, soundVolume = 1) { gainNode.connect(audioContext.destination) gainNode.gain.value = volume source.start(0) + + // Add to active sounds + activeSounds.push({ source, gainNode, volumeMultiplier: soundVolume }) + + const callbacks = [] as Array<() => void> + source.onended = () => { + // Remove from active sounds when finished + const index = activeSounds.findIndex(s => s.source === source) + if (index !== -1) activeSounds.splice(index, 1) + + for (const callback of callbacks) { + callback() + } + callbacks.length = 0 + } + + return { + onEnded (callback: () => void) { + callbacks.push(callback) + }, + } } + +export function stopAllSounds () { + for (const { source } of activeSounds) { + try { + source.stop() + } catch (err) { + console.warn('Failed to stop sound:', err) + } + } + activeSounds.length = 0 +} + +export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number) { + const normalizedVolume = newVolume / 100 + for (const { gainNode, volumeMultiplier } of activeSounds) { + try { + gainNode.gain.value = normalizedVolume * volumeMultiplier + } catch (err) { + console.warn('Failed to change sound volume:', err) + } + } +} + +subscribeKey(options, 'volume', () => { + changeVolumeOfCurrentlyPlayingSounds(options.volume) +}) diff --git a/src/connect.ts b/src/connect.ts index b7023880..e45769f7 100644 --- a/src/connect.ts +++ b/src/connect.ts @@ -2,7 +2,7 @@ import { versionsByMinecraftVersion } from 'minecraft-data' import minecraftInitialDataJson from '../generated/minecraft-initial-data.json' import { AuthenticatedAccount } from './react/ServersListProvider' import { setLoadingScreenStatus } from './utils' -import { downloadSoundsIfNeeded } from './soundSystem' +import { downloadSoundsIfNeeded } from './sounds/botSoundSystem' import { miscUiState } from './globalState' export type ConnectOptions = { diff --git a/src/index.ts b/src/index.ts index 2f412954..2643cccf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -85,7 +85,7 @@ import { fsState } from './loadSave' import { watchFov } from './rendererUtils' import { loadInMemorySave } from './react/SingleplayerProvider' -import { downloadSoundsIfNeeded } from './soundSystem' +import { downloadSoundsIfNeeded } from './sounds/botSoundSystem' import { ua } from './react/utils' import { handleMovementStickDelta, joystickPointer } from './react/TouchAreasControls' import { possiblyHandleStateVariable } from './googledrive' diff --git a/src/optionsStorage.ts b/src/optionsStorage.ts index 5d88fe38..1220baca 100644 --- a/src/optionsStorage.ts +++ b/src/optionsStorage.ts @@ -25,6 +25,7 @@ const defaultOptions = { chatOpacityOpened: 100, messagesLimit: 200, volume: 50, + enableMusic: false, // fov: 70, fov: 75, guiScale: 3, diff --git a/src/react/SoundMuffler.tsx b/src/react/SoundMuffler.tsx index d8571353..0151b97b 100644 --- a/src/react/SoundMuffler.tsx +++ b/src/react/SoundMuffler.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { useSnapshot } from 'valtio' import { hideCurrentModal } from '../globalState' -import { lastPlayedSounds } from '../soundSystem' +import { lastPlayedSounds } from '../sounds/botSoundSystem' import { options } from '../optionsStorage' import Button from './Button' import Screen from './Screen' diff --git a/src/resourcePack.ts b/src/resourcePack.ts index d7bbdc47..124d7e6c 100644 --- a/src/resourcePack.ts +++ b/src/resourcePack.ts @@ -157,7 +157,7 @@ const getSizeFromImage = async (filePath: string) => { return probeImg.width } -export const getActiveTexturepackBasePath = async () => { +export const getActiveResourcepackBasePath = async () => { if (await existsAsync('/resourcepack/pack.mcmeta')) { return '/resourcepack' } @@ -198,7 +198,7 @@ const getFilesMapFromDir = async (dir: string) => { } export const getResourcepackTiles = async (type: 'blocks' | 'items', existingTextures: string[]) => { - const basePath = await getActiveTexturepackBasePath() + const basePath = await getActiveResourcepackBasePath() if (!basePath) return let firstTextureSize: number | undefined const namespaces = await fs.promises.readdir(join(basePath, 'assets')) @@ -282,7 +282,7 @@ const prepareBlockstatesAndModels = async () => { viewer.world.customBlockStates = {} viewer.world.customModels = {} const usedTextures = new Set() - const basePath = await getActiveTexturepackBasePath() + const basePath = await getActiveResourcepackBasePath() if (!basePath) return if (appStatusState.status) { setLoadingScreenStatus('Reading resource pack blockstates and models') @@ -361,6 +361,7 @@ export const onAppLoad = () => { customEvents.on('mineflayerBotCreated', () => { // todo also handle resourcePack const handleResourcePackRequest = async (packet) => { + console.log('Received resource pack request', packet) if (options.serverResourcePacks === 'never') return const promptMessagePacket = ('promptMessage' in packet && packet.promptMessage) ? packet.promptMessage : undefined const promptMessageText = promptMessagePacket ? '' : 'Do you want to use server resource pack?' @@ -397,7 +398,7 @@ export const onAppLoad = () => { } const updateAllReplacableTextures = async () => { - const basePath = await getActiveTexturepackBasePath() + const basePath = await getActiveResourcepackBasePath() const setCustomCss = async (path: string | null, varName: string, repeat = 1) => { if (path && await existsAsync(path)) { const contents = await fs.promises.readFile(path, 'base64') diff --git a/src/soundSystem.ts b/src/sounds/botSoundSystem.ts similarity index 51% rename from src/soundSystem.ts rename to src/sounds/botSoundSystem.ts index d0caf01f..4bf014ac 100644 --- a/src/soundSystem.ts +++ b/src/sounds/botSoundSystem.ts @@ -1,50 +1,51 @@ -import { subscribeKey } from 'valtio/utils' import { Vec3 } from 'vec3' -import { versionToMajor, versionToNumber, versionsMapToMajor } from 'prismarine-viewer/viewer/prepare/utils' +import { versionToNumber } from 'prismarine-viewer/viewer/prepare/utils' import { loadScript } from 'prismarine-viewer/viewer/lib/utils' import type { Block } from 'prismarine-block' -import { miscUiState } from './globalState' -import { options } from './optionsStorage' -import { loadOrPlaySound } from './basicSounds' -import { showNotification } from './react/NotificationProvider' +import { subscribeKey } from 'valtio/utils' +import { miscUiState } from '../globalState' +import { options } from '../optionsStorage' +import { loadOrPlaySound } from '../basicSounds' +import { getActiveResourcepackBasePath, resourcePackState } from '../resourcePack' +import { createSoundMap, SoundMap } from './soundsMap' +import { musicSystem } from './musicSystem' -const globalObject = window as { - allSoundsMap?: Record>, - allSoundsVersionedMap?: Record, +let soundMap: SoundMap | undefined + +const updateResourcePack = async () => { + if (!soundMap) return + soundMap.activeResourcePackBasePath = await getActiveResourcepackBasePath() ?? undefined } +let musicInterval: ReturnType | null = null + subscribeKey(miscUiState, 'gameLoaded', async () => { - if (!miscUiState.gameLoaded) return - const soundsLegacyMap = window.allSoundsVersionedMap as Record - const { allSoundsMap } = globalObject - const allSoundsMeta = window.allSoundsMeta as { format: string, baseUrl: string } - if (!allSoundsMap) { + if (!miscUiState.gameLoaded || !loadedData.sounds) { + stopMusicSystem() + soundMap?.quit() return } - const allSoundsMajor = versionsMapToMajor(allSoundsMap) - const soundsMap = allSoundsMajor[versionToMajor(bot.version)] ?? Object.values(allSoundsMajor)[0] - - if (!soundsMap || !miscUiState.gameLoaded || !loadedData.sounds) { - return - } - - // const soundsPerId = Object.fromEntries(Object.entries(soundsMap).map(([id, sound]) => [+id.split(';')[0], sound])) - const soundsPerName = Object.fromEntries(Object.entries(soundsMap).map(([id, sound]) => [id.split(';')[1], sound])) + console.log(`Loading sounds for version ${bot.version}. Resourcepack state: ${JSON.stringify(resourcePackState)}`) + soundMap = createSoundMap(bot.version) ?? undefined + if (!soundMap) return + void updateResourcePack() + startMusicSystem() const playGeneralSound = async (soundKey: string, position?: Vec3, volume = 1, pitch?: number) => { - if (!options.volume) return - const soundStaticData = soundsPerName[soundKey]?.split(';') - if (!soundStaticData) return - const soundVolume = +soundStaticData[0]! - const soundPath = soundStaticData[1]! - const versionedSound = getVersionedSound(bot.version, soundPath, Object.entries(soundsLegacyMap)) - // todo test versionedSound - const url = allSoundsMeta.baseUrl.replace(/\/$/, '') + (versionedSound ? `/${versionedSound}` : '') + '/minecraft/sounds/' + soundPath + '.' + allSoundsMeta.format - const isMuted = options.mutedSounds.includes(soundKey) || options.mutedSounds.includes(soundPath) || options.volume === 0 + if (!options.volume || !soundMap) return + const soundData = await soundMap.getSoundUrl(soundKey, volume) + if (!soundData) return + + const isMuted = options.mutedSounds.includes(soundKey) || options.volume === 0 if (position) { if (!isMuted) { - viewer.playSound(position, url, soundVolume * Math.max(Math.min(volume, 1), 0) * (options.volume / 100), Math.max(Math.min(pitch ?? 1, 2), 0.5)) + viewer.playSound( + position, + soundData.url, + soundData.volume * (options.volume / 100), + Math.max(Math.min(pitch ?? 1, 2), 0.5) + ) } if (getDistance(bot.entity.position, position) < 4 * 16) { lastPlayedSounds.lastServerPlayed[soundKey] ??= { count: 0, last: 0 } @@ -53,7 +54,7 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { } } else { if (!isMuted) { - await loadOrPlaySound(url, volume) + await loadOrPlaySound(soundData.url, volume) } lastPlayedSounds.lastClientPlayed.push(soundKey) if (lastPlayedSounds.lastClientPlayed.length > 10) { @@ -61,84 +62,72 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { } } } + + const musicStartCheck = async (force = false) => { + if (!soundMap) return + // 20% chance to start music + if (Math.random() > 0.2 && !force && !options.enableMusic) return + + const musicKeys = ['music.game'] + if (bot.game.gameMode === 'creative') { + musicKeys.push('music.creative') + } + const randomMusicKey = musicKeys[Math.floor(Math.random() * musicKeys.length)] + const soundData = await soundMap.getSoundUrl(randomMusicKey) + if (!soundData) return + await musicSystem.playMusic(soundData.url, soundData.volume) + } + + function startMusicSystem () { + if (musicInterval) return + musicInterval = setInterval(() => { + void musicStartCheck() + }, 10_000) + } + + window.forceStartMusic = () => { + void musicStartCheck(true) + } + + + function stopMusicSystem () { + if (musicInterval) { + clearInterval(musicInterval) + musicInterval = null + } + } + const playHardcodedSound = async (soundKey: string, position?: Vec3, volume = 1, pitch?: number) => { await playGeneralSound(soundKey, position, volume, pitch) } + bot.on('soundEffectHeard', async (soundId, position, volume, pitch) => { await playHardcodedSound(soundId, position, volume, pitch) }) + bot.on('hardcodedSoundEffectHeard', async (soundIdNum, soundCategory, position, volume, pitch) => { const fixOffset = versionToNumber('1.20.4') === versionToNumber(bot.version) ? -1 : 0 const soundKey = loadedData.sounds[soundIdNum + fixOffset]?.name if (soundKey === undefined) return await playGeneralSound(soundKey, position, volume, pitch) }) - // workaround as mineflayer doesn't support soundEvent + bot._client.on('sound_effect', async (packet) => { const soundResource = packet['soundEvent']?.resource as string | undefined if (packet.soundId !== 0 || !soundResource) return const pos = new Vec3(packet.x / 8, packet.y / 8, packet.z / 8) await playHardcodedSound(soundResource.replace('minecraft:', ''), pos, packet.volume, packet.pitch) }) + bot.on('entityHurt', async (entity) => { if (entity.id === bot.entity.id) { await playHardcodedSound('entity.player.hurt') } }) - const useBlockSound = (blockName: string, category: string, fallback: string) => { - blockName = { - // todo somehow generated, not full - grass_block: 'grass', - tall_grass: 'grass', - fern: 'grass', - large_fern: 'grass', - dead_bush: 'grass', - seagrass: 'grass', - tall_seagrass: 'grass', - kelp: 'grass', - kelp_plant: 'grass', - sugar_cane: 'grass', - bamboo: 'grass', - vine: 'grass', - nether_sprouts: 'grass', - nether_wart: 'grass', - twisting_vines: 'grass', - weeping_vines: 'grass', - - cobblestone: 'stone', - stone_bricks: 'stone', - mossy_stone_bricks: 'stone', - cracked_stone_bricks: 'stone', - chiseled_stone_bricks: 'stone', - stone_brick_slab: 'stone', - stone_brick_stairs: 'stone', - stone_brick_wall: 'stone', - polished_granite: 'stone', - }[blockName] ?? blockName - const key = 'block.' + blockName + '.' + category - return soundsPerName[key] ? key : fallback - } - - const getStepSound = (blockUnder: Block) => { - // const soundsMap = globalObject.allSoundsMap?.[bot.version] - // if (!soundsMap) return - // let soundResult = 'block.stone.step' - // for (const x of Object.keys(soundsMap).map(n => n.split(';')[1])) { - // const match = /block\.(.+)\.step/.exec(x) - // const block = match?.[1] - // if (!block) continue - // if (loadedData.blocksByName[block]?.name === blockUnder.name) { - // soundResult = x - // break - // } - // } - return useBlockSound(blockUnder.name, 'step', 'block.stone.step') - } - let lastStepSound = 0 const movementHappening = async () => { - if (!bot.player) return // no info yet + if (!bot.player || !soundMap) return // no info yet const VELOCITY_THRESHOLD = 0.1 const { x, z, y } = bot.player.entity.velocity if (bot.entity.onGround && Math.abs(x) < VELOCITY_THRESHOLD && (Math.abs(z) > VELOCITY_THRESHOLD || Math.abs(y) > VELOCITY_THRESHOLD)) { @@ -146,9 +135,9 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { if (Date.now() - lastStepSound > 300) { const blockUnder = bot.world.getBlock(bot.entity.position.offset(0, -1, 0)) if (blockUnder) { - const stepSound = getStepSound(blockUnder) + const stepSound = soundMap.getStepSound(blockUnder.name) if (stepSound) { - await playHardcodedSound(stepSound, undefined, 0.6)// todo not sure why 0.6 + await playHardcodedSound(stepSound, undefined, 0.6) lastStepSound = Date.now() } } @@ -157,8 +146,8 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { } const playBlockBreak = async (blockName: string, position?: Vec3) => { - const sound = useBlockSound(blockName, 'break', 'block.stone.break') - + if (!soundMap) return + const sound = soundMap.getBreakSound(blockName) await playHardcodedSound(sound, position, 0.6, 1) } @@ -200,8 +189,8 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { if (effectId === 1010) { console.log('play record', data) } - // todo add support for all current world events }) + let diggingBlock: Block | null = null customEvents.on('digStart', () => { diggingBlock = bot.blockAtCursor(5) @@ -214,40 +203,14 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { } registerEvents() - - // 1.20+ soundEffectHeard is broken atm - // bot._client.on('packet', (data, { name }, buffer) => { - // if (name === 'sound_effect') { - // console.log(data, buffer) - // } - // }) }) -// todo -// const music = { -// activated: false, -// playing: '', -// activate () { -// const gameMusic = Object.entries(globalObject.allSoundsMap?.[bot.version] ?? {}).find(([id, sound]) => sound.includes('music.game')) -// if (!gameMusic) return -// const soundPath = gameMusic[0].split(';')[1] -// const next = () => {} -// } -// } - -const getVersionedSound = (version: string, item: string, itemsMapSortedEntries: Array<[string, string[]]>) => { - const verNumber = versionToNumber(version) - for (const [itemsVer, items] of itemsMapSortedEntries) { - // 1.18 < 1.18.1 - // 1.13 < 1.13.2 - if (items.includes(item) && verNumber <= versionToNumber(itemsVer)) { - return itemsVer - } - } -} +subscribeKey(resourcePackState, 'resourcePackInstalled', async () => { + await updateResourcePack() +}) export const downloadSoundsIfNeeded = async () => { - if (!globalObject.allSoundsMap) { + if (!window.allSoundsMap) { try { await loadScript('./sounds.js') } catch (err) { diff --git a/src/sounds/musicSystem.ts b/src/sounds/musicSystem.ts new file mode 100644 index 00000000..ecabf43e --- /dev/null +++ b/src/sounds/musicSystem.ts @@ -0,0 +1,33 @@ +import { loadOrPlaySound } from '../basicSounds' +import { options } from '../optionsStorage' + +class MusicSystem { + private currentMusic: string | null = null + + async playMusic (url: string, musicVolume = 1) { + if (!options.enableMusic || this.currentMusic) return + + try { + const { onEnded } = await loadOrPlaySound(url, 0.5 * musicVolume, 5000) ?? {} + + if (!onEnded) return + + this.currentMusic = url + + onEnded(() => { + this.currentMusic = null + }) + } catch (err) { + console.warn('Failed to play music:', err) + this.currentMusic = null + } + } + + stopMusic () { + if (this.currentMusic) { + this.currentMusic = null + } + } +} + +export const musicSystem = new MusicSystem() diff --git a/src/sounds/soundsMap.ts b/src/sounds/soundsMap.ts new file mode 100644 index 00000000..94c5a4d8 --- /dev/null +++ b/src/sounds/soundsMap.ts @@ -0,0 +1,347 @@ +import fs from 'fs' +import path from 'path' +import { versionsMapToMajor, versionToMajor, versionToNumber } from 'prismarine-viewer/viewer/prepare/utils' + +import { stopAllSounds } from '../basicSounds' +import { musicSystem } from './musicSystem' + +interface SoundMeta { + format: string + baseUrl: string +} + +interface SoundData { + volume: number + path: string +} + +interface SoundMapData { + allSoundsMap: Record> + soundsLegacyMap: Record + soundsMeta: SoundMeta +} + +interface BlockSoundMap { + [blockName: string]: string +} + +interface SoundEntry { + file: string + weight: number + volume: number +} + +export class SoundMap { + private readonly soundsPerName: Record + private readonly existingResourcePackPaths: Set + public activeResourcePackBasePath: string | undefined + + constructor ( + private readonly soundData: SoundMapData, + private readonly version: string + ) { + const allSoundsMajor = versionsMapToMajor(soundData.allSoundsMap) + const soundsMap = allSoundsMajor[versionToMajor(version)] ?? Object.values(allSoundsMajor)[0] + this.soundsPerName = Object.fromEntries( + Object.entries(soundsMap).map(([id, soundsStr]) => { + const sounds = soundsStr.split(',').map(s => { + const [volume, name, weight] = s.split(';') + if (isNaN(Number(volume))) throw new Error('volume is not a number') + if (isNaN(Number(weight))) { + // debugger + throw new TypeError('weight is not a number') + } + return { + file: name, + weight: Number(weight), + volume: Number(volume) + } + }) + return [id.split(';')[1], sounds] + }) + ) + } + + async updateExistingResourcePackPaths () { + if (!this.activeResourcePackBasePath) return + // todo support sounds.js from resource pack + const soundsBasePath = path.join(this.activeResourcePackBasePath, 'assets/minecraft/sounds') + // scan recursively for sounds files + const scan = async (dir: string) => { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }) + for (const entry of entries) { + const entryPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + await scan(entryPath) + } else if (entry.isFile() && entry.name.endsWith('.ogg')) { + const relativePath = path.relative(soundsBasePath, entryPath) + this.existingResourcePackPaths.add(relativePath) + } + } + } + + await scan(soundsBasePath) + } + + async getSoundUrl (soundKey: string, volume = 1): Promise<{ url: string; volume: number } | undefined> { + const sounds = this.soundsPerName[soundKey] + if (!sounds?.length) return undefined + + // Pick a random sound based on weights + const totalWeight = sounds.reduce((sum, s) => sum + s.weight, 0) + let random = Math.random() * totalWeight + const sound = sounds.find(s => { + random -= s.weight + return random <= 0 + }) ?? sounds[0] + + const versionedSound = this.getVersionedSound(sound.file) + + let url = this.soundData.soundsMeta.baseUrl.replace(/\/$/, '') + + (versionedSound ? `/${versionedSound}` : '') + + '/minecraft/sounds/' + + sound.file + + '.' + + this.soundData.soundsMeta.format + + // Try loading from resource pack file first + if (this.activeResourcePackBasePath) { + const tryFormat = async (format: string) => { + try { + const resourcePackPath = path.join(this.activeResourcePackBasePath!, `/assets/minecraft/sounds/${sound.file}.${format}`) + const fileData = await fs.promises.readFile(resourcePackPath) + url = `data:audio/${format};base64,${fileData.toString('base64')}` + return true + } catch (err) { + } + } + const success = await tryFormat(this.soundData.soundsMeta.format) + if (!success && this.soundData.soundsMeta.format !== 'ogg') { + await tryFormat('ogg') + } + } + + return { + url, + volume: sound.volume * Math.max(Math.min(volume, 1), 0) + } + } + + private getVersionedSound (item: string): string | undefined { + const verNumber = versionToNumber(this.version) + const entries = Object.entries(this.soundData.soundsLegacyMap) + for (const [itemsVer, items] of entries) { + if (items.includes(item) && verNumber <= versionToNumber(itemsVer)) { + return itemsVer + } + } + return undefined + } + + getBlockSound (blockName: string, category: string, fallback: string): string { + const mappedName = blockSoundAliases[blockName] ?? blockName + const key = `block.${mappedName}.${category}` + return this.soundsPerName[key] ? key : fallback + } + + getStepSound (blockName: string): string { + return this.getBlockSound(blockName, 'step', 'block.stone.step') + } + + getBreakSound (blockName: string): string { + return this.getBlockSound(blockName, 'break', 'block.stone.break') + } + + quit () { + musicSystem.stopMusic() + stopAllSounds() + } +} + +export function createSoundMap (version: string): SoundMap | null { + const globalObject = window as { + allSoundsMap?: Record>, + allSoundsVersionedMap?: Record, + allSoundsMeta?: { format: string, baseUrl: string } + } + if (!globalObject.allSoundsMap) return null + return new SoundMap({ + allSoundsMap: globalObject.allSoundsMap, + soundsLegacyMap: globalObject.allSoundsVersionedMap ?? {}, + soundsMeta: globalObject.allSoundsMeta! + }, version) +} + +// Block name mappings for sound effects +const blockSoundAliases: BlockSoundMap = { + // Grass-like blocks + grass_block: 'grass', + tall_grass: 'grass', + fern: 'grass', + large_fern: 'grass', + dead_bush: 'grass', + seagrass: 'grass', + tall_seagrass: 'grass', + kelp: 'grass', + kelp_plant: 'grass', + sugar_cane: 'grass', + bamboo: 'grass', + vine: 'grass', + nether_sprouts: 'grass', + nether_wart: 'grass', + twisting_vines: 'grass', + weeping_vines: 'grass', + sweet_berry_bush: 'grass', + glow_lichen: 'grass', + moss_carpet: 'grass', + moss_block: 'grass', + hanging_roots: 'grass', + spore_blossom: 'grass', + small_dripleaf: 'grass', + big_dripleaf: 'grass', + flowering_azalea: 'grass', + azalea: 'grass', + azalea_leaves: 'grass', + flowering_azalea_leaves: 'grass', + + // Stone-like blocks + cobblestone: 'stone', + stone_bricks: 'stone', + mossy_stone_bricks: 'stone', + cracked_stone_bricks: 'stone', + chiseled_stone_bricks: 'stone', + stone_brick_slab: 'stone', + stone_brick_stairs: 'stone', + stone_brick_wall: 'stone', + polished_granite: 'stone', + granite: 'stone', + andesite: 'stone', + diorite: 'stone', + polished_andesite: 'stone', + polished_diorite: 'stone', + deepslate: 'deepslate', + cobbled_deepslate: 'deepslate', + polished_deepslate: 'deepslate', + deepslate_bricks: 'deepslate_bricks', + deepslate_tiles: 'deepslate_tiles', + calcite: 'stone', + tuff: 'stone', + smooth_stone: 'stone', + smooth_sandstone: 'stone', + smooth_quartz: 'stone', + smooth_red_sandstone: 'stone', + + // Wood-like blocks + oak_planks: 'wood', + spruce_planks: 'wood', + birch_planks: 'wood', + jungle_planks: 'wood', + acacia_planks: 'wood', + dark_oak_planks: 'wood', + crimson_planks: 'wood', + warped_planks: 'wood', + oak_log: 'wood', + spruce_log: 'wood', + birch_log: 'wood', + jungle_log: 'wood', + acacia_log: 'wood', + dark_oak_log: 'wood', + crimson_stem: 'stem', + warped_stem: 'stem', + + // Metal blocks + iron_block: 'metal', + gold_block: 'metal', + copper_block: 'copper', + exposed_copper: 'copper', + weathered_copper: 'copper', + oxidized_copper: 'copper', + netherite_block: 'netherite_block', + ancient_debris: 'ancient_debris', + lodestone: 'lodestone', + chain: 'chain', + anvil: 'anvil', + chipped_anvil: 'anvil', + damaged_anvil: 'anvil', + + // Glass blocks + glass: 'glass', + glass_pane: 'glass', + white_stained_glass: 'glass', + orange_stained_glass: 'glass', + magenta_stained_glass: 'glass', + light_blue_stained_glass: 'glass', + yellow_stained_glass: 'glass', + lime_stained_glass: 'glass', + pink_stained_glass: 'glass', + gray_stained_glass: 'glass', + light_gray_stained_glass: 'glass', + cyan_stained_glass: 'glass', + purple_stained_glass: 'glass', + blue_stained_glass: 'glass', + brown_stained_glass: 'glass', + green_stained_glass: 'glass', + red_stained_glass: 'glass', + black_stained_glass: 'glass', + tinted_glass: 'glass', + + // Wool blocks + white_wool: 'wool', + orange_wool: 'wool', + magenta_wool: 'wool', + light_blue_wool: 'wool', + yellow_wool: 'wool', + lime_wool: 'wool', + pink_wool: 'wool', + gray_wool: 'wool', + light_gray_wool: 'wool', + cyan_wool: 'wool', + purple_wool: 'wool', + blue_wool: 'wool', + brown_wool: 'wool', + green_wool: 'wool', + red_wool: 'wool', + black_wool: 'wool', + + // Nether blocks + netherrack: 'netherrack', + nether_bricks: 'nether_bricks', + red_nether_bricks: 'nether_bricks', + nether_wart_block: 'wart_block', + warped_wart_block: 'wart_block', + soul_sand: 'soul_sand', + soul_soil: 'soul_soil', + basalt: 'basalt', + polished_basalt: 'basalt', + blackstone: 'gilded_blackstone', + gilded_blackstone: 'gilded_blackstone', + + // Amethyst blocks + amethyst_block: 'amethyst_block', + amethyst_cluster: 'amethyst_cluster', + large_amethyst_bud: 'large_amethyst_bud', + medium_amethyst_bud: 'medium_amethyst_bud', + small_amethyst_bud: 'small_amethyst_bud', + + // Miscellaneous + sand: 'sand', + red_sand: 'sand', + gravel: 'gravel', + snow: 'snow', + snow_block: 'snow', + powder_snow: 'powder_snow', + ice: 'glass', + packed_ice: 'glass', + blue_ice: 'glass', + slime_block: 'slime_block', + honey_block: 'honey_block', + scaffolding: 'scaffolding', + ladder: 'ladder', + lantern: 'lantern', + soul_lantern: 'lantern', + pointed_dripstone: 'pointed_dripstone', + dripstone_block: 'dripstone_block', + rooted_dirt: 'rooted_dirt', + sculk_sensor: 'sculk_sensor', + shroomlight: 'shroomlight' +} diff --git a/src/sounds/testSounds.ts b/src/sounds/testSounds.ts new file mode 100644 index 00000000..1f493549 --- /dev/null +++ b/src/sounds/testSounds.ts @@ -0,0 +1,8 @@ +import { createSoundMap } from './soundsMap' + +//@ts-expect-error +globalThis.window = {} +require('../../generated/sounds.js') + +const soundMap = createSoundMap('1.20.1') +console.log(soundMap?.getSoundUrl('ambient.cave')) From 2490fbe211e90b6c3bc938d85affd1e01484047a Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 29 Jan 2025 05:26:46 +0300 Subject: [PATCH 07/39] fix(regression): minecity and some other maps broke --- pnpm-lock.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a3a5ef1..db949885 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,7 +162,7 @@ importers: version: 6.1.1 prismarine-provider-anvil: specifier: github:zardoy/prismarine-provider-anvil#everything - version: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/a3a7d031069373cc3e0cd05e54512dd9461ca34b(minecraft-data@3.83.1) + version: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.83.1) prosemirror-example-setup: specifier: ^1.2.2 version: 1.2.2 @@ -7185,8 +7185,8 @@ packages: resolution: {tarball: https://codeload.github.com/zardoy/prismarine-physics/tar.gz/353e25b800149393f40539ec381218be44cbb03b} version: 1.9.0 - prismarine-provider-anvil@https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/a3a7d031069373cc3e0cd05e54512dd9461ca34b: - resolution: {tarball: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/a3a7d031069373cc3e0cd05e54512dd9461ca34b} + prismarine-provider-anvil@https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7: + resolution: {tarball: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7} version: 2.8.0 prismarine-realms@1.3.2: @@ -12788,7 +12788,7 @@ snapshots: prismarine-entity: 2.3.1 prismarine-item: 1.16.0 prismarine-nbt: 2.5.0 - prismarine-provider-anvil: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/a3a7d031069373cc3e0cd05e54512dd9461ca34b(minecraft-data@3.83.1) + prismarine-provider-anvil: https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.83.1) prismarine-windows: 2.9.0 prismarine-world: https://codeload.github.com/zardoy/prismarine-world/tar.gz/ab2146c9933eef3247c3f64446de4ccc2c484c7c rambda: 9.2.0 @@ -17858,7 +17858,7 @@ snapshots: prismarine-nbt: 2.5.0 vec3: 0.1.8 - prismarine-provider-anvil@https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/a3a7d031069373cc3e0cd05e54512dd9461ca34b(minecraft-data@3.83.1): + prismarine-provider-anvil@https://codeload.github.com/zardoy/prismarine-provider-anvil/tar.gz/1d548fac63fe977c8281f0a9a522b37e4d92d0b7(minecraft-data@3.83.1): dependencies: prismarine-block: https://codeload.github.com/zardoy/prismarine-block/tar.gz/853c559bff2b402863ee9a75b125a3ca320838f9(prismarine-registry@1.11.0) prismarine-chunk: https://codeload.github.com/zardoy/prismarine-chunk/tar.gz/e68e9a423b5b1907535878fb636f12c28a1a9374(minecraft-data@3.83.1) From 317b84943afb531b64bf35ac76887c7d5b876979 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Wed, 29 Jan 2025 19:33:55 +0300 Subject: [PATCH 08/39] feat: Add block highlight color option to allow always it to be blue (useful!) --- src/optionsGuiScheme.tsx | 13 +++++++++++++ src/optionsStorage.ts | 1 + src/worldInteractions.ts | 14 +++++++++++++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index ef7fc1c6..eab35642 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -233,6 +233,19 @@ export const guiOptionsScheme: { chatSelect: { }, }, + { + custom () { + return World + }, + highlightBlockColor: { + text: 'Block Highlight Color', + values: [ + ['auto', 'Auto'], + ['blue', 'Blue'], + ['classic', 'Classic'] + ], + }, + }, { custom () { return Sign Editor diff --git a/src/optionsStorage.ts b/src/optionsStorage.ts index 1220baca..cf1eccd5 100644 --- a/src/optionsStorage.ts +++ b/src/optionsStorage.ts @@ -96,6 +96,7 @@ const defaultOptions = { displayBossBars: false, // boss bar overlay was removed for some reason, enable safely disabledUiParts: [] as string[], neighborChunkUpdates: true, + highlightBlockColor: 'auto' as 'auto' | 'blue' | 'classic', } function getDefaultTouchControlsPositions () { diff --git a/src/worldInteractions.ts b/src/worldInteractions.ts index 3c7e4fa9..b1811569 100644 --- a/src/worldInteractions.ts +++ b/src/worldInteractions.ts @@ -6,6 +6,7 @@ import * as THREE from 'three' import { Vec3 } from 'vec3' import { LineMaterial } from 'three-stdlib' import { Entity } from 'prismarine-entity' +import { subscribeKey } from 'valtio/utils' import destroyStage0 from '../assets/destroy_stage_0.png' import destroyStage1 from '../assets/destroy_stage_1.png' import destroyStage2 from '../assets/destroy_stage_2.png' @@ -149,7 +150,16 @@ class WorldInteraction { const inCreative = bot.game.gameMode === 'creative' const pixelRatio = viewer.renderer.getPixelRatio() viewer.world.threejsCursorLineMaterial = new LineMaterial({ - color: inCreative ? 0x40_80_ff : 0x00_00_00, + color: (() => { + switch (options.highlightBlockColor) { + case 'blue': + return 0x40_80_ff + case 'classic': + return 0x00_00_00 + default: + return inCreative ? 0x40_80_ff : 0x00_00_00 + } + })(), linewidth: Math.max(pixelRatio * 0.7, 1) * 2, // dashed: true, // dashSize: 5, @@ -158,6 +168,8 @@ class WorldInteraction { upLineMaterial() // todo use gamemode update only bot.on('game', upLineMaterial) + // Update material when highlight color setting changes + subscribeKey(options, 'highlightBlockColor', upLineMaterial) } activateEntity (entity: Entity) { From 28f0546f3b07789f04da5c527ace01c5233f43e9 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 31 Jan 2025 03:52:07 +0300 Subject: [PATCH 09/39] feat(ui): add back, close buttons in settings for mobile --- src/controls.ts | 19 ++++++------------- src/globalState.ts | 7 +++++++ src/react/Button.tsx | 10 +++++++++- src/react/OptionsItems.tsx | 11 ++++++++++- 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/src/controls.ts b/src/controls.ts index 559379a3..c936518a 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -7,7 +7,7 @@ import { ControMax } from 'contro-max/build/controMax' import { CommandEventArgument, SchemaCommandInput } from 'contro-max/build/types' import { stringStartsWith } from 'contro-max/build/stringUtils' import { UserOverrideCommand, UserOverridesConfig } from 'contro-max/build/types/store' -import { isGameActive, showModal, gameAdditionalState, activeModalStack, hideCurrentModal, miscUiState, loadedGameState, hideModal } from './globalState' +import { isGameActive, showModal, gameAdditionalState, activeModalStack, hideCurrentModal, miscUiState, loadedGameState, hideModal, hideAllModals } from './globalState' import { goFullscreen, pointerLock, reloadChunks } from './utils' import { options } from './optionsStorage' import { openPlayerInventory } from './inventoryWindows' @@ -740,19 +740,12 @@ window.addEventListener('keydown', (e) => { if (activeModalStack.length) { const hideAll = e.ctrlKey || e.metaKey if (hideAll) { - while (activeModalStack.length > 0) { - hideCurrentModal(undefined, () => { - if (!activeModalStack.length) { - pointerLock.justHitEscape = true - } - }) - } + hideAllModals() } else { - hideCurrentModal(undefined, () => { - if (!activeModalStack.length) { - pointerLock.justHitEscape = true - } - }) + hideCurrentModal() + } + if (activeModalStack.length === 0) { + pointerLock.justHitEscape = true } } else if (pointerLock.hasPointerLock) { document.exitPointerLock?.() diff --git a/src/globalState.ts b/src/globalState.ts index fbaabe81..a15e1837 100644 --- a/src/globalState.ts +++ b/src/globalState.ts @@ -86,6 +86,13 @@ export const hideCurrentModal = (_data?, onHide?: () => void) => { } } +export const hideAllModals = () => { + while (activeModalStack.length > 0) { + if (!hideModal()) break + } + return activeModalStack.length === 0 +} + export const openOptionsMenu = (group: OptionsGroupType) => { showModal({ reactType: `options-${group}` }) } diff --git a/src/react/Button.tsx b/src/react/Button.tsx index 14ecc38c..23bef5d0 100644 --- a/src/react/Button.tsx +++ b/src/react/Button.tsx @@ -13,6 +13,7 @@ interface Props extends React.ComponentProps<'button'> { children?: React.ReactNode inScreen?: boolean rootRef?: Ref + overlayColor?: string } const ButtonContext = createContext({ @@ -23,7 +24,7 @@ export const ButtonProvider: FC<{ children, onClick }> = ({ children, onClick }) return {children} } -export default (({ label, icon, children, inScreen, rootRef, type = 'button', postLabel, ...args }) => { +export default (({ label, icon, children, inScreen, rootRef, type = 'button', postLabel, overlayColor, ...args }) => { const ctx = useContext(ButtonContext) const onClick = (e) => { @@ -45,6 +46,13 @@ export default (({ label, icon, children, inScreen, rootRef, type = 'button', po {label} {postLabel} {children} + {overlayColor &&
} }) satisfies FC diff --git a/src/react/OptionsItems.tsx b/src/react/OptionsItems.tsx index bc431bd2..9879aeb4 100644 --- a/src/react/OptionsItems.tsx +++ b/src/react/OptionsItems.tsx @@ -3,11 +3,12 @@ import { noCase } from 'change-case' import { titleCase } from 'title-case' import { useMemo } from 'react' import { options, qsOptions } from '../optionsStorage' -import { miscUiState } from '../globalState' +import { hideAllModals, miscUiState } from '../globalState' import Button from './Button' import Slider from './Slider' import Screen from './Screen' import { showOptionsModal } from './SelectOption' +import PixelartIcon, { pixelartIcons } from './PixelartIcon' type GeneralItem = { id?: string @@ -188,10 +189,18 @@ interface Props { } export default ({ items, title, backButtonAction }: Props) => { + const { currentTouch } = useSnapshot(miscUiState) + return
+ {currentTouch && ( +
+
+ )} {items.map((element, i) => { // make sure its unique! return From 14b7cb039a1211c0ba146842ef1778089b84538f Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 31 Jan 2025 04:40:15 +0300 Subject: [PATCH 10/39] feat(ui): Improve server list UI - Implement concurrent server status fetching with rate limiting - Show offline status for servers that cannot be reached --- src/react/AddServerOrConnect.tsx | 5 +- src/react/ServersListProvider.tsx | 87 +++++++++++++++++++++++-------- src/react/Singleplayer.tsx | 13 ++++- src/react/simpleHooks.ts | 12 +++++ 4 files changed, 91 insertions(+), 26 deletions(-) diff --git a/src/react/AddServerOrConnect.tsx b/src/react/AddServerOrConnect.tsx index e3fb2a15..0287779b 100644 --- a/src/react/AddServerOrConnect.tsx +++ b/src/react/AddServerOrConnect.tsx @@ -3,7 +3,7 @@ import Screen from './Screen' import Input from './Input' import Button from './Button' import SelectGameVersion from './SelectGameVersion' -import { useIsSmallWidth } from './simpleHooks' +import { useIsSmallWidth, usePassesWindowDimensions } from './simpleHooks' export interface BaseServerInfo { ip: string @@ -32,6 +32,7 @@ interface Props { const ELEMENTS_WIDTH = 190 export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQs, onQsConnect, placeholders, accounts, versions, allowAutoConnect }: Props) => { + const isSmallHeight = !usePassesWindowDimensions(null, 350) const qsParams = parseQs ? new URLSearchParams(window.location.search) : undefined const qsParamName = qsParams?.get('name') const qsParamIp = qsParams?.get('ip') @@ -101,7 +102,7 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ } setServerIp(value)} /> setServerPort(value)} placeholder='25565' /> -
Overrides:
+ {isSmallHeight ?
:
Overrides:
}
ip.replace(/https?:\/\//, '').replace(/\/(:|$)/, '') +const FETCH_DELAY = 100 // ms between each request +const MAX_CONCURRENT_REQUESTS = 10 + const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersList?: string[] }) => { const [proxies, setProxies] = useState(localStorage['proxies'] ? JSON.parse(localStorage['proxies']) : getInitialProxies()) const [selectedProxy, setSelectedProxy] = useState(proxyQs ?? localStorage.getItem('selectedProxy') ?? proxies?.[0] ?? '') @@ -198,30 +202,69 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL useUtilsEffect(({ signal }) => { const update = async () => { - for (const server of serversListSorted) { - const isInLocalNetwork = server.ip.startsWith('192.168.') || server.ip.startsWith('10.') || server.ip.startsWith('172.') || server.ip.startsWith('127.') || server.ip.startsWith('localhost') - if (isInLocalNetwork || signal.aborted) continue - // eslint-disable-next-line no-await-in-loop - await fetch(`https://api.mcstatus.io/v2/status/java/${server.ip}`, { - // TODO: bounty for this who fix it - // signal - }).then(async r => r.json()).then((data: ServerResponse) => { - const versionClean = data.version?.name_raw.replace(/^[^\d.]+/, '') - if (!versionClean) return - setAdditionalData(old => { - return ({ - ...old, - [server.ip]: { - formattedText: data.motd?.raw ?? '', - textNameRight: `${versionClean} ${data.players?.online ?? '??'}/${data.players?.max ?? '??'}`, - icon: data.icon, - } - }) - }) + const queue = serversListSorted + .map(server => { + const isInLocalNetwork = server.ip.startsWith('192.168.') || + server.ip.startsWith('10.') || + server.ip.startsWith('172.') || + server.ip.startsWith('127.') || + server.ip.startsWith('localhost') || + server.ip.startsWith(':') + + const VALID_IP_OR_DOMAIN = server.ip.includes('.') + if (isInLocalNetwork || signal.aborted || !VALID_IP_OR_DOMAIN) return null + + return server }) + .filter(x => x !== null) + + const activeRequests = new Set>() + + let lastRequestStart = 0 + for (const server of queue) { + // Wait if at concurrency limit + if (activeRequests.size >= MAX_CONCURRENT_REQUESTS) { + // eslint-disable-next-line no-await-in-loop + await Promise.race(activeRequests) + } + + // Create and track new request + // eslint-disable-next-line @typescript-eslint/no-loop-func + const request = new Promise(resolve => { + setTimeout(async () => { + try { + lastRequestStart = Date.now() + if (signal.aborted) return + const response = await fetch(`https://api.mcstatus.io/v2/status/java/${server.ip}`, { signal }) + const data: ServerResponse = await response.json() + const versionClean = data.version?.name_raw.replace(/^[^\d.]+/, '') + + setAdditionalData(old => ({ + ...old, + [server.ip]: { + formattedText: data.motd?.raw ?? '', + textNameRight: data.online ? + `${versionClean} ${data.players?.online ?? '??'}/${data.players?.max ?? '??'}` : + '', + icon: data.icon, + offline: !data.online + } + })) + } finally { + activeRequests.delete(request) + resolve() + } + }, lastRequestStart ? Math.max(0, FETCH_DELAY - (Date.now() - lastRequestStart)) : 0) + }) + + activeRequests.add(request) } + + // Wait for remaining requests + await Promise.all(activeRequests) } - void update().catch((err) => {}) + + void update() }, [serversListSorted]) const isEditScreenModal = useIsModalActive('editServer') @@ -394,10 +437,10 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL name: server.index.toString(), title: server.name || server.ip, detail: (server.versionOverride ?? '') + ' ' + (server.usernameOverride ?? ''), - // lastPlayed: server.lastJoined, formattedTextOverride: additional?.formattedText, worldNameRight: additional?.textNameRight ?? '', iconSrc: additional?.icon, + offline: additional?.offline } })} initialProxies={{ diff --git a/src/react/Singleplayer.tsx b/src/react/Singleplayer.tsx index 79c08ab8..d8291d90 100644 --- a/src/react/Singleplayer.tsx +++ b/src/react/Singleplayer.tsx @@ -12,6 +12,7 @@ import Button from './Button' import Tabs from './Tabs' import MessageFormattedString from './MessageFormattedString' import { useIsSmallWidth } from './simpleHooks' +import PixelartIcon from './PixelartIcon' export interface WorldProps { name: string @@ -26,9 +27,10 @@ export interface WorldProps { onFocus?: (name: string) => void onInteraction?(interaction: 'enter' | 'space') elemRef?: React.Ref + offline?: boolean } -const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction, iconSrc, formattedTextOverride, worldNameRight, elemRef }: WorldProps & { ref?: React.Ref }) => { +const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction, iconSrc, formattedTextOverride, worldNameRight, elemRef, offline }: WorldProps & { ref?: React.Ref }) => { const timeRelativeFormatted = useMemo(() => { if (!lastPlayed) return '' const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) @@ -60,7 +62,14 @@ const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus,
{title}
-
{worldNameRight}
+
+ {offline ? ( + + + Offline + + ) : worldNameRight} +
{formattedTextOverride ?
diff --git a/src/react/simpleHooks.ts b/src/react/simpleHooks.ts index ec9c88e1..20607f15 100644 --- a/src/react/simpleHooks.ts +++ b/src/react/simpleHooks.ts @@ -7,6 +7,18 @@ export const useIsSmallWidth = () => { return useMedia(SMALL_SCREEN_MEDIA.replace('@media ', '')) } +export const usePassesWindowDimensions = (minWidth: number | null = null, minHeight: number | null = null) => { + let media = '(' + if (minWidth !== null) { + media += `min-width: ${minWidth}px, ` + } + if (minHeight !== null) { + media += `min-height: ${minHeight}px, ` + } + media += ')' + return useMedia(media) +} + export const useCopyKeybinding = (getCopyText: () => string | undefined) => { useUtilsEffect(({ signal }) => { addEventListener('keydown', (e) => { From f75538598191300dfd23f159ff0975c035649552 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Fri, 31 Jan 2025 05:52:55 +0300 Subject: [PATCH 11/39] refactor: rm loadedGameState, add a way to copy server resource pack to local --- src/controls.ts | 27 ++++++++++++++----- src/globalState.ts | 10 +++---- src/index.ts | 7 ----- src/optionsGuiScheme.tsx | 4 +-- src/react/ChatProvider.tsx | 4 +-- src/react/MinimapProvider.tsx | 7 ++--- src/react/MobileTopButtons.tsx | 2 +- src/react/PlayerListOverlayProvider.tsx | 5 ++-- src/resourcePack.ts | 36 +++++++++++++++++++++---- 9 files changed, 66 insertions(+), 36 deletions(-) diff --git a/src/controls.ts b/src/controls.ts index c936518a..c15a09f3 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -7,7 +7,7 @@ import { ControMax } from 'contro-max/build/controMax' import { CommandEventArgument, SchemaCommandInput } from 'contro-max/build/types' import { stringStartsWith } from 'contro-max/build/stringUtils' import { UserOverrideCommand, UserOverridesConfig } from 'contro-max/build/types/store' -import { isGameActive, showModal, gameAdditionalState, activeModalStack, hideCurrentModal, miscUiState, loadedGameState, hideModal, hideAllModals } from './globalState' +import { isGameActive, showModal, gameAdditionalState, activeModalStack, hideCurrentModal, miscUiState, hideModal, hideAllModals } from './globalState' import { goFullscreen, pointerLock, reloadChunks } from './utils' import { options } from './optionsStorage' import { openPlayerInventory } from './inventoryWindows' @@ -19,7 +19,7 @@ import { showOptionsModal } from './react/SelectOption' import widgets from './react/widgets' import { getItemFromBlock } from './chatUtils' import { gamepadUiCursorState, moveGamepadCursorByPx } from './react/GamepadUiCursor' -import { completeTexturePackInstall, resourcePackState } from './resourcePack' +import { completeTexturePackInstall, copyServerResourcePackToRegular, resourcePackState } from './resourcePack' import { showNotification } from './react/NotificationProvider' import { lastConnectOptions } from './react/AppStatusProvider' @@ -450,7 +450,12 @@ contro.on('release', ({ command }) => { // hard-coded keybindings -export const f3Keybinds = [ +export const f3Keybinds: Array<{ + key?: string, + action: () => void, + mobileTitle: string + enabled?: () => boolean +}> = [ { key: 'KeyA', action () { @@ -496,9 +501,9 @@ export const f3Keybinds = [ key: 'KeyT', async action () { // TODO! - if (resourcePackState.resourcePackInstalled || loadedGameState.usingServerResourcePack) { + if (resourcePackState.resourcePackInstalled || gameAdditionalState.usingServerResourcePack) { showNotification('Reloading textures...') - await completeTexturePackInstall('default', 'default', loadedGameState.usingServerResourcePack) + await completeTexturePackInstall('default', 'default', gameAdditionalState.usingServerResourcePack) } }, mobileTitle: 'Reload Textures' @@ -539,7 +544,15 @@ export const f3Keybinds = [ const proxyPing = await bot['pingProxy']() void showOptionsModal(`${username}: last known total latency (ping): ${playerPing}. Connected to ${lastConnectOptions.value?.proxy} with current ping ${proxyPing}. Player UUID: ${uuid}`, []) }, - mobileTitle: 'Show Proxy & Ping Details' + mobileTitle: 'Show Proxy & Ping Details', + enabled: () => !!lastConnectOptions.value?.proxy + }, + { + action () { + void copyServerResourcePackToRegular() + }, + mobileTitle: 'Copy Server Resource Pack', + enabled: () => !!gameAdditionalState.usingServerResourcePack } ] @@ -548,7 +561,7 @@ document.addEventListener('keydown', (e) => { if (!isGameActive(false)) return if (hardcodedPressedKeys.has('F3')) { const keybind = f3Keybinds.find((v) => v.key === e.code) - if (keybind) { + if (keybind && (keybind.enabled?.() ?? true)) { keybind.action() e.stopPropagation() } diff --git a/src/globalState.ts b/src/globalState.ts index a15e1837..e09db053 100644 --- a/src/globalState.ts +++ b/src/globalState.ts @@ -146,12 +146,6 @@ export const miscUiState = proxy({ displaySearchInput: false, }) -export const loadedGameState = proxy({ - username: '', - serverIp: '' as string | null, - usingServerResourcePack: false, -}) - export const isGameActive = (foregroundCheck: boolean) => { if (foregroundCheck && activeModalStack.length) return false return miscUiState.gameLoaded @@ -165,7 +159,9 @@ export const gameAdditionalState = proxy({ isSprinting: false, isSneaking: false, isZooming: false, - warps: [] as WorldWarp[] + warps: [] as WorldWarp[], + + usingServerResourcePack: false, }) window.gameAdditionalState = gameAdditionalState diff --git a/src/index.ts b/src/index.ts index 2643cccf..2c120bbd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,7 +53,6 @@ import { hideModal, insertActiveModalStack, isGameActive, - loadedGameState, miscUiState, showModal } from './globalState' @@ -884,12 +883,6 @@ async function connect (connectOptions: ConnectOptions) { console.log('Done!') - // todo - onGameLoad(async () => { - loadedGameState.serverIp = server.host ?? null - loadedGameState.username = username - }) - if (appStatusState.isError) return setTimeout(() => { // todo diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index eab35642..47ec5350 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -2,7 +2,7 @@ import { useRef, useState } from 'react' import { useSnapshot } from 'valtio' import { openURL } from 'prismarine-viewer/viewer/lib/simpleUtils' import { noCase } from 'change-case' -import { loadedGameState, miscUiState, openOptionsMenu, showModal } from './globalState' +import { gameAdditionalState, miscUiState, openOptionsMenu, showModal } from './globalState' import { AppOptions, options } from './optionsStorage' import Button from './react/Button' import { OptionMeta, OptionSlider } from './react/OptionsItems' @@ -157,7 +157,7 @@ export const guiOptionsScheme: { { custom () { const { resourcePackInstalled } = useSnapshot(resourcePackState) - const { usingServerResourcePack } = useSnapshot(loadedGameState) + const { usingServerResourcePack } = useSnapshot(gameAdditionalState) const { enabledResourcepack } = useSnapshot(options) return
} - setServerIp(value)} /> + { + setServerIp(value) + setServerOnline(false) + }} + validateInput={serverOnline === null || fetchedServerInfoIp !== serverIp ? undefined : validateServerIp} + /> setServerPort(value)} placeholder='25565' /> {isSmallHeight ?
:
Overrides:
}
setProxyOverride(value)} placeholder={placeholders?.proxyOverride} /> - setUsernameOverride(value)} placeholder={placeholders?.usernameOverride} /> + setUsernameOverride(value)} + placeholder={placeholders?.usernameOverride} + validateInput={!serverOnline || fetchedServerInfoIp !== serverIp ? undefined : validateUsername} + />
From d4345b19f11a50a433d934f5bab2765d2accc67e Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Tue, 4 Feb 2025 05:15:41 +0300 Subject: [PATCH 34/39] linting --- src/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 059db0a8..edc44220 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import './globals' import './devtools' import './entities' import './globalDomListeners' -import './mineflayer/mc-protocol' +import { getServerInfo } from './mineflayer/mc-protocol' import './mineflayer/maps' import './mineflayer/cameraShake' import './shims/patchShims' @@ -103,7 +103,6 @@ import { getWebsocketStream } from './mineflayer/websocket-core' import { appQueryParams, appQueryParamsArray } from './appParams' import { updateCursor } from './cameraRotationControls' import { pingServerVersion } from './mineflayer/minecraft-protocol-extra' -import { getServerInfo } from './mineflayer/mc-protocol' window.debug = debug window.THREE = THREE From 0d778dad80bc1b9f5c9638e3cdd4d2f5b6ef5dbf Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Tue, 4 Feb 2025 05:22:22 +0300 Subject: [PATCH 35/39] fix auto wss --- src/mineflayer/websocket-core.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/mineflayer/websocket-core.ts b/src/mineflayer/websocket-core.ts index 5271e765..69886897 100644 --- a/src/mineflayer/websocket-core.ts +++ b/src/mineflayer/websocket-core.ts @@ -14,8 +14,9 @@ class CustomDuplex extends Duplex { } export const getWebsocketStream = async (host: string) => { - host = host.replace('ws://', '').replace('wss://', '') - const ws = new WebSocket(`ws://${host}`) + const baseProtocol = location.protocol === 'https:' ? 'wss' : host.startsWith('ws://') ? 'ws' : 'wss' + const hostClean = host.replace('ws://', '').replace('wss://', '') + const ws = new WebSocket(`${baseProtocol}://${hostClean}`) const clientDuplex = new CustomDuplex(undefined, data => { ws.send(data) }) From 50baabad909656fe5b34ff0eccc06936dd27c246 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Tue, 4 Feb 2025 07:04:34 +0300 Subject: [PATCH 36/39] dont ignore version override! --- src/index.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/index.ts b/src/index.ts index edc44220..496ccd36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -399,10 +399,7 @@ export async function connect (connectOptions: ConnectOptions) { miscUiState.loadedDataVersion = version } - const downloadVersion = connectOptions.botVersion || (singleplayer ? serverOptions.version : undefined) - if (downloadVersion) { - await downloadMcData(downloadVersion) - } + let finalVersion = connectOptions.botVersion || (singleplayer ? serverOptions.version : undefined) if (singleplayer) { // SINGLEPLAYER EXPLAINER: @@ -443,11 +440,13 @@ export async function connect (connectOptions: ConnectOptions) { } else if (p2pMultiplayer) { initialLoadingText = 'Connecting to peer' } else if (connectOptions.server) { - const versionAutoSelect = getVersionAutoSelect() - setLoadingScreenStatus(`Fetching server version. Preffered: ${versionAutoSelect}`) - const autoVersionSelect = await getServerInfo(server.host!, server.port ? Number(server.port) : undefined, versionAutoSelect) - initialLoadingText = `Connecting to server ${server.host} with version ${autoVersionSelect.version}` - connectOptions.botVersion = autoVersionSelect.version + if (!finalVersion) { + const versionAutoSelect = getVersionAutoSelect() + setLoadingScreenStatus(`Fetching server version. Preffered: ${versionAutoSelect}`) + const autoVersionSelect = await getServerInfo(server.host!, server.port ? Number(server.port) : undefined, versionAutoSelect) + finalVersion = autoVersionSelect.version + } + initialLoadingText = `Connecting to server ${server.host} with version ${finalVersion}` } else { initialLoadingText = 'We have no idea what to do' } @@ -478,20 +477,21 @@ export async function connect (connectOptions: ConnectOptions) { const { version, time } = await getViewerVersionData(connectOptions.viewerWsConnect) console.log('Latency:', Date.now() - time, 'ms') // const version = '1.21.1' - connectOptions.botVersion = version + finalVersion = version await downloadMcData(version) setLoadingScreenStatus(`Connecting to WebSocket server ${connectOptions.viewerWsConnect}`) clientDataStream = await getWsProtocolStream(connectOptions.viewerWsConnect) } - if (connectOptions.botVersion) { - await downloadMcData(connectOptions.botVersion) + if (finalVersion) { + // ensure data is downloaded + await downloadMcData(finalVersion) } bot = mineflayer.createBot({ host: server.host, port: server.port ? +server.port : undefined, - version: connectOptions.botVersion || false, + version: finalVersion || false, ...clientDataStream ? { stream: clientDataStream, } : {}, From e2a093baf1867df653a45f2ed70231ef2152954e Mon Sep 17 00:00:00 2001 From: Max Lee Date: Tue, 4 Feb 2025 06:11:11 +0100 Subject: [PATCH 37/39] feat: enable bossbar by default & support for custom title texts (#259) --- src/optionsStorage.ts | 2 +- src/react/BossBarOverlay.css | 16 +++++++++++++--- src/react/BossBarOverlay.tsx | 12 ++++-------- src/react/BossBarOverlayProvider.tsx | 25 +++++++++++++++++++------ 4 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/optionsStorage.ts b/src/optionsStorage.ts index 12188a17..2228993f 100644 --- a/src/optionsStorage.ts +++ b/src/optionsStorage.ts @@ -95,7 +95,7 @@ const defaultOptions = { wysiwygSignEditor: 'auto' as 'auto' | 'always' | 'never', showMinimap: 'never' as 'always' | 'singleplayer' | 'never', minimapOptimizations: true, - displayBossBars: false, // boss bar overlay was removed for some reason, enable safely + displayBossBars: true, disabledUiParts: [] as string[], neighborChunkUpdates: true, highlightBlockColor: 'auto' as 'auto' | 'blue' | 'classic', diff --git a/src/react/BossBarOverlay.css b/src/react/BossBarOverlay.css index 4d75a5b1..ce4d8fbd 100644 --- a/src/react/BossBarOverlay.css +++ b/src/react/BossBarOverlay.css @@ -1,11 +1,17 @@ .bossBars { display: flex; flex-direction: column; - gap: 5px; + gap: 3px; position: absolute; - top: 9px; + width: 100%; + top: 1px; left: 50%; transform: translate(-50%); + pointer-events: none; +} + +.bossBars.mobile { + top: 18px; } .bossbar-container { @@ -14,8 +20,12 @@ align-items: center; } .bossbar-title { - font-size: 7px; + font-size: 10px; + text-align: center; color: #fff; + margin-bottom: -1px; + white-space: nowrap; + overflow: hidden; } .bossbar { background-image: var(--bars-gui-atlas); diff --git a/src/react/BossBarOverlay.tsx b/src/react/BossBarOverlay.tsx index 6236283f..b5e058b8 100644 --- a/src/react/BossBarOverlay.tsx +++ b/src/react/BossBarOverlay.tsx @@ -5,29 +5,25 @@ import './BossBarOverlay.css' const colors = ['pink', 'blue', 'red', 'green', 'yellow', 'purple', 'white'] const divs = [0, 6, 10, 12, 20] -const translations = { - 'entity.minecraft.ender_dragon': 'Ender Dragon', - 'entity.minecraft.wither': 'Wither' -} export type BossBarType = BossBarTypeRaw & { // todo why not use public properties? - title: { text: string, translate: string }, - _title: { text: string, translate: string }, + title: string | Record | null, + _title: string | Record | null, _color: string, _dividers: number, _health: number } export default ({ bar }: { bar: BossBarType }) => { - const [title, setTitle] = useState('') + const [title, setTitle] = useState({}) const [bossBarStyles, setBossBarStyles] = useState<{ [key: string]: string | number }>({}) const [fillStyles, setFillStyles] = useState<{ [key: string]: string | number }>({}) const [div1Styles, setDiv1Styles] = useState<{ [key: string]: string | number }>({}) const [div2Styles, setDiv2Styles] = useState<{ [key: string]: string | number }>({}) useEffect(() => { - setTitle(bar._title.text ? bar.title.text : translations[bar.title.translate] || 'Unknown Entity') + setTitle(bar._title ?? bar.title) setBossBarStyles(prevStyles => ({ ...prevStyles, backgroundPositionY: `-${colors.indexOf(bar._color) * 10}px` diff --git a/src/react/BossBarOverlayProvider.tsx b/src/react/BossBarOverlayProvider.tsx index 5cac3c8a..8c96b35e 100644 --- a/src/react/BossBarOverlayProvider.tsx +++ b/src/react/BossBarOverlayProvider.tsx @@ -1,27 +1,40 @@ import { useState, useEffect } from 'react' import BossBar, { BossBarType } from './BossBarOverlay' import './BossBarOverlay.css' +import { useSnapshot } from 'valtio' +import { miscUiState } from '../globalState' export default () => { + const { currentTouch } = useSnapshot(miscUiState) const [bossBars, setBossBars] = useState(new Map()) + const addBossBar = (bossBar: BossBarType) => { + setBossBars(prevBossBars => new Map(prevBossBars.set(bossBar.entityUUID, bossBar))) + } + + const removeBossBar = (bossBar: BossBarType) => { + setBossBars(prevBossBars => { + const newBossBars = new Map(prevBossBars) + newBossBars.delete(bossBar.entityUUID) + return newBossBars + }) + } useEffect(() => { bot.on('bossBarCreated', (bossBar) => { - setBossBars(prevBossBars => new Map(prevBossBars.set(bossBar.entityUUID, bossBar as any))) + addBossBar(bossBar as BossBarType) }) bot.on('bossBarUpdated', (bossBar) => { - setBossBars(prevBossBars => new Map(prevBossBars.set(bossBar.entityUUID, bossBar as BossBarType))) + removeBossBar(bossBar as BossBarType) + setTimeout(() => addBossBar(bossBar as BossBarType), 1) }) bot.on('bossBarDeleted', (bossBar) => { - const newBossBars = new Map(bossBars) - newBossBars.delete(bossBar.entityUUID) - setBossBars(newBossBars) + removeBossBar(bossBar as BossBarType) }) }, []) return ( -
+
{[...bossBars.values()].map(bar => ( ))} From 2ccc46267927a7eea756e6930f356d7c43967cd2 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Tue, 4 Feb 2025 08:42:31 +0300 Subject: [PATCH 38/39] fix eslint --- src/react/BossBarOverlayProvider.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/react/BossBarOverlayProvider.tsx b/src/react/BossBarOverlayProvider.tsx index 8c96b35e..d67a4af6 100644 --- a/src/react/BossBarOverlayProvider.tsx +++ b/src/react/BossBarOverlayProvider.tsx @@ -1,8 +1,8 @@ import { useState, useEffect } from 'react' -import BossBar, { BossBarType } from './BossBarOverlay' -import './BossBarOverlay.css' import { useSnapshot } from 'valtio' import { miscUiState } from '../globalState' +import './BossBarOverlay.css' +import BossBar, { BossBarType } from './BossBarOverlay' export default () => { From 97e13954643b0833c1d5282a61ce71834ae9c626 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Tue, 4 Feb 2025 16:46:23 +0300 Subject: [PATCH 39/39] up tech --- TECH.md | 60 ++++++++++++++++++++++++++++----------------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/TECH.md b/TECH.md index 3ea76719..c7f4ef4e 100644 --- a/TECH.md +++ b/TECH.md @@ -3,35 +3,35 @@ This project uses proxies so you can connect to almost any vanilla server. Though proxies have some limitations such as increased latency and servers will complain about using VPN (though we have a workaround for that, but ping will be much higher). This client generally has better performance but some features reproduction might be inaccurate eg its less stable and more buggy in some cases. -| Feature | This project | Eaglercraft | Description | -| --------------------------------- | ------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| General | | | | -| Mobile Support (touch) | ✅(+) | ✅ | | -| Gamepad Support | ✅ | ❌ | | -| A11Y | ✅ | ❌ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page (but maybe it's not needed) | -| Game Features | | | | -| Servers Support (quality) | ❌ | ✅ | Eaglercraft is vanilla Minecraft, while this project tries to emulate original game behavior at protocol level (Mineflayer is used) | -| Servers Support (any version, ip) | ✅ | ❌ | We support almost all Minecraft versions, only important if you connect to a server where you need new content like blocks or if you play with friends. And you can connect to almost any server using proxy servers! | -| Singleplayer Survival Features | ❌ | ✅ | Just like Eaglercraft this project can generate and save worlds, but generator is simple and only a few survival features are supported (look here for [supported features list](https://github.com/zardoy/space-squid)) | -| Singleplayer Maps | ✅ | ✅ | We support any version, but adventure maps won't work, but simple parkour and build maps might be interesting to explore... | -| Singleplayer Maps World Streaming | ✅ | ❌ | Thanks to Browserfs, saves can be loaded to local singleplayer server using multiple ways: from local folder, server directory (not zip), dropbox or other cloud *backend* etc... | -| P2P Multiplayer | ✅ | ✅ | A way to connect to other browser running the project. But it's almost useless here since many survival features are not implemented. Maybe only to build / explore maps together... | -| Voice Chat | ❌ | ✅ | Eaglercraft has custom WebRTC voice chat implementation, though it could also be easily implemented there | -| Online Servers | ✅ | ❌ | We have custom implementation (including integration on proxy side) for joining to servers | -| Plugin Features | ✅ | ❌ | We have Mineflayer plugins support, like Auto Jump & Auto Parkour was added here that way | -| Direct Connection | ❌ | ✅ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page | -| Mods | ❌(roadmap) | ❌ | This project will support mods for singleplayer. In theory its possible to implement support for modded servers on protocol level (including all needed mods) | -| Video Recording | ❌ | ✅ | Don't feel needed | -| Metaverse Features | ❌(roadmap) | ❌ | Iframes, video streams inside of game world (custom protocol channel) | -| Sounds | ✅ | ✅ | | -| Resource Packs | ✅(--) | ✅ | This project has very limited support for them (only textures images are loadable for now) | -| Assets Compressing & Splitting | ✅ | ❌ | We have advanced Minecraft data processing and good code chunk splitting so the web app will open much faster and use less memory | -| Graphics | | | | -| Fancy Graphics | ❌ | ✅ | While Eaglercraft has top-level shaders we don't even support lighting | -| Fast & Efficient Graphics | ❌(+) | ❌ | Feels like no one needs to have 64 rendering distance work smoothly | -| VR | ✅ | ❌ | Feels like not needed feature. UI is missing in this project since DOM can't be rendered in VR so Eaglercraft could be better in that aspect | -| AR | ❌ | ❌ | Would be the most useless feature | -| Minimap & Waypoints | ✅(-) | ❌ | We have buggy minimap, which can be enabled in settings and full map is opened by pressing `M` key | +| Feature | This project | Eaglercraft | Description | +| --------------------------------- | ----------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| General | | | | +| Mobile Support (touch) | ✅(+) | ✅ | | +| Gamepad Support | ✅ | ❌ | | +| A11Y | ✅ | ❌ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page (but maybe it's not needed) | +| Game Features | | | | +| Servers Support (quality) | ❌ | ✅ | Eaglercraft is vanilla Minecraft, while this project tries to emulate original game behavior at protocol level (Mineflayer is used) | +| Servers Support (any version, ip) | ✅ | ❌ | We support almost all Minecraft versions, only important if you connect to a server where you need new content like blocks or if you play with friends. And you can connect to almost any server using proxy servers! | +| Singleplayer Survival Features | ❌ | ✅ | Just like Eaglercraft this project can generate and save worlds, but generator is simple and only a few survival features are supported (look here for [supported features list](https://github.com/zardoy/space-squid)) | +| Singleplayer Maps | ✅ | ✅ | We support any version, but adventure maps won't work, but simple parkour and build maps might be interesting to explore... | +| Singleplayer Maps World Streaming | ✅ | ❌ | Thanks to Browserfs, saves can be loaded to local singleplayer server using multiple ways: from local folder, server directory (not zip), dropbox or other cloud *backend* etc... | +| P2P Multiplayer | ✅ | ✅ | A way to connect to other browser running the project. But it's almost useless here since many survival features are not implemented. Maybe only to build / explore maps together... | +| Voice Chat | ❌ | ✅ | Eaglercraft has custom WebRTC voice chat implementation, though it could also be easily implemented there | +| Online Servers | ✅ | ❌ | We have custom implementation (including integration on proxy side) for joining to servers | +| Plugin Features | ✅ | ❌ | We have Mineflayer plugins support, like Auto Jump & Auto Parkour was added here that way | +| Direct Connection | ✅ | ✅ | We have DOM for almost all UI so your extensions and other browser features will work natively like on any other web page | +| Moding | ❌(roadmap, client-side) | ❌ | This project will support mods for singleplayer. In theory its possible to implement support for modded servers on protocol level (including all needed mods) | +| Video Recording | ❌ | ✅ | Don't feel needed | +| Metaverse Features | ❌(roadmap) | ❌ | Iframes, video streams inside of game world (custom protocol channel) | +| Sounds | ✅ | ✅ | | +| Resource Packs | ✅(+extras) | ✅ | This project has very limited support for them (only textures images are loadable for now) | +| Assets Compressing & Splitting | ✅ | ❌ | We have advanced Minecraft data processing and good code chunk splitting so the web app will open much faster and use less memory | +| Graphics | | | | +| Fancy Graphics | ❌ | ✅ | While Eaglercraft has top-level shaders we don't even support lighting | +| Fast & Efficient Graphics | ❌(+) | ❌ | Feels like no one needs to have 64 rendering distance work smoothly | +| VR | ✅ | ❌ | Feels like not needed feature. UI is missing in this project since DOM can't be rendered in VR so Eaglercraft could be better in that aspect | +| AR | ❌ | ❌ | Would be the most useless feature | +| Minimap & Waypoints | ✅(-) | ❌ | We have buggy minimap, which can be enabled in settings and full map is opened by pressing `M` key | Features available to only this project: @@ -52,6 +52,6 @@ TODO | API | Usage & Description | | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | | `Crypto` API | Used to make chat features work when joining online servers with authentication. | -| `requestPointerLock({ unadjustedMovement: true })` API | Required for games. Disables system mouse acceleration (important for Mac users). Aka mouse raw input | +| `requestPointerLock({ unadjustedMovement: true })` API | Required for games. Disables system mouse acceleration (important for Mac users). Aka mouse raw input | | `navigator.keyboard.lock()` | (only in Chromium browsers) When entering fullscreen it allows to use any key combination like ctrl+w in the game | | `navigator.keyboard.getLayoutMap()` | (only in Chromium browsers) To display the right keyboard symbol for the key keybinding on different keyboard layouts (e.g. QWERTY vs AZERTY) |