diff --git a/src/assets/images/puzzle/chenyun/1.png b/src/assets/images/puzzle/chenyun/1.png new file mode 100644 index 0000000..3f7f74c Binary files /dev/null and b/src/assets/images/puzzle/chenyun/1.png differ diff --git a/src/assets/images/puzzle/chenyun/10.png b/src/assets/images/puzzle/chenyun/10.png new file mode 100644 index 0000000..ab187e2 Binary files /dev/null and b/src/assets/images/puzzle/chenyun/10.png differ diff --git a/src/assets/images/puzzle/chenyun/11.png b/src/assets/images/puzzle/chenyun/11.png new file mode 100644 index 0000000..fd81f4a Binary files /dev/null and b/src/assets/images/puzzle/chenyun/11.png differ diff --git a/src/assets/images/puzzle/chenyun/12.png b/src/assets/images/puzzle/chenyun/12.png new file mode 100644 index 0000000..56370f4 Binary files /dev/null and b/src/assets/images/puzzle/chenyun/12.png differ diff --git a/src/assets/images/puzzle/chenyun/13.png b/src/assets/images/puzzle/chenyun/13.png new file mode 100644 index 0000000..2a0d181 Binary files /dev/null and b/src/assets/images/puzzle/chenyun/13.png differ diff --git a/src/assets/images/puzzle/chenyun/14.png b/src/assets/images/puzzle/chenyun/14.png new file mode 100644 index 0000000..c336102 Binary files /dev/null and b/src/assets/images/puzzle/chenyun/14.png differ diff --git a/src/assets/images/puzzle/chenyun/15.png b/src/assets/images/puzzle/chenyun/15.png new file mode 100644 index 0000000..0241b23 Binary files /dev/null and b/src/assets/images/puzzle/chenyun/15.png differ diff --git a/src/assets/images/puzzle/chenyun/16.png b/src/assets/images/puzzle/chenyun/16.png new file mode 100644 index 0000000..efacac9 Binary files /dev/null and b/src/assets/images/puzzle/chenyun/16.png differ diff --git a/src/assets/images/puzzle/chenyun/2.png b/src/assets/images/puzzle/chenyun/2.png new file mode 100644 index 0000000..dd78261 Binary files /dev/null and b/src/assets/images/puzzle/chenyun/2.png differ diff --git a/src/assets/images/puzzle/chenyun/3.png b/src/assets/images/puzzle/chenyun/3.png new file mode 100644 index 0000000..4c390a8 Binary files /dev/null and b/src/assets/images/puzzle/chenyun/3.png differ diff --git a/src/assets/images/puzzle/chenyun/4.png b/src/assets/images/puzzle/chenyun/4.png new file mode 100644 index 0000000..bb2f713 Binary files /dev/null and b/src/assets/images/puzzle/chenyun/4.png differ diff --git a/src/assets/images/puzzle/chenyun/5.png b/src/assets/images/puzzle/chenyun/5.png new file mode 100644 index 0000000..692ea1e Binary files /dev/null and b/src/assets/images/puzzle/chenyun/5.png differ diff --git a/src/assets/images/puzzle/chenyun/6.png b/src/assets/images/puzzle/chenyun/6.png new file mode 100644 index 0000000..b1203cf Binary files /dev/null and b/src/assets/images/puzzle/chenyun/6.png differ diff --git a/src/assets/images/puzzle/chenyun/7.png b/src/assets/images/puzzle/chenyun/7.png new file mode 100644 index 0000000..d5b8158 Binary files /dev/null and b/src/assets/images/puzzle/chenyun/7.png differ diff --git a/src/assets/images/puzzle/chenyun/8.png b/src/assets/images/puzzle/chenyun/8.png new file mode 100644 index 0000000..39ccd8f Binary files /dev/null and b/src/assets/images/puzzle/chenyun/8.png differ diff --git a/src/assets/images/puzzle/chenyun/9.png b/src/assets/images/puzzle/chenyun/9.png new file mode 100644 index 0000000..8e7eb7a Binary files /dev/null and b/src/assets/images/puzzle/chenyun/9.png differ diff --git a/src/assets/images/puzzle/chenyun/details/1.png b/src/assets/images/puzzle/chenyun/details/1.png new file mode 100644 index 0000000..e469b47 Binary files /dev/null and b/src/assets/images/puzzle/chenyun/details/1.png differ diff --git a/src/assets/images/puzzle/chenyun/details/10.png b/src/assets/images/puzzle/chenyun/details/10.png new file mode 100644 index 0000000..f6500e2 Binary files /dev/null and b/src/assets/images/puzzle/chenyun/details/10.png differ diff --git a/src/assets/images/puzzle/chenyun/details/11.png b/src/assets/images/puzzle/chenyun/details/11.png new file mode 100644 index 0000000..c295d11 Binary files /dev/null and b/src/assets/images/puzzle/chenyun/details/11.png differ diff --git a/src/assets/images/puzzle/chenyun/details/12.png b/src/assets/images/puzzle/chenyun/details/12.png new file mode 100644 index 0000000..55ddaef Binary files /dev/null and b/src/assets/images/puzzle/chenyun/details/12.png differ diff --git a/src/assets/images/puzzle/chenyun/details/13.png b/src/assets/images/puzzle/chenyun/details/13.png new file mode 100644 index 0000000..2c1a232 Binary files /dev/null and b/src/assets/images/puzzle/chenyun/details/13.png differ diff --git a/src/assets/images/puzzle/chenyun/details/14.png b/src/assets/images/puzzle/chenyun/details/14.png new file mode 100644 index 0000000..d7a1b50 Binary files /dev/null and b/src/assets/images/puzzle/chenyun/details/14.png differ diff --git a/src/assets/images/puzzle/chenyun/details/15.png b/src/assets/images/puzzle/chenyun/details/15.png new file mode 100644 index 0000000..34e4cd2 Binary files /dev/null and b/src/assets/images/puzzle/chenyun/details/15.png differ diff --git a/src/assets/images/puzzle/chenyun/details/16.png b/src/assets/images/puzzle/chenyun/details/16.png new file mode 100644 index 0000000..fea0766 Binary files /dev/null and b/src/assets/images/puzzle/chenyun/details/16.png differ diff --git a/src/assets/images/puzzle/chenyun/details/2.png b/src/assets/images/puzzle/chenyun/details/2.png new file mode 100644 index 0000000..c21dbfd Binary files /dev/null and b/src/assets/images/puzzle/chenyun/details/2.png differ diff --git a/src/assets/images/puzzle/chenyun/details/3.png b/src/assets/images/puzzle/chenyun/details/3.png new file mode 100644 index 0000000..a4f1b0f Binary files /dev/null and b/src/assets/images/puzzle/chenyun/details/3.png differ diff --git a/src/assets/images/puzzle/chenyun/details/4.png b/src/assets/images/puzzle/chenyun/details/4.png new file mode 100644 index 0000000..cdc60ec Binary files /dev/null and b/src/assets/images/puzzle/chenyun/details/4.png differ diff --git a/src/assets/images/puzzle/chenyun/details/5.png b/src/assets/images/puzzle/chenyun/details/5.png new file mode 100644 index 0000000..ac98cdb Binary files /dev/null and b/src/assets/images/puzzle/chenyun/details/5.png differ diff --git a/src/assets/images/puzzle/chenyun/details/6.png b/src/assets/images/puzzle/chenyun/details/6.png new file mode 100644 index 0000000..4f8f5ec Binary files /dev/null and b/src/assets/images/puzzle/chenyun/details/6.png differ diff --git a/src/assets/images/puzzle/chenyun/details/7.png b/src/assets/images/puzzle/chenyun/details/7.png new file mode 100644 index 0000000..34ac806 Binary files /dev/null and b/src/assets/images/puzzle/chenyun/details/7.png differ diff --git a/src/assets/images/puzzle/chenyun/details/8.png b/src/assets/images/puzzle/chenyun/details/8.png new file mode 100644 index 0000000..e0b8e91 Binary files /dev/null and b/src/assets/images/puzzle/chenyun/details/8.png differ diff --git a/src/assets/images/puzzle/chenyun/details/9.png b/src/assets/images/puzzle/chenyun/details/9.png new file mode 100644 index 0000000..830ea8c Binary files /dev/null and b/src/assets/images/puzzle/chenyun/details/9.png differ diff --git a/src/assets/images/puzzle/底图.png b/src/assets/images/puzzle/底图.png new file mode 100644 index 0000000..6d2619d Binary files /dev/null and b/src/assets/images/puzzle/底图.png differ diff --git a/src/assets/images/puzzle/收集进度.png b/src/assets/images/puzzle/收集进度.png new file mode 100644 index 0000000..7eef9d2 Binary files /dev/null and b/src/assets/images/puzzle/收集进度.png differ diff --git a/src/assets/images/puzzle/点击前往浏览.png b/src/assets/images/puzzle/点击前往浏览.png new file mode 100644 index 0000000..80d758e Binary files /dev/null and b/src/assets/images/puzzle/点击前往浏览.png differ diff --git a/src/assets/images/puzzle/碎片装饰.png b/src/assets/images/puzzle/碎片装饰.png new file mode 100644 index 0000000..a755d5a Binary files /dev/null and b/src/assets/images/puzzle/碎片装饰.png differ diff --git a/src/assets/images/puzzle/纯色遮罩.png b/src/assets/images/puzzle/纯色遮罩.png new file mode 100644 index 0000000..4c83367 Binary files /dev/null and b/src/assets/images/puzzle/纯色遮罩.png differ diff --git a/src/assets/images/puzzle/详情框.png b/src/assets/images/puzzle/详情框.png new file mode 100644 index 0000000..d599275 Binary files /dev/null and b/src/assets/images/puzzle/详情框.png differ diff --git a/src/assets/images/puzzle/选中碎片遮罩.png b/src/assets/images/puzzle/选中碎片遮罩.png new file mode 100644 index 0000000..e0e256d Binary files /dev/null and b/src/assets/images/puzzle/选中碎片遮罩.png differ diff --git a/src/components/animation/FlyToAnimationManager.tsx b/src/components/animation/FlyToAnimationManager.tsx index 115914d..584067a 100644 --- a/src/components/animation/FlyToAnimationManager.tsx +++ b/src/components/animation/FlyToAnimationManager.tsx @@ -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; diff --git a/src/components/exhibit/ExhibitOverlay.tsx b/src/components/exhibit/ExhibitOverlay.tsx index bf4fa2f..237fb9d 100644 --- a/src/components/exhibit/ExhibitOverlay.tsx +++ b/src/components/exhibit/ExhibitOverlay.tsx @@ -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) => { - // 如果禁用了拖拽,直接返回,不处理拖拽事件 - 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} >
{ +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(null); const previousPathRef = useRef(""); + // 碎片动画相关状态 + const [showPuzzlePiece, setShowPuzzlePiece] = useState(false); + const [puzzlePieceId, setPuzzlePieceId] = useState(null); + const [isAnimationPlaying, setIsAnimationPlaying] = useState(false); + const [isFlying, setIsFlying] = useState(false); // 标记是否正在飞行 + const puzzlePieceRef = useRef(null); + const animationInProgressRef = useRef(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} > -
- {/* 内容层 - 始终渲染,根据状态控制显示 */} -
- -
- - {/* 视频层 - 只在播放时渲染,z-index 动态调整 */} - {isVideoPlaying && ( + +
+ {/* 内容层 - 始终渲染,根据状态控制显示 */}
-
- )} -
+ + {/* 视频层 - 只在播放时渲染,z-index 动态调整 */} + {isVideoPlaying && ( +
+
+ )} + + {/* 拼图系统弹窗 */} + {residence && ( + setIsPuzzleSystemVisible(false)} + residence={residence} + /> + )} + + {/* 碎片显示 - 用于飞行动画,显示在屏幕正中间 */} + {showPuzzlePiece && puzzlePieceId && residence && ( +
); }; +const ResidenceLayout = () => { + return ( + + + + ); +}; + export default ResidenceLayout; diff --git a/src/components/puzzle/PuzzlePieceDisplay.tsx b/src/components/puzzle/PuzzlePieceDisplay.tsx new file mode 100644 index 0000000..d5a91d0 --- /dev/null +++ b/src/components/puzzle/PuzzlePieceDisplay.tsx @@ -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( + ({ residence, pieceId, showGlow = true, className = "" }, ref) => { + // 碎片图片路径 + const pieceImage = getStaticImage(`puzzle/${residence}/${pieceId}`); + + return ( +
+ + {`碎片 +
+ ); + } +); + +PuzzlePieceDisplay.displayName = "PuzzlePieceDisplay"; + +export default PuzzlePieceDisplay; + diff --git a/src/components/puzzle/PuzzlePiecePortal.tsx b/src/components/puzzle/PuzzlePiecePortal.tsx new file mode 100644 index 0000000..88cf249 --- /dev/null +++ b/src/components/puzzle/PuzzlePiecePortal.tsx @@ -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( + ({ residence, pieceId, hidden = false }, ref) => { + return createPortal( + , + document.body + ); + } +); + +PuzzlePiecePortal.displayName = "PuzzlePiecePortal"; + +export default PuzzlePiecePortal; diff --git a/src/components/puzzle/PuzzleSystem.tsx b/src/components/puzzle/PuzzleSystem.tsx new file mode 100644 index 0000000..edecac1 --- /dev/null +++ b/src/components/puzzle/PuzzleSystem.tsx @@ -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(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 ( +
{ + // 点击背景关闭 + if (e.target === e.currentTarget) { + onClose(); + } + }} + > + {/* 背景图片 */} +
+ {/* 关闭按钮 */} + 关闭 + + {/* 收集进度 */} +
+
+ + 陈云故居碎片 + + 碎片装饰 +
+
+ {/* 收集进度 */} + {collectProgress.toFixed(2) == "1.00" + ? "已集齐" + : `收集进度${collectProgress.toFixed(2)}%`} +
+
+ + {/* 完整拼图底图 - 作为背景显示所有碎片拼好的样子 */} +
+
+
+ {/* 拼图容器 - 使用绝对定位,根据配置放置每个碎片 */} +
+ {pieces.map((pieceId) => { + const isUnlocked = isPuzzleUnlocked(residence, pieceId); + const layout = getPuzzlePieceLayout(residence, pieceId); + + // 碎片图片路径 + const pieceImage = getStaticImage( + `puzzle/${residence}/${pieceId}` + ); + + return ( +
handlePieceClick(pieceId)} + > +
+ {/* 碎片图片 - 始终显示,未解锁的用CSS变黑 */} + {`碎片 + + {/* 选中效果遮罩 - 只对已解锁的碎片显示 */} + {/* {isSelected && isUnlocked && ( +
+ )} */} +
+
+ ); + })} +
+
+
+ {selectedPieceData && ( +
+ {/* 标题 */} + {selectedPieceData && ( +
+ {/* 空格换行 */} + {selectedPieceData.title + .split(" ") + .map((word: string, index: number) => ( +
+ {word} + {index < + selectedPieceData.title.split(" ").length - 1 && + " "} +
+ ))} +
+ )} + {/* 对应页面图片 */} +
+ {selectedPieceData.title} +
+ {/* 前往按钮 */} + {selectedPieceData && ( + 前往按钮 + )} +
+ )} +
+
+
+
+
+ ); +}; + +export default PuzzleSystem; diff --git a/src/components/puzzle/index.ts b/src/components/puzzle/index.ts new file mode 100644 index 0000000..609ff1c --- /dev/null +++ b/src/components/puzzle/index.ts @@ -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"; + diff --git a/src/config/puzzleData.ts b/src/config/puzzleData.ts new file mode 100644 index 0000000..9491918 --- /dev/null +++ b/src/config/puzzleData.ts @@ -0,0 +1,138 @@ +/** + * 拼图碎片数据配置 + * 存储每个碎片的标题、场景图片等数据 + */ + +export interface PuzzlePieceData { + /** 碎片标题 */ + title: string; + /** 对应的场景图片路径(相对于 puzzle/ 目录) */ + detailImage: string; + // 对应的场景路由 + route: string; +} + +/** + * 每个故居的拼图碎片数据配置 + * 格式:{ residence: { pieceId: PuzzlePieceData } } + */ +export const puzzleDataConfig: Record< + string, + Record +> = { + chenyun: { + // 碎片1 - 示例数据,请根据实际情况修改 + 1: { + title: "水乡少年·艰率成长", + detailImage: "chenyun/details/1", + route: "place1", + }, + // 碎片2 + 2: { + title: "陈云就读 青浦县乙种商业学校", + detailImage: "chenyun/details/2", + route: "place2", + }, + // 碎片3 + 3: { + title: "陈云就读颜安小学", + detailImage: "chenyun/details/3", + route: "place2", + }, + // 碎片4 + 4: { + title: "陈云在 上海商务印书馆当学徒", + detailImage: "chenyun/details/4", + route: "place3", + }, + // 碎片5 + 5: { + title: "陈云加入中国共产党", + detailImage: "chenyun/details/5", + route: "place3", + }, + // 碎片6 + 6: { + title: "陈云被选为 中央政治局委员、常委", + detailImage: "chenyun/details/6", + route: "place4", + }, + // 碎片7 + 7: { + title: "陈云任 西北财经办事处副主任", + detailImage: "chenyun/details/7", + route: "place5", + }, + // 碎片8 + 8: { + title: "陈云主持 陕甘宁边区财政工作", + detailImage: "chenyun/details/8", + route: "place5", + }, + // 碎片9 + 9: { + title: "恢复时期·经济奠基", + detailImage: "chenyun/details/9", + route: "place6", + }, + // 碎片10 + 10: { + title: "银元之战", + detailImage: "chenyun/details/10", + route: "place7", + }, + // 碎片11 + 11: { + title: '陈云起草的关于发展国民 经济"一五"计划报告的手稿', + detailImage: "chenyun/details/11", + route: "place8", + }, + // 碎片12 + 12: { + title: "陈云赴宝钢考察", + detailImage: "chenyun/details/12", + route: "place9", + }, + // 碎片13 + 13: { + title: "体悟·银元之战", + detailImage: "chenyun/details/13", + route: "reflection", + }, + // 碎片14 + 14: { + title: "体悟·米棉之战", + detailImage: "chenyun/details/14", + route: "reflection", + }, + // 碎片15 + 15: { + title: "陈云经济思想的核心内容", + detailImage: "chenyun/details/15", + route: "internalization", + }, + // 碎片16 + 16: { + title: "陈云精神", + detailImage: "chenyun/details/16", + route: "internalization", + }, + }, + // 后续可以添加其他故居的数据 + // sunzhongshan: { ... }, +}; + +/** + * 获取碎片的数据配置 + * @param residence 故居名称 + * @param pieceId 碎片ID + * @returns 碎片数据,如果不存在则返回默认值 + */ +export const getPuzzlePieceData = ( + residence: string, + pieceId: number +): PuzzlePieceData | null => { + const residenceConfig = puzzleDataConfig[residence]; + const pieceData = residenceConfig?.[pieceId]; + return pieceData || null; +}; diff --git a/src/config/puzzleExhibitMapping.ts b/src/config/puzzleExhibitMapping.ts new file mode 100644 index 0000000..d3ed39a --- /dev/null +++ b/src/config/puzzleExhibitMapping.ts @@ -0,0 +1,45 @@ +/** + * 展品与拼图碎片映射配置 + * 定义每个展品对应解锁的碎片ID + * + * 格式:{ residence: { exhibitId: pieceId } } + * 示例:chenyun: { 1: 1 } 表示陈云故居的展品1对应碎片1 + */ + +export const puzzleExhibitMapping: Record< + string, + Record +> = { + chenyun: { + // 展品1 对应 碎片1 + 1: 1, + // 展品2 对应 碎片2 + 2: 2, + // 后续可以继续添加其他展品的映射 + // 3: 3, + // 4: 4, + }, + // 后续可以添加其他故居的映射 + // sunzhongshan: { + // 1: 1, + // }, +}; + +/** + * 获取展品对应的碎片ID + * @param residence 故居名称 + * @param exhibitId 展品ID + * @returns 碎片ID,如果不存在则返回 null + */ +export const getPuzzlePieceByExhibit = ( + residence: string, + exhibitId: number +): number | null => { + const residenceMapping = puzzleExhibitMapping[residence]; + if (!residenceMapping) { + return null; + } + + return residenceMapping[exhibitId] ?? null; +}; + diff --git a/src/config/puzzleLayout.ts b/src/config/puzzleLayout.ts new file mode 100644 index 0000000..1fd2608 --- /dev/null +++ b/src/config/puzzleLayout.ts @@ -0,0 +1,146 @@ +/** + * 拼图碎片布局配置 + * 用于手动调整每个碎片的位置和大小 + * + * 格式说明: + * - position: { x, y } - 碎片在容器中的位置(百分比或像素) + * - size: { width, height } - 碎片的尺寸(百分比或像素) + * - 可以使用百分比(如 "50%")或像素值(如 "200px") + */ + +export interface PuzzlePieceLayout { + /** 碎片位置 */ + position: { + x: string; // 例如: "25%" 或 "200px" + y: string; // 例如: "30%" 或 "150px" + }; + /** 碎片尺寸 */ + size: { + width: string; // 例如: "20%" 或 "150px" + height: string; // 例如: "20%" 或 "150px" + }; +} + +/** + * 每个故居的拼图布局配置 + * 格式:{ residence: { pieceId: PuzzlePieceLayout } } + */ +export const puzzleLayoutConfig: Record< + string, + Record +> = { + chenyun: { + // 碎片1 - 示例配置,请根据实际图片调整 + 1: { + position: { x: "0%", y: "0%" }, + size: { width: "25%", height: "25%" }, + }, + // 碎片2 + 2: { + position: { x: "23.3%", y: "0%" }, + size: { width: "25%", height: "25%" }, + }, + // 碎片3 + 3: { + position: { x: "39.8%", y: "0%" }, + size: { width: "25%", height: "25%" }, + }, + // 碎片4 + 4: { + position: { x: "70.8%", y: "0%" }, + size: { width: "25%", height: "25%" }, + }, + // 碎片5 + 5: { + position: { x: "0%", y: "15%" }, + size: { width: "25%", height: "25%" }, + }, + // 碎片6 + 6: { + position: { x: "16%", y: "23.2%" }, + size: { width: "25%", height: "25%" }, + }, + // 碎片7 + 7: { + position: { x: "47.2%", y: "15.2%" }, + size: { width: "25%", height: "25%" }, + }, + // 碎片8 + 8: { + position: { x: "63.4%", y: "23%" }, + size: { width: "25%", height: "25%" }, + }, + // 碎片9 + 9: { + position: { x: "0%", y: "47%" }, + size: { width: "25%", height: "25%" }, + }, + // 碎片10 + 10: { + position: { x: "23.3%", y: "39.1%" }, + size: { width: "25%", height: "25%" }, + }, + // 碎片11 + 11: { + position: { x: "39.8%", y: "47%" }, + size: { width: "25%", height: "25%" }, + }, + // 碎片12 + 12: { + position: { x: "70.9%", y: "39.1%" }, + size: { width: "25%", height: "25%" }, + }, + // 碎片13 + 13: { + position: { x: "0%", y: "63%" }, + size: { width: "25%", height: "25%" }, + }, + // 碎片14 + 14: { + position: { x: "16%", y: "71%" }, + size: { width: "25%", height: "25%" }, + }, + // 碎片15 + 15: { + position: { x: "47%", y: "63%" }, + size: { width: "25%", height: "25%" }, + }, + // 碎片16 + 16: { + position: { x: "63.4%", y: "71%" }, + size: { width: "25%", height: "25%" }, + }, + }, + // 后续可以添加其他故居的配置 + // sunzhongshan: { ... }, +}; + +/** + * 获取碎片的布局配置 + * @param residence 故居名称 + * @param pieceId 碎片ID + * @returns 布局配置,如果不存在则返回默认值 + */ +export const getPuzzlePieceLayout = ( + residence: string, + pieceId: number +): PuzzlePieceLayout => { + const residenceConfig = puzzleLayoutConfig[residence]; + const pieceConfig = residenceConfig?.[pieceId]; + if (pieceConfig) { + return pieceConfig; + } + // 默认布局(4x4网格) + const row = Math.floor((pieceId - 1) / 4); + const col = (pieceId - 1) % 4; + return { + position: { + x: `${col * 25}%`, + y: `${row * 25}%`, + }, + size: { + width: "25%", + height: "25%", + }, + }; +}; diff --git a/src/config/puzzleMapping.ts b/src/config/puzzleMapping.ts new file mode 100644 index 0000000..c5816c9 --- /dev/null +++ b/src/config/puzzleMapping.ts @@ -0,0 +1,52 @@ +/** + * 拼图碎片与页面路由映射配置 + * 格式:故居名 -> 碎片ID -> 路由路径(相对于/:moduleName/) + * + * 示例: + * - 碎片1 对应 place1 页面 + * - 碎片2 对应 place2 页面 + * - 碎片3 对应 reflection 页面 + * - 碎片4 对应 internalization 页面 + * + * 注意:路由路径中的 :moduleName 会在使用时替换为实际的 moduleName + */ +export const puzzleMapping: Record> = { + chenyun: { + // 示例:碎片1 对应 place1 页面 + 1: "chenyun/place1", + // 后续可以继续添加其他碎片的映射 + // 2: "chenyun/place2", + // 3: "chenyun/reflection", + // 4: "chenyun/internalization", + }, + // 后续可以添加其他故居的映射 + // sunzhongshan: { + // 1: "sunzhongshan/place1", + // }, +}; + +/** + * 获取碎片对应的路由路径 + * @param residence 故居名称 + * @param pieceId 碎片ID + * @param moduleName 模块名称(用于替换路由中的 :moduleName) + * @returns 完整的路由路径,如果不存在则返回 null + */ +export const getPuzzleRoute = ( + residence: string, + pieceId: number, + moduleName: string +): string | null => { + const residenceMapping = puzzleMapping[residence]; + if (!residenceMapping) { + return null; + } + + const route = residenceMapping[pieceId]; + if (!route) { + return null; + } + + return `/${moduleName}/${route}`; +}; + diff --git a/src/contexts/PuzzleAnimationContext.tsx b/src/contexts/PuzzleAnimationContext.tsx new file mode 100644 index 0000000..d3d512f --- /dev/null +++ b/src/contexts/PuzzleAnimationContext.tsx @@ -0,0 +1,34 @@ +import { createContext, useContext, ReactNode } from "react"; + +interface PuzzleAnimationContextType { + /** 触发碎片动画,传递碎片ID */ + triggerPuzzleAnimation: (pieceId: number) => void; +} + +const PuzzleAnimationContext = createContext( + null +); + +export const PuzzleAnimationProvider = ({ + children, + triggerPuzzleAnimation, +}: { + children: ReactNode; + triggerPuzzleAnimation: (pieceId: number) => void; +}) => { + return ( + + {children} + + ); +}; + +export const usePuzzleAnimation = () => { + const context = useContext(PuzzleAnimationContext); + if (!context) { + throw new Error( + "usePuzzleAnimation must be used within PuzzleAnimationProvider" + ); + } + return context; +}; diff --git a/src/contexts/PuzzleBoxContext.tsx b/src/contexts/PuzzleBoxContext.tsx new file mode 100644 index 0000000..c312671 --- /dev/null +++ b/src/contexts/PuzzleBoxContext.tsx @@ -0,0 +1,26 @@ +import { createContext, useContext, useRef, ReactNode } from "react"; + +interface PuzzleBoxContextType { + puzzleBoxRef: React.RefObject; +} + +const PuzzleBoxContext = createContext(null); + +export const PuzzleBoxProvider = ({ children }: { children: ReactNode }) => { + const puzzleBoxRef = useRef(null); + + return ( + + {children} + + ); +}; + +export const usePuzzleBox = () => { + const context = useContext(PuzzleBoxContext); + if (!context) { + throw new Error("usePuzzleBox must be used within PuzzleBoxProvider"); + } + return context; +}; + diff --git a/src/pages/chenyun/internalization.tsx b/src/pages/chenyun/internalization.tsx index 08f34ff..c7c5b1a 100644 --- a/src/pages/chenyun/internalization.tsx +++ b/src/pages/chenyun/internalization.tsx @@ -13,7 +13,7 @@ import { getBackgroundImage, getPublicPath, getStaticImage } from "@/lib/utils"; import { SuccessAlert, DragQuizPanel } from "@/components/layout"; import { MultipleChoiceQuizPanel } from "@/components/layout/quiz"; import { GuideSteps, GuideStepsItem } from "@/components"; - +import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext"; type QuizOption = { value: string; label: string; @@ -100,6 +100,8 @@ const QUIZ_ANCHORS: QuizAnchor[] = [ const ChenyunInternalization = () => { const navigate = useNavigate(); const { moduleName } = useParams<{ moduleName: string }>(); + // 获取碎片动画触发方法 + const { triggerPuzzleAnimation } = usePuzzleAnimation(); const [activeSuccess, setActiveSuccess] = useState(null); const [successContent, setSuccessContent] = useState(""); const [resumeCallback, setResumeCallback] = useState<(() => void) | null>( @@ -196,6 +198,7 @@ const ChenyunInternalization = () => { onContinue={() => { setActiveSuccess(null); setSuccessContent(""); + triggerPuzzleAnimation(16); setDragQuizValues((prev) => { const next = { ...prev }; delete next[item.id]; @@ -242,6 +245,7 @@ const ChenyunInternalization = () => { if (isCorrect) { setActiveSuccess(item.id); setSuccessContent("回答正确,请继续探索。"); + if (item.id === "quiz-1") triggerPuzzleAnimation(15); setResumeCallback(() => resume); } }} diff --git a/src/pages/chenyun/place/place1.tsx b/src/pages/chenyun/place/place1.tsx index aaff584..bfe64aa 100644 --- a/src/pages/chenyun/place/place1.tsx +++ b/src/pages/chenyun/place/place1.tsx @@ -11,6 +11,8 @@ import { useNavigate, useParams } from "react-router-dom"; import { getBackgroundImage, getStaticImage } from "@/lib/utils"; import { useState } from "react"; import Cursor from "@/components/cursor"; +import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext"; +import { useChenyunStore } from "@/store/chenyunStore"; // starName 固定为 chenyun const starName = "chenyun"; @@ -28,6 +30,9 @@ const ChenyunPlace1 = () => { moduleName: string; }>(); + // 获取碎片动画触发方法 + const { triggerPuzzleAnimation } = usePuzzleAnimation(); + // 学习引导弹窗 const [showGuideModal, setShowGuideModal] = useState(true); // 对话结束后的学习引导弹窗 @@ -39,11 +44,18 @@ const ChenyunPlace1 = () => { const [visible, setVisible] = useState(false); // 当前激活的展品 ID(只存 ID,不直接存路径,便于作为唯一标识) const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null); - // 已浏览展品的 ID 集合 - const [visited, setVisited] = useState>(new Set()); + + // 使用持久化的访问状态 + const place = "place1"; + const { markExhibitVisited, getVisitedExhibits } = useChenyunStore(); + + // 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码 + const visitedExhibits = getVisitedExhibits(place); + const visited = new Set(visitedExhibits); const handleOpenExhibit = (index: 1 | 2) => { setActiveExhibitId(index); + // ExhibitOverlay 打开时会自动通过 onOpen 通知 ResidenceLayout 执行动画 }; const handleStartLearning = () => { @@ -128,13 +140,16 @@ const ChenyunPlace1 = () => { { + // 示例:展品1对应碎片1,展品2对应碎片2 + // 根据实际需求调用 triggerPuzzleAnimation + if (activeExhibitId === 1) { + triggerPuzzleAnimation(1); + } + }} onClose={() => { if (activeExhibitId !== null) { - setVisited((prev) => { - const next = new Set(prev); - next.add(activeExhibitId); - return next; - }); + markExhibitVisited(place, activeExhibitId); } setActiveExhibitId(null); }} diff --git a/src/pages/chenyun/place/place2.tsx b/src/pages/chenyun/place/place2.tsx index d0cd910..6c1e727 100644 --- a/src/pages/chenyun/place/place2.tsx +++ b/src/pages/chenyun/place/place2.tsx @@ -10,6 +10,8 @@ import { } from "@/components"; import { getBackgroundImage, getStaticImage } from "@/lib/utils"; import { useNavigate, useParams } from "react-router-dom"; +import { useChenyunStore } from "@/store/chenyunStore"; +import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext"; // starName 固定为 chenyun const starName = "chenyun"; @@ -31,10 +33,19 @@ const ChenyunPlace2 = () => { moduleName: string; }>(); + // 获取碎片动画触发方法 + const { triggerPuzzleAnimation } = usePuzzleAnimation(); + // 当前激活展品 ID,只存 ID 做唯一标识 const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null); - // 已浏览展品 ID 集合 - const [visited, setVisited] = useState>(new Set()); + + // 使用持久化的访问状态 + const place = "place2"; + const { markExhibitVisited, getVisitedExhibits } = useChenyunStore(); + + // 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码 + const visitedExhibits = getVisitedExhibits(place); + const visited = new Set(visitedExhibits); // GuideSteps 显示状态 const [guideVisible, setGuideVisible] = useState(false); // GuideSteps 当前步骤 @@ -128,13 +139,16 @@ const ChenyunPlace2 = () => { activeExhibitId ? exhibitDescriptionMap[activeExhibitId] : undefined } autoPlayDescription={activeExhibitId === 1} + onOpen={() => { + if (activeExhibitId === 1) { + triggerPuzzleAnimation(2); + } else if (activeExhibitId === 2) { + triggerPuzzleAnimation(3); + } + }} onClose={() => { if (activeExhibitId !== null) { - setVisited((prev) => { - const next = new Set(prev); - next.add(activeExhibitId); - return next; - }); + markExhibitVisited(place, activeExhibitId); } setActiveExhibitId(null); }} diff --git a/src/pages/chenyun/place/place3.tsx b/src/pages/chenyun/place/place3.tsx index 37d5433..360872a 100644 --- a/src/pages/chenyun/place/place3.tsx +++ b/src/pages/chenyun/place/place3.tsx @@ -11,6 +11,8 @@ import { } from "@/components"; import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils"; import { useNavigate, useParams } from "react-router-dom"; +import { useChenyunStore } from "@/store/chenyunStore"; +import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext"; // starName 固定为 chenyun const starName = "chenyun"; @@ -26,8 +28,18 @@ const ChenyunPlace3 = () => { moduleName: string; }>(); - const [visited, setVisited] = useState>(new Set()); + // 获取碎片动画触发方法 + const { triggerPuzzleAnimation } = usePuzzleAnimation(); + const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null); + + // 使用持久化的访问状态 + const place = "place3"; + const { markExhibitVisited, getVisitedExhibits } = useChenyunStore(); + + // 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码 + const visitedExhibits = getVisitedExhibits(place); + const visited = new Set(visitedExhibits); const [videoVisible, setVideoVisible] = useState(false); // GuideSteps 显示状态 const [guideVisible, setGuideVisible] = useState(false); @@ -125,7 +137,8 @@ const ChenyunPlace3 = () => { controls={false} onEnded={() => { setVideoVisible(false); - setVisited((p) => new Set(p).add(2)); + markExhibitVisited(place, 2); + triggerPuzzleAnimation(5); }} />
@@ -135,13 +148,14 @@ const ChenyunPlace3 = () => { { + if (activeExhibitId === 1) { + triggerPuzzleAnimation(4); + } + }} onClose={() => { if (activeExhibitId !== null) { - setVisited((prev) => { - const next = new Set(prev); - next.add(activeExhibitId); - return next; - }); + markExhibitVisited(place, activeExhibitId); } setActiveExhibitId(null); }} diff --git a/src/pages/chenyun/place/place4.tsx b/src/pages/chenyun/place/place4.tsx index 6bd9166..dcc2b2d 100644 --- a/src/pages/chenyun/place/place4.tsx +++ b/src/pages/chenyun/place/place4.tsx @@ -10,6 +10,8 @@ import { } from "@/components"; import { getBackgroundImage, getStaticImage } from "@/lib/utils"; import { useNavigate, useParams } from "react-router-dom"; +import { useChenyunStore } from "@/store/chenyunStore"; +import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext"; // starName 固定为 chenyun const starName = "chenyun"; @@ -25,8 +27,19 @@ const ChenyunPlace4 = () => { moduleName: string; }>(); - const [visited, setVisited] = useState>(new Set()); + // 获取碎片动画触发方法 + const { triggerPuzzleAnimation } = usePuzzleAnimation(); + const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null); + + // 使用持久化的访问状态 + const place = "place4"; + const { isExhibitVisited, markExhibitVisited, getVisitedExhibits } = + useChenyunStore(); + + // 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码 + const visitedExhibits = getVisitedExhibits(place); + const visited = new Set(visitedExhibits); // 展品1引导对话 const [guideVisible, setGuideVisible] = useState(false); const [guideStep, setGuideStep] = useState(0); @@ -124,13 +137,14 @@ const ChenyunPlace4 = () => { { + if (activeExhibitId === 1) { + triggerPuzzleAnimation(6); + } + }} onClose={() => { if (activeExhibitId !== null) { - setVisited((prev) => { - const next = new Set(prev); - next.add(activeExhibitId); - return next; - }); + markExhibitVisited(place, activeExhibitId); } setActiveExhibitId(null); }} diff --git a/src/pages/chenyun/place/place5.tsx b/src/pages/chenyun/place/place5.tsx index 56474d8..f79fc83 100644 --- a/src/pages/chenyun/place/place5.tsx +++ b/src/pages/chenyun/place/place5.tsx @@ -10,6 +10,8 @@ import { } from "@/components"; import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils"; import { useNavigate, useParams } from "react-router-dom"; +import { useChenyunStore } from "@/store/chenyunStore"; +import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext"; // starName 固定为 chenyun const starName = "chenyun"; @@ -32,10 +34,21 @@ const ChenyunPlace5 = () => { moduleName: string; }>(); - const [visited, setVisited] = useState>(new Set()); + // 获取碎片动画触发方法 + const { triggerPuzzleAnimation } = usePuzzleAnimation(); + const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | 3 | null>( null ); + + // 使用持久化的访问状态 + const place = "place5"; + const { isExhibitVisited, markExhibitVisited, getVisitedExhibits } = + useChenyunStore(); + + // 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码 + const visitedExhibits = getVisitedExhibits(place); + const visited = new Set(visitedExhibits); const [videoVisible3, setVideoVisible3] = useState(false); // 学习引导弹窗 const [showGuideModal, setShowGuideModal] = useState(true); @@ -127,11 +140,8 @@ const ChenyunPlace5 = () => { showSkipButton onEnded={() => { setVideoVisible3(false); - setVisited((prev) => { - const next = new Set(prev); - next.add(3); - return next; - }); + markExhibitVisited(place, 3); + triggerPuzzleAnimation(8); }} /> )} @@ -144,13 +154,14 @@ const ChenyunPlace5 = () => { activeExhibitId ? exhibitDescriptionMap[activeExhibitId] : undefined } autoPlayDescription={activeExhibitId === 2} + onOpen={() => { + if (activeExhibitId === 2) { + triggerPuzzleAnimation(7); + } + }} onClose={() => { if (activeExhibitId !== null) { - setVisited((prev) => { - const next = new Set(prev); - next.add(activeExhibitId); - return next; - }); + markExhibitVisited(place, activeExhibitId); } setActiveExhibitId(null); }} diff --git a/src/pages/chenyun/place/place6.tsx b/src/pages/chenyun/place/place6.tsx index 24b414c..ba61ffe 100644 --- a/src/pages/chenyun/place/place6.tsx +++ b/src/pages/chenyun/place/place6.tsx @@ -10,6 +10,8 @@ import { } from "@/components"; import { getBackgroundImage, getStaticImage } from "@/lib/utils"; import { useNavigate, useParams } from "react-router-dom"; +import { useChenyunStore } from "@/store/chenyunStore"; +import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext"; // starName 固定为 chenyun const starName = "chenyun"; @@ -24,8 +26,19 @@ const ChenyunPlace6 = () => { moduleName: string; }>(); - const [visited, setVisited] = useState>(new Set()); + // 获取碎片动画触发方法 + const { triggerPuzzleAnimation } = usePuzzleAnimation(); + const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null); + + // 使用持久化的访问状态 + const place = "place6"; + const { isExhibitVisited, markExhibitVisited, getVisitedExhibits } = + useChenyunStore(); + + // 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码 + const visitedExhibits = getVisitedExhibits(place); + const visited = new Set(visitedExhibits); const [guideVisible, setGuideVisible] = useState(false); const [guideStep, setGuideStep] = useState(0); // 学习引导弹窗 @@ -101,13 +114,14 @@ const ChenyunPlace6 = () => { { + if (activeExhibitId === 1) { + triggerPuzzleAnimation(9); + } + }} onClose={() => { if (activeExhibitId !== null) { - setVisited((prev) => { - const next = new Set(prev); - next.add(activeExhibitId); - return next; - }); + markExhibitVisited(place, activeExhibitId); } setActiveExhibitId(null); }} diff --git a/src/pages/chenyun/place/place7.tsx b/src/pages/chenyun/place/place7.tsx index db6357f..401203f 100644 --- a/src/pages/chenyun/place/place7.tsx +++ b/src/pages/chenyun/place/place7.tsx @@ -16,6 +16,8 @@ import { SuccessAlert, DragQuizPanel } from "@/components/layout"; import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils"; import { useNavigate, useParams } from "react-router-dom"; import type { VideoRef } from "@/components/video"; +import { useChenyunStore } from "@/store/chenyunStore"; +import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext"; // starName 固定为 chenyun const starName = "chenyun"; @@ -53,8 +55,19 @@ const ChenyunPlace7 = () => { moduleName: string; }>(); - const [visited, setVisited] = useState>(new Set()); + // 获取碎片动画触发方法 + const { triggerPuzzleAnimation } = usePuzzleAnimation(); + const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null); + + // 使用持久化的访问状态 + const place = "place7"; + const { isExhibitVisited, markExhibitVisited, getVisitedExhibits } = + useChenyunStore(); + + // 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码 + const visitedExhibits = getVisitedExhibits(place); + const visited = new Set(visitedExhibits); const [videoVisible1, setVideoVisible1] = useState(false); const [videoPlaying, setVideoPlaying] = useState(false); const videoRef = useRef(null); @@ -231,11 +244,8 @@ const ChenyunPlace7 = () => { showSkipButton onEnded={() => { setVideoPlaying(false); - setVisited((prev) => { - const next = new Set(prev); - next.add(1); - return next; - }); + markExhibitVisited(place, 1); + triggerPuzzleAnimation(10); // 视频播放完成后,出现单选题 setQuizVisible(true); }} @@ -349,11 +359,7 @@ const ChenyunPlace7 = () => { src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""} onClose={() => { if (activeExhibitId !== null) { - setVisited((prev) => { - const next = new Set(prev); - next.add(activeExhibitId); - return next; - }); + markExhibitVisited(place, activeExhibitId); } setActiveExhibitId(null); }} diff --git a/src/pages/chenyun/place/place8.tsx b/src/pages/chenyun/place/place8.tsx index 5d4639e..82b5f89 100644 --- a/src/pages/chenyun/place/place8.tsx +++ b/src/pages/chenyun/place/place8.tsx @@ -10,6 +10,8 @@ import { } from "@/components"; import { getBackgroundImage, getStaticImage } from "@/lib/utils"; import { useNavigate, useParams } from "react-router-dom"; +import { useChenyunStore } from "@/store/chenyunStore"; +import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext"; // starName 固定为 chenyun const starName = "chenyun"; @@ -25,8 +27,19 @@ const ChenyunPlace8 = () => { moduleName: string; }>(); - const [visited, setVisited] = useState>(new Set()); + // 获取碎片动画触发方法 + const { triggerPuzzleAnimation } = usePuzzleAnimation(); + const [activeExhibitId, setActiveExhibitId] = useState<1 | 2 | null>(null); + + // 使用持久化的访问状态 + const place = "place8"; + const { isExhibitVisited, markExhibitVisited, getVisitedExhibits } = + useChenyunStore(); + + // 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码 + const visitedExhibits = getVisitedExhibits(place); + const visited = new Set(visitedExhibits); // 展品2的对话引导 const [guideVisible, setGuideVisible] = useState(false); const [guideStep, setGuideStep] = useState(0); @@ -117,6 +130,11 @@ const ChenyunPlace8 = () => { { + if (activeExhibitId === 1) { + triggerPuzzleAnimation(11); + } + }} description={ activeExhibitId === 1 ? `打赢"银元之战""两白一黑"战役的过程彰显了中国共产党治理经济的卓越能力!这里有一份"一五"计划报告的手稿,陈云在"一五"计划的编制中做出了重大贡献。一九五四年,陈云的主要工作之一,就是主持编制第一个五年计划纲要。这次编制工作是在前四次基础上进行的,陈云提出计划编制工作中要注意几个问题:首先是工业投资金额,其次是市场供应和人民生活,第三是农业发展速度,这三个问题对编制计划有指导意义。第一个五年计划纲要草案的重点是优先发展重工业,特别是156项重点工程。当时陈云重点抓重工业建设和粮食保障,通过"统购统销"稳定粮价,为重点工程项目筑牢根基,亲手铺就了我国工业化的起步之路。` @@ -124,11 +142,7 @@ const ChenyunPlace8 = () => { } onClose={() => { if (activeExhibitId !== null) { - setVisited((prev) => { - const next = new Set(prev); - next.add(activeExhibitId); - return next; - }); + markExhibitVisited(place, activeExhibitId); } setActiveExhibitId(null); }} diff --git a/src/pages/chenyun/place/place9.tsx b/src/pages/chenyun/place/place9.tsx index 994487b..9f00057 100644 --- a/src/pages/chenyun/place/place9.tsx +++ b/src/pages/chenyun/place/place9.tsx @@ -12,6 +12,8 @@ import { import { getBackgroundImage, getStaticImage, getPublicPath } from "@/lib/utils"; import { useNavigate, useParams } from "react-router-dom"; import type { VideoRef } from "@/components/video"; +import { useChenyunStore } from "@/store/chenyunStore"; +import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext"; // starName 固定为 chenyun const starName = "chenyun"; @@ -26,8 +28,19 @@ const ChenyunPlace9 = () => { moduleName: string; }>(); - const [visited, setVisited] = useState>(new Set()); + // 获取碎片动画触发方法 + const { triggerPuzzleAnimation } = usePuzzleAnimation(); + const [activeExhibitId, setActiveExhibitId] = useState<1 | null>(null); + + // 使用持久化的访问状态 + const place = "place9"; + const { isExhibitVisited, markExhibitVisited, getVisitedExhibits } = + useChenyunStore(); + + // 从 store 获取已访问的展品,转换为 Set 用于兼容现有代码 + const visitedExhibits = getVisitedExhibits(place); + const visited = new Set(visitedExhibits); // 视频播放状态 const [videoVisible, setVideoVisible] = useState(false); const [videoPlaying, setVideoPlaying] = useState(false); @@ -127,14 +140,11 @@ const ChenyunPlace9 = () => { skipText="跳过" onEnded={() => { setVideoPlaying(false); - setVisited((prev) => { - const next = new Set(prev); - next.add(1); - return next; - }); + markExhibitVisited(place, 1); // 视频播放完成后,显示对话引导 setGuideVisible(true); setGuideStep(0); + triggerPuzzleAnimation(12); }} />
@@ -167,11 +177,7 @@ const ChenyunPlace9 = () => { src={activeExhibitId ? exhibitSrcMap[activeExhibitId] : ""} onClose={() => { if (activeExhibitId !== null) { - setVisited((prev) => { - const next = new Set(prev); - next.add(activeExhibitId); - return next; - }); + markExhibitVisited(place, activeExhibitId); } setActiveExhibitId(null); }} diff --git a/src/pages/chenyun/reflection/index.tsx b/src/pages/chenyun/reflection/index.tsx index 7964374..4b93235 100644 --- a/src/pages/chenyun/reflection/index.tsx +++ b/src/pages/chenyun/reflection/index.tsx @@ -7,7 +7,7 @@ import ErrorAlert from "@/components/layout/alert/error-alert"; import ClueOverlay from "@/components/layout/clue"; import type { ClueData } from "@/components/layout"; import { getBackgroundImage, getStaticImage } from "@/lib/utils"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { WELCOME_TEXT, starName } from "./constants"; import { useClueStates } from "./shared/useClueStates"; @@ -17,6 +17,7 @@ import { MimianBattle } from "./mimian/MimianBattle"; import type { FileType } from "./shared/types"; import { YINYUAN_VIDEO_CONFIG } from "./constants/yinyuan"; import { MIMIAN_VIDEO_CONFIG } from "./constants/mimian"; +import { useChenyunStore } from "@/store/chenyunStore"; const ChenyunReflection = () => { const navigate = useNavigate(); @@ -73,9 +74,8 @@ const ChenyunReflection = () => { markClueAsViewed, } = useClueStates(); - // ========== 银元之战状态(从 YinyuanBattle 获取) ========== - // 注意:这些状态现在由 YinyuanBattle 内部管理,这里只保留必要的共享状态 - const [isYinyuanSolved, setIsYinyuanSolved] = useState(false); + // ========== 银元之战状态(从 store 获取) ========== + const { isYinyuanSolved } = useChenyunStore(); // ========== 事件处理函数 ========== @@ -223,7 +223,6 @@ const ChenyunReflection = () => { onPlayVideo={(fileType) => { setPlayingVideo(fileType); }} - onSolvedChange={setIsYinyuanSolved} /> {/* 米棉之战模块 */} @@ -238,12 +237,6 @@ const ChenyunReflection = () => { />
- {/* 拼图盒 */} - - {/* 欢迎语层 - 使用 StudyGuideModal,仅承担学习引导/欢迎语 */} { const state = useMimianState(); + const { triggerPuzzleAnimation } = usePuzzleAnimation(); + const { isPuzzleUnlocked } = usePuzzleStore(); // 如果所有米棉之战线索都已查看,自动显示单选题1 useEffect(() => { @@ -104,6 +109,12 @@ export const MimianBattle = ({ state.setMimianSuccessAlertContent(""); // 显示旁白2(图4) state.setShowMimianNarration2(true); + + // 检查碎片14是否已解锁,如果未解锁则触发动画 + const pieceId = 14; + if (!isPuzzleUnlocked(starName, pieceId)) { + triggerPuzzleAnimation(pieceId); + } }; // 处理错误提示框继续 diff --git a/src/pages/chenyun/reflection/mimian/useMimianState.ts b/src/pages/chenyun/reflection/mimian/useMimianState.ts index dcfdc68..fceeed5 100644 --- a/src/pages/chenyun/reflection/mimian/useMimianState.ts +++ b/src/pages/chenyun/reflection/mimian/useMimianState.ts @@ -3,12 +3,21 @@ * 管理米棉之战相关的所有状态 */ import { useState } from "react"; +import { useChenyunStore } from "@/store/chenyunStore"; /** * 米棉之战状态管理 Hook * @returns 米棉之战的所有状态和更新函数 */ export const useMimianState = () => { + // 直接从 store 获取持久化的解决状态,不需要本地状态 + const { isMimianSolved, setMimianSolved } = useChenyunStore(); + + // 更新解决状态时,直接更新 store + const handleSetIsMimianSolved = (solved: boolean) => { + setMimianSolved(solved); + }; + // 单选题1相关状态 const [showMimianQuiz1, setShowMimianQuiz1] = useState(false); @@ -36,9 +45,6 @@ export const useMimianState = () => { // 旁白5相关状态(结束语) const [showMimianNarration5, setShowMimianNarration5] = useState(false); - // 米棉之战解决状态 - const [isMimianSolved, setIsMimianSolved] = useState(false); - // 米棉之战成功提示框状态 const [showMimianSuccessAlert, setShowMimianSuccessAlert] = useState(false); const [mimianSuccessAlertContent, setMimianSuccessAlertContent] = @@ -75,7 +81,7 @@ export const useMimianState = () => { // 解决状态 isMimianSolved, - setIsMimianSolved, + setIsMimianSolved: handleSetIsMimianSolved, }; }; diff --git a/src/pages/chenyun/reflection/yinyuan/YinyuanBattle.tsx b/src/pages/chenyun/reflection/yinyuan/YinyuanBattle.tsx index 340bd63..c366b18 100644 --- a/src/pages/chenyun/reflection/yinyuan/YinyuanBattle.tsx +++ b/src/pages/chenyun/reflection/yinyuan/YinyuanBattle.tsx @@ -8,6 +8,9 @@ import { YinyuanClues } from "./YinyuanClues"; import { YinyuanQuizzes } from "./YinyuanQuizzes"; import { YinyuanGuides } from "./YinyuanGuides"; import { useYinyuanState } from "./useYinyuanState"; +import { usePuzzleAnimation } from "@/contexts/PuzzleAnimationContext"; +import { usePuzzleStore } from "@/store/puzzleStore"; +import { starName } from "../constants"; import type { FileType } from "../shared/types"; import type { ClueData } from "@/components/layout"; @@ -24,8 +27,6 @@ export interface YinyuanBattleProps { onClueClick: (clue: ClueData, clueKey: string) => void; /** 播放视频回调 */ onPlayVideo: (fileType: FileType) => void; - /** 银元之战解决状态变化回调 */ - onSolvedChange?: (solved: boolean) => void; } /** @@ -39,16 +40,10 @@ export const YinyuanBattle = ({ onFileClick, onClueClick, onPlayVideo, - onSolvedChange, }: YinyuanBattleProps) => { const state = useYinyuanState(); - - // 当银元之战解决状态变化时,通知父组件 - useEffect(() => { - if (onSolvedChange) { - onSolvedChange(state.isYinyuanSolved); - } - }, [state.isYinyuanSolved, onSolvedChange]); + const { triggerPuzzleAnimation } = usePuzzleAnimation(); + const { isPuzzleUnlocked } = usePuzzleStore(); // 监听"前往解决"按钮出现的条件,显示第一个提示 useEffect(() => { @@ -157,6 +152,12 @@ export const YinyuanBattle = ({ // 关闭聚光灯、标记银元之战为已解决、显示走势图 state.setIsYinyuanSolved(true); state.setShowSuccessAlert(false); + + // 检查碎片13是否已解锁,如果未解锁则触发动画 + const pieceId = 13; + if (!isPuzzleUnlocked(starName, pieceId)) { + triggerPuzzleAnimation(pieceId); + } }; // 处理走势图点击 diff --git a/src/pages/chenyun/reflection/yinyuan/YinyuanGuides.tsx b/src/pages/chenyun/reflection/yinyuan/YinyuanGuides.tsx index b76b0b0..49f5274 100644 --- a/src/pages/chenyun/reflection/yinyuan/YinyuanGuides.tsx +++ b/src/pages/chenyun/reflection/yinyuan/YinyuanGuides.tsx @@ -78,11 +78,9 @@ export const YinyuanGuides = ({ visible={showTrendChartDetail} src="chenyun/reflection/银元价格走势图" description={`以上海为中心,代表"人民币信用"的绿色区域迅速覆盖全市,并向外辐射。`} - disableDrag={true} onClick={onTrendChartClick} onClose={onTrendChartDetailClose} /> ); }; - diff --git a/src/pages/chenyun/reflection/yinyuan/useYinyuanState.ts b/src/pages/chenyun/reflection/yinyuan/useYinyuanState.ts index 785f037..dfa63fb 100644 --- a/src/pages/chenyun/reflection/yinyuan/useYinyuanState.ts +++ b/src/pages/chenyun/reflection/yinyuan/useYinyuanState.ts @@ -3,39 +3,45 @@ * 管理银元之战相关的所有状态 */ import { useState } from "react"; +import { useChenyunStore } from "@/store/chenyunStore"; /** * 银元之战状态管理 Hook * @returns 银元之战的所有状态和更新函数 */ export const useYinyuanState = () => { + // 直接从 store 获取持久化的解决状态,不需要本地状态 + const { isYinyuanSolved, setYinyuanSolved } = useChenyunStore(); + + // 更新解决状态时,直接更新 store + const handleSetIsYinyuanSolved = (solved: boolean) => { + setYinyuanSolved(solved); + }; + // 多选题1相关状态 const [showQuiz1, setShowQuiz1] = useState(false); - + // 旁白文字状态(图2) const [showNarration2, setShowNarration2] = useState(false); const [narration2Text, setNarration2Text] = useState(""); - + // 多选题2相关状态 const [showQuiz2, setShowQuiz2] = useState(false); - + // 成功提示框状态 const [showSuccessAlert, setShowSuccessAlert] = useState(false); - - // 银元之战解决状态 - const [isYinyuanSolved, setIsYinyuanSolved] = useState(false); - + // 走势图图标显示状态(控制文件上的走势图图标是否显示) const [showTrendChartIcon, setShowTrendChartIcon] = useState(true); - + // 走势图大图显示状态 const [showTrendChartDetail, setShowTrendChartDetail] = useState(false); - + // 三个引导提示框状态 const [showGuide1, setShowGuide1] = useState(false); // 前往解决按钮出现后的提示 const [showGuide2, setShowGuide2] = useState(false); // 走势图出现后的提示 const [showGuide3, setShowGuide3] = useState(false); // 走势图大图打开后的提示 - + // 记录是否已经显示过这些提示(避免重复显示) const [hasShownGuide1, setHasShownGuide1] = useState(false); const [hasShownGuide2, setHasShownGuide2] = useState(false); @@ -47,27 +53,27 @@ export const useYinyuanState = () => { setShowQuiz1, showQuiz2, setShowQuiz2, - + // 旁白相关状态 showNarration2, setShowNarration2, narration2Text, setNarration2Text, - + // 成功提示框状态 showSuccessAlert, setShowSuccessAlert, - + // 解决状态 isYinyuanSolved, - setIsYinyuanSolved, - + setIsYinyuanSolved: handleSetIsYinyuanSolved, + // 走势图相关状态 showTrendChartIcon, setShowTrendChartIcon, showTrendChartDetail, setShowTrendChartDetail, - + // 引导提示状态 showGuide1, setShowGuide1, @@ -83,4 +89,3 @@ export const useYinyuanState = () => { setHasShownGuide3, }; }; - diff --git a/src/pages/sunzhongshan/reflection/scene1.tsx b/src/pages/sunzhongshan/reflection/scene1.tsx index 3593b51..31d3df8 100644 --- a/src/pages/sunzhongshan/reflection/scene1.tsx +++ b/src/pages/sunzhongshan/reflection/scene1.tsx @@ -269,7 +269,7 @@ const SunzhongshanReflectionScene1 = () => { {showVideo && (