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