@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.
- package/LICENSE +21 -0
- package/README.md +15 -0
- package/assets.json +346 -0
- package/package.json +32 -0
- package/release.json +135 -0
- package/src/audio/game-audio-preference.ts +9 -0
- package/src/authoritative.ts +6 -0
- package/src/catalog.ts +1 -0
- package/src/index.ts +14 -0
- package/src/legacy-stock-catalog.ts +130 -0
- package/src/pages/lobby/lobby-types.ts +140 -0
- package/src/pages/lobby/marble-factory.ts +931 -0
- package/src/pages/play/race/egypt-elevator-blender.ts +159 -0
- package/src/pages/play/race/egypt-portal-frame.ts +382 -0
- package/src/pages/play/race/finish-detection.ts +1 -0
- package/src/pages/play/race/japanese-sakura-castle-support-blender.ts +510 -0
- package/src/pages/play/race/japanese-sakura-kawara-roof.ts +201 -0
- package/src/pages/play/race/japanese-sakura-portal-frame.ts +325 -0
- package/src/pages/play/race/jungle-portal-frame.ts +308 -0
- package/src/pages/play/race/jungle-temple-lift.ts +366 -0
- package/src/pages/play/race/marble-entity.ts +257 -0
- package/src/pages/play/race/marble-material.ts +20 -0
- package/src/pages/play/race/marble-race-engine.ts +2652 -0
- package/src/pages/play/race/path-pattern-layout.ts +142 -0
- package/src/pages/play/race/peg-obstacle-visual.ts +171 -0
- package/src/pages/play/race/physics-debug.ts +1 -0
- package/src/pages/play/race/physics-dry-run.ts +90 -0
- package/src/pages/play/race/portal-derived-elevator-skins.ts +729 -0
- package/src/pages/play/race/portal-pair-color.ts +20 -0
- package/src/pages/play/race/portal-proportions.ts +25 -0
- package/src/pages/play/race/portal-visual.ts +1108 -0
- package/src/pages/play/race/race-camera-director.ts +586 -0
- package/src/pages/play/race/race-camera-obstruction.ts +37 -0
- package/src/pages/play/race/race-camera-tour-planner.ts +274 -0
- package/src/pages/play/race/race-camera.ts +206 -0
- package/src/pages/play/race/race-completion.ts +5 -0
- package/src/pages/play/race/race-configuration.ts +57 -0
- package/src/pages/play/race/race-marble-focus-renderer.ts +134 -0
- package/src/pages/play/race/race-physics-profile.ts +7 -0
- package/src/pages/play/race/race-physics.ts +16 -0
- package/src/pages/play/race/race-playback.ts +41 -0
- package/src/pages/play/race/race-rolling-audio.ts +616 -0
- package/src/pages/play/race/race-simulation-renderer.ts +221 -0
- package/src/pages/play/race/race-terminal-checker-strip.ts +226 -0
- package/src/pages/play/race/race-transform-interpolation.ts +50 -0
- package/src/pages/play/race/race-types.ts +12 -0
- package/src/pages/play/race/rebound-visual.ts +70 -0
- package/src/pages/play/race/seeded-random.ts +23 -0
- package/src/pages/play/race/space-station-portal-frame.ts +275 -0
- package/src/pages/play/race/track-appearance-canopy.ts +946 -0
- package/src/pages/play/race/track-appearance-floor-runner.ts +206 -0
- package/src/pages/play/race/track-appearance-jungle-flower-hedge.ts +483 -0
- package/src/pages/play/race/track-appearance-rope-wall.ts +443 -0
- package/src/pages/play/race/track-appearance-sakura-bridge-fence.ts +305 -0
- package/src/pages/play/race/track-appearance-sakura-castle-wall.ts +1031 -0
- package/src/pages/play/race/track-appearance-space-station-wall.ts +497 -0
- package/src/pages/play/race/track-appearance-stone-block-shell.ts +517 -0
- package/src/pages/play/race/track-appearance-support.ts +983 -0
- package/src/pages/play/race/track-appearance-vampire-carved-wall.ts +545 -0
- package/src/pages/play/race/track-appearance-wall-terminal.ts +167 -0
- package/src/pages/play/race/track-course.ts +271 -0
- package/src/pages/play/race/track-definition.ts +443 -0
- package/src/pages/play/race/track-fence-architecture.ts +33 -0
- package/src/pages/play/race/track-floor-material-rules.ts +51 -0
- package/src/pages/play/race/track-mechanisms.ts +1350 -0
- package/src/pages/play/race/track-physics-surface.ts +6 -0
- package/src/pages/play/race/track-route-network.ts +9 -0
- package/src/pages/play/race/track-shadow-batching.ts +760 -0
- package/src/pages/play/race/track-surface-basis.ts +1 -0
- package/src/pages/play/race/track-surface-topology.ts +326 -0
- package/src/pages/play/race/track-world.ts +10181 -0
- package/src/pages/play/race/under-track-support-architecture.ts +122 -0
- package/src/pages/play/race/vortex-profile.ts +5 -0
- package/src/pages/play/race/vortex-water-effect.ts +863 -0
- package/src/pages/play/race/wild-west-mine-lift.ts +281 -0
- package/src/pages/play/race/wild-west-vortex-housing.ts +572 -0
- package/src/pages/track-lab/atlas-vortex-layout.ts +1 -0
- package/src/pages/track-lab/cone-field-layout.ts +1 -0
- package/src/pages/track-lab/conveyor-layout.ts +1 -0
- package/src/pages/track-lab/engine/canonical-track-assembly.ts +64 -0
- package/src/pages/track-lab/engine/editor-object-geometry.ts +170 -0
- package/src/pages/track-lab/engine/track-lab-connection-state.ts +123 -0
- package/src/pages/track-lab/engine/track-piece-visual.ts +1420 -0
- package/src/pages/track-lab/flipper-bank-layout.ts +1 -0
- package/src/pages/track-lab/flipper-component.ts +127 -0
- package/src/pages/track-lab/rising-wall-layout.ts +1 -0
- package/src/pages/track-lab/simple-obstacle-layout.ts +1 -0
- package/src/pages/track-lab/track-piece-layout.ts +28 -0
- package/src/pages/track-lab/track-runtime-adapter.ts +1152 -0
- package/src/three/geometry-attribute-aliases.ts +24 -0
- package/src/three/geometry-detail.ts +36 -0
- package/src/three/homepage-marble-geometry.ts +15 -0
- package/src/three/japanese-sakura-appearance.ts +252 -0
- package/src/three/jungle-flower-hedge-appearance.ts +7 -0
- package/src/three/jungle-temple-appearance.ts +5 -0
- package/src/three/low-quality-scene-renderer.ts +132 -0
- package/src/three/marble-sphere-geometry.ts +13 -0
- package/src/three/mobile-rendering-budget.ts +11 -0
- package/src/three/painted-valley-appearance.ts +270 -0
- package/src/three/railroad-appearance.ts +71 -0
- package/src/three/renderer-resize.ts +32 -0
- package/src/three/rope-wall-appearance.ts +91 -0
- package/src/three/rugged-track-appearance.ts +2 -0
- package/src/three/scene-lighting.ts +183 -0
- package/src/three/scene-shader-preparation.ts +64 -0
- package/src/three/scene-theme-controller.ts +461 -0
- package/src/three/shadow-shader-preparation.ts +162 -0
- package/src/three/shared-obstacle-metal.ts +44 -0
- package/src/three/soft-additive-particles.ts +57 -0
- package/src/three/space-station-appearance.ts +213 -0
- package/src/three/static-track-batched-mesh.ts +132 -0
- package/src/three/stone-track-shell-appearance.ts +12 -0
- package/src/three/theme-ambience-controller.ts +226 -0
- package/src/three/theme-asset-cache.ts +120 -0
- package/src/three/theme-audio-preference.ts +43 -0
- package/src/three/theme-catalog.ts +1457 -0
- package/src/three/theme-material-assets.ts +258 -0
- package/src/three/theme-material-ktx2-manifest.ts +190 -0
- package/src/three/theme-material-registry.ts +1952 -0
- package/src/three/theme-world-factory.ts +590 -0
- package/src/three/track-connector-batch.ts +125 -0
- package/src/three/track-material-continuity.ts +442 -0
- package/src/three/track-theme.ts +535 -0
- package/src/three/vampire-castle-appearance.ts +258 -0
- package/src/three/visual-frame-pacing.ts +19 -0
- package/src/three/visual-geometry.ts +120 -0
- package/src/three/visual-quality.ts +83 -0
- package/src/three/water-appearance.ts +29 -0
- 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
|
+
}
|