@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,274 @@
1
+ import * as THREE from 'three'
2
+
3
+ import type { TrackDefinition } from './track-definition'
4
+
5
+ const COMPACT_MIN_DURATION_SECONDS = 2.75
6
+ const SHOWCASE_MIN_DURATION_SECONDS = 5
7
+ const MAX_DURATION_SECONDS = 9
8
+ const TOUR_SAMPLE_COUNT = 241
9
+ const MAX_EXTRA_CORKSCREW_TURNS = 1.15
10
+ const COMPACT_TRACK_SPAN = 16
11
+ const FULL_CORKSCREW_TRACK_SPAN = 64
12
+
13
+ export type CameraTourRouteDirection = 'corkscrew'
14
+
15
+ export interface CameraTourPose {
16
+ readonly position: readonly [number, number, number]
17
+ readonly target: readonly [number, number, number]
18
+ readonly fov: number
19
+ }
20
+
21
+ export interface CameraTourDiagnostics {
22
+ readonly footprintWidth: number
23
+ readonly footprintDepth: number
24
+ readonly occupiedCells: number
25
+ readonly routeDirection: CameraTourRouteDirection
26
+ readonly routeLength: number
27
+ readonly minimumClearance: number
28
+ readonly validationAttempts: number
29
+ readonly cruiseAltitude: number
30
+ readonly pitchChanges: number
31
+ readonly fallbackReason?: string
32
+ }
33
+
34
+ export interface CameraTourPlan {
35
+ readonly durationSeconds: number
36
+ readonly sampleCount: number
37
+ readonly positions: Float64Array
38
+ readonly targets: Float64Array
39
+ readonly fovs: Float32Array
40
+ readonly startPose: CameraTourPose
41
+ readonly diagnostics: CameraTourDiagnostics
42
+ }
43
+
44
+ export interface CameraTourPlannerInput {
45
+ readonly track: TrackDefinition
46
+ readonly finishPose: CameraTourPose
47
+ readonly startPose: CameraTourPose
48
+ readonly startForward: THREE.Vector3
49
+ readonly durationSeconds?: number
50
+ }
51
+
52
+ export interface CameraTourSampleOutput {
53
+ readonly position: THREE.Vector3
54
+ readonly target: THREE.Vector3
55
+ }
56
+
57
+ interface Point2 {
58
+ x: number
59
+ y: number
60
+ }
61
+
62
+ function clamp01(value: number): number {
63
+ return Math.max(0, Math.min(1, value))
64
+ }
65
+
66
+ function smootherstep(value: number): number {
67
+ const t = clamp01(value)
68
+ return t * t * t * (t * (t * 6 - 15) + 10)
69
+ }
70
+
71
+ function cross(origin: Point2, left: Point2, right: Point2): number {
72
+ return (left.x - origin.x) * (right.y - origin.y) - (left.y - origin.y) * (right.x - origin.x)
73
+ }
74
+
75
+ export function convexHull2D(points: readonly Point2[]): Point2[] {
76
+ const finitePoints = points.filter(point => Number.isFinite(point.x) && Number.isFinite(point.y))
77
+ if (finitePoints.length <= 1) return finitePoints.map(point => ({ ...point }))
78
+ const sorted = [...finitePoints].sort((left, right) => left.x - right.x || left.y - right.y)
79
+ const unique = sorted.filter(
80
+ (point, index) => index === 0 || point.x !== sorted[index - 1]!.x || point.y !== sorted[index - 1]!.y,
81
+ )
82
+ if (unique.length <= 2) return unique.map(point => ({ ...point }))
83
+ const lower: Point2[] = []
84
+ for (const point of unique) {
85
+ while (lower.length >= 2 && cross(lower.at(-2)!, lower.at(-1)!, point) <= 0) lower.pop()
86
+ lower.push(point)
87
+ }
88
+ const upper: Point2[] = []
89
+ for (let index = unique.length - 1; index >= 0; index -= 1) {
90
+ const point = unique[index]!
91
+ while (upper.length >= 2 && cross(upper.at(-2)!, upper.at(-1)!, point) <= 0) upper.pop()
92
+ upper.push(point)
93
+ }
94
+ lower.pop()
95
+ upper.pop()
96
+ return [...lower, ...upper]
97
+ }
98
+
99
+ function trackBounds(track: TrackDefinition): { bounds: THREE.Box3; pointCount: number } {
100
+ const bounds = new THREE.Box3()
101
+ let pointCount = 0
102
+ for (const path of [track.cameraPath, ...track.projectionPaths]) {
103
+ for (const point of path.points) {
104
+ const position = new THREE.Vector3().fromArray(point.position)
105
+ if (![position.x, position.y, position.z].every(Number.isFinite)) continue
106
+ bounds.expandByPoint(position)
107
+ pointCount += 1
108
+ }
109
+ }
110
+ if (bounds.isEmpty()) bounds.setFromCenterAndSize(new THREE.Vector3(), new THREE.Vector3(1, 1, 1))
111
+ return { bounds, pointCount }
112
+ }
113
+
114
+ function shortestSignedAngle(from: number, to: number): number {
115
+ return Math.atan2(Math.sin(to - from), Math.cos(to - from))
116
+ }
117
+
118
+ function countPitchChanges(positions: Float64Array, targets: Float64Array): number {
119
+ let changes = 0
120
+ let previousPitch: number | undefined
121
+ let previousDirection = 0
122
+ for (let sample = 0; sample < TOUR_SAMPLE_COUNT; sample += 1) {
123
+ const offset = sample * 3
124
+ const horizontal = Math.hypot(targets[offset]! - positions[offset]!, targets[offset + 2]! - positions[offset + 2]!)
125
+ const pitch = Math.atan2(targets[offset + 1]! - positions[offset + 1]!, Math.max(horizontal, 0.000_001))
126
+ if (previousPitch !== undefined) {
127
+ const direction = Math.sign(pitch - previousPitch)
128
+ if (direction !== 0 && previousDirection !== 0 && direction !== previousDirection) changes += 1
129
+ if (direction !== 0) previousDirection = direction
130
+ }
131
+ previousPitch = pitch
132
+ }
133
+ return changes
134
+ }
135
+
136
+ /**
137
+ * Creates the authored finish-to-start corkscrew with geometry alone.
138
+ *
139
+ * Runtime camera motion is presentation, not physics. It must never block a
140
+ * race while exhaustively querying every Rapier collider. The conservative
141
+ * ellipse is derived from the track guides, rises above their highest point,
142
+ * and hands off exactly to the existing start-line composition.
143
+ */
144
+ export function createCameraTourPlan(input: CameraTourPlannerInput): CameraTourPlan {
145
+ const { bounds, pointCount } = trackBounds(input.track)
146
+ const center = bounds.getCenter(new THREE.Vector3())
147
+ const size = bounds.getSize(new THREE.Vector3())
148
+ const finishPosition = new THREE.Vector3().fromArray(input.finishPose.position)
149
+ const finishTarget = new THREE.Vector3().fromArray(input.finishPose.target)
150
+ const startPosition = new THREE.Vector3().fromArray(input.startPose.position)
151
+ const authoredStartTarget = new THREE.Vector3().fromArray(input.startPose.target)
152
+ const startLookOffset = input.startForward.clone()
153
+ if (startLookOffset.lengthSq() < 0.000_001) startLookOffset.subVectors(authoredStartTarget, startPosition)
154
+ const startLookDistance = Math.max(1, authoredStartTarget.distanceTo(startPosition))
155
+ startLookOffset.normalize().multiplyScalar(startLookDistance)
156
+ const startTarget = startPosition.clone().add(startLookOffset)
157
+ const resolvedStartPose: CameraTourPose = {
158
+ position: input.startPose.position,
159
+ target: [startTarget.x, startTarget.y, startTarget.z],
160
+ fov: input.startPose.fov,
161
+ }
162
+
163
+ const radiusX = THREE.MathUtils.clamp(size.x * 0.58 + 7, 11, 52)
164
+ const radiusZ = THREE.MathUtils.clamp(size.z * 0.58 + 7, 11, 52)
165
+ const finishAngle = Math.atan2(finishPosition.z - center.z, finishPosition.x - center.x)
166
+ const startAngle = Math.atan2(startPosition.z - center.z, startPosition.x - center.x)
167
+ const footprintSpan = Math.hypot(size.x, size.z)
168
+ const showcaseWeight = smootherstep(
169
+ (footprintSpan - COMPACT_TRACK_SPAN) / (FULL_CORKSCREW_TRACK_SPAN - COMPACT_TRACK_SPAN),
170
+ )
171
+ const shortestArc = shortestSignedAngle(finishAngle, startAngle)
172
+ const orbitDirection = Math.sign(shortestArc) || 1
173
+ const angularTravel = shortestArc + orbitDirection * Math.PI * 2 * MAX_EXTRA_CORKSCREW_TURNS * showcaseWeight
174
+ const cruiseAltitude = Math.max(
175
+ finishPosition.y + 4,
176
+ startPosition.y + 4,
177
+ bounds.max.y + THREE.MathUtils.clamp(Math.max(size.x, size.z) * 0.12, 8, 18),
178
+ )
179
+ const positions = new Float64Array(TOUR_SAMPLE_COUNT * 3)
180
+ const targets = new Float64Array(TOUR_SAMPLE_COUNT * 3)
181
+ const fovs = new Float32Array(TOUR_SAMPLE_COUNT)
182
+ const position = new THREE.Vector3()
183
+ const target = new THREE.Vector3()
184
+ const orbit = new THREE.Vector3()
185
+ const orbitTarget = new THREE.Vector3()
186
+ const finalLookTarget = new THREE.Vector3()
187
+ const previousPosition = new THREE.Vector3()
188
+ let routeLength = 0
189
+
190
+ for (let sample = 0; sample < TOUR_SAMPLE_COUNT; sample += 1) {
191
+ const fraction = sample / (TOUR_SAMPLE_COUNT - 1)
192
+ const eased = smootherstep(fraction)
193
+ const angle = finishAngle + angularTravel * fraction
194
+ const rise = Math.sin(Math.PI * fraction)
195
+ orbit.set(
196
+ center.x + Math.cos(angle) * radiusX,
197
+ THREE.MathUtils.lerp(finishPosition.y, startPosition.y, eased) +
198
+ rise * (cruiseAltitude - THREE.MathUtils.lerp(finishPosition.y, startPosition.y, eased)),
199
+ center.z + Math.sin(angle) * radiusZ,
200
+ )
201
+ orbitTarget.copy(center)
202
+
203
+ const finishBlend = smootherstep(fraction / 0.12)
204
+ const startBlend = smootherstep((fraction - 0.8) / 0.2)
205
+ const finalPitchBlend = smootherstep((fraction - 0.55) / 0.3)
206
+ position.copy(finishPosition).lerp(orbit, finishBlend).lerp(startPosition, startBlend)
207
+ finalLookTarget.copy(position).add(startLookOffset)
208
+ target.copy(finishTarget).lerp(orbitTarget, finishBlend).lerp(finalLookTarget, finalPitchBlend)
209
+
210
+ if (sample === 0) {
211
+ position.copy(finishPosition)
212
+ target.copy(finishTarget)
213
+ } else {
214
+ routeLength += position.distanceTo(previousPosition)
215
+ }
216
+ if (sample === TOUR_SAMPLE_COUNT - 1) {
217
+ routeLength += position.distanceTo(startPosition)
218
+ position.copy(startPosition)
219
+ target.copy(startTarget)
220
+ }
221
+ previousPosition.copy(position)
222
+ const offset = sample * 3
223
+ position.toArray(positions, offset)
224
+ target.toArray(targets, offset)
225
+ fovs[sample] = THREE.MathUtils.lerp(input.finishPose.fov, input.startPose.fov, eased)
226
+ }
227
+
228
+ return Object.freeze({
229
+ durationSeconds:
230
+ input.durationSeconds !== undefined && Number.isFinite(input.durationSeconds) && input.durationSeconds > 0
231
+ ? input.durationSeconds
232
+ : THREE.MathUtils.clamp(
233
+ routeLength / 32,
234
+ THREE.MathUtils.lerp(COMPACT_MIN_DURATION_SECONDS, SHOWCASE_MIN_DURATION_SECONDS, showcaseWeight),
235
+ MAX_DURATION_SECONDS,
236
+ ),
237
+ sampleCount: TOUR_SAMPLE_COUNT,
238
+ positions,
239
+ targets,
240
+ fovs,
241
+ startPose: resolvedStartPose,
242
+ diagnostics: Object.freeze({
243
+ footprintWidth: size.x,
244
+ footprintDepth: size.z,
245
+ occupiedCells: pointCount,
246
+ routeDirection: 'corkscrew' as const,
247
+ routeLength,
248
+ minimumClearance: 0,
249
+ validationAttempts: 0,
250
+ cruiseAltitude,
251
+ pitchChanges: countPitchChanges(positions, targets),
252
+ }),
253
+ })
254
+ }
255
+
256
+ export function sampleCameraTourPlan(plan: CameraTourPlan, progress: number, output: CameraTourSampleOutput): number {
257
+ const scaled = clamp01(progress) * (plan.sampleCount - 1)
258
+ const left = Math.floor(scaled)
259
+ const right = Math.min(plan.sampleCount - 1, left + 1)
260
+ const alpha = scaled - left
261
+ const leftOffset = left * 3
262
+ const rightOffset = right * 3
263
+ output.position.set(
264
+ THREE.MathUtils.lerp(plan.positions[leftOffset]!, plan.positions[rightOffset]!, alpha),
265
+ THREE.MathUtils.lerp(plan.positions[leftOffset + 1]!, plan.positions[rightOffset + 1]!, alpha),
266
+ THREE.MathUtils.lerp(plan.positions[leftOffset + 2]!, plan.positions[rightOffset + 2]!, alpha),
267
+ )
268
+ output.target.set(
269
+ THREE.MathUtils.lerp(plan.targets[leftOffset]!, plan.targets[rightOffset]!, alpha),
270
+ THREE.MathUtils.lerp(plan.targets[leftOffset + 1]!, plan.targets[rightOffset + 1]!, alpha),
271
+ THREE.MathUtils.lerp(plan.targets[leftOffset + 2]!, plan.targets[rightOffset + 2]!, alpha),
272
+ )
273
+ return THREE.MathUtils.lerp(plan.fovs[left]!, plan.fovs[right]!, alpha)
274
+ }
@@ -0,0 +1,206 @@
1
+ import * as THREE from 'three'
2
+
3
+ /** The race camera has exactly two authored views. */
4
+ export const RACE_CAMERA_VIEWS = ['race', 'focus'] as const
5
+
6
+ export type RaceCameraView = (typeof RACE_CAMERA_VIEWS)[number]
7
+
8
+ /** Zooming out releases a view; Race orbit also releases while Focus orbit retains its subject lock. */
9
+ export const RACE_CAMERA_CONTROLS = ['locked', 'free'] as const
10
+
11
+ export type RaceCameraControl = (typeof RACE_CAMERA_CONTROLS)[number]
12
+
13
+ export const RACE_CAMERA_STATES = ['race-locked', 'race-free', 'focus-locked', 'focus-free'] as const
14
+
15
+ export type RaceCameraState = (typeof RACE_CAMERA_STATES)[number]
16
+
17
+ export interface RaceCameraSnapshot {
18
+ view: RaceCameraView
19
+ control: RaceCameraControl
20
+ targetSlotId?: string
21
+ }
22
+
23
+ export function raceCameraState(view: RaceCameraView, control: RaceCameraControl): RaceCameraState {
24
+ return `${view}-${control}`
25
+ }
26
+
27
+ const CAMERA_ZOOM_DIRECTION_EPSILON = 0.000_1
28
+ const CAMERA_RETURN_PROGRESS_PER_SECOND = 2.4
29
+ const CAMERA_ORBIT_RETURN_INPUT_RATIO = 0.3
30
+
31
+ /** Close-enough boundary where the remaining curve is consumed into the authored pose. */
32
+ export const CAMERA_ZOOM_LOCK_DISTANCE_RATIO = 1.03
33
+
34
+ export function cameraZoomIsInward(previousDistance: number, requestedDistance: number): boolean {
35
+ return (
36
+ Number.isFinite(previousDistance) &&
37
+ Number.isFinite(requestedDistance) &&
38
+ requestedDistance < previousDistance - CAMERA_ZOOM_DIRECTION_EPSILON
39
+ )
40
+ }
41
+
42
+ export function cameraZoomIsOutward(previousDistance: number, requestedDistance: number): boolean {
43
+ return (
44
+ Number.isFinite(previousDistance) &&
45
+ Number.isFinite(requestedDistance) &&
46
+ requestedDistance > previousDistance + CAMERA_ZOOM_DIRECTION_EPSILON
47
+ )
48
+ }
49
+
50
+ export function cameraReturnProgress(startDistance: number, requestedDistance: number, lockedDistance: number): number {
51
+ if (
52
+ !Number.isFinite(startDistance) ||
53
+ !Number.isFinite(requestedDistance) ||
54
+ !Number.isFinite(lockedDistance) ||
55
+ lockedDistance <= 0
56
+ ) {
57
+ return 0
58
+ }
59
+ if (requestedDistance <= lockedDistance * CAMERA_ZOOM_LOCK_DISTANCE_RATIO) return 1
60
+ if (startDistance <= lockedDistance + CAMERA_ZOOM_DIRECTION_EPSILON) return 0
61
+ return THREE.MathUtils.clamp((startDistance - requestedDistance) / (startDistance - lockedDistance), 0, 1)
62
+ }
63
+
64
+ /** Moves a free orbit with its subject without changing the user-authored orbit vector. */
65
+ export function translateFreeCameraRig(
66
+ position: THREE.Vector3,
67
+ target: THREE.Vector3,
68
+ anchor: THREE.Vector3,
69
+ nextAnchor: THREE.Vector3,
70
+ translation: THREE.Vector3,
71
+ ): void {
72
+ translation.subVectors(nextAnchor, anchor)
73
+ anchor.copy(nextAnchor)
74
+ position.add(translation)
75
+ target.add(translation)
76
+ }
77
+
78
+ /**
79
+ * Allocation-free curve from a user-authored orbit back to the live authored
80
+ * race/focus pose. Target, radius, direction, and FOV bend together, while
81
+ * `translate` carries the captured free pose with the moving race subject.
82
+ */
83
+ export class RaceCameraReturnCurve {
84
+ private readonly startPosition = new THREE.Vector3()
85
+ private readonly startTarget = new THREE.Vector3()
86
+ private readonly startOffset = new THREE.Vector3()
87
+ private readonly lockedOffset = new THREE.Vector3()
88
+ private readonly startDirection = new THREE.Vector3()
89
+ private readonly lockedDirection = new THREE.Vector3()
90
+ private readonly interpolatedDirection = new THREE.Vector3()
91
+ private readonly shortestRotation = new THREE.Quaternion()
92
+ private readonly interpolatedRotation = new THREE.Quaternion()
93
+ private startFov = 52
94
+ private startDistance = 0
95
+ private requestedDistance = 0
96
+ private requestedInputProgress = 0
97
+ private progress = 0
98
+ private active = false
99
+ private inputDriven = false
100
+
101
+ begin(position: THREE.Vector3, target: THREE.Vector3, fov: number, distance: number): void {
102
+ this.startPosition.copy(position)
103
+ this.startTarget.copy(target)
104
+ this.startFov = fov
105
+ this.startDistance = Math.max(0, distance)
106
+ this.requestedDistance = this.startDistance
107
+ this.requestedInputProgress = 0
108
+ this.progress = 0
109
+ this.active = true
110
+ this.inputDriven = false
111
+ }
112
+
113
+ cancel(): void {
114
+ this.active = false
115
+ this.progress = 0
116
+ this.requestedInputProgress = 0
117
+ this.inputDriven = false
118
+ }
119
+
120
+ isActive(): boolean {
121
+ return this.active
122
+ }
123
+
124
+ isComplete(): boolean {
125
+ return this.active && this.progress >= 1
126
+ }
127
+
128
+ setRequestedDistance(distance: number, inputDriven = false): void {
129
+ if (!Number.isFinite(distance)) return
130
+ this.requestedDistance = Math.max(0, distance)
131
+ this.inputDriven = inputDriven
132
+ }
133
+
134
+ addInwardInput(previousDistance: number, requestedDistance: number, lockedDistance: number): void {
135
+ if (
136
+ !this.active ||
137
+ !Number.isFinite(previousDistance) ||
138
+ !Number.isFinite(requestedDistance) ||
139
+ !Number.isFinite(lockedDistance) ||
140
+ lockedDistance <= 0
141
+ ) {
142
+ return
143
+ }
144
+ const inwardDistance = Math.max(0, previousDistance - requestedDistance)
145
+ const returnInputDistance = Math.max(
146
+ this.startDistance - lockedDistance,
147
+ lockedDistance * CAMERA_ORBIT_RETURN_INPUT_RATIO,
148
+ CAMERA_ZOOM_DIRECTION_EPSILON,
149
+ )
150
+ this.requestedInputProgress = THREE.MathUtils.clamp(
151
+ this.requestedInputProgress + inwardDistance / returnInputDistance,
152
+ 0,
153
+ 1,
154
+ )
155
+ this.inputDriven = true
156
+ }
157
+
158
+ translate(delta: THREE.Vector3): void {
159
+ if (!this.active) return
160
+ this.startPosition.add(delta)
161
+ this.startTarget.add(delta)
162
+ }
163
+
164
+ writePose(
165
+ lockedPosition: THREE.Vector3,
166
+ lockedTarget: THREE.Vector3,
167
+ lockedFov: number,
168
+ outputPosition: THREE.Vector3,
169
+ outputTarget: THREE.Vector3,
170
+ deltaSeconds: number,
171
+ ): number {
172
+ if (!this.active) return lockedFov
173
+
174
+ this.startOffset.subVectors(this.startPosition, this.startTarget)
175
+ this.lockedOffset.subVectors(lockedPosition, lockedTarget)
176
+ const lockedDistance = this.lockedOffset.length()
177
+ const requestedProgress = Math.max(
178
+ this.progress,
179
+ this.inputDriven
180
+ ? this.requestedInputProgress
181
+ : cameraReturnProgress(this.startDistance, this.requestedDistance, lockedDistance),
182
+ )
183
+ this.progress = this.inputDriven
184
+ ? requestedProgress
185
+ : Math.min(
186
+ requestedProgress,
187
+ this.progress + CAMERA_RETURN_PROGRESS_PER_SECOND * THREE.MathUtils.clamp(deltaSeconds, 0, 0.1),
188
+ )
189
+ const curvedProgress = THREE.MathUtils.smootherstep(this.progress, 0, 1)
190
+
191
+ outputTarget.lerpVectors(this.startTarget, lockedTarget, curvedProgress)
192
+ if (this.startOffset.lengthSq() < 0.000_001 || this.lockedOffset.lengthSq() < 0.000_001) {
193
+ outputPosition.lerpVectors(this.startPosition, lockedPosition, curvedProgress)
194
+ return THREE.MathUtils.lerp(this.startFov, lockedFov, curvedProgress)
195
+ }
196
+
197
+ this.startDirection.copy(this.startOffset).normalize()
198
+ this.lockedDirection.copy(this.lockedOffset).normalize()
199
+ this.shortestRotation.setFromUnitVectors(this.startDirection, this.lockedDirection)
200
+ this.interpolatedRotation.identity().slerp(this.shortestRotation, curvedProgress)
201
+ this.interpolatedDirection.copy(this.startDirection).applyQuaternion(this.interpolatedRotation).normalize()
202
+ const radius = THREE.MathUtils.lerp(this.startDistance, lockedDistance, this.progress)
203
+ outputPosition.copy(outputTarget).addScaledVector(this.interpolatedDirection, radius)
204
+ return THREE.MathUtils.lerp(this.startFov, lockedFov, curvedProgress)
205
+ }
206
+ }
@@ -0,0 +1,5 @@
1
+ export {
2
+ RACE_FINISH_COAST_SECONDS as FINISH_COAST_SECONDS,
3
+ RACE_FINISH_COAST_TICKS as FINISH_COAST_TICKS,
4
+ hasRaceFinishCoastElapsed as hasFinishCoastElapsed,
5
+ } from '@marble.fun/physics-engine'
@@ -0,0 +1,57 @@
1
+ import {
2
+ createRaceConfiguration as createCoreRaceConfiguration,
3
+ createRaceTuning as createCoreRaceTuning,
4
+ raceConfigurationKey,
5
+ RACE_MARBLE_RADIUS,
6
+ } from '@marble.fun/physics-engine'
7
+
8
+ import { MAX_MARBLES, MIN_MARBLES, type MarbleAssignment, type RaceConfiguration, type RaceTuning } from './race-types'
9
+ import { STOCK_MARBLES } from '../../lobby/lobby-types'
10
+ import { normalizeSeed } from './seeded-random'
11
+ import type { TrackDefinition } from './track-definition'
12
+
13
+ export const STOCK_PRESETS = [
14
+ ...STOCK_MARBLES.map(stock => ({ label: stock.id, color: stock.color })),
15
+ { label: 'DEMO 5', color: '#fd9d55' },
16
+ { label: 'DEMO 6', color: '#8d7cff' },
17
+ ] as const
18
+
19
+ export const MARBLE_RADIUS = RACE_MARBLE_RADIUS
20
+
21
+ export function createAssignments(marbleCount: number): MarbleAssignment[] {
22
+ return STOCK_PRESETS.slice(0, marbleCount).map((preset, index) => ({
23
+ slotId: `M${index + 1}`,
24
+ label: preset.label,
25
+ color: preset.color,
26
+ }))
27
+ }
28
+
29
+ export function createRaceConfiguration(
30
+ seed: string,
31
+ marbleCount: number,
32
+ track: Pick<TrackDefinition, 'id' | 'version'>,
33
+ ): RaceConfiguration {
34
+ const safeCount = Math.min(MAX_MARBLES, Math.max(MIN_MARBLES, Math.round(marbleCount)))
35
+ return createCoreRaceConfiguration(normalizeSeed(seed), safeCount, track, createAssignments(safeCount))
36
+ }
37
+
38
+ export function createLabTestRunConfiguration(
39
+ seed: string,
40
+ marbleCount: number,
41
+ track: Pick<TrackDefinition, 'id' | 'version'>,
42
+ ): RaceConfiguration {
43
+ const configuration = createRaceConfiguration(seed, marbleCount, track)
44
+ return {
45
+ ...configuration,
46
+ assignments: configuration.assignments.map(assignment => ({
47
+ ...assignment,
48
+ label: assignment.slotId,
49
+ })),
50
+ }
51
+ }
52
+
53
+ export function createRaceTuning(configuration: RaceConfiguration, track: TrackDefinition): RaceTuning {
54
+ return createCoreRaceTuning(configuration, track)
55
+ }
56
+
57
+ export { raceConfigurationKey }
@@ -0,0 +1,134 @@
1
+ import * as THREE from 'three'
2
+ import { FullScreenQuad } from 'three/addons/postprocessing/Pass.js'
3
+
4
+ const vertexShader = /* glsl */ `
5
+ varying vec2 vUv;
6
+
7
+ void main() {
8
+ vUv = uv;
9
+ gl_Position = vec4(position.xy, 0.0, 1.0);
10
+ }
11
+ `
12
+
13
+ const fragmentShader = /* glsl */ `
14
+ uniform sampler2D tColor;
15
+ uniform vec2 texelSize;
16
+ uniform vec2 focusUv;
17
+ uniform float aspect;
18
+ uniform float focusRadius;
19
+ uniform float focusFeather;
20
+ uniform float maxBlurPixels;
21
+
22
+ varying vec2 vUv;
23
+
24
+ void main() {
25
+ vec4 centerColor = texture2D(tColor, vUv);
26
+ vec2 focusDelta = (vUv - focusUv) * vec2(aspect, 1.0);
27
+ float blur = smoothstep(focusRadius, focusRadius + focusFeather, length(focusDelta));
28
+
29
+ if (blur <= 0.001) {
30
+ gl_FragColor = centerColor;
31
+ return;
32
+ }
33
+
34
+ vec2 radius = texelSize * maxBlurPixels * blur;
35
+ vec4 blurredColor = centerColor * 0.2;
36
+ for (int index = 0; index < 8; index++) {
37
+ float angle = float(index) * 0.7853981634;
38
+ vec2 tap = vec2(cos(angle), sin(angle));
39
+ vec2 sampleUv = clamp(vUv + tap * radius, vec2(0.0), vec2(1.0));
40
+ blurredColor += texture2D(tColor, sampleUv) * 0.1;
41
+ }
42
+
43
+ // The framebuffer copy already contains final display pixels. Interpolate
44
+ // those raw values only; never tone-map, expose, or encode them again.
45
+ gl_FragColor = mix(centerColor, blurredColor, blur);
46
+ }
47
+ `
48
+
49
+ /** Keeps the ball and nearby track sharp, then gradually softens the surrounding frame. */
50
+ export class RaceMarbleFocusRenderer {
51
+ private colorTexture = this.createColorTexture(1, 1)
52
+ private readonly material: THREE.ShaderMaterial
53
+ private readonly quad: FullScreenQuad
54
+ private readonly drawingBufferSize = new THREE.Vector2()
55
+ private readonly projectedFocus = new THREE.Vector3()
56
+ private width = 0
57
+ private height = 0
58
+
59
+ constructor() {
60
+ this.material = new THREE.ShaderMaterial({
61
+ depthTest: false,
62
+ depthWrite: false,
63
+ toneMapped: false,
64
+ transparent: false,
65
+ uniforms: {
66
+ tColor: { value: this.colorTexture },
67
+ texelSize: { value: new THREE.Vector2(1, 1) },
68
+ focusUv: { value: new THREE.Vector2(0.5, 0.5) },
69
+ aspect: { value: 1 },
70
+ // Screen-height-relative radii after aspect correction: a broad sharp
71
+ // neighborhood around the marble followed by a long, gentle falloff.
72
+ focusRadius: { value: 0.14 },
73
+ focusFeather: { value: 0.22 },
74
+ maxBlurPixels: { value: 4 },
75
+ },
76
+ vertexShader,
77
+ fragmentShader,
78
+ })
79
+ this.quad = new FullScreenQuad(this.material)
80
+ }
81
+
82
+ render(
83
+ renderer: THREE.WebGLRenderer,
84
+ scene: THREE.Scene,
85
+ camera: THREE.PerspectiveCamera,
86
+ focusWorldPosition: THREE.Vector3,
87
+ ): void {
88
+ this.syncSize(renderer)
89
+
90
+ // Establish the authoritative color first through the ordinary renderer.
91
+ // Copying it avoids a second scene render and guarantees identical grading.
92
+ renderer.render(scene, camera)
93
+ renderer.copyFramebufferToTexture(this.colorTexture)
94
+
95
+ this.projectedFocus.copy(focusWorldPosition).project(camera)
96
+ ;(this.material.uniforms.focusUv!.value as THREE.Vector2).set(
97
+ this.projectedFocus.x * 0.5 + 0.5,
98
+ this.projectedFocus.y * 0.5 + 0.5,
99
+ )
100
+ this.quad.render(renderer)
101
+ }
102
+
103
+ dispose(): void {
104
+ this.quad.dispose()
105
+ this.material.dispose()
106
+ this.colorTexture.dispose()
107
+ }
108
+
109
+ private createColorTexture(width: number, height: number): THREE.FramebufferTexture {
110
+ const texture = new THREE.FramebufferTexture(width, height)
111
+ texture.name = 'race-marble-focus-screen-copy'
112
+ // The copy contains already encoded display values. Treat them as raw data
113
+ // so the focus shader cannot alter exposure or color-space transforms.
114
+ texture.colorSpace = THREE.NoColorSpace
115
+ texture.magFilter = THREE.LinearFilter
116
+ texture.minFilter = THREE.LinearFilter
117
+ return texture
118
+ }
119
+
120
+ private syncSize(renderer: THREE.WebGLRenderer): void {
121
+ renderer.getDrawingBufferSize(this.drawingBufferSize)
122
+ const width = Math.max(1, Math.round(this.drawingBufferSize.x))
123
+ const height = Math.max(1, Math.round(this.drawingBufferSize.y))
124
+ if (width === this.width && height === this.height) return
125
+
126
+ this.width = width
127
+ this.height = height
128
+ this.colorTexture.dispose()
129
+ this.colorTexture = this.createColorTexture(width, height)
130
+ this.material.uniforms.tColor!.value = this.colorTexture
131
+ ;(this.material.uniforms.texelSize!.value as THREE.Vector2).set(1 / width, 1 / height)
132
+ this.material.uniforms.aspect!.value = width / height
133
+ }
134
+ }
@@ -0,0 +1,7 @@
1
+ export {
2
+ fastRaceDuration,
3
+ fastRaceElapsedSeconds,
4
+ fastRaceSpeed,
5
+ RACE_PACE,
6
+ RACE_PHYSICS as FAST_RACE_PHYSICS,
7
+ } from '@marble.fun/physics-engine'