From e1293b6cb3ee73889a75c957c8c0d14cf7661b10 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sat, 16 Aug 2025 09:15:37 +0300 Subject: [PATCH] - Introduced a patchAssets script to apply custom textures to the blocks and items atlases. - Enhanced the ThreeJsSound class to support sound playback timeout and volume adjustments. - Added a custom sound system to handle named sound effects with metadata. --- assets/customTextures/readme.md | 2 + renderer/viewer/three/threeJsSound.ts | 40 ++++++-- rsbuild.config.ts | 4 + scripts/patchAssets.ts | 137 ++++++++++++++++++++++++++ src/basicSounds.ts | 30 +++++- src/defaultOptions.ts | 1 + src/react/Slider.tsx | 58 ++++++++++- src/sounds/botSoundSystem.ts | 11 ++- src/sounds/customSoundSystem.ts | 44 +++++++++ src/sounds/soundsMap.ts | 10 +- src/watchOptions.ts | 4 + 11 files changed, 323 insertions(+), 18 deletions(-) create mode 100644 assets/customTextures/readme.md create mode 100644 scripts/patchAssets.ts create mode 100644 src/sounds/customSoundSystem.ts diff --git a/assets/customTextures/readme.md b/assets/customTextures/readme.md new file mode 100644 index 00000000..e2a78c20 --- /dev/null +++ b/assets/customTextures/readme.md @@ -0,0 +1,2 @@ +here you can place custom textures for bundled files (blocks/items) e.g. blocks/stone.png +get file names from here (blocks/items) https://zardoy.github.io/mc-assets/ diff --git a/renderer/viewer/three/threeJsSound.ts b/renderer/viewer/three/threeJsSound.ts index 46aefda9..699bb2cc 100644 --- a/renderer/viewer/three/threeJsSound.ts +++ b/renderer/viewer/three/threeJsSound.ts @@ -2,7 +2,7 @@ import * as THREE from 'three' import { WorldRendererThree } from './worldrendererThree' export interface SoundSystem { - playSound: (position: { x: number, y: number, z: number }, path: string, volume?: number, pitch?: number) => void + playSound: (position: { x: number, y: number, z: number }, path: string, volume?: number, pitch?: number, timeout?: number) => void destroy: () => void } @@ -10,7 +10,17 @@ export class ThreeJsSound implements SoundSystem { audioListener: THREE.AudioListener | undefined private readonly activeSounds = new Set() private readonly audioContext: AudioContext | undefined + private readonly soundVolumes = new Map() + baseVolume = 1 + constructor (public worldRenderer: WorldRendererThree) { + worldRenderer.onWorldSwitched.push(() => { + this.stopAll() + }) + + worldRenderer.onReactiveConfigUpdated('volume', (volume) => { + this.changeVolume(volume) + }) } initAudioListener () { @@ -19,20 +29,24 @@ export class ThreeJsSound implements SoundSystem { this.worldRenderer.camera.add(this.audioListener) } - playSound (position: { x: number, y: number, z: number }, path: string, volume = 1, pitch = 1) { + playSound (position: { x: number, y: number, z: number }, path: string, volume = 1, pitch = 1, timeout = 500) { this.initAudioListener() const sound = new THREE.PositionalAudio(this.audioListener!) this.activeSounds.add(sound) + this.soundVolumes.set(sound, volume) const audioLoader = new THREE.AudioLoader() const start = Date.now() void audioLoader.loadAsync(path).then((buffer) => { - if (Date.now() - start > 500) return + if (Date.now() - start > timeout) { + console.warn('Ignored playing sound', path, 'due to timeout:', timeout, 'ms <', Date.now() - start, 'ms') + return + } // play sound.setBuffer(buffer) sound.setRefDistance(20) - sound.setVolume(volume) + sound.setVolume(volume * this.baseVolume) sound.setPlaybackRate(pitch) // set the pitch this.worldRenderer.scene.add(sound) // set sound position @@ -43,21 +57,35 @@ export class ThreeJsSound implements SoundSystem { sound.disconnect() } this.activeSounds.delete(sound) + this.soundVolumes.delete(sound) audioLoader.manager.itemEnd(path) } sound.play() }) } - destroy () { - // Stop and clean up all active sounds + stopAll () { for (const sound of this.activeSounds) { + if (!sound) continue sound.stop() if (sound.source) { sound.disconnect() } + this.worldRenderer.scene.remove(sound) } + this.activeSounds.clear() + this.soundVolumes.clear() + } + changeVolume (volume: number) { + this.baseVolume = volume + for (const [sound, individualVolume] of this.soundVolumes) { + sound.setVolume(individualVolume * this.baseVolume) + } + } + + destroy () { + this.stopAll() // Remove and cleanup audio listener if (this.audioListener) { this.audioListener.removeFromParent() diff --git a/rsbuild.config.ts b/rsbuild.config.ts index 42d6867b..6cd6b2ed 100644 --- a/rsbuild.config.ts +++ b/rsbuild.config.ts @@ -240,6 +240,10 @@ const appConfig = defineConfig({ prep() }) build.onAfterBuild(async () => { + if (fs.readdirSync('./assets/customTextures').length > 0) { + childProcess.execSync('tsx ./scripts/patchAssets.ts', { stdio: 'inherit' }) + } + if (SINGLE_FILE_BUILD) { // check that only index.html is in the dist/single folder const singleBuildFiles = fs.readdirSync('./dist/single') diff --git a/scripts/patchAssets.ts b/scripts/patchAssets.ts new file mode 100644 index 00000000..99994f5f --- /dev/null +++ b/scripts/patchAssets.ts @@ -0,0 +1,137 @@ +import blocksAtlas from 'mc-assets/dist/blocksAtlases.json' +import itemsAtlas from 'mc-assets/dist/itemsAtlases.json' +import * as fs from 'fs' +import * as path from 'path' +import sharp from 'sharp' + +interface AtlasFile { + latest: { + suSv: number + tileSize: number + width: number + height: number + textures: { + [key: string]: { + u: number + v: number + su: number + sv: number + tileIndex: number + } + } + } +} + +async function patchTextureAtlas( + atlasType: 'blocks' | 'items', + atlasData: AtlasFile, + customTexturesDir: string, + distDir: string +) { + // Check if custom textures directory exists and has files + if (!fs.existsSync(customTexturesDir) || fs.readdirSync(customTexturesDir).length === 0) { + return + } + + // Find the latest atlas file + const atlasFiles = fs.readdirSync(distDir) + .filter(file => file.startsWith(`${atlasType}AtlasLatest`) && file.endsWith('.png')) + .sort() + + if (atlasFiles.length === 0) { + console.log(`No ${atlasType}AtlasLatest.png found in ${distDir}`) + return + } + + const latestAtlasFile = atlasFiles[atlasFiles.length - 1] + const atlasPath = path.join(distDir, latestAtlasFile) + console.log(`Patching ${atlasPath}`) + + // Get atlas dimensions + const atlasMetadata = await sharp(atlasPath).metadata() + if (!atlasMetadata.width || !atlasMetadata.height) { + throw new Error(`Failed to get atlas dimensions for ${atlasPath}`) + } + + // Process each custom texture + const customTextureFiles = fs.readdirSync(customTexturesDir) + .filter(file => file.endsWith('.png')) + + if (customTextureFiles.length === 0) return + + // Prepare composite operations + const composites: sharp.OverlayOptions[] = [] + + for (const textureFile of customTextureFiles) { + const textureName = path.basename(textureFile, '.png') + + if (atlasData.latest.textures[textureName]) { + const textureData = atlasData.latest.textures[textureName] + const customTexturePath = path.join(customTexturesDir, textureFile) + + try { + // Convert UV coordinates to pixel coordinates + const x = Math.round(textureData.u * atlasMetadata.width) + const y = Math.round(textureData.v * atlasMetadata.height) + const width = Math.round((textureData.su ?? atlasData.latest.suSv) * atlasMetadata.width) + const height = Math.round((textureData.sv ?? atlasData.latest.suSv) * atlasMetadata.height) + + // Resize custom texture to match atlas dimensions and add to composite operations + const resizedTextureBuffer = await sharp(customTexturePath) + .resize(width, height, { + fit: 'fill', + kernel: 'nearest' // Preserve pixel art quality + }) + .png() + .toBuffer() + + composites.push({ + input: resizedTextureBuffer, + left: x, + top: y, + blend: 'over' + }) + + console.log(`Prepared ${textureName} at (${x}, ${y}) with size (${width}, ${height})`) + } catch (error) { + console.error(`Failed to prepare ${textureName}:`, error) + } + } else { + console.warn(`Texture ${textureName} not found in ${atlasType} atlas`) + } + } + + if (composites.length > 0) { + // Apply all patches at once using Sharp's composite + await sharp(atlasPath) + .composite(composites) + .png() + .toFile(atlasPath + '.tmp') + + // Replace original with patched version + fs.renameSync(atlasPath + '.tmp', atlasPath) + console.log(`Saved patched ${atlasType} atlas to ${atlasPath}`) + } +} + +async function main() { + const customBlocksDir = './assets/customTextures/blocks' + const customItemsDir = './assets/customTextures/items' + const distDir = './dist/static/image' + + try { + // Patch blocks atlas + await patchTextureAtlas('blocks', blocksAtlas as unknown as AtlasFile, customBlocksDir, distDir) + + // Patch items atlas + await patchTextureAtlas('items', itemsAtlas as unknown as AtlasFile, customItemsDir, distDir) + + console.log('Texture atlas patching completed!') + } catch (error) { + console.error('Failed to patch texture atlases:', error) + process.exit(1) + } +} + +// Run the script +main() diff --git a/src/basicSounds.ts b/src/basicSounds.ts index 40428c6b..37f8dccd 100644 --- a/src/basicSounds.ts +++ b/src/basicSounds.ts @@ -43,7 +43,7 @@ export async function loadSound (path: string, contents = path) { } } -export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = 500) => { +export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = options.remoteSoundsLoadTimeout, loop = false) => { const soundBuffer = sounds[url] if (!soundBuffer) { const start = Date.now() @@ -51,10 +51,10 @@ export const loadOrPlaySound = async (url, soundVolume = 1, loadTimeout = 500) = if (cancelled || Date.now() - start > loadTimeout) return } - return playSound(url, soundVolume) + return playSound(url, soundVolume, loop) } -export async function playSound (url, soundVolume = 1) { +export async function playSound (url, soundVolume = 1, loop = false) { const volume = soundVolume * (options.volume / 100) if (!volume) return @@ -75,6 +75,7 @@ export async function playSound (url, soundVolume = 1) { const gainNode = audioContext.createGain() const source = audioContext.createBufferSource() source.buffer = soundBuffer + source.loop = loop source.connect(gainNode) gainNode.connect(audioContext.destination) gainNode.gain.value = volume @@ -99,6 +100,16 @@ export async function playSound (url, soundVolume = 1) { onEnded (callback: () => void) { callbacks.push(callback) }, + stop () { + try { + source.stop() + // Remove from active sounds + const index = activeSounds.findIndex(s => s.source === source) + if (index !== -1) activeSounds.splice(index, 1) + } catch (err) { + console.warn('Failed to stop sound:', err) + } + }, } } @@ -113,6 +124,19 @@ export function stopAllSounds () { activeSounds.length = 0 } +export function stopSound (url: string) { + const soundIndex = activeSounds.findIndex(s => s.source.buffer === sounds[url]) + if (soundIndex !== -1) { + const { source } = activeSounds[soundIndex] + try { + source.stop() + } catch (err) { + console.warn('Failed to stop sound:', err) + } + activeSounds.splice(soundIndex, 1) + } +} + export function changeVolumeOfCurrentlyPlayingSounds (newVolume: number) { const normalizedVolume = newVolume / 100 for (const { gainNode, volumeMultiplier } of activeSounds) { diff --git a/src/defaultOptions.ts b/src/defaultOptions.ts index 95dbfd63..378cd14a 100644 --- a/src/defaultOptions.ts +++ b/src/defaultOptions.ts @@ -85,6 +85,7 @@ export const defaultOptions = { } as any, preferLoadReadonly: false, experimentalClientSelfReload: true, + remoteSoundsLoadTimeout: 500, disableLoadPrompts: false, guestUsername: 'guest', askGuestName: true, diff --git a/src/react/Slider.tsx b/src/react/Slider.tsx index e177578c..2a068264 100644 --- a/src/react/Slider.tsx +++ b/src/react/Slider.tsx @@ -1,5 +1,5 @@ // Slider.tsx -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useRef, useCallback } from 'react' import styles from './slider.module.css' import SharedHudVars from './SharedHudVars' @@ -12,6 +12,7 @@ interface Props extends React.ComponentProps<'div'> { min?: number; max?: number; disabledReason?: string; + throttle?: number | false; // milliseconds, default 100, false to disable updateValue?: (value: number) => void; updateOnDragEnd?: boolean; @@ -26,15 +27,24 @@ const Slider: React.FC = ({ min = 0, max = 100, disabledReason, + throttle = 0, updateOnDragEnd = false, updateValue, ...divProps }) => { + label = translate(label) + disabledReason = translate(disabledReason) + valueDisplay = typeof valueDisplay === 'string' ? translate(valueDisplay) : valueDisplay + const [value, setValue] = useState(valueProp) const getRatio = (v = value) => Math.max(Math.min((v - min) / (max - min), 1), 0) const [ratio, setRatio] = useState(getRatio()) + // Throttling refs + const timeoutRef = useRef(null) + const lastValueRef = useRef(valueProp) + useEffect(() => { setValue(valueProp) }, [valueProp]) @@ -42,14 +52,52 @@ const Slider: React.FC = ({ setRatio(getRatio()) }, [value, min, max]) - const fireValueUpdate = (dragEnd: boolean, v = value) => { + const throttledUpdateValue = useCallback((newValue: number, dragEnd: boolean) => { if (updateOnDragEnd !== dragEnd) return - updateValue?.(v) + if (!updateValue) return + + lastValueRef.current = newValue + + if (!throttle) { + // No throttling + updateValue(newValue) + return + } + + // Clear existing timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + + // Set new timeout + timeoutRef.current = setTimeout(() => { + updateValue(lastValueRef.current) + timeoutRef.current = null + }, throttle) + }, [updateValue, updateOnDragEnd, throttle]) + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + // Fire the last value immediately on cleanup + if (updateValue && lastValueRef.current !== undefined) { + updateValue(lastValueRef.current) + } + } + } + }, [updateValue]) + + const fireValueUpdate = (dragEnd: boolean, v = value) => { + throttledUpdateValue(v, dragEnd) } + const labelText = `${label}: ${valueDisplay ?? value} ${unit}` + return ( -
+
17 ? 'settings-text-container-long' : ''}`} style={{ width }} {...divProps}> = ({
diff --git a/src/sounds/botSoundSystem.ts b/src/sounds/botSoundSystem.ts index 72aa3da8..cb237072 100644 --- a/src/sounds/botSoundSystem.ts +++ b/src/sounds/botSoundSystem.ts @@ -11,6 +11,7 @@ import { showNotification } from '../react/NotificationProvider' import { pixelartIcons } from '../react/PixelartIcon' import { createSoundMap, SoundMap } from './soundsMap' import { musicSystem } from './musicSystem' +import './customSoundSystem' let soundMap: SoundMap | undefined @@ -50,8 +51,9 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { appViewer.backend?.soundSystem?.playSound( position, soundData.url, - soundData.volume * (options.volume / 100), - Math.max(Math.min(pitch ?? 1, 2), 0.5) + soundData.volume, + Math.max(Math.min(pitch ?? 1, 2), 0.5), + soundData.timeout ?? options.remoteSoundsLoadTimeout ) } if (getDistance(bot.entity.position, position) < 4 * 16) { @@ -81,7 +83,7 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { } const randomMusicKey = musicKeys[Math.floor(Math.random() * musicKeys.length)] const soundData = await soundMap.getSoundUrl(randomMusicKey) - if (!soundData) return + if (!soundData || !soundMap) return await musicSystem.playMusic(soundData.url, soundData.volume) } @@ -109,6 +111,9 @@ subscribeKey(miscUiState, 'gameLoaded', async () => { } bot.on('soundEffectHeard', async (soundId, position, volume, pitch) => { + if (/^https?:/.test(soundId.replace('minecraft:', ''))) { + return + } await playHardcodedSound(soundId, position, volume, pitch) }) diff --git a/src/sounds/customSoundSystem.ts b/src/sounds/customSoundSystem.ts new file mode 100644 index 00000000..5dfa89f7 --- /dev/null +++ b/src/sounds/customSoundSystem.ts @@ -0,0 +1,44 @@ +import { loadOrPlaySound, stopAllSounds, stopSound } from '../basicSounds' + +const customSoundSystem = () => { + bot._client.on('named_sound_effect', packet => { + let { soundName } = packet + let metadata = {} as { loadTimeout?: number, loop?: boolean } + + // Extract JSON metadata from parentheses at the end + const jsonMatch = /\(({.*})\)$/.exec(soundName) + if (jsonMatch) { + try { + metadata = JSON.parse(jsonMatch[1]) + soundName = soundName.slice(0, -jsonMatch[0].length) + } catch (e) { + console.warn('Failed to parse sound metadata:', jsonMatch[1]) + } + } + + if (/^https?:/.test(soundName.replace('minecraft:', ''))) { + const { loadTimeout, loop } = metadata + void loadOrPlaySound(soundName, packet.volume, loadTimeout, loop) + } + }) + + bot._client.on('stop_sound', packet => { + const { flags, source, sound } = packet + + if (flags === 0) { + // Stop all sounds + stopAllSounds() + } else if (sound) { + // Stop specific sound by name + stopSound(sound) + } + }) + + bot.on('end', () => { + stopAllSounds() + }) +} + +customEvents.on('mineflayerBotCreated', () => { + customSoundSystem() +}) diff --git a/src/sounds/soundsMap.ts b/src/sounds/soundsMap.ts index 1b0a0178..47028971 100644 --- a/src/sounds/soundsMap.ts +++ b/src/sounds/soundsMap.ts @@ -35,6 +35,7 @@ interface ResourcePackSoundEntry { name: string stream?: boolean volume?: number + timeout?: number } interface ResourcePackSound { @@ -140,7 +141,7 @@ export class SoundMap { await scan(soundsBasePath) } - async getSoundUrl (soundKey: string, volume = 1): Promise<{ url: string; volume: number } | undefined> { + async getSoundUrl (soundKey: string, volume = 1): Promise<{ url: string; volume: number, timeout?: number } | undefined> { // First check resource pack sounds.json if (this.activeResourcePackSoundsJson && soundKey in this.activeResourcePackSoundsJson) { const rpSound = this.activeResourcePackSoundsJson[soundKey] @@ -151,6 +152,13 @@ export class SoundMap { if (this.activeResourcePackBasePath) { const tryFormat = async (format: string) => { try { + if (sound.name.startsWith('http://') || sound.name.startsWith('https://')) { + return { + url: sound.name, + volume: soundVolume * Math.max(Math.min(volume, 1), 0), + timeout: sound.timeout + } + } const resourcePackPath = path.join(this.activeResourcePackBasePath!, `/assets/minecraft/sounds/${sound.name}.${format}`) const fileData = await fs.promises.readFile(resourcePackPath) return { diff --git a/src/watchOptions.ts b/src/watchOptions.ts index 478da4fb..2f5199c0 100644 --- a/src/watchOptions.ts +++ b/src/watchOptions.ts @@ -80,6 +80,10 @@ export const watchOptionsAfterViewerInit = () => { updateFpsLimit(o) }) + watchValue(options, o => { + appViewer.inWorldRenderingConfig.volume = Math.max(o.volume / 100, 0) + }) + watchValue(options, o => { appViewer.inWorldRenderingConfig.vrSupport = o.vrSupport appViewer.inWorldRenderingConfig.vrPageGameRendering = o.vrPageGameRendering