diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 933a6d027..cec3d9010 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 3c335f8f2..1e56131c2 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 8f3e5bef9..f9b8cd609 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<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() +} diff --git a/scripts/uploadSoundFiles.ts b/scripts/uploadSoundFiles.ts new file mode 100644 index 000000000..e8677c872 --- /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<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); +}); diff --git a/scripts/uploadSounds.ts b/scripts/uploadSounds.ts new file mode 100644 index 000000000..b0e9ecd79 --- /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<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); +}); diff --git a/src/resourcePack.ts b/src/resourcePack.ts index d7bbdc47d..124d7e6cc 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<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') diff --git a/src/soundSystem.ts b/src/soundSystem.ts index d0caf01f9..841644cc5 100644 --- a/src/soundSystem.ts +++ b/src/soundSystem.ts @@ -1,50 +1,45 @@ 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 { createSoundMap, SoundMap } from './soundsMap' +import { getActiveResourcepackBasePath, resourcePackState } from './resourcePack' -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 } 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) { - return - } - - const allSoundsMajor = versionsMapToMajor(allSoundsMap) - const soundsMap = allSoundsMajor[versionToMajor(bot.version)] ?? Object.values(allSoundsMajor)[0] - - if (!soundsMap || !miscUiState.gameLoaded || !loadedData.sounds) { + if (!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() 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 +48,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 +56,38 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { } } } + 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 +95,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 +106,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 +149,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 +163,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/testSounds.ts b/src/sounds/testSounds.ts new file mode 100644 index 000000000..6fdaecf65 --- /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')) diff --git a/src/soundsMap.ts b/src/soundsMap.ts new file mode 100644 index 000000000..7bad1b3b6 --- /dev/null +++ b/src/soundsMap.ts @@ -0,0 +1,339 @@ +import fs from 'fs' +import path from 'path' +import { versionsMapToMajor, versionToMajor, versionToNumber } from 'prismarine-viewer/viewer/prepare/utils' + +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') + } +} + +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' +}