Files
shanghai-residences/src/components/layout/ResidenceLayout.tsx
T
zhongyidian a365964f54 feat: 添加全局键盘快捷键和更新故居数据,提升用户体验
- 在 main.tsx 中添加全局键盘快捷键功能,允许用户通过 Ctrl+Shift+R 重置所有缓存。
- 更新多个故居的拼图数据,增加黄炎培和鲁迅的相关信息,丰富学习内容。
- 调整 ResidenceLayout 组件以支持新的故居,确保数据一致性。
- 清理不再使用的拼图布局配置文件,提升代码整洁性。
2026-01-28 15:08:28 +08:00

380 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;