@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,201 @@
1
+ import * as THREE from 'three'
2
+
3
+ import { VisualCylinderGeometry, VisualBoxGeometry } from '../../../three/visual-geometry'
4
+ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
5
+
6
+ const LOCAL_UP = new THREE.Vector3(0, 1, 0)
7
+
8
+ export interface JapaneseSakuraKawaraRoofOptions {
9
+ name: string
10
+ ridgeY: number
11
+ widthScale?: number
12
+ halfDepth?: number
13
+ capCourses?: number
14
+ themeRolePrefix?: string
15
+ }
16
+
17
+ function metricPlanarUvs(geometry: THREE.BufferGeometry): THREE.BufferGeometry {
18
+ const prepared = geometry.index ? geometry.toNonIndexed() : geometry
19
+ if (prepared !== geometry) geometry.dispose()
20
+ const position = prepared.getAttribute('position')
21
+ const normal = prepared.getAttribute('normal')
22
+ const uv = new Float32Array(position.count * 2)
23
+ const themeNetUv = new Float32Array(position.count * 4)
24
+ for (let index = 0; index < position.count; index += 1) {
25
+ const x = position.getX(index)
26
+ const y = position.getY(index)
27
+ const z = position.getZ(index)
28
+ const nx = Math.abs(normal.getX(index))
29
+ const ny = Math.abs(normal.getY(index))
30
+ const nz = Math.abs(normal.getZ(index))
31
+ if (ny >= nx && ny >= nz) {
32
+ uv[index * 2] = x
33
+ uv[index * 2 + 1] = z
34
+ } else if (nx >= nz) {
35
+ uv[index * 2] = z
36
+ uv[index * 2 + 1] = y
37
+ } else {
38
+ uv[index * 2] = x
39
+ uv[index * 2 + 1] = y
40
+ }
41
+ themeNetUv[index * 4 + 2] = 1
42
+ }
43
+ prepared.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
44
+ prepared.setAttribute('themeNetUv', new THREE.BufferAttribute(themeNetUv, 4))
45
+ return prepared
46
+ }
47
+
48
+ function transformed(geometry: THREE.BufferGeometry, position: THREE.Vector3, quaternion = new THREE.Quaternion()) {
49
+ geometry.applyMatrix4(new THREE.Matrix4().compose(position, quaternion, new THREE.Vector3(1, 1, 1)))
50
+ return metricPlanarUvs(geometry)
51
+ }
52
+
53
+ function mark(object: THREE.Object3D, role: string): void {
54
+ object.userData.themeOnly = 'japanese-sakura-lake'
55
+ object.userData.themeRole = role
56
+ object.userData.decorative = true
57
+ object.userData.visualOnly = true
58
+ object.userData.roofConstruction = 'shared-japanese-sakura-portal-kawara-v1'
59
+ }
60
+
61
+ function mergedRoofPart(
62
+ root: THREE.Group,
63
+ name: string,
64
+ material: THREE.Material,
65
+ parts: THREE.BufferGeometry[],
66
+ role: string,
67
+ ): THREE.Mesh {
68
+ const geometry = mergeGeometries(parts, false)
69
+ parts.forEach(part => part.dispose())
70
+ if (!geometry) throw new Error(`Unable to merge Japanese Sakura roof batch "${name}".`)
71
+ geometry.computeBoundingBox()
72
+ geometry.computeBoundingSphere()
73
+ const mesh = new THREE.Mesh(geometry, material)
74
+ mesh.name = name
75
+ mesh.castShadow = true
76
+ mesh.receiveShadow = true
77
+ mark(mesh, role)
78
+ root.add(mesh)
79
+ return mesh
80
+ }
81
+
82
+ /**
83
+ * The solved portal roof, shared verbatim by portals and vertical mechanisms.
84
+ * Width and course count may expand to clear a larger mechanism while the
85
+ * portal pitch, tile scale, lifted eaves, pan/cap rhythm, and ridge stay intact.
86
+ */
87
+ export function addJapaneseSakuraKawaraRoof(
88
+ root: THREE.Group,
89
+ material: THREE.Material,
90
+ options: JapaneseSakuraKawaraRoofOptions,
91
+ ): THREE.Group {
92
+ const roof = new THREE.Group()
93
+ roof.name = `${options.name}-roof-assembly`
94
+ roof.scale.x = options.widthScale ?? 1
95
+ roof.userData.construction = 'shared-japanese-sakura-portal-kawara-v1'
96
+ const rolePrefix = options.themeRolePrefix ?? 'sakura'
97
+ const roofHalfDepth = options.halfDepth ?? 1.32
98
+ const capCourses = options.capCourses ?? 5
99
+ const panCourses = Math.max(1, capCourses - 1)
100
+ const isPortalDepth = options.halfDepth === undefined
101
+ const slopeDrop = 0.82 * (roofHalfDepth / 1.32)
102
+ const roofPitch = 0.5
103
+ const frontRoofRotation = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), roofPitch)
104
+ const backRoofRotation = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), -roofPitch)
105
+ const backingLength = isPortalDepth ? 1.38 : Math.hypot(roofHalfDepth, slopeDrop)
106
+ const backingZ = isPortalDepth ? 0.58 : roofHalfDepth / 2
107
+ const backing = mergedRoofPart(
108
+ roof,
109
+ `${options.name}-kawara-backing`,
110
+ material,
111
+ [
112
+ transformed(new VisualBoxGeometry(5.48, 0.12, backingLength, 2, 0.035), new THREE.Vector3(0, options.ridgeY - slopeDrop / 2, backingZ), frontRoofRotation),
113
+ transformed(new VisualBoxGeometry(5.48, 0.12, backingLength, 2, 0.035), new THREE.Vector3(0, options.ridgeY - slopeDrop / 2, -backingZ), backRoofRotation),
114
+ ],
115
+ `${rolePrefix}-kawara-roof-backing`,
116
+ )
117
+
118
+ const columns = 19
119
+ // The roof backing supplies the solid surface; tiles only need a curved outer shell.
120
+ const tileGeometry = new VisualCylinderGeometry(0.5, 0.5, 1, 4, 1, true, 0, Math.PI)
121
+ const tiles = new THREE.InstancedMesh(tileGeometry, material, columns * capCourses * 2)
122
+ tiles.name = `${options.name}-kawara-tiles`
123
+ let tileIndex = 0
124
+ for (const roofSide of [-1, 1] as const) {
125
+ for (let column = 0; column < columns; column += 1) {
126
+ const x = -2.52 + column * 0.28
127
+ const eaveLift = Math.max(0, Math.abs(x) - 2.08) * 0.3
128
+ for (let segment = 0; segment < capCourses; segment += 1) {
129
+ const inner = segment / capCourses
130
+ const outer = Math.min(1, (segment + 1) / capCourses + 0.055)
131
+ const start = new THREE.Vector3(x, options.ridgeY - inner * slopeDrop + eaveLift, roofSide * inner * roofHalfDepth)
132
+ const end = new THREE.Vector3(x, options.ridgeY - outer * slopeDrop + eaveLift, roofSide * outer * roofHalfDepth)
133
+ const direction = end.clone().sub(start)
134
+ const quaternion = new THREE.Quaternion().setFromUnitVectors(LOCAL_UP, direction.clone().normalize())
135
+ quaternion.multiply(new THREE.Quaternion().setFromAxisAngle(LOCAL_UP, roofSide > 0 ? 0 : Math.PI))
136
+ tiles.setMatrixAt(
137
+ tileIndex++,
138
+ new THREE.Matrix4().compose(
139
+ start.clone().lerp(end, 0.5).add(new THREE.Vector3(0, 0.065, 0)),
140
+ quaternion,
141
+ new THREE.Vector3(0.2, direction.length(), 0.2),
142
+ ),
143
+ )
144
+ }
145
+ }
146
+ }
147
+ tiles.instanceMatrix.needsUpdate = true
148
+ tiles.userData.kawaraTileCount = tiles.count
149
+ tiles.userData.kawaraCourseCount = capCourses
150
+ tiles.userData.mirroredRoofSides = true
151
+ mark(tiles, `${rolePrefix}-kawara-tile-shell`)
152
+ roof.add(tiles)
153
+
154
+ // A one-segment rounded box preserves the tiny pan tile's chamfered
155
+ // silhouette while avoiding 192 interior bevel triangles per instance.
156
+ const panGeometry = new VisualBoxGeometry(0.22, 0.055, 0.42, 1, 0.025)
157
+ const panTiles = new THREE.InstancedMesh(panGeometry, material, 18 * panCourses * 2)
158
+ panTiles.name = `${options.name}-kawara-pan-tiles`
159
+ let panIndex = 0
160
+ for (const roofSide of [-1, 1] as const) {
161
+ const rotation = roofSide > 0 ? frontRoofRotation : backRoofRotation
162
+ for (let course = 0; course < panCourses; course += 1) {
163
+ const progress = (course + 0.72) / (panCourses + 0.25)
164
+ for (let column = 0; column < 18; column += 1) {
165
+ const x = -2.38 + column * 0.28 + (course % 2) * 0.04
166
+ const eaveLift = Math.max(0, Math.abs(x) - 2.08) * 0.3
167
+ panTiles.setMatrixAt(
168
+ panIndex++,
169
+ new THREE.Matrix4().compose(
170
+ new THREE.Vector3(x, options.ridgeY - progress * slopeDrop + 0.065 + eaveLift, roofSide * progress * roofHalfDepth),
171
+ rotation,
172
+ new THREE.Vector3(1, 1, 1),
173
+ ),
174
+ )
175
+ }
176
+ }
177
+ }
178
+ panTiles.instanceMatrix.needsUpdate = true
179
+ panTiles.userData.kawaraPanTileCount = panTiles.count
180
+ panTiles.userData.kawaraCourseCount = panCourses
181
+ panTiles.userData.mirroredRoofSides = true
182
+ mark(panTiles, `${rolePrefix}-kawara-pan-shell`)
183
+ roof.add(panTiles)
184
+
185
+ const ridgeParts: THREE.BufferGeometry[] = []
186
+ for (const [y, z, radius] of [[options.ridgeY + 0.04, 0, 0.14], [options.ridgeY - 0.05, 0.13, 0.09], [options.ridgeY - 0.05, -0.13, 0.09]] as const) {
187
+ ridgeParts.push(
188
+ transformed(
189
+ new VisualCylinderGeometry(radius, radius, 5.58, 16),
190
+ new THREE.Vector3(0, y, z),
191
+ new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI / 2),
192
+ ),
193
+ )
194
+ }
195
+ mergedRoofPart(roof, `${options.name}-kawara-ridge`, material, ridgeParts, `${rolePrefix}-kawara-ridge`)
196
+ backing.userData.roofWidthScale = options.widthScale ?? 1
197
+ backing.userData.roofHalfDepth = roofHalfDepth
198
+ mark(roof, `${rolePrefix}-kawara-roof-assembly`)
199
+ root.add(roof)
200
+ return roof
201
+ }
@@ -0,0 +1,325 @@
1
+ import * as THREE from 'three'
2
+
3
+ import { VisualSphereGeometry, VisualCylinderGeometry, VisualTorusGeometry, VisualTubeGeometry, VisualBoxGeometry } from '../../../three/visual-geometry'
4
+ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
5
+
6
+ import { PORTAL_APERTURE_HALF_WIDTH } from './portal-proportions'
7
+ import { addJapaneseSakuraKawaraRoof } from './japanese-sakura-kawara-roof'
8
+
9
+ export const JAPANESE_SAKURA_PORTAL_FRAME_THEME_ID = 'japanese-sakura-lake'
10
+
11
+ export interface JapaneseSakuraPortalFrameMaterials {
12
+ vermilion: THREE.Material
13
+ plaster: THREE.Material
14
+ foundationStone: THREE.Material
15
+ kawara: THREE.Material
16
+ hardware: THREE.Material
17
+ }
18
+
19
+ const LOCAL_UP = new THREE.Vector3(0, 1, 0)
20
+
21
+ function markVisualOnly(object: THREE.Object3D, role: string): void {
22
+ object.userData.themeOnly = JAPANESE_SAKURA_PORTAL_FRAME_THEME_ID
23
+ object.userData.themeRole = role
24
+ object.userData.decorative = true
25
+ object.userData.visualOnly = true
26
+ object.userData.portalFrameStyle = 'japanese-sakura-roofed-gate'
27
+ }
28
+
29
+ function metricPlanarUvs(geometry: THREE.BufferGeometry): THREE.BufferGeometry {
30
+ const prepared = geometry.index ? geometry.toNonIndexed() : geometry
31
+ if (prepared !== geometry) geometry.dispose()
32
+ const position = prepared.getAttribute('position')
33
+ const normal = prepared.getAttribute('normal')
34
+ const uv = new Float32Array(position.count * 2)
35
+ const themeNetUv = new Float32Array(position.count * 4)
36
+ for (let index = 0; index < position.count; index += 1) {
37
+ const x = position.getX(index)
38
+ const y = position.getY(index)
39
+ const z = position.getZ(index)
40
+ const nx = Math.abs(normal.getX(index))
41
+ const ny = Math.abs(normal.getY(index))
42
+ const nz = Math.abs(normal.getZ(index))
43
+ if (ny >= nx && ny >= nz) {
44
+ uv[index * 2] = x
45
+ uv[index * 2 + 1] = z
46
+ } else if (nx >= nz) {
47
+ uv[index * 2] = z
48
+ uv[index * 2 + 1] = y
49
+ } else {
50
+ uv[index * 2] = x
51
+ uv[index * 2 + 1] = y
52
+ }
53
+ themeNetUv[index * 4 + 2] = 1
54
+ }
55
+ prepared.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
56
+ prepared.setAttribute('themeNetUv', new THREE.BufferAttribute(themeNetUv, 4))
57
+ return prepared
58
+ }
59
+
60
+ function transformed(geometry: THREE.BufferGeometry, position: THREE.Vector3, quaternion = new THREE.Quaternion()) {
61
+ geometry.applyMatrix4(new THREE.Matrix4().compose(position, quaternion, new THREE.Vector3(1, 1, 1)))
62
+ return metricPlanarUvs(geometry)
63
+ }
64
+
65
+ function roundedBox(size: THREE.Vector3, position: THREE.Vector3, radius = 0.045): THREE.BufferGeometry {
66
+ return transformed(new VisualBoxGeometry(size.x, size.y, size.z, 2, radius), position)
67
+ }
68
+
69
+ function boxBeam(start: THREE.Vector3, end: THREE.Vector3, width: number, depth: number): THREE.BufferGeometry {
70
+ const direction = end.clone().sub(start)
71
+ const quaternion = new THREE.Quaternion().setFromUnitVectors(LOCAL_UP, direction.clone().normalize())
72
+ return transformed(
73
+ new VisualBoxGeometry(width, direction.length(), depth, 2, Math.min(width, depth) * 0.16),
74
+ start.clone().lerp(end, 0.5),
75
+ quaternion,
76
+ )
77
+ }
78
+
79
+ function mergeBatch(
80
+ root: THREE.Group,
81
+ name: string,
82
+ material: THREE.Material,
83
+ parts: THREE.BufferGeometry[],
84
+ role: string,
85
+ ): THREE.Mesh {
86
+ const geometry = mergeGeometries(parts, false)
87
+ parts.forEach(part => part.dispose())
88
+ if (!geometry) throw new Error(`Unable to merge Japanese Sakura portal batch "${name}".`)
89
+ geometry.computeBoundingBox()
90
+ geometry.computeBoundingSphere()
91
+ const mesh = new THREE.Mesh(geometry, material)
92
+ mesh.name = name
93
+ mesh.castShadow = true
94
+ mesh.receiveShadow = true
95
+ markVisualOnly(mesh, role)
96
+ root.add(mesh)
97
+ return mesh
98
+ }
99
+
100
+ function addLantern(frame: THREE.Group, name: string, color: string, hardware: THREE.Material): void {
101
+ const lantern = new THREE.Group()
102
+ lantern.name = `${name}-sakura-pair-lantern`
103
+ lantern.position.set(0, 0.83, 0.58)
104
+ lantern.userData.portalPairColor = color
105
+ lantern.userData.portalLightRole = 'pair-color-paper-lantern'
106
+ markVisualOnly(lantern, 'sakura-pair-lantern')
107
+
108
+ const pairColor = new THREE.Color(color)
109
+ const paperMaterial = new THREE.MeshStandardMaterial({
110
+ color: '#fff1df',
111
+ emissive: pairColor,
112
+ emissiveIntensity: 0.88,
113
+ roughness: 0.72,
114
+ transparent: true,
115
+ opacity: 0.96,
116
+ })
117
+ const paper = new THREE.Mesh(new VisualSphereGeometry(0.33, 24, 16), paperMaterial)
118
+ paper.scale.y = 1.18
119
+ paper.name = `${name}-sakura-lantern-paper`
120
+ paper.userData.ownsMaterial = true
121
+ markVisualOnly(paper, 'sakura-pair-lantern-paper')
122
+ lantern.add(paper)
123
+
124
+ const rings = new THREE.InstancedMesh(new VisualTorusGeometry(0.35, 0.024, 7, 24), hardware, 5)
125
+ rings.name = `${name}-sakura-lantern-rings`
126
+ ;[-0.3, -0.15, 0, 0.15, 0.3].forEach((y, index) => {
127
+ const radiusScale = Math.sqrt(Math.max(0.18, 1 - (y / 0.39) ** 2))
128
+ rings.setMatrixAt(
129
+ index,
130
+ new THREE.Matrix4().compose(
131
+ new THREE.Vector3(0, y, 0),
132
+ new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2),
133
+ new THREE.Vector3(radiusScale, radiusScale, radiusScale),
134
+ ),
135
+ )
136
+ })
137
+ rings.instanceMatrix.needsUpdate = true
138
+ markVisualOnly(rings, 'sakura-lantern-hardware')
139
+ lantern.add(rings)
140
+
141
+ const cageParts: THREE.BufferGeometry[] = []
142
+ for (let longitude = 0; longitude < 6; longitude += 1) {
143
+ const angle = (longitude / 6) * Math.PI * 2
144
+ const points = Array.from({ length: 9 }, (_, index) => {
145
+ const latitude = -Math.PI / 2 + (index / 8) * Math.PI
146
+ return new THREE.Vector3(
147
+ Math.cos(angle) * Math.cos(latitude) * 0.335,
148
+ Math.sin(latitude) * 0.39,
149
+ Math.sin(angle) * Math.cos(latitude) * 0.335,
150
+ )
151
+ })
152
+ cageParts.push(new VisualTubeGeometry(new THREE.CatmullRomCurve3(points), 24, 0.018, 5, false))
153
+ }
154
+ mergeBatch(lantern, `${name}-sakura-lantern-cage`, hardware, cageParts, 'sakura-lantern-hardware')
155
+
156
+ const top = new THREE.Mesh(new VisualCylinderGeometry(0.25, 0.34, 0.14, 18), hardware)
157
+ top.position.y = 0.45
158
+ const bottom = new THREE.Mesh(new VisualCylinderGeometry(0.34, 0.24, 0.13, 18), hardware)
159
+ bottom.position.y = -0.45
160
+ const chain = new THREE.Mesh(new VisualCylinderGeometry(0.014, 0.014, 0.56, 8), hardware)
161
+ chain.position.y = 0.79
162
+ for (const object of [top, bottom, chain]) {
163
+ markVisualOnly(object, 'sakura-lantern-hardware')
164
+ lantern.add(object)
165
+ }
166
+ const light = new THREE.PointLight(color, 0.58, 3.4, 2)
167
+ light.position.z = 0.15
168
+ light.userData.decorative = true
169
+ light.userData.visualOnly = true
170
+ lantern.add(light)
171
+ frame.add(lantern)
172
+ }
173
+
174
+ function addBlossoms(frame: THREE.Group, name: string): void {
175
+ const branchMaterial = new THREE.MeshStandardMaterial({ color: '#4b2d25', roughness: 0.92 })
176
+ const blossomMaterial = new THREE.MeshStandardMaterial({ color: '#f4a9ca', roughness: 0.78 })
177
+ const branches: THREE.BufferGeometry[] = []
178
+ const blossomPoints: THREE.Vector3[] = []
179
+ for (const side of [-1, 1] as const) {
180
+ const start = new THREE.Vector3(side * 2.08, 2.65, 0.24)
181
+ const end = new THREE.Vector3(side * 3.16, 3.18, 0.04)
182
+ const direction = end.clone().sub(start)
183
+ const quaternion = new THREE.Quaternion().setFromUnitVectors(LOCAL_UP, direction.clone().normalize())
184
+ branches.push(
185
+ transformed(
186
+ new VisualCylinderGeometry(0.024, 0.038, direction.length(), 7),
187
+ start.clone().lerp(end, 0.5),
188
+ quaternion,
189
+ ),
190
+ )
191
+ const fork = start.clone().lerp(end, 0.54)
192
+ const forkEnd = new THREE.Vector3(side * 3.3, 2.82, 0.12)
193
+ const forkDirection = forkEnd.clone().sub(fork)
194
+ branches.push(
195
+ transformed(
196
+ new VisualCylinderGeometry(0.014, 0.024, forkDirection.length(), 7),
197
+ fork.clone().lerp(forkEnd, 0.5),
198
+ new THREE.Quaternion().setFromUnitVectors(LOCAL_UP, forkDirection.clone().normalize()),
199
+ ),
200
+ )
201
+ for (let index = 0; index < 10; index += 1) {
202
+ const point = start.clone().lerp(end, index / 9)
203
+ point.y += Math.sin(index * 1.7) * 0.1
204
+ point.z += 0.12
205
+ blossomPoints.push(point)
206
+ }
207
+ }
208
+ mergeBatch(
209
+ frame,
210
+ `${name}-sakura-branches`,
211
+ branchMaterial,
212
+ branches,
213
+ 'sakura-blossom-branches',
214
+ ).userData.ownsMaterial = true
215
+ const blossoms = new THREE.InstancedMesh(
216
+ new VisualSphereGeometry(0.045, 8, 6),
217
+ blossomMaterial,
218
+ blossomPoints.length * 5,
219
+ )
220
+ blossoms.name = `${name}-sakura-blossoms`
221
+ let blossomIndex = 0
222
+ blossomPoints.forEach((point, flowerIndex) => {
223
+ for (let petal = 0; petal < 5; petal += 1) {
224
+ const angle = (petal / 5) * Math.PI * 2
225
+ const center = point.clone().add(new THREE.Vector3(Math.cos(angle) * 0.052, Math.sin(angle) * 0.052, 0))
226
+ const scale = 0.78 + (flowerIndex % 4) * 0.07
227
+ blossoms.setMatrixAt(
228
+ blossomIndex++,
229
+ new THREE.Matrix4().compose(
230
+ center,
231
+ new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), angle),
232
+ new THREE.Vector3(scale * 1.5, scale * 0.68, scale * 0.48),
233
+ ),
234
+ )
235
+ }
236
+ })
237
+ blossoms.instanceMatrix.needsUpdate = true
238
+ blossoms.userData.ownsMaterial = true
239
+ markVisualOnly(blossoms, 'sakura-blossoms')
240
+ frame.add(blossoms)
241
+ }
242
+
243
+ /** Adds only the theme-local frame; the shared aperture, camera rift, glow and particles stay global. */
244
+ export function addJapaneseSakuraPortalFrame(
245
+ root: THREE.Group,
246
+ name: string,
247
+ color: string,
248
+ materials: JapaneseSakuraPortalFrameMaterials,
249
+ ): THREE.Group {
250
+ const frame = new THREE.Group()
251
+ frame.name = `${name}-japanese-sakura-frame`
252
+ // This frame is authored directly against the four-unit portal deck. Unlike
253
+ // the wider source geometry used by Jungle and Egypt it must not receive the
254
+ // shared 0.7 compensation a second time.
255
+ frame.scale.x = 1
256
+ frame.userData.portalApertureClearHalfWidth = PORTAL_APERTURE_HALF_WIDTH
257
+ frame.userData.drawCallBudget = 14
258
+ frame.userData.triangleBudget = 28_000
259
+ markVisualOnly(frame, 'japanese-sakura-roofed-gate')
260
+
261
+ const vermilion: THREE.BufferGeometry[] = []
262
+ const masonry: THREE.BufferGeometry[] = []
263
+ for (const side of [-1, 1] as const) {
264
+ const x = side * 1.68
265
+ vermilion.push(
266
+ roundedBox(new THREE.Vector3(0.52, 3.34, 0.62), new THREE.Vector3(x, 0.08, 0.02), 0.055),
267
+ roundedBox(new THREE.Vector3(0.7, 0.18, 0.7), new THREE.Vector3(x, 1.42, 0.02), 0.04),
268
+ roundedBox(new THREE.Vector3(0.96, 0.2, 0.7), new THREE.Vector3(x, 1.6, 0.02), 0.04),
269
+ boxBeam(new THREE.Vector3(side * 1.42, 0.82, 0.12), new THREE.Vector3(side * 0.98, 1.34, 0.12), 0.2, 0.34),
270
+ )
271
+ masonry.push(
272
+ roundedBox(new THREE.Vector3(0.62, 0.28, 0.86), new THREE.Vector3(x, -1.59, 0), 0.045),
273
+ roundedBox(new THREE.Vector3(0.56, 0.17, 0.74), new THREE.Vector3(x, -1.4, 0), 0.035),
274
+ )
275
+ }
276
+ vermilion.push(
277
+ roundedBox(new THREE.Vector3(4.92, 0.34, 0.68), new THREE.Vector3(0, 1.72, 0), 0.055),
278
+ roundedBox(new THREE.Vector3(3.78, 0.22, 0.54), new THREE.Vector3(0, 1.39, 0.03), 0.045),
279
+ )
280
+ for (let index = -5; index <= 5; index += 1) {
281
+ vermilion.push(roundedBox(new THREE.Vector3(0.18, 0.22, 0.82), new THREE.Vector3(index * 0.4, 1.94, 0), 0.025))
282
+ }
283
+ mergeBatch(frame, `${name}-sakura-vermilion-frame`, materials.vermilion, vermilion, 'sakura-vermilion-frame')
284
+ mergeBatch(
285
+ frame,
286
+ `${name}-sakura-foundation-stone-feet`,
287
+ materials.foundationStone,
288
+ masonry,
289
+ 'sakura-foundation-stone-feet',
290
+ )
291
+ addJapaneseSakuraKawaraRoof(frame, materials.kawara, {
292
+ name: `${name}-sakura`,
293
+ ridgeY: 2.8,
294
+ })
295
+
296
+ const footStraps = [-1, 1].flatMap(side => [
297
+ roundedBox(new THREE.Vector3(0.14, 0.34, 0.05), new THREE.Vector3(side * 1.78, -1.06, 0.335), 0.018),
298
+ roundedBox(new THREE.Vector3(0.14, 0.34, 0.05), new THREE.Vector3(side * 1.58, -1.06, 0.335), 0.018),
299
+ ])
300
+ mergeBatch(frame, `${name}-sakura-foot-hardware`, materials.hardware, footStraps, 'sakura-frame-hardware')
301
+
302
+ const bolts = new THREE.InstancedMesh(new VisualCylinderGeometry(0.045, 0.045, 0.08, 12), materials.hardware, 4)
303
+ bolts.name = `${name}-sakura-frame-bolts`
304
+ let boltIndex = 0
305
+ for (const x of [-1.68, 1.68]) {
306
+ for (const y of [-1.16, -0.96]) {
307
+ bolts.setMatrixAt(
308
+ boltIndex++,
309
+ new THREE.Matrix4().compose(
310
+ new THREE.Vector3(x, y, 0.33),
311
+ new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2),
312
+ new THREE.Vector3(1, 1, 1),
313
+ ),
314
+ )
315
+ }
316
+ }
317
+ bolts.instanceMatrix.needsUpdate = true
318
+ markVisualOnly(bolts, 'sakura-frame-hardware')
319
+ frame.add(bolts)
320
+
321
+ addLantern(frame, name, color, materials.hardware)
322
+ addBlossoms(frame, name)
323
+ root.add(frame)
324
+ return frame
325
+ }