import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import { Canvas, useFrame } from '@react-three/fiber'; import { OrbitControls, Stars } from '@react-three/drei'; import * as THREE from 'three'; import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib'; import type { RocketConfig, SimulationState } from './types'; import { useOptionalTexture } from '../../hooks/useOptionalTexture'; import { RocketModel } from './RocketModel'; import { clamp, fogColor, groundDrop, separationAge, skyColor, starOpacity } from './sceneMath'; export type CameraMode = 'follow' | 'global'; interface RocketFlightSceneProps { rocket: RocketConfig; state: SimulationState; cameraMode: CameraMode; viewScale: number; viewScaleResetTrigger: number; onViewScaleChange: (scale: number) => void; } const EARTH_RADIUS_METERS = 6_371_000; const EARTH_RADIUS = 34; const EARTH_TEXTURE = '/upload/texture/2k_earth_daymap.jpg'; const EARTH_CENTER = new THREE.Vector3(0, -EARTH_RADIUS, 0); const EARTH_ROTATION = new THREE.Euler(0, -1.25, -0.08); const VIEW_SCALE_CALIBRATION = 0.7; interface LaunchSite { latitude: number; longitude: number; } interface ManualCameraRef { current: boolean; } function launchSiteForRocket(rocket: RocketConfig) { return { latitude: rocket.launch_latitude_deg, longitude: rocket.launch_longitude_deg, }; } function orbitLift(targetAltitude: number) { return clamp(targetAltitude / 200_000 * 11, 8, 18); } function globalFlightFrame(altitude: number, downrange: number, targetAltitude: number, site: LaunchSite) { const radius = EARTH_RADIUS + clamp(altitude / targetAltitude, 0, 1.45) * orbitLift(targetAltitude); const angle = downrange / EARTH_RADIUS_METERS; const latitude = THREE.MathUtils.degToRad(site.latitude); const longitude = THREE.MathUtils.degToRad(site.longitude); const launchNormal = new THREE.Vector3( Math.cos(latitude) * Math.cos(longitude), Math.sin(latitude), -Math.cos(latitude) * Math.sin(longitude), ); const east = new THREE.Vector3(-Math.sin(longitude), 0, -Math.cos(longitude)); const normal = launchNormal.multiplyScalar(Math.cos(angle)).addScaledVector(east, Math.sin(angle)).applyEuler(EARTH_ROTATION); const tangent = east.multiplyScalar(Math.cos(angle)).addScaledVector( new THREE.Vector3( Math.cos(latitude) * Math.cos(longitude), Math.sin(latitude), -Math.cos(latitude) * Math.sin(longitude), ), -Math.sin(angle), ).applyEuler(EARTH_ROTATION); return { normal, tangent, position: EARTH_CENTER.clone().addScaledVector(normal, radius), }; } /** 发射场:混凝土地坪 + 导流槽 + 发射台 + 服务塔 + 避雷塔。 */ function LaunchPadStructure() { const towerHeight = 13; return ( {/* 混凝土地坪 */} {/* 尾焰熏黑区域 */} {/* 发射台 + 导流槽 */} {/* 导流锥 */} {/* 压紧机构 */} {[[-2.6, -2.6], [2.6, -2.6], [-2.6, 2.6], [2.6, 2.6]].map(([x, z]) => ( ))} {/* 服务塔 */} {[-1.2, 1.2].map((offset) => ( ))} {[3.2, 6.4, 9.6, 12.2].map((y) => ( ))} {/* 摆杆 / 加注臂 */} {[5.4, 9.4].map((y, index) => ( ))} {/* 塔顶工作平台(避雷针只装在四周的避雷塔上) */} {[-1.55, 1.55].map((offset) => ( ))} {/* 避雷塔(四根柱子顶部的避雷针) */} {[[-14, -9], [14, -9], [-14, 9], [14, 9]].map(([x, z]) => ( ))} {/* 场坪编号,让地坪有尺度参照 */} ); } /** * 发射瞬间的蒸汽/烟雾:点火后从发射台底部翻涌扩散, * 随飞行高度升高逐渐淡出(离地后不再有地面烟雾)。 */ function PadExhaust({ state }: { state: SimulationState }) { const groupRef = useRef(null); const puffs = useMemo( () => Array.from({ length: 14 }, (_, index) => ({ angle: index * 2.399, speed: 0.5 + (index % 5) * 0.09, delay: (index % 7) / 7, })), [], ); useFrame(({ clock }) => { if (!groupRef.current) return; const burning = state.isRunning && state.throttle > 0.05 && state.altitude < 4_000; groupRef.current.visible = burning; if (!burning) return; const time = clock.elapsedTime * 0.5; groupRef.current.children.forEach((child, index) => { const puff = puffs[index]; const progress = (time * puff.speed + puff.delay) % 1; const spread = 3 + progress * 26; child.position.set( Math.cos(puff.angle) * spread, progress * 9, Math.sin(puff.angle) * spread, ); child.scale.setScalar(1.4 + progress * 4.2); const material = (child as THREE.Mesh).material as THREE.MeshBasicMaterial; material.opacity = (1 - progress) * 0.24; }); }); return ( {puffs.map((_, index) => ( ))} ); } /** 地面场景容器:随飞行高度下移、随射程后移。 */ function LaunchRack({ state }: { state: SimulationState }) { const groupRef = useRef(null); useFrame(() => { if (groupRef.current) { groupRef.current.position.y = -groundDrop(state.altitude); // Slide sideways with downrange travel so the pad falls behind. groupRef.current.position.x = -clamp(state.downrange / 4000, 0, 60); } }); return ( ); } function EarthSurface({ texture, state }: { texture: THREE.Texture | null; state?: SimulationState }) { const highAltitude = state ? state.altitude >= 9_000 : true; if (!highAltitude) return null; const gap = state ? 7 + clamp((state.altitude - 9_000) / 91_000, 0, 1) * 5 : 0; const radius = state ? 62 : EARTH_RADIUS; const centerY = state ? -radius - gap : -EARTH_RADIUS; return ( ); } function TargetOrbit({ targetAltitude, launchSite }: { targetAltitude: number; launchSite: LaunchSite }) { const orbit = useMemo(() => { const radius = EARTH_RADIUS + orbitLift(targetAltitude); const points = Array.from({ length: 181 }, (_, index) => { const angle = index / 180 * Math.PI * 2; return globalFlightFrame(0, angle * EARTH_RADIUS_METERS, targetAltitude, launchSite) .normal.multiplyScalar(radius).add(EARTH_CENTER); }); const geometry = new THREE.BufferGeometry().setFromPoints(points); const material = new THREE.LineDashedMaterial({ color: '#8a9496', dashSize: 1.2, gapSize: 0.75, transparent: true, opacity: 0.65 }); const line = new THREE.LineLoop(geometry, material); line.computeLineDistances(); return line; }, [launchSite, targetAltitude]); useEffect(() => () => { orbit.geometry.dispose(); (orbit.material as THREE.Material).dispose(); }, [orbit]); return ; } function FlightPath({ state, targetAltitude, launchSite }: { state: SimulationState; targetAltitude: number; launchSite: LaunchSite }) { const line = useMemo(() => new THREE.Line( new THREE.BufferGeometry(), new THREE.LineBasicMaterial({ color: '#59c78f', transparent: true, opacity: 0.95 }), ), []); const lineRef = useRef(null); useLayoutEffect(() => { const activeLine = lineRef.current; if (!activeLine) return; const points = [globalFlightFrame(0, 0, targetAltitude, launchSite).position]; state.history.forEach((point) => { points.push(globalFlightFrame(point.altitude, point.downrange, targetAltitude, launchSite).position); }); points.push(globalFlightFrame(state.altitude, state.downrange, targetAltitude, launchSite).position); const previousGeometry = activeLine.geometry; activeLine.geometry = new THREE.BufferGeometry().setFromPoints(points); previousGeometry.dispose(); }, [launchSite, state.altitude, state.downrange, state.history, targetAltitude]); useEffect(() => () => { lineRef.current?.geometry.dispose(); (line.material as THREE.Material).dispose(); }, [line]); return ; } function GlobalEarthView({ rocket, state, texture }: { rocket: RocketConfig; state: SimulationState; texture: THREE.Texture | null }) { const targetAltitude = rocket.target_orbit_km * 1000; const launchSite = launchSiteForRocket(rocket); const frame = globalFlightFrame(state.altitude, state.downrange, targetAltitude, launchSite); const launchFrame = globalFlightFrame(0, 0, targetAltitude, launchSite); const pitchFromVertical = THREE.MathUtils.degToRad(90 - state.pitch); const direction = frame.normal.clone().multiplyScalar(Math.cos(pitchFromVertical)) .addScaledVector(frame.tangent, Math.sin(pitchFromVertical)).normalize(); const orientation = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction); const markerPosition = launchFrame.position.clone().addScaledVector(launchFrame.normal, 0.25); return ( <> ); } function FollowVehicle({ rocket, state }: { rocket: RocketConfig; state: SimulationState }) { const pitchFromVertical = THREE.MathUtils.degToRad(90 - state.pitch); return ( ); } /** Expanding shockwave ring shown briefly at stage separation. */ function SeparationBurst({ state }: { state: SimulationState }) { const ringRef = useRef(null); const age = separationAge(state); useFrame(() => { if (!ringRef.current) return; const visible = age !== null && age < 1.1; ringRef.current.visible = visible; if (visible && age !== null) { const scale = 1 + age * 9; ringRef.current.scale.set(scale, scale, scale); const mat = ringRef.current.material as THREE.MeshBasicMaterial; mat.opacity = clamp(1 - age / 1.1, 0, 1) * 0.7; } }); return ( ); } const _camPos = new THREE.Vector3(); const _lookAt = new THREE.Vector3(); /** * Two webcast-style camera modes: * - follow: tight tracking shot that stays close to the vehicle, gently * pulling back with altitude (like an onboard/tracking-dish view). * - global: wide cinematic establishing shot that pulls far back so the * receding pad, downrange arc and darkening sky are all in frame. */ function FlightCamera({ rocket, state, mode, viewScale, viewScaleResetTrigger, manualCameraRef }: { rocket: RocketConfig; state: SimulationState; mode: CameraMode; viewScale: number; viewScaleResetTrigger: number; manualCameraRef: ManualCameraRef }) { useEffect(() => { manualCameraRef.current = false; }, [manualCameraRef, mode, rocket.code, viewScaleResetTrigger]); useFrame(({ camera, size }, delta) => { if (manualCameraRef.current) return; const perspectiveCamera = camera as THREE.PerspectiveCamera; if (mode === 'follow') { const zoom = VIEW_SCALE_CALIBRATION * clamp(viewScale, 50, 250) / 100; const portraitScale = size.width / size.height < 0.8 ? 1.55 : 1; const pullback = (22 + clamp(state.altitude / 4000, 0, 14)) / zoom * portraitScale; const height = (7 + clamp(state.altitude / 7000, 0, 7)) / Math.sqrt(zoom); const pitchFromVertical = THREE.MathUtils.degToRad(90 - state.pitch); _camPos.set(pullback * 0.32, height, pullback); _lookAt.set( Math.sin(pitchFromVertical) * 5.2, Math.cos(pitchFromVertical) * 5.2, 0, ); perspectiveCamera.fov = THREE.MathUtils.lerp(perspectiveCamera.fov, 42, 0.08); } else { const portrait = size.width / size.height < 0.8; const zoom = clamp(viewScale, 50, 250) / 100; const targetAltitude = rocket.target_orbit_km * 1000; const launchFrame = globalFlightFrame(0, 0, targetAltitude, launchSiteForRocket(rocket)); const viewDirection = launchFrame.normal.clone() .addScaledVector(launchFrame.tangent, -0.72) .add(new THREE.Vector3(0, 0.16, 0)) .normalize(); _camPos.copy(EARTH_CENTER).addScaledVector(viewDirection, (portrait ? 320 : 165) / zoom); _lookAt.copy(EARTH_CENTER); perspectiveCamera.fov = THREE.MathUtils.lerp(perspectiveCamera.fov, 38, 0.08); } // Frame-rate independent smoothing. const t = 1 - Math.pow(0.001, delta); camera.position.lerp(_camPos, t * 0.9); camera.lookAt(_lookAt); perspectiveCamera.updateProjectionMatrix(); }); return null; } /** Sky + fog that shift color with altitude to sell the climb into space. */ function Atmosphere({ state, mode }: { state: SimulationState; mode: CameraMode }) { useFrame(({ scene }) => { if (mode === 'global') { scene.background = new THREE.Color('#020408'); scene.fog = null; return; } const sky = new THREE.Color(skyColor(state.altitude)); scene.background = sky; if (!scene.fog) scene.fog = new THREE.Fog(sky, 40, 260); const fog = scene.fog as THREE.Fog; fog.color.set(fogColor(state.altitude)); // Thin the fog out with altitude so orbit reads crisp and dark. const density = clamp(1 - state.altitude / 45_000, 0, 1); fog.near = 40 + (1 - density) * 400; fog.far = 260 + (1 - density) * 1400; }); return null; } function SceneContents({ rocket, state, cameraMode, viewScale, viewScaleResetTrigger, onViewScaleChange }: RocketFlightSceneProps) { const earthTexture = useOptionalTexture(EARTH_TEXTURE); const manualCameraRef = useRef(false); const orbitControlsRef = useRef(null); const manualDistanceRef = useRef(null); const viewScaleRef = useRef(viewScale); useEffect(() => { viewScaleRef.current = viewScale; }, [viewScale]); const controlTarget = useMemo<[number, number, number]>( () => cameraMode === 'follow' ? [0, 5.2, 0] : [EARTH_CENTER.x, EARTH_CENTER.y, EARTH_CENTER.z], [cameraMode], ); return ( <> { manualCameraRef.current = true; manualDistanceRef.current = orbitControlsRef.current?.getDistance() ?? null; }} onChange={() => { if (!manualCameraRef.current || manualDistanceRef.current === null || !orbitControlsRef.current) return; const distance = orbitControlsRef.current.getDistance(); if (distance <= 0) return; const nextScale = Math.round(clamp(viewScaleRef.current * manualDistanceRef.current / distance, 50, 250) / 5) * 5; manualDistanceRef.current = distance; if (nextScale !== viewScaleRef.current) { viewScaleRef.current = nextScale; onViewScaleChange(nextScale); } }} /> 0.02}> {cameraMode === 'follow' ? ( <> ) : ( )} ); } export function RocketFlightScene({ rocket, state, cameraMode, viewScale, viewScaleResetTrigger, onViewScaleChange }: RocketFlightSceneProps) { const initialSky = useMemo(() => skyColor(0), []); return (
{ scene.background = new THREE.Color(initialSky); camera.lookAt(0, 5.2, 0); }} >
); }