minecraft-renderer 0.1.90 → 0.1.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecraft-renderer",
3
- "version": "0.1.90",
3
+ "version": "0.1.91",
4
4
  "description": "The most Modular Minecraft world renderer with Three.js WebGL backend",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -18,7 +18,14 @@ import { WorldRendererConfig } from './config'
18
18
  export type MaybePromise<T> = Promise<T> | T
19
19
 
20
20
  export interface SoundSystem {
21
- playSound: (position: { x: number; y: number; z: number }, path: string, volume?: number, pitch?: number, timeout?: number) => void
21
+ playSound: (
22
+ position: { x: number; y: number; z: number },
23
+ path: string,
24
+ volume?: number,
25
+ pitch?: number,
26
+ timeout?: number,
27
+ attenuationDistance?: number
28
+ ) => void
22
29
  destroy: () => void
23
30
  }
24
31
 
@@ -0,0 +1,35 @@
1
+ //@ts-nocheck
2
+ export const DEFAULT_ATTENUATION_DISTANCE = 16
3
+
4
+ export function computeEffectiveVolume(soundEntryVolume: number, packetVolume: number): number {
5
+ return soundEntryVolume * Math.max(packetVolume, 0)
6
+ }
7
+
8
+ export function computeIndividualGain(effectiveVolume: number): number {
9
+ return Math.min(Math.max(effectiveVolume, 0), 1)
10
+ }
11
+
12
+ export function computePositionalAudioParams(effectiveVolume: number, attenuationDistance: number): { individualGain: number; maxDistance: number } {
13
+ return {
14
+ individualGain: computeIndividualGain(effectiveVolume),
15
+ maxDistance: Math.max(effectiveVolume, 1) * attenuationDistance
16
+ }
17
+ }
18
+
19
+ export function applyVanillaLinearPanner(panner: PannerNode, maxDistance: number): void {
20
+ panner.distanceModel = 'linear'
21
+ panner.refDistance = 0
22
+ panner.rolloffFactor = 1
23
+ panner.maxDistance = maxDistance
24
+ }
25
+
26
+ export function setPannerPositionImmediate(panner: PannerNode, audioContext: AudioContext, x: number, y: number, z: number): void {
27
+ const t = audioContext.currentTime
28
+ if (panner.positionX) {
29
+ panner.positionX.setValueAtTime(x, t)
30
+ panner.positionY.setValueAtTime(y, t)
31
+ panner.positionZ.setValueAtTime(z, t)
32
+ } else {
33
+ panner.setPosition(x, y, z)
34
+ }
35
+ }
@@ -0,0 +1,51 @@
1
+ //@ts-nocheck
2
+ import { describe, expect, it } from 'vitest'
3
+ import { computeEffectiveVolume, computeIndividualGain, computePositionalAudioParams, DEFAULT_ATTENUATION_DISTANCE } from '../positionalAudioAttenuation'
4
+
5
+ describe('computeEffectiveVolume', () => {
6
+ it('multiplies entry volume by non-negative packet volume', () => {
7
+ expect(computeEffectiveVolume(1, 1)).toBe(1)
8
+ expect(computeEffectiveVolume(12, 2)).toBe(24)
9
+ expect(computeEffectiveVolume(1, 0.5)).toBe(0.5)
10
+ })
11
+
12
+ it('does not clamp packet volume above 1', () => {
13
+ expect(computeEffectiveVolume(1, 2)).toBe(2)
14
+ expect(computeEffectiveVolume(12, 2)).toBe(24)
15
+ })
16
+
17
+ it('treats negative packet volume as zero', () => {
18
+ expect(computeEffectiveVolume(1, -1)).toBe(0)
19
+ })
20
+ })
21
+
22
+ describe('computePositionalAudioParams', () => {
23
+ it('uses default attenuation distance semantics', () => {
24
+ expect(computePositionalAudioParams(1, DEFAULT_ATTENUATION_DISTANCE)).toEqual({
25
+ individualGain: 1,
26
+ maxDistance: 16
27
+ })
28
+ })
29
+
30
+ it('clamps individual gain to [0, 1] while extending range for volume > 1', () => {
31
+ expect(computePositionalAudioParams(24, 16)).toEqual({
32
+ individualGain: 1,
33
+ maxDistance: 384
34
+ })
35
+ })
36
+
37
+ it('keeps individual gain independent of master volume', () => {
38
+ expect(computeIndividualGain(1)).toBe(1)
39
+ expect(computeIndividualGain(2)).toBe(1)
40
+ expect(computeIndividualGain(0.5)).toBe(0.5)
41
+ })
42
+
43
+ it('matches vanilla linear falloff checkpoints at distance 0/8/16', () => {
44
+ const { individualGain, maxDistance } = computePositionalAudioParams(1, 16)
45
+ const linearMultiplier = (distance: number) => 1 - distance / maxDistance
46
+ expect(individualGain * linearMultiplier(0)).toBe(1)
47
+ expect(individualGain * linearMultiplier(8)).toBe(0.5)
48
+ expect(individualGain * linearMultiplier(16)).toBe(0)
49
+ expect(individualGain * linearMultiplier(24)).toBeLessThanOrEqual(0)
50
+ })
51
+ })
@@ -0,0 +1,110 @@
1
+ //@ts-nocheck
2
+ import { describe, expect, it, vi, beforeEach } from 'vitest'
3
+
4
+ const mockSetVolume = vi.fn()
5
+ const mockPlay = vi.fn()
6
+ const loadAsyncDeferreds: Array<{ resolve: (buffer: unknown) => void }> = []
7
+
8
+ vi.mock('three', () => {
9
+ class MockPositionalAudio {
10
+ panner = {
11
+ distanceModel: '',
12
+ refDistance: 0,
13
+ rolloffFactor: 0,
14
+ maxDistance: 0,
15
+ positionX: { setValueAtTime: vi.fn() },
16
+ positionY: { setValueAtTime: vi.fn() },
17
+ positionZ: { setValueAtTime: vi.fn() }
18
+ }
19
+
20
+ position = { set: vi.fn() }
21
+ matrixWorld = { decompose: vi.fn() }
22
+ setBuffer = vi.fn()
23
+ setVolume = mockSetVolume
24
+ setPlaybackRate = vi.fn()
25
+ play = mockPlay
26
+ updateMatrixWorld = vi.fn()
27
+ onEnded: (() => void) | undefined
28
+ source = null
29
+ disconnect = vi.fn()
30
+ }
31
+
32
+ class MockAudioListener {
33
+ context = { currentTime: 0 }
34
+ removeFromParent = vi.fn()
35
+ }
36
+
37
+ class MockAudioLoader {
38
+ manager = { itemEnd: vi.fn() }
39
+ loadAsync() {
40
+ return new Promise(resolve => {
41
+ loadAsyncDeferreds.push({ resolve })
42
+ })
43
+ }
44
+ }
45
+
46
+ class MockVector3 {
47
+ x = 0
48
+ y = 0
49
+ z = 0
50
+ set = vi.fn()
51
+ }
52
+
53
+ return {
54
+ PositionalAudio: MockPositionalAudio,
55
+ AudioListener: MockAudioListener,
56
+ AudioLoader: MockAudioLoader,
57
+ Vector3: MockVector3,
58
+ Quaternion: class {}
59
+ }
60
+ })
61
+
62
+ import { ThreeJsSound } from '../threeJsSound'
63
+
64
+ function makeWorldRenderer() {
65
+ return {
66
+ onWorldSwitched: [] as Array<() => void>,
67
+ onReactiveConfigUpdated: vi.fn(),
68
+ camera: { add: vi.fn() },
69
+ cameraWorldPos: { x: 0, y: 64, z: 0 },
70
+ sceneOrigin: {
71
+ addAndTrack: vi.fn(),
72
+ removeAndUntrack: vi.fn()
73
+ }
74
+ }
75
+ }
76
+
77
+ describe('ThreeJsSound', () => {
78
+ beforeEach(() => {
79
+ mockSetVolume.mockClear()
80
+ mockPlay.mockClear()
81
+ loadAsyncDeferreds.length = 0
82
+ })
83
+
84
+ it('applies master volume 0 then unmute without NaN', async () => {
85
+ const soundSystem = new ThreeJsSound(makeWorldRenderer() as any)
86
+ soundSystem.baseVolume = 0
87
+ soundSystem.playSound({ x: 1, y: 2, z: 3 }, '/test.mp3', 1)
88
+
89
+ loadAsyncDeferreds[0].resolve({})
90
+ await Promise.resolve()
91
+
92
+ expect(mockSetVolume).toHaveBeenLastCalledWith(0)
93
+
94
+ soundSystem.changeVolume(1)
95
+ expect(mockSetVolume).toHaveBeenLastCalledWith(1)
96
+ expect(Number.isNaN(mockSetVolume.mock.calls.at(-1)?.[0])).toBe(false)
97
+ })
98
+
99
+ it('uses current master volume after async load completes', async () => {
100
+ const soundSystem = new ThreeJsSound(makeWorldRenderer() as any)
101
+ soundSystem.baseVolume = 1
102
+ soundSystem.playSound({ x: 0, y: 0, z: 0 }, '/test.mp3', 0.8)
103
+
104
+ soundSystem.changeVolume(0.25)
105
+ loadAsyncDeferreds[0].resolve({})
106
+ await Promise.resolve()
107
+
108
+ expect(mockSetVolume).toHaveBeenLastCalledWith(0.2)
109
+ })
110
+ })
@@ -2,11 +2,17 @@
2
2
  import * as THREE from 'three'
3
3
  import { WorldRendererThree } from './worldRendererThree'
4
4
  import { SoundSystem } from '../graphicsBackend/types'
5
+ import { applyVanillaLinearPanner, computePositionalAudioParams, DEFAULT_ATTENUATION_DISTANCE, setPannerPositionImmediate } from './positionalAudioAttenuation'
6
+
7
+ const _pannerPosition = /*@__PURE__*/ new THREE.Vector3()
8
+ const _pannerQuaternion = /*@__PURE__*/ new THREE.Quaternion()
9
+ const _pannerScale = /*@__PURE__*/ new THREE.Vector3()
5
10
 
6
11
  export class ThreeJsSound implements SoundSystem {
7
12
  audioListener: THREE.AudioListener | undefined
8
13
  private readonly activeSounds = new Set<THREE.PositionalAudio>()
9
14
  private readonly audioContext: AudioContext | undefined
15
+ /** Normalized individual gain (clamped to [0, 1], excluding master volume). */
10
16
  private readonly soundVolumes = new Map<THREE.PositionalAudio, number>()
11
17
  baseVolume = 1
12
18
 
@@ -26,28 +32,40 @@ export class ThreeJsSound implements SoundSystem {
26
32
  this.worldRenderer.camera.add(this.audioListener)
27
33
  }
28
34
 
29
- playSound(position: { x: number; y: number; z: number }, path: string, volume = 1, pitch = 1, timeout = 500) {
35
+ playSound(
36
+ position: { x: number; y: number; z: number },
37
+ path: string,
38
+ volume = 1,
39
+ pitch = 1,
40
+ timeout = 500,
41
+ attenuationDistance = DEFAULT_ATTENUATION_DISTANCE
42
+ ) {
30
43
  this.initAudioListener()
31
44
 
32
45
  const sound = new THREE.PositionalAudio(this.audioListener!)
33
46
  this.activeSounds.add(sound)
34
- this.soundVolumes.set(sound, volume)
47
+
48
+ const { individualGain, maxDistance } = computePositionalAudioParams(volume, attenuationDistance)
49
+ this.soundVolumes.set(sound, individualGain)
35
50
 
36
51
  const audioLoader = new THREE.AudioLoader()
37
52
  const start = Date.now()
38
53
  void audioLoader.loadAsync(path).then(buffer => {
39
54
  if (Date.now() - start > timeout) {
40
55
  console.warn('Ignored playing sound', path, 'due to timeout:', timeout, 'ms <', Date.now() - start, 'ms')
56
+ this.activeSounds.delete(sound)
57
+ this.soundVolumes.delete(sound)
41
58
  return
42
59
  }
43
- // play
44
60
  sound.setBuffer(buffer)
45
- sound.setRefDistance(20)
46
- sound.setVolume(volume * this.baseVolume)
47
- sound.setPlaybackRate(pitch) // set the pitch
48
- // set sound position
61
+ applyVanillaLinearPanner(sound.panner, maxDistance)
62
+ sound.setVolume(individualGain * this.baseVolume)
63
+ sound.setPlaybackRate(pitch)
49
64
  this.worldRenderer.sceneOrigin.addAndTrack(sound)
50
65
  sound.position.set(position.x, position.y, position.z)
66
+ sound.updateMatrixWorld(true)
67
+ sound.matrixWorld.decompose(_pannerPosition, _pannerQuaternion, _pannerScale)
68
+ setPannerPositionImmediate(sound.panner, this.audioListener!.context, _pannerPosition.x, _pannerPosition.y, _pannerPosition.z)
51
69
  sound.onEnded = () => {
52
70
  this.worldRenderer.sceneOrigin.removeAndUntrack(sound)
53
71
  if (sound.source) {
@@ -76,14 +94,13 @@ export class ThreeJsSound implements SoundSystem {
76
94
 
77
95
  changeVolume(volume: number) {
78
96
  this.baseVolume = volume
79
- for (const [sound, individualVolume] of this.soundVolumes) {
80
- sound.setVolume(individualVolume * this.baseVolume)
97
+ for (const [sound, individualGain] of this.soundVolumes) {
98
+ sound.setVolume(individualGain * this.baseVolume)
81
99
  }
82
100
  }
83
101
 
84
102
  destroy() {
85
103
  this.stopAll()
86
- // Remove and cleanup audio listener
87
104
  if (this.audioListener) {
88
105
  this.audioListener.removeFromParent()
89
106
  this.audioListener = undefined
@@ -91,6 +108,7 @@ export class ThreeJsSound implements SoundSystem {
91
108
  }
92
109
 
93
110
  playTestSound() {
94
- this.playSound(this.worldRenderer.camera.position, '/sound.mp3')
111
+ const { x, y, z } = this.worldRenderer.cameraWorldPos
112
+ this.playSound({ x, y, z }, '/sound.mp3')
95
113
  }
96
114
  }