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