@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,159 @@
|
|
|
1
|
+
import { geometryDetailModelUrl } from '../../../three/geometry-detail'
|
|
2
|
+
import * as THREE from 'three'
|
|
3
|
+
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
|
4
|
+
|
|
5
|
+
export const EGYPT_ELEVATOR_SHELL_MODEL_URL = '/models/elevators/egypt-elevator-shell-v5.glb'
|
|
6
|
+
export const EGYPT_ELEVATOR_PLATFORM_MODEL_URL = '/models/elevators/egypt-elevator-platform-v5.glb'
|
|
7
|
+
export const EGYPT_ELEVATOR_SHELL_RUNTIME_YAW = Math.PI
|
|
8
|
+
|
|
9
|
+
export interface EgyptElevatorBlenderMaterials {
|
|
10
|
+
sandstone: THREE.Material
|
|
11
|
+
carvedStone: THREE.Material
|
|
12
|
+
pyramidStone: THREE.Material
|
|
13
|
+
pyramidGold: THREE.Material
|
|
14
|
+
bronze: THREE.Material
|
|
15
|
+
paving: THREE.Material
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type EgyptRole = 'SANDSTONE' | 'CARVED' | 'LIMESTONE' | 'GOLD' | 'BRONZE' | 'PAVING'
|
|
19
|
+
|
|
20
|
+
const PLACEHOLDER = new THREE.MeshBasicMaterial({ visible: false })
|
|
21
|
+
let shellTemplatePromise: Promise<THREE.Object3D> | undefined
|
|
22
|
+
let platformTemplatePromise: Promise<THREE.Object3D> | undefined
|
|
23
|
+
|
|
24
|
+
function roleFromName(name: string): EgyptRole | undefined {
|
|
25
|
+
return (['SANDSTONE', 'CARVED', 'LIMESTONE', 'GOLD', 'BRONZE', 'PAVING'] as const).find(role =>
|
|
26
|
+
name.toUpperCase().includes(`_${role}`),
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function loadTemplate(url: string, name: string): Promise<THREE.Object3D> {
|
|
31
|
+
return new GLTFLoader().loadAsync(geometryDetailModelUrl(url)).then(gltf => {
|
|
32
|
+
gltf.scene.name = name
|
|
33
|
+
gltf.scene.traverse(object => {
|
|
34
|
+
if (!(object instanceof THREE.Mesh)) return
|
|
35
|
+
const embedded = Array.isArray(object.material) ? object.material : [object.material]
|
|
36
|
+
embedded.forEach(material => material.dispose())
|
|
37
|
+
object.material = PLACEHOLDER
|
|
38
|
+
})
|
|
39
|
+
return gltf.scene
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function shellTemplate(): Promise<THREE.Object3D> {
|
|
44
|
+
shellTemplatePromise ??= loadTemplate(EGYPT_ELEVATOR_SHELL_MODEL_URL, 'egypt-elevator-shell-template')
|
|
45
|
+
return shellTemplatePromise
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function platformTemplate(): Promise<THREE.Object3D> {
|
|
49
|
+
platformTemplatePromise ??= loadTemplate(EGYPT_ELEVATOR_PLATFORM_MODEL_URL, 'egypt-elevator-platform-template')
|
|
50
|
+
return platformTemplatePromise
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function materialForRole(role: EgyptRole, materials: EgyptElevatorBlenderMaterials): THREE.Material {
|
|
54
|
+
if (role === 'SANDSTONE') return materials.sandstone
|
|
55
|
+
if (role === 'CARVED') return materials.carvedStone
|
|
56
|
+
if (role === 'LIMESTONE') return materials.pyramidStone
|
|
57
|
+
if (role === 'GOLD') return materials.pyramidGold
|
|
58
|
+
if (role === 'BRONZE') return materials.bronze
|
|
59
|
+
return materials.paving
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function applyEgyptElevatorBlenderMaterials(
|
|
63
|
+
root: THREE.Object3D,
|
|
64
|
+
materials: EgyptElevatorBlenderMaterials,
|
|
65
|
+
): void {
|
|
66
|
+
root.traverse(object => {
|
|
67
|
+
if (!(object instanceof THREE.Mesh)) return
|
|
68
|
+
const role = object.userData.egyptElevatorRole as EgyptRole | undefined
|
|
69
|
+
if (role) object.material = materialForRole(role, materials)
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function attachModel(
|
|
74
|
+
host: THREE.Group,
|
|
75
|
+
template: THREE.Object3D,
|
|
76
|
+
name: string,
|
|
77
|
+
materials: EgyptElevatorBlenderMaterials,
|
|
78
|
+
): THREE.Object3D {
|
|
79
|
+
const model = template.clone(true)
|
|
80
|
+
model.name = name
|
|
81
|
+
let meshCount = 0
|
|
82
|
+
let triangleCount = 0
|
|
83
|
+
model.traverse(object => {
|
|
84
|
+
object.userData.decorative = true
|
|
85
|
+
object.userData.visualOnly = true
|
|
86
|
+
object.userData.elevatorStyle = 'approved-egypt-blender-v5'
|
|
87
|
+
if (!(object instanceof THREE.Mesh)) return
|
|
88
|
+
const role = roleFromName(object.name)
|
|
89
|
+
if (!role) {
|
|
90
|
+
object.visible = false
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
object.material = materialForRole(role, materials)
|
|
94
|
+
object.castShadow = true
|
|
95
|
+
object.receiveShadow = true
|
|
96
|
+
object.frustumCulled = true
|
|
97
|
+
object.userData.egyptElevatorRole = role
|
|
98
|
+
object.userData.sharedGeometry = true
|
|
99
|
+
meshCount += 1
|
|
100
|
+
const position = object.geometry.getAttribute('position')
|
|
101
|
+
triangleCount += object.geometry.index ? object.geometry.index.count / 3 : position.count / 3
|
|
102
|
+
})
|
|
103
|
+
host.userData.loadedMeshCount = meshCount
|
|
104
|
+
host.userData.loadedTriangleCount = triangleCount
|
|
105
|
+
host.add(model)
|
|
106
|
+
return model
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function addEgyptElevatorBlenderShell(
|
|
110
|
+
host: THREE.Group,
|
|
111
|
+
name: string,
|
|
112
|
+
materials: EgyptElevatorBlenderMaterials,
|
|
113
|
+
scale: THREE.Vector3,
|
|
114
|
+
template?: THREE.Object3D,
|
|
115
|
+
): Promise<THREE.Group> {
|
|
116
|
+
host.userData.modelUrl = EGYPT_ELEVATOR_SHELL_MODEL_URL
|
|
117
|
+
host.userData.approvedReference = '/docs/material-contracts/theme-elevator-egypt-approved-v3.png'
|
|
118
|
+
host.userData.constructionContract = 'egypt-elevator-approved-blender-v5'
|
|
119
|
+
host.userData.visualOnly = true
|
|
120
|
+
if (typeof document === 'undefined' && !template) {
|
|
121
|
+
host.userData.modelReady = Promise.resolve(host)
|
|
122
|
+
return host.userData.modelReady as Promise<THREE.Group>
|
|
123
|
+
}
|
|
124
|
+
const ready = template ? Promise.resolve(template) : shellTemplate()
|
|
125
|
+
host.userData.modelReady = ready.then(source => {
|
|
126
|
+
const model = attachModel(host, source, `${name}-egypt-approved-blender-shell`, materials)
|
|
127
|
+
model.scale.copy(scale)
|
|
128
|
+
// Blender +Y exports as Three.js -Z. The conveyor loading/front side is
|
|
129
|
+
// local +Z, so turn the authored shell around while leaving the symmetric
|
|
130
|
+
// moving platform cassettes and all mechanism physics unchanged.
|
|
131
|
+
model.rotation.y = EGYPT_ELEVATOR_SHELL_RUNTIME_YAW
|
|
132
|
+
model.userData.runtimeFront = 'local-positive-z'
|
|
133
|
+
model.userData.runtimeRelease = 'local-negative-z'
|
|
134
|
+
return host
|
|
135
|
+
})
|
|
136
|
+
return host.userData.modelReady as Promise<THREE.Group>
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function addEgyptElevatorBlenderPlatform(
|
|
140
|
+
host: THREE.Group,
|
|
141
|
+
name: string,
|
|
142
|
+
materials: EgyptElevatorBlenderMaterials,
|
|
143
|
+
scale: THREE.Vector3,
|
|
144
|
+
template?: THREE.Object3D,
|
|
145
|
+
): Promise<THREE.Group> {
|
|
146
|
+
host.userData.modelUrl = EGYPT_ELEVATOR_PLATFORM_MODEL_URL
|
|
147
|
+
host.userData.visualOnly = true
|
|
148
|
+
if (typeof document === 'undefined' && !template) {
|
|
149
|
+
host.userData.modelReady = Promise.resolve(host)
|
|
150
|
+
return host.userData.modelReady as Promise<THREE.Group>
|
|
151
|
+
}
|
|
152
|
+
const ready = template ? Promise.resolve(template) : platformTemplate()
|
|
153
|
+
host.userData.modelReady = ready.then(source => {
|
|
154
|
+
const model = attachModel(host, source, `${name}-egypt-approved-blender-platform`, materials)
|
|
155
|
+
model.scale.copy(scale)
|
|
156
|
+
return host
|
|
157
|
+
})
|
|
158
|
+
return host.userData.modelReady as Promise<THREE.Group>
|
|
159
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import * as THREE from 'three'
|
|
2
|
+
|
|
3
|
+
import { VisualCylinderGeometry, VisualTorusGeometry, VisualCircleGeometry, VisualExtrudeGeometry, 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 EGYPT_PORTAL_FRAME_THEME_ID = 'ancient-egypt'
|
|
9
|
+
|
|
10
|
+
export interface EgyptPortalFrameMaterials {
|
|
11
|
+
sandstone: THREE.Material
|
|
12
|
+
carvedStone: THREE.Material
|
|
13
|
+
agedGold: THREE.Material
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface EgyptPortalFrameOptions {
|
|
17
|
+
/** Portal-pair identity crystal and light; architectural reuse should disable this. */
|
|
18
|
+
includePairSignal?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface GeometryBatch {
|
|
22
|
+
name: string
|
|
23
|
+
material: THREE.Material
|
|
24
|
+
parts: THREE.BufferGeometry[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const IDENTITY_QUATERNION = new THREE.Quaternion()
|
|
28
|
+
const UNIT_SCALE = new THREE.Vector3(1, 1, 1)
|
|
29
|
+
const STONE_FRONT_Z = 0.5
|
|
30
|
+
const CARVED_PANEL_FRONT_Z = 0.6
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Adds the visual-only Ancient Egypt housing around the shared portal aperture.
|
|
34
|
+
* The live camera surface, black aperture, rift glow and particles deliberately
|
|
35
|
+
* remain owned by portal-visual.ts.
|
|
36
|
+
*/
|
|
37
|
+
export function addEgyptPortalFrame(
|
|
38
|
+
root: THREE.Group,
|
|
39
|
+
name: string,
|
|
40
|
+
color: string,
|
|
41
|
+
materials: EgyptPortalFrameMaterials,
|
|
42
|
+
options: EgyptPortalFrameOptions = {},
|
|
43
|
+
): THREE.Group {
|
|
44
|
+
const frame = new THREE.Group()
|
|
45
|
+
frame.name = `${name}-egypt-temple-frame`
|
|
46
|
+
frame.userData.themeOnly = EGYPT_PORTAL_FRAME_THEME_ID
|
|
47
|
+
frame.userData.decorative = true
|
|
48
|
+
frame.userData.visualOnly = true
|
|
49
|
+
frame.userData.portalFrameStyle = 'egypt-sandstone-pylon'
|
|
50
|
+
frame.scale.x = PORTAL_THEME_FRAME_X_SCALE
|
|
51
|
+
frame.userData.portalApertureClearHalfWidth = PORTAL_APERTURE_HALF_WIDTH
|
|
52
|
+
frame.userData.drawCallBudget = 4
|
|
53
|
+
frame.userData.triangleBudget = 8_000
|
|
54
|
+
|
|
55
|
+
const stone: THREE.BufferGeometry[] = []
|
|
56
|
+
const carvings: THREE.BufferGeometry[] = []
|
|
57
|
+
const gold: THREE.BufferGeometry[] = []
|
|
58
|
+
|
|
59
|
+
addPylonStone(stone)
|
|
60
|
+
addHieroglyphRelief(carvings)
|
|
61
|
+
addGoldInlays(gold)
|
|
62
|
+
|
|
63
|
+
addBatch(frame, name, { name: 'egypt-sandstone-pylon', material: materials.sandstone, parts: stone })
|
|
64
|
+
const carvedMesh = addBatch(frame, name, {
|
|
65
|
+
name: 'egypt-hieroglyph-relief',
|
|
66
|
+
material: materials.carvedStone,
|
|
67
|
+
parts: carvings,
|
|
68
|
+
})
|
|
69
|
+
carvedMesh.userData.motifs = ['ankh', 'lotus', 'reed-glyphs']
|
|
70
|
+
const goldMesh = addBatch(frame, name, {
|
|
71
|
+
name: 'egypt-aged-gold-inlays',
|
|
72
|
+
material: materials.agedGold,
|
|
73
|
+
parts: gold,
|
|
74
|
+
})
|
|
75
|
+
goldMesh.userData.motifs = ['winged-sun', 'pylon-bands', 'lotus-caps']
|
|
76
|
+
goldMesh.userData.crownTrimStyle = 'continuous-swept-gold-wings'
|
|
77
|
+
goldMesh.userData.stoneContactBackZ = STONE_FRONT_Z
|
|
78
|
+
goldMesh.userData.carvedPanelContactBackZ = CARVED_PANEL_FRONT_Z
|
|
79
|
+
|
|
80
|
+
if (options.includePairSignal !== false) addPairCrystal(frame, name, color)
|
|
81
|
+
|
|
82
|
+
frame.traverse(object => {
|
|
83
|
+
object.userData.themeOnly = EGYPT_PORTAL_FRAME_THEME_ID
|
|
84
|
+
object.userData.decorative = true
|
|
85
|
+
object.userData.visualOnly = true
|
|
86
|
+
object.userData.portalFrameStyle = 'egypt-sandstone-pylon'
|
|
87
|
+
if (object instanceof THREE.Mesh) {
|
|
88
|
+
object.castShadow = true
|
|
89
|
+
object.receiveShadow = true
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
root.add(frame)
|
|
93
|
+
return frame
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function addPylonStone(parts: THREE.BufferGeometry[]): void {
|
|
97
|
+
parts.push(createPylonUpright(-1), createPylonUpright(1))
|
|
98
|
+
parts.push(roundedBox(4.92, 0.42, 0.9, 0.06, new THREE.Vector3(0, 1.42, 0)))
|
|
99
|
+
parts.push(roundedBox(5.22, 0.2, 1.02, 0.045, new THREE.Vector3(0, 1.73, -0.01)))
|
|
100
|
+
parts.push(roundedBox(4.76, 0.17, 0.94, 0.04, new THREE.Vector3(0, 1.12, 0.01)))
|
|
101
|
+
|
|
102
|
+
// Projecting masonry courses provide the macro depth visible in the approved
|
|
103
|
+
// pylon concept. Fine erosion remains in the aligned sandstone maps.
|
|
104
|
+
for (const side of [-1, 1] as const) {
|
|
105
|
+
for (let course = 0; course < 6; course += 1) {
|
|
106
|
+
const y = -1.42 + course * 0.47
|
|
107
|
+
const taper = (5 - course) * 0.025
|
|
108
|
+
const width = course % 2 === 0 ? 0.78 : 0.68
|
|
109
|
+
parts.push(
|
|
110
|
+
roundedBox(
|
|
111
|
+
width,
|
|
112
|
+
0.39,
|
|
113
|
+
0.98 + (course % 2) * 0.035,
|
|
114
|
+
0.055,
|
|
115
|
+
new THREE.Vector3(side * (2.16 + taper), y, 0.015 + (course % 2) * 0.018),
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
parts.push(roundedBox(1.02, 0.28, 1.12, 0.055, new THREE.Vector3(side * 2.22, -1.67, -0.02)))
|
|
120
|
+
parts.push(roundedBox(0.9, 0.19, 1.02, 0.045, new THREE.Vector3(side * 2.18, -1.46, 0)))
|
|
121
|
+
parts.push(roundedBox(0.88, 0.24, 1.04, 0.05, new THREE.Vector3(side * 2.13, 1.18, 0)))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Slightly uneven foot and crown blocks make the silhouette read as worn,
|
|
125
|
+
// assembled temple masonry without paying for individual wall bricks.
|
|
126
|
+
for (const side of [-1, 1] as const) {
|
|
127
|
+
parts.push(roundedBox(0.48, 0.32, 0.92, 0.045, new THREE.Vector3(side * 1.88, 1.02, 0.01)))
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function createPylonUpright(side: -1 | 1): THREE.BufferGeometry {
|
|
132
|
+
const shape = new THREE.Shape()
|
|
133
|
+
if (side < 0) {
|
|
134
|
+
shape.moveTo(-2.56, -1.75)
|
|
135
|
+
shape.lineTo(-1.92, -1.75)
|
|
136
|
+
shape.lineTo(-1.82, 1.24)
|
|
137
|
+
shape.lineTo(-2.4, 1.24)
|
|
138
|
+
} else {
|
|
139
|
+
shape.moveTo(1.92, -1.75)
|
|
140
|
+
shape.lineTo(2.56, -1.75)
|
|
141
|
+
shape.lineTo(2.4, 1.24)
|
|
142
|
+
shape.lineTo(1.82, 1.24)
|
|
143
|
+
}
|
|
144
|
+
shape.closePath()
|
|
145
|
+
const geometry = new VisualExtrudeGeometry(shape, {
|
|
146
|
+
depth: 0.9,
|
|
147
|
+
bevelEnabled: true,
|
|
148
|
+
bevelSegments: 2,
|
|
149
|
+
bevelSize: 0.035,
|
|
150
|
+
bevelThickness: 0.025,
|
|
151
|
+
curveSegments: 1,
|
|
152
|
+
steps: 1,
|
|
153
|
+
})
|
|
154
|
+
geometry.translate(0, 0, -0.45)
|
|
155
|
+
return metricPlanarUvGeometry(geometry)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function addHieroglyphRelief(parts: THREE.BufferGeometry[]): void {
|
|
159
|
+
const reliefZ = 0.625
|
|
160
|
+
for (const side of [-1, 1] as const) {
|
|
161
|
+
const x = side * 2.14
|
|
162
|
+
|
|
163
|
+
// A separate recessed carved slab makes the inscription read as an
|
|
164
|
+
// architectural panel rather than lines floating on wallpaper.
|
|
165
|
+
parts.push(roundedBox(0.55, 2.2, 0.16, 0.045, new THREE.Vector3(x, -0.16, 0.52)))
|
|
166
|
+
|
|
167
|
+
// Ankh: loop, crossbar and stem.
|
|
168
|
+
parts.push(
|
|
169
|
+
transformed(
|
|
170
|
+
new VisualTorusGeometry(0.095, 0.026, 5, 14),
|
|
171
|
+
new THREE.Vector3(x, 0.58, reliefZ),
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
parts.push(roundedBox(0.055, 0.36, 0.045, 0.012, new THREE.Vector3(x, 0.28, reliefZ)))
|
|
175
|
+
parts.push(roundedBox(0.24, 0.05, 0.045, 0.012, new THREE.Vector3(x, 0.37, reliefZ)))
|
|
176
|
+
|
|
177
|
+
// A compact reed/river glyph stack.
|
|
178
|
+
parts.push(roundedBox(0.055, 0.34, 0.045, 0.012, new THREE.Vector3(x, -0.48, reliefZ)))
|
|
179
|
+
for (const offset of [-0.13, 0.13]) {
|
|
180
|
+
parts.push(
|
|
181
|
+
roundedBox(
|
|
182
|
+
0.055,
|
|
183
|
+
0.2,
|
|
184
|
+
0.045,
|
|
185
|
+
0.012,
|
|
186
|
+
new THREE.Vector3(x + offset, -0.42, reliefZ),
|
|
187
|
+
new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), side * offset * 2.6),
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Shallow lotus at the foot: one stem and three low-poly petals.
|
|
193
|
+
parts.push(roundedBox(0.045, 0.28, 0.045, 0.01, new THREE.Vector3(x, -1.18, reliefZ)))
|
|
194
|
+
for (const [petalIndex, offset] of [-0.1, 0, 0.1].entries()) {
|
|
195
|
+
const rotation = (petalIndex - 1) * -0.42
|
|
196
|
+
parts.push(
|
|
197
|
+
transformed(
|
|
198
|
+
new VisualCircleGeometry(0.095, 5),
|
|
199
|
+
new THREE.Vector3(x + offset, -1.01, reliefZ + 0.024),
|
|
200
|
+
new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), rotation),
|
|
201
|
+
new THREE.Vector3(0.66, 1.28, 1),
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function addGoldInlays(parts: THREE.BufferGeometry[]): void {
|
|
209
|
+
const trimDepth = 0.055
|
|
210
|
+
const stoneTrimCenterZ = STONE_FRONT_Z + trimDepth * 0.5 - 0.004
|
|
211
|
+
const panelTrimCenterZ = CARVED_PANEL_FRONT_Z + trimDepth * 0.5 - 0.004
|
|
212
|
+
for (const side of [-1, 1] as const) {
|
|
213
|
+
parts.push(
|
|
214
|
+
roundedBox(0.72, 0.085, trimDepth, 0.018, new THREE.Vector3(side * 2.17, 0.91, panelTrimCenterZ)),
|
|
215
|
+
)
|
|
216
|
+
parts.push(
|
|
217
|
+
roundedBox(0.76, 0.085, trimDepth, 0.018, new THREE.Vector3(side * 2.21, -0.86, panelTrimCenterZ)),
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
parts.push(roundedBox(4.7, 0.07, trimDepth, 0.016, new THREE.Vector3(0, 1.3, stoneTrimCenterZ)))
|
|
221
|
+
parts.push(roundedBox(4.98, 0.06, trimDepth, 0.014, new THREE.Vector3(0, 1.72, stoneTrimCenterZ)))
|
|
222
|
+
|
|
223
|
+
// Geometry-first winged sun socket: a central disc with two continuous
|
|
224
|
+
// swept wings. The previous stack of short offset bars read as a dark
|
|
225
|
+
// staircase/bridge instead of Egyptian gold ornament.
|
|
226
|
+
parts.push(
|
|
227
|
+
transformed(
|
|
228
|
+
new VisualCylinderGeometry(0.3, 0.3, 0.065, 20),
|
|
229
|
+
new THREE.Vector3(0, 1.49, STONE_FRONT_Z + 0.028),
|
|
230
|
+
new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2),
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
for (const side of [-1, 1] as const) {
|
|
234
|
+
parts.push(createSweptGoldWing(side, STONE_FRONT_Z))
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function createSweptGoldWing(side: -1 | 1, z: number): THREE.BufferGeometry {
|
|
239
|
+
const shape = new THREE.Shape()
|
|
240
|
+
shape.moveTo(side * 0.26, 1.48)
|
|
241
|
+
shape.quadraticCurveTo(side * 0.94, 1.68, side * 1.76, 1.59)
|
|
242
|
+
shape.lineTo(side * 1.76, 1.48)
|
|
243
|
+
shape.quadraticCurveTo(side * 0.98, 1.45, side * 0.26, 1.4)
|
|
244
|
+
shape.closePath()
|
|
245
|
+
const wing = new VisualExtrudeGeometry(shape, {
|
|
246
|
+
depth: 0.075,
|
|
247
|
+
bevelEnabled: true,
|
|
248
|
+
bevelSegments: 2,
|
|
249
|
+
bevelSize: 0.014,
|
|
250
|
+
bevelThickness: 0.012,
|
|
251
|
+
curveSegments: 8,
|
|
252
|
+
steps: 1,
|
|
253
|
+
})
|
|
254
|
+
wing.translate(0, 0, z)
|
|
255
|
+
return metricPlanarUvGeometry(wing)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function addPairCrystal(frame: THREE.Group, name: string, color: string): void {
|
|
259
|
+
const crystalMaterial = new THREE.MeshPhysicalMaterial({
|
|
260
|
+
color,
|
|
261
|
+
emissive: color,
|
|
262
|
+
emissiveIntensity: 1.25,
|
|
263
|
+
metalness: 0.04,
|
|
264
|
+
roughness: 0.16,
|
|
265
|
+
// A tiny decorative gem must not trigger a full-scene refraction pass.
|
|
266
|
+
transmission: 0,
|
|
267
|
+
thickness: 0.2,
|
|
268
|
+
clearcoat: 1,
|
|
269
|
+
clearcoatRoughness: 0.1,
|
|
270
|
+
})
|
|
271
|
+
crystalMaterial.name = `${name}-egypt-pair-crystal-material`
|
|
272
|
+
|
|
273
|
+
const crystalParts = [
|
|
274
|
+
transformed(
|
|
275
|
+
new THREE.OctahedronGeometry(0.22, 0),
|
|
276
|
+
new THREE.Vector3(0, 1.5, 0.66),
|
|
277
|
+
IDENTITY_QUATERNION,
|
|
278
|
+
new THREE.Vector3(0.82, 1.18, 0.56),
|
|
279
|
+
),
|
|
280
|
+
transformed(
|
|
281
|
+
new THREE.OctahedronGeometry(0.105, 0),
|
|
282
|
+
new THREE.Vector3(-0.2, 1.46, 0.61),
|
|
283
|
+
new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), -0.5),
|
|
284
|
+
new THREE.Vector3(0.65, 1.25, 0.5),
|
|
285
|
+
),
|
|
286
|
+
transformed(
|
|
287
|
+
new THREE.OctahedronGeometry(0.105, 0),
|
|
288
|
+
new THREE.Vector3(0.2, 1.46, 0.61),
|
|
289
|
+
new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), 0.5),
|
|
290
|
+
new THREE.Vector3(0.65, 1.25, 0.5),
|
|
291
|
+
),
|
|
292
|
+
]
|
|
293
|
+
const crystalGeometry = mergeGeometries(crystalParts, false)
|
|
294
|
+
crystalParts.forEach(part => part.dispose())
|
|
295
|
+
if (!crystalGeometry) throw new Error('Unable to merge Egypt portal pair crystal geometry.')
|
|
296
|
+
crystalGeometry.computeBoundingBox()
|
|
297
|
+
crystalGeometry.computeBoundingSphere()
|
|
298
|
+
crystalGeometry.userData.visualOnly = true
|
|
299
|
+
const crystal = new THREE.Mesh(crystalGeometry, crystalMaterial)
|
|
300
|
+
crystal.name = `${name}-egypt-pair-crystal`
|
|
301
|
+
crystal.userData.ownsMaterial = true
|
|
302
|
+
crystal.userData.portalPairColor = color
|
|
303
|
+
crystal.userData.portalLightSource = 'faceted-scarab-crystal'
|
|
304
|
+
frame.add(crystal)
|
|
305
|
+
|
|
306
|
+
const light = new THREE.PointLight(color, 0.55, 3.2, 2)
|
|
307
|
+
light.name = `${name}-egypt-pair-crystal-light`
|
|
308
|
+
light.position.set(0, 1.45, 0.78)
|
|
309
|
+
light.userData.portalPairColor = color
|
|
310
|
+
light.userData.portalLightSource = 'faceted-scarab-crystal'
|
|
311
|
+
frame.add(light)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function addBatch(frame: THREE.Group, name: string, batch: GeometryBatch): THREE.Mesh {
|
|
315
|
+
const geometry = mergeGeometries(batch.parts, false)
|
|
316
|
+
batch.parts.forEach(part => part.dispose())
|
|
317
|
+
if (!geometry) throw new Error(`Unable to merge Egypt portal frame batch "${batch.name}".`)
|
|
318
|
+
geometry.name = `${name}-${batch.name}-geometry`
|
|
319
|
+
geometry.computeBoundingBox()
|
|
320
|
+
geometry.computeBoundingSphere()
|
|
321
|
+
geometry.userData.visualOnly = true
|
|
322
|
+
const mesh = new THREE.Mesh(geometry, batch.material)
|
|
323
|
+
mesh.name = `${name}-${batch.name}`
|
|
324
|
+
frame.add(mesh)
|
|
325
|
+
return mesh
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function roundedBox(
|
|
329
|
+
width: number,
|
|
330
|
+
height: number,
|
|
331
|
+
depth: number,
|
|
332
|
+
radius: number,
|
|
333
|
+
position: THREE.Vector3,
|
|
334
|
+
rotation = IDENTITY_QUATERNION,
|
|
335
|
+
): THREE.BufferGeometry {
|
|
336
|
+
return transformed(
|
|
337
|
+
new VisualBoxGeometry(width, height, depth, 2, Math.min(radius, width / 4, height / 4, depth / 4)),
|
|
338
|
+
position,
|
|
339
|
+
rotation,
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function transformed(
|
|
344
|
+
geometry: THREE.BufferGeometry,
|
|
345
|
+
position: THREE.Vector3,
|
|
346
|
+
rotation = IDENTITY_QUATERNION,
|
|
347
|
+
scale = UNIT_SCALE,
|
|
348
|
+
): THREE.BufferGeometry {
|
|
349
|
+
geometry.applyMatrix4(new THREE.Matrix4().compose(position, rotation, scale))
|
|
350
|
+
return metricPlanarUvGeometry(geometry)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function metricPlanarUvGeometry(geometry: THREE.BufferGeometry): THREE.BufferGeometry {
|
|
354
|
+
const prepared = geometry.index ? geometry.toNonIndexed() : geometry
|
|
355
|
+
if (prepared !== geometry) geometry.dispose()
|
|
356
|
+
const position = prepared.getAttribute('position')
|
|
357
|
+
const normal = prepared.getAttribute('normal')
|
|
358
|
+
const uvs = new Float32Array(position.count * 2)
|
|
359
|
+
const metricUvs = new Float32Array(position.count * 4)
|
|
360
|
+
for (let index = 0; index < position.count; index += 1) {
|
|
361
|
+
const x = position.getX(index)
|
|
362
|
+
const y = position.getY(index)
|
|
363
|
+
const z = position.getZ(index)
|
|
364
|
+
const nx = Math.abs(normal.getX(index))
|
|
365
|
+
const ny = Math.abs(normal.getY(index))
|
|
366
|
+
const nz = Math.abs(normal.getZ(index))
|
|
367
|
+
if (nx >= ny && nx >= nz) {
|
|
368
|
+
uvs[index * 2] = z
|
|
369
|
+
uvs[index * 2 + 1] = y
|
|
370
|
+
} else if (ny >= nz) {
|
|
371
|
+
uvs[index * 2] = x
|
|
372
|
+
uvs[index * 2 + 1] = z
|
|
373
|
+
} else {
|
|
374
|
+
uvs[index * 2] = x
|
|
375
|
+
uvs[index * 2 + 1] = y
|
|
376
|
+
}
|
|
377
|
+
metricUvs[index * 4 + 2] = 1
|
|
378
|
+
}
|
|
379
|
+
prepared.setAttribute('uv', new THREE.BufferAttribute(uvs, 2))
|
|
380
|
+
prepared.setAttribute('themeNetUv', new THREE.BufferAttribute(metricUvs, 4))
|
|
381
|
+
return prepared
|
|
382
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { hasMarbleCrossedFinishPlane, type FinishPlaneCheck } from '@marble.fun/physics-engine'
|