@marble.fun/track-catalog 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +15 -0
  3. package/assets.json +346 -0
  4. package/package.json +32 -0
  5. package/release.json +135 -0
  6. package/src/audio/game-audio-preference.ts +9 -0
  7. package/src/authoritative.ts +6 -0
  8. package/src/catalog.ts +1 -0
  9. package/src/index.ts +14 -0
  10. package/src/legacy-stock-catalog.ts +130 -0
  11. package/src/pages/lobby/lobby-types.ts +140 -0
  12. package/src/pages/lobby/marble-factory.ts +931 -0
  13. package/src/pages/play/race/egypt-elevator-blender.ts +159 -0
  14. package/src/pages/play/race/egypt-portal-frame.ts +382 -0
  15. package/src/pages/play/race/finish-detection.ts +1 -0
  16. package/src/pages/play/race/japanese-sakura-castle-support-blender.ts +510 -0
  17. package/src/pages/play/race/japanese-sakura-kawara-roof.ts +201 -0
  18. package/src/pages/play/race/japanese-sakura-portal-frame.ts +325 -0
  19. package/src/pages/play/race/jungle-portal-frame.ts +308 -0
  20. package/src/pages/play/race/jungle-temple-lift.ts +366 -0
  21. package/src/pages/play/race/marble-entity.ts +257 -0
  22. package/src/pages/play/race/marble-material.ts +20 -0
  23. package/src/pages/play/race/marble-race-engine.ts +2652 -0
  24. package/src/pages/play/race/path-pattern-layout.ts +142 -0
  25. package/src/pages/play/race/peg-obstacle-visual.ts +171 -0
  26. package/src/pages/play/race/physics-debug.ts +1 -0
  27. package/src/pages/play/race/physics-dry-run.ts +90 -0
  28. package/src/pages/play/race/portal-derived-elevator-skins.ts +729 -0
  29. package/src/pages/play/race/portal-pair-color.ts +20 -0
  30. package/src/pages/play/race/portal-proportions.ts +25 -0
  31. package/src/pages/play/race/portal-visual.ts +1108 -0
  32. package/src/pages/play/race/race-camera-director.ts +586 -0
  33. package/src/pages/play/race/race-camera-obstruction.ts +37 -0
  34. package/src/pages/play/race/race-camera-tour-planner.ts +274 -0
  35. package/src/pages/play/race/race-camera.ts +206 -0
  36. package/src/pages/play/race/race-completion.ts +5 -0
  37. package/src/pages/play/race/race-configuration.ts +57 -0
  38. package/src/pages/play/race/race-marble-focus-renderer.ts +134 -0
  39. package/src/pages/play/race/race-physics-profile.ts +7 -0
  40. package/src/pages/play/race/race-physics.ts +16 -0
  41. package/src/pages/play/race/race-playback.ts +41 -0
  42. package/src/pages/play/race/race-rolling-audio.ts +616 -0
  43. package/src/pages/play/race/race-simulation-renderer.ts +221 -0
  44. package/src/pages/play/race/race-terminal-checker-strip.ts +226 -0
  45. package/src/pages/play/race/race-transform-interpolation.ts +50 -0
  46. package/src/pages/play/race/race-types.ts +12 -0
  47. package/src/pages/play/race/rebound-visual.ts +70 -0
  48. package/src/pages/play/race/seeded-random.ts +23 -0
  49. package/src/pages/play/race/space-station-portal-frame.ts +275 -0
  50. package/src/pages/play/race/track-appearance-canopy.ts +946 -0
  51. package/src/pages/play/race/track-appearance-floor-runner.ts +206 -0
  52. package/src/pages/play/race/track-appearance-jungle-flower-hedge.ts +483 -0
  53. package/src/pages/play/race/track-appearance-rope-wall.ts +443 -0
  54. package/src/pages/play/race/track-appearance-sakura-bridge-fence.ts +305 -0
  55. package/src/pages/play/race/track-appearance-sakura-castle-wall.ts +1031 -0
  56. package/src/pages/play/race/track-appearance-space-station-wall.ts +497 -0
  57. package/src/pages/play/race/track-appearance-stone-block-shell.ts +517 -0
  58. package/src/pages/play/race/track-appearance-support.ts +983 -0
  59. package/src/pages/play/race/track-appearance-vampire-carved-wall.ts +545 -0
  60. package/src/pages/play/race/track-appearance-wall-terminal.ts +167 -0
  61. package/src/pages/play/race/track-course.ts +271 -0
  62. package/src/pages/play/race/track-definition.ts +443 -0
  63. package/src/pages/play/race/track-fence-architecture.ts +33 -0
  64. package/src/pages/play/race/track-floor-material-rules.ts +51 -0
  65. package/src/pages/play/race/track-mechanisms.ts +1350 -0
  66. package/src/pages/play/race/track-physics-surface.ts +6 -0
  67. package/src/pages/play/race/track-route-network.ts +9 -0
  68. package/src/pages/play/race/track-shadow-batching.ts +760 -0
  69. package/src/pages/play/race/track-surface-basis.ts +1 -0
  70. package/src/pages/play/race/track-surface-topology.ts +326 -0
  71. package/src/pages/play/race/track-world.ts +10181 -0
  72. package/src/pages/play/race/under-track-support-architecture.ts +122 -0
  73. package/src/pages/play/race/vortex-profile.ts +5 -0
  74. package/src/pages/play/race/vortex-water-effect.ts +863 -0
  75. package/src/pages/play/race/wild-west-mine-lift.ts +281 -0
  76. package/src/pages/play/race/wild-west-vortex-housing.ts +572 -0
  77. package/src/pages/track-lab/atlas-vortex-layout.ts +1 -0
  78. package/src/pages/track-lab/cone-field-layout.ts +1 -0
  79. package/src/pages/track-lab/conveyor-layout.ts +1 -0
  80. package/src/pages/track-lab/engine/canonical-track-assembly.ts +64 -0
  81. package/src/pages/track-lab/engine/editor-object-geometry.ts +170 -0
  82. package/src/pages/track-lab/engine/track-lab-connection-state.ts +123 -0
  83. package/src/pages/track-lab/engine/track-piece-visual.ts +1420 -0
  84. package/src/pages/track-lab/flipper-bank-layout.ts +1 -0
  85. package/src/pages/track-lab/flipper-component.ts +127 -0
  86. package/src/pages/track-lab/rising-wall-layout.ts +1 -0
  87. package/src/pages/track-lab/simple-obstacle-layout.ts +1 -0
  88. package/src/pages/track-lab/track-piece-layout.ts +28 -0
  89. package/src/pages/track-lab/track-runtime-adapter.ts +1152 -0
  90. package/src/three/geometry-attribute-aliases.ts +24 -0
  91. package/src/three/geometry-detail.ts +36 -0
  92. package/src/three/homepage-marble-geometry.ts +15 -0
  93. package/src/three/japanese-sakura-appearance.ts +252 -0
  94. package/src/three/jungle-flower-hedge-appearance.ts +7 -0
  95. package/src/three/jungle-temple-appearance.ts +5 -0
  96. package/src/three/low-quality-scene-renderer.ts +132 -0
  97. package/src/three/marble-sphere-geometry.ts +13 -0
  98. package/src/three/mobile-rendering-budget.ts +11 -0
  99. package/src/three/painted-valley-appearance.ts +270 -0
  100. package/src/three/railroad-appearance.ts +71 -0
  101. package/src/three/renderer-resize.ts +32 -0
  102. package/src/three/rope-wall-appearance.ts +91 -0
  103. package/src/three/rugged-track-appearance.ts +2 -0
  104. package/src/three/scene-lighting.ts +183 -0
  105. package/src/three/scene-shader-preparation.ts +64 -0
  106. package/src/three/scene-theme-controller.ts +461 -0
  107. package/src/three/shadow-shader-preparation.ts +162 -0
  108. package/src/three/shared-obstacle-metal.ts +44 -0
  109. package/src/three/soft-additive-particles.ts +57 -0
  110. package/src/three/space-station-appearance.ts +213 -0
  111. package/src/three/static-track-batched-mesh.ts +132 -0
  112. package/src/three/stone-track-shell-appearance.ts +12 -0
  113. package/src/three/theme-ambience-controller.ts +226 -0
  114. package/src/three/theme-asset-cache.ts +120 -0
  115. package/src/three/theme-audio-preference.ts +43 -0
  116. package/src/three/theme-catalog.ts +1457 -0
  117. package/src/three/theme-material-assets.ts +258 -0
  118. package/src/three/theme-material-ktx2-manifest.ts +190 -0
  119. package/src/three/theme-material-registry.ts +1952 -0
  120. package/src/three/theme-world-factory.ts +590 -0
  121. package/src/three/track-connector-batch.ts +125 -0
  122. package/src/three/track-material-continuity.ts +442 -0
  123. package/src/three/track-theme.ts +535 -0
  124. package/src/three/vampire-castle-appearance.ts +258 -0
  125. package/src/three/visual-frame-pacing.ts +19 -0
  126. package/src/three/visual-geometry.ts +120 -0
  127. package/src/three/visual-quality.ts +83 -0
  128. package/src/three/water-appearance.ts +29 -0
  129. package/src/three/wild-west-sand-appearance.ts +2 -0
@@ -0,0 +1,221 @@
1
+ import type {
2
+ MarbleSimulationSnapshot,
3
+ MechanismSimulationPart,
4
+ RaceSimulationSnapshot,
5
+ SimulationQuaternion,
6
+ SimulationVector3,
7
+ } from '@marble.fun/physics-engine'
8
+ import * as THREE from 'three'
9
+
10
+ const PORTAL_SNAP_DISTANCE_SQUARED = 4
11
+
12
+ interface RisingWallVisualMetadata {
13
+ anchor: SimulationVector3
14
+ up: SimulationVector3
15
+ wallHeight: number
16
+ }
17
+
18
+ export interface RenderedMarbleSimulationState {
19
+ snapshot: MarbleSimulationSnapshot
20
+ position: SimulationVector3
21
+ rotation: SimulationQuaternion
22
+ }
23
+
24
+ export interface RaceSimulationRenderBindings {
25
+ marbleObjects: ReadonlyMap<string, THREE.Object3D>
26
+ mechanismPartObjects: ReadonlyMap<string, THREE.Object3D>
27
+ onMarbleState?(state: RenderedMarbleSimulationState): void
28
+ }
29
+
30
+ function indexedParts(snapshot: RaceSimulationSnapshot): Map<string, MechanismSimulationPart> {
31
+ const parts = new Map<string, MechanismSimulationPart>()
32
+ for (const mechanism of snapshot.mechanisms) {
33
+ for (const part of mechanism.parts ?? []) parts.set(part.id, part)
34
+ }
35
+ return parts
36
+ }
37
+
38
+ function visualObjectNameForPart(partId: string): string | undefined {
39
+ if (partId.endsWith(':paddle')) return `${partId.slice(0, -':paddle'.length)}-splitter-paddle`
40
+ const indexedPart = /^(.*):(shelf|flipper|rebound|wall):(\d+)$/.exec(partId)
41
+ if (!indexedPart) return undefined
42
+ const [, mechanismId, kind, rawIndex] = indexedPart
43
+ const index = Number(rawIndex)
44
+ switch (kind) {
45
+ case 'shelf':
46
+ return `${mechanismId}-conveyor-shelf-${index + 1}`
47
+ case 'flipper':
48
+ return `${mechanismId}-flipper-${index + 1}`
49
+ case 'rebound':
50
+ return `${mechanismId}-rebound-${index}`
51
+ case 'wall':
52
+ return `${mechanismId}-rising-wall-${index}`
53
+ default:
54
+ return undefined
55
+ }
56
+ }
57
+
58
+ /** Resolves stable core part ids onto the visual objects built by track-runtime. */
59
+ export function bindMechanismPartObjects(
60
+ root: THREE.Object3D,
61
+ snapshot: RaceSimulationSnapshot,
62
+ ): ReadonlyMap<string, THREE.Object3D> {
63
+ const bindings = new Map<string, THREE.Object3D>()
64
+ for (const part of indexedParts(snapshot).values()) {
65
+ const objectName = visualObjectNameForPart(part.id)
66
+ const object = objectName ? root.getObjectByName(objectName) : undefined
67
+ if (object) bindings.set(part.id, object)
68
+ }
69
+ return bindings
70
+ }
71
+
72
+ function writeTransform(
73
+ object: THREE.Object3D,
74
+ previousPosition: SimulationVector3,
75
+ previousRotation: SimulationQuaternion,
76
+ currentPosition: SimulationVector3,
77
+ currentRotation: SimulationQuaternion,
78
+ alpha: number,
79
+ snapLargeTranslation: boolean,
80
+ rotationScratch: THREE.Quaternion,
81
+ ): void {
82
+ const dx = currentPosition[0] - previousPosition[0]
83
+ const dy = currentPosition[1] - previousPosition[1]
84
+ const dz = currentPosition[2] - previousPosition[2]
85
+ const progress = snapLargeTranslation && dx * dx + dy * dy + dz * dz > PORTAL_SNAP_DISTANCE_SQUARED ? 1 : alpha
86
+ object.position.set(
87
+ previousPosition[0] + dx * progress,
88
+ previousPosition[1] + dy * progress,
89
+ previousPosition[2] + dz * progress,
90
+ )
91
+ object.quaternion.fromArray(previousRotation).slerp(rotationScratch.fromArray(currentRotation), progress)
92
+ }
93
+
94
+ function cropRisingWallVisual(object: THREE.Object3D): void {
95
+ const metadata = object.userData.risingWallVisual as RisingWallVisualMetadata | undefined
96
+ if (!metadata) return
97
+ const { anchor, up, wallHeight } = metadata
98
+ const centerOffset =
99
+ (object.position.x - anchor[0]) * up[0] +
100
+ (object.position.y - anchor[1]) * up[1] +
101
+ (object.position.z - anchor[2]) * up[2]
102
+ const exposedHeight = THREE.MathUtils.clamp(centerOffset + wallHeight / 2, 0, wallHeight)
103
+ object.position.set(
104
+ anchor[0] + up[0] * exposedHeight / 2,
105
+ anchor[1] + up[1] * exposedHeight / 2,
106
+ anchor[2] + up[2] * exposedHeight / 2,
107
+ )
108
+ object.scale.y = exposedHeight / wallHeight
109
+ object.visible = exposedHeight > 0.001
110
+ }
111
+
112
+ /**
113
+ * Visual-only interpolation of immutable core snapshots. This class has no
114
+ * Rapier world/body access and therefore cannot alter authoritative motion.
115
+ */
116
+ export class RaceSimulationRenderer {
117
+ #previous?: RaceSimulationSnapshot
118
+ #current?: RaceSimulationSnapshot
119
+ #previousMarbles = new Map<string, MarbleSimulationSnapshot>()
120
+ #currentMarbles = new Map<string, MarbleSimulationSnapshot>()
121
+ #previousParts = new Map<string, MechanismSimulationPart>()
122
+ #currentParts = new Map<string, MechanismSimulationPart>()
123
+ readonly #rotationScratch = new THREE.Quaternion()
124
+
125
+ constructor(readonly bindings: RaceSimulationRenderBindings) {}
126
+
127
+ push(snapshot: RaceSimulationSnapshot): void {
128
+ if (this.#current && snapshot.simulationTick < this.#current.simulationTick) {
129
+ throw new Error(`Cannot render stale simulation tick ${snapshot.simulationTick}`)
130
+ }
131
+ if (!this.#current || snapshot.simulationTick === this.#current.simulationTick) {
132
+ this.#current = snapshot
133
+ if (!this.#previous) {
134
+ this.#previous = snapshot
135
+ this.indexSnapshot(snapshot, this.#previousMarbles, this.#previousParts)
136
+ }
137
+ this.indexSnapshot(snapshot, this.#currentMarbles, this.#currentParts)
138
+ return
139
+ }
140
+ this.#previous = this.#current
141
+ this.#current = snapshot
142
+ const reusableMarbles = this.#previousMarbles
143
+ this.#previousMarbles = this.#currentMarbles
144
+ this.#currentMarbles = reusableMarbles
145
+ const reusableParts = this.#previousParts
146
+ this.#previousParts = this.#currentParts
147
+ this.#currentParts = reusableParts
148
+ this.indexSnapshot(snapshot, this.#currentMarbles, this.#currentParts)
149
+ }
150
+
151
+ render(interpolationAlpha: number): void {
152
+ const current = this.#current
153
+ if (!current) return
154
+ const alpha = THREE.MathUtils.clamp(interpolationAlpha, 0, 1)
155
+ for (const marble of current.marbles) {
156
+ const before = this.#previousMarbles.get(marble.slotId) ?? marble
157
+ const object = this.bindings.marbleObjects.get(marble.slotId)
158
+ if (object) {
159
+ writeTransform(
160
+ object,
161
+ before.position,
162
+ before.rotation,
163
+ marble.position,
164
+ marble.rotation,
165
+ alpha,
166
+ true,
167
+ this.#rotationScratch,
168
+ )
169
+ }
170
+ if (this.bindings.onMarbleState) {
171
+ const position: SimulationVector3 = object
172
+ ? [object.position.x, object.position.y, object.position.z]
173
+ : marble.position
174
+ const rotation: SimulationQuaternion = object
175
+ ? [object.quaternion.x, object.quaternion.y, object.quaternion.z, object.quaternion.w]
176
+ : marble.rotation
177
+ this.bindings.onMarbleState({ snapshot: marble, position, rotation })
178
+ }
179
+ }
180
+
181
+ for (const part of this.#currentParts.values()) {
182
+ const before = this.#previousParts.get(part.id) ?? part
183
+ const object = this.bindings.mechanismPartObjects.get(part.id)
184
+ if (object) {
185
+ writeTransform(
186
+ object,
187
+ before.position,
188
+ before.rotation,
189
+ part.position,
190
+ part.rotation,
191
+ alpha,
192
+ false,
193
+ this.#rotationScratch,
194
+ )
195
+ cropRisingWallVisual(object)
196
+ }
197
+ }
198
+ }
199
+
200
+ private indexSnapshot(
201
+ snapshot: RaceSimulationSnapshot,
202
+ marbles: Map<string, MarbleSimulationSnapshot>,
203
+ parts: Map<string, MechanismSimulationPart>,
204
+ ): void {
205
+ marbles.clear()
206
+ parts.clear()
207
+ for (const marble of snapshot.marbles) marbles.set(marble.slotId, marble)
208
+ for (const mechanism of snapshot.mechanisms) {
209
+ for (const part of mechanism.parts ?? []) parts.set(part.id, part)
210
+ }
211
+ }
212
+
213
+ clear(): void {
214
+ this.#previous = undefined
215
+ this.#current = undefined
216
+ this.#previousMarbles.clear()
217
+ this.#currentMarbles.clear()
218
+ this.#previousParts.clear()
219
+ this.#currentParts.clear()
220
+ }
221
+ }
@@ -0,0 +1,226 @@
1
+ import * as THREE from 'three'
2
+
3
+ export const RACE_TERMINAL_CHECKER_STRIP_ROWS = 2
4
+ export const RACE_TERMINAL_CHECKER_STRIP_DEPTH = 0.9
5
+ export const RACE_TERMINAL_CHECKER_CELL_SIZE = RACE_TERMINAL_CHECKER_STRIP_DEPTH / RACE_TERMINAL_CHECKER_STRIP_ROWS
6
+ export const RACE_TERMINAL_CHECKER_STRIP_FACE_OFFSET = 0.012
7
+
8
+ export interface RaceTerminalCheckerStripSurface {
9
+ totalLength: number
10
+ startDistance: number
11
+ floorThickness: number
12
+ includeEntryFace: boolean
13
+ widthAt(distance: number): number
14
+ topPointAt(distance: number, normalizedLateral: number): THREE.Vector3
15
+ upAt(distance: number): THREE.Vector3
16
+ outward: THREE.Vector3
17
+ }
18
+
19
+ /**
20
+ * Splits decorative terminal floor details around the checker. The start grid
21
+ * has no approach run because its checker begins at the exposed entry. The
22
+ * finish keeps its funnel-side architecture and resumes it at the checker's
23
+ * rear edge. Side walls, floor geometry, and colliders deliberately keep their
24
+ * original full-piece paths.
25
+ */
26
+ export function createRaceTerminalFloorDetailFrameRuns<Frame>(
27
+ frames: readonly Frame[],
28
+ distances: readonly number[],
29
+ frameAtDistance: (distance: number) => Frame,
30
+ checkerStartDistance = 0,
31
+ ): readonly (readonly Frame[])[] {
32
+ const totalLength = distances.at(-1) ?? 0
33
+ if (frames.length < 2 || totalLength <= 0.000_001) return []
34
+ const checkerStart = THREE.MathUtils.clamp(checkerStartDistance, 0, totalLength)
35
+ const checkerEnd = Math.min(checkerStart + RACE_TERMINAL_CHECKER_STRIP_DEPTH, totalLength)
36
+ const frameAtBoundary = (distance: number): Frame => {
37
+ const exactFrameIndex = distances.findIndex(candidate => Math.abs(candidate - distance) <= 0.000_001)
38
+ return exactFrameIndex >= 0 ? frames[exactFrameIndex]! : frameAtDistance(distance)
39
+ }
40
+
41
+ const sliceThroughBoundary = (startDistance: number, endDistance: number): readonly Frame[] => {
42
+ if (endDistance - startDistance <= 0.000_001) return []
43
+ const run: Frame[] = [frameAtBoundary(startDistance)]
44
+ for (let index = 0; index < frames.length; index += 1) {
45
+ const distance = distances[index]
46
+ if (distance === undefined || distance <= startDistance + 0.000_001 || distance >= endDistance - 0.000_001) {
47
+ continue
48
+ }
49
+ run.push(frames[index]!)
50
+ }
51
+ run.push(frameAtBoundary(endDistance))
52
+ return run
53
+ }
54
+
55
+ const approach = sliceThroughBoundary(0, checkerStart)
56
+ const exit = sliceThroughBoundary(checkerEnd, totalLength)
57
+
58
+ return [approach, exit].filter(run => run.length >= 2)
59
+ }
60
+
61
+ /** Locates the first frame of the final constant-width, single-file channel. */
62
+ export function resolveRaceTerminalCheckerStartDistance(
63
+ raceRole: 'start' | 'finish',
64
+ widths: readonly number[],
65
+ distances: readonly number[],
66
+ ): number {
67
+ if (raceRole === 'start' || widths.length === 0) return 0
68
+ const terminalWidth = widths.at(-1)!
69
+ for (let index = 0; index < widths.length; index += 1) {
70
+ if (widths.slice(index).every(width => Math.abs(width - terminalWidth) <= 0.000_001)) {
71
+ return distances[index] ?? 0
72
+ }
73
+ }
74
+ return 0
75
+ }
76
+
77
+ export function raceTerminalCheckerColumnCount(width: number): number {
78
+ return Math.max(2, Math.round(width / RACE_TERMINAL_CHECKER_CELL_SIZE))
79
+ }
80
+
81
+ const LIGHT = new THREE.Color('#f1f3f5')
82
+ const DARK = new THREE.Color('#20252a')
83
+
84
+ /** Adds one permanent visual checkerboard wrapped over a terminal's top and entry face. */
85
+ export function addRaceTerminalCheckerStrip(
86
+ root: THREE.Group,
87
+ id: string,
88
+ surface: RaceTerminalCheckerStripSurface,
89
+ ): THREE.Mesh | undefined {
90
+ if (surface.totalLength <= 0.000_001 || surface.floorThickness <= 0.000_001) return undefined
91
+ const checkerStartDistance = THREE.MathUtils.clamp(surface.startDistance, 0, surface.totalLength)
92
+ const checkerDepth = Math.min(RACE_TERMINAL_CHECKER_STRIP_DEPTH, surface.totalLength - checkerStartDistance)
93
+ if (checkerDepth <= 0.000_001) return undefined
94
+ const checkerWidth = surface.widthAt(checkerStartDistance)
95
+ const checkerColumns = raceTerminalCheckerColumnCount(checkerWidth)
96
+ const checkerFaceRows = Math.max(1, Math.round(surface.floorThickness / RACE_TERMINAL_CHECKER_CELL_SIZE))
97
+ const entryUp = surface.upAt(checkerStartDistance).clone().normalize()
98
+ const outward = surface.outward.clone().normalize()
99
+
100
+ const positions: number[] = []
101
+ const colors: number[] = []
102
+ const indices: number[] = []
103
+ const append = (point: THREE.Vector3, color: THREE.Color): number => {
104
+ const index = positions.length / 3
105
+ positions.push(point.x, point.y, point.z)
106
+ colors.push(color.r, color.g, color.b)
107
+ return index
108
+ }
109
+ const appendQuad = (
110
+ points: readonly [THREE.Vector3, THREE.Vector3, THREE.Vector3, THREE.Vector3],
111
+ color: THREE.Color,
112
+ expectedNormal: THREE.Vector3,
113
+ ): void => {
114
+ const base = append(points[0], color)
115
+ append(points[1], color)
116
+ append(points[2], color)
117
+ append(points[3], color)
118
+ const normal = points[1].clone().sub(points[0]).cross(points[2].clone().sub(points[0]))
119
+ if (normal.dot(expectedNormal) >= 0) {
120
+ indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
121
+ } else {
122
+ indices.push(base, base + 2, base + 1, base, base + 3, base + 2)
123
+ }
124
+ }
125
+
126
+ // Top surface: follow the real ribbon sampler so curves, grades, and troughs match.
127
+ for (let row = 0; row < RACE_TERMINAL_CHECKER_STRIP_ROWS; row += 1) {
128
+ const startDistance = checkerStartDistance + (row / RACE_TERMINAL_CHECKER_STRIP_ROWS) * checkerDepth
129
+ const endDistance = checkerStartDistance + ((row + 1) / RACE_TERMINAL_CHECKER_STRIP_ROWS) * checkerDepth
130
+ for (let column = 0; column < checkerColumns; column += 1) {
131
+ const startLateral = -1 + (column / checkerColumns) * 2
132
+ const endLateral = -1 + ((column + 1) / checkerColumns) * 2
133
+ const color = (row + column) % 2 === 0 ? LIGHT : DARK
134
+ const startUp = surface.upAt(startDistance)
135
+ const endUp = surface.upAt(endDistance)
136
+ appendQuad(
137
+ [
138
+ surface
139
+ .topPointAt(startDistance, startLateral)
140
+ .addScaledVector(startUp, RACE_TERMINAL_CHECKER_STRIP_FACE_OFFSET),
141
+ surface.topPointAt(endDistance, startLateral).addScaledVector(endUp, RACE_TERMINAL_CHECKER_STRIP_FACE_OFFSET),
142
+ surface.topPointAt(endDistance, endLateral).addScaledVector(endUp, RACE_TERMINAL_CHECKER_STRIP_FACE_OFFSET),
143
+ surface
144
+ .topPointAt(startDistance, endLateral)
145
+ .addScaledVector(startUp, RACE_TERMINAL_CHECKER_STRIP_FACE_OFFSET),
146
+ ],
147
+ color,
148
+ surface.upAt((startDistance + endDistance) / 2),
149
+ )
150
+ }
151
+ }
152
+
153
+ // Only the start grid owns an exposed entry cap. The finish line is an
154
+ // internal floor marking at the single-file transition.
155
+ for (let row = 0; surface.includeEntryFace && row < checkerFaceRows; row += 1) {
156
+ const topDrop = (row / checkerFaceRows) * surface.floorThickness
157
+ const bottomDrop = ((row + 1) / checkerFaceRows) * surface.floorThickness
158
+ for (let column = 0; column < checkerColumns; column += 1) {
159
+ const startLateral = -1 + (column / checkerColumns) * 2
160
+ const endLateral = -1 + ((column + 1) / checkerColumns) * 2
161
+ const color = (row + column + 1) % 2 === 0 ? LIGHT : DARK
162
+ appendQuad(
163
+ [
164
+ surface
165
+ .topPointAt(checkerStartDistance, startLateral)
166
+ .addScaledVector(entryUp, -topDrop)
167
+ .addScaledVector(outward, RACE_TERMINAL_CHECKER_STRIP_FACE_OFFSET),
168
+ surface
169
+ .topPointAt(checkerStartDistance, endLateral)
170
+ .addScaledVector(entryUp, -topDrop)
171
+ .addScaledVector(outward, RACE_TERMINAL_CHECKER_STRIP_FACE_OFFSET),
172
+ surface
173
+ .topPointAt(checkerStartDistance, endLateral)
174
+ .addScaledVector(entryUp, -bottomDrop)
175
+ .addScaledVector(outward, RACE_TERMINAL_CHECKER_STRIP_FACE_OFFSET),
176
+ surface
177
+ .topPointAt(checkerStartDistance, startLateral)
178
+ .addScaledVector(entryUp, -bottomDrop)
179
+ .addScaledVector(outward, RACE_TERMINAL_CHECKER_STRIP_FACE_OFFSET),
180
+ ],
181
+ color,
182
+ outward,
183
+ )
184
+ }
185
+ }
186
+
187
+ const geometry = new THREE.BufferGeometry()
188
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
189
+ geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3))
190
+ geometry.setIndex(indices)
191
+ geometry.computeVertexNormals()
192
+ geometry.computeBoundingBox()
193
+ geometry.computeBoundingSphere()
194
+
195
+ const material = new THREE.MeshStandardMaterial({
196
+ vertexColors: true,
197
+ roughness: 0.78,
198
+ metalness: 0,
199
+ side: THREE.FrontSide,
200
+ polygonOffset: true,
201
+ polygonOffsetFactor: -1,
202
+ polygonOffsetUnits: -1,
203
+ })
204
+ material.name = 'race-terminal-checker-strip-material'
205
+ const strip = new THREE.Mesh(geometry, material)
206
+ strip.name = `${id}-race-terminal-checker-strip`
207
+ strip.receiveShadow = true
208
+ strip.renderOrder = 4
209
+ strip.userData.ownsMaterial = true
210
+ strip.userData.visualOnly = true
211
+ strip.userData.decorative = true
212
+ strip.userData.themePermanent = true
213
+ strip.userData.checkerRows = RACE_TERMINAL_CHECKER_STRIP_ROWS
214
+ strip.userData.checkerColumns = checkerColumns
215
+ strip.userData.checkerFaceRows = surface.includeEntryFace ? checkerFaceRows : 0
216
+ strip.userData.checkerCellTargetSize = RACE_TERMINAL_CHECKER_CELL_SIZE
217
+ strip.userData.checkerCellWidth = checkerWidth / checkerColumns
218
+ strip.userData.checkerCellDepth = checkerDepth / RACE_TERMINAL_CHECKER_STRIP_ROWS
219
+ strip.userData.checkerStartDistance = checkerStartDistance
220
+ strip.userData.checkerDepth = checkerDepth
221
+ strip.userData.checkerFaceHeight = surface.includeEntryFace ? surface.floorThickness : 0
222
+ strip.userData.wrapsEntryFace = surface.includeEntryFace
223
+ strip.userData.faceOffset = RACE_TERMINAL_CHECKER_STRIP_FACE_OFFSET
224
+ root.add(strip)
225
+ return strip
226
+ }
@@ -0,0 +1,50 @@
1
+ import * as THREE from 'three'
2
+
3
+ interface TransformSource {
4
+ translation(): { x: number; y: number; z: number }
5
+ rotation(): { x: number; y: number; z: number; w: number }
6
+ }
7
+
8
+ export function clampInterpolationAlpha(alpha: number): number {
9
+ return Number.isFinite(alpha) ? THREE.MathUtils.clamp(alpha, 0, 1) : 1
10
+ }
11
+
12
+ /** Render-only history for one authoritative rigid-body transform. */
13
+ export class RigidBodyTransformInterpolator {
14
+ readonly previousPosition = new THREE.Vector3()
15
+ readonly currentPosition = new THREE.Vector3()
16
+ readonly previousRotation = new THREE.Quaternion()
17
+ readonly currentRotation = new THREE.Quaternion()
18
+
19
+ constructor(source: TransformSource) {
20
+ this.reset(source)
21
+ }
22
+
23
+ reset(source: TransformSource): void {
24
+ const position = source.translation()
25
+ const rotation = source.rotation()
26
+ this.currentPosition.set(position.x, position.y, position.z)
27
+ this.previousPosition.copy(this.currentPosition)
28
+ this.currentRotation.set(rotation.x, rotation.y, rotation.z, rotation.w).normalize()
29
+ this.previousRotation.copy(this.currentRotation)
30
+ }
31
+
32
+ capture(source: TransformSource, snapDistance = Number.POSITIVE_INFINITY): void {
33
+ this.previousPosition.copy(this.currentPosition)
34
+ this.previousRotation.copy(this.currentRotation)
35
+ const position = source.translation()
36
+ const rotation = source.rotation()
37
+ this.currentPosition.set(position.x, position.y, position.z)
38
+ this.currentRotation.set(rotation.x, rotation.y, rotation.z, rotation.w).normalize()
39
+ if (this.previousPosition.distanceToSquared(this.currentPosition) > snapDistance ** 2) {
40
+ this.previousPosition.copy(this.currentPosition)
41
+ this.previousRotation.copy(this.currentRotation)
42
+ }
43
+ }
44
+
45
+ write(object: THREE.Object3D, alpha: number): void {
46
+ const safeAlpha = clampInterpolationAlpha(alpha)
47
+ object.position.lerpVectors(this.previousPosition, this.currentPosition, safeAlpha)
48
+ object.quaternion.slerpQuaternions(this.previousRotation, this.currentRotation, safeAlpha)
49
+ }
50
+ }
@@ -0,0 +1,12 @@
1
+ export type {
2
+ MarbleAssignment,
3
+ MarbleStanding,
4
+ MarbleTuning,
5
+ RaceConfiguration,
6
+ RacePhase,
7
+ RaceSnapshot,
8
+ RaceTuning,
9
+ RaceWinner,
10
+ } from '@marble.fun/physics-engine'
11
+
12
+ export { MAX_RACE_MARBLES as MAX_MARBLES, MIN_RACE_MARBLES as MIN_MARBLES } from '@marble.fun/physics-engine'
@@ -0,0 +1,70 @@
1
+ import * as THREE from 'three'
2
+
3
+ import { VisualCapsuleGeometry } from '../../../three/visual-geometry'
4
+
5
+ export interface ReboundVisualFactoryOptions {
6
+ reboundLength: number
7
+ reboundWidth: number
8
+ reboundHeight: number
9
+ bodyMaterial: THREE.Material
10
+ accentMaterial: THREE.Material
11
+ }
12
+
13
+ /** Creates shared visual geometry and a cheap per-bumper Group factory. */
14
+ export function createReboundVisualFactory(options: ReboundVisualFactoryOptions): () => THREE.Group {
15
+ const barLength = options.reboundLength - options.reboundWidth
16
+ const capRadius = options.reboundWidth / 2
17
+ const shellGeometry = createReboundShellGeometry(
18
+ capRadius,
19
+ barLength,
20
+ options.reboundHeight / options.reboundWidth,
21
+ )
22
+
23
+ return () => {
24
+ const visual = new THREE.Group()
25
+ // One closed shell avoids the old body/end-cap intersections, while
26
+ // geometry groups retain the red centre and dark armored ends.
27
+ const bar = new THREE.Mesh(shellGeometry, [options.bodyMaterial, options.accentMaterial])
28
+ bar.name = 'body'
29
+ bar.userData.reboundVisualStyle = 'reinforced-single-shell-bumper'
30
+ bar.castShadow = true
31
+ bar.receiveShadow = true
32
+ visual.add(bar)
33
+ return visual
34
+ }
35
+ }
36
+
37
+ function createReboundShellGeometry(
38
+ radius: number,
39
+ bodyLength: number,
40
+ heightScale: number,
41
+ ): THREE.BufferGeometry {
42
+ const capsule = new VisualCapsuleGeometry(radius, bodyLength, 8, 24)
43
+ capsule.rotateZ(Math.PI / 2)
44
+ capsule.scale(1, heightScale, 1)
45
+ const geometry = capsule.toNonIndexed()
46
+ capsule.dispose()
47
+ geometry.clearGroups()
48
+ const positions = geometry.getAttribute('position')
49
+ const capStart = bodyLength / 2
50
+ const bodyIndices: number[] = []
51
+ const accentIndices: number[] = []
52
+ for (let vertex = 0; vertex < positions.count; vertex += 3) {
53
+ const centerX = (positions.getX(vertex) + positions.getX(vertex + 1) + positions.getX(vertex + 2)) / 3
54
+ const indices = Math.abs(centerX) >= capStart ? accentIndices : bodyIndices
55
+ indices.push(vertex, vertex + 1, vertex + 2)
56
+ }
57
+ // CapsuleGeometry arrives as alternating body/cap triangles. One group per
58
+ // triangle made each tiny bumper cost hundreds of draw calls despite using
59
+ // only two opaque materials. Reordering the index stream by material keeps
60
+ // every position, normal, UV, triangle, and material assignment identical
61
+ // while reducing the shell to one submission per material.
62
+ geometry.setIndex([...bodyIndices, ...accentIndices])
63
+ geometry.addGroup(0, bodyIndices.length, 0)
64
+ geometry.addGroup(bodyIndices.length, accentIndices.length, 1)
65
+ geometry.name = 'reinforced-rebound-single-shell-geometry'
66
+ geometry.computeBoundingBox()
67
+ geometry.computeBoundingSphere()
68
+ geometry.userData.closedSurface = true
69
+ return geometry
70
+ }
@@ -0,0 +1,23 @@
1
+ import {
2
+ appendRaceFingerprint,
3
+ createRaceRandom,
4
+ formatRaceFingerprint,
5
+ hashRaceSeed,
6
+ normalizeRaceSeed,
7
+ raceRandomBetween,
8
+ shuffleRaceValues,
9
+ } from '@marble.fun/physics-engine'
10
+
11
+ export const normalizeSeed = normalizeRaceSeed
12
+ export const hashSeed = hashRaceSeed
13
+ export const createSeededRandom = createRaceRandom
14
+ export const randomBetween = raceRandomBetween
15
+ export const shuffled = shuffleRaceValues
16
+ export const appendFingerprint = appendRaceFingerprint
17
+ export const formatFingerprint = formatRaceFingerprint
18
+
19
+ export function generateSeed(): string {
20
+ const words = new Uint32Array(2)
21
+ crypto.getRandomValues(words)
22
+ return `${words[0]!.toString(16).padStart(8, '0')}-${words[1]!.toString(16).padStart(8, '0')}`
23
+ }