cosmo/frontend/src/components/CelestialBody.tsx

357 lines
11 KiB
TypeScript

/**
* CelestialBody component - renders a planet or probe with textures
*/
import { useRef, useMemo, useState, useEffect } from 'react';
import { Mesh, DoubleSide } from 'three'; // Removed AdditiveBlending here
import * as THREE from 'three'; // Imported as * to access AdditiveBlending, SpriteMaterial, CanvasTexture
import { useFrame } from '@react-three/fiber';
import { useTexture, Html } from '@react-three/drei';
import type { CelestialBody as CelestialBodyType } from '../types';
import { calculateRenderPosition, getOffsetDescription } from '../utils/renderPosition';
import { fetchBodyResources } from '../utils/api';
import { PLANET_SIZES, SATELLITE_SIZES, getCelestialSize } from '../config/celestialSizes';
interface CelestialBodyProps {
body: CelestialBodyType;
allBodies: CelestialBodyType[];
isSelected?: boolean;
}
// Saturn Rings component - multiple rings for band effect
function SaturnRings() {
return (
<group rotation={[Math.PI / 2, 0, 0]}>
{/* Inner bright ring */}
<mesh>
<ringGeometry args={[1.4, 1.6, 64]} />
<meshBasicMaterial
color="#D4B896"
transparent
opacity={0.7}
side={DoubleSide}
/>
</mesh>
{/* Middle darker band */}
<mesh>
<ringGeometry args={[1.6, 1.75, 64]} />
<meshBasicMaterial
color="#8B7355"
transparent
opacity={0.5}
side={DoubleSide}
/>
</mesh>
{/* Outer bright ring */}
<mesh>
<ringGeometry args={[1.75, 2.0, 64]} />
<meshBasicMaterial
color="#C4A582"
transparent
opacity={0.6}
side={DoubleSide}
/>
</mesh>
{/* Cassini Division (gap) */}
<mesh>
<ringGeometry args={[2.0, 2.05, 64]} />
<meshBasicMaterial
color="#000000"
transparent
opacity={0.2}
side={DoubleSide}
/>
</mesh>
{/* A Ring (outer) */}
<mesh>
<ringGeometry args={[2.05, 2.2, 64]} />
<meshBasicMaterial
color="#B89968"
transparent
opacity={0.5}
side={DoubleSide}
/>
</mesh>
</group>
);
}
// Planet component with texture
function Planet({ body, size, emissive, emissiveIntensity, allBodies, isSelected = false }: {
body: CelestialBodyType;
size: number;
emissive: string;
emissiveIntensity: number;
allBodies: CelestialBodyType[];
isSelected?: boolean;
}) {
const meshRef = useRef<Mesh>(null);
const position = body.positions[0];
const [texturePath, setTexturePath] = useState<string | null | undefined>(undefined);
// Use smart render position calculation
const renderPosition = useMemo(() => {
return calculateRenderPosition(body, allBodies);
}, [position.x, position.y, position.z, body, allBodies]);
const scaledPos = { x: renderPosition.x, y: renderPosition.y, z: renderPosition.z };
// Fetch texture from backend API
useEffect(() => {
fetchBodyResources(body.id, 'texture')
.then((response) => {
// Find the main texture (not atmosphere or night layers)
const mainTexture = response.resources.find(
(r) => !r.file_path.includes('atmosphere') && !r.file_path.includes('night')
);
if (mainTexture) {
// Construct full URL from file_path
// file_path is like "texture/2k_sun.jpg", need to add "upload/" prefix
const protocol = window.location.protocol;
const hostname = window.location.hostname;
const port = import.meta.env.VITE_API_BASE_URL ? '' : ':8000';
setTexturePath(`${protocol}//${hostname}${port}/upload/${mainTexture.file_path}`);
} else {
setTexturePath(null);
}
})
.catch((err) => {
console.error(`Failed to load texture for ${body.name}:`, err);
setTexturePath(null);
});
}, [body.id, body.name]);
// Show nothing while loading
if (texturePath === undefined) {
return null;
}
return <PlanetMesh
body={body}
size={size}
emissive={emissive}
emissiveIntensity={emissiveIntensity}
scaledPos={scaledPos}
texturePath={texturePath}
position={position}
meshRef={meshRef}
hasOffset={renderPosition.hasOffset}
allBodies={allBodies}
isSelected={isSelected}
/>;
}
// Comet Particles Component
function CometParticles({ radius, count = 6, color = '#88ccff' }: { radius: number; count?: number; color?: string }) {
const positions = useMemo(() => {
const p = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
// Random spherical distribution
const r = radius * (1.2 + Math.random() * 2.0); // Spread: 1.2x to 3.2x radius
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
p[i * 3] = r * Math.sin(phi) * Math.cos(theta);
p[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
p[i * 3 + 2] = r * Math.cos(phi);
}
return p;
}, [radius, count]);
// Ref for animation
const pointsRef = useRef<THREE.Points>(null);
useFrame((_, delta) => {
if (pointsRef.current) {
// Subtle rotation
pointsRef.current.rotation.y += delta * 0.1;
pointsRef.current.rotation.z += delta * 0.05;
}
});
return (
<points ref={pointsRef}>
<bufferGeometry>
<bufferAttribute attach="position" args={[positions, 3]} />
</bufferGeometry>
<pointsMaterial
size={radius * 0.4} // Particle size relative to comet size
color={color}
transparent
opacity={0.6}
sizeAttenuation={true}
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
</points>
);
}
// Separate component to handle texture loading
function PlanetMesh({ body, size, emissive, emissiveIntensity, scaledPos, texturePath, position, meshRef, hasOffset, allBodies, isSelected = false }: {
body: CelestialBodyType;
size: number;
emissive: string;
emissiveIntensity: number;
scaledPos: { x: number; y: number; z: number };
texturePath: string | null;
position: { x: number; y: number; z: number };
meshRef: React.RefObject<Mesh>;
hasOffset: boolean;
allBodies: CelestialBodyType[];
isSelected?: boolean;
}) {
// Load texture if path is provided
const texture = texturePath ? useTexture(texturePath) : null;
// Slow rotation for visual effect
useFrame((_, delta) => {
if (meshRef.current) {
meshRef.current.rotation.y += delta * 0.1;
}
});
// Calculate ACTUAL distance from Sun for display (not scaled)
const distance = Math.sqrt(position.x ** 2 + position.y ** 2 + position.z ** 2);
// Get offset description if this body has one
const offsetDesc = hasOffset ? getOffsetDescription(body, allBodies) : null;
return (
<group position={[scaledPos.x, scaledPos.z, scaledPos.y]}>
<mesh ref={meshRef} renderOrder={0}>
<sphereGeometry args={[size, 64, 64]} />
{texture ? (
<meshStandardMaterial
map={texture}
emissive={emissive}
emissiveIntensity={emissiveIntensity}
roughness={body.type === 'star' ? 0 : 0.7}
metalness={0.1}
depthTest={true}
depthWrite={true}
/>
) : (
<meshStandardMaterial
color="#888888"
emissive={emissive}
emissiveIntensity={emissiveIntensity}
roughness={0.7}
metalness={0.1}
depthTest={true}
depthWrite={true}
/>
)}
</mesh>
{/* Saturn Rings */}
{body.id === '699' && <SaturnRings />}
{/* Comet Particles */}
{body.type === 'comet' && (
<CometParticles radius={size} count={6} />
)}
{/* Sun glow effect */}
{body.type === 'star' && (
<>
<pointLight intensity={10} distance={400} color="#fff8e7" />
<mesh>
<sphereGeometry args={[size * 1.8, 32, 32]} />
<meshBasicMaterial color="#FDB813" transparent opacity={0.35} />
</mesh>
</>
)}
{/* Name label */}
<Html
position={[0, size + 0.3, 0]}
center
distanceFactor={10}
style={{
color: body.type === 'star' ? '#FDB813' : (body.type === 'comet' ? '#88ccff' : '#ffffff'),
fontSize: '9px', // 从 11px 减小到 9px
fontWeight: 'bold',
textShadow: '0 0 4px rgba(0,0,0,0.8)',
pointerEvents: 'none',
userSelect: 'none',
whiteSpace: 'nowrap',
opacity: isSelected ? 1 : 0.4, // 未选中时更暗淡
transition: 'opacity 0.3s ease',
}}
>
{body.name_zh || body.name}
{offsetDesc && (
<>
<br />
<span style={{ fontSize: '7px', color: '#ffaa00', opacity: isSelected ? 0.9 : 0.5 }}> {/* 从 9px 减小到 7px */}
{offsetDesc}
</span>
</>
)}
<br />
<span style={{ fontSize: '7px', opacity: isSelected ? 0.7 : 0.3 }}> {/* 从 8px 减小到 7px */}
{distance.toFixed(2)} AU
</span>
</Html>
</group>
);
}
export function CelestialBody({ body, allBodies, isSelected = false }: CelestialBodyProps) {
// Get the current position (use the first position for now)
const position = body.positions[0];
if (!position) return null;
// Skip probes - they will use 3D models
if (body.type === 'probe') {
return null;
}
// Determine size based on body type
const appearance = useMemo(() => {
if (body.type === 'star') {
return {
size: 0.4, // Sun size
emissive: '#FDB813',
emissiveIntensity: 1.5,
};
}
// Comet - bright core with glow
if (body.type === 'comet') {
return {
size: getCelestialSize(body.name, body.type),
emissive: '#000000', // Revert to no special emissive color for texture
emissiveIntensity: 0, // Revert to no special emissive intensity
};
}
// Satellite (natural moons) - small size with slight glow for visibility
if (body.type === 'satellite') {
return {
size: getCelestialSize(body.name, body.type),
emissive: '#888888', // Slight glow to make it visible
emissiveIntensity: 0.4,
};
}
// Planet and dwarf planet sizes
return {
size: getCelestialSize(body.name, body.type),
emissive: '#000000',
emissiveIntensity: 0,
};
}, [body.name, body.type]);
return (
<Planet
body={body}
size={appearance.size}
emissive={appearance.emissive}
emissiveIntensity={appearance.emissiveIntensity}
allBodies={allBodies}
isSelected={isSelected}
/>
);
}