import { Outlet, useMatches, useLocation } from "react-router-dom"; import { useState, useEffect, useRef, useMemo } from "react"; import { createPortal } from "react-dom"; import { getPublicPath, getStaticImage } from "@/lib/utils"; import type { VideoRouteHandle } from "@/router/types"; import Video, { type VideoRef } from "@/components/video"; import { VideoProvider } from "@/contexts/VideoContext"; import { PuzzleBoxProvider, usePuzzleBox } from "@/contexts/PuzzleBoxContext"; import { PuzzleAnimationProvider } from "@/contexts/PuzzleAnimationContext"; import { Image, PuzzleSystem, flyToAnimation } from "@/components"; import { usePuzzleStore } from "@/store/puzzleStore"; import PuzzlePiecePortal from "@/components/puzzle/PuzzlePiecePortal"; /** * 支持拼图盒功能的故居列表 * 后续如需为其他故居添加拼图盒,只需在此数组中添加对应的故居名称即可 */ const RESIDENCES_WITH_PUZZLE = ["chenyun", "huangyanpei","sunzhongshan","luxun"] as const; /** * 故居布局组件 * 用于所有故居页面的统一布局容器 * 支持视频前置播放和平滑过渡动画 * 渲染子路由内容 */ const ResidenceLayoutContent = () => { const matches = useMatches(); const location = useLocation(); const { puzzleBoxRef } = usePuzzleBox(); const { unlockPuzzle, isPuzzleUnlocked } = usePuzzleStore(); const [isVideoPlaying, setIsVideoPlaying] = useState(false); const [isContentVisible, setIsContentVisible] = useState(false); const [isVideoFadingOut, setIsVideoFadingOut] = useState(false); const [isPuzzleSystemVisible, setIsPuzzleSystemVisible] = useState(false); const videoRef = useRef(null); const previousPathRef = useRef(""); // 碎片动画相关状态 const [showPuzzlePiece, setShowPuzzlePiece] = useState(false); const [puzzlePieceId, setPuzzlePieceId] = useState(null); const [isAnimationPlaying, setIsAnimationPlaying] = useState(false); const [isFlying, setIsFlying] = useState(false); // 标记是否正在飞行 const puzzlePieceRef = useRef(null); const animationInProgressRef = useRef(false); // 防止重复执行动画 // 从路径中提取故居名称(starName),如 'chenyun', 'sunzhongshan' 等 // 路径格式:/:moduleName/:starName/:page const residence = useMemo(() => { const pathParts = location.pathname.split("/").filter(Boolean); // 路径格式:moduleName/starName/page,所以 starName 是第二个部分 if (pathParts.length >= 2) { return pathParts[1]; } return ""; }, [location.pathname]); // 判断当前故居是否支持拼图盒功能 const hasPuzzleFeature = useMemo( () => RESIDENCES_WITH_PUZZLE.includes(residence as any), [residence] ); // 动画配置常量 const ANIMATION_CONFIG = { APPEAR_DURATION: 800, // 初始出现动画持续时间(3D旋转+放大,毫秒) GLOW_DURATION: 800, // 发光动画持续时间(毫秒) FLY_DURATION: 1400, // 飞行动画持续时间(毫秒) RETRY_DELAY: 100, // 重试延迟(毫秒) MAX_RETRIES: 3, // 最大重试次数 SCALE: { from: 1, to: 0.25 }, // 缩放比例(更小的目标尺寸,效果更明显) OPACITY: [1, 1, 0.6, 0] as number[], // 透明度变化(更平滑的淡出) }; // 清理动画状态 const cleanupAnimation = () => { setIsAnimationPlaying(false); setShowPuzzlePiece(false); setPuzzlePieceId(null); setIsFlying(false); animationInProgressRef.current = false; }; // 执行飞行动画 const executeFlyAnimation = (pieceId: number) => { if (!residence || !puzzleBoxRef.current || !puzzlePieceRef.current) { return false; } setIsFlying(true); // 使用 requestAnimationFrame 确保元素已完全渲染 requestAnimationFrame(() => { if (!puzzlePieceRef.current || !residence) { return; } flyToAnimation({ fromRef: puzzlePieceRef, toRef: puzzleBoxRef, targetPosition: "center", duration: ANIMATION_CONFIG.FLY_DURATION, scale: ANIMATION_CONFIG.SCALE, opacity: ANIMATION_CONFIG.OPACITY, ease: [0.25, 0.1, 0.25, 1], // 自定义缓动函数,更自然的加速和减速 onComplete: () => { if (residence) { unlockPuzzle(residence, pieceId); } cleanupAnimation(); }, }); }); return true; }; // 等待元素准备就绪并执行动画 const waitForElementsAndAnimate = async ( pieceId: number, retryCount = 0 ): Promise => { if (!residence) { cleanupAnimation(); return; } // 检查元素是否已准备好 if (puzzleBoxRef.current && puzzlePieceRef.current) { const success = executeFlyAnimation(pieceId); if (success) return; } // 如果元素未准备好且未超过最大重试次数,则重试 if (retryCount < ANIMATION_CONFIG.MAX_RETRIES) { await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.RETRY_DELAY) ); return waitForElementsAndAnimate(pieceId, retryCount + 1); } // 超过最大重试次数,直接解锁并清理 console.warn("Puzzle animation: 元素未准备好,直接解锁"); unlockPuzzle(residence, pieceId); cleanupAnimation(); }; // 处理碎片动画触发(仅支持拼图功能的故居) const handleTriggerPuzzleAnimation = (pieceId: number) => { if (!residence || !hasPuzzleFeature) return; // 防止重复执行动画 if (animationInProgressRef.current) return; // 检查碎片是否已解锁 if (isPuzzleUnlocked(residence, pieceId)) return; // 标记动画正在进行 animationInProgressRef.current = true; // 开始动画流程:禁用所有操作 setIsAnimationPlaying(true); setPuzzlePieceId(pieceId); setShowPuzzlePiece(true); // 使用双重 requestAnimationFrame 确保 Portal 渲染完成 requestAnimationFrame(() => { requestAnimationFrame(async () => { // 等待初始出现动画(旋转+放大)完成 await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.APPEAR_DURATION) ); // 等待发光动画显示一段时间,让用户看到碎片 await new Promise((resolve) => setTimeout(resolve, ANIMATION_CONFIG.GLOW_DURATION) ); // 等待元素准备就绪并执行飞行动画 await waitForElementsAndAnimate(pieceId); }); }); }; // 获取当前路由的视频路径 const videoSrc = useMemo(() => { const currentMatch = matches[matches.length - 1]; return (currentMatch?.handle as VideoRouteHandle | undefined)?.videoSrc; }, [matches]); // 判断是否是前进导航(而不是后退) const isForwardNavigation = useMemo(() => { const currentPath = location.pathname; const previousPath = previousPathRef.current || ""; // 提取场景编号(例如:/module/chenyun/place5 -> 5) const getCurrentPlaceNumber = (path: string) => { const match = path.match(/place(\d+)/); return match && match[1] ? parseInt(match[1], 10) : 0; }; const currentPlace = getCurrentPlaceNumber(currentPath); const previousPlace = getCurrentPlaceNumber(previousPath); // 如果当前场景编号大于前一个场景编号,说明是前进 // 如果是首次加载(previousPath为空),也视为前进 return !previousPath || currentPlace > previousPlace; }, [location.pathname]); // 处理视频播放完成(自然结束) const handleVideoEnded = () => { // 先让内容层开始淡入 setIsContentVisible(true); // 开始视频层淡出动画 setIsVideoFadingOut(true); // 然后延迟移除视频层,让视频层淡出动画完成 setTimeout(() => { setIsVideoPlaying(false); setIsVideoFadingOut(false); }, 500); // 与 CSS transition 时间一致 }; // 处理跳过操作 const handleSkip = () => { // 立即暂停视频 videoRef.current?.pause(); // 立即显示内容层 setIsContentVisible(true); // 立即移除视频层,不等待淡出动画 setIsVideoPlaying(false); setIsVideoFadingOut(false); }; // 处理视频加载错误 const handleVideoError = () => { // 视频加载失败,直接显示内容 setIsVideoPlaying(false); setIsContentVisible(true); setIsVideoFadingOut(false); }; // 路由变化时重置状态 useEffect(() => { // 只有在前进导航且有视频时才播放视频 if (videoSrc && isForwardNavigation) { // 有视频且是前进,重置状态准备播放 setIsVideoPlaying(true); setIsContentVisible(false); setIsVideoFadingOut(false); } else { // 没有视频或是后退,直接显示内容 setIsVideoPlaying(false); setIsContentVisible(true); setIsVideoFadingOut(false); } // 更新前一个路径 previousPathRef.current = location.pathname; }, [location.pathname, videoSrc, isForwardNavigation]); // 当视频元素渲染后,重置并确保播放 useEffect(() => { if (isVideoPlaying && videoRef.current?.video && videoSrc) { const video = videoRef.current.video; video.currentTime = 0; video.play().catch(() => { // 自动播放失败,直接显示内容 handleVideoError(); }); } }, [isVideoPlaying, videoSrc]); return (
{/* 内容层 - 始终渲染,根据状态控制显示 */}
{/* 拼图盒 - 独立于内容层,确保始终可点击(仅支持拼图功能的故居显示) */} {hasPuzzleFeature && (
{ e.stopPropagation(); if (residence) { setIsPuzzleSystemVisible(true); } }} >
)}
{/* 视频层 - 只在播放时渲染,z-index 动态调整 */} {isVideoPlaying && (
)} {/* 拼图系统弹窗(仅支持拼图功能的故居显示) */} {hasPuzzleFeature && residence && ( setIsPuzzleSystemVisible(false)} residence={residence} /> )} {/* 碎片显示 - 用于飞行动画,显示在屏幕正中间(仅支持拼图功能的故居显示) */} {hasPuzzleFeature && showPuzzlePiece && puzzlePieceId && residence && (