diff --git a/public/models/fox.glb b/public/models/fox.glb new file mode 100644 index 00000000..e36eac43 Binary files /dev/null and b/public/models/fox.glb differ diff --git a/src/assets/models/fox.glb b/src/assets/models/fox.glb new file mode 100644 index 00000000..e36eac43 Binary files /dev/null and b/src/assets/models/fox.glb differ diff --git a/src/components/FoxMascot/FoxMascot.jsx b/src/components/FoxMascot/FoxMascot.jsx new file mode 100644 index 00000000..250270da --- /dev/null +++ b/src/components/FoxMascot/FoxMascot.jsx @@ -0,0 +1,309 @@ +/** + * @fileoverview FoxMascot Component - Main canvas wrapper for 3D fox animation + * @module components/FoxMascot/FoxMascot + */ + +import React, { useRef, useEffect, useState, useCallback, Suspense } from 'react'; +import { Canvas } from '@react-three/fiber'; +import { OrbitControls, PerspectiveCamera } from '@react-three/drei'; +import * as THREE from 'three'; +import FoxModel from './FoxModel'; +import useAnimationSequence from './useAnimationSequence'; +import styles from './FoxMascot.module.scss'; + +/** + * FoxMascot Component - Renders the 3D fox mascot animation overlay + * @param {Object} props + * @param {Function} props.onComplete - Called when animation finishes or is skipped + * @param {boolean} props.isVisible - Controls visibility of the component + */ +const FoxMascot = ({ onComplete, isVisible = true }) => { + const foxRef = useRef(); + const canvasRef = useRef(); + const [showSkip, setShowSkip] = useState(true); + const [currentPhase, setCurrentPhase] = useState(null); + const [isLoaded, setIsLoaded] = useState(false); + + /** + * Handle phase change from animation sequence + */ + const handlePhaseChange = useCallback((phase) => { + setCurrentPhase(phase); + console.log(`[FoxMascot] Phase: ${phase}`); + }, []); + + /** + * Handle animation complete + */ + const handleComplete = useCallback(() => { + console.log('[FoxMascot] Animation complete'); + setShowSkip(false); + if (onComplete) { + onComplete(); + } + }, [onComplete]); + + // Animation sequence hook + const animation = useAnimationSequence(foxRef, handleComplete, handlePhaseChange); + + /** + * Start animation when fox model is loaded + * Sequence: Jump → Run (move left) → Walk (continue left) → Sit + * Combines skeletal animations with position movement + */ + /** + * AVAILABLE FOX ANIMATIONS: + * ------------------------- + * Fox_Attack_Paws, Fox_Attack_Tail, Fox_Falling, Fox_Falling_Left, + * Fox_Idle, Fox_Jump, Fox_Jump_InAir, Fox_Jump_Pivot_InPlace, + * Fox_Run, Fox_Run_InPlace, Fox_Run_Left, Fox_Run_Left_InPlace, + * Fox_Run_Right, Fox_Run_Right_InPlace, Fox_Sit1, Fox_Sit2_Idle, + * Fox_Sit3_StandUp, Fox_Sit_Idle_Break, Fox_Sit_No, Fox_Sit_Yes, + * Fox_Somersault, Fox_Somersault_InPlace, Fox_Walk, Fox_Walk_Back, + * Fox_Walk_Back_InPlace, Fox_Walk_InPlace, Fox_Walk_Left, + * Fox_Walk_Left_InPlace, Fox_Walk_Right, Fox_Walk_Right_InPlace + */ + const handleFoxLoaded = useCallback(() => { + setIsLoaded(true); + console.log('[FoxMascot] Fox loaded - IN-PLACE ANIMATION with GSAP movement'); + + if (!foxRef.current) return; + + const fox = foxRef.current; + const group = fox.getGroup(); + + if (!group) { + console.error('[FoxMascot] Could not get group for position animation'); + return; + } + + // Animation durations (seconds) + const JUMP_DURATION = 1.2; + const RUN_DURATION = 1.5; + const FALL_DURATION = 1.2; // Falling animation + const IDLE_DURATION = 1.0; // Stand up / recover + const TURN_DURATION = 0.8; // Turn to face user + const WALK_DURATION = 2.0; // Walk to final position + const SIT_DURATION = 0.53; + + const crossfade = 0.3; // Smooth crossfade between animations + + // Starting position (right side of screen) + const START_X = 55; + const END_X = -20; // Final position (left side) + + // Set initial position + group.position.x = START_X; + group.position.y = -35; + + console.log('[FoxMascot] Animation sequence:'); + console.log(' 1. Jump → 2. Run → 3. Fall'); + console.log(' 4. Idle (recover) → 5. Turn to face user'); + console.log(' 6. Walk → 7. Sit'); + + // Import GSAP and create timeline + import('gsap').then(({ default: gsap }) => { + + // Calculate movement distances + const jumpDistance = 5; + const runDistance = 30; + const fallDistance = 3; // Slight forward during fall + const walkDistance = 37; // Walk to final position + + // Position checkpoints + const afterJumpX = START_X - jumpDistance; // 50 + const afterRunX = afterJumpX - runDistance; // 20 + const afterFallX = afterRunX - fallDistance; // 17 + const afterWalkX = afterFallX - walkDistance; // -20 + + // Create GSAP timeline + const tl = gsap.timeline({ + onComplete: () => { + console.log('[FoxMascot] ✓ Animation sequence complete!'); + console.log(`[FoxMascot] Final position: ${group.position.x.toFixed(2)}`); + } + }); + + // ===== PHASE 1: JUMP ===== + tl.add(() => { + fox.playAnimation('Fox_Jump', 0, false, 1); + console.log('[FoxMascot] ▶ Phase 1: JUMP'); + }) + .to(group.position, { + x: afterJumpX, + duration: JUMP_DURATION, + ease: 'power2.out' + }, '<') + + // ===== PHASE 2: RUN ===== + .add(() => { + fox.playAnimation('Fox_Run_InPlace', crossfade, true, 1); + console.log('[FoxMascot] ▶ Phase 2: RUN'); + }) + .to(group.position, { + x: afterRunX, + duration: RUN_DURATION, + ease: 'linear' + }, '<') + + // ===== PHASE 3: FALL (cute stumble) ===== + .add(() => { + fox.playAnimation('Fox_Falling', crossfade, false, 1); + console.log('[FoxMascot] ▶ Phase 3: FALL (oops!)'); + }) + .to(group.position, { + x: afterFallX, + duration: FALL_DURATION, + ease: 'power2.out' + }, '<') + + // ===== PHASE 4: IDLE (recover/stand up) ===== + .add(() => { + fox.playAnimation('Fox_Idle', crossfade, true, 1); + console.log('[FoxMascot] ▶ Phase 4: IDLE (recovering)'); + }) + .to({}, { duration: IDLE_DURATION }) + + // ===== PHASE 5: TURN HEAD TO FACE USER ===== + // Rotate only the head bone while staying in Idle (no leg/body movement) + .add(() => { + console.log('[FoxMascot] ▶ Phase 5: TURNING HEAD TO FACE USER'); + fox.setHeadRotation(0, true); // Enable head rotation + }) + .to({ headX: 0 }, { + headX: 1, // Turn head LEFT to look at user + duration: TURN_DURATION, + ease: 'power2.inOut', + onUpdate: function () { + const xVal = this.targets()[0].headX; + fox.setHeadRotation(xVal, true); + } + }) + + // ===== PHASE 5b: TURN HEAD BACK TO ORIGINAL ===== + .add(() => { + console.log('[FoxMascot] ▶ Phase 5b: TURNING HEAD BACK'); + }) + .to({ headX: 1 }, { + headX: 0, // Turn head back to original direction + duration: TURN_DURATION * 0.7, + ease: 'power2.inOut', + onUpdate: function () { + const xVal = this.targets()[0].headX; + fox.setHeadRotation(xVal, true); + } + }) + + // ===== PHASE 6: WALK ===== + .add(() => { + fox.setHeadRotation(0, false); // Disable head override + fox.playAnimation('Fox_Walk_InPlace', crossfade, true, 1); + console.log('[FoxMascot] ▶ Phase 6: WALK'); + }) + .to(group.position, { + x: afterWalkX, + duration: WALK_DURATION, + ease: 'linear' + }, '<') + + // ===== PHASE 7: TURN TO FACE USER ===== + .add(() => { + fox.playAnimation('Fox_Walk_Left_InPlace', crossfade, true, 1); + console.log('[FoxMascot] ▶ Phase 7: TURN TO FACE USER'); + }) + .to(group.rotation, { + y: Math.PI / 12, // Rotate slightly to face camera + duration: 1.0, + ease: 'power2.inOut' + }, '<') + + // ===== PHASE 8: SIT (facing user) ===== + .add(() => { + fox.playAnimation('Fox_Sit2_Idle', crossfade, true, 1); // Use idle sit - more forward looking + console.log('[FoxMascot] ▶ Phase 8: SIT (facing user)'); + }) + // Tilt fox upward so it looks at the screen/user + .to(group.rotation, { + x: -0.30, // Tilt more to look at user + duration: 0.5, + ease: 'power2.out' + }, '<+0.3'); + + }); + + }, []); + + /** + * Handle skip button click + */ + const handleSkip = useCallback(() => { + animation.skip(); + }, [animation]); + + // DISABLED: Start animation once loaded + // useEffect(() => { + // if (isLoaded && foxRef.current && isVisible) { + // animation.play(); + // } + // }, [isLoaded, isVisible]); + + if (!isVisible) { + return null; + } + + return ( +
+ {/* Skip Button */} + {showSkip && ( + + )} + + {/* Three.js Canvas */} + + {/* Lighting */} + + + + + {/* Camera - FOV=50, z=100 where size was correct */} + + + {/* Fox Model - PROPERLY SIZED AND POSITIONED */} + {/* Base model is ~27 units, scale=0.5 gives ~13 unit fox (visible height ~93 units) */} + + + + + {/* Debug controls (uncomment to debug) */} + {/* */} + +
+ ); +}; + +export default FoxMascot; diff --git a/src/components/FoxMascot/FoxMascot.module.scss b/src/components/FoxMascot/FoxMascot.module.scss new file mode 100644 index 00000000..34ed3f58 --- /dev/null +++ b/src/components/FoxMascot/FoxMascot.module.scss @@ -0,0 +1,62 @@ +/** + * FoxMascot Styles + * Full-screen overlay for 3D fox mascot animation + */ + +.foxMascotContainer { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + pointer-events: none; + z-index: 9998; + /* Below chatbot modal (9999), above everything else */ + overflow: hidden; +} + +.canvas { + width: 100%; + height: 100%; + background: transparent; +} + +.skipButton { + position: fixed; + bottom: 100px; + right: 20px; + padding: 10px 20px; + background: rgba(255, 255, 255, 0.9); + border: 2px solid #ff6b35; + border-radius: 25px; + color: #ff6b35; + font-size: 14px; + font-weight: 600; + cursor: pointer; + pointer-events: auto; + z-index: 10000; + transition: all 0.2s ease; + backdrop-filter: blur(5px); + box-shadow: 0 4px 15px rgba(255, 107, 53, 0.3); + + &:hover { + background: #ff6b35; + color: white; + transform: scale(1.05); + box-shadow: 0 6px 20px rgba(255, 107, 53, 0.4); + } + + &:active { + transform: scale(0.98); + } +} + +/* Mobile adjustments */ +@media (max-width: 768px) { + .skipButton { + bottom: 80px; + right: 15px; + padding: 8px 16px; + font-size: 12px; + } +} \ No newline at end of file diff --git a/src/components/FoxMascot/FoxModel.jsx b/src/components/FoxMascot/FoxModel.jsx new file mode 100644 index 00000000..f8e3da5b --- /dev/null +++ b/src/components/FoxMascot/FoxModel.jsx @@ -0,0 +1,370 @@ +/** + * @fileoverview FoxModel Component - Loads and animates the 3D fox mascot + * @module components/FoxMascot/FoxModel + * + * FIXES APPLIED: + * 1. Using Center from drei to auto-center the model geometry + * 2. Computing bounding box to understand model dimensions + * 3. Positioning the group AFTER centering + * 4. Removed cloning - using original scene with Center + */ + +import React, { useRef, useEffect, useMemo, forwardRef, useImperativeHandle } from 'react'; +import { useGLTF, useAnimations, Center } from '@react-three/drei'; +import { useFrame } from '@react-three/fiber'; +import * as THREE from 'three'; + +/** + * AVAILABLE FOX ANIMATIONS: + * ------------------------- + * Fox_Attack_Paws - Attack with paws + * Fox_Attack_Tail - Attack with tail + * Fox_Falling - Falling animation + * Fox_Falling_Left - Falling to the left + * Fox_Idle - Standard idle/breathing + * Fox_Jump - Jump and land + * Fox_Jump_InAir - In-air jump pose + * Fox_Jump_Pivot_InPlace - Pivot jump in place + * Fox_Run - Running with root motion + * Fox_Run_InPlace - Running in place (treadmill) + * Fox_Run_Left - Running while turning left + * Fox_Run_Left_InPlace - Running left in place + * Fox_Run_Right - Running while turning right + * Fox_Run_Right_InPlace - Running right in place + * Fox_Sit1 - Transition from standing to sitting + * Fox_Sit2_Idle - Idle while sitting + * Fox_Sit3_StandUp - Standing up from sit + * Fox_Sit_Idle_Break - Idle break while sitting + * Fox_Sit_No - Shaking head no while sitting + * Fox_Sit_Yes - Nodding yes while sitting + * Fox_Somersault - Flip/tumble animation + * Fox_Somersault_InPlace - Somersault in place + * Fox_Walk - Walking with root motion + * Fox_Walk_Back - Walking backwards + * Fox_Walk_Back_InPlace - Walking backwards in place + * Fox_Walk_InPlace - Walking in place (treadmill) + * Fox_Walk_Left - Walking while turning left + * Fox_Walk_Left_InPlace - Walking left in place + * Fox_Walk_Right - Walking while turning right + * Fox_Walk_Right_InPlace - Walking right in place + */ + +// Animation name constants +const ANIMATIONS = { + // Basic + IDLE: 'Fox_Idle', + JUMP: 'Fox_Jump', + JUMP_INAIR: 'Fox_Jump_InAir', + JUMP_PIVOT_INPLACE: 'Fox_Jump_Pivot_InPlace', + FALLING: 'Fox_Falling', + FALLING_LEFT: 'Fox_Falling_Left', + + // Attack + ATTACK_PAWS: 'Fox_Attack_Paws', + ATTACK_TAIL: 'Fox_Attack_Tail', + + // Run + RUN: 'Fox_Run', + RUN_INPLACE: 'Fox_Run_InPlace', + RUN_LEFT: 'Fox_Run_Left', + RUN_LEFT_INPLACE: 'Fox_Run_Left_InPlace', + RUN_RIGHT: 'Fox_Run_Right', + RUN_RIGHT_INPLACE: 'Fox_Run_Right_InPlace', + + // Walk + WALK: 'Fox_Walk', + WALK_INPLACE: 'Fox_Walk_InPlace', + WALK_LEFT: 'Fox_Walk_Left', + WALK_LEFT_INPLACE: 'Fox_Walk_Left_InPlace', + WALK_RIGHT: 'Fox_Walk_Right', + WALK_RIGHT_INPLACE: 'Fox_Walk_Right_InPlace', + WALK_BACK: 'Fox_Walk_Back', + WALK_BACK_INPLACE: 'Fox_Walk_Back_InPlace', + + // Sit + SIT: 'Fox_Sit1', + SIT_IDLE: 'Fox_Sit2_Idle', + SIT_STANDUP: 'Fox_Sit3_StandUp', + SIT_IDLE_BREAK: 'Fox_Sit_Idle_Break', + SIT_NO: 'Fox_Sit_No', + SIT_YES: 'Fox_Sit_Yes', + + // Tricks + SOMERSAULT: 'Fox_Somersault', + SOMERSAULT_INPLACE: 'Fox_Somersault_InPlace', +}; + +// Model path - must be in public folder for Vite +const MODEL_PATH = '/models/fox.glb'; + +/** + * FoxModel component - Loads GLB model and manages animations + * Uses Center from drei to properly center the model geometry + */ +const FoxModel = forwardRef(({ position = [0, 0, 0], rotation = [0, 0, 0], scale = 1, onLoaded }, ref) => { + const group = useRef(); + const { scene, animations } = useGLTF(MODEL_PATH); + const { actions, mixer } = useAnimations(animations, group); + + // Current playing animation tracking + const currentAnimation = useRef(null); + + // Head rotation state (applied every frame after mixer update) + const headRotation = useRef({ y: 0, x: 0, enabled: false }); + const headBoneRef = useRef(null); + + // Compute bounding box and log model info on mount + useEffect(() => { + if (scene) { + // Compute bounding box to understand model size + const box = new THREE.Box3().setFromObject(scene); + const size = box.getSize(new THREE.Vector3()); + const center = box.getCenter(new THREE.Vector3()); + + console.log('[FoxModel] === Model Info ==='); + console.log('[FoxModel] Bounding box size:', size.x.toFixed(2), size.y.toFixed(2), size.z.toFixed(2)); + console.log('[FoxModel] Bounding box center:', center.x.toFixed(2), center.y.toFixed(2), center.z.toFixed(2)); + console.log('[FoxModel] Animations available:', Object.keys(actions)); + + // Log DURATION of each animation clip + console.log('[FoxModel] === Animation Durations ==='); + animations.forEach(clip => { + console.log(`[FoxModel] ${clip.name}: ${clip.duration.toFixed(2)}s`); + }); + + // LOG ALL BONES in the model + console.log('[FoxModel] === ALL BONES ==='); + const bones = []; + scene.traverse((child) => { + if (child.isBone) { + const worldPos = new THREE.Vector3(); + child.getWorldPosition(worldPos); + bones.push({ + name: child.name, + x: worldPos.x.toFixed(4), + y: worldPos.y.toFixed(4), + z: worldPos.z.toFixed(4) + }); + console.log(`[FoxModel] BONE: "${child.name}" at (${worldPos.x.toFixed(4)}, ${worldPos.y.toFixed(4)}, ${worldPos.z.toFixed(4)})`); + } + }); + console.log('[FoxModel] Total bones found:', bones.length); + + console.log('[FoxModel] Props:', { position, rotation, scale }); + } + + if (onLoaded) { + onLoaded(); + } + }, [scene, actions, animations, onLoaded, position, rotation, scale]); + + /** + * Get the duration of an animation clip in seconds + */ + const getAnimationDuration = (animationName) => { + const clip = animations.find(c => c.name === animationName); + return clip ? clip.duration : 0; + }; + + /** + * Crossfade to a new animation smoothly + * @param {string} animationName - Name of the animation to play + * @param {number} duration - Crossfade duration (default 0.5) + * @param {boolean|null} loop - Force loop on/off, or null for auto-detect + * @param {number} timeScale - Playback speed: 1=normal, 0.5=half speed, 2=double speed + */ + const playAnimation = (animationName, duration = 0.5, loop = null, timeScale = 1) => { + const nextAction = actions[animationName]; + if (!nextAction) { + console.warn(`[FoxModel] Animation "${animationName}" not found`); + return; + } + + if (currentAnimation.current === animationName) { + return; + } + + console.log(`[FoxModel] Playing animation: ${animationName} at speed ${timeScale}x`); + + const prevAction = currentAnimation.current ? actions[currentAnimation.current] : null; + + const shouldLoop = loop !== null ? loop : + animationName.includes('Walk') || + animationName.includes('Run') || + animationName.includes('Idle'); + + nextAction.reset(); + nextAction.setLoop(shouldLoop ? THREE.LoopRepeat : THREE.LoopOnce, shouldLoop ? Infinity : 1); + nextAction.clampWhenFinished = !shouldLoop; + nextAction.setEffectiveWeight(1); + nextAction.setEffectiveTimeScale(timeScale); // Use the timeScale parameter + nextAction.play(); + + if (prevAction) { + prevAction.crossFadeTo(nextAction, duration, true); + } + + currentAnimation.current = animationName; + }; + + /** + * Stop all animations + */ + const stopAllAnimations = () => { + Object.values(actions).forEach(action => { + if (action) action.stop(); + }); + currentAnimation.current = null; + }; + + /** + * Get the world position of a bone (for tracking root motion) + * Returns the position of the root/hip bone or falls back to bounding box center + */ + const getRootBonePosition = () => { + if (!scene) return new THREE.Vector3(); + + // Try to find root/hip bone + let rootBone = null; + scene.traverse((child) => { + if (child.isBone && ( + child.name.toLowerCase().includes('root') || + child.name.toLowerCase().includes('hip') || + child.name.toLowerCase().includes('pelvis') + )) { + rootBone = child; + } + }); + + if (rootBone) { + const worldPos = new THREE.Vector3(); + rootBone.getWorldPosition(worldPos); + return worldPos; + } + + // Fallback: use bounding box center + const box = new THREE.Box3().setFromObject(scene); + return box.getCenter(new THREE.Vector3()); + }; + + /** + * Get the current bounding box of the model (for position tracking) + */ + const getBoundingBox = () => { + if (!scene) return null; + const box = new THREE.Box3().setFromObject(scene); + return { + center: box.getCenter(new THREE.Vector3()), + min: box.min.clone(), + max: box.max.clone() + }; + }; + + /** + * Force update the mixer to apply current animation pose + * Call this after playAnimation to ensure bones reflect the new pose + */ + const forceUpdate = (deltaTime = 0) => { + if (mixer) { + mixer.update(deltaTime); + } + }; + + /** + * Get the head bone for procedural rotation + * Searches for bones with 'head' or 'neck' in the name + */ + const getHeadBone = () => { + if (!scene) return null; + + let headBone = null; + scene.traverse((child) => { + if (child.isBone && ( + child.name.toLowerCase().includes('head') || + child.name.toLowerCase().includes('neck') + )) { + // Prefer 'head' over 'neck' + if (!headBone || child.name.toLowerCase().includes('head')) { + headBone = child; + } + } + }); + return headBone; + }; + + /** + * Set head rotation target - applied every frame after mixer updates + * @param {number} xRotation - X rotation (left/right turn) in radians + * @param {boolean} enabled - Whether to apply head rotation + */ + const setHeadRotation = (xRotation = 0, enabled = true) => { + headRotation.current = { x: xRotation, enabled }; + // Cache the head bone + if (!headBoneRef.current) { + headBoneRef.current = getHeadBone(); + } + console.log(`[FoxModel] Head rotation set: X=${xRotation.toFixed(2)}, enabled=${enabled}`); + }; + + /** + * Apply head rotation AFTER animation mixer updates + * This ensures our rotation overrides the animation + */ + useFrame(() => { + if (headRotation.current.enabled && headBoneRef.current) { + headBoneRef.current.rotation.x = headRotation.current.x; + } + }); + + /** + * Get the scene for bone access + */ + const getScene = () => scene; + + // Expose methods to parent via ref + useImperativeHandle(ref, () => ({ + playAnimation, + stopAllAnimations, + getGroup: () => group.current, + getAnimationDuration, + getRootBonePosition, + getBoundingBox, + forceUpdate, + getMixer: () => mixer, + getHeadBone, + setHeadRotation, + getScene, + ANIMATIONS, + })); + + // Use the scene directly without cloning + // Center component will handle geometry centering + console.log('[FoxModel] Rendering at position:', position, 'scale:', scale); + + return ( + <> + {/* + * Key fix: Use group for GSAP/position control + * Center component auto-centers the model geometry + */} + +
+ +
+
+ + ); +}); + +FoxModel.displayName = 'FoxModel'; + +// Preload the model +useGLTF.preload(MODEL_PATH); + +export default FoxModel; +export { ANIMATIONS }; diff --git a/src/components/FoxMascot/index.jsx b/src/components/FoxMascot/index.jsx new file mode 100644 index 00000000..5d7bc261 --- /dev/null +++ b/src/components/FoxMascot/index.jsx @@ -0,0 +1,8 @@ +/** + * @fileoverview FoxMascot Component Export + * @module components/FoxMascot + */ + +export { default } from './FoxMascot'; +export { default as FoxModel, ANIMATIONS } from './FoxModel'; +export { default as useAnimationSequence, PHASES } from './useAnimationSequence'; diff --git a/src/components/FoxMascot/useAnimationSequence.js b/src/components/FoxMascot/useAnimationSequence.js new file mode 100644 index 00000000..8d74f7c9 --- /dev/null +++ b/src/components/FoxMascot/useAnimationSequence.js @@ -0,0 +1,248 @@ +/** + * @fileoverview useAnimationSequence Hook - GSAP timeline for fox mascot animation + * Uses mathematical camera frustum calculation for screen-to-world conversion + * @module components/FoxMascot/useAnimationSequence + */ + +import { useRef, useCallback, useEffect } from 'react'; +import gsap from 'gsap'; + +/** + * Animation phases configuration + */ +const PHASES = { + EMERGE: 'emerge', + WALK_LEFT: 'walkLeft', + WALK_RIGHT: 'walkRight', + TRANSFORM: 'transform', +}; + +/** + * Hook to manage the complete fox mascot animation sequence using GSAP + * @param {Object} foxRef - Ref to FoxModel component + * @param {Function} onComplete - Callback when animation completes + * @param {Function} onPhaseChange - Callback when animation phase changes + * @returns {Object} Animation control methods + */ +const useAnimationSequence = (foxRef, onComplete, onPhaseChange) => { + const timeline = useRef(null); + const isPlaying = useRef(false); + const currentPhase = useRef(null); + + /** + * Set current phase and notify + */ + const setPhase = useCallback((phase) => { + currentPhase.current = phase; + if (onPhaseChange) { + onPhaseChange(phase); + } + }, [onPhaseChange]); + + /** + * Calculate visible area at z=0 using camera frustum math + * Formula: visibleHeight = 2 * tan(FOV/2) * distance + * Camera: FOV=50, position z=100 + */ + const getVisibleBounds = useCallback(() => { + const fov = 50; // degrees (must match FoxMascot.jsx) + const cameraZ = 100; // must match FoxMascot.jsx + const aspectRatio = window.innerWidth / window.innerHeight; + + // Convert FOV to radians and calculate visible height at z=0 + const vFOVRad = (fov * Math.PI) / 180; + const visibleHeight = 2 * Math.tan(vFOVRad / 2) * cameraZ; + const visibleWidth = visibleHeight * aspectRatio; + + return { + halfWidth: visibleWidth / 2, + halfHeight: visibleHeight / 2 + }; + }, []); + + /** + * Calculate positions based on viewport using camera frustum math + */ + const getPositions = useCallback(() => { + const bounds = getVisibleBounds(); + + // Position at 90% towards each edge (very close to chatbot icon) + const rightEdge = bounds.halfWidth * 0.90; + const bottomEdge = -bounds.halfHeight * 0.85; + const leftEdge = -bounds.halfWidth * 0.75; + + console.log('[Fox] Visible bounds:', bounds); + console.log('[Fox] Positions: right=', rightEdge, 'bottom=', bottomEdge); + + return { + startX: rightEdge, // Right side (near chatbot) + startY: bottomEdge, // Bottom + centerX: 0, // Center + leftEdgeX: leftEdge, // Left side + groundY: bottomEdge // Ground level + }; + }, [getVisibleBounds]); + + /** + * Build and play the master timeline + */ + const play = useCallback(() => { + if (!foxRef.current || isPlaying.current) return; + + const foxModel = foxRef.current; + const group = foxModel.getGroup(); + if (!group) return; + + const { startX, startY, centerX, leftEdgeX, groundY } = getPositions(); + + // Scale adjusted for mathematical positioning (previous was too small) + // With FOV=50, z=100, the visible area is ~93 units, so we need larger scale + const targetScale = 0.001; + + console.log('[Fox] Animation starting with:', { startX, startY, groundY, targetScale }); + + // Kill any existing timeline + if (timeline.current) { + timeline.current.kill(); + } + + isPlaying.current = true; + + // Create master timeline + timeline.current = gsap.timeline({ + onComplete: () => { + isPlaying.current = false; + if (onComplete) onComplete(); + }, + }); + + const tl = timeline.current; + + // Phase 1: Emerge from bottom-right (0s - 1s) + tl.call(() => { + setPhase(PHASES.EMERGE); + foxModel.playAnimation('Fox_Jump', 0.2); + }) + .set(group.position, { x: startX, y: startY, z: 0 }) // Bottom-right corner + .set(group.rotation, { y: -Math.PI / 2 }) // Face left (-90 degrees) + // Pop in with scale + .to(group.scale, { + x: targetScale, y: targetScale, z: targetScale, + duration: 0.4, + ease: 'back.out(1.7)' + }) + // Jump up slightly then land + .to(group.position, { + y: groundY + 3, + duration: 0.3, + ease: 'power2.out' + }) + .to(group.position, { + y: groundY, + duration: 0.2, + ease: 'bounce.out' + }) + + // Phase 2: Walk left across screen (1s - 4s) + .call(() => { + setPhase(PHASES.WALK_LEFT); + foxModel.playAnimation('Fox_Walk', 0.3); + }) + .to(group.position, { + x: centerX, + duration: 2.5, + ease: 'none', + }) + // Stop in center, do idle animation + .call(() => foxModel.playAnimation('Fox_Idle', 0.2)) + .to({}, { duration: 0.8 }) // Pause for idle + .call(() => foxModel.playAnimation('Fox_Sit_Yes', 0.3)) + .to({}, { duration: 0.6 }) + + // Phase 3: Walk right / return (5s - 8s) + .call(() => { + setPhase(PHASES.WALK_RIGHT); + foxModel.playAnimation('Fox_Walk', 0.3); + }) + // Turn around to face right + .to(group.rotation, { y: Math.PI * 0.5, duration: 0.3, ease: 'power2.inOut' }) + .to(group.position, { + x: startX, + duration: 2.5, + ease: 'none', + }) + + // Phase 4: Transform back to icon (8s - 9s) + .call(() => { + setPhase(PHASES.TRANSFORM); + foxModel.playAnimation('Fox_Idle', 0.2); + }) + // Move to bottom right corner + .to(group.position, { + y: startY, + duration: 0.2, + ease: 'power2.in' + }) + // Shrink back to nothing + .to(group.scale, { + x: 0, y: 0, z: 0, + duration: 0.4, + ease: 'power2.in' + }); + + }, [foxRef, onComplete, onPhaseChange, setPhase, getPositions]); + + /** + * Skip to end of animation + */ + const skip = useCallback(() => { + if (timeline.current) { + timeline.current.progress(1); + timeline.current.kill(); + } + isPlaying.current = false; + if (onComplete) onComplete(); + }, [onComplete]); + + /** + * Pause animation + */ + const pause = useCallback(() => { + if (timeline.current) { + timeline.current.pause(); + } + }, []); + + /** + * Resume animation + */ + const resume = useCallback(() => { + if (timeline.current) { + timeline.current.resume(); + } + }, []); + + /** + * Cleanup on unmount + */ + useEffect(() => { + return () => { + if (timeline.current) { + timeline.current.kill(); + } + }; + }, []); + + return { + play, + skip, + pause, + resume, + isPlaying: () => isPlaying.current, + getCurrentPhase: () => currentPhase.current, + PHASES, + }; +}; + +export default useAnimationSequence; +export { PHASES }; diff --git a/src/data/Sponser.json b/src/data/Sponser.json index 9de63e67..9bb27730 100644 --- a/src/data/Sponser.json +++ b/src/data/Sponser.json @@ -1,25 +1,22 @@ [ - { - "image": "https://cdn.prod.website-files.com/6898a84f39288fa31fb19eb3/68b1b1e566f93e724d817493_eef89a14894ab60c2de4591433117cb8_347615ad16304501c571bf1208b83615bc2eb3b7.jpg" - }, - { - "image": "https://cdn.prod.website-files.com/6898a84f39288fa31fb19eb3/68b1b277c3dff77928f42d07_42506cd40d9f845726c5b38fa6135dab_1737828471512.jpeg" - }, - { - "image": "https://cdn.prod.website-files.com/6898a84f39288fa31fb19eb3/6898aa1700b8583e9b0cea09_9a90e8c4f7933718410931fff16e1d67d0b1a728%20(1)-modified.png" - }, - { - "image": "https://cdn.prod.website-files.com/6898a84f39288fa31fb19eb3/68b1b4a473e0c91daf83aee0_SR%20burger%20point%202.png" - }, - - { - "image": "https://cdn.prod.website-files.com/6898a84f39288fa31fb19eb3/6898a9a97a1a9e6121d28cfb_988e7f74979dc9924fdc07590a81526d0d19ddc4.png" - }, - - { - "image": "https://cdn.prod.website-files.com/6898a84f39288fa31fb19eb3/68b1b4713f09aa52852051a1_Adda%20Cafe%20-%20Logo%202.png" - }, - + { + "image": "https://cdn.prod.website-files.com/6898a84f39288fa31fb19eb3/68b1b1e566f93e724d817493_eef89a14894ab60c2de4591433117cb8_347615ad16304501c571bf1208b83615bc2eb3b7.jpg" + }, + { + "image": "https://cdn.prod.website-files.com/6898a84f39288fa31fb19eb3/68b1b277c3dff77928f42d07_42506cd40d9f845726c5b38fa6135dab_1737828471512.jpeg" + }, + { + "image": "https://cdn.prod.website-files.com/6898a84f39288fa31fb19eb3/6898aa1700b8583e9b0cea09_9a90e8c4f7933718410931fff16e1d67d0b1a728%20(1)-modified.png" + }, + { + "image": "https://cdn.prod.website-files.com/6898a84f39288fa31fb19eb3/68b1b4a473e0c91daf83aee0_SR%20burger%20point%202.png" + }, + { + "image": "https://cdn.prod.website-files.com/6898a84f39288fa31fb19eb3/6898a9a97a1a9e6121d28cfb_988e7f74979dc9924fdc07590a81526d0d19ddc4.png" + }, + { + "image": "https://cdn.prod.website-files.com/6898a84f39288fa31fb19eb3/68b1b4713f09aa52852051a1_Adda%20Cafe%20-%20Logo%202.png" + }, { "image": "https://res.cloudinary.com/dm6jd6bhk/image/upload/v1723474142/WebImages/idm0kbdoybdmiklcgvkv.png" }, @@ -59,17 +56,43 @@ { "image": "https://res.cloudinary.com/dm6jd6bhk/image/upload/v1723474142/WebImages/jvvudiuohzmgczo188us.png" }, - { "image": "https://res.cloudinary.com/dm6jd6bhk/image/upload/v1723476284/WebImages/t4bje7l7nqvusixwr423.jpg" }, { "image": "https://res.cloudinary.com/dm6jd6bhk/image/upload/v1723474152/WebImages/ox8zpeciavvell8jvmgd.png" }, + { + "image": "https://res.cloudinary.com/daq1wz35t/image/upload/v1771618449/Confique_sgmn7g.png" + }, { "image": "https://res.cloudinary.com/dm6jd6bhk/image/upload/v1723474144/WebImages/tqkublfvrp45lut6pvva.png" }, + { + "image": "https://res.cloudinary.com/daq1wz35t/image/upload/v1771618655/Anime_no_Sekai_mapjx2.png" + }, + { + "image": "https://res.cloudinary.com/daq1wz35t/image/upload/v1771618655/Teology_od0lpf.png" + }, + { + "image": "https://res.cloudinary.com/daq1wz35t/image/upload/v1771618656/Beardo_nzyawr.png" + }, + { + "image": "https://res.cloudinary.com/daq1wz35t/image/upload/v1771618656/Exsolvia_rmueuy.png" + }, + { + "image": "https://res.cloudinary.com/daq1wz35t/image/upload/v1771618656/Pure_Veggie_Bliss_jkhyzu.png" + }, + { + "image": "https://res.cloudinary.com/daq1wz35t/image/upload/v1771618656/Marg_tara_y1mied.png" + }, + { + "image": "https://res.cloudinary.com/daq1wz35t/image/upload/v1771618657/Kiti_Kitchen_oekgkf.png" + }, + { + "image": "https://res.cloudinary.com/daq1wz35t/image/upload/v1771618658/Short_Cut_ngohps.png" + }, { "image": "https://res.cloudinary.com/dm6jd6bhk/image/upload/v1723474145/WebImages/ctvvq74mtiltxhiniwsf.png" } -] +] \ No newline at end of file diff --git a/src/layouts/Footer/Footer.jsx b/src/layouts/Footer/Footer.jsx index 3f96e5b4..0ddea54d 100644 --- a/src/layouts/Footer/Footer.jsx +++ b/src/layouts/Footer/Footer.jsx @@ -34,25 +34,22 @@ export default function Footer() {

Explore

Home Events Team @@ -64,17 +61,15 @@ export default function Footer() { Contact Alumni @@ -82,9 +77,8 @@ export default function Footer() { href="http://medium.com/@fedkiit" target="_blank" rel="noopener noreferrer" - className={`${styles.footerleftlink} ${ - isOmega ? styles.omegaLink : "" - }`} + className={`${styles.footerleftlink} ${isOmega ? styles.omegaLink : "" + }`} > Blog @@ -95,18 +89,16 @@ export default function Footer() { Manifesto Partners @@ -119,56 +111,48 @@ export default function Footer() { href="https://www.linkedin.com/company/fedkiit/" target="_blank" rel="noopener noreferrer" - className={`${styles.link1} ${ - isOmega ? styles.omegaSocialLink : "" - }`} + className={`${styles.link1} ${isOmega ? styles.omegaSocialLink : "" + }`} > @@ -185,24 +169,22 @@ export default function Footer() {
Terms and conditions

&

Privacy policy
-

© 2024, fedkiit

+

© {new Date().getFullYear()}, fedkiit

{/*
*/}