@marble.fun/track-catalog 3.0.0

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.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +15 -0
  3. package/assets.json +346 -0
  4. package/package.json +32 -0
  5. package/release.json +135 -0
  6. package/src/audio/game-audio-preference.ts +9 -0
  7. package/src/authoritative.ts +6 -0
  8. package/src/catalog.ts +1 -0
  9. package/src/index.ts +14 -0
  10. package/src/legacy-stock-catalog.ts +130 -0
  11. package/src/pages/lobby/lobby-types.ts +140 -0
  12. package/src/pages/lobby/marble-factory.ts +931 -0
  13. package/src/pages/play/race/egypt-elevator-blender.ts +159 -0
  14. package/src/pages/play/race/egypt-portal-frame.ts +382 -0
  15. package/src/pages/play/race/finish-detection.ts +1 -0
  16. package/src/pages/play/race/japanese-sakura-castle-support-blender.ts +510 -0
  17. package/src/pages/play/race/japanese-sakura-kawara-roof.ts +201 -0
  18. package/src/pages/play/race/japanese-sakura-portal-frame.ts +325 -0
  19. package/src/pages/play/race/jungle-portal-frame.ts +308 -0
  20. package/src/pages/play/race/jungle-temple-lift.ts +366 -0
  21. package/src/pages/play/race/marble-entity.ts +257 -0
  22. package/src/pages/play/race/marble-material.ts +20 -0
  23. package/src/pages/play/race/marble-race-engine.ts +2652 -0
  24. package/src/pages/play/race/path-pattern-layout.ts +142 -0
  25. package/src/pages/play/race/peg-obstacle-visual.ts +171 -0
  26. package/src/pages/play/race/physics-debug.ts +1 -0
  27. package/src/pages/play/race/physics-dry-run.ts +90 -0
  28. package/src/pages/play/race/portal-derived-elevator-skins.ts +729 -0
  29. package/src/pages/play/race/portal-pair-color.ts +20 -0
  30. package/src/pages/play/race/portal-proportions.ts +25 -0
  31. package/src/pages/play/race/portal-visual.ts +1108 -0
  32. package/src/pages/play/race/race-camera-director.ts +586 -0
  33. package/src/pages/play/race/race-camera-obstruction.ts +37 -0
  34. package/src/pages/play/race/race-camera-tour-planner.ts +274 -0
  35. package/src/pages/play/race/race-camera.ts +206 -0
  36. package/src/pages/play/race/race-completion.ts +5 -0
  37. package/src/pages/play/race/race-configuration.ts +57 -0
  38. package/src/pages/play/race/race-marble-focus-renderer.ts +134 -0
  39. package/src/pages/play/race/race-physics-profile.ts +7 -0
  40. package/src/pages/play/race/race-physics.ts +16 -0
  41. package/src/pages/play/race/race-playback.ts +41 -0
  42. package/src/pages/play/race/race-rolling-audio.ts +616 -0
  43. package/src/pages/play/race/race-simulation-renderer.ts +221 -0
  44. package/src/pages/play/race/race-terminal-checker-strip.ts +226 -0
  45. package/src/pages/play/race/race-transform-interpolation.ts +50 -0
  46. package/src/pages/play/race/race-types.ts +12 -0
  47. package/src/pages/play/race/rebound-visual.ts +70 -0
  48. package/src/pages/play/race/seeded-random.ts +23 -0
  49. package/src/pages/play/race/space-station-portal-frame.ts +275 -0
  50. package/src/pages/play/race/track-appearance-canopy.ts +946 -0
  51. package/src/pages/play/race/track-appearance-floor-runner.ts +206 -0
  52. package/src/pages/play/race/track-appearance-jungle-flower-hedge.ts +483 -0
  53. package/src/pages/play/race/track-appearance-rope-wall.ts +443 -0
  54. package/src/pages/play/race/track-appearance-sakura-bridge-fence.ts +305 -0
  55. package/src/pages/play/race/track-appearance-sakura-castle-wall.ts +1031 -0
  56. package/src/pages/play/race/track-appearance-space-station-wall.ts +497 -0
  57. package/src/pages/play/race/track-appearance-stone-block-shell.ts +517 -0
  58. package/src/pages/play/race/track-appearance-support.ts +983 -0
  59. package/src/pages/play/race/track-appearance-vampire-carved-wall.ts +545 -0
  60. package/src/pages/play/race/track-appearance-wall-terminal.ts +167 -0
  61. package/src/pages/play/race/track-course.ts +271 -0
  62. package/src/pages/play/race/track-definition.ts +443 -0
  63. package/src/pages/play/race/track-fence-architecture.ts +33 -0
  64. package/src/pages/play/race/track-floor-material-rules.ts +51 -0
  65. package/src/pages/play/race/track-mechanisms.ts +1350 -0
  66. package/src/pages/play/race/track-physics-surface.ts +6 -0
  67. package/src/pages/play/race/track-route-network.ts +9 -0
  68. package/src/pages/play/race/track-shadow-batching.ts +760 -0
  69. package/src/pages/play/race/track-surface-basis.ts +1 -0
  70. package/src/pages/play/race/track-surface-topology.ts +326 -0
  71. package/src/pages/play/race/track-world.ts +10181 -0
  72. package/src/pages/play/race/under-track-support-architecture.ts +122 -0
  73. package/src/pages/play/race/vortex-profile.ts +5 -0
  74. package/src/pages/play/race/vortex-water-effect.ts +863 -0
  75. package/src/pages/play/race/wild-west-mine-lift.ts +281 -0
  76. package/src/pages/play/race/wild-west-vortex-housing.ts +572 -0
  77. package/src/pages/track-lab/atlas-vortex-layout.ts +1 -0
  78. package/src/pages/track-lab/cone-field-layout.ts +1 -0
  79. package/src/pages/track-lab/conveyor-layout.ts +1 -0
  80. package/src/pages/track-lab/engine/canonical-track-assembly.ts +64 -0
  81. package/src/pages/track-lab/engine/editor-object-geometry.ts +170 -0
  82. package/src/pages/track-lab/engine/track-lab-connection-state.ts +123 -0
  83. package/src/pages/track-lab/engine/track-piece-visual.ts +1420 -0
  84. package/src/pages/track-lab/flipper-bank-layout.ts +1 -0
  85. package/src/pages/track-lab/flipper-component.ts +127 -0
  86. package/src/pages/track-lab/rising-wall-layout.ts +1 -0
  87. package/src/pages/track-lab/simple-obstacle-layout.ts +1 -0
  88. package/src/pages/track-lab/track-piece-layout.ts +28 -0
  89. package/src/pages/track-lab/track-runtime-adapter.ts +1152 -0
  90. package/src/three/geometry-attribute-aliases.ts +24 -0
  91. package/src/three/geometry-detail.ts +36 -0
  92. package/src/three/homepage-marble-geometry.ts +15 -0
  93. package/src/three/japanese-sakura-appearance.ts +252 -0
  94. package/src/three/jungle-flower-hedge-appearance.ts +7 -0
  95. package/src/three/jungle-temple-appearance.ts +5 -0
  96. package/src/three/low-quality-scene-renderer.ts +132 -0
  97. package/src/three/marble-sphere-geometry.ts +13 -0
  98. package/src/three/mobile-rendering-budget.ts +11 -0
  99. package/src/three/painted-valley-appearance.ts +270 -0
  100. package/src/three/railroad-appearance.ts +71 -0
  101. package/src/three/renderer-resize.ts +32 -0
  102. package/src/three/rope-wall-appearance.ts +91 -0
  103. package/src/three/rugged-track-appearance.ts +2 -0
  104. package/src/three/scene-lighting.ts +183 -0
  105. package/src/three/scene-shader-preparation.ts +64 -0
  106. package/src/three/scene-theme-controller.ts +461 -0
  107. package/src/three/shadow-shader-preparation.ts +162 -0
  108. package/src/three/shared-obstacle-metal.ts +44 -0
  109. package/src/three/soft-additive-particles.ts +57 -0
  110. package/src/three/space-station-appearance.ts +213 -0
  111. package/src/three/static-track-batched-mesh.ts +132 -0
  112. package/src/three/stone-track-shell-appearance.ts +12 -0
  113. package/src/three/theme-ambience-controller.ts +226 -0
  114. package/src/three/theme-asset-cache.ts +120 -0
  115. package/src/three/theme-audio-preference.ts +43 -0
  116. package/src/three/theme-catalog.ts +1457 -0
  117. package/src/three/theme-material-assets.ts +258 -0
  118. package/src/three/theme-material-ktx2-manifest.ts +190 -0
  119. package/src/three/theme-material-registry.ts +1952 -0
  120. package/src/three/theme-world-factory.ts +590 -0
  121. package/src/three/track-connector-batch.ts +125 -0
  122. package/src/three/track-material-continuity.ts +442 -0
  123. package/src/three/track-theme.ts +535 -0
  124. package/src/three/vampire-castle-appearance.ts +258 -0
  125. package/src/three/visual-frame-pacing.ts +19 -0
  126. package/src/three/visual-geometry.ts +120 -0
  127. package/src/three/visual-quality.ts +83 -0
  128. package/src/three/water-appearance.ts +29 -0
  129. package/src/three/wild-west-sand-appearance.ts +2 -0
@@ -0,0 +1,16 @@
1
+ export {
2
+ CONTACT_NATURAL_FREQUENCY,
3
+ createMarblePhysicsState,
4
+ createRacePhysicsWorld,
5
+ configureRacePhysicsWorld,
6
+ FIXED_TIMESTEP,
7
+ initializeRacePhysics,
8
+ MAX_RACE_SECONDS,
9
+ MAX_RACE_TICKS,
10
+ updateMarblePhysicsState,
11
+ type MarblePhysicsState,
12
+ type MutablePhysicsVector3,
13
+ type PhysicsCourse,
14
+ type PhysicsTrackPose,
15
+ type PhysicsTrackProjection,
16
+ } from '@marble.fun/physics-engine'
@@ -0,0 +1,41 @@
1
+ // UI playback labels map directly to deterministic simulation time. Rapier
2
+ // still advances at 120 Hz; 1x simply consumes two fixed steps per 60 Hz frame.
3
+ export const STANDARD_RACE_TIME_SCALE = 1
4
+ export const MIN_RACE_TIME_SCALE = 0.25
5
+ export const MAX_RACE_TIME_SCALE = 3
6
+ export const RACE_COUNTDOWN_SECONDS = 3
7
+
8
+ export function playbackRateToTimeScale(playbackRate: number): number {
9
+ return playbackRate * STANDARD_RACE_TIME_SCALE
10
+ }
11
+
12
+ export function advanceRaceCountdown(secondsRemaining: number, realDeltaSeconds: number): number {
13
+ if (!Number.isFinite(realDeltaSeconds) || realDeltaSeconds <= 0) return secondsRemaining
14
+ return Math.max(0, secondsRemaining - realDeltaSeconds)
15
+ }
16
+
17
+ export function synchronizedRaceIntroState(
18
+ durationSeconds: number,
19
+ synchronizedElapsedMs: number,
20
+ synchronizedIntroElapsedMs?: number,
21
+ ): { countdownSeconds: number; elapsedSeconds: number; phase: 'countdown' | 'touring' } {
22
+ const finiteDuration = Number.isFinite(durationSeconds) ? Math.max(0, durationSeconds) : 0
23
+ const secondsUntilStart = Number.isFinite(synchronizedElapsedMs)
24
+ ? Math.max(0, -synchronizedElapsedMs / 1_000)
25
+ : finiteDuration
26
+ const synchronizedWindowSeconds =
27
+ synchronizedIntroElapsedMs !== undefined &&
28
+ Number.isFinite(synchronizedIntroElapsedMs) &&
29
+ Number.isFinite(synchronizedElapsedMs)
30
+ ? Math.max(0.001, (synchronizedIntroElapsedMs - synchronizedElapsedMs) / 1_000)
31
+ : undefined
32
+ const elapsedSeconds =
33
+ synchronizedWindowSeconds === undefined
34
+ ? Math.max(0, Math.min(finiteDuration, finiteDuration - secondsUntilStart))
35
+ : finiteDuration * Math.max(0, Math.min(1, synchronizedIntroElapsedMs! / 1_000 / synchronizedWindowSeconds))
36
+ return {
37
+ countdownSeconds: Math.min(RACE_COUNTDOWN_SECONDS, secondsUntilStart),
38
+ elapsedSeconds,
39
+ phase: secondsUntilStart <= RACE_COUNTDOWN_SECONDS ? 'countdown' : 'touring',
40
+ }
41
+ }
@@ -0,0 +1,616 @@
1
+ import type RAPIER from '@dimforge/rapier3d-deterministic-compat'
2
+ import * as THREE from 'three'
3
+
4
+ import { isGameAudioEnabled } from '../../../audio/game-audio-preference'
5
+ import type { MarbleEntity } from './marble-entity'
6
+
7
+ const ROLL_SOURCE = '/audio/sfx/marble-roll.mp3'
8
+ const COLLISION_SOURCE = '/audio/sfx/marble-collision.mp3'
9
+ const TELEPORT_SOURCE = '/audio/sfx/teleport.mp3'
10
+ const REBOUND_SOURCE = '/audio/sfx/boing.mp3'
11
+ const RISING_WALL_CONTACT_SOURCE = '/audio/sfx/metalcontact.mp3'
12
+ const CONE_CONTACT_SOURCE = '/audio/sfx/cones.mp3'
13
+ const FLIPPER_ACCELERATING_HIT_SOURCE = '/audio/sfx/metalping.mp3'
14
+ const WATER_ENTRY_SOURCE = '/audio/sfx/water-entry.mp3'
15
+ const WATER_ROLL_SOURCE = '/audio/sfx/water-roll-loop.mp3'
16
+ const MIN_ROLL_SPEED = 0.32
17
+ const FULL_ROLL_SPEED = 8.5
18
+ const MAX_PLAYBACK_SPEED = 18
19
+ export const RACE_ROLLING_MASTER_GAIN = 0.88
20
+ export const RACE_COLLISION_MASTER_GAIN = 0.6
21
+ export const PORTAL_TELEPORT_MASTER_GAIN = 0.48
22
+ export const REBOUND_HIT_MASTER_GAIN = 0.42
23
+ export const RISING_WALL_CONTACT_MASTER_GAIN = 0.34
24
+ export const CONE_CONTACT_MASTER_GAIN = 0.32
25
+ export const FLIPPER_ACCELERATING_HIT_MASTER_GAIN = 0.13
26
+ export const WATER_ENTRY_MASTER_GAIN = 0.34
27
+ export const WATER_ROLLING_MASTER_GAIN = 0.62
28
+ export const RISING_WALL_CONTACT_PLAYBACK_RATE = 1.12
29
+ export const CONE_CONTACT_PLAYBACK_RATE = 1.48
30
+ /** Approved mix shared by Test Run and the authoritative live race. */
31
+ export const RACE_ROLLING_MIX_GAIN = 0.38
32
+ export const RACE_COLLISION_MIX_GAIN = 0.65
33
+ const GAIN_RESPONSE = 15
34
+ const MAX_ONE_SHOTS = 6
35
+ const MAX_COLLISION_ONE_SHOTS = 4
36
+ const MAX_FEATURE_ONE_SHOTS = 2
37
+ const IMPACT_COOLDOWN_STEPS = 8
38
+ const MIN_IMPACT_IMPULSE = 0.8
39
+ const FULL_IMPACT_IMPULSE = 14
40
+
41
+ interface LoopVoice {
42
+ source?: AudioBufferSourceNode
43
+ gainNode?: GainNode
44
+ panNode?: StereoPannerNode
45
+ gain: number
46
+ targetGain: number
47
+ playbackRate: number
48
+ position: THREE.Vector3
49
+ }
50
+
51
+ interface PendingImpact {
52
+ gain: number
53
+ playbackRate: number
54
+ position: THREE.Vector3
55
+ }
56
+
57
+ interface ActiveOneShot {
58
+ source: AudioBufferSourceNode
59
+ gainNode: GainNode
60
+ panNode: StereoPannerNode
61
+ path: string
62
+ startedAt: number
63
+ }
64
+
65
+ export interface OneShotVoice {
66
+ path: string
67
+ startedAt: number
68
+ }
69
+
70
+ /**
71
+ * Reuse the oldest matching voice before consuming another slot. If the
72
+ * bounded pool is full, feature sounds replace a generic collision first so
73
+ * one noisy contact category cannot silence every later mechanism sound.
74
+ */
75
+ export function oneShotVoiceReplacement(voices: readonly OneShotVoice[], path: string): number | undefined {
76
+ const matching = voices
77
+ .map((voice, index) => ({ voice, index }))
78
+ .filter(entry => entry.voice.path === path)
79
+ .sort((left, right) => left.voice.startedAt - right.voice.startedAt)
80
+ const sourceLimit = path === COLLISION_SOURCE ? MAX_COLLISION_ONE_SHOTS : MAX_FEATURE_ONE_SHOTS
81
+ if (matching.length >= sourceLimit) return matching[0]?.index
82
+ if (voices.length < MAX_ONE_SHOTS) return undefined
83
+
84
+ if (path !== COLLISION_SOURCE) {
85
+ const oldestCollision = voices
86
+ .map((voice, index) => ({ voice, index }))
87
+ .filter(entry => entry.voice.path === COLLISION_SOURCE)
88
+ .sort((left, right) => left.voice.startedAt - right.voice.startedAt)[0]
89
+ if (oldestCollision) return oldestCollision.index
90
+ }
91
+ return voices
92
+ .map((voice, index) => ({ voice, index }))
93
+ .sort((left, right) => left.voice.startedAt - right.voice.startedAt)[0]?.index
94
+ }
95
+
96
+ export interface RollingAudioMix {
97
+ gain: number
98
+ playbackRate: number
99
+ }
100
+
101
+ export interface CollisionAudioMix {
102
+ gain: number
103
+ playbackRate: number
104
+ }
105
+
106
+ export interface RaceAudioFeatures {
107
+ water?: boolean
108
+ portals?: boolean
109
+ rebounds?: boolean
110
+ risingWalls?: boolean
111
+ cones?: boolean
112
+ flippers?: boolean
113
+ }
114
+
115
+ export function waterRollingAudioMix(linearSpeed: number, inWater: boolean): RollingAudioMix {
116
+ if (!inWater || linearSpeed < 0.25) return { gain: 0, playbackRate: 0.88 }
117
+ const response = smoothstep((linearSpeed - 0.25) / 5.75)
118
+ return { gain: response, playbackRate: THREE.MathUtils.lerp(0.88, 1.16, response) }
119
+ }
120
+
121
+ export function waterEntryGain(linearSpeed: number): number {
122
+ return THREE.MathUtils.lerp(0.48, 1, smoothstep(linearSpeed / 12))
123
+ }
124
+
125
+ function smoothstep(value: number): number {
126
+ const clamped = THREE.MathUtils.clamp(value, 0, 1)
127
+ return clamped * clamped * (3 - 2 * clamped)
128
+ }
129
+
130
+ export function rollingAudioMix(
131
+ linearSpeed: number,
132
+ angularSpeed: number,
133
+ radius: number,
134
+ grounded: boolean,
135
+ ): RollingAudioMix {
136
+ if (!grounded || linearSpeed < MIN_ROLL_SPEED || angularSpeed <= 0 || radius <= 0) {
137
+ return { gain: 0, playbackRate: 0.72 }
138
+ }
139
+ const rollingSpeed = Math.min(linearSpeed, angularSpeed * radius * 1.2)
140
+ return {
141
+ gain: smoothstep((rollingSpeed - MIN_ROLL_SPEED) / (FULL_ROLL_SPEED - MIN_ROLL_SPEED)),
142
+ playbackRate: THREE.MathUtils.lerp(
143
+ 0.72,
144
+ 1.58,
145
+ smoothstep((rollingSpeed - MIN_ROLL_SPEED) / (MAX_PLAYBACK_SPEED - MIN_ROLL_SPEED)),
146
+ ),
147
+ }
148
+ }
149
+
150
+ export function collisionAudioMix(contactImpulse: number): CollisionAudioMix {
151
+ const gain = smoothstep((contactImpulse - MIN_IMPACT_IMPULSE) / (FULL_IMPACT_IMPULSE - MIN_IMPACT_IMPULSE))
152
+ return { gain, playbackRate: THREE.MathUtils.lerp(0.88, 1.15, gain) }
153
+ }
154
+
155
+ /** Lightweight race mixer: decoded buffers + stereo pan, with no media-element or HRTF graph. */
156
+ export class RaceRollingAudio {
157
+ private context?: AudioContext
158
+ private readonly ownsContext: boolean
159
+ private readonly buffers = new Map<string, Promise<AudioBuffer | undefined>>()
160
+ private readonly voices: LoopVoice[]
161
+ private readonly waterVoices: LoopVoice[]
162
+ private readonly waterStates: boolean[]
163
+ private readonly waterEntryCooldownSeconds: number[]
164
+ private readonly activeOneShots = new Set<ActiveOneShot>()
165
+ private readonly activeContactKeys = new Set<string>()
166
+ private readonly impactCooldownSteps: number[]
167
+ private readonly pendingImpacts: PendingImpact[] = []
168
+ private readonly pendingTeleports: THREE.Vector3[] = []
169
+ private readonly pendingReboundHits: THREE.Vector3[] = []
170
+ private readonly pendingRisingWallContacts: THREE.Vector3[] = []
171
+ private readonly pendingConeContacts: THREE.Vector3[] = []
172
+ private readonly pendingFlipperAcceleratingHits: THREE.Vector3[] = []
173
+ private readonly listenerPosition = new THREE.Vector3()
174
+ private readonly listenerRight = new THREE.Vector3()
175
+ private readonly listenerForward = new THREE.Vector3()
176
+ private readonly sourcePosition = new THREE.Vector3()
177
+ private readonly sourceOffset = new THREE.Vector3()
178
+ private enabled: boolean
179
+ private readonly rollingGain: number
180
+ private readonly collisionGain: number
181
+ private readonly features: RaceAudioFeatures
182
+ private disposed = false
183
+ private readonly startedSources: Record<string, number> = {}
184
+ private bufferFailures = 0
185
+
186
+ diagnostics() {
187
+ return {
188
+ enabled: this.enabled,
189
+ contextState: this.context?.state ?? 'not-created',
190
+ startedSources: { ...this.startedSources },
191
+ audibleLoops: [...this.voices, ...this.waterVoices].filter(
192
+ voice => voice.source && (voice.gainNode?.gain.value ?? 0) > 0.0001,
193
+ ).length,
194
+ activeOneShots: this.activeOneShots.size,
195
+ bufferFailures: this.bufferFailures,
196
+ }
197
+ }
198
+
199
+ constructor(
200
+ voiceCount: number,
201
+ enabled = isGameAudioEnabled(),
202
+ rollingGain = 1,
203
+ collisionGain = rollingGain,
204
+ features: RaceAudioFeatures = {},
205
+ context?: AudioContext,
206
+ ) {
207
+ this.context = context
208
+ this.ownsContext = context === undefined
209
+ this.enabled = enabled
210
+ this.rollingGain = THREE.MathUtils.clamp(rollingGain, 0, 1)
211
+ this.collisionGain = THREE.MathUtils.clamp(collisionGain, 0, 1)
212
+ this.features = features
213
+ this.voices = Array.from({ length: voiceCount }, () => this.createLoopState())
214
+ this.waterVoices = features.water ? Array.from({ length: voiceCount }, () => this.createLoopState()) : []
215
+ this.waterStates = Array.from({ length: voiceCount }, () => false)
216
+ this.waterEntryCooldownSeconds = Array.from({ length: voiceCount }, () => 0)
217
+ this.impactCooldownSteps = Array.from({ length: voiceCount }, () => 0)
218
+ }
219
+
220
+ setEnabled(enabled: boolean): void {
221
+ this.enabled = enabled
222
+ if (!enabled) this.silenceImmediately()
223
+ }
224
+
225
+ queuePortalTeleport(position: THREE.Vector3): void {
226
+ if (!this.canQueue(this.features.portals, this.pendingTeleports)) return
227
+ this.pendingTeleports.push(position.clone())
228
+ }
229
+
230
+ queueReboundHit(position: THREE.Vector3): void {
231
+ if (!this.canQueue(this.features.rebounds, this.pendingReboundHits)) return
232
+ this.pendingReboundHits.push(position.clone())
233
+ }
234
+
235
+ queueRisingWallContact(position: THREE.Vector3): void {
236
+ if (!this.canQueue(this.features.risingWalls, this.pendingRisingWallContacts)) return
237
+ this.pendingRisingWallContacts.push(position.clone())
238
+ }
239
+
240
+ queueConeContact(position: THREE.Vector3): void {
241
+ if (!this.canQueue(this.features.cones, this.pendingConeContacts)) return
242
+ this.pendingConeContacts.push(position.clone())
243
+ }
244
+
245
+ queueFlipperAcceleratingHit(position: THREE.Vector3): void {
246
+ if (!this.canQueue(this.features.flippers, this.pendingFlipperAcceleratingHits)) return
247
+ this.pendingFlipperAcceleratingHits.push(position.clone())
248
+ }
249
+
250
+ queueCollisionImpact(impulse: number, position: THREE.Vector3): void {
251
+ if (this.disposed || !this.enabled) return
252
+ const mix = collisionAudioMix(impulse)
253
+ if (mix.gain <= 0) return
254
+ if (this.pendingImpacts.length < MAX_ONE_SHOTS) this.pendingImpacts.push({ ...mix, position: position.clone() })
255
+ }
256
+
257
+ captureCollisions(
258
+ world: RAPIER.World,
259
+ marbles: readonly MarbleEntity[],
260
+ audible = true,
261
+ specializedAudioColliderHandles: ReadonlySet<number> = new Set(),
262
+ ): void {
263
+ if (this.disposed) return
264
+ const currentContactKeys = new Set<string>()
265
+ this.impactCooldownSteps.forEach((cooldown, index) => {
266
+ this.impactCooldownSteps[index] = Math.max(0, cooldown - 1)
267
+ })
268
+ marbles.forEach((marble, marbleIndex) => {
269
+ const collider = marble.body.collider(0)
270
+ world.contactPairsWith(collider, other => {
271
+ if (specializedAudioColliderHandles.has(other.handle)) return
272
+ const contactKey = `${Math.min(collider.handle, other.handle)}:${Math.max(collider.handle, other.handle)}`
273
+ if (currentContactKeys.has(contactKey)) return
274
+ let strongestImpulse = 0
275
+ world.contactPair(collider, other, manifold => {
276
+ for (let index = 0; index < manifold.numContacts(); index += 1) {
277
+ strongestImpulse = Math.max(strongestImpulse, Math.abs(manifold.contactImpulse(index)))
278
+ }
279
+ })
280
+ if (strongestImpulse <= 0) return
281
+ currentContactKeys.add(contactKey)
282
+ if (this.activeContactKeys.has(contactKey) || (this.impactCooldownSteps[marbleIndex] ?? 0) > 0) return
283
+ if (audible) {
284
+ const translation = marble.body.translation()
285
+ this.queueCollisionImpact(strongestImpulse, new THREE.Vector3(translation.x, translation.y, translation.z))
286
+ }
287
+ this.impactCooldownSteps[marbleIndex] = IMPACT_COOLDOWN_STEPS
288
+ })
289
+ })
290
+ this.activeContactKeys.clear()
291
+ currentContactKeys.forEach(key => this.activeContactKeys.add(key))
292
+ }
293
+
294
+ update(
295
+ deltaSeconds: number,
296
+ active: boolean,
297
+ world: RAPIER.World | undefined,
298
+ marbles: readonly MarbleEntity[],
299
+ visualRoot: THREE.Object3D,
300
+ camera: THREE.Camera,
301
+ waterByMarble: readonly boolean[] = [],
302
+ groundedByMarble: readonly boolean[] = [],
303
+ ): void {
304
+ if (this.disposed || !this.enabled || !active || document.visibilityState === 'hidden' || !world) {
305
+ this.fadeLoops(deltaSeconds, false)
306
+ return
307
+ }
308
+ const context = this.ensureContext()
309
+ if (context.state === 'suspended') void context.resume().catch(() => undefined)
310
+ camera.getWorldPosition(this.listenerPosition)
311
+ camera.getWorldDirection(this.listenerForward)
312
+ this.listenerRight.crossVectors(this.listenerForward, camera.up).normalize()
313
+ this.flushOneShots(visualRoot)
314
+
315
+ let audibleRolls = 0
316
+ let audibleWater = 0
317
+ this.voices.forEach((voice, index) => {
318
+ this.waterEntryCooldownSeconds[index] = Math.max(0, (this.waterEntryCooldownSeconds[index] ?? 0) - deltaSeconds)
319
+ const marble = marbles[index]
320
+ if (!marble) {
321
+ voice.targetGain = 0
322
+ return
323
+ }
324
+ const velocity = marble.body.linvel()
325
+ const angularVelocity = marble.body.angvel()
326
+ const linearSpeed = Math.hypot(velocity.x, velocity.y, velocity.z)
327
+ const angularSpeed = Math.hypot(angularVelocity.x, angularVelocity.y, angularVelocity.z)
328
+ const inWater = waterByMarble[index] === true
329
+ const mix = rollingAudioMix(
330
+ linearSpeed,
331
+ angularSpeed,
332
+ marble.tuning.radius,
333
+ groundedByMarble[index] ?? this.isGrounded(world, marble),
334
+ )
335
+ voice.targetGain = mix.gain * (inWater ? 0.22 : 1)
336
+ voice.playbackRate = mix.playbackRate
337
+ this.writeWorldPosition(voice.position, marble, visualRoot)
338
+ if (voice.targetGain > 0) audibleRolls += 1
339
+
340
+ const waterVoice = this.waterVoices[index]
341
+ if (!waterVoice) return
342
+ const waterMix = waterRollingAudioMix(linearSpeed, inWater)
343
+ waterVoice.targetGain = waterMix.gain
344
+ waterVoice.playbackRate = waterMix.playbackRate
345
+ waterVoice.position.copy(voice.position)
346
+ if (waterVoice.targetGain > 0) audibleWater += 1
347
+ if (inWater && !this.waterStates[index] && (this.waterEntryCooldownSeconds[index] ?? 0) <= 0) {
348
+ this.playOneShot(
349
+ WATER_ENTRY_SOURCE,
350
+ waterEntryGain(linearSpeed) * WATER_ENTRY_MASTER_GAIN,
351
+ 0.96,
352
+ voice.position,
353
+ )
354
+ this.waterEntryCooldownSeconds[index] = 0.45
355
+ }
356
+ this.waterStates[index] = inWater
357
+ })
358
+ const rollPackGain = audibleRolls > 0 ? 1 / audibleRolls ** 0.25 : 1
359
+ const waterPackGain = audibleWater > 0 ? 1 / audibleWater ** 0.2 : 1
360
+ this.updateLoopSet(
361
+ this.voices,
362
+ ROLL_SOURCE,
363
+ deltaSeconds,
364
+ RACE_ROLLING_MASTER_GAIN * rollPackGain * this.rollingGain,
365
+ )
366
+ this.updateLoopSet(
367
+ this.waterVoices,
368
+ WATER_ROLL_SOURCE,
369
+ deltaSeconds,
370
+ WATER_ROLLING_MASTER_GAIN * waterPackGain * this.rollingGain,
371
+ )
372
+ }
373
+
374
+ dispose(): void {
375
+ if (this.disposed) return
376
+ this.disposed = true
377
+ this.silenceImmediately()
378
+ if (this.ownsContext) void this.context?.close()
379
+ this.context = undefined
380
+ this.buffers.clear()
381
+ }
382
+
383
+ private createLoopState(): LoopVoice {
384
+ return { gain: 0, targetGain: 0, playbackRate: 1, position: new THREE.Vector3() }
385
+ }
386
+
387
+ private canQueue(feature: boolean | undefined, queue: readonly unknown[]): boolean {
388
+ return !this.disposed && this.enabled && feature === true && queue.length < 2
389
+ }
390
+
391
+ private ensureContext(): AudioContext {
392
+ this.context ??= new AudioContext({ latencyHint: 'playback' })
393
+ return this.context
394
+ }
395
+
396
+ private loadBuffer(path: string): Promise<AudioBuffer | undefined> {
397
+ const existing = this.buffers.get(path)
398
+ if (existing) return existing
399
+ const promise = fetch(path)
400
+ .then(response => (response.ok ? response.arrayBuffer() : Promise.reject(new Error(`Audio ${response.status}`))))
401
+ .then(bytes => (this.disposed ? undefined : this.ensureContext().decodeAudioData(bytes)))
402
+ .catch(() => {
403
+ // A transient fetch/decode failure must not permanently mute this
404
+ // sound for the rest of the race.
405
+ this.bufferFailures += 1
406
+ this.buffers.delete(path)
407
+ return undefined
408
+ })
409
+ this.buffers.set(path, promise)
410
+ return promise
411
+ }
412
+
413
+ private updateLoopSet(voices: readonly LoopVoice[], path: string, deltaSeconds: number, masterGain: number): void {
414
+ const alpha = 1 - Math.exp(-GAIN_RESPONSE * Math.max(0, deltaSeconds))
415
+ voices.forEach(voice => {
416
+ voice.gain = THREE.MathUtils.lerp(voice.gain, voice.targetGain * masterGain, alpha)
417
+ if (voice.gain < 0.0001) voice.gain = 0
418
+ if (voice.targetGain > 0 && !voice.source) void this.startLoop(voice, path)
419
+ if (voice.source) voice.source.playbackRate.value = voice.playbackRate
420
+ if (voice.gainNode) voice.gainNode.gain.value = voice.gain * this.distanceGain(voice.position)
421
+ if (voice.panNode) voice.panNode.pan.value = this.stereoPan(voice.position)
422
+ if (voice.gain === 0 && voice.targetGain === 0) this.stopLoop(voice)
423
+ })
424
+ }
425
+
426
+ private async startLoop(voice: LoopVoice, path: string): Promise<void> {
427
+ const buffer = await this.loadBuffer(path)
428
+ if (!buffer || this.disposed || voice.source || voice.targetGain <= 0) return
429
+ const context = this.ensureContext()
430
+ const source = context.createBufferSource()
431
+ const gainNode = context.createGain()
432
+ const panNode = context.createStereoPanner()
433
+ source.buffer = buffer
434
+ source.loop = true
435
+ source.playbackRate.value = voice.playbackRate
436
+ gainNode.gain.value = voice.gain
437
+ panNode.pan.value = this.stereoPan(voice.position)
438
+ source.connect(gainNode).connect(panNode).connect(context.destination)
439
+ voice.source = source
440
+ voice.gainNode = gainNode
441
+ voice.panNode = panNode
442
+ source.start()
443
+ this.startedSources[path] = (this.startedSources[path] ?? 0) + 1
444
+ }
445
+
446
+ private stopLoop(voice: LoopVoice): void {
447
+ voice.source?.stop()
448
+ voice.source?.disconnect()
449
+ voice.gainNode?.disconnect()
450
+ voice.panNode?.disconnect()
451
+ voice.source = undefined
452
+ voice.gainNode = undefined
453
+ voice.panNode = undefined
454
+ }
455
+
456
+ private flushOneShots(visualRoot: THREE.Object3D): void {
457
+ this.pendingImpacts
458
+ .sort((left, right) => right.gain - left.gain)
459
+ .forEach(impact => {
460
+ this.writeEventPosition(impact.position, visualRoot)
461
+ this.playOneShot(
462
+ COLLISION_SOURCE,
463
+ impact.gain * RACE_COLLISION_MASTER_GAIN * this.collisionGain,
464
+ impact.playbackRate,
465
+ impact.position,
466
+ )
467
+ })
468
+ this.flushFeature(this.pendingTeleports, TELEPORT_SOURCE, PORTAL_TELEPORT_MASTER_GAIN, 1, visualRoot)
469
+ this.flushFeature(this.pendingReboundHits, REBOUND_SOURCE, REBOUND_HIT_MASTER_GAIN, 1, visualRoot)
470
+ this.flushFeature(
471
+ this.pendingRisingWallContacts,
472
+ RISING_WALL_CONTACT_SOURCE,
473
+ RISING_WALL_CONTACT_MASTER_GAIN,
474
+ RISING_WALL_CONTACT_PLAYBACK_RATE,
475
+ visualRoot,
476
+ )
477
+ this.flushFeature(
478
+ this.pendingConeContacts,
479
+ CONE_CONTACT_SOURCE,
480
+ CONE_CONTACT_MASTER_GAIN,
481
+ CONE_CONTACT_PLAYBACK_RATE,
482
+ visualRoot,
483
+ )
484
+ this.flushFeature(
485
+ this.pendingFlipperAcceleratingHits,
486
+ FLIPPER_ACCELERATING_HIT_SOURCE,
487
+ FLIPPER_ACCELERATING_HIT_MASTER_GAIN,
488
+ 1,
489
+ visualRoot,
490
+ )
491
+ this.pendingImpacts.length = 0
492
+ }
493
+
494
+ private flushFeature(
495
+ queue: THREE.Vector3[],
496
+ path: string,
497
+ gain: number,
498
+ rate: number,
499
+ visualRoot: THREE.Object3D,
500
+ ): void {
501
+ queue.forEach(position => {
502
+ this.writeEventPosition(position, visualRoot)
503
+ this.playOneShot(path, gain * this.collisionGain, rate, position)
504
+ })
505
+ queue.length = 0
506
+ }
507
+
508
+ private async playOneShot(path: string, gain: number, playbackRate: number, position: THREE.Vector3): Promise<void> {
509
+ const buffer = await this.loadBuffer(path)
510
+ if (!buffer || this.disposed || !this.enabled) return
511
+ const activeVoices = [...this.activeOneShots]
512
+ const replacementIndex = oneShotVoiceReplacement(activeVoices, path)
513
+ if (replacementIndex !== undefined) this.stopOneShot(activeVoices[replacementIndex]!)
514
+ const context = this.ensureContext()
515
+ const source = context.createBufferSource()
516
+ const gainNode = context.createGain()
517
+ const panNode = context.createStereoPanner()
518
+ source.buffer = buffer
519
+ source.playbackRate.value = playbackRate
520
+ gainNode.gain.value = gain * this.distanceGain(position)
521
+ panNode.pan.value = this.stereoPan(position)
522
+ source.connect(gainNode).connect(panNode).connect(context.destination)
523
+ const voice: ActiveOneShot = { source, gainNode, panNode, path, startedAt: context.currentTime }
524
+ this.activeOneShots.add(voice)
525
+ source.addEventListener(
526
+ 'ended',
527
+ () => {
528
+ this.releaseOneShot(voice)
529
+ },
530
+ { once: true },
531
+ )
532
+ source.start()
533
+ this.startedSources[path] = (this.startedSources[path] ?? 0) + 1
534
+ }
535
+
536
+ private releaseOneShot(voice: ActiveOneShot): void {
537
+ if (!this.activeOneShots.delete(voice)) return
538
+ voice.source.disconnect()
539
+ voice.gainNode.disconnect()
540
+ voice.panNode.disconnect()
541
+ }
542
+
543
+ private stopOneShot(voice: ActiveOneShot): void {
544
+ if (!this.activeOneShots.has(voice)) return
545
+ voice.source.stop()
546
+ this.releaseOneShot(voice)
547
+ }
548
+
549
+ private writeWorldPosition(target: THREE.Vector3, marble: MarbleEntity, visualRoot: THREE.Object3D): void {
550
+ const translation = marble.body.translation()
551
+ target.set(translation.x, translation.y, translation.z)
552
+ visualRoot.localToWorld(target)
553
+ }
554
+
555
+ private writeEventPosition(position: THREE.Vector3, visualRoot: THREE.Object3D): void {
556
+ this.sourcePosition.copy(position)
557
+ visualRoot.localToWorld(this.sourcePosition)
558
+ position.copy(this.sourcePosition)
559
+ }
560
+
561
+ private stereoPan(position: THREE.Vector3): number {
562
+ this.sourceOffset.copy(position).sub(this.listenerPosition)
563
+ const distance = Math.max(5, this.sourceOffset.length())
564
+ return THREE.MathUtils.clamp(this.sourceOffset.dot(this.listenerRight) / distance, -1, 1)
565
+ }
566
+
567
+ private distanceGain(position: THREE.Vector3): number {
568
+ const distance = position.distanceTo(this.listenerPosition)
569
+ return 1 / (1 + Math.max(0, distance - 5) * 0.025)
570
+ }
571
+
572
+ private isGrounded(world: RAPIER.World, marble: MarbleEntity): boolean {
573
+ const collider = marble.body.collider(0)
574
+ let grounded = false
575
+ world.contactPairsWith(collider, other => {
576
+ if (grounded) return
577
+ world.contactPair(collider, other, manifold => {
578
+ if (manifold.numSolverContacts() > 0) grounded = true
579
+ })
580
+ })
581
+ return grounded
582
+ }
583
+
584
+ private fadeLoops(deltaSeconds: number, canStart: boolean): void {
585
+ this.voices.forEach(voice => {
586
+ voice.targetGain = canStart ? voice.targetGain : 0
587
+ })
588
+ this.waterVoices.forEach(voice => {
589
+ voice.targetGain = canStart ? voice.targetGain : 0
590
+ })
591
+ this.updateLoopSet(this.voices, ROLL_SOURCE, deltaSeconds, RACE_ROLLING_MASTER_GAIN * this.rollingGain)
592
+ this.updateLoopSet(this.waterVoices, WATER_ROLL_SOURCE, deltaSeconds, WATER_ROLLING_MASTER_GAIN * this.rollingGain)
593
+ }
594
+
595
+ private silenceImmediately(): void {
596
+ this.voices.forEach(voice => {
597
+ voice.gain = 0
598
+ voice.targetGain = 0
599
+ this.stopLoop(voice)
600
+ })
601
+ this.waterVoices.forEach(voice => {
602
+ voice.gain = 0
603
+ voice.targetGain = 0
604
+ this.stopLoop(voice)
605
+ })
606
+ this.activeOneShots.forEach(voice => this.stopOneShot(voice))
607
+ this.pendingImpacts.length = 0
608
+ this.pendingTeleports.length = 0
609
+ this.pendingReboundHits.length = 0
610
+ this.pendingRisingWallContacts.length = 0
611
+ this.pendingConeContacts.length = 0
612
+ this.pendingFlipperAcceleratingHits.length = 0
613
+ this.waterStates.fill(false)
614
+ this.waterEntryCooldownSeconds.fill(0)
615
+ }
616
+ }