@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,270 @@
1
+ import {
2
+ createThemeMaterialSet,
3
+ type MaterialPatternRecipe,
4
+ type MaterialRecipe,
5
+ type ThemeMaterialSlot,
6
+ } from './track-theme'
7
+
8
+ export const PAINTED_VALLEY_THEME_ID = 'painted-valley'
9
+ export const PAINTED_VALLEY_GRASS_ORANGE_DIRT_PATH_FLOOR_ID =
10
+ 'painted-valley.grass-orange-dirt-path'
11
+ export const PAINTED_VALLEY_GRASS_SHOULDER_MATERIAL_ID = 'painted-valley.grass-shoulder'
12
+ export const PAINTED_VALLEY_TROPICAL_FLOWER_HEDGE_WALL_ID =
13
+ 'painted-valley.tropical-flower-hedge'
14
+ export const LEGACY_JUNGLE_GRASS_DIRT_PATH_FLOOR_ID = 'jungle.grass-dirt-path'
15
+ export const LEGACY_JUNGLE_FLOWER_HEDGE_WALL_ID = 'jungle.flower-hedge'
16
+
17
+ export const PAINTED_VALLEY_GRASS_ORANGE_DIRT_PATH_MATERIAL: MaterialRecipe = {
18
+ kind: 'standard',
19
+ color: '#ffffff',
20
+ roughness: 0.9,
21
+ metalness: 0,
22
+ envMapIntensity: 0.52,
23
+ textureUrl: '/themes/textures/painted-valley-orange-dirt-v1-albedo.ktx2',
24
+ normalTextureUrl: '/themes/textures/painted-valley-orange-dirt-v1-normal.ktx2',
25
+ heightTextureUrl: '/themes/textures/painted-valley-orange-dirt-v1-height.ktx2',
26
+ roughnessTextureUrl: '/themes/textures/painted-valley-orange-dirt-v1-roughness.ktx2',
27
+ aoTextureUrl: '/themes/textures/painted-valley-orange-dirt-v1-ao.ktx2',
28
+ textureWorldSize: [4.8, 4.8],
29
+ normalScale: [0.58, 0.58],
30
+ bumpScale: 0.026,
31
+ aoMapIntensity: 0.52,
32
+ surfaceTreatment: {
33
+ kind: 'floor-perimeter-overlay',
34
+ materialId: PAINTED_VALLEY_GRASS_SHOULDER_MATERIAL_ID,
35
+ bandWidth: 0.62,
36
+ featherWidth: 0.36,
37
+ normalOffset: 0.004,
38
+ },
39
+ }
40
+
41
+ export const PAINTED_VALLEY_GRASS_SHOULDER_MATERIAL: MaterialRecipe = {
42
+ kind: 'standard',
43
+ color: '#ffffff',
44
+ roughness: 0.96,
45
+ metalness: 0,
46
+ envMapIntensity: 0.45,
47
+ textureUrl: '/themes/textures/painted-valley-grass-shoulder-v1-albedo.ktx2',
48
+ normalTextureUrl: '/themes/textures/painted-valley-grass-shoulder-v1-normal.ktx2',
49
+ heightTextureUrl: '/themes/textures/painted-valley-grass-shoulder-v1-height.ktx2',
50
+ roughnessTextureUrl: '/themes/textures/painted-valley-grass-shoulder-v1-roughness.ktx2',
51
+ aoTextureUrl: '/themes/textures/painted-valley-grass-shoulder-v1-ao.ktx2',
52
+ textureWorldSize: [2.8, 2.8],
53
+ normalScale: [0.7, 0.7],
54
+ bumpScale: 0.032,
55
+ aoMapIntensity: 0.58,
56
+ }
57
+
58
+ export const PAINTED_VALLEY_TROPICAL_FLOWER_HEDGE_CORE_MATERIAL: MaterialRecipe = {
59
+ kind: 'standard',
60
+ color: '#3b5029',
61
+ roughness: 0.98,
62
+ metalness: 0,
63
+ emissive: '#0d180a',
64
+ emissiveIntensity: 0.24,
65
+ envMapIntensity: 0.38,
66
+ }
67
+
68
+ export const PAINTED_VALLEY_BIRCH_PLANK_MATERIAL: MaterialRecipe = {
69
+ kind: 'toon',
70
+ color: '#ffffff',
71
+ roughness: 0.9,
72
+ metalness: 0,
73
+ envMapIntensity: 0.72,
74
+ textureUrl: '/themes/textures/painted-valley-wild-west-birch-planks-v1-albedo.webp',
75
+ textureWorldSize: [2.4, 2.4],
76
+ bumpScale: 0.035,
77
+ gradientSteps: 5,
78
+ inkColor: '#73654a',
79
+ inkStrength: 0.055,
80
+ }
81
+
82
+ export const PAINTED_VALLEY_BIRCH_RANCH_FENCE_MATERIAL: MaterialRecipe = {
83
+ kind: 'toon',
84
+ color: '#ffffff',
85
+ roughness: 0.92,
86
+ metalness: 0,
87
+ envMapIntensity: 0.7,
88
+ textureUrl: '/themes/textures/painted-valley-wild-west-birch-fence-v1-albedo.webp',
89
+ textureWorldSize: [2.1, 2.1],
90
+ bumpScale: 0.04,
91
+ gradientSteps: 5,
92
+ inkColor: '#716248',
93
+ inkStrength: 0.06,
94
+ }
95
+
96
+ const paintedCedar: MaterialPatternRecipe = {
97
+ kind: 'painted-wood',
98
+ primary: '#ead7ad',
99
+ secondary: '#9d7b56',
100
+ worldSize: [1.4, 4.8],
101
+ }
102
+
103
+ const paintedStone: MaterialPatternRecipe = {
104
+ kind: 'painted-stone',
105
+ primary: '#c4c8a6',
106
+ secondary: '#728a6d',
107
+ worldSize: [2.2, 3.4],
108
+ }
109
+
110
+ const meadowWash: MaterialPatternRecipe = {
111
+ kind: 'watercolor',
112
+ primary: '#6f985d',
113
+ secondary: '#b8bd72',
114
+ worldSize: [2.6, 2.6],
115
+ }
116
+
117
+ function paintedToonMaterial(
118
+ recipe: MaterialRecipe,
119
+ options: {
120
+ pattern?: MaterialPatternRecipe
121
+ gradientSteps?: number
122
+ inkColor?: string
123
+ inkStrength?: number
124
+ transparent?: boolean
125
+ opacity?: number
126
+ depthWrite?: boolean
127
+ } = {},
128
+ ): MaterialRecipe {
129
+ const pattern = options.pattern ?? recipe.pattern
130
+ return {
131
+ kind: 'toon',
132
+ color: pattern ? '#ffffff' : recipe.color,
133
+ roughness: recipe.roughness,
134
+ metalness: 0,
135
+ ...(recipe.emissive ? { emissive: recipe.emissive } : {}),
136
+ ...(recipe.emissiveIntensity !== undefined ? { emissiveIntensity: recipe.emissiveIntensity } : {}),
137
+ transparent: options.transparent ?? recipe.transparent,
138
+ opacity: options.opacity ?? recipe.opacity,
139
+ depthWrite: options.depthWrite ?? recipe.depthWrite,
140
+ pattern,
141
+ bumpScale: recipe.bumpScale,
142
+ gradientSteps: options.gradientSteps ?? 4,
143
+ inkColor: options.inkColor ?? '#29352a',
144
+ inkStrength: options.inkStrength ?? 0.2,
145
+ }
146
+ }
147
+
148
+ export function createPaintedValleyMaterials(): Record<ThemeMaterialSlot, MaterialRecipe> {
149
+ const base = createThemeMaterialSet(
150
+ {
151
+ laneSurface: '#edc58d',
152
+ laneEdge: '#f7ebcf',
153
+ laneGuard: '#f1e7cf',
154
+ structure: '#aab7a0',
155
+ structureSecondary: '#617d79',
156
+ mechanism: '#91abb0',
157
+ moving: '#d66a4d',
158
+ route: '#e4c866',
159
+ hazard: '#c94432',
160
+ finish: '#fff0b6',
161
+ energy: '#78b8c9',
162
+ portal: '#8a78ad',
163
+ glass: '#b8d8d5',
164
+ worldGround: '#7fa45b',
165
+ worldPrimary: '#82634d',
166
+ worldSecondary: '#8d987b',
167
+ foliage: '#4f7f4c',
168
+ accent: '#cb5338',
169
+ dark: '#30443d',
170
+ },
171
+ {
172
+ surfacePattern: paintedCedar,
173
+ surfaceBumpScale: 0.045,
174
+ guardPattern: paintedCedar,
175
+ transparentGuardBacking: true,
176
+ structurePattern: paintedStone,
177
+ trackRoughness: 0.92,
178
+ trackMetalness: 0,
179
+ guardMetalness: 0,
180
+ },
181
+ )
182
+
183
+ const materials = Object.fromEntries(
184
+ (Object.entries(base) as [ThemeMaterialSlot, MaterialRecipe][]).map(([slot, recipe]) => [
185
+ slot,
186
+ paintedToonMaterial(recipe),
187
+ ]),
188
+ ) as Record<ThemeMaterialSlot, MaterialRecipe>
189
+
190
+ materials['lane.surface'] = PAINTED_VALLEY_GRASS_ORANGE_DIRT_PATH_MATERIAL
191
+ materials['lane.edge'] = {
192
+ ...paintedToonMaterial(base['lane.edge'], {
193
+ inkColor: '#88775f',
194
+ inkStrength: 0.05,
195
+ }),
196
+ emissive: '#705f45',
197
+ emissiveIntensity: 0.34,
198
+ }
199
+ // The theme-default guard is the same birch stock used by the canonical
200
+ // short ranch-fence helper. The backing stays transparent and visual-only.
201
+ materials['lane.guard'] = PAINTED_VALLEY_BIRCH_RANCH_FENCE_MATERIAL
202
+ materials['lane.guard.backing'] = {
203
+ ...paintedToonMaterial(base['lane.guard.backing'], {
204
+ pattern: paintedCedar,
205
+ inkColor: '#8d8069',
206
+ inkStrength: 0.035,
207
+ transparent: true,
208
+ opacity: 0,
209
+ depthWrite: false,
210
+ }),
211
+ emissive: '#665b48',
212
+ emissiveIntensity: 0.12,
213
+ }
214
+ materials['structure.primary'] = paintedToonMaterial(base['structure.primary'], {
215
+ pattern: paintedStone,
216
+ inkColor: '#53685f',
217
+ inkStrength: 0.1,
218
+ })
219
+ materials['structure.secondary'] = paintedToonMaterial(base['structure.secondary'], {
220
+ inkColor: '#3f5953',
221
+ inkStrength: 0.12,
222
+ })
223
+ materials['mechanism.body'] = paintedToonMaterial(base['mechanism.body'], {
224
+ pattern: paintedStone,
225
+ inkColor: '#4a6266',
226
+ inkStrength: 0.1,
227
+ })
228
+ materials['mechanism.moving'] = paintedToonMaterial(base['mechanism.moving'], {
229
+ inkColor: '#793d31',
230
+ inkStrength: 0.12,
231
+ })
232
+ materials['world.ground'] = paintedToonMaterial(base['world.ground'], {
233
+ pattern: meadowWash,
234
+ inkStrength: 0.12,
235
+ })
236
+ materials['world.primary'] = paintedToonMaterial(base['world.primary'], {
237
+ pattern: paintedCedar,
238
+ inkStrength: 0.2,
239
+ })
240
+ materials['world.secondary'] = paintedToonMaterial(base['world.secondary'], {
241
+ pattern: paintedStone,
242
+ inkStrength: 0.18,
243
+ })
244
+ materials['world.foliage'] = paintedToonMaterial(base['world.foliage'], {
245
+ pattern: meadowWash,
246
+ gradientSteps: 3,
247
+ inkStrength: 0.04,
248
+ })
249
+ materials['world.accent'] = paintedToonMaterial(base['world.accent'], {
250
+ gradientSteps: 3,
251
+ inkColor: '#653329',
252
+ inkStrength: 0.24,
253
+ })
254
+ materials.glass = paintedToonMaterial(base.glass, {
255
+ gradientSteps: 3,
256
+ inkColor: '#3d676b',
257
+ inkStrength: 0.26,
258
+ transparent: true,
259
+ opacity: 0.48,
260
+ depthWrite: false,
261
+ })
262
+ return materials
263
+ }
264
+
265
+ export function isTropicalFlowerHedgeAppearance(id: string | undefined): boolean {
266
+ return (
267
+ id === PAINTED_VALLEY_TROPICAL_FLOWER_HEDGE_WALL_ID ||
268
+ id === LEGACY_JUNGLE_FLOWER_HEDGE_WALL_ID
269
+ )
270
+ }
@@ -0,0 +1,71 @@
1
+ import type { TrackPieceAppearance } from '@marble.fun/physics-engine'
2
+
3
+ import { JAPANESE_SAKURA_REDWOOD_PLANKS_ID } from './japanese-sakura-appearance'
4
+ import type { TrackThemeDefinition } from './track-theme'
5
+
6
+ export const WILD_WEST_WOODEN_PLANKS_FLOOR_ID = 'wild-west.wooden-planks'
7
+ export const WILD_WEST_WEATHERED_BOARDS_FLOOR_ID = 'wild-west.weathered-boards'
8
+ export const WILD_WEST_RAIL_MATERIAL_ID = 'wild-west.railroad-iron'
9
+ export const WILD_WEST_RAIL_TIE_MATERIAL_ID = 'wild-west.railroad-tie-timber'
10
+ export const LEGACY_WILD_WEST_RAILROAD_FLOOR_ID = 'wild-west.railroad-planks'
11
+ export const STANDARD_RAILROAD_LANE_WIDTH = 4
12
+ export const STANDARD_RAILROAD_HALF_GAUGE = 1.16
13
+
14
+ /**
15
+ * Half the visual railway gauge carried by a logical lane. Any lane at least
16
+ * as wide as the standard four-unit connector keeps one invariant gauge;
17
+ * widening carrier floors and routing unions must never spread the rails.
18
+ * Only an explicitly narrower corridor is allowed to reduce its gauge.
19
+ */
20
+ export function railroadHalfGaugeForLaneWidth(width: number): number {
21
+ const safeWidth = Number.isFinite(width) ? Math.max(0, width) : STANDARD_RAILROAD_LANE_WIDTH
22
+ if (safeWidth >= STANDARD_RAILROAD_LANE_WIDTH) return STANDARD_RAILROAD_HALF_GAUGE
23
+ return Math.max(0.34, safeWidth * 0.29)
24
+ }
25
+
26
+ /**
27
+ * Rails are a routed floor treatment, not a `lane.edge` material slot. Every
28
+ * ordinary track piece can carry them; only the two vertical/bowl mechanisms
29
+ * have no continuous lane on which rails could be laid.
30
+ */
31
+ export function supportsRailAppearanceChoices(runtimeKind: string): boolean {
32
+ return runtimeKind !== 'vortex' && runtimeKind !== 'conveyor'
33
+ }
34
+
35
+ export function resolvedBaseFloorMaterialId(materialId: string | undefined): string | undefined {
36
+ return materialId === LEGACY_WILD_WEST_RAILROAD_FLOOR_ID ? WILD_WEST_WOODEN_PLANKS_FLOOR_ID : materialId
37
+ }
38
+
39
+ export function usesSeparatedPlankFloorAppearance(materialId: string | undefined): boolean {
40
+ return (
41
+ materialId === WILD_WEST_WOODEN_PLANKS_FLOOR_ID ||
42
+ materialId === WILD_WEST_WEATHERED_BOARDS_FLOOR_ID ||
43
+ materialId === JAPANESE_SAKURA_REDWOOD_PLANKS_ID ||
44
+ materialId === LEGACY_WILD_WEST_RAILROAD_FLOOR_ID
45
+ )
46
+ }
47
+
48
+ /**
49
+ * Resolves a saved floor choice into the active theme by geometry family.
50
+ * This prevents a stale theme-specific id from combining plank geometry with
51
+ * the active theme's fallback surface material (or the reverse).
52
+ */
53
+ export function resolvedFloorMaterialIdForTheme(
54
+ theme: TrackThemeDefinition,
55
+ materialId: string | undefined,
56
+ ): string | undefined {
57
+ const resolvedId = resolvedBaseFloorMaterialId(materialId)
58
+ if (!resolvedId) return undefined
59
+
60
+ const presets = (theme.appearancePresets?.floor ?? []).filter(preset => !preset.hidden)
61
+ if (presets.some(preset => preset.id === resolvedId)) return resolvedId
62
+
63
+ const wantsPlanks = usesSeparatedPlankFloorAppearance(resolvedId)
64
+ return presets.find(preset => usesSeparatedPlankFloorAppearance(preset.id) === wantsPlanks)?.id
65
+ }
66
+
67
+ export function resolvedRailMaterialId(appearance: TrackPieceAppearance | undefined): string | undefined {
68
+ if (appearance?.waterOverlayId) return undefined
69
+ if (appearance?.railMaterialId) return appearance.railMaterialId
70
+ return appearance?.floorMaterialId === LEGACY_WILD_WEST_RAILROAD_FLOOR_ID ? WILD_WEST_RAIL_MATERIAL_ID : undefined
71
+ }
@@ -0,0 +1,32 @@
1
+ interface DrawingBufferRenderer {
2
+ readonly domElement: Pick<HTMLCanvasElement, 'height' | 'width'>
3
+ getPixelRatio(): number
4
+ setDrawingBufferSize(width: number, height: number, pixelRatio: number): void
5
+ }
6
+
7
+ /**
8
+ * Resize a renderer with at most one drawing-buffer allocation.
9
+ *
10
+ * WebGLRenderer.setPixelRatio() calls setSize() internally, so pairing those
11
+ * methods reallocates and clears the canvas twice. Comparing physical buffer
12
+ * dimensions first also keeps duplicate ResizeObserver notifications inert.
13
+ */
14
+ export function resizeRendererDrawingBuffer(
15
+ renderer: DrawingBufferRenderer,
16
+ width: number,
17
+ height: number,
18
+ pixelRatio: number,
19
+ ): boolean {
20
+ const drawingBufferWidth = Math.floor(width * pixelRatio)
21
+ const drawingBufferHeight = Math.floor(height * pixelRatio)
22
+ if (
23
+ renderer.domElement.width === drawingBufferWidth &&
24
+ renderer.domElement.height === drawingBufferHeight &&
25
+ renderer.getPixelRatio() === pixelRatio
26
+ ) {
27
+ return false
28
+ }
29
+
30
+ renderer.setDrawingBufferSize(width, height, pixelRatio)
31
+ return true
32
+ }
@@ -0,0 +1,91 @@
1
+ import type { MaterialRecipe } from './track-theme'
2
+
3
+ export const SHARED_ROPE_WALL_MATERIAL_ID = 'shared.rope-wall'
4
+ export const SHARED_ROPE_WALL_BACKING_MATERIAL_ID = 'shared.rope-wall-backing'
5
+ export const SHARED_ROPE_WALL_TIMBER_MATERIAL_ID = 'shared.rope-wall-timber'
6
+ export const SHARED_ROPE_WALL_END_GRAIN_MATERIAL_ID = 'shared.rope-wall-end-grain'
7
+
8
+ export const ROPE_WALL_SUPPORTED_THEME_IDS = ['low-poly-jungle', 'ancient-egypt'] as const
9
+
10
+ /** One art-directed recipe shared verbatim by the Jungle and Egyptian themes. */
11
+ export const SHARED_ROPE_WALL_MATERIAL: MaterialRecipe = {
12
+ kind: 'standard',
13
+ color: '#c49a6d',
14
+ roughness: 0.92,
15
+ metalness: 0,
16
+ envMapIntensity: 0.28,
17
+ pattern: {
18
+ kind: 'braided-rope',
19
+ primary: '#c58a38',
20
+ secondary: '#56300f',
21
+ worldSize: [0.18, 0.22],
22
+ },
23
+ textureUrl: '/themes/textures/old-assets/shared-rope-bridge-fibre-v2-albedo.webp',
24
+ normalTextureUrl: '/themes/textures/old-assets/shared-rope-bridge-fibre-v2-normal.png',
25
+ roughnessTextureUrl: '/themes/textures/old-assets/shared-rope-bridge-fibre-v2-roughness.png',
26
+ aoTextureUrl: '/themes/textures/old-assets/shared-rope-bridge-fibre-v2-ao.png',
27
+ textureWorldSize: [0.42, 0.58],
28
+ normalScale: [1, 1],
29
+ aoMapIntensity: 0.62,
30
+ }
31
+
32
+ export const SHARED_ROPE_WALL_TIMBER_MATERIAL: MaterialRecipe = {
33
+ kind: 'standard',
34
+ color: '#b59a82',
35
+ roughness: 0.78,
36
+ metalness: 0,
37
+ envMapIntensity: 0.26,
38
+ pattern: {
39
+ kind: 'rough-wood',
40
+ primary: '#59331d',
41
+ secondary: '#160b07',
42
+ worldSize: [0.72, 1.55],
43
+ },
44
+ textureUrl: '/themes/textures/old-assets/shared-rope-bridge-post-timber-v2-albedo.webp',
45
+ normalTextureUrl: '/themes/textures/old-assets/shared-rope-bridge-post-timber-v2-normal.png',
46
+ roughnessTextureUrl: '/themes/textures/old-assets/shared-rope-bridge-post-timber-v2-roughness.png',
47
+ aoTextureUrl: '/themes/textures/old-assets/shared-rope-bridge-post-timber-v2-ao.png',
48
+ textureWorldSize: [0.55, 0.85],
49
+ normalScale: [0.82, 0.82],
50
+ aoMapIntensity: 0.62,
51
+ }
52
+
53
+ export const SHARED_ROPE_WALL_END_GRAIN_MATERIAL: MaterialRecipe = {
54
+ kind: 'standard',
55
+ color: '#c39a72',
56
+ roughness: 0.72,
57
+ metalness: 0,
58
+ envMapIntensity: 0.24,
59
+ pattern: {
60
+ kind: 'wood',
61
+ primary: '#bc8247',
62
+ secondary: '#4b2916',
63
+ worldSize: [1, 1],
64
+ },
65
+ textureUrl: '/themes/textures/old-assets/shared-rope-bridge-end-grain-v2-albedo.webp',
66
+ normalTextureUrl: '/themes/textures/old-assets/shared-rope-bridge-end-grain-v2-normal.png',
67
+ roughnessTextureUrl: '/themes/textures/old-assets/shared-rope-bridge-end-grain-v2-roughness.png',
68
+ aoTextureUrl: '/themes/textures/old-assets/shared-rope-bridge-end-grain-v2-ao.png',
69
+ textureWorldSize: [1, 1],
70
+ normalScale: [0.72, 0.72],
71
+ aoMapIntensity: 0.58,
72
+ }
73
+
74
+ /**
75
+ * The existing continuous wall remains the sole physical barrier. This
76
+ * transparent recipe hides only its visual shell behind the open rope work.
77
+ */
78
+ export const SHARED_ROPE_WALL_BACKING_MATERIAL: MaterialRecipe = {
79
+ kind: 'standard',
80
+ color: '#ffffff',
81
+ roughness: 1,
82
+ metalness: 0,
83
+ transparent: true,
84
+ opacity: 0,
85
+ depthWrite: false,
86
+ envMapIntensity: 0,
87
+ }
88
+
89
+ export function isSharedRopeWallAppearance(materialId: string | undefined): boolean {
90
+ return materialId === SHARED_ROPE_WALL_MATERIAL_ID
91
+ }
@@ -0,0 +1,2 @@
1
+ export const WILD_WEST_ANTIQUE_BRASS_MATERIAL_ID = 'wild-west.antique-brass'
2
+ export const WILD_WEST_GUNMETAL_FLIPPER_MATERIAL_ID = 'wild-west.gunmetal-flipper'
@@ -0,0 +1,183 @@
1
+ import * as THREE from 'three'
2
+
3
+ import { usesMobileRenderingBudget } from './mobile-rendering-budget'
4
+
5
+ import type { ThemeId, TrackThemeDefinition } from './track-theme'
6
+
7
+ export const SCENE_LIGHTING_MOBILE_BREAKPOINT = 700
8
+ export const STATIC_SHADOW_PROXY_LAYER = 31
9
+
10
+ export function usesMobileSceneLighting(viewportWidth: number): boolean {
11
+ return viewportWidth < SCENE_LIGHTING_MOBILE_BREAKPOINT || usesMobileRenderingBudget(Number.POSITIVE_INFINITY)
12
+ }
13
+
14
+ export function resolveDynamicShadowRefreshRate(
15
+ viewportWidth: number,
16
+ deltaSeconds: number,
17
+ currentRate = 60,
18
+ ): 30 | 60 {
19
+ if (usesMobileSceneLighting(viewportWidth)) return 30
20
+ // Hysteresis keeps crisp shadow motion on fast desktops but backs off
21
+ // before shadow rendering can amplify an already slow frame.
22
+ if (deltaSeconds >= 1 / 45) return 30
23
+ if (deltaSeconds <= 1 / 55) return 60
24
+ return currentRate === 30 ? 30 : 60
25
+ }
26
+
27
+ export interface SceneLightingRig {
28
+ environmentIntensity: number
29
+ exposure: number
30
+ hemisphere: {
31
+ skyColor: string
32
+ groundColor: string
33
+ intensity: number
34
+ }
35
+ key: {
36
+ color: string
37
+ intensity: number
38
+ position: readonly [number, number, number]
39
+ }
40
+ rim: {
41
+ color: string
42
+ intensity: number
43
+ position: readonly [number, number, number]
44
+ }
45
+ shadow: {
46
+ mapSize: number
47
+ extent: number
48
+ near: number
49
+ far: number
50
+ bias: number
51
+ normalBias: number
52
+ intensity: number
53
+ focusDistance: number
54
+ }
55
+ }
56
+
57
+ const SHADOW_INTENSITY_BY_THEME: Partial<Record<ThemeId, number>> = {
58
+ 'rocky-mountain-wild-west': 0.82,
59
+ 'ancient-egypt': 0.82,
60
+ 'japanese-sakura-lake': 0.8,
61
+ 'intergalactic-space-station': 0.74,
62
+ 'low-poly-jungle': 0.82,
63
+ 'vampire-castle': 0.58,
64
+ }
65
+
66
+ const GAMEPLAY_ENVIRONMENT_SCALE = 0.72
67
+ const GAMEPLAY_HEMISPHERE_SCALE = 0.34
68
+ const GAMEPLAY_KEY_SCALE = 0.82
69
+ const GAMEPLAY_RIM_SCALE = 0.28
70
+ const GAMEPLAY_EXPOSURE_SCALE = 0.97
71
+
72
+ export function resolveSceneLightingRig(theme: TrackThemeDefinition, mobile: boolean): SceneLightingRig {
73
+ const vampireCastle = theme.id === 'vampire-castle'
74
+ const spaceStation = theme.id === 'intergalactic-space-station'
75
+ return {
76
+ environmentIntensity: THREE.MathUtils.clamp(
77
+ theme.environment.environmentIntensity * GAMEPLAY_ENVIRONMENT_SCALE,
78
+ 0.38,
79
+ vampireCastle ? 0.92 : 0.86,
80
+ ),
81
+ exposure: THREE.MathUtils.clamp(
82
+ theme.lighting.exposure * GAMEPLAY_EXPOSURE_SCALE,
83
+ 0.82,
84
+ vampireCastle || spaceStation ? 1.15 : 1.05,
85
+ ),
86
+ hemisphere: {
87
+ skyColor: theme.lighting.skyColor,
88
+ groundColor: theme.lighting.groundColor,
89
+ intensity: THREE.MathUtils.clamp(
90
+ theme.lighting.ambientIntensity * GAMEPLAY_HEMISPHERE_SCALE,
91
+ 0.3,
92
+ vampireCastle ? 0.82 : 0.62,
93
+ ),
94
+ },
95
+ key: {
96
+ color: theme.lighting.keyColor,
97
+ intensity: theme.lighting.keyIntensity * GAMEPLAY_KEY_SCALE,
98
+ position: theme.lighting.keyPosition,
99
+ },
100
+ rim: {
101
+ color: theme.lighting.rimColor,
102
+ intensity: theme.lighting.rimIntensity * GAMEPLAY_RIM_SCALE,
103
+ position: theme.lighting.rimPosition,
104
+ },
105
+ shadow: {
106
+ mapSize: mobile ? 1024 : 1536,
107
+ extent: mobile ? 36 : 52,
108
+ near: 1,
109
+ far: 220,
110
+ bias: -0.00008,
111
+ normalBias: 0.028,
112
+ intensity: SHADOW_INTENSITY_BY_THEME[theme.id as ThemeId] ?? 0.8,
113
+ focusDistance: mobile ? 12 : 18,
114
+ },
115
+ }
116
+ }
117
+
118
+ export function configureSceneRenderer(renderer: THREE.WebGLRenderer, exposure = 1): void {
119
+ renderer.outputColorSpace = THREE.SRGBColorSpace
120
+ renderer.toneMapping = THREE.ACESFilmicToneMapping
121
+ renderer.toneMappingExposure = exposure
122
+ renderer.shadowMap.enabled = true
123
+ renderer.shadowMap.type = THREE.PCFShadowMap
124
+ renderer.shadowMap.autoUpdate = false
125
+ renderer.shadowMap.needsUpdate = true
126
+ }
127
+
128
+ export function configureKeyShadow(light: THREE.DirectionalLight, rig: SceneLightingRig): void {
129
+ const shadow = rig.shadow
130
+ light.castShadow = true
131
+ light.shadow.mapSize.set(shadow.mapSize, shadow.mapSize)
132
+ light.shadow.camera.left = -shadow.extent
133
+ light.shadow.camera.right = shadow.extent
134
+ light.shadow.camera.top = shadow.extent
135
+ light.shadow.camera.bottom = -shadow.extent
136
+ light.shadow.camera.near = shadow.near
137
+ light.shadow.camera.far = shadow.far
138
+ light.shadow.bias = shadow.bias
139
+ light.shadow.normalBias = shadow.normalBias
140
+ light.shadow.intensity = shadow.intensity
141
+ light.shadow.camera.layers.enable(STATIC_SHADOW_PROXY_LAYER)
142
+ light.shadow.camera.updateProjectionMatrix()
143
+ }
144
+
145
+ /** Dispose GPU shadow targets before removing a themed light rig. */
146
+ export function disposeSceneLightShadows(root: THREE.Object3D): void {
147
+ root.traverse(object => {
148
+ if (
149
+ object instanceof THREE.DirectionalLight ||
150
+ object instanceof THREE.PointLight ||
151
+ object instanceof THREE.SpotLight
152
+ ) {
153
+ object.shadow.dispose()
154
+ }
155
+ })
156
+ }
157
+
158
+ export function computeShadowFocus(
159
+ camera: THREE.Camera,
160
+ rig: SceneLightingRig,
161
+ target = new THREE.Vector3(),
162
+ focusPoint?: THREE.Vector3,
163
+ shadowMapSize = rig.shadow.mapSize,
164
+ ): THREE.Vector3 {
165
+ if (focusPoint) {
166
+ target.copy(focusPoint)
167
+ } else {
168
+ camera.getWorldDirection(target).multiplyScalar(rig.shadow.focusDistance)
169
+ const cameraMatrix = camera.matrixWorld.elements
170
+ target.x += cameraMatrix[12]
171
+ target.y += cameraMatrix[13]
172
+ target.z += cameraMatrix[14]
173
+ }
174
+
175
+ // Stable, texel-sized steps prevent the shadow projection from shimmering as the camera eases forward.
176
+ const texelWorldSize = (rig.shadow.extent * 2) / shadowMapSize
177
+ target.set(
178
+ Math.round(target.x / texelWorldSize) * texelWorldSize,
179
+ Math.round(target.y / texelWorldSize) * texelWorldSize,
180
+ Math.round(target.z / texelWorldSize) * texelWorldSize,
181
+ )
182
+ return target
183
+ }