@marble.fun/track-catalog 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +15 -0
- package/assets.json +346 -0
- package/package.json +32 -0
- package/release.json +135 -0
- package/src/audio/game-audio-preference.ts +9 -0
- package/src/authoritative.ts +6 -0
- package/src/catalog.ts +1 -0
- package/src/index.ts +14 -0
- package/src/legacy-stock-catalog.ts +130 -0
- package/src/pages/lobby/lobby-types.ts +140 -0
- package/src/pages/lobby/marble-factory.ts +931 -0
- package/src/pages/play/race/egypt-elevator-blender.ts +159 -0
- package/src/pages/play/race/egypt-portal-frame.ts +382 -0
- package/src/pages/play/race/finish-detection.ts +1 -0
- package/src/pages/play/race/japanese-sakura-castle-support-blender.ts +510 -0
- package/src/pages/play/race/japanese-sakura-kawara-roof.ts +201 -0
- package/src/pages/play/race/japanese-sakura-portal-frame.ts +325 -0
- package/src/pages/play/race/jungle-portal-frame.ts +308 -0
- package/src/pages/play/race/jungle-temple-lift.ts +366 -0
- package/src/pages/play/race/marble-entity.ts +257 -0
- package/src/pages/play/race/marble-material.ts +20 -0
- package/src/pages/play/race/marble-race-engine.ts +2652 -0
- package/src/pages/play/race/path-pattern-layout.ts +142 -0
- package/src/pages/play/race/peg-obstacle-visual.ts +171 -0
- package/src/pages/play/race/physics-debug.ts +1 -0
- package/src/pages/play/race/physics-dry-run.ts +90 -0
- package/src/pages/play/race/portal-derived-elevator-skins.ts +729 -0
- package/src/pages/play/race/portal-pair-color.ts +20 -0
- package/src/pages/play/race/portal-proportions.ts +25 -0
- package/src/pages/play/race/portal-visual.ts +1108 -0
- package/src/pages/play/race/race-camera-director.ts +586 -0
- package/src/pages/play/race/race-camera-obstruction.ts +37 -0
- package/src/pages/play/race/race-camera-tour-planner.ts +274 -0
- package/src/pages/play/race/race-camera.ts +206 -0
- package/src/pages/play/race/race-completion.ts +5 -0
- package/src/pages/play/race/race-configuration.ts +57 -0
- package/src/pages/play/race/race-marble-focus-renderer.ts +134 -0
- package/src/pages/play/race/race-physics-profile.ts +7 -0
- package/src/pages/play/race/race-physics.ts +16 -0
- package/src/pages/play/race/race-playback.ts +41 -0
- package/src/pages/play/race/race-rolling-audio.ts +616 -0
- package/src/pages/play/race/race-simulation-renderer.ts +221 -0
- package/src/pages/play/race/race-terminal-checker-strip.ts +226 -0
- package/src/pages/play/race/race-transform-interpolation.ts +50 -0
- package/src/pages/play/race/race-types.ts +12 -0
- package/src/pages/play/race/rebound-visual.ts +70 -0
- package/src/pages/play/race/seeded-random.ts +23 -0
- package/src/pages/play/race/space-station-portal-frame.ts +275 -0
- package/src/pages/play/race/track-appearance-canopy.ts +946 -0
- package/src/pages/play/race/track-appearance-floor-runner.ts +206 -0
- package/src/pages/play/race/track-appearance-jungle-flower-hedge.ts +483 -0
- package/src/pages/play/race/track-appearance-rope-wall.ts +443 -0
- package/src/pages/play/race/track-appearance-sakura-bridge-fence.ts +305 -0
- package/src/pages/play/race/track-appearance-sakura-castle-wall.ts +1031 -0
- package/src/pages/play/race/track-appearance-space-station-wall.ts +497 -0
- package/src/pages/play/race/track-appearance-stone-block-shell.ts +517 -0
- package/src/pages/play/race/track-appearance-support.ts +983 -0
- package/src/pages/play/race/track-appearance-vampire-carved-wall.ts +545 -0
- package/src/pages/play/race/track-appearance-wall-terminal.ts +167 -0
- package/src/pages/play/race/track-course.ts +271 -0
- package/src/pages/play/race/track-definition.ts +443 -0
- package/src/pages/play/race/track-fence-architecture.ts +33 -0
- package/src/pages/play/race/track-floor-material-rules.ts +51 -0
- package/src/pages/play/race/track-mechanisms.ts +1350 -0
- package/src/pages/play/race/track-physics-surface.ts +6 -0
- package/src/pages/play/race/track-route-network.ts +9 -0
- package/src/pages/play/race/track-shadow-batching.ts +760 -0
- package/src/pages/play/race/track-surface-basis.ts +1 -0
- package/src/pages/play/race/track-surface-topology.ts +326 -0
- package/src/pages/play/race/track-world.ts +10181 -0
- package/src/pages/play/race/under-track-support-architecture.ts +122 -0
- package/src/pages/play/race/vortex-profile.ts +5 -0
- package/src/pages/play/race/vortex-water-effect.ts +863 -0
- package/src/pages/play/race/wild-west-mine-lift.ts +281 -0
- package/src/pages/play/race/wild-west-vortex-housing.ts +572 -0
- package/src/pages/track-lab/atlas-vortex-layout.ts +1 -0
- package/src/pages/track-lab/cone-field-layout.ts +1 -0
- package/src/pages/track-lab/conveyor-layout.ts +1 -0
- package/src/pages/track-lab/engine/canonical-track-assembly.ts +64 -0
- package/src/pages/track-lab/engine/editor-object-geometry.ts +170 -0
- package/src/pages/track-lab/engine/track-lab-connection-state.ts +123 -0
- package/src/pages/track-lab/engine/track-piece-visual.ts +1420 -0
- package/src/pages/track-lab/flipper-bank-layout.ts +1 -0
- package/src/pages/track-lab/flipper-component.ts +127 -0
- package/src/pages/track-lab/rising-wall-layout.ts +1 -0
- package/src/pages/track-lab/simple-obstacle-layout.ts +1 -0
- package/src/pages/track-lab/track-piece-layout.ts +28 -0
- package/src/pages/track-lab/track-runtime-adapter.ts +1152 -0
- package/src/three/geometry-attribute-aliases.ts +24 -0
- package/src/three/geometry-detail.ts +36 -0
- package/src/three/homepage-marble-geometry.ts +15 -0
- package/src/three/japanese-sakura-appearance.ts +252 -0
- package/src/three/jungle-flower-hedge-appearance.ts +7 -0
- package/src/three/jungle-temple-appearance.ts +5 -0
- package/src/three/low-quality-scene-renderer.ts +132 -0
- package/src/three/marble-sphere-geometry.ts +13 -0
- package/src/three/mobile-rendering-budget.ts +11 -0
- package/src/three/painted-valley-appearance.ts +270 -0
- package/src/three/railroad-appearance.ts +71 -0
- package/src/three/renderer-resize.ts +32 -0
- package/src/three/rope-wall-appearance.ts +91 -0
- package/src/three/rugged-track-appearance.ts +2 -0
- package/src/three/scene-lighting.ts +183 -0
- package/src/three/scene-shader-preparation.ts +64 -0
- package/src/three/scene-theme-controller.ts +461 -0
- package/src/three/shadow-shader-preparation.ts +162 -0
- package/src/three/shared-obstacle-metal.ts +44 -0
- package/src/three/soft-additive-particles.ts +57 -0
- package/src/three/space-station-appearance.ts +213 -0
- package/src/three/static-track-batched-mesh.ts +132 -0
- package/src/three/stone-track-shell-appearance.ts +12 -0
- package/src/three/theme-ambience-controller.ts +226 -0
- package/src/three/theme-asset-cache.ts +120 -0
- package/src/three/theme-audio-preference.ts +43 -0
- package/src/three/theme-catalog.ts +1457 -0
- package/src/three/theme-material-assets.ts +258 -0
- package/src/three/theme-material-ktx2-manifest.ts +190 -0
- package/src/three/theme-material-registry.ts +1952 -0
- package/src/three/theme-world-factory.ts +590 -0
- package/src/three/track-connector-batch.ts +125 -0
- package/src/three/track-material-continuity.ts +442 -0
- package/src/three/track-theme.ts +535 -0
- package/src/three/vampire-castle-appearance.ts +258 -0
- package/src/three/visual-frame-pacing.ts +19 -0
- package/src/three/visual-geometry.ts +120 -0
- package/src/three/visual-quality.ts +83 -0
- package/src/three/water-appearance.ts +29 -0
- package/src/three/wild-west-sand-appearance.ts +2 -0
|
@@ -0,0 +1,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
|
+
}
|