Files
shanghai-residences/src/components/puzzle/PuzzleSystem.tsx
T
2026-04-29 12:55:02 +08:00

408 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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