161 lines
6.2 KiB
TypeScript
161 lines
6.2 KiB
TypeScript
/**
|
|
* CameraController - handles camera movement and focus
|
|
*/
|
|
import { useEffect, useRef, type RefObject } from 'react';
|
|
import { useFrame, useThree } from '@react-three/fiber';
|
|
import { Vector3 } from 'three';
|
|
import type { CelestialBody } from '../types';
|
|
import { calculateRenderPosition, findParentPlanet } from '../utils/renderPosition';
|
|
import { getCelestialSize } from '../config/celestialSizes';
|
|
|
|
interface OrbitControlsLike {
|
|
target: Vector3;
|
|
update: () => void;
|
|
}
|
|
|
|
type CelestialTypeConfigs = Record<string, { ratio?: number; default?: number }> | null;
|
|
|
|
interface CameraControllerProps {
|
|
focusTarget: CelestialBody | null;
|
|
allBodies: CelestialBody[];
|
|
onAnimationComplete?: () => void;
|
|
resetTrigger?: number;
|
|
defaultPosition?: [number, number, number];
|
|
typeConfigs?: CelestialTypeConfigs;
|
|
controlsRef: RefObject<OrbitControlsLike | null>;
|
|
}
|
|
|
|
export function CameraController({
|
|
focusTarget,
|
|
allBodies,
|
|
onAnimationComplete,
|
|
resetTrigger = 0,
|
|
defaultPosition = [10, 8, 10],
|
|
typeConfigs,
|
|
controlsRef,
|
|
}: CameraControllerProps) {
|
|
const { camera } = useThree();
|
|
const targetPosition = useRef(new Vector3());
|
|
const targetLookPosition = useRef(new Vector3());
|
|
const isAnimating = useRef(false);
|
|
const isResetting = useRef(false);
|
|
const animationProgress = useRef(0);
|
|
const startPosition = useRef(new Vector3());
|
|
const startLookPosition = useRef(new Vector3());
|
|
const trackedLookPosition = useRef(new Vector3());
|
|
const lastResetTrigger = useRef(0);
|
|
|
|
// Keep the latest bodies list without making it a trigger for the focus effect.
|
|
// `allBodies` gets a new array reference on every timeline/historical frame, and we
|
|
// don't want that to re-fire the fly-to animation while the user is watching playback.
|
|
const allBodiesRef = useRef(allBodies);
|
|
allBodiesRef.current = allBodies;
|
|
|
|
// Handle manual reset trigger
|
|
useEffect(() => {
|
|
if (resetTrigger !== lastResetTrigger.current) {
|
|
lastResetTrigger.current = resetTrigger;
|
|
// Force reset
|
|
targetPosition.current.set(...defaultPosition);
|
|
targetLookPosition.current.set(0, 0, 0);
|
|
startPosition.current.copy(camera.position);
|
|
startLookPosition.current.copy(controlsRef.current?.target ?? new Vector3());
|
|
isAnimating.current = true;
|
|
isResetting.current = true;
|
|
animationProgress.current = 0;
|
|
}
|
|
}, [resetTrigger, camera, defaultPosition, controlsRef]); // Only run when resetTrigger changes
|
|
|
|
// Handle focus target changes
|
|
// Trigger only on the SELECTED body changing (by id), not on every new bodies array.
|
|
useEffect(() => {
|
|
const bodies = allBodiesRef.current;
|
|
if (focusTarget) {
|
|
isResetting.current = false; // Cancel reset if target selected
|
|
|
|
// Focus on target - use smart rendered position
|
|
const latestBody = bodies.find((body) => body.id === focusTarget.id) ?? focusTarget;
|
|
const renderPos = calculateRenderPosition(latestBody, bodies, typeConfigs);
|
|
const currentTargetPos = new Vector3(renderPos.x, renderPos.z, renderPos.y);
|
|
const bodySize = getCelestialSize(latestBody, undefined, typeConfigs);
|
|
const parentInfo = findParentPlanet(latestBody, bodies);
|
|
const focusDistance = latestBody.type === 'star'
|
|
? Math.max(5, bodySize * 9)
|
|
: latestBody.type === 'planet'
|
|
? Math.max(4, bodySize * 7)
|
|
: latestBody.type === 'dwarf_planet'
|
|
? Math.max(2.4, bodySize * 8)
|
|
: parentInfo
|
|
? Math.max(1.4, bodySize * 8)
|
|
: Math.max(1.6, bodySize * 9);
|
|
const previousLook = controlsRef.current?.target ?? new Vector3();
|
|
const viewDirection = camera.position.clone().sub(previousLook);
|
|
if (viewDirection.lengthSq() < 0.0001) viewDirection.set(1, 0.65, 1);
|
|
viewDirection.normalize();
|
|
|
|
targetLookPosition.current.copy(currentTargetPos);
|
|
targetPosition.current.copy(currentTargetPos)
|
|
.addScaledVector(viewDirection, focusDistance)
|
|
.add(new Vector3(0, focusDistance * 0.12, 0));
|
|
|
|
// Start animation to target
|
|
startPosition.current.copy(camera.position);
|
|
startLookPosition.current.copy(previousLook);
|
|
trackedLookPosition.current.copy(currentTargetPos);
|
|
isAnimating.current = true;
|
|
animationProgress.current = 0;
|
|
|
|
} else {
|
|
// Target became null (e.g. info window closed)
|
|
// Only stop animation if we are NOT in the middle of a reset
|
|
if (!isResetting.current && isAnimating.current) {
|
|
isAnimating.current = false;
|
|
animationProgress.current = 0;
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [focusTarget?.id, camera, controlsRef, typeConfigs]);
|
|
|
|
useFrame((_, delta) => {
|
|
const orbitControls = controlsRef.current;
|
|
if (isAnimating.current) {
|
|
// Smooth animation using easing
|
|
animationProgress.current += delta * 0.8; // Animation speed
|
|
|
|
if (animationProgress.current >= 1) {
|
|
animationProgress.current = 1;
|
|
isAnimating.current = false;
|
|
isResetting.current = false; // Animation done
|
|
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);
|
|
|
|
if (orbitControls) {
|
|
orbitControls.target.lerpVectors(startLookPosition.current, targetLookPosition.current, eased);
|
|
orbitControls.update();
|
|
}
|
|
camera.lookAt(targetLookPosition.current);
|
|
} else if (focusTarget && orbitControls) {
|
|
const bodies = allBodiesRef.current;
|
|
const latestBody = bodies.find((body) => body.id === focusTarget.id) ?? focusTarget;
|
|
const renderPos = calculateRenderPosition(latestBody, bodies, typeConfigs);
|
|
const nextLook = new Vector3(renderPos.x, renderPos.z, renderPos.y);
|
|
const movement = nextLook.sub(trackedLookPosition.current);
|
|
if (movement.lengthSq() > 0.00000001) {
|
|
camera.position.add(movement);
|
|
orbitControls.target.add(movement);
|
|
trackedLookPosition.current.add(movement);
|
|
orbitControls.update();
|
|
}
|
|
}
|
|
});
|
|
|
|
return null;
|
|
}
|