- 在 main.tsx 中添加全局键盘快捷键功能,允许用户通过 Ctrl+Shift+R 重置所有缓存。 - 更新多个故居的拼图数据,增加黄炎培和鲁迅的相关信息,丰富学习内容。 - 调整 ResidenceLayout 组件以支持新的故居,确保数据一致性。 - 清理不再使用的拼图布局配置文件,提升代码整洁性。
380 lines
13 KiB
TypeScript
380 lines
13 KiB
TypeScript
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<VideoRef>(null);
|
||
const previousPathRef = useRef<string>("");
|
||
|
||
// 碎片动画相关状态
|
||
const [showPuzzlePiece, setShowPuzzlePiece] = useState(false);
|
||
const [puzzlePieceId, setPuzzlePieceId] = useState<number | null>(null);
|
||
const [isAnimationPlaying, setIsAnimationPlaying] = useState(false);
|
||
const [isFlying, setIsFlying] = useState(false); // 标记是否正在飞行
|
||
const puzzlePieceRef = useRef<HTMLDivElement>(null);
|
||
const animationInProgressRef = useRef<boolean>(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<void> => {
|
||
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 (
|
||
<VideoProvider
|
||
isVideoPlaying={isVideoPlaying}
|
||
isContentVisible={isContentVisible}
|
||
>
|
||
<PuzzleAnimationProvider
|
||
triggerPuzzleAnimation={handleTriggerPuzzleAnimation}
|
||
>
|
||
<div className="relative w-full h-full">
|
||
{/* 内容层 - 始终渲染,根据状态控制显示 */}
|
||
<div
|
||
className={`absolute inset-0 ${
|
||
isContentVisible
|
||
? "z-50 opacity-100" // 显示时在上层
|
||
: "z-10 opacity-0 pointer-events-none" // 隐藏时在下层且不可交互
|
||
} transition-opacity duration-300 ease-in`}
|
||
>
|
||
<Outlet />
|
||
{/* 拼图盒 - 独立于内容层,确保始终可点击(仅支持拼图功能的故居显示) */}
|
||
{hasPuzzleFeature && (
|
||
<div
|
||
ref={puzzleBoxRef}
|
||
className="absolute left-4 bottom-4 z-[1] cursor-pointer"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (residence) {
|
||
setIsPuzzleSystemVisible(true);
|
||
}
|
||
}}
|
||
>
|
||
<Image src={getStaticImage("components/拼图盒")} button />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 视频层 - 只在播放时渲染,z-index 动态调整 */}
|
||
{isVideoPlaying && (
|
||
<div
|
||
className={`absolute inset-0 ${
|
||
isVideoFadingOut
|
||
? "z-40 opacity-0 pointer-events-none" // 淡出时移到下层
|
||
: "z-50 opacity-100" // 播放时在上层
|
||
} bg-black transition-opacity duration-500 ease-out`}
|
||
>
|
||
<Video
|
||
ref={videoRef}
|
||
src={videoSrc ? getPublicPath(videoSrc) : undefined}
|
||
autoPlay
|
||
playsInline
|
||
onEnded={handleVideoEnded}
|
||
onError={handleVideoError}
|
||
showSkipButton={true}
|
||
skipText="跳过"
|
||
onSkip={handleSkip}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* 拼图系统弹窗(仅支持拼图功能的故居显示) */}
|
||
{hasPuzzleFeature && residence && (
|
||
<PuzzleSystem
|
||
visible={isPuzzleSystemVisible}
|
||
onClose={() => setIsPuzzleSystemVisible(false)}
|
||
residence={residence}
|
||
/>
|
||
)}
|
||
|
||
{/* 碎片显示 - 用于飞行动画,显示在屏幕正中间(仅支持拼图功能的故居显示) */}
|
||
{hasPuzzleFeature &&
|
||
showPuzzlePiece &&
|
||
puzzlePieceId &&
|
||
residence && (
|
||
<PuzzlePiecePortal
|
||
ref={puzzlePieceRef}
|
||
residence={residence}
|
||
pieceId={puzzlePieceId}
|
||
hidden={isFlying}
|
||
/>
|
||
)}
|
||
|
||
{/* 动画遮罩层 - 动画播放时盖住所有元素,防止操作 */}
|
||
{isAnimationPlaying &&
|
||
createPortal(
|
||
<div
|
||
className="fixed inset-0 z-[100000] pointer-events-auto"
|
||
style={{ touchAction: "none" }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
onTouchStart={(e) => e.preventDefault()}
|
||
onTouchMove={(e) => e.preventDefault()}
|
||
onTouchEnd={(e) => e.preventDefault()}
|
||
/>,
|
||
document.body
|
||
)}
|
||
</div>
|
||
</PuzzleAnimationProvider>
|
||
</VideoProvider>
|
||
);
|
||
};
|
||
|
||
const ResidenceLayout = () => {
|
||
return (
|
||
<PuzzleBoxProvider>
|
||
<ResidenceLayoutContent />
|
||
</PuzzleBoxProvider>
|
||
);
|
||
};
|
||
|
||
export default ResidenceLayout;
|