- 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.
This commit is contained in:
Vitaly Turovsky 2025-08-16 09:15:37 +03:00
commit e1293b6cb3
11 changed files with 323 additions and 18 deletions

View file

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

View file

@ -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<THREE.PositionalAudio>()
private readonly audioContext: AudioContext | undefined
private readonly soundVolumes = new Map<THREE.PositionalAudio, number>()
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()

View file

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

137
scripts/patchAssets.ts Normal file
View file

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

View file

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

View file

@ -85,6 +85,7 @@ export const defaultOptions = {
} as any,
preferLoadReadonly: false,
experimentalClientSelfReload: true,
remoteSoundsLoadTimeout: 500,
disableLoadPrompts: false,
guestUsername: 'guest',
askGuestName: true,

View file

@ -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<Props> = ({
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<NodeJS.Timeout | null>(null)
const lastValueRef = useRef<number>(valueProp)
useEffect(() => {
setValue(valueProp)
}, [valueProp])
@ -42,14 +52,52 @@ const Slider: React.FC<Props> = ({
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 (
<SharedHudVars>
<div className={styles['slider-container']} style={{ width }} {...divProps}>
<div className={`${styles['slider-container']} settings-text-container ${labelText.length > 17 ? 'settings-text-container-long' : ''}`} style={{ width }} {...divProps}>
<input
type="range"
className={styles.slider}
@ -76,7 +124,7 @@ const Slider: React.FC<Props> = ({
<div className={styles.disabled} title={disabledReason} />
<div className={styles['slider-thumb']} style={{ left: `calc((100% * ${ratio}) - (8px * ${ratio}))` }} />
<label className={styles.label}>
{label}: {valueDisplay ?? value} {unit}
{labelText}
</label>
</div>
</SharedHudVars>

View file

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

View file

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

View file

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

View file

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