feat(sounds): Add sound variants and resource pack support! (#258)

feat: add in-game music support! Enable it with `options.enableMusic = true`
This commit is contained in:
Vitaly 2025-01-29 04:54:51 +03:00 committed by GitHub
commit df442338f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 784 additions and 160 deletions

View file

@ -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' })

View file

@ -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')

View file

@ -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<string, { removed: string[],
const soundsPathVersionsRemap = {}
const downloadAllSounds = async () => {
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()
}

109
scripts/uploadSoundFiles.ts Normal file
View file

@ -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<string | null> {
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);
});

67
scripts/uploadSounds.ts Normal file
View file

@ -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<string | null> {
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);
});

View file

@ -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<string, any> = {}
// 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)
})

View file

@ -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 = {

View file

@ -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'

View file

@ -25,6 +25,7 @@ const defaultOptions = {
chatOpacityOpened: 100,
messagesLimit: 200,
volume: 50,
enableMusic: false,
// fov: 70,
fov: 75,
guiScale: 3,

View file

@ -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'

View file

@ -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<string>()
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')

View file

@ -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<string, Record<string, string>>,
allSoundsVersionedMap?: Record<string, string[]>,
let soundMap: SoundMap | undefined
const updateResourcePack = async () => {
if (!soundMap) return
soundMap.activeResourcePackBasePath = await getActiveResourcepackBasePath() ?? undefined
}
let musicInterval: ReturnType<typeof setInterval> | null = null
subscribeKey(miscUiState, 'gameLoaded', async () => {
if (!miscUiState.gameLoaded) return
const soundsLegacyMap = window.allSoundsVersionedMap as Record<string, string[]>
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) {

33
src/sounds/musicSystem.ts Normal file
View file

@ -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()

347
src/sounds/soundsMap.ts Normal file
View file

@ -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<string, Record<string, string>>
soundsLegacyMap: Record<string, string[]>
soundsMeta: SoundMeta
}
interface BlockSoundMap {
[blockName: string]: string
}
interface SoundEntry {
file: string
weight: number
volume: number
}
export class SoundMap {
private readonly soundsPerName: Record<string, SoundEntry[]>
private readonly existingResourcePackPaths: Set<string>
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<string, Record<string, string>>,
allSoundsVersionedMap?: Record<string, string[]>,
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'
}

8
src/sounds/testSounds.ts Normal file
View file

@ -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'))