@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,221 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MarbleSimulationSnapshot,
|
|
3
|
+
MechanismSimulationPart,
|
|
4
|
+
RaceSimulationSnapshot,
|
|
5
|
+
SimulationQuaternion,
|
|
6
|
+
SimulationVector3,
|
|
7
|
+
} from '@marble.fun/physics-engine'
|
|
8
|
+
import * as THREE from 'three'
|
|
9
|
+
|
|
10
|
+
const PORTAL_SNAP_DISTANCE_SQUARED = 4
|
|
11
|
+
|
|
12
|
+
interface RisingWallVisualMetadata {
|
|
13
|
+
anchor: SimulationVector3
|
|
14
|
+
up: SimulationVector3
|
|
15
|
+
wallHeight: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RenderedMarbleSimulationState {
|
|
19
|
+
snapshot: MarbleSimulationSnapshot
|
|
20
|
+
position: SimulationVector3
|
|
21
|
+
rotation: SimulationQuaternion
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RaceSimulationRenderBindings {
|
|
25
|
+
marbleObjects: ReadonlyMap<string, THREE.Object3D>
|
|
26
|
+
mechanismPartObjects: ReadonlyMap<string, THREE.Object3D>
|
|
27
|
+
onMarbleState?(state: RenderedMarbleSimulationState): void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function indexedParts(snapshot: RaceSimulationSnapshot): Map<string, MechanismSimulationPart> {
|
|
31
|
+
const parts = new Map<string, MechanismSimulationPart>()
|
|
32
|
+
for (const mechanism of snapshot.mechanisms) {
|
|
33
|
+
for (const part of mechanism.parts ?? []) parts.set(part.id, part)
|
|
34
|
+
}
|
|
35
|
+
return parts
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function visualObjectNameForPart(partId: string): string | undefined {
|
|
39
|
+
if (partId.endsWith(':paddle')) return `${partId.slice(0, -':paddle'.length)}-splitter-paddle`
|
|
40
|
+
const indexedPart = /^(.*):(shelf|flipper|rebound|wall):(\d+)$/.exec(partId)
|
|
41
|
+
if (!indexedPart) return undefined
|
|
42
|
+
const [, mechanismId, kind, rawIndex] = indexedPart
|
|
43
|
+
const index = Number(rawIndex)
|
|
44
|
+
switch (kind) {
|
|
45
|
+
case 'shelf':
|
|
46
|
+
return `${mechanismId}-conveyor-shelf-${index + 1}`
|
|
47
|
+
case 'flipper':
|
|
48
|
+
return `${mechanismId}-flipper-${index + 1}`
|
|
49
|
+
case 'rebound':
|
|
50
|
+
return `${mechanismId}-rebound-${index}`
|
|
51
|
+
case 'wall':
|
|
52
|
+
return `${mechanismId}-rising-wall-${index}`
|
|
53
|
+
default:
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Resolves stable core part ids onto the visual objects built by track-runtime. */
|
|
59
|
+
export function bindMechanismPartObjects(
|
|
60
|
+
root: THREE.Object3D,
|
|
61
|
+
snapshot: RaceSimulationSnapshot,
|
|
62
|
+
): ReadonlyMap<string, THREE.Object3D> {
|
|
63
|
+
const bindings = new Map<string, THREE.Object3D>()
|
|
64
|
+
for (const part of indexedParts(snapshot).values()) {
|
|
65
|
+
const objectName = visualObjectNameForPart(part.id)
|
|
66
|
+
const object = objectName ? root.getObjectByName(objectName) : undefined
|
|
67
|
+
if (object) bindings.set(part.id, object)
|
|
68
|
+
}
|
|
69
|
+
return bindings
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function writeTransform(
|
|
73
|
+
object: THREE.Object3D,
|
|
74
|
+
previousPosition: SimulationVector3,
|
|
75
|
+
previousRotation: SimulationQuaternion,
|
|
76
|
+
currentPosition: SimulationVector3,
|
|
77
|
+
currentRotation: SimulationQuaternion,
|
|
78
|
+
alpha: number,
|
|
79
|
+
snapLargeTranslation: boolean,
|
|
80
|
+
rotationScratch: THREE.Quaternion,
|
|
81
|
+
): void {
|
|
82
|
+
const dx = currentPosition[0] - previousPosition[0]
|
|
83
|
+
const dy = currentPosition[1] - previousPosition[1]
|
|
84
|
+
const dz = currentPosition[2] - previousPosition[2]
|
|
85
|
+
const progress = snapLargeTranslation && dx * dx + dy * dy + dz * dz > PORTAL_SNAP_DISTANCE_SQUARED ? 1 : alpha
|
|
86
|
+
object.position.set(
|
|
87
|
+
previousPosition[0] + dx * progress,
|
|
88
|
+
previousPosition[1] + dy * progress,
|
|
89
|
+
previousPosition[2] + dz * progress,
|
|
90
|
+
)
|
|
91
|
+
object.quaternion.fromArray(previousRotation).slerp(rotationScratch.fromArray(currentRotation), progress)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function cropRisingWallVisual(object: THREE.Object3D): void {
|
|
95
|
+
const metadata = object.userData.risingWallVisual as RisingWallVisualMetadata | undefined
|
|
96
|
+
if (!metadata) return
|
|
97
|
+
const { anchor, up, wallHeight } = metadata
|
|
98
|
+
const centerOffset =
|
|
99
|
+
(object.position.x - anchor[0]) * up[0] +
|
|
100
|
+
(object.position.y - anchor[1]) * up[1] +
|
|
101
|
+
(object.position.z - anchor[2]) * up[2]
|
|
102
|
+
const exposedHeight = THREE.MathUtils.clamp(centerOffset + wallHeight / 2, 0, wallHeight)
|
|
103
|
+
object.position.set(
|
|
104
|
+
anchor[0] + up[0] * exposedHeight / 2,
|
|
105
|
+
anchor[1] + up[1] * exposedHeight / 2,
|
|
106
|
+
anchor[2] + up[2] * exposedHeight / 2,
|
|
107
|
+
)
|
|
108
|
+
object.scale.y = exposedHeight / wallHeight
|
|
109
|
+
object.visible = exposedHeight > 0.001
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Visual-only interpolation of immutable core snapshots. This class has no
|
|
114
|
+
* Rapier world/body access and therefore cannot alter authoritative motion.
|
|
115
|
+
*/
|
|
116
|
+
export class RaceSimulationRenderer {
|
|
117
|
+
#previous?: RaceSimulationSnapshot
|
|
118
|
+
#current?: RaceSimulationSnapshot
|
|
119
|
+
#previousMarbles = new Map<string, MarbleSimulationSnapshot>()
|
|
120
|
+
#currentMarbles = new Map<string, MarbleSimulationSnapshot>()
|
|
121
|
+
#previousParts = new Map<string, MechanismSimulationPart>()
|
|
122
|
+
#currentParts = new Map<string, MechanismSimulationPart>()
|
|
123
|
+
readonly #rotationScratch = new THREE.Quaternion()
|
|
124
|
+
|
|
125
|
+
constructor(readonly bindings: RaceSimulationRenderBindings) {}
|
|
126
|
+
|
|
127
|
+
push(snapshot: RaceSimulationSnapshot): void {
|
|
128
|
+
if (this.#current && snapshot.simulationTick < this.#current.simulationTick) {
|
|
129
|
+
throw new Error(`Cannot render stale simulation tick ${snapshot.simulationTick}`)
|
|
130
|
+
}
|
|
131
|
+
if (!this.#current || snapshot.simulationTick === this.#current.simulationTick) {
|
|
132
|
+
this.#current = snapshot
|
|
133
|
+
if (!this.#previous) {
|
|
134
|
+
this.#previous = snapshot
|
|
135
|
+
this.indexSnapshot(snapshot, this.#previousMarbles, this.#previousParts)
|
|
136
|
+
}
|
|
137
|
+
this.indexSnapshot(snapshot, this.#currentMarbles, this.#currentParts)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
this.#previous = this.#current
|
|
141
|
+
this.#current = snapshot
|
|
142
|
+
const reusableMarbles = this.#previousMarbles
|
|
143
|
+
this.#previousMarbles = this.#currentMarbles
|
|
144
|
+
this.#currentMarbles = reusableMarbles
|
|
145
|
+
const reusableParts = this.#previousParts
|
|
146
|
+
this.#previousParts = this.#currentParts
|
|
147
|
+
this.#currentParts = reusableParts
|
|
148
|
+
this.indexSnapshot(snapshot, this.#currentMarbles, this.#currentParts)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
render(interpolationAlpha: number): void {
|
|
152
|
+
const current = this.#current
|
|
153
|
+
if (!current) return
|
|
154
|
+
const alpha = THREE.MathUtils.clamp(interpolationAlpha, 0, 1)
|
|
155
|
+
for (const marble of current.marbles) {
|
|
156
|
+
const before = this.#previousMarbles.get(marble.slotId) ?? marble
|
|
157
|
+
const object = this.bindings.marbleObjects.get(marble.slotId)
|
|
158
|
+
if (object) {
|
|
159
|
+
writeTransform(
|
|
160
|
+
object,
|
|
161
|
+
before.position,
|
|
162
|
+
before.rotation,
|
|
163
|
+
marble.position,
|
|
164
|
+
marble.rotation,
|
|
165
|
+
alpha,
|
|
166
|
+
true,
|
|
167
|
+
this.#rotationScratch,
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
if (this.bindings.onMarbleState) {
|
|
171
|
+
const position: SimulationVector3 = object
|
|
172
|
+
? [object.position.x, object.position.y, object.position.z]
|
|
173
|
+
: marble.position
|
|
174
|
+
const rotation: SimulationQuaternion = object
|
|
175
|
+
? [object.quaternion.x, object.quaternion.y, object.quaternion.z, object.quaternion.w]
|
|
176
|
+
: marble.rotation
|
|
177
|
+
this.bindings.onMarbleState({ snapshot: marble, position, rotation })
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const part of this.#currentParts.values()) {
|
|
182
|
+
const before = this.#previousParts.get(part.id) ?? part
|
|
183
|
+
const object = this.bindings.mechanismPartObjects.get(part.id)
|
|
184
|
+
if (object) {
|
|
185
|
+
writeTransform(
|
|
186
|
+
object,
|
|
187
|
+
before.position,
|
|
188
|
+
before.rotation,
|
|
189
|
+
part.position,
|
|
190
|
+
part.rotation,
|
|
191
|
+
alpha,
|
|
192
|
+
false,
|
|
193
|
+
this.#rotationScratch,
|
|
194
|
+
)
|
|
195
|
+
cropRisingWallVisual(object)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private indexSnapshot(
|
|
201
|
+
snapshot: RaceSimulationSnapshot,
|
|
202
|
+
marbles: Map<string, MarbleSimulationSnapshot>,
|
|
203
|
+
parts: Map<string, MechanismSimulationPart>,
|
|
204
|
+
): void {
|
|
205
|
+
marbles.clear()
|
|
206
|
+
parts.clear()
|
|
207
|
+
for (const marble of snapshot.marbles) marbles.set(marble.slotId, marble)
|
|
208
|
+
for (const mechanism of snapshot.mechanisms) {
|
|
209
|
+
for (const part of mechanism.parts ?? []) parts.set(part.id, part)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
clear(): void {
|
|
214
|
+
this.#previous = undefined
|
|
215
|
+
this.#current = undefined
|
|
216
|
+
this.#previousMarbles.clear()
|
|
217
|
+
this.#currentMarbles.clear()
|
|
218
|
+
this.#previousParts.clear()
|
|
219
|
+
this.#currentParts.clear()
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import * as THREE from 'three'
|
|
2
|
+
|
|
3
|
+
export const RACE_TERMINAL_CHECKER_STRIP_ROWS = 2
|
|
4
|
+
export const RACE_TERMINAL_CHECKER_STRIP_DEPTH = 0.9
|
|
5
|
+
export const RACE_TERMINAL_CHECKER_CELL_SIZE = RACE_TERMINAL_CHECKER_STRIP_DEPTH / RACE_TERMINAL_CHECKER_STRIP_ROWS
|
|
6
|
+
export const RACE_TERMINAL_CHECKER_STRIP_FACE_OFFSET = 0.012
|
|
7
|
+
|
|
8
|
+
export interface RaceTerminalCheckerStripSurface {
|
|
9
|
+
totalLength: number
|
|
10
|
+
startDistance: number
|
|
11
|
+
floorThickness: number
|
|
12
|
+
includeEntryFace: boolean
|
|
13
|
+
widthAt(distance: number): number
|
|
14
|
+
topPointAt(distance: number, normalizedLateral: number): THREE.Vector3
|
|
15
|
+
upAt(distance: number): THREE.Vector3
|
|
16
|
+
outward: THREE.Vector3
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Splits decorative terminal floor details around the checker. The start grid
|
|
21
|
+
* has no approach run because its checker begins at the exposed entry. The
|
|
22
|
+
* finish keeps its funnel-side architecture and resumes it at the checker's
|
|
23
|
+
* rear edge. Side walls, floor geometry, and colliders deliberately keep their
|
|
24
|
+
* original full-piece paths.
|
|
25
|
+
*/
|
|
26
|
+
export function createRaceTerminalFloorDetailFrameRuns<Frame>(
|
|
27
|
+
frames: readonly Frame[],
|
|
28
|
+
distances: readonly number[],
|
|
29
|
+
frameAtDistance: (distance: number) => Frame,
|
|
30
|
+
checkerStartDistance = 0,
|
|
31
|
+
): readonly (readonly Frame[])[] {
|
|
32
|
+
const totalLength = distances.at(-1) ?? 0
|
|
33
|
+
if (frames.length < 2 || totalLength <= 0.000_001) return []
|
|
34
|
+
const checkerStart = THREE.MathUtils.clamp(checkerStartDistance, 0, totalLength)
|
|
35
|
+
const checkerEnd = Math.min(checkerStart + RACE_TERMINAL_CHECKER_STRIP_DEPTH, totalLength)
|
|
36
|
+
const frameAtBoundary = (distance: number): Frame => {
|
|
37
|
+
const exactFrameIndex = distances.findIndex(candidate => Math.abs(candidate - distance) <= 0.000_001)
|
|
38
|
+
return exactFrameIndex >= 0 ? frames[exactFrameIndex]! : frameAtDistance(distance)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sliceThroughBoundary = (startDistance: number, endDistance: number): readonly Frame[] => {
|
|
42
|
+
if (endDistance - startDistance <= 0.000_001) return []
|
|
43
|
+
const run: Frame[] = [frameAtBoundary(startDistance)]
|
|
44
|
+
for (let index = 0; index < frames.length; index += 1) {
|
|
45
|
+
const distance = distances[index]
|
|
46
|
+
if (distance === undefined || distance <= startDistance + 0.000_001 || distance >= endDistance - 0.000_001) {
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
run.push(frames[index]!)
|
|
50
|
+
}
|
|
51
|
+
run.push(frameAtBoundary(endDistance))
|
|
52
|
+
return run
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const approach = sliceThroughBoundary(0, checkerStart)
|
|
56
|
+
const exit = sliceThroughBoundary(checkerEnd, totalLength)
|
|
57
|
+
|
|
58
|
+
return [approach, exit].filter(run => run.length >= 2)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Locates the first frame of the final constant-width, single-file channel. */
|
|
62
|
+
export function resolveRaceTerminalCheckerStartDistance(
|
|
63
|
+
raceRole: 'start' | 'finish',
|
|
64
|
+
widths: readonly number[],
|
|
65
|
+
distances: readonly number[],
|
|
66
|
+
): number {
|
|
67
|
+
if (raceRole === 'start' || widths.length === 0) return 0
|
|
68
|
+
const terminalWidth = widths.at(-1)!
|
|
69
|
+
for (let index = 0; index < widths.length; index += 1) {
|
|
70
|
+
if (widths.slice(index).every(width => Math.abs(width - terminalWidth) <= 0.000_001)) {
|
|
71
|
+
return distances[index] ?? 0
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function raceTerminalCheckerColumnCount(width: number): number {
|
|
78
|
+
return Math.max(2, Math.round(width / RACE_TERMINAL_CHECKER_CELL_SIZE))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const LIGHT = new THREE.Color('#f1f3f5')
|
|
82
|
+
const DARK = new THREE.Color('#20252a')
|
|
83
|
+
|
|
84
|
+
/** Adds one permanent visual checkerboard wrapped over a terminal's top and entry face. */
|
|
85
|
+
export function addRaceTerminalCheckerStrip(
|
|
86
|
+
root: THREE.Group,
|
|
87
|
+
id: string,
|
|
88
|
+
surface: RaceTerminalCheckerStripSurface,
|
|
89
|
+
): THREE.Mesh | undefined {
|
|
90
|
+
if (surface.totalLength <= 0.000_001 || surface.floorThickness <= 0.000_001) return undefined
|
|
91
|
+
const checkerStartDistance = THREE.MathUtils.clamp(surface.startDistance, 0, surface.totalLength)
|
|
92
|
+
const checkerDepth = Math.min(RACE_TERMINAL_CHECKER_STRIP_DEPTH, surface.totalLength - checkerStartDistance)
|
|
93
|
+
if (checkerDepth <= 0.000_001) return undefined
|
|
94
|
+
const checkerWidth = surface.widthAt(checkerStartDistance)
|
|
95
|
+
const checkerColumns = raceTerminalCheckerColumnCount(checkerWidth)
|
|
96
|
+
const checkerFaceRows = Math.max(1, Math.round(surface.floorThickness / RACE_TERMINAL_CHECKER_CELL_SIZE))
|
|
97
|
+
const entryUp = surface.upAt(checkerStartDistance).clone().normalize()
|
|
98
|
+
const outward = surface.outward.clone().normalize()
|
|
99
|
+
|
|
100
|
+
const positions: number[] = []
|
|
101
|
+
const colors: number[] = []
|
|
102
|
+
const indices: number[] = []
|
|
103
|
+
const append = (point: THREE.Vector3, color: THREE.Color): number => {
|
|
104
|
+
const index = positions.length / 3
|
|
105
|
+
positions.push(point.x, point.y, point.z)
|
|
106
|
+
colors.push(color.r, color.g, color.b)
|
|
107
|
+
return index
|
|
108
|
+
}
|
|
109
|
+
const appendQuad = (
|
|
110
|
+
points: readonly [THREE.Vector3, THREE.Vector3, THREE.Vector3, THREE.Vector3],
|
|
111
|
+
color: THREE.Color,
|
|
112
|
+
expectedNormal: THREE.Vector3,
|
|
113
|
+
): void => {
|
|
114
|
+
const base = append(points[0], color)
|
|
115
|
+
append(points[1], color)
|
|
116
|
+
append(points[2], color)
|
|
117
|
+
append(points[3], color)
|
|
118
|
+
const normal = points[1].clone().sub(points[0]).cross(points[2].clone().sub(points[0]))
|
|
119
|
+
if (normal.dot(expectedNormal) >= 0) {
|
|
120
|
+
indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
|
|
121
|
+
} else {
|
|
122
|
+
indices.push(base, base + 2, base + 1, base, base + 3, base + 2)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Top surface: follow the real ribbon sampler so curves, grades, and troughs match.
|
|
127
|
+
for (let row = 0; row < RACE_TERMINAL_CHECKER_STRIP_ROWS; row += 1) {
|
|
128
|
+
const startDistance = checkerStartDistance + (row / RACE_TERMINAL_CHECKER_STRIP_ROWS) * checkerDepth
|
|
129
|
+
const endDistance = checkerStartDistance + ((row + 1) / RACE_TERMINAL_CHECKER_STRIP_ROWS) * checkerDepth
|
|
130
|
+
for (let column = 0; column < checkerColumns; column += 1) {
|
|
131
|
+
const startLateral = -1 + (column / checkerColumns) * 2
|
|
132
|
+
const endLateral = -1 + ((column + 1) / checkerColumns) * 2
|
|
133
|
+
const color = (row + column) % 2 === 0 ? LIGHT : DARK
|
|
134
|
+
const startUp = surface.upAt(startDistance)
|
|
135
|
+
const endUp = surface.upAt(endDistance)
|
|
136
|
+
appendQuad(
|
|
137
|
+
[
|
|
138
|
+
surface
|
|
139
|
+
.topPointAt(startDistance, startLateral)
|
|
140
|
+
.addScaledVector(startUp, RACE_TERMINAL_CHECKER_STRIP_FACE_OFFSET),
|
|
141
|
+
surface.topPointAt(endDistance, startLateral).addScaledVector(endUp, RACE_TERMINAL_CHECKER_STRIP_FACE_OFFSET),
|
|
142
|
+
surface.topPointAt(endDistance, endLateral).addScaledVector(endUp, RACE_TERMINAL_CHECKER_STRIP_FACE_OFFSET),
|
|
143
|
+
surface
|
|
144
|
+
.topPointAt(startDistance, endLateral)
|
|
145
|
+
.addScaledVector(startUp, RACE_TERMINAL_CHECKER_STRIP_FACE_OFFSET),
|
|
146
|
+
],
|
|
147
|
+
color,
|
|
148
|
+
surface.upAt((startDistance + endDistance) / 2),
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Only the start grid owns an exposed entry cap. The finish line is an
|
|
154
|
+
// internal floor marking at the single-file transition.
|
|
155
|
+
for (let row = 0; surface.includeEntryFace && row < checkerFaceRows; row += 1) {
|
|
156
|
+
const topDrop = (row / checkerFaceRows) * surface.floorThickness
|
|
157
|
+
const bottomDrop = ((row + 1) / checkerFaceRows) * surface.floorThickness
|
|
158
|
+
for (let column = 0; column < checkerColumns; column += 1) {
|
|
159
|
+
const startLateral = -1 + (column / checkerColumns) * 2
|
|
160
|
+
const endLateral = -1 + ((column + 1) / checkerColumns) * 2
|
|
161
|
+
const color = (row + column + 1) % 2 === 0 ? LIGHT : DARK
|
|
162
|
+
appendQuad(
|
|
163
|
+
[
|
|
164
|
+
surface
|
|
165
|
+
.topPointAt(checkerStartDistance, startLateral)
|
|
166
|
+
.addScaledVector(entryUp, -topDrop)
|
|
167
|
+
.addScaledVector(outward, RACE_TERMINAL_CHECKER_STRIP_FACE_OFFSET),
|
|
168
|
+
surface
|
|
169
|
+
.topPointAt(checkerStartDistance, endLateral)
|
|
170
|
+
.addScaledVector(entryUp, -topDrop)
|
|
171
|
+
.addScaledVector(outward, RACE_TERMINAL_CHECKER_STRIP_FACE_OFFSET),
|
|
172
|
+
surface
|
|
173
|
+
.topPointAt(checkerStartDistance, endLateral)
|
|
174
|
+
.addScaledVector(entryUp, -bottomDrop)
|
|
175
|
+
.addScaledVector(outward, RACE_TERMINAL_CHECKER_STRIP_FACE_OFFSET),
|
|
176
|
+
surface
|
|
177
|
+
.topPointAt(checkerStartDistance, startLateral)
|
|
178
|
+
.addScaledVector(entryUp, -bottomDrop)
|
|
179
|
+
.addScaledVector(outward, RACE_TERMINAL_CHECKER_STRIP_FACE_OFFSET),
|
|
180
|
+
],
|
|
181
|
+
color,
|
|
182
|
+
outward,
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const geometry = new THREE.BufferGeometry()
|
|
188
|
+
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
|
189
|
+
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3))
|
|
190
|
+
geometry.setIndex(indices)
|
|
191
|
+
geometry.computeVertexNormals()
|
|
192
|
+
geometry.computeBoundingBox()
|
|
193
|
+
geometry.computeBoundingSphere()
|
|
194
|
+
|
|
195
|
+
const material = new THREE.MeshStandardMaterial({
|
|
196
|
+
vertexColors: true,
|
|
197
|
+
roughness: 0.78,
|
|
198
|
+
metalness: 0,
|
|
199
|
+
side: THREE.FrontSide,
|
|
200
|
+
polygonOffset: true,
|
|
201
|
+
polygonOffsetFactor: -1,
|
|
202
|
+
polygonOffsetUnits: -1,
|
|
203
|
+
})
|
|
204
|
+
material.name = 'race-terminal-checker-strip-material'
|
|
205
|
+
const strip = new THREE.Mesh(geometry, material)
|
|
206
|
+
strip.name = `${id}-race-terminal-checker-strip`
|
|
207
|
+
strip.receiveShadow = true
|
|
208
|
+
strip.renderOrder = 4
|
|
209
|
+
strip.userData.ownsMaterial = true
|
|
210
|
+
strip.userData.visualOnly = true
|
|
211
|
+
strip.userData.decorative = true
|
|
212
|
+
strip.userData.themePermanent = true
|
|
213
|
+
strip.userData.checkerRows = RACE_TERMINAL_CHECKER_STRIP_ROWS
|
|
214
|
+
strip.userData.checkerColumns = checkerColumns
|
|
215
|
+
strip.userData.checkerFaceRows = surface.includeEntryFace ? checkerFaceRows : 0
|
|
216
|
+
strip.userData.checkerCellTargetSize = RACE_TERMINAL_CHECKER_CELL_SIZE
|
|
217
|
+
strip.userData.checkerCellWidth = checkerWidth / checkerColumns
|
|
218
|
+
strip.userData.checkerCellDepth = checkerDepth / RACE_TERMINAL_CHECKER_STRIP_ROWS
|
|
219
|
+
strip.userData.checkerStartDistance = checkerStartDistance
|
|
220
|
+
strip.userData.checkerDepth = checkerDepth
|
|
221
|
+
strip.userData.checkerFaceHeight = surface.includeEntryFace ? surface.floorThickness : 0
|
|
222
|
+
strip.userData.wrapsEntryFace = surface.includeEntryFace
|
|
223
|
+
strip.userData.faceOffset = RACE_TERMINAL_CHECKER_STRIP_FACE_OFFSET
|
|
224
|
+
root.add(strip)
|
|
225
|
+
return strip
|
|
226
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as THREE from 'three'
|
|
2
|
+
|
|
3
|
+
interface TransformSource {
|
|
4
|
+
translation(): { x: number; y: number; z: number }
|
|
5
|
+
rotation(): { x: number; y: number; z: number; w: number }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function clampInterpolationAlpha(alpha: number): number {
|
|
9
|
+
return Number.isFinite(alpha) ? THREE.MathUtils.clamp(alpha, 0, 1) : 1
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Render-only history for one authoritative rigid-body transform. */
|
|
13
|
+
export class RigidBodyTransformInterpolator {
|
|
14
|
+
readonly previousPosition = new THREE.Vector3()
|
|
15
|
+
readonly currentPosition = new THREE.Vector3()
|
|
16
|
+
readonly previousRotation = new THREE.Quaternion()
|
|
17
|
+
readonly currentRotation = new THREE.Quaternion()
|
|
18
|
+
|
|
19
|
+
constructor(source: TransformSource) {
|
|
20
|
+
this.reset(source)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
reset(source: TransformSource): void {
|
|
24
|
+
const position = source.translation()
|
|
25
|
+
const rotation = source.rotation()
|
|
26
|
+
this.currentPosition.set(position.x, position.y, position.z)
|
|
27
|
+
this.previousPosition.copy(this.currentPosition)
|
|
28
|
+
this.currentRotation.set(rotation.x, rotation.y, rotation.z, rotation.w).normalize()
|
|
29
|
+
this.previousRotation.copy(this.currentRotation)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
capture(source: TransformSource, snapDistance = Number.POSITIVE_INFINITY): void {
|
|
33
|
+
this.previousPosition.copy(this.currentPosition)
|
|
34
|
+
this.previousRotation.copy(this.currentRotation)
|
|
35
|
+
const position = source.translation()
|
|
36
|
+
const rotation = source.rotation()
|
|
37
|
+
this.currentPosition.set(position.x, position.y, position.z)
|
|
38
|
+
this.currentRotation.set(rotation.x, rotation.y, rotation.z, rotation.w).normalize()
|
|
39
|
+
if (this.previousPosition.distanceToSquared(this.currentPosition) > snapDistance ** 2) {
|
|
40
|
+
this.previousPosition.copy(this.currentPosition)
|
|
41
|
+
this.previousRotation.copy(this.currentRotation)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
write(object: THREE.Object3D, alpha: number): void {
|
|
46
|
+
const safeAlpha = clampInterpolationAlpha(alpha)
|
|
47
|
+
object.position.lerpVectors(this.previousPosition, this.currentPosition, safeAlpha)
|
|
48
|
+
object.quaternion.slerpQuaternions(this.previousRotation, this.currentRotation, safeAlpha)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
MarbleAssignment,
|
|
3
|
+
MarbleStanding,
|
|
4
|
+
MarbleTuning,
|
|
5
|
+
RaceConfiguration,
|
|
6
|
+
RacePhase,
|
|
7
|
+
RaceSnapshot,
|
|
8
|
+
RaceTuning,
|
|
9
|
+
RaceWinner,
|
|
10
|
+
} from '@marble.fun/physics-engine'
|
|
11
|
+
|
|
12
|
+
export { MAX_RACE_MARBLES as MAX_MARBLES, MIN_RACE_MARBLES as MIN_MARBLES } from '@marble.fun/physics-engine'
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as THREE from 'three'
|
|
2
|
+
|
|
3
|
+
import { VisualCapsuleGeometry } from '../../../three/visual-geometry'
|
|
4
|
+
|
|
5
|
+
export interface ReboundVisualFactoryOptions {
|
|
6
|
+
reboundLength: number
|
|
7
|
+
reboundWidth: number
|
|
8
|
+
reboundHeight: number
|
|
9
|
+
bodyMaterial: THREE.Material
|
|
10
|
+
accentMaterial: THREE.Material
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Creates shared visual geometry and a cheap per-bumper Group factory. */
|
|
14
|
+
export function createReboundVisualFactory(options: ReboundVisualFactoryOptions): () => THREE.Group {
|
|
15
|
+
const barLength = options.reboundLength - options.reboundWidth
|
|
16
|
+
const capRadius = options.reboundWidth / 2
|
|
17
|
+
const shellGeometry = createReboundShellGeometry(
|
|
18
|
+
capRadius,
|
|
19
|
+
barLength,
|
|
20
|
+
options.reboundHeight / options.reboundWidth,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
return () => {
|
|
24
|
+
const visual = new THREE.Group()
|
|
25
|
+
// One closed shell avoids the old body/end-cap intersections, while
|
|
26
|
+
// geometry groups retain the red centre and dark armored ends.
|
|
27
|
+
const bar = new THREE.Mesh(shellGeometry, [options.bodyMaterial, options.accentMaterial])
|
|
28
|
+
bar.name = 'body'
|
|
29
|
+
bar.userData.reboundVisualStyle = 'reinforced-single-shell-bumper'
|
|
30
|
+
bar.castShadow = true
|
|
31
|
+
bar.receiveShadow = true
|
|
32
|
+
visual.add(bar)
|
|
33
|
+
return visual
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createReboundShellGeometry(
|
|
38
|
+
radius: number,
|
|
39
|
+
bodyLength: number,
|
|
40
|
+
heightScale: number,
|
|
41
|
+
): THREE.BufferGeometry {
|
|
42
|
+
const capsule = new VisualCapsuleGeometry(radius, bodyLength, 8, 24)
|
|
43
|
+
capsule.rotateZ(Math.PI / 2)
|
|
44
|
+
capsule.scale(1, heightScale, 1)
|
|
45
|
+
const geometry = capsule.toNonIndexed()
|
|
46
|
+
capsule.dispose()
|
|
47
|
+
geometry.clearGroups()
|
|
48
|
+
const positions = geometry.getAttribute('position')
|
|
49
|
+
const capStart = bodyLength / 2
|
|
50
|
+
const bodyIndices: number[] = []
|
|
51
|
+
const accentIndices: number[] = []
|
|
52
|
+
for (let vertex = 0; vertex < positions.count; vertex += 3) {
|
|
53
|
+
const centerX = (positions.getX(vertex) + positions.getX(vertex + 1) + positions.getX(vertex + 2)) / 3
|
|
54
|
+
const indices = Math.abs(centerX) >= capStart ? accentIndices : bodyIndices
|
|
55
|
+
indices.push(vertex, vertex + 1, vertex + 2)
|
|
56
|
+
}
|
|
57
|
+
// CapsuleGeometry arrives as alternating body/cap triangles. One group per
|
|
58
|
+
// triangle made each tiny bumper cost hundreds of draw calls despite using
|
|
59
|
+
// only two opaque materials. Reordering the index stream by material keeps
|
|
60
|
+
// every position, normal, UV, triangle, and material assignment identical
|
|
61
|
+
// while reducing the shell to one submission per material.
|
|
62
|
+
geometry.setIndex([...bodyIndices, ...accentIndices])
|
|
63
|
+
geometry.addGroup(0, bodyIndices.length, 0)
|
|
64
|
+
geometry.addGroup(bodyIndices.length, accentIndices.length, 1)
|
|
65
|
+
geometry.name = 'reinforced-rebound-single-shell-geometry'
|
|
66
|
+
geometry.computeBoundingBox()
|
|
67
|
+
geometry.computeBoundingSphere()
|
|
68
|
+
geometry.userData.closedSurface = true
|
|
69
|
+
return geometry
|
|
70
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendRaceFingerprint,
|
|
3
|
+
createRaceRandom,
|
|
4
|
+
formatRaceFingerprint,
|
|
5
|
+
hashRaceSeed,
|
|
6
|
+
normalizeRaceSeed,
|
|
7
|
+
raceRandomBetween,
|
|
8
|
+
shuffleRaceValues,
|
|
9
|
+
} from '@marble.fun/physics-engine'
|
|
10
|
+
|
|
11
|
+
export const normalizeSeed = normalizeRaceSeed
|
|
12
|
+
export const hashSeed = hashRaceSeed
|
|
13
|
+
export const createSeededRandom = createRaceRandom
|
|
14
|
+
export const randomBetween = raceRandomBetween
|
|
15
|
+
export const shuffled = shuffleRaceValues
|
|
16
|
+
export const appendFingerprint = appendRaceFingerprint
|
|
17
|
+
export const formatFingerprint = formatRaceFingerprint
|
|
18
|
+
|
|
19
|
+
export function generateSeed(): string {
|
|
20
|
+
const words = new Uint32Array(2)
|
|
21
|
+
crypto.getRandomValues(words)
|
|
22
|
+
return `${words[0]!.toString(16).padStart(8, '0')}-${words[1]!.toString(16).padStart(8, '0')}`
|
|
23
|
+
}
|