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

@@ -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;
+252
View File
@@ -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;
+6
View File
@@ -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";