@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,590 @@
1
+ import * as THREE from 'three'
2
+
3
+ import { VisualCylinderGeometry, VisualConeGeometry, VisualTorusGeometry, VisualCircleGeometry, VisualIcosahedronGeometry } from './visual-geometry'
4
+
5
+ import type { ThemeMaterialRegistry } from './theme-material-registry'
6
+ import type { ThemeParticleStyle, TrackThemeDefinition } from './track-theme'
7
+
8
+ export interface ThemeWorldState {
9
+ root: THREE.Group
10
+ update(deltaSeconds: number, elapsedSeconds: number): void
11
+ dispose(): void
12
+ }
13
+
14
+ export function createThemeWorld(
15
+ theme: TrackThemeDefinition,
16
+ materials: ThemeMaterialRegistry,
17
+ mobile: boolean,
18
+ ): ThemeWorldState {
19
+ const root = new THREE.Group()
20
+ root.name = `theme-world:${theme.id}`
21
+ root.userData.themeWorld = true
22
+ const scale = mobile ? 0.62 : 1
23
+ if (theme.world.kit !== 'studio') {
24
+ addGround(root, materials, theme.world.kit)
25
+ }
26
+
27
+ switch (theme.world.kit) {
28
+ case 'wild-west':
29
+ addWildWestKit(root, materials, scale * theme.world.density)
30
+ break
31
+ case 'ancient-egypt':
32
+ addAncientEgyptKit(root, materials, scale * theme.world.density)
33
+ break
34
+ case 'japanese-sakura-lake':
35
+ addJapaneseSakuraLakeKit(root, materials, scale * theme.world.density)
36
+ break
37
+ case 'space-station':
38
+ addSpaceStationKit(root, materials, scale * theme.world.density)
39
+ break
40
+ case 'jungle':
41
+ addJungleKit(root, materials, scale * theme.world.density)
42
+ break
43
+ case 'studio':
44
+ break
45
+ }
46
+
47
+ const particles = createThemeParticles(theme, mobile)
48
+ if (particles) root.add(particles.points)
49
+
50
+ return {
51
+ root,
52
+ update: (deltaSeconds, elapsedSeconds) => {
53
+ particles?.update(deltaSeconds, elapsedSeconds)
54
+ },
55
+ dispose: () => disposeThemeWorld(root),
56
+ }
57
+ }
58
+
59
+ function addGround(
60
+ root: THREE.Group,
61
+ materials: ThemeMaterialRegistry,
62
+ kit: TrackThemeDefinition['world']['kit'],
63
+ ): void {
64
+ const geometry =
65
+ kit === 'space-station' ? new VisualCylinderGeometry(100, 100, 0.22, 64) : new VisualCircleGeometry(105, 48)
66
+ const ground = new THREE.Mesh(geometry, materials.get('world.ground'))
67
+ ground.name = 'theme-ground'
68
+ if (geometry instanceof THREE.CircleGeometry) ground.rotation.x = -Math.PI / 2
69
+ ground.position.y = -0.12
70
+ ground.receiveShadow = true
71
+ root.add(ground)
72
+ }
73
+
74
+ function addWildWestKit(root: THREE.Group, materials: ThemeMaterialRegistry, density: number): void {
75
+ const count = Math.max(10, Math.round(34 * density))
76
+ const trunkGeometry = new VisualCylinderGeometry(0.3, 0.46, 4.6, 7)
77
+ const armGeometry = new VisualCylinderGeometry(0.16, 0.22, 2.1, 6)
78
+ const foliage = materials.get('world.foliage')
79
+ const trunks = new THREE.InstancedMesh(trunkGeometry, foliage, count)
80
+ const arms = new THREE.InstancedMesh(armGeometry, foliage, count * 2)
81
+ const matrix = new THREE.Matrix4()
82
+ let armIndex = 0
83
+
84
+ for (let index = 0; index < count; index += 1) {
85
+ const angle = (index / count) * Math.PI * 2 + seeded(index, 11) * 0.28
86
+ const radius = 28 + seeded(index, 17) * 62
87
+ const height = 0.72 + seeded(index, 29) * 0.65
88
+ const position = new THREE.Vector3(Math.cos(angle) * radius, 2.2 * height, Math.sin(angle) * radius)
89
+ matrix.compose(position, new THREE.Quaternion(), new THREE.Vector3(height, height, height))
90
+ trunks.setMatrixAt(index, matrix)
91
+ for (const side of [-1, 1]) {
92
+ const armPosition = position
93
+ .clone()
94
+ .add(new THREE.Vector3(side * 0.66 * height, 0.35 * height, seeded(index, side + 40) * 0.3))
95
+ const quaternion = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, 0, side * Math.PI * 0.48))
96
+ matrix.compose(armPosition, quaternion, new THREE.Vector3(height, height, height))
97
+ arms.setMatrixAt(armIndex, matrix)
98
+ armIndex += 1
99
+ }
100
+ }
101
+ trunks.instanceMatrix.needsUpdate = true
102
+ arms.instanceMatrix.needsUpdate = true
103
+ root.add(trunks, arms)
104
+
105
+ addPerimeterFence(root, materials, 46, Math.max(18, Math.round(42 * density)), 1.65)
106
+ addRockField(root, materials, Math.max(12, Math.round(30 * density)), 30, 86)
107
+ }
108
+
109
+ function addAncientEgyptKit(root: THREE.Group, materials: ThemeMaterialRegistry, density: number): void {
110
+ const monumentCount = Math.max(6, Math.round(10 * density))
111
+ const obeliskGeometry = new VisualCylinderGeometry(0.18, 0.72, 7.2, 4)
112
+ const capGeometry = new VisualConeGeometry(0.51, 1.2, 4)
113
+ const obelisks = new THREE.InstancedMesh(obeliskGeometry, materials.get('world.primary'), monumentCount)
114
+ const caps = new THREE.InstancedMesh(capGeometry, materials.get('world.accent'), monumentCount)
115
+ const matrix = new THREE.Matrix4()
116
+
117
+ for (let index = 0; index < monumentCount; index += 1) {
118
+ const angle = (index / monumentCount) * Math.PI * 2 + 0.18
119
+ const radius = 42 + (index % 3) * 12
120
+ const scale = 0.76 + seeded(index, 83) * 0.46
121
+ const position = new THREE.Vector3(Math.cos(angle) * radius, 3.6 * scale, Math.sin(angle) * radius)
122
+ const quaternion = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, Math.PI / 4, 0))
123
+ matrix.compose(position, quaternion, new THREE.Vector3(scale, scale, scale))
124
+ obelisks.setMatrixAt(index, matrix)
125
+ matrix.compose(position.clone().setY(7.72 * scale), quaternion, new THREE.Vector3(scale, scale, scale))
126
+ caps.setMatrixAt(index, matrix)
127
+ }
128
+ obelisks.name = 'ancient-egypt-obelisks'
129
+ caps.name = 'ancient-egypt-obelisk-caps'
130
+ obelisks.userData.decorative = true
131
+ caps.userData.decorative = true
132
+ obelisks.instanceMatrix.needsUpdate = true
133
+ caps.instanceMatrix.needsUpdate = true
134
+ root.add(obelisks, caps)
135
+
136
+ const pyramidSpecs = [
137
+ [-58, 0, -34, 12],
138
+ [52, 0, 43, 9],
139
+ [68, 0, -22, 7],
140
+ ] as const
141
+ for (const [index, [x, y, z, size]] of pyramidSpecs.entries()) {
142
+ const pyramid = new THREE.Mesh(new VisualConeGeometry(size, size * 0.92, 4), materials.get('world.secondary'))
143
+ pyramid.name = `ancient-egypt-pyramid-${index + 1}`
144
+ pyramid.userData.decorative = true
145
+ pyramid.position.set(x, y + size * 0.46, z)
146
+ pyramid.rotation.y = Math.PI / 4
147
+ root.add(pyramid)
148
+ }
149
+
150
+ addRockField(root, materials, Math.max(10, Math.round(22 * density)), 34, 88)
151
+ }
152
+
153
+ function addJapaneseSakuraLakeKit(root: THREE.Group, materials: ThemeMaterialRegistry, density: number): void {
154
+ const toriiCount = Math.max(4, Math.round(7 * density))
155
+ const postGeometry = new THREE.BoxGeometry(0.42, 5.4, 0.42)
156
+ const beamGeometry = new THREE.BoxGeometry(5.8, 0.38, 0.55)
157
+ const upperBeamGeometry = new THREE.BoxGeometry(6.7, 0.3, 0.68)
158
+ const toriiPosts = new THREE.InstancedMesh(postGeometry, materials.get('world.primary'), toriiCount * 2)
159
+ const toriiBeams = new THREE.InstancedMesh(beamGeometry, materials.get('world.dark'), toriiCount)
160
+ const toriiUpperBeams = new THREE.InstancedMesh(upperBeamGeometry, materials.get('world.primary'), toriiCount)
161
+ const parentMatrix = new THREE.Matrix4()
162
+ const localMatrix = new THREE.Matrix4()
163
+ const instanceMatrix = new THREE.Matrix4()
164
+ const position = new THREE.Vector3()
165
+ const quaternion = new THREE.Quaternion()
166
+ const scale = new THREE.Vector3(1, 1, 1)
167
+ let postIndex = 0
168
+
169
+ for (let index = 0; index < toriiCount; index += 1) {
170
+ const angle = (index / toriiCount) * Math.PI * 2 + 0.24
171
+ const radius = 39 + (index % 2) * 17
172
+ position.set(Math.cos(angle) * radius, 0, Math.sin(angle) * radius)
173
+ quaternion.setFromEuler(new THREE.Euler(0, -angle + Math.PI / 2, 0))
174
+ parentMatrix.compose(position, quaternion, scale)
175
+
176
+ for (const x of [-2.15, 2.15]) {
177
+ localMatrix.makeTranslation(x, 2.7, 0)
178
+ toriiPosts.setMatrixAt(postIndex, instanceMatrix.multiplyMatrices(parentMatrix, localMatrix))
179
+ postIndex += 1
180
+ }
181
+ localMatrix.makeTranslation(0, 4.55, 0)
182
+ toriiBeams.setMatrixAt(index, instanceMatrix.multiplyMatrices(parentMatrix, localMatrix))
183
+ localMatrix.makeTranslation(0, 5.35, 0)
184
+ toriiUpperBeams.setMatrixAt(index, instanceMatrix.multiplyMatrices(parentMatrix, localMatrix))
185
+ }
186
+ toriiPosts.name = 'japanese-sakura-torii-posts'
187
+ toriiBeams.name = 'japanese-sakura-torii-beams'
188
+ toriiUpperBeams.name = 'japanese-sakura-torii-upper-beams'
189
+ for (const toriiPart of [toriiPosts, toriiBeams, toriiUpperBeams]) {
190
+ toriiPart.userData.decorative = true
191
+ toriiPart.instanceMatrix.needsUpdate = true
192
+ }
193
+ root.add(toriiPosts, toriiBeams, toriiUpperBeams)
194
+
195
+ const pagodaSpecs = [
196
+ [-58, 0, -35, 1.15],
197
+ [55, 0, 42, 0.95],
198
+ [67, 0, -24, 0.78],
199
+ ] as const
200
+ for (let tier = 0; tier < 3; tier += 1) {
201
+ const bodies = new THREE.InstancedMesh(
202
+ new THREE.BoxGeometry(3.5 - tier * 0.55, 1.35, 3.5 - tier * 0.55),
203
+ materials.get('world.secondary'),
204
+ pagodaSpecs.length,
205
+ )
206
+ const roofs = new THREE.InstancedMesh(
207
+ new VisualCylinderGeometry(1.6 - tier * 0.2, 2.9 - tier * 0.36, 0.65, 4),
208
+ materials.get('world.dark'),
209
+ pagodaSpecs.length,
210
+ )
211
+ for (const [index, [x, y, z, pagodaScale]] of pagodaSpecs.entries()) {
212
+ position.set(x, y, z)
213
+ quaternion.identity()
214
+ scale.setScalar(pagodaScale)
215
+ parentMatrix.compose(position, quaternion, scale)
216
+ localMatrix.makeTranslation(0, 0.8 + tier * 1.55, 0)
217
+ bodies.setMatrixAt(index, instanceMatrix.multiplyMatrices(parentMatrix, localMatrix))
218
+ localMatrix.compose(
219
+ new THREE.Vector3(0, 1.65 + tier * 1.55, 0),
220
+ quaternion.setFromAxisAngle(THREE.Object3D.DEFAULT_UP, Math.PI / 4),
221
+ new THREE.Vector3(1, 1, 1),
222
+ )
223
+ roofs.setMatrixAt(index, instanceMatrix.multiplyMatrices(parentMatrix, localMatrix))
224
+ }
225
+ bodies.name = `japanese-sakura-pagoda-tier-${tier + 1}-bodies`
226
+ roofs.name = `japanese-sakura-pagoda-tier-${tier + 1}-roofs`
227
+ bodies.userData.decorative = true
228
+ roofs.userData.decorative = true
229
+ bodies.instanceMatrix.needsUpdate = true
230
+ roofs.instanceMatrix.needsUpdate = true
231
+ root.add(bodies, roofs)
232
+ }
233
+ }
234
+
235
+ function addSpaceStationKit(root: THREE.Group, materials: ThemeMaterialRegistry, density: number): void {
236
+ const segments = Math.max(12, Math.round(28 * density))
237
+ const pylonGeometry = new THREE.BoxGeometry(1.1, 6.5, 1.1)
238
+ const lightGeometry = new THREE.BoxGeometry(0.22, 2.8, 0.28)
239
+ const pylons = new THREE.InstancedMesh(pylonGeometry, materials.get('world.primary'), segments)
240
+ const lights = new THREE.InstancedMesh(lightGeometry, materials.get('world.accent'), segments)
241
+ const matrix = new THREE.Matrix4()
242
+
243
+ for (let index = 0; index < segments; index += 1) {
244
+ const angle = (index / segments) * Math.PI * 2
245
+ const radius = 42 + (index % 3) * 8
246
+ const position = new THREE.Vector3(Math.cos(angle) * radius, 3.25, Math.sin(angle) * radius)
247
+ const quaternion = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, -angle, 0))
248
+ matrix.compose(position, quaternion, new THREE.Vector3(1, 1, 1))
249
+ pylons.setMatrixAt(index, matrix)
250
+ matrix.compose(position.clone().add(new THREE.Vector3(0, 0.35, 0)), quaternion, new THREE.Vector3(1, 1, 1))
251
+ lights.setMatrixAt(index, matrix)
252
+ }
253
+ pylons.instanceMatrix.needsUpdate = true
254
+ lights.instanceMatrix.needsUpdate = true
255
+ root.add(pylons, lights)
256
+
257
+ for (const radius of [34, 62]) {
258
+ const ring = new THREE.Mesh(
259
+ new VisualTorusGeometry(radius, radius === 34 ? 0.26 : 0.42, 6, 96),
260
+ materials.get(radius === 34 ? 'world.accent' : 'world.secondary'),
261
+ )
262
+ ring.rotation.x = Math.PI / 2
263
+ ring.position.y = radius === 34 ? 1.2 : 8
264
+ root.add(ring)
265
+ }
266
+
267
+ const planetSpecs = [
268
+ { position: [-18, 9, -22], radius: 5.5, slot: 'world.secondary' },
269
+ { position: [12, 8, -14], radius: 3.2, slot: 'world.primary' },
270
+ { position: [-30, 5, 2], radius: 2.8, slot: 'world.accent' },
271
+ ] as const
272
+ for (const [index, planetSpec] of planetSpecs.entries()) {
273
+ const planet = new THREE.Mesh(new VisualIcosahedronGeometry(planetSpec.radius, 3), materials.get(planetSpec.slot))
274
+ planet.name = `space-background-planet-${index + 1}`
275
+ planet.position.set(planetSpec.position[0], planetSpec.position[1], planetSpec.position[2])
276
+ root.add(planet)
277
+ if (index === 0) {
278
+ const planetRing = new THREE.Mesh(
279
+ new VisualTorusGeometry(planetSpec.radius * 1.45, 0.32, 8, 72),
280
+ materials.get('world.accent'),
281
+ )
282
+ planetRing.name = 'space-background-planet-ring'
283
+ planetRing.position.copy(planet.position)
284
+ planetRing.rotation.set(Math.PI * 0.62, 0.18, 0.25)
285
+ root.add(planetRing)
286
+ }
287
+ }
288
+ }
289
+
290
+ function addJungleKit(root: THREE.Group, materials: ThemeMaterialRegistry, density: number): void {
291
+ addJungleGreenPerimeter(root, materials, 36, density)
292
+ addRockField(root, materials, Math.max(18, Math.round(38 * density)), 30, 82)
293
+
294
+ const leafCount = Math.max(30, Math.round(86 * density))
295
+ const leafGeometry = new VisualConeGeometry(0.62, 2.8, 4)
296
+ leafGeometry.rotateZ(Math.PI / 2)
297
+ const leaves = new THREE.InstancedMesh(leafGeometry, materials.get('world.foliage'), leafCount)
298
+ const matrix = new THREE.Matrix4()
299
+ for (let index = 0; index < leafCount; index += 1) {
300
+ const angle = (index / leafCount) * Math.PI * 2 + seeded(index, 19) * 0.3
301
+ const radius = 24 + seeded(index, 31) * 68
302
+ const position = new THREE.Vector3(
303
+ Math.cos(angle) * radius,
304
+ 0.45 + seeded(index, 7) * 2.3,
305
+ Math.sin(angle) * radius,
306
+ )
307
+ const quaternion = new THREE.Quaternion().setFromEuler(
308
+ new THREE.Euler(seeded(index, 23) * 0.6, angle + seeded(index, 37), seeded(index, 41) * 0.6),
309
+ )
310
+ const size = 0.65 + seeded(index, 43) * 1.35
311
+ matrix.compose(position, quaternion, new THREE.Vector3(size, size, size))
312
+ leaves.setMatrixAt(index, matrix)
313
+ }
314
+ leaves.name = 'jungle-broad-leaves'
315
+ leaves.userData.decorative = true
316
+ leaves.instanceMatrix.needsUpdate = true
317
+ root.add(leaves)
318
+ }
319
+
320
+ function addJungleGreenPerimeter(
321
+ root: THREE.Group,
322
+ materials: ThemeMaterialRegistry,
323
+ radius: number,
324
+ density: number,
325
+ ): void {
326
+ // Decorative only: the authored track guards remain the sole wall colliders.
327
+ const clusterCount = Math.max(144, Math.round(280 * density))
328
+ const clusterGeometry = new THREE.DodecahedronGeometry(0.72, 0)
329
+ const clusters = new THREE.InstancedMesh(clusterGeometry, materials.get('world.foliage'), clusterCount)
330
+ clusters.name = 'jungle-perimeter-leaf-clusters'
331
+ clusters.userData.decorative = true
332
+ const matrix = new THREE.Matrix4()
333
+ const foliageColors = [
334
+ new THREE.Color('#256f35'),
335
+ new THREE.Color('#3d8f3b'),
336
+ new THREE.Color('#61a844'),
337
+ new THREE.Color('#8dbc4b'),
338
+ new THREE.Color('#b2cf5b'),
339
+ ]
340
+
341
+ for (let index = 0; index < clusterCount; index += 1) {
342
+ const angle = (index / clusterCount) * Math.PI * 2 + (seeded(index, 47) - 0.5) * 0.045
343
+ const clusterRadius = radius + (seeded(index, 53) - 0.5) * 1.15
344
+ const band = index % 3
345
+ const height = 0.48 + band * 0.78 + seeded(index, 59) * 0.58
346
+ const position = new THREE.Vector3(Math.cos(angle) * clusterRadius, height, Math.sin(angle) * clusterRadius)
347
+ const quaternion = new THREE.Quaternion().setFromEuler(
348
+ new THREE.Euler(seeded(index, 67) * 1.2, -angle + seeded(index, 71), seeded(index, 79) * 1.1),
349
+ )
350
+ const width = 0.78 + seeded(index, 61) * 0.92
351
+ const heightScale = 0.65 + seeded(index, 83) * 0.9
352
+ const depth = 0.72 + seeded(index, 89) * 0.84
353
+ matrix.compose(position, quaternion, new THREE.Vector3(width, heightScale, depth))
354
+ clusters.setMatrixAt(index, matrix)
355
+ clusters.setColorAt(index, foliageColors[Math.floor(seeded(index, 73) * foliageColors.length)]!)
356
+ }
357
+ clusters.instanceMatrix.needsUpdate = true
358
+ if (clusters.instanceColor) clusters.instanceColor.needsUpdate = true
359
+ root.add(clusters)
360
+
361
+ const broadLeafCount = Math.max(90, Math.round(180 * density))
362
+ const broadLeafGeometry = new VisualConeGeometry(0.46, 1.9, 4)
363
+ broadLeafGeometry.rotateZ(Math.PI / 2)
364
+ const broadLeaves = new THREE.InstancedMesh(broadLeafGeometry, materials.get('world.foliage'), broadLeafCount)
365
+ broadLeaves.name = 'jungle-perimeter-broad-leaves'
366
+ broadLeaves.userData.decorative = true
367
+ for (let index = 0; index < broadLeafCount; index += 1) {
368
+ const angle = (index / broadLeafCount) * Math.PI * 2 + (seeded(index, 97) - 0.5) * 0.08
369
+ const leafRadius = radius + (seeded(index, 101) - 0.5) * 1.4
370
+ const position = new THREE.Vector3(
371
+ Math.cos(angle) * leafRadius,
372
+ 0.38 + seeded(index, 103) * 2.6,
373
+ Math.sin(angle) * leafRadius,
374
+ )
375
+ const quaternion = new THREE.Quaternion().setFromEuler(
376
+ new THREE.Euler(seeded(index, 107) * 1.1, -angle + seeded(index, 109) * 1.5, seeded(index, 113) * 1.3),
377
+ )
378
+ const size = 0.62 + seeded(index, 127) * 0.92
379
+ matrix.compose(position, quaternion, new THREE.Vector3(size, size, size))
380
+ broadLeaves.setMatrixAt(index, matrix)
381
+ broadLeaves.setColorAt(index, foliageColors[Math.floor(seeded(index, 131) * foliageColors.length)]!)
382
+ }
383
+ broadLeaves.instanceMatrix.needsUpdate = true
384
+ if (broadLeaves.instanceColor) broadLeaves.instanceColor.needsUpdate = true
385
+ root.add(broadLeaves)
386
+ }
387
+
388
+ function addPerimeterFence(
389
+ root: THREE.Group,
390
+ materials: ThemeMaterialRegistry,
391
+ radius: number,
392
+ count: number,
393
+ height: number,
394
+ ): void {
395
+ const postGeometry = new VisualCylinderGeometry(0.13, 0.19, height, 5)
396
+ const railGeometry = new THREE.BoxGeometry((Math.PI * 2 * radius) / count + 0.24, 0.12, 0.15)
397
+ const posts = new THREE.InstancedMesh(postGeometry, materials.get('world.primary'), count)
398
+ const rails = new THREE.InstancedMesh(railGeometry, materials.get('world.primary'), count * 2)
399
+ const ties = new THREE.InstancedMesh(new VisualTorusGeometry(0.2, 0.035, 4, 8), materials.get('world.accent'), count)
400
+ const matrix = new THREE.Matrix4()
401
+ let railIndex = 0
402
+ for (let index = 0; index < count; index += 1) {
403
+ const angle = (index / count) * Math.PI * 2
404
+ const position = new THREE.Vector3(Math.cos(angle) * radius, height / 2 - 0.04, Math.sin(angle) * radius)
405
+ const lean = (seeded(index, 13) - 0.5) * 0.22
406
+ const postQuaternion = new THREE.Quaternion().setFromEuler(new THREE.Euler(lean, -angle, lean * 0.7))
407
+ matrix.compose(position, postQuaternion, new THREE.Vector3(1, 1, 1))
408
+ posts.setMatrixAt(index, matrix)
409
+
410
+ for (const railHeight of [height * 0.38, height * 0.72]) {
411
+ const railPosition = new THREE.Vector3(Math.cos(angle) * radius, railHeight, Math.sin(angle) * radius)
412
+ const railQuaternion = new THREE.Quaternion().setFromEuler(
413
+ new THREE.Euler(0, -angle, (seeded(index, railIndex + 3) - 0.5) * 0.08),
414
+ )
415
+ matrix.compose(railPosition, railQuaternion, new THREE.Vector3(1, 1, 1))
416
+ rails.setMatrixAt(railIndex, matrix)
417
+ railIndex += 1
418
+ }
419
+
420
+ matrix.compose(
421
+ position.clone().setY(height * 0.72),
422
+ new THREE.Quaternion().setFromEuler(new THREE.Euler(Math.PI / 2, -angle, 0)),
423
+ new THREE.Vector3(1, 1, 1),
424
+ )
425
+ ties.setMatrixAt(index, matrix)
426
+ }
427
+ posts.instanceMatrix.needsUpdate = true
428
+ rails.instanceMatrix.needsUpdate = true
429
+ ties.instanceMatrix.needsUpdate = true
430
+ root.add(posts, rails, ties)
431
+ }
432
+
433
+ function addRockField(
434
+ root: THREE.Group,
435
+ materials: ThemeMaterialRegistry,
436
+ count: number,
437
+ minimumRadius: number,
438
+ maximumRadius: number,
439
+ ): void {
440
+ const geometry = new THREE.DodecahedronGeometry(1.25, 0)
441
+ const rocks = new THREE.InstancedMesh(geometry, materials.get('world.secondary'), count)
442
+ const matrix = new THREE.Matrix4()
443
+ for (let index = 0; index < count; index += 1) {
444
+ const angle = (index / count) * Math.PI * 2 + seeded(index, 5) * 0.4
445
+ const radius = THREE.MathUtils.lerp(minimumRadius, maximumRadius, seeded(index, 9))
446
+ const scale = new THREE.Vector3(
447
+ 0.6 + seeded(index, 21) * 1.8,
448
+ 0.45 + seeded(index, 27) * 1.4,
449
+ 0.6 + seeded(index, 33) * 1.8,
450
+ )
451
+ matrix.compose(
452
+ new THREE.Vector3(Math.cos(angle) * radius, scale.y * 0.45, Math.sin(angle) * radius),
453
+ new THREE.Quaternion().setFromEuler(new THREE.Euler(seeded(index, 15), seeded(index, 25) * Math.PI, 0)),
454
+ scale,
455
+ )
456
+ rocks.setMatrixAt(index, matrix)
457
+ }
458
+ rocks.instanceMatrix.needsUpdate = true
459
+ root.add(rocks)
460
+ }
461
+
462
+ interface ParticleState {
463
+ points: THREE.Points
464
+ update(deltaSeconds: number, elapsedSeconds: number): void
465
+ }
466
+
467
+ function createThemeParticles(theme: TrackThemeDefinition, mobile: boolean): ParticleState | undefined {
468
+ if (theme.particles.style === 'none' || theme.particles.count === 0) return undefined
469
+ const count = Math.max(64, Math.round(theme.particles.count * (mobile ? 0.58 : 1)))
470
+ const positions = new Float32Array(count * 3)
471
+ const velocities = new Float32Array(count * 3)
472
+ const colors = new Float32Array(count * 3)
473
+ const primary = new THREE.Color(theme.particles.color)
474
+ const secondary = new THREE.Color(theme.particles.secondaryColor)
475
+ const random = createRandom(`${theme.id}:particles`)
476
+
477
+ for (let index = 0; index < count; index += 1) {
478
+ const offset = index * 3
479
+ positions[offset] = (random() - 0.5) * 190
480
+ positions[offset + 1] = random() * 72 - 8
481
+ positions[offset + 2] = (random() - 0.5) * 190
482
+ const velocity = particleVelocity(theme.particles.style, random)
483
+ velocities.set(velocity, offset)
484
+ const color = primary.clone().lerp(secondary, random())
485
+ colors[offset] = color.r
486
+ colors[offset + 1] = color.g
487
+ colors[offset + 2] = color.b
488
+ }
489
+
490
+ const geometry = new THREE.BufferGeometry()
491
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
492
+ geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))
493
+ const material = new THREE.PointsMaterial({
494
+ size: theme.particles.size,
495
+ transparent: true,
496
+ opacity: theme.particles.opacity,
497
+ depthWrite: false,
498
+ vertexColors: true,
499
+ blending: theme.particles.style === 'space-drift' ? THREE.AdditiveBlending : THREE.NormalBlending,
500
+ sizeAttenuation: true,
501
+ })
502
+ const points = new THREE.Points(geometry, material)
503
+ points.name = `theme-particles:${theme.particles.style}`
504
+ points.frustumCulled = false
505
+
506
+ return {
507
+ points,
508
+ update: (deltaSeconds, elapsedSeconds) => {
509
+ const positionAttribute = geometry.getAttribute('position') as THREE.BufferAttribute
510
+ for (let index = 0; index < count; index += 1) {
511
+ const offset = index * 3
512
+ const speed = theme.particles.speed
513
+ let x = positions[offset]!
514
+ let y = positions[offset + 1]!
515
+ let z = positions[offset + 2]!
516
+ x += velocities[offset]! * deltaSeconds * speed
517
+ y += velocities[offset + 1]! * deltaSeconds * speed
518
+ z += velocities[offset + 2]! * deltaSeconds * speed
519
+
520
+ if (theme.particles.style === 'space-drift') {
521
+ x += Math.sin(elapsedSeconds * 0.7 + index) * deltaSeconds * 0.18
522
+ } else if (theme.particles.style === 'fireflies') {
523
+ y += Math.sin(elapsedSeconds * 1.4 + index * 0.7) * deltaSeconds * 0.3
524
+ } else if (theme.particles.style === 'petals') {
525
+ x += Math.sin(elapsedSeconds * 0.9 + index * 0.73) * deltaSeconds * 0.52
526
+ z += Math.cos(elapsedSeconds * 0.64 + index * 0.41) * deltaSeconds * 0.3
527
+ y += Math.sin(elapsedSeconds * 1.2 + index * 0.37) * deltaSeconds * 0.08
528
+ }
529
+
530
+ if (x > 96) x = -96
531
+ if (x < -96) x = 96
532
+ if (y > 72) y = -8
533
+ if (y < -8) y = 72
534
+ if (z > 96) z = -96
535
+ if (z < -96) z = 96
536
+ positions[offset] = x
537
+ positions[offset + 1] = y
538
+ positions[offset + 2] = z
539
+ }
540
+ positionAttribute.needsUpdate = true
541
+ },
542
+ }
543
+ }
544
+
545
+ function particleVelocity(style: ThemeParticleStyle, random: () => number): [number, number, number] {
546
+ switch (style) {
547
+ case 'space-drift':
548
+ return [0.45 + random() * 0.9, (random() - 0.5) * 0.18, 0.8 + random() * 1.4]
549
+ case 'dust':
550
+ return [0.5 + random(), random() * 0.1, (random() - 0.5) * 0.25]
551
+ case 'fireflies':
552
+ return [(random() - 0.5) * 0.18, (random() - 0.5) * 0.08, (random() - 0.5) * 0.18]
553
+ case 'petals':
554
+ return [0.42 + random() * 0.38, -0.08 - random() * 0.16, (random() - 0.5) * 0.32]
555
+ case 'none':
556
+ return [0, 0, 0]
557
+ }
558
+ }
559
+
560
+ function disposeThemeWorld(root: THREE.Group): void {
561
+ root.traverse(object => {
562
+ if (!(object instanceof THREE.Mesh || object instanceof THREE.Line || object instanceof THREE.Points)) return
563
+ object.geometry.dispose()
564
+ const materials = Array.isArray(object.material) ? object.material : [object.material]
565
+ materials.forEach(material => {
566
+ if (!material.userData.themeRegistryManaged) material.dispose()
567
+ })
568
+ })
569
+ root.removeFromParent()
570
+ }
571
+
572
+ function seeded(index: number, salt: number): number {
573
+ const value = Math.sin(index * 12.9898 + salt * 78.233) * 43_758.5453
574
+ return value - Math.floor(value)
575
+ }
576
+
577
+ function createRandom(seed: string): () => number {
578
+ let state = 2_166_136_261
579
+ for (const char of seed) {
580
+ state ^= char.charCodeAt(0)
581
+ state = Math.imul(state, 16_777_619)
582
+ }
583
+ return () => {
584
+ state += 0x6d2b79f5
585
+ let value = state
586
+ value = Math.imul(value ^ (value >>> 15), value | 1)
587
+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61)
588
+ return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296
589
+ }
590
+ }