1/**2 * The marble: a scalar `s` along the centerline plus a rolling visual.3 *4 * The kit's marble mesh is grounded like every other piece (origin = bottom5 * centre), and the centerline runs along the channel FLOOR — so the outer6 * object sits exactly on the curve point and an inner pivot lifts the mesh by7 * its radius. Roll is applied to the pivot, which keeps the contact point put.8 */9import * as THREE from "three";10import type { Assets } from "./assets.ts";11import type { FanField } from "./fanField.ts";12import { addMarbleCore, disposeMarbleOwnedVisuals } from "./marbleGlass.ts";1314const MARBLE_PIECE = "marble-center-high";15const BASE_SPEED = 2.2;16const MIN_SPEED = 1.2;17const MAX_SPEED = 7;18const GRAVITY = 6;19const DRAG = 0.9;2021export interface Marble {22 obj: THREE.Object3D;23 /** Distance travelled along the curve (wraps at curve length). */24 s: number;25 speed: number;26 radius: number;27 /** True only while a trackside boost is actively accelerating it. */28 boosting: boolean;29 update(dt: number): void;30 dispose(): void;31}3233export function createMarble(34 assets: Assets,35 curve: THREE.Curve<THREE.Vector3>,36 /** Alternate-lap variant (a diamond's other branch); laps alternate. */37 altCurve?: THREE.Curve<THREE.Vector3>,38 /** Trackside fan boosts: accelerate while the marble is within range. */39 boosts: { pos: [number, number, number]; strength: number }[] = [],40 /** Bridge holes: the marble may drop through onto the channel below —41 * deterministic per lap, so replays of a seed are identical. */42 fallPoints: {43 pos: [number, number, number];44 landPos: [number, number, number];45 /** Takeable spur drains always drop (end-hole law). */46 guaranteed?: boolean;47 }[] = [],48 /** M19a loop backport: the endless wind-field law over the loop's own49 * fans. The kinematic marble is curve-locked, so wind resolves into50 * its two honest effects: the tangential component works the speed,51 * the lateral component sways the marble inside the channel (a52 * spring-damped visual offset, clamped well inside the walls). */53 windField?: FanField,54): Marble {55 const obj = new THREE.Group();56 obj.name = "marble";5758 const pivot = new THREE.Group();59 obj.add(pivot);6061 const mesh = assets.getPiece(MARBLE_PIECE);62 let radius = 0.1;63 if (mesh) {64 // Half the kit's authored size (user: "shrink the ball 50%").65 mesh.scale.setScalar(0.5);66 // The kit shell is empty glass — inject the opaque core so the67 // marble reads solid (see marbleGlass.marbleCoreMesh).68 addMarbleCore(mesh);69 // Recenter the grounded mesh inside the pivot, then lift the pivot by the70 // same offset so the sphere's contact point stays at the object's origin.71 const box = new THREE.Box3().setFromObject(mesh);72 const center = box.getCenter(new THREE.Vector3());73 const size = box.getSize(new THREE.Vector3());74 radius = Math.max(size.x, size.y, size.z) / 2 || 0.2;75 pivot.position.copy(center);76 mesh.position.sub(center);77 pivot.add(mesh);78 } else {79 console.warn(80 `[marble] missing piece "${MARBLE_PIECE}" — using a fallback sphere`,81 );82 const fallback = new THREE.Mesh(83 new THREE.SphereGeometry(radius, 24, 16),84 new THREE.MeshStandardMaterial({85 color: 0x4fc3f7,86 roughness: 0.15,87 metalness: 0.1,88 }),89 );90 fallback.userData.marbleOwnedVisual = true;91 fallback.castShadow = true;92 pivot.position.y = radius;93 pivot.add(fallback);94 }9596 // Laps alternate between the two curve variants (diamond branches).97 const variants = altCurve ? [curve, altCurve] : [curve];98 let lap = 0;99 const active = () => variants[lap % variants.length];100101 // Map each bridge hole to arc positions on every variant: the hole s102 // (where the drop can trigger) and the landing s on the channel below.103 // Dense uniform sampling is fine — both points are ≥0.5 apart in y from104 // any other part of the loop passing that cell.105 const closestS = (106 c: THREE.Curve<THREE.Vector3>,107 p: [number, number, number],108 ): { s: number; dist: number } => {109 const total = c.getLength();110 const probe = new THREE.Vector3();111 let best = 0;112 let bestD = Infinity;113 const steps = 600;114 for (let i = 0; i < steps; i++) {115 c.getPointAt(i / steps, probe);116 const d = probe.distanceToSquared(new THREE.Vector3(...p));117 if (d < bestD) {118 bestD = d;119 best = (i / steps) * total;120 }121 }122 return { s: best, dist: Math.sqrt(bestD) };123 };124 const falls = variants.map((c) =>125 fallPoints.map((f) => {126 const hole = closestS(c, f.pos);127 const land = closestS(c, f.landPos);128 return {129 holeS: hole.s,130 landS: land.s,131 dropY: f.pos[1] - f.landPos[1],132 // A hole is live only on variants that actually PASS it — a133 // takeable spur's hole lies on the branch variant alone; mapping134 // it onto the main line would fire a phantom drop at the nearest135 // unrelated point.136 active: hole.dist < 0.5,137 guaranteed: f.guaranteed === true,138 };139 })140 );141 // Falling state: eased vertical drop from the hole to the landing.142 let falling:143 | { t: number; from: THREE.Vector3; toS: number; drop: number }144 | null = null;145 const FALL_TIME = 0.35;146 /** Deterministic per (lap, hole): drops on roughly half the passes. */147 const takesFall = (lapN: number, holeI: number): boolean =>148 ((lapN * 2654435761 + holeI * 40503 + 12345) >>> 0) % 100 < 50;149150 const up = new THREE.Vector3(0, 1, 0);151 const point = new THREE.Vector3();152 const tangent = new THREE.Vector3();153 const axis = new THREE.Vector3();154 const spin = new THREE.Quaternion();155 const lateral = new THREE.Vector3();156157 // Wind sway state: lateral offset inside the channel, spring-damped.158 // ±0.15 keeps the (0.2-radius) marble clear of the 0.5-half-width walls.159 const SWAY_MAX = 0.15;160 const SWAY_K = 18; // spring toward the centerline161 const SWAY_D = 6; // damping162 let sway = 0;163 let swayV = 0;164 const windAccel: [number, number, number] = [0, 0, 0];165166 const marble: Marble = {167 obj,168 s: 0,169 speed: BASE_SPEED,170 radius,171 boosting: false,172 dispose(): void {173 disposeMarbleOwnedVisuals(obj);174 },175 update(dt: number) {176 const total = active().getLength();177 if (total <= 0 || dt <= 0) return;178 // Boost is an acceleration state, not a lingering appearance. Clear it179 // before any early-returning fall path so a bridge drop cannot draw a180 // hot vertical streak after the marble has left the fan.181 marble.boosting = false;182183 // Mid-fall: eased vertical drop, then resume on the lower channel.184 if (falling) {185 falling.t += dt / FALL_TIME;186 if (falling.t >= 1) {187 marble.s = falling.toS;188 falling = null;189 } else {190 const k = falling.t * falling.t; // accelerate downward191 obj.position.set(192 falling.from.x,193 falling.from.y - k * falling.drop,194 falling.from.z,195 );196 return;197 }198 }199200 const u = ((marble.s % total) + total) % total / total;201 active().getTangentAt(u, tangent);202203 // Gravity-flavoured: downhill accelerates, uphill and drag bleed speed.204 marble.speed +=205 (-tangent.y * GRAVITY - DRAG * (marble.speed - BASE_SPEED)) * dt;206 // Trackside fans shove the marble along while it's in front of them.207 for (const b of boosts) {208 const dx = obj.position.x - b.pos[0];209 const dz = obj.position.z - b.pos[2];210 if (dx * dx + dz * dz < 1.2 * 1.2) {211 marble.speed += b.strength * dt;212 marble.boosting = true;213 }214 }215 // Wind (M19a): tangential component works the speed; lateral216 // component drives the in-channel sway spring.217 let latAccel = 0;218 if (windField) {219 windAccel[0] = 0;220 windAccel[1] = 0;221 windAccel[2] = 0;222 if (223 windField.forceAt(224 [obj.position.x, obj.position.y, obj.position.z],225 1,226 windAccel,227 )228 ) {229 marble.speed += (windAccel[0] * tangent.x +230 windAccel[1] * tangent.y +231 windAccel[2] * tangent.z) * dt;232 lateral.copy(up).cross(tangent);233 if (lateral.lengthSq() > 1e-8) {234 lateral.normalize();235 latAccel = windAccel[0] * lateral.x + windAccel[2] * lateral.z;236 }237 }238 }239 swayV += (latAccel - SWAY_K * sway - SWAY_D * swayV) * dt;240 sway = Math.max(-SWAY_MAX, Math.min(SWAY_MAX, sway + swayV * dt));241 marble.speed = Math.min(MAX_SPEED, Math.max(MIN_SPEED, marble.speed));242243 const prevS = marble.s;244 const next = marble.s + marble.speed * dt;245 if (next >= total) lap++;246 marble.s = next % total;247248 // Bridge holes: on a taking lap, crossing the hole s starts the drop.249 // Spur drains (guaranteed) always take; inactive holes (not on this250 // variant) never do.251 for (const [i, f] of falls[lap % variants.length].entries()) {252 if (!f.active) continue;253 const crossed = prevS < f.holeS && next >= f.holeS;254 if (crossed && (f.guaranteed || takesFall(lap, i))) {255 active().getPointAt(f.holeS / total, point);256 falling = {257 t: 0,258 from: point.clone(),259 toS: f.landS,260 drop: f.dropY,261 };262 obj.position.copy(point);263 return;264 }265 }266267 active().getPointAt(marble.s / total, point);268 obj.position.copy(point);269 if (sway !== 0) {270 lateral.copy(up).cross(tangent);271 if (lateral.lengthSq() > 1e-8) {272 obj.position.addScaledVector(lateral.normalize(), sway);273 }274 }275276 // Roll: spin about the horizontal axis perpendicular to travel.277 axis.copy(up).cross(tangent);278 if (axis.lengthSq() > 1e-8) {279 axis.normalize();280 spin.setFromAxisAngle(axis, (marble.speed * dt) / radius);281 pivot.quaternion.premultiply(spin);282 }283 },284 };285286 // Seed the initial pose so frame 0 renders the marble on the track.287 curve.getPointAt(0, point);288 obj.position.copy(point);289290 return marble;291}