408 lines
14 KiB
TypeScript
408 lines
14 KiB
TypeScript
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 { getPuzzlePieceData } from '@/config/puzzleData'
|
||
import { getPuzzlePiecePath, getPuzzleViewBox } from '@/config/puzzlePaths'
|
||
import { PUZZLE_RESIDENCES } from '@/config/puzzleResidences'
|
||
import { Image } from '@/components'
|
||
|
||
/**
|
||
* 将英文故居名称转换为中文显示名称
|
||
*/
|
||
const getResidenceDisplayName = (residence: string): string => {
|
||
const nameMap: Record<string, string> = {
|
||
chenyun: '陈云',
|
||
huangyanpei: '黄炎培',
|
||
luxun: '鲁迅',
|
||
sunzhongshan: '孙中山',
|
||
maozedong: '毛泽东',
|
||
zhangleping: '张乐平',
|
||
nieer: '聂耳',
|
||
rongdesheng: '荣德生',
|
||
}
|
||
|
||
return nameMap[residence] || residence
|
||
}
|
||
|
||
export interface PuzzleSystemProps {
|
||
/** 是否可见 */
|
||
visible: boolean
|
||
/** 关闭回调 */
|
||
onClose: () => void
|
||
/** 故居名称,如 'chenyun' */
|
||
residence: string
|
||
/** 是否显示菜单入口的故居导航 */
|
||
showResidenceNav?: boolean
|
||
/** 切换故居 */
|
||
onResidenceChange?: (residence: string) => void
|
||
}
|
||
|
||
/**
|
||
* SVG拼图碎片组件
|
||
*/
|
||
const PuzzlePieceSvg = ({
|
||
residence,
|
||
pieceId,
|
||
isUnlocked,
|
||
isSelected,
|
||
isHovered,
|
||
onClick,
|
||
onHover,
|
||
onLeave,
|
||
}: {
|
||
residence: string
|
||
pieceId: number
|
||
isUnlocked: boolean
|
||
isSelected: boolean
|
||
isHovered: boolean
|
||
onClick: () => void
|
||
onHover: (id: number) => void
|
||
onLeave: () => void
|
||
}) => {
|
||
const pathData = getPuzzlePiecePath(residence, pieceId)
|
||
|
||
if (!pathData) return null
|
||
|
||
// 为每个碎片生成唯一的pattern ID
|
||
const patternId = `pattern-${residence}-${pieceId}`
|
||
|
||
// 碎片图片路径
|
||
const pieceImage = getStaticImage(`puzzle/${residence}/${pieceId}`)
|
||
|
||
return (
|
||
<>
|
||
<style>
|
||
{`
|
||
.puzzle-piece {
|
||
transition: transform 0.2s ease-out;
|
||
transform-origin: center;
|
||
transform-box: fill-box; /* 确保 SVG 围绕自身中心缩放 */
|
||
}
|
||
|
||
.puzzle-piece:hover {
|
||
transform: scale(1.04) translate(-5px, -5px);
|
||
}
|
||
`}
|
||
</style>
|
||
<g
|
||
onClick={isUnlocked ? onClick : undefined}
|
||
onMouseEnter={() => (isUnlocked ? onHover(pieceId) : undefined)}
|
||
onMouseLeave={onLeave}
|
||
style={{
|
||
cursor: isUnlocked ? 'pointer' : 'not-allowed',
|
||
}}
|
||
className={cn(isUnlocked ? 'puzzle-piece' : '')}
|
||
>
|
||
<defs>
|
||
{/* Safari/iOS 对 SVG image 的 CSS filter 支持有限,使用滤镜定义保证生效 */}
|
||
<filter
|
||
id={`${patternId}-dim`}
|
||
colorInterpolationFilters="sRGB"
|
||
x="0%"
|
||
y="0%"
|
||
width="100%"
|
||
height="100%"
|
||
>
|
||
{/* 将亮度压低到约 20% */}
|
||
<feColorMatrix
|
||
type="matrix"
|
||
values="0.2 0 0 0 0 0 0.2 0 0 0 0 0 0.2 0 0 0 0 0 1 0"
|
||
/>
|
||
</filter>
|
||
|
||
<pattern
|
||
id={patternId}
|
||
patternUnits="objectBoundingBox"
|
||
patternContentUnits="objectBoundingBox"
|
||
width={1}
|
||
height={1}
|
||
>
|
||
<image
|
||
href={pieceImage}
|
||
width={1}
|
||
height={1}
|
||
preserveAspectRatio="none"
|
||
filter={isUnlocked ? undefined : `url(#${patternId}-dim)`}
|
||
style={{
|
||
transition: 'filter 220ms ease',
|
||
}}
|
||
/>
|
||
</pattern>
|
||
</defs>
|
||
|
||
{/* 实际的路径:只有点击到路径填充区域才会触发 onClick */}
|
||
<path
|
||
d={pathData}
|
||
fill={`url(#${patternId})`}
|
||
stroke={isUnlocked ? (isSelected ? '#75d1fb' : '#fff') : '#888'}
|
||
strokeWidth={isUnlocked ? (isSelected ? 3 : isHovered ? 2.3 : 2) : 1}
|
||
opacity={isUnlocked ? 1 : 0.8}
|
||
/>
|
||
</g>
|
||
</>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 拼图系统组件
|
||
* - 全屏弹窗,显示拼图碎片
|
||
* - 4x4网格布局(16块碎片)
|
||
* - 已解锁显示正常图片,未解锁显示遮罩
|
||
* - 点击碎片可跳转到对应页面
|
||
*/
|
||
const PuzzleSystem = ({
|
||
visible,
|
||
onClose,
|
||
residence,
|
||
showResidenceNav = false,
|
||
onResidenceChange,
|
||
}: PuzzleSystemProps) => {
|
||
const navigate = useNavigate()
|
||
const { moduleName } = useParams<{ moduleName: string }>()
|
||
const { isPuzzleUnlocked, getPuzzleCollectProgress } = usePuzzleStore()
|
||
const [selectedPiece, setSelectedPiece] = useState<number | null>(null)
|
||
const [hoveredPiece, setHoveredPiece] = useState<number | null>(null)
|
||
|
||
// 当弹窗关闭时重置选中状态
|
||
useEffect(() => {
|
||
if (!visible) {
|
||
setSelectedPiece(null)
|
||
setHoveredPiece(null)
|
||
}
|
||
}, [visible])
|
||
|
||
useEffect(() => {
|
||
setSelectedPiece(null)
|
||
setHoveredPiece(null)
|
||
}, [residence])
|
||
|
||
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 (activeModuleName && selectedPiece) {
|
||
// console.log(residence, selectedPiece, moduleName);
|
||
// const route = getPuzzleRoute(residence, selectedPiece, moduleName);
|
||
// console.log(route);
|
||
const route = selectedPieceData?.route
|
||
if (route) {
|
||
navigate(`/${activeModuleName}/${residence}/${route}`)
|
||
onClose()
|
||
}
|
||
}
|
||
}
|
||
|
||
// 获取当前选中碎片的数据
|
||
const selectedPieceData = selectedPiece ? getPuzzlePieceData(residence, selectedPiece) : null
|
||
|
||
// 生成16块碎片(1-16)
|
||
const pieces = Array.from({ length: 16 }, (_, i) => i + 1)
|
||
// 将悬停的碎片放到最后渲染,保证 hover 层级在最上
|
||
const renderPieces =
|
||
hoveredPiece != null ? [...pieces.filter((p) => p !== hoveredPiece), hoveredPiece] : pieces
|
||
|
||
// 获取收集进度
|
||
const collectProgress = getPuzzleCollectProgress(residence)
|
||
const activeResidenceConfig = PUZZLE_RESIDENCES.find((item) => item.residence === residence)
|
||
const activeModuleName = moduleName || activeResidenceConfig?.moduleName
|
||
const residenceNavItems = PUZZLE_RESIDENCES.filter((item) => item.hasPuzzle).map((item) => ({
|
||
...item,
|
||
progress: getPuzzleCollectProgress(item.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})`,
|
||
}}
|
||
>
|
||
{showResidenceNav && (
|
||
<div className="absolute left-0 top-0 z-10 h-full w-[360px] pt-10 flex flex-col gap-3 pointer-events-auto">
|
||
{residenceNavItems.map((item) => {
|
||
const isActive = item.residence === residence
|
||
const progress = Math.round(item.progress)
|
||
|
||
return (
|
||
<button
|
||
key={item.residence}
|
||
type="button"
|
||
className={cn(
|
||
'relative w-[341px] h-[112px] bg-contain bg-center bg-no-repeat text-left transition-all hover:brightness-110',
|
||
isActive ? 'translate-x-3 scale-105' : ''
|
||
)}
|
||
style={{
|
||
backgroundImage: `url(${getStaticImage(
|
||
isActive ? 'puzzle/选中' : 'puzzle/未选中'
|
||
)})`,
|
||
}}
|
||
onClick={() => onResidenceChange?.(item.residence)}
|
||
>
|
||
<div className="relative h-full pl-14 pr-5 flex items-center justify-between text-white font-bold">
|
||
<span className="text-2xl drop-shadow">{item.displayName}</span>
|
||
<span className="text-xl drop-shadow">{progress}%</span>
|
||
</div>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* 关闭按钮 */}
|
||
<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)]'
|
||
)}
|
||
>
|
||
{getResidenceDisplayName(residence)}故居碎片
|
||
</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={cn('absolute inset-0 flex justify-end', showResidenceNav ? 'pl-[360px]' : '')}
|
||
>
|
||
<div className="flex items-center gap-4 px-6 pt-30">
|
||
<div className="w-200 h-200 perspective-[1000px]">
|
||
{/* SVG拼图容器 - 使用3D变换和缩放 */}
|
||
<div className="relative h-full transform-3d rotate-x-20 rotate-y-0 rotate-z-0 scale-85">
|
||
<svg
|
||
viewBox={getPuzzleViewBox(residence)}
|
||
className="w-full h-full"
|
||
style={{
|
||
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.3))',
|
||
overflow: 'visible',
|
||
}}
|
||
>
|
||
{renderPieces.map((pieceId) => {
|
||
const isUnlocked = isPuzzleUnlocked(residence, pieceId)
|
||
|
||
return (
|
||
<PuzzlePieceSvg
|
||
key={pieceId}
|
||
residence={residence}
|
||
pieceId={pieceId}
|
||
isUnlocked={isUnlocked}
|
||
isSelected={selectedPiece === pieceId}
|
||
isHovered={hoveredPiece === pieceId}
|
||
onClick={() => handlePieceClick(pieceId)}
|
||
onHover={(id) => setHoveredPiece(id)}
|
||
onLeave={() => setHoveredPiece(null)}
|
||
/>
|
||
)
|
||
})}
|
||
</svg>
|
||
</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-8 mb-4 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
|
||
className="mt-4"
|
||
src={getStaticImage('puzzle/点击前往浏览')}
|
||
alt="前往按钮"
|
||
button
|
||
onClick={handleGoToPage}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default PuzzleSystem
|