2025-11-27 05:16:19 +00:00
|
|
|
/**
|
|
|
|
|
* CameraController - handles camera movement and focus
|
|
|
|
|
*/
|
|
|
|
|
import { useEffect, useRef } from 'react';
|
|
|
|
|
import { useFrame, useThree } from '@react-three/fiber';
|
|
|
|
|
import { Vector3 } from 'three';
|
|
|
|
|
import type { CelestialBody } from '../types';
|
2025-11-29 15:10:00 +00:00
|
|
|
import { calculateRenderPosition, findParentPlanet } from '../utils/renderPosition';
|
2025-11-27 05:16:19 +00:00
|
|
|
|
|
|
|
|
interface CameraControllerProps {
|
|
|
|
|
focusTarget: CelestialBody | null;
|
2025-11-29 15:10:00 +00:00
|
|
|
allBodies: CelestialBody[];
|
2025-11-27 05:16:19 +00:00
|
|
|
onAnimationComplete?: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-29 15:10:00 +00:00
|
|
|
export function CameraController({ focusTarget, allBodies, onAnimationComplete }: CameraControllerProps) {
|
2025-11-27 05:16:19 +00:00
|
|
|
const { camera } = useThree();
|
|
|
|
|
const targetPosition = useRef(new Vector3());
|
|
|
|
|
const isAnimating = useRef(false);
|
|
|
|
|
const animationProgress = useRef(0);
|
|
|
|
|
const startPosition = useRef(new Vector3());
|
2025-11-29 16:58:58 +00:00
|
|
|
|
|
|
|
|
// Track previous focus to detect changes vs updates
|
|
|
|
|
const lastFocusId = useRef<string | null>(null);
|
|
|
|
|
const lastTargetPos = useRef<Vector3 | null>(null);
|
2025-11-27 05:16:19 +00:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (focusTarget) {
|
2025-11-29 15:10:00 +00:00
|
|
|
// Focus on target - use smart rendered position
|
|
|
|
|
const renderPos = calculateRenderPosition(focusTarget, allBodies);
|
2025-11-29 16:58:58 +00:00
|
|
|
const currentTargetPos = new Vector3(renderPos.x, renderPos.z, renderPos.y); // Note: Y/Z swap in rendering
|
|
|
|
|
|
|
|
|
|
// Check if we are just updating positions for the same body
|
|
|
|
|
if (lastFocusId.current === focusTarget.id && lastTargetPos.current) {
|
|
|
|
|
// Same body, position changed (timeline playing)
|
|
|
|
|
// Move camera by the same delta to maintain relative view
|
|
|
|
|
const delta = new Vector3().subVectors(currentTargetPos, lastTargetPos.current);
|
|
|
|
|
camera.position.add(delta);
|
|
|
|
|
|
|
|
|
|
// Update tracking ref
|
|
|
|
|
lastTargetPos.current.copy(currentTargetPos);
|
|
|
|
|
return; // Skip animation and reset logic
|
|
|
|
|
}
|
2025-11-29 15:10:00 +00:00
|
|
|
|
2025-11-29 16:58:58 +00:00
|
|
|
// New target or first load: Calculate ideal camera position
|
|
|
|
|
const pos = focusTarget.positions[0];
|
2025-11-27 05:16:19 +00:00
|
|
|
const distance = Math.sqrt(pos.x ** 2 + pos.y ** 2 + pos.z ** 2);
|
2025-11-29 15:10:00 +00:00
|
|
|
const parentInfo = findParentPlanet(focusTarget, allBodies);
|
2025-11-27 05:16:19 +00:00
|
|
|
|
2025-11-29 15:10:00 +00:00
|
|
|
// Calculate camera position based on target type and context
|
2025-11-27 05:16:19 +00:00
|
|
|
let offset: number;
|
|
|
|
|
let heightMultiplier = 1; // For adjusting vertical position
|
|
|
|
|
let sideMultiplier = 1; // For adjusting horizontal offset
|
|
|
|
|
|
|
|
|
|
if (focusTarget.type === 'planet') {
|
|
|
|
|
// For planets, use closer view from above
|
|
|
|
|
offset = 4;
|
|
|
|
|
heightMultiplier = 1.5;
|
|
|
|
|
sideMultiplier = 1;
|
|
|
|
|
} else if (focusTarget.type === 'probe') {
|
2025-11-29 15:10:00 +00:00
|
|
|
// For probes, determine view based on context
|
|
|
|
|
if (parentInfo) {
|
|
|
|
|
// Probe near a planet - use closer view to see both probe and planet
|
|
|
|
|
offset = 3; // Closer view (was 5)
|
|
|
|
|
heightMultiplier = 0.8;
|
|
|
|
|
sideMultiplier = 1.2;
|
|
|
|
|
} else if (distance < 10) {
|
|
|
|
|
// Inner solar system probe (not near a planet)
|
|
|
|
|
offset = 5; // Closer view (was 8)
|
|
|
|
|
heightMultiplier = 0.6;
|
|
|
|
|
sideMultiplier = 1.5;
|
|
|
|
|
} else if (distance > 50) {
|
|
|
|
|
// Far probes (Voyagers, New Horizons) - need even closer view since they're so far
|
|
|
|
|
offset = 4; // Much closer (was 12)
|
|
|
|
|
heightMultiplier = 0.8;
|
2025-11-27 05:16:19 +00:00
|
|
|
sideMultiplier = 1;
|
|
|
|
|
} else {
|
|
|
|
|
// Medium distance probes
|
2025-11-29 15:10:00 +00:00
|
|
|
offset = 6; // Closer view (was 10)
|
|
|
|
|
heightMultiplier = 0.8;
|
|
|
|
|
sideMultiplier = 1.2;
|
2025-11-27 05:16:19 +00:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
offset = 10;
|
|
|
|
|
heightMultiplier = 1;
|
|
|
|
|
sideMultiplier = 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
targetPosition.current.set(
|
2025-11-29 16:58:58 +00:00
|
|
|
currentTargetPos.x + (offset * sideMultiplier),
|
|
|
|
|
currentTargetPos.y + (offset * heightMultiplier), // already swapped in currentTargetPos? No, currentTargetPos is X, Z, Y world coords.
|
|
|
|
|
// Wait, let's look at the original code logic carefully.
|
|
|
|
|
// Original: scaledPos.x + offset, scaledPos.z + offset, scaledPos.y + offset
|
|
|
|
|
// currentTargetPos is created as (renderPos.x, renderPos.z, renderPos.y).
|
|
|
|
|
// In Three.js scene: x=x, y=z(up), z=y(depth) usually?
|
|
|
|
|
// Let's verify Scene coordinate system.
|
|
|
|
|
// Scene.tsx: controlsTarget = [scaledPos.x, scaledPos.z, scaledPos.y]
|
|
|
|
|
// So Y is up in Threejs, but Z is Up in data?
|
|
|
|
|
// renderPos (from calculateRenderPosition) usually returns data coords.
|
|
|
|
|
// let's stick to the exact mapping used in the original code.
|
|
|
|
|
// Original:
|
|
|
|
|
// targetPosition.current.set(
|
|
|
|
|
// scaledPos.x + (offset * sideMultiplier),
|
|
|
|
|
// scaledPos.z + (offset * heightMultiplier),
|
|
|
|
|
// scaledPos.y + offset
|
|
|
|
|
// );
|
|
|
|
|
// So: X_cam = X_data + off
|
|
|
|
|
// Y_cam = Z_data + off <-- Y is up in ThreeJS, so Z_data is mapped to Y
|
|
|
|
|
// Z_cam = Y_data + off
|
|
|
|
|
currentTargetPos.y + (offset * heightMultiplier), // Z_data is in .y of currentTargetPos because we did new Vector3(x, z, y)
|
|
|
|
|
currentTargetPos.z + offset // Y_data is in .z of currentTargetPos
|
|
|
|
|
);
|
|
|
|
|
// Wait, my `currentTargetPos` construction `new Vector3(renderPos.x, renderPos.z, renderPos.y)`
|
|
|
|
|
// maps Data X->X, Data Z->Y, Data Y->Z.
|
|
|
|
|
// So currentTargetPos.x = Data X
|
|
|
|
|
// currentTargetPos.y = Data Z (Up)
|
|
|
|
|
// currentTargetPos.z = Data Y (Depth)
|
|
|
|
|
// This matches the Scene.tsx controlsTarget `[scaledPos.x, scaledPos.z, scaledPos.y]`.
|
|
|
|
|
|
|
|
|
|
// So the calculation should be relative to currentTargetPos:
|
|
|
|
|
targetPosition.current.set(
|
|
|
|
|
currentTargetPos.x + (offset * sideMultiplier),
|
|
|
|
|
currentTargetPos.y + (offset * heightMultiplier),
|
|
|
|
|
currentTargetPos.z + offset
|
2025-11-27 05:16:19 +00:00
|
|
|
);
|
|
|
|
|
|
2025-11-29 16:58:58 +00:00
|
|
|
// Update tracking refs
|
|
|
|
|
lastFocusId.current = focusTarget.id;
|
|
|
|
|
if (!lastTargetPos.current) {
|
|
|
|
|
lastTargetPos.current = new Vector3();
|
|
|
|
|
}
|
|
|
|
|
lastTargetPos.current.copy(currentTargetPos);
|
|
|
|
|
|
2025-11-27 05:16:19 +00:00
|
|
|
// Start animation
|
|
|
|
|
startPosition.current.copy(camera.position);
|
|
|
|
|
isAnimating.current = true;
|
|
|
|
|
animationProgress.current = 0;
|
|
|
|
|
} else {
|
2025-11-29 16:58:58 +00:00
|
|
|
// No target - stop any ongoing animation and reset tracking refs.
|
|
|
|
|
// Do NOT force camera to return to solar system overview.
|
|
|
|
|
// OrbitControls will maintain the current camera position.
|
|
|
|
|
if (isAnimating.current) { // If an animation was in progress, stop it.
|
|
|
|
|
isAnimating.current = false;
|
|
|
|
|
animationProgress.current = 0;
|
|
|
|
|
}
|
|
|
|
|
lastFocusId.current = null;
|
|
|
|
|
lastTargetPos.current = null;
|
2025-11-27 05:16:19 +00:00
|
|
|
}
|
2025-11-29 15:10:00 +00:00
|
|
|
}, [focusTarget, allBodies, camera]);
|
2025-11-27 05:16:19 +00:00
|
|
|
|
|
|
|
|
useFrame((_, delta) => {
|
|
|
|
|
if (isAnimating.current) {
|
|
|
|
|
// Smooth animation using easing
|
|
|
|
|
animationProgress.current += delta * 0.8; // Animation speed
|
|
|
|
|
|
|
|
|
|
if (animationProgress.current >= 1) {
|
|
|
|
|
animationProgress.current = 1;
|
|
|
|
|
isAnimating.current = false;
|
|
|
|
|
if (onAnimationComplete) onAnimationComplete();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Easing function (ease-in-out)
|
|
|
|
|
const t = animationProgress.current;
|
|
|
|
|
const eased = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
|
|
|
|
|
|
|
|
|
// Interpolate camera position
|
|
|
|
|
camera.position.lerpVectors(startPosition.current, targetPosition.current, eased);
|
|
|
|
|
|
2025-11-29 15:10:00 +00:00
|
|
|
// Look at target - use smart rendered position (only during animation)
|
2025-11-27 05:16:19 +00:00
|
|
|
if (focusTarget) {
|
2025-11-29 15:10:00 +00:00
|
|
|
const renderPos = calculateRenderPosition(focusTarget, allBodies);
|
|
|
|
|
camera.lookAt(renderPos.x, renderPos.z, renderPos.y);
|
2025-11-27 05:16:19 +00:00
|
|
|
} else {
|
|
|
|
|
camera.lookAt(0, 0, 0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// After animation completes, OrbitControls will take over
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|