@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,308 @@
1
+ import * as THREE from 'three'
2
+
3
+ import { VisualConeGeometry, VisualTubeGeometry, VisualBoxGeometry } from '../../../three/visual-geometry'
4
+ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
5
+
6
+ import { PORTAL_APERTURE_HALF_WIDTH, PORTAL_THEME_FRAME_X_SCALE } from './portal-proportions'
7
+
8
+ export const JUNGLE_PORTAL_FRAME_THEME_ID = 'low-poly-jungle'
9
+
10
+ export interface JunglePortalFrameMaterials {
11
+ /** Exact `jungle.temple-stone` appearance from the active registry. */
12
+ stone: THREE.Material
13
+ /** Retained for shared material-bundle compatibility; portals use stone courses on both pillars. */
14
+ facade?: THREE.Material
15
+ /** Exact `jungle.temple-bronze` appearance. */
16
+ bronze: THREE.Material
17
+ /** Existing Jungle world foliage appearance; no portal-private plant recipe. */
18
+ foliage: THREE.Material
19
+ }
20
+
21
+ export interface JunglePortalFrameOptions {
22
+ root: THREE.Group
23
+ name: string
24
+ color: string
25
+ materials: JunglePortalFrameMaterials
26
+ }
27
+
28
+ const FRONT_Z = 0.46
29
+ const IDENTITY_QUATERNION = new THREE.Quaternion()
30
+ const SHARED_SCALE = new THREE.Vector3(1, 1, 1)
31
+
32
+ function markVisualOnly(object: THREE.Object3D, frameStyle = 'jungle-serpent-temple'): void {
33
+ object.userData.themeOnly = JUNGLE_PORTAL_FRAME_THEME_ID
34
+ object.userData.decorative = true
35
+ object.userData.visualOnly = true
36
+ object.userData.portalFrameStyle = frameStyle
37
+ }
38
+
39
+ /** Give repeatable stone/metal recipes stable world-sized planar coordinates. */
40
+ function metricPlanarUvs(geometry: THREE.BufferGeometry): THREE.BufferGeometry {
41
+ const prepared = geometry.index ? geometry.toNonIndexed() : geometry
42
+ if (prepared !== geometry) geometry.dispose()
43
+ const position = prepared.getAttribute('position')
44
+ const normal = prepared.getAttribute('normal')
45
+ const uv = new Float32Array(position.count * 2)
46
+ const themeNetUv = new Float32Array(position.count * 4)
47
+ for (let index = 0; index < position.count; index += 1) {
48
+ const x = position.getX(index)
49
+ const y = position.getY(index)
50
+ const z = position.getZ(index)
51
+ const nx = Math.abs(normal.getX(index))
52
+ const ny = Math.abs(normal.getY(index))
53
+ const nz = Math.abs(normal.getZ(index))
54
+ if (nx >= ny && nx >= nz) {
55
+ uv[index * 2] = z
56
+ uv[index * 2 + 1] = y
57
+ } else if (ny >= nz) {
58
+ uv[index * 2] = x
59
+ uv[index * 2 + 1] = z
60
+ } else {
61
+ uv[index * 2] = x
62
+ uv[index * 2 + 1] = y
63
+ }
64
+ themeNetUv[index * 4 + 2] = 1
65
+ }
66
+ prepared.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
67
+ prepared.setAttribute('themeNetUv', new THREE.BufferAttribute(themeNetUv, 4))
68
+ return prepared
69
+ }
70
+
71
+ function transformGeometry(
72
+ geometry: THREE.BufferGeometry,
73
+ position: THREE.Vector3,
74
+ rotation = IDENTITY_QUATERNION,
75
+ scale = SHARED_SCALE,
76
+ ): THREE.BufferGeometry {
77
+ geometry.applyMatrix4(new THREE.Matrix4().compose(position, rotation, scale))
78
+ return geometry
79
+ }
80
+
81
+ function roundedStone(size: THREE.Vector3, position: THREE.Vector3, radius = 0.07): THREE.BufferGeometry {
82
+ return transformGeometry(metricPlanarUvs(new VisualBoxGeometry(size.x, size.y, size.z, 2, radius)), position)
83
+ }
84
+
85
+ function mergeBatch(
86
+ frame: THREE.Group,
87
+ name: string,
88
+ material: THREE.Material,
89
+ parts: THREE.BufferGeometry[],
90
+ ): THREE.Mesh {
91
+ const geometry = mergeGeometries(parts, false)
92
+ parts.forEach(part => part.dispose())
93
+ if (!geometry) throw new Error(`Unable to merge Jungle portal batch "${name}".`)
94
+ geometry.name = `${name}-geometry`
95
+ geometry.computeBoundingBox()
96
+ geometry.computeBoundingSphere()
97
+ geometry.userData.visualOnly = true
98
+ const mesh = new THREE.Mesh(geometry, material)
99
+ mesh.name = name
100
+ mesh.castShadow = true
101
+ mesh.receiveShadow = true
102
+ markVisualOnly(mesh)
103
+ frame.add(mesh)
104
+ return mesh
105
+ }
106
+
107
+ function addPairCrystal(frame: THREE.Group, name: string, color: string, bronze: THREE.Material): THREE.Group {
108
+ const crown = new THREE.Group()
109
+ crown.name = `${name}-jungle-pair-crystal`
110
+ crown.position.set(0, 1.78, 0.68)
111
+ crown.userData.portalPairColor = color
112
+ crown.userData.portalLightRole = 'pair-color-temple-crystal'
113
+ markVisualOnly(crown)
114
+
115
+ const pairColor = new THREE.Color(color)
116
+ const crystalMaterial = new THREE.MeshPhysicalMaterial({
117
+ color: pairColor.clone().lerp(new THREE.Color('#ffffff'), 0.16),
118
+ emissive: pairColor,
119
+ emissiveIntensity: 2.1,
120
+ roughness: 0.14,
121
+ metalness: 0,
122
+ // A tiny decorative gem must not trigger a full-scene refraction pass.
123
+ transmission: 0,
124
+ thickness: 0.22,
125
+ transparent: true,
126
+ opacity: 0.94,
127
+ toneMapped: false,
128
+ })
129
+ const crystal = new THREE.Mesh(new THREE.OctahedronGeometry(0.25, 0), crystalMaterial)
130
+ crystal.name = `${name}-jungle-pair-crystal-gem`
131
+ crystal.scale.set(0.76, 1.25, 0.7)
132
+ crystal.rotation.y = Math.PI / 4
133
+ crystal.castShadow = false
134
+ crystal.userData.ownsMaterial = true
135
+ crystal.userData.portalPairColor = color
136
+ markVisualOnly(crystal)
137
+ crown.add(crystal)
138
+
139
+ const haloMaterial = new THREE.MeshBasicMaterial({
140
+ color,
141
+ transparent: true,
142
+ opacity: 0.2,
143
+ blending: THREE.AdditiveBlending,
144
+ depthWrite: false,
145
+ toneMapped: false,
146
+ })
147
+ const halo = new THREE.Mesh(new THREE.OctahedronGeometry(0.31, 1), haloMaterial)
148
+ halo.name = `${name}-jungle-pair-crystal-halo`
149
+ halo.scale.set(0.76, 1.25, 0.7)
150
+ halo.rotation.y = Math.PI / 4
151
+ halo.userData.ownsMaterial = true
152
+ halo.userData.portalPairColor = color
153
+ markVisualOnly(halo)
154
+ crown.add(halo)
155
+
156
+ const holderParts = [
157
+ roundedStone(new THREE.Vector3(0.56, 0.12, 0.34), new THREE.Vector3(0, -0.3, -0.03), 0.035),
158
+ roundedStone(new THREE.Vector3(0.12, 0.38, 0.28), new THREE.Vector3(-0.3, -0.12, -0.03), 0.025),
159
+ roundedStone(new THREE.Vector3(0.12, 0.38, 0.28), new THREE.Vector3(0.3, -0.12, -0.03), 0.025),
160
+ ]
161
+ mergeBatch(crown, `${name}-jungle-crystal-bronze-setting`, bronze, holderParts)
162
+
163
+ const light = new THREE.PointLight(color, 0.82, 3.2, 2)
164
+ light.name = `${name}-jungle-pair-crystal-light`
165
+ light.position.set(0, -0.04, 0.18)
166
+ light.userData.portalPairColor = color
167
+ light.userData.portalLightRole = 'pair-color-temple-crystal'
168
+ markVisualOnly(light)
169
+ crown.add(light)
170
+ frame.add(crown)
171
+ return crown
172
+ }
173
+
174
+ function addOuterPlants(frame: THREE.Group, name: string, foliage: THREE.Material): void {
175
+ const leafGeometry = metricPlanarUvs(new VisualConeGeometry(0.12, 0.48, 5, 1))
176
+ leafGeometry.translate(0, 0.24, 0)
177
+ leafGeometry.name = `${name}-jungle-outer-leaf-geometry`
178
+ const placements = [
179
+ [-2.58, -1.46, 0.48, -0.72, 1.16],
180
+ [-2.43, -1.42, 0.57, -0.32, 1.34],
181
+ [-2.28, -1.5, 0.51, 0.2, 1.04],
182
+ [-2.69, -1.51, 0.36, -1.02, 0.92],
183
+ [2.58, -1.47, 0.48, 0.64, 1.2],
184
+ [2.42, -1.43, 0.58, 0.22, 1.36],
185
+ [2.27, -1.51, 0.5, -0.28, 1.02],
186
+ [2.68, -1.5, 0.36, 0.96, 0.9],
187
+ [-2.42, 1.08, 0.5, -0.9, 0.84],
188
+ [-2.2, 1.34, 0.52, -0.34, 0.72],
189
+ [-2.22, 1.84, 0.48, -0.84, 0.68],
190
+ [2.42, 1.06, 0.5, 0.78, 0.82],
191
+ [2.18, 1.34, 0.52, 0.3, 0.72],
192
+ [2.22, 1.84, 0.48, 0.82, 0.68],
193
+ ] as const
194
+ const leaves = new THREE.InstancedMesh(leafGeometry, foliage, placements.length)
195
+ leaves.name = `${name}-jungle-outer-plants`
196
+ leaves.castShadow = true
197
+ leaves.receiveShadow = true
198
+ const matrix = new THREE.Matrix4()
199
+ const quaternion = new THREE.Quaternion()
200
+ const scale = new THREE.Vector3()
201
+ placements.forEach(([x, y, z, angle, size], index) => {
202
+ quaternion.setFromAxisAngle(new THREE.Vector3(0, 0, 1), angle)
203
+ scale.set(size, size, size * 0.72)
204
+ matrix.compose(new THREE.Vector3(x, y, z), quaternion, scale)
205
+ leaves.setMatrixAt(index, matrix)
206
+ })
207
+ leaves.instanceMatrix.needsUpdate = true
208
+ leaves.computeBoundingBox()
209
+ leaves.computeBoundingSphere()
210
+ leaves.userData.plantPlacement = 'outer-frame-only'
211
+ markVisualOnly(leaves)
212
+ frame.add(leaves)
213
+
214
+ const vineCurves = [
215
+ [
216
+ new THREE.Vector3(-2.28, 1.65, FRONT_Z + 0.08),
217
+ new THREE.Vector3(-2.47, 1.04, FRONT_Z + 0.12),
218
+ new THREE.Vector3(-2.36, 0.38, FRONT_Z + 0.14),
219
+ new THREE.Vector3(-2.5, -0.28, FRONT_Z + 0.1),
220
+ ],
221
+ [
222
+ new THREE.Vector3(2.32, 1.58, FRONT_Z + 0.07),
223
+ new THREE.Vector3(2.48, 1.08, FRONT_Z + 0.12),
224
+ new THREE.Vector3(2.36, 0.56, FRONT_Z + 0.13),
225
+ ],
226
+ [
227
+ new THREE.Vector3(-1.62, 1.92, FRONT_Z + 0.04),
228
+ new THREE.Vector3(-0.82, 1.78, FRONT_Z + 0.1),
229
+ new THREE.Vector3(0.12, 1.9, FRONT_Z + 0.08),
230
+ new THREE.Vector3(1.02, 1.74, FRONT_Z + 0.12),
231
+ new THREE.Vector3(1.72, 1.9, FRONT_Z + 0.05),
232
+ ],
233
+ ]
234
+ const vines = vineCurves.map(points =>
235
+ metricPlanarUvs(new VisualTubeGeometry(new THREE.CatmullRomCurve3(points), 18, 0.04, 6, false)),
236
+ )
237
+ mergeBatch(frame, `${name}-jungle-outer-vines`, foliage, vines)
238
+ }
239
+
240
+ /**
241
+ * Adds only the Jungle portal's theme-local architectural frame and light source.
242
+ * The black aperture, live camera, animated rift, glow, and particles stay owned by
243
+ * the shared portal visual. This function creates no physics bodies or colliders.
244
+ */
245
+ export function addJunglePortalFrame(options: JunglePortalFrameOptions): THREE.Group {
246
+ const { root, name, color, materials } = options
247
+ const frame = new THREE.Group()
248
+ frame.name = `${name}-jungle-temple-frame`
249
+ frame.userData.portalPairColor = color
250
+ frame.scale.x = PORTAL_THEME_FRAME_X_SCALE
251
+ frame.userData.portalApertureClearHalfWidth = PORTAL_APERTURE_HALF_WIDTH
252
+ frame.userData.drawCallBudget = 7
253
+ frame.userData.triangleBudget = 5_500
254
+ frame.userData.materialReuse = {
255
+ stone: 'jungle.temple-stone',
256
+ bronze: 'jungle.temple-bronze',
257
+ plants: 'world.foliage',
258
+ }
259
+ markVisualOnly(frame)
260
+
261
+ const stoneParts: THREE.BufferGeometry[] = []
262
+ // Deliberately chunky, slightly offset courses read as an old temple without
263
+ // spending a draw call per block. They sit outside the shared clear aperture.
264
+ for (const side of [-1, 1] as const) {
265
+ const x = side * 2.16
266
+ stoneParts.push(
267
+ roundedStone(new THREE.Vector3(0.76, 0.54, 0.82), new THREE.Vector3(x + side * 0.03, -1.33, 0), 0.095),
268
+ roundedStone(new THREE.Vector3(0.68, 0.52, 0.86), new THREE.Vector3(x - side * 0.035, -0.82, 0.02), 0.09),
269
+ roundedStone(new THREE.Vector3(0.75, 0.5, 0.84), new THREE.Vector3(x + side * 0.025, -0.32, 0), 0.09),
270
+ roundedStone(new THREE.Vector3(0.69, 0.5, 0.88), new THREE.Vector3(x - side * 0.03, 0.18, 0.025), 0.09),
271
+ roundedStone(new THREE.Vector3(0.76, 0.5, 0.84), new THREE.Vector3(x + side * 0.025, 0.68, 0), 0.095),
272
+ roundedStone(new THREE.Vector3(0.72, 0.42, 0.86), new THREE.Vector3(x, 1.13, 0.015), 0.085),
273
+ roundedStone(new THREE.Vector3(1.02, 0.34, 1.0), new THREE.Vector3(side * 2.2, -1.66, -0.02), 0.08),
274
+ roundedStone(new THREE.Vector3(0.86, 0.28, 0.94), new THREE.Vector3(side * 2.16, 1.38, 0), 0.07),
275
+ roundedStone(new THREE.Vector3(0.68, 2.36, 0.18), new THREE.Vector3(side * 2.165, -0.08, 0.46), 0.055),
276
+ )
277
+ }
278
+ stoneParts.push(
279
+ roundedStone(new THREE.Vector3(1.18, 0.54, 0.88), new THREE.Vector3(-1.72, 1.65, 0), 0.1),
280
+ roundedStone(new THREE.Vector3(1.08, 0.57, 0.9), new THREE.Vector3(-0.62, 1.63, 0.02), 0.1),
281
+ roundedStone(new THREE.Vector3(1.08, 0.57, 0.9), new THREE.Vector3(0.62, 1.64, 0), 0.1),
282
+ roundedStone(new THREE.Vector3(1.18, 0.54, 0.88), new THREE.Vector3(1.72, 1.65, 0.015), 0.1),
283
+ roundedStone(new THREE.Vector3(0.76, 0.7, 0.96), new THREE.Vector3(0, 1.65, 0.03), 0.1),
284
+ roundedStone(new THREE.Vector3(5.08, 0.16, 0.98), new THREE.Vector3(0, 1.96, -0.02), 0.045),
285
+ )
286
+ mergeBatch(frame, `${name}-jungle-temple-stone`, materials.stone, stoneParts)
287
+
288
+ const bronzeParts: THREE.BufferGeometry[] = []
289
+ for (const side of [-1, 1] as const) {
290
+ for (const y of [-0.72, 0.63]) {
291
+ bronzeParts.push(
292
+ roundedStone(new THREE.Vector3(0.82, 0.15, 0.94), new THREE.Vector3(side * 2.16, y, 0.05), 0.04),
293
+ )
294
+ }
295
+ }
296
+ for (const x of [-1.48, 1.48]) {
297
+ bronzeParts.push(roundedStone(new THREE.Vector3(0.16, 0.72, 0.98), new THREE.Vector3(x, 1.64, 0.045), 0.04))
298
+ }
299
+ mergeBatch(frame, `${name}-jungle-temple-bronze`, materials.bronze, bronzeParts)
300
+
301
+ addPairCrystal(frame, name, color, materials.bronze)
302
+ addOuterPlants(frame, name, materials.foliage)
303
+
304
+ // Reassert invariant tags on every descendant, including meshes created by helpers.
305
+ frame.traverse(object => markVisualOnly(object))
306
+ root.add(frame)
307
+ return frame
308
+ }
@@ -0,0 +1,366 @@
1
+ import * as THREE from 'three'
2
+
3
+ import { VisualTorusGeometry, VisualTubeGeometry, VisualBoxGeometry } from '../../../three/visual-geometry'
4
+ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
5
+
6
+ export const JUNGLE_TEMPLE_THEME_ID = 'low-poly-jungle'
7
+
8
+ const STONE_UV_SPAN = new THREE.Vector2(4.8, 4.8)
9
+ const TIMBER_UV_SPAN = new THREE.Vector2(3.6, 3.6)
10
+ const BRONZE_UV_SPAN = new THREE.Vector2(3.2, 3.2)
11
+
12
+ export interface JungleTempleLiftMaterials {
13
+ stone: THREE.Material
14
+ facade: THREE.Material
15
+ timber: THREE.Material
16
+ platform: THREE.Material
17
+ moss: THREE.Material
18
+ accent: THREE.Material
19
+ }
20
+
21
+ interface JungleTempleLiftOptions {
22
+ id: string
23
+ bottom: THREE.Vector3
24
+ rotation: THREE.Quaternion
25
+ width: number
26
+ height: number
27
+ depth: number
28
+ releaseHeight: number
29
+ materials: JungleTempleLiftMaterials
30
+ }
31
+
32
+ interface GeometryBatch {
33
+ readonly name: string
34
+ readonly material: THREE.Material
35
+ readonly parts: THREE.BufferGeometry[]
36
+ }
37
+
38
+ function authoredUvGeometry(geometry: THREE.BufferGeometry, span: THREE.Vector2): THREE.BufferGeometry {
39
+ const prepared = geometry.index ? geometry.toNonIndexed() : geometry
40
+ if (prepared !== geometry) geometry.dispose()
41
+ const uv = prepared.getAttribute('uv')
42
+ if (uv) {
43
+ for (let index = 0; index < uv.count; index += 1) {
44
+ uv.setXY(index, uv.getX(index) * span.x, uv.getY(index) * span.y)
45
+ }
46
+ uv.needsUpdate = true
47
+ }
48
+ const position = prepared.getAttribute('position')
49
+ const metricUvs = new Float32Array(position.count * 4)
50
+ for (let index = 0; index < position.count; index += 1) metricUvs[index * 4 + 2] = 1
51
+ prepared.setAttribute('themeNetUv', new THREE.BufferAttribute(metricUvs, 4))
52
+ return prepared
53
+ }
54
+
55
+ function metricPlanarUvGeometry(geometry: THREE.BufferGeometry): THREE.BufferGeometry {
56
+ const prepared = geometry.index ? geometry.toNonIndexed() : geometry
57
+ if (prepared !== geometry) geometry.dispose()
58
+ const position = prepared.getAttribute('position')
59
+ const normal = prepared.getAttribute('normal')
60
+ const uvs = new Float32Array(position.count * 2)
61
+ const metricUvs = new Float32Array(position.count * 4)
62
+ for (let index = 0; index < position.count; index += 1) {
63
+ const x = position.getX(index)
64
+ const y = position.getY(index)
65
+ const z = position.getZ(index)
66
+ const normalX = Math.abs(normal.getX(index))
67
+ const normalY = Math.abs(normal.getY(index))
68
+ const normalZ = Math.abs(normal.getZ(index))
69
+ if (normalX >= normalY && normalX >= normalZ) {
70
+ uvs[index * 2] = z
71
+ uvs[index * 2 + 1] = y
72
+ } else if (normalY >= normalZ) {
73
+ uvs[index * 2] = x
74
+ uvs[index * 2 + 1] = z
75
+ } else {
76
+ uvs[index * 2] = x
77
+ uvs[index * 2 + 1] = y
78
+ }
79
+ metricUvs[index * 4 + 2] = 1
80
+ }
81
+ prepared.setAttribute('uv', new THREE.BufferAttribute(uvs, 2))
82
+ prepared.setAttribute('themeNetUv', new THREE.BufferAttribute(metricUvs, 4))
83
+ return prepared
84
+ }
85
+
86
+ function transformedGeometry(
87
+ geometry: THREE.BufferGeometry,
88
+ position: THREE.Vector3,
89
+ rotation = new THREE.Quaternion(),
90
+ ): THREE.BufferGeometry {
91
+ geometry.applyMatrix4(new THREE.Matrix4().compose(position, rotation, new THREE.Vector3(1, 1, 1)))
92
+ return geometry
93
+ }
94
+
95
+ function roundedBox(size: THREE.Vector3, radius: number, position: THREE.Vector3): THREE.BufferGeometry {
96
+ return transformedGeometry(
97
+ metricPlanarUvGeometry(new VisualBoxGeometry(size.x, size.y, size.z, 2, radius)),
98
+ position,
99
+ )
100
+ }
101
+
102
+ function addMergedBatch(group: THREE.Group, id: string, batch: GeometryBatch): THREE.Mesh | undefined {
103
+ if (batch.parts.length === 0) return undefined
104
+ const geometry = mergeGeometries(batch.parts, false)
105
+ batch.parts.forEach(part => part.dispose())
106
+ if (!geometry) throw new Error(`Unable to merge Jungle temple lift batch "${batch.name}".`)
107
+ geometry.name = `${id}-${batch.name}-geometry`
108
+ geometry.computeBoundingBox()
109
+ geometry.computeBoundingSphere()
110
+ geometry.userData.visualOnly = true
111
+ const mesh = new THREE.Mesh(geometry, batch.material)
112
+ mesh.name = `${id}-${batch.name}`
113
+ mesh.castShadow = true
114
+ mesh.receiveShadow = true
115
+ mesh.userData.decorative = true
116
+ mesh.userData.visualOnly = true
117
+ group.add(mesh)
118
+ return mesh
119
+ }
120
+
121
+ function facadeUvGeometry(
122
+ geometry: THREE.BufferGeometry,
123
+ side: -1 | 1,
124
+ wallDepth: number,
125
+ wallBottom: number,
126
+ wallHeight: number,
127
+ ): THREE.BufferGeometry {
128
+ const prepared = geometry.index ? geometry.toNonIndexed() : geometry
129
+ if (prepared !== geometry) geometry.dispose()
130
+ const position = prepared.getAttribute('position')
131
+ const uvs = new Float32Array(position.count * 2)
132
+ const metricUvs = new Float32Array(position.count * 4)
133
+ for (let index = 0; index < position.count; index += 1) {
134
+ const depthUv = THREE.MathUtils.clamp((position.getZ(index) + wallDepth / 2) / wallDepth, 0, 1)
135
+ uvs[index * 2] = side > 0 ? 1 - depthUv : depthUv
136
+ uvs[index * 2 + 1] = THREE.MathUtils.clamp((position.getY(index) - wallBottom) / wallHeight, 0, 1)
137
+ metricUvs[index * 4 + 2] = 1
138
+ }
139
+ prepared.setAttribute('uv', new THREE.BufferAttribute(uvs, 2))
140
+ prepared.setAttribute('themeNetUv', new THREE.BufferAttribute(metricUvs, 4))
141
+ return prepared
142
+ }
143
+
144
+ export function addJungleTempleLiftSkin(root: THREE.Group, options: JungleTempleLiftOptions): THREE.Group {
145
+ const { id, bottom, rotation, width, height, depth, releaseHeight, materials } = options
146
+ const group = new THREE.Group()
147
+ group.name = `${id}-jungle-temple-lift`
148
+ group.position.copy(bottom)
149
+ group.quaternion.copy(rotation)
150
+ group.userData.themeOnly = JUNGLE_TEMPLE_THEME_ID
151
+ group.userData.decorative = true
152
+ group.userData.visualOnly = true
153
+ group.userData.drawCallBudget = 5
154
+ group.userData.triangleBudget = 12_000
155
+
156
+ const stone: THREE.BufferGeometry[] = []
157
+ const facade: THREE.BufferGeometry[] = []
158
+ const timber: THREE.BufferGeometry[] = []
159
+ const bronze: THREE.BufferGeometry[] = []
160
+ const moss: THREE.BufferGeometry[] = []
161
+ const sideX = width / 2 + 0.18
162
+ const frontZ = depth / 2 + 0.2
163
+ const backZ = -depth / 2 - 0.2
164
+ const shellHeight = height + 1.02
165
+ const shellBottom = -0.35
166
+ const shellDepth = depth + 0.62
167
+ const shellCoreThickness = 0.34
168
+ const facadeBaseDepth = 0.04
169
+ const facadeFrameDepth = 0.15
170
+ const facadeBlockInset = 0.018
171
+ const facadeFrameRegions = [
172
+ [0.005, 0.205, 0.06, 0.94],
173
+ [0.795, 0.995, 0.06, 0.94],
174
+ [0.005, 0.995, 0.94, 0.995],
175
+ [0.005, 0.995, 0.005, 0.06],
176
+ ] as const
177
+
178
+ for (const side of [-1, 1] as const) {
179
+ // The full authored facade supplies the approved serpent composition. Only its
180
+ // matching outer frame is raised into geometry. Internal stone seams and the
181
+ // serpent carving stay in the aligned normal response rather than becoming
182
+ // guessed geometry that can drift away from the source.
183
+ stone.push(
184
+ roundedBox(
185
+ new THREE.Vector3(shellCoreThickness, shellHeight, shellDepth),
186
+ 0.055,
187
+ new THREE.Vector3(side * sideX, shellBottom + shellHeight / 2, 0),
188
+ ),
189
+ )
190
+ const facadeCenterX = side * (sideX + shellCoreThickness / 2 + facadeBaseDepth / 2 - 0.008)
191
+ facade.push(
192
+ facadeUvGeometry(
193
+ transformedGeometry(
194
+ new THREE.BoxGeometry(facadeBaseDepth, shellHeight, shellDepth),
195
+ new THREE.Vector3(facadeCenterX, shellBottom + shellHeight / 2, 0),
196
+ ),
197
+ side,
198
+ shellDepth,
199
+ shellBottom,
200
+ shellHeight,
201
+ ),
202
+ )
203
+ for (const [u0, u1, v0, v1] of facadeFrameRegions) {
204
+ const blockDepth = (u1 - u0) * shellDepth - facadeBlockInset
205
+ const blockHeight = (v1 - v0) * shellHeight - facadeBlockInset
206
+ const blockCenterU = (u0 + u1) / 2
207
+ const blockCenterV = (v0 + v1) / 2
208
+ const blockCenterZ =
209
+ side > 0 ? shellDepth / 2 - blockCenterU * shellDepth : -shellDepth / 2 + blockCenterU * shellDepth
210
+ const blockRelief = facadeFrameDepth
211
+ const blockCenterX = side * (sideX + shellCoreThickness / 2 + facadeBaseDepth + blockRelief / 2 - 0.014)
212
+ facade.push(
213
+ facadeUvGeometry(
214
+ transformedGeometry(
215
+ new THREE.BoxGeometry(blockRelief, blockHeight, blockDepth),
216
+ new THREE.Vector3(blockCenterX, shellBottom + blockCenterV * shellHeight, blockCenterZ),
217
+ ),
218
+ side,
219
+ shellDepth,
220
+ shellBottom,
221
+ shellHeight,
222
+ ),
223
+ )
224
+ }
225
+ moss.push(
226
+ roundedBox(
227
+ new THREE.Vector3(0.12, 0.09, depth * 0.72),
228
+ 0.035,
229
+ new THREE.Vector3(side * (sideX + 0.34), height + 0.66, -depth * 0.04),
230
+ ),
231
+ )
232
+ }
233
+ group.userData.sideWallRelief = 'source-aligned-outer-frame'
234
+ group.userData.sideWallReliefBlockCount = facadeFrameRegions.length * 2
235
+
236
+ const rearPanelZ = -depth / 2 - 0.07
237
+ const exitOpeningBottom = THREE.MathUtils.clamp(releaseHeight - 0.34, 0.9, height - 1.5)
238
+ const exitOpeningTop = Math.min(height + 0.42, releaseHeight + 1.48)
239
+ const rearPanelBottom = -0.12
240
+ const rearPanelTop = height + 0.68
241
+ const lowerPanelHeight = exitOpeningBottom - rearPanelBottom
242
+ const upperPanelHeight = Math.max(0.24, rearPanelTop - exitOpeningTop)
243
+ group.userData.hasBackWall = true
244
+ group.userData.releaseOpeningBottom = exitOpeningBottom
245
+ group.userData.releaseOpeningTop = exitOpeningTop
246
+ stone.push(
247
+ roundedBox(
248
+ new THREE.Vector3(width - 0.38, lowerPanelHeight, 0.28),
249
+ 0.07,
250
+ new THREE.Vector3(0, (rearPanelBottom + exitOpeningBottom) / 2, rearPanelZ),
251
+ ),
252
+ roundedBox(
253
+ new THREE.Vector3(width - 0.38, upperPanelHeight, 0.28),
254
+ 0.07,
255
+ new THREE.Vector3(0, (exitOpeningTop + rearPanelTop) / 2, rearPanelZ),
256
+ ),
257
+ )
258
+ for (const y of [exitOpeningBottom - 0.08, exitOpeningTop + 0.08]) {
259
+ stone.push(
260
+ roundedBox(new THREE.Vector3(width - 0.14, 0.26, 0.38), 0.06, new THREE.Vector3(0, y, rearPanelZ - 0.025)),
261
+ )
262
+ }
263
+ for (const z of [frontZ, backZ]) {
264
+ stone.push(roundedBox(new THREE.Vector3(width + 0.8, 0.58, 0.52), 0.09, new THREE.Vector3(0, height + 0.3, z)))
265
+ }
266
+
267
+ const postX = width / 2 - 0.57
268
+ for (const x of [-postX, postX]) {
269
+ timber.push(
270
+ roundedBox(new THREE.Vector3(0.42, height + 0.36, 0.4), 0.07, new THREE.Vector3(x, height / 2, frontZ + 0.035)),
271
+ )
272
+ for (const y of [height * 0.2, height * 0.5, height * 0.8]) {
273
+ timber.push(
274
+ transformedGeometry(
275
+ authoredUvGeometry(new VisualTorusGeometry(0.27, 0.045, 6, 16), TIMBER_UV_SPAN),
276
+ new THREE.Vector3(x, y, frontZ + 0.035),
277
+ new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2),
278
+ ),
279
+ )
280
+ }
281
+ }
282
+
283
+ const drumPosition = new THREE.Vector3(0, height + 0.55, depth / 2 + 0.2)
284
+ for (const x of [-width * 0.31, 0, width * 0.31]) {
285
+ bronze.push(
286
+ transformedGeometry(
287
+ authoredUvGeometry(new VisualTorusGeometry(0.59, 0.05, 7, 20), BRONZE_UV_SPAN),
288
+ drumPosition.clone().add(new THREE.Vector3(x, 0, 0)),
289
+ new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI / 2),
290
+ ),
291
+ )
292
+ }
293
+
294
+ const vinePoints = [
295
+ new THREE.Vector3(-width * 0.42, height + 0.54, backZ),
296
+ new THREE.Vector3(-width * 0.12, height + 0.78, 0),
297
+ new THREE.Vector3(width * 0.18, height + 0.55, frontZ),
298
+ new THREE.Vector3(width * 0.42, height - 0.62, frontZ + 0.04),
299
+ ]
300
+ moss.push(
301
+ authoredUvGeometry(
302
+ new VisualTubeGeometry(new THREE.CatmullRomCurve3(vinePoints), 22, 0.055, 6, false),
303
+ STONE_UV_SPAN,
304
+ ),
305
+ )
306
+
307
+ addMergedBatch(group, id, { name: 'temple-stone-shell', material: materials.stone, parts: stone })
308
+ addMergedBatch(group, id, { name: 'temple-serpent-facade', material: materials.facade, parts: facade })
309
+ addMergedBatch(group, id, { name: 'temple-timber-frame', material: materials.timber, parts: timber })
310
+ addMergedBatch(group, id, { name: 'temple-bronze-details', material: materials.accent, parts: bronze })
311
+ addMergedBatch(group, id, { name: 'temple-moss-details', material: materials.moss, parts: moss })
312
+
313
+ root.add(group)
314
+ return group
315
+ }
316
+
317
+ export function decorateJungleTempleShelf(
318
+ shelf: THREE.Mesh,
319
+ id: string,
320
+ width: number,
321
+ depth: number,
322
+ materials: JungleTempleLiftMaterials,
323
+ ): void {
324
+ const decoration = new THREE.Group()
325
+ decoration.name = `${id}-jungle-temple-platform`
326
+ decoration.userData.themeOnly = JUNGLE_TEMPLE_THEME_ID
327
+ decoration.userData.decorative = true
328
+ decoration.userData.visualOnly = true
329
+ decoration.userData.drawCallBudget = 3
330
+
331
+ const platformGeometry = roundedBox(
332
+ new THREE.Vector3(width - 0.08, 0.07, depth - 0.1),
333
+ 0.035,
334
+ new THREE.Vector3(0, 0.125, 0),
335
+ )
336
+ const platform = new THREE.Mesh(platformGeometry, materials.platform)
337
+ platform.name = `${id}-temple-bamboo-deck`
338
+ platform.castShadow = true
339
+ platform.receiveShadow = true
340
+ platform.userData.decorative = true
341
+ platform.userData.visualOnly = true
342
+ decoration.add(platform)
343
+
344
+ const lipGeometry = roundedBox(
345
+ new THREE.Vector3(width, 0.22, 0.12),
346
+ 0.035,
347
+ new THREE.Vector3(0, 0.19, depth / 2 + 0.015),
348
+ )
349
+ const lip = new THREE.Mesh(lipGeometry, materials.timber)
350
+ lip.name = `${id}-temple-platform-lip`
351
+ lip.castShadow = true
352
+ lip.receiveShadow = true
353
+ lip.userData.decorative = true
354
+ lip.userData.visualOnly = true
355
+ decoration.add(lip)
356
+
357
+ const amberParts = [-width * 0.3, 0, width * 0.3].map(x =>
358
+ roundedBox(new THREE.Vector3(0.36, 0.1, 0.035), 0.015, new THREE.Vector3(x, 0.22, depth / 2 + 0.085)),
359
+ )
360
+ addMergedBatch(decoration, id, {
361
+ name: 'temple-platform-amber-inlays',
362
+ material: materials.accent,
363
+ parts: amberParts,
364
+ })
365
+ shelf.add(decoration)
366
+ }