@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,44 @@
1
+ import type { MaterialRecipe, ThemeMaterialSlot } from './track-theme'
2
+
3
+ export interface SharedObstacleMetalPalette {
4
+ light: string
5
+ dark: string
6
+ }
7
+
8
+ const SHARED_OBSTACLE_METAL_SURFACE = {
9
+ kind: 'physical',
10
+ roughness: 0.12,
11
+ metalness: 0.88,
12
+ clearcoat: 1,
13
+ clearcoatRoughness: 0.05,
14
+ envMapIntensity: 2.25,
15
+ } as const satisfies Omit<Extract<MaterialRecipe, { kind: 'physical' }>, 'color'>
16
+
17
+ function tintedObstacleMetal(color: string): MaterialRecipe {
18
+ return { ...SHARED_OBSTACLE_METAL_SURFACE, color }
19
+ }
20
+
21
+ /**
22
+ * Every themed obstacle uses this one metal response. Themes own only the two
23
+ * tints; geometry owns whether a surface is a light body or a darker detail.
24
+ */
25
+ export function createSharedObstacleMetalMaterials(
26
+ palette: SharedObstacleMetalPalette,
27
+ ): Pick<
28
+ Record<ThemeMaterialSlot, MaterialRecipe>,
29
+ | 'obstacle.primary'
30
+ | 'obstacle.secondary'
31
+ | 'obstacle.base'
32
+ | 'mechanism.flipper.primary'
33
+ | 'mechanism.flipper.secondary'
34
+ > {
35
+ const light = tintedObstacleMetal(palette.light)
36
+ const dark = tintedObstacleMetal(palette.dark)
37
+ return {
38
+ 'obstacle.primary': light,
39
+ 'obstacle.secondary': dark,
40
+ 'obstacle.base': dark,
41
+ 'mechanism.flipper.primary': light,
42
+ 'mechanism.flipper.secondary': dark,
43
+ }
44
+ }
@@ -0,0 +1,57 @@
1
+ import * as THREE from 'three'
2
+
3
+ export interface SoftAdditiveParticleMaterialOptions {
4
+ color: THREE.ColorRepresentation
5
+ fog?: boolean
6
+ opacity: number
7
+ size: number
8
+ sizeAttenuation: boolean
9
+ }
10
+
11
+ export function createSoftParticleSpriteTexture(size = 32): THREE.DataTexture {
12
+ const data = new Uint8Array(size * size * 4)
13
+ const center = (size - 1) * 0.5
14
+ const radius = size * 0.5
15
+
16
+ for (let y = 0; y < size; y += 1) {
17
+ for (let x = 0; x < size; x += 1) {
18
+ const distance = Math.min(1, Math.hypot(x - center, y - center) / radius)
19
+ const fade =
20
+ distance <= 0.35
21
+ ? THREE.MathUtils.lerp(1, 0.8, THREE.MathUtils.smoothstep(distance, 0, 0.35))
22
+ : THREE.MathUtils.lerp(0.8, 0, THREE.MathUtils.smoothstep(distance, 0.35, 1))
23
+ const value = Math.round(fade * 255)
24
+ const offset = (y * size + x) * 4
25
+ data[offset] = value
26
+ data[offset + 1] = value
27
+ data[offset + 2] = value
28
+ data[offset + 3] = value
29
+ }
30
+ }
31
+
32
+ const texture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat)
33
+ texture.name = 'soft-additive-particle-sprite'
34
+ texture.colorSpace = THREE.NoColorSpace
35
+ texture.minFilter = THREE.LinearFilter
36
+ texture.magFilter = THREE.LinearFilter
37
+ texture.generateMipmaps = false
38
+ texture.needsUpdate = true
39
+ return texture
40
+ }
41
+
42
+ export function createSoftAdditiveParticleMaterial(options: SoftAdditiveParticleMaterialOptions): THREE.PointsMaterial {
43
+ const sprite = createSoftParticleSpriteTexture()
44
+ return new THREE.PointsMaterial({
45
+ color: options.color,
46
+ map: sprite,
47
+ alphaMap: sprite,
48
+ size: options.size,
49
+ sizeAttenuation: options.sizeAttenuation,
50
+ blending: THREE.AdditiveBlending,
51
+ transparent: true,
52
+ opacity: options.opacity,
53
+ depthWrite: false,
54
+ fog: options.fog ?? false,
55
+ toneMapped: false,
56
+ })
57
+ }
@@ -0,0 +1,213 @@
1
+ import type { MaterialRecipe } from './track-theme'
2
+ import * as THREE from 'three'
3
+
4
+ export const SPACE_STATION_ARMORED_WALL_ID = 'space-station.armored-wall'
5
+ export const SPACE_STATION_FLOOR_DECK_ID = 'space-station.modular-deck'
6
+ export const SPACE_STATION_FLOOR_DECK_BASE_ID = 'space-station.modular-deck-base'
7
+ export const SPACE_STATION_HOVER_GRID_FLOOR_ID = 'space-station.holographic-hover-grid'
8
+ export const SPACE_STATION_WALL_PANEL_ID = 'space-station.armored-wall-panel'
9
+ export const SPACE_STATION_WALL_RIB_ID = 'space-station.armored-wall-rib'
10
+ export const SPACE_STATION_WALL_TRIM_ID = 'space-station.armored-wall-trim'
11
+ export const SPACE_STATION_WALL_LIGHT_ID = 'space-station.armored-wall-light'
12
+ export const SPACE_STATION_CENTER_GUIDE_ID = 'space-station.routed-center-guide'
13
+
14
+ export const SPACE_STATION_FLOOR_DECK_MATERIAL: MaterialRecipe = {
15
+ kind: 'physical',
16
+ color: '#ffffff',
17
+ roughness: 0.5,
18
+ metalness: 0.68,
19
+ envMapIntensity: 1.24,
20
+ clearcoat: 0.08,
21
+ clearcoatRoughness: 0.48,
22
+ textureUrl: '/themes/textures/space-station-floor-deck-v2-neutral-albedo.webp',
23
+ normalTextureUrl: '/themes/textures/space-station-floor-deck-v2-neutral-normal.png',
24
+ heightTextureUrl: '/themes/textures/space-station-floor-deck-v2-neutral-height.png',
25
+ roughnessTextureUrl: '/themes/textures/space-station-floor-deck-v2-neutral-roughness.png',
26
+ aoTextureUrl: '/themes/textures/space-station-floor-deck-v2-neutral-ao.png',
27
+ textureWorldSize: [4.2, 5],
28
+ normalScale: [0.24, 0.24],
29
+ bumpScale: 0.008,
30
+ aoMapIntensity: 0.22,
31
+ }
32
+
33
+ /** Neutral carrier beneath the routed, unchanged approved deck cross-section. */
34
+ export const SPACE_STATION_FLOOR_DECK_BASE_MATERIAL: MaterialRecipe = {
35
+ kind: 'physical',
36
+ color: '#ffffff',
37
+ roughness: 0.5,
38
+ metalness: 0.68,
39
+ envMapIntensity: 1.24,
40
+ clearcoat: 0.08,
41
+ clearcoatRoughness: 0.48,
42
+ textureUrl: '/themes/textures/space-station-floor-deck-v2-neutral-albedo.webp',
43
+ normalTextureUrl: '/themes/textures/space-station-floor-deck-v2-neutral-normal.png',
44
+ heightTextureUrl: '/themes/textures/space-station-floor-deck-v2-neutral-height.png',
45
+ roughnessTextureUrl: '/themes/textures/space-station-floor-deck-v2-neutral-roughness.png',
46
+ aoTextureUrl: '/themes/textures/space-station-floor-deck-v2-neutral-ao.png',
47
+ textureWorldSize: [4.2, 5],
48
+ normalScale: [0.24, 0.24],
49
+ bumpScale: 0.008,
50
+ aoMapIntensity: 0.22,
51
+ }
52
+
53
+ /**
54
+ * Transparent rift-energy lattice approved for the Space Station theme. The
55
+ * continuous shell and Rapier collider remain unchanged beneath this visual.
56
+ */
57
+ export const SPACE_STATION_HOVER_GRID_FLOOR_MATERIAL: MaterialRecipe = {
58
+ kind: 'standard',
59
+ color: '#19cfff',
60
+ roughness: 0,
61
+ metalness: 0,
62
+ transparent: true,
63
+ opacity: 1,
64
+ depthWrite: false,
65
+ emissiveSurface: 'upward',
66
+ pattern: {
67
+ kind: 'holographic-hover-grid',
68
+ primary: '#19cfff',
69
+ secondary: '#e0faff',
70
+ worldSize: [0.28, 0.28],
71
+ },
72
+ hoverGridCoreWidth: 0.0045,
73
+ hoverGridAuraFalloff: 46,
74
+ hoverGridAuraStrength: 0.62,
75
+ hoverGridJunctionStrength: 0.34,
76
+ }
77
+
78
+ export const SPACE_STATION_WALL_CHASSIS_MATERIAL: MaterialRecipe = {
79
+ kind: 'physical',
80
+ color: '#ffffff',
81
+ roughness: 0.34,
82
+ metalness: 0.86,
83
+ envMapIntensity: 1.28,
84
+ clearcoat: 0.08,
85
+ clearcoatRoughness: 0.5,
86
+ textureUrl: '/themes/textures/space-station-wall-chassis-v1-albedo.webp',
87
+ normalTextureUrl: '/themes/textures/space-station-wall-chassis-v1-normal.png',
88
+ heightTextureUrl: '/themes/textures/space-station-wall-chassis-v1-height.png',
89
+ roughnessTextureUrl: '/themes/textures/space-station-wall-chassis-v1-roughness.png',
90
+ aoTextureUrl: '/themes/textures/space-station-wall-chassis-v1-ao.png',
91
+ textureWorldSize: [1.8, 1.8],
92
+ normalScale: [0.34, 0.34],
93
+ bumpScale: 0.012,
94
+ aoMapIntensity: 0.28,
95
+ }
96
+
97
+ export const SPACE_STATION_WALL_PANEL_MATERIAL: MaterialRecipe = {
98
+ kind: 'physical',
99
+ color: '#ffffff',
100
+ roughness: 0.46,
101
+ metalness: 0.18,
102
+ envMapIntensity: 1.08,
103
+ clearcoat: 0.08,
104
+ clearcoatRoughness: 0.62,
105
+ textureUrl: '/themes/textures/space-station-wall-panel-v1-albedo.webp',
106
+ normalTextureUrl: '/themes/textures/space-station-wall-panel-v1-normal.png',
107
+ heightTextureUrl: '/themes/textures/space-station-wall-panel-v1-height.png',
108
+ roughnessTextureUrl: '/themes/textures/space-station-wall-panel-v1-roughness.png',
109
+ aoTextureUrl: '/themes/textures/space-station-wall-panel-v1-ao.png',
110
+ textureWorldSize: [1.55, 1.28],
111
+ normalScale: [0.3, 0.3],
112
+ bumpScale: 0.01,
113
+ aoMapIntensity: 0.24,
114
+ }
115
+
116
+ export const SPACE_STATION_WALL_RIB_MATERIAL: MaterialRecipe = {
117
+ kind: 'physical',
118
+ color: '#ffffff',
119
+ roughness: 0.3,
120
+ metalness: 0.7,
121
+ envMapIntensity: 1.42,
122
+ clearcoat: 0.2,
123
+ clearcoatRoughness: 0.3,
124
+ textureUrl: '/themes/textures/space-station-wall-rib-v1-albedo.webp',
125
+ normalTextureUrl: '/themes/textures/space-station-wall-rib-v1-normal.png',
126
+ heightTextureUrl: '/themes/textures/space-station-wall-rib-v1-height.png',
127
+ roughnessTextureUrl: '/themes/textures/space-station-wall-rib-v1-roughness.png',
128
+ aoTextureUrl: '/themes/textures/space-station-wall-rib-v1-ao.png',
129
+ textureWorldSize: [0.42, 1.28],
130
+ normalScale: [0.52, 0.52],
131
+ bumpScale: 0.009,
132
+ aoMapIntensity: 0.2,
133
+ }
134
+
135
+ export const SPACE_STATION_WALL_TRIM_MATERIAL: MaterialRecipe = {
136
+ kind: 'physical',
137
+ color: '#303b46',
138
+ roughness: 0.36,
139
+ metalness: 0.76,
140
+ envMapIntensity: 1.16,
141
+ }
142
+
143
+ export const SPACE_STATION_WALL_LIGHT_MATERIAL: MaterialRecipe = {
144
+ kind: 'physical',
145
+ color: '#00a8d8',
146
+ roughness: 0.08,
147
+ metalness: 0,
148
+ envMapIntensity: 1.4,
149
+ emissive: '#00c8ff',
150
+ emissiveIntensity: 2.2,
151
+ clearcoat: 1,
152
+ clearcoatRoughness: 0.04,
153
+ }
154
+
155
+ /** Saturated emissive route line. Reuses the existing guide mesh and draw call. */
156
+ export const SPACE_STATION_CENTER_GUIDE_MATERIAL: MaterialRecipe = {
157
+ kind: 'physical',
158
+ color: '#003dba',
159
+ roughness: 0.06,
160
+ metalness: 0,
161
+ envMapIntensity: 0.72,
162
+ emissive: '#0077ff',
163
+ emissiveIntensity: 1.45,
164
+ clearcoat: 1,
165
+ clearcoatRoughness: 0.03,
166
+ }
167
+
168
+ export function createSpaceStationCenterGuideGlowMaterial(source: THREE.Material): THREE.ShaderMaterial {
169
+ const sourceColor =
170
+ source instanceof THREE.MeshStandardMaterial ? source.emissive.clone() : new THREE.Color('#0077ff')
171
+ const material = new THREE.ShaderMaterial({
172
+ uniforms: {
173
+ uAuraColor: { value: sourceColor.clone().multiplyScalar(0.72) },
174
+ uCoreColor: { value: sourceColor.clone().lerp(new THREE.Color('#5eeaff'), 0.42) },
175
+ },
176
+ vertexShader: `
177
+ attribute float themeGuideGlow;
178
+ varying float vGuideGlow;
179
+ void main() {
180
+ vGuideGlow = themeGuideGlow;
181
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
182
+ }
183
+ `,
184
+ fragmentShader: `
185
+ uniform vec3 uAuraColor;
186
+ uniform vec3 uCoreColor;
187
+ varying float vGuideGlow;
188
+ void main() {
189
+ float aura = pow(clamp(vGuideGlow, 0.0, 1.0), 1.45);
190
+ float core = smoothstep(0.78, 0.98, vGuideGlow);
191
+ vec3 color = mix(uAuraColor, uCoreColor, core);
192
+ float intensity = mix(0.72, 2.35, core) * aura;
193
+ float alpha = mix(0.22, 0.96, core) * aura;
194
+ gl_FragColor = vec4(color * intensity, alpha);
195
+ }
196
+ `,
197
+ transparent: true,
198
+ depthWrite: false,
199
+ blending: THREE.AdditiveBlending,
200
+ side: THREE.FrontSide,
201
+ toneMapped: false,
202
+ })
203
+ material.name = 'space-station-routed-center-guide-glow-material'
204
+ return material
205
+ }
206
+
207
+ export function isSpaceStationArmoredWallAppearance(appearanceId: string | undefined): boolean {
208
+ return appearanceId === SPACE_STATION_ARMORED_WALL_ID
209
+ }
210
+
211
+ export function supportsSpaceStationHoverGridFloor(themeId: string, materialSlots: readonly string[]): boolean {
212
+ return themeId === 'intergalactic-space-station' && materialSlots.includes('lane.surface')
213
+ }
@@ -0,0 +1,132 @@
1
+ import * as THREE from 'three'
2
+
3
+ /**
4
+ * Static course geometry often renders more than once for the same camera
5
+ * (transmission, then the main view). Reuse Three's exact draw list instead
6
+ * of repeating per-instance culling, sorting, and indirect-texture uploads.
7
+ * Moving cameras and editor mutations still use Three's normal preparation.
8
+ */
9
+ export class StaticTrackBatchedMesh extends THREE.BatchedMesh {
10
+ declare customSort: THREE.BatchedMesh['customSort']
11
+ private renderListValid = false
12
+ private readonly cachedProjection = new THREE.Matrix4()
13
+ private readonly cachedView = new THREE.Matrix4()
14
+ private readonly cachedCameraWorld = new THREE.Matrix4()
15
+ private readonly cachedWorld = new THREE.Matrix4()
16
+ private cachedCoordinateSystem?: THREE.CoordinateSystem
17
+ private cachedReversedDepth = false
18
+ private cachedTransparent = false
19
+ private cachedWireframe = false
20
+ private cachedCulling = true
21
+ private cachedSorting = true
22
+
23
+ override onBeforeRender(
24
+ renderer: THREE.WebGLRenderer,
25
+ scene: THREE.Scene,
26
+ camera: THREE.Camera,
27
+ geometry: THREE.BufferGeometry,
28
+ material: THREE.Material,
29
+ group: THREE.Group,
30
+ ): void {
31
+ const wireframe = 'wireframe' in material && material.wireframe === true
32
+ // Array-camera subviews and arbitrary custom sort callbacks can depend on
33
+ // state beyond these matrices; leave their preparation to Three.
34
+ const cacheable = !(camera instanceof THREE.ArrayCamera) && this.customSort === null
35
+ if (
36
+ cacheable &&
37
+ this.renderListValid &&
38
+ this.cachedProjection.equals(camera.projectionMatrix) &&
39
+ this.cachedView.equals(camera.matrixWorldInverse) &&
40
+ this.cachedCameraWorld.equals(camera.matrixWorld) &&
41
+ this.cachedWorld.equals(this.matrixWorld) &&
42
+ this.cachedCoordinateSystem === camera.coordinateSystem &&
43
+ this.cachedReversedDepth === camera.reversedDepth &&
44
+ this.cachedTransparent === material.transparent &&
45
+ this.cachedWireframe === wireframe &&
46
+ this.cachedCulling === this.perObjectFrustumCulled &&
47
+ this.cachedSorting === this.sortObjects
48
+ )
49
+ return
50
+
51
+ super.onBeforeRender(renderer, scene, camera, geometry, material, group)
52
+ this.cachedProjection.copy(camera.projectionMatrix)
53
+ this.cachedView.copy(camera.matrixWorldInverse)
54
+ this.cachedCameraWorld.copy(camera.matrixWorld)
55
+ this.cachedWorld.copy(this.matrixWorld)
56
+ this.cachedCoordinateSystem = camera.coordinateSystem
57
+ this.cachedReversedDepth = camera.reversedDepth
58
+ this.cachedTransparent = material.transparent
59
+ this.cachedWireframe = wireframe
60
+ this.cachedCulling = this.perObjectFrustumCulled
61
+ this.cachedSorting = this.sortObjects
62
+ this.renderListValid = cacheable
63
+ }
64
+
65
+ override setMatrixAt(instanceId: number, matrix: THREE.Matrix4): this {
66
+ this.renderListValid = false
67
+ return super.setMatrixAt(instanceId, matrix)
68
+ }
69
+
70
+ override setVisibleAt(instanceId: number, visible: boolean): this {
71
+ this.renderListValid = false
72
+ return super.setVisibleAt(instanceId, visible)
73
+ }
74
+
75
+ override setGeometryIdAt(instanceId: number, geometryId: number): this {
76
+ this.renderListValid = false
77
+ return super.setGeometryIdAt(instanceId, geometryId)
78
+ }
79
+
80
+ override addInstance(geometryId: number): number {
81
+ this.renderListValid = false
82
+ return super.addInstance(geometryId)
83
+ }
84
+
85
+ override deleteInstance(instanceId: number): this {
86
+ this.renderListValid = false
87
+ return super.deleteInstance(instanceId)
88
+ }
89
+
90
+ override addGeometry(
91
+ geometry: THREE.BufferGeometry,
92
+ reservedVertexRange?: number,
93
+ reservedIndexRange?: number,
94
+ ): number {
95
+ this.renderListValid = false
96
+ return super.addGeometry(geometry, reservedVertexRange, reservedIndexRange)
97
+ }
98
+
99
+ override setGeometryAt(geometryId: number, geometry: THREE.BufferGeometry): number {
100
+ this.renderListValid = false
101
+ const result = super.setGeometryAt(geometryId, geometry)
102
+ // Resolve lazy geometry bounds outside onBeforeRender. Three shares its
103
+ // scratch vectors between bounds calculation and camera-distance sorting.
104
+ this.getBoundingSphereAt(geometryId, new THREE.Sphere())
105
+ return result
106
+ }
107
+
108
+ override deleteGeometry(geometryId: number): this {
109
+ this.renderListValid = false
110
+ return super.deleteGeometry(geometryId)
111
+ }
112
+
113
+ override optimize(): this {
114
+ this.renderListValid = false
115
+ return super.optimize()
116
+ }
117
+
118
+ override setGeometrySize(maxVertexCount: number, maxIndexCount: number): void {
119
+ this.renderListValid = false
120
+ super.setGeometrySize(maxVertexCount, maxIndexCount)
121
+ }
122
+
123
+ override setInstanceCount(maxInstanceCount: number): void {
124
+ this.renderListValid = false
125
+ super.setInstanceCount(maxInstanceCount)
126
+ }
127
+
128
+ override copy(source: THREE.BatchedMesh): this {
129
+ this.renderListValid = false
130
+ return super.copy(source)
131
+ }
132
+ }
@@ -0,0 +1,12 @@
1
+ export const SHARED_STONE_TRACK_BLOCK_MATERIAL_ID = 'shared.stone-track-block'
2
+ export const SHARED_STONE_TRACK_SHELL_THEME_IDS = ['low-poly-jungle', 'ancient-egypt', 'vampire-castle'] as const
3
+
4
+ export function isSharedStoneTrackShellAppearance(id: string | undefined): boolean {
5
+ return (
6
+ id === 'jungle.temple-masonry' || id === 'ancient-egypt.temple-ashlar' || id === 'vampire-castle.dark-stone-wall'
7
+ )
8
+ }
9
+
10
+ export function isSharedStoneTrackShellDefaultTheme(themeId: string | undefined): boolean {
11
+ return SHARED_STONE_TRACK_SHELL_THEME_IDS.some(candidate => candidate === themeId)
12
+ }
@@ -0,0 +1,226 @@
1
+ import {
2
+ isThemeAudioMuted,
3
+ shouldPageAudioRemainActive,
4
+ subscribeToThemeAudioPreference,
5
+ } from './theme-audio-preference'
6
+ import type { ThemeAmbienceStyle, TrackThemeDefinition } from './track-theme'
7
+
8
+ interface AmbienceVoice {
9
+ gain: GainNode
10
+ stop: () => void
11
+ }
12
+
13
+ export class ThemeAmbienceController {
14
+ private context?: AudioContext
15
+ private master?: GainNode
16
+ private active?: AmbienceVoice
17
+ private pendingTheme?: TrackThemeDefinition
18
+ private disposed = false
19
+ private muted = typeof window === 'undefined' ? true : isThemeAudioMuted()
20
+ private readonly mobileQuery = window.matchMedia('(max-width: 767px)')
21
+ private readonly unsubscribePreference: () => void
22
+
23
+ constructor() {
24
+ this.unsubscribePreference = subscribeToThemeAudioPreference(muted => {
25
+ this.muted = muted
26
+ this.updateMaster()
27
+ })
28
+ window.addEventListener('pointerdown', this.unlock, { passive: true })
29
+ window.addEventListener('keydown', this.unlock)
30
+ window.addEventListener('focus', this.handlePageFocusChange)
31
+ window.addEventListener('blur', this.handlePageFocusChange)
32
+ document.addEventListener('visibilitychange', this.handleVisibilityChange)
33
+ this.mobileQuery.addEventListener('change', this.handlePageFocusChange)
34
+ }
35
+
36
+ applyTheme(theme: TrackThemeDefinition): void {
37
+ this.pendingTheme = theme
38
+ if (!this.context || this.context.state !== 'running') return
39
+
40
+ const next = createAmbienceVoice(this.context, theme.ambience.style)
41
+ next.gain.gain.setValueAtTime(0, this.context.currentTime)
42
+ next.gain.gain.linearRampToValueAtTime(theme.ambience.volume, this.context.currentTime + 0.45)
43
+ next.gain.connect(this.master as GainNode)
44
+
45
+ const previous = this.active
46
+ this.active = next
47
+ if (previous) {
48
+ previous.gain.gain.cancelScheduledValues(this.context.currentTime)
49
+ previous.gain.gain.setValueAtTime(previous.gain.gain.value, this.context.currentTime)
50
+ previous.gain.gain.linearRampToValueAtTime(0, this.context.currentTime + 0.45)
51
+ window.setTimeout(previous.stop, 520)
52
+ }
53
+ }
54
+
55
+ dispose(): void {
56
+ if (this.disposed) return
57
+ this.disposed = true
58
+ this.unsubscribePreference()
59
+ window.removeEventListener('pointerdown', this.unlock)
60
+ window.removeEventListener('keydown', this.unlock)
61
+ window.removeEventListener('focus', this.handlePageFocusChange)
62
+ window.removeEventListener('blur', this.handlePageFocusChange)
63
+ document.removeEventListener('visibilitychange', this.handleVisibilityChange)
64
+ this.mobileQuery.removeEventListener('change', this.handlePageFocusChange)
65
+ this.active?.stop()
66
+ this.active = undefined
67
+ void this.context?.close()
68
+ this.context = undefined
69
+ }
70
+
71
+ private readonly unlock = (): void => {
72
+ if (this.disposed) return
73
+ this.context ??= new AudioContext()
74
+ this.master ??= this.context.createGain()
75
+ this.master.connect(this.context.destination)
76
+ this.updateMaster()
77
+ void this.context.resume().then(() => {
78
+ if (this.pendingTheme && !this.active) this.applyTheme(this.pendingTheme)
79
+ })
80
+ }
81
+
82
+ private readonly handleVisibilityChange = (): void => {
83
+ this.updateContextActivity()
84
+ }
85
+
86
+ private readonly handlePageFocusChange = (): void => {
87
+ this.updateContextActivity()
88
+ }
89
+
90
+ private updateContextActivity(): void {
91
+ if (!this.context) return
92
+ const active = shouldPageAudioRemainActive({
93
+ focused: document.hasFocus(),
94
+ mobile: this.mobileQuery.matches,
95
+ visible: !document.hidden,
96
+ })
97
+ if (!active) {
98
+ void this.context.suspend()
99
+ } else {
100
+ void this.context.resume()
101
+ }
102
+ }
103
+
104
+ private updateMaster(): void {
105
+ if (!this.master || !this.context) return
106
+ this.master.gain.cancelScheduledValues(this.context.currentTime)
107
+ this.master.gain.linearRampToValueAtTime(this.muted ? 0 : 1, this.context.currentTime + 0.08)
108
+ }
109
+ }
110
+
111
+ function createAmbienceVoice(context: AudioContext, style: ThemeAmbienceStyle): AmbienceVoice {
112
+ const output = context.createGain()
113
+ const nodes: AudioScheduledSourceNode[] = []
114
+ const cleanup: AudioNode[] = [output]
115
+ const settings = ambienceSettings(style)
116
+
117
+ for (const [frequency, level] of settings.tones) {
118
+ const oscillator = context.createOscillator()
119
+ const gain = context.createGain()
120
+ oscillator.type = settings.wave
121
+ oscillator.frequency.value = frequency
122
+ gain.gain.value = level
123
+ oscillator.connect(gain)
124
+ gain.connect(output)
125
+ oscillator.start()
126
+ nodes.push(oscillator)
127
+ cleanup.push(oscillator, gain)
128
+ }
129
+
130
+ if (settings.noise > 0) {
131
+ const buffer = context.createBuffer(1, context.sampleRate * 2, context.sampleRate)
132
+ const channel = buffer.getChannelData(0)
133
+ for (let index = 0; index < channel.length; index += 1) {
134
+ channel[index] = Math.random() * 2 - 1
135
+ }
136
+ const source = context.createBufferSource()
137
+ const filter = context.createBiquadFilter()
138
+ const gain = context.createGain()
139
+ source.buffer = buffer
140
+ source.loop = true
141
+ filter.type = settings.filterType
142
+ filter.frequency.value = settings.filterFrequency
143
+ filter.Q.value = 0.7
144
+ gain.gain.value = settings.noise
145
+ source.connect(filter)
146
+ filter.connect(gain)
147
+ gain.connect(output)
148
+ source.start()
149
+ nodes.push(source)
150
+ cleanup.push(source, filter, gain)
151
+ }
152
+
153
+ return {
154
+ gain: output,
155
+ stop: () => {
156
+ nodes.forEach(node => {
157
+ try {
158
+ node.stop()
159
+ } catch {
160
+ // A source may already have stopped during scene teardown.
161
+ }
162
+ })
163
+ cleanup.forEach(node => node.disconnect())
164
+ },
165
+ }
166
+ }
167
+
168
+ function ambienceSettings(style: ThemeAmbienceStyle): {
169
+ tones: readonly (readonly [number, number])[]
170
+ wave: OscillatorType
171
+ noise: number
172
+ filterType: BiquadFilterType
173
+ filterFrequency: number
174
+ } {
175
+ switch (style) {
176
+ case 'desert':
177
+ return { tones: [[92, 0.08]], wave: 'sine', noise: 0.52, filterType: 'lowpass', filterFrequency: 480 }
178
+ case 'space':
179
+ return {
180
+ tones: [
181
+ [46, 0.22],
182
+ [92, 0.08],
183
+ [184, 0.025],
184
+ ],
185
+ wave: 'sine',
186
+ noise: 0.16,
187
+ filterType: 'bandpass',
188
+ filterFrequency: 720,
189
+ }
190
+ case 'jungle':
191
+ return {
192
+ tones: [
193
+ [118, 0.035],
194
+ [236, 0.012],
195
+ ],
196
+ wave: 'sine',
197
+ noise: 0.42,
198
+ filterType: 'bandpass',
199
+ filterFrequency: 1_650,
200
+ }
201
+ case 'meadow':
202
+ return {
203
+ tones: [
204
+ [146.8, 0.022],
205
+ [220, 0.009],
206
+ ],
207
+ wave: 'sine',
208
+ noise: 0.3,
209
+ filterType: 'bandpass',
210
+ filterFrequency: 1_280,
211
+ }
212
+ case 'sakura-lake':
213
+ return {
214
+ tones: [
215
+ [146.8, 0.018],
216
+ [220, 0.008],
217
+ ],
218
+ wave: 'sine',
219
+ noise: 0.38,
220
+ filterType: 'bandpass',
221
+ filterFrequency: 1_420,
222
+ }
223
+ case 'studio':
224
+ return { tones: [[60, 0.035]], wave: 'sine', noise: 0.07, filterType: 'lowpass', filterFrequency: 400 }
225
+ }
226
+ }