diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 52824004..cbd2f1a2 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -29,6 +29,8 @@ jobs: run: vercel build --token=${{ secrets.VERCEL_TOKEN }} - name: Copy playground files run: node prismarine-viewer/esbuild.mjs && cp prismarine-viewer/public/index.html .vercel/output/static/playground.html && cp prismarine-viewer/public/playground.js .vercel/output/static/playground.js + - name: Download Generated Sounds map + run: node scripts/downloadSoundsMap.mjs - name: Deploy Project Artifacts to Vercel uses: mathiasvr/command-output@v2.0.0 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2b84b90c..a52e6ab9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,6 +21,8 @@ jobs: - run: vercel build --token=${{ secrets.VERCEL_TOKEN }} --prod - name: Copy playground files run: node prismarine-viewer/esbuild.mjs && cp prismarine-viewer/public/index.html .vercel/output/static/playground.html && cp prismarine-viewer/public/playground.js .vercel/output/static/playground.js + - name: Download Generated Sounds map + run: node scripts/downloadSoundsMap.mjs - name: Deploy Project to Vercel uses: mathiasvr/command-output@v2.0.0 with: diff --git a/prismarine-viewer/viewer/lib/viewer.ts b/prismarine-viewer/viewer/lib/viewer.ts index 92860fbf..56e45ad1 100644 --- a/prismarine-viewer/viewer/lib/viewer.ts +++ b/prismarine-viewer/viewer/lib/viewer.ts @@ -21,8 +21,9 @@ export class Viewer { isSneaking: boolean version: string cameraObjectOverride?: THREE.Object3D // for xr + audioListener: THREE.AudioListener - constructor (public renderer: THREE.WebGLRenderer, numWorkers?: number) { + constructor(public renderer: THREE.WebGLRenderer, numWorkers?: number) { this.scene = new THREE.Scene() this.scene.background = new THREE.Color('lightblue') @@ -91,6 +92,33 @@ export class Viewer { cam.rotation.set(pitch, yaw, roll, 'ZYX') } + playSound (position: Vec3, path: string, volume = 1) { + if (!this.audioListener) { + this.audioListener = new THREE.AudioListener() + this.camera.add(this.audioListener) + } + + const sound = new THREE.PositionalAudio(this.audioListener) + + const audioLoader = new THREE.AudioLoader() + let start = Date.now() + audioLoader.loadAsync(path).then((buffer) => { + if (Date.now() - start > 500) return + // play + sound.setBuffer(buffer) + sound.setRefDistance(20) + sound.setVolume(volume) + this.scene.add(sound) + // set sound position + sound.position.set(position.x, position.y, position.z) + sound.play() + sound.onEnded = () => { + this.scene.remove(sound) + sound.disconnect() + } + }) + } + // todo type listen (emitter) { emitter.on('entity', (e) => { diff --git a/scripts/downloadSoundsMap.mjs b/scripts/downloadSoundsMap.mjs new file mode 100644 index 00000000..391d5a1c --- /dev/null +++ b/scripts/downloadSoundsMap.mjs @@ -0,0 +1,7 @@ +import fs from 'fs' + +const url = 'https://github.com/zardoy/prismarine-web-client/raw/sounds-generated/sounds.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 new file mode 100644 index 00000000..4ed119cb --- /dev/null +++ b/scripts/prepareSounds.mjs @@ -0,0 +1,243 @@ +//@ts-check + +import { getVersionList, DEFAULT_RESOURCE_ROOT_URL } from '@xmcl/installer' +import path from 'path' +import fs from 'fs' +import { fileURLToPath } from 'url' +import { exec } from 'child_process' +import { promisify } from 'util' +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'] + +/** @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 perVersionData: Record { + 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) + 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()) + const soundAssets = Object.entries(index.objects).filter(([name]) => /* name.endsWith('.ogg') || */ name.startsWith('minecraft/sounds/')).map(([name, { size, hash }]) => ({ name, size, hash })) + soundAssets.sort((a, b) => a.name.localeCompare(b.name)) + if (prevSounds) { + const prevSoundNames = new Set(prevSounds.map(x => x.name)) + const addedSounds = prevSounds.filter(x => !soundAssets.some(y => y.name === x.name)) + // todo implement removed + const removedSounds = soundAssets.filter(x => !prevSoundNames.has(x.name)) + // console.log('+', addedSounds.map(x => x.name)) + // console.log('-', removedSounds.map(x => x.name)) + 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', '')) + } + if (addedSounds.length) { + console.log('downloading new sounds for version', targetedVersion) + downloadSounds(addedSounds, targetedVersion + '/') + } + if (changedSize.length) { + console.log('downloading changed sounds for version', targetedVersion) + downloadSounds(changedSize, targetedVersion + '/') + } + } else { + console.log('downloading sounds for version', targetedVersion) + downloadSounds(soundAssets) + } + prevSounds = soundAssets + } + async function downloadSound ({ name, hash, size }, namePath, log) { + const savePath = path.resolve(`generated/sounds/${namePath}`) + if (fs.existsSync(savePath)) { + // console.log('skipped', name) + return + } + log() + const r = await fetch(DEFAULT_RESOURCE_ROOT_URL + '/' + hash.slice(0, 2) + '/' + hash, /* {headers: {range: `bytes=0-${size-1}`}} */) + // save file + const file = await r.blob() + fs.mkdirSync(path.dirname(savePath), { recursive: true }) + await fs.promises.writeFile(savePath, Buffer.from(await file.arrayBuffer())) + + const reader = file.stream().getReader() + + const writer = fs.createWriteStream(savePath) + let offset = 0 + while (true) { + const { done, value } = await reader.read() + if (done) break + writer.write(Buffer.from(value)) + offset += value.byteLength + } + writer.close() + } + async function downloadSounds (assets, addPath = '') { + 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) + }))) + } + } + + fs.writeFileSync('./generated/soundsPathVersionsRemap.json', JSON.stringify(soundsPathVersionsRemap), 'utf8') +} + +const lightpackOverrideSounds = { + 'Block breaking': 'step/stone1', + 'Block broken': 'dig/stone1', + 'Block placed': 'dig/stone1' +} + +// 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 maintainBitrate = true + +const scanFilesDeep = async (root, onOggFile) => { + const files = await fs.promises.readdir(root, { withFileTypes: true }) + for (const file of files) { + if (file.isDirectory()) { + await scanFilesDeep(path.join(root, file.name), onOggFile) + } else if (file.name.endsWith('.ogg') && !files.some(x => x.name === file.name.replace('.ogg', '.mp3'))) { + await onOggFile(path.join(root, file.name)) + } + } +} + +const convertSounds = async () => { + const toConvert = [] + await scanFilesDeep('generated/sounds', (oggPath) => { + toConvert.push(oggPath) + }) + + 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')}"`) + // pipe stdout to the console + proc.child.stdout.pipe(process.stdout) + await proc + console.log('converted to mp3', i, '/', toConvert.length, toConvert[i]) + } + + const CONCURRENCY = 5 + for(let i = 0; i < toConvert.length; i += CONCURRENCY) { + await Promise.all(toConvert.slice(i, i + CONCURRENCY).map((oggPath, j) => convertSound(i + j))) + } +} + +const getSoundsMap = (burgerData) => { + /** @type {Record} */ + return burgerData[0].sounds + // const map = JSON.parse(fs.readFileSync(burgerDataPath, 'utf8'))[0].sounds +} + +const writeSoundsMap = async () => { + // const burgerData = await fetch(burgerDataUrl(targetedVersions[0])).then((r) => r.json()) + // fs.writeFileSync(burgerDataPath, JSON.stringify(burgerData[0].sounds), 'utf8') + + const allSoundsMapOutput = {} + let prevMap + + // todo REMAP ONLY IDS. Do diffs, as mostly only ids are changed between versions + // const localTargetedVersions = targetedVersions.slice(0, 2) + const localTargetedVersions = targetedVersions + for (const targetedVersion of localTargetedVersions) { + const burgerData = await fetch(burgerDataUrl(targetedVersion)).then((r) => r.json()) + const allSoundsMap = getSoundsMap(burgerData) + // console.log(Object.keys(sounds).length, 'ids') + const outputIdMap = {} + const outputFilesMap = {} + + const classes = {} + let keysStats = { + new: 0, + same: 0 + } + for (const { id, subtitle, sounds, name } of Object.values(allSoundsMap)) { + if (!sounds?.length /* && !subtitle */) continue + const firstName = sounds[0].name + // 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` + // if (!fs.existsSync(soundFilePath)) { + // console.warn('no sound file', targetSound.name) + // continue + // } + const key = `${id};${name}` + outputIdMap[key] = `${targetSound.volume ?? 1};${targetSound.name}` + if (prevMap && prevMap[key]) { + keysStats.same++ + } else { + keysStats.new++ + } + // for (const {name: soundName} of sounds ?? []) { + // let obj = classes + // for (const part of soundName.split('/')) { + // obj[part] ??= {} + // obj = obj[part] + // } + // } + } + // console.log(classes) + // console.log('to download', new Set(Object.values(outputIdMap).flatMap(x => x.sounds)).size) + // console.log('json size', JSON.stringify(outputIdMap).length / 1024 / 1024) + allSoundsMapOutput[targetedVersion] = outputIdMap + prevMap = outputIdMap + // const allSoundNames = new Set(Object.values(allSoundsMap).flatMap(({ name, sounds }) => { + // if (!sounds) { + // console.log(name) + // return [] + // } + // return sounds.map(sound => sound.name) + // })) + // console.log(allSoundNames.size, 'sounds') + } + + fs.writeFileSync('./generated/sounds.json', JSON.stringify(allSoundsMapOutput), 'utf8') +} + +const makeSoundsBundle = async () => { + const allSoundsMap = JSON.parse(fs.readFileSync('./generated/sounds.json', 'utf8')) + const allSoundsVersionedMap = JSON.parse(fs.readFileSync('./generated/soundsPathVersionsRemap.json', 'utf8')) + + const allSoundsMeta = { + format: 'mp3', + baseUrl: 'https://raw.githubusercontent.com/zardoy/prismarine-web-client/sounds-generated/sounds/' + } + + await build({ + bundle: true, + outfile: `dist/sounds.js`, + stdin: { + contents: `window.allSoundsMap = ${JSON.stringify(allSoundsMap)}\nwindow.allSoundsVersionedMap = ${JSON.stringify(allSoundsVersionedMap)}\nwindow.allSoundsMeta = ${JSON.stringify(allSoundsMeta)}`, + resolveDir: __dirname, + sourcefile: `sounds.js`, + loader: 'js', + }, + metafile: true, + }) +} + +// downloadAllSounds() +// convertSounds() +// writeSoundsMap() +// makeSoundsBundle() diff --git a/server.js b/server.js index 0f3b1ca2..b6dd4135 100644 --- a/server.js +++ b/server.js @@ -17,6 +17,8 @@ app.use(netApi({ allowOrigin: '*' })) if (!isProd) { app.use('/blocksStates', express.static(path.join(__dirname, './prismarine-viewer/public/blocksStates'))) app.use('/textures', express.static(path.join(__dirname, './prismarine-viewer/public/textures'))) + + app.use('/sounds', express.static(path.join(__dirname, './generated/sounds/'))) } // patch config app.get('/config.json', (req, res, next) => { diff --git a/src/basicSounds.ts b/src/basicSounds.ts index 2a084692..40b6963e 100644 --- a/src/basicSounds.ts +++ b/src/basicSounds.ts @@ -7,7 +7,7 @@ const sounds: Record = {} const loadingSounds = [] as string[] const convertedSounds = [] as string[] export async function loadSound (path: string) { - if (loadingSounds.includes(path)) return + if (loadingSounds.includes(path)) return true loadingSounds.push(path) const res = await window.fetch(path) const data = await res.arrayBuffer() @@ -16,8 +16,19 @@ export async function loadSound (path: string) { loadingSounds.splice(loadingSounds.indexOf(path), 1) } -export async function playSound (path) { - const volume = options.volume / 100 +export const loadOrPlaySound = async (url, soundVolume = 1) => { + const soundBuffer = sounds[url] + if (!soundBuffer) { + const start = Date.now() + const cancelled = await loadSound(url) + if (cancelled || Date.now() - start > 500) return + } + + await playSound(url) +} + +export async function playSound (url, soundVolume = 1) { + const volume = soundVolume * (options.volume / 100) if (!volume) return @@ -29,9 +40,9 @@ export async function playSound (path) { convertedSounds.push(soundName) } - const soundBuffer = sounds[path] + const soundBuffer = sounds[url] if (!soundBuffer) { - console.warn(`Sound ${path} not loaded`) + console.warn(`Sound ${url} not loaded yet`) return } diff --git a/src/globalState.ts b/src/globalState.ts index 92bbb5c1..e9391c83 100644 --- a/src/globalState.ts +++ b/src/globalState.ts @@ -180,7 +180,7 @@ const initialNotification = { } export const notification = proxy(initialNotification) -export const showNotification = (/** @type {Partial} */newNotification) => { +export const showNotification = (newNotification: Partial) => { Object.assign(notification, { show: true, ...newNotification }, initialNotification) } diff --git a/src/globals.d.ts b/src/globals.d.ts index 0a83643c..e7753d1a 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -10,6 +10,10 @@ declare const localServer: import('flying-squid/dist/types').FullServer & { opti /** all currently loaded mc data */ declare const mcData: Record declare const loadedData: import('minecraft-data').IndexedData +declare const customEvents: import('typed-emitter').default<{ + singleplayer (): void + digStart() +}> declare interface Document { getElementById (id): any diff --git a/src/guessProblem.ts b/src/guessProblem.ts index 2ebdbfa6..ecc7dbf1 100644 --- a/src/guessProblem.ts +++ b/src/guessProblem.ts @@ -1,4 +1,4 @@ -export const guessProblem = (/** @type {string} */errorMessage) => { +export const guessProblem = (errorMessage: string) => { if (errorMessage.endsWith('Socket error: ECONNREFUSED')) { return 'Most probably the server is not running.' } diff --git a/src/index.ts b/src/index.ts index 0a1c2285..cb5d44d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,8 +82,13 @@ import { fsState } from './loadSave' import { watchFov } from './rendererUtils' import { loadInMemorySave } from './react/SingleplayerProvider' +// side effects +import { downloadSoundsIfNeeded } from './soundSystem' +import EventEmitter from 'events' + window.debug = debug window.THREE = THREE +window.customEvents = new EventEmitter() // ACTUAL CODE @@ -342,6 +347,7 @@ async function connect (connectOptions: { Object.assign(serverOptions, connectOptions.serverOverridesFlat ?? {}) const downloadMcData = async (version: string) => { setLoadingScreenStatus(`Downloading data for ${version}`) + await downloadSoundsIfNeeded() await loadScript(`./mc-data/${toMajorVersion(version)}.js`) miscUiState.loadedDataVersion = version try { diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index 5ee73a83..07242f37 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { useSnapshot } from 'valtio' -import { miscUiState, openOptionsMenu } from './globalState' +import { miscUiState, openOptionsMenu, showModal } from './globalState' import { openURL } from './menus/components/common' import { AppOptions, options } from './optionsStorage' import Button from './react/Button' @@ -181,6 +181,11 @@ export const guiOptionsScheme: { ], sound: [ { volume: {} }, + { + custom () { + return + +} + +export default () => { + const isModalActive = useIsModalActive('sound-muffler') + + const [showMuted, setShowMuted] = useState(true) + const [i, setI] = useState(0) + const { mutedSounds } = useSnapshot(options) + + if (!isModalActive) return null + + return +
+ + + Last World Played + {Object.entries(lastPlayedSounds.lastServerPlayed).map(([key, value]) => { + if (!showMuted && mutedSounds.includes(key)) return null as never + return [key, value.count] as const + }).filter(Boolean).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([key, count]) => { + return + {count} + + })} + Last Client Played + {lastPlayedSounds.lastClientPlayed.map((key) => { + if (!showMuted && mutedSounds.includes(key)) return null as never + return + })} + +
+
+} diff --git a/src/reactUi.jsx b/src/reactUi.jsx index 77139bb4..6e7d2f9b 100644 --- a/src/reactUi.jsx +++ b/src/reactUi.jsx @@ -18,6 +18,7 @@ import AppStatusProvider from './react/AppStatusProvider' import SelectOption from './react/SelectOption' import EnterFullscreenButton from './react/EnterFullscreenButton' import ChatProvider from './react/ChatProvider' +import SoundMuffler from './react/SoundMuffler' // todo useInterfaceState.setState({ @@ -127,6 +128,7 @@ const InGameUi = () => { {/* apply scaling */} + diff --git a/src/soundEvents.ts b/src/soundEvents.ts deleted file mode 100644 index 78a97c68..00000000 --- a/src/soundEvents.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Block } from 'prismarine-block' - -let lastStepSound = 0 - -const getStepSound = (blockUnder: Block) => { - if (!blockUnder || blockUnder.type === 0) return - const soundsMap = window.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 soundResult -} - -export const movementHappening = () => { - const THRESHOLD = 0.1 - const { x, z, y } = bot.player.entity.velocity - if (Math.abs(x) < THRESHOLD && (Math.abs(z) > THRESHOLD || Math.abs(y) > THRESHOLD)) { - // movement happening - if (Date.now() - lastStepSound > 500) { - const blockUnder = bot.world.getBlock(bot.entity.position.offset(0, -1, 0)) - const stepSound = getStepSound(blockUnder) - if (stepSound) { - playHardcodedSound(stepSound) - lastStepSound = Date.now() - } - } - } -} diff --git a/src/soundSystem.ts b/src/soundSystem.ts new file mode 100644 index 00000000..f1604228 --- /dev/null +++ b/src/soundSystem.ts @@ -0,0 +1,229 @@ +import { subscribeKey } from 'valtio/utils' +import { Vec3 } from 'vec3' +import { versionToNumber } from 'prismarine-viewer/viewer/prepare/utils' +import { loadScript } from 'prismarine-viewer/viewer/lib/utils' +import type { Block } from 'prismarine-block' +import { miscUiState, showNotification } from './globalState' +import { options } from './optionsStorage' +import { loadOrPlaySound } from './basicSounds' + +subscribeKey(miscUiState, 'gameLoaded', async () => { + const soundsLegacyMap = window.allSoundsVersionedMap as Record + const allSoundsMap = window.allSoundsMap as Record> + const allSoundsMeta = window.allSoundsMeta as { format: string, baseUrl: string } + if (!allSoundsMap) { + return + } + + // todo also use major versioned hardcoded sounds + const soundsMap = allSoundsMap[bot.version] + + if (!soundsMap) { + console.warn('No sounds map for version', bot.version, 'supported versions are', Object.keys(allSoundsMap).join(', ')) + showNotification({ + message: 'No sounds map for version ' + bot.version, + }) + return + } + + if (!miscUiState.gameLoaded || !soundsMap) { + 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])) + const soundIdToName = Object.fromEntries(Object.entries(soundsMap).map(([id, sound]) => [+id.split(';')[0], id.split(';')[1]])) + + const playGeneralSound = async (soundId: string, soundString: string | undefined, position?: Vec3, volume = 1, pitch?: number) => { + if (!soundString) { + console.warn('Unknown sound received from server to play', soundId) + return + } + if (!options.volume) return + console.debug('play sound', soundId) + const parts = soundString.split(';') + const soundVolume = +parts[0]! + const soundName = parts[1]! + // console.log('pitch', pitch) + const versionedSound = getVersionedSound(bot.version, soundName, Object.entries(soundsLegacyMap)) + // todo test versionedSound + const url = allSoundsMeta.baseUrl.replace(/\/$/, '') + (versionedSound ? `/${versionedSound}` : '') + '/minecraft/sounds/' + soundName + '.' + allSoundsMeta.format + const soundKey = soundIdToName[+soundId] ?? soundId + const isMuted = options.mutedSounds.includes(soundKey) + if (position) { + if (!isMuted) { + viewer.playSound(position, url, soundVolume * volume * (options.volume / 100)) + } + if (getDistance(bot.entity.position, position) < 16) { + lastPlayedSounds.lastServerPlayed[soundKey] ??= { count: 0, last: 0 } + lastPlayedSounds.lastServerPlayed[soundKey].count++ + lastPlayedSounds.lastServerPlayed[soundKey].last = Date.now() + } + } else { + if (!isMuted) { + await loadOrPlaySound(url, volume) + } + lastPlayedSounds.lastClientPlayed.push(soundKey) + if (lastPlayedSounds.lastClientPlayed.length > 10) { + lastPlayedSounds.lastClientPlayed.shift() + } + } + } + const playHardcodedSound = async (soundId: string, position?: Vec3, volume = 1, pitch?: number) => { + const sound = soundsPerName[soundId] + await playGeneralSound(soundId, sound, position, volume, pitch) + } + bot.on('soundEffectHeard', async (soundId, position, volume, pitch) => { + console.debug('soundEffectHeard', soundId, volume) + await playHardcodedSound(soundId, position, volume, pitch) + }) + bot.on('hardcodedSoundEffectHeard', async (soundId, soundCategory, position, volume, pitch) => { + const sound = soundsPerId[soundId] + await playGeneralSound(soundId.toString(), sound, position, volume, 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 = window.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 () => { + 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)) { + // movement happening + if (Date.now() - lastStepSound > 300) { + const blockUnder = bot.world.getBlock(bot.entity.position.offset(0, -1, 0)) + const stepSound = getStepSound(blockUnder) + if (stepSound) { + await playHardcodedSound(stepSound, undefined, 0.6)// todo not sure why 0.6 + lastStepSound = Date.now() + } + } + } + } + + const playBlockBreak = async (blockName: string, position?: Vec3) => { + const sound = useBlockSound(blockName, 'break', 'block.stone.break') + + await playHardcodedSound(sound, position, 0.6, 1) + } + + const registerEvents = () => { + bot.on('move', () => { + void movementHappening() + }) + bot._client.on('world_event', async ({ effectId, location, data, global:disablePosVolume }) => { + const position = disablePosVolume ? undefined : new Vec3(location.x, location.y, location.z) + if (effectId === 2001) { + // break event + const block = loadedData.blocksByStateId[data] + await playBlockBreak(block.name, position) + } + // these produce glass break sound + if (effectId === 2002 || effectId === 2003 || effectId === 2007) { + await playHardcodedSound('block.glass.break', position, 1, 1) + } + }) + let diggingBlock: Block | null = null + customEvents.on('digStart', () => { + diggingBlock = bot.blockAtCursor(5) + }) + bot.on('diggingCompleted', async () => { + if (diggingBlock) { + await playBlockBreak(diggingBlock.name) + } + }) + } + + registerEvents() + + console.log(getVersionedSound('1.13', 'mob/fox/spit1', Object.entries(soundsLegacyMap))) + // 1.20+ soundEffectHeard is broken atm + // bot._client.on('packet', (data, { name }, buffer) => { + // if (name === 'sound_effect') { + // console.log(data, buffer) + // } + // }) +}) + +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 + } + } +} + +export const downloadSoundsIfNeeded = async () => { + if (!window.allSoundsMap) { + try { + await loadScript('./sounds.js') + } catch (err) { + console.warn('Sounds map was not generated. Sounds will not be played.') + } + } +} + +export const lastPlayedSounds = { + lastClientPlayed: [] as string[], + lastServerPlayed: {} as Record, +} + +const getDistance = (pos1: Vec3, pos2: Vec3) => { + return Math.hypot((pos1.x - pos2.x), (pos1.y - pos2.y), (pos1.z - pos2.z)) +} diff --git a/src/worldInteractions.js b/src/worldInteractions.js index 45622060..c86680f7 100644 --- a/src/worldInteractions.js +++ b/src/worldInteractions.js @@ -195,6 +195,7 @@ class WorldInteraction { if (err.message === 'Digging aborted') return throw err }) + customEvents.emit('digStart') this.lastDigged = Date.now() } this.prevOnGround = onGround