feat:碎片完善
This commit is contained in:
1 parent
ec7e8e2bd2
commit
046501705f
74 files changed
+1602
-172
No files matched your search
@@ -126,8 +126,10 @@ class FlyToAnimationManagerClass {
|
||||
// 使用 cloneNode 深度克隆,包括所有子元素
|
||||
const clonedElement = fromRef.current!.cloneNode(true) as HTMLElement;
|
||||
|
||||
// 复制计算后的样式到克隆元素
|
||||
// 复制计算后的样式到克隆元素,确保尺寸正确
|
||||
const computedStyle = window.getComputedStyle(fromRef.current!);
|
||||
|
||||
// 复制所有样式属性
|
||||
const allStyles = Array.from(computedStyle);
|
||||
allStyles.forEach((prop) => {
|
||||
const value = computedStyle.getPropertyValue(prop);
|
||||
@@ -135,6 +137,15 @@ class FlyToAnimationManagerClass {
|
||||
clonedElement.style.setProperty(prop, value);
|
||||
}
|
||||
});
|
||||
|
||||
// 确保克隆元素的尺寸与原始元素一致
|
||||
clonedElement.style.width = `${fromRect.width}px`;
|
||||
clonedElement.style.height = `${fromRect.height}px`;
|
||||
clonedElement.style.position = "absolute";
|
||||
clonedElement.style.left = "0";
|
||||
clonedElement.style.top = "0";
|
||||
clonedElement.style.margin = "0";
|
||||
clonedElement.style.padding = "0";
|
||||
|
||||
// 获取克隆后的 HTML(只获取内部内容,不包含外层元素)
|
||||
const elementHtml = clonedElement.innerHTML;
|
||||
|
||||
@@ -28,8 +28,8 @@ export interface ExhibitOverlayProps {
|
||||
autoPlayDescription?: boolean;
|
||||
/** 点击展示 */
|
||||
onClick?: () => void;
|
||||
/** 是否禁用拖拽,默认 false。当为 true 时,禁用拖拽和缩放功能,确保 onClick 事件能正常触发 */
|
||||
disableDrag?: boolean;
|
||||
/** 打开回调 */
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
@@ -50,7 +50,7 @@ const ExhibitOverlay = ({
|
||||
description,
|
||||
autoPlayDescription = false,
|
||||
onClick,
|
||||
disableDrag = false,
|
||||
onOpen,
|
||||
}: ExhibitOverlayProps) => {
|
||||
const maskSrc = getStaticImage("components/蒙版遮罩");
|
||||
const backSrc = getStaticImage("components/关闭");
|
||||
@@ -139,6 +139,12 @@ const ExhibitOverlay = ({
|
||||
applyTransformStyle();
|
||||
}, [applyTransformStyle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
onOpen?.();
|
||||
}
|
||||
}, [visible, onOpen]);
|
||||
|
||||
// 关闭或切换时重置
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
@@ -242,9 +248,6 @@ const ExhibitOverlay = ({
|
||||
// 指针按下:记录初始状态
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
// 如果禁用了拖拽,直接返回,不处理拖拽事件
|
||||
if (disableDrag) return;
|
||||
|
||||
if (!containerRef.current || !viewElRef.current) return;
|
||||
try {
|
||||
containerRef.current.setPointerCapture(e.pointerId);
|
||||
@@ -280,7 +283,7 @@ const ExhibitOverlay = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[getDistance, disableDrag]
|
||||
[getDistance]
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -514,18 +517,14 @@ const ExhibitOverlay = ({
|
||||
maxHeight: containerSize,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
cursor: disableDrag
|
||||
? "default"
|
||||
: isDragging
|
||||
? "grabbing"
|
||||
: "grab",
|
||||
cursor: isDragging ? "grabbing" : "grab",
|
||||
}}
|
||||
onPointerDown={disableDrag ? undefined : handlePointerDown}
|
||||
onPointerMove={disableDrag ? undefined : handlePointerMove}
|
||||
onPointerUp={disableDrag ? undefined : handlePointerUp}
|
||||
onPointerCancel={disableDrag ? undefined : handlePointerUp}
|
||||
onWheel={disableDrag ? undefined : handleWheel}
|
||||
onDoubleClick={disableDrag ? undefined : handleDoubleClick}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
onWheel={handleWheel}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
<div
|
||||
ref={viewElRef}
|
||||
|
||||
@@ -89,3 +89,7 @@ export type {
|
||||
LetterToHuangyanpeiProps,
|
||||
LetterToLuxunProps,
|
||||
} from "./letter";
|
||||
|
||||
// Puzzle components
|
||||
export { PuzzleSystem, PuzzlePieceDisplay } from "./puzzle";
|
||||
export type { PuzzleSystemProps, PuzzlePieceDisplayProps } from "./puzzle";
|
||||
@@ -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;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { forwardRef } from "react";
|
||||
import { getStaticImage } from "@/lib/utils";
|
||||
|
||||
export interface PuzzlePieceDisplayProps {
|
||||
/** 故居名称,如 'chenyun' */
|
||||
residence: string;
|
||||
/** 碎片ID */
|
||||
pieceId: number;
|
||||
/** 是否显示发光动画 */
|
||||
showGlow?: boolean;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼图碎片显示组件
|
||||
* - 显示碎片图片
|
||||
* - 支持发光动画效果
|
||||
* - 可以被 ref 引用,用于飞行动画
|
||||
*/
|
||||
const PuzzlePieceDisplay = forwardRef<HTMLDivElement, PuzzlePieceDisplayProps>(
|
||||
({ residence, pieceId, showGlow = true, className = "" }, ref) => {
|
||||
// 碎片图片路径
|
||||
const pieceImage = getStaticImage(`puzzle/${residence}/${pieceId}`);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`relative inline-block ${className}`}
|
||||
style={{
|
||||
animation: showGlow ? "puzzleGlow 1s ease-in-out infinite" : "none",
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes puzzleGlow {
|
||||
0%, 100% {
|
||||
filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.8)) drop-shadow(0 0 16px rgba(255, 255, 255, 0.6));
|
||||
}
|
||||
50% {
|
||||
filter: drop-shadow(0 0 16px rgba(255, 255, 255, 1)) drop-shadow(0 0 32px rgba(255, 255, 255, 0.8)) drop-shadow(0 0 48px rgba(255, 255, 255, 0.6));
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<img
|
||||
src={pieceImage}
|
||||
alt={`碎片 ${pieceId}`}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
PuzzlePieceDisplay.displayName = "PuzzlePieceDisplay";
|
||||
|
||||
export default PuzzlePieceDisplay;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { forwardRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import PuzzlePieceDisplay from "./PuzzlePieceDisplay";
|
||||
|
||||
interface PuzzlePiecePortalProps {
|
||||
residence: string;
|
||||
pieceId: number;
|
||||
/** 是否隐藏(飞行时隐藏原始碎片) */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 碎片 Portal 组件
|
||||
* 将碎片渲染到 document.body,确保在最上层显示
|
||||
*/
|
||||
const PuzzlePiecePortal = forwardRef<HTMLDivElement, PuzzlePiecePortalProps>(
|
||||
({ residence, pieceId, hidden = false }, ref) => {
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
className="fixed z-[99999] pointer-events-none"
|
||||
style={{
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: "120px",
|
||||
height: "120px",
|
||||
opacity: hidden ? 0 : 1,
|
||||
visibility: hidden ? "hidden" : "visible",
|
||||
}}
|
||||
>
|
||||
<PuzzlePieceDisplay
|
||||
residence={residence}
|
||||
pieceId={pieceId}
|
||||
showGlow={true}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
PuzzlePiecePortal.displayName = "PuzzlePiecePortal";
|
||||
|
||||
export default PuzzlePiecePortal;
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { cn, getBackgroundImage, getStaticImage } from "@/lib/utils";
|
||||
import { usePuzzleStore } from "@/store/puzzleStore";
|
||||
import { getPuzzlePieceLayout } from "@/config/puzzleLayout";
|
||||
import { getPuzzlePieceData } from "@/config/puzzleData";
|
||||
import { Image } from "@/components";
|
||||
|
||||
export interface PuzzleSystemProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
/** 故居名称,如 'chenyun' */
|
||||
residence: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼图系统组件
|
||||
* - 全屏弹窗,显示拼图碎片
|
||||
* - 4x4网格布局(16块碎片)
|
||||
* - 已解锁显示正常图片,未解锁显示遮罩
|
||||
* - 点击碎片可跳转到对应页面
|
||||
*/
|
||||
const PuzzleSystem = ({ visible, onClose, residence }: PuzzleSystemProps) => {
|
||||
const navigate = useNavigate();
|
||||
const { moduleName } = useParams<{ moduleName: string }>();
|
||||
const { isPuzzleUnlocked, getPuzzleCollectProgress } = usePuzzleStore();
|
||||
const [selectedPiece, setSelectedPiece] = useState<number | null>(null);
|
||||
|
||||
// 当弹窗关闭时重置选中状态
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setSelectedPiece(null);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
// 背景图片
|
||||
const backgroundImage = getStaticImage("puzzle/底图");
|
||||
|
||||
// 处理碎片点击
|
||||
const handlePieceClick = (pieceId: number) => {
|
||||
const isUnlocked = isPuzzleUnlocked(residence, pieceId);
|
||||
|
||||
if (isUnlocked) {
|
||||
// 显示选中效果,更新详情框
|
||||
setSelectedPiece(pieceId);
|
||||
} else {
|
||||
// 未解锁的碎片,可以显示提示或不做任何操作
|
||||
// 暂时不做处理,或者可以添加提示
|
||||
}
|
||||
};
|
||||
|
||||
// 处理前往按钮点击
|
||||
const handleGoToPage = () => {
|
||||
if (moduleName && selectedPiece) {
|
||||
// console.log(residence, selectedPiece, moduleName);
|
||||
// const route = getPuzzleRoute(residence, selectedPiece, moduleName);
|
||||
// console.log(route);
|
||||
const route = selectedPieceData?.route;
|
||||
if (route) {
|
||||
navigate(`/${moduleName}/${residence}/${route}`);
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 获取当前选中碎片的数据
|
||||
const selectedPieceData = selectedPiece
|
||||
? getPuzzlePieceData(residence, selectedPiece)
|
||||
: null;
|
||||
|
||||
// 生成16块碎片(1-16)
|
||||
const pieces = Array.from({ length: 16 }, (_, i) => i + 1);
|
||||
|
||||
// 获取收集进度
|
||||
const collectProgress = getPuzzleCollectProgress(residence);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center"
|
||||
style={{
|
||||
background: "rgba(0, 0, 0, 0.8)",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// 点击背景关闭
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 背景图片 */}
|
||||
<div
|
||||
className="relative w-full h-full bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url(${backgroundImage})`,
|
||||
}}
|
||||
>
|
||||
{/* 关闭按钮 */}
|
||||
<Image
|
||||
className="absolute right-8 top-8 z-10"
|
||||
src={getStaticImage("components/关闭")}
|
||||
alt="关闭"
|
||||
button
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 收集进度 */}
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2 top-8 z-10 w-[708px] h-[107px] px-12 flex justify-between items-center"
|
||||
style={{ backgroundImage: getBackgroundImage("puzzle/收集进度") }}
|
||||
>
|
||||
<div className="relative">
|
||||
<span
|
||||
className={cn(
|
||||
"text-white text-2xl font-bold",
|
||||
"[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]"
|
||||
)}
|
||||
>
|
||||
陈云故居碎片
|
||||
</span>
|
||||
<img
|
||||
className="absolute -top-10 left-0 -z-10"
|
||||
src={getStaticImage("puzzle/碎片装饰")}
|
||||
alt="碎片装饰"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[#490000] text-4xl font-bold">
|
||||
{/* 收集进度 */}
|
||||
{collectProgress.toFixed(2) == "1.00"
|
||||
? "已集齐"
|
||||
: `收集进度${collectProgress.toFixed(2)}%`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 完整拼图底图 - 作为背景显示所有碎片拼好的样子 */}
|
||||
<div className="absolute inset-0 flex justify-end">
|
||||
<div className="flex items-center gap-4 px-6 pt-40">
|
||||
<div className="w-200 h-200 perspective-[1000px]">
|
||||
{/* 拼图容器 - 使用绝对定位,根据配置放置每个碎片 */}
|
||||
<div className="relative h-full transform-3d rotate-x-20 -rotate-y-0 -rotate-z-0 scale-85">
|
||||
{pieces.map((pieceId) => {
|
||||
const isUnlocked = isPuzzleUnlocked(residence, pieceId);
|
||||
const layout = getPuzzlePieceLayout(residence, pieceId);
|
||||
|
||||
// 碎片图片路径
|
||||
const pieceImage = getStaticImage(
|
||||
`puzzle/${residence}/${pieceId}`
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={pieceId}
|
||||
className={`absolute transition-all ${
|
||||
isUnlocked
|
||||
? "cursor-pointer hover:scale-105 hover:z-99 z-10"
|
||||
: "cursor-not-allowed z-0"
|
||||
}`}
|
||||
style={{
|
||||
left: layout.position.x,
|
||||
top: layout.position.y,
|
||||
}}
|
||||
onClick={() => handlePieceClick(pieceId)}
|
||||
>
|
||||
<div className="relative">
|
||||
{/* 碎片图片 - 始终显示,未解锁的用CSS变黑 */}
|
||||
<img
|
||||
src={pieceImage}
|
||||
alt={`碎片 ${pieceId}`}
|
||||
style={{
|
||||
filter: isUnlocked ? "" : `brightness(0.3) `,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 选中效果遮罩 - 只对已解锁的碎片显示 */}
|
||||
{/* {isSelected && isUnlocked && (
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none z-10"
|
||||
style={{
|
||||
backgroundImage: `url(${selectedMaskImage})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
}}
|
||||
/>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-2xl h-200">
|
||||
{selectedPieceData && (
|
||||
<div
|
||||
className="w-[657px] h-[776px] bg-no-repeat bg-cover p-10 text-center"
|
||||
style={{
|
||||
backgroundImage: getBackgroundImage("puzzle/详情框"),
|
||||
}}
|
||||
>
|
||||
{/* 标题 */}
|
||||
{selectedPieceData && (
|
||||
<div
|
||||
className={cn(
|
||||
"text-white text-5xl font-bold whitespace-nowrap mt-6 h-20 space-y-4",
|
||||
"[text-shadow:_-4px_-4px_0_#ae6400,_4px_-4px_0_#ae6400,_-4px_4px_0_#ae6400,_4px_4px_0_#ae6400,_-5px_0_0_#ae6400,_5px_0_0_#ae6400,_0_-5px_0_#ae6400,_0_5px_0_#ae6400,_0_0_20px_rgba(255,215,0,1),_0_0_40px_rgba(255,215,0,0.8),_0_0_60px_rgba(255,215,0,0.5)]"
|
||||
)}
|
||||
>
|
||||
{/* 空格换行 */}
|
||||
{selectedPieceData.title
|
||||
.split(" ")
|
||||
.map((word: string, index: number) => (
|
||||
<div key={index}>
|
||||
{word}
|
||||
{index <
|
||||
selectedPieceData.title.split(" ").length - 1 &&
|
||||
" "}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 对应页面图片 */}
|
||||
<div className="max-h-[500px] min-h-[400px] flex items-center justify-center">
|
||||
<img
|
||||
src={getStaticImage(
|
||||
`puzzle/${selectedPieceData.detailImage}`
|
||||
)}
|
||||
alt={selectedPieceData.title}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
{/* 前往按钮 */}
|
||||
{selectedPieceData && (
|
||||
<Image
|
||||
src={getStaticImage("puzzle/点击前往浏览")}
|
||||
alt="前往按钮"
|
||||
button
|
||||
onClick={handleGoToPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PuzzleSystem;
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as PuzzleSystem } from "./PuzzleSystem";
|
||||
export type { PuzzleSystemProps } from "./PuzzleSystem";
|
||||
export { default as PuzzlePieceDisplay } from "./PuzzlePieceDisplay";
|
||||
export type { PuzzlePieceDisplayProps } from "./PuzzlePieceDisplay";
|
||||
export { default as PuzzlePiecePortal } from "./PuzzlePiecePortal";
|
||||
|
||||
Reference in new issue
Block a user