@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,2652 @@
1
+ import { prepareSceneShaders } from '../../../three/scene-shader-preparation'
2
+ import { ShadowShaderPreparation } from '../../../three/shadow-shader-preparation'
3
+ import { nextVisualFrameDeadline } from '../../../three/visual-frame-pacing'
4
+ import { LowQualitySceneRenderer } from '../../../three/low-quality-scene-renderer'
5
+ import { usesMobileRenderingBudget } from '../../../three/mobile-rendering-budget'
6
+ import RAPIER from '@dimforge/rapier3d-deterministic-compat'
7
+ import * as THREE from 'three'
8
+ import { OrbitControls } from 'three/addons'
9
+ import {
10
+ CURRENT_RACE_ENGINE_VERSION,
11
+ createPreparedRaceSimulation,
12
+ preparePhysicsTrack,
13
+ type DeterministicRaceSimulation,
14
+ type PreparedPhysicsTrack,
15
+ type RaceSimulationEvent,
16
+ type RaceSimulationSnapshot,
17
+ } from '@marble.fun/physics-engine'
18
+
19
+ import { SceneThemeController } from '../../../three/scene-theme-controller'
20
+ import { configureSceneRenderer } from '../../../three/scene-lighting'
21
+ import { resizeRendererDrawingBuffer } from '../../../three/renderer-resize'
22
+ import { ThemeMaterialRegistry } from '../../../three/theme-material-registry'
23
+ import { resolveTrackTheme } from '../../../three/theme-catalog'
24
+ import { fluidSurfaceStyleFromId } from '../../../three/water-appearance'
25
+ import { visualQualityPolicy, type VisualQualityPolicy } from '../../../three/visual-quality'
26
+ import {
27
+ applyMarbleSimulationSnapshot,
28
+ captureMarblePhysicsTransform,
29
+ createMarbleEntity,
30
+ syncMarbleVisual,
31
+ updateMarbleTrail,
32
+ type RaceMarbleVisualStyle,
33
+ } from './marble-entity'
34
+ import type { MarbleEntity } from './marble-entity'
35
+ import type { PhysicsDebugReport } from './physics-debug'
36
+ import {
37
+ cameraZoomIsInward,
38
+ cameraZoomIsOutward,
39
+ raceCameraState,
40
+ RaceCameraReturnCurve,
41
+ translateFreeCameraRig,
42
+ type RaceCameraControl,
43
+ type RaceCameraSnapshot,
44
+ type RaceCameraState,
45
+ type RaceCameraView,
46
+ } from './race-camera'
47
+ import {
48
+ advanceCameraOrientation,
49
+ cameraPoseWithinTolerance,
50
+ RaceCameraDirector,
51
+ RaceCameraPackTracker,
52
+ retainCameraLeader,
53
+ smoothCameraTourProgress,
54
+ writeStableCameraGuideDirection,
55
+ writeLeadingPackFrame,
56
+ type LeadingPackFrame,
57
+ type RaceCameraDirectorState,
58
+ } from './race-camera-director'
59
+ import {
60
+ advanceCameraObstructionDistance,
61
+ CAMERA_MIN_TARGET_DISTANCE,
62
+ cameraObstructionProbeOffset,
63
+ resolveCameraObstructionDistance,
64
+ } from './race-camera-obstruction'
65
+ import { FINISH_COAST_TICKS } from './race-completion'
66
+ import { createRaceTuning } from './race-configuration'
67
+ import type { RaceMarbleFocusRenderer } from './race-marble-focus-renderer'
68
+ import {
69
+ advanceRaceCountdown,
70
+ MAX_RACE_TIME_SCALE,
71
+ MIN_RACE_TIME_SCALE,
72
+ RACE_COUNTDOWN_SECONDS,
73
+ STANDARD_RACE_TIME_SCALE,
74
+ synchronizedRaceIntroState,
75
+ } from './race-playback'
76
+ import { RaceRollingAudio } from './race-rolling-audio'
77
+ import { createRacePhysicsWorld, FIXED_TIMESTEP, initializeRacePhysics, MAX_RACE_TICKS } from './race-physics'
78
+ import type { MarbleStanding, RaceConfiguration, RaceSnapshot, RaceTuning, RaceWinner } from './race-types'
79
+ import { formatFingerprint, hashSeed } from './seeded-random'
80
+ import { TrackCourse } from './track-course'
81
+ import type { PortalPieceDefinition, TrackDefinition } from './track-definition'
82
+ import { buildTrackWorld, type TrackWorld } from './track-world'
83
+ import type { PortalTeleportEvent } from './track-mechanisms'
84
+ import { bindMechanismPartObjects, RaceSimulationRenderer } from './race-simulation-renderer'
85
+ import {
86
+ createCanonicalTrackAssembly,
87
+ type CanonicalTrackAssembly,
88
+ type OwnedCanonicalTrackAssembly,
89
+ } from '../../track-lab/engine/canonical-track-assembly'
90
+ import { createCameraTourPlan, sampleCameraTourPlan, type CameraTourPlan } from './race-camera-tour-planner'
91
+
92
+ const SNAPSHOT_INTERVAL_TICKS = Math.max(1, Math.round(0.1 / FIXED_TIMESTEP))
93
+ const TRAIL_INTERVAL_TICKS = Math.max(1, Math.round(0.05 / FIXED_TIMESTEP))
94
+ const DIAGNOSTICS_INTERVAL_MS = 500
95
+ const FRAME_TIMING_SAMPLE_COUNT = 240
96
+ // At 120 Hz, ordinary 60 FPS presentation consumes two physics steps per
97
+ // frame. Keep stalled-frame recovery bounded so mobile cannot enter a runaway
98
+ // catch-up spiral.
99
+ const MAX_PHYSICS_STEPS_PER_FRAME = 8
100
+ const INITIAL_CATCH_UP_SLICE_MS = 6
101
+ const DEFERRED_LAB_SHADOW_BATCH_TIMEOUT_MS = 5_000
102
+ const CAMERA_PROGRESS_RESPONSE = 3.1
103
+ const FOCUS_CAMERA_PROGRESS_RESPONSE = 2.4
104
+ const CAMERA_FOV_RESPONSE = 3
105
+ const CAMERA_COLLISION_RADIUS = 0.52
106
+ const CAMERA_COLLISION_PREDICTION = 0.12
107
+ const CAMERA_TARGET_HANDOFF_SECONDS = 0.72
108
+ const PORTAL_REBASE_MINIMUM_HOLD_TICKS = Math.max(1, Math.round(0.25 / FIXED_TIMESTEP))
109
+ const PORTAL_EVENT_ACTIVATION_TICKS = Math.max(1, Math.round(0.2 / FIXED_TIMESTEP))
110
+ const PORTAL_EVENT_EXPIRY_TICKS = Math.max(1, Math.round(2 / FIXED_TIMESTEP))
111
+ const CAMERA_STAGING_SETTLE_SECONDS = 0.28
112
+ const CAMERA_STAGING_POSITION_TOLERANCE = 1.35
113
+ const CAMERA_STAGING_TARGET_TOLERANCE = 0.85
114
+ const CAMERA_STAGING_ANGLE_TOLERANCE = THREE.MathUtils.degToRad(4)
115
+
116
+ function getInitialTimeScale(developmentMode: boolean): number {
117
+ if (!developmentMode) {
118
+ return STANDARD_RACE_TIME_SCALE
119
+ }
120
+
121
+ const requestedTimeScale = Number(
122
+ new URLSearchParams(window.location.search).get('raceSpeed') ?? STANDARD_RACE_TIME_SCALE,
123
+ )
124
+ return Number.isFinite(requestedTimeScale)
125
+ ? THREE.MathUtils.clamp(requestedTimeScale, MIN_RACE_TIME_SCALE, MAX_RACE_TIME_SCALE)
126
+ : STANDARD_RACE_TIME_SCALE
127
+ }
128
+
129
+ function responseAlpha(response: number, deltaSeconds: number): number {
130
+ return 1 - Math.exp(-response * deltaSeconds)
131
+ }
132
+
133
+ function yieldToBrowser(): Promise<void> {
134
+ const scheduler = (window as Window & { scheduler?: { yield?: () => Promise<void> } }).scheduler
135
+ return scheduler?.yield ? scheduler.yield() : new Promise(resolve => window.setTimeout(resolve, 0))
136
+ }
137
+
138
+ export interface RendererDiagnostics {
139
+ audio?: ReturnType<RaceRollingAudio['diagnostics']>
140
+ calls: number
141
+ triangles: number
142
+ geometries: number
143
+ textures: number
144
+ sceneMeshes: number
145
+ bodies: number
146
+ colliders: number
147
+ authoritativeBodies: number
148
+ authoritativeColliders: number
149
+ mirrorBodies: number
150
+ mirrorColliders: number
151
+ ccdBodies: number
152
+ sensors: number
153
+ fixedTimestep: number
154
+ timeScale: number
155
+ dpr: number
156
+ paused: boolean
157
+ cameraMode: RaceCameraState
158
+ cameraTargetSlotId?: string
159
+ cameraDistance: number
160
+ cameraDirector: {
161
+ state: RaceCameraDirectorState
162
+ subjectSlotId?: string
163
+ stateSeconds: number
164
+ packCount: number
165
+ packRadius: number
166
+ obstructed: boolean
167
+ probes: number
168
+ desiredDistance: number
169
+ resolvedDistance: number
170
+ linearSpeed: number
171
+ targetSpeed: number
172
+ }
173
+ cameraTour?: {
174
+ footprintWidth: number
175
+ footprintDepth: number
176
+ occupiedCells: number
177
+ routeDirection: string
178
+ routeLength: number
179
+ minimumClearance: number
180
+ validationAttempts: number
181
+ cruiseAltitude: number
182
+ pitchChanges: number
183
+ fallbackReason?: string
184
+ }
185
+ themeId: string
186
+ themeMaterials: number
187
+ themeWorldMeshes: number
188
+ themeInstancedMeshes: number
189
+ themeParticles: number
190
+ frameIntervalMs: number
191
+ physicsSteps: number
192
+ physicsBacklogSteps: number
193
+ catchUpSaturated: boolean
194
+ maximumPhysicsBacklogSteps: number
195
+ catchUpSaturationCount: number
196
+ frameTiming: {
197
+ samples: number
198
+ p50Ms: number
199
+ p95Ms: number
200
+ p99Ms: number
201
+ maximumMs: number
202
+ framesAbove16Ms: number
203
+ }
204
+ }
205
+
206
+ export interface RaceStartFrameTiming {
207
+ phase: RaceSnapshot['phase']
208
+ countdown: number
209
+ tick: number
210
+ timeMs: number
211
+ updateMs: number
212
+ renderMs?: number
213
+ }
214
+
215
+ export interface RaceStartupDiagnostics {
216
+ firstPhysicsStepMs?: number
217
+ startFrames?: RaceStartFrameTiming[]
218
+ physicsRuntimeInitializationMs: number
219
+ physicsTrackPreparationMs: number
220
+ physicsSimulationConstructionMs: number
221
+ visualQueryWorldCreationMs: number
222
+ physicsInitializationMs: number
223
+ trackColliderAttachmentMs: number
224
+ trackConstructionMs: number
225
+ trackRuntimeAssemblyMs: number
226
+ trackShadowBatchingMs: number
227
+ materialUploadMs: number
228
+ marbleConstructionMs: number
229
+ marbleAssetsMs: number
230
+ marbleGpuPreparationMs: number
231
+ initialCatchUpMs: number
232
+ initialCatchUpTicks: number
233
+ cameraPlanningMs: number
234
+ totalMs: number
235
+ reusedCanonicalAssembly: boolean
236
+ reusedPreparedSimulation: boolean
237
+ visualTrackConstructionMs: number
238
+ }
239
+
240
+ interface MarbleRaceTestHooks {
241
+ getSnapshot: () => RaceSnapshot
242
+ getRendererDiagnostics: () => RendererDiagnostics
243
+ getPhysicsDebugReport: () => PhysicsDebugReport | undefined
244
+ getMarbleRenderStates: () => Array<{
245
+ slotId: string
246
+ physicsPosition: readonly [number, number, number]
247
+ visualPosition: readonly [number, number, number]
248
+ finishedAtTick?: number
249
+ }>
250
+ setPaused: (paused: boolean) => void
251
+ setTimeScale: (timeScale: number) => void
252
+ followMarble: (slotId: string) => void
253
+ advanceTicks: (ticks: number) => RaceSnapshot
254
+ }
255
+
256
+ declare global {
257
+ interface Window {
258
+ __MARBLE_RACE_TEST_HOOKS__?: MarbleRaceTestHooks
259
+ __THREE_GAME_DIAGNOSTICS__?: RendererDiagnostics
260
+ }
261
+ }
262
+
263
+ export interface MarbleRaceEngineOptions {
264
+ container: HTMLElement
265
+ configuration: RaceConfiguration
266
+ surface?: {
267
+ scene: THREE.Scene
268
+ camera: THREE.PerspectiveCamera
269
+ renderer: THREE.WebGLRenderer
270
+ root: THREE.Group
271
+ materials: ThemeMaterialRegistry
272
+ }
273
+ track: TrackDefinition
274
+ marbleVisualStyle?: RaceMarbleVisualStyle
275
+ onSnapshot: (snapshot: RaceSnapshot) => void
276
+ onCameraChange?: (snapshot: RaceCameraSnapshot) => void
277
+ onRaceCue?: (cue: 'countdown' | 'start') => void
278
+ finishCoastTicks?: number
279
+ initialIntroElapsedMs?: () => number
280
+ initialSimulationElapsedMs?: () => number
281
+ beforeInitialSynchronization?: () => Promise<void>
282
+ introDurationSeconds?: number
283
+ preparedPhysicsTrack?: PreparedPhysicsTrack
284
+ preparedSimulation?: DeterministicRaceSimulation
285
+ rollingAudioGraphEnabled?: boolean
286
+ rollingAudioEnabled?: boolean
287
+ rollingAudioContext?: AudioContext
288
+ rollingAudioGain?: number
289
+ collisionAudioGain?: number
290
+ countdownSeconds?: number
291
+ introEnabled?: boolean
292
+ deferStaticShadowBatching?: boolean
293
+ canonicalAssembly?: CanonicalTrackAssembly
294
+ developmentMode?: boolean
295
+ visualQualityPolicy?: VisualQualityPolicy
296
+ }
297
+
298
+ function disposeMaterial(material: THREE.Material): void {
299
+ if (material.userData.themeRegistryManaged) return
300
+ const candidate = material as THREE.Material & { map?: THREE.Texture | null }
301
+ candidate.map?.dispose()
302
+ material.dispose()
303
+ }
304
+
305
+ function disposeObject(root: THREE.Object3D): void {
306
+ root.traverse(object => {
307
+ if (object instanceof THREE.Mesh || object instanceof THREE.Line || object instanceof THREE.Points) {
308
+ object.geometry.dispose()
309
+ const materials = Array.isArray(object.material) ? object.material : [object.material]
310
+ materials.forEach(disposeMaterial)
311
+ }
312
+
313
+ if (object instanceof THREE.Sprite) {
314
+ disposeMaterial(object.material)
315
+ }
316
+ })
317
+ }
318
+
319
+ export class MarbleRaceEngine {
320
+ private readonly container: HTMLElement
321
+ private readonly configuration: RaceConfiguration
322
+ private readonly onSnapshot: (snapshot: RaceSnapshot) => void
323
+ private readonly onCameraChange?: (snapshot: RaceCameraSnapshot) => void
324
+ private readonly onRaceCue?: (cue: 'countdown' | 'start') => void
325
+ private readonly track: TrackDefinition
326
+ private readonly course: TrackCourse
327
+ private readonly tuning: RaceTuning
328
+ private readonly scene: THREE.Scene
329
+ private readonly camera: THREE.PerspectiveCamera
330
+ private readonly renderer: THREE.WebGLRenderer
331
+ private readonly visualRoot: THREE.Group
332
+ private readonly sharedMaterials?: ThemeMaterialRegistry
333
+ private readonly marbleVisualStyle: RaceMarbleVisualStyle
334
+ private readonly ownsSurface: boolean
335
+ private readonly waterPieceIds: ReadonlySet<string>
336
+ private readonly developmentMode: boolean
337
+ private readonly diagnosticsEnabled: boolean
338
+ private readonly lowQualityRenderer = new LowQualitySceneRenderer()
339
+ private readonly shadowShaderPreparation = new ShadowShaderPreparation()
340
+ private visualQualityPolicy: VisualQualityPolicy
341
+ private readonly cameraPose
342
+ private readonly cameraBehindPose
343
+ private readonly cameraTargetPose
344
+ private readonly cameraDirector = new RaceCameraDirector()
345
+ private readonly cameraPackTracker = new RaceCameraPackTracker()
346
+ private readonly cameraTarget = new THREE.Vector3()
347
+ private readonly cinematicShadowFocusTarget = new THREE.Vector3()
348
+ private cameraTourPlan?: CameraTourPlan
349
+ private readonly cameraPackFrame: LeadingPackFrame = {
350
+ center: new THREE.Vector3(),
351
+ count: 0,
352
+ progress: 0,
353
+ radius: 0,
354
+ }
355
+ private readonly desiredCameraTarget = new THREE.Vector3()
356
+ private readonly desiredCameraPosition = new THREE.Vector3()
357
+ private readonly cameraTargetDelta = new THREE.Vector3()
358
+ private cameraPackDiscontinuityPending = false
359
+ private readonly cameraForward = new THREE.Vector3()
360
+ private readonly cameraGuideForward = new THREE.Vector3()
361
+ private readonly cameraDesiredGuideForward = new THREE.Vector3()
362
+ private readonly cameraGuideChord = new THREE.Vector3()
363
+ private readonly cameraRight = new THREE.Vector3()
364
+ private readonly cameraUp = new THREE.Vector3()
365
+ private readonly cameraWorldUp = new THREE.Vector3()
366
+ private readonly cameraSightLine = new THREE.Vector3()
367
+ private readonly cameraCollisionShape = new RAPIER.Ball(CAMERA_COLLISION_RADIUS)
368
+ private readonly cameraCollisionRotation = { x: 0, y: 0, z: 0, w: 1 }
369
+ private readonly cameraObstructionOrigin = new THREE.Vector3()
370
+ private readonly cameraLookMatrix = new THREE.Matrix4()
371
+ private readonly desiredCameraQuaternion = new THREE.Quaternion()
372
+ private readonly pendingPortalEvents = new Map<string, PortalTeleportEvent>()
373
+ private activePortalTransition?: PortalTeleportEvent
374
+ private controls?: OrbitControls
375
+ private marbleFocusRenderer?: RaceMarbleFocusRenderer
376
+ private marbleFocusRendererPromise?: Promise<void>
377
+ private readonly cameraReturnCurve = new RaceCameraReturnCurve()
378
+ private readonly cameraInteractionStartPosition = new THREE.Vector3()
379
+ private readonly cameraInteractionStartTarget = new THREE.Vector3()
380
+ private readonly cameraInteractionStartDirection = new THREE.Vector3()
381
+ private readonly cameraFreeFollowAnchor = new THREE.Vector3()
382
+ private readonly cameraPointerIds = new Set<number>()
383
+ private cameraInteractionActive = false
384
+ private cameraInteractionAllowsCombinedZoom = false
385
+ private cameraInteractionIsWheel = false
386
+ private cameraInteractionDistance = 0
387
+ private cameraLockedDistance = 0
388
+ private cameraFreeMinimumDistance = 0.35
389
+ private cameraFreeFov = 52
390
+ private cameraReturnLockPending = false
391
+ private cameraReturnNeedsControlsSync = false
392
+ private cameraControlsUpdating = false
393
+ private cameraSnapshotKey = ''
394
+ private focusSubjectLocked = false
395
+ private focusOrbitActive = false
396
+ private cameraTargetHandoffPending = false
397
+ private cameraTargetHandoffSeconds = CAMERA_TARGET_HANDOFF_SECONDS
398
+ private readonly cameraTargetHandoffOffset = new THREE.Vector3()
399
+ private cameraTargetSlotId?: string
400
+ private cameraLeaderSlotId?: string
401
+ private cameraView: RaceCameraView = 'race'
402
+ private cameraControl: RaceCameraControl = 'locked'
403
+ private cameraObstructed = false
404
+ private cameraProbeCount = 0
405
+ private cameraDesiredDistance = 0
406
+ private cameraResolvedDistance = 0
407
+ private desiredCameraFov = 52
408
+ private world?: RAPIER.World
409
+ private queryWorld?: RAPIER.World
410
+ private trackWorld?: TrackWorld
411
+ private simulation?: DeterministicRaceSimulation
412
+ private simulationRenderer?: RaceSimulationRenderer
413
+ private authoritativeSnapshot?: RaceSimulationSnapshot
414
+ private themeController?: SceneThemeController
415
+ private marbles: MarbleEntity[] = []
416
+ private readonly marblesBySlot = new Map<string, MarbleEntity>()
417
+ private readonly marbleIndexBySlot = new Map<string, number>()
418
+ private readonly rollingAudioWaterStates: boolean[] = []
419
+ private readonly rollingAudioGroundedStates: boolean[] = []
420
+ private resizeObserver?: ResizeObserver
421
+ private resizeFrame = 0
422
+ private animationFrame = 0
423
+ private previousFrameTime?: number
424
+ private nextVisualFrameTime = 0
425
+ private lastDiagnosticsWriteMs = Number.NEGATIVE_INFINITY
426
+ private accumulator = 0
427
+ private synchronizedInterpolationAlpha = 1
428
+ private frameIntervalMs = 0
429
+ private physicsStepsLastUpdate = 0
430
+ private physicsBacklogSteps = 0
431
+ private catchUpSaturated = false
432
+ private maximumPhysicsBacklogSteps = 0
433
+ private catchUpSaturationCount = 0
434
+ private readonly frameIntervals = new Float32Array(FRAME_TIMING_SAMPLE_COUNT)
435
+ private frameIntervalCursor = 0
436
+ private frameIntervalCount = 0
437
+ private timeScale: number
438
+ private countdownSecondsRemaining: number
439
+ private lastCountdownCue?: number
440
+ private raceStartCuePlayed = false
441
+ private simulationTick = 0
442
+ private cameraProgress = 0
443
+ private cameraTourElapsedSeconds = 0
444
+ private cameraStagingSettledSeconds = 0
445
+ private replayFingerprint: string
446
+ private winner?: RaceWinner
447
+ private allMarblesFinishedAtTick?: number
448
+ private phase: RaceSnapshot['phase'] = 'loading'
449
+ private worldReady = false
450
+ private readonly finishCoastTicks: number
451
+ private readonly initialIntroElapsedMs?: () => number
452
+ private readonly initialSimulationElapsedMs?: () => number
453
+ private readonly beforeInitialSynchronization?: () => Promise<void>
454
+ private readonly introDurationSeconds?: number
455
+ private readonly introEnabled: boolean
456
+ private suppressSnapshots = false
457
+ private initialCatchUpActive = false
458
+ private paused = false
459
+ private interactionEnabled = true
460
+ private readonly presentationMaterialStates = new Map<
461
+ THREE.Material,
462
+ { depthWrite: boolean; opacity: number; transparent: boolean }
463
+ >()
464
+ private disposed = false
465
+ private latestSnapshot: RaceSnapshot
466
+ private readonly rollingAudio?: RaceRollingAudio
467
+ private startFrameTiming?: RaceStartFrameTiming
468
+ private startupDiagnostics?: RaceStartupDiagnostics
469
+ private preparedPhysicsTrack?: PreparedPhysicsTrack
470
+ private preparedSimulation?: DeterministicRaceSimulation
471
+ private readonly deferStaticShadowBatching: boolean
472
+ private canonicalAssembly?: CanonicalTrackAssembly
473
+ private ownedCanonicalAssembly?: OwnedCanonicalTrackAssembly
474
+ private readonly preservesCanonicalAssembly: boolean
475
+ private readonly marbleRoot = new THREE.Group()
476
+
477
+ constructor(options: MarbleRaceEngineOptions) {
478
+ this.container = options.container
479
+ this.configuration = options.configuration
480
+ this.developmentMode = options.developmentMode === true
481
+ this.visualQualityPolicy = options.visualQualityPolicy ?? visualQualityPolicy('quality')
482
+ this.diagnosticsEnabled = this.developmentMode || new URLSearchParams(window.location.search).has('raceDiagnostics')
483
+ this.timeScale = getInitialTimeScale(this.developmentMode)
484
+ this.onSnapshot = options.onSnapshot
485
+ this.onCameraChange = options.onCameraChange
486
+ this.onRaceCue = options.onRaceCue
487
+ this.finishCoastTicks = options.finishCoastTicks ?? FINISH_COAST_TICKS
488
+ this.initialIntroElapsedMs = options.initialIntroElapsedMs
489
+ this.initialSimulationElapsedMs = options.initialSimulationElapsedMs
490
+ this.beforeInitialSynchronization = options.beforeInitialSynchronization
491
+ this.introDurationSeconds = options.introDurationSeconds
492
+ this.preparedPhysicsTrack = options.preparedPhysicsTrack
493
+ this.preparedSimulation = options.preparedSimulation
494
+ this.introEnabled = options.introEnabled ?? false
495
+ this.deferStaticShadowBatching = options.deferStaticShadowBatching === true
496
+ this.canonicalAssembly = options.canonicalAssembly
497
+ this.preservesCanonicalAssembly = options.canonicalAssembly !== undefined
498
+ this.countdownSecondsRemaining = THREE.MathUtils.clamp(
499
+ options.countdownSeconds ?? RACE_COUNTDOWN_SECONDS,
500
+ 0,
501
+ RACE_COUNTDOWN_SECONDS,
502
+ )
503
+ this.ownsSurface = !options.surface
504
+ this.scene = options.surface?.scene ?? new THREE.Scene()
505
+ this.camera = options.surface?.camera ?? new THREE.PerspectiveCamera(52, 16 / 9, 0.1, 900)
506
+ this.renderer =
507
+ options.surface?.renderer ?? new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' })
508
+ this.visualRoot = options.surface?.root ?? new THREE.Group()
509
+ this.sharedMaterials = options.surface?.materials
510
+ this.marbleVisualStyle = options.marbleVisualStyle ?? 'default'
511
+ if (!this.visualRoot.parent) this.scene.add(this.visualRoot)
512
+ this.marbleRoot.name = `${options.configuration.seed}-race-marbles`
513
+ // Portal destination images are cached. Exclude moving marbles so a
514
+ // capture can never leave a racer visibly frozen inside a doorway.
515
+ this.marbleRoot.userData.portalAuxiliaryEffect = true
516
+ this.visualRoot.add(this.marbleRoot)
517
+ this.track = options.track
518
+ this.waterPieceIds = new Set(
519
+ this.track.pieces
520
+ .filter(piece => fluidSurfaceStyleFromId(piece.appearance?.waterOverlayId ?? piece.appearance?.vortexWaterId))
521
+ .map(piece => piece.id),
522
+ )
523
+ this.course = new TrackCourse(this.track)
524
+ this.tuning = createRaceTuning(options.configuration, this.track)
525
+ this.replayFingerprint = formatFingerprint(hashSeed(this.tuning.simulationKey))
526
+ this.cameraPose = this.course.createPose()
527
+ this.cameraBehindPose = this.course.createPose()
528
+ this.cameraTargetPose = this.course.createPose()
529
+ this.latestSnapshot = {
530
+ phase: 'loading',
531
+ paused: false,
532
+ countdown: Math.ceil(this.countdownSecondsRemaining),
533
+ elapsedMs: 0,
534
+ simulationTick: 0,
535
+ replayFingerprint: this.replayFingerprint,
536
+ standings: [],
537
+ }
538
+ if (options.rollingAudioGraphEnabled !== false) {
539
+ this.rollingAudio = new RaceRollingAudio(
540
+ this.configuration.assignments.length,
541
+ options.rollingAudioEnabled,
542
+ options.rollingAudioGain,
543
+ options.collisionAudioGain,
544
+ {
545
+ water: this.waterPieceIds.size > 0,
546
+ portals: this.track.pieces.some(piece => piece.type === 'portal'),
547
+ rebounds: this.track.pieces.some(piece => piece.type === 'rebounds'),
548
+ risingWalls: this.track.pieces.some(piece => piece.type === 'rising-wall-forest'),
549
+ cones: this.track.pieces.some(piece => piece.type === 'peg-field' && piece.colliderShape !== 'cylinder'),
550
+ flippers: this.track.pieces.some(piece => piece.type === 'flipper-bank'),
551
+ },
552
+ options.rollingAudioContext,
553
+ )
554
+ }
555
+ this.emitCameraChange()
556
+ }
557
+
558
+ async start(): Promise<void> {
559
+ const startupStartedAt = performance.now()
560
+ let phaseStartedAt: number
561
+ const timings: RaceStartupDiagnostics = {
562
+ physicsRuntimeInitializationMs: 0,
563
+ physicsTrackPreparationMs: 0,
564
+ physicsSimulationConstructionMs: 0,
565
+ visualQueryWorldCreationMs: 0,
566
+ physicsInitializationMs: 0,
567
+ trackColliderAttachmentMs: 0,
568
+ trackConstructionMs: 0,
569
+ trackRuntimeAssemblyMs: 0,
570
+ trackShadowBatchingMs: 0,
571
+ materialUploadMs: 0,
572
+ marbleConstructionMs: 0,
573
+ marbleAssetsMs: 0,
574
+ marbleGpuPreparationMs: 0,
575
+ initialCatchUpMs: 0,
576
+ initialCatchUpTicks: 0,
577
+ cameraPlanningMs: 0,
578
+ totalMs: 0,
579
+ reusedCanonicalAssembly: this.canonicalAssembly !== undefined,
580
+ reusedPreparedSimulation: this.preparedSimulation !== undefined,
581
+ visualTrackConstructionMs: 0,
582
+ }
583
+ try {
584
+ const synchronizedElapsedBeforeWorldBuild = this.beforeInitialSynchronization
585
+ ? undefined
586
+ : this.initialSimulationElapsedMs?.()
587
+ if (
588
+ this.introEnabled &&
589
+ synchronizedElapsedBeforeWorldBuild !== undefined &&
590
+ synchronizedElapsedBeforeWorldBuild < 0
591
+ ) {
592
+ this.phase = 'touring'
593
+ this.beginCameraTour()
594
+ this.synchronizeCameraTour(synchronizedElapsedBeforeWorldBuild, this.initialIntroElapsedMs?.())
595
+ this.writeDefaultCameraPose()
596
+ this.applyImmediateCameraPose()
597
+ this.emitSnapshot(true)
598
+ }
599
+ this.configureControls()
600
+ phaseStartedAt = performance.now()
601
+ await initializeRacePhysics()
602
+ timings.physicsRuntimeInitializationMs = performance.now() - phaseStartedAt
603
+ const preparedSimulation = this.preparedSimulation
604
+ if (preparedSimulation) {
605
+ this.simulation = preparedSimulation
606
+ this.preparedSimulation = undefined
607
+ } else {
608
+ phaseStartedAt = performance.now()
609
+ const preparedPhysicsTrack = this.preparedPhysicsTrack ?? preparePhysicsTrack(this.track)
610
+ timings.physicsTrackPreparationMs = performance.now() - phaseStartedAt
611
+ phaseStartedAt = performance.now()
612
+ this.simulation = await createPreparedMarbleRaceSimulation(
613
+ this.configuration,
614
+ this.track,
615
+ preparedPhysicsTrack,
616
+ this.diagnosticsEnabled,
617
+ )
618
+ timings.physicsSimulationConstructionMs = performance.now() - phaseStartedAt
619
+ }
620
+ this.preparedPhysicsTrack = undefined
621
+ this.authoritativeSnapshot = this.simulation.snapshot()
622
+ this.queryWorld = this.simulation.queryWorld()
623
+ this.replayFingerprint = this.authoritativeSnapshot.replayFingerprint
624
+ timings.physicsInitializationMs =
625
+ timings.physicsRuntimeInitializationMs +
626
+ timings.physicsTrackPreparationMs +
627
+ timings.physicsSimulationConstructionMs
628
+
629
+ if (this.disposed) return
630
+
631
+ phaseStartedAt = performance.now()
632
+ this.world ??= createRacePhysicsWorld()
633
+ timings.visualQueryWorldCreationMs = performance.now() - phaseStartedAt
634
+ if (this.ownsSurface) {
635
+ this.configureRenderer()
636
+ this.themeController = new SceneThemeController(this.scene, this.renderer, this.container)
637
+ this.themeController.setVisualQualityPolicy(this.visualQualityPolicy)
638
+ const themeResolution = resolveTrackTheme(this.track.themeId ?? this.track.skin.id)
639
+ if (themeResolution.usedFallback) {
640
+ console.warn(`[marble-race] Unknown theme "${themeResolution.requestedId}", using Neutral Studio.`)
641
+ }
642
+ await this.themeController.applyTheme(themeResolution.theme)
643
+ }
644
+ if (!this.canonicalAssembly && this.track.sourceDocument) {
645
+ phaseStartedAt = performance.now()
646
+ const materials = this.sharedMaterials ?? this.themeController?.materials
647
+ if (!materials) throw new Error('Canonical track construction requires a material registry')
648
+ const assembly = createCanonicalTrackAssembly(this.track.sourceDocument, materials)
649
+ this.ownedCanonicalAssembly = assembly
650
+ this.canonicalAssembly = assembly
651
+ this.visualRoot.add(assembly.root)
652
+ timings.visualTrackConstructionMs = performance.now() - phaseStartedAt
653
+ }
654
+ phaseStartedAt = performance.now()
655
+ const trackConstructionStartedAt = phaseStartedAt
656
+ // Camera collision queries use the already-authoritative simulation
657
+ // world. Reattaching the same 20 MB of static trimeshes to the unstepped
658
+ // render mirror doubled startup work and retained duplicate WASM data.
659
+ const preparedCanonicalColliderHandles = this.canonicalAssembly ? new Map<string, number>() : undefined
660
+ timings.trackColliderAttachmentMs = performance.now() - phaseStartedAt
661
+ if (this.disposed) return
662
+ phaseStartedAt = performance.now()
663
+ this.trackWorld = buildTrackWorld(
664
+ this.visualRoot,
665
+ this.world,
666
+ this.track,
667
+ this.tuning,
668
+ this.sharedMaterials ?? this.themeController?.materials,
669
+ {
670
+ deferStaticShadowBatching: this.deferStaticShadowBatching,
671
+ canonicalAssembly: this.canonicalAssembly,
672
+ preparedCanonicalColliderHandles,
673
+ rankingSlotCount: this.configuration.marbleCount,
674
+ visualQualityPolicy: this.visualQualityPolicy,
675
+ },
676
+ )
677
+ timings.trackRuntimeAssemblyMs = performance.now() - phaseStartedAt
678
+ timings.trackConstructionMs = performance.now() - trackConstructionStartedAt
679
+ if (this.deferStaticShadowBatching) {
680
+ // Give the interactive race and its first rendered frame priority over
681
+ // this optional, yielded render optimization.
682
+ const prepareShadows = (): void => {
683
+ if (this.disposed) return
684
+ void this.trackWorld?.prepare().catch(error => {
685
+ console.warn('[marble-race] Static shadow batching could not be completed.', error)
686
+ })
687
+ }
688
+ const idleWindow = window as Window & {
689
+ requestIdleCallback?: (callback: () => void, options?: { timeout: number }) => number
690
+ }
691
+ if (idleWindow.requestIdleCallback) {
692
+ idleWindow.requestIdleCallback(prepareShadows, { timeout: DEFERRED_LAB_SHADOW_BATCH_TIMEOUT_MS })
693
+ } else {
694
+ window.setTimeout(prepareShadows, DEFERRED_LAB_SHADOW_BATCH_TIMEOUT_MS)
695
+ }
696
+ } else {
697
+ await this.trackWorld.prepare()
698
+ }
699
+ timings.trackShadowBatchingMs = Number(this.trackWorld.root.userData.staticShadowBatchingMs ?? 0)
700
+ phaseStartedAt = performance.now()
701
+ // Shared-surface Lab/race scenes already uploaded this registry while
702
+ // rendering the editor. Reinitialising every texture here adds hundreds
703
+ // of milliseconds without creating any new GPU resource.
704
+ if (!this.sharedMaterials) await this.themeController?.materials.preUpload(this.renderer)
705
+ timings.materialUploadMs = performance.now() - phaseStartedAt
706
+ if (this.disposed) return
707
+ phaseStartedAt = performance.now()
708
+ this.marbles = this.configuration.assignments.map((assignment, index) =>
709
+ createMarbleEntity(
710
+ this.marbleRoot,
711
+ this.world as RAPIER.World,
712
+ this.course,
713
+ assignment,
714
+ this.tuning.marbles[index]!,
715
+ this.marbleVisualStyle,
716
+ ),
717
+ )
718
+ this.marblesBySlot.clear()
719
+ this.marbleIndexBySlot.clear()
720
+ this.rollingAudioWaterStates.length = this.marbles.length
721
+ this.rollingAudioGroundedStates.length = this.marbles.length
722
+ for (let index = 0; index < this.marbles.length; index += 1) {
723
+ const marble = this.marbles[index]!
724
+ this.marblesBySlot.set(marble.assignment.slotId, marble)
725
+ this.marbleIndexBySlot.set(marble.assignment.slotId, index)
726
+ this.rollingAudioWaterStates[index] = false
727
+ this.rollingAudioGroundedStates[index] = false
728
+ }
729
+ if (!this.authoritativeSnapshot) throw new Error('Authoritative simulation snapshot is unavailable')
730
+ this.applyAuthoritativeSnapshot(this.authoritativeSnapshot)
731
+ this.simulationRenderer = new RaceSimulationRenderer({
732
+ marbleObjects: new Map(),
733
+ mechanismPartObjects: bindMechanismPartObjects(this.visualRoot, this.authoritativeSnapshot),
734
+ })
735
+ this.simulationRenderer.push(this.authoritativeSnapshot)
736
+ timings.marbleConstructionMs = performance.now() - phaseStartedAt
737
+ phaseStartedAt = performance.now()
738
+ await Promise.all(this.marbles.map(marble => marble.visualReady))
739
+ this.marbleRoot.traverse(object => {
740
+ if (object instanceof THREE.Mesh) object.castShadow = false
741
+ })
742
+ timings.marbleAssetsMs = performance.now() - phaseStartedAt
743
+ if (this.disposed) return
744
+ phaseStartedAt = performance.now()
745
+ const raceVisualTextures = new Set<THREE.Texture>()
746
+ this.marbleRoot.traverse(object => {
747
+ if (!(object instanceof THREE.Mesh || object instanceof THREE.Line || object instanceof THREE.Sprite)) return
748
+ const materials = Array.isArray(object.material) ? object.material : [object.material]
749
+ for (const material of materials) {
750
+ for (const value of Object.values(material)) {
751
+ if (value instanceof THREE.Texture) raceVisualTextures.add(value)
752
+ }
753
+ }
754
+ })
755
+ raceVisualTextures.forEach(texture => this.renderer.initTexture(texture))
756
+ // Final lighting can differ from betting-time/Lab preparation. Include
757
+ // the canonical track and marbles before anchoring the live race clock.
758
+ await prepareSceneShaders(
759
+ this.renderer,
760
+ this.ownsSurface ? this.scene : this.visualRoot,
761
+ this.camera,
762
+ this.scene,
763
+ {
764
+ visibleRoot: this.visualRoot,
765
+ shouldContinue: () => !this.disposed,
766
+ includeVisibleScene: true,
767
+ },
768
+ )
769
+ await this.shadowShaderPreparation.prepare(this.renderer, this.visualRoot, this.camera, this.scene)
770
+ timings.marbleGpuPreparationMs = performance.now() - phaseStartedAt
771
+ if (this.disposed) return
772
+ await this.beforeInitialSynchronization?.()
773
+ if (this.disposed) return
774
+ this.worldReady = true
775
+ const synchronized = this.initialSimulationElapsedMs !== undefined
776
+ const initialElapsedMs = this.initialSimulationElapsedMs?.() ?? 0
777
+ this.phase = this.introEnabled ? 'touring' : this.countdownSecondsRemaining > 0 ? 'countdown' : 'racing'
778
+ if (this.phase === 'countdown') {
779
+ if (synchronized && !this.introEnabled && initialElapsedMs < 0) this.synchronizeCountdown(initialElapsedMs)
780
+ else this.emitCountdownCue(Math.ceil(this.countdownSecondsRemaining))
781
+ }
782
+ if (synchronized && initialElapsedMs >= 0) {
783
+ this.countdownSecondsRemaining = 0
784
+ this.phase = 'racing'
785
+ this.suppressSnapshots = true
786
+ this.initialCatchUpActive = true
787
+ const catchUpStartedAt = performance.now()
788
+ const catchUpStartedAtTick = this.simulationTick
789
+ let sliceStartedAt = performance.now()
790
+ let catchUpSnapshot: RaceSimulationSnapshot | undefined
791
+ try {
792
+ while (this.phase === 'racing') {
793
+ // Re-read the authoritative clock after every yielded slice. A
794
+ // one-shot target is already stale by the time a long late-join
795
+ // catch-up finishes and visibly leaves the viewer behind.
796
+ const targetTicks = Math.min(
797
+ MAX_RACE_TICKS + this.finishCoastTicks,
798
+ Math.floor((this.initialSimulationElapsedMs?.() ?? initialElapsedMs) / (FIXED_TIMESTEP * 1_000)),
799
+ )
800
+ if (this.simulationTick >= targetTicks) break
801
+ // Late join only exposes the final synchronized pose. Advancing
802
+ // the authoritative core without materialising a snapshot on
803
+ // every hidden tick preserves the exact 120 Hz simulation while
804
+ // avoiding hundreds of temporary snapshots, mirror-body writes,
805
+ // course projections, and renderer interpolation updates.
806
+ // Let the final hidden tick capture contacts once. That keeps the
807
+ // next visible tick from treating every ongoing contact as a new
808
+ // collision (and playing a false impact sound) while still
809
+ // avoiding per-tick snapshots throughout the catch-up.
810
+ if (this.simulationTick + 1 >= targetTicks) {
811
+ catchUpSnapshot = this.simulation.step()
812
+ } else {
813
+ this.simulation.advance()
814
+ catchUpSnapshot = undefined
815
+ }
816
+ this.simulationTick = this.simulation.simulationTick
817
+ if (this.simulation.complete) {
818
+ this.completeRace()
819
+ break
820
+ }
821
+ if (performance.now() - sliceStartedAt >= INITIAL_CATCH_UP_SLICE_MS) {
822
+ await yieldToBrowser()
823
+ if (this.disposed) return
824
+ sliceStartedAt = performance.now()
825
+ }
826
+ }
827
+ const synchronizedSnapshot = catchUpSnapshot ?? this.simulation.snapshot()
828
+ this.applyAuthoritativeSnapshot(synchronizedSnapshot)
829
+ this.simulationRenderer?.push(synchronizedSnapshot)
830
+ this.simulation.drainEvents()
831
+ } finally {
832
+ this.initialCatchUpActive = false
833
+ }
834
+ timings.initialCatchUpMs = performance.now() - catchUpStartedAt
835
+ timings.initialCatchUpTicks = this.simulationTick - catchUpStartedAtTick
836
+ this.suppressSnapshots = false
837
+ this.marbles.forEach(captureMarblePhysicsTransform)
838
+ this.syncVisuals()
839
+ }
840
+ this.cameraProgress = THREE.MathUtils.clamp(this.cameraSubject()?.bestProgress ?? 0, 0, this.course.totalLength)
841
+ if (this.phase === 'touring') {
842
+ phaseStartedAt = performance.now()
843
+ if (!this.cameraTourPlan) this.beginCameraTour()
844
+ if (synchronized) {
845
+ this.synchronizeCameraTour(initialElapsedMs, this.initialIntroElapsedMs?.())
846
+ this.writeDefaultCameraPose()
847
+ this.applyImmediateCameraPose()
848
+ }
849
+ timings.cameraPlanningMs = performance.now() - phaseStartedAt
850
+ } else this.resetCamera()
851
+ this.emitSnapshot(true)
852
+ timings.totalMs = performance.now() - startupStartedAt
853
+ this.startupDiagnostics = timings
854
+ if (this.diagnosticsEnabled) {
855
+ this.installTestHooks()
856
+ }
857
+ if (this.ownsSurface) this.animationFrame = requestAnimationFrame(this.frame)
858
+ } catch (error) {
859
+ if (this.disposed) return
860
+ this.phase = 'error'
861
+ this.latestSnapshot = {
862
+ ...this.latestSnapshot,
863
+ phase: 'error',
864
+ error: error instanceof Error ? error.message : 'The race engine failed to start',
865
+ }
866
+ this.rollingAudio?.setEnabled(false)
867
+ this.onSnapshot(this.latestSnapshot)
868
+ }
869
+ }
870
+
871
+ getStartupDiagnostics(): RaceStartupDiagnostics | undefined {
872
+ return this.startupDiagnostics ? { ...this.startupDiagnostics } : undefined
873
+ }
874
+
875
+ dispose(): void {
876
+ this.lowQualityRenderer.dispose()
877
+ this.shadowShaderPreparation.dispose()
878
+ this.disposed = true
879
+ cancelAnimationFrame(this.animationFrame)
880
+ cancelAnimationFrame(this.resizeFrame)
881
+ this.resizeObserver?.disconnect()
882
+ this.controls?.removeEventListener('start', this.markCameraAsUserControlled)
883
+ this.controls?.removeEventListener('change', this.handleCameraControlsChange)
884
+ this.controls?.removeEventListener('end', this.finishCameraInteraction)
885
+ this.renderer.domElement.removeEventListener('pointerdown', this.handleCameraPointerDown, true)
886
+ this.renderer.domElement.removeEventListener('pointerup', this.handleCameraPointerUp, true)
887
+ this.renderer.domElement.removeEventListener('pointercancel', this.handleCameraPointerUp, true)
888
+ this.renderer.domElement.removeEventListener('wheel', this.handleCameraWheel, true)
889
+ this.controls?.dispose()
890
+ this.marbleFocusRenderer?.dispose()
891
+ this.trackWorld?.dispose()
892
+ this.themeController?.dispose()
893
+ this.rollingAudio?.dispose()
894
+ this.marbles.forEach(marble => marble.disposeVisual())
895
+ disposeObject(this.marbleRoot)
896
+ this.marbleRoot.removeFromParent()
897
+ if (!this.preservesCanonicalAssembly) {
898
+ this.ownedCanonicalAssembly?.dispose()
899
+ this.ownedCanonicalAssembly = undefined
900
+ disposeObject(this.visualRoot)
901
+ this.visualRoot.removeFromParent()
902
+ }
903
+ if (this.ownsSurface) {
904
+ this.renderer.setAnimationLoop(null)
905
+ this.renderer.domElement.remove()
906
+ this.renderer.dispose()
907
+ }
908
+ this.simulationRenderer?.clear()
909
+ this.queryWorld = undefined
910
+ this.simulation?.dispose()
911
+ this.simulation = undefined
912
+ this.preparedSimulation?.dispose()
913
+ this.preparedSimulation = undefined
914
+ this.world?.free()
915
+ this.world = undefined
916
+ this.preparedPhysicsTrack = undefined
917
+
918
+ if (window.__MARBLE_RACE_TEST_HOOKS__?.getSnapshot === this.getSnapshot) {
919
+ delete window.__MARBLE_RACE_TEST_HOOKS__
920
+ delete window.__THREE_GAME_DIAGNOSTICS__
921
+ }
922
+ }
923
+
924
+ resetCamera(): void {
925
+ this.cameraTargetSlotId = undefined
926
+ this.cameraLeaderSlotId = undefined
927
+ this.cameraView = 'race'
928
+ this.cameraControl = 'locked'
929
+ this.focusOrbitActive = false
930
+ this.cameraPackTracker.reset()
931
+ this.cameraPackTracker.update(this.marbles, this.cameraPackFrame, 0)
932
+ this.cameraPackDiscontinuityPending = false
933
+ this.cameraInteractionActive = false
934
+ this.cameraReturnLockPending = false
935
+ this.cameraReturnNeedsControlsSync = false
936
+ this.cameraReturnCurve.cancel()
937
+ this.beginCameraTargetHandoff()
938
+ this.writeDefaultCameraPose()
939
+ if (!this.ownsSurface) {
940
+ this.visualRoot.updateMatrixWorld(true)
941
+ this.visualRoot.localToWorld(this.desiredCameraPosition)
942
+ this.visualRoot.localToWorld(this.desiredCameraTarget)
943
+ }
944
+ if (this.phase !== 'racing' || !this.cameraDirector.isInitialized()) {
945
+ this.camera.position.copy(this.desiredCameraPosition)
946
+ this.cameraTarget.copy(this.desiredCameraTarget)
947
+ this.camera.fov = this.desiredCameraFov
948
+ this.camera.updateProjectionMatrix()
949
+ this.controls?.target.copy(this.cameraTarget)
950
+ this.camera.up.copy(THREE.Object3D.DEFAULT_UP)
951
+ this.camera.lookAt(this.cameraTarget)
952
+ }
953
+ this.cameraDirector.synchronize(this.camera.position, this.controls?.target ?? this.cameraTarget)
954
+ this.updateControlsEnabled()
955
+ this.emitCameraChange()
956
+ }
957
+
958
+ resetCameraAdjustment(): void {
959
+ if (this.cameraControl === 'locked') return
960
+ this.requestCameraReturn()
961
+ }
962
+
963
+ snapCameraToTourOpening(): void {
964
+ if (!this.introEnabled) return
965
+ this.writeCameraTourOpeningPose()
966
+ this.applyImmediateCameraPose()
967
+ }
968
+
969
+ private beginCameraTour(): void {
970
+ this.cameraControl = 'locked'
971
+ this.focusOrbitActive = false
972
+ this.cameraReturnCurve.cancel()
973
+ this.cameraReturnLockPending = false
974
+ this.cameraReturnNeedsControlsSync = false
975
+ this.cameraTourElapsedSeconds = 0
976
+ this.buildCameraTourPlan()
977
+ this.writeDefaultCameraPose()
978
+ this.applyImmediateCameraPose()
979
+ this.updateControlsEnabled()
980
+ }
981
+
982
+ private applyImmediateCameraPose(): void {
983
+ if (!this.ownsSurface) {
984
+ this.visualRoot.updateMatrixWorld(true)
985
+ this.visualRoot.localToWorld(this.desiredCameraPosition)
986
+ this.visualRoot.localToWorld(this.desiredCameraTarget)
987
+ }
988
+ // This happens while Test Run is still being prepared, so the first
989
+ // presented frame is already a deliberate finish-line composition.
990
+ this.camera.position.copy(this.desiredCameraPosition)
991
+ this.cameraTarget.copy(this.desiredCameraTarget)
992
+ this.controls?.target.copy(this.cameraTarget)
993
+ this.camera.fov = this.desiredCameraFov
994
+ this.camera.updateProjectionMatrix()
995
+ this.camera.up.copy(THREE.Object3D.DEFAULT_UP)
996
+ this.camera.lookAt(this.cameraTarget)
997
+ this.cameraDirector.synchronize(this.camera.position, this.cameraTarget)
998
+ }
999
+
1000
+ private beginCameraStaging(): void {
1001
+ this.cameraControl = 'locked'
1002
+ this.focusOrbitActive = false
1003
+ this.cameraReturnCurve.cancel()
1004
+ this.cameraReturnLockPending = false
1005
+ this.cameraReturnNeedsControlsSync = false
1006
+ this.cameraStagingSettledSeconds = 0
1007
+ this.writeDefaultCameraPose()
1008
+ if (!this.ownsSurface) {
1009
+ this.visualRoot.updateMatrixWorld(true)
1010
+ this.visualRoot.localToWorld(this.desiredCameraPosition)
1011
+ this.visualRoot.localToWorld(this.desiredCameraTarget)
1012
+ }
1013
+ // The tour's final pose is authored from this exact start-line pose. Keep
1014
+ // the already-presented camera untouched so staging is a hold, not a
1015
+ // second camera move disguised as obstruction recovery.
1016
+ this.cameraTarget.copy(this.controls?.target ?? this.cameraTarget)
1017
+ this.controls?.target.copy(this.cameraTarget)
1018
+ this.cameraDirector.synchronize(this.camera.position, this.controls?.target ?? this.cameraTarget)
1019
+ this.updateControlsEnabled()
1020
+ this.emitCameraChange()
1021
+ }
1022
+
1023
+ followMarble(slotId: string): void {
1024
+ const marble = this.marbles.find(candidate => candidate.assignment.slotId === slotId)
1025
+ if (!marble) {
1026
+ return
1027
+ }
1028
+
1029
+ this.cameraTargetSlotId = slotId
1030
+ this.cameraProgress = marble.bestProgress
1031
+ this.cameraView = 'focus'
1032
+ this.cameraControl = 'locked'
1033
+ this.focusOrbitActive = false
1034
+ this.cameraPackDiscontinuityPending = false
1035
+ void this.ensureMarbleFocusRenderer()
1036
+ this.cameraInteractionActive = false
1037
+ this.cameraReturnLockPending = false
1038
+ this.cameraReturnNeedsControlsSync = false
1039
+ this.cameraReturnCurve.cancel()
1040
+ this.beginCameraTargetHandoff()
1041
+ this.cameraDirector.synchronize(this.camera.position, this.controls?.target ?? this.cameraTarget)
1042
+ this.updateControlsEnabled()
1043
+ this.emitCameraChange()
1044
+ }
1045
+
1046
+ private beginCameraTargetHandoff(): void {
1047
+ this.cameraTargetHandoffPending = true
1048
+ this.cameraTargetHandoffSeconds = 0
1049
+ }
1050
+
1051
+ setPaused(paused: boolean): void {
1052
+ if (this.disposed || this.paused === paused || this.phase === 'finished' || this.phase === 'error') {
1053
+ return
1054
+ }
1055
+
1056
+ this.paused = paused
1057
+ this.emitSnapshot(true)
1058
+ }
1059
+
1060
+ setInteractionEnabled(enabled: boolean): void {
1061
+ if (enabled && !this.interactionEnabled) {
1062
+ this.beginCameraTargetHandoff()
1063
+ this.cameraDirector.synchronize(this.camera.position, this.controls?.target ?? this.cameraTarget)
1064
+ }
1065
+ this.interactionEnabled = enabled
1066
+ this.updateControlsEnabled()
1067
+ }
1068
+
1069
+ skipIntro(): void {
1070
+ if (this.phase !== 'touring' && this.phase !== 'staging' && this.phase !== 'countdown') return
1071
+
1072
+ this.paused = false
1073
+ this.phase = 'staging'
1074
+ this.cameraProgress = THREE.MathUtils.clamp(this.track.spawnProgress, 0, this.course.totalLength)
1075
+ this.writeDefaultCameraPose()
1076
+ if (!this.ownsSurface) {
1077
+ this.visualRoot.updateMatrixWorld(true)
1078
+ this.visualRoot.localToWorld(this.desiredCameraPosition)
1079
+ this.visualRoot.localToWorld(this.desiredCameraTarget)
1080
+ }
1081
+ this.cameraWorldUp.copy(THREE.Object3D.DEFAULT_UP)
1082
+ this.camera.position.copy(this.desiredCameraPosition)
1083
+ this.cameraTarget.copy(this.desiredCameraTarget)
1084
+ this.controls?.target.copy(this.cameraTarget)
1085
+ this.camera.fov = this.desiredCameraFov
1086
+ this.camera.updateProjectionMatrix()
1087
+ this.camera.up.copy(THREE.Object3D.DEFAULT_UP)
1088
+ this.camera.lookAt(this.cameraTarget)
1089
+ this.cameraDirector.synchronize(this.camera.position, this.cameraTarget)
1090
+ this.countdownSecondsRemaining = 0
1091
+ this.accumulator = 0
1092
+ this.phase = 'racing'
1093
+ this.updateControlsEnabled()
1094
+ this.emitRaceStartCue()
1095
+ this.emitSnapshot(true)
1096
+ }
1097
+
1098
+ setPresentationProgress(progress: number): void {
1099
+ const opacity = THREE.MathUtils.smoothstep(THREE.MathUtils.clamp(progress, 0, 1), 0, 1)
1100
+ this.visualRoot.visible = opacity > 0.002
1101
+ this.visualRoot.traverse(object => {
1102
+ if (
1103
+ !(
1104
+ object instanceof THREE.Mesh ||
1105
+ object instanceof THREE.Line ||
1106
+ object instanceof THREE.Points ||
1107
+ object instanceof THREE.Sprite
1108
+ ) ||
1109
+ !object.material
1110
+ ) {
1111
+ return
1112
+ }
1113
+ const materials = Array.isArray(object.material) ? object.material : [object.material]
1114
+ for (const material of materials) {
1115
+ let state = this.presentationMaterialStates.get(material)
1116
+ if (!state) {
1117
+ const themeOpacity = material.userData.themeBaseOpacity
1118
+ const themeTransparent = material.userData.themeBaseTransparent
1119
+ const themeDepthWrite = material.userData.themeBaseDepthWrite
1120
+ state = {
1121
+ depthWrite: typeof themeDepthWrite === 'boolean' ? themeDepthWrite : material.depthWrite,
1122
+ opacity: typeof themeOpacity === 'number' ? themeOpacity : material.opacity,
1123
+ transparent: typeof themeTransparent === 'boolean' ? themeTransparent : material.transparent,
1124
+ }
1125
+ this.presentationMaterialStates.set(material, state)
1126
+ }
1127
+ const fading = opacity < 0.999
1128
+ const transparent = state.transparent || fading
1129
+ if (material.transparent !== transparent) {
1130
+ material.transparent = transparent
1131
+ material.needsUpdate = true
1132
+ }
1133
+ material.depthWrite = fading ? false : state.depthWrite
1134
+ material.opacity = state.opacity * opacity
1135
+ }
1136
+ })
1137
+ }
1138
+
1139
+ togglePause(): void {
1140
+ this.setPaused(!this.paused)
1141
+ }
1142
+
1143
+ setTimeScale(timeScale: number): void {
1144
+ if (this.disposed || !Number.isFinite(timeScale)) return
1145
+ this.timeScale = THREE.MathUtils.clamp(timeScale, MIN_RACE_TIME_SCALE, MAX_RACE_TIME_SCALE)
1146
+ }
1147
+
1148
+ setVisualQualityPolicy(policy: VisualQualityPolicy): void {
1149
+ if (this.disposed || this.visualQualityPolicy === policy) return
1150
+ this.visualQualityPolicy = policy
1151
+ if (this.ownsSurface) this.renderer.domElement.dataset.visualQuality = policy.tier
1152
+ this.nextVisualFrameTime = 0
1153
+ this.trackWorld?.setVisualQualityPolicy(policy)
1154
+ this.themeController?.setVisualQualityPolicy(policy)
1155
+ if (this.ownsSurface) this.resize()
1156
+ }
1157
+
1158
+ setRollingAudioEnabled(enabled: boolean): void {
1159
+ this.rollingAudio?.setEnabled(enabled)
1160
+ }
1161
+
1162
+ private configureRenderer(): void {
1163
+ configureSceneRenderer(this.renderer)
1164
+ this.renderer.domElement.dataset.visualQuality = this.visualQualityPolicy.tier
1165
+ this.renderer.domElement.className = 'race-canvas block size-full touch-none transition-opacity duration-500'
1166
+ this.renderer.domElement.setAttribute('aria-label', 'Deterministic 3D marble race')
1167
+ this.container.appendChild(this.renderer.domElement)
1168
+
1169
+ this.resizeObserver = new ResizeObserver(this.scheduleResize)
1170
+ this.resizeObserver.observe(this.container)
1171
+ this.resize()
1172
+ }
1173
+
1174
+ private configureControls(): void {
1175
+ this.controls = new OrbitControls(this.camera, this.renderer.domElement)
1176
+ this.controls.enableDamping = true
1177
+ this.controls.dampingFactor = 0.08
1178
+ this.controls.enablePan = false
1179
+ this.controls.rotateSpeed = 0.8
1180
+ this.controls.panSpeed = 0.72
1181
+ this.controls.zoomSpeed = 1
1182
+ this.cameraFreeMinimumDistance = this.ownsSurface ? 4.5 : 0.35
1183
+ this.controls.minDistance = this.cameraFreeMinimumDistance
1184
+ this.controls.maxDistance = this.ownsSurface ? 280 : 28
1185
+ this.controls.minPolarAngle = 0.16
1186
+ this.controls.maxPolarAngle = Math.PI * 0.49
1187
+ this.controls.touches.ONE = THREE.TOUCH.ROTATE
1188
+ this.controls.touches.TWO = THREE.TOUCH.DOLLY_ROTATE
1189
+ this.updateControlsEnabled()
1190
+ this.controls.addEventListener('start', this.markCameraAsUserControlled)
1191
+ this.controls.addEventListener('change', this.handleCameraControlsChange)
1192
+ this.controls.addEventListener('end', this.finishCameraInteraction)
1193
+ this.renderer.domElement.addEventListener('pointerdown', this.handleCameraPointerDown, true)
1194
+ this.renderer.domElement.addEventListener('pointerup', this.handleCameraPointerUp, true)
1195
+ this.renderer.domElement.addEventListener('pointercancel', this.handleCameraPointerUp, true)
1196
+ this.renderer.domElement.addEventListener('wheel', this.handleCameraWheel, { capture: true, passive: true })
1197
+ }
1198
+
1199
+ private updateControlsEnabled(): void {
1200
+ if (this.controls) {
1201
+ this.controls.enabled =
1202
+ this.interactionEnabled &&
1203
+ this.phase !== 'touring' &&
1204
+ this.phase !== 'staging' &&
1205
+ this.phase !== 'countdown'
1206
+ }
1207
+ }
1208
+
1209
+ private readonly resize = (): void => {
1210
+ const width = Math.max(1, this.container.clientWidth)
1211
+ const height = Math.max(1, this.container.clientHeight)
1212
+ const dpr = Math.min(
1213
+ window.devicePixelRatio || 1,
1214
+ (usesMobileRenderingBudget(width) ? 1.2 : 1.5) * this.visualQualityPolicy.pixelRatioScale,
1215
+ )
1216
+ this.camera.aspect = width / height
1217
+ this.camera.updateProjectionMatrix()
1218
+ if (resizeRendererDrawingBuffer(this.renderer, width, height, dpr)) {
1219
+ this.lowQualityRenderer.render(this.renderer, this.scene, this.camera, this.visualQualityPolicy)
1220
+ }
1221
+ }
1222
+
1223
+ private readonly scheduleResize = (): void => {
1224
+ if (this.resizeFrame !== 0 || this.disposed) return
1225
+ this.resizeFrame = requestAnimationFrame(() => {
1226
+ this.resizeFrame = 0
1227
+ this.resize()
1228
+ })
1229
+ }
1230
+
1231
+ private readonly frame = (time: number): void => {
1232
+ if (this.disposed) {
1233
+ return
1234
+ }
1235
+ this.animationFrame = requestAnimationFrame(this.frame)
1236
+ const nextFrame = nextVisualFrameDeadline(
1237
+ time,
1238
+ this.nextVisualFrameTime,
1239
+ this.visualQualityPolicy.visualFramesPerSecond,
1240
+ )
1241
+ if (nextFrame === undefined) return
1242
+ this.nextVisualFrameTime = nextFrame
1243
+
1244
+ try {
1245
+ const rawDeltaSeconds = this.previousFrameTime ? (time - this.previousFrameTime) / 1_000 : 0
1246
+ const deltaSeconds = Math.min(rawDeltaSeconds, 0.1)
1247
+ this.frameIntervalMs = rawDeltaSeconds * 1_000
1248
+ if (this.frameIntervalMs > 0) {
1249
+ this.frameIntervals[this.frameIntervalCursor] = this.frameIntervalMs
1250
+ this.frameIntervalCursor = (this.frameIntervalCursor + 1) % this.frameIntervals.length
1251
+ this.frameIntervalCount = Math.min(this.frameIntervalCount + 1, this.frameIntervals.length)
1252
+ }
1253
+ this.previousFrameTime = time
1254
+ this.update(deltaSeconds)
1255
+ this.prepareRender()
1256
+ this.render(deltaSeconds)
1257
+ if (this.diagnosticsEnabled && time - this.lastDiagnosticsWriteMs >= DIAGNOSTICS_INTERVAL_MS) {
1258
+ const diagnostics = this.getRendererDiagnostics()
1259
+ window.__THREE_GAME_DIAGNOSTICS__ = diagnostics
1260
+ this.renderer.domElement.dataset.diagnostics = JSON.stringify(diagnostics)
1261
+ this.lastDiagnosticsWriteMs = time
1262
+ }
1263
+ } catch (error) {
1264
+ cancelAnimationFrame(this.animationFrame)
1265
+ this.animationFrame = 0
1266
+ this.phase = 'error'
1267
+ this.latestSnapshot = {
1268
+ ...this.latestSnapshot,
1269
+ phase: 'error',
1270
+ error: error instanceof Error ? error.message : 'The simulation stopped unexpectedly',
1271
+ }
1272
+ this.onSnapshot(this.latestSnapshot)
1273
+ }
1274
+ }
1275
+
1276
+ // Loading and late-join catch-up intentionally expose no world frame yet.
1277
+ // Avoid allocating/updating portal render targets behind the cover while
1278
+ // the main course and Rapier world already hold the startup memory peak.
1279
+ prepareRender(): void {
1280
+ if (this.disposed || this.phase === 'loading' || this.initialCatchUpActive) return
1281
+ this.trackWorld?.prepareRender(this.renderer, this.scene, this.camera)
1282
+ }
1283
+
1284
+ render(deltaSeconds = 1 / 60): void {
1285
+ const timing = this.startFrameTiming
1286
+ if (!timing) return this.renderFrame(deltaSeconds)
1287
+ const startedAt = performance.now()
1288
+ try {
1289
+ this.renderFrame(deltaSeconds)
1290
+ } finally {
1291
+ timing.renderMs = performance.now() - startedAt
1292
+ this.startFrameTiming = undefined
1293
+ }
1294
+ }
1295
+
1296
+ private renderFrame(deltaSeconds: number): void {
1297
+ if (this.disposed || this.initialCatchUpActive || !Number.isFinite(deltaSeconds)) return
1298
+ if (
1299
+ this.visualQualityPolicy.tier !== 'low' &&
1300
+ this.cameraView === 'focus' &&
1301
+ this.cameraControl === 'locked' &&
1302
+ this.marbleFocusRenderer
1303
+ ) {
1304
+ this.marbleFocusRenderer.render(this.renderer, this.scene, this.camera, this.cameraFocusTarget())
1305
+ return
1306
+ }
1307
+ if (this.visualQualityPolicy.tier !== 'low' && this.cameraView === 'focus') void this.ensureMarbleFocusRenderer()
1308
+ this.lowQualityRenderer.render(this.renderer, this.scene, this.camera, this.visualQualityPolicy)
1309
+ }
1310
+
1311
+ private ensureMarbleFocusRenderer(): Promise<void> {
1312
+ if (this.marbleFocusRenderer) return Promise.resolve()
1313
+ if (this.marbleFocusRendererPromise) return this.marbleFocusRendererPromise
1314
+ this.marbleFocusRendererPromise = import('./race-marble-focus-renderer')
1315
+ .then(({ RaceMarbleFocusRenderer }) => {
1316
+ if (this.disposed) return
1317
+ this.marbleFocusRenderer = new RaceMarbleFocusRenderer()
1318
+ })
1319
+ .catch(error => {
1320
+ console.warn('[marble-race] Marble focus could not be initialized.', error)
1321
+ })
1322
+ .finally(() => {
1323
+ this.marbleFocusRendererPromise = undefined
1324
+ })
1325
+ return this.marbleFocusRendererPromise
1326
+ }
1327
+
1328
+ update(deltaSeconds: number): void {
1329
+ if (this.disposed || this.initialCatchUpActive) return
1330
+ // Bounded diagnostics around release expose first-use work without
1331
+ // changing the countdown, physics, or render ordering.
1332
+ const diagnostics = this.startupDiagnostics
1333
+ const capture =
1334
+ this.diagnosticsEnabled &&
1335
+ diagnostics &&
1336
+ ((this.phase === 'countdown' && this.countdownSecondsRemaining <= 1.2) ||
1337
+ (this.phase === 'racing' && this.simulationTick < 120))
1338
+ if (!capture) return this.updateFrame(deltaSeconds)
1339
+ const frames = (diagnostics.startFrames ??= [])
1340
+ if (frames.length >= 180) return this.updateFrame(deltaSeconds)
1341
+ const timing: RaceStartFrameTiming = {
1342
+ phase: this.phase,
1343
+ countdown: this.countdownSecondsRemaining,
1344
+ tick: this.simulationTick,
1345
+ timeMs: performance.now(),
1346
+ updateMs: 0,
1347
+ }
1348
+ try {
1349
+ this.updateFrame(deltaSeconds)
1350
+ } finally {
1351
+ timing.updateMs = performance.now() - timing.timeMs
1352
+ frames.push(timing)
1353
+ this.startFrameTiming = timing
1354
+ }
1355
+ }
1356
+
1357
+ private updateFrame(deltaSeconds: number): void {
1358
+ let physicsStepsRemaining = MAX_PHYSICS_STEPS_PER_FRAME
1359
+ let synchronizedBacklogTicks = 0
1360
+ // A shared Lab Test Run can be rendered while its world is still being
1361
+ // prepared. Keep the deliberately snapped opening composition untouched;
1362
+ // the normal race camera has no subject yet and would otherwise drift
1363
+ // toward its empty-course fallback before the tour begins.
1364
+ if (this.phase === 'loading') {
1365
+ this.recordPhysicsWork(physicsStepsRemaining, synchronizedBacklogTicks)
1366
+ this.syncVisuals()
1367
+ this.themeController?.update(deltaSeconds, this.camera, false, this.shadowFocusTarget())
1368
+ this.updateRollingAudio(deltaSeconds)
1369
+ return
1370
+ }
1371
+ const synchronizedElapsedMs = this.initialSimulationElapsedMs?.()
1372
+ if (synchronizedElapsedMs !== undefined && (this.phase === 'touring' || this.phase === 'countdown')) {
1373
+ if (this.introEnabled) this.synchronizeCameraTour(synchronizedElapsedMs, this.initialIntroElapsedMs?.())
1374
+ else this.synchronizeCountdown(synchronizedElapsedMs)
1375
+ if (synchronizedElapsedMs >= 0) this.finishCameraTour()
1376
+ }
1377
+ if (!this.worldReady) {
1378
+ this.recordPhysicsWork(physicsStepsRemaining, synchronizedBacklogTicks)
1379
+ this.syncVisuals()
1380
+ this.updateCamera(deltaSeconds)
1381
+ this.updateRollingAudio(deltaSeconds)
1382
+ return
1383
+ }
1384
+ if (synchronizedElapsedMs !== undefined && synchronizedElapsedMs < 0) {
1385
+ this.recordPhysicsWork(physicsStepsRemaining, synchronizedBacklogTicks)
1386
+ this.syncVisuals()
1387
+ this.updateCamera(deltaSeconds)
1388
+ this.themeController?.update(deltaSeconds, this.camera, false, this.shadowFocusTarget())
1389
+ this.updateRollingAudio(deltaSeconds)
1390
+ return
1391
+ }
1392
+ if (synchronizedElapsedMs !== undefined && this.phase === 'racing') {
1393
+ const exactTargetTick = synchronizedElapsedMs / (FIXED_TIMESTEP * 1_000)
1394
+ const targetTick = Math.min(MAX_RACE_TICKS + this.finishCoastTicks, Math.floor(exactTargetTick))
1395
+ this.synchronizedInterpolationAlpha = THREE.MathUtils.clamp(exactTargetTick - targetTick, 0, 1)
1396
+ if (targetTick > this.simulationTick) {
1397
+ this.suppressSnapshots = true
1398
+ while (this.simulationTick < targetTick && this.phase === 'racing' && physicsStepsRemaining > 0) {
1399
+ this.fixedStep()
1400
+ physicsStepsRemaining -= 1
1401
+ }
1402
+ this.suppressSnapshots = false
1403
+ synchronizedBacklogTicks = Math.max(0, targetTick - this.simulationTick)
1404
+ }
1405
+ }
1406
+ if (this.phase === 'countdown') {
1407
+ const overlappingTour =
1408
+ this.cameraTourPlan !== undefined && this.cameraTourElapsedSeconds < this.cameraTourPlan.durationSeconds
1409
+ const tourComplete = overlappingTour && !this.paused ? this.advanceCameraTour(deltaSeconds) : false
1410
+ if (!overlappingTour && !this.paused) this.advanceCountdown(deltaSeconds)
1411
+ this.recordPhysicsWork(physicsStepsRemaining, synchronizedBacklogTicks)
1412
+ this.syncVisuals()
1413
+ this.updateCamera(deltaSeconds)
1414
+ if (tourComplete) this.finishCameraTour()
1415
+ this.themeController?.update(deltaSeconds, this.camera, false, this.shadowFocusTarget())
1416
+ this.updateRollingAudio(deltaSeconds)
1417
+ return
1418
+ }
1419
+ if (this.phase === 'touring') {
1420
+ const tourComplete = !this.paused && this.advanceCameraTour(deltaSeconds)
1421
+ this.recordPhysicsWork(physicsStepsRemaining, synchronizedBacklogTicks)
1422
+ this.syncVisuals()
1423
+ this.updateCamera(deltaSeconds)
1424
+ if (tourComplete) this.finishCameraTour()
1425
+ this.themeController?.update(deltaSeconds, this.camera, false, this.shadowFocusTarget())
1426
+ this.updateRollingAudio(deltaSeconds)
1427
+ return
1428
+ }
1429
+ if (this.phase === 'staging') {
1430
+ this.recordPhysicsWork(physicsStepsRemaining, synchronizedBacklogTicks)
1431
+ this.syncVisuals()
1432
+ this.updateCamera(deltaSeconds)
1433
+ this.advanceCameraStaging(deltaSeconds)
1434
+ this.themeController?.update(deltaSeconds, this.camera, false, this.shadowFocusTarget())
1435
+ this.updateRollingAudio(deltaSeconds)
1436
+ return
1437
+ }
1438
+ // Shared-surface Test Runs are inserted into the lobby render loop before
1439
+ // their async world/material setup has finished. Never let that render
1440
+ // loop advance mechanisms (or newly-created marbles) behind the loading
1441
+ // screen: the first physics tick must be the same tick in rendered and
1442
+ // headless deterministic simulations.
1443
+ if (!this.paused && this.phase === 'racing') {
1444
+ this.accumulator += Math.min(deltaSeconds, 0.1) * this.timeScale
1445
+ }
1446
+ while (!this.paused && this.accumulator >= FIXED_TIMESTEP && this.phase === 'racing' && physicsStepsRemaining > 0) {
1447
+ this.fixedStep()
1448
+ this.accumulator -= FIXED_TIMESTEP
1449
+ physicsStepsRemaining -= 1
1450
+ }
1451
+ this.recordPhysicsWork(physicsStepsRemaining, synchronizedBacklogTicks)
1452
+ this.syncVisuals()
1453
+ this.updateCamera(deltaSeconds)
1454
+ this.themeController?.update(deltaSeconds, this.camera, false, this.shadowFocusTarget())
1455
+ this.updateRollingAudio(deltaSeconds)
1456
+ }
1457
+
1458
+ private recordPhysicsWork(physicsStepsRemaining: number, synchronizedBacklogTicks: number): void {
1459
+ this.physicsStepsLastUpdate = MAX_PHYSICS_STEPS_PER_FRAME - physicsStepsRemaining
1460
+ this.physicsBacklogSteps = Math.max(
1461
+ synchronizedBacklogTicks,
1462
+ Math.max(0, Math.floor(this.accumulator / FIXED_TIMESTEP)),
1463
+ )
1464
+ this.catchUpSaturated = physicsStepsRemaining === 0 && this.physicsBacklogSteps > 0
1465
+ this.maximumPhysicsBacklogSteps = Math.max(this.maximumPhysicsBacklogSteps, this.physicsBacklogSteps)
1466
+ if (this.catchUpSaturated) this.catchUpSaturationCount += 1
1467
+ }
1468
+
1469
+ private updateRollingAudio(deltaSeconds: number): void {
1470
+ if (!this.rollingAudio) return
1471
+ for (let index = 0; index < this.marbles.length; index += 1) {
1472
+ const marble = this.marbles[index]!
1473
+ this.rollingAudioWaterStates[index] = this.waterPieceIds.has(
1474
+ marble.projection.sampleIndex >= 0
1475
+ ? (this.course.projectionSamples[marble.projection.sampleIndex]?.pieceId ?? '')
1476
+ : '',
1477
+ )
1478
+ }
1479
+ this.rollingAudio.update(
1480
+ deltaSeconds,
1481
+ this.phase === 'racing' && !this.paused,
1482
+ this.world,
1483
+ this.marbles,
1484
+ this.visualRoot,
1485
+ this.camera,
1486
+ this.rollingAudioWaterStates,
1487
+ this.rollingAudioGroundedStates,
1488
+ )
1489
+ }
1490
+
1491
+ cameraFocusTarget(): THREE.Vector3 {
1492
+ return this.controls?.target ?? this.cameraTarget
1493
+ }
1494
+
1495
+ shadowFocusTarget(): THREE.Vector3 {
1496
+ const cinematic = this.phase === 'touring' || this.phase === 'staging' || this.phase === 'countdown'
1497
+ if (!cinematic || !this.cameraTourPlan) return this.cameraFocusTarget()
1498
+
1499
+ // Keep the directional shadow projection fixed at the start line while
1500
+ // the presentation camera corkscrews around the course. Following the
1501
+ // moving tour target forces full shadow-map redraws at spatial thresholds,
1502
+ // which show up as repeatable mobile hitches. The endpoint is also the
1503
+ // first racing focus, so the handoff does not require another redraw.
1504
+ this.cinematicShadowFocusTarget.fromArray(this.cameraTourPlan.startPose.target)
1505
+ if (!this.ownsSurface) {
1506
+ this.visualRoot.updateWorldMatrix(true, false)
1507
+ this.visualRoot.localToWorld(this.cinematicShadowFocusTarget)
1508
+ }
1509
+ return this.cinematicShadowFocusTarget
1510
+ }
1511
+
1512
+ private fixedStep(): void {
1513
+ if (!this.simulation) return
1514
+
1515
+ const firstStepStartedAt = this.simulationTick === 0 && this.diagnosticsEnabled ? performance.now() : undefined
1516
+ const snapshot = this.simulation.step()
1517
+ if (firstStepStartedAt !== undefined && this.startupDiagnostics) {
1518
+ this.startupDiagnostics.firstPhysicsStepMs = performance.now() - firstStepStartedAt
1519
+ }
1520
+ this.applyAuthoritativeSnapshot(snapshot)
1521
+ this.simulationRenderer?.push(snapshot)
1522
+ this.processAuthoritativeEvents(this.simulation.drainEvents())
1523
+
1524
+ if (!this.initialCatchUpActive && this.simulationTick % TRAIL_INTERVAL_TICKS === 0) {
1525
+ this.marbles.forEach(updateMarbleTrail)
1526
+ }
1527
+
1528
+ if (this.simulation.complete) this.completeRace()
1529
+
1530
+ if (
1531
+ !this.initialCatchUpActive &&
1532
+ (this.simulationTick % SNAPSHOT_INTERVAL_TICKS === 0 || this.phase === 'finished')
1533
+ ) {
1534
+ this.emitSnapshot(this.phase === 'finished')
1535
+ }
1536
+ }
1537
+
1538
+ private applyAuthoritativeSnapshot(snapshot: RaceSimulationSnapshot): void {
1539
+ this.authoritativeSnapshot = snapshot
1540
+ this.simulationTick = snapshot.simulationTick
1541
+ this.replayFingerprint = snapshot.replayFingerprint
1542
+ for (const marbleSnapshot of snapshot.marbles) {
1543
+ const marble = this.marblesBySlot.get(marbleSnapshot.slotId)
1544
+ if (!marble) continue
1545
+ const marbleIndex = this.marbleIndexBySlot.get(marbleSnapshot.slotId)
1546
+ if (marbleIndex !== undefined) this.rollingAudioGroundedStates[marbleIndex] = marbleSnapshot.grounded
1547
+ applyMarbleSimulationSnapshot(marble, marbleSnapshot, this.course, !this.initialCatchUpActive)
1548
+ }
1549
+ }
1550
+
1551
+ private processAuthoritativeEvents(events: readonly RaceSimulationEvent[]): void {
1552
+ if (this.initialCatchUpActive || this.suppressSnapshots || events.length === 0) return
1553
+ const specializedContacts = new Set<string>()
1554
+ for (const event of events) {
1555
+ if (event.type === 'collision' && event.mechanismId) {
1556
+ specializedContacts.add(`${event.tick}:${event.slotId}`)
1557
+ }
1558
+ }
1559
+ for (const event of events) {
1560
+ if (event.type === 'portal-entered') {
1561
+ const position = new THREE.Vector3(...event.position)
1562
+ const portal = this.track.pieces.find(
1563
+ (piece): piece is PortalPieceDefinition => piece.type === 'portal' && piece.id === event.portalId,
1564
+ )
1565
+ if (!portal) continue
1566
+ this.rollingAudio?.queuePortalTeleport(position)
1567
+ this.pendingPortalEvents.set(event.slotId, {
1568
+ slotId: event.slotId,
1569
+ portalId: event.portalId,
1570
+ tick: event.tick,
1571
+ entryPosition: new THREE.Vector3(...portal.entry),
1572
+ exitPosition: position,
1573
+ exitDirection: new THREE.Vector3(...portal.exitDirection).normalize(),
1574
+ })
1575
+ continue
1576
+ }
1577
+ if (event.type !== 'collision') continue
1578
+ const position = new THREE.Vector3(...event.position)
1579
+ if (!event.mechanismId) {
1580
+ if (!specializedContacts.has(`${event.tick}:${event.slotId}`)) {
1581
+ this.rollingAudio?.queueCollisionImpact(event.impulse, position)
1582
+ }
1583
+ continue
1584
+ }
1585
+ const mechanism = this.track.pieces.find(piece => piece.id === event.mechanismId)
1586
+ switch (mechanism?.type) {
1587
+ case 'rebounds':
1588
+ this.rollingAudio?.queueReboundHit(position)
1589
+ break
1590
+ case 'rising-wall-forest':
1591
+ this.rollingAudio?.queueRisingWallContact(position)
1592
+ break
1593
+ case 'peg-field':
1594
+ if (mechanism.colliderShape !== 'cylinder') this.rollingAudio?.queueConeContact(position)
1595
+ else this.rollingAudio?.queueCollisionImpact(event.impulse, position)
1596
+ break
1597
+ case 'flipper-bank':
1598
+ this.rollingAudio?.queueFlipperAcceleratingHit(position)
1599
+ break
1600
+ }
1601
+ }
1602
+ }
1603
+
1604
+ private advanceCountdown(deltaSeconds: number): void {
1605
+ const previousValue = Math.ceil(this.countdownSecondsRemaining)
1606
+ this.countdownSecondsRemaining = advanceRaceCountdown(this.countdownSecondsRemaining, deltaSeconds)
1607
+ const nextValue = Math.ceil(this.countdownSecondsRemaining)
1608
+ if (this.countdownSecondsRemaining === 0) {
1609
+ this.phase = 'racing'
1610
+ this.accumulator = 0
1611
+ this.updateControlsEnabled()
1612
+ this.emitRaceStartCue()
1613
+ }
1614
+ if (nextValue !== previousValue) {
1615
+ if (nextValue > 0) this.emitCountdownCue(nextValue)
1616
+ this.emitSnapshot(this.phase === 'racing')
1617
+ }
1618
+ }
1619
+
1620
+ private advanceCameraStaging(deltaSeconds: number): void {
1621
+ const safeDelta = THREE.MathUtils.clamp(deltaSeconds, 0, 0.1)
1622
+ const renderedTarget = this.controls?.target ?? this.cameraTarget
1623
+ this.cameraLookMatrix.lookAt(this.camera.position, renderedTarget, this.cameraWorldUp)
1624
+ this.desiredCameraQuaternion.setFromRotationMatrix(this.cameraLookMatrix)
1625
+ const settled = cameraPoseWithinTolerance(
1626
+ this.camera.position,
1627
+ this.desiredCameraPosition,
1628
+ renderedTarget,
1629
+ this.desiredCameraTarget,
1630
+ this.camera.quaternion,
1631
+ this.desiredCameraQuaternion,
1632
+ CAMERA_STAGING_POSITION_TOLERANCE,
1633
+ CAMERA_STAGING_TARGET_TOLERANCE,
1634
+ CAMERA_STAGING_ANGLE_TOLERANCE,
1635
+ )
1636
+ this.cameraStagingSettledSeconds = settled ? this.cameraStagingSettledSeconds + safeDelta : 0
1637
+ if (this.cameraStagingSettledSeconds < CAMERA_STAGING_SETTLE_SECONDS) return
1638
+ this.phase = 'countdown'
1639
+ this.accumulator = 0
1640
+ this.updateControlsEnabled()
1641
+ this.emitCountdownCue(Math.ceil(this.countdownSecondsRemaining))
1642
+ this.emitSnapshot(true)
1643
+ }
1644
+
1645
+ private advanceCameraTour(deltaSeconds: number): boolean {
1646
+ const durationSeconds = this.cameraTourPlan?.durationSeconds ?? 6
1647
+ const previousCountdown = Math.ceil(this.countdownSecondsRemaining)
1648
+ this.cameraTourElapsedSeconds = Math.min(
1649
+ durationSeconds,
1650
+ this.cameraTourElapsedSeconds + THREE.MathUtils.clamp(deltaSeconds, 0, 0.1),
1651
+ )
1652
+ const remainingTourSeconds = Math.max(0, durationSeconds - this.cameraTourElapsedSeconds)
1653
+ if (
1654
+ this.phase === 'touring' &&
1655
+ this.countdownSecondsRemaining > 0 &&
1656
+ remainingTourSeconds <= this.countdownSecondsRemaining
1657
+ ) {
1658
+ this.phase = 'countdown'
1659
+ this.countdownSecondsRemaining = remainingTourSeconds
1660
+ this.updateControlsEnabled()
1661
+ this.emitCountdownCue(Math.ceil(this.countdownSecondsRemaining))
1662
+ this.emitSnapshot(true)
1663
+ } else if (this.phase === 'countdown') {
1664
+ this.countdownSecondsRemaining = remainingTourSeconds
1665
+ const nextCountdown = Math.ceil(this.countdownSecondsRemaining)
1666
+ if (nextCountdown !== previousCountdown) {
1667
+ if (nextCountdown > 0) this.emitCountdownCue(nextCountdown)
1668
+ this.emitSnapshot(false)
1669
+ }
1670
+ }
1671
+ return remainingTourSeconds === 0
1672
+ }
1673
+
1674
+ private synchronizeCameraTour(synchronizedElapsedMs: number, synchronizedIntroElapsedMs?: number): void {
1675
+ const plan = this.cameraTourPlan
1676
+ if (!plan) return
1677
+ const previousPhase = this.phase
1678
+ const previousCountdown = Math.ceil(this.countdownSecondsRemaining)
1679
+ const state = synchronizedRaceIntroState(plan.durationSeconds, synchronizedElapsedMs, synchronizedIntroElapsedMs)
1680
+ this.cameraTourElapsedSeconds = state.elapsedSeconds
1681
+ this.countdownSecondsRemaining = state.countdownSeconds
1682
+ this.phase = state.phase
1683
+ const nextCountdown = Math.ceil(this.countdownSecondsRemaining)
1684
+ if (this.phase === 'countdown' && (previousPhase !== 'countdown' || nextCountdown !== previousCountdown)) {
1685
+ this.emitCountdownCue(nextCountdown)
1686
+ // Synchronized races derive countdown time from the authoritative
1687
+ // clock rather than advanceCameraTour(). Publish the same boundary
1688
+ // snapshots so the HUD receives 3, 2, and 1 before the racing snapshot
1689
+ // that displays GO.
1690
+ if (nextCountdown > 0) this.emitSnapshot(true)
1691
+ }
1692
+ }
1693
+
1694
+ private synchronizeCountdown(synchronizedElapsedMs: number): void {
1695
+ const previousCountdown = Math.ceil(this.countdownSecondsRemaining)
1696
+ this.countdownSecondsRemaining = THREE.MathUtils.clamp(-synchronizedElapsedMs / 1_000, 0, RACE_COUNTDOWN_SECONDS)
1697
+ const nextCountdown = Math.ceil(this.countdownSecondsRemaining)
1698
+ if (nextCountdown > 0) this.emitCountdownCue(nextCountdown)
1699
+ if (nextCountdown === previousCountdown) return
1700
+ this.emitSnapshot(true)
1701
+ }
1702
+
1703
+ private finishCameraTour(): void {
1704
+ if (!this.worldReady) return
1705
+ this.countdownSecondsRemaining = 0
1706
+ this.cameraProgress = THREE.MathUtils.clamp(this.track.spawnProgress, 0, this.course.totalLength)
1707
+ this.phase = 'racing'
1708
+ this.accumulator = 0
1709
+ this.updateControlsEnabled()
1710
+ this.emitRaceStartCue()
1711
+ this.emitSnapshot(true)
1712
+ }
1713
+
1714
+ private emitCountdownCue(countdown: number): void {
1715
+ if (countdown <= 0 || countdown === this.lastCountdownCue) return
1716
+ this.lastCountdownCue = countdown
1717
+ this.emitRaceCue('countdown')
1718
+ }
1719
+
1720
+ private emitRaceStartCue(): void {
1721
+ if (this.raceStartCuePlayed) return
1722
+ this.raceStartCuePlayed = true
1723
+ this.emitRaceCue('start')
1724
+ }
1725
+
1726
+ private emitRaceCue(cue: 'countdown' | 'start'): void {
1727
+ try {
1728
+ this.onRaceCue?.(cue)
1729
+ } catch {
1730
+ // Presentation audio must never interrupt deterministic race timing.
1731
+ }
1732
+ }
1733
+
1734
+ private updateCameraProgress(deltaSeconds: number): void {
1735
+ if (this.phase !== 'racing') return
1736
+
1737
+ const subject = this.cameraSubject()
1738
+ const packSize = this.cameraView === 'race' ? this.cameraPackFrame.count : 0
1739
+ const targetProgress =
1740
+ packSize > 0 ? this.cameraPackFrame.progress : (subject?.bestProgress ?? this.cameraProgress)
1741
+ if (Math.abs(targetProgress - this.cameraProgress) < 0.18) return
1742
+ this.cameraProgress = THREE.MathUtils.lerp(
1743
+ this.cameraProgress,
1744
+ targetProgress,
1745
+ responseAlpha(
1746
+ this.cameraView === 'focus' ? FOCUS_CAMERA_PROGRESS_RESPONSE : CAMERA_PROGRESS_RESPONSE,
1747
+ deltaSeconds,
1748
+ ),
1749
+ )
1750
+ }
1751
+
1752
+ private updateCamera(deltaSeconds: number): void {
1753
+ if (this.cameraView === 'race') {
1754
+ this.cameraPackTracker.update(this.marbles, this.cameraPackFrame, deltaSeconds)
1755
+ this.cameraPackDiscontinuityPending = this.cameraPackTracker.consumeDiscontinuity(this.cameraTargetDelta)
1756
+ if (this.cameraPackDiscontinuityPending) this.cameraProgress = this.cameraPackFrame.progress
1757
+ }
1758
+ this.updateCameraProgress(deltaSeconds)
1759
+ this.writeDefaultCameraPose(deltaSeconds)
1760
+ if (!this.ownsSurface) {
1761
+ this.visualRoot.updateMatrixWorld(true)
1762
+ this.visualRoot.localToWorld(this.desiredCameraPosition)
1763
+ this.visualRoot.localToWorld(this.desiredCameraTarget)
1764
+ }
1765
+ this.applyCameraTargetHandoff(this.controls?.target ?? this.cameraTarget, deltaSeconds)
1766
+ if (this.phase === 'touring' || this.phase === 'staging' || this.phase === 'countdown') {
1767
+ const renderedTarget = this.controls?.target ?? this.cameraTarget
1768
+ this.camera.position.copy(this.desiredCameraPosition)
1769
+ renderedTarget.copy(this.desiredCameraTarget)
1770
+ this.cameraTarget.copy(renderedTarget)
1771
+ this.camera.fov = this.desiredCameraFov
1772
+ this.camera.updateProjectionMatrix()
1773
+ this.camera.up.copy(THREE.Object3D.DEFAULT_UP)
1774
+ this.camera.lookAt(renderedTarget)
1775
+ this.cameraDirector.synchronize(this.camera.position, renderedTarget)
1776
+ return
1777
+ }
1778
+ // Track banking and loop normals position the camera relative to the
1779
+ // course, but must not roll the viewer or leak a tilted up-vector back to
1780
+ // the shared Track Lab camera.
1781
+ this.cameraWorldUp.copy(THREE.Object3D.DEFAULT_UP)
1782
+ this.resolveCameraObstruction(deltaSeconds)
1783
+ this.cameraLockedDistance = this.desiredCameraPosition.distanceTo(this.desiredCameraTarget)
1784
+ const returning = this.cameraControl === 'free' && this.cameraReturnCurve.isActive()
1785
+ if (!returning) {
1786
+ this.updateCameraFov(this.cameraControl === 'free' ? this.cameraFreeFov : this.desiredCameraFov, deltaSeconds)
1787
+ }
1788
+
1789
+ const renderedTarget = this.controls?.target ?? this.cameraTarget
1790
+ this.applyRacePackDiscontinuity(renderedTarget)
1791
+ this.applyFocusPortalDiscontinuity(renderedTarget)
1792
+ if ((this.cameraControl === 'free' || this.focusOrbitActive) && this.controls) {
1793
+ if (this.cameraView === 'focus') this.translateFreeCameraWithAnchor(renderedTarget)
1794
+ if (this.cameraView === 'race') {
1795
+ this.cameraTargetDelta.copy(renderedTarget)
1796
+ this.cameraDirector.updateTarget(renderedTarget, this.desiredCameraTarget, deltaSeconds)
1797
+ this.cameraTargetDelta.subVectors(renderedTarget, this.cameraTargetDelta)
1798
+ this.camera.position.add(this.cameraTargetDelta)
1799
+ this.cameraReturnCurve.translate(this.cameraTargetDelta)
1800
+ } else if (!this.cameraInteractionActive) {
1801
+ this.cameraTargetDelta.copy(renderedTarget)
1802
+ this.cameraDirector.update(
1803
+ this.camera.position,
1804
+ renderedTarget,
1805
+ this.camera.position,
1806
+ this.desiredCameraTarget,
1807
+ deltaSeconds,
1808
+ )
1809
+ this.cameraTargetDelta.subVectors(renderedTarget, this.cameraTargetDelta)
1810
+ this.camera.position.add(this.cameraTargetDelta)
1811
+ this.cameraReturnCurve.translate(this.cameraTargetDelta)
1812
+ }
1813
+ if (returning) {
1814
+ this.synchronizeReturningControls()
1815
+ this.cameraFreeFov = this.cameraReturnCurve.writePose(
1816
+ this.desiredCameraPosition,
1817
+ this.desiredCameraTarget,
1818
+ this.desiredCameraFov,
1819
+ this.camera.position,
1820
+ renderedTarget,
1821
+ deltaSeconds,
1822
+ )
1823
+ if (Math.abs(this.cameraFreeFov - this.camera.fov) > 0.001) {
1824
+ this.camera.fov = this.cameraFreeFov
1825
+ this.camera.updateProjectionMatrix()
1826
+ }
1827
+ this.camera.up.copy(this.cameraWorldUp)
1828
+ this.camera.lookAt(renderedTarget)
1829
+ } else {
1830
+ this.cameraControlsUpdating = true
1831
+ try {
1832
+ this.controls.update()
1833
+ } finally {
1834
+ this.cameraControlsUpdating = false
1835
+ }
1836
+ }
1837
+ } else {
1838
+ this.cameraDirector.update(
1839
+ this.camera.position,
1840
+ renderedTarget,
1841
+ this.desiredCameraPosition,
1842
+ this.desiredCameraTarget,
1843
+ deltaSeconds,
1844
+ this.focusSubjectLocked,
1845
+ )
1846
+ this.camera.up.copy(this.cameraWorldUp)
1847
+ }
1848
+ if (this.focusSubjectLocked) {
1849
+ this.camera.up.copy(this.cameraWorldUp)
1850
+ this.camera.lookAt(renderedTarget)
1851
+ } else if (this.cameraControl === 'locked') {
1852
+ this.orientCameraToward(renderedTarget, deltaSeconds)
1853
+ }
1854
+ this.cameraTarget.copy(renderedTarget)
1855
+ if (this.cameraReturnLockPending && this.cameraReturnCurve.isComplete()) this.completeCameraReturn()
1856
+
1857
+ if (
1858
+ this.activePortalTransition &&
1859
+ this.simulationTick - this.activePortalTransition.tick >= PORTAL_REBASE_MINIMUM_HOLD_TICKS &&
1860
+ this.camera.position.distanceToSquared(this.desiredCameraPosition) <= 1.5 ** 2
1861
+ ) {
1862
+ this.pendingPortalEvents.delete(this.activePortalTransition.slotId)
1863
+ this.activePortalTransition = undefined
1864
+ }
1865
+ }
1866
+
1867
+ private updateCameraFov(targetFov: number, deltaSeconds: number): void {
1868
+ const nextFov =
1869
+ Math.abs(targetFov - this.camera.fov) < 0.25
1870
+ ? this.camera.fov
1871
+ : THREE.MathUtils.lerp(this.camera.fov, targetFov, responseAlpha(CAMERA_FOV_RESPONSE, deltaSeconds))
1872
+ if (Math.abs(nextFov - this.camera.fov) <= 0.001) return
1873
+ this.camera.fov = nextFov
1874
+ this.camera.updateProjectionMatrix()
1875
+ }
1876
+
1877
+ /**
1878
+ * Keeps the authored camera on its existing sightline. This is deliberately
1879
+ * one query and one dimension: an obstruction may shorten the boom, but it
1880
+ * can never choose another side or rotate the user's view.
1881
+ */
1882
+ private resolveCameraObstruction(deltaSeconds: number): void {
1883
+ this.cameraProbeCount = 0
1884
+ this.cameraObstructed = false
1885
+ this.cameraSightLine.subVectors(this.desiredCameraPosition, this.desiredCameraTarget)
1886
+ this.cameraDesiredDistance = this.cameraSightLine.length()
1887
+ const resolvingLockedPose = this.cameraControl === 'locked' || this.cameraReturnCurve.isActive()
1888
+ if (
1889
+ !resolvingLockedPose ||
1890
+ !this.queryWorld ||
1891
+ this.cameraDesiredDistance <= CAMERA_MIN_TARGET_DISTANCE
1892
+ ) {
1893
+ this.cameraResolvedDistance = this.cameraDesiredDistance
1894
+ return
1895
+ }
1896
+
1897
+ this.cameraSightLine.multiplyScalar(1 / this.cameraDesiredDistance)
1898
+ const probeOffset = cameraObstructionProbeOffset(this.cameraDesiredDistance)
1899
+ this.cameraObstructionOrigin
1900
+ .copy(this.desiredCameraTarget)
1901
+ .addScaledVector(this.cameraSightLine, probeOffset)
1902
+ const hit = this.queryWorld.castShape(
1903
+ this.cameraObstructionOrigin,
1904
+ this.cameraCollisionRotation,
1905
+ this.cameraSightLine,
1906
+ this.cameraCollisionShape,
1907
+ CAMERA_COLLISION_PREDICTION,
1908
+ Math.max(0, this.cameraDesiredDistance - probeOffset),
1909
+ true,
1910
+ RAPIER.QueryFilterFlags.EXCLUDE_DYNAMIC | RAPIER.QueryFilterFlags.EXCLUDE_SENSORS,
1911
+ )
1912
+ this.cameraProbeCount = 1
1913
+ const allowedDistance = resolveCameraObstructionDistance(
1914
+ this.cameraDesiredDistance,
1915
+ hit?.time_of_impact,
1916
+ probeOffset,
1917
+ )
1918
+ this.cameraObstructed = allowedDistance < this.cameraDesiredDistance - 0.001
1919
+ this.cameraResolvedDistance = advanceCameraObstructionDistance(
1920
+ this.cameraResolvedDistance,
1921
+ allowedDistance,
1922
+ deltaSeconds,
1923
+ )
1924
+ this.cameraResolvedDistance = Math.min(this.cameraDesiredDistance, this.cameraResolvedDistance)
1925
+ this.desiredCameraPosition
1926
+ .copy(this.desiredCameraTarget)
1927
+ .addScaledVector(this.cameraSightLine, this.cameraResolvedDistance)
1928
+ }
1929
+
1930
+ private synchronizeReturningControls(): void {
1931
+ const controls = this.controls
1932
+ if (!controls || !this.cameraReturnNeedsControlsSync) return
1933
+ const dampingEnabled = controls.enableDamping
1934
+ controls.enableDamping = false
1935
+ this.cameraControlsUpdating = true
1936
+ try {
1937
+ // Clear the final native orbit/dolly delta once, before the curve owns
1938
+ // its first presented frame. OrbitControls does not write again while
1939
+ // the return is active.
1940
+ controls.update()
1941
+ } finally {
1942
+ this.cameraControlsUpdating = false
1943
+ controls.enableDamping = dampingEnabled
1944
+ this.cameraReturnNeedsControlsSync = false
1945
+ }
1946
+ }
1947
+
1948
+ /** Rebases the existing pack rig when the retained pack crosses a portal. */
1949
+ private applyRacePackDiscontinuity(renderedTarget: THREE.Vector3): void {
1950
+ if (!this.cameraPackDiscontinuityPending || this.cameraView !== 'race') return
1951
+ this.cameraTargetDelta.subVectors(this.desiredCameraTarget, renderedTarget)
1952
+ this.camera.position.add(this.cameraTargetDelta)
1953
+ renderedTarget.copy(this.desiredCameraTarget)
1954
+ this.cameraTarget.copy(renderedTarget)
1955
+ this.cameraReturnCurve.translate(this.cameraTargetDelta)
1956
+ this.cameraDirector.synchronize(this.camera.position, renderedTarget)
1957
+ this.cameraPackDiscontinuityPending = false
1958
+ }
1959
+
1960
+ /** A portal is a world discontinuity, not a path the camera should collide through. */
1961
+ private applyFocusPortalDiscontinuity(renderedTarget: THREE.Vector3): void {
1962
+ if (
1963
+ this.cameraView !== 'focus' ||
1964
+ this.activePortalTransition?.slotId !== this.cameraTargetSlotId ||
1965
+ renderedTarget.distanceTo(this.desiredCameraTarget) <= Math.max(20, this.cameraLockedDistance * 1.25)
1966
+ ) {
1967
+ return
1968
+ }
1969
+ this.cameraTargetDelta.subVectors(this.desiredCameraTarget, renderedTarget)
1970
+ this.camera.position.add(this.cameraTargetDelta)
1971
+ renderedTarget.add(this.cameraTargetDelta)
1972
+ this.cameraReturnCurve.translate(this.cameraTargetDelta)
1973
+ if (this.cameraControl === 'free' || this.focusOrbitActive) {
1974
+ this.cameraFreeFollowAnchor.copy(this.desiredCameraTarget)
1975
+ }
1976
+ this.cameraDirector.synchronize(this.camera.position, renderedTarget)
1977
+ }
1978
+
1979
+ private translateFreeCameraWithAnchor(renderedTarget: THREE.Vector3): void {
1980
+ // Preserve the user's orbit vector while moving its entire coordinate
1981
+ // frame with the marble or pack. OrbitControls remains the sole owner of
1982
+ // rotation and dolly during an active gesture, so this cannot fight drag.
1983
+ translateFreeCameraRig(
1984
+ this.camera.position,
1985
+ renderedTarget,
1986
+ this.cameraFreeFollowAnchor,
1987
+ this.desiredCameraTarget,
1988
+ this.cameraTargetDelta,
1989
+ )
1990
+ this.cameraReturnCurve.translate(this.cameraTargetDelta)
1991
+ }
1992
+
1993
+ private applyCameraTargetHandoff(renderedTarget: THREE.Vector3, deltaSeconds: number): void {
1994
+ if (this.focusSubjectLocked) {
1995
+ this.cameraTargetHandoffPending = false
1996
+ this.cameraTargetHandoffSeconds = CAMERA_TARGET_HANDOFF_SECONDS
1997
+ return
1998
+ }
1999
+ if (this.cameraTargetHandoffPending) {
2000
+ this.cameraTargetHandoffOffset.subVectors(renderedTarget, this.desiredCameraTarget)
2001
+ this.cameraTargetHandoffPending = false
2002
+ }
2003
+ if (this.cameraTargetHandoffSeconds >= CAMERA_TARGET_HANDOFF_SECONDS) return
2004
+
2005
+ this.cameraTargetHandoffSeconds = Math.min(
2006
+ CAMERA_TARGET_HANDOFF_SECONDS,
2007
+ this.cameraTargetHandoffSeconds + THREE.MathUtils.clamp(deltaSeconds, 0, 0.1),
2008
+ )
2009
+ const progress = THREE.MathUtils.smootherstep(this.cameraTargetHandoffSeconds, 0, CAMERA_TARGET_HANDOFF_SECONDS)
2010
+ this.desiredCameraTarget.addScaledVector(this.cameraTargetHandoffOffset, 1 - progress)
2011
+ }
2012
+
2013
+ private writeDefaultCameraPose(deltaSeconds = 0): void {
2014
+ const mobileCamera = this.container.clientWidth < 520
2015
+ const subject = this.cameraSubject()
2016
+ const subjectPosition = subject?.visual.position
2017
+ const tourShot =
2018
+ this.phase === 'touring' ||
2019
+ (this.phase === 'countdown' &&
2020
+ this.cameraTourPlan !== undefined &&
2021
+ this.cameraTourElapsedSeconds <= this.cameraTourPlan.durationSeconds)
2022
+ const startLineShot = this.phase === 'staging' || (this.phase === 'countdown' && !tourShot)
2023
+ this.focusSubjectLocked = this.cameraControl === 'locked' && !tourShot && !startLineShot && this.cameraView === 'focus'
2024
+ if (tourShot) {
2025
+ this.writeTourCameraPose()
2026
+ return
2027
+ }
2028
+ const cameraFrameProgress =
2029
+ this.focusSubjectLocked && subject ? subject.projection.distance : this.cameraProgress
2030
+ this.writeSmoothedCameraFrame(
2031
+ cameraFrameProgress,
2032
+ this.cameraView === 'focus' ? 4 : 8,
2033
+ deltaSeconds,
2034
+ this.focusSubjectLocked,
2035
+ )
2036
+ if (this.focusSubjectLocked && subject) this.writeFocusSubjectFrame(subject)
2037
+ if (startLineShot) {
2038
+ this.writeStartLineCameraPose(subject, mobileCamera)
2039
+ return
2040
+ }
2041
+
2042
+ this.updatePortalCameraState(this.cameraView === 'focus' ? subject : undefined)
2043
+
2044
+ this.cameraDirector.setState('follow')
2045
+
2046
+ if (this.cameraView === 'focus') {
2047
+ this.desiredCameraFov = mobileCamera ? 66 : 60
2048
+ this.desiredCameraTarget.set(
2049
+ subjectPosition?.x ?? this.cameraPose.position.x,
2050
+ subjectPosition?.y ?? this.cameraPose.position.y,
2051
+ subjectPosition?.z ?? this.cameraPose.position.z,
2052
+ )
2053
+ this.desiredCameraPosition
2054
+ .copy(this.desiredCameraTarget)
2055
+ .addScaledVector(this.cameraForward, mobileCamera ? -13 : -11.5)
2056
+ .addScaledVector(this.cameraUp, mobileCamera ? 7 : 6.2)
2057
+ .addScaledVector(this.cameraRight, mobileCamera ? 0 : 1.2)
2058
+ return
2059
+ }
2060
+
2061
+ this.writePackTarget(subject)
2062
+
2063
+ const packRadius = this.cameraPackFrame.radius
2064
+ const framingDistance = THREE.MathUtils.clamp(14 + packRadius * 1.35, 14, mobileCamera ? 23 : 21)
2065
+ this.desiredCameraFov = THREE.MathUtils.clamp((mobileCamera ? 57 : 50) + packRadius * 0.72, 50, 62)
2066
+ this.desiredCameraPosition
2067
+ .copy(this.desiredCameraTarget)
2068
+ .addScaledVector(this.cameraForward, -framingDistance)
2069
+ .addScaledVector(THREE.Object3D.DEFAULT_UP, framingDistance * (mobileCamera ? 0.74 : 0.62))
2070
+ .addScaledVector(this.cameraRight, framingDistance * (mobileCamera ? 0.07 : 0.26))
2071
+ }
2072
+
2073
+ private cameraSubject(): MarbleEntity | undefined {
2074
+ let leader: MarbleEntity | undefined
2075
+ let retainedLeader: MarbleEntity | undefined
2076
+ for (const marble of this.marbles) {
2077
+ if (this.cameraTargetSlotId && marble.assignment.slotId === this.cameraTargetSlotId) {
2078
+ return marble
2079
+ }
2080
+ if (marble.assignment.slotId === this.cameraLeaderSlotId) retainedLeader = marble
2081
+ if (!leader || marble.bestProgress > leader.bestProgress) leader = marble
2082
+ }
2083
+ if (retainedLeader && leader && retainCameraLeader(retainedLeader.bestProgress, leader.bestProgress)) {
2084
+ return retainedLeader
2085
+ }
2086
+ this.cameraLeaderSlotId = leader?.assignment.slotId
2087
+ return leader
2088
+ }
2089
+
2090
+ private buildCameraTourPlan(): void {
2091
+ this.cameraTourPlan = undefined
2092
+ const mobileCamera = this.container.clientWidth < 520
2093
+ this.cameraProgress = THREE.MathUtils.clamp(this.track.spawnProgress, 0, this.course.totalLength)
2094
+ this.writeSmoothedCameraFrame(this.cameraProgress, 8, 0)
2095
+ this.writeStartLineCameraPose(this.cameraSubject(), mobileCamera)
2096
+ const startPosition = this.desiredCameraPosition.clone()
2097
+ const startTarget = this.desiredCameraTarget.clone()
2098
+ const startForward = this.cameraForward.clone()
2099
+ this.writeCameraTourOpeningPose()
2100
+ this.cameraTourPlan = createCameraTourPlan({
2101
+ track: this.track,
2102
+ finishPose: {
2103
+ position: this.desiredCameraPosition.toArray(),
2104
+ target: this.desiredCameraTarget.toArray(),
2105
+ fov: this.desiredCameraFov,
2106
+ },
2107
+ startPose: {
2108
+ position: startPosition.toArray(),
2109
+ target: startTarget.toArray(),
2110
+ fov: mobileCamera ? 58 : 52,
2111
+ },
2112
+ startForward,
2113
+ durationSeconds: this.introDurationSeconds,
2114
+ })
2115
+ }
2116
+
2117
+ private writeCameraTourOpeningPose(): void {
2118
+ // The camera guide can terminate before or after the visible gate on
2119
+ // compiled/folded courses. The finish piece centre matches TrackWorld's
2120
+ // canonical finish position and is available before Rapier construction.
2121
+ const finishPiece = this.track.pieces.find(piece => piece.type === 'finish')
2122
+ const finish =
2123
+ this.trackWorld?.finishPosition.clone() ??
2124
+ (finishPiece?.type === 'finish'
2125
+ ? new THREE.Vector3().fromArray(finishPiece.center)
2126
+ : new THREE.Vector3().fromArray(this.track.cameraPath.points.at(-1)!.position))
2127
+ this.course.writePoseAtDistance(this.course.totalLength, this.cameraPose)
2128
+ this.desiredCameraTarget.copy(finish).addScaledVector(THREE.Object3D.DEFAULT_UP, 1.35)
2129
+ this.desiredCameraPosition
2130
+ .copy(this.desiredCameraTarget)
2131
+ // Stay centered on the exterior side of the finish. This keeps the gate
2132
+ // recognizable without trapping the opening camera inside a folded
2133
+ // course footprint or letting a one-sided mechanism dominate the frame.
2134
+ .addScaledVector(this.cameraPose.tangent, 11)
2135
+ .addScaledVector(THREE.Object3D.DEFAULT_UP, 5.5)
2136
+ this.desiredCameraFov = this.container.clientWidth < 520 ? 58 : 52
2137
+ }
2138
+
2139
+ private writeTourCameraPose(): void {
2140
+ this.cameraDirector.setState('tour')
2141
+ const plan = this.cameraTourPlan
2142
+ if (!plan) return
2143
+ const progress = smoothCameraTourProgress(this.cameraTourElapsedSeconds / plan.durationSeconds)
2144
+ this.desiredCameraFov = sampleCameraTourPlan(plan, progress, {
2145
+ position: this.desiredCameraPosition,
2146
+ target: this.desiredCameraTarget,
2147
+ })
2148
+ }
2149
+
2150
+ private writeStartLineCameraPose(subject: MarbleEntity | undefined, mobileCamera: boolean): void {
2151
+ this.cameraDirector.setState('start-line')
2152
+ const packSize = writeLeadingPackFrame(this.marbles, this.cameraPackFrame)
2153
+ this.desiredCameraTarget.copy(this.cameraPackFrame.center)
2154
+ if (packSize === 0 && subject) this.desiredCameraTarget.copy(subject.visual.position)
2155
+ else if (packSize === 0) this.desiredCameraTarget.copy(this.cameraPose.position)
2156
+
2157
+ const distance = mobileCamera ? 14 : 12
2158
+ this.desiredCameraPosition
2159
+ .copy(this.desiredCameraTarget)
2160
+ .addScaledVector(this.cameraForward, -distance)
2161
+ .addScaledVector(THREE.Object3D.DEFAULT_UP, mobileCamera ? 3.2 : 2.65)
2162
+ this.desiredCameraFov = mobileCamera ? 58 : 52
2163
+ if ((this.phase === 'staging' || this.phase === 'countdown') && this.cameraTourPlan) {
2164
+ this.desiredCameraPosition.fromArray(this.cameraTourPlan.startPose.position)
2165
+ this.desiredCameraTarget.fromArray(this.cameraTourPlan.startPose.target)
2166
+ this.desiredCameraFov = this.cameraTourPlan.startPose.fov
2167
+ }
2168
+ }
2169
+
2170
+ private updatePortalCameraState(subject: MarbleEntity | undefined): void {
2171
+ if (this.activePortalTransition?.slotId !== subject?.assignment.slotId) {
2172
+ this.activePortalTransition = undefined
2173
+ }
2174
+ if (subject) {
2175
+ const pending = this.pendingPortalEvents.get(subject.assignment.slotId)
2176
+ if (
2177
+ pending &&
2178
+ this.simulationTick - pending.tick <= PORTAL_EVENT_ACTIVATION_TICKS &&
2179
+ (!this.activePortalTransition || pending.tick > this.activePortalTransition.tick)
2180
+ ) {
2181
+ this.activePortalTransition = pending
2182
+ }
2183
+ }
2184
+ if (this.pendingPortalEvents.size > 0) {
2185
+ for (const [slotId, event] of this.pendingPortalEvents) {
2186
+ if (this.simulationTick - event.tick > PORTAL_EVENT_EXPIRY_TICKS) this.pendingPortalEvents.delete(slotId)
2187
+ }
2188
+ }
2189
+ }
2190
+
2191
+ private writeSmoothedCameraFrame(
2192
+ progress: number,
2193
+ span: number,
2194
+ deltaSeconds: number,
2195
+ useImmediateLocalTangent = false,
2196
+ ): void {
2197
+ this.course.writePoseAtDistance(progress, this.cameraPose)
2198
+ this.course.writePoseAtDistance(Math.max(0, progress - span), this.cameraBehindPose)
2199
+ this.course.writePoseAtDistance(Math.min(this.course.totalLength, progress + span), this.cameraTargetPose)
2200
+ this.cameraGuideChord.subVectors(this.cameraTargetPose.position, this.cameraBehindPose.position)
2201
+ if (useImmediateLocalTangent) {
2202
+ this.cameraDesiredGuideForward.copy(this.cameraPose.tangent).normalize()
2203
+ } else {
2204
+ writeStableCameraGuideDirection(this.cameraPose.tangent, this.cameraGuideChord, this.cameraDesiredGuideForward)
2205
+ }
2206
+ if (useImmediateLocalTangent || this.cameraGuideForward.lengthSq() < 0.000_001 || deltaSeconds <= 0) {
2207
+ this.cameraGuideForward.copy(this.cameraDesiredGuideForward)
2208
+ } else {
2209
+ this.cameraGuideForward.lerp(this.cameraDesiredGuideForward, responseAlpha(4, deltaSeconds)).normalize()
2210
+ }
2211
+ this.cameraForward.copy(this.cameraGuideForward)
2212
+ this.cameraUp.copy(this.cameraPose.up).normalize()
2213
+ this.cameraRight.crossVectors(this.cameraUp, this.cameraForward)
2214
+ if (this.cameraRight.lengthSq() < 0.0001) this.cameraRight.copy(this.cameraPose.right)
2215
+ else this.cameraRight.normalize()
2216
+ this.cameraUp.crossVectors(this.cameraForward, this.cameraRight).normalize()
2217
+ }
2218
+
2219
+ private writeFocusSubjectFrame(subject: MarbleEntity): void {
2220
+ const projectionSample = this.course.projectionSamples[subject.projection.sampleIndex]
2221
+ if (!projectionSample) return
2222
+ this.course.writeProjectionFrameAtDistance(
2223
+ subject.projection.sampleIndex,
2224
+ subject.projection.distance,
2225
+ this.cameraForward,
2226
+ this.cameraRight,
2227
+ this.cameraUp,
2228
+ )
2229
+ this.cameraGuideForward.copy(this.cameraForward)
2230
+ }
2231
+
2232
+ private orientCameraToward(target: THREE.Vector3, deltaSeconds: number): void {
2233
+ if (target.distanceToSquared(this.camera.position) < 0.000_001) return
2234
+ this.cameraLookMatrix.lookAt(this.camera.position, target, this.cameraWorldUp)
2235
+ this.desiredCameraQuaternion.setFromRotationMatrix(this.cameraLookMatrix)
2236
+ advanceCameraOrientation(this.camera.quaternion, this.desiredCameraQuaternion, deltaSeconds)
2237
+ }
2238
+
2239
+ private writePackTarget(subject: MarbleEntity | undefined): void {
2240
+ const packSize = this.cameraPackFrame.count
2241
+ this.desiredCameraTarget.copy(this.cameraPackFrame.center)
2242
+ if (packSize === 0 && subject) {
2243
+ const position = subject.visual.position
2244
+ this.desiredCameraTarget.set(position.x, position.y, position.z)
2245
+ } else if (packSize === 0) this.desiredCameraTarget.copy(this.cameraPose.position)
2246
+ this.desiredCameraTarget.addScaledVector(this.cameraUp, 1).addScaledVector(this.cameraForward, 4.5)
2247
+ }
2248
+
2249
+ private readonly handleCameraPointerDown = (event: PointerEvent): void => {
2250
+ this.cameraPointerIds.add(event.pointerId)
2251
+ this.cameraInteractionIsWheel = false
2252
+ this.cameraInteractionAllowsCombinedZoom = event.pointerType === 'touch' && this.cameraPointerIds.size > 1
2253
+ }
2254
+
2255
+ private readonly handleCameraPointerUp = (event: PointerEvent): void => {
2256
+ this.cameraPointerIds.delete(event.pointerId)
2257
+ if (this.cameraPointerIds.size < 2) this.cameraInteractionAllowsCombinedZoom = false
2258
+ }
2259
+
2260
+ private readonly handleCameraWheel = (): void => {
2261
+ // OrbitControls keeps full ownership of native wheel/trackpad dolly. This
2262
+ // flag only prevents residual rotation damping from misclassifying a wheel
2263
+ // change as an orbit gesture while we observe its resulting distance.
2264
+ this.cameraInteractionIsWheel = true
2265
+ this.cameraInteractionAllowsCombinedZoom = false
2266
+ }
2267
+
2268
+ private readonly markCameraAsUserControlled = (): void => {
2269
+ const controls = this.controls
2270
+ if (!controls) return
2271
+ this.cameraInteractionActive = true
2272
+ this.cameraInteractionStartPosition.copy(this.camera.position)
2273
+ this.cameraInteractionStartTarget.copy(controls.target)
2274
+ this.cameraInteractionStartDirection.subVectors(this.camera.position, controls.target).normalize()
2275
+ this.cameraInteractionDistance = this.camera.position.distanceTo(controls.target)
2276
+ this.cameraLockedDistance =
2277
+ this.cameraControl === 'locked'
2278
+ ? this.cameraInteractionDistance
2279
+ : this.desiredCameraPosition.distanceTo(this.desiredCameraTarget)
2280
+ controls.minDistance =
2281
+ this.cameraControl === 'locked' ? this.cameraInteractionDistance : this.cameraFreeMinimumDistance
2282
+ }
2283
+
2284
+ private readonly handleCameraControlsChange = (): void => {
2285
+ const controls = this.controls
2286
+ if (!controls || !this.cameraInteractionActive || this.cameraControlsUpdating) return
2287
+ const nextDistance = this.camera.position.distanceTo(controls.target)
2288
+ this.cameraSightLine.subVectors(this.camera.position, controls.target).normalize()
2289
+ const directionChanged = this.cameraInteractionStartDirection.angleTo(this.cameraSightLine) > 0.000_1
2290
+ const rotatesView = !this.cameraInteractionIsWheel && directionChanged
2291
+ const zoomedIn = cameraZoomIsInward(this.cameraInteractionDistance, nextDistance)
2292
+ const zoomedOut = cameraZoomIsOutward(this.cameraInteractionDistance, nextDistance)
2293
+ if (this.cameraControl === 'locked') {
2294
+ if (zoomedOut) {
2295
+ this.enterFreeCamera(controls)
2296
+ } else if (rotatesView) {
2297
+ if (this.cameraView === 'focus') this.beginFocusedOrbit(controls)
2298
+ else this.enterFreeCamera(controls)
2299
+ }
2300
+ return
2301
+ }
2302
+
2303
+ if (zoomedOut || (rotatesView && !(this.cameraInteractionAllowsCombinedZoom && zoomedIn))) {
2304
+ this.cameraReturnCurve.cancel()
2305
+ this.cameraReturnLockPending = false
2306
+ this.cameraReturnNeedsControlsSync = false
2307
+ this.cameraFreeFov = this.camera.fov
2308
+ this.cameraInteractionStartPosition.copy(this.camera.position)
2309
+ this.cameraInteractionStartTarget.copy(controls.target)
2310
+ this.cameraInteractionStartDirection.copy(this.cameraSightLine)
2311
+ this.cameraInteractionDistance = nextDistance
2312
+ return
2313
+ }
2314
+ if (!zoomedIn) return
2315
+
2316
+ const startingReturn = !this.cameraReturnCurve.isActive()
2317
+ if (startingReturn) {
2318
+ this.cameraReturnCurve.begin(
2319
+ this.cameraInteractionStartPosition,
2320
+ this.cameraInteractionStartTarget,
2321
+ this.cameraFreeFov,
2322
+ this.cameraInteractionDistance,
2323
+ )
2324
+ }
2325
+ this.cameraReturnCurve.addInwardInput(this.cameraInteractionDistance, nextDistance, this.cameraLockedDistance)
2326
+ this.cameraReturnLockPending = true
2327
+ if (startingReturn) this.cameraReturnNeedsControlsSync = true
2328
+ this.cameraInteractionDistance = nextDistance
2329
+ }
2330
+
2331
+ private readonly finishCameraInteraction = (): void => {
2332
+ this.cameraInteractionActive = false
2333
+ this.cameraInteractionIsWheel = false
2334
+ }
2335
+
2336
+ private enterFreeCamera(controls: OrbitControls): void {
2337
+ this.cameraControl = 'free'
2338
+ this.focusOrbitActive = false
2339
+ this.cameraFreeFollowAnchor.copy(this.desiredCameraTarget)
2340
+ controls.minDistance = this.cameraFreeMinimumDistance
2341
+ this.cameraFreeFov = this.camera.fov
2342
+ this.cameraReturnCurve.cancel()
2343
+ this.cameraReturnLockPending = false
2344
+ this.cameraReturnNeedsControlsSync = false
2345
+ // Race View keeps its existing pack-pivot velocity so zooming out does not
2346
+ // introduce a visible pause. Focus uses exact subject translation in free
2347
+ // mode and clears the locked camera body's remaining motion instead.
2348
+ if (this.cameraView === 'focus') this.cameraDirector.synchronize(this.camera.position, controls.target)
2349
+ this.emitCameraChange()
2350
+ }
2351
+
2352
+ private beginFocusedOrbit(controls: OrbitControls): void {
2353
+ if (this.focusOrbitActive) return
2354
+ this.focusOrbitActive = true
2355
+ this.cameraFreeFollowAnchor.copy(this.desiredCameraTarget)
2356
+ this.cameraDirector.synchronize(this.camera.position, controls.target)
2357
+ }
2358
+
2359
+ private requestCameraReturn(): void {
2360
+ const target = this.controls?.target ?? this.cameraTarget
2361
+ if (!this.cameraReturnCurve.isActive()) {
2362
+ this.cameraReturnCurve.begin(
2363
+ this.camera.position,
2364
+ target,
2365
+ this.camera.fov,
2366
+ this.camera.position.distanceTo(target),
2367
+ )
2368
+ }
2369
+ this.cameraReturnCurve.setRequestedDistance(0)
2370
+ this.cameraReturnLockPending = true
2371
+ this.cameraReturnNeedsControlsSync = true
2372
+ this.cameraInteractionActive = false
2373
+ }
2374
+
2375
+ private completeCameraReturn(): void {
2376
+ this.cameraControl = 'locked'
2377
+ this.focusOrbitActive = false
2378
+ this.cameraReturnCurve.cancel()
2379
+ this.cameraReturnLockPending = false
2380
+ this.cameraReturnNeedsControlsSync = false
2381
+ this.cameraInteractionActive = false
2382
+ if (this.controls) {
2383
+ this.controls.minDistance = this.camera.position.distanceTo(this.controls.target)
2384
+ }
2385
+ this.cameraDirector.synchronize(this.camera.position, this.controls?.target ?? this.cameraTarget)
2386
+ this.emitCameraChange()
2387
+ }
2388
+
2389
+ private emitCameraChange(): void {
2390
+ if (!this.onCameraChange) return
2391
+ const snapshot: RaceCameraSnapshot = {
2392
+ view: this.cameraView,
2393
+ control: this.cameraControl,
2394
+ ...(this.cameraTargetSlotId ? { targetSlotId: this.cameraTargetSlotId } : {}),
2395
+ }
2396
+ const key = `${snapshot.view}:${snapshot.control}:${snapshot.targetSlotId ?? ''}`
2397
+ if (key === this.cameraSnapshotKey) return
2398
+ this.cameraSnapshotKey = key
2399
+ try {
2400
+ this.onCameraChange(snapshot)
2401
+ } catch {
2402
+ // Camera UI must never interrupt rendering or deterministic simulation.
2403
+ }
2404
+ }
2405
+
2406
+ private syncVisuals(interpolationAlpha = this.renderInterpolationAlpha()): void {
2407
+ // A rendered frame can advance many 120 Hz physics ticks. Mechanism meshes
2408
+ // only need the final rigid-body pose; syncing inside fixedStep multiplies
2409
+ // the same scene traversal by the playback rate and catch-up backlog.
2410
+ this.simulationRenderer?.render(interpolationAlpha)
2411
+ this.marbles.forEach(marble => syncMarbleVisual(marble, interpolationAlpha))
2412
+
2413
+ const compactLabels = this.container.clientWidth < 520
2414
+ this.marbles.forEach(marble => {
2415
+ marble.label.visible = true
2416
+ marble.label.scale.set(compactLabels ? 1.92 : 2.35, compactLabels ? 0.54 : 0.66, 1)
2417
+ })
2418
+ }
2419
+
2420
+ private renderInterpolationAlpha(): number {
2421
+ if (this.paused || this.phase !== 'racing') return 1
2422
+ if (this.initialSimulationElapsedMs !== undefined) return this.synchronizedInterpolationAlpha
2423
+ return THREE.MathUtils.clamp(this.accumulator / FIXED_TIMESTEP, 0, 1)
2424
+ }
2425
+
2426
+ private completeRace(): void {
2427
+ const result = this.simulation?.result()
2428
+ if (!result || this.phase === 'finished') return
2429
+ this.winner = result.winner
2430
+ this.replayFingerprint = result.replayFingerprint
2431
+ if (this.developmentMode) {
2432
+ const physicsReport = this.simulation?.debugReport()
2433
+ console.info('[marble-race] physics report', physicsReport)
2434
+ this.renderer.domElement.dataset.physicsDebug = JSON.stringify(physicsReport)
2435
+ }
2436
+ this.phase = 'finished'
2437
+ }
2438
+
2439
+ private getStandings(): MarbleStanding[] {
2440
+ return [...this.marbles]
2441
+ .sort((left, right) => {
2442
+ if (left.finishedAtTick !== undefined && right.finishedAtTick !== undefined) {
2443
+ return (
2444
+ left.finishedAtTick - right.finishedAtTick || left.assignment.slotId.localeCompare(right.assignment.slotId)
2445
+ )
2446
+ }
2447
+ if (left.finishedAtTick !== undefined) {
2448
+ return -1
2449
+ }
2450
+ if (right.finishedAtTick !== undefined) {
2451
+ return 1
2452
+ }
2453
+ return right.bestProgress - left.bestProgress || left.assignment.slotId.localeCompare(right.assignment.slotId)
2454
+ })
2455
+ .map((marble, index) => {
2456
+ const translation = marble.body.translation()
2457
+ return {
2458
+ ...marble.assignment,
2459
+ rank: index + 1,
2460
+ currentProgressMeters: Number(marble.progress.toFixed(2)),
2461
+ progressMeters: Number(marble.bestProgress.toFixed(2)),
2462
+ progressRatio:
2463
+ marble.finishedAtTick === undefined ? Math.min(1, marble.bestProgress / this.course.totalLength) : 1,
2464
+ speedMetersPerSecond: Number(marble.speed.toFixed(2)),
2465
+ worldPosition: [translation.x, translation.y, translation.z] as const,
2466
+ finishTimeMs:
2467
+ marble.finishedAtTick === undefined
2468
+ ? undefined
2469
+ : Math.round(marble.finishedAtTick * FIXED_TIMESTEP * 1_000),
2470
+ }
2471
+ })
2472
+ }
2473
+
2474
+ private emitSnapshot(force = false): void {
2475
+ if (this.suppressSnapshots || (!force && this.disposed)) {
2476
+ return
2477
+ }
2478
+
2479
+ this.latestSnapshot = {
2480
+ phase: this.phase,
2481
+ paused: this.paused,
2482
+ countdown: Math.ceil(this.countdownSecondsRemaining),
2483
+ elapsedMs: Math.round(this.simulationTick * FIXED_TIMESTEP * 1_000),
2484
+ simulationTick: this.simulationTick,
2485
+ replayFingerprint: this.replayFingerprint,
2486
+ standings: this.getStandings(),
2487
+ winner: this.winner,
2488
+ }
2489
+ this.onSnapshot(this.latestSnapshot)
2490
+ }
2491
+
2492
+ private readonly getSnapshot = (): RaceSnapshot => this.latestSnapshot
2493
+
2494
+ private readonly getPhysicsDebugReport = (): PhysicsDebugReport | undefined => this.simulation?.debugReport()
2495
+
2496
+ private readonly getMarbleRenderStates: MarbleRaceTestHooks['getMarbleRenderStates'] = () =>
2497
+ this.marbles.map(marble => {
2498
+ const physics = marble.body.translation()
2499
+ return {
2500
+ slotId: marble.assignment.slotId,
2501
+ physicsPosition: [physics.x, physics.y, physics.z],
2502
+ visualPosition: marble.visual.position.toArray(),
2503
+ finishedAtTick: marble.finishedAtTick,
2504
+ }
2505
+ })
2506
+
2507
+ private frameTimingDiagnostics(): RendererDiagnostics['frameTiming'] {
2508
+ const samples = Array.from(this.frameIntervals.subarray(0, this.frameIntervalCount)).sort(
2509
+ (left, right) => left - right,
2510
+ )
2511
+ const percentile = (fraction: number): number =>
2512
+ samples.length === 0 ? 0 : samples[Math.min(samples.length - 1, Math.floor((samples.length - 1) * fraction))]!
2513
+ return {
2514
+ samples: samples.length,
2515
+ p50Ms: Number(percentile(0.5).toFixed(2)),
2516
+ p95Ms: Number(percentile(0.95).toFixed(2)),
2517
+ p99Ms: Number(percentile(0.99).toFixed(2)),
2518
+ maximumMs: Number((samples.at(-1) ?? 0).toFixed(2)),
2519
+ framesAbove16Ms: samples.filter(sample => sample > 16.7).length,
2520
+ }
2521
+ }
2522
+
2523
+ readonly getRendererDiagnostics = (): RendererDiagnostics => {
2524
+ let sceneMeshes = 0
2525
+ this.scene.traverse(object => {
2526
+ if (object instanceof THREE.Mesh || object instanceof THREE.InstancedMesh) {
2527
+ sceneMeshes += 1
2528
+ }
2529
+ })
2530
+
2531
+ const themeDiagnostics = this.themeController?.diagnostics()
2532
+ const cameraDiagnostics = this.cameraDirector.diagnostics()
2533
+ const authoritativeBodies = this.queryWorld?.bodies.len() ?? 0
2534
+ const authoritativeColliders = this.queryWorld?.colliders.len() ?? 0
2535
+ const mirrorBodies = this.marbles.length + (this.trackWorld?.bodyCount ?? 0)
2536
+ const mirrorColliders = this.marbles.length + (this.trackWorld?.colliderCount ?? 0)
2537
+ return {
2538
+ calls: this.renderer.info.render.calls,
2539
+ triangles: this.renderer.info.render.triangles,
2540
+ geometries: this.renderer.info.memory.geometries,
2541
+ textures: this.renderer.info.memory.textures,
2542
+ sceneMeshes,
2543
+ audio: this.rollingAudio?.diagnostics(),
2544
+ bodies: authoritativeBodies + mirrorBodies,
2545
+ colliders: authoritativeColliders + mirrorColliders,
2546
+ authoritativeBodies,
2547
+ authoritativeColliders,
2548
+ mirrorBodies,
2549
+ mirrorColliders,
2550
+ ccdBodies: this.marbles.length,
2551
+ sensors: this.trackWorld?.sensorCount ?? 0,
2552
+ fixedTimestep: FIXED_TIMESTEP,
2553
+ timeScale: this.timeScale,
2554
+ dpr: this.renderer.getPixelRatio(),
2555
+ paused: this.paused,
2556
+ cameraMode: raceCameraState(this.cameraView, this.cameraControl),
2557
+ cameraTargetSlotId: this.cameraTargetSlotId,
2558
+ cameraDistance: Number(this.camera.position.distanceTo(this.controls?.target ?? this.cameraTarget).toFixed(2)),
2559
+ cameraDirector: {
2560
+ state: cameraDiagnostics.state,
2561
+ ...(this.cameraSubject()?.assignment.slotId ? { subjectSlotId: this.cameraSubject()!.assignment.slotId } : {}),
2562
+ stateSeconds: Number(cameraDiagnostics.stateSeconds.toFixed(2)),
2563
+ packCount: this.cameraPackFrame.count,
2564
+ packRadius: Number(this.cameraPackFrame.radius.toFixed(2)),
2565
+ obstructed: this.cameraObstructed,
2566
+ probes: this.cameraProbeCount,
2567
+ desiredDistance: Number(this.cameraDesiredDistance.toFixed(2)),
2568
+ resolvedDistance: Number(this.cameraResolvedDistance.toFixed(2)),
2569
+ linearSpeed: Number(cameraDiagnostics.linearSpeed.toFixed(2)),
2570
+ targetSpeed: Number(cameraDiagnostics.targetSpeed.toFixed(2)),
2571
+ },
2572
+ ...(this.cameraTourPlan
2573
+ ? {
2574
+ cameraTour: {
2575
+ footprintWidth: Number(this.cameraTourPlan.diagnostics.footprintWidth.toFixed(2)),
2576
+ footprintDepth: Number(this.cameraTourPlan.diagnostics.footprintDepth.toFixed(2)),
2577
+ occupiedCells: this.cameraTourPlan.diagnostics.occupiedCells,
2578
+ routeDirection: this.cameraTourPlan.diagnostics.routeDirection,
2579
+ routeLength: Number(this.cameraTourPlan.diagnostics.routeLength.toFixed(2)),
2580
+ minimumClearance: Number(this.cameraTourPlan.diagnostics.minimumClearance.toFixed(2)),
2581
+ validationAttempts: this.cameraTourPlan.diagnostics.validationAttempts,
2582
+ cruiseAltitude: Number(this.cameraTourPlan.diagnostics.cruiseAltitude.toFixed(2)),
2583
+ pitchChanges: this.cameraTourPlan.diagnostics.pitchChanges,
2584
+ ...(this.cameraTourPlan.diagnostics.fallbackReason
2585
+ ? { fallbackReason: this.cameraTourPlan.diagnostics.fallbackReason }
2586
+ : {}),
2587
+ },
2588
+ }
2589
+ : {}),
2590
+ themeId: themeDiagnostics?.themeId ?? 'unavailable',
2591
+ themeMaterials: themeDiagnostics?.materialCount ?? 0,
2592
+ themeWorldMeshes: themeDiagnostics?.worldMeshes ?? 0,
2593
+ themeInstancedMeshes: themeDiagnostics?.instancedMeshes ?? 0,
2594
+ themeParticles: themeDiagnostics?.particleCount ?? 0,
2595
+ frameIntervalMs: Number(this.frameIntervalMs.toFixed(3)),
2596
+ physicsSteps: this.physicsStepsLastUpdate,
2597
+ physicsBacklogSteps: this.physicsBacklogSteps,
2598
+ catchUpSaturated: this.catchUpSaturated,
2599
+ maximumPhysicsBacklogSteps: this.maximumPhysicsBacklogSteps,
2600
+ catchUpSaturationCount: this.catchUpSaturationCount,
2601
+ frameTiming: this.frameTimingDiagnostics(),
2602
+ }
2603
+ }
2604
+
2605
+ private installTestHooks(): void {
2606
+ window.__MARBLE_RACE_TEST_HOOKS__ = {
2607
+ getSnapshot: this.getSnapshot,
2608
+ getRendererDiagnostics: this.getRendererDiagnostics,
2609
+ getPhysicsDebugReport: this.getPhysicsDebugReport,
2610
+ getMarbleRenderStates: this.getMarbleRenderStates,
2611
+ setPaused: paused => this.setPaused(paused),
2612
+ setTimeScale: timeScale => this.setTimeScale(timeScale),
2613
+ followMarble: slotId => this.followMarble(slotId),
2614
+ advanceTicks: ticks => {
2615
+ const safeTicks = Math.min(20_000, Math.max(0, Math.floor(ticks)))
2616
+ for (let tick = 0; tick < safeTicks && this.phase !== 'finished'; tick += 1) {
2617
+ this.fixedStep()
2618
+ }
2619
+ this.syncVisuals(1)
2620
+ this.updateCamera(1 / 60)
2621
+ this.prepareRender()
2622
+ this.render(1 / 60)
2623
+ this.emitSnapshot(true)
2624
+ return this.latestSnapshot
2625
+ },
2626
+ }
2627
+ }
2628
+ }
2629
+
2630
+ export function prepareMarbleRacePhysicsTrack(track: TrackDefinition): PreparedPhysicsTrack {
2631
+ return preparePhysicsTrack(track)
2632
+ }
2633
+
2634
+ export function createPreparedMarbleRaceSimulation(
2635
+ configuration: RaceConfiguration,
2636
+ track: TrackDefinition,
2637
+ preparedPhysicsTrack: PreparedPhysicsTrack,
2638
+ diagnosticsEnabled: boolean,
2639
+ ): Promise<DeterministicRaceSimulation> {
2640
+ return createPreparedRaceSimulation(
2641
+ {
2642
+ engineVersion: CURRENT_RACE_ENGINE_VERSION,
2643
+ seed: configuration.seed,
2644
+ trackId: track.id,
2645
+ trackJson: track.sourceDocument ? JSON.stringify(track.sourceDocument) : '{}',
2646
+ entrants: configuration.assignments,
2647
+ },
2648
+ preparedPhysicsTrack,
2649
+ undefined,
2650
+ { enabled: diagnosticsEnabled },
2651
+ )
2652
+ }