feat:碎片完善
This commit is contained in:
1 parent
ec7e8e2bd2
commit
046501705f
74 files changed
+1602
-172
No files matched your search
@@ -1,9 +1,15 @@
|
||||
import { Outlet, useMatches, useLocation } from "react-router-dom";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { getPublicPath } from "@/lib/utils";
|
||||
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";
|
||||
|
||||
/**
|
||||
* 故居布局组件
|
||||
@@ -11,15 +17,145 @@ import { VideoProvider } from "@/contexts/VideoContext";
|
||||
* 支持视频前置播放和平滑过渡动画
|
||||
* 渲染子路由内容
|
||||
*/
|
||||
const ResidenceLayout = () => {
|
||||
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 handleTriggerPuzzleAnimation = (pieceId: number) => {
|
||||
if (!residence) return;
|
||||
|
||||
// 防止重复执行动画
|
||||
if (animationInProgressRef.current) return;
|
||||
|
||||
// 检查碎片是否已解锁
|
||||
if (!isPuzzleUnlocked(residence, pieceId)) {
|
||||
// 标记动画正在进行
|
||||
animationInProgressRef.current = true;
|
||||
|
||||
// 开始动画流程:禁用所有操作
|
||||
setIsAnimationPlaying(true);
|
||||
setPuzzlePieceId(pieceId);
|
||||
|
||||
// 显示碎片在屏幕正中间
|
||||
setShowPuzzlePiece(true);
|
||||
|
||||
// 使用双重 requestAnimationFrame 确保 Portal 渲染完成
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
// 延迟执行飞行动画,让发光动画先显示
|
||||
setTimeout(() => {
|
||||
if (puzzleBoxRef.current && puzzlePieceRef.current) {
|
||||
// 标记开始飞行,隐藏原始碎片
|
||||
setIsFlying(true);
|
||||
|
||||
// 使用 requestAnimationFrame 确保隐藏操作在下一帧执行,并且元素已完全渲染
|
||||
requestAnimationFrame(() => {
|
||||
// 再次确保元素已渲染并获取准确尺寸
|
||||
if (puzzlePieceRef.current) {
|
||||
flyToAnimation({
|
||||
fromRef: puzzlePieceRef,
|
||||
toRef: puzzleBoxRef,
|
||||
targetPosition: "center",
|
||||
duration: 1200,
|
||||
scale: { from: 1, to: 0.3 },
|
||||
opacity: [1, 0.8, 0],
|
||||
onComplete: () => {
|
||||
// 动画完成后解锁碎片并恢复交互
|
||||
unlockPuzzle(residence, pieceId);
|
||||
setShowPuzzlePiece(false);
|
||||
setPuzzlePieceId(null);
|
||||
setIsAnimationPlaying(false);
|
||||
setIsFlying(false);
|
||||
animationInProgressRef.current = false;
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 如果 ref 不存在,直接解锁并恢复
|
||||
unlockPuzzle(residence, pieceId);
|
||||
setIsAnimationPlaying(false);
|
||||
setShowPuzzlePiece(false);
|
||||
setPuzzlePieceId(null);
|
||||
setIsFlying(false);
|
||||
animationInProgressRef.current = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 如果 ref 不存在,等待一下再试
|
||||
setTimeout(() => {
|
||||
if (puzzleBoxRef.current && puzzlePieceRef.current) {
|
||||
setIsFlying(true);
|
||||
requestAnimationFrame(() => {
|
||||
if (puzzlePieceRef.current) {
|
||||
flyToAnimation({
|
||||
fromRef: puzzlePieceRef,
|
||||
toRef: puzzleBoxRef,
|
||||
targetPosition: "center",
|
||||
duration: 1200,
|
||||
scale: { from: 1, to: 0.3 },
|
||||
opacity: [1, 0.8, 0],
|
||||
onComplete: () => {
|
||||
unlockPuzzle(residence, pieceId);
|
||||
setShowPuzzlePiece(false);
|
||||
setPuzzlePieceId(null);
|
||||
setIsAnimationPlaying(false);
|
||||
setIsFlying(false);
|
||||
animationInProgressRef.current = false;
|
||||
},
|
||||
});
|
||||
} else {
|
||||
unlockPuzzle(residence, pieceId);
|
||||
setIsAnimationPlaying(false);
|
||||
setShowPuzzlePiece(false);
|
||||
setPuzzlePieceId(null);
|
||||
setIsFlying(false);
|
||||
animationInProgressRef.current = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 如果还是不存在,直接解锁并恢复
|
||||
unlockPuzzle(residence, pieceId);
|
||||
setIsAnimationPlaying(false);
|
||||
setShowPuzzlePiece(false);
|
||||
setPuzzlePieceId(null);
|
||||
setIsFlying(false);
|
||||
animationInProgressRef.current = false;
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, 500); // 发光动画显示 500ms 后开始飞行
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 获取当前路由的视频路径
|
||||
const videoSrc = useMemo(() => {
|
||||
const currentMatch = matches[matches.length - 1];
|
||||
@@ -113,43 +249,101 @@ const ResidenceLayout = () => {
|
||||
isVideoPlaying={isVideoPlaying}
|
||||
isContentVisible={isContentVisible}
|
||||
>
|
||||
<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 />
|
||||
</div>
|
||||
|
||||
{/* 视频层 - 只在播放时渲染,z-index 动态调整 */}
|
||||
{isVideoPlaying && (
|
||||
<PuzzleAnimationProvider
|
||||
triggerPuzzleAnimation={handleTriggerPuzzleAnimation}
|
||||
>
|
||||
<div className="relative w-full h-full">
|
||||
{/* 内容层 - 始终渲染,根据状态控制显示 */}
|
||||
<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`}
|
||||
isContentVisible
|
||||
? "z-50 opacity-100" // 显示时在上层
|
||||
: "z-10 opacity-0 pointer-events-none" // 隐藏时在下层且不可交互
|
||||
} transition-opacity duration-300 ease-in`}
|
||||
>
|
||||
<Video
|
||||
ref={videoRef}
|
||||
src={videoSrc ? getPublicPath(videoSrc) : undefined}
|
||||
autoPlay
|
||||
playsInline
|
||||
onEnded={handleVideoEnded}
|
||||
onError={handleVideoError}
|
||||
showSkipButton={true}
|
||||
skipText="跳过"
|
||||
onSkip={handleSkip}
|
||||
/>
|
||||
<Outlet />
|
||||
{/* 拼图盒 - 独立于内容层,确保始终可点击 */}
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 拼图系统弹窗 */}
|
||||
{residence && (
|
||||
<PuzzleSystem
|
||||
visible={isPuzzleSystemVisible}
|
||||
onClose={() => setIsPuzzleSystemVisible(false)}
|
||||
residence={residence}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 碎片显示 - 用于飞行动画,显示在屏幕正中间 */}
|
||||
{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;
|
||||
Reference in new issue
Block a user