feat:碎片完善

This commit is contained in:
zhongyidian committed 2026-01-11 18:18:20 +08:00
1 parent ec7e8e2bd2
commit 046501705f
74 files changed
+1602 -172

No files matched your search

+227 -33
View File
@@ -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;