@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,586 @@
1
+ import * as THREE from 'three'
2
+
3
+ export type RaceCameraDirectorState = 'tour' | 'start-line' | 'follow'
4
+
5
+ export interface CameraMotionLimits {
6
+ deadZone: number
7
+ response: number
8
+ maximumSpeed: number
9
+ maximumAcceleration: number
10
+ }
11
+
12
+ export interface CameraDirectorDiagnostics {
13
+ state: RaceCameraDirectorState
14
+ stateSeconds: number
15
+ linearSpeed: number
16
+ targetSpeed: number
17
+ }
18
+
19
+ export const CAMERA_POSITION_LIMITS: CameraMotionLimits = {
20
+ deadZone: 0.14,
21
+ response: 3,
22
+ // At the minimum Race-view boom length this stays below the angular
23
+ // comfort limit, so the camera cannot orbit its target faster than it can
24
+ // turn to keep that target framed.
25
+ maximumSpeed: 18,
26
+ maximumAcceleration: 44,
27
+ }
28
+
29
+ export const CAMERA_TARGET_LIMITS: CameraMotionLimits = {
30
+ deadZone: 0.3,
31
+ response: 3.1,
32
+ maximumSpeed: 18,
33
+ maximumAcceleration: 40,
34
+ }
35
+
36
+ export const CAMERA_MAXIMUM_ANGULAR_SPEED = THREE.MathUtils.degToRad(75)
37
+
38
+ /** Quintic easing gives the authored intro zero velocity and acceleration at both ends. */
39
+ export function smoothCameraTourProgress(value: number): number {
40
+ const clamped = THREE.MathUtils.clamp(value, 0, 1)
41
+ return clamped * clamped * clamped * (clamped * (clamped * 6 - 15) + 10)
42
+ }
43
+
44
+ export function retainCameraLeader(currentProgress: number, bestProgress: number, switchGap = 1.4): boolean {
45
+ return bestProgress - currentProgress <= switchGap
46
+ }
47
+
48
+ export function advanceCameraOrientation(
49
+ current: THREE.Quaternion,
50
+ desired: THREE.Quaternion,
51
+ deltaSeconds: number,
52
+ ): void {
53
+ current.rotateTowards(desired, CAMERA_MAXIMUM_ANGULAR_SPEED * THREE.MathUtils.clamp(deltaSeconds, 0, 0.1))
54
+ }
55
+
56
+ /**
57
+ * Resolves a stable course-facing direction. Long guide chords are useful on
58
+ * gentle track, but on consecutive curves they can cut across the inside of
59
+ * the bend or even point backward. The local tangent remains authoritative in
60
+ * those cases.
61
+ */
62
+ export function writeStableCameraGuideDirection(
63
+ localTangent: THREE.Vector3,
64
+ guideChord: THREE.Vector3,
65
+ output: THREE.Vector3,
66
+ ): THREE.Vector3 {
67
+ output.copy(localTangent)
68
+ if (output.lengthSq() < 0.000_001) output.set(0, 0, 1)
69
+ else output.normalize()
70
+
71
+ const chordLengthSquared = guideChord.lengthSq()
72
+ if (chordLengthSquared < 4) return output
73
+ const inverseChordLength = 1 / Math.sqrt(chordLengthSquared)
74
+ const alignment = output.dot(guideChord) * inverseChordLength
75
+ if (alignment < 0.45) return output
76
+
77
+ output.x = output.x * 0.65 + guideChord.x * inverseChordLength * 0.35
78
+ output.y = output.y * 0.65 + guideChord.y * inverseChordLength * 0.35
79
+ output.z = output.z * 0.65 + guideChord.z * inverseChordLength * 0.35
80
+ return output.normalize()
81
+ }
82
+
83
+ export function cameraPoseWithinTolerance(
84
+ position: THREE.Vector3,
85
+ desiredPosition: THREE.Vector3,
86
+ target: THREE.Vector3,
87
+ desiredTarget: THREE.Vector3,
88
+ orientation: THREE.Quaternion,
89
+ desiredOrientation: THREE.Quaternion,
90
+ positionTolerance: number,
91
+ targetTolerance: number,
92
+ angleTolerance: number,
93
+ ): boolean {
94
+ return (
95
+ position.distanceToSquared(desiredPosition) <= positionTolerance * positionTolerance &&
96
+ target.distanceToSquared(desiredTarget) <= targetTolerance * targetTolerance &&
97
+ orientation.angleTo(desiredOrientation) <= angleTolerance
98
+ )
99
+ }
100
+
101
+ function moveVectorTowards(current: THREE.Vector3, target: THREE.Vector3, maximumDelta: number): void {
102
+ const deltaX = target.x - current.x
103
+ const deltaY = target.y - current.y
104
+ const deltaZ = target.z - current.z
105
+ const distance = Math.hypot(deltaX, deltaY, deltaZ)
106
+ if (distance <= maximumDelta || distance < 0.000_001) {
107
+ current.copy(target)
108
+ return
109
+ }
110
+ const scale = maximumDelta / distance
111
+ current.x += deltaX * scale
112
+ current.y += deltaY * scale
113
+ current.z += deltaZ * scale
114
+ }
115
+
116
+ /**
117
+ * Advances a camera value with bounded velocity and acceleration. The function
118
+ * mutates supplied vectors so the rendered hot path does not allocate.
119
+ */
120
+ export function advanceCameraVector(
121
+ current: THREE.Vector3,
122
+ velocity: THREE.Vector3,
123
+ goal: THREE.Vector3,
124
+ deltaSeconds: number,
125
+ limits: CameraMotionLimits,
126
+ desiredVelocity: THREE.Vector3,
127
+ ): void {
128
+ const safeDelta = THREE.MathUtils.clamp(deltaSeconds, 0, 0.1)
129
+ if (safeDelta <= 0) return
130
+
131
+ desiredVelocity.subVectors(goal, current)
132
+ const remainingDistance = desiredVelocity.length()
133
+ if (remainingDistance <= limits.deadZone && velocity.lengthSq() <= 0.04) {
134
+ velocity.set(0, 0, 0)
135
+ return
136
+ }
137
+
138
+ desiredVelocity.multiplyScalar(limits.response)
139
+ if (desiredVelocity.lengthSq() > limits.maximumSpeed ** 2) {
140
+ desiredVelocity.setLength(limits.maximumSpeed)
141
+ }
142
+ moveVectorTowards(velocity, desiredVelocity, limits.maximumAcceleration * safeDelta)
143
+
144
+ const stepDistance = velocity.length() * safeDelta
145
+ if (remainingDistance <= stepDistance && remainingDistance > limits.deadZone) {
146
+ current.copy(goal)
147
+ velocity.set(0, 0, 0)
148
+ return
149
+ }
150
+ current.addScaledVector(velocity, safeDelta)
151
+ }
152
+
153
+ export class RaceCameraDirector {
154
+ private readonly positionVelocity = new THREE.Vector3()
155
+ private readonly targetVelocity = new THREE.Vector3()
156
+ private readonly desiredPositionVelocity = new THREE.Vector3()
157
+ private readonly desiredTargetVelocity = new THREE.Vector3()
158
+ private state: RaceCameraDirectorState = 'follow'
159
+ private stateSeconds = 0
160
+ private initialized = false
161
+
162
+ initialize(position: THREE.Vector3, target: THREE.Vector3): void {
163
+ this.positionVelocity.set(0, 0, 0)
164
+ this.targetVelocity.set(0, 0, 0)
165
+ this.initialized = true
166
+ // The caller owns the actual position and target. Initialization only
167
+ // clears motion history so the first presented frame may be composed.
168
+ void position
169
+ void target
170
+ }
171
+
172
+ isInitialized(): boolean {
173
+ return this.initialized
174
+ }
175
+
176
+ setState(state: RaceCameraDirectorState): void {
177
+ if (this.state === state) return
178
+ this.state = state
179
+ this.stateSeconds = 0
180
+ }
181
+
182
+ update(
183
+ position: THREE.Vector3,
184
+ target: THREE.Vector3,
185
+ desiredPosition: THREE.Vector3,
186
+ desiredTarget: THREE.Vector3,
187
+ deltaSeconds: number,
188
+ lockTarget = false,
189
+ positionLimits = CAMERA_POSITION_LIMITS,
190
+ targetLimits = CAMERA_TARGET_LIMITS,
191
+ ): void {
192
+ if (!this.initialized) this.initialize(position, target)
193
+ this.stateSeconds += THREE.MathUtils.clamp(deltaSeconds, 0, 0.1)
194
+ advanceCameraVector(
195
+ position,
196
+ this.positionVelocity,
197
+ desiredPosition,
198
+ deltaSeconds,
199
+ positionLimits,
200
+ this.desiredPositionVelocity,
201
+ )
202
+ if (lockTarget) {
203
+ target.copy(desiredTarget)
204
+ this.targetVelocity.set(0, 0, 0)
205
+ this.desiredTargetVelocity.set(0, 0, 0)
206
+ } else {
207
+ advanceCameraVector(
208
+ target,
209
+ this.targetVelocity,
210
+ desiredTarget,
211
+ deltaSeconds,
212
+ targetLimits,
213
+ this.desiredTargetVelocity,
214
+ )
215
+ }
216
+ }
217
+
218
+ /** Advances only a free-orbit pivot; the caller retains ownership of orbit rotation and radius. */
219
+ updateTarget(target: THREE.Vector3, desiredTarget: THREE.Vector3, deltaSeconds: number): void {
220
+ if (!this.initialized) this.initialize(target, target)
221
+ this.stateSeconds += THREE.MathUtils.clamp(deltaSeconds, 0, 0.1)
222
+ advanceCameraVector(
223
+ target,
224
+ this.targetVelocity,
225
+ desiredTarget,
226
+ deltaSeconds,
227
+ CAMERA_TARGET_LIMITS,
228
+ this.desiredTargetVelocity,
229
+ )
230
+ }
231
+
232
+ synchronize(position: THREE.Vector3, target: THREE.Vector3): void {
233
+ this.initialize(position, target)
234
+ }
235
+
236
+ diagnostics(): CameraDirectorDiagnostics {
237
+ return {
238
+ state: this.state,
239
+ stateSeconds: this.stateSeconds,
240
+ linearSpeed: this.positionVelocity.length(),
241
+ targetSpeed: this.targetVelocity.length(),
242
+ }
243
+ }
244
+ }
245
+
246
+ export interface LeadingPackCandidate {
247
+ bestProgress: number
248
+ progress?: number
249
+ visual: { position: { x: number; y: number; z: number } }
250
+ }
251
+
252
+ export interface LeadingPackFrame {
253
+ center: THREE.Vector3
254
+ count: number
255
+ progress: number
256
+ radius: number
257
+ }
258
+
259
+ const RACE_PACK_SWITCH_HOLD_SECONDS = 0.65
260
+ const RACE_PACK_DECISIVE_SWITCH_HOLD_SECONDS = 0.18
261
+
262
+ function resetLeadingPackFrame(output: LeadingPackFrame): void {
263
+ output.center.set(0, 0, 0)
264
+ output.count = 0
265
+ output.progress = 0
266
+ output.radius = 0
267
+ }
268
+
269
+ function selectLeadingPackLeader(
270
+ candidates: readonly LeadingPackCandidate[],
271
+ maxProgressGap: number,
272
+ maxSpatialGap: number,
273
+ preferredCenter?: THREE.Vector3,
274
+ ): LeadingPackCandidate | undefined {
275
+ const maximumSpatialGapSquared = maxSpatialGap * maxSpatialGap
276
+ let packLeader: LeadingPackCandidate | undefined
277
+ let packCount = 0
278
+ let packProgress = Number.NEGATIVE_INFINITY
279
+ let packCenterDistanceSquared = Number.POSITIVE_INFINITY
280
+
281
+ for (const candidateLeader of candidates) {
282
+ const leaderPosition = candidateLeader.visual.position
283
+ let candidateCount = 0
284
+ let candidateProgress = 0
285
+ let candidateCenterX = 0
286
+ let candidateCenterY = 0
287
+ let candidateCenterZ = 0
288
+ for (const candidate of candidates) {
289
+ const progressGap = candidateLeader.bestProgress - candidate.bestProgress
290
+ if (progressGap < 0 || progressGap > maxProgressGap) continue
291
+ const position = candidate.visual.position
292
+ const deltaX = position.x - leaderPosition.x
293
+ const deltaY = position.y - leaderPosition.y
294
+ const deltaZ = position.z - leaderPosition.z
295
+ if (deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ > maximumSpatialGapSquared) continue
296
+ candidateCount += 1
297
+ candidateProgress += candidate.bestProgress
298
+ candidateCenterX += position.x
299
+ candidateCenterY += position.y
300
+ candidateCenterZ += position.z
301
+ }
302
+ const averageProgress = candidateCount > 0 ? candidateProgress / candidateCount : Number.NEGATIVE_INFINITY
303
+ const inverseCount = candidateCount > 0 ? 1 / candidateCount : 0
304
+ const centerDeltaX = candidateCenterX * inverseCount - (preferredCenter?.x ?? 0)
305
+ const centerDeltaY = candidateCenterY * inverseCount - (preferredCenter?.y ?? 0)
306
+ const centerDeltaZ = candidateCenterZ * inverseCount - (preferredCenter?.z ?? 0)
307
+ const centerDistanceSquared = preferredCenter
308
+ ? centerDeltaX * centerDeltaX + centerDeltaY * centerDeltaY + centerDeltaZ * centerDeltaZ
309
+ : Number.POSITIVE_INFINITY
310
+ const closerToRetainedCenter =
311
+ preferredCenter !== undefined &&
312
+ candidateCount === packCount &&
313
+ centerDistanceSquared < packCenterDistanceSquared - 0.000_001
314
+ const equallyCloseToRetainedCenter =
315
+ preferredCenter === undefined || Math.abs(centerDistanceSquared - packCenterDistanceSquared) <= 0.000_001
316
+ if (
317
+ candidateCount > packCount ||
318
+ closerToRetainedCenter ||
319
+ (candidateCount === packCount && equallyCloseToRetainedCenter && averageProgress > packProgress)
320
+ ) {
321
+ packLeader = candidateLeader
322
+ packCount = candidateCount
323
+ packProgress = averageProgress
324
+ packCenterDistanceSquared = centerDistanceSquared
325
+ }
326
+ }
327
+
328
+ return packLeader
329
+ }
330
+
331
+ function writePackFrameForLeader(
332
+ candidates: readonly LeadingPackCandidate[],
333
+ packLeader: LeadingPackCandidate,
334
+ output: LeadingPackFrame,
335
+ maxProgressGap: number,
336
+ maxSpatialGap: number,
337
+ members?: LeadingPackCandidate[],
338
+ ): number {
339
+ resetLeadingPackFrame(output)
340
+ if (members) members.length = 0
341
+
342
+ const leaderPosition = packLeader.visual.position
343
+ const maximumSpatialGapSquared = maxSpatialGap * maxSpatialGap
344
+ for (const candidate of candidates) {
345
+ const progressGap = packLeader.bestProgress - candidate.bestProgress
346
+ if (progressGap < 0 || progressGap > maxProgressGap) continue
347
+ const position = candidate.visual.position
348
+ const deltaX = position.x - leaderPosition.x
349
+ const deltaY = position.y - leaderPosition.y
350
+ const deltaZ = position.z - leaderPosition.z
351
+ if (deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ > maximumSpatialGapSquared) continue
352
+ output.center.x += position.x
353
+ output.center.y += position.y
354
+ output.center.z += position.z
355
+ output.progress += candidate.progress ?? candidate.bestProgress
356
+ output.count += 1
357
+ members?.push(candidate)
358
+ }
359
+ if (output.count === 0) return 0
360
+ output.center.multiplyScalar(1 / output.count)
361
+ output.progress /= output.count
362
+
363
+ const packMembers = members ?? candidates
364
+ for (const candidate of packMembers) {
365
+ if (!members) {
366
+ const progressGap = packLeader.bestProgress - candidate.bestProgress
367
+ if (progressGap < 0 || progressGap > maxProgressGap) continue
368
+ const position = candidate.visual.position
369
+ const leaderDeltaX = position.x - leaderPosition.x
370
+ const leaderDeltaY = position.y - leaderPosition.y
371
+ const leaderDeltaZ = position.z - leaderPosition.z
372
+ if (
373
+ leaderDeltaX * leaderDeltaX + leaderDeltaY * leaderDeltaY + leaderDeltaZ * leaderDeltaZ >
374
+ maximumSpatialGapSquared
375
+ ) {
376
+ continue
377
+ }
378
+ }
379
+ const position = candidate.visual.position
380
+ const deltaX = position.x - output.center.x
381
+ const deltaY = position.y - output.center.y
382
+ const deltaZ = position.z - output.center.z
383
+ output.radius = Math.max(output.radius, Math.hypot(deltaX, deltaY, deltaZ))
384
+ }
385
+ return output.count
386
+ }
387
+
388
+ function copyLeadingPackFrame(source: LeadingPackFrame, output: LeadingPackFrame): void {
389
+ output.center.copy(source.center)
390
+ output.count = source.count
391
+ output.progress = source.progress
392
+ output.radius = source.radius
393
+ }
394
+
395
+ /**
396
+ * Retains one broadcast pack across frames. Normal membership changes that
397
+ * still overlap the retained pack are accepted immediately, while a disjoint
398
+ * group must remain the better choice before the camera is allowed to switch.
399
+ */
400
+ export class RaceCameraPackTracker {
401
+ private readonly selectedMembers: LeadingPackCandidate[] = []
402
+ private readonly bestMembers: LeadingPackCandidate[] = []
403
+ private readonly retainedMembers: LeadingPackCandidate[] = []
404
+ private readonly challengerMembers: LeadingPackCandidate[] = []
405
+ private readonly bestFrame: LeadingPackFrame = {
406
+ center: new THREE.Vector3(),
407
+ count: 0,
408
+ progress: 0,
409
+ radius: 0,
410
+ }
411
+ private readonly retainedFrame: LeadingPackFrame = {
412
+ center: new THREE.Vector3(),
413
+ count: 0,
414
+ progress: 0,
415
+ radius: 0,
416
+ }
417
+ private readonly lastCenter = new THREE.Vector3()
418
+ private readonly pendingDiscontinuity = new THREE.Vector3()
419
+ private hasLastCenter = false
420
+ private challengerSeconds = 0
421
+
422
+ reset(): void {
423
+ this.selectedMembers.length = 0
424
+ this.bestMembers.length = 0
425
+ this.retainedMembers.length = 0
426
+ this.challengerMembers.length = 0
427
+ this.lastCenter.set(0, 0, 0)
428
+ this.pendingDiscontinuity.set(0, 0, 0)
429
+ this.hasLastCenter = false
430
+ this.challengerSeconds = 0
431
+ }
432
+
433
+ update(
434
+ candidates: readonly LeadingPackCandidate[],
435
+ output: LeadingPackFrame,
436
+ deltaSeconds: number,
437
+ maxProgressGap = 7,
438
+ maxSpatialGap = 14,
439
+ ): number {
440
+ this.pendingDiscontinuity.set(0, 0, 0)
441
+ const bestLeader = selectLeadingPackLeader(candidates, maxProgressGap, maxSpatialGap)
442
+ if (!bestLeader) {
443
+ this.reset()
444
+ resetLeadingPackFrame(output)
445
+ return 0
446
+ }
447
+ writePackFrameForLeader(
448
+ candidates,
449
+ bestLeader,
450
+ this.bestFrame,
451
+ maxProgressGap,
452
+ maxSpatialGap,
453
+ this.bestMembers,
454
+ )
455
+
456
+ if (this.selectedMembers.length === 0) {
457
+ this.adoptBest(output, maxSpatialGap, false)
458
+ return output.count
459
+ }
460
+
461
+ const retainedLeader = selectLeadingPackLeader(
462
+ this.selectedMembers,
463
+ maxProgressGap,
464
+ maxSpatialGap,
465
+ this.hasLastCenter ? this.lastCenter : undefined,
466
+ )
467
+ if (retainedLeader) {
468
+ writePackFrameForLeader(
469
+ this.selectedMembers,
470
+ retainedLeader,
471
+ this.retainedFrame,
472
+ maxProgressGap,
473
+ maxSpatialGap,
474
+ this.retainedMembers,
475
+ )
476
+ } else {
477
+ resetLeadingPackFrame(this.retainedFrame)
478
+ this.retainedMembers.length = 0
479
+ }
480
+
481
+ let overlap = 0
482
+ for (const candidate of this.bestMembers) {
483
+ if (this.selectedMembers.includes(candidate)) overlap += 1
484
+ }
485
+ const samePack = overlap * 2 > Math.max(this.selectedMembers.length, this.bestMembers.length)
486
+ if (samePack) {
487
+ this.adoptBest(output, maxSpatialGap, true)
488
+ return output.count
489
+ }
490
+
491
+ if (this.retainedFrame.count === 0) {
492
+ this.adoptBest(output, maxSpatialGap, false)
493
+ return output.count
494
+ }
495
+
496
+ let challengerOverlap = 0
497
+ for (const candidate of this.bestMembers) {
498
+ if (this.challengerMembers.includes(candidate)) challengerOverlap += 1
499
+ }
500
+ const sameChallenger =
501
+ this.challengerMembers.length > 0 &&
502
+ challengerOverlap * 2 > Math.max(this.challengerMembers.length, this.bestMembers.length)
503
+ if (!sameChallenger) {
504
+ this.challengerMembers.length = 0
505
+ this.challengerMembers.push(...this.bestMembers)
506
+ this.challengerSeconds = 0
507
+ } else {
508
+ this.challengerSeconds += THREE.MathUtils.clamp(deltaSeconds, 0, 0.1)
509
+ }
510
+ const decisiveSwitch = this.retainedFrame.count === 1 && this.bestFrame.count >= 3
511
+ const switchHoldSeconds = decisiveSwitch
512
+ ? RACE_PACK_DECISIVE_SWITCH_HOLD_SECONDS
513
+ : RACE_PACK_SWITCH_HOLD_SECONDS
514
+ if (this.challengerSeconds >= switchHoldSeconds) {
515
+ this.adoptBest(output, maxSpatialGap, false)
516
+ return output.count
517
+ }
518
+
519
+ this.publish(this.retainedFrame, output, maxSpatialGap, true)
520
+ return output.count
521
+ }
522
+
523
+ consumeDiscontinuity(output: THREE.Vector3): boolean {
524
+ if (this.pendingDiscontinuity.lengthSq() < 0.000_001) return false
525
+ output.copy(this.pendingDiscontinuity)
526
+ this.pendingDiscontinuity.set(0, 0, 0)
527
+ return true
528
+ }
529
+
530
+ private adoptBest(output: LeadingPackFrame, maxSpatialGap: number, preserveIdentity: boolean): void {
531
+ this.selectedMembers.length = 0
532
+ this.selectedMembers.push(...this.bestMembers)
533
+ this.challengerMembers.length = 0
534
+ this.challengerSeconds = 0
535
+ this.publish(this.bestFrame, output, maxSpatialGap, preserveIdentity)
536
+ }
537
+
538
+ private publish(
539
+ frame: LeadingPackFrame,
540
+ output: LeadingPackFrame,
541
+ maxSpatialGap: number,
542
+ preserveIdentity: boolean,
543
+ ): void {
544
+ if (
545
+ preserveIdentity &&
546
+ this.hasLastCenter &&
547
+ this.lastCenter.distanceToSquared(frame.center) > (maxSpatialGap * 1.5) ** 2
548
+ ) {
549
+ this.pendingDiscontinuity.subVectors(frame.center, this.lastCenter)
550
+ }
551
+ this.lastCenter.copy(frame.center)
552
+ this.hasLastCenter = frame.count > 0
553
+ copyLeadingPackFrame(frame, output)
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Frames the largest progress-close and spatially coherent group, preferring
559
+ * the front-most group when two packs contain the same number of racers. This
560
+ * keeps a breakaway marble from turning Race View into a leader-follow camera.
561
+ * The spatial gate also prevents portal-separated or tightly folded track
562
+ * sections from producing a centroid in empty space.
563
+ */
564
+ export function writeLeadingPackFrame(
565
+ candidates: readonly LeadingPackCandidate[],
566
+ output: LeadingPackFrame,
567
+ maxProgressGap = 14,
568
+ maxSpatialGap = 20,
569
+ ): number {
570
+ const packLeader = selectLeadingPackLeader(candidates, maxProgressGap, maxSpatialGap)
571
+ if (!packLeader) {
572
+ resetLeadingPackFrame(output)
573
+ return 0
574
+ }
575
+ return writePackFrameForLeader(candidates, packLeader, output, maxProgressGap, maxSpatialGap)
576
+ }
577
+
578
+ /** Writes the centroid of the largest progress-coherent pack. */
579
+ export function writeLeadingPackCentroid(
580
+ candidates: readonly LeadingPackCandidate[],
581
+ output: THREE.Vector3,
582
+ maxProgressGap = 14,
583
+ ): number {
584
+ const frame: LeadingPackFrame = { center: output, count: 0, progress: 0, radius: 0 }
585
+ return writeLeadingPackFrame(candidates, frame, maxProgressGap, Number.POSITIVE_INFINITY)
586
+ }
@@ -0,0 +1,37 @@
1
+ export const CAMERA_OBSTRUCTION_CLEARANCE = 0.75
2
+ export const CAMERA_MIN_TARGET_DISTANCE = 0.35
3
+ export const CAMERA_OBSTRUCTION_TARGET_OFFSET = 1.25
4
+
5
+ /** Starts the sightline beyond the subject's immediate track contact patch. */
6
+ export function cameraObstructionProbeOffset(desiredDistance: number): number {
7
+ return Math.max(0, Math.min(CAMERA_OBSTRUCTION_TARGET_OFFSET, desiredDistance - CAMERA_MIN_TARGET_DISTANCE))
8
+ }
9
+
10
+ /** Compresses the boom along its existing sightline; it never selects another side. */
11
+ export function resolveCameraObstructionDistance(
12
+ desiredDistance: number,
13
+ firstHitDistance?: number,
14
+ probeOffset = 0,
15
+ ): number {
16
+ if (firstHitDistance === undefined) return desiredDistance
17
+ const absoluteHitDistance = probeOffset + firstHitDistance
18
+ if (absoluteHitDistance >= desiredDistance) return desiredDistance
19
+ return Math.min(
20
+ desiredDistance,
21
+ Math.max(CAMERA_MIN_TARGET_DISTANCE, absoluteHitDistance - CAMERA_OBSTRUCTION_CLEARANCE),
22
+ )
23
+ }
24
+
25
+ /** Expands slowly after an obstruction clears, but contracts immediately to remain in front of it. */
26
+ export function advanceCameraObstructionDistance(
27
+ currentDistance: number,
28
+ allowedDistance: number,
29
+ deltaSeconds: number,
30
+ releaseResponse = 4,
31
+ ): number {
32
+ if (!Number.isFinite(currentDistance) || currentDistance <= 0 || allowedDistance <= currentDistance) {
33
+ return allowedDistance
34
+ }
35
+ const alpha = 1 - Math.exp(-releaseResponse * Math.max(0, deltaSeconds))
36
+ return currentDistance + (allowedDistance - currentDistance) * alpha
37
+ }